Skip to main content

rlx_ir/
shape.rs

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