Skip to main content

rlx_ir/
shape.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Tensor shapes with static and dynamic dimensions.
6//!
7//! Shapes are first-class in RLX IR — every node's output shape is known
8//! (or symbolically bounded) at graph construction time. This enables
9//! buffer size computation for memory planning.
10
11use crate::DType;
12use smallvec::SmallVec;
13
14/// A single dimension — either a concrete size or a symbolic dynamic dim.
15#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Dim {
18    /// Known at graph construction time.
19    Static(usize),
20    /// Unknown until runtime. Identified by a symbol index so that
21    /// `Dim::Dynamic(0)` in two shapes means "same unknown size".
22    Dynamic(u32),
23}
24
25impl Dim {
26    pub fn unwrap_static(self) -> usize {
27        match self {
28            Self::Static(n) => n,
29            Self::Dynamic(s) => panic!("expected static dim, got dynamic symbol {s}"),
30        }
31    }
32
33    pub fn is_static(self) -> bool {
34        matches!(self, Self::Static(_))
35    }
36}
37
38impl From<usize> for Dim {
39    fn from(n: usize) -> Self {
40        Self::Static(n)
41    }
42}
43
44impl std::fmt::Display for Dim {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::Static(n) => write!(f, "{n}"),
48            Self::Dynamic(s) => write!(f, "?{s}"),
49        }
50    }
51}
52
53/// Tensor shape: ordered list of dimensions + element type.
54///
55/// SmallVec<[Dim; 4]> avoids heap allocation for up to 4D tensors (the common case).
56#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub struct Shape {
59    dims: SmallVec<[Dim; 4]>,
60    dtype: DType,
61}
62
63impl Shape {
64    /// Create a shape from static dimensions.
65    pub fn new(dims: &[usize], dtype: DType) -> Self {
66        Self {
67            dims: dims.iter().map(|&d| Dim::Static(d)).collect(),
68            dtype,
69        }
70    }
71
72    /// Create a shape with mixed static/dynamic dimensions.
73    pub fn from_dims(dims: &[Dim], dtype: DType) -> Self {
74        Self {
75            dims: dims.into(),
76            dtype,
77        }
78    }
79
80    /// Scalar (0-dimensional).
81    pub fn scalar(dtype: DType) -> Self {
82        Self {
83            dims: SmallVec::new(),
84            dtype,
85        }
86    }
87
88    pub fn rank(&self) -> usize {
89        self.dims.len()
90    }
91    pub fn dtype(&self) -> DType {
92        self.dtype
93    }
94    pub fn dims(&self) -> &[Dim] {
95        &self.dims
96    }
97    pub fn dim(&self, i: usize) -> Dim {
98        self.dims.get(i).copied().unwrap_or_else(|| {
99            let dims: Vec<_> = self.dims.iter().map(|d| d.unwrap_static()).collect();
100            panic!(
101                "Shape::dim({i}) out of bounds for rank {} dims={dims:?}",
102                self.rank()
103            );
104        })
105    }
106
107    /// Set of dynamic dim symbols this shape references. Useful for
108    /// "what bindings does this graph need?" queries on inputs.
109    pub fn dynamic_symbols(&self) -> Vec<u32> {
110        let mut syms: Vec<u32> = self
111            .dims
112            .iter()
113            .filter_map(|d| match d {
114                Dim::Dynamic(s) => Some(*s),
115                _ => None,
116            })
117            .collect();
118        syms.sort();
119        syms.dedup();
120        syms
121    }
122
123    /// Specialize the shape against a binding (`symbol → static
124    /// size`). Unknown symbols stay [`Dim::Dynamic`]. Plan #54: the
125    /// step that takes a "compile once, run at any seq length" graph
126    /// and produces the runtime-specific concrete shape.
127    pub fn bind(&self, bindings: &DimBinding) -> Self {
128        let dims = self
129            .dims
130            .iter()
131            .map(|d| match d {
132                Dim::Dynamic(s) => match bindings.get(*s) {
133                    Some(n) => Dim::Static(n),
134                    None => *d,
135                },
136                _ => *d,
137            })
138            .collect();
139        Self {
140            dims,
141            dtype: self.dtype,
142        }
143    }
144
145    /// Total number of elements (only if all dims are static).
146    pub fn num_elements(&self) -> Option<usize> {
147        let mut total = 1usize;
148        for d in &self.dims {
149            match d {
150                Dim::Static(n) => total = total.checked_mul(*n)?,
151                Dim::Dynamic(_) => return None,
152            }
153        }
154        Some(total)
155    }
156
157    /// Total size in bytes (only if all dims are static).
158    pub fn size_bytes(&self) -> Option<usize> {
159        self.num_elements().map(|n| n * self.dtype.size_bytes())
160    }
161
162    /// True if all dimensions are statically known.
163    pub fn is_static(&self) -> bool {
164        self.dims.iter().all(|d| d.is_static())
165    }
166
167    /// Replace a dimension.
168    pub fn with_dim(mut self, axis: usize, dim: Dim) -> Self {
169        self.dims[axis] = dim;
170        self
171    }
172
173    /// Change dtype (for cast operations).
174    pub fn with_dtype(mut self, dtype: DType) -> Self {
175        self.dtype = dtype;
176        self
177    }
178
179    /// Numpy-style broadcast with another shape (fusion / lowering).
180    pub fn broadcast_with(&self, other: &Shape) -> Result<Shape, String> {
181        broadcast(self, other)
182    }
183}
184
185/// Pack leading-dim broadcast metadata for DiT modulation kernels
186/// ([`Op::AdaLayerNorm`](crate::op::Op::AdaLayerNorm) /
187/// [`Op::GatedResidual`](crate::op::Op::GatedResidual)).
188///
189/// Layout: `[lead_rank, x_lead[0..8], mod_lead[0..8]]` — right-aligns
190/// `mod_dims` to `x_dims` with size-1 padding (NumPy broadcast). Last axis
191/// of both shapes is the feature dim and is omitted from the pack.
192pub fn ada_modulation_lead_pack(x_dims: &[usize], mod_dims_in: &[usize]) -> [u32; 17] {
193    let rank = x_dims.len();
194    assert!(rank >= 1, "AdaLayerNorm/GatedResidual: rank ≥ 1");
195    let lead = rank - 1;
196    assert!(
197        lead <= 8,
198        "AdaLayerNorm/GatedResidual: at most 8 leading dims"
199    );
200    let pad = rank
201        .checked_sub(mod_dims_in.len())
202        .expect("modulation rank exceeds x");
203    let mut mod_dims = vec![1usize; rank];
204    mod_dims[pad..].copy_from_slice(mod_dims_in);
205    let mut pack = [0u32; 17];
206    pack[0] = lead as u32;
207    for j in 0..lead {
208        pack[1 + j] = x_dims[j] as u32;
209        pack[9 + j] = mod_dims[j] as u32;
210    }
211    pack
212}
213
214/// Launch geometry for DiT modulation backward kernels: one block/threadgroup
215/// per unique modulation row; that unit loops `seq_per_mod` feature-rows that
216/// share it (typical DiT `[B,S,D]` / `[B,1,D]` → `(B, S)`).
217pub fn ada_modulation_launch(x_dims: &[usize], mod_dims: &[usize]) -> (u32, u32) {
218    assert!(!x_dims.is_empty() && !mod_dims.is_empty());
219    let xr = x_dims.len() - 1;
220    let mr = mod_dims.len() - 1;
221    let mut seq = 1u32;
222    let mut mods = 1u32;
223    for i in 0..xr {
224        let xd = x_dims[i] as u32;
225        let md = if i + mr >= xr {
226            mod_dims[i - (xr - mr)] as u32
227        } else {
228            1
229        };
230        if md == 1 && xd > 1 {
231            seq = seq.saturating_mul(xd);
232        } else {
233            mods = mods.saturating_mul(xd.max(1));
234        }
235    }
236    (mods.max(1), seq.max(1))
237}
238
239// ── Shape inference functions ────────────────────────────────────────────
240
241/// Numpy-style broadcast of two shapes. Returns the broadcast result.
242pub fn broadcast(a: &Shape, b: &Shape) -> Result<Shape, String> {
243    let max_rank = a.rank().max(b.rank());
244    let mut dims = SmallVec::new();
245    for i in 0..max_rank {
246        let ad = if i < max_rank - a.rank() {
247            Dim::Static(1)
248        } else {
249            a.dims[i - (max_rank - a.rank())]
250        };
251        let bd = if i < max_rank - b.rank() {
252            Dim::Static(1)
253        } else {
254            b.dims[i - (max_rank - b.rank())]
255        };
256        let d = broadcast_dim(ad, bd)?;
257        dims.push(d);
258    }
259    Ok(Shape {
260        dims,
261        dtype: a.dtype,
262    })
263}
264
265fn broadcast_dim(a: Dim, b: Dim) -> Result<Dim, String> {
266    match (a, b) {
267        (Dim::Static(1), d) | (d, Dim::Static(1)) => Ok(d),
268        (Dim::Static(x), Dim::Static(y)) if x == y => Ok(Dim::Static(x)),
269        (Dim::Static(x), Dim::Static(y)) => Err(format!("cannot broadcast {x} with {y}")),
270        (Dim::Dynamic(s), Dim::Dynamic(t)) if s == t => Ok(Dim::Dynamic(s)),
271        (Dim::Dynamic(_), _) | (_, Dim::Dynamic(_)) => Ok(a), // keep first dynamic
272    }
273}
274
275/// Operand dims of a [`crate::op::Op::GroupedMatMul`], as every backend needs
276/// them: `input [.., M, K] · weight[idx[r]] [K, N] → out [M, N]`.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct GroupedMatMulDims {
279    pub m: usize,
280    pub k: usize,
281    pub n: usize,
282    pub num_experts: usize,
283}
284
285/// Validate a [`crate::op::Op::GroupedMatMul`]'s operands and derive `(M, K, N, E)`.
286///
287/// The expert bank is `[E, K, N]` — K **second**, N last — which is the
288/// transpose of the `[out, in]` layout checkpoints ship. Getting that backwards
289/// is the classic MoE porting bug and, until this check existed, an entirely
290/// silent one: a bank left in `[E, N, K]` has the right rank and the right
291/// element count, so nothing rejects it. The kernels then read `N` off
292/// `weight.dim(2)` — which is really K — and write `M·K` floats into the `M·N`
293/// slot the planner sized from the declared output shape. When `K < N` that
294/// leaves the tail of every output row holding whatever the arena had (looks
295/// like a non-causal model); when `K > N` it runs off the end of the slot and
296/// corrupts the neighbouring tensor.
297///
298/// So: K must agree between the input and the bank, and — when `out` is given —
299/// the declared output must be `M × N`.
300pub fn grouped_matmul_dims(
301    input: &Shape,
302    weight: &Shape,
303    out: Option<&Shape>,
304) -> Result<GroupedMatMulDims, String> {
305    if input.rank() < 2 {
306        return Err(format!(
307            "GroupedMatMul input must be rank >= 2 ([.., M, K]), got {input}"
308        ));
309    }
310    if weight.rank() != 3 {
311        return Err(format!(
312            "GroupedMatMul expert bank must be rank 3 ([E, K, N]), got {weight}"
313        ));
314    }
315    let statics = |d: Dim, what: &str| match d {
316        Dim::Static(v) => Ok(v),
317        Dim::Dynamic(s) => Err(format!(
318            "GroupedMatMul {what} must be static, got dynamic symbol {s}"
319        )),
320    };
321    let m = statics(input.dim(input.rank() - 2), "M")?;
322    let k = statics(input.dim(input.rank() - 1), "K")?;
323    let num_experts = statics(weight.dim(0), "E")?;
324    let k_w = statics(weight.dim(1), "the bank's K axis")?;
325    let n = statics(weight.dim(2), "N")?;
326
327    if k != k_w {
328        return Err(format!(
329            "GroupedMatMul K mismatch: input is {input} (K={k}) but the expert bank \
330             is {weight} (K={k_w}, N={n}). The bank must be [E, K, N]; a checkpoint \
331             tensor stored [E, N, K] ([out, in], the usual `x @ W.T` layout) has to \
332             be transposed before it reaches this op"
333        ));
334    }
335    if let Some(out) = out {
336        let out_n = statics(out.dim(out.rank().saturating_sub(1)), "the output's N")?;
337        let out_elems = out.num_elements().unwrap_or(0);
338        if out_n != n || out_elems != m * n {
339            return Err(format!(
340                "GroupedMatMul output shape mismatch: declared {out} but the operands \
341                 give [{m}, {n}] (input {input}, bank {weight})"
342            ));
343        }
344    }
345    Ok(GroupedMatMulDims {
346        m,
347        k,
348        n,
349        num_experts,
350    })
351}
352
353/// `[.., M, K]` × `[E, K, N]` → `[M, N]`. See [`grouped_matmul_dims`].
354pub fn grouped_matmul_shape(input: &Shape, weight: &Shape) -> Result<Shape, String> {
355    let d = grouped_matmul_dims(input, weight, None)?;
356    Ok(Shape::new(&[d.m, d.n], input.dtype()))
357}
358
359/// MatMul output shape: `[..,M,K] × [..,K,N] → [..,M,N]`.
360pub fn matmul_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
361    if lhs.rank() < 2 || rhs.rank() < 2 {
362        return Err(format!(
363            "matmul requires rank >= 2, got {} and {}",
364            lhs.rank(),
365            rhs.rank()
366        ));
367    }
368    let m = lhs.dims[lhs.rank() - 2];
369    let k1 = lhs.dims[lhs.rank() - 1];
370    let k2 = rhs.dims[rhs.rank() - 2];
371    let n = rhs.dims[rhs.rank() - 1];
372
373    // Verify K dimensions match
374    match (k1, k2) {
375        (Dim::Static(a), Dim::Static(b)) if a != b => {
376            return Err(format!("matmul K mismatch: {a} vs {b}"));
377        }
378        (Dim::Dynamic(s), Dim::Dynamic(t)) if s != t => {
379            return Err(format!("matmul K mismatch: ?{s} vs ?{t}"));
380        }
381        _ => {}
382    }
383
384    // Broadcast batch dimensions
385    let lhs_batch = &lhs.dims[..lhs.rank() - 2];
386    let rhs_batch = &rhs.dims[..rhs.rank() - 2];
387    let batch_a = Shape::from_dims(lhs_batch, lhs.dtype);
388    let batch_b = Shape::from_dims(rhs_batch, rhs.dtype);
389    let batch = if lhs_batch.is_empty() && rhs_batch.is_empty() {
390        SmallVec::new()
391    } else if lhs_batch.is_empty() {
392        rhs_batch.into()
393    } else if rhs_batch.is_empty() {
394        lhs_batch.into()
395    } else {
396        broadcast(&batch_a, &batch_b)?.dims.clone()
397    };
398
399    let mut dims = batch;
400    dims.push(m);
401    dims.push(n);
402    Ok(Shape {
403        dims,
404        dtype: lhs.dtype,
405    })
406}
407
408/// ONNX Expand: broadcast `input` to `target` (numpy-style).
409pub fn expand_shape(input: &Shape, target: &[i64]) -> Result<Shape, String> {
410    if target.iter().any(|&d| d < 0) {
411        return Err("expand target has negative dim".into());
412    }
413    let target_s = Shape::new(
414        &target.iter().map(|&d| d as usize).collect::<Vec<_>>(),
415        input.dtype(),
416    );
417    broadcast(input, &target_s)
418}
419
420/// Binary element-wise shape (broadcast).
421pub fn binary_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
422    broadcast(lhs, rhs)
423}
424
425/// Unary op: output = input shape.
426pub fn unary_shape(input: &Shape) -> Shape {
427    input.clone()
428}
429
430/// Cast: change dtype, keep shape.
431pub fn cast_shape(input: &Shape, to: DType) -> Shape {
432    input.clone().with_dtype(to)
433}
434
435/// Compare: broadcast + Bool dtype.
436pub fn compare_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
437    Ok(broadcast(lhs, rhs)?.with_dtype(DType::Bool))
438}
439
440/// Reduce along axes.
441pub fn reduce_shape(input: &Shape, axes: &[usize], keep_dim: bool) -> Result<Shape, String> {
442    let mut dims = SmallVec::new();
443    for (i, &d) in input.dims.iter().enumerate() {
444        if axes.contains(&i) {
445            if keep_dim {
446                dims.push(Dim::Static(1));
447            }
448        } else {
449            dims.push(d);
450        }
451    }
452    Ok(Shape {
453        dims,
454        dtype: input.dtype,
455    })
456}
457
458/// Softmax: preserves shape.
459pub fn softmax_shape(input: &Shape) -> Shape {
460    input.clone()
461}
462
463/// Transpose: permute dims.
464pub fn transpose_shape(input: &Shape, perm: &[usize]) -> Result<Shape, String> {
465    if perm.len() != input.rank() {
466        return Err(format!("perm len {} != rank {}", perm.len(), input.rank()));
467    }
468    let dims: SmallVec<[Dim; 4]> = perm.iter().map(|&i| input.dims[i]).collect();
469    Ok(Shape {
470        dims,
471        dtype: input.dtype,
472    })
473}
474
475/// Narrow: slice along one axis.
476pub fn narrow_shape(input: &Shape, axis: usize, len: usize) -> Result<Shape, String> {
477    if axis >= input.rank() {
478        return Err(format!("axis {axis} >= rank {}", input.rank()));
479    }
480    Ok(input.clone().with_dim(axis, Dim::Static(len)))
481}
482
483/// Tile: axis `i` grows by factor `reps[i]` (aligned to the input rank).
484pub fn tile_shape(input: &Shape, reps: &[usize]) -> Result<Shape, String> {
485    if reps.len() != input.rank() {
486        return Err(format!(
487            "tile: reps length {} != rank {}",
488            reps.len(),
489            input.rank()
490        ));
491    }
492    let mut out = input.clone();
493    for (axis, &r) in reps.iter().enumerate() {
494        if r == 1 {
495            continue;
496        }
497        match input.dims[axis] {
498            Dim::Static(n) => out = out.with_dim(axis, Dim::Static(n * r)),
499            Dim::Dynamic(_) => return Err(format!("tile: cannot tile dynamic axis {axis}")),
500        }
501    }
502    Ok(out)
503}
504
505/// Strided slice: output axis `axis` has length `len` (the number of strided
506/// reads); all other axes unchanged.
507pub fn slice_shape(input: &Shape, axis: usize, len: usize) -> Result<Shape, String> {
508    if axis >= input.rank() {
509        return Err(format!("slice: axis {axis} >= rank {}", input.rank()));
510    }
511    Ok(input.clone().with_dim(axis, Dim::Static(len)))
512}
513
514/// Pad: each axis `i` grows by `pads[i][0] + pads[i][1]`. `pads` is aligned to
515/// the input rank. Padding a dynamic axis is rejected (the padded extent isn't
516/// statically known).
517pub fn pad_shape(input: &Shape, pads: &[[usize; 2]]) -> Result<Shape, String> {
518    if pads.len() != input.rank() {
519        return Err(format!(
520            "pad: pads length {} != rank {}",
521            pads.len(),
522            input.rank()
523        ));
524    }
525    let mut out = input.clone();
526    for (axis, &[before, after]) in pads.iter().enumerate() {
527        if before == 0 && after == 0 {
528            continue;
529        }
530        match input.dims[axis] {
531            Dim::Static(n) => out = out.with_dim(axis, Dim::Static(n + before + after)),
532            Dim::Dynamic(_) => {
533                return Err(format!(
534                    "pad: cannot pad dynamic axis {axis} by ({before},{after})"
535                ));
536            }
537        }
538    }
539    Ok(out)
540}
541
542/// Concat along axis.
543pub fn concat_shape(inputs: &[&Shape], axis: usize) -> Result<Shape, String> {
544    if inputs.is_empty() {
545        return Err("concat: no inputs".into());
546    }
547    let base = inputs[0];
548    let mut static_sum = 0usize;
549    let mut dyn_sym: Option<u32> = None;
550    for s in inputs {
551        if s.rank() == 0 {
552            return Err("concat: input has rank 0".into());
553        }
554        if s.rank() != base.rank() {
555            return Err(format!(
556                "concat: rank mismatch {} vs {}",
557                s.rank(),
558                base.rank()
559            ));
560        }
561        let ax = axis.min(s.rank().saturating_sub(1));
562        match s.dims[ax] {
563            Dim::Static(n) => static_sum += n,
564            Dim::Dynamic(sym) => {
565                if let Some(prev) = dyn_sym {
566                    if prev != sym {
567                        return Err(format!(
568                            "concat: mismatched dynamic symbols {prev} vs {sym} on axis {axis}"
569                        ));
570                    }
571                }
572                dyn_sym = Some(sym);
573            }
574        }
575    }
576    let out_dim = match dyn_sym {
577        None => Dim::Static(static_sum),
578        Some(sym) if static_sum == 0 => Dim::Dynamic(sym),
579        Some(sym) => {
580            // Mixed static + dynamic (e.g. conv_state || qkv). After `bind_graph`,
581            // `sync_concat_shapes` recomputes from concrete input shapes.
582            let _ = static_sum;
583            Dim::Dynamic(sym)
584        }
585    };
586    let out_axis = axis.min(base.rank().saturating_sub(1));
587    Ok(base.clone().with_dim(out_axis, out_dim))
588}
589
590/// Gather (embedding lookup): table\[V,D\] + indices\[B,S\] → \[B,S,D\].
591pub fn gather_shape(table: &Shape, indices: &Shape, axis: usize) -> Result<Shape, String> {
592    if axis >= table.rank() {
593        return Err(format!("gather: axis {axis} >= rank {}", table.rank()));
594    }
595    // ONNX Gather output = table[:axis] ++ indices.shape ++ table[axis+1:].
596    // The leading `table[:axis]` dims were previously DROPPED — correct only for
597    // axis 0 (empty prefix); an axis!=0 gather like `[4,3]` on axis 1 wrongly
598    // collapsed to `[6]` instead of `[4,6]` (StyleTTS2 nearest-resize / any
599    // batched/channel gather).
600    let mut dims: SmallVec<[Dim; 4]> = table.dims[..axis].iter().copied().collect();
601    dims.extend(indices.dims.iter().copied());
602    for i in (axis + 1)..table.rank() {
603        dims.push(table.dims[i]);
604    }
605    Ok(Shape {
606        dims,
607        dtype: table.dtype,
608    })
609}
610
611/// Reshape with -1 wildcard support.
612pub fn reshape_shape(input: &Shape, new_shape: &[i64]) -> Result<Shape, String> {
613    let neg_count = new_shape.iter().filter(|&&d| d == -1).count();
614    if neg_count > 1 {
615        return Err("reshape: at most one -1".into());
616    }
617
618    if input.is_static() {
619        // Only the `-1` case needs the input's element count. Computing `total`
620        // eagerly made a reshape to a FULLY-CONCRETE target fail whenever the
621        // input's product couldn't be formed (a stray over-large/garbage static
622        // dim from an upstream mis-resolved dynamic axis → `num_elements()` = None
623        // via checked_mul), aborting the whole import ("input has dynamic dims").
624        // A fully-specified target shape is well-defined regardless of the input.
625        let known_product: i64 = new_shape.iter().filter(|&&d| d != -1).product();
626        let mut dims = SmallVec::new();
627        for &d in new_shape {
628            if d == -1 {
629                let total = input
630                    .num_elements()
631                    .ok_or_else(|| "reshape: input has dynamic dims".to_string())?;
632                let inferred = total as i64 / known_product.max(1);
633                dims.push(Dim::Static(inferred as usize));
634            } else if d < 0 {
635                return Err(format!("reshape: invalid dim {d}"));
636            } else {
637                dims.push(Dim::Static(d as usize));
638            }
639        }
640        return Ok(Shape {
641            dims,
642            dtype: input.dtype,
643        });
644    }
645
646    // Symbolic input: map `-1` to the sole dynamic symbol when unambiguous
647    // (qwen35 prefill with batch=1 and `sym::SEQ`), otherwise keep dynamic.
648    let dyn_syms = input.dynamic_symbols();
649    let neg_idx = new_shape.iter().position(|&d| d == -1);
650    let mut out_dims: SmallVec<[Dim; 4]> = SmallVec::new();
651    for (i, &d) in new_shape.iter().enumerate() {
652        if Some(i) == neg_idx {
653            continue;
654        }
655        if d < 0 {
656            return Err(format!("reshape: invalid dim {d}"));
657        }
658        out_dims.push(Dim::Static(d as usize));
659    }
660    if let Some(ni) = neg_idx {
661        let inferred = if dyn_syms.len() == 1 {
662            Dim::Dynamic(dyn_syms[0])
663        } else if dyn_syms.is_empty() {
664            return Err("reshape: cannot infer -1 on static input".into());
665        } else {
666            Dim::Dynamic(crate::dynamic::sym::ROWS)
667        };
668        out_dims.insert(ni, inferred);
669    }
670    Ok(Shape {
671        dims: out_dims,
672        dtype: input.dtype,
673    })
674}
675
676/// Flatten leading axes to `[∏leading, H]` — used by `FuseRmsNormReshape` and shape verify.
677pub fn leading_flatten_fused_shape(input: &Shape) -> Option<Shape> {
678    if input.rank() < 2 {
679        return None;
680    }
681    let Dim::Static(h) = input.dim(input.rank() - 1) else {
682        return None;
683    };
684    let leading = &input.dims()[..input.rank() - 1];
685    let lead_dim = if leading.iter().all(|d| d.is_static()) {
686        Dim::Static(leading.iter().map(|d| d.unwrap_static()).product::<usize>())
687    } else {
688        let mut syms: Vec<u32> = leading
689            .iter()
690            .filter_map(|d| match d {
691                Dim::Dynamic(s) => Some(*s),
692                _ => None,
693            })
694            .collect();
695        syms.sort();
696        syms.dedup();
697        match syms.len() {
698            0 => Dim::Static(leading.iter().map(|d| d.unwrap_static()).product::<usize>()),
699            1 => Dim::Dynamic(syms[0]),
700            _ => Dim::Dynamic(crate::dynamic::sym::ROWS),
701        }
702    };
703    Some(Shape::from_dims(&[lead_dim, Dim::Static(h)], input.dtype()))
704}
705
706/// Match `Reshape { new_shape }` after RmsNorm when fusing to a single op.
707pub fn leading_flatten_shape(input: &Shape, new_shape: &[i64]) -> Option<Shape> {
708    if new_shape.len() != 2 {
709        return None;
710    }
711    let flat = leading_flatten_fused_shape(input)?;
712    let Dim::Static(h) = input.dim(input.rank() - 1) else {
713        return None;
714    };
715    if new_shape[1] as usize != h {
716        return None;
717    }
718    match flat.dim(0) {
719        Dim::Static(lead) if new_shape[0] as usize == lead => Some(flat),
720        Dim::Dynamic(_) if new_shape[0] == -1 => Some(flat),
721        _ => None,
722    }
723}
724
725/// Attention: output shape = Q shape.
726pub fn attention_shape(q: &Shape) -> Shape {
727    q.clone()
728}
729
730/// Attention output shape accounting for an asymmetric V/output width. The
731/// output has the same layout as `q` except the per-head width is `v_head_dim`:
732/// rank-4 `[.., D]` → `[.., v_head_dim]`; rank ≤ 3 `[.., H·head_dim]` →
733/// `[.., num_heads·v_head_dim]`. Equals `q` when `head_dim == v_head_dim`.
734pub fn attention_shape_vdim(
735    q: &Shape,
736    num_heads: usize,
737    head_dim: usize,
738    v_head_dim: usize,
739) -> Shape {
740    if head_dim == v_head_dim || q.rank() == 0 {
741        return q.clone();
742    }
743    let rank = q.rank();
744    let new_last = if rank >= 4 {
745        v_head_dim
746    } else {
747        num_heads * v_head_dim
748    };
749    q.clone().with_dim(rank - 1, Dim::Static(new_last))
750}
751
752impl std::fmt::Display for Shape {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        write!(f, "[")?;
755        for (i, d) in self.dims.iter().enumerate() {
756            if i > 0 {
757                write!(f, ", ")?;
758            }
759            write!(f, "{d}")?;
760        }
761        write!(f, "] {}", self.dtype)
762    }
763}
764
765/// Spatial output size for NCHW `Op::Conv` / `conv2d`.
766pub fn conv2d_spatial_output(
767    in_size: usize,
768    kernel: usize,
769    stride: usize,
770    padding: usize,
771    dilation: usize,
772) -> usize {
773    let dil_k = dilation.saturating_mul(kernel.saturating_sub(1));
774    (in_size + 2 * padding)
775        .saturating_sub(dil_k)
776        .saturating_sub(1)
777        / stride
778        + 1
779}
780
781/// Spatial output size for NCHW `Op::ConvTranspose2d`.
782pub fn conv_transpose2d_spatial_output(
783    in_size: usize,
784    kernel: usize,
785    stride: usize,
786    padding: usize,
787    dilation: usize,
788    output_padding: usize,
789) -> usize {
790    let dil_k = dilation.saturating_mul(kernel.saturating_sub(1));
791    (in_size - 1) * stride + output_padding + dil_k - 2 * padding + 1
792}
793
794/// Output shape for `conv2d` given NCHW `input` and weight `[C_out, C_in/g, kH, kW]`.
795pub fn conv2d_output_shape(
796    input: &Shape,
797    weight: &Shape,
798    kernel_size: [usize; 2],
799    stride: [usize; 2],
800    padding: [usize; 2],
801    dilation: [usize; 2],
802    groups: usize,
803) -> Result<Shape, String> {
804    if input.rank() != 4 || weight.rank() != 4 {
805        return Err("conv2d requires NCHW input and 4-D weight".into());
806    }
807    let n = input.dim(0);
808    let c_in = input.dim(1).unwrap_static();
809    let h = input.dim(2).unwrap_static();
810    let w = input.dim(3).unwrap_static();
811    let c_out = weight.dim(0).unwrap_static();
812    let w_cin = weight.dim(1).unwrap_static();
813    if w_cin * groups != c_in {
814        return Err(format!(
815            "conv2d weight C_in/g={w_cin} * groups={groups} != input C={c_in}"
816        ));
817    }
818    let h_out = conv2d_spatial_output(h, kernel_size[0], stride[0], padding[0], dilation[0]);
819    let w_out = conv2d_spatial_output(w, kernel_size[1], stride[1], padding[1], dilation[1]);
820    Ok(Shape::from_dims(
821        &[
822            n,
823            Dim::Static(c_out),
824            Dim::Static(h_out),
825            Dim::Static(w_out),
826        ],
827        input.dtype(),
828    ))
829}
830
831/// Output shape for NCHW `Op::Im2Col`: `[M, C·kH·kW]` with
832/// `M = N · H_out · W_out`. Dynamic batch maps to [`dynamic::sym::ROWS`].
833pub fn im2col_output_shape(
834    input: &Shape,
835    kernel_size: [usize; 2],
836    stride: [usize; 2],
837    padding: [usize; 2],
838    dilation: [usize; 2],
839) -> Result<Shape, String> {
840    if input.rank() != 4 {
841        return Err("im2col requires NCHW input".into());
842    }
843    let c_in = input.dim(1).unwrap_static();
844    let h = input.dim(2).unwrap_static();
845    let w = input.dim(3).unwrap_static();
846    let kh = kernel_size[0];
847    let kw = kernel_size[1];
848    let h_out = conv2d_spatial_output(h, kh, stride[0], padding[0], dilation[0]);
849    let w_out = conv2d_spatial_output(w, kw, stride[1], padding[1], dilation[1]);
850    let k = c_in * kh * kw;
851    let spatial = h_out * w_out;
852    let m = match input.dim(0) {
853        Dim::Static(n) => Dim::Static(n * spatial),
854        Dim::Dynamic(crate::dynamic::sym::BATCH) | Dim::Dynamic(crate::dynamic::sym::ROWS) => {
855            Dim::Dynamic(crate::dynamic::sym::ROWS)
856        }
857        Dim::Dynamic(_) => Dim::Dynamic(crate::dynamic::sym::ROWS),
858    };
859    Ok(Shape::from_dims(&[m, Dim::Static(k)], input.dtype()))
860}
861
862/// Output shape for `conv_transpose2d` (weight `[C_in, C_out/g, kH, kW]`).
863pub fn conv_transpose2d_output_shape(
864    input: &Shape,
865    weight: &Shape,
866    kernel_size: [usize; 2],
867    stride: [usize; 2],
868    padding: [usize; 2],
869    dilation: [usize; 2],
870    output_padding: [usize; 2],
871    groups: usize,
872) -> Result<Shape, String> {
873    if input.rank() != 4 || weight.rank() != 4 {
874        return Err("conv_transpose2d requires NCHW input and 4-D weight".into());
875    }
876    // Preserve the batch dim (it may be dynamic) — mirror `conv2d_output_shape`.
877    let n = input.dim(0);
878    let c_in = input.dim(1).unwrap_static();
879    let h = input.dim(2).unwrap_static();
880    let w = input.dim(3).unwrap_static();
881    let w_cin = weight.dim(0).unwrap_static();
882    let c_out_per_g = weight.dim(1).unwrap_static();
883    if w_cin != c_in {
884        return Err(format!(
885            "conv_transpose2d weight C_in={w_cin} != input C={c_in}"
886        ));
887    }
888    let h_out = conv_transpose2d_spatial_output(
889        h,
890        kernel_size[0],
891        stride[0],
892        padding[0],
893        dilation[0],
894        output_padding[0],
895    );
896    let w_out = conv_transpose2d_spatial_output(
897        w,
898        kernel_size[1],
899        stride[1],
900        padding[1],
901        dilation[1],
902        output_padding[1],
903    );
904    Ok(Shape::from_dims(
905        &[
906            n,
907            Dim::Static(c_out_per_g * groups),
908            Dim::Static(h_out),
909            Dim::Static(w_out),
910        ],
911        input.dtype(),
912    ))
913}
914
915/// Output shape for NCDHW `Op::Conv3d` given weight `[C_out, C_in/g, kD, kH, kW]`.
916/// Reuses the 1-D `conv2d_spatial_output` formula per spatial axis.
917#[allow(clippy::too_many_arguments)]
918pub fn conv3d_output_shape(
919    input: &Shape,
920    weight: &Shape,
921    kernel_size: [usize; 3],
922    stride: [usize; 3],
923    padding: [usize; 3],
924    dilation: [usize; 3],
925    groups: usize,
926) -> Result<Shape, String> {
927    if input.rank() != 5 || weight.rank() != 5 {
928        return Err("conv3d requires NCDHW input and 5-D weight".into());
929    }
930    let n = input.dim(0);
931    let c_in = input.dim(1).unwrap_static();
932    let d = input.dim(2).unwrap_static();
933    let h = input.dim(3).unwrap_static();
934    let w = input.dim(4).unwrap_static();
935    let c_out = weight.dim(0).unwrap_static();
936    let w_cin = weight.dim(1).unwrap_static();
937    if w_cin * groups != c_in {
938        return Err(format!(
939            "conv3d weight C_in/g={w_cin} * groups={groups} != input C={c_in}"
940        ));
941    }
942    let d_out = conv2d_spatial_output(d, kernel_size[0], stride[0], padding[0], dilation[0]);
943    let h_out = conv2d_spatial_output(h, kernel_size[1], stride[1], padding[1], dilation[1]);
944    let w_out = conv2d_spatial_output(w, kernel_size[2], stride[2], padding[2], dilation[2]);
945    Ok(Shape::from_dims(
946        &[
947            n,
948            Dim::Static(c_out),
949            Dim::Static(d_out),
950            Dim::Static(h_out),
951            Dim::Static(w_out),
952        ],
953        input.dtype(),
954    ))
955}
956
957/// Output shape for NCDHW `Op::ConvTranspose3d` (weight `[C_in, C_out/g, kD, kH, kW]`).
958#[allow(clippy::too_many_arguments)]
959pub fn conv_transpose3d_output_shape(
960    input: &Shape,
961    weight: &Shape,
962    kernel_size: [usize; 3],
963    stride: [usize; 3],
964    padding: [usize; 3],
965    dilation: [usize; 3],
966    output_padding: [usize; 3],
967    groups: usize,
968) -> Result<Shape, String> {
969    if input.rank() != 5 || weight.rank() != 5 {
970        return Err("conv_transpose3d requires NCDHW input and 5-D weight".into());
971    }
972    let n = input.dim(0);
973    let c_in = input.dim(1).unwrap_static();
974    let d = input.dim(2).unwrap_static();
975    let h = input.dim(3).unwrap_static();
976    let w = input.dim(4).unwrap_static();
977    let w_cin = weight.dim(0).unwrap_static();
978    let c_out_per_g = weight.dim(1).unwrap_static();
979    if w_cin != c_in {
980        return Err(format!(
981            "conv_transpose3d weight C_in={w_cin} != input C={c_in}"
982        ));
983    }
984    let d_out = conv_transpose2d_spatial_output(
985        d,
986        kernel_size[0],
987        stride[0],
988        padding[0],
989        dilation[0],
990        output_padding[0],
991    );
992    let h_out = conv_transpose2d_spatial_output(
993        h,
994        kernel_size[1],
995        stride[1],
996        padding[1],
997        dilation[1],
998        output_padding[1],
999    );
1000    let w_out = conv_transpose2d_spatial_output(
1001        w,
1002        kernel_size[2],
1003        stride[2],
1004        padding[2],
1005        dilation[2],
1006        output_padding[2],
1007    );
1008    Ok(Shape::from_dims(
1009        &[
1010            n,
1011            Dim::Static(c_out_per_g * groups),
1012            Dim::Static(d_out),
1013            Dim::Static(h_out),
1014            Dim::Static(w_out),
1015        ],
1016        input.dtype(),
1017    ))
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn static_shape() {
1026        let s = Shape::new(&[4, 15, 384], DType::F32);
1027        assert_eq!(s.rank(), 3);
1028        assert_eq!(s.num_elements(), Some(4 * 15 * 384));
1029        assert_eq!(s.size_bytes(), Some(4 * 15 * 384 * 4));
1030        assert!(s.is_static());
1031        assert_eq!(format!("{s}"), "[4, 15, 384] f32");
1032    }
1033
1034    // ── GroupedMatMul operand checks ─────────────────────────
1035
1036    #[test]
1037    fn grouped_matmul_derives_m_and_n() {
1038        let input = Shape::new(&[5, 6], DType::F32);
1039        let bank = Shape::new(&[8, 6, 12], DType::F32); // [E, K, N]
1040        let d = grouped_matmul_dims(&input, &bank, Some(&Shape::new(&[5, 12], DType::F32)))
1041            .expect("valid");
1042        assert_eq!(
1043            d,
1044            GroupedMatMulDims {
1045                m: 5,
1046                k: 6,
1047                n: 12,
1048                num_experts: 8
1049            }
1050        );
1051        assert_eq!(
1052            grouped_matmul_shape(&input, &bank).unwrap(),
1053            Shape::new(&[5, 12], DType::F32)
1054        );
1055    }
1056
1057    /// The whole point: a bank still in the checkpoint's `[E, N, K]` order has
1058    /// the right rank and element count, so only the K axis gives it away.
1059    #[test]
1060    fn grouped_matmul_rejects_an_untransposed_bank() {
1061        let input = Shape::new(&[5, 6], DType::F32);
1062        let bank = Shape::new(&[8, 12, 6], DType::F32); // [E, N, K] — wrong way round
1063        let err = grouped_matmul_dims(&input, &bank, None).unwrap_err();
1064        assert!(err.contains("K mismatch"), "unhelpful: {err}");
1065        assert!(err.contains("[E, N, K]"), "should name the cause: {err}");
1066    }
1067
1068    /// `2·inter == hidden` makes both bank layouts the *same shape*, so the K
1069    /// check passes and only the declared output catches it. This is the case
1070    /// that silently under-writes half of every output row.
1071    #[test]
1072    fn grouped_matmul_rejects_a_square_bank_by_output_shape() {
1073        let input = Shape::new(&[4, 12, 12], DType::F32);
1074        let bank = Shape::new(&[8, 12, 12], DType::F32);
1075        // K and N agree, so operand-only validation cannot object…
1076        assert!(grouped_matmul_dims(&input, &bank, None).is_ok());
1077        // …but a declared output of a different width is still caught.
1078        let err = grouped_matmul_dims(&input, &bank, Some(&Shape::new(&[12, 6], DType::F32)))
1079            .unwrap_err();
1080        assert!(err.contains("output shape mismatch"), "unhelpful: {err}");
1081    }
1082
1083    #[test]
1084    fn grouped_matmul_rejects_a_non_bank_weight() {
1085        let input = Shape::new(&[5, 6], DType::F32);
1086        let err = grouped_matmul_dims(&input, &Shape::new(&[6, 12], DType::F32), None).unwrap_err();
1087        assert!(err.contains("rank 3"), "unhelpful: {err}");
1088    }
1089
1090    // ── Shape inference tests ────────────────────────────────
1091
1092    #[test]
1093    fn broadcast_same() {
1094        let a = Shape::new(&[4, 15, 384], DType::F32);
1095        let r = broadcast(&a, &a).unwrap();
1096        assert_eq!(r.dims(), a.dims());
1097    }
1098
1099    #[test]
1100    fn broadcast_bias() {
1101        let a = Shape::new(&[4, 15, 384], DType::F32);
1102        let b = Shape::new(&[384], DType::F32);
1103        let r = broadcast(&a, &b).unwrap();
1104        assert_eq!(r, Shape::new(&[4, 15, 384], DType::F32));
1105    }
1106
1107    #[test]
1108    fn broadcast_scalar() {
1109        let a = Shape::new(&[4, 15, 384], DType::F32);
1110        let b = Shape::scalar(DType::F32);
1111        let r = broadcast(&a, &b).unwrap();
1112        assert_eq!(r, a);
1113    }
1114
1115    #[test]
1116    fn broadcast_mismatch() {
1117        let a = Shape::new(&[4, 15, 384], DType::F32);
1118        let b = Shape::new(&[4, 15, 256], DType::F32);
1119        assert!(broadcast(&a, &b).is_err());
1120    }
1121
1122    #[test]
1123    fn matmul_basic() {
1124        let a = Shape::new(&[4, 15, 384], DType::F32);
1125        let b = Shape::new(&[384, 1536], DType::F32);
1126        let r = matmul_shape(&a, &b).unwrap();
1127        assert_eq!(r, Shape::new(&[4, 15, 1536], DType::F32));
1128    }
1129
1130    #[test]
1131    fn matmul_batched() {
1132        let a = Shape::new(&[4, 15, 384], DType::F32);
1133        let b = Shape::new(&[4, 384, 1536], DType::F32);
1134        let r = matmul_shape(&a, &b).unwrap();
1135        assert_eq!(r, Shape::new(&[4, 15, 1536], DType::F32));
1136    }
1137
1138    #[test]
1139    fn matmul_k_mismatch() {
1140        let a = Shape::new(&[4, 15, 384], DType::F32);
1141        let b = Shape::new(&[512, 1536], DType::F32);
1142        assert!(matmul_shape(&a, &b).is_err());
1143    }
1144
1145    #[test]
1146    fn reduce_keepdim() {
1147        let a = Shape::new(&[4, 15, 384], DType::F32);
1148        let r = reduce_shape(&a, &[2], true).unwrap();
1149        assert_eq!(r, Shape::new(&[4, 15, 1], DType::F32));
1150    }
1151
1152    #[test]
1153    fn reduce_no_keepdim() {
1154        let a = Shape::new(&[4, 15, 384], DType::F32);
1155        let r = reduce_shape(&a, &[2], false).unwrap();
1156        assert_eq!(r, Shape::new(&[4, 15], DType::F32));
1157    }
1158
1159    #[test]
1160    fn concat_basic() {
1161        let a = Shape::new(&[4, 15, 384], DType::F32);
1162        let b = Shape::new(&[4, 15, 384], DType::F32);
1163        let r = concat_shape(&[&a, &b], 2).unwrap();
1164        assert_eq!(r, Shape::new(&[4, 15, 768], DType::F32));
1165    }
1166
1167    #[test]
1168    fn gather_embedding() {
1169        let table = Shape::new(&[30522, 384], DType::F32);
1170        let indices = Shape::new(&[4, 15], DType::I64);
1171        let r = gather_shape(&table, &indices, 0).unwrap();
1172        assert_eq!(
1173            r,
1174            Shape::from_dims(
1175                &[Dim::Static(4), Dim::Static(15), Dim::Static(384)],
1176                DType::F32
1177            )
1178        );
1179    }
1180
1181    #[test]
1182    fn gather_nonzero_axis_keeps_prefix() {
1183        // ONNX Gather output = table[:axis] ++ indices.shape ++ table[axis+1:].
1184        // Regression: axis!=0 must NOT drop the leading dims (a nearest-upsample
1185        // gather `[4,3]` on axis 1 with a `[6]` index → `[4,6]`, not `[6]`).
1186        let table = Shape::new(&[4, 3], DType::F32);
1187        let indices = Shape::new(&[6], DType::I64);
1188        let r = gather_shape(&table, &indices, 1).unwrap();
1189        assert_eq!(r, Shape::new(&[4, 6], DType::F32));
1190        // axis 1 of a rank-3 table, 2-D index.
1191        let table = Shape::new(&[2, 5, 7], DType::F32);
1192        let indices = Shape::new(&[3, 4], DType::I64);
1193        let r = gather_shape(&table, &indices, 1).unwrap();
1194        assert_eq!(r, Shape::new(&[2, 3, 4, 7], DType::F32));
1195    }
1196
1197    #[test]
1198    fn reshape_with_neg1() {
1199        let a = Shape::new(&[4, 15, 384], DType::F32);
1200        let r = reshape_shape(&a, &[60, -1]).unwrap();
1201        assert_eq!(r, Shape::new(&[60, 384], DType::F32));
1202    }
1203
1204    #[test]
1205    fn transpose_basic() {
1206        let a = Shape::new(&[4, 15, 384], DType::F32);
1207        let r = transpose_shape(&a, &[0, 2, 1]).unwrap();
1208        assert_eq!(r, Shape::new(&[4, 384, 15], DType::F32));
1209    }
1210
1211    #[test]
1212    fn narrow_basic() {
1213        let a = Shape::new(&[4, 15, 1152], DType::F32);
1214        let r = narrow_shape(&a, 2, 384).unwrap();
1215        assert_eq!(r, Shape::new(&[4, 15, 384], DType::F32));
1216    }
1217
1218    #[test]
1219    fn compare_bool_output() {
1220        let a = Shape::new(&[4, 15], DType::F32);
1221        let b = Shape::new(&[4, 15], DType::F32);
1222        let r = compare_shape(&a, &b).unwrap();
1223        assert_eq!(r.dtype(), DType::Bool);
1224        assert_eq!(r.rank(), 2);
1225    }
1226
1227    // ── Original tests ──────────────────────────────────────
1228
1229    #[test]
1230    fn dynamic_shape() {
1231        let s = Shape::from_dims(
1232            &[Dim::Dynamic(0), Dim::Dynamic(1), Dim::Static(384)],
1233            DType::F32,
1234        );
1235        assert_eq!(s.rank(), 3);
1236        assert_eq!(s.num_elements(), None);
1237        assert!(!s.is_static());
1238        assert_eq!(format!("{s}"), "[?0, ?1, 384] f32");
1239    }
1240
1241    #[test]
1242    fn dynamic_symbols_lists_distinct_dims() {
1243        let s = Shape::from_dims(
1244            &[
1245                Dim::Dynamic(1),
1246                Dim::Static(384),
1247                Dim::Dynamic(0),
1248                Dim::Dynamic(1),
1249            ],
1250            DType::F32,
1251        );
1252        assert_eq!(s.dynamic_symbols(), vec![0, 1]);
1253    }
1254
1255    #[test]
1256    fn bind_specializes_known_symbols() {
1257        let s = Shape::from_dims(
1258            &[Dim::Dynamic(0), Dim::Dynamic(1), Dim::Static(384)],
1259            DType::F32,
1260        );
1261        let mut b = DimBinding::new();
1262        b.set(0, 8);
1263        b.set(1, 64);
1264        let s2 = s.bind(&b);
1265        assert!(s2.is_static());
1266        assert_eq!(s2.num_elements(), Some(8 * 64 * 384));
1267    }
1268
1269    #[test]
1270    fn bind_leaves_unknown_symbols_alone() {
1271        let s = Shape::from_dims(&[Dim::Dynamic(0), Dim::Dynamic(99)], DType::F32);
1272        let mut b = DimBinding::new();
1273        b.set(0, 4);
1274        let s2 = s.bind(&b);
1275        assert!(!s2.is_static()); // ?99 still dynamic
1276        assert_eq!(s2.dynamic_symbols(), vec![99]);
1277    }
1278}
1279
1280/// Mapping from a dynamic-dim symbol to its concrete size at
1281/// runtime. Plan #54.
1282#[derive(Debug, Clone, Default)]
1283pub struct DimBinding {
1284    map: std::collections::HashMap<u32, usize>,
1285}
1286
1287impl DimBinding {
1288    pub fn new() -> Self {
1289        Self::default()
1290    }
1291    pub fn set(&mut self, symbol: u32, size: usize) -> Option<usize> {
1292        self.map.insert(symbol, size)
1293    }
1294    pub fn get(&self, symbol: u32) -> Option<usize> {
1295        self.map.get(&symbol).copied()
1296    }
1297    pub fn is_empty(&self) -> bool {
1298        self.map.is_empty()
1299    }
1300    pub fn len(&self) -> usize {
1301        self.map.len()
1302    }
1303    pub fn iter(&self) -> impl Iterator<Item = (u32, usize)> + '_ {
1304        self.map.iter().map(|(&s, &n)| (s, n))
1305    }
1306}