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};
8
9use crate::dim_expr::DimExpr;
10use crate::error::ShapeInferError;
11use crate::shape_data::ShapeData;
12
13/// An inferred shape: an ordered list of symbolic dimension expressions. The
14/// rank is always known (unknown-rank tensors are represented by the *absence*
15/// of a [`TypeInfo`], never by a `TypedShape`).
16pub type TypedShape = Vec<DimExpr>;
17
18/// The inferred type of a value: element dtype plus a symbolic shape.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct TypeInfo {
21    pub dtype: DataType,
22    pub shape: TypedShape,
23}
24
25impl TypeInfo {
26    /// A new type info from a dtype and shape.
27    pub fn new(dtype: DataType, shape: TypedShape) -> Self {
28        Self { dtype, shape }
29    }
30
31    /// The rank (number of dimensions).
32    pub fn rank(&self) -> usize {
33        self.shape.len()
34    }
35}
36
37/// The resolved inference state of a single input or output slot: an optional
38/// type and an optional [`ShapeData`] side-value.
39#[derive(Clone, Debug, Default)]
40pub struct NodeIo {
41    pub type_info: Option<TypeInfo>,
42    pub shape_data: Option<ShapeData>,
43}
44
45impl NodeIo {
46    /// An i/o slot carrying only a type.
47    pub fn typed(type_info: TypeInfo) -> Self {
48        Self {
49            type_info: Some(type_info),
50            shape_data: None,
51        }
52    }
53}
54
55/// How to reconcile an inferred shape with a value's declared shape.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
57pub enum MergePolicy {
58    /// Prefer the more specific dimension and keep going; never error on a
59    /// disagreement. This is the robust default used for whole-graph inference.
60    #[default]
61    Permissive,
62    /// Raise [`ShapeInferError::ShapeConflict`] / [`ShapeInferError::RankConflict`]
63    /// on a *concrete* disagreement between inferred and declared shapes.
64    /// Symbolic differences are treated as naming and never conflict.
65    Strict,
66}
67
68/// Allocates and interns fresh symbolic dimensions, and lowers [`DimExpr`]s to
69/// IR [`Dim`]s.
70///
71/// A derived dimension that is neither a pure constant nor a bare symbol (e.g.
72/// `floor((d-k)/s)+1` where `d` is symbolic) cannot be stored in the IR's
73/// [`Dim`] enum. Such an expression is assigned a *fresh* symbol; because
74/// [`DimExpr`] is canonical, two structurally-identical derived dimensions
75/// intern to the **same** symbol and stay unified across the graph.
76#[derive(Debug)]
77pub struct SymbolInterner {
78    next: u32,
79    cache: HashMap<DimExpr, SymbolId>,
80    /// Symbols minted during inference, to be registered on the graph.
81    fresh: Vec<SymbolId>,
82}
83
84impl SymbolInterner {
85    /// A new interner that allocates symbol ids starting at `next` (which must
86    /// be greater than every symbol id already present in the graph).
87    pub fn new(next: u32) -> Self {
88        Self {
89            next,
90            cache: HashMap::new(),
91            fresh: Vec::new(),
92        }
93    }
94
95    /// Mint a brand-new opaque symbol (not tied to any expression).
96    pub fn fresh_symbol(&mut self) -> SymbolId {
97        let id = SymbolId(self.next);
98        // Saturating rather than wrapping: exhausting the u32 symbol space is
99        // adversarial/pathological, but must never wrap `next` back into the
100        // range of already-minted ids (which would alias symbols).
101        self.next = self.next.saturating_add(1);
102        self.fresh.push(id);
103        id
104    }
105
106    /// Mint a fresh opaque dimension expression.
107    pub fn fresh_dim(&mut self) -> DimExpr {
108        DimExpr::symbol(self.fresh_symbol())
109    }
110
111    /// Lower a [`DimExpr`] to an IR [`Dim`], interning derived expressions to a
112    /// stable fresh symbol.
113    pub fn lower(&mut self, expr: &DimExpr) -> Dim {
114        // An overflowed (unknown) expression has no representable value and must
115        // not alias other overflows via the cache: mint a distinct fresh symbol.
116        if expr.is_overflow() {
117            return Dim::Symbolic(self.fresh_symbol());
118        }
119        if let Some(n) = expr.as_const() {
120            if n >= 0 {
121                return Dim::Static(n as usize);
122            }
123            // A negative extent is nonsensical; degrade to a fresh symbol.
124            return Dim::Symbolic(self.fresh_symbol());
125        }
126        if let Some(s) = expr.as_symbol() {
127            return Dim::Symbolic(s);
128        }
129        if let Some(&id) = self.cache.get(expr) {
130            return Dim::Symbolic(id);
131        }
132        let id = self.fresh_symbol();
133        self.cache.insert(expr.clone(), id);
134        Dim::Symbolic(id)
135    }
136
137    /// The symbols minted during inference (to register on the graph).
138    pub fn fresh_symbols(&self) -> &[SymbolId] {
139        &self.fresh
140    }
141}
142
143/// The context passed to every op inference rule.
144///
145/// It exposes each input's inferred type and shape-data, lets a rule mint fresh
146/// symbolic dimensions and broadcast shapes, and collects the outputs the rule
147/// produces. Rules never touch the [`Graph`](onnx_runtime_ir::Graph) directly —
148/// they operate purely on this context, which makes them trivially unit
149/// testable in isolation.
150pub struct InferenceContext<'a> {
151    /// The node being inferred.
152    pub node: &'a Node,
153    opset_imports: &'a HashMap<String, u64>,
154    policy: MergePolicy,
155    inputs: Vec<NodeIo>,
156    outputs: Vec<NodeIo>,
157    interner: &'a mut SymbolInterner,
158}
159
160impl<'a> InferenceContext<'a> {
161    /// Build a context for `node` from its resolved `inputs` (aligned with
162    /// `node.inputs`, skipped slots carrying an empty [`NodeIo`]).
163    pub fn new(
164        node: &'a Node,
165        inputs: Vec<NodeIo>,
166        opset_imports: &'a HashMap<String, u64>,
167        policy: MergePolicy,
168        interner: &'a mut SymbolInterner,
169    ) -> Self {
170        let outputs = vec![NodeIo::default(); node.outputs.len()];
171        Self {
172            node,
173            opset_imports,
174            policy,
175            inputs,
176            outputs,
177            interner,
178        }
179    }
180
181    // === input access ===
182
183    /// The op type of the node.
184    pub fn op(&self) -> &str {
185        &self.node.op_type
186    }
187
188    /// The number of input slots (including skipped optional ones).
189    pub fn num_inputs(&self) -> usize {
190        self.inputs.len()
191    }
192
193    /// The number of output slots.
194    pub fn num_outputs(&self) -> usize {
195        self.outputs.len()
196    }
197
198    /// Whether input slot `i` is present (a value is connected).
199    pub fn has_input(&self, i: usize) -> bool {
200        self.node
201            .inputs
202            .get(i)
203            .map(Option::is_some)
204            .unwrap_or(false)
205    }
206
207    /// The inferred type of input `i`, if resolved.
208    pub fn input_type(&self, i: usize) -> Option<&TypeInfo> {
209        self.inputs.get(i)?.type_info.as_ref()
210    }
211
212    /// The inferred shape of input `i`, if resolved.
213    pub fn input_shape(&self, i: usize) -> Option<&[DimExpr]> {
214        self.input_type(i).map(|t| t.shape.as_slice())
215    }
216
217    /// The inferred dtype of input `i`, if resolved.
218    pub fn input_dtype(&self, i: usize) -> Option<DataType> {
219        self.input_type(i).map(|t| t.dtype)
220    }
221
222    /// The inferred rank of input `i`, if resolved.
223    pub fn input_rank(&self, i: usize) -> Option<usize> {
224        self.input_type(i).map(TypeInfo::rank)
225    }
226
227    /// The propagated shape-data of input `i`, if any.
228    pub fn input_shape_data(&self, i: usize) -> Option<&ShapeData> {
229        self.inputs.get(i)?.shape_data.as_ref()
230    }
231
232    // === output production ===
233
234    /// Set the type of output `i`.
235    pub fn set_output_type(&mut self, i: usize, type_info: TypeInfo) {
236        if let Some(slot) = self.outputs.get_mut(i) {
237            slot.type_info = Some(type_info);
238        }
239    }
240
241    /// Set the dtype and shape of output `i`.
242    pub fn set_output(&mut self, i: usize, dtype: DataType, shape: TypedShape) {
243        self.set_output_type(i, TypeInfo::new(dtype, shape));
244    }
245
246    /// Set the propagated shape-data of output `i`.
247    pub fn set_output_shape_data(&mut self, i: usize, data: ShapeData) {
248        if let Some(slot) = self.outputs.get_mut(i) {
249            slot.shape_data = Some(data);
250        }
251    }
252
253    /// Consume the context, returning the outputs the rule produced.
254    pub fn into_outputs(self) -> Vec<NodeIo> {
255        self.outputs
256    }
257
258    // === helpers available to rules ===
259
260    /// The active merge policy.
261    pub fn policy(&self) -> MergePolicy {
262        self.policy
263    }
264
265    /// The imported opset version for `domain` (the default `""`/`ai.onnx`
266    /// domain falls back to the highest imported ai.onnx version, else `1`).
267    pub fn opset(&self, domain: &str) -> u64 {
268        if domain.is_empty() || domain == "ai.onnx" {
269            self.opset_imports
270                .get("")
271                .or_else(|| self.opset_imports.get("ai.onnx"))
272                .copied()
273                .unwrap_or(1)
274        } else {
275            self.opset_imports.get(domain).copied().unwrap_or(1)
276        }
277    }
278
279    /// Mint a fresh opaque dimension.
280    pub fn fresh_dim(&mut self) -> DimExpr {
281        self.interner.fresh_dim()
282    }
283
284    /// Broadcast two shapes under NumPy rules. Where two distinct symbolic dims
285    /// must be unified, keeps a deterministic representative symbol (see
286    /// [`broadcast_dim`](Self::broadcast_dim)) rather than minting a fresh one.
287    /// Errors only under [`MergePolicy::Strict`] on a concrete incompatibility.
288    pub fn broadcast(
289        &mut self,
290        a: &[DimExpr],
291        b: &[DimExpr],
292    ) -> Result<TypedShape, ShapeInferError> {
293        let rank = a.len().max(b.len());
294        let mut out = Vec::with_capacity(rank);
295        for axis in 0..rank {
296            // Align from the right; missing leading dims are implicitly `1`.
297            let da = dim_from_right(a, rank, axis);
298            let db = dim_from_right(b, rank, axis);
299            out.push(self.broadcast_dim(&da, &db)?);
300        }
301        Ok(out)
302    }
303
304    /// Broadcast a single pair of dimensions.
305    pub fn broadcast_dim(&mut self, a: &DimExpr, b: &DimExpr) -> Result<DimExpr, ShapeInferError> {
306        let ac = a.as_const();
307        let bc = b.as_const();
308        if ac == Some(1) {
309            return Ok(b.clone());
310        }
311        if bc == Some(1) {
312            return Ok(a.clone());
313        }
314        if a == b {
315            return Ok(a.clone());
316        }
317        match (ac, bc) {
318            (Some(x), Some(y)) => {
319                if x == y {
320                    Ok(a.clone())
321                } else if self.policy == MergePolicy::Strict {
322                    Err(ShapeInferError::Invalid {
323                        op: self.node.op_type.clone(),
324                        detail: format!("incompatible broadcast dims {x} and {y}"),
325                    })
326                } else {
327                    // Permissive: two provably-unequal, non-1 concrete extents
328                    // are genuinely incompatible. Rather than fabricate a
329                    // `max(x, y)` that matches neither operand, degrade to a
330                    // fresh symbol (an honest "unknown") so we never assert a
331                    // bogus concrete dimension.
332                    Ok(self.fresh_dim())
333                }
334            }
335            // A concrete non-`1` extent dominates a symbolic one (the symbol
336            // must broadcast up to it, or the model is invalid).
337            (Some(_), None) => Ok(a.clone()),
338            (None, Some(_)) => Ok(b.clone()),
339            // Two distinct symbolic dims. In a valid model they must be equal at
340            // this position (or one is 1), so keeping a single *representative*
341            // symbol — rather than minting a fresh one no downstream consumer
342            // could ever bind — is both conformance-safe and what reference
343            // symbolic inference (onnxruntime) does. When both are bare symbols
344            // we keep the one with the smaller id, which deterministically
345            // prefers a named graph symbol (low-range, e.g. `batch`/`seq`) over
346            // an anonymous fresh one (allocated at/above `0x8000_0000`); this is
347            // what lets a data-dependent extent re-unify with the graph's real
348            // dims (e.g. a `Shape`-driven `Expand` target). A derived expression
349            // (not a bare symbol) has no id to compare, so it stays a fresh
350            // opaque symbol — the honest "unknown".
351            (None, None) => match (a.as_symbol(), b.as_symbol()) {
352                (Some(sa), Some(sb)) => {
353                    Ok(if sa.0 <= sb.0 { a.clone() } else { b.clone() })
354                }
355                _ => Ok(self.fresh_dim()),
356            },
357        }
358    }
359}
360
361/// The dimension of `shape` at `axis` counting from the right of a rank-`rank`
362/// aligned view; leading positions absent from `shape` are `1`.
363fn dim_from_right(shape: &[DimExpr], rank: usize, axis: usize) -> DimExpr {
364    let offset = rank - shape.len();
365    if axis < offset {
366        DimExpr::constant(1)
367    } else {
368        shape[axis - offset].clone()
369    }
370}
371
372/// Reconcile an inferred shape with a value's declared IR shape under `policy`.
373///
374/// Returns the merged shape (each dim the more specific of the two). Under
375/// [`MergePolicy::Strict`], a concrete-vs-concrete disagreement — or a rank
376/// mismatch — is an error; symbolic disagreements are treated as naming and are
377/// never conflicts, so that inference using freshly-minted symbols never
378/// spuriously clashes with the loader's differently-named symbols.
379pub fn merge_shapes(
380    value: ValueId,
381    inferred: &[DimExpr],
382    declared: &[Dim],
383    policy: MergePolicy,
384) -> Result<Vec<DimExpr>, ShapeInferError> {
385    if inferred.len() != declared.len() {
386        if policy == MergePolicy::Strict {
387            return Err(ShapeInferError::RankConflict {
388                value,
389                inferred: inferred.len(),
390                declared: declared.len(),
391            });
392        }
393        // Permissive: prefer the inferred (known) rank.
394        return Ok(inferred.to_vec());
395    }
396    let mut out = Vec::with_capacity(inferred.len());
397    for (axis, (inf, dec)) in inferred.iter().zip(declared.iter()).enumerate() {
398        let dec_expr: DimExpr = (*dec).into();
399        let merged = match (inf.as_const(), dec_expr.as_const()) {
400            (Some(a), Some(b)) if a != b => {
401                if policy == MergePolicy::Strict {
402                    return Err(ShapeInferError::ShapeConflict {
403                        value,
404                        axis,
405                        inferred: a,
406                        declared: b,
407                    });
408                }
409                // Permissive: keep the inferred value.
410                inf.clone()
411            }
412            // Prefer whichever side is concrete (more specific).
413            (Some(_), _) => inf.clone(),
414            (None, Some(_)) => dec_expr,
415            // Both symbolic: keep the inferred symbol.
416            (None, None) => inf.clone(),
417        };
418        out.push(merged);
419    }
420    Ok(out)
421}