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}