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    /// How many positions a gather/scatter along `axis` takes, when `self` is
108    /// the shape of the INDEX operand.
109    ///
110    /// Gather's output rank is `data_rank - 1 + index_rank`, so an index may be
111    /// either:
112    ///
113    ///   * rank > `axis` — the usual case, where the index carries a dimension
114    ///     at the gather axis and that dimension is the count; or
115    ///   * a flat list (typically rank 1) that has no dimension at `axis` and
116    ///     whose whole extent IS the count.
117    ///
118    /// Every backend previously wrote `idx_shape.dim(axis)` here, which panics
119    /// with "Shape::dim(1) out of bounds for rank 1" on the second form — so a
120    /// rank-1 gather built a correct forward graph and then blew up the moment
121    /// it needed a backward pass. Sharing the rule keeps the five backends that
122    /// need it from disagreeing about it.
123    pub fn gather_index_count(&self, axis: usize) -> usize {
124        if self.rank() > axis {
125            self.dim(axis).unwrap_static()
126        } else {
127            self.dims
128                .iter()
129                .map(|d| d.unwrap_static())
130                .product::<usize>()
131                .max(1)
132        }
133    }
134
135    /// Set of dynamic dim symbols this shape references. Useful for
136    /// "what bindings does this graph need?" queries on inputs.
137    pub fn dynamic_symbols(&self) -> Vec<u32> {
138        let mut syms: Vec<u32> = self
139            .dims
140            .iter()
141            .filter_map(|d| match d {
142                Dim::Dynamic(s) => Some(*s),
143                _ => None,
144            })
145            .collect();
146        syms.sort();
147        syms.dedup();
148        syms
149    }
150
151    /// Specialize the shape against a binding (`symbol → static
152    /// size`). Unknown symbols stay [`Dim::Dynamic`]. Plan #54: the
153    /// step that takes a "compile once, run at any seq length" graph
154    /// and produces the runtime-specific concrete shape.
155    pub fn bind(&self, bindings: &DimBinding) -> Self {
156        let dims = self
157            .dims
158            .iter()
159            .map(|d| match d {
160                Dim::Dynamic(s) => match bindings.get(*s) {
161                    Some(n) => Dim::Static(n),
162                    None => *d,
163                },
164                _ => *d,
165            })
166            .collect();
167        Self {
168            dims,
169            dtype: self.dtype,
170        }
171    }
172
173    /// Total number of elements (only if all dims are static).
174    pub fn num_elements(&self) -> Option<usize> {
175        let mut total = 1usize;
176        for d in &self.dims {
177            match d {
178                Dim::Static(n) => total = total.checked_mul(*n)?,
179                Dim::Dynamic(_) => return None,
180            }
181        }
182        Some(total)
183    }
184
185    /// Total size in bytes (only if all dims are static).
186    pub fn size_bytes(&self) -> Option<usize> {
187        self.num_elements().map(|n| n * self.dtype.size_bytes())
188    }
189
190    /// True if all dimensions are statically known.
191    pub fn is_static(&self) -> bool {
192        self.dims.iter().all(|d| d.is_static())
193    }
194
195    /// Replace a dimension.
196    pub fn with_dim(mut self, axis: usize, dim: Dim) -> Self {
197        self.dims[axis] = dim;
198        self
199    }
200
201    /// Change dtype (for cast operations).
202    pub fn with_dtype(mut self, dtype: DType) -> Self {
203        self.dtype = dtype;
204        self
205    }
206
207    /// Numpy-style broadcast with another shape (fusion / lowering).
208    pub fn broadcast_with(&self, other: &Shape) -> Result<Shape, String> {
209        broadcast(self, other)
210    }
211}
212
213/// Pack leading-dim broadcast metadata for DiT modulation kernels
214/// ([`Op::AdaLayerNorm`](crate::op::Op::AdaLayerNorm) /
215/// [`Op::GatedResidual`](crate::op::Op::GatedResidual)).
216///
217/// Layout: `[lead_rank, x_lead[0..8], mod_lead[0..8]]` — right-aligns
218/// `mod_dims` to `x_dims` with size-1 padding (NumPy broadcast). Last axis
219/// of both shapes is the feature dim and is omitted from the pack.
220pub fn ada_modulation_lead_pack(x_dims: &[usize], mod_dims_in: &[usize]) -> [u32; 17] {
221    let rank = x_dims.len();
222    assert!(rank >= 1, "AdaLayerNorm/GatedResidual: rank ≥ 1");
223    let lead = rank - 1;
224    assert!(
225        lead <= 8,
226        "AdaLayerNorm/GatedResidual: at most 8 leading dims"
227    );
228    let pad = rank
229        .checked_sub(mod_dims_in.len())
230        .expect("modulation rank exceeds x");
231    let mut mod_dims = vec![1usize; rank];
232    mod_dims[pad..].copy_from_slice(mod_dims_in);
233    let mut pack = [0u32; 17];
234    pack[0] = lead as u32;
235    for j in 0..lead {
236        pack[1 + j] = x_dims[j] as u32;
237        pack[9 + j] = mod_dims[j] as u32;
238    }
239    pack
240}
241
242/// Launch geometry for DiT modulation backward kernels: one block/threadgroup
243/// per unique modulation row; that unit loops `seq_per_mod` feature-rows that
244/// share it (typical DiT `[B,S,D]` / `[B,1,D]` → `(B, S)`).
245pub fn ada_modulation_launch(x_dims: &[usize], mod_dims: &[usize]) -> (u32, u32) {
246    assert!(!x_dims.is_empty() && !mod_dims.is_empty());
247    let xr = x_dims.len() - 1;
248    let mr = mod_dims.len() - 1;
249    let mut seq = 1u32;
250    let mut mods = 1u32;
251    for i in 0..xr {
252        let xd = x_dims[i] as u32;
253        let md = if i + mr >= xr {
254            mod_dims[i - (xr - mr)] as u32
255        } else {
256            1
257        };
258        if md == 1 && xd > 1 {
259            seq = seq.saturating_mul(xd);
260        } else {
261            mods = mods.saturating_mul(xd.max(1));
262        }
263    }
264    (mods.max(1), seq.max(1))
265}
266
267// ── Shape inference functions ────────────────────────────────────────────
268
269/// Numpy-style broadcast of two shapes. Returns the broadcast result.
270pub fn broadcast(a: &Shape, b: &Shape) -> Result<Shape, String> {
271    let max_rank = a.rank().max(b.rank());
272    let mut dims = SmallVec::new();
273    for i in 0..max_rank {
274        let ad = if i < max_rank - a.rank() {
275            Dim::Static(1)
276        } else {
277            a.dims[i - (max_rank - a.rank())]
278        };
279        let bd = if i < max_rank - b.rank() {
280            Dim::Static(1)
281        } else {
282            b.dims[i - (max_rank - b.rank())]
283        };
284        let d = broadcast_dim(ad, bd)?;
285        dims.push(d);
286    }
287    Ok(Shape {
288        dims,
289        dtype: a.dtype,
290    })
291}
292
293fn broadcast_dim(a: Dim, b: Dim) -> Result<Dim, String> {
294    match (a, b) {
295        (Dim::Static(1), d) | (d, Dim::Static(1)) => Ok(d),
296        (Dim::Static(x), Dim::Static(y)) if x == y => Ok(Dim::Static(x)),
297        (Dim::Static(x), Dim::Static(y)) => Err(format!("cannot broadcast {x} with {y}")),
298        (Dim::Dynamic(s), Dim::Dynamic(t)) if s == t => Ok(Dim::Dynamic(s)),
299        (Dim::Dynamic(_), _) | (_, Dim::Dynamic(_)) => Ok(a), // keep first dynamic
300    }
301}
302
303/// Operand dims of a [`crate::op::Op::GroupedMatMul`], as every backend needs
304/// them: `input [.., M, K] · weight[idx[r]] [K, N] → out [M, N]`.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub struct GroupedMatMulDims {
307    pub m: usize,
308    pub k: usize,
309    pub n: usize,
310    pub num_experts: usize,
311}
312
313/// Validate a [`crate::op::Op::GroupedMatMul`]'s operands and derive `(M, K, N, E)`.
314///
315/// The expert bank is `[E, K, N]` — K **second**, N last — which is the
316/// transpose of the `[out, in]` layout checkpoints ship. Getting that backwards
317/// is the classic MoE porting bug and, until this check existed, an entirely
318/// silent one: a bank left in `[E, N, K]` has the right rank and the right
319/// element count, so nothing rejects it. The kernels then read `N` off
320/// `weight.dim(2)` — which is really K — and write `M·K` floats into the `M·N`
321/// slot the planner sized from the declared output shape. When `K < N` that
322/// leaves the tail of every output row holding whatever the arena had (looks
323/// like a non-causal model); when `K > N` it runs off the end of the slot and
324/// corrupts the neighbouring tensor.
325///
326/// So: K must agree between the input and the bank, and — when `out` is given —
327/// the declared output must be `M × N`.
328pub fn grouped_matmul_dims(
329    input: &Shape,
330    weight: &Shape,
331    out: Option<&Shape>,
332) -> Result<GroupedMatMulDims, String> {
333    if input.rank() < 2 {
334        return Err(format!(
335            "GroupedMatMul input must be rank >= 2 ([.., M, K]), got {input}"
336        ));
337    }
338    if weight.rank() != 3 {
339        return Err(format!(
340            "GroupedMatMul expert bank must be rank 3 ([E, K, N]), got {weight}"
341        ));
342    }
343    let statics = |d: Dim, what: &str| match d {
344        Dim::Static(v) => Ok(v),
345        Dim::Dynamic(s) => Err(format!(
346            "GroupedMatMul {what} must be static, got dynamic symbol {s}"
347        )),
348    };
349    let m = statics(input.dim(input.rank() - 2), "M")?;
350    let k = statics(input.dim(input.rank() - 1), "K")?;
351    let num_experts = statics(weight.dim(0), "E")?;
352    let k_w = statics(weight.dim(1), "the bank's K axis")?;
353    let n = statics(weight.dim(2), "N")?;
354
355    if k != k_w {
356        return Err(format!(
357            "GroupedMatMul K mismatch: input is {input} (K={k}) but the expert bank \
358             is {weight} (K={k_w}, N={n}). The bank must be [E, K, N]; a checkpoint \
359             tensor stored [E, N, K] ([out, in], the usual `x @ W.T` layout) has to \
360             be transposed before it reaches this op"
361        ));
362    }
363    if let Some(out) = out {
364        let out_n = statics(out.dim(out.rank().saturating_sub(1)), "the output's N")?;
365        let out_elems = out.num_elements().unwrap_or(0);
366        if out_n != n || out_elems != m * n {
367            return Err(format!(
368                "GroupedMatMul output shape mismatch: declared {out} but the operands \
369                 give [{m}, {n}] (input {input}, bank {weight})"
370            ));
371        }
372    }
373    Ok(GroupedMatMulDims {
374        m,
375        k,
376        n,
377        num_experts,
378    })
379}
380
381/// `[.., M, K]` × `[E, K, N]` → `[M, N]`. See [`grouped_matmul_dims`].
382pub fn grouped_matmul_shape(input: &Shape, weight: &Shape) -> Result<Shape, String> {
383    let d = grouped_matmul_dims(input, weight, None)?;
384    Ok(Shape::new(&[d.m, d.n], input.dtype()))
385}
386
387/// MatMul output shape: `[..,M,K] × [..,K,N] → [..,M,N]`.
388pub fn matmul_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
389    if lhs.rank() < 2 || rhs.rank() < 2 {
390        return Err(format!(
391            "matmul requires rank >= 2, got {} and {}",
392            lhs.rank(),
393            rhs.rank()
394        ));
395    }
396    let m = lhs.dims[lhs.rank() - 2];
397    let k1 = lhs.dims[lhs.rank() - 1];
398    let k2 = rhs.dims[rhs.rank() - 2];
399    let n = rhs.dims[rhs.rank() - 1];
400
401    // Verify K dimensions match
402    match (k1, k2) {
403        (Dim::Static(a), Dim::Static(b)) if a != b => {
404            return Err(format!("matmul K mismatch: {a} vs {b}"));
405        }
406        (Dim::Dynamic(s), Dim::Dynamic(t)) if s != t => {
407            return Err(format!("matmul K mismatch: ?{s} vs ?{t}"));
408        }
409        _ => {}
410    }
411
412    // Broadcast batch dimensions
413    let lhs_batch = &lhs.dims[..lhs.rank() - 2];
414    let rhs_batch = &rhs.dims[..rhs.rank() - 2];
415    let batch_a = Shape::from_dims(lhs_batch, lhs.dtype);
416    let batch_b = Shape::from_dims(rhs_batch, rhs.dtype);
417    let batch = if lhs_batch.is_empty() && rhs_batch.is_empty() {
418        SmallVec::new()
419    } else if lhs_batch.is_empty() {
420        rhs_batch.into()
421    } else if rhs_batch.is_empty() {
422        lhs_batch.into()
423    } else {
424        broadcast(&batch_a, &batch_b)?.dims.clone()
425    };
426
427    let mut dims = batch;
428    dims.push(m);
429    dims.push(n);
430    Ok(Shape {
431        dims,
432        dtype: lhs.dtype,
433    })
434}
435
436/// GGUF [`crate::Op::DequantMatMul`] output shape: `[.., M, K] × [N, K] → [.., M, N]`.
437///
438/// The weight is `[n, k]`, **not** the `[k, n]` [`matmul_shape`] assumes: GGUF
439/// stores a linear's weight in `[out_dim, in_dim]` order (GGML `ne = [in, out]`),
440/// so the op contracts the *last* axis of both operands. The same order is why
441/// `Graph::dequant_grouped_matmul_packed` can take an expert bank straight from
442/// a `ffn_*_exps.weight` blob with no transpose.
443///
444/// Usually a GGUF weight reaches the graph as a rank-1 packed byte blob whose
445/// shape says nothing about `n` or `k` — the block layout carries that, and the
446/// caller declares the output shape directly. This rule is for the case where a
447/// caller declares the *logical* rank-2 shape instead.
448pub fn dequant_matmul_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
449    if lhs.rank() < 2 {
450        return Err(format!(
451            "dequant matmul activations require rank >= 2, got {}",
452            lhs.rank()
453        ));
454    }
455    if rhs.rank() != 2 {
456        return Err(format!(
457            "dequant matmul weight must be rank 2 `[n, k]`, got rank {}",
458            rhs.rank()
459        ));
460    }
461    let m = lhs.dims[lhs.rank() - 2];
462    let k_lhs = lhs.dims[lhs.rank() - 1];
463    let n = rhs.dims[0];
464    let k_rhs = rhs.dims[1];
465
466    match (k_lhs, k_rhs) {
467        (Dim::Static(a), Dim::Static(b)) if a != b => {
468            return Err(format!(
469                "dequant matmul K mismatch: {a} vs {b} — the GGUF weight is \
470                 `[n, k]`, so K is its last axis, not its first"
471            ));
472        }
473        (Dim::Dynamic(s), Dim::Dynamic(t)) if s != t => {
474            return Err(format!("dequant matmul K mismatch: ?{s} vs ?{t}"));
475        }
476        _ => {}
477    }
478
479    let mut dims: SmallVec<[Dim; 4]> = lhs.dims[..lhs.rank() - 2].into();
480    dims.push(m);
481    dims.push(n);
482    Ok(Shape {
483        dims,
484        dtype: lhs.dtype,
485    })
486}
487
488/// ONNX Expand: broadcast `input` to `target` (numpy-style).
489pub fn expand_shape(input: &Shape, target: &[i64]) -> Result<Shape, String> {
490    if target.iter().any(|&d| d < 0) {
491        return Err("expand target has negative dim".into());
492    }
493    let target_s = Shape::new(
494        &target.iter().map(|&d| d as usize).collect::<Vec<_>>(),
495        input.dtype(),
496    );
497    broadcast(input, &target_s)
498}
499
500/// Binary element-wise shape (broadcast).
501pub fn binary_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
502    broadcast(lhs, rhs)
503}
504
505/// Unary op: output = input shape.
506pub fn unary_shape(input: &Shape) -> Shape {
507    input.clone()
508}
509
510/// Cast: change dtype, keep shape.
511pub fn cast_shape(input: &Shape, to: DType) -> Shape {
512    input.clone().with_dtype(to)
513}
514
515/// Compare: broadcast + Bool dtype.
516pub fn compare_shape(lhs: &Shape, rhs: &Shape) -> Result<Shape, String> {
517    Ok(broadcast(lhs, rhs)?.with_dtype(DType::Bool))
518}
519
520/// Reduce along axes.
521pub fn reduce_shape(input: &Shape, axes: &[usize], keep_dim: bool) -> Result<Shape, String> {
522    let mut dims = SmallVec::new();
523    for (i, &d) in input.dims.iter().enumerate() {
524        if axes.contains(&i) {
525            if keep_dim {
526                dims.push(Dim::Static(1));
527            }
528        } else {
529            dims.push(d);
530        }
531    }
532    Ok(Shape {
533        dims,
534        dtype: input.dtype,
535    })
536}
537
538/// Softmax: preserves shape.
539pub fn softmax_shape(input: &Shape) -> Shape {
540    input.clone()
541}
542
543/// Transpose: permute dims.
544pub fn transpose_shape(input: &Shape, perm: &[usize]) -> Result<Shape, String> {
545    if perm.len() != input.rank() {
546        return Err(format!("perm len {} != rank {}", perm.len(), input.rank()));
547    }
548    let dims: SmallVec<[Dim; 4]> = perm.iter().map(|&i| input.dims[i]).collect();
549    Ok(Shape {
550        dims,
551        dtype: input.dtype,
552    })
553}
554
555/// Narrow: slice along one axis.
556pub fn narrow_shape(input: &Shape, axis: usize, len: usize) -> Result<Shape, String> {
557    if axis >= input.rank() {
558        return Err(format!("axis {axis} >= rank {}", input.rank()));
559    }
560    Ok(input.clone().with_dim(axis, Dim::Static(len)))
561}
562
563/// Tile: axis `i` grows by factor `reps[i]` (aligned to the input rank).
564pub fn tile_shape(input: &Shape, reps: &[usize]) -> Result<Shape, String> {
565    if reps.len() != input.rank() {
566        return Err(format!(
567            "tile: reps length {} != rank {}",
568            reps.len(),
569            input.rank()
570        ));
571    }
572    let mut out = input.clone();
573    for (axis, &r) in reps.iter().enumerate() {
574        if r == 1 {
575            continue;
576        }
577        match input.dims[axis] {
578            Dim::Static(n) => out = out.with_dim(axis, Dim::Static(n * r)),
579            Dim::Dynamic(_) => return Err(format!("tile: cannot tile dynamic axis {axis}")),
580        }
581    }
582    Ok(out)
583}
584
585/// Strided slice: output axis `axis` has length `len` (the number of strided
586/// reads); all other axes unchanged.
587pub fn slice_shape(input: &Shape, axis: usize, len: usize) -> Result<Shape, String> {
588    if axis >= input.rank() {
589        return Err(format!("slice: axis {axis} >= rank {}", input.rank()));
590    }
591    Ok(input.clone().with_dim(axis, Dim::Static(len)))
592}
593
594/// Pad: each axis `i` grows by `pads[i][0] + pads[i][1]`. `pads` is aligned to
595/// the input rank. Padding a dynamic axis is rejected (the padded extent isn't
596/// statically known).
597pub fn pad_shape(input: &Shape, pads: &[[usize; 2]]) -> Result<Shape, String> {
598    if pads.len() != input.rank() {
599        return Err(format!(
600            "pad: pads length {} != rank {}",
601            pads.len(),
602            input.rank()
603        ));
604    }
605    let mut out = input.clone();
606    for (axis, &[before, after]) in pads.iter().enumerate() {
607        if before == 0 && after == 0 {
608            continue;
609        }
610        match input.dims[axis] {
611            Dim::Static(n) => out = out.with_dim(axis, Dim::Static(n + before + after)),
612            Dim::Dynamic(_) => {
613                return Err(format!(
614                    "pad: cannot pad dynamic axis {axis} by ({before},{after})"
615                ));
616            }
617        }
618    }
619    Ok(out)
620}
621
622/// Concat along axis.
623pub fn concat_shape(inputs: &[&Shape], axis: usize) -> Result<Shape, String> {
624    if inputs.is_empty() {
625        return Err("concat: no inputs".into());
626    }
627    let base = inputs[0];
628    let mut static_sum = 0usize;
629    let mut dyn_sym: Option<u32> = None;
630    for s in inputs {
631        if s.rank() == 0 {
632            return Err("concat: input has rank 0".into());
633        }
634        if s.rank() != base.rank() {
635            return Err(format!(
636                "concat: rank mismatch {} vs {}",
637                s.rank(),
638                base.rank()
639            ));
640        }
641        let ax = axis.min(s.rank().saturating_sub(1));
642        // Every axis OTHER than the concat axis must agree, as it does in
643        // numpy and torch. This used to go unchecked, and the output simply
644        // inherited `inputs[0]`'s dims — so a graph that concatenated a
645        // [B,1,1,31] pad onto a [B,1,C,T] activation was accepted and silently
646        // declared [B,1,1,·], collapsing the channel axis. CPU/Metal/wgpu/
647        // CoreML all ran it; only MLX refused, so the defect looked like a
648        // backend gap instead of a malformed graph (`rlx-eeginceptionerp`).
649        // Dynamic dims are skipped: their extent is not known here.
650        for d in 0..s.rank() {
651            if d == ax {
652                continue;
653            }
654            if let (Dim::Static(a), Dim::Static(b)) = (s.dims[d], base.dims[d])
655                && a != b
656            {
657                return Err(format!(
658                    "concat: axis {d} mismatch {a} vs {b} (concat axis is {axis});                      all axes but the concat axis must match"
659                ));
660            }
661        }
662        match s.dims[ax] {
663            Dim::Static(n) => static_sum += n,
664            Dim::Dynamic(sym) => {
665                if let Some(prev) = dyn_sym {
666                    if prev != sym {
667                        return Err(format!(
668                            "concat: mismatched dynamic symbols {prev} vs {sym} on axis {axis}"
669                        ));
670                    }
671                }
672                dyn_sym = Some(sym);
673            }
674        }
675    }
676    let out_dim = match dyn_sym {
677        None => Dim::Static(static_sum),
678        Some(sym) if static_sum == 0 => Dim::Dynamic(sym),
679        Some(sym) => {
680            // Mixed static + dynamic (e.g. conv_state || qkv). After `bind_graph`,
681            // `sync_concat_shapes` recomputes from concrete input shapes.
682            let _ = static_sum;
683            Dim::Dynamic(sym)
684        }
685    };
686    let out_axis = axis.min(base.rank().saturating_sub(1));
687    Ok(base.clone().with_dim(out_axis, out_dim))
688}
689
690/// Gather (embedding lookup): table\[V,D\] + indices\[B,S\] → \[B,S,D\].
691pub fn gather_shape(table: &Shape, indices: &Shape, axis: usize) -> Result<Shape, String> {
692    if axis >= table.rank() {
693        return Err(format!("gather: axis {axis} >= rank {}", table.rank()));
694    }
695    // ONNX Gather output = table[:axis] ++ indices.shape ++ table[axis+1:].
696    // The leading `table[:axis]` dims were previously DROPPED — correct only for
697    // axis 0 (empty prefix); an axis!=0 gather like `[4,3]` on axis 1 wrongly
698    // collapsed to `[6]` instead of `[4,6]` (StyleTTS2 nearest-resize / any
699    // batched/channel gather).
700    let mut dims: SmallVec<[Dim; 4]> = table.dims[..axis].iter().copied().collect();
701    dims.extend(indices.dims.iter().copied());
702    for i in (axis + 1)..table.rank() {
703        dims.push(table.dims[i]);
704    }
705    Ok(Shape {
706        dims,
707        dtype: table.dtype,
708    })
709}
710
711/// Reshape with -1 wildcard support.
712pub fn reshape_shape(input: &Shape, new_shape: &[i64]) -> Result<Shape, String> {
713    let neg_count = new_shape.iter().filter(|&&d| d == -1).count();
714    if neg_count > 1 {
715        return Err("reshape: at most one -1".into());
716    }
717
718    if input.is_static() {
719        // Only the `-1` case needs the input's element count. Computing `total`
720        // eagerly made a reshape to a FULLY-CONCRETE target fail whenever the
721        // input's product couldn't be formed (a stray over-large/garbage static
722        // dim from an upstream mis-resolved dynamic axis → `num_elements()` = None
723        // via checked_mul), aborting the whole import ("input has dynamic dims").
724        // A fully-specified target shape is well-defined regardless of the input.
725        let known_product: i64 = new_shape.iter().filter(|&&d| d != -1).product();
726        let mut dims = SmallVec::new();
727        for &d in new_shape {
728            if d == -1 {
729                let total = input
730                    .num_elements()
731                    .ok_or_else(|| "reshape: input has dynamic dims".to_string())?;
732                let inferred = total as i64 / known_product.max(1);
733                dims.push(Dim::Static(inferred as usize));
734            } else if d < 0 {
735                return Err(format!("reshape: invalid dim {d}"));
736            } else {
737                dims.push(Dim::Static(d as usize));
738            }
739        }
740        return Ok(Shape {
741            dims,
742            dtype: input.dtype,
743        });
744    }
745
746    // Symbolic input: map `-1` to the sole dynamic symbol when unambiguous
747    // (qwen35 prefill with batch=1 and `sym::SEQ`), otherwise keep dynamic.
748    let dyn_syms = input.dynamic_symbols();
749    let neg_idx = new_shape.iter().position(|&d| d == -1);
750    let mut out_dims: SmallVec<[Dim; 4]> = SmallVec::new();
751    for (i, &d) in new_shape.iter().enumerate() {
752        if Some(i) == neg_idx {
753            continue;
754        }
755        if d < 0 {
756            return Err(format!("reshape: invalid dim {d}"));
757        }
758        out_dims.push(Dim::Static(d as usize));
759    }
760    if let Some(ni) = neg_idx {
761        let inferred = if dyn_syms.len() == 1 {
762            Dim::Dynamic(dyn_syms[0])
763        } else if dyn_syms.is_empty() {
764            return Err("reshape: cannot infer -1 on static input".into());
765        } else {
766            Dim::Dynamic(crate::dynamic::sym::ROWS)
767        };
768        out_dims.insert(ni, inferred);
769    }
770    Ok(Shape {
771        dims: out_dims,
772        dtype: input.dtype,
773    })
774}
775
776/// Flatten leading axes to `[∏leading, H]` — used by `FuseRmsNormReshape` and shape verify.
777pub fn leading_flatten_fused_shape(input: &Shape) -> Option<Shape> {
778    if input.rank() < 2 {
779        return None;
780    }
781    let Dim::Static(h) = input.dim(input.rank() - 1) else {
782        return None;
783    };
784    let leading = &input.dims()[..input.rank() - 1];
785    let lead_dim = if leading.iter().all(|d| d.is_static()) {
786        Dim::Static(leading.iter().map(|d| d.unwrap_static()).product::<usize>())
787    } else {
788        let mut syms: Vec<u32> = leading
789            .iter()
790            .filter_map(|d| match d {
791                Dim::Dynamic(s) => Some(*s),
792                _ => None,
793            })
794            .collect();
795        syms.sort();
796        syms.dedup();
797        match syms.len() {
798            0 => Dim::Static(leading.iter().map(|d| d.unwrap_static()).product::<usize>()),
799            1 => Dim::Dynamic(syms[0]),
800            _ => Dim::Dynamic(crate::dynamic::sym::ROWS),
801        }
802    };
803    Some(Shape::from_dims(&[lead_dim, Dim::Static(h)], input.dtype()))
804}
805
806/// Match `Reshape { new_shape }` after RmsNorm when fusing to a single op.
807pub fn leading_flatten_shape(input: &Shape, new_shape: &[i64]) -> Option<Shape> {
808    if new_shape.len() != 2 {
809        return None;
810    }
811    let flat = leading_flatten_fused_shape(input)?;
812    let Dim::Static(h) = input.dim(input.rank() - 1) else {
813        return None;
814    };
815    if new_shape[1] as usize != h {
816        return None;
817    }
818    match flat.dim(0) {
819        Dim::Static(lead) if new_shape[0] as usize == lead => Some(flat),
820        Dim::Dynamic(_) if new_shape[0] == -1 => Some(flat),
821        _ => None,
822    }
823}
824
825/// Attention: output shape = Q shape.
826pub fn attention_shape(q: &Shape) -> Shape {
827    q.clone()
828}
829
830/// Attention output shape accounting for an asymmetric V/output width. The
831/// output has the same layout as `q` except the per-head width is `v_head_dim`:
832/// rank-4 `[.., D]` → `[.., v_head_dim]`; rank ≤ 3 `[.., H·head_dim]` →
833/// `[.., num_heads·v_head_dim]`. Equals `q` when `head_dim == v_head_dim`.
834pub fn attention_shape_vdim(
835    q: &Shape,
836    num_heads: usize,
837    head_dim: usize,
838    v_head_dim: usize,
839) -> Shape {
840    if head_dim == v_head_dim || q.rank() == 0 {
841        return q.clone();
842    }
843    let rank = q.rank();
844    let new_last = if rank >= 4 {
845        v_head_dim
846    } else {
847        num_heads * v_head_dim
848    };
849    q.clone().with_dim(rank - 1, Dim::Static(new_last))
850}
851
852impl std::fmt::Display for Shape {
853    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
854        write!(f, "[")?;
855        for (i, d) in self.dims.iter().enumerate() {
856            if i > 0 {
857                write!(f, ", ")?;
858            }
859            write!(f, "{d}")?;
860        }
861        write!(f, "] {}", self.dtype)
862    }
863}
864
865/// Spatial output size for NCHW `Op::Conv` / `conv2d`.
866pub fn conv2d_spatial_output(
867    in_size: usize,
868    kernel: usize,
869    stride: usize,
870    padding: usize,
871    dilation: usize,
872) -> usize {
873    let dil_k = dilation.saturating_mul(kernel.saturating_sub(1));
874    (in_size + 2 * padding)
875        .saturating_sub(dil_k)
876        .saturating_sub(1)
877        / stride
878        + 1
879}
880
881/// Spatial output size for NCHW `Op::ConvTranspose2d`.
882pub fn conv_transpose2d_spatial_output(
883    in_size: usize,
884    kernel: usize,
885    stride: usize,
886    padding: usize,
887    dilation: usize,
888    output_padding: usize,
889) -> usize {
890    let dil_k = dilation.saturating_mul(kernel.saturating_sub(1));
891    (in_size - 1) * stride + output_padding + dil_k - 2 * padding + 1
892}
893
894/// Output shape for `conv2d` given NCHW `input` and weight `[C_out, C_in/g, kH, kW]`.
895pub fn conv2d_output_shape(
896    input: &Shape,
897    weight: &Shape,
898    kernel_size: [usize; 2],
899    stride: [usize; 2],
900    padding: [usize; 2],
901    dilation: [usize; 2],
902    groups: usize,
903) -> Result<Shape, String> {
904    if input.rank() != 4 || weight.rank() != 4 {
905        return Err("conv2d requires NCHW input and 4-D weight".into());
906    }
907    let n = input.dim(0);
908    let c_in = input.dim(1).unwrap_static();
909    let h = input.dim(2).unwrap_static();
910    let w = input.dim(3).unwrap_static();
911    let c_out = weight.dim(0).unwrap_static();
912    let w_cin = weight.dim(1).unwrap_static();
913    if w_cin * groups != c_in {
914        return Err(format!(
915            "conv2d weight C_in/g={w_cin} * groups={groups} != input C={c_in}"
916        ));
917    }
918    let h_out = conv2d_spatial_output(h, kernel_size[0], stride[0], padding[0], dilation[0]);
919    let w_out = conv2d_spatial_output(w, kernel_size[1], stride[1], padding[1], dilation[1]);
920    Ok(Shape::from_dims(
921        &[
922            n,
923            Dim::Static(c_out),
924            Dim::Static(h_out),
925            Dim::Static(w_out),
926        ],
927        input.dtype(),
928    ))
929}
930
931/// Row width, in elements, of a RoPE cos/sin table.
932///
933/// Every backend needs this and each used to derive it locally, which is how
934/// the CPU and GPU rules drifted apart in the first place.
935///
936/// * A table shaped `[positions, width]` carries its row width in the last
937///   dimension, and that is what must be used. A table allocated at
938///   `head_dim/2` but only partly filled under partial rotation is *wider* than
939///   `n_rot/2`; assuming otherwise reads the wrong row for every position past
940///   the first.
941/// * A **rank-1** table is different: a flat `positions * n_rot/2` run has no
942///   row structure in its shape, so its last dimension is the whole table.
943///   Taking that as the stride sends position 1 past the end, which reads as
944///   zeros — silently zeroing every position after the first. Rows there are
945///   `n_rot/2` wide by construction.
946pub fn rope_table_stride(cos: &Shape, n_rot: usize) -> usize {
947    if cos.rank() >= 2 {
948        cos.dims()
949            .last()
950            .map_or_else(|| n_rot / 2, |d| d.unwrap_static())
951            .max(1)
952    } else {
953        (n_rot / 2).max(1)
954    }
955}
956
957/// Output shape for NCHW [`crate::Op::Pool`].
958///
959/// Pooling has no weight, so the channel count passes through; only the two
960/// spatial extents shrink, by the same rule convolution uses with dilation 1.
961pub fn pool2d_output_shape(
962    input: &Shape,
963    kernel_size: [usize; 2],
964    stride: [usize; 2],
965    padding: [usize; 2],
966) -> Result<Shape, String> {
967    if input.rank() != 4 {
968        return Err("pool2d requires an NCHW input".into());
969    }
970    if stride[0] == 0 || stride[1] == 0 {
971        return Err("pool2d stride must be non-zero".into());
972    }
973    let n = input.dim(0);
974    let c = input.dim(1);
975    let h = input.dim(2).unwrap_static();
976    let w = input.dim(3).unwrap_static();
977    let h_out = conv2d_spatial_output(h, kernel_size[0], stride[0], padding[0], 1);
978    let w_out = conv2d_spatial_output(w, kernel_size[1], stride[1], padding[1], 1);
979    if h_out == 0 || w_out == 0 {
980        return Err(format!(
981            "pool2d window {kernel_size:?} with stride {stride:?} and padding {padding:?} \
982             leaves no output for a {h}x{w} input"
983        ));
984    }
985    Ok(Shape::from_dims(
986        &[n, c, Dim::Static(h_out), Dim::Static(w_out)],
987        input.dtype(),
988    ))
989}
990
991/// Output shape for NCHW `Op::Im2Col`: `[M, C·kH·kW]` with
992/// `M = N · H_out · W_out`. Dynamic batch maps to `dynamic::sym::ROWS`.
993pub fn im2col_output_shape(
994    input: &Shape,
995    kernel_size: [usize; 2],
996    stride: [usize; 2],
997    padding: [usize; 2],
998    dilation: [usize; 2],
999) -> Result<Shape, String> {
1000    if input.rank() != 4 {
1001        return Err("im2col requires NCHW input".into());
1002    }
1003    let c_in = input.dim(1).unwrap_static();
1004    let h = input.dim(2).unwrap_static();
1005    let w = input.dim(3).unwrap_static();
1006    let kh = kernel_size[0];
1007    let kw = kernel_size[1];
1008    let h_out = conv2d_spatial_output(h, kh, stride[0], padding[0], dilation[0]);
1009    let w_out = conv2d_spatial_output(w, kw, stride[1], padding[1], dilation[1]);
1010    let k = c_in * kh * kw;
1011    let spatial = h_out * w_out;
1012    let m = match input.dim(0) {
1013        Dim::Static(n) => Dim::Static(n * spatial),
1014        Dim::Dynamic(crate::dynamic::sym::BATCH) | Dim::Dynamic(crate::dynamic::sym::ROWS) => {
1015            Dim::Dynamic(crate::dynamic::sym::ROWS)
1016        }
1017        Dim::Dynamic(_) => Dim::Dynamic(crate::dynamic::sym::ROWS),
1018    };
1019    Ok(Shape::from_dims(&[m, Dim::Static(k)], input.dtype()))
1020}
1021
1022/// Output shape for `conv_transpose2d` (weight `[C_in, C_out/g, kH, kW]`).
1023pub fn conv_transpose2d_output_shape(
1024    input: &Shape,
1025    weight: &Shape,
1026    kernel_size: [usize; 2],
1027    stride: [usize; 2],
1028    padding: [usize; 2],
1029    dilation: [usize; 2],
1030    output_padding: [usize; 2],
1031    groups: usize,
1032) -> Result<Shape, String> {
1033    if input.rank() != 4 || weight.rank() != 4 {
1034        return Err("conv_transpose2d requires NCHW input and 4-D weight".into());
1035    }
1036    // Preserve the batch dim (it may be dynamic) — mirror `conv2d_output_shape`.
1037    let n = input.dim(0);
1038    let c_in = input.dim(1).unwrap_static();
1039    let h = input.dim(2).unwrap_static();
1040    let w = input.dim(3).unwrap_static();
1041    let w_cin = weight.dim(0).unwrap_static();
1042    let c_out_per_g = weight.dim(1).unwrap_static();
1043    if w_cin != c_in {
1044        return Err(format!(
1045            "conv_transpose2d weight C_in={w_cin} != input C={c_in}"
1046        ));
1047    }
1048    let h_out = conv_transpose2d_spatial_output(
1049        h,
1050        kernel_size[0],
1051        stride[0],
1052        padding[0],
1053        dilation[0],
1054        output_padding[0],
1055    );
1056    let w_out = conv_transpose2d_spatial_output(
1057        w,
1058        kernel_size[1],
1059        stride[1],
1060        padding[1],
1061        dilation[1],
1062        output_padding[1],
1063    );
1064    Ok(Shape::from_dims(
1065        &[
1066            n,
1067            Dim::Static(c_out_per_g * groups),
1068            Dim::Static(h_out),
1069            Dim::Static(w_out),
1070        ],
1071        input.dtype(),
1072    ))
1073}
1074
1075/// Output shape for NCDHW `Op::Conv3d` given weight `[C_out, C_in/g, kD, kH, kW]`.
1076/// Reuses the 1-D `conv2d_spatial_output` formula per spatial axis.
1077#[allow(clippy::too_many_arguments)]
1078pub fn conv3d_output_shape(
1079    input: &Shape,
1080    weight: &Shape,
1081    kernel_size: [usize; 3],
1082    stride: [usize; 3],
1083    padding: [usize; 3],
1084    dilation: [usize; 3],
1085    groups: usize,
1086) -> Result<Shape, String> {
1087    if input.rank() != 5 || weight.rank() != 5 {
1088        return Err("conv3d requires NCDHW input and 5-D weight".into());
1089    }
1090    let n = input.dim(0);
1091    let c_in = input.dim(1).unwrap_static();
1092    let d = input.dim(2).unwrap_static();
1093    let h = input.dim(3).unwrap_static();
1094    let w = input.dim(4).unwrap_static();
1095    let c_out = weight.dim(0).unwrap_static();
1096    let w_cin = weight.dim(1).unwrap_static();
1097    if w_cin * groups != c_in {
1098        return Err(format!(
1099            "conv3d weight C_in/g={w_cin} * groups={groups} != input C={c_in}"
1100        ));
1101    }
1102    let d_out = conv2d_spatial_output(d, kernel_size[0], stride[0], padding[0], dilation[0]);
1103    let h_out = conv2d_spatial_output(h, kernel_size[1], stride[1], padding[1], dilation[1]);
1104    let w_out = conv2d_spatial_output(w, kernel_size[2], stride[2], padding[2], dilation[2]);
1105    Ok(Shape::from_dims(
1106        &[
1107            n,
1108            Dim::Static(c_out),
1109            Dim::Static(d_out),
1110            Dim::Static(h_out),
1111            Dim::Static(w_out),
1112        ],
1113        input.dtype(),
1114    ))
1115}
1116
1117/// Output shape for NCDHW `Op::ConvTranspose3d` (weight `[C_in, C_out/g, kD, kH, kW]`).
1118#[allow(clippy::too_many_arguments)]
1119pub fn conv_transpose3d_output_shape(
1120    input: &Shape,
1121    weight: &Shape,
1122    kernel_size: [usize; 3],
1123    stride: [usize; 3],
1124    padding: [usize; 3],
1125    dilation: [usize; 3],
1126    output_padding: [usize; 3],
1127    groups: usize,
1128) -> Result<Shape, String> {
1129    if input.rank() != 5 || weight.rank() != 5 {
1130        return Err("conv_transpose3d requires NCDHW input and 5-D weight".into());
1131    }
1132    let n = input.dim(0);
1133    let c_in = input.dim(1).unwrap_static();
1134    let d = input.dim(2).unwrap_static();
1135    let h = input.dim(3).unwrap_static();
1136    let w = input.dim(4).unwrap_static();
1137    let w_cin = weight.dim(0).unwrap_static();
1138    let c_out_per_g = weight.dim(1).unwrap_static();
1139    if w_cin != c_in {
1140        return Err(format!(
1141            "conv_transpose3d weight C_in={w_cin} != input C={c_in}"
1142        ));
1143    }
1144    let d_out = conv_transpose2d_spatial_output(
1145        d,
1146        kernel_size[0],
1147        stride[0],
1148        padding[0],
1149        dilation[0],
1150        output_padding[0],
1151    );
1152    let h_out = conv_transpose2d_spatial_output(
1153        h,
1154        kernel_size[1],
1155        stride[1],
1156        padding[1],
1157        dilation[1],
1158        output_padding[1],
1159    );
1160    let w_out = conv_transpose2d_spatial_output(
1161        w,
1162        kernel_size[2],
1163        stride[2],
1164        padding[2],
1165        dilation[2],
1166        output_padding[2],
1167    );
1168    Ok(Shape::from_dims(
1169        &[
1170            n,
1171            Dim::Static(c_out_per_g * groups),
1172            Dim::Static(d_out),
1173            Dim::Static(h_out),
1174            Dim::Static(w_out),
1175        ],
1176        input.dtype(),
1177    ))
1178}
1179
1180/// `KvAppend`: write `row` into `cache` at index `pos` along `axis`, and return
1181/// the `[..pos+1]` prefix (which ALIASES the cache buffer).
1182///
1183/// Every operand relationship is checked here rather than left to the backends,
1184/// because none of them can check it: by the time a row write reaches a kernel
1185/// it is a byte offset and a length, and a `pos` past the cache or a row of the
1186/// wrong width is an out-of-bounds store into whatever the memory planner put
1187/// next in the arena. That is a corrupted neighbouring tensor, not a crash —
1188/// wrong logits with no error anywhere. The op is also the one place where a
1189/// caller must size a buffer to a CAPACITY rather than to its current contents
1190/// (`pos` indexes into the spare rows), so an off-by-one here is easy to write
1191/// and invisible afterwards.
1192///
1193/// Dynamic dims are skipped rather than guessed at: a symbolic sequence
1194/// capacity is legitimate, and the check that matters (`pos < cap`) can only be
1195/// made against a static one.
1196pub fn kv_append_shape(
1197    cache: &Shape,
1198    row: &Shape,
1199    axis: usize,
1200    pos: usize,
1201) -> Result<Shape, String> {
1202    if axis >= cache.rank() {
1203        return Err(format!(
1204            "kv_append: axis {axis} is out of range for a rank-{} cache",
1205            cache.rank()
1206        ));
1207    }
1208    if row.rank() != cache.rank() {
1209        return Err(format!(
1210            "kv_append: row rank {} does not match cache rank {} — the row is a \
1211             one-step slice of the cache, not a squeezed vector",
1212            row.rank(),
1213            cache.rank()
1214        ));
1215    }
1216    if row.dtype() != cache.dtype() {
1217        return Err(format!(
1218            "kv_append: row dtype {:?} does not match cache dtype {:?} — the write \
1219             is a raw copy, so a mismatch reinterprets the row's bits",
1220            row.dtype(),
1221            cache.dtype()
1222        ));
1223    }
1224    if let Dim::Static(cap) = cache.dim(axis)
1225        && pos >= cap
1226    {
1227        return Err(format!(
1228            "kv_append: pos {pos} is past the cache's axis-{axis} capacity {cap} — \
1229             the cache must be sized to the CAPACITY it will grow to, not to the \
1230             number of rows it currently holds"
1231        ));
1232    }
1233    if let Dim::Static(n) = row.dim(axis)
1234        && n != 1
1235    {
1236        return Err(format!(
1237            "kv_append: row axis-{axis} extent is {n}, must be 1 (one step)"
1238        ));
1239    }
1240    for i in 0..cache.rank() {
1241        if i == axis {
1242            continue;
1243        }
1244        if let (Dim::Static(c), Dim::Static(r)) = (cache.dim(i), row.dim(i))
1245            && c != r
1246        {
1247            return Err(format!(
1248                "kv_append: row dim {i} is {r}, cache dim {i} is {c} — every axis but \
1249                 {axis} must match or the copy walks off the row"
1250            ));
1251        }
1252    }
1253    Ok(cache.clone().with_dim(axis, Dim::Static(pos + 1)))
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258
1259    /// A `Concat` whose inputs disagree on a NON-concat axis must be rejected,
1260    /// not silently shaped from `inputs[0]`.
1261    ///
1262    /// `rlx-eeginceptionerp` declared its zero-padding as [B,cin,1,l] while the
1263    /// activation was [B,cin,C,T]. The old inference returned [B,cin,1,·],
1264    /// collapsing the channel axis of the first block. CPU/Metal/wgpu/CoreML
1265    /// executed it anyway; only MLX refused, so a malformed graph looked like a
1266    /// backend gap.
1267    #[test]
1268    fn concat_rejects_non_concat_axis_mismatch() {
1269        let pad = Shape::new(&[3, 1, 1, 31], DType::F32);
1270        let act = Shape::new(&[3, 1, 8, 1000], DType::F32);
1271        let err = concat_shape(&[&pad, &act], 3).unwrap_err();
1272        assert!(
1273            err.contains("axis 2 mismatch"),
1274            "expected the channel-axis mismatch to be named, got: {err}"
1275        );
1276
1277        // The well-formed version still infers normally.
1278        let pad_ok = Shape::new(&[3, 1, 8, 31], DType::F32);
1279        let out = concat_shape(&[&pad_ok, &act], 3).expect("matching dims must concat");
1280        assert_eq!(out.dims()[3].unwrap_static(), 1031);
1281        assert_eq!(out.dims()[2].unwrap_static(), 8);
1282    }
1283
1284    /// The concat axis itself is exempt — that is the whole point of it.
1285    #[test]
1286    fn concat_axis_itself_may_differ() {
1287        let a = Shape::new(&[2, 3], DType::F32);
1288        let b = Shape::new(&[5, 3], DType::F32);
1289        let out = concat_shape(&[&a, &b], 0).expect("concat axis may differ");
1290        assert_eq!(out.dims()[0].unwrap_static(), 7);
1291    }
1292
1293    use super::*;
1294
1295    /// A GGUF weight is `[n, k]`. Reading it as `[k, n]` rejects correct graphs
1296    /// — a square weight hides that, so both cases here are non-square.
1297    #[test]
1298    fn dequant_matmul_contracts_the_weights_last_axis() {
1299        let (m, k, n) = (2usize, 64usize, 3usize);
1300        let x = Shape::new(&[m, k], DType::F32);
1301        let w_nk = Shape::new(&[n, k], DType::F32);
1302
1303        assert_eq!(
1304            dequant_matmul_shape(&x, &w_nk).expect("[n,k] weight is the GGUF layout"),
1305            Shape::new(&[m, n], DType::F32),
1306        );
1307
1308        // The plain matmul rule reads the same operands backwards. This is the
1309        // rejection `coreml_quant::dequant_matmul_through_session` used to hit.
1310        assert!(matmul_shape(&x, &w_nk).unwrap_err().contains("K mismatch"));
1311
1312        // Leading batch axes ride along; only the last two participate.
1313        assert_eq!(
1314            dequant_matmul_shape(&Shape::new(&[5, m, k], DType::F32), &w_nk).unwrap(),
1315            Shape::new(&[5, m, n], DType::F32),
1316        );
1317
1318        // A genuinely wrong K is still caught, and says which axis it means.
1319        let err = dequant_matmul_shape(&x, &Shape::new(&[n, k + 1], DType::F32)).unwrap_err();
1320        assert!(err.contains("K mismatch"), "{err}");
1321        assert!(err.contains("last axis"), "{err}");
1322
1323        // A packed byte blob has no `[n, k]` to read; the caller declares the
1324        // output shape instead, and `infer_shape` never reaches this rule.
1325        assert!(dequant_matmul_shape(&x, &Shape::new(&[1024], DType::U8)).is_err());
1326    }
1327
1328    /// The two index forms a gather backward has to accept.
1329    #[test]
1330    fn gather_index_count_handles_both_index_forms() {
1331        // Indexed AT the axis: the count is that dimension.
1332        let batched = Shape::new(&[2, 11], DType::F32);
1333        assert_eq!(batched.gather_index_count(1), 11);
1334        assert_eq!(batched.gather_index_count(0), 2);
1335
1336        // A flat list with no dimension at the axis: its extent IS the count.
1337        // `dim(1)` would panic here, which is exactly the bug this replaced.
1338        let flat = Shape::new(&[11], DType::F32);
1339        assert_eq!(flat.gather_index_count(1), 11);
1340        assert_eq!(flat.gather_index_count(0), 11);
1341        assert_eq!(flat.gather_index_count(3), 11, "any axis beyond rank");
1342
1343        // A scalar index selects exactly one position, never zero.
1344        assert_eq!(Shape::new(&[], DType::F32).gather_index_count(0), 1);
1345    }
1346
1347    #[test]
1348    fn static_shape() {
1349        let s = Shape::new(&[4, 15, 384], DType::F32);
1350        assert_eq!(s.rank(), 3);
1351        assert_eq!(s.num_elements(), Some(4 * 15 * 384));
1352        assert_eq!(s.size_bytes(), Some(4 * 15 * 384 * 4));
1353        assert!(s.is_static());
1354        assert_eq!(format!("{s}"), "[4, 15, 384] f32");
1355    }
1356
1357    // ── GroupedMatMul operand checks ─────────────────────────
1358
1359    #[test]
1360    fn grouped_matmul_derives_m_and_n() {
1361        let input = Shape::new(&[5, 6], DType::F32);
1362        let bank = Shape::new(&[8, 6, 12], DType::F32); // [E, K, N]
1363        let d = grouped_matmul_dims(&input, &bank, Some(&Shape::new(&[5, 12], DType::F32)))
1364            .expect("valid");
1365        assert_eq!(
1366            d,
1367            GroupedMatMulDims {
1368                m: 5,
1369                k: 6,
1370                n: 12,
1371                num_experts: 8
1372            }
1373        );
1374        assert_eq!(
1375            grouped_matmul_shape(&input, &bank).unwrap(),
1376            Shape::new(&[5, 12], DType::F32)
1377        );
1378    }
1379
1380    /// The whole point: a bank still in the checkpoint's `[E, N, K]` order has
1381    /// the right rank and element count, so only the K axis gives it away.
1382    #[test]
1383    fn grouped_matmul_rejects_an_untransposed_bank() {
1384        let input = Shape::new(&[5, 6], DType::F32);
1385        let bank = Shape::new(&[8, 12, 6], DType::F32); // [E, N, K] — wrong way round
1386        let err = grouped_matmul_dims(&input, &bank, None).unwrap_err();
1387        assert!(err.contains("K mismatch"), "unhelpful: {err}");
1388        assert!(err.contains("[E, N, K]"), "should name the cause: {err}");
1389    }
1390
1391    /// `2·inter == hidden` makes both bank layouts the *same shape*, so the K
1392    /// check passes and only the declared output catches it. This is the case
1393    /// that silently under-writes half of every output row.
1394    #[test]
1395    fn grouped_matmul_rejects_a_square_bank_by_output_shape() {
1396        let input = Shape::new(&[4, 12, 12], DType::F32);
1397        let bank = Shape::new(&[8, 12, 12], DType::F32);
1398        // K and N agree, so operand-only validation cannot object…
1399        assert!(grouped_matmul_dims(&input, &bank, None).is_ok());
1400        // …but a declared output of a different width is still caught.
1401        let err = grouped_matmul_dims(&input, &bank, Some(&Shape::new(&[12, 6], DType::F32)))
1402            .unwrap_err();
1403        assert!(err.contains("output shape mismatch"), "unhelpful: {err}");
1404    }
1405
1406    #[test]
1407    fn grouped_matmul_rejects_a_non_bank_weight() {
1408        let input = Shape::new(&[5, 6], DType::F32);
1409        let err = grouped_matmul_dims(&input, &Shape::new(&[6, 12], DType::F32), None).unwrap_err();
1410        assert!(err.contains("rank 3"), "unhelpful: {err}");
1411    }
1412
1413    // ── Shape inference tests ────────────────────────────────
1414
1415    #[test]
1416    fn broadcast_same() {
1417        let a = Shape::new(&[4, 15, 384], DType::F32);
1418        let r = broadcast(&a, &a).unwrap();
1419        assert_eq!(r.dims(), a.dims());
1420    }
1421
1422    #[test]
1423    fn broadcast_bias() {
1424        let a = Shape::new(&[4, 15, 384], DType::F32);
1425        let b = Shape::new(&[384], DType::F32);
1426        let r = broadcast(&a, &b).unwrap();
1427        assert_eq!(r, Shape::new(&[4, 15, 384], DType::F32));
1428    }
1429
1430    #[test]
1431    fn broadcast_scalar() {
1432        let a = Shape::new(&[4, 15, 384], DType::F32);
1433        let b = Shape::scalar(DType::F32);
1434        let r = broadcast(&a, &b).unwrap();
1435        assert_eq!(r, a);
1436    }
1437
1438    #[test]
1439    fn broadcast_mismatch() {
1440        let a = Shape::new(&[4, 15, 384], DType::F32);
1441        let b = Shape::new(&[4, 15, 256], DType::F32);
1442        assert!(broadcast(&a, &b).is_err());
1443    }
1444
1445    #[test]
1446    fn matmul_basic() {
1447        let a = Shape::new(&[4, 15, 384], DType::F32);
1448        let b = Shape::new(&[384, 1536], DType::F32);
1449        let r = matmul_shape(&a, &b).unwrap();
1450        assert_eq!(r, Shape::new(&[4, 15, 1536], DType::F32));
1451    }
1452
1453    #[test]
1454    fn matmul_batched() {
1455        let a = Shape::new(&[4, 15, 384], DType::F32);
1456        let b = Shape::new(&[4, 384, 1536], DType::F32);
1457        let r = matmul_shape(&a, &b).unwrap();
1458        assert_eq!(r, Shape::new(&[4, 15, 1536], DType::F32));
1459    }
1460
1461    #[test]
1462    fn matmul_k_mismatch() {
1463        let a = Shape::new(&[4, 15, 384], DType::F32);
1464        let b = Shape::new(&[512, 1536], DType::F32);
1465        assert!(matmul_shape(&a, &b).is_err());
1466    }
1467
1468    #[test]
1469    fn reduce_keepdim() {
1470        let a = Shape::new(&[4, 15, 384], DType::F32);
1471        let r = reduce_shape(&a, &[2], true).unwrap();
1472        assert_eq!(r, Shape::new(&[4, 15, 1], DType::F32));
1473    }
1474
1475    #[test]
1476    fn reduce_no_keepdim() {
1477        let a = Shape::new(&[4, 15, 384], DType::F32);
1478        let r = reduce_shape(&a, &[2], false).unwrap();
1479        assert_eq!(r, Shape::new(&[4, 15], DType::F32));
1480    }
1481
1482    #[test]
1483    fn concat_basic() {
1484        let a = Shape::new(&[4, 15, 384], DType::F32);
1485        let b = Shape::new(&[4, 15, 384], DType::F32);
1486        let r = concat_shape(&[&a, &b], 2).unwrap();
1487        assert_eq!(r, Shape::new(&[4, 15, 768], DType::F32));
1488    }
1489
1490    #[test]
1491    fn gather_embedding() {
1492        let table = Shape::new(&[30522, 384], DType::F32);
1493        let indices = Shape::new(&[4, 15], DType::I64);
1494        let r = gather_shape(&table, &indices, 0).unwrap();
1495        assert_eq!(
1496            r,
1497            Shape::from_dims(
1498                &[Dim::Static(4), Dim::Static(15), Dim::Static(384)],
1499                DType::F32
1500            )
1501        );
1502    }
1503
1504    #[test]
1505    fn gather_nonzero_axis_keeps_prefix() {
1506        // ONNX Gather output = table[:axis] ++ indices.shape ++ table[axis+1:].
1507        // Regression: axis!=0 must NOT drop the leading dims (a nearest-upsample
1508        // gather `[4,3]` on axis 1 with a `[6]` index → `[4,6]`, not `[6]`).
1509        let table = Shape::new(&[4, 3], DType::F32);
1510        let indices = Shape::new(&[6], DType::I64);
1511        let r = gather_shape(&table, &indices, 1).unwrap();
1512        assert_eq!(r, Shape::new(&[4, 6], DType::F32));
1513        // axis 1 of a rank-3 table, 2-D index.
1514        let table = Shape::new(&[2, 5, 7], DType::F32);
1515        let indices = Shape::new(&[3, 4], DType::I64);
1516        let r = gather_shape(&table, &indices, 1).unwrap();
1517        assert_eq!(r, Shape::new(&[2, 3, 4, 7], DType::F32));
1518    }
1519
1520    #[test]
1521    fn reshape_with_neg1() {
1522        let a = Shape::new(&[4, 15, 384], DType::F32);
1523        let r = reshape_shape(&a, &[60, -1]).unwrap();
1524        assert_eq!(r, Shape::new(&[60, 384], DType::F32));
1525    }
1526
1527    #[test]
1528    fn transpose_basic() {
1529        let a = Shape::new(&[4, 15, 384], DType::F32);
1530        let r = transpose_shape(&a, &[0, 2, 1]).unwrap();
1531        assert_eq!(r, Shape::new(&[4, 384, 15], DType::F32));
1532    }
1533
1534    #[test]
1535    fn narrow_basic() {
1536        let a = Shape::new(&[4, 15, 1152], DType::F32);
1537        let r = narrow_shape(&a, 2, 384).unwrap();
1538        assert_eq!(r, Shape::new(&[4, 15, 384], DType::F32));
1539    }
1540
1541    #[test]
1542    fn compare_bool_output() {
1543        let a = Shape::new(&[4, 15], DType::F32);
1544        let b = Shape::new(&[4, 15], DType::F32);
1545        let r = compare_shape(&a, &b).unwrap();
1546        assert_eq!(r.dtype(), DType::Bool);
1547        assert_eq!(r.rank(), 2);
1548    }
1549
1550    // ── Original tests ──────────────────────────────────────
1551
1552    #[test]
1553    fn dynamic_shape() {
1554        let s = Shape::from_dims(
1555            &[Dim::Dynamic(0), Dim::Dynamic(1), Dim::Static(384)],
1556            DType::F32,
1557        );
1558        assert_eq!(s.rank(), 3);
1559        assert_eq!(s.num_elements(), None);
1560        assert!(!s.is_static());
1561        assert_eq!(format!("{s}"), "[?0, ?1, 384] f32");
1562    }
1563
1564    #[test]
1565    fn dynamic_symbols_lists_distinct_dims() {
1566        let s = Shape::from_dims(
1567            &[
1568                Dim::Dynamic(1),
1569                Dim::Static(384),
1570                Dim::Dynamic(0),
1571                Dim::Dynamic(1),
1572            ],
1573            DType::F32,
1574        );
1575        assert_eq!(s.dynamic_symbols(), vec![0, 1]);
1576    }
1577
1578    #[test]
1579    fn bind_specializes_known_symbols() {
1580        let s = Shape::from_dims(
1581            &[Dim::Dynamic(0), Dim::Dynamic(1), Dim::Static(384)],
1582            DType::F32,
1583        );
1584        let mut b = DimBinding::new();
1585        b.set(0, 8);
1586        b.set(1, 64);
1587        let s2 = s.bind(&b);
1588        assert!(s2.is_static());
1589        assert_eq!(s2.num_elements(), Some(8 * 64 * 384));
1590    }
1591
1592    #[test]
1593    fn bind_leaves_unknown_symbols_alone() {
1594        let s = Shape::from_dims(&[Dim::Dynamic(0), Dim::Dynamic(99)], DType::F32);
1595        let mut b = DimBinding::new();
1596        b.set(0, 4);
1597        let s2 = s.bind(&b);
1598        assert!(!s2.is_static()); // ?99 still dynamic
1599        assert_eq!(s2.dynamic_symbols(), vec![99]);
1600    }
1601}
1602
1603/// Mapping from a dynamic-dim symbol to its concrete size at
1604/// runtime. Plan #54.
1605#[derive(Debug, Clone, Default)]
1606pub struct DimBinding {
1607    map: std::collections::HashMap<u32, usize>,
1608}
1609
1610impl DimBinding {
1611    pub fn new() -> Self {
1612        Self::default()
1613    }
1614    pub fn set(&mut self, symbol: u32, size: usize) -> Option<usize> {
1615        self.map.insert(symbol, size)
1616    }
1617    pub fn get(&self, symbol: u32) -> Option<usize> {
1618        self.map.get(&symbol).copied()
1619    }
1620    pub fn is_empty(&self) -> bool {
1621        self.map.is_empty()
1622    }
1623    pub fn len(&self) -> usize {
1624        self.map.len()
1625    }
1626    pub fn iter(&self) -> impl Iterator<Item = (u32, usize)> + '_ {
1627        self.map.iter().map(|(&s, &n)| (s, n))
1628    }
1629}