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::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 resolves to the canonical `""` key, else `1`).
267    pub fn opset(&self, domain: &str) -> u64 {
268        self.opset_imports
269            .get(normalize_domain(domain))
270            .copied()
271            .unwrap_or(1)
272    }
273
274    /// Mint a fresh opaque dimension.
275    pub fn fresh_dim(&mut self) -> DimExpr {
276        self.interner.fresh_dim()
277    }
278
279    /// Broadcast two shapes under NumPy rules. Where two distinct symbolic dims
280    /// must be unified, keeps a deterministic representative symbol (see
281    /// [`broadcast_dim`](Self::broadcast_dim)) rather than minting a fresh one.
282    /// Errors only under [`MergePolicy::Strict`] on a concrete incompatibility.
283    pub fn broadcast(
284        &mut self,
285        a: &[DimExpr],
286        b: &[DimExpr],
287    ) -> Result<TypedShape, ShapeInferError> {
288        let rank = a.len().max(b.len());
289        let mut out = Vec::with_capacity(rank);
290        for axis in 0..rank {
291            // Align from the right; missing leading dims are implicitly `1`.
292            let da = dim_from_right(a, rank, axis);
293            let db = dim_from_right(b, rank, axis);
294            out.push(self.broadcast_dim(&da, &db)?);
295        }
296        Ok(out)
297    }
298
299    /// Broadcast a single pair of dimensions.
300    pub fn broadcast_dim(&mut self, a: &DimExpr, b: &DimExpr) -> Result<DimExpr, ShapeInferError> {
301        let ac = a.as_const();
302        let bc = b.as_const();
303        if ac == Some(1) {
304            return Ok(b.clone());
305        }
306        if bc == Some(1) {
307            return Ok(a.clone());
308        }
309        if a == b {
310            return Ok(a.clone());
311        }
312        match (ac, bc) {
313            (Some(x), Some(y)) => {
314                if x == y {
315                    Ok(a.clone())
316                } else if self.policy == MergePolicy::Strict {
317                    Err(ShapeInferError::Invalid {
318                        op: self.node.op_type.clone(),
319                        detail: format!("incompatible broadcast dims {x} and {y}"),
320                    })
321                } else {
322                    // Permissive: two provably-unequal, non-1 concrete extents
323                    // are genuinely incompatible. Rather than fabricate a
324                    // `max(x, y)` that matches neither operand, degrade to a
325                    // fresh symbol (an honest "unknown") so we never assert a
326                    // bogus concrete dimension.
327                    Ok(self.fresh_dim())
328                }
329            }
330            // A concrete non-`1` extent dominates a symbolic one (the symbol
331            // must broadcast up to it, or the model is invalid).
332            (Some(_), None) => Ok(a.clone()),
333            (None, Some(_)) => Ok(b.clone()),
334            // Two distinct symbolic dims. In a valid model they must be equal at
335            // this position (or one is 1), so keeping a single *representative*
336            // symbol — rather than minting a fresh one no downstream consumer
337            // could ever bind — is both conformance-safe and what reference
338            // symbolic inference (onnxruntime) does. When both are bare symbols
339            // we keep the one with the smaller id, which deterministically
340            // prefers a named graph symbol (low-range, e.g. `batch`/`seq`) over
341            // an anonymous fresh one (allocated at/above `0x8000_0000`); this is
342            // what lets a data-dependent extent re-unify with the graph's real
343            // dims (e.g. a `Shape`-driven `Expand` target). A derived expression
344            // (not a bare symbol) has no id to compare, so it stays a fresh
345            // opaque symbol — the honest "unknown".
346            (None, None) => match (a.as_symbol(), b.as_symbol()) {
347                (Some(sa), Some(sb)) => Ok(if sa.0 <= sb.0 { a.clone() } else { b.clone() }),
348                _ => Ok(self.fresh_dim()),
349            },
350        }
351    }
352}
353
354/// The dimension of `shape` at `axis` counting from the right of a rank-`rank`
355/// aligned view; leading positions absent from `shape` are `1`.
356fn dim_from_right(shape: &[DimExpr], rank: usize, axis: usize) -> DimExpr {
357    let offset = rank - shape.len();
358    if axis < offset {
359        DimExpr::constant(1)
360    } else {
361        shape[axis - offset].clone()
362    }
363}
364
365/// Reconcile an inferred shape with a value's declared IR shape under `policy`.
366///
367/// Returns the merged shape (each dim the more specific of the two). Under
368/// [`MergePolicy::Strict`], a concrete-vs-concrete disagreement — or a rank
369/// mismatch — is an error; symbolic disagreements are treated as naming and are
370/// never conflicts, so that inference using freshly-minted symbols never
371/// spuriously clashes with the loader's differently-named symbols.
372pub fn merge_shapes(
373    value: ValueId,
374    inferred: &[DimExpr],
375    declared: &[Dim],
376    policy: MergePolicy,
377) -> Result<Vec<DimExpr>, ShapeInferError> {
378    if inferred.len() != declared.len() {
379        if policy == MergePolicy::Strict {
380            return Err(ShapeInferError::RankConflict {
381                value,
382                inferred: inferred.len(),
383                declared: declared.len(),
384            });
385        }
386        // Permissive: prefer the inferred (known) rank.
387        return Ok(inferred.to_vec());
388    }
389    let mut out = Vec::with_capacity(inferred.len());
390    for (axis, (inf, dec)) in inferred.iter().zip(declared.iter()).enumerate() {
391        let dec_expr: DimExpr = (*dec).into();
392        let merged = match (inf.as_const(), dec_expr.as_const()) {
393            (Some(a), Some(b)) if a != b => {
394                if policy == MergePolicy::Strict {
395                    return Err(ShapeInferError::ShapeConflict {
396                        value,
397                        axis,
398                        inferred: a,
399                        declared: b,
400                    });
401                }
402                // Permissive: keep the inferred value.
403                inf.clone()
404            }
405            // Prefer whichever side is concrete (more specific).
406            (Some(_), _) => inf.clone(),
407            (None, Some(_)) => dec_expr,
408            // Both symbolic: keep the inferred symbol.
409            (None, None) => inf.clone(),
410        };
411        out.push(merged);
412    }
413    Ok(out)
414}