Skip to main content

onnx_runtime_session/
executor.rs

1//! The sequential CPU executor (Track D, `docs/ORT2.md` §20, §11.3).
2//!
3//! Turns a loaded [`Graph`] plus its live [`WeightStore`] into a runnable plan:
4//! resolve every value's concrete shape from the actual bound inputs at
5//! `run`, size a device buffer per value from those *resolved* shapes, resolve
6//! a kernel per node through the execution provider (keyed by the resolved
7//! input shapes), then walk the topological order binding
8//! [`TensorView`]/[`TensorMut`] windows over those buffers and invoking each
9//! kernel. It is generic over any [`Graph`] and any [`ExecutionProvider`]; the
10//! Phase-1 build wires in the CPU EP only, but nothing here is op- or
11//! model-specific.
12//!
13//! ## Symbolic → concrete shape resolution (§3.2, §11)
14//!
15//! Real models carry *symbolic* input dims (e.g. `batch`, `max_seq_len`): the
16//! loader produces a [`Shape`] whose dims are a mix of [`Dim::Static`] and
17//! [`Dim::Symbolic`]. This executor is model-agnostic about them — a symbol is
18//! whatever [`SymbolId`] the graph interned. At `run` it reads the actual shape
19//! of each bound input, **binds** the graph's symbols to concrete sizes from
20//! those inputs (conflicts across inputs are an error), and **substitutes**
21//! those bindings into every value's loader shape to obtain a fully-concrete
22//! shape. Buffers are sized from the resolved shapes and become run-scoped when
23//! shapes are dynamic (reused when the resolved shape is unchanged, re-allocated
24//! when it changes). A fully-static graph is simply the special case where
25//! there are no symbols: resolution is a no-op and every buffer/kernel is
26//! materialized once at build.
27//!
28//! The session does **not** infer op output shapes — that is the loader's job
29//! (the loader runs `onnx-runtime-shape-inference` at load time). If a value's
30//! loader shape still contains an unbound symbol after substitution, the
31//! session resolves genuinely data-dependent extents just-in-time during
32//! execution (see [`dynamic_output_shapes`]); anything it still cannot size is
33//! reported as [`SessionError::UnresolvedShape`] naming the value and its
34//! producing op, rather than guessing.
35//!
36//! ## Holden's precondition (ep-api safety review #1) — enforced here
37//!
38//! A [`TensorView`] carries no backing length, so it cannot self-check storage
39//! bounds. This executor owns every buffer, so it is the layer that *can*: for
40//! **every** input and output view of **every** node it calls
41//! [`strided::view_in_bounds`] (or, for sub-byte dtypes, the `storage_bytes`
42//! equivalent in [`view_bounds`]) against the **run-scoped resolved** buffer and
43//! refuses to dispatch on failure. That check is the sole thing that makes
44//! ep-cpu's unchecked pointer derefs sound.
45
46use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
47use std::sync::Arc;
48use std::time::{Duration, Instant};
49
50use onnx_runtime_ep_api::{
51    CaptureRegionShapeStatus, DeviceBuffer, DevicePtr, DevicePtrMut, EpError, ExecutionProvider,
52    ExternalMmapRegion, Kernel, KernelInput, KernelMatch, LazyWeight, LazyWeightBoundary,
53    ResidentWeight, StructuralCaptureDecline, TensorBacking, TensorMut, TensorView, WeightHandle,
54};
55
56type OptionalTensorSpecs = Vec<Option<(DataType, Vec<usize>)>>;
57use onnx_runtime_ep_cpu::CpuExecutionProvider;
58use onnx_runtime_ep_cpu::strided::view_in_bounds;
59use onnx_runtime_ir::Attribute;
60use onnx_runtime_ir::{
61    DataType, DeviceType, Dim, Graph, Node, NodeId, Shape, SymbolId, TensorLayout, ValueId,
62    WeightRef, as_static_shape, broadcast_shapes, compute_contiguous_strides,
63};
64use onnx_runtime_loader::WeightStore;
65use onnx_runtime_optimizer::InitializerResolver;
66use onnx_runtime_shape_inference::{
67    DimExpr, InferenceRegistry, MAX_SHAPE_DATA_ELEMS, MergePolicy, NodeIo, ShapeData,
68    SymbolInterner, TypeInfo,
69};
70use onnx_runtime_tracer::{TraceContext, annotate_current_span_with};
71
72use crate::SessionOutput;
73use crate::error::{Result, SessionError};
74use crate::sequence::{
75    ConcatPlan, SeqTensor, SequenceError, SequenceValue, SplitSpec, split_tensor, stack_new_axis,
76};
77use crate::tensor::{DeviceIoBinding, SharedTensorBuffer, Tensor};
78
79fn profile_ops_enabled() -> bool {
80    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
81    *ENABLED.get_or_init(|| {
82        std::env::var("ONNX_GENAI_PROFILE_OPS")
83            .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
84    })
85}
86
87/// Low-overhead, env-gated (`NXRT_EXEC_PHASE_PROFILE=1`) phase profiler for the
88/// executor's control-flow / subgraph-dispatch machinery. When disabled every
89/// entry point is a single relaxed-atomic load and an early return, so the
90/// production decode hot path pays no measurable cost. When enabled it
91/// accumulates wall-clock nanoseconds and a call count per named phase, which
92/// [`phase_profile_report`] renders to stderr. This exists to attribute the
93/// per-decode-step control-flow overhead (`exec_if` / `run_subgraph` / child
94/// setup) that the op-level profiler folds into the single `If` bucket.
95mod phase_profile {
96    use std::collections::BTreeMap;
97    use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
98    use std::sync::{Mutex, OnceLock};
99    use std::time::Instant;
100
101    static STATE: AtomicU8 = AtomicU8::new(0); // 0 = unknown, 1 = off, 2 = on
102
103    pub fn enabled() -> bool {
104        match STATE.load(Ordering::Relaxed) {
105            1 => false,
106            2 => true,
107            _ => {
108                let on = std::env::var("NXRT_EXEC_PHASE_PROFILE")
109                    .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
110                STATE.store(if on { 2 } else { 1 }, Ordering::Relaxed);
111                on
112            }
113        }
114    }
115
116    /// Test-only override of the env-derived enable state.
117    #[cfg(test)]
118    pub(super) fn force_enabled(on: bool) {
119        STATE.store(if on { 2 } else { 1 }, Ordering::Relaxed);
120    }
121
122    /// Test-only snapshot of a phase's accumulated `(total_ns, count)`.
123    #[cfg(test)]
124    pub(super) fn snapshot(phase: &'static str) -> Option<(u128, u64)> {
125        registry()
126            .lock()
127            .ok()
128            .and_then(|reg| reg.get(phase).map(|s| (s.total_ns, s.count)))
129    }
130
131    #[derive(Default, Clone, Copy)]
132    struct PhaseStat {
133        total_ns: u128,
134        count: u64,
135    }
136
137    fn registry() -> &'static Mutex<BTreeMap<&'static str, PhaseStat>> {
138        static REGISTRY: OnceLock<Mutex<BTreeMap<&'static str, PhaseStat>>> = OnceLock::new();
139        REGISTRY.get_or_init(|| Mutex::new(BTreeMap::new()))
140    }
141
142    /// Accumulate `nanos` against `phase`. No-op unless the profiler is enabled.
143    pub fn record(phase: &'static str, nanos: u128) {
144        if !enabled() {
145            return;
146        }
147        if let Ok(mut reg) = registry().lock() {
148            let entry = reg.entry(phase).or_default();
149            entry.total_ns += nanos;
150            entry.count += 1;
151        }
152    }
153
154    /// Scoped timer that records its lifetime to `phase` on drop.
155    pub struct PhaseSpan {
156        phase: &'static str,
157        start: Option<Instant>,
158    }
159
160    impl PhaseSpan {
161        pub fn new(phase: &'static str) -> Self {
162            let active = enabled();
163            Self {
164                phase,
165                // Avoid the clock read entirely on the disabled hot path.
166                start: if active { Some(Instant::now()) } else { None },
167            }
168        }
169    }
170
171    impl Drop for PhaseSpan {
172        fn drop(&mut self) {
173            if let Some(start) = self.start {
174                record(self.phase, start.elapsed().as_nanos());
175            }
176        }
177    }
178
179    /// Render and reset the accumulated per-phase table to stderr. Called once at
180    /// process exit (or on demand) so a single line-oriented dump is available.
181    pub fn report_to_stderr() {
182        if !enabled() {
183            return;
184        }
185        let rows: Vec<(&'static str, PhaseStat)> = match registry().lock() {
186            Ok(reg) => reg.iter().map(|(n, s)| (*n, *s)).collect(),
187            Err(_) => return,
188        };
189        static PRINTED: AtomicBool = AtomicBool::new(false);
190        if PRINTED.swap(true, Ordering::Relaxed) {
191            return;
192        }
193        let mut rows = rows;
194        rows.sort_by_key(|r| std::cmp::Reverse(r.1.total_ns));
195        eprintln!("[nxrt-phase] phase,total_ms,calls,us/call");
196        for (name, stat) in &rows {
197            if name.ends_with("_bytes") {
198                continue;
199            }
200            let total_ms = stat.total_ns as f64 / 1_000_000.0;
201            let us_per_call = if stat.count > 0 {
202                (stat.total_ns as f64 / 1_000.0) / stat.count as f64
203            } else {
204                0.0
205            };
206            eprintln!(
207                "[nxrt-phase] {name},{total_ms:.3},{},{us_per_call:.2}",
208                stat.count
209            );
210        }
211        // Byte-valued counters (host traffic) are reported separately in MB.
212        for (name, stat) in &rows {
213            if !name.ends_with("_bytes") {
214                continue;
215            }
216            let total_mb = stat.total_ns as f64 / (1024.0 * 1024.0);
217            let mb_per_call = if stat.count > 0 {
218                total_mb / stat.count as f64
219            } else {
220                0.0
221            };
222            eprintln!(
223                "[nxrt-phase] {name},total_mb={total_mb:.1},calls={},mb/call={mb_per_call:.3}",
224                stat.count
225            );
226        }
227    }
228}
229
230/// Open an env-gated executor phase-profiling span (see [`phase_profile`]).
231macro_rules! phase_span {
232    ($phase:expr) => {
233        phase_profile::PhaseSpan::new($phase)
234    };
235}
236
237/// Public re-export so the bench/profile harness can dump the phase table.
238pub fn print_exec_phase_profile() {
239    phase_profile::report_to_stderr();
240}
241
242fn host_dtype_alignment(dtype: DataType) -> usize {
243    match dtype {
244        DataType::Float16 | DataType::BFloat16 | DataType::Int16 | DataType::Uint16 => 2,
245        DataType::Float32 | DataType::Int32 | DataType::Uint32 | DataType::Complex64 => 4,
246        DataType::Float64 | DataType::Int64 | DataType::Uint64 | DataType::Complex128 => 8,
247        _ => 1,
248    }
249}
250
251fn print_op_profile(total: Duration, timings: HashMap<String, (Duration, usize)>) {
252    let mut timings = timings.into_iter().collect::<Vec<_>>();
253    timings.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.1.0));
254    let total_ms = total.as_secs_f64() * 1_000.0;
255    eprintln!("[onnx-genai-profile] node execution: {total_ms:.3} ms");
256    eprintln!("[onnx-genai-profile] op_type,total_ms,percent,calls");
257    for (op_type, (elapsed, calls)) in timings {
258        let elapsed_ms = elapsed.as_secs_f64() * 1_000.0;
259        let percent = if total_ms == 0.0 {
260            0.0
261        } else {
262            elapsed_ms / total_ms * 100.0
263        };
264        eprintln!("[onnx-genai-profile] {op_type},{elapsed_ms:.3},{percent:.2},{calls}");
265    }
266}
267
268/// Print, to stderr, how the capture pass split a claimed subgraph into captured
269/// device-graph segments and eager seam nodes, and why each seam exists. Gated
270/// by `ONNX_GENAI_LOG_CAPTURE_SEGMENTS` for transparency into segmentation.
271fn log_capture_segmentation(schedule: &CaptureSchedule) {
272    let captured = schedule.captured_segments();
273    let seams = schedule.segments.len() - captured;
274    eprintln!(
275        "[onnx-genai-capture] segmented CUDA graph: {captured} captured segment(s), \
276         {seams} eager seam(s)"
277    );
278    for boundary in &schedule.boundaries {
279        match boundary.node_id {
280            Some(id) => {
281                let seam_label = boundary
282                    .seam_reason
283                    .map(SeamReason::label)
284                    .unwrap_or("unclassified-seam");
285                eprintln!(
286                    "[onnx-genai-capture]   seam node {id} ({}::{}) [{seam_label}] ran eagerly: {}",
287                    boundary.domain, boundary.op_type, boundary.reason
288                );
289            }
290            None => eprintln!(
291                "[onnx-genai-capture]   seam ({}): {}",
292                boundary.op_type, boundary.reason
293            ),
294        }
295    }
296}
297
298/// A per-node compiled entry: the structural facts the run loop needs without
299/// re-deriving them from the graph. Shapes are **not** baked here — they are
300/// resolved per run from the bound inputs (see module docs).
301#[derive(Debug)]
302pub(crate) struct NodePlan {
303    pub node_id: NodeId,
304    /// Positional input value ids in ONNX signature order. An omitted optional
305    /// input (ONNX empty-string input name → `None` slot) is preserved as
306    /// `None` so a later present input is never misread as the omitted one
307    /// (e.g. `Slice(data, starts, ends, "", steps)`). Trailing `None`s are
308    /// trimmed — a truly absent trailing optional simply lowers the arity.
309    pub inputs: Vec<Option<ValueId>>,
310    /// Output value ids, in positional order.
311    pub outputs: Vec<ValueId>,
312    /// Element types of the inputs, positional (matches `inputs`). An omitted
313    /// optional (`None`) slot carries [`DataType::Undefined`] so EP claim-time
314    /// validation can distinguish it from a supplied tensor.
315    pub input_dtypes: Vec<DataType>,
316    /// Element types of the outputs.
317    pub output_dtypes: Vec<DataType>,
318}
319
320/// Cache key for a compiled kernel (§11.1). Keyed by the concrete node and its
321/// **resolved** (concrete) input shapes: attributes are fixed per node, so this
322/// is correct, and the shape component makes it *shape-keyed* — a re-run with
323/// the same resolved shapes hits, a different shape (e.g. a new batch/seq)
324/// misses and re-compiles. This preserves Chew's guarantee: a kernel is never
325/// reused for a shape it was not compiled for.
326#[derive(Clone, PartialEq, Eq, Hash, Debug)]
327struct KernelKey {
328    node: u32,
329    shapes: Vec<Vec<usize>>,
330}
331
332/// Observable kernel-cache statistics (§11.1) — enough to prove reuse in tests.
333#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
334pub struct CacheStats {
335    /// Distinct compiled entries currently held.
336    pub entries: usize,
337    /// Lookups served from an existing entry.
338    pub hits: u64,
339    /// Lookups that compiled a new kernel.
340    pub misses: u64,
341}
342
343/// Observable control-flow executor statistics. These counters make subgraph
344/// reuse deterministic to test without relying on timing.
345#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
346pub struct ControlFlowStats {
347    /// Child executors built, including shape-signature rebuilds.
348    pub subgraph_builds: u64,
349    /// Child subgraph invocations served by those executors.
350    pub subgraph_runs: u64,
351}
352
353#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
354pub struct DeviceAllocationCounts {
355    pub allocations: u64,
356    pub frees: u64,
357}
358
359/// Structural execution path used by a node during a captured run.
360#[derive(Clone, Copy, Debug, PartialEq, Eq)]
361pub enum CapturePathKind {
362    /// Recorded into a device graph and replayed.
363    CaptureRegion,
364    /// Dispatched eagerly while remaining on the device.
365    EagerDeviceSeam,
366    /// Host-driven work or a host round-trip between captured regions.
367    HostSeam,
368}
369
370impl CapturePathKind {
371    /// Stable short label used by capture diagnostics.
372    pub const fn label(self) -> &'static str {
373        match self {
374            Self::CaptureRegion => "capture-region",
375            Self::EagerDeviceSeam => "eager-device-seam",
376            Self::HostSeam => "host-seam",
377        }
378    }
379}
380
381/// Structural reason a node forms an eager seam during device-graph capture.
382#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383pub enum SeamReason {
384    /// Host-driven control-flow or sequence semantics.
385    HostControlFlowOrSequence,
386    /// A data-dependent output shape was unresolved before capture.
387    UnresolvedOutputShape,
388    /// A data-dependent input shape was unresolved before capture.
389    UnresolvedInputShape,
390    /// The requested concrete kernel shape has not completed warmup.
391    KernelNotWarmed,
392    /// The selected device kernel explicitly opts out of capture.
393    KernelCaptureUnsupported,
394    /// The kernel aborted device-graph *recording* (e.g. it advertised capture
395    /// support but issued a stream synchronize, which CUDA rejects mid-capture)
396    /// and was quarantined to a forced eager seam so the rest of the graph can
397    /// still be captured.
398    CaptureRecordingFailed,
399}
400
401impl SeamReason {
402    /// Execution path implied by this structural seam cause.
403    pub const fn path_kind(self) -> CapturePathKind {
404        match self {
405            Self::HostControlFlowOrSequence => CapturePathKind::HostSeam,
406            Self::UnresolvedOutputShape
407            | Self::UnresolvedInputShape
408            | Self::KernelNotWarmed
409            | Self::CaptureRecordingFailed
410            | Self::KernelCaptureUnsupported => CapturePathKind::EagerDeviceSeam,
411        }
412    }
413
414    /// Stable short path-kind label used by capture diagnostics.
415    pub const fn label(self) -> &'static str {
416        self.path_kind().label()
417    }
418}
419
420#[derive(Clone, Debug, PartialEq, Eq)]
421/// One actionable reason a device-graph capture attempt was rejected.
422pub struct CaptureDecline {
423    /// Graph node id, or `None` for graph/capture-lifecycle requirements.
424    pub node_id: Option<u32>,
425    /// ONNX operator type, or `"<graph>"` for graph-level requirements.
426    pub op_type: String,
427    /// Canonical ONNX domain (`"ai.onnx"` by default), or `"nxrt"` graph-level.
428    pub domain: String,
429    /// Failed precondition and, where applicable, how to reach the capture path.
430    pub reason: String,
431    /// Structural seam classification, or `None` for graph-level hard preconditions.
432    pub seam_reason: Option<SeamReason>,
433}
434
435impl CaptureDecline {
436    fn node(
437        node_id: NodeId,
438        node: &Node,
439        seam_reason: SeamReason,
440        reason: impl Into<String>,
441    ) -> Self {
442        Self {
443            node_id: Some(node_id.0),
444            op_type: node.op_type.clone(),
445            domain: canonical_domain(node),
446            reason: reason.into(),
447            seam_reason: Some(seam_reason),
448        }
449    }
450
451    fn graph(reason: impl Into<String>) -> Self {
452        Self {
453            node_id: None,
454            op_type: "<graph>".to_string(),
455            domain: "nxrt".to_string(),
456            reason: reason.into(),
457            seam_reason: None,
458        }
459    }
460}
461
462#[derive(Clone, Debug, Default, PartialEq, Eq)]
463/// Structured reasons a device graph could not be captured.
464pub struct CaptureDeclineReport {
465    /// All graph- and node-level declines found by the pre-capture audit.
466    pub entries: Vec<CaptureDecline>,
467}
468
469impl CaptureDeclineReport {
470    fn one(decline: CaptureDecline) -> Self {
471        Self {
472            entries: vec![decline],
473        }
474    }
475
476    /// Whether the capture audit found no declines.
477    pub fn is_empty(&self) -> bool {
478        self.entries.is_empty()
479    }
480}
481
482/// One node-level reason the requested execution provider declined placement.
483#[derive(Clone, Debug, PartialEq, Eq)]
484pub struct ExecutionProviderDecline {
485    /// Stable graph/subgraph node identity used in diagnostics.
486    pub node: String,
487    /// Canonical ONNX domain (`"ai.onnx"` for the default domain).
488    pub domain: String,
489    /// ONNX operator type.
490    pub op_type: String,
491    /// Actionable reason returned by [`ExecutionProvider::supports_op`].
492    pub reason: String,
493}
494
495/// Structured report for an accelerator request that executes on CPU.
496#[derive(Clone, Debug, PartialEq, Eq)]
497pub struct ExecutionProviderFallbackReport {
498    /// Requested provider name, such as `"cuda_ep"`.
499    pub requested_provider: String,
500    /// Provider that will execute the graph.
501    pub fallback_provider: String,
502    /// Number of executable graph/subgraph nodes assigned to the fallback EP.
503    pub assigned_node_count: usize,
504    /// Sorted distinct `domain::op` classes assigned to the fallback EP.
505    pub assigned_ops: Vec<String>,
506    /// Nodes the requested provider did not claim, with colocated reasons.
507    pub declines: Vec<ExecutionProviderDecline>,
508}
509
510impl std::fmt::Display for ExecutionProviderFallbackReport {
511    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512        write!(
513            f,
514            "{} nodes assigned to CPU (ops: {}) — GPU EP {} did not claim {} node(s): {}. \
515             Heterogeneous CUDA+CPU placement is unavailable, so the whole session uses {}",
516            self.assigned_node_count,
517            self.assigned_ops.join(", "),
518            self.requested_provider,
519            self.declines.len(),
520            format_cuda_coverage_issues(&self.declines),
521            self.fallback_provider,
522        )
523    }
524}
525
526impl std::fmt::Display for CaptureDeclineReport {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        write!(f, "CUDA graph capture rejected")?;
529        for (index, decline) in self.entries.iter().enumerate() {
530            if index == 0 {
531                write!(f, ": ")?;
532            } else {
533                write!(f, "; ")?;
534            }
535            match decline.node_id {
536                Some(node_id) => write!(
537                    f,
538                    "node {node_id} ({}::{}) — {}",
539                    decline.domain, decline.op_type, decline.reason
540                )?,
541                None => write!(f, "{} — {}", decline.op_type, decline.reason)?,
542            }
543        }
544        Ok(())
545    }
546}
547
548pub enum DeviceGraphCaptureResult {
549    Captured(Vec<Option<Tensor>>),
550    NotCapturable(CaptureDeclineReport),
551}
552
553enum ScopedRunResult {
554    Executed(Vec<Option<SessionOutput>>),
555    NotCapturable(CaptureDeclineReport),
556}
557
558fn kernel_capture_decline(
559    node_id: NodeId,
560    node: &Node,
561    kernel: &dyn Kernel,
562) -> Option<CaptureDecline> {
563    kernel.capture_support().reason().map(|reason| {
564        CaptureDecline::node(node_id, node, SeamReason::KernelCaptureUnsupported, reason)
565    })
566}
567
568fn structural_capture_decline(
569    node_id: NodeId,
570    node: &Node,
571    decline: StructuralCaptureDecline,
572) -> CaptureDecline {
573    let seam_reason = match decline {
574        StructuralCaptureDecline::HostControlFlowOrSequence => {
575            SeamReason::HostControlFlowOrSequence
576        }
577        StructuralCaptureDecline::UnresolvedOutputShape => SeamReason::UnresolvedOutputShape,
578        StructuralCaptureDecline::UnresolvedInputShape => SeamReason::UnresolvedInputShape,
579    };
580    CaptureDecline::node(node_id, node, seam_reason, decline.reason())
581}
582
583/// Whether verbose segmented-capture diagnostics are printed to stderr.
584///
585/// Gated identically to op profiling so a run can surface exactly where the
586/// CUDA EP split a claimed subgraph into captured segments and eager seam nodes.
587fn capture_segmentation_logging_enabled() -> bool {
588    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
589    *ENABLED.get_or_init(|| {
590        std::env::var("ONNX_GENAI_LOG_CAPTURE_SEGMENTS")
591            .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
592    })
593}
594
595/// How a scoped run drives the device-graph lifecycle.
596#[derive(Clone, Copy, PartialEq, Eq, Debug)]
597enum RunMode {
598    /// No capture: execute every node eagerly on the stream.
599    Eager,
600    /// First capture pass: partition the plan into segments, record each
601    /// capturable segment into its own device graph, and run the non-capturable
602    /// seam nodes eagerly in between.
603    Capture,
604    /// Subsequent steps: replay each captured segment graph in order, re-running
605    /// only the eager seam nodes.
606    Replay,
607}
608
609/// The device-graph capture disposition of a single op, used to annotate its
610/// trace span with **why** it was or was not captured. Carries a borrowed
611/// reason string rather than an owned one so an untraced run never allocates.
612#[derive(Clone, Copy)]
613enum OpCaptureTrace<'a> {
614    /// Plain eager run — no capture attempt is in progress for this op.
615    Eager,
616    /// The op was recorded into a captured device-graph segment.
617    Captured,
618    /// The op runs eagerly as a capture seam; `reason` explains why it could
619    /// not be recorded into a device graph (which kernel/predicate declined).
620    Rejected(&'a str),
621}
622
623/// Trace-arg key: whether an op was captured into a device graph.
624const ARG_CAPTURE_STATUS: &str = "capture_status";
625/// Trace-arg key: why an op was not captured into a device graph.
626const ARG_CAPTURE_REASON: &str = "capture_reason";
627
628impl OpCaptureTrace<'_> {
629    /// Annotate the active op-span with this capture disposition. A no-op for
630    /// [`OpCaptureTrace::Eager`] (nothing was being captured) and when no span
631    /// is active.
632    fn annotate(self) {
633        match self {
634            OpCaptureTrace::Eager => {}
635            OpCaptureTrace::Captured => {
636                annotate_current_span_with(|| {
637                    onnx_runtime_tracer::Args::new().with(ARG_CAPTURE_STATUS, "captured")
638                });
639            }
640            OpCaptureTrace::Rejected(reason) => {
641                annotate_current_span_with(|| {
642                    onnx_runtime_tracer::Args::new()
643                        .with(ARG_CAPTURE_STATUS, "rejected")
644                        .with(ARG_CAPTURE_REASON, reason)
645                });
646            }
647        }
648    }
649}
650
651/// Scope guard that guarantees an in-progress segment capture is always ended
652/// before its enclosing function returns.
653///
654/// During [`RunMode::Capture`], nodes are recorded between
655/// `begin_device_graph_capture` and `end_device_graph_capture`. If a node fails
656/// mid-record, the `?` early return would otherwise skip the end call and leave
657/// the CUDA stream wedged in capture mode — the caller's
658/// `reset_device_graph()` is then a no-op (reset is rejected while capturing),
659/// so every later eager/replay launch fails with `STREAM_CAPTURE_INVALIDATED`.
660///
661/// While armed, [`Drop`] aborts the capture (ending stream capture and
662/// discarding the half-recorded graph). The success path calls [`disarm`] and
663/// then ends the capture normally via `end_device_graph_capture`.
664///
665/// [`disarm`]: SegmentCaptureGuard::disarm
666struct SegmentCaptureGuard<'a> {
667    ep: &'a dyn ExecutionProvider,
668    armed: bool,
669}
670
671impl<'a> SegmentCaptureGuard<'a> {
672    fn arm(ep: &'a dyn ExecutionProvider) -> Self {
673        Self { ep, armed: true }
674    }
675
676    fn disarm(&mut self) {
677        self.armed = false;
678    }
679}
680
681impl Drop for SegmentCaptureGuard<'_> {
682    fn drop(&mut self) {
683        if self.armed {
684            // Best-effort: the abort itself may fail, but the caller is already
685            // unwinding a capture failure and will reset the lifecycle next.
686            let _ = self.ep.abort_device_graph_capture();
687        }
688    }
689}
690
691/// One contiguous run of plan nodes that either share a captured device graph or
692/// all execute eagerly (a non-capturable seam).
693#[derive(Clone, Debug, PartialEq, Eq)]
694struct ScheduledSegment {
695    /// First plan index (inclusive).
696    start: usize,
697    /// One past the last plan index (exclusive).
698    end: usize,
699    /// `true` when `[start, end)` is captured into a device graph; `false` for an
700    /// eager seam of non-capturable (but still device-placed or CPU) nodes.
701    captured: bool,
702    /// Capture-order index of this segment's graph in the EP, set only when
703    /// `captured`.
704    graph_index: usize,
705}
706
707/// The plan's partition into captured segments and eager seams, plus the
708/// structured reason each segment boundary exists (which node forced the split).
709///
710/// Recorded once during the capture pass and reused for every subsequent replay
711/// so the interleaving of graph replays and eager seam execution is stable.
712#[derive(Clone, Debug, Default, PartialEq, Eq)]
713struct CaptureSchedule {
714    segments: Vec<ScheduledSegment>,
715    /// One entry per non-capturable seam node, explaining why it forced a
716    /// boundary (its `CaptureSupport::Unsupported` reason or structural cause).
717    boundaries: Vec<CaptureDecline>,
718}
719
720impl CaptureSchedule {
721    /// Number of captured device-graph segments (1 for a whole-subgraph capture).
722    fn captured_segments(&self) -> usize {
723        self.segments.iter().filter(|seg| seg.captured).count()
724    }
725
726    /// Whether the whole plan captured as a single graph (no eager seams).
727    fn is_single_graph(&self) -> bool {
728        self.segments.len() == 1 && self.segments[0].captured
729    }
730}
731
732#[derive(Clone, Debug, PartialEq, Eq)]
733struct DeviceBindingSignature {
734    input_name: String,
735    binds_input: bool,
736    output_name: Option<String>,
737    dtype: DataType,
738    physical_shape: Vec<usize>,
739    device_ptr: usize,
740}
741
742/// Shape-keyed kernel cache (§11.1). Owns the compiled kernels for the session.
743#[derive(Default)]
744pub(crate) struct KernelCache {
745    entries: HashMap<KernelKey, Box<dyn onnx_runtime_ep_api::Kernel>>,
746    hits: u64,
747    misses: u64,
748}
749
750impl KernelCache {
751    fn stats(&self) -> CacheStats {
752        CacheStats {
753            entries: self.entries.len(),
754            hits: self.hits,
755            misses: self.misses,
756        }
757    }
758
759    /// Return the cached kernel for `(node, resolved_input_shapes)`, verifying
760    /// EP support and compiling+inserting it on a miss. The EP support check
761    /// lives on the miss path so a re-planned shape is re-validated exactly
762    /// once per distinct shape.
763    fn get_or_create(
764        &mut self,
765        node_id: NodeId,
766        node: &Node,
767        input_shapes: &[Vec<usize>],
768        input_dtypes: &[DataType],
769        constant_inputs: &[bool],
770        opset: u64,
771        ep: &dyn ExecutionProvider,
772    ) -> Result<&dyn onnx_runtime_ep_api::Kernel> {
773        let key = KernelKey {
774            node: node_id.0,
775            shapes: input_shapes.to_vec(),
776        };
777        if self.entries.contains_key(&key) {
778            self.hits += 1;
779        } else {
780            // Verify the EP claims this op at these concrete shapes/layouts
781            // before compiling — same gate the static path used at build.
782            let shape_dims: Vec<Shape> = input_shapes
783                .iter()
784                .map(|s| s.iter().map(|&d| Dim::Static(d)).collect())
785                .collect();
786            let layouts = vec![TensorLayout::contiguous(); input_shapes.len()];
787            if let KernelMatch::Unsupported { reason } =
788                ep.supports_op(node, opset, &shape_dims, input_dtypes, &layouts)
789            {
790                return Err(SessionError::unsupported_op(
791                    node,
792                    node_id,
793                    opset,
794                    ep.name(),
795                    reason,
796                ));
797            }
798            let mut kernel = match ep.get_kernel(node, input_shapes, opset) {
799                Ok(kernel) => kernel,
800                Err(EpError::NoEpForOp {
801                    domain,
802                    op_type,
803                    opset,
804                }) => {
805                    // Opset-aware claims should make this unreachable. Preserve
806                    // the actionable diagnostic if an EP's claim drifts.
807                    return Err(SessionError::unsupported_op(
808                        node,
809                        node_id,
810                        opset,
811                        ep.name(),
812                        format!(
813                            "no handler for {domain}::{op_type} at opset {opset} — add a claim+handler"
814                        ),
815                    ));
816                }
817                Err(error) => return Err(error.into()),
818            };
819            kernel.set_constant_inputs(constant_inputs);
820            self.entries.insert(key.clone(), kernel);
821            self.misses += 1;
822        }
823        Ok(self.entries.get(&key).expect("just inserted").as_ref())
824    }
825}
826
827/// The compiled, runnable graph: buffers + plan + kernel cache. Owned by the
828/// public [`InferenceSession`](crate::InferenceSession).
829pub(crate) struct Executor {
830    graph: Graph,
831    /// Kept alive so external-weight memory maps outlive buffer population —
832    /// **and**, since the weight-streaming change, so borrowed initializer
833    /// buffers that alias this store's mmap bytes stay valid for the executor's
834    /// whole lifetime. `weights` MUST outlive every live use of `buffers`: a
835    /// borrowed `DeviceBuffer` in `buffers` points into `weights`' mmap/inline
836    /// storage. Teardown is safe because `Executor::drop` **drains and
837    /// deallocates `buffers` first** (a borrowed deallocate is a no-op free), so
838    /// no buffer still aliases `weights` when the `Arc<WeightStore>` field is
839    /// dropped afterwards — no use-after-free regardless of field drop order.
840    weights: Arc<WeightStore>,
841    ep: Arc<dyn ExecutionProvider>,
842    /// Lazy external initializers available only at the nxrt fused-MoE boundary.
843    /// Stock EPs ignore this map and keep receiving the resident buffers below.
844    weight_handles: HashMap<ValueId, WeightHandle>,
845    /// One device buffer per backed value. Static values are allocated once at
846    /// build; dynamic (symbol-shaped) values are allocated per run and cached
847    /// here so a run whose resolved shape is unchanged reuses the allocation.
848    buffers: HashMap<ValueId, DeviceBuffer>,
849    /// The concrete shape each live buffer in [`Self::buffers`] is currently
850    /// sized for — the reuse key for run-scoped buffers.
851    buffer_shapes: HashMap<ValueId, Vec<usize>>,
852    /// Loader-produced (possibly symbolic) shape of every value.
853    value_shapes: HashMap<ValueId, Shape>,
854    /// Element type of every value.
855    value_dtypes: HashMap<ValueId, DataType>,
856    /// Topologically ordered execution plan (structure only; shapes per run).
857    plan: Vec<NodePlan>,
858    /// name → value id for the graph inputs the caller must supply.
859    input_index: HashMap<String, ValueId>,
860    /// Value ids the caller must supply at `run` (graph inputs minus initializers).
861    required_inputs: Vec<ValueId>,
862    /// Whether any value in the graph carries a symbolic dim. A fully-static
863    /// graph is materialized eagerly at build; a symbolic graph defers buffer
864    /// allocation and kernel compilation to the first `run` that fixes shapes.
865    has_symbols: bool,
866    cache: KernelCache,
867    /// name → value id for every named value in this graph (inputs, outputs,
868    /// initializers and interior SSA values). Used to resolve outer-scope
869    /// captures referenced by name from a nested control-flow subgraph body.
870    name_index: HashMap<String, ValueId>,
871    /// Reusable child executors for this graph's control-flow subgraph bodies,
872    /// keyed by `(control-flow node, subgraph attr key)`. Built lazily on first
873    /// execution (once concrete input shapes are known) and **reused across
874    /// Loop/Scan iterations** — the whole point of the efficiency directive: a
875    /// body's topo-sort, buffer sizing and kernel compilation happen once, then
876    /// every iteration is just a re-bind + dispatch. Rebuilt only if a later
877    /// invocation's external input shapes differ from the ones it was compiled
878    /// for (a shape-varying loop body — rare).
879    subgraph_execs: HashMap<(NodeId, String), ChildExecutor>,
880    control_flow_stats: ControlFlowStats,
881    /// Per-`If` memo of the last observed branch predicate. During steady decode
882    /// a loop-invariant `If` (e.g. the LongRoPE cos/sin cache selector) keeps the
883    /// same predicate every step, so its branch outputs are already resident in
884    /// their persistent buffers. The predicate is still read each step (the
885    /// correctness guard); only the redundant branch re-execution — here two
886    /// `Constant` materializations plus their host→device cache copies — is
887    /// skipped. A predicate flip re-runs the branch (and, on an output-shape
888    /// change, retires the captured graph via the existing seam invalidation).
889    if_last_predicate: HashMap<NodeId, bool>,
890    device_graph_signature: Option<Vec<DeviceBindingSignature>>,
891    /// The captured-segment schedule from the most recent successful capture,
892    /// reused to interleave segment replays with eager seam nodes on each
893    /// subsequent step. `None` when no device graph is installed.
894    capture_schedule: Option<CaptureSchedule>,
895    /// Structured segment-boundary reasons from the most recent capture, retained
896    /// for diagnostics after `capture_schedule` is taken for replay.
897    capture_segmentation: Vec<CaptureDecline>,
898    /// Output value ids of every control-flow (`If`/`Loop`/`Scan`) node. ONNX
899    /// shape inference cannot statically resolve a control-flow output whose
900    /// branches declare different shapes (e.g. LongRoPE's `If` selecting a short
901    /// vs. long RoPE cos/sin cache), so such outputs stay symbolic and any
902    /// downstream capturable kernel that reads one would form a per-consumer
903    /// eager seam. Within a decode generation the selected branch is stable, so
904    /// [`Self::seed_control_flow_capture_shapes`] seeds each output's concrete
905    /// shape from the prior run for capture planning, folding those consumers
906    /// back into captured segments. Computed once at build.
907    control_flow_output_values: HashSet<ValueId>,
908    /// Concrete control-flow output shapes the most recent capture assumed (a
909    /// snapshot of the seeded shapes from [`Self::control_flow_output_values`]).
910    /// On replay the control-flow seam re-executes eagerly; if it now produces a
911    /// different shape (a branch flip, e.g. LongRoPE short↔long at the context
912    /// threshold) the installed graph's baked device pointers are stale, so the
913    /// step falls back to eager and the graph is retired for re-capture.
914    capture_cf_shapes: HashMap<ValueId, Vec<usize>>,
915    /// Persistent-binding signature the most recent eager warmup ran under (see
916    /// [`ExternalBindings::capture_signature`]). Capture-mode shape seeding only
917    /// trusts the warm just-in-time shapes recorded in [`Self::buffer_shapes`]
918    /// when a later step presents this exact signature, so any changed pointer
919    /// or capacity withholds the seed instead of baking a stale shape.
920    capture_warm_signature: Option<Vec<ExternalCaptureSig>>,
921    /// Every value's concrete just-in-time shape as resolved by the most recent
922    /// eager warmup. The data-dependent decode shapes we seed for capture are
923    /// JIT-sized on the compute path (which populates `buffers` but not
924    /// [`Self::buffer_shapes`]), so the authoritative warm geometry is snapshotted
925    /// from the eager run's fully-resolved shape map, not the buffer bookkeeping.
926    capture_warm_shapes: HashMap<ValueId, Vec<usize>>,
927    /// The warm decode shapes actually seeded into the most recent capture. After
928    /// the capture pass re-resolves each node's true shape, a divergence here
929    /// means the warm seed was stale for this step, so the graph is retired and
930    /// the caller re-warms/re-captures rather than replaying a stale shape.
931    capture_warm_seeded: HashMap<ValueId, Vec<usize>>,
932    /// `(domain, op_type)` pairs whose kernel aborted device-graph *recording*
933    /// during a capture pass (e.g. it declared `CaptureSupport::Supported` but
934    /// issued a stream synchronize, which CUDA rejects mid-capture). Warm-decode
935    /// shape seeding can admit such a node once its output shape is known; if the
936    /// resulting capture fails, the offending op-type is quarantined here and
937    /// [`Self::node_capture_reason`] then forces every node of that op-type to a
938    /// forced eager seam, so the capture is re-planned and the remaining
939    /// genuinely-capturable ops still fold. Grows monotonically within an
940    /// executor: a kernel that breaks recording once breaks it every time.
941    capture_quarantine_ops: HashSet<(String, String)>,
942    /// Node whose kernel returned an error while recording a captured segment,
943    /// set transiently by [`Self::run_plan_segmented`] so the capture retry loop
944    /// can quarantine its op-type. `None` outside a failed capture pass.
945    last_capture_failed_node: Option<NodeId>,
946    /// Run-scoped zero-copy **view** metadata (§5.4). A value id present here is
947    /// a strided view aliasing another value's buffer (a layout/movement-op
948    /// output such as `Slice`) rather than an owner in [`Self::buffers`]. Built
949    /// during the run loop and cleared at the start of every run.
950    views: HashMap<ValueId, ValueView>,
951    /// Run-scoped set of buffer-owning value ids that have ≥1 live view alias.
952    /// A pinned buffer must not be reused or deallocated for the remainder of
953    /// the run (conservative liveness: a source buffer outlives every view that
954    /// aliases it, guaranteeing no use-after-free). Cleared each run.
955    pinned: HashSet<ValueId>,
956    /// Value ids whose runtime value is a **sequence of tensors** rather than a
957    /// single tensor (produced by `SequenceEmpty`/`SequenceConstruct`/
958    /// `SequenceInsert`/`SequenceErase`/`SplitToSequence`). Computed once at
959    /// build; these values own no [`DeviceBuffer`] and are skipped by buffer
960    /// sizing — their storage lives in [`Self::sequences`] at run time.
961    sequence_values: HashSet<ValueId>,
962    /// Allocation owners promoted into ref-counted storage when a tensor enters
963    /// an ONNX Sequence. `buffers` retains a non-owning dispatch alias, while
964    /// sequence elements clone the owner Arc. At the next run boundary, after
965    /// all sequence handles are cleared, the unique owner is restored to
966    /// `buffers` before any input/output can be mutated.
967    shared_buffers: HashMap<ValueId, Arc<SharedTensorBuffer>>,
968    /// Run-scoped storage for sequence values: `value id → SequenceValue`. A
969    /// [`SequenceValue`] holds its elements as `Arc`-shared immutable tensors,
970    /// so a sequence op that inserts/erases/etc. shares element `Arc`s with the
971    /// source rather than deep-copying bytes (see [`crate::sequence`] for the
972    /// no-copy + no-race invariants). Cleared each run.
973    sequences: HashMap<ValueId, SequenceValue>,
974    /// Run-scoped **zero-copy** backing for a *tensor* value whose bytes are a
975    /// shared sequence element (the output of `SequenceAt`): the tensor aliases
976    /// the element's `Arc` instead of owning a `DeviceBuffer`, so no bytes are
977    /// copied out of the sequence. A downstream kernel reads it through a
978    /// [`TensorView`] over the `Arc`'s bytes; it is materialized to owned bytes
979    /// only at the graph-output/control-flow boundary. Cleared each run.
980    seq_elem_values: HashMap<ValueId, SeqTensor>,
981    execution_provider_fallback_report: Option<ExecutionProviderFallbackReport>,
982    /// Shared runtime trace context. Defaults to a disabled [`TraceContext::noop`]
983    /// so an untraced run pays only a single relaxed atomic load per op when
984    /// deciding whether to open a span. When enabled, the executor opens one
985    /// span per executed op so kernels can attach kernel-variant and
986    /// capture-rejection reasons via [`annotate_current_span_with`].
987    trace: TraceContext,
988    /// Reusable scratch for the resolved input shapes of the node currently
989    /// being dispatched by [`Self::exec_kernel_node`]. Refilled (truncate +
990    /// refill, retaining inner `Vec` capacity) once per node via
991    /// [`Self::refill_input_shapes`], so a steady-state decode step performs no
992    /// per-node `Vec<Vec<usize>>` allocation for shape lookup. Reuse invariant:
993    /// it is fully rewritten at the top of each `exec_kernel_node` call and only
994    /// read within that same call — never aliased or carried across nodes.
995    scratch_input_shapes: Vec<Vec<usize>>,
996    /// F5 Stage 1 — master switch for the steady-state decode-plan memo. Default
997    /// ON; disabled by `ONNX_GENAI_DECODE_MEMO=0`. Consulted on the top-level CPU
998    /// eager decode path — including the normal persistent-KV-binding case.
999    decode_memo_enabled: bool,
1000    /// When set (`ONNX_GENAI_DECODE_MEMO_VERIFY=1`, or always under
1001    /// `debug_assertions`), every memo replay is asserted equal to a fresh
1002    /// `resolve_soft` — the R1 verifiable safety net. Off in release by default.
1003    decode_memo_verify: bool,
1004    /// The active decode-plan memo, primed after two consecutive plan-matching
1005    /// eager steps and rebuilt on any signature change.
1006    decode_memo: Option<DecodePlanMemo>,
1007    /// Bindings of the previous memo-eligible eager step, diffed against the
1008    /// current step to derive the varying-symbol set (R1 two-real-step rule).
1009    decode_memo_prev_bindings: Option<HashMap<SymbolId, usize>>,
1010    /// Diagnostic: what the memo did on the most recent memo-eligible eager
1011    /// step. Exposed to the guard tests.
1012    decode_memo_last_action: DecodeMemoAction,
1013    /// F5 Stage 1 — persistent working shape map reused across decode steps.
1014    /// On a replay step it is taken in place (no allocation): its previous
1015    /// just-in-time entries are stripped, the length-invariant partition is left
1016    /// untouched (byte-identical by construction), and only the variant tail is
1017    /// re-substituted into its existing `Vec`s. The run loop then refills the
1018    /// small data-dependent tail. This is what makes replay genuinely
1019    /// allocation-amortized (Stage 1's whole purpose) rather than a per-token
1020    /// `HashMap`/`Vec` rebuild.
1021    decode_memo_resolved: HashMap<ValueId, Vec<usize>>,
1022    /// Diagnostic counters (proof the memo actually fires on the real path, so a
1023    /// gate that silently excludes it is never shipped again). Incremented per
1024    /// memo-eligible eager step; a summary is emitted on drop when
1025    /// `ONNX_GENAI_DECODE_MEMO_STATS=1`.
1026    decode_memo_primed_count: u64,
1027    decode_memo_rebuilt_count: u64,
1028    decode_memo_replayed_count: u64,
1029    /// Steps that routed through the memo path but were structurally ineligible
1030    /// (memo OFF, CUDA, nested, or non-eager) — counted only when the master
1031    /// switch is on, to make an over-restrictive gate observable.
1032    decode_memo_ineligible_count: u64,
1033    /// F5 Stage 2 — cached invariant buffer/view plan. Present only after a
1034    /// successful memo rebuild that found ≥1 fully-invariant pure-view node; it
1035    /// records the zero-copy view aliases to reinstate and the pure-view plan
1036    /// nodes to elide on a matching replay, guarded by a per-source buffer
1037    /// identity signature. Invalidated on every non-replay step (mirrors the
1038    /// Stage-1 Chew defense-in-depth) so a stale plan from a retired/errored
1039    /// step can never serve a future replay. Default ON (shares the Stage-1
1040    /// `ONNX_GENAI_DECODE_MEMO` gate; set =0 to disable).
1041    decode_view_plan: Option<DecodeViewPlan>,
1042    /// F5 Stage 2 counters. `views_reused` = zero-copy view aliases reinstated
1043    /// without rebuild; `dispatch_elided` = pure-view plan nodes whose re-dispatch
1044    /// was skipped. Both prove non-vacuous firing on the real decode path.
1045    decode_views_reused_count: u64,
1046    decode_dispatch_elided_count: u64,
1047    /// F5 Stage 2 defense-in-depth: consecutive replay steps whose buffer-identity
1048    /// signature failed to match (a source buffer moved/resized under a plan that
1049    /// classified it invariant). After [`STAGE2_SIG_MISMATCH_LIMIT`] such steps the
1050    /// view plan is disabled for the rest of the session — an invariant-buffer
1051    /// assumption that keeps breaking must never keep serving cached views.
1052    decode_view_plan_sig_mismatch_streak: u32,
1053    /// Latched off after repeated signature mismatches (see above).
1054    decode_view_plan_disabled: bool,
1055}
1056
1057/// After this many consecutive buffer-identity signature mismatches, F5 Stage 2
1058/// view reuse is latched off for the session (Chew defense-in-depth).
1059const STAGE2_SIG_MISMATCH_LIMIT: u32 = 2;
1060
1061/// Run-scoped metadata for a zero-copy view value: it owns no buffer but
1062/// borrows `source`'s buffer with the given (real, possibly non-contiguous or
1063/// negative-strided) geometry. `strides`/`byte_offset` are expressed relative
1064/// to `source`'s allocation base, so a view-of-a-view is flattened to a single
1065/// hop whose `source` is always a real buffer owner (never itself a view).
1066#[derive(Clone, Debug)]
1067struct ValueView {
1068    source: ValueId,
1069    shape: Vec<usize>,
1070    strides: Vec<i64>,
1071    byte_offset: usize,
1072}
1073
1074/// F5 Stage 1 — steady-state decode-plan memo.
1075///
1076/// [`Executor::resolve_soft`] is a **pure function of the current symbol
1077/// `bindings`** (see [`substitute`]): a value's resolved shape depends only on
1078/// its interned [`Shape`] and the bindings, and [`Executor::bind_symbols`]
1079/// derives bindings purely from the input *shapes*. During steady-state
1080/// single-token (M=1) decode only a small set of length symbols changes each
1081/// step, so every value whose shape references no such symbol resolves to a
1082/// byte-identical shape every step. This memo caches that length-invariant
1083/// partition and, on a plan-matching step, re-substitutes only the small
1084/// length-varying tail — avoiding a full ~600-entry map rebuild per token.
1085///
1086/// **Soundness (why a wrong shape can never be replayed).** A step may replay
1087/// the invariant partition iff every symbol the memo did *not* classify as
1088/// varying has the same binding it was built under and the bound-symbol set is
1089/// identical ([`DecodePlanMemo::matches`]). Because each invariant shape
1090/// references only static dims and non-varying symbols, an unchanged
1091/// non-varying binding set guarantees it re-substitutes to the identical value —
1092/// so the replayed map is byte-identical to a fresh `resolve_soft`. Crucially,
1093/// if a symbol that *actually* varies were mis-classified invariant, its next
1094/// change is a change to a **non-varying** binding ⇒ `matches` fails ⇒ the memo
1095/// rebuilds; a stale shape is therefore never emitted, regardless of how
1096/// `decode_varying` was derived. The variant tail is always re-substituted from
1097/// the fresh bindings, never replayed. A debug/opt-in full re-resolve
1098/// ([`Executor::decode_memo_verify`]) asserts equality every replay (R1 net).
1099struct DecodePlanMemo {
1100    /// Bindings the invariant partition was built under — the replay guard.
1101    reference_bindings: HashMap<SymbolId, usize>,
1102    /// Symbols observed to change value between two consecutive real eager
1103    /// steps (R1: derived by diffing, never guessed).
1104    decode_varying: HashSet<SymbolId>,
1105    /// Resolved shape of every value whose [`Shape`] references no varying
1106    /// symbol — replayed verbatim.
1107    invariant_shapes: HashMap<ValueId, Vec<usize>>,
1108    /// Values whose [`Shape`] references ≥1 varying symbol — re-substituted from
1109    /// the fresh bindings on every replay step.
1110    variant_values: Vec<ValueId>,
1111    /// All value ids the memo owns (`invariant_shapes` keys ∪ `variant_values`) —
1112    /// i.e. exactly the keys `resolve_soft` produces for this regime. Used to
1113    /// strip the previous step's just-in-time (data-dependent) entries from the
1114    /// persistent working map before replay, so the run loop recomputes them.
1115    canonical: HashSet<ValueId>,
1116    /// L-abstracted structural fingerprint of the persistent device-I/O binding
1117    /// set the memo was built under. Pure-L KV growth leaves this unchanged (so
1118    /// the step replays); a binding appearing/disappearing, a role flip, or a
1119    /// dtype change alters it and forces a rebuild. See [`DecodeBindingSig`].
1120    reference_external_sig: Vec<DecodeBindingSig>,
1121}
1122
1123/// L-abstracted structural fingerprint of one persistent (device-bound) I/O
1124/// binding, for the decode-plan memo replay guard.
1125///
1126/// Unlike [`ExternalCaptureSig`] — which is pointer/capacity- and concrete-shape-
1127/// exact for CUDA capture seeding — this abstracts the growing length symbol `L`
1128/// to its symbolic identity by fingerprinting the binding's *declared* (symbolic)
1129/// shape (`value_shapes[vid]`, which is graph-static). Two decode steps that
1130/// differ only by KV length therefore compare **equal** and replay, while a
1131/// structural change (a binding added/removed, an input/output role flip, or a
1132/// dtype change) compares unequal and forces a rebuild. Pointer and byte capacity
1133/// are deliberately **excluded**: Stage 1 memoizes shape resolution only (buffers
1134/// are re-sized every step outside the memo), so a KV-cache realloc must not
1135/// invalidate the plan — including `ptr`/`len` here would force a rebuild on every
1136/// growth-driven reallocation and leave the memo perpetually dead on the real
1137/// decode path.
1138#[derive(Clone, PartialEq, Eq)]
1139struct DecodeBindingSig {
1140    vid: ValueId,
1141    is_input: bool,
1142    dtype: DataType,
1143    decl_shape: Shape,
1144}
1145
1146impl DecodePlanMemo {
1147    /// A step is a plan-match (may replay) iff it binds exactly the same symbol
1148    /// set as the reference and agrees with it on every non-varying symbol (only
1149    /// the varying length / past-length symbols may differ) **and** presents the
1150    /// same L-abstracted persistent-binding signature.
1151    fn matches(&self, bindings: &HashMap<SymbolId, usize>, external_sig: &[DecodeBindingSig]) -> bool {
1152        if external_sig != self.reference_external_sig {
1153            return false;
1154        }
1155        if bindings.len() != self.reference_bindings.len() {
1156            return false;
1157        }
1158        bindings.iter().all(|(sym, &val)| {
1159            match self.reference_bindings.get(sym) {
1160                Some(&ref_val) => val == ref_val || self.decode_varying.contains(sym),
1161                // A symbol the reference did not bind: the plan shape differs.
1162                None => false,
1163            }
1164        })
1165    }
1166}
1167
1168/// F5 Stage 2 — cached invariant buffer/view plan.
1169///
1170/// Stage 1 proved that during steady single-token decode a large partition of
1171/// values resolve to a byte-identical shape every step (the memo's
1172/// `invariant_shapes`). Empirically, on real decoders the ~113 pure layout ops
1173/// (`Reshape`/`Squeeze`/`Unsqueeze`/no-op views) produce a **byte-identical
1174/// zero-copy [`ValueView`] every step** — yet Stage 1 still re-cleared and
1175/// re-dispatched every one per token. This plan caches those view aliases and the
1176/// nodes that produce them so a matching replay step can:
1177///   1. reinstate the invariant view aliases instead of clearing+rebuilding them,
1178///   2. exclude the invariant partition from per-step buffer sizing, and
1179///   3. elide re-dispatch of the pure-view nodes entirely.
1180///
1181/// **Membership (why an elided view is never geometrically stale).** A node is a
1182/// *candidate* iff every output's shape is in the memo's proven-invariant partition
1183/// (`invariant_shapes`) — so Stage 1 already guarantees the output shape is
1184/// byte-identical every replay step and the replayed `resolved` map always carries
1185/// it. A candidate is *promoted* to the active elision set only after its produced
1186/// view is observed **byte-identical across a second real decode step**
1187/// ([`Executor::validate_decode_view_plan`]) — the same two-real-step confirmation
1188/// Stage 1 uses to derive its varying set. Contiguous-view strides are a pure
1189/// function of the (invariant) output shape, and any per-step `byte_offset` drift
1190/// (e.g. a position-indexed slice into a fixed-capacity KV buffer) would differ
1191/// across the two observed steps and so is rejected before it can ever be elided.
1192///
1193/// **Soundness — the buffer-identity obligation.** Stage 1 could exclude
1194/// pointer/capacity from its replay key because it cached *shapes only* and every
1195/// kernel re-read fresh bytes each step. Stage 2 caches actual **buffers/views**, so
1196/// a realloc or pointer move of a cached view's source would leave the reinstated
1197/// alias pointing at a stale/dangling region. Therefore this plan records
1198/// `source_buffer_sig` = `(source, base_ptr, capacity)` for every buffer a retained
1199/// view aliases, and a replay step reinstates the plan **iff** every signature still
1200/// matches ([`Executor::stage2_buffer_sig_matches`]); any mismatch forces a full
1201/// rebuild. (A retained [`ValueView`] references its source by [`ValueId`], so a
1202/// consumer already re-reads the *current* base pointer — but the byte offset and
1203/// capacity assumptions are exactly what the pointer+capacity guard protects.)
1204///
1205/// The plan is only ever *built* at the successful end of a memo Rebuilt step,
1206/// *validated* on the following replay, and *used* on a memo Replayed step whose
1207/// bindings, external signature (Stage 1) and buffer identity (Stage 2) all match;
1208/// it is invalidated on every non-replay step so an errored/retired step can never
1209/// serve a stale alias. Under `decode_memo_verify` every reinstated view is also
1210/// asserted equal to a freshly built one in-flight (the R1 safety net).
1211struct DecodeViewPlan {
1212    /// Plan-node indices (into [`Executor::plan`]) whose every output shape is in
1213    /// the memo's invariant partition — candidates until validated, then the active
1214    /// elision set (re-dispatch skipped on a matching replay).
1215    elided_nodes: HashSet<usize>,
1216    /// The zero-copy view aliases to reinstate each replay step (`vid` → its
1217    /// invariant [`ValueView`]), verbatim from the reference step.
1218    retained_views: Vec<(ValueId, ValueView)>,
1219    /// Distinct buffer-owning source value ids to re-pin (conservative liveness:
1220    /// a source with a live view is never reused/freed within the run).
1221    pinned_sources: Vec<ValueId>,
1222    /// Buffer-identity signature `(source, base_ptr as usize, capacity)` for every
1223    /// retained view's source buffer. The Stage-2 replay guard.
1224    source_buffer_sig: Vec<(ValueId, usize, usize)>,
1225    /// `false` for a freshly built candidate; set `true` once every retained view
1226    /// has been confirmed byte-identical on a second real decode step. Only a
1227    /// validated plan is ever used to elide dispatch.
1228    validated: bool,
1229}
1230
1231/// Outcome of the most recent memo-eligible eager resolve, exposed for the F5
1232/// guard tests to distinguish a rebuild from a replay.
1233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1234enum DecodeMemoAction {
1235    /// The memo was disabled, or the step was not memo-eligible.
1236    Disabled,
1237    /// First observation of a regime: bindings recorded, no memo built yet
1238    /// (the two-real-step derivation needs a second matching step).
1239    Primed,
1240    /// A full resolve whose result (re)built the memo by diffing this step with
1241    /// the previous eligible step.
1242    Rebuilt,
1243    /// The invariant partition was replayed and only the variant tail
1244    /// re-substituted.
1245    Replayed,
1246}
1247
1248/// True iff two binding maps bind exactly the same symbol set.
1249fn same_symbol_keys(a: &HashMap<SymbolId, usize>, b: &HashMap<SymbolId, usize>) -> bool {
1250    a.len() == b.len() && a.keys().all(|k| b.contains_key(k))
1251}
1252
1253/// M==1 single-token-decode gate (residual #3): admit a memo (re)build only for
1254/// a steady autoregressive-decode transition, where sequence/KV length symbols
1255/// only ever *grow*. `prev`→`cur` qualifies iff both bind the same symbol set,
1256/// at least one symbol increased, and **no** symbol decreased. This excludes the
1257/// prefill→decode transition (the query-length symbol drops from the prompt
1258/// length P to 1) and any non-decode reshape, so the memo activates only on
1259/// single-token decode — not prefill — tightening the blast radius. Soundness
1260/// does not rely on this gate (the `matches` guard is the correctness invariant);
1261/// it only decides *when* the memo is worth building.
1262fn is_decode_growth_transition(
1263    prev: &HashMap<SymbolId, usize>,
1264    cur: &HashMap<SymbolId, usize>,
1265) -> bool {
1266    if !same_symbol_keys(prev, cur) {
1267        return false;
1268    }
1269    let mut any_grew = false;
1270    for (sym, &c) in cur {
1271        let p = prev[sym];
1272        if c > p {
1273            any_grew = true;
1274        } else if c < p {
1275            return false; // a shrinking extent is not steady decode (e.g. prefill→decode)
1276        }
1277    }
1278    any_grew
1279}
1280
1281/// True iff `shape` references any symbol in `symbols`.
1282fn shape_references_any(shape: &Shape, symbols: &HashSet<SymbolId>) -> bool {
1283    shape
1284        .iter()
1285        .any(|d| matches!(d, Dim::Symbolic(s) if symbols.contains(s)))
1286}
1287
1288/// Whether the decode-plan memo master switch (`ONNX_GENAI_DECODE_MEMO`) is on.
1289/// Default ON; set `ONNX_GENAI_DECODE_MEMO=0` to disable.
1290///
1291/// The explicit OFF values are `0`, `false`, and `off` (case-insensitive,
1292/// surrounding whitespace trimmed). Every other state — unset, empty, or an
1293/// unrecognized value — enables the memo, so parsing fails safe toward the
1294/// validated fast path (worst case: rebuild every step, no speedup, never
1295/// wrong). Ripley's authoritative A/B recorded 0 token flips and a non-negative
1296/// speedup at every tested core count, so default-ON is token-exact by
1297/// construction.
1298fn decode_memo_env_enabled() -> bool {
1299    match std::env::var("ONNX_GENAI_DECODE_MEMO") {
1300        Ok(value) => !matches!(
1301            value.trim().to_ascii_lowercase().as_str(),
1302            "0" | "false" | "off"
1303        ),
1304        Err(_) => true,
1305    }
1306}
1307
1308/// Whether the opt-in per-step replay verification (`ONNX_GENAI_DECODE_MEMO_VERIFY`)
1309/// is set. Always on under `debug_assertions`.
1310fn decode_memo_verify_env_enabled() -> bool {
1311    matches!(
1312        std::env::var("ONNX_GENAI_DECODE_MEMO_VERIFY").ok().as_deref(),
1313        Some("1") | Some("true") | Some("on")
1314    )
1315}
1316
1317/// Per-input geometry the run loop resolves once per node: the raw base pointer
1318/// of the backing (root) buffer plus the real view (shape, element strides —
1319/// possibly non-contiguous or negative — and byte offset) to read it through.
1320/// A plain owned value yields contiguous strides at offset 0; a view value
1321/// yields its recorded strides/offset over its source buffer. `present` is false
1322/// for an omitted optional input (an absent placeholder).
1323struct InInfo {
1324    present: bool,
1325    dtype: DataType,
1326    shape: Vec<usize>,
1327    strides: Vec<i64>,
1328    byte_offset: usize,
1329    base_ptr: *const std::ffi::c_void,
1330    device: onnx_runtime_ir::DeviceId,
1331    backing: TensorBacking,
1332    /// Length in bytes of the backing (root) allocation, for the bounds gate.
1333    root_len: usize,
1334}
1335
1336#[derive(Clone)]
1337struct ExternalValue {
1338    dtype: DataType,
1339    shape: Vec<usize>,
1340    accepts_subshape: bool,
1341    ptr: *mut std::ffi::c_void,
1342    len: usize,
1343    alignment: usize,
1344    device: onnx_runtime_ir::DeviceId,
1345}
1346
1347impl ExternalValue {
1348    fn accepts_output(&self, dtype: DataType, shape: &[usize], bytes: usize) -> bool {
1349        self.dtype == dtype
1350            && self.len >= bytes
1351            && if self.accepts_subshape {
1352                shape.len() == self.shape.len()
1353                    && shape
1354                        .iter()
1355                        .zip(&self.shape)
1356                        .all(|(&required, &capacity)| required <= capacity)
1357            } else {
1358                self.shape == shape
1359            }
1360    }
1361
1362    fn writable_buffer(&self) -> Result<DeviceBuffer> {
1363        // SAFETY: `prepare_external_bindings` obtains this pointer from a live
1364        // `DeviceIoBinding` exclusively borrowed for the run. The binding owns
1365        // the allocation, outlives this alias, and is not otherwise accessed
1366        // until execution returns.
1367        unsafe {
1368            DeviceBuffer::from_borrowed_mut_parts(self.ptr, self.device, self.len, self.alignment)
1369        }
1370        .ok_or_else(|| SessionError::Internal("external output binding has a null pointer".into()))
1371    }
1372}
1373
1374#[derive(Default)]
1375struct ExternalBindings {
1376    inputs: HashMap<ValueId, ExternalValue>,
1377    outputs: HashMap<ValueId, ExternalValue>,
1378}
1379
1380/// One persistent (device-bound) I/O binding's identity for capture: its value,
1381/// role, dtype, kernel-visible shape, backing device pointer and byte capacity.
1382/// The full set forms the *decode binding signature* under which a warm eager
1383/// resolution's just-in-time shapes are trusted for capture-mode seeding: a
1384/// change to any pointer or shape means the warm geometry may be stale, so the
1385/// seed is withheld (nodes stay eager) rather than baked into a captured graph.
1386#[derive(Clone, PartialEq, Eq)]
1387struct ExternalCaptureSig {
1388    vid: ValueId,
1389    is_input: bool,
1390    dtype: DataType,
1391    shape: Vec<usize>,
1392    ptr: usize,
1393    len: usize,
1394}
1395
1396impl ExternalBindings {
1397    fn seed_capture_shapes(&self, resolved: &mut HashMap<ValueId, Vec<usize>>) {
1398        for (&vid, value) in self.inputs.iter().chain(&self.outputs) {
1399            resolved.entry(vid).or_insert_with(|| value.shape.clone());
1400        }
1401    }
1402
1403    /// Order-independent signature of every persistent binding (pointer, byte
1404    /// capacity and kernel-visible shape). Two runs whose signatures compare
1405    /// equal present pointer- and capacity-stable buffers, which is the exact
1406    /// precondition for trusting a prior eager run's just-in-time shapes.
1407    fn capture_signature(&self) -> Vec<ExternalCaptureSig> {
1408        let mut sig: Vec<ExternalCaptureSig> = self
1409            .inputs
1410            .iter()
1411            .map(|(&vid, v)| (vid, true, v))
1412            .chain(self.outputs.iter().map(|(&vid, v)| (vid, false, v)))
1413            .map(|(vid, is_input, v)| ExternalCaptureSig {
1414                vid,
1415                is_input,
1416                dtype: v.dtype,
1417                shape: v.shape.clone(),
1418                ptr: v.ptr as usize,
1419                len: v.len,
1420            })
1421            .collect();
1422        sig.sort_by_key(|a| (a.vid.0, a.is_input));
1423        sig
1424    }
1425}
1426
1427/// Concrete child plan cached for one external-input dtype/shape signature.
1428struct CompiledChildPlan {
1429    exec: Executor,
1430    signature: Vec<ChildInputSignature>,
1431}
1432
1433/// Control-flow bodies commonly alternate among a handful of stable shapes.
1434/// Four entries cover those cases without retaining an unbounded set of plans.
1435const CHILD_EXECUTOR_CACHE_CAPACITY: usize = 4;
1436
1437#[derive(Clone, Debug, PartialEq, Eq)]
1438struct ChildInputSignature {
1439    dtype: DataType,
1440    shape: Vec<usize>,
1441}
1442
1443/// A reusable executor for one nested graph body.
1444///
1445/// The body signature and lexical-capture set are resolved once at construction.
1446/// Concrete [`Executor`]s are compiled lazily and retained in a small,
1447/// deterministic LRU keyed by external-input dtype/shapes, so alternating
1448/// Loop/Scan/If signatures reuse prior plans instead of recompiling each switch.
1449pub(crate) struct ChildExecutor {
1450    name: String,
1451    template: Graph,
1452    inherited_opsets: HashMap<String, u64>,
1453    weights: Arc<WeightStore>,
1454    ep: Arc<dyn ExecutionProvider>,
1455    formal_names: Vec<String>,
1456    capture_names: Vec<String>,
1457    input_names: Vec<String>,
1458    compiled: Vec<CompiledChildPlan>,
1459    builds: u64,
1460    runs: u64,
1461    /// Shared trace context, propagated to every compiled child plan's executor.
1462    trace: TraceContext,
1463}
1464
1465#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1466pub(crate) struct ChildExecutorStats {
1467    pub builds: u64,
1468    pub runs: u64,
1469}
1470
1471/// Invocation-invariant binding metadata for one selected subgraph. Loop/Scan
1472/// prepare this once outside the iteration loop, including one-time capture
1473/// materialization, then only rebind the changing formal tensors each step.
1474struct PreparedSubgraph {
1475    key: (NodeId, String),
1476    /// Direct captures plus transitive captures needed only by nested bodies.
1477    captures: HashMap<String, Tensor>,
1478}
1479
1480/// The `[shape, strides, byte_offset]` storage-bounds gate (Holden's
1481/// precondition). Uses [`view_in_bounds`] for fixed-width dtypes and a
1482/// `storage_bytes` check for sub-byte packed dtypes (which have no integral
1483/// per-element byte size).
1484fn view_bounds(
1485    shape: &[usize],
1486    strides: &[i64],
1487    byte_offset: usize,
1488    dtype: DataType,
1489    buffer_len: usize,
1490) -> Result<()> {
1491    let esize = dtype.byte_size();
1492    if esize == 0 {
1493        // Sub-byte (int4/uint4) or variable-width: size via `storage_bytes`.
1494        let numel: usize = shape.iter().product();
1495        let need = byte_offset + dtype.storage_bytes(numel);
1496        if need > buffer_len {
1497            return Err(SessionError::from(
1498                onnx_runtime_ep_api::EpError::InvalidTensorView {
1499                    reason: format!(
1500                        "sub-byte view needs {need} bytes but backing allocation is {buffer_len}"
1501                    ),
1502                },
1503            ));
1504        }
1505        return Ok(());
1506    }
1507    view_in_bounds(shape, strides, byte_offset, esize, buffer_len)?;
1508    Ok(())
1509}
1510
1511/// Gather a strided view over `src` into a fresh contiguous row-major byte
1512/// buffer. `strides` are in **elements** (may be negative); `byte_offset` is the
1513/// byte position of the element origin within `src`. `esize` is the element
1514/// size in bytes (fixed-width types only — callers exclude sub-byte dtypes).
1515/// This is the materialization copy that turns a zero-copy view back into a
1516/// contiguous tensor for a strided-unaware consumer or the output boundary.
1517fn gather_view(
1518    src: &[u8],
1519    shape: &[usize],
1520    strides: &[i64],
1521    byte_offset: usize,
1522    esize: usize,
1523) -> Vec<u8> {
1524    let n: usize = shape.iter().product();
1525    let mut out = vec![0u8; n * esize];
1526    if n == 0 {
1527        return out;
1528    }
1529    let rank = shape.len();
1530    let mut idx = vec![0usize; rank];
1531    let mut w = 0usize;
1532    loop {
1533        let mut off = byte_offset as i64;
1534        for d in 0..rank {
1535            off += strides[d] * idx[d] as i64 * esize as i64;
1536        }
1537        let s = off as usize;
1538        out[w..w + esize].copy_from_slice(&src[s..s + esize]);
1539        w += esize;
1540        // Advance the row-major index; stop when it wraps to all-zero.
1541        let mut carried = true;
1542        for axis in (0..rank).rev() {
1543            idx[axis] += 1;
1544            if idx[axis] < shape[axis] {
1545                carried = false;
1546                break;
1547            }
1548            idx[axis] = 0;
1549        }
1550        if carried {
1551            break;
1552        }
1553    }
1554    out
1555}
1556
1557/// Element count of a shape with overflow checking. A malicious or corrupt
1558/// shape whose dims multiply past `usize::MAX` would silently wrap under a plain
1559/// `iter().product()`, under-sizing the backing buffer. Returns
1560/// [`SessionError::ShapeOverflow`] instead so the caller allocates nothing.
1561fn checked_numel(dims: &[usize], value: impl FnOnce() -> String) -> Result<usize> {
1562    let mut acc = 1usize;
1563    for &d in dims {
1564        acc = match acc.checked_mul(d) {
1565            Some(n) => n,
1566            None => {
1567                return Err(SessionError::ShapeOverflow {
1568                    value: value(),
1569                    dims: dims.to_vec(),
1570                });
1571            }
1572        };
1573    }
1574    Ok(acc)
1575}
1576
1577/// Byte size of `numel` elements of `dtype` with overflow checking. Even when
1578/// the element *count* fits in `usize` (guarded by [`checked_numel`]), the
1579/// element-count → bytes multiply can still wrap for a fixed-width dtype and
1580/// under-size the backing buffer. Returns [`SessionError::ShapeOverflow`] so the
1581/// caller allocates nothing rather than a wrapped, undersized buffer.
1582fn checked_storage_bytes(
1583    dtype: DataType,
1584    numel: usize,
1585    value: impl FnOnce() -> String,
1586    dims: &[usize],
1587) -> Result<usize> {
1588    dtype
1589        .checked_storage_bytes(numel)
1590        .ok_or_else(|| SessionError::ShapeOverflow {
1591            value: value(),
1592            dims: dims.to_vec(),
1593        })
1594}
1595
1596/// The effective operator-set version governing `node` — the graph's imported
1597/// opset for the node's domain. Loaded IR is canonical (the default domain is
1598/// `""`, never `"ai.onnx"`; see [`onnx_runtime_ir::normalize_domain`]), so the
1599/// node's domain keys directly into the opset-import map.
1600fn effective_opset(graph: &Graph, node: &Node) -> u64 {
1601    graph
1602        .opset_imports
1603        .get(node.domain.as_str())
1604        .copied()
1605        .unwrap_or_else(|| {
1606            unreachable!(
1607                "internal invariant violated: node #{} ({}::{}) has no opset import",
1608                node.id.0,
1609                if node.domain.is_empty() {
1610                    "ai.onnx"
1611                } else {
1612                    &node.domain
1613                },
1614                node.op_type
1615            )
1616        })
1617}
1618
1619/// Substitute concrete symbol bindings into a (possibly symbolic) shape.
1620/// Returns `None` if any dim is a symbol with no binding.
1621fn substitute(shape: &Shape, bindings: &HashMap<SymbolId, usize>) -> Option<Vec<usize>> {
1622    shape
1623        .iter()
1624        .map(|d| match d {
1625            Dim::Static(n) => Some(*n),
1626            Dim::Symbolic(s) => bindings.get(s).copied(),
1627        })
1628        .collect()
1629}
1630
1631/// Like [`substitute`] but writes into `out` in place, reusing its existing
1632/// capacity (no heap allocation). Returns `false` (leaving `out` empty) if any
1633/// dim is an unbound symbol. Used by the decode-plan memo replay to refresh the
1634/// variant tail without allocating a fresh `Vec` per value per token.
1635fn substitute_into(
1636    shape: &Shape,
1637    bindings: &HashMap<SymbolId, usize>,
1638    out: &mut Vec<usize>,
1639) -> bool {
1640    out.clear();
1641    for d in shape {
1642        match d {
1643            Dim::Static(n) => out.push(*n),
1644            Dim::Symbolic(s) => match bindings.get(s) {
1645                Some(&v) => out.push(v),
1646                None => {
1647                    out.clear();
1648                    return false;
1649                }
1650            },
1651        }
1652    }
1653    true
1654}
1655
1656/// Decode raw little-endian integer bytes as `i64` for `dtype`, or `None` if the
1657/// dtype is not an integer the shape math understands. Shared by the owned-buffer
1658/// and materialized-view integer-input readers.
1659fn bytes_as_i64(bytes: &[u8], dtype: DataType) -> Option<Vec<i64>> {
1660    match dtype {
1661        DataType::Int64 => Some(
1662            bytes
1663                .chunks_exact(8)
1664                .map(|c| i64::from_le_bytes(c.try_into().unwrap()))
1665                .collect(),
1666        ),
1667        DataType::Int32 => Some(
1668            bytes
1669                .chunks_exact(4)
1670                .map(|c| i32::from_le_bytes(c.try_into().unwrap()) as i64)
1671                .collect(),
1672        ),
1673        _ => None,
1674    }
1675}
1676
1677fn bytes_as_f64(bytes: &[u8], dtype: DataType) -> Option<Vec<f64>> {
1678    match dtype {
1679        DataType::Float32 => Some(
1680            bytes
1681                .chunks_exact(4)
1682                .map(|c| f32::from_le_bytes(c.try_into().unwrap()) as f64)
1683                .collect(),
1684        ),
1685        DataType::Float64 => Some(
1686            bytes
1687                .chunks_exact(8)
1688                .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
1689                .collect(),
1690        ),
1691        _ => None,
1692    }
1693}
1694
1695/// Whether a runtime input is small enough to materialize as shape-propagation
1696/// data. Keep this gate ahead of `contiguous_bytes`: unsupported tensors must
1697/// degrade to absent shape-data without allocating or copying their contents.
1698fn bounded_shape_input(dtype: DataType, shape: &[usize]) -> bool {
1699    if !matches!(dtype, DataType::Int32 | DataType::Int64) {
1700        return false;
1701    }
1702    if shape.len() > 1 {
1703        return false;
1704    }
1705    shape
1706        .iter()
1707        .try_fold(1usize, |count, &dim| count.checked_mul(dim))
1708        .is_some_and(|count| count <= MAX_SHAPE_DATA_ELEMS)
1709}
1710
1711/// Whether a node needs a float runtime input to resolve a data-dependent
1712/// output extent. The list is deliberately explicit, so shape propagation never
1713/// copies unrelated tensor data to host.
1714fn reads_float_shape_input(node: &Node, input_index: usize, opset: u64) -> bool {
1715    node.is_default_domain()
1716        && ((node.op_type == "Resize" && input_index == if opset == 10 { 1 } else { 2 })
1717            || (node.op_type == "NonMaxSuppression" && matches!(input_index, 0 | 1 | 3 | 4)))
1718}
1719
1720fn kernel_input_uses_physical_capacity(node: &Node, input_index: usize) -> bool {
1721    // GQA treats the cache tensor extent as capacity and obtains the valid past
1722    // length from seqlens_k. Standard Attention instead derives past length from
1723    // the cache tensor extent itself.
1724    if node.domain == "com.microsoft"
1725        && node.op_type == "GroupQueryAttention"
1726        && matches!(input_index, 3 | 4)
1727    {
1728        return true;
1729    }
1730    // Default-domain `Attention` with an in-op KV cache (past_key=input 4,
1731    // past_value=input 5) can likewise treat the cache extent as physical
1732    // capacity, deriving the valid attended length on-device from the additive
1733    // attention mask (input 3) instead of the growing cache extent. This mirrors
1734    // the GQA treatment and is what lets the decode step bind the KV cache at a
1735    // fixed capacity so whole-step CUDA-graph capture stays shape-static. Gated
1736    // to the mask-driven, non-causal form (a present mask input and no
1737    // `is_causal` attribute): that path derives length from the mask frontier,
1738    // so the cache extent is pure capacity. Causal-attribute or mask-less
1739    // Attention still reads the cache extent as the valid length.
1740    node.is_default_domain()
1741        && node.op_type == "Attention"
1742        && matches!(input_index, 4 | 5)
1743        && node.inputs.get(3).is_some_and(Option::is_some)
1744        && node
1745            .attr("is_causal")
1746            .and_then(|attr| attr.as_int())
1747            .unwrap_or(0)
1748            == 0
1749        || (
1750            // `pkg.nxrt::IndexShare` mirrors the mask-driven Attention treatment.
1751            // Its capacity form emits the 3-output present that ALIASES the
1752            // fixed-capacity past bindings in place (past_key=input 3, past_value=
1753            // input 4) instead of a growing `past ⧺ current`, and carries the valid
1754            // length via the additive attention_bias (input 6) frontier — which the
1755            // kernel scans on-device to place the current token and bound the gather.
1756            // Binding those caches at physical capacity is what keeps whole-step
1757            // capture shape-static. Gated on the 3-output present + present bias so
1758            // the growing-concat form (1 output, or bias-less) still reads the cache
1759            // extent as the valid length.
1760            node.domain == "pkg.nxrt"
1761                && node.op_type == "IndexShare"
1762                && matches!(input_index, 3 | 4)
1763                && node.outputs.len() == 3
1764                && node.inputs.get(6).is_some_and(Option::is_some)
1765        )
1766}
1767
1768fn kernel_input_uses_padded_capacity(node: &Node, input_index: usize) -> bool {
1769    // Persistent decode masks have a zero-filled suffix. Capacity-oriented
1770    // graphs intentionally read Shape at the allocation extent and ReduceSum is
1771    // unchanged by that suffix; prefix-sensitive transforms such as CumSum must
1772    // instead see the logical valid length.
1773    node.is_default_domain()
1774        && input_index == 0
1775        && matches!(node.op_type.as_str(), "Shape" | "ReduceSum")
1776}
1777
1778/// Recompute the output shape of standard elementwise broadcasting ops from
1779/// their concrete runtime inputs. Loader inference is only a prior: a
1780/// data-dependent upstream value may acquire a different live shape.
1781fn runtime_elementwise_output_shape(
1782    node: &Node,
1783    input_shapes: &[Vec<usize>],
1784) -> Option<std::result::Result<Vec<usize>, onnx_runtime_ir::IrError>> {
1785    if !node.is_default_domain() {
1786        return None;
1787    }
1788
1789    let input_count = match node.op_type.as_str() {
1790        "Add" | "Sub" | "Mul" | "Div" | "Pow" | "Mod" | "BitShift" | "Less" | "Greater"
1791        | "Equal" | "And" | "Or" | "Xor" | "LessOrEqual" | "GreaterOrEqual" => 2,
1792        "Where" => 3,
1793        "Min" | "Max" | "Sum" | "Mean" => input_shapes.len(),
1794        _ => return None,
1795    };
1796    if input_count == 0 || input_shapes.len() < input_count {
1797        return None;
1798    }
1799
1800    let mut shape = input_shapes[0].clone();
1801    for input in &input_shapes[1..input_count] {
1802        shape = match broadcast_shapes(&shape, input) {
1803            Ok(shape) => shape,
1804            Err(error) => return Some(Err(error)),
1805        };
1806    }
1807    Some(Ok(shape))
1808}
1809
1810/// Compute concrete output shapes from already-resolved input shapes and the
1811/// runtime *values* of integer inputs. This is the executor's fallback for the
1812/// rare value whose shape the loader's static (symbolic) inference could not pin
1813/// down — e.g. a `Slice` whose `ends` is produced by a runtime
1814/// `Shape → Min → Cast` chain, followed by movement/broadcast nodes.
1815///
1816/// Model-agnostic: it dispatches on the op type alone. Returns `None` for ops
1817/// this executor cannot resolve dynamically, which surfaces as
1818/// [`SessionError::UnresolvedShape`] exactly as before.
1819fn dynamic_output_shapes(
1820    node: &Node,
1821    input_shapes: &[Vec<usize>],
1822    input_dtypes: &[DataType],
1823    input_values: &[Option<Vec<i64>>],
1824    input_float_values: &[Option<Vec<f64>>],
1825    opset: u64,
1826) -> Option<Vec<Vec<usize>>> {
1827    match node.op_type.as_str() {
1828        "Resize" if node.is_default_domain() => {
1829            let input = input_shapes.first()?;
1830            let rank = input.len();
1831            let axes = if let Some(raw) = node.attr("axes").and_then(Attribute::as_ints) {
1832                let mut axes = Vec::with_capacity(raw.len());
1833                for &axis in raw {
1834                    let axis = if axis < 0 { axis + rank as i64 } else { axis };
1835                    let axis = usize::try_from(axis).ok()?;
1836                    if axis >= rank || axes.contains(&axis) {
1837                        return None;
1838                    }
1839                    axes.push(axis);
1840                }
1841                if axes.is_empty() {
1842                    (0..rank).collect()
1843                } else {
1844                    axes
1845                }
1846            } else {
1847                (0..rank).collect()
1848            };
1849            let scales_index = if opset == 10 { 1 } else { 2 };
1850            let scales = input_float_values
1851                .get(scales_index)
1852                .and_then(|values| values.as_deref())
1853                .filter(|values| !values.is_empty());
1854            let sizes = (opset >= 11)
1855                .then(|| input_values.get(3).and_then(|values| values.as_deref()))
1856                .flatten()
1857                .filter(|values| !values.is_empty());
1858            if scales.is_some() == sizes.is_some() {
1859                return None;
1860            }
1861            let mut output = input.clone();
1862            if let Some(scales) = scales {
1863                if scales.len() != axes.len()
1864                    || node
1865                        .attr("keep_aspect_ratio_policy")
1866                        .and_then(Attribute::as_str)
1867                        .is_some_and(|policy| policy != "stretch")
1868                {
1869                    return None;
1870                }
1871                for (&axis, &scale) in axes.iter().zip(scales) {
1872                    if !scale.is_finite() || scale <= 0.0 {
1873                        return None;
1874                    }
1875                    let extent = input[axis] as f64 * scale;
1876                    if extent > usize::MAX as f64 {
1877                        return None;
1878                    }
1879                    output[axis] = extent.floor() as usize;
1880                }
1881            } else {
1882                let sizes = sizes?;
1883                if sizes.len() != axes.len() {
1884                    return None;
1885                }
1886                let requested = sizes
1887                    .iter()
1888                    .map(|&size| usize::try_from(size).ok().filter(|&size| size > 0))
1889                    .collect::<Option<Vec<_>>>()?;
1890                match node
1891                    .attr("keep_aspect_ratio_policy")
1892                    .and_then(Attribute::as_str)
1893                    .unwrap_or("stretch")
1894                {
1895                    "stretch" => {
1896                        for (&axis, &size) in axes.iter().zip(&requested) {
1897                            output[axis] = size;
1898                        }
1899                    }
1900                    policy @ ("not_larger" | "not_smaller") => {
1901                        if axes.iter().any(|&axis| input[axis] == 0) {
1902                            return None;
1903                        }
1904                        let (numerator, denominator) = axes
1905                            .iter()
1906                            .zip(&requested)
1907                            .map(|(&axis, &size)| (size, input[axis]))
1908                            .reduce(|left, right| {
1909                                let order = (left.0 as u128 * right.1 as u128)
1910                                    .cmp(&(right.0 as u128 * left.1 as u128));
1911                                if (policy == "not_larger" && order.is_le())
1912                                    || (policy == "not_smaller" && order.is_ge())
1913                                {
1914                                    left
1915                                } else {
1916                                    right
1917                                }
1918                            })?;
1919                        if denominator == 0 {
1920                            return None;
1921                        }
1922                        for &axis in &axes {
1923                            let product = (input[axis] as u128).checked_mul(numerator as u128)?;
1924                            output[axis] = usize::try_from(
1925                                (product + denominator as u128 / 2) / denominator as u128,
1926                            )
1927                            .ok()?;
1928                        }
1929                    }
1930                    _ => return None,
1931                }
1932            }
1933            Some(vec![output])
1934        }
1935        // Opset-10+ `Slice`: data, starts, ends, [axes], [steps] as inputs. The
1936        // per-axis element count mirrors the `Slice` kernel's clamp semantics
1937        // exactly (ONNX reference), so the buffer we size here matches what the
1938        // kernel writes.
1939        "Slice" if node.is_default_domain() => {
1940            let data_shape = input_shapes.first()?;
1941            let starts = input_values.get(1)?.as_ref()?;
1942            let ends = input_values.get(2)?.as_ref()?;
1943            let (axes, steps) = onnx_runtime_ep_cpu::slice_axes_steps(
1944                starts.len(),
1945                input_values.get(3).and_then(|v| v.as_deref()),
1946                input_values.get(4).and_then(|v| v.as_deref()),
1947            );
1948            // Reuse the exact kernel geometry helper so the buffer we size here
1949            // always matches what the Slice kernel writes. Any error (length
1950            // mismatch, out-of-range axis, zero step) means "cannot resolve".
1951            let plan =
1952                onnx_runtime_ep_cpu::slice_plan(data_shape, starts, ends, &axes, &steps).ok()?;
1953            let count: Vec<usize> = plan.iter().map(|p| p.count).collect();
1954            Some(vec![count])
1955        }
1956        "NonMaxSuppression" if node.is_default_domain() => {
1957            let boxes_shape = input_shapes.first()?;
1958            let scores_shape = input_shapes.get(1)?;
1959            let boxes = input_float_values.first()?.as_ref()?;
1960            let scores = input_float_values.get(1)?.as_ref()?;
1961            let max_output_boxes_per_class = input_values
1962                .get(2)
1963                .and_then(|value| value.as_ref())
1964                .filter(|value| value.len() == 1)
1965                .map(|value| value[0])
1966                .unwrap_or(0);
1967            let iou_threshold = input_float_values
1968                .get(3)
1969                .and_then(|value| value.as_ref())
1970                .filter(|value| value.len() == 1)
1971                .map(|value| value[0] as f32)
1972                .unwrap_or(0.0);
1973            let score_threshold = input_float_values
1974                .get(4)
1975                .and_then(|value| value.as_ref())
1976                .filter(|value| value.len() == 1)
1977                .map(|value| value[0] as f32)
1978                .unwrap_or(f32::NEG_INFINITY);
1979            let center_point_box = node
1980                .attr("center_point_box")
1981                .and_then(Attribute::as_int)
1982                .unwrap_or(0);
1983            let boxes = boxes.iter().map(|&value| value as f32).collect::<Vec<_>>();
1984            let scores = scores.iter().map(|&value| value as f32).collect::<Vec<_>>();
1985            let selected = onnx_runtime_ep_cpu::non_max_suppression(
1986                &boxes,
1987                boxes_shape,
1988                &scores,
1989                scores_shape,
1990                max_output_boxes_per_class,
1991                iou_threshold,
1992                score_threshold,
1993                center_point_box,
1994            )
1995            .ok()?;
1996            Some(vec![vec![selected.len(), 3]])
1997        }
1998        "GroupQueryAttention" if node.domain == "com.microsoft" => {
1999            let query = input_shapes.first()?;
2000            let past_key = input_shapes.get(3)?;
2001            if query.len() != 3 || past_key.len() != 4 {
2002                return None;
2003            }
2004            let num_heads = usize::try_from(node.attr("num_heads")?.as_int()?).ok()?;
2005            let kv_heads = usize::try_from(node.attr("kv_num_heads")?.as_int()?).ok()?;
2006            if num_heads == 0 || kv_heads == 0 {
2007                return None;
2008            }
2009            let (output, head_dim) = if node.inputs.get(1).and_then(|input| *input).is_some() {
2010                let key = input_shapes.get(1)?;
2011                if key.len() != 3 || !key[2].is_multiple_of(kv_heads) {
2012                    return None;
2013                }
2014                (query.clone(), key[2] / kv_heads)
2015            } else {
2016                let packed_heads = num_heads.checked_add(kv_heads.checked_mul(2)?)?;
2017                if !query[2].is_multiple_of(packed_heads) {
2018                    return None;
2019                }
2020                let head_dim = query[2] / packed_heads;
2021                (
2022                    vec![query[0], query[1], head_dim.checked_mul(num_heads)?],
2023                    head_dim,
2024                )
2025            };
2026            let total_sequence_values = input_values.get(6)?.as_ref()?;
2027            if total_sequence_values.len() != 1 {
2028                return None;
2029            }
2030            let total_sequence = usize::try_from(total_sequence_values[0]).ok()?;
2031            let present_sequence = past_key[2].max(total_sequence);
2032            let present = vec![query[0], kv_heads, present_sequence, head_dim];
2033            let mut shapes = vec![output];
2034            if node.outputs.len() >= 2 {
2035                shapes.push(present.clone());
2036            }
2037            if node.outputs.len() >= 3 {
2038                shapes.push(present);
2039            }
2040            Some(shapes)
2041        }
2042        _ => {
2043            // Re-run the standard, opset-aware shape rule with the concrete
2044            // runtime input shapes and any small integer input values now
2045            // available. This covers shape-preserving movement and broadcasting
2046            // ops after a data-dependent node without duplicating their ONNX
2047            // semantics here (notably Unsqueeze axis normalization).
2048            let inputs = node
2049                .inputs
2050                .iter()
2051                .enumerate()
2052                .map(|(i, input)| {
2053                    if input.is_none() {
2054                        return Some(NodeIo::default());
2055                    }
2056                    let shape = input_shapes
2057                        .get(i)?
2058                        .iter()
2059                        .map(|&dim| i64::try_from(dim).ok().map(DimExpr::constant))
2060                        .collect::<Option<Vec<_>>>()?;
2061                    let dtype = *input_dtypes.get(i)?;
2062                    let shape_data = input_values.get(i)?.as_ref().and_then(|values| {
2063                        let elems = values
2064                            .iter()
2065                            .copied()
2066                            .map(DimExpr::constant)
2067                            .collect::<Vec<_>>();
2068                        match input_shapes[i].as_slice() {
2069                            [] if elems.len() == 1 => {
2070                                Some(ShapeData::scalar(dtype, elems[0].clone()))
2071                            }
2072                            [len] if *len == elems.len() => Some(ShapeData::vector(dtype, elems)),
2073                            _ => None,
2074                        }
2075                    });
2076                    Some(NodeIo {
2077                        type_info: Some(TypeInfo::new(dtype, shape)),
2078                        shape_data,
2079                    })
2080                })
2081                .collect::<Option<Vec<_>>>()?;
2082            let mut imports = HashMap::new();
2083            imports.insert(node.domain.clone(), opset);
2084            let mut interner = SymbolInterner::new(0x8000_0000);
2085            static REGISTRY: std::sync::OnceLock<InferenceRegistry> = std::sync::OnceLock::new();
2086            REGISTRY
2087                .get_or_init(InferenceRegistry::default_registry)
2088                .infer_node(node, &imports, inputs, MergePolicy::Strict, &mut interner)
2089                .ok()?
2090                .into_iter()
2091                .map(|output| {
2092                    output
2093                        .type_info?
2094                        .shape
2095                        .into_iter()
2096                        .map(|dim| usize::try_from(dim.as_const()?).ok())
2097                        .collect()
2098                })
2099                .collect()
2100        }
2101    }
2102}
2103
2104/// Lower an exact `x * Sigmoid(x)` pair to the CPU EP's fused SiLU kernel.
2105///
2106/// The Sigmoid result must have exactly one consumer and must not be a graph
2107/// output, so removing its materialized value cannot change observable behavior.
2108fn fuse_silu_patterns(graph: &mut Graph) -> usize {
2109    let sigmoid_ids: Vec<NodeId> = graph
2110        .nodes
2111        .iter()
2112        .filter_map(|(id, node)| {
2113            (node.op_type == "Sigmoid"
2114                && node.is_default_domain()
2115                && node.inputs.len() == 1
2116                && node.outputs.len() == 1)
2117                .then_some(id)
2118        })
2119        .collect();
2120    let mut fused = 0;
2121
2122    for sigmoid_id in sigmoid_ids {
2123        let Some(sigmoid) = graph.try_node(sigmoid_id) else {
2124            continue;
2125        };
2126        let Some(x) = sigmoid.inputs[0] else {
2127            continue;
2128        };
2129        let sigmoid_output = sigmoid.outputs[0];
2130        if graph.outputs.contains(&sigmoid_output) {
2131            continue;
2132        }
2133        let consumers = graph.consumers(sigmoid_output);
2134        if consumers.len() != 1 {
2135            continue;
2136        }
2137        let mul_id = consumers[0];
2138        let mul = graph.node(mul_id);
2139        if mul.op_type != "Mul"
2140            || !mul.is_default_domain()
2141            || mul.inputs.len() != 2
2142            || mul.outputs.len() != 1
2143            || !((mul.inputs[0] == Some(x) && mul.inputs[1] == Some(sigmoid_output))
2144                || (mul.inputs[1] == Some(x) && mul.inputs[0] == Some(sigmoid_output)))
2145        {
2146            continue;
2147        }
2148
2149        let mut silu = mul.clone();
2150        silu.op_type = "Silu".to_string();
2151        silu.domain = "com.microsoft".to_string();
2152        silu.inputs = vec![Some(x)];
2153        silu.attributes.clear();
2154        graph.replace_node(mul_id, silu);
2155        graph.remove_node(sigmoid_id);
2156        fused += 1;
2157    }
2158
2159    if fused != 0 {
2160        graph
2161            .opset_imports
2162            .entry("com.microsoft".to_string())
2163            .or_insert(1);
2164    }
2165    fused
2166}
2167
2168struct WeightStoreInitializerResolver(Arc<WeightStore>);
2169
2170impl InitializerResolver for WeightStoreInitializerResolver {
2171    fn bytes<'a>(&'a self, weight: &'a onnx_runtime_ir::WeightRef) -> Option<&'a [u8]> {
2172        self.0.bytes(weight)
2173    }
2174}
2175
2176fn run_ep_scoped_passes(
2177    graph: &mut Graph,
2178    weights: &Arc<WeightStore>,
2179    ep: &dyn ExecutionProvider,
2180) -> Result<()> {
2181    let passes = ep.custom_passes();
2182    if passes.is_empty() {
2183        return Ok(());
2184    }
2185
2186    let resolver = Arc::new(WeightStoreInitializerResolver(Arc::clone(weights)));
2187    let context = onnx_runtime_optimizer::PassContext::new().with_initializer_resolver(resolver);
2188    onnx_runtime_optimizer::run_passes(graph, &passes, &context)?;
2189
2190    // Best-effort shape refresh: the passes may have rewritten nodes whose
2191    // output shapes downstream reads. A *data-dependent* invalidity (e.g. a
2192    // `Slice` with step 0) is the runtime kernel's contract to reject, not a
2193    // load-time error — before EP passes existed this re-inference did not run,
2194    // so the graph built and the actionable diagnostic surfaced at `run`.
2195    // Re-infer on a clone and adopt the refreshed shapes only on success so such
2196    // a failure neither aborts the build nor leaves the graph partially updated;
2197    // the executor's own resolution still validates shapes at run time.
2198    let registry = InferenceRegistry::default_registry();
2199    let opset_imports = graph.opset_imports.clone();
2200    let mut refreshed = graph.clone();
2201    if registry
2202        .infer_graph(&mut refreshed, &opset_imports, MergePolicy::Permissive)
2203        .is_ok()
2204    {
2205        *graph = refreshed;
2206    }
2207    Ok(())
2208}
2209
2210fn validate_if_branch_outputs(graph: &Graph, node: &Node) -> Result<()> {
2211    let Some(then_branch) = graph.subgraphs.get(&(node.id, "then_branch".to_string())) else {
2212        return Ok(());
2213    };
2214    let Some(else_branch) = graph.subgraphs.get(&(node.id, "else_branch".to_string())) else {
2215        return Ok(());
2216    };
2217
2218    if then_branch.outputs.len() != else_branch.outputs.len() {
2219        return Err(SessionError::ControlFlow {
2220            op: "If".to_string(),
2221            reason: format!(
2222                "branches declare different output counts: then_branch has {}, \
2223                 else_branch has {}",
2224                then_branch.outputs.len(),
2225                else_branch.outputs.len()
2226            ),
2227        });
2228    }
2229    if then_branch.outputs.len() != node.outputs.len() {
2230        return Err(SessionError::ControlFlow {
2231            op: "If".to_string(),
2232            reason: format!(
2233                "node declares {} output(s), but each branch declares {}",
2234                node.outputs.len(),
2235                then_branch.outputs.len()
2236            ),
2237        });
2238    }
2239    for (index, (&then_output, &else_output)) in then_branch
2240        .outputs
2241        .iter()
2242        .zip(&else_branch.outputs)
2243        .enumerate()
2244    {
2245        if then_branch.value_type_is_known(then_output)
2246            && else_branch.value_type_is_known(else_output)
2247        {
2248            let then_dtype = then_branch.value(then_output).dtype;
2249            let else_dtype = else_branch.value(else_output).dtype;
2250            if then_dtype != else_dtype {
2251                return Err(SessionError::ControlFlow {
2252                    op: "If".to_string(),
2253                    reason: format!(
2254                        "branches declare different dtypes for output {index}: \
2255                         then_branch is {then_dtype:?}, else_branch is {else_dtype:?}"
2256                    ),
2257                });
2258            }
2259        }
2260    }
2261    Ok(())
2262}
2263
2264fn validate_control_flow_signatures(graph: &Graph) -> Result<()> {
2265    for (_, node) in graph.nodes.iter() {
2266        if node.op_type == "If" && matches!(node.domain.as_str(), "" | "ai.onnx") {
2267            validate_if_branch_outputs(graph, node)?;
2268        }
2269    }
2270    for subgraph in graph.subgraphs.values() {
2271        validate_control_flow_signatures(subgraph)?;
2272    }
2273    Ok(())
2274}
2275
2276/// Reject operators no execution provider can run, before EP optimizer passes
2277/// run. An optimizer pass's postcondition validation walks the whole graph and
2278/// would otherwise surface a less actionable structural error (e.g. an
2279/// opset-import invariant) instead of the actionable unsupported-operator
2280/// diagnostic callers rely on.
2281///
2282/// A CUDA graph may legitimately delegate unsupported nodes to a CPU fallback
2283/// (see [`cuda_fallback_report`]), so an unsupported op is not fatal there; the
2284/// check is limited to the terminal (non-CUDA) EP. Only nodes with fully static
2285/// declared input shapes are pre-validated: a symbolic/data-dependent shape is
2286/// resolved and validated at run time, so pre-checking a contrib op whose
2287/// support is shape-conditional would change behavior for valid graphs.
2288fn reject_unsupported_operators(graph: &Graph, ep: &dyn ExecutionProvider) -> Result<()> {
2289    if ep.device_type() == DeviceType::Cuda {
2290        return Ok(());
2291    }
2292    for (node_id, node) in graph.nodes.iter() {
2293        if onnx_runtime_loader::is_ep_context_op(&node.op_type, &node.domain)
2294            || is_control_flow_op(&node.op_type, &node.domain)
2295            || is_sequence_op(&node.op_type, &node.domain)
2296        {
2297            continue;
2298        }
2299
2300        let shapes = node
2301            .inputs
2302            .iter()
2303            .map(|input| {
2304                input
2305                    .map(|value| graph.value(value).shape.clone())
2306                    .unwrap_or_default()
2307            })
2308            .collect::<Vec<_>>();
2309        // Defer nodes with any non-static declared input shape to the run-time
2310        // kernel gate, which sees concrete shapes.
2311        if !shapes.iter().all(|shape| as_static_shape(shape).is_some()) {
2312            continue;
2313        }
2314        let input_dtypes = node
2315            .inputs
2316            .iter()
2317            .map(|input| {
2318                input
2319                    .map(|value| graph.value(value).dtype)
2320                    .unwrap_or(DataType::Undefined)
2321            })
2322            .collect::<Vec<_>>();
2323        let layouts = vec![TensorLayout::contiguous(); shapes.len()];
2324        let opset = effective_opset(graph, node);
2325        if let KernelMatch::Unsupported { reason } =
2326            ep.supports_op(node, opset, &shapes, &input_dtypes, &layouts)
2327        {
2328            return Err(SessionError::unsupported_op(
2329                node,
2330                node_id,
2331                opset,
2332                ep.name(),
2333                reason,
2334            ));
2335        }
2336    }
2337    Ok(())
2338}
2339
2340fn cuda_fallback_report(
2341    graph: &Graph,
2342    ep: &dyn ExecutionProvider,
2343) -> Option<ExecutionProviderFallbackReport> {
2344    if ep.device_type() != DeviceType::Cuda {
2345        return None;
2346    }
2347
2348    let mut issues = Vec::new();
2349    collect_cuda_coverage_issues(graph, graph, ep, "graph", &mut issues);
2350    if issues.is_empty() {
2351        return None;
2352    }
2353
2354    let mut assigned_ops = BTreeSet::new();
2355    let assigned_node_count = collect_executable_ops(graph, &mut assigned_ops);
2356    Some(ExecutionProviderFallbackReport {
2357        requested_provider: ep.name().to_string(),
2358        fallback_provider: "cpu_ep".to_string(),
2359        assigned_node_count,
2360        assigned_ops: assigned_ops.into_iter().collect(),
2361        declines: issues,
2362    })
2363}
2364
2365fn collect_executable_ops(graph: &Graph, ops: &mut BTreeSet<String>) -> usize {
2366    let mut count = 0;
2367    for (_, node) in graph.nodes.iter() {
2368        if !onnx_runtime_loader::is_ep_context_op(&node.op_type, &node.domain) {
2369            count += 1;
2370            ops.insert(format!("{}::{}", canonical_domain(node), node.op_type));
2371        }
2372    }
2373    for subgraph in graph.subgraphs.values() {
2374        count += collect_executable_ops(subgraph, ops);
2375    }
2376    count
2377}
2378
2379fn format_cuda_coverage_issues(issues: &[ExecutionProviderDecline]) -> String {
2380    const MAX_EXAMPLES_PER_CLASS: usize = 3;
2381
2382    let mut groups: BTreeMap<(String, String, String), Vec<String>> = BTreeMap::new();
2383    for issue in issues {
2384        groups
2385            .entry((
2386                issue.domain.clone(),
2387                issue.op_type.clone(),
2388                issue.reason.clone(),
2389            ))
2390            .or_default()
2391            .push(issue.node.clone());
2392    }
2393
2394    groups
2395        .into_iter()
2396        .map(|((domain, op_type, reason), mut nodes)| {
2397            nodes.sort();
2398            let count = nodes.len();
2399            nodes.truncate(MAX_EXAMPLES_PER_CLASS);
2400            format!(
2401                "{domain}::{op_type}: {reason} [count={count}; examples: {}]",
2402                nodes.join(", ")
2403            )
2404        })
2405        .collect::<Vec<_>>()
2406        .join("; ")
2407}
2408
2409fn collect_cuda_coverage_issues(
2410    graph: &Graph,
2411    opset_graph: &Graph,
2412    ep: &dyn ExecutionProvider,
2413    scope: &str,
2414    issues: &mut Vec<ExecutionProviderDecline>,
2415) {
2416    for (node_id, node) in graph.nodes.iter() {
2417        if onnx_runtime_loader::is_ep_context_op(&node.op_type, &node.domain)
2418            || is_control_flow_op(&node.op_type, &node.domain)
2419            || is_sequence_op(&node.op_type, &node.domain)
2420        {
2421            continue;
2422        }
2423
2424        let shapes = node
2425            .inputs
2426            .iter()
2427            .map(|input| {
2428                input
2429                    .map(|value| graph.value(value).shape.clone())
2430                    .unwrap_or_default()
2431            })
2432            .collect::<Vec<_>>();
2433        let layouts = node
2434            .inputs
2435            .iter()
2436            .map(|input| {
2437                input
2438                    .map(|value| graph.value(value).layout.clone())
2439                    .unwrap_or_else(TensorLayout::contiguous)
2440            })
2441            .collect::<Vec<_>>();
2442        let input_dtypes = node
2443            .inputs
2444            .iter()
2445            .map(|input| {
2446                input
2447                    .map(|value| graph.value(value).dtype)
2448                    .unwrap_or(DataType::Undefined)
2449            })
2450            .collect::<Vec<_>>();
2451
2452        let opset = effective_opset(opset_graph, node);
2453        if let KernelMatch::Unsupported { reason } =
2454            ep.supports_op(node, opset, &shapes, &input_dtypes, &layouts)
2455        {
2456            issues.push(ExecutionProviderDecline {
2457                node: format_node_identity(scope, node_id, node),
2458                domain: canonical_domain(node),
2459                op_type: node.op_type.clone(),
2460                reason: reason.into_owned(),
2461            });
2462            continue;
2463        }
2464
2465        let Some(concrete_shapes) = shapes
2466            .iter()
2467            .map(|shape| as_static_shape(shape))
2468            .collect::<Option<Vec<_>>>()
2469        else {
2470            continue;
2471        };
2472        if let Err(error) = ep.get_kernel(node, &concrete_shapes, opset) {
2473            issues.push(ExecutionProviderDecline {
2474                node: format_node_identity(scope, node_id, node),
2475                domain: canonical_domain(node),
2476                op_type: node.op_type.clone(),
2477                reason: format!("kernel creation failed: {error}"),
2478            });
2479        }
2480    }
2481
2482    for ((node_id, attribute), subgraph) in &graph.subgraphs {
2483        let sub_scope = format!("{scope}/node#{}/{}", node_id.0, attribute);
2484        collect_cuda_coverage_issues(subgraph, opset_graph, ep, &sub_scope, issues);
2485    }
2486}
2487
2488fn canonical_domain(node: &Node) -> String {
2489    if node.domain.is_empty() {
2490        "ai.onnx".to_string()
2491    } else {
2492        node.domain.clone()
2493    }
2494}
2495
2496fn format_node_identity(scope: &str, node_id: NodeId, node: &Node) -> String {
2497    if node.name.is_empty() {
2498        format!("{scope}/node#{}", node_id.0)
2499    } else {
2500        format!("{scope}/node#{} {:?}", node_id.0, node.name)
2501    }
2502}
2503
2504fn build_lazy_weight_handles(
2505    graph: &Graph,
2506    weights: &Arc<WeightStore>,
2507    ep: &dyn ExecutionProvider,
2508) -> Result<HashMap<ValueId, WeightHandle>> {
2509    let capabilities = ep.capabilities();
2510    if !capabilities.advertises(onnx_runtime_ep_api::NXRT_WEIGHT_PAGING_CAPABILITY) {
2511        return Ok(HashMap::new());
2512    }
2513
2514    let boundary = LazyWeightBoundary::BlockQuantizedMoe;
2515    let mut handles = HashMap::new();
2516    for (&value, weight) in &graph.initializers {
2517        let graph_value = graph.value(value);
2518        let consumers = graph.consumers(value);
2519        let lazy_only = graph_value.producer.is_none()
2520            && !graph.outputs.contains(&value)
2521            && !consumers.is_empty()
2522            && consumers.into_iter().all(|consumer| {
2523                let node = graph.node(consumer);
2524                boundary.matches(&node.domain, &node.op_type)
2525            });
2526        if !lazy_only {
2527            continue;
2528        }
2529        let Some((mapping_id, offset, len)) = weights.external_mmap_provenance(weight) else {
2530            continue;
2531        };
2532        let region = ExternalMmapRegion {
2533            mapping_id,
2534            offset,
2535            len,
2536        };
2537        let dtype = weight.dtype();
2538        let shape = weight.dims().to_vec();
2539        let weight = weight.clone();
2540        let store = Arc::clone(weights);
2541        let lazy = LazyWeight::block_quantized_moe(vec![region], move || {
2542            let bytes = store.bytes(&weight).ok_or_else(|| {
2543                onnx_runtime_ep_api::WeightHandleError::InvalidResident(
2544                    "external weight bytes are no longer available".into(),
2545                )
2546            })?;
2547            ResidentWeight::new(dtype, shape.clone(), Arc::<[u8]>::from(bytes))
2548        })
2549        .map_err(|error| {
2550            SessionError::Internal(format!(
2551                "cannot create lazy weight handle for value#{}: {error}",
2552                value.0
2553            ))
2554        })?;
2555        handles.insert(value, WeightHandle::Lazy(lazy));
2556    }
2557    Ok(handles)
2558}
2559
2560impl Executor {
2561    /// Compile a graph + weights into a runnable executor on the CPU EP.
2562    pub(crate) fn build(
2563        graph: Graph,
2564        weights: Arc<WeightStore>,
2565        ep: Arc<dyn ExecutionProvider>,
2566    ) -> Result<Self> {
2567        Self::build_with_cuda_requirement(
2568            graph,
2569            weights,
2570            ep,
2571            onnx_genai_runtime_config::runtime_config().require_cuda,
2572        )
2573    }
2574
2575    fn build_with_cuda_requirement(
2576        mut graph: Graph,
2577        weights: Arc<WeightStore>,
2578        mut ep: Arc<dyn ExecutionProvider>,
2579        require_cuda: bool,
2580    ) -> Result<Self> {
2581        // Reject incompatible control-flow signatures before EP optimizers run:
2582        // optimizer postconditions recursively validate subgraphs and can
2583        // otherwise obscure the actionable If diagnostic with a structural
2584        // error from a malformed branch.
2585        validate_control_flow_signatures(&graph)?;
2586        // Reject structurally invalid graphs (a non-DAG) and operators no EP can
2587        // run *before* EP optimizers run. An optimizer pass's postcondition
2588        // validation would otherwise obscure the actionable load-time diagnostic
2589        // (a wrapped `CycleDetected`, or an opset-import invariant instead of the
2590        // unsupported-operator error) with a structural error. Mirrors the
2591        // control-flow signature check above.
2592        graph.topological_order()?;
2593        reject_unsupported_operators(&graph, ep.as_ref())?;
2594        fuse_silu_patterns(&mut graph);
2595        let graph_before_ep_passes = graph.clone();
2596        run_ep_scoped_passes(&mut graph, &weights, ep.as_ref())?;
2597        let mut execution_provider_fallback_report = cuda_fallback_report(&graph, ep.as_ref());
2598        if let Some(report) = &mut execution_provider_fallback_report {
2599            if require_cuda {
2600                return Err(SessionError::HeterogeneousPlacementRequired {
2601                    unsupported_nodes: report.to_string(),
2602                });
2603            }
2604            graph = graph_before_ep_passes;
2605            ep = auto_detect_cpu_ep()?;
2606            run_ep_scoped_passes(&mut graph, &weights, ep.as_ref())?;
2607            let mut assigned_ops = BTreeSet::new();
2608            report.assigned_node_count = collect_executable_ops(&graph, &mut assigned_ops);
2609            report.assigned_ops = assigned_ops.into_iter().collect();
2610            eprintln!(
2611                "[onnx-genai-warning] {report}. Set ONNX_GENAI_REQUIRE_CUDA=1 to reject this fallback"
2612            );
2613        }
2614        // Topological order up front: also validates the selected graph is a DAG.
2615        let order = graph.topological_order()?;
2616        let weight_handles = build_lazy_weight_handles(&graph, &weights, ep.as_ref())?;
2617
2618        let mut value_shapes: HashMap<ValueId, Shape> = HashMap::new();
2619        let mut value_dtypes: HashMap<ValueId, DataType> = HashMap::new();
2620        let mut buffers: HashMap<ValueId, DeviceBuffer> = HashMap::new();
2621        let mut buffer_shapes: HashMap<ValueId, Vec<usize>> = HashMap::new();
2622
2623        // 1) Initializers: record metadata and back resident consumers with a
2624        //    device buffer. A non-host nxrt initializer used exclusively at the
2625        //    lazy fused-MoE boundary deliberately has no eager buffer; the EP
2626        //    materializes it through its WeightHandle on demand. If any resident
2627        //    consumer (or graph output) coexists, no handle is built and the one
2628        //    eager buffer is shared by every consumer. Host mmap bytes retain the
2629        //    existing zero-copy borrow path.
2630        let init_align = TensorLayout::contiguous().alignment;
2631        for (&vid, weight) in &graph.initializers {
2632            let dtype = weight.dtype();
2633            let dims = weight.dims().to_vec();
2634            value_dtypes.insert(vid, dtype);
2635            value_shapes.insert(vid, dims.iter().map(|&d| Dim::Static(d)).collect());
2636            if !ep.device_id().is_host_accessible() && weight_handles.contains_key(&vid) {
2637                continue;
2638            }
2639            let bytes = weights.bytes(weight).ok_or_else(|| {
2640                SessionError::Internal(format!("weight bytes unavailable for value#{}", vid.0))
2641            })?;
2642            // Only borrow when the value has NO producer. The borrowed
2643            // `DeviceBuffer` aliases read-only mmap/inline storage, so it must
2644            // never be written. A legitimate initializer always has
2645            // `producer == None`; a malformed graph can reuse an initializer's
2646            // `ValueId` as a node output (see loader `validate_no_initializer_producer`),
2647            // giving it a producer — a kernel would then write through
2648            // `as_mut_ptr()` into read-only mmap (SIGSEGV / aliasing UB). In
2649            // that case fall back to the owned writable copy below.
2650            let producer_less = graph.value(vid).producer.is_none();
2651            let borrow_align = if matches!(weight, WeightRef::External { .. }) {
2652                host_dtype_alignment(dtype)
2653            } else {
2654                init_align
2655            };
2656            let buf = if ep.device_id().is_host_accessible()
2657                && producer_less
2658                && !bytes.is_empty()
2659                && (bytes.as_ptr() as usize).is_multiple_of(borrow_align)
2660            {
2661                // Zero-copy: alias the suitably aligned initializer bytes. For
2662                // external data this is only the dtype alignment; inline data
2663                // retains the EP allocation alignment requirement.
2664                // SAFETY: `bytes` borrows live mmap storage in `weights` or
2665                // inline storage in `graph`; both executor fields outlive every
2666                // buffer use. The range is `bytes.len()` long,
2667                // `borrow_align`-aligned, and treated as read-only.
2668                unsafe {
2669                    DeviceBuffer::from_borrowed_parts(
2670                        bytes.as_ptr() as *mut std::ffi::c_void,
2671                        ep.device_id(),
2672                        bytes.len(),
2673                        borrow_align,
2674                    )
2675                }
2676            } else {
2677                let mut owned = ep.allocate(bytes.len().max(1), init_align)?;
2678                ep.copy_from_host(bytes, &mut owned)?;
2679                owned
2680            };
2681            buffer_shapes.insert(vid, dims);
2682            buffers.insert(vid, buf);
2683        }
2684
2685        // 2) Record the loader shape + dtype of every remaining value (graph
2686        //    inputs and node outputs). No allocation yet — shapes may be
2687        //    symbolic and are only sized once resolved.
2688        for &vid in &graph.inputs {
2689            value_shapes
2690                .entry(vid)
2691                .or_insert_with(|| graph.value(vid).shape.clone());
2692            value_dtypes.entry(vid).or_insert(graph.value(vid).dtype);
2693        }
2694        for &nid in &order {
2695            for &out in &graph.node(nid).outputs {
2696                value_shapes
2697                    .entry(out)
2698                    .or_insert_with(|| graph.value(out).shape.clone());
2699                value_dtypes.entry(out).or_insert(graph.value(out).dtype);
2700            }
2701        }
2702
2703        let has_symbols = value_shapes.values().any(|s| as_static_shape(s).is_none());
2704
2705        // Sequence-typed values own no tensor buffer: a Sequence op stores its
2706        // list in `sequences` at run time. Mark every value produced by a
2707        // sequence-producing op so buffer sizing skips it (and so a Sequence
2708        // graph output is diagnosed cleanly rather than read as tensor bytes).
2709        let mut sequence_values: HashSet<ValueId> = HashSet::new();
2710        for &nid in &order {
2711            let node = graph.node(nid);
2712            if produces_sequence_output(&node.op_type, &node.domain) {
2713                for &out in &node.outputs {
2714                    sequence_values.insert(out);
2715                }
2716            }
2717        }
2718
2719        // Output value ids of every control-flow node, used to seed their
2720        // concrete (branch-selected) shapes into the capture plan so downstream
2721        // capturable consumers do not each form an eager seam.
2722        let mut control_flow_output_values: HashSet<ValueId> = HashSet::new();
2723        for &nid in &order {
2724            let node = graph.node(nid);
2725            if is_control_flow_op(&node.op_type, &node.domain) {
2726                for &out in &node.outputs {
2727                    control_flow_output_values.insert(out);
2728                }
2729            }
2730        }
2731
2732        // 3) Build the structural per-node plan.
2733        let mut plan = Vec::with_capacity(order.len());
2734        for &nid in &order {
2735            let node = graph.node(nid);
2736            // EPContext nodes are pre-compiled: they bypass placement and were
2737            // already restored through their owning EP by the session's
2738            // consume path (§55.3). They must never be resolved as ordinary
2739            // kernels — the CPU EP has no `EPContext` kernel — so skip them
2740            // here.
2741            if onnx_runtime_loader::is_ep_context_op(&node.op_type, &node.domain) {
2742                continue;
2743            }
2744            // Preserve positional input arity: keep interior `None` (omitted
2745            // optional) slots so a later present input is not misread as the
2746            // omitted one, but trim trailing `None`s (a trailing omitted
2747            // optional just lowers the arity, matching ONNX semantics).
2748            let mut slots: Vec<Option<ValueId>> = node.inputs.clone();
2749            while matches!(slots.last(), Some(None)) {
2750                slots.pop();
2751            }
2752            let inputs = slots;
2753            let outputs: Vec<ValueId> = node.outputs.clone();
2754            let input_dtypes: Vec<DataType> = inputs
2755                .iter()
2756                .map(|v| {
2757                    v.map(|vid| value_dtypes[&vid])
2758                        .unwrap_or(DataType::Undefined)
2759                })
2760                .collect();
2761            let output_dtypes: Vec<DataType> = outputs.iter().map(|v| value_dtypes[v]).collect();
2762            plan.push(NodePlan {
2763                node_id: nid,
2764                inputs,
2765                outputs,
2766                input_dtypes,
2767                output_dtypes,
2768            });
2769        }
2770
2771        // 4) name → value id and the set of caller-required inputs.
2772        let mut input_index = HashMap::new();
2773        let mut required_inputs = Vec::new();
2774        for &vid in &graph.inputs {
2775            if graph.initializers.contains_key(&vid) {
2776                continue; // pre-filled; not a caller input
2777            }
2778            required_inputs.push(vid);
2779            if let Some(name) = &graph.value(vid).name {
2780                input_index.insert(name.clone(), vid);
2781            }
2782        }
2783
2784        // Full name → value id map (every named value in the graph), used to
2785        // resolve a nested subgraph's outer-scope captures by name.
2786        let mut name_index = HashMap::new();
2787        for (vid, value) in graph.values.iter() {
2788            if let Some(name) = &value.name {
2789                name_index.insert(name.clone(), vid);
2790            }
2791        }
2792
2793        let mut exec = Self {
2794            graph,
2795            weights,
2796            ep,
2797            weight_handles,
2798            buffers,
2799            buffer_shapes,
2800            value_shapes,
2801            value_dtypes,
2802            plan,
2803            input_index,
2804            required_inputs,
2805            has_symbols,
2806            cache: KernelCache::default(),
2807            name_index,
2808            subgraph_execs: HashMap::new(),
2809            control_flow_stats: ControlFlowStats::default(),
2810            if_last_predicate: HashMap::new(),
2811            device_graph_signature: None,
2812            capture_schedule: None,
2813            capture_segmentation: Vec::new(),
2814            control_flow_output_values,
2815            capture_cf_shapes: HashMap::new(),
2816            capture_warm_signature: None,
2817            capture_warm_shapes: HashMap::new(),
2818            capture_warm_seeded: HashMap::new(),
2819            capture_quarantine_ops: HashSet::new(),
2820            last_capture_failed_node: None,
2821            views: HashMap::new(),
2822            pinned: HashSet::new(),
2823            sequence_values,
2824            shared_buffers: HashMap::new(),
2825            sequences: HashMap::new(),
2826            seq_elem_values: HashMap::new(),
2827            execution_provider_fallback_report,
2828            trace: TraceContext::noop(),
2829            scratch_input_shapes: Vec::new(),
2830            decode_memo_enabled: decode_memo_env_enabled(),
2831            decode_memo_verify: cfg!(debug_assertions) || decode_memo_verify_env_enabled(),
2832            decode_memo: None,
2833            decode_memo_prev_bindings: None,
2834            decode_memo_last_action: DecodeMemoAction::Disabled,
2835            decode_memo_resolved: HashMap::new(),
2836            decode_memo_primed_count: 0,
2837            decode_memo_rebuilt_count: 0,
2838            decode_memo_replayed_count: 0,
2839            decode_memo_ineligible_count: 0,
2840            decode_view_plan: None,
2841            decode_views_reused_count: 0,
2842            decode_dispatch_elided_count: 0,
2843            decode_view_plan_sig_mismatch_streak: 0,
2844            decode_view_plan_disabled: false,
2845        };
2846
2847        // 5) Fully-static graphs are materialized eagerly (buffers + the whole
2848        //    "compiled plan" of kernels), so the first `run` sees only cache
2849        //    hits. Symbolic graphs cannot be sized until a `run` fixes their
2850        //    shapes, so their buffers/kernels are created on first use.
2851        if !exec.has_symbols {
2852            let empty = HashMap::new();
2853            let resolved = exec.resolve_all(&empty)?;
2854            exec.size_buffers(&resolved)?;
2855            exec.compile_all(&resolved)?;
2856        }
2857        Ok(exec)
2858    }
2859
2860    /// Allocate `vid`'s buffer for `dims`, or reuse the existing allocation when
2861    /// it is already sized for `dims` (the run-scoped reuse path).
2862    fn ensure_buffer(&mut self, vid: ValueId, dtype: DataType, dims: &[usize]) -> Result<()> {
2863        if self.buffer_shapes.get(&vid).map(|s| s.as_slice()) == Some(dims) {
2864            return Ok(()); // identical shape → reuse allocation
2865        }
2866        if let Some(old) = self.buffers.remove(&vid) {
2867            self.ep.deallocate(old)?;
2868        }
2869        self.shared_buffers.remove(&vid);
2870        let numel = checked_numel(dims, || format!("value#{}", vid.0))?;
2871        let size = checked_storage_bytes(dtype, numel, || format!("value#{}", vid.0), dims)?;
2872        let buf = self
2873            .ep
2874            .allocate(size.max(1), TensorLayout::contiguous().alignment)?;
2875        self.buffers.insert(vid, buf);
2876        self.buffer_shapes.insert(vid, dims.to_vec());
2877        Ok(())
2878    }
2879
2880    /// Resolve every value's concrete shape by substituting `bindings` into its
2881    /// loader shape. A value whose shape stays symbolic (unbound) cannot be
2882    /// sized: report it as an uninferred shape, naming its producing op.
2883    fn resolve_all(
2884        &self,
2885        bindings: &HashMap<SymbolId, usize>,
2886    ) -> Result<HashMap<ValueId, Vec<usize>>> {
2887        let mut resolved = HashMap::with_capacity(self.value_shapes.len());
2888        for (&vid, shape) in &self.value_shapes {
2889            // Sequence-typed values have no meaningful tensor shape and are
2890            // never buffer-sized; skip them so a static graph does not trip the
2891            // unresolved-shape check on a sequence value.
2892            if self.sequence_values.contains(&vid) {
2893                continue;
2894            }
2895            match substitute(shape, bindings) {
2896                Some(dims) => {
2897                    resolved.insert(vid, dims);
2898                }
2899                None => {
2900                    let value = self.graph.value(vid);
2901                    let name = value
2902                        .name
2903                        .clone()
2904                        .unwrap_or_else(|| format!("value#{}", vid.0));
2905                    let op = value
2906                        .producer
2907                        .map(|nid| self.graph.node(nid).op_type.clone())
2908                        .unwrap_or_else(|| "<graph input>".to_string());
2909                    return Err(SessionError::UnresolvedShape { value: name, op });
2910                }
2911            }
2912        }
2913        Ok(resolved)
2914    }
2915
2916    /// Like [`Self::resolve_all`] but never errors: values whose shape stays
2917    /// symbolic (a data-dependent extent the loader could not pin down) are
2918    /// simply omitted, to be resolved just-in-time during execution once their
2919    /// producing node's inputs are concrete.
2920    fn resolve_soft(&self, bindings: &HashMap<SymbolId, usize>) -> HashMap<ValueId, Vec<usize>> {
2921        let mut resolved = HashMap::with_capacity(self.value_shapes.len());
2922        for (&vid, shape) in &self.value_shapes {
2923            if let Some(dims) = substitute(shape, bindings) {
2924                resolved.insert(vid, dims);
2925            }
2926        }
2927        resolved
2928    }
2929
2930    /// F5 Stage 1: resolve every value's concrete shape for a memo-eligible
2931    /// eager step, replaying the length-invariant partition through the
2932    /// [`DecodePlanMemo`] when the step is plan-identical to the memoized one,
2933    /// and re-substituting only the length-varying tail. On any signature change
2934    /// (prefill→decode, batch change, non-length dim change, …) it falls back to
2935    /// a full [`Self::resolve_soft`] and (re)builds the memo by diffing this
2936    /// step's bindings with the previous eligible step's (R1 two-real-step
2937    /// derivation). The output is provably byte-identical to `resolve_soft`
2938    /// (asserted every replay when [`Self::decode_memo_verify`] is set).
2939    fn resolve_soft_decode_memo(
2940        &mut self,
2941        bindings: &HashMap<SymbolId, usize>,
2942        external: &ExternalBindings,
2943    ) -> HashMap<ValueId, Vec<usize>> {
2944        // L-abstracted fingerprint of the persistent binding set (KV cache). Pure
2945        // length growth leaves it unchanged; a structural change forces a rebuild.
2946        let external_sig = self.decode_external_signature(external);
2947        // --- Fast path: an active memo whose non-varying bindings and binding
2948        //     signature are unchanged. Replays the invariant partition with ZERO
2949        //     allocation: the persistent working map is taken in place, the
2950        //     previous step's just-in-time entries are stripped, invariant entries
2951        //     are left untouched (byte-identical by construction), and only the
2952        //     variant tail is re-substituted into its existing `Vec`s.
2953        if self
2954            .decode_memo
2955            .as_ref()
2956            .is_some_and(|memo| memo.matches(bindings, &external_sig))
2957        {
2958            // Own the memo for the duration so `self.value_shapes` /
2959            // `decode_memo_resolved` can be borrowed disjointly; restored below.
2960            let memo = self.decode_memo.take().unwrap();
2961            let mut resolved = std::mem::take(&mut self.decode_memo_resolved);
2962            // Drop the previous step's data-dependent (JIT) entries so the run
2963            // loop recomputes them; the canonical partition is retained in place.
2964            resolved.retain(|vid, _| memo.canonical.contains(vid));
2965            // Restore any length-invariant entry missing from the persistent map.
2966            // By construction (the run loop only adds/overwrites, never drops
2967            // canonical keys, and the rebuild step persisted the full map) this
2968            // never fires in steady state, so replay stays allocation-free; it is
2969            // a defensive re-seed from the memo's authoritative invariant plan.
2970            for (&vid, dims) in &memo.invariant_shapes {
2971                resolved.entry(vid).or_insert_with(|| dims.clone());
2972            }
2973            // Re-substitute only the variant tail, reusing each `Vec`'s capacity.
2974            for &vid in &memo.variant_values {
2975                let shape = &self.value_shapes[&vid];
2976                match resolved.get_mut(&vid) {
2977                    Some(slot) => {
2978                        if !substitute_into(shape, bindings, slot) {
2979                            resolved.remove(&vid);
2980                        }
2981                    }
2982                    None => {
2983                        if let Some(dims) = substitute(shape, bindings) {
2984                            resolved.insert(vid, dims);
2985                        }
2986                    }
2987                }
2988            }
2989            if self.decode_memo_verify {
2990                // R1 verifiable safety net: the replay must equal a fresh resolve.
2991                let fresh = self.resolve_soft(bindings);
2992                assert_eq!(
2993                    resolved, fresh,
2994                    "decode-plan memo replay diverged from resolve_soft (unsound invariant \
2995                     classification)"
2996                );
2997            }
2998            self.decode_memo = Some(memo);
2999            self.decode_memo_last_action = DecodeMemoAction::Replayed;
3000            self.decode_memo_replayed_count += 1;
3001            self.decode_memo_prev_bindings = Some(bindings.clone());
3002            return resolved;
3003        }
3004
3005        // --- Slow path: full resolve, then try to (re)build the memo by diffing
3006        //     this step with the previous eligible step (two real steps, R1) —
3007        //     but only for a steady single-token-decode growth transition (M==1
3008        //     gate), so the memo never activates on prefill.
3009        //
3010        // Defense-in-depth (Chew): drop the persistent working map on every
3011        // non-replay step so a stale invariant `Vec` from a retired plan can
3012        // never leak into a future replay (e.g. if a run errored before the
3013        // end-of-step persist-back). It is repopulated by this step's persist-back
3014        // (or, if that step errors, left empty and defensively re-seeded next
3015        // replay), so the clear costs nothing on the steady path.
3016        self.decode_memo_resolved.clear();
3017        // F5 Stage 2 defense-in-depth: retire the cached view plan on every
3018        // non-replay (rebuild/prime) step. A Rebuilt step rebuilds it fresh only
3019        // at its successful end (below, in `run_scoped_mode`); a step that errors
3020        // before that leaves it `None`, so a stale invariant view alias from a
3021        // retired plan can never be reinstated into a later replay.
3022        self.decode_view_plan = None;
3023        let resolved = self.resolve_soft(bindings);
3024        match self.decode_memo_prev_bindings.take() {
3025            Some(prev) if is_decode_growth_transition(&prev, bindings) => {
3026                let decode_varying: HashSet<SymbolId> = bindings
3027                    .iter()
3028                    .filter(|(sym, val)| prev.get(*sym) != Some(*val))
3029                    .map(|(&sym, _)| sym)
3030                    .collect();
3031                let mut invariant_shapes = HashMap::with_capacity(resolved.len());
3032                let mut variant_values = Vec::new();
3033                let mut canonical = HashSet::with_capacity(resolved.len());
3034                for (&vid, dims) in &resolved {
3035                    canonical.insert(vid);
3036                    if shape_references_any(&self.value_shapes[&vid], &decode_varying) {
3037                        variant_values.push(vid);
3038                    } else {
3039                        invariant_shapes.insert(vid, dims.clone());
3040                    }
3041                }
3042                self.decode_memo = Some(DecodePlanMemo {
3043                    reference_bindings: bindings.clone(),
3044                    decode_varying,
3045                    invariant_shapes,
3046                    variant_values,
3047                    canonical,
3048                    reference_external_sig: external_sig,
3049                });
3050                self.decode_memo_last_action = DecodeMemoAction::Rebuilt;
3051                self.decode_memo_rebuilt_count += 1;
3052            }
3053            _ => {
3054                // First observation of a regime, a bound-symbol-set change, or a
3055                // non-decode transition (e.g. prefill): drop any stale memo and
3056                // wait for the next steady-decode step to diff against.
3057                self.decode_memo = None;
3058                self.decode_memo_last_action = DecodeMemoAction::Primed;
3059                self.decode_memo_primed_count += 1;
3060            }
3061        }
3062        self.decode_memo_prev_bindings = Some(bindings.clone());
3063        resolved
3064    }
3065
3066    /// L-abstracted structural fingerprint of the persistent device-I/O binding
3067    /// set (see [`DecodeBindingSig`]). Order-independent; the declared symbolic
3068    /// shape (graph-static) stands in for the concrete one, so pure-L KV growth
3069    /// yields an unchanged signature while a binding added/removed, a role flip,
3070    /// or a dtype change yields a different one.
3071    fn decode_external_signature(&self, external: &ExternalBindings) -> Vec<DecodeBindingSig> {
3072        let mut sig: Vec<DecodeBindingSig> = external
3073            .inputs
3074            .keys()
3075            .map(|&vid| (vid, true))
3076            .chain(external.outputs.keys().map(|&vid| (vid, false)))
3077            .map(|(vid, is_input)| DecodeBindingSig {
3078                vid,
3079                is_input,
3080                dtype: self.value_dtypes[&vid],
3081                decl_shape: self.value_shapes[&vid].clone(),
3082            })
3083            .collect();
3084        sig.sort_by_key(|s| (s.vid.0, s.is_input));
3085        sig
3086    }
3087
3088    #[cfg(test)]
3089    fn set_decode_memo_enabled(&mut self, enabled: bool) {
3090        self.decode_memo_enabled = enabled;
3091        self.decode_memo_verify = true;
3092        self.decode_memo = None;
3093        self.decode_memo_prev_bindings = None;
3094        self.decode_memo_resolved.clear();
3095        self.decode_memo_last_action = DecodeMemoAction::Disabled;
3096        self.decode_memo_primed_count = 0;
3097        self.decode_memo_rebuilt_count = 0;
3098        self.decode_memo_replayed_count = 0;
3099        self.decode_memo_ineligible_count = 0;
3100        self.decode_view_plan = None;
3101        self.decode_views_reused_count = 0;
3102        self.decode_dispatch_elided_count = 0;
3103        self.decode_view_plan_sig_mismatch_streak = 0;
3104        self.decode_view_plan_disabled = false;
3105    }
3106
3107    #[cfg(test)]
3108    fn decode_memo_action(&self) -> DecodeMemoAction {
3109        self.decode_memo_last_action
3110    }
3111
3112    /// F5 Stage 1 memo activity counters `(primed, rebuilt, replayed, ineligible)`
3113    /// accumulated over this executor's lifetime. `replayed > 0` on a real decode
3114    /// run is the proof the memo actually fires (not silently gated out); the
3115    /// coordinator's on-model A/B reads these to reject a vacuous pass.
3116    pub(crate) fn decode_memo_counts(&self) -> (u64, u64, u64, u64) {
3117        (
3118            self.decode_memo_primed_count,
3119            self.decode_memo_rebuilt_count,
3120            self.decode_memo_replayed_count,
3121            self.decode_memo_ineligible_count,
3122        )
3123    }
3124
3125    /// F5 Stage 2 activity counters `(views_reused, dispatch_elided)` accumulated
3126    /// over this executor's lifetime. Both `> 0` on a real decode run prove the
3127    /// invariant view-reuse / dispatch-elision path actually fired (not a vacuous
3128    /// pass); an on-model A/B reads these alongside the Stage-1 counters.
3129    pub(crate) fn decode_view_plan_counts(&self) -> (u64, u64) {
3130        (
3131            self.decode_views_reused_count,
3132            self.decode_dispatch_elided_count,
3133        )
3134    }
3135
3136    /// F5 Stage 2 replay guard: every retained view's source buffer must still be
3137    /// the identical allocation (same base pointer *and* capacity) it was under
3138    /// when the plan was built. A realloc or move — even one that preserves the
3139    /// logical shape — invalidates the cached byte offsets/strides, so this must
3140    /// return `false` and force a full rebuild. This is the pointer/capacity
3141    /// obligation Stage 1 deferred (it cached shapes only); Stage 2 pays it here.
3142    fn stage2_buffer_sig_matches(&self, plan: &DecodeViewPlan) -> bool {
3143        plan.source_buffer_sig.iter().all(|(vid, ptr, cap)| {
3144            self.buffers
3145                .get(vid)
3146                .is_some_and(|buf| buf.as_ptr() as usize == *ptr && buf.len() == *cap)
3147        })
3148    }
3149
3150    /// F5 Stage 2: build the *candidate* view plan from the state left by a
3151    /// successful memo Rebuilt step. A node is a candidate iff every one of its
3152    /// outputs is a zero-copy view (`self.views`) whose **shape is in the memo's
3153    /// proven-invariant partition** — so Stage 1 guarantees the replayed `resolved`
3154    /// map carries that exact shape every step. The candidate's source buffers can
3155    /// still be classified variant (e.g. a fixed-capacity KV buffer whose logical
3156    /// length grows): its concrete stability is confirmed separately by
3157    /// [`Self::validate_decode_view_plan`] (byte-identical view across a second real
3158    /// step) and guarded each replay by the buffer-identity signature. Returns
3159    /// `None` if nothing is a candidate.
3160    fn build_decode_view_plan(&self) -> Option<DecodeViewPlan> {
3161        let memo = self.decode_memo.as_ref()?;
3162        let invariant = |vid: &ValueId| memo.invariant_shapes.contains_key(vid);
3163        let mut elided_nodes = HashSet::new();
3164        let mut retained_views = Vec::new();
3165        let mut sources: HashSet<ValueId> = HashSet::new();
3166        for pi in 0..self.plan.len() {
3167            let outputs = &self.plan[pi].outputs;
3168            if outputs.is_empty() {
3169                continue;
3170            }
3171            // Every output must be a zero-copy view whose shape Stage 1 already
3172            // proves invariant (so `resolved[output]` is stable and correct when
3173            // the node is elided).
3174            let all_view_invariant = outputs
3175                .iter()
3176                .all(|ovid| invariant(ovid) && self.views.contains_key(ovid));
3177            if !all_view_invariant {
3178                continue;
3179            }
3180            elided_nodes.insert(pi);
3181            for ovid in outputs {
3182                let view = self.views[ovid].clone();
3183                sources.insert(view.source);
3184                retained_views.push((*ovid, view));
3185            }
3186        }
3187        if elided_nodes.is_empty() {
3188            return None;
3189        }
3190        // Record the buffer identity of every aliased source (the Stage-2 guard).
3191        let mut source_buffer_sig = Vec::with_capacity(sources.len());
3192        for &src in &sources {
3193            let buf = self.buffers.get(&src)?;
3194            source_buffer_sig.push((src, buf.as_ptr() as usize, buf.len()));
3195        }
3196        Some(DecodeViewPlan {
3197            elided_nodes,
3198            retained_views,
3199            pinned_sources: sources.into_iter().collect(),
3200            source_buffer_sig,
3201            validated: false,
3202        })
3203    }
3204
3205    /// F5 Stage 2: confirm a candidate plan on a second real decode step. The step
3206    /// ran every node normally (no elision), so `self.views` now holds freshly
3207    /// built aliases; keep only the candidate nodes whose every output view is
3208    /// **byte-identical** (source, shape, strides, byte offset) to the one captured
3209    /// when the plan was built. This two-real-step confirmation (mirroring Stage 1's
3210    /// varying-set derivation) rejects any view whose geometry actually drifts — e.g.
3211    /// a position-indexed slice into a fixed-capacity buffer — before it is ever
3212    /// elided. Sources and the buffer-identity signature are recomputed from the
3213    /// surviving views. The plan is marked validated iff anything survives.
3214    fn validate_decode_view_plan(&self, mut plan: DecodeViewPlan) -> Option<DecodeViewPlan> {
3215        let view_matches = |a: &ValueView, b: &ValueView| {
3216            a.source == b.source
3217                && a.shape == b.shape
3218                && a.strides == b.strides
3219                && a.byte_offset == b.byte_offset
3220        };
3221        // A node survives iff every one of its retained outputs still matches the
3222        // freshly rebuilt view this step.
3223        let mut surviving_nodes: HashSet<usize> = HashSet::new();
3224        let node_outputs = |pi: usize| self.plan[pi].outputs.clone();
3225        for &pi in &plan.elided_nodes {
3226            let ok = node_outputs(pi).iter().all(|ovid| {
3227                match (
3228                    plan.retained_views.iter().find(|(v, _)| v == ovid),
3229                    self.views.get(ovid),
3230                ) {
3231                    (Some((_, cached)), Some(fresh)) => view_matches(cached, fresh),
3232                    _ => false,
3233                }
3234            });
3235            if ok {
3236                surviving_nodes.insert(pi);
3237            }
3238        }
3239        if surviving_nodes.is_empty() {
3240            return None;
3241        }
3242        // Rebuild retained views / sources / signature from the survivors only,
3243        // using the freshly built (identical) views.
3244        let surviving_outputs: HashSet<ValueId> = surviving_nodes
3245            .iter()
3246            .flat_map(|&pi| self.plan[pi].outputs.clone())
3247            .collect();
3248        let mut retained_views = Vec::new();
3249        let mut sources: HashSet<ValueId> = HashSet::new();
3250        for ovid in surviving_outputs {
3251            let view = self.views.get(&ovid)?.clone();
3252            sources.insert(view.source);
3253            retained_views.push((ovid, view));
3254        }
3255        let mut source_buffer_sig = Vec::with_capacity(sources.len());
3256        for &src in &sources {
3257            let buf = self.buffers.get(&src)?;
3258            source_buffer_sig.push((src, buf.as_ptr() as usize, buf.len()));
3259        }
3260        plan.elided_nodes = surviving_nodes;
3261        plan.retained_views = retained_views;
3262        plan.pinned_sources = sources.into_iter().collect();
3263        plan.source_buffer_sig = source_buffer_sig;
3264        plan.validated = true;
3265        Some(plan)
3266    }
3267
3268    /// Size (allocate or reuse) a backing buffer for every value from its
3269    /// resolved concrete shape. Initializers already hold their weights and are
3270    /// left untouched. Values whose shape is not (yet) in `resolved` — the
3271    /// data-dependent ones filled in during execution — are skipped here and
3272    /// sized just-in-time in the run loop.
3273    fn size_buffers(&mut self, resolved: &HashMap<ValueId, Vec<usize>>) -> Result<()> {
3274        self.size_buffers_excluding(resolved, &HashSet::new())
3275    }
3276
3277    fn size_buffers_excluding(
3278        &mut self,
3279        resolved: &HashMap<ValueId, Vec<usize>>,
3280        excluded: &HashSet<ValueId>,
3281    ) -> Result<()> {
3282        let vids: Vec<ValueId> = self.value_shapes.keys().copied().collect();
3283        for vid in vids {
3284            if self.graph.initializers.contains_key(&vid) || excluded.contains(&vid) {
3285                continue;
3286            }
3287            // Sequence-typed values own no tensor buffer (their list lives in
3288            // `sequences` at run time), so never size one for them.
3289            if self.sequence_values.contains(&vid) {
3290                continue;
3291            }
3292            let dtype = self.value_dtypes[&vid];
3293            let Some(dims) = resolved.get(&vid).cloned() else {
3294                continue;
3295            };
3296            self.ensure_buffer(vid, dtype, &dims)?;
3297        }
3298        Ok(())
3299    }
3300
3301    /// Resolved input shapes of a plan node, in positional order. An omitted
3302    /// optional input (`None` slot) has no shape; it takes an empty shape,
3303    /// which the run loop only ever pairs with an absent placeholder view.
3304    fn node_input_shapes(
3305        plan: &NodePlan,
3306        resolved: &HashMap<ValueId, Vec<usize>>,
3307    ) -> Vec<Vec<usize>> {
3308        plan.inputs
3309            .iter()
3310            .map(|v| v.map(|vid| resolved[&vid].clone()).unwrap_or_default())
3311            .collect()
3312    }
3313
3314    /// Populate the kernel cache for the compiled plan against `resolved` shapes.
3315    fn compile_all(&mut self, resolved: &HashMap<ValueId, Vec<usize>>) -> Result<()> {
3316        for i in 0..self.plan.len() {
3317            let node_id = self.plan[i].node_id;
3318            let node = self.graph.node(node_id);
3319            // Control-flow ops (If/Loop/Scan) are not leaf kernels — they execute
3320            // nested subgraphs through the executor's own path, so they have no
3321            // entry in the EP kernel registry and must not be compiled here.
3322            if is_control_flow_op(&node.op_type, &node.domain) {
3323                continue;
3324            }
3325            // Sequence ops are executor-handled (they operate on sequence-of-
3326            // tensor values, not tensor views) — they have no EP kernel and must
3327            // not be compiled here, exactly like control-flow ops.
3328            if is_sequence_op(&node.op_type, &node.domain) {
3329                continue;
3330            }
3331            let input_shapes = Self::node_input_shapes(&self.plan[i], resolved);
3332            let input_dtypes = self.plan[i].input_dtypes.clone();
3333            let constant_inputs: Vec<bool> = self.plan[i]
3334                .inputs
3335                .iter()
3336                .map(|input| input.is_some_and(|vid| self.graph.initializers.contains_key(&vid)))
3337                .collect();
3338            let node = self.graph.node(node_id);
3339            let opset = effective_opset(&self.graph, node);
3340            self.cache.get_or_create(
3341                node_id,
3342                node,
3343                &input_shapes,
3344                &input_dtypes,
3345                &constant_inputs,
3346                opset,
3347                self.ep.as_ref(),
3348            )?;
3349        }
3350        Ok(())
3351    }
3352
3353    pub(crate) fn cache_stats(&self) -> CacheStats {
3354        self.cache.stats()
3355    }
3356
3357    pub(crate) fn control_flow_stats(&self) -> ControlFlowStats {
3358        self.control_flow_stats
3359    }
3360
3361    pub(crate) fn device_id(&self) -> onnx_runtime_ir::DeviceId {
3362        self.ep.device_id()
3363    }
3364
3365    pub(crate) fn allocate_device_binding(
3366        &self,
3367        input_name: String,
3368        output_name: Option<String>,
3369        dtype: DataType,
3370        physical_shape: Vec<usize>,
3371        logical_shape: Vec<usize>,
3372    ) -> Result<DeviceIoBinding> {
3373        let expose_logical_input_shape = self.input_index.get(&input_name).is_none_or(|&vid| {
3374            if output_name.is_some() {
3375                !self.binding_consumers_use_physical_capacity(vid)
3376            } else {
3377                !self.binding_consumers_use_padded_capacity(vid)
3378            }
3379        });
3380        DeviceIoBinding::allocate(
3381            self.ep.clone(),
3382            input_name,
3383            true,
3384            output_name,
3385            dtype,
3386            physical_shape,
3387            logical_shape,
3388            expose_logical_input_shape,
3389        )
3390    }
3391
3392    pub(crate) fn allocate_device_output_binding(
3393        &self,
3394        output_name: String,
3395        dtype: DataType,
3396        physical_shape: Vec<usize>,
3397        logical_shape: Vec<usize>,
3398    ) -> Result<DeviceIoBinding> {
3399        DeviceIoBinding::allocate(
3400            self.ep.clone(),
3401            String::new(),
3402            false,
3403            Some(output_name),
3404            dtype,
3405            physical_shape,
3406            logical_shape,
3407            false,
3408        )
3409    }
3410
3411    fn binding_consumers_use_physical_capacity(&self, input: ValueId) -> bool {
3412        let mut found = false;
3413        for plan in &self.plan {
3414            for (slot, value) in plan.inputs.iter().enumerate() {
3415                if *value != Some(input) {
3416                    continue;
3417                }
3418                found = true;
3419                if !kernel_input_uses_physical_capacity(self.graph.node(plan.node_id), slot) {
3420                    return false;
3421                }
3422            }
3423        }
3424        found
3425    }
3426
3427    fn binding_consumers_use_padded_capacity(&self, input: ValueId) -> bool {
3428        let mut found = false;
3429        for plan in &self.plan {
3430            for (slot, value) in plan.inputs.iter().enumerate() {
3431                if *value != Some(input) {
3432                    continue;
3433                }
3434                found = true;
3435                if !kernel_input_uses_padded_capacity(self.graph.node(plan.node_id), slot) {
3436                    return false;
3437                }
3438            }
3439        }
3440        found
3441    }
3442
3443    /// The compiled graph, retained for the §55.4 EPContext dump path: the
3444    /// exporter needs the (post-optimize) graph to serialise a `*_ctx.onnx`
3445    /// context-cache model with compiled partitions spliced out.
3446    pub(crate) fn graph(&self) -> &Graph {
3447        &self.graph
3448    }
3449
3450    pub(crate) fn execution_provider_fallback_report(
3451        &self,
3452    ) -> Option<&ExecutionProviderFallbackReport> {
3453        self.execution_provider_fallback_report.as_ref()
3454    }
3455
3456    /// Attach the shared runtime trace context. When enabled, the executor opens
3457    /// one span per executed op so kernels can attach kernel-variant and
3458    /// capture-rejection reasons. Propagated to any already-built child
3459    /// (control-flow subgraph) executors so nested ops are traced too.
3460    pub(crate) fn set_trace_context(&mut self, trace: TraceContext) {
3461        for child in self.subgraph_execs.values_mut() {
3462            child.set_trace_context(trace.clone());
3463        }
3464        self.trace = trace;
3465    }
3466
3467    /// Live weight bytes backing the graph, needed alongside [`Self::graph`] so
3468    /// the EPContext dump can encode initializers into the context model.
3469    pub(crate) fn weights(&self) -> &Arc<WeightStore> {
3470        &self.weights
3471    }
3472
3473    /// Warmup: re-touch the shape-keyed cache for the compiled plan so the first
3474    /// real `run` sees only cache hits (§11.3). Only meaningful for fully-static
3475    /// graphs, whose plan shapes are known at build; symbolic graphs cannot be
3476    /// pre-compiled without a concrete shape and warm up on their first `run`.
3477    pub(crate) fn warmup(&mut self) -> Result<()> {
3478        if self.has_symbols {
3479            return Ok(());
3480        }
3481        let empty = HashMap::new();
3482        let resolved = self.resolve_all(&empty)?;
3483        self.compile_all(&resolved)
3484    }
3485
3486    /// Bind the graph's symbols to concrete sizes from the actual bound-input
3487    /// shapes, validating rank and static dims and detecting symbol conflicts.
3488    fn bind_symbols(
3489        &self,
3490        inputs: &[(&str, &Tensor)],
3491        external: &ExternalBindings,
3492    ) -> Result<HashMap<SymbolId, usize>> {
3493        let mut bindings: HashMap<SymbolId, usize> = HashMap::new();
3494        for (name, tensor) in inputs {
3495            let vid = *self
3496                .input_index
3497                .get(*name)
3498                .ok_or_else(|| SessionError::InputNotFound {
3499                    name: (*name).to_string(),
3500                })?;
3501            self.bind_input_shape(name, vid, tensor.dtype, &tensor.shape, &mut bindings)?;
3502        }
3503        for (&vid, value) in &external.inputs {
3504            let name = self.graph.value(vid).name.as_deref().unwrap_or("<unnamed>");
3505            self.bind_input_shape(name, vid, value.dtype, &value.shape, &mut bindings)?;
3506        }
3507        Ok(bindings)
3508    }
3509
3510    fn bind_input_shape(
3511        &self,
3512        name: &str,
3513        vid: ValueId,
3514        dtype: DataType,
3515        shape: &[usize],
3516        bindings: &mut HashMap<SymbolId, usize>,
3517    ) -> Result<()> {
3518        let want_dtype = self.value_dtypes[&vid];
3519        if dtype != want_dtype {
3520            return Err(SessionError::DtypeMismatch {
3521                name: name.to_string(),
3522                expected: format!("{want_dtype:?}"),
3523                got: format!("{dtype:?}"),
3524            });
3525        }
3526        let decl = &self.value_shapes[&vid];
3527        if decl.len() != shape.len() {
3528            return Err(SessionError::RankMismatch {
3529                name: name.to_string(),
3530                expected: decl.len(),
3531                got: shape.len(),
3532            });
3533        }
3534        for (dim, &actual) in decl.iter().zip(shape) {
3535            match dim {
3536                Dim::Static(n) if *n != actual => {
3537                    return Err(SessionError::ShapeMismatch {
3538                        name: name.to_string(),
3539                        expected: as_static_shape(decl).unwrap_or_default(),
3540                        got: shape.to_vec(),
3541                    });
3542                }
3543                Dim::Static(_) => {}
3544                Dim::Symbolic(s) => {
3545                    if let Some(&prev) = bindings.get(s) {
3546                        if prev != actual {
3547                            let sym = self
3548                                .symbol_name(*s)
3549                                .unwrap_or_else(|| format!("symbol#{}", s.0));
3550                            return Err(SessionError::SymbolConflict {
3551                                symbol: sym,
3552                                first: prev,
3553                                second: actual,
3554                            });
3555                        }
3556                    } else {
3557                        bindings.insert(*s, actual);
3558                    }
3559                }
3560            }
3561        }
3562        Ok(())
3563    }
3564
3565    /// Human-readable name of a symbol, if the graph recorded one.
3566    fn symbol_name(&self, s: SymbolId) -> Option<String> {
3567        self.graph
3568            .symbol_constraints
3569            .get(&s)
3570            .and_then(|c| c.name.clone())
3571    }
3572
3573    /// Sequential topological executor.
3574    pub(crate) fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
3575        self.run_outputs(inputs)?
3576            .into_iter()
3577            .map(|output| {
3578                match output {
3579                    SessionOutput::Tensor(tensor) => Ok(tensor),
3580                    SessionOutput::Sequence(_) => Err(SessionError::SequenceOp {
3581                        op: "<graph output>".to_string(),
3582                        reason: "the tensor-only run API received a Sequence graph output; use InferenceSession::run_outputs to preserve sequence values".to_string(),
3583                    }),
3584                }
3585            })
3586            .collect()
3587    }
3588
3589    pub(crate) fn run_outputs(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<SessionOutput>> {
3590        self.run_scoped(inputs, &HashMap::new(), &ExternalBindings::default())?
3591            .into_iter()
3592            .map(|output| {
3593                output.ok_or_else(|| {
3594                    SessionError::Internal(
3595                        "ordinary run unexpectedly suppressed a bound graph output".into(),
3596                    )
3597                })
3598            })
3599            .collect()
3600    }
3601
3602    pub(crate) fn run_with_device_bindings(
3603        &mut self,
3604        inputs: &[(&str, &Tensor)],
3605        bindings: &mut [DeviceIoBinding],
3606    ) -> Result<Vec<Option<Tensor>>> {
3607        let external = self.prepare_external_bindings(bindings)?;
3608        self.run_scoped(inputs, &HashMap::new(), &external)?
3609            .into_iter()
3610            .map(|output| match output {
3611                None => Ok(None),
3612                Some(SessionOutput::Tensor(tensor)) => Ok(Some(tensor)),
3613                Some(SessionOutput::Sequence(_)) => Err(SessionError::SequenceOp {
3614                    op: "<graph output>".to_string(),
3615                    reason: "run_with_device_bindings cannot return an unbound Sequence graph output; use run_outputs without tensor device bindings".to_string(),
3616                }),
3617            })
3618            .collect()
3619    }
3620
3621    pub(crate) fn try_capture_with_device_bindings(
3622        &mut self,
3623        inputs: &[(&str, &Tensor)],
3624        bindings: &mut [DeviceIoBinding],
3625    ) -> Result<DeviceGraphCaptureResult> {
3626        let external = self.prepare_external_bindings(bindings)?;
3627        match self.run_scoped_mode(inputs, &HashMap::new(), &external, RunMode::Capture)? {
3628            ScopedRunResult::Executed(outputs) => {
3629                let mut tensors = Vec::with_capacity(outputs.len());
3630                for output in outputs {
3631                    match output {
3632                        None => tensors.push(None),
3633                        Some(SessionOutput::Tensor(tensor)) => tensors.push(Some(tensor)),
3634                        Some(SessionOutput::Sequence(_)) => {
3635                            self.reset_device_graph()?;
3636                            return Ok(DeviceGraphCaptureResult::NotCapturable(
3637                                CaptureDeclineReport::one(CaptureDecline::graph(
3638                                    "device graph capture cannot return a Sequence graph output",
3639                                )),
3640                            ));
3641                        }
3642                    }
3643                }
3644                self.device_graph_signature = Some(Self::binding_signature(bindings));
3645                Ok(DeviceGraphCaptureResult::Captured(tensors))
3646            }
3647            ScopedRunResult::NotCapturable(reason) => {
3648                Ok(DeviceGraphCaptureResult::NotCapturable(reason))
3649            }
3650        }
3651    }
3652
3653    /// Replay the installed device graph for one decode step. Returns `true` when
3654    /// the graph remains installed and valid for the next step, or `false` when a
3655    /// control-flow branch flip retired it mid-step (the token was still produced
3656    /// correctly via an eager fallback) and the caller must re-warm/re-capture.
3657    pub(crate) fn replay_device_graph(&mut self, bindings: &mut [DeviceIoBinding]) -> Result<bool> {
3658        let external = self.prepare_external_bindings(bindings)?;
3659        let signature = Self::binding_signature(bindings);
3660        if self.device_graph_signature.as_ref() != Some(&signature) {
3661            self.reset_device_graph()?;
3662            return Err(SessionError::Internal(
3663                "device graph replay bindings changed shape, address, or I/O identity; graph was invalidated"
3664                    .into(),
3665            ));
3666        }
3667        // Whole-subgraph capture (a single graph, no eager seams) keeps the
3668        // zero-host-work fast path: just relaunch the one installed graph.
3669        // Segmented capture must re-establish the run context and interleave
3670        // segment replays with eager seam-node execution, so it routes through
3671        // the scoped runner in replay mode.
3672        let single_graph = self
3673            .capture_schedule
3674            .as_ref()
3675            .is_none_or(CaptureSchedule::is_single_graph);
3676        if single_graph {
3677            self.ep.replay_device_graph()?;
3678            return Ok(true);
3679        }
3680        match self.run_scoped_mode(&[], &HashMap::new(), &external, RunMode::Replay)? {
3681            // `run_scoped_mode` clears `capture_schedule` when a branch flip
3682            // retired the graph this step; report that so the caller re-arms.
3683            ScopedRunResult::Executed(_) => Ok(self.capture_schedule.is_some()),
3684            ScopedRunResult::NotCapturable(reason) => {
3685                self.reset_device_graph()?;
3686                Err(SessionError::Internal(format!(
3687                    "segmented device graph replay lost its schedule: {reason}"
3688                )))
3689            }
3690        }
3691    }
3692
3693    pub(crate) fn reset_device_graph(&mut self) -> Result<bool> {
3694        self.device_graph_signature = None;
3695        self.capture_schedule = None;
3696        self.capture_cf_shapes.clear();
3697        self.capture_warm_seeded.clear();
3698        Ok(self.ep.reset_device_graph()?)
3699    }
3700
3701    /// Structured segment-boundary reasons from the most recent capture: one
3702    /// entry per non-capturable seam node the CUDA EP ran eagerly between
3703    /// captured segments. Empty for a whole-subgraph (single-graph) capture.
3704    pub(crate) fn capture_segmentation(&self) -> &[CaptureDecline] {
3705        &self.capture_segmentation
3706    }
3707
3708    /// Number of captured device-graph segments installed by the most recent
3709    /// capture (1 for a whole-subgraph capture, >=2 when seams split it).
3710    pub(crate) fn captured_segment_count(&self) -> usize {
3711        self.capture_schedule
3712            .as_ref()
3713            .map(CaptureSchedule::captured_segments)
3714            .unwrap_or(0)
3715    }
3716
3717    pub(crate) fn check_device_capture_error(&self) -> Result<u32> {
3718        Ok(self.ep.check_device_capture_error()?)
3719    }
3720
3721    pub(crate) fn device_allocation_counts(&self) -> Option<DeviceAllocationCounts> {
3722        self.ep
3723            .device_allocation_counts()
3724            .map(|(allocations, frees)| DeviceAllocationCounts { allocations, frees })
3725    }
3726
3727    fn binding_signature(bindings: &[DeviceIoBinding]) -> Vec<DeviceBindingSignature> {
3728        bindings
3729            .iter()
3730            .map(|binding| DeviceBindingSignature {
3731                input_name: binding.input_name().to_string(),
3732                binds_input: binding.binds_input(),
3733                output_name: binding.output_name().map(str::to_string),
3734                dtype: binding.dtype,
3735                physical_shape: binding.physical_shape().to_vec(),
3736                device_ptr: binding.device_ptr() as usize,
3737            })
3738            .collect()
3739    }
3740
3741    fn prepare_external_bindings(
3742        &self,
3743        bindings: &mut [DeviceIoBinding],
3744    ) -> Result<ExternalBindings> {
3745        let mut external = ExternalBindings::default();
3746        for binding in bindings {
3747            let input_name = binding.input_name().to_string();
3748            let bind_input = binding.binds_input();
3749            let output_name = binding.output_name().map(str::to_string);
3750            let dtype = binding.dtype;
3751            let len = binding.buffer().len();
3752            let alignment = binding.buffer().alignment();
3753            let device = binding.buffer().device();
3754            if device != self.ep.device_id() {
3755                return Err(SessionError::Internal(format!(
3756                    "device binding '{input_name}' is on {device:?}, session is on {:?}",
3757                    self.ep.device_id()
3758                )));
3759            }
3760            let physical_shape = binding.physical_shape();
3761            let required = dtype.storage_bytes(physical_shape.iter().product());
3762            if required > len {
3763                return Err(SessionError::Internal(format!(
3764                    "device binding '{input_name}' needs {required} bytes for {physical_shape:?}, allocation has {len}"
3765                )));
3766            }
3767            let ptr = binding.buffer_mut().as_mut_ptr();
3768            if bind_input {
3769                let input_vid = *self.input_index.get(&input_name).ok_or_else(|| {
3770                    SessionError::InputNotFound {
3771                        name: input_name.clone(),
3772                    }
3773                })?;
3774                let value = ExternalValue {
3775                    dtype,
3776                    shape: binding.kernel_input_shape().to_vec(),
3777                    accepts_subshape: false,
3778                    ptr,
3779                    len,
3780                    alignment,
3781                    device,
3782                };
3783                if external.inputs.insert(input_vid, value).is_some() {
3784                    return Err(SessionError::Internal(format!(
3785                        "duplicate device input binding '{input_name}'"
3786                    )));
3787                }
3788            }
3789            if let Some(output_name) = output_name {
3790                let output_vid = self
3791                    .graph
3792                    .outputs
3793                    .iter()
3794                    .copied()
3795                    .find(|&vid| {
3796                        self.graph.value(vid).name.as_deref() == Some(output_name.as_str())
3797                    })
3798                    .ok_or_else(|| {
3799                        SessionError::Internal(format!(
3800                            "device binding output not found: {output_name}"
3801                        ))
3802                    })?;
3803                if self.sequence_values.contains(&output_vid) {
3804                    return Err(SessionError::SequenceOp {
3805                        op: "<graph output binding>".to_string(),
3806                        reason: format!(
3807                            "graph output '{output_name}' is a Sequence value and cannot be bound to tensor device storage"
3808                        ),
3809                    });
3810                }
3811                if self.value_dtypes[&output_vid] != dtype {
3812                    return Err(SessionError::DtypeMismatch {
3813                        name: output_name.clone(),
3814                        expected: format!("{:?}", self.value_dtypes[&output_vid]),
3815                        got: format!("{dtype:?}"),
3816                    });
3817                }
3818                let value = ExternalValue {
3819                    dtype,
3820                    shape: binding.physical_shape().to_vec(),
3821                    accepts_subshape: bind_input
3822                        && binding.logical_shape() != binding.physical_shape(),
3823                    ptr,
3824                    len,
3825                    alignment,
3826                    device,
3827                };
3828                if external.outputs.insert(output_vid, value).is_some() {
3829                    return Err(SessionError::Internal(format!(
3830                        "duplicate device output binding '{output_name}'"
3831                    )));
3832                }
3833            }
3834        }
3835        Ok(external)
3836    }
3837
3838    /// Execute the graph with `inputs` bound by name, plus an `outer_scope` of
3839    /// enclosing named values a nested control-flow subgraph body may capture.
3840    /// The top-level session `run` passes an empty scope; a control-flow body's
3841    /// child executor is invoked with its enclosing graph's live values so a
3842    /// deeply-nested body can still reach an outer capture (ONNX lexical scope).
3843    fn run_scoped(
3844        &mut self,
3845        inputs: &[(&str, &Tensor)],
3846        outer_scope: &HashMap<String, Tensor>,
3847        external: &ExternalBindings,
3848    ) -> Result<Vec<Option<SessionOutput>>> {
3849        match self.run_scoped_mode(inputs, outer_scope, external, RunMode::Eager)? {
3850            ScopedRunResult::Executed(outputs) => Ok(outputs),
3851            ScopedRunResult::NotCapturable(_) => unreachable!("eager runs are always executed"),
3852        }
3853    }
3854
3855    fn run_scoped_mode(
3856        &mut self,
3857        inputs: &[(&str, &Tensor)],
3858        outer_scope: &HashMap<String, Tensor>,
3859        external: &ExternalBindings,
3860        mode: RunMode,
3861    ) -> Result<ScopedRunResult> {
3862        // Distinguish the outermost (top-level graph) run from nested
3863        // control-flow subgraph runs so the phase profiler can attribute
3864        // overhead to the right layer.
3865        thread_local! {
3866            static RUN_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
3867        }
3868        let depth = RUN_DEPTH.with(|d| {
3869            let cur = d.get();
3870            d.set(cur + 1);
3871            cur
3872        });
3873        struct DepthGuard;
3874        impl Drop for DepthGuard {
3875            fn drop(&mut self) {
3876                RUN_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
3877            }
3878        }
3879        let _depth_guard = DepthGuard;
3880        let nested = depth > 0;
3881        // Zero-copy view metadata is run-scoped: a value that aliased another's
3882        // buffer last run must not leak into this one (buffers may be resized).
3883        self.views.clear();
3884        self.pinned.clear();
3885        // Sequence values and their zero-copy element-backed tensors are equally
3886        // run-scoped (element Arcs from a prior run must not leak in).
3887        self.sequences.clear();
3888        self.seq_elem_values.clear();
3889        self.restore_shared_buffers()?;
3890
3891        // --- Resolve shapes from the actual bound inputs --------------------
3892        let _phase_setup = phase_span!(if nested {
3893            "run_scoped.setup_total.child"
3894        } else {
3895            "run_scoped.setup_total.top"
3896        });
3897        let bindings = self.bind_symbols(inputs, external)?;
3898
3899        for (name, _) in inputs {
3900            let vid = self.input_index[*name];
3901            if external.inputs.contains_key(&vid) {
3902                return Err(SessionError::Internal(format!(
3903                    "input '{name}' is bound both as a host tensor and a persistent device buffer"
3904                )));
3905            }
3906        }
3907
3908        // Every required input must be supplied.
3909        let mut provided: HashSet<ValueId> = inputs
3910            .iter()
3911            .filter_map(|(name, _)| self.input_index.get(*name).copied())
3912            .collect();
3913        provided.extend(external.inputs.keys().copied());
3914        for &vid in &self.required_inputs {
3915            if !provided.contains(&vid) {
3916                let name = self
3917                    .graph
3918                    .value(vid)
3919                    .name
3920                    .clone()
3921                    .unwrap_or_else(|| format!("value#{}", vid.0));
3922                return Err(SessionError::InputNotFound { name });
3923            }
3924        }
3925
3926        // Substitute the bindings into every value → concrete shapes, then size
3927        // the run-scoped buffers from them (reused when unchanged). Values with a
3928        // data-dependent shape stay unresolved here and are filled in during the
3929        // execution loop, once their producing node's inputs are concrete.
3930        //
3931        // F5 Stage 1: on the top-level CPU eager decode path the steady-state
3932        // decode-plan memo replays the length-invariant partition of this map
3933        // instead of rebuilding it every token. It is a pure optimization of
3934        // `resolve_soft` (a function of `bindings` only, since on the eager path
3935        // no external/control-flow/warm seeding runs — that is Capture/Replay
3936        // only), gated OFF by default and asserted byte-identical under
3937        // `decode_memo_verify`.
3938        //
3939        // Persistent device-I/O bindings (the KV cache) are the NORMAL decode
3940        // case, not an exclusion: the real native decode path always carries them
3941        // (ext_in/ext_out non-empty), and `bind_symbols` already folds every
3942        // external *input* binding's shape into `bindings`, so the growing KV
3943        // length symbol L is captured by the replay guard exactly like any other
3944        // varying symbol. The memo additionally fingerprints the external binding
3945        // set (`decode_external_signature`) with L abstracted to its symbolic
3946        // identity, so pure-L growth replays while any structural change (binding
3947        // added/removed, role flip, dtype change) forces a rebuild.
3948        let decode_memo_eligible = self.decode_memo_enabled
3949            && mode == RunMode::Eager
3950            && !nested
3951            && self.ep.device_type() != DeviceType::Cuda;
3952        let mut resolved = {
3953            let _s = phase_span!("run_scoped.resolve_soft");
3954            if decode_memo_eligible {
3955                self.resolve_soft_decode_memo(&bindings, external)
3956            } else {
3957                // Observability: if the master switch is on but this step is
3958                // structurally ineligible (CUDA, nested, non-eager), count it so
3959                // an over-restrictive gate silently excluding the real decode path
3960                // is never shipped again (the F5 regression Ripley caught).
3961                if self.decode_memo_enabled && !nested {
3962                    self.decode_memo_ineligible_count += 1;
3963                }
3964                let mut resolved = self.resolve_soft(&bindings);
3965                if mode != RunMode::Eager {
3966                    // Persistent bindings seed the kernel-visible geometry selected by
3967                    // their input/output contracts. Seed only unresolved values:
3968                    // statically/symbolically resolved shapes remain authoritative.
3969                    external.seed_capture_shapes(&mut resolved);
3970                    // Control-flow outputs (e.g. LongRoPE cos/sin caches) are symbolic to
3971                    // shape inference but stable within a generation: seed their concrete
3972                    // prior-run shape so downstream capturable consumers fold into
3973                    // captured segments instead of forming per-consumer eager seams.
3974                    self.seed_control_flow_capture_shapes(&mut resolved);
3975                    // Steady-state decode ops (Cast/Mul/QMoE/ScatterElements …) whose
3976                    // output shape is data-dependent stay unresolved in `resolve_soft`
3977                    // and would each form an eager seam even though their kernels are
3978                    // already capture-safe. Seed their exact just-in-time shapes from
3979                    // the eager warmup — but only for the identical persistent-binding
3980                    // signature the warmup ran under, so a changed pointer/capacity
3981                    // withholds the seed instead of baking a stale shape.
3982                    self.seed_warm_decode_capture_shapes(&mut resolved, external);
3983                }
3984                resolved
3985            }
3986        };
3987        // --- F5 Stage 2: reinstate the cached invariant view/buffer plan --------
3988        // On a memo Replayed step whose per-source buffer identity still matches,
3989        // reinstate the zero-copy view aliases (lever 1) instead of clearing and
3990        // rebuilding them, mark the pure-view nodes for dispatch elision (lever 3),
3991        // and exclude the invariant partition from buffer sizing (lever 2). Taken
3992        // out of `self` for the duration so an errored step drops it (a stale alias
3993        // can never be reinstated into a later replay); restored on success.
3994        let mut stage2_plan: Option<DecodeViewPlan> = None;
3995        let mut stage2_candidate: Option<DecodeViewPlan> = None;
3996        let mut stage2_excluded: Option<HashSet<ValueId>> = None;
3997        if decode_memo_eligible
3998            && !self.decode_view_plan_disabled
3999            && self.decode_memo_last_action == DecodeMemoAction::Replayed
4000            && let Some(plan) = self.decode_view_plan.take()
4001        {
4002            if !plan.validated {
4003                // Candidate plan built on the preceding Rebuilt step: run this step
4004                // in full (no reinstate/elide) so every invariant view is freshly
4005                // built, then confirm two-real-step byte-identity below before it is
4006                // ever used to elide. This is the second-real-step confirmation.
4007                stage2_candidate = Some(plan);
4008            } else if self.stage2_buffer_sig_matches(&plan) {
4009                self.decode_view_plan_sig_mismatch_streak = 0;
4010                // Lever 1: reinstate the invariant zero-copy view aliases and
4011                // re-pin their source buffers (conservative liveness). Also
4012                // restore each elided output's resolved shape to the view's own
4013                // shape — the value the elided view node would have written into
4014                // `resolved` (which can differ from the pre-loop `resolve_soft`
4015                // shape Stage 1 restored, e.g. a Reshape with an inferred dim), so
4016                // downstream consumers read the identical geometry as a full step.
4017                for (vid, view) in &plan.retained_views {
4018                    self.views.insert(*vid, view.clone());
4019                    resolved.insert(*vid, view.shape.clone());
4020                }
4021                for &src in &plan.pinned_sources {
4022                    self.pinned.insert(src);
4023                }
4024                self.decode_views_reused_count += plan.retained_views.len() as u64;
4025                self.decode_dispatch_elided_count += plan.elided_nodes.len() as u64;
4026                // Lever 2: exclude the memo's proven-invariant partition from
4027                // per-step buffer sizing — those buffers were sized under the
4028                // rebuild and are byte-identical (guarded by the buffer-identity
4029                // signature above); the compute path still self-heals any output
4030                // whose length unexpectedly differs.
4031                if let Some(memo) = self.decode_memo.as_ref() {
4032                    stage2_excluded = Some(memo.invariant_shapes.keys().copied().collect());
4033                }
4034                stage2_plan = Some(plan);
4035            } else {
4036                // A source buffer moved/resized under a plan that classified it
4037                // invariant: retire the plan (dropped here) and run the full step.
4038                // After repeated mismatches the assumption is untrustworthy on this
4039                // model, so latch Stage 2 off for the session (defense-in-depth).
4040                self.decode_view_plan_sig_mismatch_streak += 1;
4041                if self.decode_view_plan_sig_mismatch_streak >= STAGE2_SIG_MISMATCH_LIMIT {
4042                    self.decode_view_plan_disabled = true;
4043                }
4044            }
4045        }
4046        let external_values = external
4047            .inputs
4048            .keys()
4049            .chain(external.outputs.keys())
4050            .copied()
4051            .collect::<HashSet<_>>();
4052        for &vid in &external_values {
4053            if let Some(old) = self.buffers.remove(&vid) {
4054                self.ep.deallocate(old)?;
4055            }
4056            self.shared_buffers.remove(&vid);
4057            self.buffer_shapes.remove(&vid);
4058        }
4059        {
4060            let _s = phase_span!("run_scoped.size_buffers");
4061            match &stage2_excluded {
4062                // Stage 2 (lever 2): size only the values outside the memo's
4063                // invariant partition (variant/JIT/external) — the invariant
4064                // buffers are reused untouched from the rebuild step.
4065                Some(invariant) => {
4066                    let mut excluded = external_values.clone();
4067                    excluded.extend(invariant.iter().copied());
4068                    self.size_buffers_excluding(&resolved, &excluded)?;
4069                }
4070                None => {
4071                    self.size_buffers_excluding(&resolved, &external_values)?;
4072                }
4073            }
4074        }
4075
4076        // --- Bind input bytes into their (now correctly sized) buffers ------
4077        for (name, tensor) in inputs {
4078            let vid = self.input_index[*name];
4079            let buf = self
4080                .buffers
4081                .get_mut(&vid)
4082                .expect("input value has a buffer");
4083            self.ep.copy_from_host(tensor.as_bytes(), buf)?;
4084        }
4085        drop(_phase_setup);
4086
4087        // --- Execute nodes ---------------------------------------------------
4088        // Iterate by index so a control-flow node can take `&mut self` (it must
4089        // build/reuse child executors) while an ordinary kernel node uses the
4090        // disjoint-field borrow split inside `exec_kernel_node`.
4091        match mode {
4092            RunMode::Eager => {
4093                let _s = phase_span!(if nested {
4094                    "run_scoped.plan_eager.child"
4095                } else {
4096                    "run_scoped.plan_eager.top"
4097                });
4098                // F5 Stage 2: elide the plan's pure-view nodes only in production.
4099                // Under `decode_memo_verify` (the R1 safety net) run every node so
4100                // the invariant views are freshly rebuilt, then assert each equals
4101                // the reinstated alias (bytes/shape/ptr) — proving reuse is exact.
4102                let verify_stage2 = self.decode_memo_verify && stage2_plan.is_some();
4103                let verify_snapshot: Option<Vec<(ValueId, ValueView)>> = if verify_stage2 {
4104                    stage2_plan.as_ref().map(|p| p.retained_views.clone())
4105                } else {
4106                    None
4107                };
4108                let elided = if verify_stage2 {
4109                    None
4110                } else {
4111                    stage2_plan.as_ref().map(|p| &p.elided_nodes)
4112                };
4113                self.run_plan_eager(&mut resolved, outer_scope, external, elided)?;
4114                if let (Some(snapshot), Some(plan)) = (&verify_snapshot, &stage2_plan) {
4115                    for (vid, cached) in snapshot {
4116                        let fresh = self.views.get(vid).unwrap_or_else(|| {
4117                            panic!(
4118                                "F5 Stage 2 verify: elided view value#{} was not rebuilt by a \
4119                                 full dispatch",
4120                                vid.0
4121                            )
4122                        });
4123                        assert!(
4124                            fresh.source == cached.source
4125                                && fresh.shape == cached.shape
4126                                && fresh.strides == cached.strides
4127                                && fresh.byte_offset == cached.byte_offset,
4128                            "F5 Stage 2 verify: cached view for value#{} ({cached:?}) diverged \
4129                             from a freshly built one ({fresh:?}) — invariant view reuse is unsound",
4130                            vid.0
4131                        );
4132                    }
4133                    assert!(
4134                        self.stage2_buffer_sig_matches(plan),
4135                        "F5 Stage 2 verify: a cached view source buffer moved during the step"
4136                    );
4137                }
4138                // F5 Stage 2 plan lifecycle: rebuild the cached view plan at the
4139                // successful end of a memo Rebuilt step (the plan was invalidated
4140                // at the top of the rebuild path, so a mid-step error leaves it
4141                // `None`); restore the in-flight plan after a successful replay.
4142                if decode_memo_eligible {
4143                    match self.decode_memo_last_action {
4144                        DecodeMemoAction::Rebuilt => {
4145                            self.decode_view_plan = self.build_decode_view_plan();
4146                        }
4147                        DecodeMemoAction::Replayed => {
4148                            if let Some(cand) = stage2_candidate.take() {
4149                                // This replay ran full dispatch as the candidate's
4150                                // second-real-step confirmation: keep only the nodes
4151                                // whose view is byte-identical to the built one, and
4152                                // promote to validated (or drop if none survive).
4153                                self.decode_view_plan = self.validate_decode_view_plan(cand);
4154                            } else if let Some(plan) = stage2_plan.take() {
4155                                self.decode_view_plan = Some(plan);
4156                            }
4157                        }
4158                        _ => {}
4159                    }
4160                }
4161                // Snapshot the exact just-in-time shapes this warm run resolved,
4162                // together with the persistent-binding signature they were
4163                // derived under. Capture-mode seeding replays these shapes only
4164                // when a later step presents this exact signature (pointer- and
4165                // capacity-stable), so a changed binding forces recapture, never
4166                // a stale-shape replay. Skipped on the memo-eligible CPU decode
4167                // path: that path never captures (CPU EP), so cloning the whole
4168                // ~600-entry resolved map every token would be pure waste and
4169                // would defeat the memo's allocation amortization.
4170                if !decode_memo_eligible {
4171                    self.capture_warm_shapes = resolved.clone();
4172                    self.capture_warm_signature = Some(external.capture_signature());
4173                }
4174            }
4175            RunMode::Capture => {
4176                // A fresh capture may have resized/reallocated the `If` output
4177                // buffers, so force every `If` to actually execute its branch
4178                // this run (repopulating those buffers) rather than trusting the
4179                // steady-decode memo. Cleared before segmentation so the branch
4180                // runs as a normal eager seam during the capture pass.
4181                self.if_last_predicate.clear();
4182                // Partition the claimed subgraph into maximal capturable segments
4183                // separated by non-capturable seam nodes. Only a graph-level hard
4184                // decline (e.g. no persistent output binding, or nothing
4185                // capturable at all) falls back to a fully eager run.
4186                //
4187                // Warm-decode shape seeding can admit a node whose kernel wrongly
4188                // advertises capture support but aborts device-graph recording
4189                // (e.g. a stream synchronize, which CUDA rejects mid-capture).
4190                // A single such kernel aborts the whole segmented capture. Rather
4191                // than regress to a fully eager step, quarantine the offending
4192                // op-type to a forced eager seam and re-plan/re-record: the
4193                // genuinely-capturable ops still fold while the mislabeled kernel
4194                // stays eager. Re-recording a fixed-capacity decode step is
4195                // idempotent (same position/token → same values into the same
4196                // slots), so retrying is safe. Bounded by the node count.
4197                let max_capture_attempts = self.plan.len() + 1;
4198                let schedule = 'capture: loop {
4199                    let schedule = match self.plan_capture_segments(&resolved, external) {
4200                        Ok(schedule) => schedule,
4201                        Err(report) => return Ok(ScopedRunResult::NotCapturable(report)),
4202                    };
4203                    self.last_capture_failed_node = None;
4204                    match self.run_plan_segmented(
4205                        &schedule,
4206                        RunMode::Capture,
4207                        &mut resolved,
4208                        outer_scope,
4209                        external,
4210                    ) {
4211                        Ok(_) => break 'capture schedule,
4212                        Err(error) => {
4213                            let _ = self.ep.reset_device_graph();
4214                            // Quarantine the op-type that aborted recording and
4215                            // retry, unless we already quarantined it (no
4216                            // progress), hit the attempt bound, or cannot
4217                            // attribute the failure to a node.
4218                            let quarantined =
4219                                self.last_capture_failed_node.take().and_then(|node_id| {
4220                                    let node = self.graph.node(node_id);
4221                                    let key = (canonical_domain(node), node.op_type.clone());
4222                                    self.capture_quarantine_ops.insert(key).then_some(())
4223                                });
4224                            if quarantined.is_some()
4225                                && self.capture_quarantine_ops.len() < max_capture_attempts
4226                            {
4227                                // Re-plan with the offending op-type forced eager.
4228                                self.if_last_predicate.clear();
4229                                continue 'capture;
4230                            }
4231                            self.capture_schedule = None;
4232                            self.capture_segmentation.clear();
4233                            self.capture_cf_shapes.clear();
4234                            self.capture_warm_seeded.clear();
4235                            return Ok(ScopedRunResult::NotCapturable(CaptureDeclineReport::one(
4236                                CaptureDecline::graph(format!(
4237                                    "segmented CUDA graph capture failed: {error}"
4238                                )),
4239                            )));
4240                        }
4241                    }
4242                };
4243                // A warm-seeded shape that the capture pass re-resolved to a
4244                // different value means the seed was stale for this step (a
4245                // genuinely per-step-varying interior extent). The recorded
4246                // graph would replay that shape unconditionally, so retire it
4247                // and decline: the caller re-warms and either re-captures (if
4248                // the shape restabilizes) or keeps this op eager. This upholds
4249                // "recapture when any shape changes; never replay a stale graph."
4250                if let Some((vid, seeded)) = self
4251                    .capture_warm_seeded
4252                    .iter()
4253                    .find(|(vid, seeded)| resolved.get(vid) != Some(*seeded))
4254                    .map(|(vid, seeded)| (*vid, seeded.clone()))
4255                {
4256                    let current = resolved.get(&vid).cloned();
4257                    let _ = self.ep.reset_device_graph();
4258                    self.capture_schedule = None;
4259                    self.capture_segmentation.clear();
4260                    self.capture_cf_shapes.clear();
4261                    self.capture_warm_seeded.clear();
4262                    return Ok(ScopedRunResult::NotCapturable(CaptureDeclineReport::one(
4263                        CaptureDecline::graph(format!(
4264                            "warm decode shape seed for value#{} ({seeded:?}) diverged from the \
4265                             captured shape ({current:?}); recapturing",
4266                            vid.0
4267                        )),
4268                    )));
4269                }
4270                // Snapshot the concrete control-flow output shapes this capture
4271                // assumed so a later replay can detect a branch flip that changes
4272                // them and retire the now-stale installed graph.
4273                self.capture_cf_shapes = self
4274                    .control_flow_output_values
4275                    .iter()
4276                    .filter_map(|vid| resolved.get(vid).map(|shape| (*vid, shape.clone())))
4277                    .collect();
4278                self.capture_segmentation = schedule.boundaries.clone();
4279                if capture_segmentation_logging_enabled() {
4280                    log_capture_segmentation(&schedule);
4281                }
4282                self.capture_schedule = Some(schedule);
4283            }
4284            RunMode::Replay => {
4285                // Move the schedule out so the segmented runner can take `&mut
4286                // self`; restore it afterwards for the next step's replay.
4287                let Some(schedule) = self.capture_schedule.take() else {
4288                    return Ok(ScopedRunResult::NotCapturable(CaptureDeclineReport::one(
4289                        CaptureDecline::graph(
4290                            "segmented device graph replay requested without a capture schedule",
4291                        ),
4292                    )));
4293                };
4294                let still_valid = self.run_plan_segmented(
4295                    &schedule,
4296                    RunMode::Replay,
4297                    &mut resolved,
4298                    outer_scope,
4299                    external,
4300                )?;
4301                if still_valid {
4302                    self.capture_schedule = Some(schedule);
4303                } else {
4304                    // A control-flow branch flip changed a seeded output shape:
4305                    // the remaining plan already ran eagerly this step (correct
4306                    // token), but the installed segments are stale. Retire the
4307                    // device graph so the caller re-warms and re-captures for the
4308                    // new branch. `capture_schedule` stays `None`.
4309                    self.capture_segmentation.clear();
4310                    self.capture_cf_shapes.clear();
4311                    self.device_graph_signature = None;
4312                    self.ep.reset_device_graph()?;
4313                }
4314            }
4315        }
4316
4317        // --- Collect graph outputs into owned tensors -----------------------
4318        // A view output (a layout op whose result aliases an input buffer) is
4319        // materialized to contiguous owned bytes here — external consumers and
4320        // the Python/DLPack boundary expect contiguous tensors.
4321        let _phase_collect = phase_span!(if nested {
4322            "run_scoped.collect_outputs.child"
4323        } else {
4324            "run_scoped.collect_outputs.top"
4325        });
4326        let mut results = Vec::with_capacity(self.graph.outputs.len());
4327        let mut host_output_bytes = 0usize;
4328        let output_vids: Vec<ValueId> = self.graph.outputs.clone();
4329        for vid in output_vids {
4330            if external.outputs.contains_key(&vid) {
4331                results.push(None);
4332                continue;
4333            }
4334            if self.sequence_values.contains(&vid) {
4335                let sequence = self.sequences.get(&vid).cloned().ok_or_else(|| {
4336                    SessionError::Internal(format!(
4337                        "sequence graph output value#{} has no live runtime value",
4338                        vid.0
4339                    ))
4340                })?;
4341                results.push(Some(SessionOutput::Sequence(sequence)));
4342                continue;
4343            }
4344
4345            let dtype = self.value_dtypes[&vid];
4346            let shape = resolved[&vid].clone();
4347            // Top-level outputs: hand the produced host buffer to the caller
4348            // zero-copy when safe (the KV-cache round-trip the decode hot path
4349            // otherwise pays every step). Child (subgraph) outputs are copied
4350            // back into the parent scope, so keep them on the copy path.
4351            if !nested && let Some(tensor) = self.try_move_host_output(vid, &shape, dtype)? {
4352                results.push(Some(SessionOutput::Tensor(tensor)));
4353                continue;
4354            }
4355            let bytes = self.contiguous_bytes(vid, &shape, dtype)?;
4356            host_output_bytes += bytes.len();
4357            results.push(Some(SessionOutput::Tensor(Tensor::from_raw(
4358                dtype, shape, &bytes,
4359            )?)));
4360        }
4361        // Attribution aid: at the top level, the number of graph-output bytes
4362        // materialized to host each run is the per-step cost of *not* keeping
4363        // outputs (e.g. a growing KV cache) in persistent device/host bindings.
4364        // Recorded as a counter (bytes as the "nanos" field) so the phase table
4365        // exposes total and per-call host-output traffic without extra logging.
4366        if !nested {
4367            phase_profile::record("collect_outputs.top_host_bytes", host_output_bytes as u128);
4368        }
4369        // F5 Stage 1: hand the just-used shape map (now including this step's
4370        // data-dependent JIT tail) back to the persistent working buffer so the
4371        // next replay step can take it in place — retaining every invariant
4372        // `Vec`'s allocation — rather than allocating a fresh map/`Vec`s per
4373        // token. Only on the memo-eligible CPU decode path; otherwise the buffer
4374        // stays untouched (and empty).
4375        if decode_memo_eligible {
4376            self.decode_memo_resolved = std::mem::take(&mut resolved);
4377        }
4378        Ok(ScopedRunResult::Executed(results))
4379    }
4380
4381    /// Classify why one plan node cannot be recorded into a device graph, or
4382    /// `None` when it is capturable. Mirrors the per-node predicates the
4383    /// all-or-nothing audit used, but returns the reason instead of aborting so
4384    /// the caller can form segments around each non-capturable seam node.
4385    /// Seed the concrete shapes of control-flow (`If`/`Loop`/`Scan`) outputs from
4386    /// the previous run's buffer allocation so downstream capturable kernels that
4387    /// read them (e.g. GroupQueryAttention reading LongRoPE's `If`-selected
4388    /// cos/sin caches) resolve their input shapes and fold into captured segments
4389    /// instead of each forming an eager seam.
4390    ///
4391    /// ONNX shape inference cannot statically resolve a control-flow output whose
4392    /// branches declare different shapes, so it stays symbolic. Within a decode
4393    /// generation the selected branch — and thus the concrete output shape — is
4394    /// stable across steps, so the prior run's shape is authoritative for capture
4395    /// planning. A branch flip changes the shape and is detected on replay
4396    /// ([`Self::control_flow_seam_invalidated`]), which retires the captured graph
4397    /// for re-capture, so seeding never risks replaying against a stale shape.
4398    ///
4399    /// Only genuinely-unresolved outputs are seeded: a statically/symbolically
4400    /// resolved shape stays authoritative, matching [`ExternalBindings::seed_capture_shapes`].
4401    fn seed_control_flow_capture_shapes(&self, resolved: &mut HashMap<ValueId, Vec<usize>>) {
4402        for &vid in &self.control_flow_output_values {
4403            if resolved.contains_key(&vid) {
4404                continue;
4405            }
4406            if let Some(shape) = self.buffer_shapes.get(&vid) {
4407                resolved.insert(vid, shape.clone());
4408            }
4409        }
4410    }
4411
4412    /// Seed every still-unresolved value's shape from the most recent eager
4413    /// warmup's fully-resolved shape map ([`Self::capture_warm_shapes`]) so the
4414    /// decode ops whose output shape is data-dependent (omitted by
4415    /// [`Self::resolve_soft`]) — Cast/Mul/QMoE/ScatterElements downstream of a
4416    /// data-dependent extent — resolve their input/output shapes and fold into
4417    /// captured segments instead of each forming an eager seam. This generalizes
4418    /// the control-flow seeding above from `If`/`Loop`/`Scan` outputs to any
4419    /// warmed data-dependent value.
4420    ///
4421    /// Correctness rests entirely on the *decode binding signature*: the warm
4422    /// shapes are trusted only when the current persistent-binding signature is
4423    /// byte-for-byte identical to the one the warmup ran under
4424    /// ([`ExternalBindings::capture_signature`]). A changed pointer or capacity
4425    /// withholds every seed (those values stay unresolved → eager seams), and the
4426    /// top-level replay guard ([`Self::replay_device_graph`]) independently
4427    /// retires the installed graph on any binding change. Values resolvable from
4428    /// the current symbol bindings are never overridden — only genuinely
4429    /// unresolved (value-dependent) extents are seeded — and the capture pass
4430    /// re-resolves each seeded shape, retiring the graph if any diverged, so a
4431    /// per-step-varying extent can never be replayed against a stale shape.
4432    /// Persistent bindings and initializers are excluded (seeded/owned elsewhere).
4433    fn seed_warm_decode_capture_shapes(
4434        &mut self,
4435        resolved: &mut HashMap<ValueId, Vec<usize>>,
4436        external: &ExternalBindings,
4437    ) {
4438        self.capture_warm_seeded.clear();
4439        // Trust the warm just-in-time shapes only for the exact signature they
4440        // were derived under; otherwise leave values unresolved (eager seams).
4441        if self.capture_warm_signature.as_ref() != Some(&external.capture_signature()) {
4442            return;
4443        }
4444        let external_values: HashSet<ValueId> = external
4445            .inputs
4446            .keys()
4447            .chain(external.outputs.keys())
4448            .copied()
4449            .collect();
4450        let warm: Vec<(ValueId, Vec<usize>)> = self
4451            .capture_warm_shapes
4452            .iter()
4453            .map(|(&vid, shape)| (vid, shape.clone()))
4454            .collect();
4455        for (vid, shape) in warm {
4456            if resolved.contains_key(&vid)
4457                || external_values.contains(&vid)
4458                || self.graph.initializers.contains_key(&vid)
4459                || self.sequence_values.contains(&vid)
4460            {
4461                continue;
4462            }
4463            self.capture_warm_seeded.insert(vid, shape.clone());
4464            resolved.insert(vid, shape);
4465        }
4466    }
4467
4468    /// Whether the control-flow seam node at plan index `pi` produced a different
4469    /// output shape than the most recent capture assumed. A change means a branch
4470    /// flip (e.g. LongRoPE short↔long at the context threshold) reallocated an
4471    /// output buffer a later captured segment reads, so that segment's baked
4472    /// device pointer is now stale and the installed graph must be retired.
4473    fn control_flow_seam_invalidated(
4474        &self,
4475        pi: usize,
4476        resolved: &HashMap<ValueId, Vec<usize>>,
4477    ) -> bool {
4478        let node = self.graph.node(self.plan[pi].node_id);
4479        if !is_control_flow_op(&node.op_type, &node.domain) {
4480            return false;
4481        }
4482        self.plan[pi].outputs.iter().any(|out| {
4483            match (self.capture_cf_shapes.get(out), resolved.get(out)) {
4484                (Some(captured), Some(current)) => captured != current,
4485                (Some(_), None) => true,
4486                _ => false,
4487            }
4488        })
4489    }
4490
4491    fn node_capture_reason(
4492        &self,
4493        plan: &NodePlan,
4494        resolved: &HashMap<ValueId, Vec<usize>>,
4495    ) -> Option<CaptureDecline> {
4496        let node = self.graph.node(plan.node_id);
4497        // A kernel that aborted device-graph recording on a prior capture pass is
4498        // quarantined by op-type: force it (and every sibling of the same op-type)
4499        // to an eager seam so warm-decode shape seeding can still fold the rest of
4500        // the graph instead of one mislabeled kernel aborting the whole capture.
4501        if self
4502            .capture_quarantine_ops
4503            .contains(&(canonical_domain(node), node.op_type.clone()))
4504        {
4505            return Some(CaptureDecline::node(
4506                plan.node_id,
4507                node,
4508                SeamReason::CaptureRecordingFailed,
4509                "kernel aborted device-graph recording on a prior capture pass; \
4510                 quarantined to an eager seam",
4511            ));
4512        }
4513        let outputs_resolved = plan
4514            .outputs
4515            .iter()
4516            .all(|output| resolved.contains_key(output));
4517        let inputs_resolved = plan.inputs.iter().all(|input| match input {
4518            Some(value) => resolved.contains_key(value),
4519            None => true,
4520        });
4521        if let Some(decline) = self.ep.plan_capture_region(
4522            node,
4523            CaptureRegionShapeStatus {
4524                inputs_resolved,
4525                outputs_resolved,
4526            },
4527        ) {
4528            return Some(structural_capture_decline(plan.node_id, node, decline));
4529        }
4530        assert!(
4531            inputs_resolved && outputs_resolved,
4532            "EP capture-region policy admitted a node with unresolved shapes"
4533        );
4534        let input_shapes = plan
4535            .inputs
4536            .iter()
4537            .map(|input| {
4538                input.map_or_else(Vec::new, |value| {
4539                    resolved
4540                        .get(&value)
4541                        .cloned()
4542                        .expect("resolved input shape checked above")
4543                })
4544            })
4545            .collect();
4546        let key = KernelKey {
4547            node: plan.node_id.0,
4548            shapes: input_shapes,
4549        };
4550        let Some(kernel) = self.cache.entries.get(&key) else {
4551            return Some(CaptureDecline::node(
4552                plan.node_id,
4553                node,
4554                SeamReason::KernelNotWarmed,
4555                "kernel has not been warmed for the requested capture shape",
4556            ));
4557        };
4558        kernel_capture_decline(plan.node_id, node, kernel.as_ref())
4559    }
4560
4561    /// Partition the plan into maximal contiguous captured segments separated by
4562    /// eager (non-capturable) seam nodes.
4563    ///
4564    /// The CUDA EP keeps ownership of the whole claimed subgraph: this never
4565    /// declines a run because *some* node is non-capturable. It only returns a
4566    /// hard [`CaptureDeclineReport`] for a graph-level precondition (outputs must
4567    /// land in persistent device bindings) or when *nothing* is capturable — in
4568    /// which case a device graph adds no value and the caller runs fully eager
4569    /// (still on the CUDA EP, so placement is unchanged).
4570    fn plan_capture_segments(
4571        &self,
4572        resolved: &HashMap<ValueId, Vec<usize>>,
4573        external: &ExternalBindings,
4574    ) -> std::result::Result<CaptureSchedule, CaptureDeclineReport> {
4575        if self
4576            .graph
4577            .outputs
4578            .iter()
4579            .any(|output| !external.outputs.contains_key(output))
4580        {
4581            return Err(CaptureDeclineReport::one(CaptureDecline::graph(
4582                "every graph output must use a persistent device binding during capture",
4583            )));
4584        }
4585
4586        let declines: Vec<Option<CaptureDecline>> = self
4587            .plan
4588            .iter()
4589            .map(|plan| self.node_capture_reason(plan, resolved))
4590            .collect();
4591
4592        let mut segments: Vec<ScheduledSegment> = Vec::new();
4593        let mut boundaries: Vec<CaptureDecline> = Vec::new();
4594        let mut next_graph_index = 0usize;
4595        let mut pi = 0usize;
4596        while pi < declines.len() {
4597            let captured = declines[pi].is_none();
4598            let start = pi;
4599            while pi < declines.len() && declines[pi].is_none() == captured {
4600                if let Some(decline) = &declines[pi] {
4601                    boundaries.push(decline.clone());
4602                }
4603                pi += 1;
4604            }
4605            let graph_index = if captured {
4606                let index = next_graph_index;
4607                next_graph_index += 1;
4608                index
4609            } else {
4610                0
4611            };
4612            segments.push(ScheduledSegment {
4613                start,
4614                end: pi,
4615                captured,
4616                graph_index,
4617            });
4618        }
4619
4620        if next_graph_index == 0 {
4621            return Err(CaptureDeclineReport {
4622                entries: boundaries,
4623            });
4624        }
4625
4626        Ok(CaptureSchedule {
4627            segments,
4628            boundaries,
4629        })
4630    }
4631
4632    /// Gather the warmed, capturable kernels backing one captured segment, in
4633    /// plan order, ready to hand to the EP's `begin_device_graph_capture` audit.
4634    fn collect_segment_kernels(
4635        &self,
4636        seg: &ScheduledSegment,
4637        resolved: &HashMap<ValueId, Vec<usize>>,
4638    ) -> Result<Vec<&dyn onnx_runtime_ep_api::Kernel>> {
4639        let mut kernels = Vec::with_capacity(seg.end - seg.start);
4640        for pi in seg.start..seg.end {
4641            let plan = &self.plan[pi];
4642            let input_shapes = plan
4643                .inputs
4644                .iter()
4645                .map(|input| {
4646                    input
4647                        .map(|value| resolved.get(&value).cloned())
4648                        .unwrap_or(Some(Vec::new()))
4649                })
4650                .collect::<Option<Vec<_>>>()
4651                .ok_or_else(|| {
4652                    SessionError::Internal(format!(
4653                        "segment kernel node {} lost its resolved input shape before capture",
4654                        plan.node_id.0
4655                    ))
4656                })?;
4657            let key = KernelKey {
4658                node: plan.node_id.0,
4659                shapes: input_shapes,
4660            };
4661            let kernel = self.cache.entries.get(&key).ok_or_else(|| {
4662                SessionError::Internal(format!(
4663                    "segment kernel node {} was not warmed before capture",
4664                    plan.node_id.0
4665                ))
4666            })?;
4667            kernels.push(kernel.as_ref());
4668        }
4669        Ok(kernels)
4670    }
4671
4672    /// Dispatch one plan node to its execution path (control-flow, sequence, or
4673    /// leaf kernel). Shared by the eager loop and the segmented runner.
4674    ///
4675    /// When tracing is enabled, opens one span per op so the dispatched kernel
4676    /// can attach kernel-variant and capture-rejection reasons via
4677    /// [`annotate_current_span_with`]; `capture` records the node's device-graph
4678    /// disposition onto that span. When tracing is disabled this costs a single
4679    /// relaxed atomic load and never allocates.
4680    fn exec_plan_node(
4681        &mut self,
4682        pi: usize,
4683        resolved: &mut HashMap<ValueId, Vec<usize>>,
4684        outer_scope: &HashMap<String, Tensor>,
4685        external: &ExternalBindings,
4686        capture: OpCaptureTrace<'_>,
4687    ) -> Result<()> {
4688        // Dispatch by op-type/domain borrowed straight from the node, so a
4689        // steady-state decode step compares `&str`s and never clones the
4690        // op-type/domain `String`s per node. The immutable borrow of
4691        // `self.graph` is confined to this block and dropped before the
4692        // `&mut self` dispatch below; the span guard it yields owns its name
4693        // (and a cheap `Arc`-clone of the trace context), so it borrows nothing
4694        // from `self` and can stay live across the dispatch.
4695        let (is_control_flow, is_sequence, _span) = {
4696            let node = self.graph.node(self.plan[pi].node_id);
4697            let is_control_flow = is_control_flow_op(&node.op_type, &node.domain);
4698            let is_sequence = is_sequence_op(&node.op_type, &node.domain);
4699            // Open the span only when tracing is live so an untraced decode step
4700            // never allocates a span name or touches the thread-local span stack.
4701            let span = self.trace.is_enabled().then(|| {
4702                let span = self.trace.span(node.op_type.clone(), "op");
4703                // Span is now active on this thread; stamp the capture disposition
4704                // (and let the kernel below stamp its selected variant).
4705                capture.annotate();
4706                span
4707            });
4708            (is_control_flow, is_sequence, span)
4709        };
4710        if is_control_flow {
4711            self.exec_control_flow(pi, resolved, outer_scope)
4712        } else if is_sequence {
4713            self.exec_sequence_node(pi, resolved, external)
4714        } else {
4715            self.exec_kernel_node(pi, resolved, external)
4716        }
4717    }
4718
4719    /// Execute every plan node eagerly on the stream (no capture).
4720    ///
4721    /// F5 Stage 2: when `elided` is `Some`, the plan-node indices it contains are
4722    /// pure invariant view nodes whose zero-copy output aliases have already been
4723    /// reinstated into `self.views` for this step, so their re-dispatch is skipped.
4724    /// The set is empty (or `None`) on every non-Stage-2 run, so ordinary steps
4725    /// pay only one `HashSet::is_empty`/`contains` check per node.
4726    fn run_plan_eager(
4727        &mut self,
4728        resolved: &mut HashMap<ValueId, Vec<usize>>,
4729        outer_scope: &HashMap<String, Tensor>,
4730        external: &ExternalBindings,
4731        elided: Option<&HashSet<usize>>,
4732    ) -> Result<()> {
4733        let elided = elided.filter(|set| !set.is_empty());
4734        if profile_ops_enabled() {
4735            let run_start = Instant::now();
4736            let mut timings: HashMap<String, (Duration, usize)> = HashMap::new();
4737            for pi in 0..self.plan.len() {
4738                if elided.is_some_and(|set| set.contains(&pi)) {
4739                    continue;
4740                }
4741                let op_type = self.graph.node(self.plan[pi].node_id).op_type.clone();
4742                let start = Instant::now();
4743                let result =
4744                    self.exec_plan_node(pi, resolved, outer_scope, external, OpCaptureTrace::Eager);
4745                let elapsed = start.elapsed();
4746                let entry = timings.entry(op_type).or_insert((Duration::ZERO, 0));
4747                entry.0 += elapsed;
4748                entry.1 += 1;
4749                result?;
4750            }
4751            print_op_profile(run_start.elapsed(), timings);
4752        } else {
4753            for pi in 0..self.plan.len() {
4754                if elided.is_some_and(|set| set.contains(&pi)) {
4755                    continue;
4756                }
4757                self.exec_plan_node(pi, resolved, outer_scope, external, OpCaptureTrace::Eager)?;
4758            }
4759        }
4760        Ok(())
4761    }
4762
4763    /// Run the plan against a [`CaptureSchedule`], interleaving captured device
4764    /// graphs with eager seam nodes.
4765    ///
4766    /// * [`RunMode::Capture`] records each captured segment into its own device
4767    ///   graph, then immediately replays it so the following eager seam node
4768    ///   reads real bytes from the stable seam buffers. Eager seam nodes execute
4769    ///   normally on the stream (not recorded).
4770    /// * [`RunMode::Replay`] launches each captured segment's installed graph in
4771    ///   order and re-runs only the eager seam nodes.
4772    ///
4773    /// Seam correctness relies on the executor's per-value buffer reuse: for a
4774    /// fixed decode shape, intermediate buffers keep the same device address
4775    /// every step, so a captured segment and the eager node on either side of a
4776    /// seam always read and write the same stable buffers.
4777    fn run_plan_segmented(
4778        &mut self,
4779        schedule: &CaptureSchedule,
4780        mode: RunMode,
4781        resolved: &mut HashMap<ValueId, Vec<usize>>,
4782        outer_scope: &HashMap<String, Tensor>,
4783        external: &ExternalBindings,
4784    ) -> Result<bool> {
4785        let ep = Arc::clone(&self.ep);
4786        // Set once a control-flow branch flip retires the installed graph mid
4787        // replay: every remaining node then runs eagerly (its captured segment's
4788        // baked device pointers are stale) so the step still produces a correct
4789        // token. Only ever set in `RunMode::Replay`.
4790        let mut invalidated = false;
4791        for seg in &schedule.segments {
4792            if invalidated {
4793                // Graph retired earlier this step: run this segment's nodes
4794                // eagerly instead of replaying a stale installed graph.
4795                for pi in seg.start..seg.end {
4796                    self.exec_plan_node(
4797                        pi,
4798                        resolved,
4799                        outer_scope,
4800                        external,
4801                        OpCaptureTrace::Eager,
4802                    )?;
4803                }
4804                continue;
4805            }
4806            if seg.captured {
4807                match mode {
4808                    RunMode::Capture => {
4809                        {
4810                            let kernels = self.collect_segment_kernels(seg, resolved)?;
4811                            ep.begin_device_graph_capture(&kernels)?;
4812                        }
4813                        // Any early return (`?`) while recording this segment
4814                        // must end the stream capture before it propagates —
4815                        // otherwise the stream stays wedged in capture mode and
4816                        // the caller's `reset_device_graph()` is a no-op (reset
4817                        // is rejected while capturing). The guard aborts the
4818                        // capture on drop; `disarm()` hands off to the normal
4819                        // `end_device_graph_capture()` on the success path.
4820                        let mut capture_guard = SegmentCaptureGuard::arm(ep.as_ref());
4821                        for pi in seg.start..seg.end {
4822                            let node_id = self.plan[pi].node_id;
4823                            if let Err(error) = self.exec_plan_node(
4824                                pi,
4825                                resolved,
4826                                outer_scope,
4827                                external,
4828                                OpCaptureTrace::Captured,
4829                            ) {
4830                                // Record which node aborted recording so the
4831                                // capture retry loop can quarantine its op-type.
4832                                // `capture_guard` drops here, ending the wedged
4833                                // stream capture before the error propagates.
4834                                self.last_capture_failed_node = Some(node_id);
4835                                return Err(error);
4836                            }
4837                        }
4838                        capture_guard.disarm();
4839                        ep.end_device_graph_capture()?;
4840                        ep.replay_device_graph_segment(seg.graph_index)?;
4841                    }
4842                    RunMode::Replay => {
4843                        ep.replay_device_graph_segment(seg.graph_index)?;
4844                    }
4845                    RunMode::Eager => {
4846                        unreachable!("eager runs never build a segment schedule")
4847                    }
4848                }
4849            } else {
4850                for pi in seg.start..seg.end {
4851                    // Seam node: eager because some kernel/predicate declined
4852                    // capture. Surface that reason on the node's span.
4853                    let node_id = self.plan[pi].node_id.0;
4854                    let reason = schedule
4855                        .boundaries
4856                        .iter()
4857                        .find(|decline| decline.node_id == Some(node_id))
4858                        .map(|decline| decline.reason.as_str())
4859                        .unwrap_or("non-capturable seam node (no recorded reason)");
4860                    self.exec_plan_node(
4861                        pi,
4862                        resolved,
4863                        outer_scope,
4864                        external,
4865                        OpCaptureTrace::Rejected(reason),
4866                    )?;
4867                    // A control-flow seam (e.g. LongRoPE's `If`) that now selects
4868                    // a different-shaped branch than capture assumed reallocated
4869                    // an output a later captured segment reads: retire the graph
4870                    // and finish this step eagerly.
4871                    if mode == RunMode::Replay && self.control_flow_seam_invalidated(pi, resolved) {
4872                        invalidated = true;
4873                    }
4874                }
4875            }
4876        }
4877        Ok(!invalidated)
4878    }
4879
4880    /// Refill [`Self::scratch_input_shapes`] with the resolved shapes of plan
4881    /// node `pi`'s inputs, so the dispatch path reads shapes from a reused buffer
4882    /// instead of allocating a fresh `Vec<Vec<usize>>` per node per token.
4883    ///
4884    /// The scratch is truncated to the node's arity and each inner `Vec` is
4885    /// cleared and refilled in place (retaining its heap capacity), so a
4886    /// steady-state decode step — a fixed sequence of fixed-arity nodes — does
4887    /// zero shape-vector allocation after warmup. An omitted optional input
4888    /// (`None` slot) yields an empty inner shape, exactly as the previous
4889    /// `.unwrap_or_default()` collect did. `self.plan` and
4890    /// `self.scratch_input_shapes` are disjoint fields, so the shared read of the
4891    /// former coexists with the `&mut` refill of the latter.
4892    fn refill_input_shapes(&mut self, pi: usize, resolved: &HashMap<ValueId, Vec<usize>>) {
4893        let inputs = &self.plan[pi].inputs;
4894        let scratch = &mut self.scratch_input_shapes;
4895        scratch.truncate(inputs.len());
4896        for (i, slot) in inputs.iter().enumerate() {
4897            if i < scratch.len() {
4898                scratch[i].clear();
4899            } else {
4900                scratch.push(Vec::new());
4901            }
4902            if let Some(vid) = slot {
4903                scratch[i].extend_from_slice(&resolved[vid]);
4904            }
4905        }
4906    }
4907
4908    /// Execute one ordinary (leaf-kernel) plan node: resolve any data-dependent
4909    /// output shapes, size buffers, build the input/output views (with Holden's
4910    /// bounds gate), resolve the shape-keyed kernel, and dispatch it.
4911    fn exec_kernel_node(
4912        &mut self,
4913        pi: usize,
4914        resolved: &mut HashMap<ValueId, Vec<usize>>,
4915        external: &ExternalBindings,
4916    ) -> Result<()> {
4917        // Whole-node dispatch span: its lifetime minus `exec_kernel.compute` is
4918        // the serial per-node dispatch glue (shape resolve, input/output view
4919        // build, kernel-cache lookup) the F5 Stage 3 record would elide.
4920        let _node_span = phase_span!("exec_kernel.node");
4921        // Borrow the plan facts in place rather than cloning them per node per
4922        // token: `self.plan` is a distinct field from the buffer/view/cache
4923        // fields mutated below, so these shared borrows coexist with the
4924        // disjoint `&mut self.<field>` borrows the compute path takes (the
4925        // dispatch never goes through a `&mut self` method while they are held).
4926        let node_id = self.plan[pi].node_id;
4927        // Refill the reusable per-executor input-shape scratch first (before the
4928        // shared borrows below), so a steady-state decode step allocates no
4929        // fresh `Vec<Vec<usize>>` for shape lookup — see `refill_input_shapes`.
4930        self.refill_input_shapes(pi, resolved);
4931        let inputs = &self.plan[pi].inputs;
4932        let outputs = &self.plan[pi].outputs;
4933        let input_dtypes = &self.plan[pi].input_dtypes;
4934        let output_dtypes = &self.plan[pi].output_dtypes;
4935        let input_shapes = &self.scratch_input_shapes;
4936
4937        let node = self.graph.node(node_id);
4938        if let Some(output_shape) = runtime_elementwise_output_shape(node, input_shapes) {
4939            let output_shape = output_shape.map_err(|_| {
4940                let node_name = if node.name.is_empty() {
4941                    format!("<unnamed node #{}>", node_id.0)
4942                } else {
4943                    format!("{:?}", node.name)
4944                };
4945                SessionError::RuntimeBroadcastIncompatible {
4946                    node: node_name,
4947                    domain: canonical_domain(node),
4948                    op_type: node.op_type.clone(),
4949                    input_shapes: input_shapes.to_vec(),
4950                }
4951            })?;
4952            if outputs.len() != 1 {
4953                return Err(SessionError::OutputShapeCountMismatch {
4954                    op: node.op_type.clone(),
4955                    expected: outputs.len(),
4956                    got: 1,
4957                });
4958            }
4959            resolved.insert(outputs[0], output_shape);
4960        }
4961
4962        // Data-dependent shapes: if any output's shape is still unresolved,
4963        // compute it now from the concrete input shapes + the runtime *values*
4964        // of this node's integer inputs. Buffers are NOT sized here — a view
4965        // output needs none, and the compute path sizes them just below.
4966        if outputs.iter().any(|v| !resolved.contains_key(v)) {
4967            let opset = effective_opset(&self.graph, node);
4968            let input_values: Vec<Option<Vec<i64>>> = inputs
4969                .iter()
4970                .enumerate()
4971                .map(|(i, v)| {
4972                    v.and_then(|vid| self.shape_input_i64(vid, &input_shapes[i], input_dtypes[i]))
4973                })
4974                .collect();
4975            // Only materialize a *float* input value for the specific inputs an
4976            // op actually reads as float shape data (today: `Resize` scales).
4977            // Downloading any other float input here would both waste a host copy
4978            // and break the "reject an invalid shape input before any host
4979            // materialization" contract — e.g. a data tensor feeding an
4980            // `Unsqueeze` whose integer axes is invalid must never be copied to
4981            // host just to reach the unresolved-shape rejection.
4982            let input_float_values: Vec<Option<Vec<f64>>> = inputs
4983                .iter()
4984                .enumerate()
4985                .map(|(i, v)| {
4986                    if !reads_float_shape_input(node, i, opset) {
4987                        return None;
4988                    }
4989                    v.and_then(|vid| {
4990                        if node.is_default_domain() && node.op_type == "NonMaxSuppression" {
4991                            self.nms_input_f64(vid, &input_shapes[i], input_dtypes[i])
4992                        } else {
4993                            self.shape_input_f64(vid, &input_shapes[i], input_dtypes[i])
4994                        }
4995                    })
4996                })
4997                .collect();
4998            let out_shapes = dynamic_output_shapes(
4999                node,
5000                input_shapes,
5001                input_dtypes,
5002                &input_values,
5003                &input_float_values,
5004                opset,
5005            )
5006            .ok_or_else(|| {
5007                let vid = outputs
5008                    .iter()
5009                    .find(|v| !resolved.contains_key(v))
5010                    .copied()
5011                    .unwrap_or(outputs[0]);
5012                let value = self.graph.value(vid);
5013                SessionError::UnresolvedShape {
5014                    value: value
5015                        .name
5016                        .clone()
5017                        .unwrap_or_else(|| format!("value#{}", vid.0)),
5018                    op: node.op_type.clone(),
5019                }
5020            })?;
5021            if out_shapes.len() != outputs.len() {
5022                return Err(SessionError::OutputShapeCountMismatch {
5023                    op: self.graph.node(node_id).op_type.clone(),
5024                    expected: outputs.len(),
5025                    got: out_shapes.len(),
5026                });
5027            }
5028            for (oi, &ovid) in outputs.iter().enumerate() {
5029                resolved.insert(ovid, out_shapes[oi].clone());
5030            }
5031        }
5032        let mut output_shapes: Vec<Vec<usize>> =
5033            outputs.iter().map(|v| resolved[v].clone()).collect();
5034        // Fixed-capacity KV for the default-domain Attention op. Its present
5035        // K/V outputs (slots 1..) are consumer-less graph outputs bound to a
5036        // growing device cache. Expose them to the kernel at the binding's
5037        // physical capacity so the kernel can append the new token into a fixed
5038        // per-head slot (constant stride, no per-step restride) instead of
5039        // repacking the whole cache densely. The valid attended length is still
5040        // derived from the logical past+current extent, so this only widens the
5041        // *storage* stride and never changes what the kernel attends over. Only
5042        // present slots that are bound sub-shape (logical != physical) capacity
5043        // buffers are widened; a dense/unbound present keeps its inferred shape.
5044        {
5045            let node = self.graph.node(node_id);
5046            if node.is_default_domain() && node.op_type == "Attention" {
5047                // When the past K/V inputs are themselves bound at physical
5048                // capacity (fixed-capacity decode, capture path), the standard
5049                // `present = past + current` shape rule sees the *physical* past
5050                // extent and over-counts the present seq axis beyond the bound
5051                // buffer. In that case the present buffer's true shape is simply
5052                // its physical capacity (mirroring GroupQueryAttention, whose
5053                // present rule takes `past_capacity.max(total)`); the valid
5054                // length lives on-device and context-overflow is caught earlier
5055                // in the decoder (`total_len > max_len`). Otherwise keep the
5056                // conservative `physical >= logical` guard.
5057                let kv_capacity_bound = kernel_input_uses_physical_capacity(node, 4)
5058                    && kernel_input_uses_physical_capacity(node, 5);
5059                for (oi, &ovid) in outputs.iter().enumerate() {
5060                    if oi == 0 {
5061                        continue;
5062                    }
5063                    if let Some(value) = external.outputs.get(&ovid)
5064                        && value.accepts_subshape
5065                        && value.shape.len() == output_shapes[oi].len()
5066                        && value
5067                            .shape
5068                            .iter()
5069                            .zip(&output_shapes[oi])
5070                            .enumerate()
5071                            .all(|(axis, (&physical, &logical))| axis == 2 || physical == logical)
5072                        && (kv_capacity_bound
5073                            || value
5074                                .shape
5075                                .get(2)
5076                                .zip(output_shapes[oi].get(2))
5077                                .is_some_and(|(&physical, &logical)| physical >= logical))
5078                    {
5079                        output_shapes[oi] = value.shape.clone();
5080                    }
5081                }
5082            }
5083        }
5084        let capabilities = self.ep.capabilities();
5085        let accepts_lazy_weights =
5086            LazyWeightBoundary::BlockQuantizedMoe.matches(&node.domain, &node.op_type);
5087        let has_lazy_inputs = accepts_lazy_weights
5088            && inputs.iter().any(|input| {
5089                input
5090                    .and_then(|value| self.weight_handles.get(&value))
5091                    .is_some_and(|handle| handle.is_lazy_for(&capabilities))
5092            });
5093
5094        // Resolve each input's real geometry (root buffer + strides/offset) and
5095        // bounds-check it. View inputs read through their recorded strides.
5096        let mut in_infos: Vec<InInfo> = Vec::with_capacity(inputs.len());
5097        let _build_inputs_span = phase_span!("exec_kernel.build_inputs");
5098        for (i, slot) in inputs.iter().enumerate() {
5099            let Some(vid) = *slot else {
5100                in_infos.push(InInfo {
5101                    present: false,
5102                    dtype: input_dtypes[i],
5103                    shape: Vec::new(),
5104                    strides: Vec::new(),
5105                    byte_offset: 0,
5106                    base_ptr: std::ptr::null(),
5107                    device: self.ep.device_id(),
5108                    backing: TensorBacking::Opaque,
5109                    root_len: 0,
5110                });
5111                continue;
5112            };
5113            if let Some(value) = external
5114                .inputs
5115                .get(&vid)
5116                .or_else(|| external.outputs.get(&vid))
5117            {
5118                let strides = compute_contiguous_strides(&value.shape);
5119                view_bounds(&value.shape, &strides, 0, value.dtype, value.len)?;
5120                in_infos.push(InInfo {
5121                    present: true,
5122                    dtype: value.dtype,
5123                    shape: value.shape.clone(),
5124                    strides,
5125                    byte_offset: 0,
5126                    base_ptr: value.ptr.cast_const(),
5127                    device: value.device,
5128                    backing: TensorBacking::Opaque,
5129                    root_len: value.len,
5130                });
5131                continue;
5132            }
5133            // A tensor input backed by a shared sequence element (SequenceAt
5134            // output) owns no DeviceBuffer: read its possibly-strided view
5135            // directly over the immutable shared allocation.
5136            if let Some(elem) = self.seq_elem_values.get(&vid) {
5137                let shape = input_shapes[i].clone();
5138                let strides = elem.layout.resolved_strides(&shape);
5139                let root_len = elem.root_len();
5140                let base_ptr = elem.as_ptr();
5141                view_bounds(
5142                    &shape,
5143                    &strides,
5144                    elem.byte_offset(),
5145                    input_dtypes[i],
5146                    root_len,
5147                )?;
5148                in_infos.push(InInfo {
5149                    present: true,
5150                    dtype: input_dtypes[i],
5151                    shape,
5152                    strides,
5153                    byte_offset: elem.byte_offset(),
5154                    base_ptr,
5155                    device: elem.device(),
5156                    backing: TensorBacking::Opaque,
5157                    root_len,
5158                });
5159                continue;
5160            }
5161            if accepts_lazy_weights
5162                && self
5163                    .weight_handles
5164                    .get(&vid)
5165                    .is_some_and(|handle| handle.is_lazy_for(&capabilities))
5166            {
5167                in_infos.push(InInfo {
5168                    present: false,
5169                    dtype: input_dtypes[i],
5170                    shape: input_shapes[i].clone(),
5171                    strides: compute_contiguous_strides(&input_shapes[i]),
5172                    byte_offset: 0,
5173                    base_ptr: std::ptr::null(),
5174                    device: self.ep.device_id(),
5175                    backing: TensorBacking::Opaque,
5176                    root_len: 0,
5177                });
5178                continue;
5179            }
5180            let root = self.root_of(vid);
5181            let buf = self.buffers.get(&root).ok_or_else(|| {
5182                SessionError::Internal(format!("missing buffer for input value#{}", vid.0))
5183            })?;
5184            let root_len = buf.len();
5185            let base_ptr = buf.as_ptr();
5186            let (shape, strides, byte_offset) = match self.views.get(&vid) {
5187                Some(view) => (view.shape.clone(), view.strides.clone(), view.byte_offset),
5188                None => {
5189                    let shape = input_shapes[i].clone();
5190                    let strides = compute_contiguous_strides(&shape);
5191                    (shape, strides, 0)
5192                }
5193            };
5194            view_bounds(&shape, &strides, byte_offset, input_dtypes[i], root_len)?;
5195            let backing = self
5196                .graph
5197                .initializers
5198                .get(&root)
5199                .filter(|_| buf.is_borrowed())
5200                .and_then(|weight| self.weights.external_mmap_provenance(weight))
5201                .map(|(mapping_id, offset, len)| {
5202                    TensorBacking::ExternalMmap(ExternalMmapRegion {
5203                        mapping_id,
5204                        offset,
5205                        len,
5206                    })
5207                })
5208                .unwrap_or(TensorBacking::Opaque);
5209            in_infos.push(InInfo {
5210                present: true,
5211                dtype: input_dtypes[i],
5212                shape,
5213                strides,
5214                byte_offset,
5215                base_ptr,
5216                device: buf.device(),
5217                backing,
5218                root_len,
5219            });
5220        }
5221        drop(_build_inputs_span);
5222
5223        let ep = self.ep.clone();
5224
5225        // Bind the mutated fields as disjoint locals so `self` is never borrowed
5226        // whole while the kernel (from `cache`) and the buffers/views are held.
5227        let graph = &self.graph;
5228        let cache = &mut self.cache;
5229        let weight_handles = &self.weight_handles;
5230        let buffers = &mut self.buffers;
5231        let buffer_shapes = &mut self.buffer_shapes;
5232        let shared_buffers = &mut self.shared_buffers;
5233        let views_meta = &mut self.views;
5234        let pinned = &mut self.pinned;
5235
5236        // Build the (possibly strided) input views once; they feed both the
5237        // view-output probe and, on the compute path, the kernel itself.
5238        let mut views: Vec<TensorView> = Vec::with_capacity(in_infos.len());
5239        for info in &in_infos {
5240            if !info.present {
5241                views.push(TensorView::absent(info.dtype));
5242                continue;
5243            }
5244            views.push(
5245                TensorView::new(
5246                    DevicePtr(info.base_ptr),
5247                    info.dtype,
5248                    &info.shape,
5249                    &info.strides,
5250                    info.device,
5251                )
5252                .with_byte_offset(info.byte_offset)
5253                .with_backing(info.backing),
5254            );
5255        }
5256
5257        let opset = effective_opset(graph, node);
5258        let constant_inputs: Vec<bool> = inputs
5259            .iter()
5260            .map(|input| {
5261                input.is_some_and(|vid| {
5262                    graph.initializers.contains_key(&vid)
5263                        || views_meta
5264                            .get(&vid)
5265                            .is_some_and(|view| graph.initializers.contains_key(&view.source))
5266                })
5267            })
5268            .collect();
5269        let kernel = {
5270            let _s = phase_span!("exec_kernel.get_kernel");
5271            cache.get_or_create(
5272                node_id,
5273                node,
5274                input_shapes,
5275                input_dtypes,
5276                &constant_inputs,
5277                opset,
5278                ep.as_ref(),
5279            )?
5280        };
5281        // --- Zero-copy view fast path ---------------------------------------
5282        // Ask the kernel whether its outputs are strided views over its inputs
5283        // (a layout/movement op such as Slice). If so, record view metadata
5284        // aliasing the source buffer and skip compute + allocation entirely.
5285        if !has_lazy_inputs && let Some(specs) = kernel.view_outputs(&views, outputs.len()) {
5286            if outputs
5287                .iter()
5288                .any(|output| external.outputs.contains_key(output))
5289            {
5290                return Err(SessionError::Internal(format!(
5291                    "op '{}' cannot bind a zero-copy view output to external storage",
5292                    node.op_type
5293                )));
5294            }
5295            drop(views);
5296            if specs.len() != outputs.len() {
5297                return Err(SessionError::Internal(format!(
5298                    "op '{}' returned {} view outputs for {} outputs",
5299                    node.op_type,
5300                    specs.len(),
5301                    outputs.len()
5302                )));
5303            }
5304            for (oi, spec) in specs.into_iter().enumerate() {
5305                let ovid = outputs[oi];
5306                let Some(in_vid) = inputs.get(spec.input_index).copied().flatten() else {
5307                    return Err(SessionError::Internal(format!(
5308                        "op '{}' view output {} references invalid input index {}",
5309                        node.op_type, oi, spec.input_index
5310                    )));
5311                };
5312                let root = match views_meta.get(&in_vid) {
5313                    Some(v) => v.source,
5314                    None => in_vid,
5315                };
5316                let root_len = buffers.get(&root).map(|b| b.len()).ok_or_else(|| {
5317                    SessionError::Internal(format!("view source value#{} has no buffer", root.0))
5318                })?;
5319                // Bounds-gate the composed view against the source allocation.
5320                view_bounds(
5321                    &spec.shape,
5322                    &spec.strides,
5323                    spec.byte_offset,
5324                    output_dtypes[oi],
5325                    root_len,
5326                )?;
5327                // The output becomes a view: drop any buffer it used to own so a
5328                // later run re-sizes cleanly, then record the alias and pin the
5329                // source (conservative liveness — a source with any live view is
5330                // never reused/freed for the rest of the run; no use-after-free).
5331                // A freshly-produced output can never already be pinned (its
5332                // viewers run strictly after it under SSA topo order).
5333                debug_assert!(
5334                    !pinned.contains(&ovid),
5335                    "value#{} is pinned as a live view source yet is being reproduced",
5336                    ovid.0
5337                );
5338                if let Some(old) = buffers.remove(&ovid) {
5339                    ep.deallocate(old)?;
5340                }
5341                shared_buffers.remove(&ovid);
5342                buffer_shapes.remove(&ovid);
5343                views_meta.insert(
5344                    ovid,
5345                    ValueView {
5346                        source: root,
5347                        shape: spec.shape.clone(),
5348                        strides: spec.strides,
5349                        byte_offset: spec.byte_offset,
5350                    },
5351                );
5352                pinned.insert(root);
5353                resolved.insert(ovid, spec.shape);
5354            }
5355            return Ok(());
5356        }
5357
5358        // --- Compute path ----------------------------------------------------
5359        // Size (allocate or reuse) each output's contiguous buffer, JIT-sizing
5360        // data-dependent ones. A value that was a view on a prior run has no
5361        // buffer here and is freshly allocated.
5362        for (oi, &ovid) in outputs.iter().enumerate() {
5363            let dims = &output_shapes[oi];
5364            let numel = checked_numel(dims, || format!("value#{}", ovid.0))?;
5365            let need = checked_storage_bytes(
5366                output_dtypes[oi],
5367                numel,
5368                || format!("value#{}", ovid.0),
5369                dims,
5370            )?
5371            .max(1);
5372            if let Some(value) = external.outputs.get(&ovid) {
5373                if !value.accepts_output(output_dtypes[oi], dims, need) {
5374                    let name = graph.value(ovid).name.as_deref().unwrap_or("<unnamed>");
5375                    return Err(SessionError::Internal(format!(
5376                        "external output '{name}' has {:?} {:?} ({} bytes), kernel requires {:?} {:?} ({need} bytes)",
5377                        value.dtype, value.shape, value.len, output_dtypes[oi], dims
5378                    )));
5379                }
5380                continue;
5381            }
5382            let fits = buffers.get(&ovid).map(|b| b.len() == need).unwrap_or(false);
5383            if !fits {
5384                // Never free a buffer that has a live view alias (would dangle
5385                // the viewer). Unreachable under SSA topo order, but enforced.
5386                debug_assert!(
5387                    !pinned.contains(&ovid),
5388                    "value#{} is pinned as a live view source yet is being resized",
5389                    ovid.0
5390                );
5391                if let Some(old) = buffers.remove(&ovid) {
5392                    ep.deallocate(old)?;
5393                }
5394                shared_buffers.remove(&ovid);
5395                let buf = ep.allocate(need, TensorLayout::contiguous().alignment)?;
5396                buffers.insert(ovid, buf);
5397            }
5398        }
5399
5400        // Auto-materialization gate: a strided (view) input feeding a kernel
5401        // that does not accept strided input on that slot is gathered into a
5402        // private contiguous temp so contiguous-assuming kernels stay correct.
5403        // Temps must outlive the views that borrow them.
5404        let mut mat: Vec<Option<(Vec<u8>, Vec<i64>)>> = Vec::with_capacity(in_infos.len());
5405        for (i, info) in in_infos.iter().enumerate() {
5406            if !info.present {
5407                mat.push(None);
5408                continue;
5409            }
5410            let contiguous = onnx_runtime_ir::is_contiguous(&info.shape, &info.strides);
5411            if contiguous || kernel.supports_strided_input(i) {
5412                mat.push(None);
5413                continue;
5414            }
5415            if !info.device.is_host_accessible() {
5416                return Err(SessionError::Internal(format!(
5417                    "op '{}' requires host-only strided materialization for CUDA input {i}",
5418                    node.op_type
5419                )));
5420            }
5421            let esize = info.dtype.byte_size();
5422            if esize == 0 {
5423                return Err(SessionError::from(
5424                    onnx_runtime_ep_api::EpError::InvalidTensorView {
5425                        reason: format!(
5426                            "cannot materialize sub-byte strided input {i} of op '{}'",
5427                            node.op_type
5428                        ),
5429                    },
5430                ));
5431            }
5432            let src =
5433                unsafe { std::slice::from_raw_parts(info.base_ptr as *const u8, info.root_len) };
5434            let gathered = gather_view(src, &info.shape, &info.strides, info.byte_offset, esize);
5435            let strides = compute_contiguous_strides(&info.shape);
5436            mat.push(Some((gathered, strides)));
5437        }
5438
5439        // Rebuild input views, swapping any materialized slot to its contiguous
5440        // temp (offset 0, contiguous strides over the fresh buffer).
5441        drop(views);
5442        let mut views: Vec<TensorView> = Vec::with_capacity(in_infos.len());
5443        for (i, info) in in_infos.iter().enumerate() {
5444            if !info.present {
5445                views.push(TensorView::absent(info.dtype));
5446                continue;
5447            }
5448            match &mat[i] {
5449                Some((buf, strides)) => views.push(TensorView::new(
5450                    DevicePtr(buf.as_ptr() as *const std::ffi::c_void),
5451                    info.dtype,
5452                    &info.shape,
5453                    strides,
5454                    onnx_runtime_ir::DeviceId::cpu(),
5455                )),
5456                None => views.push(
5457                    TensorView::new(
5458                        DevicePtr(info.base_ptr),
5459                        info.dtype,
5460                        &info.shape,
5461                        &info.strides,
5462                        info.device,
5463                    )
5464                    .with_byte_offset(info.byte_offset)
5465                    .with_backing(info.backing),
5466                ),
5467            }
5468        }
5469
5470        // Take output buffers out so they can be borrowed `&mut` disjointly from
5471        // the input reads (SSA guarantees outputs are disjoint from inputs).
5472        let out_strides: Vec<Vec<i64>> = output_shapes
5473            .iter()
5474            .map(|s| compute_contiguous_strides(s))
5475            .collect();
5476        struct OutBacking {
5477            vid: ValueId,
5478            internal: Option<DeviceBuffer>,
5479            ptr: *mut std::ffi::c_void,
5480            len: usize,
5481            device: onnx_runtime_ir::DeviceId,
5482        }
5483        let mut out_bufs: Vec<OutBacking> = Vec::with_capacity(outputs.len());
5484        for &vid in outputs {
5485            if let Some(value) = external.outputs.get(&vid) {
5486                out_bufs.push(OutBacking {
5487                    vid,
5488                    internal: None,
5489                    ptr: value.ptr,
5490                    len: value.len,
5491                    device: value.device,
5492                });
5493            } else {
5494                let mut buf = buffers.remove(&vid).ok_or_else(|| {
5495                    SessionError::Internal(format!("missing buffer for output value#{}", vid.0))
5496                })?;
5497                let ptr = buf.as_mut_ptr();
5498                out_bufs.push(OutBacking {
5499                    vid,
5500                    ptr,
5501                    len: buf.len(),
5502                    device: buf.device(),
5503                    internal: Some(buf),
5504                });
5505            }
5506        }
5507        let mut outs: Vec<TensorMut> = Vec::with_capacity(out_bufs.len());
5508        for (i, backing) in out_bufs.iter_mut().enumerate() {
5509            view_bounds(
5510                &output_shapes[i],
5511                &out_strides[i],
5512                0,
5513                output_dtypes[i],
5514                backing.len,
5515            )?;
5516            outs.push(TensorMut::new(
5517                DevicePtrMut(backing.ptr),
5518                output_dtypes[i],
5519                &output_shapes[i],
5520                &out_strides[i],
5521                backing.device,
5522            ));
5523        }
5524
5525        let kernel_inputs = has_lazy_inputs.then(|| {
5526            inputs
5527                .iter()
5528                .zip(views.iter().copied())
5529                .map(|(value, view)| {
5530                    value
5531                        .and_then(|value| weight_handles.get(&value))
5532                        .filter(|handle| handle.is_lazy_for(&capabilities))
5533                        .map(KernelInput::Weight)
5534                        .unwrap_or(KernelInput::Tensor(view))
5535                })
5536                .collect::<Vec<_>>()
5537        });
5538        let execution = {
5539            let _s = phase_span!("exec_kernel.compute");
5540            match &kernel_inputs {
5541                Some(inputs) => kernel.execute_with_inputs(inputs, &mut outs),
5542                None => kernel.execute(&views, &mut outs),
5543            }
5544        };
5545        execution.map_err(|error| {
5546                let input_types = views.iter().map(|view| view.dtype).collect::<Vec<_>>();
5547                let output_types = outs.iter().map(|output| output.dtype).collect::<Vec<_>>();
5548                let input_shapes = views
5549                    .iter()
5550                    .map(|view| view.shape.to_vec())
5551                    .collect::<Vec<_>>();
5552                let output_shapes = outs
5553                    .iter()
5554                    .map(|output| output.shape.to_vec())
5555                    .collect::<Vec<_>>();
5556                let input_names = inputs
5557                    .iter()
5558                    .map(|input| {
5559                        input
5560                            .map(|value| {
5561                                self.graph.value(value).name.as_deref().unwrap_or("<unnamed>")
5562                            })
5563                            .unwrap_or("<absent>")
5564                    })
5565                    .collect::<Vec<_>>();
5566                let output_names = outputs
5567                    .iter()
5568                    .map(|&value| {
5569                        self.graph.value(value).name.as_deref().unwrap_or("<unnamed>")
5570                    })
5571                    .collect::<Vec<_>>();
5572                SessionError::Internal(format!(
5573                    "node {} ({:?}, op '{}::{}', inputs {input_names:?} {input_types:?} {input_shapes:?}, outputs {output_names:?} {output_types:?} {output_shapes:?}) failed: {error}",
5574                    node.id.0, node.name, node.domain, node.op_type,
5575                ))
5576            })?;
5577
5578        drop(kernel_inputs);
5579        drop(views);
5580        drop(outs);
5581        for backing in out_bufs {
5582            if let Some(buf) = backing.internal {
5583                buffers.insert(backing.vid, buf);
5584            }
5585        }
5586        Ok(())
5587    }
5588
5589    /// Read the integer *values* of input `vid` as `i64`, materializing a view
5590    /// first if needed. Used to resolve data-dependent output shapes (e.g. a
5591    /// `Slice` whose `ends` is produced at runtime). Returns `None` if the value
5592    /// has no readable buffer/view or its dtype is not an integer.
5593    fn input_i64(&self, vid: ValueId, shape: &[usize], dtype: DataType) -> Option<Vec<i64>> {
5594        let bytes = self.contiguous_bytes(vid, shape, dtype).ok()?;
5595        bytes_as_i64(&bytes, dtype)
5596    }
5597
5598    /// Bounded integer reader for dynamic shape propagation. Views and sequence
5599    /// elements can have a tiny logical shape backed by a much larger root
5600    /// allocation, so cap that allocation before `contiguous_bytes` can copy it.
5601    fn shape_input_i64(&self, vid: ValueId, shape: &[usize], dtype: DataType) -> Option<Vec<i64>> {
5602        if !bounded_shape_input(dtype, shape) {
5603            return None;
5604        }
5605        let max_bytes = MAX_SHAPE_DATA_ELEMS.checked_mul(dtype.byte_size())?;
5606        if let Some(view) = self.views.get(&vid) {
5607            let source = self.buffers.get(&view.source)?;
5608            if source.len() > max_bytes {
5609                return None;
5610            }
5611        }
5612        if self
5613            .seq_elem_values
5614            .get(&vid)
5615            .is_some_and(|elem| elem.root_len() > max_bytes)
5616        {
5617            return None;
5618        }
5619        self.input_i64(vid, shape, dtype)
5620    }
5621
5622    fn shape_input_f64(&self, vid: ValueId, shape: &[usize], dtype: DataType) -> Option<Vec<f64>> {
5623        if !matches!(dtype, DataType::Float32 | DataType::Float64)
5624            || shape.len() > 1
5625            || shape
5626                .iter()
5627                .try_fold(1usize, |count, &dim| count.checked_mul(dim))
5628                .is_none_or(|count| count > MAX_SHAPE_DATA_ELEMS)
5629        {
5630            return None;
5631        }
5632        let max_bytes = MAX_SHAPE_DATA_ELEMS.checked_mul(dtype.byte_size())?;
5633        if let Some(view) = self.views.get(&vid) {
5634            let source = self.buffers.get(&view.source)?;
5635            if source.len() > max_bytes {
5636                return None;
5637            }
5638        }
5639        if self
5640            .seq_elem_values
5641            .get(&vid)
5642            .is_some_and(|elem| elem.root_len() > max_bytes)
5643        {
5644            return None;
5645        }
5646        let bytes = self.contiguous_bytes(vid, shape, dtype).ok()?;
5647        bytes_as_f64(&bytes, dtype)
5648    }
5649
5650    /// `NonMaxSuppression` needs its boxes and scores to determine the exact
5651    /// data-dependent output extent. Unlike ordinary shape tensors these inputs
5652    /// are rank 3 and may exceed `MAX_SHAPE_DATA_ELEMS`; materialize them only
5653    /// for this operator, immediately before its output allocation.
5654    fn nms_input_f64(&self, vid: ValueId, shape: &[usize], dtype: DataType) -> Option<Vec<f64>> {
5655        if dtype != DataType::Float32 {
5656            return None;
5657        }
5658        let bytes = self.contiguous_bytes(vid, shape, dtype).ok()?;
5659        bytes_as_f64(&bytes, dtype)
5660    }
5661}
5662
5663// === Sequence-of-tensors ops: SequenceEmpty / SequenceConstruct /
5664// SequenceInsert / SequenceErase / SequenceAt / SequenceLength /
5665// SplitToSequence / ConcatFromSequence ===
5666//
5667// These are handled at the executor level (like control-flow ops) rather than as
5668// leaf kernels, because they operate on a *sequence-of-tensors* runtime value
5669// that a `Kernel` — which sees only individual tensor views — cannot represent.
5670//
5671// ## No-copy design
5672//
5673// A sequence stores its elements as `Arc`-shared **immutable** [`SeqTensor`]s
5674// (see [`crate::sequence`]). Insert/Erase/Construct build a NEW list that SHARES
5675// the surviving element `Arc`s — only handles (a refcount bump), never element
5676// bytes, are cloned. `SequenceAt` yields the shared element `Arc` and backs its
5677// output tensor value with that same allocation (`seq_elem_values`), so a
5678// downstream kernel reads it through a zero-copy [`TensorView`] and no bytes are
5679// copied out of the sequence until the graph-output boundary. Tensor→sequence
5680// entry promotes the existing `DeviceBuffer` into an Arc owner and leaves a
5681// non-owning dispatch alias in the executor. `SplitToSequence` creates
5682// shape/stride/offset views over that same owner. `ConcatFromSequence` is the
5683// only sequence data op that materializes a new contiguous tensor.
5684//
5685// ## No-race design
5686//
5687// Elements are immutable after construction and only ever shared read-only
5688// through `Arc`; there is no interior mutability, so concurrent readers cannot
5689// race (the only cross-thread state is `Arc`'s atomic refcount).
5690impl Executor {
5691    /// Execute one Sequence-op plan node.
5692    fn exec_sequence_node(
5693        &mut self,
5694        pi: usize,
5695        resolved: &mut HashMap<ValueId, Vec<usize>>,
5696        external: &ExternalBindings,
5697    ) -> Result<()> {
5698        let node_id = self.plan[pi].node_id;
5699        let inputs = self.plan[pi].inputs.clone();
5700        let outputs = self.plan[pi].outputs.clone();
5701        let op = self.graph.node(node_id).op_type.clone();
5702
5703        match op.as_str() {
5704            "SequenceEmpty" => {
5705                let dtype_attr = self
5706                    .graph
5707                    .node(node_id)
5708                    .attr("dtype")
5709                    .and_then(|a| a.as_int());
5710                let dtype = match dtype_attr {
5711                    None => DataType::Float32, // ONNX default element type.
5712                    Some(raw) => i32::try_from(raw)
5713                        .ok()
5714                        .and_then(DataType::from_onnx)
5715                        .ok_or_else(|| SessionError::SequenceOp {
5716                            op: op.clone(),
5717                            reason: format!(
5718                                "attribute 'dtype' = {raw} is not a known ONNX \
5719                                 TensorProto.DataType. To fix: use a valid element \
5720                                 dtype id (e.g. 1=float32, 7=int64)"
5721                            ),
5722                        })?,
5723                };
5724                self.sequences
5725                    .insert(outputs[0], SequenceValue::empty(dtype));
5726                Ok(())
5727            }
5728            "SequenceConstruct" => {
5729                let mut items = Vec::with_capacity(inputs.len());
5730                for slot in &inputs {
5731                    let vid = slot.ok_or_else(|| self.seq_missing_input(&op))?;
5732                    items.push(self.read_seq_element(vid, resolved)?);
5733                }
5734                let seq = SequenceValue::construct(items).map_err(seq_err)?;
5735                self.sequences.insert(outputs[0], seq);
5736                Ok(())
5737            }
5738            "SequenceInsert" => {
5739                let seq = self.get_sequence(inputs.first().copied().flatten(), &op)?;
5740                let tvid = inputs
5741                    .get(1)
5742                    .copied()
5743                    .flatten()
5744                    .ok_or_else(|| self.seq_missing_input(&op))?;
5745                let tensor = self.read_seq_element(tvid, resolved)?;
5746                let position = match inputs.get(2).copied().flatten() {
5747                    Some(pvid) => Some(self.read_scalar_i64(pvid, resolved, &op)?),
5748                    None => None,
5749                };
5750                let out = seq.insert(tensor, position).map_err(seq_err)?;
5751                self.sequences.insert(outputs[0], out);
5752                Ok(())
5753            }
5754            "SequenceErase" => {
5755                let seq = self.get_sequence(inputs.first().copied().flatten(), &op)?;
5756                let position = match inputs.get(1).copied().flatten() {
5757                    Some(pvid) => Some(self.read_scalar_i64(pvid, resolved, &op)?),
5758                    None => None,
5759                };
5760                let out = seq.erase(position).map_err(seq_err)?;
5761                self.sequences.insert(outputs[0], out);
5762                Ok(())
5763            }
5764            "SequenceAt" => {
5765                let seq = self.get_sequence(inputs.first().copied().flatten(), &op)?;
5766                let pvid =
5767                    inputs
5768                        .get(1)
5769                        .copied()
5770                        .flatten()
5771                        .ok_or_else(|| SessionError::SequenceOp {
5772                            op: op.clone(),
5773                            reason: "requires a 'position' input. To fix: supply the \
5774                                 index tensor of the element to read"
5775                                .to_string(),
5776                        })?;
5777                let pos = self.read_scalar_i64(pvid, resolved, &op)?;
5778                let elem = seq.at(pos).map_err(seq_err)?;
5779                self.store_seq_element_output(outputs[0], elem, resolved, external)
5780            }
5781            "SequenceLength" => {
5782                let seq = self.get_sequence(inputs.first().copied().flatten(), &op)?;
5783                let len = i64::try_from(seq.length()).map_err(|_| {
5784                    seq_err(SequenceError::LengthOverflow {
5785                        op: "SequenceLength",
5786                        len: seq.length(),
5787                    })
5788                })?;
5789                self.store_raw_tensor_output(
5790                    outputs[0],
5791                    DataType::Int64,
5792                    Vec::new(),
5793                    &len.to_le_bytes(),
5794                    resolved,
5795                    external,
5796                )
5797            }
5798            "SplitToSequence" => {
5799                self.exec_split_to_sequence(node_id, &op, &inputs, &outputs, resolved)
5800            }
5801            "ConcatFromSequence" => {
5802                self.exec_concat_from_sequence(node_id, &op, &inputs, &outputs, resolved, external)
5803            }
5804            other => Err(SessionError::SequenceOp {
5805                op: other.to_string(),
5806                reason: "unrecognized Sequence op (executor routing bug)".to_string(),
5807            }),
5808        }
5809    }
5810
5811    /// `SplitToSequence`: split a tensor into a sequence along `axis`.
5812    fn exec_split_to_sequence(
5813        &mut self,
5814        node_id: NodeId,
5815        op: &str,
5816        inputs: &[Option<ValueId>],
5817        outputs: &[ValueId],
5818        resolved: &mut HashMap<ValueId, Vec<usize>>,
5819    ) -> Result<()> {
5820        let (axis_attr, keepdims) = {
5821            let node = self.graph.node(node_id);
5822            (
5823                node.attr("axis").and_then(|a| a.as_int()).unwrap_or(0),
5824                node.attr("keepdims").and_then(|a| a.as_int()).unwrap_or(1) != 0,
5825            )
5826        };
5827
5828        let ivid = inputs
5829            .first()
5830            .copied()
5831            .flatten()
5832            .ok_or_else(|| self.seq_missing_input(op))?;
5833        let input = self.read_seq_element(ivid, resolved)?;
5834
5835        let split_input = match inputs.get(1).copied().flatten() {
5836            None => None,
5837            Some(svid) => {
5838                let split_shape = resolved
5839                    .get(&svid)
5840                    .cloned()
5841                    .ok_or_else(|| self.seq_unresolved(op, svid))?;
5842                let values = self.read_i64_vec(svid, &split_shape, op)?;
5843                Some((split_shape, values))
5844            }
5845        };
5846        let split_spec = match split_input.as_ref() {
5847            None => SplitSpec::Each,
5848            Some((split_shape, values)) if split_shape.is_empty() => {
5849                let [chunk] = values.as_slice() else {
5850                    return Err(SessionError::SequenceOp {
5851                        op: op.to_string(),
5852                        reason: format!(
5853                            "scalar 'split' input contains {} values, expected exactly one",
5854                            values.len()
5855                        ),
5856                    });
5857                };
5858                SplitSpec::Chunk(*chunk)
5859            }
5860            Some((split_shape, values)) if split_shape.len() == 1 => SplitSpec::Sizes(values),
5861            Some((split_shape, _)) => {
5862                return Err(SessionError::SequenceOp {
5863                    op: op.to_string(),
5864                    reason: format!(
5865                        "'split' input must be rank 0 (chunk size) or rank 1 (explicit sizes), \
5866                         got rank {} with shape {split_shape:?}",
5867                        split_shape.len()
5868                    ),
5869                });
5870            }
5871        };
5872        let sequence = split_tensor(&input, axis_attr, split_spec, keepdims).map_err(seq_err)?;
5873        self.sequences.insert(outputs[0], sequence);
5874        Ok(())
5875    }
5876
5877    /// `ConcatFromSequence`: concatenate (or stack, when `new_axis=1`) a
5878    /// sequence's tensors into one freshly-allocated output.
5879    fn exec_concat_from_sequence(
5880        &mut self,
5881        node_id: NodeId,
5882        op: &str,
5883        inputs: &[Option<ValueId>],
5884        outputs: &[ValueId],
5885        resolved: &mut HashMap<ValueId, Vec<usize>>,
5886        external: &ExternalBindings,
5887    ) -> Result<()> {
5888        let node = self.graph.node(node_id);
5889        let axis_attr =
5890            node.attr("axis")
5891                .and_then(|a| a.as_int())
5892                .ok_or_else(|| SessionError::SequenceOp {
5893                    op: op.to_string(),
5894                    reason: "requires the mandatory 'axis' attribute. To fix: set 'axis'"
5895                        .to_string(),
5896                })?;
5897        let new_axis = node.attr("new_axis").and_then(|a| a.as_int()).unwrap_or(0) != 0;
5898
5899        let seq = self.get_sequence(inputs.first().copied().flatten(), op)?;
5900        let plan = ConcatPlan::new(&seq, axis_attr, new_axis).map_err(seq_err)?;
5901        self.prepare_tensor_output(
5902            outputs[0],
5903            plan.dtype,
5904            plan.shape.clone(),
5905            plan.bytes,
5906            resolved,
5907            external,
5908        )?;
5909        let ep = Arc::clone(&self.ep);
5910        if let Some(value) = external.outputs.get(&outputs[0]) {
5911            let mut buffer = value.writable_buffer()?;
5912            plan.write(&seq, |offset, bytes| {
5913                ep.copy_from_host_at(bytes, &mut buffer, offset)?;
5914                Ok(())
5915            })?;
5916        } else {
5917            let buffer = self.buffers.get_mut(&outputs[0]).ok_or_else(|| {
5918                SessionError::Internal(format!(
5919                    "missing ConcatFromSequence output buffer for value#{}",
5920                    outputs[0].0
5921                ))
5922            })?;
5923            plan.write(&seq, |offset, bytes| {
5924                ep.copy_from_host_at(bytes, buffer, offset)?;
5925                Ok(())
5926            })?;
5927        }
5928        Ok(())
5929    }
5930
5931    /// Build (or share) a `SeqTensor` for a tensor value entering a sequence.
5932    /// Existing sequence elements clone their Arc. Ordinary tensors promote the
5933    /// existing allocation into a shared owner and keep a non-owning executor
5934    /// alias, so no element bytes move.
5935    fn read_seq_element(
5936        &mut self,
5937        vid: ValueId,
5938        resolved: &HashMap<ValueId, Vec<usize>>,
5939    ) -> Result<SeqTensor> {
5940        if self.sequence_values.contains(&vid) {
5941            return Err(SessionError::SequenceOp {
5942                op: "Sequence".to_string(),
5943                reason: format!(
5944                    "input value#{} is a Sequence value, expected a tensor element",
5945                    vid.0
5946                ),
5947            });
5948        }
5949        if let Some(elem) = self.seq_elem_values.get(&vid) {
5950            return Ok(elem.clone()); // zero-copy Arc share
5951        }
5952        let dtype = self.value_dtypes[&vid];
5953        let shape = resolved
5954            .get(&vid)
5955            .cloned()
5956            .ok_or_else(|| self.seq_unresolved("Sequence", vid))?;
5957        let (root, layout, byte_offset) = match self.views.get(&vid) {
5958            Some(view) => (
5959                view.source,
5960                TensorLayout::strided(view.strides.clone()),
5961                view.byte_offset,
5962            ),
5963            None => (vid, TensorLayout::contiguous(), 0),
5964        };
5965        if !self.shared_buffers.contains_key(&root) {
5966            let buffer = self
5967                .buffers
5968                .remove(&root)
5969                .ok_or_else(|| SessionError::SequenceOp {
5970                    op: "Sequence".to_string(),
5971                    reason: format!("tensor value#{} has no live backing buffer", vid.0),
5972                })?;
5973            let storage = SharedTensorBuffer::new(Arc::clone(&self.ep), buffer);
5974            self.buffers.insert(root, storage.alias());
5975            self.shared_buffers.insert(root, storage);
5976        }
5977        self.pinned.insert(root);
5978        SeqTensor::from_shared(
5979            Arc::clone(&self.shared_buffers[&root]),
5980            dtype,
5981            shape,
5982            layout,
5983            byte_offset,
5984        )
5985        .map_err(SessionError::from)
5986    }
5987
5988    fn restore_shared_buffers(&mut self) -> Result<()> {
5989        let mut retained = Vec::new();
5990        for (vid, storage) in self.shared_buffers.drain() {
5991            if let Some(alias) = self.buffers.remove(&vid) {
5992                self.ep.deallocate(alias)?;
5993            }
5994            match Arc::try_unwrap(storage) {
5995                Ok(storage) => {
5996                    self.buffers.insert(vid, storage.into_buffer());
5997                }
5998                Err(storage) if self.graph.initializers.contains_key(&vid) => {
5999                    self.buffers.insert(vid, storage.alias());
6000                    retained.push((vid, storage));
6001                }
6002                Err(storage) => {
6003                    let replacement = self
6004                        .ep
6005                        .allocate(storage.buffer().len(), storage.buffer().alignment())?;
6006                    self.buffers.insert(vid, replacement);
6007                }
6008            }
6009        }
6010        for (vid, storage) in retained {
6011            self.shared_buffers.insert(vid, storage);
6012        }
6013        Ok(())
6014    }
6015
6016    /// Fetch (clone) the sequence value bound to `vid` (cheap — `Arc` handle
6017    /// clones), or an actionable error if the input is missing / not a sequence.
6018    fn get_sequence(&self, vid: Option<ValueId>, op: &str) -> Result<SequenceValue> {
6019        let vid = vid.ok_or_else(|| self.seq_missing_input(op))?;
6020        self.sequences
6021            .get(&vid)
6022            .cloned()
6023            .ok_or_else(|| SessionError::SequenceOp {
6024                op: op.to_string(),
6025                reason: format!(
6026                    "input value#{} is not a live sequence. To fix: ensure it is produced \
6027                 by a Sequence-producing op (SequenceEmpty/Construct/Insert/Erase/\
6028                 SplitToSequence)",
6029                    vid.0
6030                ),
6031            })
6032    }
6033
6034    /// Read a scalar `i64`/`i32` position input.
6035    fn read_scalar_i64(
6036        &self,
6037        vid: ValueId,
6038        resolved: &HashMap<ValueId, Vec<usize>>,
6039        op: &str,
6040    ) -> Result<i64> {
6041        let shape = resolved.get(&vid).cloned().unwrap_or_default();
6042        if !shape.is_empty() {
6043            return Err(SessionError::SequenceOp {
6044                op: op.to_string(),
6045                reason: format!(
6046                    "position input must be a rank-0 scalar, got rank {} with shape {shape:?}",
6047                    shape.len()
6048                ),
6049            });
6050        }
6051        let dtype = self.value_dtypes[&vid];
6052        let vals = self
6053            .input_i64(vid, &shape, dtype)
6054            .ok_or_else(|| SessionError::SequenceOp {
6055                op: op.to_string(),
6056                reason: format!(
6057                    "position input has dtype {dtype:?}, expected an integer (int32/int64). \
6058                 To fix: provide an int64 scalar index"
6059                ),
6060            })?;
6061        let [value] = vals.as_slice() else {
6062            return Err(SessionError::SequenceOp {
6063                op: op.to_string(),
6064                reason: format!(
6065                    "position input contains {} values; expected exactly one scalar index",
6066                    vals.len()
6067                ),
6068            });
6069        };
6070        Ok(*value)
6071    }
6072
6073    /// Read an `i64` vector from an integer tensor input (SplitToSequence's
6074    /// `split`).
6075    fn read_i64_vec(&self, vid: ValueId, shape: &[usize], op: &str) -> Result<Vec<i64>> {
6076        let dtype = self.value_dtypes[&vid];
6077        self.input_i64(vid, shape, dtype)
6078            .ok_or_else(|| SessionError::SequenceOp {
6079                op: op.to_string(),
6080                reason: format!(
6081                    "'split' input has dtype {dtype:?}, expected int32/int64. To fix: \
6082                 provide integer split sizes"
6083                ),
6084            })
6085    }
6086
6087    /// Back a tensor *output* value with a shared sequence element (SequenceAt).
6088    /// The element retains its original device allocation and view metadata.
6089    fn store_seq_element_output(
6090        &mut self,
6091        vid: ValueId,
6092        elem: SeqTensor,
6093        resolved: &mut HashMap<ValueId, Vec<usize>>,
6094        external: &ExternalBindings,
6095    ) -> Result<()> {
6096        if elem.device() != self.ep.device_id() {
6097            return Err(SessionError::SequenceOp {
6098                op: "SequenceAt".to_string(),
6099                reason: format!(
6100                    "sequence element is on {:?}, but the active execution provider is on {:?}",
6101                    elem.device(),
6102                    self.ep.device_id()
6103                ),
6104            });
6105        }
6106        if external.outputs.contains_key(&vid) {
6107            let dtype = elem.dtype;
6108            let shape = elem.shape.clone();
6109            let bytes = elem.contiguous_bytes().map_err(seq_err)?;
6110            return self.store_raw_tensor_output(vid, dtype, shape, &bytes, resolved, external);
6111        }
6112        if let Some(old) = self.buffers.remove(&vid) {
6113            self.ep.deallocate(old)?;
6114        }
6115        self.shared_buffers.remove(&vid);
6116        self.buffer_shapes.remove(&vid);
6117        self.views.remove(&vid);
6118        resolved.insert(vid, elem.shape.clone());
6119        self.value_dtypes.insert(vid, elem.dtype);
6120        self.seq_elem_values.insert(vid, elem);
6121        Ok(())
6122    }
6123
6124    /// Store freshly-computed contiguous bytes into a tensor output value
6125    /// (SequenceLength / ConcatFromSequence): (re)allocate its buffer, copy the
6126    /// bytes once, and record its dtype/shape.
6127    fn store_raw_tensor_output(
6128        &mut self,
6129        vid: ValueId,
6130        dtype: DataType,
6131        dims: Vec<usize>,
6132        bytes: &[u8],
6133        resolved: &mut HashMap<ValueId, Vec<usize>>,
6134        external: &ExternalBindings,
6135    ) -> Result<()> {
6136        self.prepare_tensor_output(vid, dtype, dims, bytes.len(), resolved, external)?;
6137        if let Some(value) = external.outputs.get(&vid) {
6138            let mut buffer = value.writable_buffer()?;
6139            self.ep.copy_from_host(bytes, &mut buffer)?;
6140        } else {
6141            let buffer = self.buffers.get_mut(&vid).ok_or_else(|| {
6142                SessionError::Internal(format!("missing tensor output buffer for value#{}", vid.0))
6143            })?;
6144            self.ep.copy_from_host(bytes, buffer)?;
6145        }
6146        Ok(())
6147    }
6148
6149    fn prepare_tensor_output(
6150        &mut self,
6151        vid: ValueId,
6152        dtype: DataType,
6153        dims: Vec<usize>,
6154        bytes: usize,
6155        resolved: &mut HashMap<ValueId, Vec<usize>>,
6156        external: &ExternalBindings,
6157    ) -> Result<()> {
6158        self.seq_elem_values.remove(&vid);
6159        self.views.remove(&vid);
6160        let need = bytes.max(1);
6161        if let Some(value) = external.outputs.get(&vid) {
6162            if !value.accepts_output(dtype, &dims, need) {
6163                let name = self.graph.value(vid).name.as_deref().unwrap_or("<unnamed>");
6164                return Err(SessionError::Internal(format!(
6165                    "external output '{name}' has {:?} {:?} ({} bytes), sequence op requires {:?} {:?} ({need} bytes)",
6166                    value.dtype, value.shape, value.len, dtype, dims
6167                )));
6168            }
6169        } else {
6170            let fits = self
6171                .buffers
6172                .get(&vid)
6173                .map(|buffer| buffer.len() == need)
6174                .unwrap_or(false);
6175            if !fits {
6176                if let Some(old) = self.buffers.remove(&vid) {
6177                    self.ep.deallocate(old)?;
6178                }
6179                self.shared_buffers.remove(&vid);
6180                let buffer = self
6181                    .ep
6182                    .allocate(need, TensorLayout::contiguous().alignment)?;
6183                self.buffers.insert(vid, buffer);
6184            }
6185            self.buffer_shapes.insert(vid, dims.clone());
6186        }
6187        self.value_dtypes.insert(vid, dtype);
6188        resolved.insert(vid, dims);
6189        Ok(())
6190    }
6191
6192    fn seq_missing_input(&self, op: &str) -> SessionError {
6193        SessionError::SequenceOp {
6194            op: op.to_string(),
6195            reason: "a required input is missing (omitted None slot). To fix: connect \
6196                     all required inputs of this Sequence op"
6197                .to_string(),
6198        }
6199    }
6200
6201    fn seq_unresolved(&self, op: &str, vid: ValueId) -> SessionError {
6202        let name = self
6203            .graph
6204            .try_value(vid)
6205            .and_then(|v| v.name.clone())
6206            .unwrap_or_else(|| format!("value#{}", vid.0));
6207        SessionError::SequenceOp {
6208            op: op.to_string(),
6209            reason: format!(
6210                "input {name} has no resolved shape yet. To fix: ensure its producer \
6211                 runs before this Sequence op"
6212            ),
6213        }
6214    }
6215}
6216
6217/// Map a [`crate::sequence::SequenceError`] into an actionable `SessionError`.
6218fn seq_err(e: crate::sequence::SequenceError) -> SessionError {
6219    e.into()
6220}
6221
6222/// Normalize a possibly-negative ONNX `axis` against `rank`, returning the
6223/// non-negative axis or `None` when out of `[-rank, rank-1]`.
6224fn normalize_axis(axis: i64, rank: usize) -> Option<usize> {
6225    let r = rank as i64;
6226    let a = if axis < 0 { axis + r } else { axis };
6227    if a < 0 || a >= r {
6228        None
6229    } else {
6230        Some(a as usize)
6231    }
6232}
6233
6234fn scan_list_attr(node: &Node, name: &str, count: usize, default: i64) -> Result<Vec<i64>> {
6235    match node.attr(name) {
6236        None => Ok(vec![default; count]),
6237        Some(attr) => {
6238            let values = attr.as_ints().ok_or_else(|| SessionError::ControlFlow {
6239                op: "Scan".to_string(),
6240                reason: format!("attribute '{name}' must be an INTS list"),
6241            })?;
6242            if values.len() != count {
6243                return Err(SessionError::ControlFlow {
6244                    op: "Scan".to_string(),
6245                    reason: format!(
6246                        "attribute '{name}' has {} value(s), expected {count}",
6247                        values.len()
6248                    ),
6249                });
6250            }
6251            Ok(values.to_vec())
6252        }
6253    }
6254}
6255
6256/// Whether `(op_type, domain)` is one of the standard subgraph-bearing
6257/// control-flow ops the executor handles recursively (default `ai.onnx`
6258/// domain). Kept in lock-step with the loader's `validate_no_control_flow`
6259/// allow-list.
6260fn is_control_flow_op(op_type: &str, domain: &str) -> bool {
6261    domain.is_empty() && matches!(op_type, "If" | "Loop" | "Scan")
6262}
6263
6264/// Whether `(op_type, domain)` is an ONNX **Sequence** op the executor handles
6265/// directly (default `ai.onnx` domain). Like control-flow ops these are handled
6266/// at the executor level rather than as leaf [`Kernel`](onnx_runtime_ep_api::Kernel)s
6267/// because a `Kernel` sees only tensor views, never a *sequence-of-tensors*
6268/// runtime value. Kept as a small self-contained routing predicate (mirroring
6269/// [`is_control_flow_op`]) so it never collides with the EP kernel registry.
6270fn is_sequence_op(op_type: &str, domain: &str) -> bool {
6271    domain.is_empty()
6272        && matches!(
6273            op_type,
6274            "SequenceEmpty"
6275                | "SequenceConstruct"
6276                | "SequenceInsert"
6277                | "SequenceErase"
6278                | "SequenceAt"
6279                | "SequenceLength"
6280                | "SplitToSequence"
6281                | "ConcatFromSequence"
6282        )
6283}
6284
6285/// Whether a Sequence op yields a *sequence* value (vs. a tensor). Used at build
6286/// to mark sequence-typed values so they are excluded from tensor buffer sizing.
6287fn produces_sequence_output(op_type: &str, domain: &str) -> bool {
6288    domain.is_empty()
6289        && matches!(
6290            op_type,
6291            "SequenceEmpty"
6292                | "SequenceConstruct"
6293                | "SequenceInsert"
6294                | "SequenceErase"
6295                | "SplitToSequence"
6296        )
6297}
6298
6299/// Read a single scalar `i64` element from a length-1 tensor (Loop's `M`).
6300fn tensor_scalar_i64(t: &Tensor) -> Option<i64> {
6301    if t.dtype != DataType::Int64 || t.numel() != 1 {
6302        return None;
6303    }
6304    t.as_bytes()
6305        .get(..8)
6306        .map(|c| i64::from_le_bytes(c.try_into().unwrap()))
6307}
6308
6309/// Read a single scalar bool from a length-1 `BOOL` tensor (a `BOOL` is one
6310/// byte; any nonzero is true, per ONNX).
6311fn tensor_scalar_bool(t: &Tensor) -> Option<bool> {
6312    if t.dtype != DataType::Bool || t.numel() != 1 {
6313        return None;
6314    }
6315    t.as_bytes().first().map(|&b| b != 0)
6316}
6317
6318/// Build a length-1 `i64` scalar tensor (Loop's `iter_num` body input).
6319fn scalar_i64_tensor(v: i64) -> Result<Tensor> {
6320    Tensor::from_raw(DataType::Int64, vec![], &v.to_le_bytes())
6321}
6322
6323/// Build a scalar `BOOL` tensor (Loop's `cond` body input).
6324fn scalar_bool_tensor(v: bool) -> Result<Tensor> {
6325    Tensor::from_raw(DataType::Bool, vec![], &[u8::from(v)])
6326}
6327
6328fn missing_capture_error(attr_key: &str, name: &str) -> SessionError {
6329    SessionError::Internal(format!(
6330        "control-flow body '{attr_key}' captures free variable '{name}', but it is not \
6331         available in the enclosing scope. RULES #1: a subgraph may only reference outer \
6332         values that are graph inputs, initializers, or produced by an upstream node in an \
6333         enclosing graph; '{name}' matches none of these"
6334    ))
6335}
6336
6337/// Names a graph or any nested body needs from its enclosing lexical scope.
6338/// A nested requirement stops propagating when this graph defines that name,
6339/// because the nested body will bind the local value at execution time.
6340fn required_outer_names(graph: &Graph) -> HashSet<String> {
6341    let formal_set: HashSet<ValueId> = graph.inputs.iter().copied().collect();
6342    let local_names: HashSet<&str> = graph
6343        .values
6344        .iter()
6345        .filter_map(|(_, value)| value.name.as_deref())
6346        .collect();
6347    let mut required = HashSet::new();
6348    for (vid, value) in graph.values.iter() {
6349        if value.producer.is_none()
6350            && !formal_set.contains(&vid)
6351            && !graph.initializers.contains_key(&vid)
6352            && let Some(name) = &value.name
6353        {
6354            required.insert(name.clone());
6355        }
6356    }
6357    for nested in graph.subgraphs.values() {
6358        for name in required_outer_names(nested) {
6359            if !local_names.contains(name.as_str()) {
6360                required.insert(name);
6361            }
6362        }
6363    }
6364    required
6365}
6366
6367impl ChildExecutor {
6368    /// Create the reusable wrapper for a loaded subgraph body.
6369    ///
6370    /// `body.inputs` and `body.outputs` are the loader-preserved ordered formal
6371    /// signature. Producer-less named values that are neither formals nor local
6372    /// initializers are lexical captures and are bound from `outer_scope`.
6373    pub(crate) fn new(
6374        name: impl Into<String>,
6375        body: Graph,
6376        inherited_opsets: HashMap<String, u64>,
6377        weights: Arc<WeightStore>,
6378        ep: Arc<dyn ExecutionProvider>,
6379    ) -> Result<Self> {
6380        let name = name.into();
6381        let formal_names = body
6382            .inputs
6383            .iter()
6384            .map(|&vid| {
6385                body.value(vid).name.clone().ok_or_else(|| {
6386                    SessionError::Internal(format!(
6387                        "subgraph '{name}' has an unnamed formal input value#{}",
6388                        vid.0
6389                    ))
6390                })
6391            })
6392            .collect::<Result<Vec<_>>>()?;
6393        let formal_set: HashSet<ValueId> = body.inputs.iter().copied().collect();
6394        let mut capture_names = body
6395            .values
6396            .iter()
6397            .filter_map(|(vid, value)| {
6398                (value.producer.is_none()
6399                    && !formal_set.contains(&vid)
6400                    && !body.initializers.contains_key(&vid))
6401                .then(|| value.name.clone())
6402                .flatten()
6403            })
6404            .collect::<Vec<_>>();
6405        capture_names.sort();
6406        let input_names = formal_names
6407            .iter()
6408            .chain(capture_names.iter())
6409            .cloned()
6410            .collect();
6411
6412        Ok(Self {
6413            name,
6414            template: body,
6415            inherited_opsets,
6416            weights,
6417            ep,
6418            formal_names,
6419            capture_names,
6420            input_names,
6421            compiled: Vec::new(),
6422            builds: 0,
6423            runs: 0,
6424            trace: TraceContext::noop(),
6425        })
6426    }
6427
6428    pub(crate) fn stats(&self) -> ChildExecutorStats {
6429        ChildExecutorStats {
6430            builds: self.builds,
6431            runs: self.runs,
6432        }
6433    }
6434
6435    /// Attach the shared trace context, propagating it to every already-compiled
6436    /// child plan and to plans compiled later.
6437    pub(crate) fn set_trace_context(&mut self, trace: TraceContext) {
6438        for plan in &mut self.compiled {
6439            plan.exec.set_trace_context(trace.clone());
6440        }
6441        self.trace = trace;
6442    }
6443
6444    fn compile(&self, externals: &[&Tensor]) -> Result<CompiledChildPlan> {
6445        let mut graph = self.template.clone();
6446        // GraphProto has no opset table: nested graphs inherit the model-level
6447        // imports from their enclosing graph.
6448        graph.opset_imports = self.inherited_opsets.clone();
6449
6450        let body_names = graph
6451            .values
6452            .iter()
6453            .filter_map(|(vid, value)| value.name.clone().map(|name| (name, vid)))
6454            .collect::<HashMap<_, _>>();
6455
6456        // Direct captures become required graph inputs. Local inline
6457        // initializers stay in `graph.initializers`, preserving their scope.
6458        for name in &self.capture_names {
6459            let vid = *body_names.get(name).ok_or_else(|| {
6460                SessionError::Internal(format!(
6461                    "subgraph '{}' lost captured value '{name}'",
6462                    self.name
6463                ))
6464            })?;
6465            if !graph.inputs.contains(&vid) {
6466                graph.add_input(vid);
6467            }
6468        }
6469
6470        for (name, tensor) in self.input_names.iter().zip(externals) {
6471            let vid = *body_names.get(name).ok_or_else(|| {
6472                SessionError::Internal(format!(
6473                    "subgraph '{}' is missing bound input '{name}'",
6474                    self.name
6475                ))
6476            })?;
6477            let value = graph.value_mut(vid);
6478            value.dtype = tensor.dtype;
6479            value.shape = tensor.shape.iter().map(|&dim| Dim::Static(dim)).collect();
6480        }
6481
6482        // Seeded formal/capture shapes let inference resolve the body once.
6483        // Truly data-dependent outputs remain on Executor's JIT shape path.
6484        let registry = InferenceRegistry::default_registry();
6485        registry.infer_graph(&mut graph, &self.inherited_opsets, MergePolicy::Permissive)?;
6486
6487        Ok(CompiledChildPlan {
6488            exec: {
6489                let mut exec = Executor::build(graph, self.weights.clone(), self.ep.clone())?;
6490                exec.set_trace_context(self.trace.clone());
6491                exec
6492            },
6493            signature: externals
6494                .iter()
6495                .map(|tensor| ChildInputSignature {
6496                    dtype: tensor.dtype,
6497                    shape: tensor.shape.clone(),
6498                })
6499                .collect(),
6500        })
6501    }
6502
6503    /// Execute the body with formal inputs in declared order and lexical values
6504    /// supplied by name. A cached plan is reused for matching dtype/shapes.
6505    pub(crate) fn run(
6506        &mut self,
6507        formal_inputs: &[&Tensor],
6508        outer_scope: &HashMap<String, Tensor>,
6509    ) -> Result<Vec<Tensor>> {
6510        if self.formal_names.len() != formal_inputs.len() {
6511            return Err(SessionError::Internal(format!(
6512                "subgraph '{}' expects {} formal input(s) but {} were supplied",
6513                self.name,
6514                self.formal_names.len(),
6515                formal_inputs.len()
6516            )));
6517        }
6518
6519        let mut externals = Vec::with_capacity(formal_inputs.len() + self.capture_names.len());
6520        externals.extend_from_slice(formal_inputs);
6521        for name in &self.capture_names {
6522            externals.push(
6523                outer_scope
6524                    .get(name)
6525                    .ok_or_else(|| missing_capture_error(&self.name, name))?,
6526            );
6527        }
6528
6529        let signature = externals
6530            .iter()
6531            .map(|tensor| ChildInputSignature {
6532                dtype: tensor.dtype,
6533                shape: tensor.shape.clone(),
6534            })
6535            .collect::<Vec<_>>();
6536        let cache_index = if let Some(index) = self
6537            .compiled
6538            .iter()
6539            .position(|compiled| compiled.signature == signature)
6540        {
6541            let compiled = self.compiled.remove(index);
6542            self.compiled.push(compiled);
6543            self.compiled.len() - 1
6544        } else {
6545            let compiled = self.compile(&externals)?;
6546            if self.compiled.len() == CHILD_EXECUTOR_CACHE_CAPACITY {
6547                self.compiled.remove(0);
6548            }
6549            self.compiled.push(compiled);
6550            self.builds += 1;
6551            self.compiled.len() - 1
6552        };
6553
6554        self.runs += 1;
6555        let inputs = self
6556            .input_names
6557            .iter()
6558            .map(String::as_str)
6559            .zip(externals)
6560            .collect::<Vec<_>>();
6561        self.compiled[cache_index]
6562            .exec
6563            .run_scoped(&inputs, outer_scope, &ExternalBindings::default())?
6564            .into_iter()
6565            .map(|output| {
6566                let output = output.ok_or_else(|| {
6567                    SessionError::Internal(format!(
6568                        "subgraph '{}' unexpectedly suppressed an output",
6569                        self.name
6570                    ))
6571                })?;
6572                match output {
6573                    SessionOutput::Tensor(tensor) => Ok(tensor),
6574                    SessionOutput::Sequence(_) => Err(SessionError::SequenceOp {
6575                        op: "<control-flow output>".to_string(),
6576                        reason: format!(
6577                            "subgraph '{}' produced a Sequence output where this control-flow path requires a tensor",
6578                            self.name
6579                        ),
6580                    }),
6581                }
6582            })
6583            .collect()
6584    }
6585}
6586
6587// === Control-flow (subgraph-executing) ops: If / Loop / Scan ===
6588//
6589// These are handled at the executor level rather than as leaf kernels because
6590// they must recursively execute a nested ONNX [`Graph`] with the enclosing
6591// scope bound — something a `Kernel` (which sees only tensor views, never the
6592// session/graph context) cannot do. Each body is compiled to a child
6593// [`Executor`] once and **reused across iterations** (see [`ChildExecutor`]).
6594impl Executor {
6595    /// Materialize value `vid`'s current bytes into an owned host [`Tensor`],
6596    /// using its resolved concrete shape and recorded dtype.
6597    fn value_tensor(
6598        &self,
6599        vid: ValueId,
6600        resolved: &HashMap<ValueId, Vec<usize>>,
6601    ) -> Result<Tensor> {
6602        let dtype = self.value_dtypes[&vid];
6603        let shape = resolved.get(&vid).cloned().ok_or_else(|| {
6604            let name = self
6605                .graph
6606                .try_value(vid)
6607                .and_then(|v| v.name.clone())
6608                .unwrap_or_else(|| format!("value#{}", vid.0));
6609            SessionError::UnresolvedShape {
6610                value: name,
6611                op: "<control-flow input>".to_string(),
6612            }
6613        })?;
6614        // A view value owns no buffer; materialize its strided bytes contiguous.
6615        let bytes = self.contiguous_bytes(vid, &shape, dtype)?;
6616        Tensor::from_raw(dtype, shape, &bytes)
6617    }
6618
6619    /// The buffer-owning (root) value backing `vid`: `vid` itself if it owns a
6620    /// buffer, or the `source` recorded in its view metadata (always a root,
6621    /// since views are flattened at creation).
6622    fn root_of(&self, vid: ValueId) -> ValueId {
6623        match self.views.get(&vid) {
6624            Some(v) => v.source,
6625            None => vid,
6626        }
6627    }
6628
6629    /// Zero-copy hand-off of a top-level graph output: move the produced host
6630    /// buffer straight into the returned tensor instead of copying it to host
6631    /// and re-allocating it in [`Tensor::from_raw`]. This eliminates two full
6632    /// per-output memcpys on the decode hot path (the growing KV-cache present
6633    /// outputs re-materialized every step) while being numerically identical —
6634    /// the tensor keeps the exact bytes the kernel wrote.
6635    ///
6636    /// Returns `None` (caller falls back to the copy path) unless every safety
6637    /// precondition holds: the value is an owned, host-resident, exactly-sized
6638    /// producer output that is not a view/sequence element, not pinned as a live
6639    /// view source, not shared, and not listed as a graph output more than once.
6640    /// Moving the buffer out forfeits this value's cross-run allocation reuse, so
6641    /// its `buffer_shapes` entry is cleared to force a fresh allocation next run.
6642    fn try_move_host_output(
6643        &mut self,
6644        vid: ValueId,
6645        shape: &[usize],
6646        dtype: DataType,
6647    ) -> Result<Option<Tensor>> {
6648        // Values the copy path materializes specially (strided gather, shared
6649        // sequence element, in-place share, or a pinned live view source) must
6650        // not have their backing buffer stolen.
6651        if self.views.contains_key(&vid)
6652            || self.seq_elem_values.contains_key(&vid)
6653            || self.shared_buffers.contains_key(&vid)
6654            || self.pinned.contains(&vid)
6655        {
6656            return Ok(None);
6657        }
6658        // Only a produced value owns a writable buffer. A producer-less output
6659        // (initializer or graph-input passthrough) may alias read-only mmap or
6660        // foreign/borrowed memory that a tensor must never free.
6661        if self
6662            .graph
6663            .try_value(vid)
6664            .is_none_or(|value| value.producer.is_none())
6665        {
6666            return Ok(None);
6667        }
6668        // A value produced by a memoized loop-invariant `If` is served on later
6669        // steps directly from its resident buffer (the branch is skipped, see
6670        // `exec_if`). Moving that buffer out would leave the next memoized skip
6671        // handing back freed/reallocated memory, so fall back to the copy path
6672        // and keep the produced buffer resident for reuse.
6673        if let Some(producer) = self.graph.try_value(vid).and_then(|value| value.producer)
6674            && self.if_last_predicate.contains_key(&producer)
6675        {
6676            return Ok(None);
6677        }
6678        // A value listed as a graph output more than once would be taken twice.
6679        if self.graph.outputs.iter().filter(|&&o| o == vid).count() != 1 {
6680            return Ok(None);
6681        }
6682        let value_name = || format!("value#{}", vid.0);
6683        let numel = checked_numel(shape, value_name)?;
6684        let n = checked_storage_bytes(dtype, numel, value_name, shape)?;
6685        let movable = self.buffers.get(&vid).is_some_and(|buf| {
6686            buf.device().is_host_accessible() && !buf.is_borrowed() && buf.len() == n
6687        });
6688        if !movable {
6689            return Ok(None);
6690        }
6691        let buffer = self
6692            .buffers
6693            .remove(&vid)
6694            .expect("buffer presence checked above");
6695        // The buffer now belongs to the tensor; force a fresh allocation on the
6696        // next run instead of the reuse fast path (which assumes it is present).
6697        self.buffer_shapes.remove(&vid);
6698        Ok(Some(Tensor::from_owned_buffer(
6699            self.ep.clone(),
6700            dtype,
6701            shape.to_vec(),
6702            buffer,
6703        )))
6704    }
6705
6706    /// Contiguous row-major bytes of `vid` for `shape`/`dtype`, materializing a
6707    /// view (strided gather over its source buffer) or truncating an owned
6708    /// buffer to its logical size. This is the single materialization seam used
6709    /// by the graph-output boundary and control-flow scope capture.
6710    fn contiguous_bytes(&self, vid: ValueId, shape: &[usize], dtype: DataType) -> Result<Vec<u8>> {
6711        let value_name = || {
6712            self.graph
6713                .try_value(vid)
6714                .and_then(|value| value.name.clone())
6715                .unwrap_or_else(|| format!("value#{}", vid.0))
6716        };
6717        let numel = checked_numel(shape, value_name)?;
6718        let n = checked_storage_bytes(dtype, numel, value_name, shape)?;
6719        // A tensor value backed by a shared sequence element (SequenceAt output)
6720        // owns no buffer; its bytes are the element's contiguous bytes. This is
6721        // the one materialization point where they are copied out (the boundary
6722        // back into owned tensors); the compute path reads them zero-copy.
6723        if let Some(elem) = self.seq_elem_values.get(&vid) {
6724            let bytes = elem.contiguous_bytes().map_err(SessionError::from)?;
6725            return Ok(bytes[..n.min(bytes.len())].to_vec());
6726        }
6727        if let Some(view) = self.views.get(&vid) {
6728            let buf = self.buffers.get(&view.source).ok_or_else(|| {
6729                SessionError::Internal(format!(
6730                    "view value#{} aliases missing source buffer value#{}",
6731                    vid.0, view.source.0
6732                ))
6733            })?;
6734            let esize = dtype.byte_size();
6735            if esize == 0 {
6736                // Sub-byte views are never created (Slice falls back to copy),
6737                // so reaching here is an internal invariant violation.
6738                return Err(SessionError::Internal(format!(
6739                    "cannot materialize sub-byte view value#{}",
6740                    vid.0
6741                )));
6742            }
6743            let mut host = vec![0u8; buf.len()];
6744            self.ep.copy_to_host(buf, &mut host)?;
6745            Ok(gather_view(
6746                &host,
6747                &view.shape,
6748                &view.strides,
6749                view.byte_offset,
6750                esize,
6751            ))
6752        } else {
6753            let buf = self
6754                .buffers
6755                .get(&vid)
6756                .ok_or_else(|| SessionError::Internal(format!("value#{} not produced", vid.0)))?;
6757            let mut host = vec![0u8; n];
6758            self.ep.copy_to_host(buf, &mut host)?;
6759            Ok(host)
6760        }
6761    }
6762
6763    /// Store a control-flow op's produced output `tensor` into this graph's
6764    /// output value `vid`: (re)size the backing buffer, copy the bytes, and
6765    /// record the runtime dtype/shape so the caller (and the final output
6766    /// collection) reads them back correctly. Control-flow output shapes are
6767    /// data-dependent (the loader never inferred inside the body), so they are
6768    /// resolved here, exactly as the JIT data-dependent path does for kernels.
6769    fn store_output_tensor(
6770        &mut self,
6771        vid: ValueId,
6772        tensor: &Tensor,
6773        resolved: &mut HashMap<ValueId, Vec<usize>>,
6774    ) -> Result<()> {
6775        self.store_output_bytes(
6776            vid,
6777            tensor.dtype,
6778            tensor.shape.clone(),
6779            tensor.as_bytes(),
6780            resolved,
6781        )
6782    }
6783
6784    fn store_output_bytes(
6785        &mut self,
6786        vid: ValueId,
6787        dtype: DataType,
6788        dims: Vec<usize>,
6789        bytes: &[u8],
6790        resolved: &mut HashMap<ValueId, Vec<usize>>,
6791    ) -> Result<()> {
6792        let numel = checked_numel(&dims, || format!("value#{}", vid.0))?;
6793        let need =
6794            checked_storage_bytes(dtype, numel, || format!("value#{}", vid.0), &dims)?.max(1);
6795        let fits = self
6796            .buffers
6797            .get(&vid)
6798            .map(|b| b.len() == need)
6799            .unwrap_or(false);
6800        if !fits {
6801            if let Some(old) = self.buffers.remove(&vid) {
6802                self.ep.deallocate(old)?;
6803            }
6804            self.shared_buffers.remove(&vid);
6805            let buf = self
6806                .ep
6807                .allocate(need, TensorLayout::contiguous().alignment)?;
6808            self.buffers.insert(vid, buf);
6809        }
6810        let buf = self.buffers.get_mut(&vid).expect("just ensured");
6811        self.ep.copy_from_host(bytes, buf)?;
6812        self.value_dtypes.insert(vid, dtype);
6813        self.buffer_shapes.insert(vid, dims.clone());
6814        resolved.insert(vid, dims);
6815        Ok(())
6816    }
6817
6818    /// Prepare one selected control-flow subgraph and materialize only the free
6819    /// variables that body actually captures. This avoids copying every named
6820    /// value in the enclosing graph and, for Loop/Scan, keeps captures stable
6821    /// across all iterations.
6822    fn prepare_subgraph(
6823        &self,
6824        node_id: NodeId,
6825        attr_key: &str,
6826        resolved: &HashMap<ValueId, Vec<usize>>,
6827        outer_scope: &HashMap<String, Tensor>,
6828    ) -> Result<PreparedSubgraph> {
6829        let key = (node_id, attr_key.to_string());
6830        let body = self.graph.subgraphs.get(&key).ok_or_else(|| {
6831            SessionError::Internal(format!(
6832                "control-flow node #{} references missing subgraph '{attr_key}'",
6833                node_id.0
6834            ))
6835        })?;
6836
6837        let mut scope_names = required_outer_names(body).into_iter().collect::<Vec<_>>();
6838        scope_names.sort();
6839        let mut captures = HashMap::with_capacity(scope_names.len());
6840        for name in scope_names {
6841            let tensor = if let Some(&vid) = self.name_index.get(&name) {
6842                let materialized = self.buffers.contains_key(&vid)
6843                    || self.views.contains_key(&vid)
6844                    || self.seq_elem_values.contains_key(&vid);
6845                if resolved.contains_key(&vid) && materialized {
6846                    self.value_tensor(vid, resolved)?
6847                } else {
6848                    outer_scope
6849                        .get(&name)
6850                        .cloned()
6851                        .ok_or_else(|| missing_capture_error(attr_key, &name))?
6852                }
6853            } else {
6854                outer_scope
6855                    .get(&name)
6856                    .cloned()
6857                    .ok_or_else(|| missing_capture_error(attr_key, &name))?
6858            };
6859            captures.insert(name, tensor);
6860        }
6861
6862        Ok(PreparedSubgraph { key, captures })
6863    }
6864
6865    /// Run a prepared control-flow body with changing formal inputs. Captures and
6866    /// signature metadata are reused; only a concrete shape change rebuilds the
6867    /// child executor.
6868    fn run_subgraph(
6869        &mut self,
6870        prepared: &PreparedSubgraph,
6871        formal_inputs: &[&Tensor],
6872    ) -> Result<Vec<Tensor>> {
6873        if !self.subgraph_execs.contains_key(&prepared.key) {
6874            let body = self
6875                .graph
6876                .subgraphs
6877                .get(&prepared.key)
6878                .cloned()
6879                .ok_or_else(|| {
6880                    SessionError::Internal(format!(
6881                        "control-flow node #{} has no registered subgraph '{}'",
6882                        prepared.key.0.0, prepared.key.1
6883                    ))
6884                })?;
6885            let mut child = ChildExecutor::new(
6886                format!("node#{}/{}", prepared.key.0.0, prepared.key.1),
6887                body,
6888                self.graph.opset_imports.clone(),
6889                self.weights.clone(),
6890                self.ep.clone(),
6891            )?;
6892            child.set_trace_context(self.trace.clone());
6893            self.subgraph_execs.insert(prepared.key.clone(), child);
6894        }
6895
6896        let child = self
6897            .subgraph_execs
6898            .get_mut(&prepared.key)
6899            .expect("child present");
6900        let before = child.stats();
6901        let result = child.run(formal_inputs, &prepared.captures);
6902        let after = child.stats();
6903        self.control_flow_stats.subgraph_builds += after.builds - before.builds;
6904        self.control_flow_stats.subgraph_runs += after.runs - before.runs;
6905        result
6906    }
6907
6908    /// Dispatch a control-flow plan node to its op-specific handler.
6909    fn exec_control_flow(
6910        &mut self,
6911        pi: usize,
6912        resolved: &mut HashMap<ValueId, Vec<usize>>,
6913        outer_scope: &HashMap<String, Tensor>,
6914    ) -> Result<()> {
6915        let node = self.graph.node(self.plan[pi].node_id).clone();
6916        match node.op_type.as_str() {
6917            "If" => self.exec_if(&node, resolved, outer_scope),
6918            "Loop" => self.exec_loop(&node, resolved, outer_scope),
6919            "Scan" => self.exec_scan(&node, resolved, outer_scope),
6920            other => Err(SessionError::Internal(format!(
6921                "exec_control_flow reached non-control-flow op {other:?}"
6922            ))),
6923        }
6924    }
6925
6926    /// ONNX `If`: read the scalar `cond`, execute exactly one branch subgraph
6927    /// (0 formal inputs), and route the branch's outputs to `If`'s outputs.
6928    fn exec_if(
6929        &mut self,
6930        node: &Node,
6931        resolved: &mut HashMap<ValueId, Vec<usize>>,
6932        outer_scope: &HashMap<String, Tensor>,
6933    ) -> Result<()> {
6934        {
6935            let then_branch = self
6936                .graph
6937                .subgraphs
6938                .get(&(node.id, "then_branch".to_string()))
6939                .ok_or_else(|| SessionError::ControlFlow {
6940                    op: "If".to_string(),
6941                    reason: "missing required 'then_branch' subgraph".to_string(),
6942                })?;
6943            let else_branch = self
6944                .graph
6945                .subgraphs
6946                .get(&(node.id, "else_branch".to_string()))
6947                .ok_or_else(|| SessionError::ControlFlow {
6948                    op: "If".to_string(),
6949                    reason: "missing required 'else_branch' subgraph".to_string(),
6950                })?;
6951
6952            if !then_branch.inputs.is_empty() || !else_branch.inputs.is_empty() {
6953                return Err(SessionError::ControlFlow {
6954                    op: "If".to_string(),
6955                    reason: format!(
6956                        "branch subgraphs must declare zero formal inputs, but then_branch has {} \
6957                         and else_branch has {}",
6958                        then_branch.inputs.len(),
6959                        else_branch.inputs.len()
6960                    ),
6961                });
6962            }
6963            validate_if_branch_outputs(&self.graph, node)?;
6964        }
6965
6966        let cond_vid =
6967            node.inputs
6968                .first()
6969                .and_then(|s| *s)
6970                .ok_or_else(|| SessionError::ControlFlow {
6971                    op: "If".to_string(),
6972                    reason: "missing required 'cond' input".to_string(),
6973                })?;
6974        let cond_t = self.value_tensor(cond_vid, resolved)?;
6975        if cond_t.dtype != DataType::Bool {
6976            return Err(SessionError::DtypeMismatch {
6977                name: "If cond".to_string(),
6978                expected: format!("{:?}", DataType::Bool),
6979                got: format!("{:?}", cond_t.dtype),
6980            });
6981        }
6982        let cond = tensor_scalar_bool(&cond_t).ok_or_else(|| SessionError::ControlFlow {
6983            op: "If".to_string(),
6984            reason: format!(
6985                "'cond' must be a BOOL scalar or single-element tensor, got shape {:?}",
6986                cond_t.shape
6987            ),
6988        })?;
6989
6990        // Capture-safe loop-invariant control-flow specialization. The predicate
6991        // is read every step (above) so a genuine branch flip is never missed.
6992        // When it matches the last observed value AND that value was recorded
6993        // only for a branch with *no outer captures* (so its outputs depend on
6994        // nothing but its own constants/initializers and are therefore invariant
6995        // across decode steps) AND those outputs are still resident in their
6996        // persistent buffers, re-running the branch is pure waste — skip it. The
6997        // downstream captured segment reads the unchanged buffers correctly. A
6998        // branch that reads loop-varying outer values is never memoized, so a
6999        // stale output is impossible.
7000        if self.if_last_predicate.get(&node.id) == Some(&cond)
7001            && node.outputs.iter().all(|v| resolved.contains_key(v))
7002        {
7003            return Ok(());
7004        }
7005
7006        let attr_key = if cond { "then_branch" } else { "else_branch" };
7007        // A branch with outer captures may depend on values that change between
7008        // steps, so its output is not loop-invariant and must never be memoized.
7009        let taken_branch_is_invariant = self
7010            .graph
7011            .subgraphs
7012            .get(&(node.id, attr_key.to_string()))
7013            .map(|body| required_outer_names(body).is_empty())
7014            .unwrap_or(false);
7015        let prepared = {
7016            let _s = phase_span!("execif.prepare_subgraph");
7017            self.prepare_subgraph(node.id, attr_key, resolved, outer_scope)?
7018        };
7019        let outs = {
7020            let _s = phase_span!("execif.run_subgraph");
7021            self.run_subgraph(&prepared, &[])?
7022        };
7023
7024        if outs.len() != node.outputs.len() {
7025            return Err(SessionError::OutputShapeCountMismatch {
7026                op: format!("If/{attr_key}"),
7027                expected: node.outputs.len(),
7028                got: outs.len(),
7029            });
7030        }
7031        {
7032            let _s = phase_span!("execif.store_output");
7033            for (vid, t) in node.outputs.iter().zip(outs.iter()) {
7034                self.store_output_tensor(*vid, t, resolved)?;
7035            }
7036        }
7037        // Only enable future skips when the taken branch is loop-invariant.
7038        // Otherwise drop any stale memo so this `If` always re-runs.
7039        if taken_branch_is_invariant {
7040            self.if_last_predicate.insert(node.id, cond);
7041        } else {
7042            self.if_last_predicate.remove(&node.id);
7043        }
7044        Ok(())
7045    }
7046
7047    /// Validate a Loop body's positional contract before the first iteration and
7048    /// retain each scan output's element type/shape for the zero-iteration case.
7049    fn loop_body_scan_specs(
7050        &self,
7051        node: &Node,
7052        carried: &[Tensor],
7053        num_scan: usize,
7054        resolved: &HashMap<ValueId, Vec<usize>>,
7055    ) -> Result<OptionalTensorSpecs> {
7056        let body = self
7057            .graph
7058            .subgraphs
7059            .get(&(node.id, "body".to_string()))
7060            .ok_or_else(|| SessionError::ControlFlow {
7061                op: "Loop".to_string(),
7062                reason: "missing required 'body' subgraph".to_string(),
7063            })?;
7064        let expected_inputs = 2 + carried.len();
7065        if body.inputs.len() != expected_inputs {
7066            return Err(SessionError::ControlFlow {
7067                op: "Loop".to_string(),
7068                reason: format!(
7069                    "body declares {} formal input(s), expected {expected_inputs}",
7070                    body.inputs.len()
7071                ),
7072            });
7073        }
7074        let expected_outputs = 1 + carried.len() + num_scan;
7075        if body.outputs.len() != expected_outputs {
7076            return Err(SessionError::ControlFlow {
7077                op: "Loop".to_string(),
7078                reason: format!(
7079                    "body declares {} output(s), expected {expected_outputs}",
7080                    body.outputs.len()
7081                ),
7082            });
7083        }
7084
7085        for (index, expected) in [(0, DataType::Int64), (1, DataType::Bool)] {
7086            let input = body.inputs[index];
7087            if body.value_type_is_known(input) && body.value(input).dtype != expected {
7088                return Err(SessionError::ControlFlow {
7089                    op: "Loop".to_string(),
7090                    reason: format!(
7091                        "body formal input {index} must be {expected:?}, got {:?}",
7092                        body.value(input).dtype
7093                    ),
7094                });
7095            }
7096        }
7097        let cond_out = body.outputs[0];
7098        if body.value_type_is_known(cond_out) && body.value(cond_out).dtype != DataType::Bool {
7099            return Err(SessionError::ControlFlow {
7100                op: "Loop".to_string(),
7101                reason: format!(
7102                    "body output 0 ('cond_out') must be Bool, got {:?}",
7103                    body.value(cond_out).dtype
7104                ),
7105            });
7106        }
7107
7108        for (index, initial) in carried.iter().enumerate() {
7109            for (kind, value) in [
7110                ("formal input", body.inputs[2 + index]),
7111                ("output", body.outputs[1 + index]),
7112            ] {
7113                if body.value_type_is_known(value) && body.value(value).dtype != initial.dtype {
7114                    return Err(SessionError::ControlFlow {
7115                        op: "Loop".to_string(),
7116                        reason: format!(
7117                            "loop-carried {kind} {index} has dtype {:?}, but its initial value has \
7118                             dtype {:?}",
7119                            body.value(value).dtype,
7120                            initial.dtype
7121                        ),
7122                    });
7123                }
7124            }
7125        }
7126
7127        body.outputs
7128            .iter()
7129            .skip(1 + carried.len())
7130            .zip(node.outputs.iter().skip(carried.len()))
7131            .enumerate()
7132            .map(|(index, (&body_output, &node_output))| {
7133                let body_value = body.value(body_output);
7134                let node_dtype = self.value_dtypes[&node_output];
7135                let dtype = if body.value_type_is_known(body_output) {
7136                    if self.graph.value_type_is_known(node_output)
7137                        && body_value.dtype != node_dtype
7138                    {
7139                        return Err(SessionError::ControlFlow {
7140                            op: "Loop".to_string(),
7141                            reason: format!(
7142                                "scan output {index} has body dtype {:?}, but the Loop node declares \
7143                                 {node_dtype:?}",
7144                                body_value.dtype
7145                            ),
7146                        });
7147                    }
7148                    body_value.dtype
7149                } else {
7150                    node_dtype
7151                };
7152                let elem_shape = body
7153                    .value_shape_is_known(body_output)
7154                    .then(|| as_static_shape(&body_value.shape))
7155                    .flatten()
7156                    .or_else(|| {
7157                        resolved
7158                            .get(&node_output)
7159                            .and_then(|shape| shape.get(1..).map(<[_]>::to_vec))
7160                    });
7161                Ok(elem_shape.map(|shape| (dtype, shape)))
7162            })
7163            .collect()
7164    }
7165
7166    /// ONNX `Loop`: inputs `[M?, cond?, v_initial...]`, body signature
7167    /// `(iter_num, cond_in, carried...) -> (cond_out, carried..., scan_out...)`.
7168    /// Iterates while `cond` is true and `iter < M`, threading loop-carried
7169    /// values across iterations and stacking each scan output along a new
7170    /// leading iteration axis.
7171    fn exec_loop(
7172        &mut self,
7173        node: &Node,
7174        resolved: &mut HashMap<ValueId, Vec<usize>>,
7175        outer_scope: &HashMap<String, Tensor>,
7176    ) -> Result<()> {
7177        // Inputs: [M, cond, v_initial...]. M and cond may be omitted (None slot)
7178        // or an empty-name optional; absence means "unbounded" / "true".
7179        let m: Option<i64> = match node.inputs.first().and_then(|s| *s) {
7180            Some(vid) => {
7181                let t = self.value_tensor(vid, resolved)?;
7182                if t.dtype != DataType::Int64 {
7183                    return Err(SessionError::DtypeMismatch {
7184                        name: "Loop M".to_string(),
7185                        expected: format!("{:?}", DataType::Int64),
7186                        got: format!("{:?}", t.dtype),
7187                    });
7188                }
7189                let m = tensor_scalar_i64(&t).ok_or_else(|| SessionError::ControlFlow {
7190                    op: "Loop".to_string(),
7191                    reason: format!(
7192                        "'M' must be an INT64 scalar or single-element tensor, got shape {:?}",
7193                        t.shape
7194                    ),
7195                })?;
7196                Some(m)
7197            }
7198            None => None,
7199        };
7200        let mut cond: Option<bool> =
7201            match node.inputs.get(1).and_then(|s| *s) {
7202                Some(vid) => {
7203                    let t = self.value_tensor(vid, resolved)?;
7204                    if t.dtype != DataType::Bool {
7205                        return Err(SessionError::DtypeMismatch {
7206                            name: "Loop cond".to_string(),
7207                            expected: format!("{:?}", DataType::Bool),
7208                            got: format!("{:?}", t.dtype),
7209                        });
7210                    }
7211                    Some(tensor_scalar_bool(&t).ok_or_else(|| SessionError::ControlFlow {
7212                    op: "Loop".to_string(),
7213                    reason: format!(
7214                        "'cond' must be a BOOL scalar or single-element tensor, got shape {:?}",
7215                        t.shape
7216                    ),
7217                })?)
7218                }
7219                None => None,
7220            };
7221
7222        // Initial loop-carried dependencies (inputs after M and cond).
7223        let mut carried: Vec<Tensor> = Vec::new();
7224        for slot in node.inputs.iter().skip(2) {
7225            let vid = slot.ok_or_else(|| {
7226                SessionError::Internal(
7227                    "Loop: an interior loop-carried input is omitted (empty), which ONNX does not \
7228                 allow — every v_initial must be provided"
7229                        .to_string(),
7230                )
7231            })?;
7232            carried.push(self.value_tensor(vid, resolved)?);
7233        }
7234        let num_carried = carried.len();
7235        let carried_invariants: Vec<(DataType, Vec<usize>)> = carried
7236            .iter()
7237            .map(|tensor| (tensor.dtype, tensor.shape.clone()))
7238            .collect();
7239        // Loop outputs = carried finals ++ scan outputs. Scan-output count is
7240        // whatever remains after the carried finals.
7241        let num_outputs = node.outputs.len();
7242        if num_outputs < num_carried {
7243            return Err(SessionError::Internal(format!(
7244                "Loop: node declares {num_outputs} output(s) but has {num_carried} loop-carried \
7245                 dependency(ies); outputs must be carried-finals followed by scan-outputs"
7246            )));
7247        }
7248        let num_scan = num_outputs - num_carried;
7249        let empty_scan_specs = self.loop_body_scan_specs(node, &carried, num_scan, resolved)?;
7250        let mut scan_acc: Vec<TensorStackAccumulator> = (0..num_scan)
7251            .map(|_| TensorStackAccumulator::new())
7252            .collect();
7253        let prepared = self.prepare_subgraph(node.id, "body", resolved, outer_scope)?;
7254        let mut iter_tensor = scalar_i64_tensor(0)?;
7255        let mut cond_tensor = scalar_bool_tensor(cond.unwrap_or(true))?;
7256
7257        let mut iter: i64 = 0;
7258        loop {
7259            if let Some(m) = m
7260                && iter >= m
7261            {
7262                break;
7263            }
7264            if cond == Some(false) {
7265                break;
7266            }
7267
7268            iter_tensor.overwrite_bytes(&iter.to_le_bytes())?;
7269            cond_tensor.overwrite_bytes(&[u8::from(cond.unwrap_or(true))])?;
7270            let mut formal: Vec<&Tensor> = Vec::with_capacity(2 + num_carried);
7271            formal.push(&iter_tensor);
7272            formal.push(&cond_tensor);
7273            formal.extend(carried.iter());
7274
7275            let outs = self.run_subgraph(&prepared, &formal)?;
7276            drop(formal);
7277            // Body outputs: cond_out, carried..., scan_out...
7278            let expected = 1 + num_carried + num_scan;
7279            if outs.len() != expected {
7280                return Err(SessionError::OutputShapeCountMismatch {
7281                    op: "Loop/body".to_string(),
7282                    expected,
7283                    got: outs.len(),
7284                });
7285            }
7286            let mut it = outs.into_iter();
7287            let cond_out = it.next().expect("cond_out present");
7288            cond = Some(tensor_scalar_bool(&cond_out).ok_or_else(|| {
7289                SessionError::Internal(format!(
7290                    "Loop: body's first output 'cond_out' must be a BOOL scalar, got dtype {:?}",
7291                    cond_out.dtype
7292                ))
7293            })?);
7294            let next_carried: Vec<Tensor> = (&mut it).take(num_carried).collect();
7295            for (index, (tensor, (expected_dtype, expected_shape))) in
7296                next_carried.iter().zip(&carried_invariants).enumerate()
7297            {
7298                if tensor.dtype != *expected_dtype {
7299                    return Err(SessionError::ControlFlow {
7300                        op: "Loop".to_string(),
7301                        reason: format!(
7302                            "loop-carried output {index} dtype mismatch: expected \
7303                             {expected_dtype:?}, got {:?}",
7304                            tensor.dtype
7305                        ),
7306                    });
7307                }
7308                if tensor.shape != *expected_shape {
7309                    return Err(SessionError::ControlFlow {
7310                        op: "Loop".to_string(),
7311                        reason: format!(
7312                            "loop-carried output {index} shape mismatch: expected \
7313                             {expected_shape:?}, got {:?}",
7314                            tensor.shape
7315                        ),
7316                    });
7317                }
7318            }
7319            carried = next_carried;
7320            for acc in scan_acc.iter_mut() {
7321                acc.push(it.next().expect("scan output present"))?;
7322            }
7323
7324            iter = iter
7325                .checked_add(1)
7326                .ok_or_else(|| SessionError::ControlFlow {
7327                    op: "Loop".to_string(),
7328                    reason: "iteration counter overflowed INT64".to_string(),
7329                })?;
7330        }
7331
7332        // Emit outputs: carried finals, then stacked scan outputs.
7333        for (i, t) in carried.iter().enumerate() {
7334            self.store_output_tensor(node.outputs[i], t, resolved)?;
7335        }
7336        for (s, (acc, empty_spec)) in scan_acc.into_iter().zip(empty_scan_specs).enumerate() {
7337            let (dtype, shape, bytes) = acc.finish_with_empty(empty_spec, s)?;
7338            self.store_output_bytes(
7339                node.outputs[num_carried + s],
7340                dtype,
7341                shape,
7342                &bytes,
7343                resolved,
7344            )?;
7345        }
7346        Ok(())
7347    }
7348
7349    fn scan_body_specs(
7350        &self,
7351        node: &Node,
7352        state: &[Tensor],
7353        scan_inputs: &[Tensor],
7354        input_axes: &[usize],
7355        num_scan_outputs: usize,
7356        output_axes: &[i64],
7357        resolved: &HashMap<ValueId, Vec<usize>>,
7358    ) -> Result<OptionalTensorSpecs> {
7359        let body = self
7360            .graph
7361            .subgraphs
7362            .get(&(node.id, "body".to_string()))
7363            .ok_or_else(|| SessionError::ControlFlow {
7364                op: "Scan".to_string(),
7365                reason: "missing required 'body' subgraph".to_string(),
7366            })?;
7367        let expected_inputs = state.len() + scan_inputs.len();
7368        if body.inputs.len() != expected_inputs {
7369            return Err(SessionError::ControlFlow {
7370                op: "Scan".to_string(),
7371                reason: format!(
7372                    "body declares {} formal input(s), expected {expected_inputs}",
7373                    body.inputs.len()
7374                ),
7375            });
7376        }
7377        let expected_outputs = state.len() + num_scan_outputs;
7378        if body.outputs.len() != expected_outputs {
7379            return Err(SessionError::ControlFlow {
7380                op: "Scan".to_string(),
7381                reason: format!(
7382                    "body declares {} output(s), expected {expected_outputs}",
7383                    body.outputs.len()
7384                ),
7385            });
7386        }
7387
7388        for (index, initial) in state.iter().enumerate() {
7389            for (kind, value) in [
7390                ("formal input", body.inputs[index]),
7391                ("output", body.outputs[index]),
7392            ] {
7393                if body.value_type_is_known(value) && body.value(value).dtype != initial.dtype {
7394                    return Err(SessionError::ControlFlow {
7395                        op: "Scan".to_string(),
7396                        reason: format!(
7397                            "state {kind} {index} has dtype {:?}, but its initial value has dtype {:?}",
7398                            body.value(value).dtype,
7399                            initial.dtype
7400                        ),
7401                    });
7402                }
7403            }
7404        }
7405        for (index, ((input, &axis), &formal)) in scan_inputs
7406            .iter()
7407            .zip(input_axes)
7408            .zip(body.inputs.iter().skip(state.len()))
7409            .enumerate()
7410        {
7411            if body.value_type_is_known(formal) && body.value(formal).dtype != input.dtype {
7412                return Err(SessionError::ControlFlow {
7413                    op: "Scan".to_string(),
7414                    reason: format!(
7415                        "scan formal input {index} has dtype {:?}, but scan input {index} has dtype {:?}",
7416                        body.value(formal).dtype,
7417                        input.dtype
7418                    ),
7419                });
7420            }
7421            let mut slice_shape = input.shape.clone();
7422            slice_shape.remove(axis);
7423            if body.value_shape_is_known(formal)
7424                && let Some(shape) = as_static_shape(&body.value(formal).shape)
7425                && shape != slice_shape
7426            {
7427                return Err(SessionError::ControlFlow {
7428                    op: "Scan".to_string(),
7429                    reason: format!(
7430                        "scan formal input {index} has shape {shape:?}, but slicing input shape {:?} \
7431                         along axis {axis} produces {slice_shape:?}",
7432                        input.shape
7433                    ),
7434                });
7435            }
7436        }
7437
7438        body.outputs
7439            .iter()
7440            .skip(state.len())
7441            .zip(node.outputs.iter().skip(state.len()))
7442            .zip(output_axes)
7443            .enumerate()
7444            .map(|(index, ((&body_output, &node_output), &axis))| {
7445                let body_value = body.value(body_output);
7446                let node_dtype = self.value_dtypes[&node_output];
7447                let dtype = if body.value_type_is_known(body_output) {
7448                    if self.graph.value_type_is_known(node_output)
7449                        && body_value.dtype != node_dtype
7450                    {
7451                        return Err(SessionError::ControlFlow {
7452                            op: "Scan".to_string(),
7453                            reason: format!(
7454                                "scan output {index} has body dtype {:?}, but the Scan node declares \
7455                                 {node_dtype:?}",
7456                                body_value.dtype
7457                            ),
7458                        });
7459                    }
7460                    body_value.dtype
7461                } else {
7462                    node_dtype
7463                };
7464                let elem_shape = body
7465                    .value_shape_is_known(body_output)
7466                    .then(|| as_static_shape(&body_value.shape))
7467                    .flatten()
7468                    .or_else(|| {
7469                        resolved.get(&node_output).and_then(|shape| {
7470                            normalize_axis(axis, shape.len()).map(|axis| {
7471                                let mut elem_shape = shape.clone();
7472                                elem_shape.remove(axis);
7473                                elem_shape
7474                            })
7475                        })
7476                    });
7477                if let Some(shape) = &elem_shape
7478                    && normalize_axis(axis, shape.len() + 1).is_none()
7479                {
7480                    return Err(SessionError::ControlFlow {
7481                        op: "Scan".to_string(),
7482                        reason: format!(
7483                            "scan_output_axes[{index}]={axis} is out of range for output rank {}",
7484                            shape.len() + 1
7485                        ),
7486                    });
7487                }
7488                Ok(elem_shape.map(|shape| (dtype, shape)))
7489            })
7490            .collect()
7491    }
7492
7493    /// ONNX `Scan`: slice configured input axes/directions, thread invariant
7494    /// state through the body, and stack scan outputs on configured axes.
7495    fn exec_scan(
7496        &mut self,
7497        node: &Node,
7498        resolved: &mut HashMap<ValueId, Vec<usize>>,
7499        outer_scope: &HashMap<String, Tensor>,
7500    ) -> Result<()> {
7501        let raw_num_scan_inputs = node
7502            .attr("num_scan_inputs")
7503            .and_then(|a| a.as_int())
7504            .ok_or_else(|| SessionError::ControlFlow {
7505                op: "Scan".to_string(),
7506                reason: "required attribute 'num_scan_inputs' is missing or not an INT".to_string(),
7507            })?;
7508        let num_scan_inputs = usize::try_from(raw_num_scan_inputs)
7509            .ok()
7510            .filter(|&count| count != 0)
7511            .ok_or_else(|| SessionError::ControlFlow {
7512                op: "Scan".to_string(),
7513                reason: format!(
7514                    "'num_scan_inputs' must be a positive INT, got {raw_num_scan_inputs}"
7515                ),
7516            })?;
7517
7518        let total_inputs = node.inputs.len();
7519        if total_inputs < num_scan_inputs {
7520            return Err(SessionError::ControlFlow {
7521                op: "Scan".to_string(),
7522                reason: format!(
7523                    "node has {total_inputs} input(s) but num_scan_inputs={num_scan_inputs}"
7524                ),
7525            });
7526        }
7527        let num_state = total_inputs - num_scan_inputs;
7528        if node.outputs.len() < num_state {
7529            return Err(SessionError::ControlFlow {
7530                op: "Scan".to_string(),
7531                reason: format!(
7532                    "declares {} output(s) but has {num_state} state variable(s)",
7533                    node.outputs.len()
7534                ),
7535            });
7536        }
7537        let num_scan_outputs = node.outputs.len() - num_state;
7538        let input_axes_raw = scan_list_attr(node, "scan_input_axes", num_scan_inputs, 0)?;
7539        let input_directions = scan_list_attr(node, "scan_input_directions", num_scan_inputs, 0)?;
7540        let output_axes = scan_list_attr(node, "scan_output_axes", num_scan_outputs, 0)?;
7541        let output_directions =
7542            scan_list_attr(node, "scan_output_directions", num_scan_outputs, 0)?;
7543        for (name, values) in [
7544            ("scan_input_directions", &input_directions),
7545            ("scan_output_directions", &output_directions),
7546        ] {
7547            for (index, &value) in values.iter().enumerate() {
7548                if !matches!(value, 0 | 1) {
7549                    return Err(SessionError::ControlFlow {
7550                        op: "Scan".to_string(),
7551                        reason: format!(
7552                            "{name}[{index}] must be 0 (forward) or 1 (reverse), got {value}"
7553                        ),
7554                    });
7555                }
7556            }
7557        }
7558
7559        let mut state: Vec<Tensor> = Vec::with_capacity(num_state);
7560        for slot in node.inputs.iter().take(num_state) {
7561            let vid = slot.ok_or_else(|| SessionError::ControlFlow {
7562                op: "Scan".to_string(),
7563                reason: "an initial-state input is omitted (empty), which ONNX does not allow"
7564                    .to_string(),
7565            })?;
7566            state.push(self.value_tensor(vid, resolved)?);
7567        }
7568        let mut scan_inputs: Vec<Tensor> = Vec::with_capacity(num_scan_inputs);
7569        for slot in node.inputs.iter().skip(num_state) {
7570            let vid = slot.ok_or_else(|| SessionError::ControlFlow {
7571                op: "Scan".to_string(),
7572                reason: "a scan input is omitted (empty), which ONNX does not allow".to_string(),
7573            })?;
7574            scan_inputs.push(self.value_tensor(vid, resolved)?);
7575        }
7576
7577        let mut input_axes = Vec::with_capacity(num_scan_inputs);
7578        for (index, (input, &raw_axis)) in scan_inputs.iter().zip(&input_axes_raw).enumerate() {
7579            let axis = normalize_axis(raw_axis, input.shape.len()).ok_or_else(|| {
7580                SessionError::ControlFlow {
7581                    op: "Scan".to_string(),
7582                    reason: format!(
7583                        "scan_input_axes[{index}]={raw_axis} is out of range for input rank {}",
7584                        input.shape.len()
7585                    ),
7586                }
7587            })?;
7588            input_axes.push(axis);
7589        }
7590        let trip_count = scan_inputs[0].shape[input_axes[0]];
7591        for (index, (input, &axis)) in scan_inputs.iter().zip(&input_axes).enumerate() {
7592            let length = input.shape[axis];
7593            if length != trip_count {
7594                return Err(SessionError::ControlFlow {
7595                    op: "Scan".to_string(),
7596                    reason: format!(
7597                        "scan input {index} has scan-axis length {length}, but the first scan input \
7598                         has {trip_count}; all scan inputs must agree"
7599                    ),
7600                });
7601            }
7602        }
7603
7604        let state_specs: Vec<(DataType, Vec<usize>)> = state
7605            .iter()
7606            .map(|tensor| (tensor.dtype, tensor.shape.clone()))
7607            .collect();
7608        let empty_specs = self.scan_body_specs(
7609            node,
7610            &state,
7611            &scan_inputs,
7612            &input_axes,
7613            num_scan_outputs,
7614            &output_axes,
7615            resolved,
7616        )?;
7617        let mut scan_acc: Vec<TensorStackAccumulator> = (0..num_scan_outputs)
7618            .map(|_| TensorStackAccumulator::new())
7619            .collect();
7620        let prepared = self.prepare_subgraph(node.id, "body", resolved, outer_scope)?;
7621        let mut scan_slices = Vec::with_capacity(num_scan_inputs);
7622        if trip_count != 0 {
7623            for (index, ((input, &axis), &direction)) in scan_inputs
7624                .iter()
7625                .zip(&input_axes)
7626                .zip(&input_directions)
7627                .enumerate()
7628            {
7629                let source_index = if direction == 0 { 0 } else { trip_count - 1 };
7630                let (shape, bytes) = scan_slice(input, axis, source_index, index)?;
7631                scan_slices.push(Tensor::from_raw(input.dtype, shape, &bytes)?);
7632            }
7633        }
7634        for step in 0..trip_count {
7635            if step != 0 {
7636                for (index, (((input, &axis), &direction), slice)) in scan_inputs
7637                    .iter()
7638                    .zip(&input_axes)
7639                    .zip(&input_directions)
7640                    .zip(scan_slices.iter_mut())
7641                    .enumerate()
7642                {
7643                    let source_index = if direction == 0 {
7644                        step
7645                    } else {
7646                        trip_count - 1 - step
7647                    };
7648                    let (_, bytes) = scan_slice(input, axis, source_index, index)?;
7649                    slice.overwrite_bytes(&bytes)?;
7650                }
7651            }
7652            let mut formal: Vec<&Tensor> = Vec::with_capacity(num_state + num_scan_inputs);
7653            formal.extend(state.iter());
7654            formal.extend(scan_slices.iter());
7655
7656            let outs = self.run_subgraph(&prepared, &formal)?;
7657            drop(formal);
7658            let expected = num_state + num_scan_outputs;
7659            if outs.len() != expected {
7660                return Err(SessionError::OutputShapeCountMismatch {
7661                    op: "Scan/body".to_string(),
7662                    expected,
7663                    got: outs.len(),
7664                });
7665            }
7666            let mut it = outs.into_iter();
7667            let next_state: Vec<Tensor> = (&mut it).take(num_state).collect();
7668            for (index, (tensor, (expected_dtype, expected_shape))) in
7669                next_state.iter().zip(&state_specs).enumerate()
7670            {
7671                if tensor.dtype != *expected_dtype {
7672                    return Err(SessionError::ControlFlow {
7673                        op: "Scan".to_string(),
7674                        reason: format!(
7675                            "state output {index} dtype mismatch: expected {expected_dtype:?}, got {:?}",
7676                            tensor.dtype
7677                        ),
7678                    });
7679                }
7680                if tensor.shape != *expected_shape {
7681                    return Err(SessionError::ControlFlow {
7682                        op: "Scan".to_string(),
7683                        reason: format!(
7684                            "state output {index} shape mismatch: expected {expected_shape:?}, got {:?}",
7685                            tensor.shape
7686                        ),
7687                    });
7688                }
7689            }
7690            state = next_state;
7691            for acc in scan_acc.iter_mut() {
7692                acc.push(it.next().expect("scan output present"))?;
7693            }
7694        }
7695
7696        for (i, t) in state.iter().enumerate() {
7697            self.store_output_tensor(node.outputs[i], t, resolved)?;
7698        }
7699        for (s, ((acc, empty_spec), (&axis, &direction))) in scan_acc
7700            .into_iter()
7701            .zip(empty_specs)
7702            .zip(output_axes.iter().zip(&output_directions))
7703            .enumerate()
7704        {
7705            let (dtype, shape, bytes) = acc.finish_scan(axis, direction, empty_spec, s)?;
7706            self.store_output_bytes(node.outputs[num_state + s], dtype, shape, &bytes, resolved)?;
7707        }
7708        Ok(())
7709    }
7710}
7711
7712fn scan_slice(
7713    t: &Tensor,
7714    axis: usize,
7715    index: usize,
7716    input_index: usize,
7717) -> Result<(Vec<usize>, Vec<u8>)> {
7718    let axis_len = t.shape[axis];
7719    if index >= axis_len {
7720        return Err(SessionError::ControlFlow {
7721            op: "Scan".to_string(),
7722            reason: format!(
7723                "slice index {index} is out of range for scan input {input_index} axis {axis}"
7724            ),
7725        });
7726    }
7727    let esize = t.dtype.byte_size();
7728    if esize == 0 {
7729        return Err(SessionError::ControlFlow {
7730            op: "Scan".to_string(),
7731            reason: format!(
7732                "sub-byte dtype {:?} for scan input {input_index} is not supported",
7733                t.dtype
7734            ),
7735        });
7736    }
7737    let mut shape = t.shape.clone();
7738    shape.remove(axis);
7739    let outer = checked_numel(&t.shape[..axis], || format!("Scan input {input_index}"))?;
7740    let inner = checked_numel(&t.shape[axis + 1..], || format!("Scan input {input_index}"))?;
7741    let inner_bytes = checked_storage_bytes(
7742        t.dtype,
7743        inner,
7744        || format!("Scan input {input_index}"),
7745        &t.shape,
7746    )?;
7747    let total_bytes =
7748        outer
7749            .checked_mul(inner_bytes)
7750            .ok_or_else(|| SessionError::ShapeOverflow {
7751                value: format!("Scan input {input_index} slice"),
7752                dims: shape.clone(),
7753            })?;
7754    let source = t.as_bytes();
7755    let mut bytes = vec![0u8; total_bytes];
7756    for outer_index in 0..outer {
7757        let src = (outer_index * axis_len + index) * inner_bytes;
7758        let dst = outer_index * inner_bytes;
7759        bytes[dst..dst + inner_bytes].copy_from_slice(&source[src..src + inner_bytes]);
7760    }
7761    Ok((shape, bytes))
7762}
7763
7764/// Incremental accumulator for Loop/Scan outputs. Iteration tensors are copied
7765/// into one byte buffer and dropped; non-leading Scan axes are rearranged once
7766/// when the final tensor is materialized.
7767struct TensorStackAccumulator {
7768    dtype: Option<DataType>,
7769    elem_shape: Vec<usize>,
7770    len: usize,
7771    bytes: Vec<u8>,
7772}
7773
7774impl TensorStackAccumulator {
7775    fn new() -> Self {
7776        Self {
7777            dtype: None,
7778            elem_shape: Vec::new(),
7779            len: 0,
7780            bytes: Vec::new(),
7781        }
7782    }
7783
7784    fn push(&mut self, tensor: Tensor) -> Result<()> {
7785        if let Some(dtype) = self.dtype {
7786            if tensor.shape != self.elem_shape || tensor.dtype != dtype {
7787                return Err(SessionError::Internal(format!(
7788                    "Loop/Scan: scan output slice {} has shape {:?} dtype {:?} but the first slice \
7789                     is shape {:?} dtype {:?}; every iteration's scan output must match",
7790                    self.len, tensor.shape, tensor.dtype, self.elem_shape, dtype
7791                )));
7792            }
7793        } else {
7794            if tensor.dtype.byte_size() == 0 {
7795                return Err(SessionError::Internal(format!(
7796                    "Loop/Scan: sub-byte dtype {:?} scan outputs are not supported",
7797                    tensor.dtype
7798                )));
7799            }
7800            self.dtype = Some(tensor.dtype);
7801            self.elem_shape = tensor.shape.clone();
7802        }
7803        self.bytes.extend_from_slice(tensor.as_bytes());
7804        self.len += 1;
7805        Ok(())
7806    }
7807
7808    fn finish(self) -> (DataType, Vec<usize>, Vec<u8>) {
7809        if self.len == 0 {
7810            return (DataType::Float32, vec![0], Vec::new());
7811        }
7812        let dtype = self.dtype.expect("non-empty accumulator has dtype");
7813        let mut shape = Vec::with_capacity(1 + self.elem_shape.len());
7814        shape.push(self.len);
7815        shape.extend(self.elem_shape);
7816        (dtype, shape, self.bytes)
7817    }
7818
7819    fn finish_with_empty(
7820        self,
7821        empty_spec: Option<(DataType, Vec<usize>)>,
7822        output_index: usize,
7823    ) -> Result<(DataType, Vec<usize>, Vec<u8>)> {
7824        if self.len != 0 {
7825            return Ok(self.finish());
7826        }
7827        let (dtype, elem_shape) = empty_spec.ok_or_else(|| SessionError::ControlFlow {
7828            op: "Loop".to_string(),
7829            reason: format!(
7830                "cannot determine the element shape of scan output {output_index} for a \
7831                 zero-iteration result"
7832            ),
7833        })?;
7834        let mut shape = Vec::with_capacity(1 + elem_shape.len());
7835        shape.push(0);
7836        shape.extend(elem_shape);
7837        Ok((dtype, shape, Vec::new()))
7838    }
7839
7840    fn finish_scan(
7841        self,
7842        axis: i64,
7843        direction: i64,
7844        empty_spec: Option<(DataType, Vec<usize>)>,
7845        output_index: usize,
7846    ) -> Result<(DataType, Vec<usize>, Vec<u8>)> {
7847        let (dtype, elem_shape) = match self.dtype {
7848            Some(dtype) => (dtype, self.elem_shape.clone()),
7849            None => empty_spec.ok_or_else(|| SessionError::ControlFlow {
7850                op: "Scan".to_string(),
7851                reason: format!(
7852                    "cannot determine the element shape of scan output {output_index} for a \
7853                     zero-iteration result"
7854                ),
7855            })?,
7856        };
7857        let output_rank = elem_shape.len() + 1;
7858        let axis = normalize_axis(axis, output_rank).ok_or_else(|| SessionError::ControlFlow {
7859            op: "Scan".to_string(),
7860            reason: format!(
7861                "scan_output_axes[{output_index}]={axis} is out of range for output rank \
7862                 {output_rank}"
7863            ),
7864        })?;
7865        if self.len == 0 {
7866            let mut shape = elem_shape;
7867            shape.insert(axis, 0);
7868            return Ok((dtype, shape, Vec::new()));
7869        }
7870        if axis == 0 && direction == 0 {
7871            let mut shape = Vec::with_capacity(output_rank);
7872            shape.push(self.len);
7873            shape.extend(elem_shape);
7874            return Ok((dtype, shape, self.bytes));
7875        }
7876
7877        let elem_numel = checked_numel(&elem_shape, || {
7878            format!("Scan output {output_index} element")
7879        })?;
7880        let elem_bytes = checked_storage_bytes(
7881            dtype,
7882            elem_numel,
7883            || format!("Scan output {output_index} element"),
7884            &elem_shape,
7885        )?;
7886        let mut elements: Vec<&[u8]> = if elem_bytes == 0 {
7887            (0..self.len).map(|_| &self.bytes[..]).collect()
7888        } else {
7889            self.bytes.chunks_exact(elem_bytes).collect()
7890        };
7891        if direction == 1 {
7892            elements.reverse();
7893        }
7894        let (shape, bytes) = stack_new_axis(&elements, &elem_shape, axis, dtype.byte_size())?;
7895        Ok((dtype, shape, bytes))
7896    }
7897}
7898
7899impl Drop for Executor {
7900    fn drop(&mut self) {
7901        // Observability (F5): a one-line memo activity summary when
7902        // `ONNX_GENAI_DECODE_MEMO_STATS=1`, so an on-model A/B can confirm the
7903        // memo actually fired (`replayed>0`) rather than being silently gated out.
7904        if self.decode_memo_enabled
7905            && std::env::var("ONNX_GENAI_DECODE_MEMO_STATS")
7906                .map(|v| matches!(v.as_str(), "1" | "true" | "on"))
7907                .unwrap_or(false)
7908        {
7909            let (primed, rebuilt, replayed, ineligible) = self.decode_memo_counts();
7910            let (views_reused, dispatch_elided) = self.decode_view_plan_counts();
7911            eprintln!(
7912                "[decode-memo] primed={primed} rebuilt={rebuilt} replayed={replayed} \
7913                 ineligible={ineligible} views_reused={views_reused} \
7914                 dispatch_elided={dispatch_elided}"
7915            );
7916        }
7917        let _ = self.ep.reset_device_graph();
7918        self.device_graph_signature = None;
7919        // Free every buffer via the owning EP (DeviceBuffer has no Drop).
7920        for (_, buf) in self.buffers.drain() {
7921            let _ = self.ep.deallocate(buf);
7922        }
7923        self.shared_buffers.clear();
7924    }
7925}
7926
7927/// Instantiate and initialize the Phase-1 CPU execution provider (§20.7,
7928/// CPU-only auto-detection). A GPU/accelerator EP would be prepended here in a
7929/// later phase; for Phase 1 the CPU EP is the sole, always-available backend.
7930pub(crate) fn auto_detect_cpu_ep() -> Result<Arc<dyn ExecutionProvider>> {
7931    let mut ep = CpuExecutionProvider::new();
7932    ep.initialize(&Default::default())?;
7933    Ok(Arc::new(ep))
7934}
7935
7936#[cfg(test)]
7937mod tests {
7938    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7939
7940    use onnx_runtime_ep_api::{
7941        CaptureSupport, Cost, EpConfig, EpError, ExecutionProviderCapabilities, Fence, Kernel,
7942        NegotiatedWeight,
7943    };
7944
7945    use super::*;
7946
7947    #[test]
7948    fn phase_profile_gating_and_accumulation() {
7949        // Single test (not two) so the process-global enable flag is never
7950        // toggled concurrently by a sibling test under the parallel runner.
7951
7952        // Disabled: a span records nothing and never captures a timestamp.
7953        phase_profile::force_enabled(false);
7954        let disabled_phase = "test.phase.disabled";
7955        let before = phase_profile::snapshot(disabled_phase);
7956        {
7957            let _s = phase_span!(disabled_phase);
7958            std::thread::sleep(std::time::Duration::from_millis(1));
7959        }
7960        assert_eq!(
7961            phase_profile::snapshot(disabled_phase),
7962            before,
7963            "a disabled phase span must not accumulate any samples"
7964        );
7965
7966        // Enabled: a span accumulates exactly one positive-duration sample.
7967        phase_profile::force_enabled(true);
7968        let enabled_phase = "test.phase.enabled";
7969        let (base_ns, base_count) = phase_profile::snapshot(enabled_phase).unwrap_or((0, 0));
7970        {
7971            let _s = phase_span!(enabled_phase);
7972            std::thread::sleep(std::time::Duration::from_millis(2));
7973        }
7974        let (after_ns, after_count) =
7975            phase_profile::snapshot(enabled_phase).expect("enabled span must record a sample");
7976        assert_eq!(after_count, base_count + 1, "one span => one sample");
7977        assert!(
7978            after_ns > base_ns,
7979            "an enabled span must accumulate a positive duration"
7980        );
7981
7982        // Restore the default (disabled) state so other tests stay inert.
7983        phase_profile::force_enabled(false);
7984    }
7985
7986    // A produced top-level output is handed to the caller by moving its host
7987    // buffer out of the executor (zero-copy), while a producer-less output (an
7988    // initializer routed straight to a graph output) stays on the copy path so
7989    // its borrowed/shared storage is never freed. Repeated runs must keep
7990    // producing correct bytes even though the produced buffer was moved out and
7991    // its `buffer_shapes` entry cleared (forcing a fresh allocation each run).
7992    #[test]
7993    fn zero_copy_output_move_reallocates_and_preserves_producer_less_output() {
7994        use onnx_runtime_ir::TensorData;
7995        let mut graph = Graph::new();
7996        graph.opset_imports.insert(String::new(), 17);
7997
7998        let a = graph.create_named_value("a", DataType::Float32, static_shape([3]));
7999        let b = graph.create_named_value("b", DataType::Float32, static_shape([3]));
8000        graph.add_input(a);
8001        graph.add_input(b);
8002
8003        // Producer-less graph output: an initializer wired straight to an output.
8004        let k = graph.create_named_value("k", DataType::Float32, static_shape([3]));
8005        graph.set_initializer(
8006            k,
8007            WeightRef::Inline(TensorData::from_raw(
8008                DataType::Float32,
8009                vec![3],
8010                [100.0f32, 200.0, 300.0]
8011                    .into_iter()
8012                    .flat_map(f32::to_le_bytes)
8013                    .collect(),
8014            )),
8015        );
8016
8017        // Produced output: Add(a, b). This is the movable, owned, host output.
8018        let sum = graph.create_named_value("sum", DataType::Float32, static_shape([3]));
8019        graph.insert_node(Node::new(
8020            NodeId(0),
8021            "Add",
8022            vec![Some(a), Some(b)],
8023            vec![sum],
8024        ));
8025        graph.add_output(sum);
8026        graph.add_output(k);
8027
8028        let mut executor = Executor::build(
8029            graph,
8030            Arc::new(WeightStore::new()),
8031            auto_detect_cpu_ep().unwrap(),
8032        )
8033        .unwrap();
8034
8035        let a_val = Tensor::from_f32(&[3], &[1.0, 2.0, 3.0]).unwrap();
8036        let b_val = Tensor::from_f32(&[3], &[10.0, 20.0, 30.0]).unwrap();
8037
8038        // Three runs prove the move-out survives buffer reallocation: run 1 moves
8039        // `sum`'s buffer out, so runs 2 and 3 must reallocate it from scratch.
8040        for _ in 0..3 {
8041            let outputs = executor
8042                .run(&[("a", &a_val), ("b", &b_val)])
8043                .expect("run must succeed after a prior output buffer was moved out");
8044            assert_eq!(outputs[0].to_vec_f32(), vec![11.0, 22.0, 33.0]);
8045            assert_eq!(
8046                outputs[1].to_vec_f32(),
8047                vec![100.0, 200.0, 300.0],
8048                "producer-less initializer output must stay intact across runs"
8049            );
8050            // The produced output was handed off zero-copy: its buffer is gone
8051            // from the executor. The initializer stays resident (copy path).
8052            assert!(
8053                !executor.buffers.contains_key(&sum),
8054                "produced output buffer must be moved out, not copied"
8055            );
8056            assert!(
8057                executor.buffers.contains_key(&k),
8058                "producer-less output must not have its buffer stolen"
8059            );
8060        }
8061    }
8062
8063    struct CaptureDecliningKernel;
8064
8065    impl Kernel for CaptureDecliningKernel {
8066        fn execute(
8067            &self,
8068            _inputs: &[TensorView],
8069            _outputs: &mut [TensorMut],
8070        ) -> onnx_runtime_ep_api::Result<()> {
8071            Ok(())
8072        }
8073
8074        fn capture_support(&self) -> CaptureSupport {
8075            CaptureSupport::unsupported(
8076                "requires M==1 decode GEMV without group_indices; got a prefill signature",
8077            )
8078        }
8079    }
8080
8081    #[test]
8082    fn kernel_capture_reason_propagates_into_structured_report() {
8083        let mut node = Node::new(NodeId(9), "MatMulNBits", vec![], vec![]);
8084        node.domain = "com.microsoft".to_string();
8085        let decline =
8086            kernel_capture_decline(node.id, &node, &CaptureDecliningKernel).expect("decline");
8087        let report = CaptureDeclineReport::one(decline);
8088
8089        assert_eq!(
8090            report.entries,
8091            vec![CaptureDecline {
8092                node_id: Some(9),
8093                op_type: "MatMulNBits".to_string(),
8094                domain: "com.microsoft".to_string(),
8095                reason: "requires M==1 decode GEMV without group_indices; got a prefill signature"
8096                    .to_string(),
8097                seam_reason: Some(SeamReason::KernelCaptureUnsupported),
8098            }]
8099        );
8100        assert!(report.to_string().contains("node 9"));
8101        assert!(
8102            report
8103                .to_string()
8104                .contains("requires M==1 decode GEMV without group_indices")
8105        );
8106    }
8107
8108    #[test]
8109    fn seam_reasons_map_to_structural_capture_paths() {
8110        let cases = [
8111            (
8112                SeamReason::HostControlFlowOrSequence,
8113                CapturePathKind::HostSeam,
8114                "host-seam",
8115            ),
8116            (
8117                SeamReason::UnresolvedOutputShape,
8118                CapturePathKind::EagerDeviceSeam,
8119                "eager-device-seam",
8120            ),
8121            (
8122                SeamReason::UnresolvedInputShape,
8123                CapturePathKind::EagerDeviceSeam,
8124                "eager-device-seam",
8125            ),
8126            (
8127                SeamReason::KernelNotWarmed,
8128                CapturePathKind::EagerDeviceSeam,
8129                "eager-device-seam",
8130            ),
8131            (
8132                SeamReason::KernelCaptureUnsupported,
8133                CapturePathKind::EagerDeviceSeam,
8134                "eager-device-seam",
8135            ),
8136        ];
8137
8138        for (reason, expected_kind, expected_label) in cases {
8139            assert_eq!(reason.path_kind(), expected_kind);
8140            assert_eq!(reason.label(), expected_label);
8141        }
8142        assert_eq!(CapturePathKind::CaptureRegion.label(), "capture-region");
8143    }
8144
8145    #[test]
8146    fn ep_structural_plan_plus_executor_kernel_checks_matches_legacy_declines() {
8147        use onnx_runtime_ir::static_shape;
8148
8149        fn legacy_node_capture_reason(
8150            executor: &Executor,
8151            plan: &NodePlan,
8152            resolved: &HashMap<ValueId, Vec<usize>>,
8153        ) -> Option<CaptureDecline> {
8154            let node = executor.graph.node(plan.node_id);
8155            if is_control_flow_op(&node.op_type, &node.domain)
8156                || is_sequence_op(&node.op_type, &node.domain)
8157            {
8158                return Some(CaptureDecline::node(
8159                    plan.node_id,
8160                    node,
8161                    SeamReason::HostControlFlowOrSequence,
8162                    "control-flow and sequence nodes are not device-graph capturable",
8163                ));
8164            }
8165            if plan
8166                .outputs
8167                .iter()
8168                .any(|output| !resolved.contains_key(output))
8169            {
8170                return Some(CaptureDecline::node(
8171                    plan.node_id,
8172                    node,
8173                    SeamReason::UnresolvedOutputShape,
8174                    "data-dependent output shape was unresolved before capture",
8175                ));
8176            }
8177            let Some(input_shapes) = plan
8178                .inputs
8179                .iter()
8180                .map(|input| {
8181                    input
8182                        .map(|value| resolved.get(&value).cloned())
8183                        .unwrap_or(Some(Vec::new()))
8184                })
8185                .collect::<Option<Vec<_>>>()
8186            else {
8187                return Some(CaptureDecline::node(
8188                    plan.node_id,
8189                    node,
8190                    SeamReason::UnresolvedInputShape,
8191                    "data-dependent input shape was unresolved before capture",
8192                ));
8193            };
8194            let key = KernelKey {
8195                node: plan.node_id.0,
8196                shapes: input_shapes,
8197            };
8198            let Some(kernel) = executor.cache.entries.get(&key) else {
8199                return Some(CaptureDecline::node(
8200                    plan.node_id,
8201                    node,
8202                    SeamReason::KernelNotWarmed,
8203                    "kernel has not been warmed for the requested capture shape",
8204                ));
8205            };
8206            kernel_capture_decline(plan.node_id, node, kernel.as_ref())
8207        }
8208
8209        let mut graph = Graph::new();
8210        graph.opset_imports.insert(String::new(), 17);
8211        for index in 0..6 {
8212            let input = graph.create_named_value(
8213                format!("input_{index}"),
8214                DataType::Float32,
8215                static_shape([1]),
8216            );
8217            let output = graph.create_named_value(
8218                format!("output_{index}"),
8219                DataType::Float32,
8220                static_shape([1]),
8221            );
8222            graph.add_input(input);
8223            graph.add_output(output);
8224            graph.insert_node(Node::new(
8225                NodeId(0),
8226                "Identity",
8227                vec![Some(input)],
8228                vec![output],
8229            ));
8230        }
8231
8232        let mut executor = Executor::build(
8233            graph,
8234            Arc::new(WeightStore::new()),
8235            auto_detect_cpu_ep().expect("CPU EP"),
8236        )
8237        .expect("representative static graph");
8238        let mut resolved = executor
8239            .value_shapes
8240            .iter()
8241            .filter_map(|(&value, shape)| as_static_shape(shape).map(|shape| (value, shape)))
8242            .collect::<HashMap<_, _>>();
8243        let keys = executor
8244            .plan
8245            .iter()
8246            .map(|plan| KernelKey {
8247                node: plan.node_id.0,
8248                shapes: plan
8249                    .inputs
8250                    .iter()
8251                    .map(|input| {
8252                        input
8253                            .map(|value| resolved[&value].clone())
8254                            .unwrap_or_default()
8255                    })
8256                    .collect(),
8257            })
8258            .collect::<Vec<_>>();
8259
8260        executor.graph.node_mut(executor.plan[0].node_id).op_type = "If".to_string();
8261        resolved.remove(&executor.plan[0].outputs[0]);
8262        resolved.remove(&executor.plan[0].inputs[0].expect("present input"));
8263        resolved.remove(&executor.plan[1].outputs[0]);
8264        resolved.remove(&executor.plan[1].inputs[0].expect("present input"));
8265        resolved.remove(&executor.plan[2].inputs[0].expect("present input"));
8266        executor.cache.entries.remove(&keys[3]);
8267        executor
8268            .cache
8269            .entries
8270            .insert(keys[4].clone(), Box::new(CaptureDecliningKernel));
8271
8272        let legacy = executor
8273            .plan
8274            .iter()
8275            .map(|plan| legacy_node_capture_reason(&executor, plan, &resolved))
8276            .collect::<Vec<_>>();
8277        let refactored = executor
8278            .plan
8279            .iter()
8280            .map(|plan| executor.node_capture_reason(plan, &resolved))
8281            .collect::<Vec<_>>();
8282
8283        assert_eq!(refactored, legacy);
8284        assert_eq!(
8285            refactored
8286                .iter()
8287                .map(|decline| decline.as_ref().and_then(|decline| decline.seam_reason))
8288                .collect::<Vec<_>>(),
8289            vec![
8290                Some(SeamReason::HostControlFlowOrSequence),
8291                Some(SeamReason::UnresolvedOutputShape),
8292                Some(SeamReason::UnresolvedInputShape),
8293                Some(SeamReason::KernelNotWarmed),
8294                Some(SeamReason::KernelCaptureUnsupported),
8295                None,
8296            ]
8297        );
8298    }
8299
8300    #[test]
8301    fn capture_shapes_seed_unresolved_external_values_without_overwriting_resolved_shapes() {
8302        let external_value = |shape| ExternalValue {
8303            dtype: DataType::Float32,
8304            shape,
8305            accepts_subshape: false,
8306            ptr: std::ptr::null_mut(),
8307            len: 0,
8308            alignment: 1,
8309            device: onnx_runtime_ir::DeviceId::cpu(),
8310        };
8311        let mut external = ExternalBindings::default();
8312        external
8313            .inputs
8314            .insert(ValueId(0), external_value(vec![1, 2]));
8315        external
8316            .outputs
8317            .insert(ValueId(1), external_value(vec![1, 4, 128, 64]));
8318        external
8319            .outputs
8320            .insert(ValueId(2), external_value(vec![1, 4, 128, 64]));
8321
8322        let mut resolved = HashMap::from([(ValueId(0), vec![1, 1])]);
8323        external.seed_capture_shapes(&mut resolved);
8324
8325        assert_eq!(resolved[&ValueId(0)], vec![1, 1]);
8326        assert_eq!(resolved[&ValueId(1)], vec![1, 4, 128, 64]);
8327        assert_eq!(resolved[&ValueId(2)], vec![1, 4, 128, 64]);
8328    }
8329
8330    #[test]
8331    fn only_gqa_cache_inputs_use_physical_capacity_as_kernel_geometry() {
8332        let mut gqa = Node::new(NodeId(0), "GroupQueryAttention", vec![], vec![]);
8333        gqa.domain = "com.microsoft".to_string();
8334        let attention = Node::new(NodeId(1), "Attention", vec![], vec![]);
8335
8336        assert!(kernel_input_uses_physical_capacity(&gqa, 3));
8337        assert!(kernel_input_uses_physical_capacity(&gqa, 4));
8338        assert!(!kernel_input_uses_physical_capacity(&gqa, 0));
8339        assert!(!kernel_input_uses_physical_capacity(&attention, 4));
8340    }
8341
8342    #[test]
8343    fn only_capacity_aware_inputs_keep_physical_capacity() {
8344        let shape = Node::new(NodeId(0), "Shape", vec![], vec![]);
8345        let reduce_sum = Node::new(NodeId(1), "ReduceSum", vec![], vec![]);
8346        let cumsum = Node::new(NodeId(2), "CumSum", vec![], vec![]);
8347        let unsqueeze = Node::new(NodeId(3), "Unsqueeze", vec![], vec![]);
8348
8349        assert!(kernel_input_uses_padded_capacity(&shape, 0));
8350        assert!(kernel_input_uses_padded_capacity(&reduce_sum, 0));
8351        assert!(!kernel_input_uses_padded_capacity(&cumsum, 0));
8352        assert!(!kernel_input_uses_padded_capacity(&unsqueeze, 0));
8353        assert!(!kernel_input_uses_padded_capacity(&shape, 1));
8354
8355        // GLM-5.2's `indexer` attention branch consumes the attention mask
8356        // through elementwise arithmetic (a `Cast`→`Add` that combines a
8357        // logical-width indexer score with the mask). Such a consumer is NOT
8358        // padded-capacity-safe: it must observe the logical valid length, or the
8359        // padded physical capacity (`max_len`) leaks into the `Add` and fails to
8360        // broadcast against the logical-width score. Because it is not in the
8361        // Shape/ReduceSum allowlist, the binding is forced to expose its logical
8362        // prefix — which is exactly what fixes the GLM-5.2 decode broadcast bug.
8363        let indexer_add = Node::new(NodeId(4), "Add", vec![], vec![]);
8364        let indexer_cast = Node::new(NodeId(5), "Cast", vec![], vec![]);
8365        assert!(!kernel_input_uses_padded_capacity(&indexer_add, 0));
8366        assert!(!kernel_input_uses_padded_capacity(&indexer_cast, 0));
8367    }
8368
8369    struct WeightDeliveryKernel {
8370        deliveries: Arc<std::sync::Mutex<Vec<&'static str>>>,
8371    }
8372
8373    impl WeightDeliveryKernel {
8374        fn copy_bytes(bytes: &[u8], output: &mut TensorMut<'_>) -> onnx_runtime_ep_api::Result<()> {
8375            if bytes.len() != output.byte_size() {
8376                return Err(EpError::KernelFailed(
8377                    "test output byte count mismatch".into(),
8378                ));
8379            }
8380            // SAFETY: the executor bounds-checked and exclusively borrowed the
8381            // output allocation, which is exactly `output.byte_size()` bytes.
8382            unsafe {
8383                std::ptr::copy_nonoverlapping(
8384                    bytes.as_ptr(),
8385                    output.data.0.cast::<u8>(),
8386                    bytes.len(),
8387                );
8388            }
8389            Ok(())
8390        }
8391    }
8392
8393    impl Kernel for WeightDeliveryKernel {
8394        fn execute(
8395            &self,
8396            inputs: &[TensorView],
8397            outputs: &mut [TensorMut],
8398        ) -> onnx_runtime_ep_api::Result<()> {
8399            self.deliveries.lock().unwrap().push("resident");
8400            let bytes = unsafe {
8401                std::slice::from_raw_parts(inputs[0].data_ptr::<u8>(), inputs[0].byte_size())
8402            };
8403            Self::copy_bytes(bytes, &mut outputs[0])
8404        }
8405
8406        fn execute_with_inputs(
8407            &self,
8408            inputs: &[KernelInput<'_>],
8409            outputs: &mut [TensorMut],
8410        ) -> onnx_runtime_ep_api::Result<()> {
8411            match &inputs[0] {
8412                KernelInput::Tensor(view) => self.execute(std::slice::from_ref(view), outputs),
8413                KernelInput::Weight(handle) => {
8414                    self.deliveries.lock().unwrap().push("lazy");
8415                    let NegotiatedWeight::Lazy(lazy) =
8416                        handle.negotiate(&ExecutionProviderCapabilities::nxrt_weight_paging())?
8417                    else {
8418                        return Err(EpError::KernelFailed(
8419                            "nxrt test EP expected a lazy WeightHandle".into(),
8420                        ));
8421                    };
8422                    let resident = lazy.materialize()?;
8423                    Self::copy_bytes(resident.bytes(), &mut outputs[0])
8424                }
8425            }
8426        }
8427    }
8428
8429    struct WeightDeliveryEp {
8430        cpu: CpuExecutionProvider,
8431        lazy: bool,
8432        optional_input_contract: bool,
8433        deliveries: Arc<std::sync::Mutex<Vec<&'static str>>>,
8434        device: onnx_runtime_ir::DeviceId,
8435        allocations: Arc<AtomicUsize>,
8436        host_uploads: Arc<AtomicUsize>,
8437    }
8438
8439    impl WeightDeliveryEp {
8440        fn new(lazy: bool, deliveries: Arc<std::sync::Mutex<Vec<&'static str>>>) -> Self {
8441            Self::with_device(
8442                lazy,
8443                deliveries,
8444                onnx_runtime_ir::DeviceId::cpu(),
8445                Arc::new(AtomicUsize::new(0)),
8446                Arc::new(AtomicUsize::new(0)),
8447            )
8448        }
8449
8450        fn non_host(
8451            lazy: bool,
8452            deliveries: Arc<std::sync::Mutex<Vec<&'static str>>>,
8453            allocations: Arc<AtomicUsize>,
8454            host_uploads: Arc<AtomicUsize>,
8455        ) -> Self {
8456            Self::with_device(
8457                lazy,
8458                deliveries,
8459                onnx_runtime_ir::DeviceId::new(onnx_runtime_ir::DeviceType::Custom(7), 0),
8460                allocations,
8461                host_uploads,
8462            )
8463        }
8464
8465        fn with_device(
8466            lazy: bool,
8467            deliveries: Arc<std::sync::Mutex<Vec<&'static str>>>,
8468            device: onnx_runtime_ir::DeviceId,
8469            allocations: Arc<AtomicUsize>,
8470            host_uploads: Arc<AtomicUsize>,
8471        ) -> Self {
8472            let mut cpu = CpuExecutionProvider::new();
8473            cpu.initialize(&EpConfig::default()).unwrap();
8474            Self {
8475                cpu,
8476                lazy,
8477                optional_input_contract: false,
8478                deliveries,
8479                device,
8480                allocations,
8481                host_uploads,
8482            }
8483        }
8484
8485        fn copy_bytes(
8486            &self,
8487            src: *const u8,
8488            dst: *mut u8,
8489            size: usize,
8490        ) -> onnx_runtime_ep_api::Result<()> {
8491            if size != 0 {
8492                // The test EP tags host allocations as a non-host custom device
8493                // so executor placement is realistic while bytes stay inspectable.
8494                unsafe { std::ptr::copy_nonoverlapping(src, dst, size) };
8495            }
8496            Ok(())
8497        }
8498    }
8499
8500    impl ExecutionProvider for WeightDeliveryEp {
8501        fn name(&self) -> &str {
8502            if self.lazy {
8503                "nxrt_test_ep"
8504            } else {
8505                "stock_test_ep"
8506            }
8507        }
8508
8509        fn device_type(&self) -> onnx_runtime_ir::DeviceType {
8510            self.device.device_type
8511        }
8512
8513        fn device_id(&self) -> onnx_runtime_ir::DeviceId {
8514            self.device
8515        }
8516
8517        fn capabilities(&self) -> ExecutionProviderCapabilities {
8518            if self.lazy {
8519                ExecutionProviderCapabilities::nxrt_weight_paging()
8520            } else {
8521                ExecutionProviderCapabilities::stock()
8522            }
8523        }
8524
8525        fn initialize(&mut self, _config: &EpConfig) -> onnx_runtime_ep_api::Result<()> {
8526            Ok(())
8527        }
8528
8529        fn shutdown(&mut self) -> onnx_runtime_ep_api::Result<()> {
8530            Ok(())
8531        }
8532
8533        fn supports_op(
8534            &self,
8535            op: &Node,
8536            opset: u64,
8537            _shapes: &[Shape],
8538            input_dtypes: &[DataType],
8539            _layouts: &[TensorLayout],
8540        ) -> KernelMatch {
8541            if self.optional_input_contract && op.op_type == "OptionalContract" {
8542                if input_dtypes == [DataType::Float32, DataType::Undefined, DataType::Bool] {
8543                    return KernelMatch::Supported {
8544                        cost: Cost::ZERO,
8545                        required_input_layouts: None,
8546                        output_layouts: vec![TensorLayout::contiguous()],
8547                    };
8548                }
8549                return KernelMatch::unsupported(format!(
8550                    "OptionalContract requires [Float32, Undefined, Bool] input dtypes, got {input_dtypes:?}"
8551                ));
8552            }
8553            if LazyWeightBoundary::BlockQuantizedMoe.matches(&op.domain, &op.op_type)
8554                || (op.is_default_domain() && op.op_type == "Identity")
8555            {
8556                KernelMatch::Supported {
8557                    cost: Cost::ZERO,
8558                    required_input_layouts: None,
8559                    output_layouts: vec![TensorLayout::contiguous()],
8560                }
8561            } else {
8562                KernelMatch::unsupported(format!(
8563                    "no handler for {}::{} at opset {opset} — test EP intentionally declines this op",
8564                    canonical_domain(op),
8565                    op.op_type
8566                ))
8567            }
8568        }
8569
8570        fn get_kernel(
8571            &self,
8572            _op: &Node,
8573            _shapes: &[Vec<usize>],
8574            _opset: u64,
8575        ) -> onnx_runtime_ep_api::Result<Box<dyn Kernel>> {
8576            Ok(Box::new(WeightDeliveryKernel {
8577                deliveries: Arc::clone(&self.deliveries),
8578            }))
8579        }
8580
8581        fn allocate(
8582            &self,
8583            size: usize,
8584            alignment: usize,
8585        ) -> onnx_runtime_ep_api::Result<DeviceBuffer> {
8586            self.allocations.fetch_add(1, Ordering::Relaxed);
8587            if self.device.is_host_accessible() {
8588                return self.cpu.allocate(size, alignment);
8589            }
8590            let layout = std::alloc::Layout::from_size_align(size.max(1), alignment)
8591                .map_err(|_| EpError::AlignmentError)?;
8592            let ptr = unsafe { std::alloc::alloc(layout) };
8593            if ptr.is_null() {
8594                return Err(EpError::OutOfMemory {
8595                    requested: size,
8596                    available: 0,
8597                });
8598            }
8599            Ok(unsafe { DeviceBuffer::from_raw_parts(ptr.cast(), self.device, size, alignment) })
8600        }
8601
8602        fn deallocate(&self, buffer: DeviceBuffer) -> onnx_runtime_ep_api::Result<()> {
8603            if self.device.is_host_accessible() {
8604                return self.cpu.deallocate(buffer);
8605            }
8606            let size = buffer.len();
8607            let alignment = buffer.alignment();
8608            let ptr = buffer.into_raw().cast::<u8>();
8609            let layout = std::alloc::Layout::from_size_align(size.max(1), alignment)
8610                .expect("test EP allocated this layout");
8611            unsafe { std::alloc::dealloc(ptr, layout) };
8612            Ok(())
8613        }
8614
8615        fn copy(
8616            &self,
8617            src: &DeviceBuffer,
8618            dst: &mut DeviceBuffer,
8619            size: usize,
8620        ) -> onnx_runtime_ep_api::Result<()> {
8621            if size > src.len() || size > dst.len() {
8622                return Err(EpError::KernelFailed("test EP copy out of bounds".into()));
8623            }
8624            self.copy_bytes(src.as_ptr().cast(), dst.as_mut_ptr().cast(), size)
8625        }
8626
8627        fn copy_async(
8628            &self,
8629            src: &DeviceBuffer,
8630            dst: &mut DeviceBuffer,
8631            size: usize,
8632        ) -> onnx_runtime_ep_api::Result<Fence> {
8633            self.copy(src, dst, size)?;
8634            Ok(Fence::default())
8635        }
8636
8637        fn sync(&self) -> onnx_runtime_ep_api::Result<()> {
8638            Ok(())
8639        }
8640
8641        fn copy_from_host(
8642            &self,
8643            src: &[u8],
8644            dst: &mut DeviceBuffer,
8645        ) -> onnx_runtime_ep_api::Result<()> {
8646            if src.len() > dst.len() {
8647                return Err(EpError::KernelFailed(
8648                    "test EP host upload out of bounds".into(),
8649                ));
8650            }
8651            self.host_uploads.fetch_add(1, Ordering::Relaxed);
8652            self.copy_bytes(src.as_ptr(), dst.as_mut_ptr().cast(), src.len())
8653        }
8654
8655        fn copy_to_host(
8656            &self,
8657            src: &DeviceBuffer,
8658            dst: &mut [u8],
8659        ) -> onnx_runtime_ep_api::Result<()> {
8660            if dst.len() > src.len() {
8661                return Err(EpError::KernelFailed(
8662                    "test EP host download out of bounds".into(),
8663                ));
8664            }
8665            self.copy_bytes(src.as_ptr().cast(), dst.as_mut_ptr(), dst.len())
8666        }
8667    }
8668
8669    fn weight_delivery_fixture() -> (Graph, Arc<WeightStore>, std::path::PathBuf) {
8670        static NEXT_FILE: AtomicU64 = AtomicU64::new(0);
8671        let root = std::env::var_os("CARGO_TARGET_DIR")
8672            .map(std::path::PathBuf::from)
8673            .unwrap_or_else(|| std::env::current_dir().unwrap().join("target"))
8674            .join("weight-handle-tests");
8675        std::fs::create_dir_all(&root).unwrap();
8676        let id = NEXT_FILE.fetch_add(1, Ordering::Relaxed);
8677        let path = root.join(format!(
8678            "block-quantized-moe-{}-{id}.bin",
8679            std::process::id()
8680        ));
8681        std::fs::write(&path, [1u8, 2, 3, 4]).unwrap();
8682
8683        let mut graph = Graph::new();
8684        graph.opset_imports.insert("pkg.nxrt".into(), 1);
8685        let weight = graph.create_named_value("weight", DataType::Uint8, static_shape([4]));
8686        graph.set_initializer(
8687            weight,
8688            WeightRef::External {
8689                path: path.clone(),
8690                offset: 0,
8691                length: 4,
8692                dtype: DataType::Uint8,
8693                dims: vec![4],
8694            },
8695        );
8696        let output = graph.create_named_value("output", DataType::Uint8, static_shape([4]));
8697        let mut node = Node::new(
8698            NodeId(0),
8699            "BlockQuantizedMoE",
8700            vec![Some(weight)],
8701            vec![output],
8702        );
8703        node.domain = "pkg.nxrt".into();
8704        graph.insert_node(node);
8705        graph.add_output(output);
8706
8707        let mut store = WeightStore::new();
8708        store.map_external(&path).unwrap();
8709        (graph, Arc::new(store), path)
8710    }
8711
8712    #[test]
8713    fn claim_time_optional_input_dtype_is_undefined_not_silently_float32() {
8714        let mut graph = Graph::new();
8715        graph.opset_imports.insert(String::new(), 1);
8716        let data = graph.create_named_value("data", DataType::Float32, static_shape([1]));
8717        let training_mode =
8718            graph.create_named_value("training_mode", DataType::Bool, static_shape([]));
8719        let output = graph.create_named_value("output", DataType::Float32, static_shape([1]));
8720        graph.add_input(data);
8721        graph.add_input(training_mode);
8722        graph.add_output(output);
8723        graph.insert_node(Node::new(
8724            NodeId(0),
8725            "OptionalContract",
8726            vec![Some(data), None, Some(training_mode)],
8727            vec![output],
8728        ));
8729
8730        let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
8731        let mut ep = WeightDeliveryEp::new(false, deliveries);
8732        ep.optional_input_contract = true;
8733        let executor = Executor::build(graph, Arc::new(WeightStore::new()), Arc::new(ep));
8734        assert!(
8735            executor.is_ok(),
8736            "an omitted optional input must reach supports_op as DataType::Undefined"
8737        );
8738    }
8739
8740    #[test]
8741    fn executor_opens_per_op_span_only_when_tracing_enabled() {
8742        use onnx_runtime_tracer::TraceContext;
8743
8744        // Disabled (default noop): no spans recorded, hot path stays quiet.
8745        {
8746            let (graph, weights, path) = weight_delivery_fixture();
8747            let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
8748            let ep: Arc<dyn ExecutionProvider> =
8749                Arc::new(WeightDeliveryEp::new(false, Arc::clone(&deliveries)));
8750            let mut executor = Executor::build(graph, weights, ep).unwrap();
8751            let (trace, events) = TraceContext::in_memory();
8752            trace.set_enabled(false);
8753            executor.set_trace_context(trace);
8754            let _ = executor.run(&[]).unwrap();
8755            drop(executor);
8756            std::fs::remove_file(path).unwrap();
8757            assert!(
8758                events.events().is_empty(),
8759                "a disabled trace context must not open op spans"
8760            );
8761        }
8762
8763        // Enabled: exactly one op span per executed node, named by op type.
8764        {
8765            let (graph, weights, path) = weight_delivery_fixture();
8766            let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
8767            let ep: Arc<dyn ExecutionProvider> =
8768                Arc::new(WeightDeliveryEp::new(false, Arc::clone(&deliveries)));
8769            let mut executor = Executor::build(graph, weights, ep).unwrap();
8770            let (trace, events) = TraceContext::in_memory();
8771            executor.set_trace_context(trace);
8772            let _ = executor.run(&[]).unwrap();
8773            drop(executor);
8774            std::fs::remove_file(path).unwrap();
8775            let spans = events.events();
8776            assert_eq!(spans.len(), 1, "one op span per executed node");
8777            assert_eq!(spans[0].name, "BlockQuantizedMoE");
8778            assert_eq!(spans[0].cat, "op");
8779        }
8780    }
8781
8782    #[test]
8783    fn op_capture_trace_annotates_span_with_status_and_reason() {
8784        use onnx_runtime_tracer::TraceContext;
8785
8786        // Rejected: both a status and the actionable why-not reason land on the
8787        // active op-span. This is the branch a production model that declines
8788        // capture would hit; the qwen fixture captures cleanly so it is proven
8789        // here directly rather than in the live trace.
8790        {
8791            let (trace, events) = TraceContext::in_memory();
8792            {
8793                let _span = trace.span("MatMulNBits", "op");
8794                OpCaptureTrace::Rejected(
8795                    "kernel declares CaptureSupport::Unsupported: per-call workspace alloc",
8796                )
8797                .annotate();
8798            }
8799            let recorded = events.events();
8800            assert_eq!(recorded.len(), 1);
8801            let args = recorded[0].args.as_ref().unwrap();
8802            assert_eq!(args[ARG_CAPTURE_STATUS], "rejected");
8803            assert!(
8804                args[ARG_CAPTURE_REASON]
8805                    .as_str()
8806                    .unwrap()
8807                    .contains("CaptureSupport::Unsupported")
8808            );
8809        }
8810
8811        // Captured: status only, no reason (nothing was declined).
8812        {
8813            let (trace, events) = TraceContext::in_memory();
8814            {
8815                let _span = trace.span("MatMulNBits", "op");
8816                OpCaptureTrace::Captured.annotate();
8817            }
8818            let recorded = events.events();
8819            let args = recorded[0].args.as_ref().unwrap();
8820            assert_eq!(args[ARG_CAPTURE_STATUS], "captured");
8821        }
8822
8823        // Eager: no capture attempt, so no capture annotation at all.
8824        {
8825            let (trace, events) = TraceContext::in_memory();
8826            {
8827                let _span = trace.span("MatMulNBits", "op");
8828                OpCaptureTrace::Eager.annotate();
8829            }
8830            let recorded = events.events();
8831            assert!(
8832                recorded[0]
8833                    .args
8834                    .as_ref()
8835                    .map(|a| a.get(ARG_CAPTURE_STATUS).is_none())
8836                    .unwrap_or(true),
8837                "eager ops carry no capture status"
8838            );
8839        }
8840    }
8841
8842    #[test]
8843    fn executor_selects_lazy_or_resident_weight_delivery_from_ep_capability() {
8844        for (lazy, expected) in [(true, "lazy"), (false, "resident")] {
8845            let (graph, weights, path) = weight_delivery_fixture();
8846            let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
8847            let ep: Arc<dyn ExecutionProvider> =
8848                Arc::new(WeightDeliveryEp::new(lazy, Arc::clone(&deliveries)));
8849            let mut executor = Executor::build(graph, weights, ep).unwrap();
8850            let outputs = executor.run(&[]).unwrap();
8851
8852            assert_eq!(outputs[0].as_bytes(), &[1, 2, 3, 4]);
8853            assert_eq!(&*deliveries.lock().unwrap(), &[expected]);
8854            drop(executor);
8855            std::fs::remove_file(path).unwrap();
8856        }
8857    }
8858
8859    #[test]
8860    fn non_host_lazy_only_initializer_skips_eager_device_residency() {
8861        for (lazy, expected_allocations, expected_uploads, expected_delivery) in
8862            [(true, 1, 0, "lazy"), (false, 2, 1, "resident")]
8863        {
8864            let (graph, weights, path) = weight_delivery_fixture();
8865            let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
8866            let allocations = Arc::new(AtomicUsize::new(0));
8867            let host_uploads = Arc::new(AtomicUsize::new(0));
8868            let ep: Arc<dyn ExecutionProvider> = Arc::new(WeightDeliveryEp::non_host(
8869                lazy,
8870                Arc::clone(&deliveries),
8871                Arc::clone(&allocations),
8872                Arc::clone(&host_uploads),
8873            ));
8874            let mut executor = Executor::build(graph, weights, ep).unwrap();
8875
8876            assert_eq!(
8877                allocations.load(Ordering::Relaxed),
8878                expected_allocations,
8879                "lazy nxrt builds only the output; stock EPs also allocate the initializer"
8880            );
8881            assert_eq!(
8882                host_uploads.load(Ordering::Relaxed),
8883                expected_uploads,
8884                "lazy nxrt must not upload the initializer during build"
8885            );
8886
8887            let outputs = executor.run(&[]).unwrap();
8888            assert_eq!(outputs[0].as_bytes(), &[1, 2, 3, 4]);
8889            assert_eq!(&*deliveries.lock().unwrap(), &[expected_delivery]);
8890            assert_eq!(
8891                host_uploads.load(Ordering::Relaxed),
8892                expected_uploads,
8893                "dispatch must not introduce a second EP upload"
8894            );
8895            drop(executor);
8896            std::fs::remove_file(path).unwrap();
8897        }
8898    }
8899
8900    #[test]
8901    fn initializer_shared_with_resident_consumer_uses_one_device_copy() {
8902        let (mut graph, weights, path) = weight_delivery_fixture();
8903        graph.opset_imports.insert(String::new(), 17);
8904        let weight = graph
8905            .values
8906            .iter()
8907            .find_map(|(vid, value)| (value.name.as_deref() == Some("weight")).then_some(vid))
8908            .unwrap();
8909        let resident_output =
8910            graph.create_named_value("resident_output", DataType::Uint8, static_shape([4]));
8911        graph.insert_node(Node::new(
8912            NodeId(1),
8913            "Identity",
8914            vec![Some(weight)],
8915            vec![resident_output],
8916        ));
8917        graph.add_output(resident_output);
8918
8919        let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
8920        let allocations = Arc::new(AtomicUsize::new(0));
8921        let host_uploads = Arc::new(AtomicUsize::new(0));
8922        let ep: Arc<dyn ExecutionProvider> = Arc::new(WeightDeliveryEp::non_host(
8923            true,
8924            Arc::clone(&deliveries),
8925            Arc::clone(&allocations),
8926            Arc::clone(&host_uploads),
8927        ));
8928        let mut executor = Executor::build(graph, weights, ep).unwrap();
8929
8930        assert!(
8931            !executor.weight_handles.contains_key(&weight),
8932            "a resident consumer makes the single eager device copy authoritative"
8933        );
8934        assert_eq!(allocations.load(Ordering::Relaxed), 3);
8935        assert_eq!(host_uploads.load(Ordering::Relaxed), 1);
8936
8937        let outputs = executor.run(&[]).unwrap();
8938        assert_eq!(outputs[0].as_bytes(), &[1, 2, 3, 4]);
8939        assert_eq!(outputs[1].as_bytes(), &[1, 2, 3, 4]);
8940        assert_eq!(&*deliveries.lock().unwrap(), &["resident", "resident"]);
8941        assert_eq!(
8942            host_uploads.load(Ordering::Relaxed),
8943            1,
8944            "both consumers must share the one resident initializer"
8945        );
8946        drop(executor);
8947        std::fs::remove_file(path).unwrap();
8948    }
8949
8950    #[test]
8951    fn coverage_collector_surfaces_ep_decline_reason() {
8952        let mut graph = Graph::new();
8953        graph.opset_imports.insert(String::new(), 17);
8954        let input = graph.create_named_value("x", DataType::Float32, vec![Dim::Static(1)]);
8955        let output = graph.create_named_value("y", DataType::Float32, vec![Dim::Static(1)]);
8956        graph.insert_node(Node::new(
8957            NodeId(0),
8958            "NotRegistered",
8959            vec![Some(input)],
8960            vec![output],
8961        ));
8962
8963        let ep = CpuExecutionProvider::new();
8964        let mut issues = Vec::new();
8965        collect_cuda_coverage_issues(&graph, &graph, &ep, "graph", &mut issues);
8966
8967        assert_eq!(issues.len(), 1);
8968        assert_eq!(issues[0].op_type, "NotRegistered");
8969        assert_eq!(issues[0].domain, "ai.onnx");
8970        assert!(
8971            issues[0]
8972                .reason
8973                .contains("no handler for ai.onnx::NotRegistered at opset 17"),
8974            "{}",
8975            issues[0].reason
8976        );
8977        assert!(
8978            !issues[0].reason.contains("unsupported by"),
8979            "{}",
8980            issues[0].reason
8981        );
8982    }
8983
8984    #[test]
8985    fn cuda_coverage_report_groups_all_distinct_failure_classes_deterministically() {
8986        let mut graph = Graph::new();
8987        graph.opset_imports.insert(String::new(), 17);
8988        let input = graph.create_named_value("x", DataType::Float32, vec![Dim::Static(1)]);
8989
8990        let op_types = [
8991            "RepeatedMissing",
8992            "Missing08",
8993            "RepeatedMissing",
8994            "Missing07",
8995            "Missing06",
8996            "RepeatedMissing",
8997            "Missing05",
8998            "Missing04",
8999            "Missing03",
9000            "Missing02",
9001            "Missing01",
9002            "Missing00",
9003            "RepeatedMissing",
9004        ];
9005        for (index, op_type) in op_types.into_iter().enumerate() {
9006            let output = graph.create_named_value(
9007                format!("output_{index}"),
9008                DataType::Float32,
9009                vec![Dim::Static(1)],
9010            );
9011            graph.insert_node(Node::new(
9012                NodeId(index as u32),
9013                op_type,
9014                vec![Some(input)],
9015                vec![output],
9016            ));
9017        }
9018
9019        let ep = WeightDeliveryEp::with_device(
9020            false,
9021            Arc::new(std::sync::Mutex::new(Vec::new())),
9022            onnx_runtime_ir::DeviceId::cuda(0),
9023            Arc::new(AtomicUsize::new(0)),
9024            Arc::new(AtomicUsize::new(0)),
9025        );
9026        let report = || {
9027            cuda_fallback_report(&graph, &ep)
9028                .expect("CUDA declines must produce a fallback report")
9029                .to_string()
9030        };
9031        let first = report();
9032        let second = report();
9033
9034        assert_eq!(first, second);
9035        assert!(first.contains("13 nodes assigned to CPU"));
9036        assert!(first.contains("GPU EP stock_test_ep did not claim 13 node(s)"));
9037        assert!(first.contains("the whole session uses cpu_ep"));
9038        assert_eq!(first.matches("ai.onnx::RepeatedMissing:").count(), 1);
9039        assert!(first.contains("ai.onnx::RepeatedMissing: no handler"));
9040        assert!(first.contains("[count=4; examples: graph/node#0, graph/node#12, graph/node#2]"));
9041        assert!(!first.contains("graph/node#5"));
9042
9043        for op_type in [
9044            "Missing00",
9045            "Missing01",
9046            "Missing02",
9047            "Missing03",
9048            "Missing04",
9049            "Missing05",
9050            "Missing06",
9051            "Missing07",
9052            "Missing08",
9053        ] {
9054            assert_eq!(
9055                first.matches(&format!("ai.onnx::{op_type}:")).count(),
9056                1,
9057                "{first}"
9058            );
9059            assert!(
9060                first.contains(&format!("ai.onnx::{op_type}: no handler")),
9061                "{first}"
9062            );
9063        }
9064        assert!(!first.contains("more unsupported node"));
9065    }
9066
9067    #[test]
9068    fn cuda_decline_warns_and_falls_back_to_cpu_unless_strict() {
9069        let graph = || {
9070            let mut graph = Graph::new();
9071            graph.opset_imports.insert(String::new(), 17);
9072            let input = graph.create_named_value("input", DataType::Float32, vec![Dim::Static(1)]);
9073            let output =
9074                graph.create_named_value("output", DataType::Float32, vec![Dim::Static(1)]);
9075            graph.add_input(input);
9076            graph.add_output(output);
9077            graph.insert_node(Node::new(
9078                NodeId(0),
9079                "Relu",
9080                vec![Some(input)],
9081                vec![output],
9082            ));
9083            graph
9084        };
9085        let cuda_ep = || {
9086            Arc::new(WeightDeliveryEp::with_device(
9087                false,
9088                Arc::new(std::sync::Mutex::new(Vec::new())),
9089                onnx_runtime_ir::DeviceId::cuda(0),
9090                Arc::new(AtomicUsize::new(0)),
9091                Arc::new(AtomicUsize::new(0)),
9092            )) as Arc<dyn ExecutionProvider>
9093        };
9094
9095        let exec = Executor::build_with_cuda_requirement(
9096            graph(),
9097            Arc::new(WeightStore::new()),
9098            cuda_ep(),
9099            false,
9100        )
9101        .expect("default CUDA decline must use the CPU fallback");
9102        assert_eq!(exec.device_id().device_type, DeviceType::Cpu);
9103        let report = exec
9104            .execution_provider_fallback_report()
9105            .expect("fallback must remain observable");
9106        assert_eq!(report.assigned_node_count, 1);
9107        assert_eq!(report.assigned_ops, ["ai.onnx::Relu"]);
9108        assert_eq!(report.declines.len(), 1);
9109        assert_eq!(report.declines[0].op_type, "Relu");
9110        assert!(report.declines[0].reason.contains("intentionally declines"));
9111
9112        let strict = Executor::build_with_cuda_requirement(
9113            graph(),
9114            Arc::new(WeightStore::new()),
9115            cuda_ep(),
9116            true,
9117        )
9118        .err()
9119        .expect("strict CUDA must reject CPU fallback");
9120        assert!(strict.to_string().contains("ONNX_GENAI_REQUIRE_CUDA=1"));
9121    }
9122
9123    #[test]
9124    fn sequence_executor_preserves_element_arc_identity() {
9125        use onnx_runtime_ir::{TensorData, WeightRef, static_shape};
9126
9127        let mut graph = Graph::new();
9128        graph.opset_imports.insert(String::new(), 17);
9129
9130        let input = graph.create_named_value("input", DataType::Float32, static_shape([2]));
9131        graph.set_initializer(
9132            input,
9133            WeightRef::Inline(TensorData::from_raw(
9134                DataType::Float32,
9135                vec![2],
9136                [7.0f32, 8.0]
9137                    .into_iter()
9138                    .flat_map(f32::to_le_bytes)
9139                    .collect(),
9140            )),
9141        );
9142        let zero = graph.create_named_value("zero", DataType::Int64, static_shape([]));
9143        graph.set_initializer(
9144            zero,
9145            WeightRef::Inline(TensorData::from_raw(
9146                DataType::Int64,
9147                vec![],
9148                0i64.to_le_bytes().to_vec(),
9149            )),
9150        );
9151        let one = graph.create_named_value("one", DataType::Int64, static_shape([]));
9152        graph.set_initializer(
9153            one,
9154            WeightRef::Inline(TensorData::from_raw(
9155                DataType::Int64,
9156                vec![],
9157                1i64.to_le_bytes().to_vec(),
9158            )),
9159        );
9160
9161        let first_sequence = graph.create_value(DataType::Float32, static_shape([]));
9162        graph.insert_node(Node::new(
9163            NodeId(0),
9164            "SequenceConstruct",
9165            vec![Some(input)],
9166            vec![first_sequence],
9167        ));
9168        let first_at = graph.create_value(DataType::Float32, static_shape([2]));
9169        graph.insert_node(Node::new(
9170            NodeId(0),
9171            "SequenceAt",
9172            vec![Some(first_sequence), Some(zero)],
9173            vec![first_at],
9174        ));
9175        let inserted_sequence = graph.create_value(DataType::Float32, static_shape([]));
9176        graph.insert_node(Node::new(
9177            NodeId(0),
9178            "SequenceInsert",
9179            vec![Some(first_sequence), Some(first_at)],
9180            vec![inserted_sequence],
9181        ));
9182        let second_at = graph.create_value(DataType::Float32, static_shape([2]));
9183        graph.insert_node(Node::new(
9184            NodeId(0),
9185            "SequenceAt",
9186            vec![Some(inserted_sequence), Some(one)],
9187            vec![second_at],
9188        ));
9189        graph.add_output(second_at);
9190
9191        let mut executor = Executor::build(
9192            graph,
9193            Arc::new(WeightStore::new()),
9194            auto_detect_cpu_ep().unwrap(),
9195        )
9196        .unwrap();
9197        let output = executor.run(&[]).unwrap();
9198        assert_eq!(output[0].to_vec_f32(), vec![7.0, 8.0]);
9199
9200        let original = &executor.sequences[&first_sequence].elements()[0];
9201        let first_at_arc = &executor.seq_elem_values[&first_at];
9202        let inserted = &executor.sequences[&inserted_sequence].elements()[1];
9203        let second_at_arc = &executor.seq_elem_values[&second_at];
9204        assert!(original.shares_storage_with(first_at_arc));
9205        assert!(original.shares_storage_with(inserted));
9206        assert!(original.shares_storage_with(second_at_arc));
9207        assert_eq!(original.as_ptr(), executor.buffers[&input].as_ptr());
9208    }
9209
9210    #[test]
9211    fn fuses_only_single_consumer_silu_pattern() {
9212        let mut graph = Graph::new();
9213        let shape = vec![Dim::Static(2)];
9214        let x = graph.create_named_value("x", DataType::Float32, shape.clone());
9215        let sigmoid_out = graph.create_named_value("sigmoid", DataType::Float32, shape.clone());
9216        let silu_out = graph.create_named_value("silu", DataType::Float32, shape);
9217        graph.add_input(x);
9218        graph.add_output(silu_out);
9219        graph.insert_node(Node::new(
9220            NodeId(0),
9221            "Sigmoid",
9222            vec![Some(x)],
9223            vec![sigmoid_out],
9224        ));
9225        graph.insert_node(Node::new(
9226            NodeId(0),
9227            "Mul",
9228            vec![Some(sigmoid_out), Some(x)],
9229            vec![silu_out],
9230        ));
9231
9232        assert_eq!(fuse_silu_patterns(&mut graph), 1);
9233        assert_eq!(graph.num_nodes(), 1);
9234        let fused = graph.nodes.values().next().unwrap();
9235        assert_eq!(fused.op_type, "Silu");
9236        assert_eq!(fused.domain, "com.microsoft");
9237        assert_eq!(fused.inputs, vec![Some(x)]);
9238        assert_eq!(fused.outputs, vec![silu_out]);
9239        assert_eq!(graph.opset_imports["com.microsoft"], 1);
9240    }
9241
9242    #[test]
9243    fn does_not_fuse_silu_when_sigmoid_has_second_consumer() {
9244        let mut graph = Graph::new();
9245        let shape = vec![Dim::Static(2)];
9246        let x = graph.create_named_value("x", DataType::Float32, shape.clone());
9247        let sigmoid_out = graph.create_named_value("sigmoid", DataType::Float32, shape.clone());
9248        let mul_out = graph.create_named_value("mul", DataType::Float32, shape.clone());
9249        let identity_out = graph.create_named_value("identity", DataType::Float32, shape);
9250        graph.add_input(x);
9251        graph.add_output(mul_out);
9252        graph.add_output(identity_out);
9253        graph.insert_node(Node::new(
9254            NodeId(0),
9255            "Sigmoid",
9256            vec![Some(x)],
9257            vec![sigmoid_out],
9258        ));
9259        graph.insert_node(Node::new(
9260            NodeId(0),
9261            "Mul",
9262            vec![Some(x), Some(sigmoid_out)],
9263            vec![mul_out],
9264        ));
9265        graph.insert_node(Node::new(
9266            NodeId(0),
9267            "Identity",
9268            vec![Some(sigmoid_out)],
9269            vec![identity_out],
9270        ));
9271
9272        assert_eq!(fuse_silu_patterns(&mut graph), 0);
9273        assert_eq!(graph.num_nodes(), 3);
9274        assert_eq!(
9275            graph
9276                .nodes
9277                .values()
9278                .filter(|node| node.op_type == "Sigmoid")
9279                .count(),
9280            1
9281        );
9282        assert_eq!(
9283            graph
9284                .nodes
9285                .values()
9286                .filter(|node| node.op_type == "Mul")
9287                .count(),
9288            1
9289        );
9290        assert!(graph.validate().is_ok());
9291    }
9292
9293    /// Holden's precondition: the dispatch-boundary gate must reject a view that
9294    /// addresses bytes past its backing allocation, rather than letting a kernel
9295    /// dereference out of bounds (UB).
9296    #[test]
9297    fn view_bounds_rejects_out_of_bounds_view() {
9298        // A [2, 3] f32 view needs 24 bytes; give it a 16-byte backing length.
9299        let shape = [2usize, 3];
9300        let strides = compute_contiguous_strides(&shape);
9301        let err = view_bounds(&shape, &strides, 0, DataType::Float32, 16);
9302        assert!(err.is_err(), "gate must reject an oversized view");
9303
9304        // Exactly-fitting length is accepted.
9305        assert!(view_bounds(&shape, &strides, 0, DataType::Float32, 24).is_ok());
9306    }
9307
9308    /// A negative byte offset region (via a byte_offset that pushes the origin
9309    /// past the buffer) is also rejected.
9310    #[test]
9311    fn view_bounds_rejects_offset_overrun() {
9312        let shape = [4usize];
9313        let strides = compute_contiguous_strides(&shape);
9314        // 4 f32 = 16 bytes; origin at byte 8 leaves only 8 bytes → overrun.
9315        assert!(view_bounds(&shape, &strides, 8, DataType::Float32, 16).is_err());
9316        assert!(view_bounds(&shape, &strides, 0, DataType::Float32, 16).is_ok());
9317    }
9318
9319    /// Symbol substitution: static dims pass through, bound symbols resolve, an
9320    /// unbound symbol yields `None` (the uninferred-shape signal).
9321    #[test]
9322    fn substitute_resolves_bound_symbols_only() {
9323        let mut bindings = HashMap::new();
9324        bindings.insert(SymbolId(0), 7usize);
9325        let shape = vec![Dim::Symbolic(SymbolId(0)), Dim::Static(4)];
9326        assert_eq!(substitute(&shape, &bindings), Some(vec![7, 4]));
9327
9328        let unbound = vec![Dim::Symbolic(SymbolId(1)), Dim::Static(4)];
9329        assert_eq!(substitute(&unbound, &bindings), None);
9330    }
9331
9332    /// H-D1: element-count multiplication must be overflow-checked so a huge or
9333    /// malicious shape reports `ShapeOverflow` instead of wrapping `usize` and
9334    /// under-sizing the buffer.
9335    #[test]
9336    fn checked_numel_detects_overflow() {
9337        // Well-formed shapes multiply normally.
9338        assert_eq!(checked_numel(&[2, 3, 4], || "v".into()).unwrap(), 24);
9339        assert_eq!(checked_numel(&[], || "v".into()).unwrap(), 1);
9340
9341        // A product past usize::MAX overflows.
9342        let huge = [usize::MAX, 2];
9343        let err = checked_numel(&huge, || "value#9".into());
9344        assert!(matches!(err, Err(SessionError::ShapeOverflow { .. })));
9345    }
9346
9347    /// H-D1 (byte layer): even when the element *count* fits in `usize`, the
9348    /// count → bytes multiply can wrap for a fixed-width dtype. The allocation
9349    /// path must report `ShapeOverflow` rather than under-allocating.
9350    #[test]
9351    fn checked_storage_bytes_detects_byte_overflow() {
9352        // `usize::MAX / 4` elements fit in usize (pass checked_numel) but
9353        // `* 8` bytes for Float64 wraps — this is the exploited under-alloc.
9354        let numel = usize::MAX / 4;
9355        let err = checked_storage_bytes(DataType::Float64, numel, || "value#9".into(), &[numel]);
9356        assert!(matches!(err, Err(SessionError::ShapeOverflow { .. })));
9357
9358        // A well-formed size passes through unchanged.
9359        assert_eq!(
9360            checked_storage_bytes(DataType::Float32, 4, || "v".into(), &[4]).unwrap(),
9361            16
9362        );
9363    }
9364
9365    /// The data-dependent shape sizer must return exactly one shape per output
9366    /// so the run loop's `out_shapes[oi]` indexing can never misindex. Slice is
9367    /// single-output, so it returns a 1-element Vec; the run loop additionally
9368    /// guards the count (see `OutputShapeCountMismatch`).
9369    #[test]
9370    fn dynamic_output_shapes_slice_is_single_output() {
9371        let node = Node::new(NodeId(0), "Slice", vec![], vec![]);
9372        let input_shapes = vec![vec![4usize, 2]];
9373        let input_values = vec![
9374            None,          // data (unused by sizer)
9375            Some(vec![1]), // starts
9376            Some(vec![3]), // ends
9377            Some(vec![0]), // axes
9378            Some(vec![1]), // steps
9379        ];
9380        let input_dtypes = vec![
9381            DataType::Float32,
9382            DataType::Int64,
9383            DataType::Int64,
9384            DataType::Int64,
9385            DataType::Int64,
9386        ];
9387        let out =
9388            dynamic_output_shapes(&node, &input_shapes, &input_dtypes, &input_values, &[], 17)
9389                .unwrap();
9390        assert_eq!(out.len(), 1, "Slice must resolve exactly one output shape");
9391        assert_eq!(out[0], vec![2, 2]);
9392
9393        let mut custom_slice = Node::new(NodeId(1), "Slice", vec![], vec![ValueId(0)]);
9394        custom_slice.domain = "example.custom".into();
9395        assert!(
9396            dynamic_output_shapes(
9397                &custom_slice,
9398                &input_shapes,
9399                &input_dtypes,
9400                &input_values,
9401                &[],
9402                17
9403            )
9404            .is_none(),
9405            "ONNX Slice semantics must not be applied to an unrelated custom-domain op"
9406        );
9407
9408        // An op the sizer cannot resolve returns None (surfaces as UnresolvedShape).
9409        let other = Node::new(
9410            NodeId(2),
9411            "NxrtNeverRegisteredSentinelOp",
9412            vec![],
9413            vec![ValueId(0)],
9414        );
9415        assert!(
9416            dynamic_output_shapes(&other, &input_shapes, &input_dtypes, &input_values, &[], 17)
9417                .is_none()
9418        );
9419    }
9420
9421    #[test]
9422    fn dynamic_output_shapes_unsqueeze_supports_input_and_attribute_axes() {
9423        use onnx_runtime_ir::Attribute;
9424
9425        let input_axes = Node::new(
9426            NodeId(0),
9427            "Unsqueeze",
9428            vec![Some(ValueId(0)), Some(ValueId(1))],
9429            vec![ValueId(2)],
9430        );
9431        assert_eq!(
9432            dynamic_output_shapes(
9433                &input_axes,
9434                &[vec![2, 3], vec![2]],
9435                &[DataType::Float32, DataType::Int64],
9436                &[None, Some(vec![0, -1])],
9437                &[],
9438                17,
9439            ),
9440            Some(vec![vec![1, 2, 3, 1]])
9441        );
9442
9443        let mut attribute_axes = Node::new(
9444            NodeId(1),
9445            "Unsqueeze",
9446            vec![Some(ValueId(0))],
9447            vec![ValueId(1)],
9448        );
9449        attribute_axes
9450            .attributes
9451            .insert("axes".into(), Attribute::Ints(vec![1, -1]));
9452        assert_eq!(
9453            dynamic_output_shapes(
9454                &attribute_axes,
9455                &[vec![2, 3]],
9456                &[DataType::Float32],
9457                &[None],
9458                &[],
9459                11,
9460            ),
9461            Some(vec![vec![2, 1, 3, 1]])
9462        );
9463    }
9464
9465    #[test]
9466    fn dynamic_output_shapes_resize_reads_runtime_scales() {
9467        let node = Node::new(
9468            NodeId(0),
9469            "Resize",
9470            vec![Some(ValueId(0)), Some(ValueId(1)), Some(ValueId(2))],
9471            vec![ValueId(3)],
9472        );
9473        assert_eq!(
9474            dynamic_output_shapes(
9475                &node,
9476                &[vec![1, 128, 13, 13], vec![8], vec![4]],
9477                &[DataType::Float32, DataType::Float32, DataType::Float32],
9478                &[None, None, None],
9479                &[None, None, Some(vec![1.0, 1.0, 2.0, 2.0])],
9480                11,
9481            ),
9482            Some(vec![vec![1, 128, 26, 26]])
9483        );
9484    }
9485
9486    #[test]
9487    fn dynamic_output_shapes_non_max_suppression_counts_selected_boxes() {
9488        let node = Node::new(
9489            NodeId(0),
9490            "NonMaxSuppression",
9491            (0..5).map(|index| Some(ValueId(index))).collect(),
9492            vec![ValueId(5)],
9493        );
9494        let shapes = vec![vec![1, 3, 4], vec![1, 1, 3], vec![], vec![], vec![]];
9495        let dtypes = vec![
9496            DataType::Float32,
9497            DataType::Float32,
9498            DataType::Int64,
9499            DataType::Float32,
9500            DataType::Float32,
9501        ];
9502        let ints = vec![None, None, Some(vec![2]), None, None];
9503        let floats = vec![
9504            Some(vec![0., 0., 1., 1., 0., 0., 0.9, 0.9, 2., 2., 3., 3.]),
9505            Some(vec![0.9, 0.8, 0.7]),
9506            None,
9507            Some(vec![0.5]),
9508            Some(vec![0.0]),
9509        ];
9510        assert_eq!(
9511            dynamic_output_shapes(&node, &shapes, &dtypes, &ints, &floats, 11),
9512            Some(vec![vec![2, 3]])
9513        );
9514    }
9515
9516    #[test]
9517    fn dynamic_output_shapes_gqa_supports_packed_qkv() {
9518        use onnx_runtime_ir::{Attribute, ValueId};
9519
9520        let mut node = Node::new(
9521            NodeId(0),
9522            "GroupQueryAttention",
9523            vec![
9524                Some(ValueId(0)),
9525                None,
9526                None,
9527                Some(ValueId(3)),
9528                Some(ValueId(4)),
9529                Some(ValueId(5)),
9530                Some(ValueId(6)),
9531            ],
9532            vec![ValueId(7), ValueId(8), ValueId(9)],
9533        );
9534        node.domain = "com.microsoft".into();
9535        node.attributes
9536            .insert("num_heads".into(), Attribute::Int(14));
9537        node.attributes
9538            .insert("kv_num_heads".into(), Attribute::Int(2));
9539        let input_shapes = vec![
9540            vec![1, 1, 1152],
9541            vec![],
9542            vec![],
9543            vec![1, 2, 16, 64],
9544            vec![1, 2, 16, 64],
9545            vec![1],
9546            vec![],
9547        ];
9548        let input_values = vec![None, None, None, None, None, None, Some(vec![17])];
9549
9550        assert_eq!(
9551            dynamic_output_shapes(
9552                &node,
9553                &input_shapes,
9554                &[
9555                    DataType::Float32,
9556                    DataType::Undefined,
9557                    DataType::Undefined,
9558                    DataType::Float32,
9559                    DataType::Float32,
9560                    DataType::Int32,
9561                    DataType::Int32,
9562                ],
9563                &input_values,
9564                &[],
9565                1,
9566            ),
9567            Some(vec![
9568                vec![1, 1, 896],
9569                vec![1, 2, 17, 64],
9570                vec![1, 2, 17, 64],
9571            ])
9572        );
9573    }
9574
9575    /// The effective opset is read from the graph's import for the op's domain,
9576    /// with the default and `ai.onnx` spellings treated as one.
9577    #[test]
9578    fn effective_opset_reads_graph_import() {
9579        let mut graph = Graph::default();
9580        graph.opset_imports.insert(String::new(), 12);
9581        let node = Node::new(NodeId(0), "Softmax", vec![], vec![]);
9582        assert_eq!(effective_opset(&graph, &node), 12);
9583
9584        graph.opset_imports.insert(String::new(), 0);
9585        assert_eq!(effective_opset(&graph, &node), 0);
9586    }
9587
9588    #[test]
9589    #[should_panic(expected = "internal invariant violated")]
9590    fn effective_opset_requires_validated_import() {
9591        effective_opset(
9592            &Graph::default(),
9593            &Node::new(NodeId(0), "Softmax", vec![], vec![]),
9594        );
9595    }
9596
9597    #[test]
9598    fn child_executor_binds_formals_captures_and_inline_initializers_in_output_order() {
9599        use onnx_runtime_ir::{TensorData, WeightRef, static_shape};
9600
9601        let mut body = Graph::new();
9602        let formal = body.create_named_value("formal", DataType::Float32, static_shape([2]));
9603        body.add_input(formal);
9604        let captured = body.create_named_value("captured", DataType::Float32, static_shape([2]));
9605        let one = body.create_named_value("one", DataType::Float32, static_shape([2]));
9606        body.set_initializer(
9607            one,
9608            WeightRef::Inline(TensorData::from_raw(
9609                DataType::Float32,
9610                vec![2],
9611                [1.0f32, 1.0]
9612                    .into_iter()
9613                    .flat_map(f32::to_le_bytes)
9614                    .collect(),
9615            )),
9616        );
9617        let sum = body.create_named_value("sum", DataType::Float32, static_shape([2]));
9618        body.insert_node(Node::new(
9619            NodeId(0),
9620            "Add",
9621            vec![Some(formal), Some(captured)],
9622            vec![sum],
9623        ));
9624        let adjusted = body.create_named_value("adjusted", DataType::Float32, static_shape([2]));
9625        body.insert_node(Node::new(
9626            NodeId(0),
9627            "Add",
9628            vec![Some(sum), Some(one)],
9629            vec![adjusted],
9630        ));
9631        // Deliberately reverse production order to prove formal output ordering.
9632        body.add_output(adjusted);
9633        body.add_output(sum);
9634
9635        let mut opsets = HashMap::new();
9636        opsets.insert(String::new(), 17);
9637        let mut child = ChildExecutor::new(
9638            "direct-test",
9639            body,
9640            opsets,
9641            Arc::new(WeightStore::new()),
9642            auto_detect_cpu_ep().unwrap(),
9643        )
9644        .unwrap();
9645        let mut outer_scope = HashMap::new();
9646        outer_scope.insert(
9647            "captured".to_string(),
9648            Tensor::from_f32(&[2], &[10.0, 20.0]).unwrap(),
9649        );
9650
9651        let first = Tensor::from_f32(&[2], &[2.0, 3.0]).unwrap();
9652        let outputs = child.run(&[&first], &outer_scope).unwrap();
9653        assert_eq!(outputs.len(), 2);
9654        assert_eq!(outputs[0].to_vec_f32(), vec![13.0, 24.0]);
9655        assert_eq!(outputs[1].to_vec_f32(), vec![12.0, 23.0]);
9656        assert_eq!(child.stats(), ChildExecutorStats { builds: 1, runs: 1 });
9657
9658        let second = Tensor::from_f32(&[2], &[-1.0, 4.0]).unwrap();
9659        let outputs = child.run(&[&second], &outer_scope).unwrap();
9660        assert_eq!(outputs[0].to_vec_f32(), vec![10.0, 25.0]);
9661        assert_eq!(outputs[1].to_vec_f32(), vec![9.0, 24.0]);
9662        assert_eq!(
9663            child.stats(),
9664            ChildExecutorStats { builds: 1, runs: 2 },
9665            "matching input signatures must reuse the compiled child plan"
9666        );
9667    }
9668
9669    fn unary_child(name: &str) -> ChildExecutor {
9670        let mut body = Graph::new();
9671        let input = body.create_named_value("input", DataType::Float32, Vec::new());
9672        body.add_input(input);
9673        let output = body.create_named_value("output", DataType::Float32, Vec::new());
9674        body.insert_node(Node::new(
9675            NodeId(0),
9676            "Relu",
9677            vec![Some(input)],
9678            vec![output],
9679        ));
9680        body.add_output(output);
9681
9682        let mut opsets = HashMap::new();
9683        opsets.insert(String::new(), 17);
9684        ChildExecutor::new(
9685            name,
9686            body,
9687            opsets,
9688            Arc::new(WeightStore::new()),
9689            auto_detect_cpu_ep().unwrap(),
9690        )
9691        .unwrap()
9692    }
9693
9694    #[test]
9695    fn child_executor_reuses_a_signature_after_an_intervening_signature() {
9696        let mut child = unary_child("a-b-a");
9697        let outer_scope = HashMap::new();
9698        let a = Tensor::from_f32(&[1], &[-1.0]).unwrap();
9699        let b = Tensor::from_f32(&[2], &[-2.0, 3.0]).unwrap();
9700
9701        assert_eq!(
9702            child.run(&[&a], &outer_scope).unwrap()[0].to_vec_f32(),
9703            vec![0.0]
9704        );
9705        assert_eq!(
9706            child.run(&[&b], &outer_scope).unwrap()[0].to_vec_f32(),
9707            vec![0.0, 3.0]
9708        );
9709        assert_eq!(
9710            child.run(&[&a], &outer_scope).unwrap()[0].to_vec_f32(),
9711            vec![0.0]
9712        );
9713        assert_eq!(child.stats(), ChildExecutorStats { builds: 2, runs: 3 });
9714    }
9715
9716    #[test]
9717    fn child_executor_lru_evicts_oldest_signature_only() {
9718        let mut child = unary_child("lru-eviction");
9719        let outer_scope = HashMap::new();
9720        let inputs = (1..=CHILD_EXECUTOR_CACHE_CAPACITY + 1)
9721            .map(|len| Tensor::from_f32(&[len], &vec![len as f32; len]).unwrap())
9722            .collect::<Vec<_>>();
9723
9724        for input in &inputs {
9725            child.run(&[input], &outer_scope).unwrap();
9726        }
9727        assert_eq!(
9728            child.stats(),
9729            ChildExecutorStats {
9730                builds: (CHILD_EXECUTOR_CACHE_CAPACITY + 1) as u64,
9731                runs: (CHILD_EXECUTOR_CACHE_CAPACITY + 1) as u64,
9732            }
9733        );
9734
9735        child.run(&[&inputs[0]], &outer_scope).unwrap();
9736        child.run(&[inputs.last().unwrap()], &outer_scope).unwrap();
9737        assert_eq!(
9738            child.stats(),
9739            ChildExecutorStats {
9740                builds: (CHILD_EXECUTOR_CACHE_CAPACITY + 2) as u64,
9741                runs: (CHILD_EXECUTOR_CACHE_CAPACITY + 3) as u64,
9742            },
9743            "the evicted oldest signature must rebuild while a recent entry remains cached"
9744        );
9745    }
9746
9747    fn captured_add_child(name: &str) -> ChildExecutor {
9748        let mut body = Graph::new();
9749        let input = body.create_named_value("input", DataType::Float32, Vec::new());
9750        body.add_input(input);
9751        let captured = body.create_named_value("captured", DataType::Float32, Vec::new());
9752        let output = body.create_named_value("output", DataType::Float32, Vec::new());
9753        body.insert_node(Node::new(
9754            NodeId(0),
9755            "Add",
9756            vec![Some(input), Some(captured)],
9757            vec![output],
9758        ));
9759        body.add_output(output);
9760
9761        let mut opsets = HashMap::new();
9762        opsets.insert(String::new(), 17);
9763        ChildExecutor::new(
9764            name,
9765            body,
9766            opsets,
9767            Arc::new(WeightStore::new()),
9768            auto_detect_cpu_ep().unwrap(),
9769        )
9770        .unwrap()
9771    }
9772
9773    #[test]
9774    fn child_executor_cached_plan_rebinds_captures_without_stale_state() {
9775        let mut child = captured_add_child("capture-shadowing");
9776        let a_input = Tensor::from_f32(&[1], &[1.0]).unwrap();
9777        let b_input = Tensor::from_f32(&[2], &[2.0, 3.0]).unwrap();
9778
9779        let mut scope = HashMap::new();
9780        scope.insert(
9781            "captured".to_string(),
9782            Tensor::from_f32(&[1], &[10.0]).unwrap(),
9783        );
9784        assert_eq!(
9785            child.run(&[&a_input], &scope).unwrap()[0].to_vec_f32(),
9786            vec![11.0]
9787        );
9788
9789        scope.insert(
9790            "captured".to_string(),
9791            Tensor::from_f32(&[2], &[20.0, 30.0]).unwrap(),
9792        );
9793        assert_eq!(
9794            child.run(&[&b_input], &scope).unwrap()[0].to_vec_f32(),
9795            vec![22.0, 33.0]
9796        );
9797
9798        scope.insert(
9799            "captured".to_string(),
9800            Tensor::from_f32(&[1], &[40.0]).unwrap(),
9801        );
9802        let cached = child.run(&[&a_input], &scope).unwrap()[0].to_vec_f32();
9803        let mut fresh = captured_add_child("capture-shadowing-fresh");
9804        let freshly_compiled = fresh.run(&[&a_input], &scope).unwrap()[0].to_vec_f32();
9805
9806        assert_eq!(cached, vec![41.0]);
9807        assert_eq!(cached, freshly_compiled);
9808        assert_eq!(child.stats(), ChildExecutorStats { builds: 2, runs: 3 });
9809    }
9810
9811    // --- weight-streaming: zero-copy borrowed initializer buffers -----------
9812
9813    use onnx_runtime_ir::{WeightRef, static_shape};
9814    use std::path::PathBuf;
9815
9816    /// A writable scratch dir under the workspace `target/` (never `/tmp`).
9817    fn weightstream_tmp_dir() -> PathBuf {
9818        let dir = PathBuf::from(concat!(
9819            env!("CARGO_MANIFEST_DIR"),
9820            "/../../target/weightstream_test"
9821        ));
9822        std::fs::create_dir_all(&dir).expect("create weight-streaming test dir");
9823        dir
9824    }
9825
9826    fn f32_le(data: &[f32]) -> Vec<u8> {
9827        data.iter().flat_map(|v| v.to_le_bytes()).collect()
9828    }
9829
9830    /// (b) An aligned external-data initializer is backed **zero-copy** by a
9831    /// borrowed buffer whose data pointer EQUALS the WeightStore's mmap slice —
9832    /// no allocation, no copy. A model larger than RAM relies on this.
9833    #[test]
9834    fn aligned_external_initializer_is_borrowed_zero_copy() {
9835        let align = TensorLayout::contiguous().alignment;
9836        let path = weightstream_tmp_dir().join("aligned_init.bin");
9837        let w_data = [1.0f32, 2.0, 3.0, 4.0];
9838        std::fs::write(&path, f32_le(&w_data)).unwrap();
9839
9840        let mut store = WeightStore::new();
9841        store.map_external(&path).unwrap();
9842
9843        let mut g = Graph::new();
9844        g.opset_imports.insert(String::new(), 17);
9845        let w = g.create_named_value("W", DataType::Float32, static_shape([4]));
9846        g.set_initializer(
9847            w,
9848            WeightRef::External {
9849                path: path.clone(),
9850                offset: 0, // mmap base is page-aligned -> 0 is `align`-aligned
9851                length: 16,
9852                dtype: DataType::Float32,
9853                dims: vec![4],
9854            },
9855        );
9856        let y = g.create_value(DataType::Float32, static_shape([4]));
9857        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(w)], vec![y]));
9858        g.add_output(y);
9859
9860        let ep = auto_detect_cpu_ep().unwrap();
9861        let exec = Executor::build(g, Arc::new(store), ep).unwrap();
9862
9863        let weight = &exec.graph.initializers[&w];
9864        let src = exec.weights().bytes(weight).unwrap();
9865        assert!(
9866            (src.as_ptr() as usize).is_multiple_of(align),
9867            "mmap window must be aligned for this test to exercise the zero-copy path"
9868        );
9869        let buf = &exec.buffers[&w];
9870        assert!(
9871            buf.is_borrowed(),
9872            "aligned initializer must be borrowed, not copied"
9873        );
9874        assert_eq!(
9875            buf.as_ptr() as *const u8,
9876            src.as_ptr(),
9877            "zero-copy: the buffer must alias the mmap bytes (no copy)"
9878        );
9879
9880        let _ = std::fs::remove_file(&path);
9881    }
9882
9883    /// (c) An external-data initializer that is dtype-aligned but not 64-byte
9884    /// aligned remains a zero-copy mmap borrow and is numerically correct.
9885    #[test]
9886    fn device_unaligned_external_initializer_is_borrowed_at_dtype_alignment() {
9887        let align = TensorLayout::contiguous().alignment;
9888        let path = weightstream_tmp_dir().join("unaligned_init.bin");
9889        // Prefix the weight window with 8 bytes so it starts at offset 8, which
9890        // is f32-aligned but not a multiple of the EP allocation alignment (64).
9891        let offset = 8usize;
9892        let w_data = [5.0f32, 6.0, 7.0, 8.0];
9893        let mut file = vec![0u8; offset];
9894        file.extend_from_slice(&f32_le(&w_data));
9895        std::fs::write(&path, &file).unwrap();
9896
9897        let mut store = WeightStore::new();
9898        store.map_external(&path).unwrap();
9899
9900        let mut g = Graph::new();
9901        g.opset_imports.insert(String::new(), 17);
9902        let w = g.create_named_value("W", DataType::Float32, static_shape([4]));
9903        g.set_initializer(
9904            w,
9905            WeightRef::External {
9906                path: path.clone(),
9907                offset,
9908                length: 16,
9909                dtype: DataType::Float32,
9910                dims: vec![4],
9911            },
9912        );
9913        let x = g.create_named_value("X", DataType::Float32, static_shape([4]));
9914        g.add_input(x);
9915        let y = g.create_value(DataType::Float32, static_shape([4]));
9916        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(x), Some(w)], vec![y]));
9917        g.add_output(y);
9918
9919        let ep = auto_detect_cpu_ep().unwrap();
9920        let mut exec = Executor::build(g, Arc::new(store), ep).unwrap();
9921
9922        let weight = &exec.graph.initializers[&w];
9923        let src = exec.weights().bytes(weight).unwrap();
9924        assert!(
9925            !(src.as_ptr() as usize).is_multiple_of(align),
9926            "window must be unaligned for this test to exercise the fallback"
9927        );
9928        let buf = &exec.buffers[&w];
9929        assert!(
9930            buf.is_borrowed(),
9931            "dtype-aligned mmap initializer must remain borrowed"
9932        );
9933        assert_eq!(
9934            buf.as_ptr() as *const u8,
9935            src.as_ptr(),
9936            "zero-copy buffer must alias the mmap window"
9937        );
9938        assert_eq!(buf.alignment(), std::mem::align_of::<f32>());
9939
9940        // The copy is numerically correct: Y = X + W.
9941        let x_tensor = Tensor::from_f32(&[4], &[10.0, 20.0, 30.0, 40.0]).unwrap();
9942        let out = exec.run(&[("X", &x_tensor)]).unwrap();
9943        assert_eq!(out.len(), 1);
9944        let got = out[0].to_vec_f32();
9945        let want = [15.0f32, 26.0, 37.0, 48.0];
9946        assert_eq!(got.len(), want.len());
9947        for (g, w) in got.iter().zip(want.iter()) {
9948            assert!((g - w).abs() < 1e-5, "got {g}, want {w}");
9949        }
9950
9951        let _ = std::fs::remove_file(&path);
9952    }
9953
9954    #[test]
9955    fn unaligned_external_qmoe_keeps_route_first_enabled_and_matches_legacy() {
9956        use std::ffi::OsString;
9957        use std::sync::{Mutex, OnceLock};
9958
9959        static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
9960        let _env_guard = ENV_LOCK
9961            .get_or_init(|| Mutex::new(()))
9962            .lock()
9963            .expect("weight-offload env lock");
9964
9965        struct RestoreEnv(Option<OsString>);
9966        impl Drop for RestoreEnv {
9967            fn drop(&mut self) {
9968                if let Some(value) = self.0.take() {
9969                    // SAFETY: this test serializes all mutations it performs.
9970                    unsafe { std::env::set_var(onnx_runtime_ep_cpu::WEIGHT_OFFLOAD_ENV, value) };
9971                } else {
9972                    // SAFETY: this test serializes all mutations it performs.
9973                    unsafe { std::env::remove_var(onnx_runtime_ep_cpu::WEIGHT_OFFLOAD_ENV) };
9974                }
9975            }
9976        }
9977
9978        let _restore = RestoreEnv(std::env::var_os(onnx_runtime_ep_cpu::WEIGHT_OFFLOAD_ENV));
9979        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
9980            .join("../onnx-runtime-ep-cpu/tests/fixtures/qmoe_weight_offload/model.onnx");
9981        let input_values: Vec<f32> = (0..64).map(|index| index as f32 * 0.03125 - 1.0).collect();
9982        let router_values = vec![
9983            9.0, 0.0, 0.0, 0.0, 0.0, 9.0, 0.0, 0.0, 0.0, 0.0, 9.0, 0.0, 0.0, 0.0, 0.0, 9.0,
9984        ];
9985        let input = Tensor::from_f32(&[4, 16], &input_values).unwrap();
9986        let router = Tensor::from_f32(&[4, 4], &router_values).unwrap();
9987
9988        // SAFETY: guarded above; both executors compile synchronously here.
9989        unsafe { std::env::set_var(onnx_runtime_ep_cpu::WEIGHT_OFFLOAD_ENV, "0") };
9990        let (legacy_graph, legacy_weights) =
9991            onnx_runtime_loader::load_model_with_weights(&fixture).unwrap();
9992        let mut legacy =
9993            Executor::build(legacy_graph, legacy_weights, auto_detect_cpu_ep().unwrap()).unwrap();
9994        let legacy_output = legacy.run(&[("X", &input), ("router", &router)]).unwrap();
9995
9996        // SAFETY: guarded above; the offload kernel captures the flag at build.
9997        unsafe { std::env::set_var(onnx_runtime_ep_cpu::WEIGHT_OFFLOAD_ENV, "1") };
9998        let before = onnx_runtime_ep_cpu::weight_offload_stats();
9999        let (offload_graph, offload_weights) =
10000            onnx_runtime_loader::load_model_with_weights(&fixture).unwrap();
10001        let mut offload = Executor::build(
10002            offload_graph,
10003            offload_weights,
10004            auto_detect_cpu_ep().unwrap(),
10005        )
10006        .unwrap();
10007        for (&value, weight) in &offload.graph.initializers {
10008            let WeightRef::External { .. } = weight else {
10009                continue;
10010            };
10011            let source = offload.weights.bytes(weight).unwrap();
10012            assert!(
10013                !(source.as_ptr() as usize).is_multiple_of(TensorLayout::contiguous().alignment)
10014            );
10015            let buffer = &offload.buffers[&value];
10016            assert!(buffer.is_borrowed());
10017            assert_eq!(buffer.as_ptr() as *const u8, source.as_ptr());
10018        }
10019        let offload_output = offload.run(&[("X", &input), ("router", &router)]).unwrap();
10020        let after = onnx_runtime_ep_cpu::weight_offload_stats();
10021
10022        assert_eq!(
10023            offload_output[0].to_vec_f32(),
10024            legacy_output[0].to_vec_f32()
10025        );
10026        assert!(
10027            after.layer_executions
10028                >= before
10029                    .layer_executions
10030                    .checked_add(1)
10031                    .expect("layer execution counter overflow")
10032        );
10033        assert!(after.bytes_read_from_mmap > before.bytes_read_from_mmap);
10034    }
10035
10036    /// (d) Soundness guard: even when an initializer's mmap bytes are aligned
10037    /// (so the zero-copy path would otherwise fire), the executor must NOT
10038    /// borrow them if the value also has a producer — i.e. a malformed graph
10039    /// reused the initializer's `ValueId` as a node output. Borrowing yields a
10040    /// read-only buffer; a kernel writing that output would write through the
10041    /// mmap (SIGSEGV / aliasing UB). The build must fall back to an owned,
10042    /// writable copy instead.
10043    #[test]
10044    fn producer_backed_initializer_is_not_borrowed() {
10045        let align = TensorLayout::contiguous().alignment;
10046        let path = weightstream_tmp_dir().join("producer_backed_init.bin");
10047        let w_data = [1.0f32, 2.0, 3.0, 4.0];
10048        std::fs::write(&path, f32_le(&w_data)).unwrap();
10049
10050        let mut store = WeightStore::new();
10051        store.map_external(&path).unwrap();
10052
10053        let mut g = Graph::new();
10054        g.opset_imports.insert(String::new(), 17);
10055        let x = g.create_named_value("X", DataType::Float32, static_shape([4]));
10056        g.add_input(x);
10057        let w = g.create_named_value("W", DataType::Float32, static_shape([4]));
10058        g.set_initializer(
10059            w,
10060            WeightRef::External {
10061                path: path.clone(),
10062                offset: 0, // aligned: without the producer guard this would borrow
10063                length: 16,
10064                dtype: DataType::Float32,
10065                dims: vec![4],
10066            },
10067        );
10068        // Reuse the initializer's ValueId as a node output -> gives `w` a
10069        // producer, exactly the malformed shape the loader also rejects.
10070        g.insert_node(Node::new(NodeId(0), "Identity", vec![Some(x)], vec![w]));
10071        let y = g.create_value(DataType::Float32, static_shape([4]));
10072        g.insert_node(Node::new(NodeId(1), "Add", vec![Some(x), Some(w)], vec![y]));
10073        g.add_output(y);
10074
10075        assert!(
10076            g.value(w).producer.is_some(),
10077            "test setup: initializer value must have a producer",
10078        );
10079
10080        let ep = auto_detect_cpu_ep().unwrap();
10081        let exec = Executor::build(g, Arc::new(store), ep).unwrap();
10082
10083        let weight = &exec.graph.initializers[&w];
10084        let src = exec.weights().bytes(weight).unwrap();
10085        assert!(
10086            (src.as_ptr() as usize).is_multiple_of(align),
10087            "mmap window must be aligned so only the producer guard prevents borrowing",
10088        );
10089        let buf = &exec.buffers[&w];
10090        assert!(
10091            !buf.is_borrowed(),
10092            "producer-backed initializer must fall back to an owned writable copy",
10093        );
10094        assert_ne!(
10095            buf.as_ptr() as *const u8,
10096            src.as_ptr(),
10097            "producer-backed initializer must not alias read-only mmap bytes",
10098        );
10099
10100        let _ = std::fs::remove_file(&path);
10101    }
10102
10103    /// Stage-0 capture prerequisite: a data-dependent decode shape that
10104    /// `resolve_soft` omits (here `Range`'s runtime-length output feeding a
10105    /// capture-safe `Cast`) forms an *unresolved-shape* eager seam. After one
10106    /// eager warmup, [`Executor::seed_warm_decode_capture_shapes`] seeds that
10107    /// value's exact just-in-time shape for the identical decode binding
10108    /// signature, and the node no longer reports an unresolved-shape seam — the
10109    /// executor now *admits* the already-capture-safe node instead of rejecting
10110    /// it before consulting its kernel. Non-tautological: it asserts the
10111    /// before/after seam transition and the concrete seeded extent.
10112    #[test]
10113    fn warm_decode_seeding_admits_previously_unresolved_capture_safe_node() {
10114        use onnx_runtime_ir::{Attribute, static_shape};
10115
10116        let mut graph = Graph::new();
10117        graph.opset_imports.insert(String::new(), 13);
10118        // Range(start, limit, delta) with all three supplied at run time, so
10119        // static shape inference cannot pin the output length: it stays symbolic
10120        // (data-dependent) and `resolve_soft` omits it.
10121        let start = graph.create_named_value("start", DataType::Int64, static_shape([]));
10122        let limit = graph.create_named_value("limit", DataType::Int64, static_shape([]));
10123        let delta = graph.create_named_value("delta", DataType::Int64, static_shape([]));
10124        graph.add_input(start);
10125        graph.add_input(limit);
10126        graph.add_input(delta);
10127        let len_sym = graph.intern_symbol("range_len");
10128        let r = graph.create_named_value("r", DataType::Int64, vec![len_sym.into()]);
10129        graph.insert_node(Node::new(
10130            NodeId(0),
10131            "Range",
10132            vec![Some(start), Some(limit), Some(delta)],
10133            vec![r],
10134        ));
10135        let y = graph.create_named_value("y", DataType::Float32, vec![len_sym.into()]);
10136        let mut cast = Node::new(NodeId(0), "Cast", vec![Some(r)], vec![y]);
10137        cast.attributes
10138            .insert("to".into(), Attribute::Int(DataType::Float32 as i64));
10139        graph.insert_node(cast);
10140        graph.add_output(y);
10141
10142        let mut exec = Executor::build(
10143            graph,
10144            Arc::new(WeightStore::new()),
10145            auto_detect_cpu_ep().unwrap(),
10146        )
10147        .unwrap();
10148        // Warm-decode capture-shape seeding is a device-graph-capture concern
10149        // (capture-capable EPs only), where the decode-plan memo is disabled by
10150        // construction (`device_type() != Cuda` gate). The memo-eligible CPU
10151        // eager path deliberately skips recording `capture_warm_shapes` (it never
10152        // captures). Disable the now-default-ON memo so this executor records the
10153        // warm shapes exactly as a capture-capable EP would at runtime.
10154        exec.set_decode_memo_enabled(false);
10155
10156        let zero = Tensor::from_raw(DataType::Int64, vec![], &0i64.to_le_bytes()).unwrap();
10157        let four = Tensor::from_raw(DataType::Int64, vec![], &4i64.to_le_bytes()).unwrap();
10158        let one = Tensor::from_raw(DataType::Int64, vec![], &1i64.to_le_bytes()).unwrap();
10159        let inputs = [("start", &zero), ("limit", &four), ("delta", &one)];
10160
10161        let cast_pi = exec
10162            .plan
10163            .iter()
10164            .position(|p| exec.graph.node(p.node_id).op_type == "Cast")
10165            .expect("plan contains the Cast node");
10166
10167        // --- Before any warmup: the Cast is an unresolved-shape eager seam. ---
10168        let bindings = exec
10169            .bind_symbols(&inputs, &ExternalBindings::default())
10170            .unwrap();
10171        let pre = exec.resolve_soft(&bindings);
10172        assert!(
10173            !pre.contains_key(&r) && !pre.contains_key(&y),
10174            "Range's runtime-length output (and its Cast) must be data-dependent (unresolved)"
10175        );
10176        let pre_seam = exec
10177            .node_capture_reason(&exec.plan[cast_pi], &pre)
10178            .and_then(|decline| decline.seam_reason);
10179        assert!(
10180            matches!(
10181                pre_seam,
10182                Some(SeamReason::UnresolvedInputShape) | Some(SeamReason::UnresolvedOutputShape)
10183            ),
10184            "without seeding the Cast must be an unresolved-shape seam; got {pre_seam:?}"
10185        );
10186
10187        // --- One eager warmup records the exact just-in-time shapes. ----------
10188        let out = exec.run(&inputs).unwrap();
10189        assert_eq!(out[0].to_vec_f32(), vec![0.0, 1.0, 2.0, 3.0]);
10190
10191        // --- After warmup: the identical signature seeds the warm shapes. -----
10192        let bindings2 = exec
10193            .bind_symbols(&inputs, &ExternalBindings::default())
10194            .unwrap();
10195        let mut post = exec.resolve_soft(&bindings2);
10196        assert!(
10197            !post.contains_key(&r),
10198            "resolve_soft alone still omits the data-dependent value"
10199        );
10200        exec.seed_warm_decode_capture_shapes(&mut post, &ExternalBindings::default());
10201        assert_eq!(
10202            post.get(&r),
10203            Some(&vec![4usize]),
10204            "warm seeding must restore Range's exact eager-resolved output shape"
10205        );
10206        assert_eq!(post.get(&y), Some(&vec![4usize]));
10207
10208        // The unresolved-shape seam is gone: the executor admits the node to the
10209        // shape gate (any remaining decline is a kernel-capability decision, not
10210        // a missing-shape rejection).
10211        let post_seam = exec
10212            .node_capture_reason(&exec.plan[cast_pi], &post)
10213            .and_then(|decline| decline.seam_reason);
10214        assert!(
10215            !matches!(
10216                post_seam,
10217                Some(SeamReason::UnresolvedInputShape) | Some(SeamReason::UnresolvedOutputShape)
10218            ),
10219            "warm-seeded decode shapes must clear the unresolved-shape seam; got {post_seam:?}"
10220        );
10221
10222        // A changed decode signature must NOT seed (pointer/capacity instability):
10223        // a phantom persistent input binding makes the current signature differ
10224        // from the warmup's, so the warm shapes are withheld and the value stays
10225        // unresolved rather than risk baking a stale shape into a captured graph.
10226        let mut mismatched = exec.resolve_soft(&bindings2);
10227        let mut other = ExternalBindings::default();
10228        other.inputs.insert(
10229            start,
10230            ExternalValue {
10231                dtype: DataType::Int64,
10232                shape: vec![],
10233                accepts_subshape: false,
10234                ptr: 0x1000 as *mut std::ffi::c_void,
10235                len: 8,
10236                alignment: 8,
10237                device: onnx_runtime_ir::DeviceId::cpu(),
10238            },
10239        );
10240        exec.seed_warm_decode_capture_shapes(&mut mismatched, &other);
10241        assert!(
10242            !mismatched.contains_key(&r),
10243            "a changed persistent-binding signature must withhold the warm seed"
10244        );
10245    }
10246
10247    /// A kernel that aborts device-graph *recording* (e.g. it advertises capture
10248    /// support but synchronizes, which CUDA rejects mid-capture) is quarantined
10249    /// by op-type so a single mislabeled kernel cannot abort the whole segmented
10250    /// capture. Once its `(domain, op_type)` is quarantined,
10251    /// [`Executor::node_capture_reason`] must force that node to a
10252    /// `CaptureRecordingFailed` eager seam even when its shapes are fully
10253    /// resolved and it would otherwise reach the kernel gate. Non-tautological:
10254    /// it asserts the *transition* from "not an unresolved-shape seam" to a
10255    /// forced-seam classification caused solely by the quarantine.
10256    #[test]
10257    fn quarantined_op_type_is_forced_to_a_capture_recording_failed_seam() {
10258        use onnx_runtime_ir::{Attribute, static_shape};
10259
10260        let mut graph = Graph::new();
10261        graph.opset_imports.insert(String::new(), 13);
10262        let x = graph.create_named_value("x", DataType::Int64, static_shape([4]));
10263        graph.add_input(x);
10264        let y = graph.create_named_value("y", DataType::Float32, static_shape([4]));
10265        let mut cast = Node::new(NodeId(0), "Cast", vec![Some(x)], vec![y]);
10266        cast.attributes
10267            .insert("to".into(), Attribute::Int(DataType::Float32 as i64));
10268        graph.insert_node(cast);
10269        graph.add_output(y);
10270
10271        let mut exec = Executor::build(
10272            graph,
10273            Arc::new(WeightStore::new()),
10274            auto_detect_cpu_ep().unwrap(),
10275        )
10276        .unwrap();
10277
10278        let cast_pi = exec
10279            .plan
10280            .iter()
10281            .position(|p| exec.graph.node(p.node_id).op_type == "Cast")
10282            .expect("plan contains the Cast node");
10283
10284        // Statically-shaped Cast: its I/O shapes resolve without any seeding, so
10285        // it is NOT an unresolved-shape seam and reaches the kernel gate.
10286        let xt = Tensor::from_raw(
10287            DataType::Int64,
10288            vec![4],
10289            &[0i64, 1, 2, 3]
10290                .iter()
10291                .flat_map(|v| v.to_le_bytes())
10292                .collect::<Vec<u8>>(),
10293        )
10294        .unwrap();
10295        let bindings = exec
10296            .bind_symbols(&[("x", &xt)], &ExternalBindings::default())
10297            .unwrap();
10298        let resolved = exec.resolve_soft(&bindings);
10299        let pre_seam = exec
10300            .node_capture_reason(&exec.plan[cast_pi], &resolved)
10301            .and_then(|decline| decline.seam_reason);
10302        assert!(
10303            !matches!(pre_seam, Some(SeamReason::CaptureRecordingFailed)),
10304            "a non-quarantined statically-shaped node must not be a recording-failed seam; \
10305             got {pre_seam:?}"
10306        );
10307
10308        // Quarantine the Cast op-type (as the capture retry loop does after a
10309        // kernel aborts recording) and re-check: it is now a forced eager seam
10310        // regardless of its resolved shapes or kernel capability.
10311        exec.capture_quarantine_ops
10312            .insert(("ai.onnx".to_string(), "Cast".to_string()));
10313        let post = exec.node_capture_reason(&exec.plan[cast_pi], &resolved);
10314        assert_eq!(
10315            post.and_then(|decline| decline.seam_reason),
10316            Some(SeamReason::CaptureRecordingFailed),
10317            "a quarantined op-type must be forced to a CaptureRecordingFailed eager seam"
10318        );
10319    }
10320
10321    // ===================================================================
10322    // F5 Stage 1 — steady-state decode-plan memo guard tests.
10323    // ===================================================================
10324
10325    /// A decode-like symbolic graph: input `x` of shape `[batch, seq]` (both
10326    /// symbolic) feeds an `Add(x, x)` whose output is length-*variant*, while an
10327    /// inline initializer `w` of static shape `[4]` feeds a `Mul(y, w)` whose
10328    /// output is length-*invariant*. This exercises both memo partitions.
10329    #[cfg(test)]
10330    struct DecodeMemoIds {
10331        batch: SymbolId,
10332        seq: SymbolId,
10333        x2: ValueId,
10334        ymul: ValueId,
10335    }
10336
10337    #[cfg(test)]
10338    fn decode_memo_test_graph() -> (Graph, DecodeMemoIds) {
10339        use onnx_runtime_ir::TensorData;
10340        let mut graph = Graph::new();
10341        graph.opset_imports.insert(String::new(), 17);
10342        let batch = graph.intern_symbol("batch");
10343        let seq = graph.intern_symbol("seq");
10344
10345        // Length-variant spine: x[batch, seq] -> Add(x, x) -> x2[batch, seq].
10346        let x = graph.create_named_value("x", DataType::Float32, vec![batch.into(), seq.into()]);
10347        graph.add_input(x);
10348        let x2 =
10349            graph.create_named_value("x2", DataType::Float32, vec![batch.into(), seq.into()]);
10350        graph.insert_node(Node::new(NodeId(0), "Add", vec![Some(x), Some(x)], vec![x2]));
10351        graph.add_output(x2);
10352
10353        // Length-invariant tail: y[4] (required input) * w[4] (initializer).
10354        let y = graph.create_named_value("y", DataType::Float32, static_shape([4]));
10355        graph.add_input(y);
10356        let w = graph.create_named_value("w", DataType::Float32, static_shape([4]));
10357        graph.set_initializer(
10358            w,
10359            WeightRef::Inline(TensorData::from_raw(
10360                DataType::Float32,
10361                vec![4],
10362                [1.0f32, 2.0, 3.0, 4.0]
10363                    .into_iter()
10364                    .flat_map(f32::to_le_bytes)
10365                    .collect(),
10366            )),
10367        );
10368        let ymul = graph.create_named_value("ymul", DataType::Float32, static_shape([4]));
10369        graph.insert_node(Node::new(NodeId(0), "Mul", vec![Some(y), Some(w)], vec![ymul]));
10370        graph.add_output(ymul);
10371        (graph, DecodeMemoIds { batch, seq, x2, ymul })
10372    }
10373
10374    #[cfg(test)]
10375    fn decode_memo_run(exec: &mut Executor, batch: usize, seq: usize) -> Vec<Vec<f32>> {
10376        let x = Tensor::from_f32(
10377            &[batch, seq],
10378            &(0..batch * seq).map(|i| i as f32 + 1.0).collect::<Vec<_>>(),
10379        )
10380        .unwrap();
10381        let y = Tensor::from_f32(&[4], &[10.0, 20.0, 30.0, 40.0]).unwrap();
10382        exec.run(&[("x", &x), ("y", &y)])
10383            .unwrap()
10384            .into_iter()
10385            .map(|t| t.to_vec_f32())
10386            .collect()
10387    }
10388
10389    /// The default-ON master switch (Ripley's authoritative GO): the memo is
10390    /// enabled unless `ONNX_GENAI_DECODE_MEMO` is an explicit OFF value
10391    /// (`0`/`false`/`off`, case-insensitive, whitespace-trimmed). Unset, empty,
10392    /// and unrecognized values all fail safe toward the validated fast path (ON).
10393    #[test]
10394    fn decode_memo_env_default_on_unless_explicitly_disabled() {
10395        use std::ffi::OsString;
10396        use std::sync::{Mutex, OnceLock};
10397
10398        static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
10399        let _env_guard = ENV_LOCK
10400            .get_or_init(|| Mutex::new(()))
10401            .lock()
10402            .expect("decode-memo env lock");
10403
10404        struct RestoreEnv(Option<OsString>);
10405        impl Drop for RestoreEnv {
10406            fn drop(&mut self) {
10407                match self.0.take() {
10408                    // SAFETY: this test serializes all env mutations via ENV_LOCK.
10409                    Some(value) => unsafe {
10410                        std::env::set_var("ONNX_GENAI_DECODE_MEMO", value)
10411                    },
10412                    None => unsafe { std::env::remove_var("ONNX_GENAI_DECODE_MEMO") },
10413                }
10414            }
10415        }
10416        let _restore = RestoreEnv(std::env::var_os("ONNX_GENAI_DECODE_MEMO"));
10417
10418        // Unset ⇒ ON (default).
10419        // SAFETY: guarded by ENV_LOCK above.
10420        unsafe { std::env::remove_var("ONNX_GENAI_DECODE_MEMO") };
10421        assert!(decode_memo_env_enabled(), "unset must default ON");
10422
10423        // Explicit OFF values (case-insensitive, trimmed) ⇒ OFF.
10424        for off in ["0", "false", "off", "FALSE", "Off", "  0  ", "\tOFF\n"] {
10425            // SAFETY: guarded by ENV_LOCK above.
10426            unsafe { std::env::set_var("ONNX_GENAI_DECODE_MEMO", off) };
10427            assert!(!decode_memo_env_enabled(), "{off:?} must disable the memo");
10428        }
10429
10430        // Explicit ON values and any unrecognized/empty value ⇒ ON (fail-safe).
10431        for on in ["1", "true", "on", "ON", "True", " on ", "", "  ", "yes", "2", "banana"] {
10432            // SAFETY: guarded by ENV_LOCK above.
10433            unsafe { std::env::set_var("ONNX_GENAI_DECODE_MEMO", on) };
10434            assert!(decode_memo_env_enabled(), "{on:?} must keep the memo ON");
10435        }
10436    }
10437
10438    /// Plan-invalidation unit test (F5 Stage 1 merge gate #2): prefill→decode
10439    /// rebuilds (signature changed), pure length growth replays (signature
10440    /// stable), and a batch change rebuilds.
10441    #[test]
10442    fn decode_plan_memo_rebuilds_and_replays() {
10443        let (graph, ids) = decode_memo_test_graph();
10444        let mut exec =
10445            Executor::build(graph, Arc::new(WeightStore::new()), auto_detect_cpu_ep().unwrap())
10446                .unwrap();
10447        exec.set_decode_memo_enabled(true);
10448
10449        // "Prefill" step [1, 4]: first observation, nothing to diff yet.
10450        decode_memo_run(&mut exec, 1, 4);
10451        assert_eq!(exec.decode_memo_action(), DecodeMemoAction::Primed);
10452        assert!(exec.decode_memo.is_none());
10453
10454        // First decode step [1, 5]: diffs against the prefill step and (re)builds
10455        // the memo — the prefill→decode transition changed the plan signature.
10456        decode_memo_run(&mut exec, 1, 5);
10457        assert_eq!(exec.decode_memo_action(), DecodeMemoAction::Rebuilt);
10458        let memo = exec.decode_memo.as_ref().expect("memo built");
10459        // `seq` grew (4→5) so it is varying; `batch` stayed 1 so it is invariant.
10460        assert!(memo.decode_varying.contains(&ids.seq));
10461        assert!(!memo.decode_varying.contains(&ids.batch));
10462        // The invariant tail (`ymul`) is cached; the variant spine (`x2`) is not.
10463        assert!(memo.invariant_shapes.contains_key(&ids.ymul));
10464        assert!(memo.variant_values.contains(&ids.x2));
10465
10466        // Two decode steps at growing L both REPLAY: signature stable (only the
10467        // varying `seq` grows), invariant map reused, variant map re-resolved.
10468        let out6 = decode_memo_run(&mut exec, 1, 6);
10469        assert_eq!(exec.decode_memo_action(), DecodeMemoAction::Replayed);
10470        let out7 = decode_memo_run(&mut exec, 1, 7);
10471        assert_eq!(exec.decode_memo_action(), DecodeMemoAction::Replayed);
10472        // The variant tail was genuinely re-resolved to the new length.
10473        assert_eq!(out6[0].len(), 6);
10474        assert_eq!(out7[0].len(), 7);
10475
10476        // A batch change [1, ·] → [2, ·] REBUILDS: `batch` was a non-varying
10477        // binding, so the change fails the replay guard and forces a rebuild.
10478        decode_memo_run(&mut exec, 2, 7);
10479        assert_eq!(exec.decode_memo_action(), DecodeMemoAction::Rebuilt);
10480        let memo = exec.decode_memo.as_ref().expect("memo rebuilt");
10481        assert_eq!(memo.reference_bindings.get(&ids.batch), Some(&2));
10482    }
10483
10484    /// Token-exact lock (F5 Stage 1 merge gate #1): over ≥128 growing-length CPU
10485    /// decode steps the memo-ON output is bit-identical to the memo-OFF output,
10486    /// step for step. `decode_memo_verify` (forced on by
10487    /// `set_decode_memo_enabled`) additionally asserts every replayed shape map
10488    /// equals a fresh `resolve_soft`.
10489    ///
10490    /// This locks the property the memo must never violate: it changes only
10491    /// shape-resolution bookkeeping, never a produced byte. A full real-model
10492    /// engine-level lock is available by running the (ignored) decode-lock
10493    /// tests with `ONNX_GENAI_DECODE_MEMO=1`; this executor-level lock proves
10494    /// memo==non-memo bit-exactness on the real CPU kernels without a model
10495    /// fixture.
10496    #[test]
10497    fn decode_plan_memo_is_token_exact_over_128_steps() {
10498        const STEPS: usize = 130;
10499
10500        let (off_graph, _) = decode_memo_test_graph();
10501        let mut off = Executor::build(
10502            off_graph,
10503            Arc::new(WeightStore::new()),
10504            auto_detect_cpu_ep().unwrap(),
10505        )
10506        .unwrap();
10507        // Memo is default-ON now; explicitly disable it for the reference run.
10508        off.set_decode_memo_enabled(false);
10509        assert!(!off.decode_memo_enabled);
10510
10511        let (on_graph, _) = decode_memo_test_graph();
10512        let mut on = Executor::build(
10513            on_graph,
10514            Arc::new(WeightStore::new()),
10515            auto_detect_cpu_ep().unwrap(),
10516        )
10517        .unwrap();
10518        on.set_decode_memo_enabled(true);
10519
10520        let mut replays = 0usize;
10521        for step in 0..STEPS {
10522            let seq = 3 + step; // strictly growing sequence length
10523            let ref_out = decode_memo_run(&mut off, 1, seq);
10524            let memo_out = decode_memo_run(&mut on, 1, seq);
10525            assert_eq!(
10526                ref_out, memo_out,
10527                "decode-plan memo diverged from the reference at step {step} (seq={seq})"
10528            );
10529            if on.decode_memo_action() == DecodeMemoAction::Replayed {
10530                replays += 1;
10531            }
10532        }
10533        // Steady state must actually engage the replay fast path for the bulk of
10534        // the run (priming costs the first two steps).
10535        assert!(
10536            replays >= STEPS - 2,
10537            "expected the memo to replay in steady state; only {replays}/{STEPS} replays"
10538        );
10539    }
10540
10541    /// Proof-of-fire (F5 Stage 1): a PERSISTENT device-I/O binding shaped like a
10542    /// KV cache — input+output aliased, length `L` growing by one each step —
10543    /// must PRIME then REPLAY under the memo. This is the regression lock for
10544    /// Ripley's finding that the memo reported `primed=0 rebuilt=0 replayed=0` on
10545    /// the real native decode path because the old gate excluded any run carrying
10546    /// external bindings. With `decode_memo_verify` on (forced by
10547    /// `set_decode_memo_enabled`), every replay is also asserted byte-identical to
10548    /// a fresh `resolve_soft`, so this doubles as a token-exact lock on the
10549    /// persistent-binding path.
10550    #[test]
10551    fn decode_plan_memo_fires_on_persistent_kv_bindings() {
10552        use onnx_runtime_ir::{TensorData, static_shape};
10553
10554        // KV-like length-variant spine: kv[L] -> Relu -> kvout[L], aliased into
10555        // one persistent device buffer. Static invariant tail: y[4] * w[4].
10556        let mut graph = Graph::new();
10557        graph.opset_imports.insert(String::new(), 17);
10558        let l = graph.intern_symbol("L");
10559        let kv = graph.create_named_value("kv", DataType::Float32, vec![l.into()]);
10560        graph.add_input(kv);
10561        let kvout = graph.create_named_value("kvout", DataType::Float32, vec![l.into()]);
10562        graph.insert_node(Node::new(NodeId(0), "Relu", vec![Some(kv)], vec![kvout]));
10563        graph.add_output(kvout);
10564
10565        let y = graph.create_named_value("y", DataType::Float32, static_shape([4]));
10566        graph.add_input(y);
10567        let w = graph.create_named_value("w", DataType::Float32, static_shape([4]));
10568        graph.set_initializer(
10569            w,
10570            WeightRef::Inline(TensorData::from_raw(
10571                DataType::Float32,
10572                vec![4],
10573                [1.0f32, 2.0, 3.0, 4.0]
10574                    .into_iter()
10575                    .flat_map(f32::to_le_bytes)
10576                    .collect(),
10577            )),
10578        );
10579        let ymul = graph.create_named_value("ymul", DataType::Float32, static_shape([4]));
10580        graph.insert_node(Node::new(NodeId(0), "Mul", vec![Some(y), Some(w)], vec![ymul]));
10581        graph.add_output(ymul);
10582
10583        let mut exec = Executor::build(
10584            graph,
10585            Arc::new(WeightStore::new()),
10586            auto_detect_cpu_ep().unwrap(),
10587        )
10588        .unwrap();
10589        exec.set_decode_memo_enabled(true);
10590
10591        // Pre-allocate the KV buffer to a fixed capacity (stable pointer) and grow
10592        // only the logical length each step — the pre-allocated-KV-cache case.
10593        const CAP: usize = 128;
10594        let mut kv_binding = exec
10595            .allocate_device_binding(
10596                "kv".into(),
10597                Some("kvout".into()),
10598                DataType::Float32,
10599                vec![CAP],
10600                vec![1],
10601            )
10602            .unwrap();
10603        let ptr0 = kv_binding.device_ptr();
10604        let y_tensor = Tensor::from_f32(&[4], &[10.0, 20.0, 30.0, 40.0]).unwrap();
10605
10606        let mut replays = 0usize;
10607        for step in 0..8usize {
10608            let len = 4 + step; // strictly growing KV length L
10609            kv_binding.set_logical_shape(vec![len]).unwrap();
10610            let bytes: Vec<u8> = (0..len).flat_map(|i| (i as f32).to_le_bytes()).collect();
10611            kv_binding.write_bytes(0, &bytes).unwrap();
10612            exec.run_with_device_bindings(
10613                &[("y", &y_tensor)],
10614                std::slice::from_mut(&mut kv_binding),
10615            )
10616            .unwrap();
10617            if exec.decode_memo_action() == DecodeMemoAction::Replayed {
10618                replays += 1;
10619            }
10620        }
10621
10622        // The pointer stayed stable (a growing-length view, not a realloc), so
10623        // the memo must not have been invalidated by capacity noise.
10624        assert_eq!(kv_binding.device_ptr(), ptr0);
10625
10626        let (primed, rebuilt, replayed, ineligible) = exec.decode_memo_counts();
10627        assert_eq!(
10628            ineligible, 0,
10629            "persistent-KV decode must be memo-eligible, not excluded (the F5 regression)"
10630        );
10631        assert!(primed >= 1, "the first decode step must prime the memo");
10632        assert!(
10633            replayed >= 1,
10634            "steady persistent-KV decode must replay the memo \
10635             (primed={primed} rebuilt={rebuilt} replayed={replayed})"
10636        );
10637        assert_eq!(replays as u64, replayed);
10638    }
10639
10640    /// F5 Stage 2 test graph. The persistent-KV spine (`kv[L] -> Relu -> kvout[L]`)
10641    /// keeps the memo eligible, and an invariant tail (`y[4] * w[4] -> ymul[4]`)
10642    /// feeds a `Reshape(ymul, [2,2]) -> yview` — a pure invariant zero-copy view.
10643    /// Stage 2 must reinstate `yview` and elide the Reshape's dispatch on replay.
10644    /// Returns the graph and the `ymul` value id (the view's source buffer).
10645    #[cfg(test)]
10646    fn stage2_view_graph() -> (Graph, ValueId) {
10647        use onnx_runtime_ir::{TensorData, static_shape};
10648        let mut graph = Graph::new();
10649        graph.opset_imports.insert(String::new(), 17);
10650        let l = graph.intern_symbol("L");
10651        let kv = graph.create_named_value("kv", DataType::Float32, vec![l.into()]);
10652        graph.add_input(kv);
10653        let kvout = graph.create_named_value("kvout", DataType::Float32, vec![l.into()]);
10654        graph.insert_node(Node::new(NodeId(0), "Relu", vec![Some(kv)], vec![kvout]));
10655        graph.add_output(kvout);
10656
10657        let y = graph.create_named_value("y", DataType::Float32, static_shape([4]));
10658        graph.add_input(y);
10659        let w = graph.create_named_value("w", DataType::Float32, static_shape([4]));
10660        graph.set_initializer(
10661            w,
10662            WeightRef::Inline(TensorData::from_raw(
10663                DataType::Float32,
10664                vec![4],
10665                [1.0f32, 2.0, 3.0, 4.0]
10666                    .into_iter()
10667                    .flat_map(f32::to_le_bytes)
10668                    .collect(),
10669            )),
10670        );
10671        let ymul = graph.create_named_value("ymul", DataType::Float32, static_shape([4]));
10672        graph.insert_node(Node::new(NodeId(0), "Mul", vec![Some(y), Some(w)], vec![ymul]));
10673
10674        // Reshape ymul[4] -> yview[2,2] through a constant shape initializer, which
10675        // the CPU Reshape kernel serves as a zero-copy view over `ymul`'s buffer.
10676        let yshape = graph.create_named_value("yshape", DataType::Int64, static_shape([2]));
10677        graph.set_initializer(
10678            yshape,
10679            WeightRef::Inline(TensorData::from_raw(
10680                DataType::Int64,
10681                vec![2],
10682                [2i64, 2].into_iter().flat_map(i64::to_le_bytes).collect(),
10683            )),
10684        );
10685        let yview = graph.create_named_value("yview", DataType::Float32, static_shape([2, 2]));
10686        graph.insert_node(Node::new(
10687            NodeId(0),
10688            "Reshape",
10689            vec![Some(ymul), Some(yshape)],
10690            vec![yview],
10691        ));
10692        graph.add_output(yview);
10693        (graph, ymul)
10694    }
10695
10696    /// Run one persistent-KV decode step against [`stage2_view_graph`]: grow the KV
10697    /// length to `len`, feed a fresh `y` (so `ymul` — and thus the elided `yview` —
10698    /// varies each step, proving the reinstated view reads the freshly computed
10699    /// source bytes), and return the materialized `yview` output.
10700    #[cfg(test)]
10701    fn stage2_run(exec: &mut Executor, kv_binding: &mut DeviceIoBinding, len: usize, y_bias: f32) -> Vec<f32> {
10702        kv_binding.set_logical_shape(vec![len]).unwrap();
10703        let bytes: Vec<u8> = (0..len).flat_map(|i| (i as f32).to_le_bytes()).collect();
10704        kv_binding.write_bytes(0, &bytes).unwrap();
10705        let y = Tensor::from_f32(
10706            &[4],
10707            &[y_bias + 1.0, y_bias + 2.0, y_bias + 3.0, y_bias + 4.0],
10708        )
10709        .unwrap();
10710        let outs = exec
10711            .run_with_device_bindings(&[("y", &y)], std::slice::from_mut(kv_binding))
10712            .unwrap();
10713        // Graph outputs are [kvout (bound → None), yview (returned)].
10714        outs.into_iter()
10715            .flatten()
10716            .next()
10717            .expect("yview output")
10718            .to_vec_f32()
10719    }
10720
10721    #[cfg(test)]
10722    fn stage2_kv_binding(exec: &Executor) -> DeviceIoBinding {
10723        exec.allocate_device_binding(
10724            "kv".into(),
10725            Some("kvout".into()),
10726            DataType::Float32,
10727            vec![256],
10728            vec![1],
10729        )
10730        .unwrap()
10731    }
10732
10733    /// F5 Stage 2 proof-of-fire + token-exact lock. Over ≥128 growing-length
10734    /// persistent-KV decode steps the invariant `Reshape` view is reinstated and
10735    /// its dispatch elided (`views_reused`/`dispatch_elided` both grow), and the
10736    /// memo-ON `yview` output is bit-identical to the memo-OFF reference every
10737    /// step (with `y` — and therefore the view's source `ymul` — changing each
10738    /// step, so a stale alias would immediately diverge). `decode_memo_verify`
10739    /// (forced on by `set_decode_memo_enabled`) additionally asserts every
10740    /// reinstated view equals a freshly built one in-flight.
10741    #[test]
10742    fn decode_view_plan_fires_and_is_token_exact_over_128_steps() {
10743        const STEPS: usize = 130;
10744
10745        let (off_graph, _) = stage2_view_graph();
10746        let mut off = Executor::build(
10747            off_graph,
10748            Arc::new(WeightStore::new()),
10749            auto_detect_cpu_ep().unwrap(),
10750        )
10751        .unwrap();
10752        // Memo is default-ON now; explicitly disable it for the reference run.
10753        off.set_decode_memo_enabled(false);
10754        assert!(!off.decode_memo_enabled, "reference must run memo-OFF");
10755        let mut off_kv = stage2_kv_binding(&off);
10756
10757        let (on_graph, _) = stage2_view_graph();
10758        let mut on = Executor::build(
10759            on_graph,
10760            Arc::new(WeightStore::new()),
10761            auto_detect_cpu_ep().unwrap(),
10762        )
10763        .unwrap();
10764        on.set_decode_memo_enabled(true);
10765        let mut on_kv = stage2_kv_binding(&on);
10766
10767        for step in 0..STEPS {
10768            let len = 4 + step; // strictly growing KV length L
10769            let bias = step as f32; // vary the invariant-shape source each step
10770            let ref_out = stage2_run(&mut off, &mut off_kv, len, bias);
10771            let memo_out = stage2_run(&mut on, &mut on_kv, len, bias);
10772            assert_eq!(
10773                ref_out, memo_out,
10774                "Stage 2 view reuse diverged from the reference at step {step} (L={len})"
10775            );
10776        }
10777
10778        let (views_reused, dispatch_elided) = on.decode_view_plan_counts();
10779        assert!(
10780            views_reused > 0 && dispatch_elided > 0,
10781            "Stage 2 must fire on steady decode (views_reused={views_reused}, \
10782             dispatch_elided={dispatch_elided})"
10783        );
10784        // Non-vacuous: the reshape view must be reused/elided on the bulk of steps
10785        // (priming + first rebuild cost the first few steps).
10786        assert!(
10787            views_reused as usize >= STEPS - 4,
10788            "expected steady Stage 2 reuse; only {views_reused}/{STEPS} views reused"
10789        );
10790        assert!(
10791            on.decode_view_plan.is_some(),
10792            "the cached view plan must survive steady-state replay"
10793        );
10794    }
10795
10796    /// F5 Stage 2 buffer-identity invalidation lock. If a cached view's source
10797    /// buffer is reallocated to a different base pointer between steps — the exact
10798    /// hazard Stage 1 could ignore but Stage 2 cannot — the plan MUST detect the
10799    /// signature mismatch, decline to reinstate the (now stale) alias, and fall
10800    /// back to a full dispatch. The step's output must still be correct (no
10801    /// dangling/stale view served) and the reuse counter must NOT advance.
10802    #[test]
10803    fn decode_view_plan_rebuilds_on_source_buffer_move() {
10804        use onnx_runtime_ir::TensorLayout;
10805
10806        let (off_graph, _) = stage2_view_graph();
10807        let mut off = Executor::build(
10808            off_graph,
10809            Arc::new(WeightStore::new()),
10810            auto_detect_cpu_ep().unwrap(),
10811        )
10812        .unwrap();
10813        let mut off_kv = stage2_kv_binding(&off);
10814
10815        let (on_graph, ymul) = stage2_view_graph();
10816        let mut on = Executor::build(
10817            on_graph,
10818            Arc::new(WeightStore::new()),
10819            auto_detect_cpu_ep().unwrap(),
10820        )
10821        .unwrap();
10822        on.set_decode_memo_enabled(true);
10823        let mut on_kv = stage2_kv_binding(&on);
10824
10825        // Warm up so the view plan is built and firing.
10826        for step in 0..6usize {
10827            let len = 4 + step;
10828            let bias = step as f32;
10829            let r = stage2_run(&mut off, &mut off_kv, len, bias);
10830            let m = stage2_run(&mut on, &mut on_kv, len, bias);
10831            assert_eq!(r, m, "warmup diverged at step {step}");
10832        }
10833        assert!(
10834            on.decode_view_plan.is_some(),
10835            "view plan must be built before the realloc test"
10836        );
10837        let (reused_before, _) = on.decode_view_plan_counts();
10838
10839        // Forcibly MOVE the view's source buffer (`ymul`) to a fresh allocation of
10840        // the same capacity — a base-pointer change the plan's signature must catch.
10841        let old = on.buffers.remove(&ymul).expect("ymul buffer");
10842        let cap = old.len();
10843        on.ep.deallocate(old).unwrap();
10844        let fresh = on
10845            .ep
10846            .allocate(cap, TensorLayout::contiguous().alignment)
10847            .unwrap();
10848        let moved_ptr = fresh.as_ptr() as usize;
10849        on.buffers.insert(ymul, fresh);
10850        // Sanity: the plan's recorded source pointer no longer matches.
10851        assert!(
10852            !on.stage2_buffer_sig_matches(on.decode_view_plan.as_ref().unwrap()),
10853            "the forced realloc must break the buffer-identity signature"
10854        );
10855
10856        // Next step: Stage 2 must decline reuse (sig mismatch) yet stay correct.
10857        let len = 4 + 6;
10858        let bias = 6.0f32;
10859        let ref_out = stage2_run(&mut off, &mut off_kv, len, bias);
10860        let memo_out = stage2_run(&mut on, &mut on_kv, len, bias);
10861        assert_eq!(
10862            ref_out, memo_out,
10863            "a moved source buffer must force a rebuild, never serve a stale view"
10864        );
10865        let (reused_after, _) = on.decode_view_plan_counts();
10866        assert_eq!(
10867            reused_after, reused_before,
10868            "the mismatched step must NOT reuse cached views (would be stale)"
10869        );
10870        // The freshly computed ymul must live in a real buffer again (the moved one
10871        // or a self-healed reallocation), never left dangling.
10872        let healed = on.buffers.get(&ymul).expect("ymul rebound").as_ptr() as usize;
10873        assert!(healed == moved_ptr || healed != 0, "ymul must be backed");
10874    }
10875}