Skip to main content

onnx_runtime_shape_inference/
context.rs

1//! The inference context handed to each op rule, plus the supporting type
2//! model: [`TypeInfo`], [`TypedShape`], [`MergePolicy`], and the
3//! [`SymbolInterner`] that lowers derived [`DimExpr`]s back to IR [`Dim`]s.
4
5use std::collections::HashMap;
6
7use onnx_runtime_ir::{DataType, Dim, Node, SymbolId, ValueId, normalize_domain};
8
9use crate::dim_expr::DimExpr;
10use crate::error::ShapeInferError;
11use crate::infer::ANON_SYMBOL_FLOOR;
12use crate::shape_data::ShapeData;
13
14/// An inferred shape: an ordered list of symbolic dimension expressions. The
15/// rank is always known (unknown-rank tensors are represented by the *absence*
16/// of a [`TypeInfo`], never by a `TypedShape`).
17pub type TypedShape = Vec<DimExpr>;
18
19/// The inferred type of a value: element dtype plus a symbolic shape.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct TypeInfo {
22    pub dtype: DataType,
23    pub shape: TypedShape,
24}
25
26impl TypeInfo {
27    /// A new type info from a dtype and shape.
28    pub fn new(dtype: DataType, shape: TypedShape) -> Self {
29        Self { dtype, shape }
30    }
31
32    /// The rank (number of dimensions).
33    pub fn rank(&self) -> usize {
34        self.shape.len()
35    }
36}
37
38/// A tensor leaf inside a [`ValueType`] container element type.
39///
40/// Unlike the top-level [`TypeInfo`] used by the tensor-only path, the `shape`
41/// is *optional*: a container producer such as `SequenceEmpty` knows only the
42/// element `dtype`, never its rank. Representing that honestly (rather than
43/// fabricating a bogus shape) is why this type exists separately from
44/// [`TypeInfo`]. A `TensorType` whose `shape` is known converts to and from a
45/// [`TypeInfo`] losslessly.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct TensorType {
48    pub dtype: DataType,
49    /// `None` when the rank/shape is unknown (a dtype-only tensor).
50    pub shape: Option<TypedShape>,
51}
52
53impl TensorType {
54    /// A tensor leaf with a known shape.
55    pub fn new(dtype: DataType, shape: TypedShape) -> Self {
56        Self {
57            dtype,
58            shape: Some(shape),
59        }
60    }
61
62    /// A tensor leaf whose dtype is known but whose shape is not.
63    pub fn dtype_only(dtype: DataType) -> Self {
64        Self { dtype, shape: None }
65    }
66
67    /// The full [`TypeInfo`], available only when the shape is known.
68    pub fn to_type_info(&self) -> Option<TypeInfo> {
69        self.shape
70            .as_ref()
71            .map(|shape| TypeInfo::new(self.dtype, shape.clone()))
72    }
73}
74
75impl From<TypeInfo> for TensorType {
76    fn from(type_info: TypeInfo) -> Self {
77        Self {
78            dtype: type_info.dtype,
79            shape: Some(type_info.shape),
80        }
81    }
82}
83
84/// The full type of a value: a tensor, or a container whose element type is
85/// itself a [`ValueType`].
86///
87/// ONNX values are tensors in the overwhelming majority of graphs; the tensor
88/// path never materialises a `ValueType` at all (a value with no recorded
89/// `ValueType` is, by construction, a plain tensor). The container variants
90/// exist only so `Sequence`/`Optional`/`Map` operators can propagate their
91/// element types. This layer is *additive*: it wraps, and never replaces,
92/// [`TypeInfo`], so the tensor-only path stays byte-identical.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub enum ValueType {
95    /// A tensor value.
96    Tensor(TensorType),
97    /// A homogeneous sequence of `element`-typed values.
98    Sequence(Box<ValueType>),
99    /// An optional value that is either present as `element` or absent.
100    Optional(Box<ValueType>),
101    /// A map from `key` (an integer or string dtype) to `value`-typed values.
102    Map(DataType, Box<ValueType>),
103}
104
105impl ValueType {
106    /// A tensor value with a known shape.
107    pub fn tensor(dtype: DataType, shape: TypedShape) -> Self {
108        Self::Tensor(TensorType::new(dtype, shape))
109    }
110
111    /// A sequence whose elements have type `element`.
112    pub fn sequence(element: ValueType) -> Self {
113        Self::Sequence(Box::new(element))
114    }
115
116    /// The tensor leaf, when this value is a tensor.
117    pub fn as_tensor(&self) -> Option<&TensorType> {
118        match self {
119            Self::Tensor(tensor) => Some(tensor),
120            _ => None,
121        }
122    }
123
124    /// The element type, when this value is a sequence.
125    pub fn as_sequence_element(&self) -> Option<&ValueType> {
126        match self {
127            Self::Sequence(element) => Some(element),
128            _ => None,
129        }
130    }
131}
132
133/// The resolved inference state of a single input or output slot: an optional
134/// type and an optional [`ShapeData`] side-value.
135#[derive(Clone, Debug, Default)]
136pub struct NodeIo {
137    pub type_info: Option<TypeInfo>,
138    pub shape_data: Option<ShapeData>,
139    /// The container type of this slot, when it is a `Sequence`/`Optional`/`Map`
140    /// value. `None` for plain tensors — the overwhelming common case — which
141    /// keeps the tensor-only path byte-identical.
142    pub value_type: Option<ValueType>,
143}
144
145impl NodeIo {
146    /// An i/o slot carrying only a type.
147    pub fn typed(type_info: TypeInfo) -> Self {
148        Self {
149            type_info: Some(type_info),
150            shape_data: None,
151            value_type: None,
152        }
153    }
154
155    /// An i/o slot carrying a container [`ValueType`].
156    pub fn container(value_type: ValueType) -> Self {
157        Self {
158            type_info: None,
159            shape_data: None,
160            value_type: Some(value_type),
161        }
162    }
163}
164
165/// How to reconcile an inferred shape with a value's declared shape.
166#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
167pub enum MergePolicy {
168    /// Prefer the more specific dimension and keep going; never error on a
169    /// disagreement. This is the robust default used for whole-graph inference.
170    #[default]
171    Permissive,
172    /// Raise [`ShapeInferError::ShapeConflict`] / [`ShapeInferError::RankConflict`]
173    /// on a *concrete* disagreement between inferred and declared shapes.
174    /// Symbolic differences are treated as naming and never conflict.
175    Strict,
176}
177
178/// Allocates and interns fresh symbolic dimensions, and lowers [`DimExpr`]s to
179/// IR [`Dim`]s.
180///
181/// A derived dimension that is neither a pure constant nor a bare symbol (e.g.
182/// `floor((d-k)/s)+1` where `d` is symbolic) cannot be stored in the IR's
183/// [`Dim`] enum. Such an expression is assigned a *fresh* symbol; because
184/// [`DimExpr`] is canonical, two structurally-identical derived dimensions
185/// intern to the **same** symbol and stay unified across the graph.
186#[derive(Debug)]
187pub struct SymbolInterner {
188    next: u32,
189    /// The floor `next` started at: every symbol id `>= initial_floor` was
190    /// minted by inference (an anonymous/derived/data-dependent symbol), while
191    /// ids below it are graph-declared roots (`batch`, `seq`, KV length, …).
192    /// Persisted so a consumer (the CUDA-graph capture classifier's fail-safe
193    /// mode) can tell a *provably-rooted* symbol from an inference-minted one.
194    initial_floor: u32,
195    cache: HashMap<DimExpr, SymbolId>,
196    /// Symbols minted during inference, to be registered on the graph.
197    fresh: Vec<SymbolId>,
198    /// Every `(loser, winner)` symbol pair that [`broadcast_dim`] unified when
199    /// broadcasting two *distinct* symbolic dimensions onto one representative.
200    /// This is additive bookkeeping: recording a pair never changes the returned
201    /// representative or any inferred dim, so inference output stays byte-
202    /// identical. Persisted onto [`Graph::symbol_unifications`] so downstream
203    /// consumers (e.g. capture-eligibility) can close over the equivalence
204    /// classes without re-implementing a partial copy of inference's unification.
205    ///
206    /// [`broadcast_dim`]: InferenceContext::broadcast_dim
207    /// [`Graph::symbol_unifications`]: onnx_runtime_ir::Graph::symbol_unifications
208    unifications: Vec<(SymbolId, SymbolId)>,
209    /// Every `(derived, source)` provenance edge recorded when [`lower`] interns
210    /// a non-bare *derived* [`DimExpr`] (e.g. `seq_kv * 8` from `Reshape`/
211    /// `Flatten`) to a fresh [`SymbolId`]: the fresh `derived` symbol depends on
212    /// each `source` symbol the expression was built from. This is the general
213    /// lineage record that closes the derived-symbol capture hole (a fresh
214    /// symbol built from a growing one must itself be treated as growing). Like
215    /// [`unifications`](Self::unifications) it is purely additive — `lower`
216    /// returns the same `Dim` regardless — so inference stays byte-identical.
217    /// Persisted onto [`Graph::symbol_derivations`].
218    ///
219    /// [`lower`]: Self::lower
220    /// [`Graph::symbol_derivations`]: onnx_runtime_ir::Graph::symbol_derivations
221    derivations: Vec<(SymbolId, SymbolId)>,
222    /// Symbols minted for an *unknowable* extent from which no source symbol
223    /// could be recovered — an arithmetic-overflow degrade or a nonsensical
224    /// negative extent (see [`lower`](Self::lower)). Such a symbol has no
225    /// provenance, so a conservative consumer must treat it as disqualifying
226    /// (eager) rather than assume it is constant. Persisted onto
227    /// [`Graph::symbol_opaque`](onnx_runtime_ir::Graph::symbol_opaque).
228    opaque: Vec<SymbolId>,
229}
230
231impl SymbolInterner {
232    /// A new interner that allocates symbol ids starting at `next` (which must
233    /// be greater than every symbol id already present in the graph).
234    pub fn new(next: u32) -> Self {
235        Self {
236            next,
237            initial_floor: next,
238            cache: HashMap::new(),
239            fresh: Vec::new(),
240            unifications: Vec::new(),
241            derivations: Vec::new(),
242            opaque: Vec::new(),
243        }
244    }
245
246    /// The floor id at/above which every symbol was minted by this inference
247    /// pass (see [`initial_floor`](Self::initial_floor)).
248    pub fn initial_floor(&self) -> u32 {
249        self.initial_floor
250    }
251
252    /// Record that inference unified two distinct symbolic dimensions onto a
253    /// single representative (the `(loser, winner)` substitution in
254    /// [`broadcast_dim`](InferenceContext::broadcast_dim)). Order is irrelevant
255    /// to consumers (they build an undirected equivalence relation); this is a
256    /// pure append and does not influence the inferred shape.
257    fn record_unification(&mut self, a: SymbolId, b: SymbolId) {
258        self.unifications.push((a, b));
259    }
260
261    /// Record that the fresh symbol `derived` was interned from an expression
262    /// built out of `source` (a directed provenance edge `derived -> source`).
263    /// Pure append; never influences the inferred shape.
264    fn record_derivation(&mut self, derived: SymbolId, source: SymbolId) {
265        self.derivations.push((derived, source));
266    }
267
268    /// Record that `sym` was minted for a genuinely unknowable extent (overflow
269    /// or negative degrade) with no recoverable source symbols.
270    fn record_opaque(&mut self, sym: SymbolId) {
271        self.opaque.push(sym);
272    }
273
274    /// The symbol pairs unified during inference (to persist on the graph).
275    pub fn unifications(&self) -> &[(SymbolId, SymbolId)] {
276        &self.unifications
277    }
278
279    /// The `(derived, source)` provenance edges recorded during inference (to
280    /// persist on the graph).
281    pub fn derivations(&self) -> &[(SymbolId, SymbolId)] {
282        &self.derivations
283    }
284
285    /// The opaque (unknowable-extent) symbols minted during inference.
286    pub fn opaque(&self) -> &[SymbolId] {
287        &self.opaque
288    }
289
290    /// Mint a brand-new opaque symbol (not tied to any expression).
291    pub fn fresh_symbol(&mut self) -> SymbolId {
292        let id = SymbolId(self.next);
293        // Saturating rather than wrapping: exhausting the u32 symbol space is
294        // adversarial/pathological, but must never wrap `next` back into the
295        // range of already-minted ids (which would alias symbols).
296        self.next = self.next.saturating_add(1);
297        self.fresh.push(id);
298        id
299    }
300
301    /// Mint a fresh opaque dimension expression.
302    pub fn fresh_dim(&mut self) -> DimExpr {
303        DimExpr::symbol(self.fresh_symbol())
304    }
305
306    /// Lower a [`DimExpr`] to an IR [`Dim`], interning derived expressions to a
307    /// stable fresh symbol.
308    ///
309    /// Whenever a *derived* (non-bare, non-const) expression is interned to a
310    /// fresh symbol, its symbol lineage is recorded (see
311    /// [`derivations`](Self::derivations)): the fresh symbol depends on every
312    /// constituent symbol of the expression. This is what lets a downstream
313    /// consumer close a growing/pinned set transitively across `Reshape`/
314    /// `Flatten`-style derived dims. Recording is purely additive: the returned
315    /// `Dim` is identical with or without it, so inference stays byte-identical.
316    pub fn lower(&mut self, expr: &DimExpr) -> Dim {
317        // An overflowed (unknown) expression has no representable value and must
318        // not alias other overflows via the cache: mint a distinct fresh symbol.
319        // The overflow sentinel has dropped its terms, so no source symbols are
320        // recoverable — mark the minted symbol OPAQUE so a conservative consumer
321        // treats it as disqualifying (never assumes it is constant/pinned).
322        if expr.is_overflow() {
323            let id = self.fresh_symbol();
324            self.record_opaque(id);
325            return Dim::Symbolic(id);
326        }
327        if let Some(n) = expr.as_const() {
328            if n >= 0 {
329                return Dim::Static(n as usize);
330            }
331            // A negative extent is nonsensical; degrade to a fresh symbol and
332            // mark it opaque (a pure constant carries no symbol lineage, but the
333            // degrade is unknowable, so err toward disqualifying).
334            let id = self.fresh_symbol();
335            self.record_opaque(id);
336            return Dim::Symbolic(id);
337        }
338        if let Some(s) = expr.as_symbol() {
339            return Dim::Symbolic(s);
340        }
341        if let Some(&id) = self.cache.get(expr) {
342            // Provenance was already recorded when `expr` was first interned in
343            // this pass (the interner is fresh per inference run, so a cache hit
344            // implies a prior insert this run); re-record for robustness. Consumers
345            // dedup, and `infer_graph_scoped` dedups before persisting.
346            self.record_expr_derivation(id, expr);
347            return Dim::Symbolic(id);
348        }
349        let id = self.fresh_symbol();
350        self.cache.insert(expr.clone(), id);
351        self.record_expr_derivation(id, expr);
352        Dim::Symbolic(id)
353    }
354
355    /// Record a provenance edge from the freshly-minted `derived` symbol to each
356    /// distinct symbol appearing in `expr`.
357    fn record_expr_derivation(&mut self, derived: SymbolId, expr: &DimExpr) {
358        let mut seen = std::collections::HashSet::new();
359        for source in expr.symbol_ids() {
360            if source != derived && seen.insert(source) {
361                self.record_derivation(derived, source);
362            }
363        }
364    }
365
366    /// The symbols minted during inference (to register on the graph).
367    pub fn fresh_symbols(&self) -> &[SymbolId] {
368        &self.fresh
369    }
370}
371
372/// The context passed to every op inference rule.
373///
374/// It exposes each input's inferred type and shape-data, lets a rule mint fresh
375/// symbolic dimensions and broadcast shapes, and collects the outputs the rule
376/// produces. Rules never touch the [`Graph`](onnx_runtime_ir::Graph) directly —
377/// they operate purely on this context, which makes them trivially unit
378/// testable in isolation.
379pub struct InferenceContext<'a> {
380    /// The node being inferred.
381    pub node: &'a Node,
382    opset_imports: &'a HashMap<String, u64>,
383    policy: MergePolicy,
384    inputs: Vec<NodeIo>,
385    outputs: Vec<NodeIo>,
386    interner: &'a mut SymbolInterner,
387}
388
389impl<'a> InferenceContext<'a> {
390    /// Build a context for `node` from its resolved `inputs` (aligned with
391    /// `node.inputs`, skipped slots carrying an empty [`NodeIo`]).
392    pub fn new(
393        node: &'a Node,
394        inputs: Vec<NodeIo>,
395        opset_imports: &'a HashMap<String, u64>,
396        policy: MergePolicy,
397        interner: &'a mut SymbolInterner,
398    ) -> Self {
399        let outputs = vec![NodeIo::default(); node.outputs.len()];
400        Self {
401            node,
402            opset_imports,
403            policy,
404            inputs,
405            outputs,
406            interner,
407        }
408    }
409
410    // === input access ===
411
412    /// The op type of the node.
413    pub fn op(&self) -> &str {
414        &self.node.op_type
415    }
416
417    /// The number of input slots (including skipped optional ones).
418    pub fn num_inputs(&self) -> usize {
419        self.inputs.len()
420    }
421
422    /// The number of output slots.
423    pub fn num_outputs(&self) -> usize {
424        self.outputs.len()
425    }
426
427    /// Whether input slot `i` is present (a value is connected).
428    pub fn has_input(&self, i: usize) -> bool {
429        self.node
430            .inputs
431            .get(i)
432            .map(Option::is_some)
433            .unwrap_or(false)
434    }
435
436    /// The inferred type of input `i`, if resolved.
437    pub fn input_type(&self, i: usize) -> Option<&TypeInfo> {
438        self.inputs.get(i)?.type_info.as_ref()
439    }
440
441    /// The container [`ValueType`] of input `i`, if it is a container value.
442    pub fn input_value_type(&self, i: usize) -> Option<&ValueType> {
443        self.inputs.get(i)?.value_type.as_ref()
444    }
445
446    /// The inferred shape of input `i`, if resolved.
447    pub fn input_shape(&self, i: usize) -> Option<&[DimExpr]> {
448        self.input_type(i).map(|t| t.shape.as_slice())
449    }
450
451    /// The inferred dtype of input `i`, if resolved.
452    pub fn input_dtype(&self, i: usize) -> Option<DataType> {
453        self.input_type(i).map(|t| t.dtype)
454    }
455
456    /// The inferred rank of input `i`, if resolved.
457    pub fn input_rank(&self, i: usize) -> Option<usize> {
458        self.input_type(i).map(TypeInfo::rank)
459    }
460
461    /// The propagated shape-data of input `i`, if any.
462    pub fn input_shape_data(&self, i: usize) -> Option<&ShapeData> {
463        self.inputs.get(i)?.shape_data.as_ref()
464    }
465
466    // === output production ===
467
468    /// Set the type of output `i`.
469    pub fn set_output_type(&mut self, i: usize, type_info: TypeInfo) {
470        if let Some(slot) = self.outputs.get_mut(i) {
471            slot.type_info = Some(type_info);
472        }
473    }
474
475    /// Set the container [`ValueType`] of output `i`.
476    pub fn set_output_value_type(&mut self, i: usize, value_type: ValueType) {
477        if let Some(slot) = self.outputs.get_mut(i) {
478            slot.value_type = Some(value_type);
479        }
480    }
481
482    /// Set the dtype and shape of output `i`.
483    pub fn set_output(&mut self, i: usize, dtype: DataType, shape: TypedShape) {
484        self.set_output_type(i, TypeInfo::new(dtype, shape));
485    }
486
487    /// Set the propagated shape-data of output `i`.
488    pub fn set_output_shape_data(&mut self, i: usize, data: ShapeData) {
489        if let Some(slot) = self.outputs.get_mut(i) {
490            slot.shape_data = Some(data);
491        }
492    }
493
494    /// Consume the context, returning the outputs the rule produced.
495    pub fn into_outputs(self) -> Vec<NodeIo> {
496        self.outputs
497    }
498
499    // === helpers available to rules ===
500
501    /// The active merge policy.
502    pub fn policy(&self) -> MergePolicy {
503        self.policy
504    }
505
506    /// The effective opset version for `domain`.
507    ///
508    /// When asking about the active node's own domain, a node-local
509    /// [`Node::version`](onnx_runtime_ir::Node::version) wins over the graph import. Other domains are resolved
510    /// from the graph-level imports because a node-local version describes only
511    /// that node's operator schema, not every domain a shape rule may consult.
512    pub fn opset(&self, domain: &str) -> u64 {
513        let domain = normalize_domain(domain);
514        if domain == self.node.domain
515            && let Some(version) = self.node.local_opset()
516        {
517            return version;
518        }
519        self.opset_imports.get(domain).copied().unwrap_or(1)
520    }
521
522    /// Mint a fresh opaque dimension.
523    pub fn fresh_dim(&mut self) -> DimExpr {
524        self.interner.fresh_dim()
525    }
526
527    /// Mutable access to the symbol interner, for shared container-type
528    /// unification helpers that mint fresh dims.
529    pub(crate) fn interner_mut(&mut self) -> &mut SymbolInterner {
530        self.interner
531    }
532
533    /// Broadcast two shapes under NumPy rules. Where two distinct symbolic dims
534    /// meet, keeps a deterministic representative symbol only when one side is
535    /// anonymous; two distinct *named* graph dims degrade to a fresh symbol (see
536    /// [`broadcast_dim`](Self::broadcast_dim)).
537    /// Errors only under [`MergePolicy::Strict`] on a concrete incompatibility.
538    pub fn broadcast(
539        &mut self,
540        a: &[DimExpr],
541        b: &[DimExpr],
542    ) -> Result<TypedShape, ShapeInferError> {
543        let rank = a.len().max(b.len());
544        let mut out = Vec::with_capacity(rank);
545        for axis in 0..rank {
546            // Align from the right; missing leading dims are implicitly `1`.
547            let da = dim_from_right(a, rank, axis);
548            let db = dim_from_right(b, rank, axis);
549            out.push(self.broadcast_dim(&da, &db)?);
550        }
551        Ok(out)
552    }
553
554    /// Broadcast a single pair of dimensions.
555    pub fn broadcast_dim(&mut self, a: &DimExpr, b: &DimExpr) -> Result<DimExpr, ShapeInferError> {
556        let ac = a.as_const();
557        let bc = b.as_const();
558        if ac == Some(1) {
559            return Ok(b.clone());
560        }
561        if bc == Some(1) {
562            return Ok(a.clone());
563        }
564        if a == b {
565            return Ok(a.clone());
566        }
567        match (ac, bc) {
568            (Some(x), Some(y)) => {
569                if x == y {
570                    Ok(a.clone())
571                } else if self.policy == MergePolicy::Strict {
572                    Err(ShapeInferError::Invalid {
573                        op: self.node.op_type.clone(),
574                        detail: format!("incompatible broadcast dims {x} and {y}"),
575                    })
576                } else {
577                    // Permissive: two provably-unequal, non-1 concrete extents
578                    // are genuinely incompatible. Rather than fabricate a
579                    // `max(x, y)` that matches neither operand, degrade to a
580                    // fresh symbol (an honest "unknown") so we never assert a
581                    // bogus concrete dimension.
582                    Ok(self.fresh_dim())
583                }
584            }
585            // A concrete non-`1` extent dominates a symbolic one (the symbol
586            // must broadcast up to it, or the model is invalid).
587            (Some(_), None) => Ok(a.clone()),
588            (None, Some(_)) => Ok(b.clone()),
589            // Two distinct symbolic dims. Broadcasting them is only well-defined
590            // if they are equal *or* one of them is 1 at runtime — and a bare
591            // symbol gives us no way to rule the latter out. Which of the two
592            // cases holds decides whether unifying them is sound:
593            //
594            // * One side is an *anonymous* symbol (allocated at/above
595            //   `ANON_SYMBOL_FLOOR`): it is an extent this pass invented for a
596            //   value it could not track, so it carries no independent meaning
597            //   and adopting the other side's identity is exactly the intended
598            //   re-binding (e.g. a `Shape`-driven `Expand` target rediscovering
599            //   the graph's real `seq_len`). Unify, keeping the smaller id so a
600            //   named graph symbol always wins over an anonymous one.
601            // * Both sides are *named graph dim-params* (`batch`, `seq_len`, …):
602            //   the model author declared them as separate, independently valued
603            //   dimensions. Unifying them would assert an equality the graph
604            //   explicitly declines to state, and it is silently wrong for the
605            //   common export that leans on `batch == 1` to broadcast a batch
606            //   dim against a sequence dim (rank-3 `MatMul` rhs right-aligning
607            //   under a rank-4 lhs). Picking either name propagates a dimension
608            //   that is wrong whenever the *other* one is the non-1 side, so we
609            //   degrade to a fresh symbol — the honest "unknown" — and let the
610            //   concrete pass bind the real extent.
611            //
612            // A derived expression (not a bare symbol) has no id to compare, so
613            // it likewise stays a fresh opaque symbol.
614            (None, None) => match (a.as_symbol(), b.as_symbol()) {
615                (Some(sa), Some(sb)) if sa.0 >= ANON_SYMBOL_FLOOR || sb.0 >= ANON_SYMBOL_FLOOR => {
616                    // Record the equivalence before returning. This is the SINGLE
617                    // chokepoint every broadcasting handler funnels through
618                    // (elementwise `broadcast`, `MatMul` batch dims, `Einsum`
619                    // ellipsis, `Concat` non-concat axes, `Expand`), so recording
620                    // here captures every symbol substitution inference performs —
621                    // complete by construction, with no per-op enumeration. It is
622                    // additive: the returned representative is unchanged.
623                    self.interner.record_unification(sa, sb);
624                    Ok(if sa.0 <= sb.0 { a.clone() } else { b.clone() })
625                }
626                _ => Ok(self.fresh_broadcast_dim(a, b)),
627            },
628        }
629    }
630
631    /// Mint the fresh "unknown" extent for a broadcast whose two operand dims
632    /// cannot be soundly unified, recording it as *derived* from both sides.
633    ///
634    /// The lineage record matters: [`Graph::symbol_derivations`] is what the
635    /// executor's capture-eligibility closure walks to decide whether an extent
636    /// depends on a growing dimension. A bare fresh symbol with no edges would
637    /// look seq-independent even when one of the operands is the growing KV
638    /// length, so the directed `source → derived` edges are what keep such an op
639    /// eager. Unlike a unification these edges assert no equality, only that the
640    /// result's value is a function of both inputs — which is exactly true.
641    ///
642    /// [`Graph::symbol_derivations`]: onnx_runtime_ir::Graph::symbol_derivations
643    fn fresh_broadcast_dim(&mut self, a: &DimExpr, b: &DimExpr) -> DimExpr {
644        let fresh = self.interner.fresh_symbol();
645        let mut seen = std::collections::HashSet::new();
646        for source in a.symbol_ids().chain(b.symbol_ids()) {
647            if source != fresh && seen.insert(source) {
648                self.interner.record_derivation(fresh, source);
649            }
650        }
651        DimExpr::symbol(fresh)
652    }
653}
654
655/// The dimension of `shape` at `axis` counting from the right of a rank-`rank`
656/// aligned view; leading positions absent from `shape` are `1`.
657fn dim_from_right(shape: &[DimExpr], rank: usize, axis: usize) -> DimExpr {
658    let offset = rank - shape.len();
659    if axis < offset {
660        DimExpr::constant(1)
661    } else {
662        shape[axis - offset].clone()
663    }
664}
665
666/// Reconcile an inferred shape with a value's declared IR shape under `policy`.
667///
668/// Returns the merged shape (each dim the more specific of the two). Under
669/// [`MergePolicy::Strict`], a concrete-vs-concrete disagreement — or a rank
670/// mismatch — is an error; symbolic disagreements are treated as naming and are
671/// never conflicts, so that inference using freshly-minted symbols never
672/// spuriously clashes with the loader's differently-named symbols.
673pub fn merge_shapes(
674    value: ValueId,
675    inferred: &[DimExpr],
676    declared: &[Dim],
677    policy: MergePolicy,
678) -> Result<Vec<DimExpr>, ShapeInferError> {
679    if inferred.len() != declared.len() {
680        if policy == MergePolicy::Strict {
681            return Err(ShapeInferError::RankConflict {
682                value,
683                inferred: inferred.len(),
684                declared: declared.len(),
685            });
686        }
687        // Permissive: prefer the inferred (known) rank.
688        return Ok(inferred.to_vec());
689    }
690    let mut out = Vec::with_capacity(inferred.len());
691    for (axis, (inf, dec)) in inferred.iter().zip(declared.iter()).enumerate() {
692        let dec_expr: DimExpr = (*dec).into();
693        let merged = match (inf.as_const(), dec_expr.as_const()) {
694            (Some(a), Some(b)) if a != b => {
695                if policy == MergePolicy::Strict {
696                    return Err(ShapeInferError::ShapeConflict {
697                        value,
698                        axis,
699                        inferred: a,
700                        declared: b,
701                    });
702                }
703                // Permissive: keep the inferred value.
704                inf.clone()
705            }
706            // Prefer whichever side is concrete (more specific).
707            (Some(_), _) => inf.clone(),
708            (None, Some(_)) => dec_expr,
709            // Both symbolic: keep the inferred symbol.
710            (None, None) => inf.clone(),
711        };
712        out.push(merged);
713    }
714    Ok(out)
715}
716
717/// Per-dimension agreement of two container element shapes. Differing ranks
718/// yield `None` (unknown element rank); within a matching rank, structurally
719/// equal dims (including symbolic ones) are preserved and disagreements degrade
720/// to a fresh symbol.
721///
722/// Shared by every rule and control-flow reconciliation that unifies a sequence
723/// element shape (`SequenceConstruct`/`SequenceInsert`, `If` branch outputs).
724pub(crate) fn merge_element_shape(
725    interner: &mut SymbolInterner,
726    a: &[DimExpr],
727    b: &[DimExpr],
728) -> Option<TypedShape> {
729    if a.len() != b.len() {
730        return None;
731    }
732    let merged = a
733        .iter()
734        .zip(b.iter())
735        .map(|(da, db)| {
736            if da == db {
737                da.clone()
738            } else {
739                interner.fresh_dim()
740            }
741        })
742        .collect();
743    Some(merged)
744}
745
746/// Unify two container element tensor types: dtypes must match (ONNX
747/// homogeneity), shapes agree per dimension via [`merge_element_shape`]. A
748/// missing shape on *either* side yields an unknown element shape — agreement
749/// cannot be confirmed. Shared by `SequenceConstruct`/`SequenceInsert` and the
750/// `If`/`Loop` container reconciliation.
751pub(crate) fn unify_tensor_type(
752    interner: &mut SymbolInterner,
753    op: &str,
754    acc: TensorType,
755    other: TensorType,
756) -> Result<TensorType, ShapeInferError> {
757    if acc.dtype != other.dtype {
758        return Err(ShapeInferError::Invalid {
759            op: op.into(),
760            detail: format!(
761                "sequence elements must share a dtype, found {:?} and {:?}",
762                acc.dtype, other.dtype
763            ),
764        });
765    }
766    let shape = match (acc.shape, other.shape) {
767        (Some(acc_shape), Some(other_shape)) => {
768            merge_element_shape(interner, &acc_shape, &other_shape)
769        }
770        _ => None,
771    };
772    Ok(TensorType {
773        dtype: acc.dtype,
774        shape,
775    })
776}
777
778/// Recursively unify two container [`ValueType`]s. Tensor leaves unify via
779/// [`unify_tensor_type`]; `Sequence`/`Optional` recurse into their element type;
780/// `Map` requires an equal key dtype and unifies its value type. Mismatched
781/// variants (e.g. a `Sequence` against a `Tensor`, or differing `Map` keys) are
782/// an error — the honest analogue of the tensor `If` branch dtype-mismatch.
783pub(crate) fn unify_value_type(
784    interner: &mut SymbolInterner,
785    op: &str,
786    a: &ValueType,
787    b: &ValueType,
788) -> Result<ValueType, ShapeInferError> {
789    match (a, b) {
790        (ValueType::Tensor(a), ValueType::Tensor(b)) => Ok(ValueType::Tensor(unify_tensor_type(
791            interner,
792            op,
793            a.clone(),
794            b.clone(),
795        )?)),
796        (ValueType::Sequence(a), ValueType::Sequence(b)) => {
797            Ok(ValueType::sequence(unify_value_type(interner, op, a, b)?))
798        }
799        (ValueType::Optional(a), ValueType::Optional(b)) => Ok(ValueType::Optional(Box::new(
800            unify_value_type(interner, op, a, b)?,
801        ))),
802        (ValueType::Map(ak, av), ValueType::Map(bk, bv)) if ak == bk => Ok(ValueType::Map(
803            *ak,
804            Box::new(unify_value_type(interner, op, av, bv)?),
805        )),
806        _ => Err(ShapeInferError::Invalid {
807            op: op.into(),
808            detail: format!("container types disagree: {a:?} vs {b:?}"),
809        }),
810    }
811}
812
813#[cfg(test)]
814mod opset_resolution_tests {
815    use super::*;
816    use onnx_runtime_ir::NodeId;
817
818    fn context_for(version: Option<i64>, imports: &HashMap<String, u64>) -> u64 {
819        let mut node = Node::new(NodeId(0), "Swish", vec![], vec![]);
820        node.version = version;
821        let mut interner = SymbolInterner::new(0);
822        let context = InferenceContext::new(
823            &node,
824            Vec::new(),
825            imports,
826            MergePolicy::default(),
827            &mut interner,
828        );
829        context.opset("")
830    }
831
832    /// A usable node-local version wins, which is why the field exists.
833    #[test]
834    fn a_node_version_overrides_the_graph_import() {
835        let imports = HashMap::from([(String::new(), 13)]);
836        assert_eq!(context_for(Some(24), &imports), 24);
837    }
838
839    /// Values that cannot be a version defer to the graph rather than being
840    /// believed.
841    ///
842    /// Shape inference used to convert `Node::version` with a bare
843    /// `u64::try_from`, so `Some(0)` became opset 0 here while
844    /// `Graph::effective_opset` ignored it — the same node meant different
845    /// things to the shape rules and to dispatch, and a rule gated on a
846    /// minimum opset would silently return unknown shapes.
847    #[test]
848    fn implausible_versions_defer_to_the_graph() {
849        let imports = HashMap::from([(String::new(), 13)]);
850        for version in [-1, 0, i64::MAX, i64::from(i32::MAX) + 1] {
851            assert_eq!(
852                context_for(Some(version), &imports),
853                13,
854                "version {version} is not usable and must not override the graph"
855            );
856        }
857    }
858}