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}