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    narrow_shape_at(input, axis, 0, len)
558}
559
560/// [`narrow_shape`], validating the window against the axis.
561///
562/// `start + len` past the axis produced a *larger* output shape than the input
563/// with no complaint — `[2, 3]` narrowed to `len` 9 inferred `[2, 9]`, which
564/// compiles, passes `check`, and reads out of bounds at execution. The window
565/// has to be checked where `start` is known, and only the builder knows it.
566pub fn narrow_shape_at(
567    input: &Shape,
568    axis: usize,
569    start: usize,
570    len: usize,
571) -> Result<Shape, String> {
572    if axis >= input.rank() {
573        return Err(format!("axis {axis} >= rank {}", input.rank()));
574    }
575    // A dynamic axis has no bound to check against at build time.
576    if let Dim::Static(extent) = input.dim(axis) {
577        if start + len > extent {
578            return Err(format!(
579                "narrow: window start {start} + len {len} exceeds axis {axis} extent {extent}"
580            ));
581        }
582    }
583    Ok(input.clone().with_dim(axis, Dim::Static(len)))
584}
585
586/// Tile: axis `i` grows by factor `reps[i]` (aligned to the input rank).
587pub fn tile_shape(input: &Shape, reps: &[usize]) -> Result<Shape, String> {
588    if reps.len() != input.rank() {
589        return Err(format!(
590            "tile: reps length {} != rank {}",
591            reps.len(),
592            input.rank()
593        ));
594    }
595    let mut out = input.clone();
596    for (axis, &r) in reps.iter().enumerate() {
597        if r == 1 {
598            continue;
599        }
600        match input.dims[axis] {
601            Dim::Static(n) => out = out.with_dim(axis, Dim::Static(n * r)),
602            Dim::Dynamic(_) => return Err(format!("tile: cannot tile dynamic axis {axis}")),
603        }
604    }
605    Ok(out)
606}
607
608/// Strided slice: output axis `axis` has length `len` (the number of strided
609/// reads); all other axes unchanged.
610pub fn slice_shape(input: &Shape, axis: usize, len: usize) -> Result<Shape, String> {
611    if axis >= input.rank() {
612        return Err(format!("slice: axis {axis} >= rank {}", input.rank()));
613    }
614    Ok(input.clone().with_dim(axis, Dim::Static(len)))
615}
616
617/// Pad: each axis `i` grows by `pads[i][0] + pads[i][1]`. `pads` is aligned to
618/// the input rank. Padding a dynamic axis is rejected (the padded extent isn't
619/// statically known).
620pub fn pad_shape(input: &Shape, pads: &[[usize; 2]]) -> Result<Shape, String> {
621    if pads.len() != input.rank() {
622        return Err(format!(
623            "pad: pads length {} != rank {}",
624            pads.len(),
625            input.rank()
626        ));
627    }
628    let mut out = input.clone();
629    for (axis, &[before, after]) in pads.iter().enumerate() {
630        if before == 0 && after == 0 {
631            continue;
632        }
633        match input.dims[axis] {
634            Dim::Static(n) => out = out.with_dim(axis, Dim::Static(n + before + after)),
635            Dim::Dynamic(_) => {
636                return Err(format!(
637                    "pad: cannot pad dynamic axis {axis} by ({before},{after})"
638                ));
639            }
640        }
641    }
642    Ok(out)
643}
644
645/// Concat along axis.
646pub fn concat_shape(inputs: &[&Shape], axis: usize) -> Result<Shape, String> {
647    if inputs.is_empty() {
648        return Err("concat: no inputs".into());
649    }
650    let base = inputs[0];
651    let mut static_sum = 0usize;
652    let mut dyn_sym: Option<u32> = None;
653    for s in inputs {
654        if s.rank() == 0 {
655            return Err("concat: input has rank 0".into());
656        }
657        if s.rank() != base.rank() {
658            return Err(format!(
659                "concat: rank mismatch {} vs {}",
660                s.rank(),
661                base.rank()
662            ));
663        }
664        let ax = axis.min(s.rank().saturating_sub(1));
665        // Every axis OTHER than the concat axis must agree, as it does in
666        // numpy and torch. This used to go unchecked, and the output simply
667        // inherited `inputs[0]`'s dims — so a graph that concatenated a
668        // [B,1,1,31] pad onto a [B,1,C,T] activation was accepted and silently
669        // declared [B,1,1,·], collapsing the channel axis. CPU/Metal/wgpu/
670        // CoreML all ran it; only MLX refused, so the defect looked like a
671        // backend gap instead of a malformed graph (`rlx-eeginceptionerp`).
672        // Dynamic dims are skipped: their extent is not known here.
673        for d in 0..s.rank() {
674            if d == ax {
675                continue;
676            }
677            if let (Dim::Static(a), Dim::Static(b)) = (s.dims[d], base.dims[d])
678                && a != b
679            {
680                return Err(format!(
681                    "concat: axis {d} mismatch {a} vs {b} (concat axis is {axis});                      all axes but the concat axis must match"
682                ));
683            }
684        }
685        match s.dims[ax] {
686            Dim::Static(n) => static_sum += n,
687            Dim::Dynamic(sym) => {
688                if let Some(prev) = dyn_sym {
689                    if prev != sym {
690                        return Err(format!(
691                            "concat: mismatched dynamic symbols {prev} vs {sym} on axis {axis}"
692                        ));
693                    }
694                }
695                dyn_sym = Some(sym);
696            }
697        }
698    }
699    let out_dim = match dyn_sym {
700        None => Dim::Static(static_sum),
701        Some(sym) if static_sum == 0 => Dim::Dynamic(sym),
702        Some(sym) => {
703            // Mixed static + dynamic (e.g. conv_state || qkv). After `bind_graph`,
704            // `sync_concat_shapes` recomputes from concrete input shapes.
705            let _ = static_sum;
706            Dim::Dynamic(sym)
707        }
708    };
709    let out_axis = axis.min(base.rank().saturating_sub(1));
710    Ok(base.clone().with_dim(out_axis, out_dim))
711}
712
713/// Gather (embedding lookup): table\[V,D\] + indices\[B,S\] → \[B,S,D\].
714pub fn gather_shape(table: &Shape, indices: &Shape, axis: usize) -> Result<Shape, String> {
715    if axis >= table.rank() {
716        return Err(format!("gather: axis {axis} >= rank {}", table.rank()));
717    }
718    // ONNX Gather output = table[:axis] ++ indices.shape ++ table[axis+1:].
719    // The leading `table[:axis]` dims were previously DROPPED — correct only for
720    // axis 0 (empty prefix); an axis!=0 gather like `[4,3]` on axis 1 wrongly
721    // collapsed to `[6]` instead of `[4,6]` (StyleTTS2 nearest-resize / any
722    // batched/channel gather).
723    let mut dims: SmallVec<[Dim; 4]> = table.dims[..axis].iter().copied().collect();
724    dims.extend(indices.dims.iter().copied());
725    for i in (axis + 1)..table.rank() {
726        dims.push(table.dims[i]);
727    }
728    Ok(Shape {
729        dims,
730        dtype: table.dtype,
731    })
732}
733
734/// Reshape with -1 wildcard support.
735pub fn reshape_shape(input: &Shape, new_shape: &[i64]) -> Result<Shape, String> {
736    let neg_count = new_shape.iter().filter(|&&d| d == -1).count();
737    if neg_count > 1 {
738        return Err("reshape: at most one -1".into());
739    }
740
741    if input.is_static() {
742        // Only the `-1` case needs the input's element count. Computing `total`
743        // eagerly made a reshape to a FULLY-CONCRETE target fail whenever the
744        // input's product couldn't be formed (a stray over-large/garbage static
745        // dim from an upstream mis-resolved dynamic axis → `num_elements()` = None
746        // via checked_mul), aborting the whole import ("input has dynamic dims").
747        // A fully-specified target shape is well-defined regardless of the input.
748        let known_product: i64 = new_shape.iter().filter(|&&d| d != -1).product();
749        let mut dims = SmallVec::new();
750        for &d in new_shape {
751            if d == -1 {
752                let total = input
753                    .num_elements()
754                    .ok_or_else(|| "reshape: input has dynamic dims".to_string())?;
755                let inferred = total as i64 / known_product.max(1);
756                dims.push(Dim::Static(inferred as usize));
757            } else if d < 0 {
758                return Err(format!("reshape: invalid dim {d}"));
759            } else {
760                dims.push(Dim::Static(d as usize));
761            }
762        }
763        // A reshape must preserve the element count. Without this check a
764        // fully-concrete target silently reinterprets the buffer: an MoE router
765        // reshaping `[rows, 1, k]` to `[rows, 1]` kept the first `rows` floats
766        // and dropped the rest, so every token was weighted by another token's
767        // routing probability — in range, plausible, and wrong.
768        //
769        // Only enforced when the input's product is actually computable.
770        // `num_elements()` returns None for an over-large/garbage static dim
771        // left by an upstream mis-resolved dynamic axis, and the comment above
772        // documents that a fully-specified target should still be honoured
773        // there rather than aborting the whole import.
774        if let Some(total) = input.num_elements() {
775            let out_total: usize = dims
776                .iter()
777                .map(|d: &Dim| d.unwrap_static())
778                .try_fold(1usize, |a, d| a.checked_mul(d))
779                .unwrap_or(usize::MAX);
780            if out_total != total {
781                return Err(format!(
782                    "reshape: {:?} ({total} elements) -> {new_shape:?} ({out_total} elements); \
783                     a reshape must preserve the element count",
784                    input.dims()
785                ));
786            }
787        }
788
789        return Ok(Shape {
790            dims,
791            dtype: input.dtype,
792        });
793    }
794
795    // Symbolic input: map `-1` to the sole dynamic symbol when unambiguous
796    // (qwen35 prefill with batch=1 and `sym::SEQ`), otherwise keep dynamic.
797    let dyn_syms = input.dynamic_symbols();
798    let neg_idx = new_shape.iter().position(|&d| d == -1);
799    let mut out_dims: SmallVec<[Dim; 4]> = SmallVec::new();
800    for (i, &d) in new_shape.iter().enumerate() {
801        if Some(i) == neg_idx {
802            continue;
803        }
804        if d < 0 {
805            return Err(format!("reshape: invalid dim {d}"));
806        }
807        out_dims.push(Dim::Static(d as usize));
808    }
809    if let Some(ni) = neg_idx {
810        let inferred = if dyn_syms.len() == 1 {
811            Dim::Dynamic(dyn_syms[0])
812        } else if dyn_syms.is_empty() {
813            return Err("reshape: cannot infer -1 on static input".into());
814        } else {
815            Dim::Dynamic(crate::dynamic::sym::ROWS)
816        };
817        out_dims.insert(ni, inferred);
818    }
819    Ok(Shape {
820        dims: out_dims,
821        dtype: input.dtype,
822    })
823}
824
825/// Flatten leading axes to `[∏leading, H]` — used by `FuseRmsNormReshape` and shape verify.
826pub fn leading_flatten_fused_shape(input: &Shape) -> Option<Shape> {
827    if input.rank() < 2 {
828        return None;
829    }
830    let Dim::Static(h) = input.dim(input.rank() - 1) else {
831        return None;
832    };
833    let leading = &input.dims()[..input.rank() - 1];
834    let lead_dim = if leading.iter().all(|d| d.is_static()) {
835        Dim::Static(leading.iter().map(|d| d.unwrap_static()).product::<usize>())
836    } else {
837        let mut syms: Vec<u32> = leading
838            .iter()
839            .filter_map(|d| match d {
840                Dim::Dynamic(s) => Some(*s),
841                _ => None,
842            })
843            .collect();
844        syms.sort();
845        syms.dedup();
846        match syms.len() {
847            0 => Dim::Static(leading.iter().map(|d| d.unwrap_static()).product::<usize>()),
848            1 => Dim::Dynamic(syms[0]),
849            _ => Dim::Dynamic(crate::dynamic::sym::ROWS),
850        }
851    };
852    Some(Shape::from_dims(&[lead_dim, Dim::Static(h)], input.dtype()))
853}
854
855/// Match `Reshape { new_shape }` after RmsNorm when fusing to a single op.
856pub fn leading_flatten_shape(input: &Shape, new_shape: &[i64]) -> Option<Shape> {
857    if new_shape.len() != 2 {
858        return None;
859    }
860    let flat = leading_flatten_fused_shape(input)?;
861    let Dim::Static(h) = input.dim(input.rank() - 1) else {
862        return None;
863    };
864    if new_shape[1] as usize != h {
865        return None;
866    }
867    match flat.dim(0) {
868        Dim::Static(lead) if new_shape[0] as usize == lead => Some(flat),
869        Dim::Dynamic(_) if new_shape[0] == -1 => Some(flat),
870        _ => None,
871    }
872}
873
874/// Attention: output shape = Q shape.
875pub fn attention_shape(q: &Shape) -> Shape {
876    q.clone()
877}
878
879/// Attention output shape accounting for an asymmetric V/output width. The
880/// output has the same layout as `q` except the per-head width is `v_head_dim`:
881/// rank-4 `[.., D]` → `[.., v_head_dim]`; rank ≤ 3 `[.., H·head_dim]` →
882/// `[.., num_heads·v_head_dim]`. Equals `q` when `head_dim == v_head_dim`.
883pub fn attention_shape_vdim(
884    q: &Shape,
885    num_heads: usize,
886    head_dim: usize,
887    v_head_dim: usize,
888) -> Shape {
889    if head_dim == v_head_dim || q.rank() == 0 {
890        return q.clone();
891    }
892    let rank = q.rank();
893    let new_last = if rank >= 4 {
894        v_head_dim
895    } else {
896        num_heads * v_head_dim
897    };
898    q.clone().with_dim(rank - 1, Dim::Static(new_last))
899}
900
901impl std::fmt::Display for Shape {
902    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
903        write!(f, "[")?;
904        for (i, d) in self.dims.iter().enumerate() {
905            if i > 0 {
906                write!(f, ", ")?;
907            }
908            write!(f, "{d}")?;
909        }
910        write!(f, "] {}", self.dtype)
911    }
912}
913
914/// Spatial output size for NCHW `Op::Conv` / `conv2d`.
915pub fn conv2d_spatial_output(
916    in_size: usize,
917    kernel: usize,
918    stride: usize,
919    padding: usize,
920    dilation: usize,
921) -> usize {
922    let dil_k = dilation.saturating_mul(kernel.saturating_sub(1));
923    (in_size + 2 * padding)
924        .saturating_sub(dil_k)
925        .saturating_sub(1)
926        / stride
927        + 1
928}
929
930/// Spatial output size for NCHW `Op::ConvTranspose2d`.
931pub fn conv_transpose2d_spatial_output(
932    in_size: usize,
933    kernel: usize,
934    stride: usize,
935    padding: usize,
936    dilation: usize,
937    output_padding: usize,
938) -> usize {
939    let dil_k = dilation.saturating_mul(kernel.saturating_sub(1));
940    (in_size - 1) * stride + output_padding + dil_k - 2 * padding + 1
941}
942
943/// Output shape for `conv2d` given NCHW `input` and weight `[C_out, C_in/g, kH, kW]`.
944pub fn conv2d_output_shape(
945    input: &Shape,
946    weight: &Shape,
947    kernel_size: [usize; 2],
948    stride: [usize; 2],
949    padding: [usize; 2],
950    dilation: [usize; 2],
951    groups: usize,
952) -> Result<Shape, String> {
953    if input.rank() != 4 || weight.rank() != 4 {
954        return Err("conv2d requires NCHW input and 4-D weight".into());
955    }
956    let n = input.dim(0);
957    let c_in = input.dim(1).unwrap_static();
958    let h = input.dim(2).unwrap_static();
959    let w = input.dim(3).unwrap_static();
960    let c_out = weight.dim(0).unwrap_static();
961    let w_cin = weight.dim(1).unwrap_static();
962    if w_cin * groups != c_in {
963        return Err(format!(
964            "conv2d weight C_in/g={w_cin} * groups={groups} != input C={c_in}"
965        ));
966    }
967    let h_out = conv2d_spatial_output(h, kernel_size[0], stride[0], padding[0], dilation[0]);
968    let w_out = conv2d_spatial_output(w, kernel_size[1], stride[1], padding[1], dilation[1]);
969    Ok(Shape::from_dims(
970        &[
971            n,
972            Dim::Static(c_out),
973            Dim::Static(h_out),
974            Dim::Static(w_out),
975        ],
976        input.dtype(),
977    ))
978}
979
980/// Row width, in elements, of a RoPE cos/sin table.
981///
982/// Every backend needs this and each used to derive it locally, which is how
983/// the CPU and GPU rules drifted apart in the first place.
984///
985/// * A table shaped `[positions, width]` carries its row width in the last
986///   dimension, and that is what must be used. A table allocated at
987///   `head_dim/2` but only partly filled under partial rotation is *wider* than
988///   `n_rot/2`; assuming otherwise reads the wrong row for every position past
989///   the first.
990/// * A **rank-1** table is different: a flat `positions * n_rot/2` run has no
991///   row structure in its shape, so its last dimension is the whole table.
992///   Taking that as the stride sends position 1 past the end, which reads as
993///   zeros — silently zeroing every position after the first. Rows there are
994///   `n_rot/2` wide by construction.
995pub fn rope_table_stride(cos: &Shape, n_rot: usize) -> usize {
996    if cos.rank() >= 2 {
997        cos.dims()
998            .last()
999            .map_or_else(|| n_rot / 2, |d| d.unwrap_static())
1000            .max(1)
1001    } else {
1002        (n_rot / 2).max(1)
1003    }
1004}
1005
1006/// Output shape for NCHW [`crate::Op::Pool`].
1007///
1008/// Pooling has no weight, so the channel count passes through; only the two
1009/// spatial extents shrink, by the same rule convolution uses with dilation 1.
1010pub fn pool2d_output_shape(
1011    input: &Shape,
1012    kernel_size: [usize; 2],
1013    stride: [usize; 2],
1014    padding: [usize; 2],
1015) -> Result<Shape, String> {
1016    if input.rank() != 4 {
1017        return Err("pool2d requires an NCHW input".into());
1018    }
1019    if stride[0] == 0 || stride[1] == 0 {
1020        return Err("pool2d stride must be non-zero".into());
1021    }
1022    let n = input.dim(0);
1023    let c = input.dim(1);
1024    let h = input.dim(2).unwrap_static();
1025    let w = input.dim(3).unwrap_static();
1026    let h_out = conv2d_spatial_output(h, kernel_size[0], stride[0], padding[0], 1);
1027    let w_out = conv2d_spatial_output(w, kernel_size[1], stride[1], padding[1], 1);
1028    if h_out == 0 || w_out == 0 {
1029        return Err(format!(
1030            "pool2d window {kernel_size:?} with stride {stride:?} and padding {padding:?} \
1031             leaves no output for a {h}x{w} input"
1032        ));
1033    }
1034    Ok(Shape::from_dims(
1035        &[n, c, Dim::Static(h_out), Dim::Static(w_out)],
1036        input.dtype(),
1037    ))
1038}
1039
1040/// Output shape for NCHW `Op::Im2Col`: `[M, C·kH·kW]` with
1041/// `M = N · H_out · W_out`. Dynamic batch maps to `dynamic::sym::ROWS`.
1042pub fn im2col_output_shape(
1043    input: &Shape,
1044    kernel_size: [usize; 2],
1045    stride: [usize; 2],
1046    padding: [usize; 2],
1047    dilation: [usize; 2],
1048) -> Result<Shape, String> {
1049    if input.rank() != 4 {
1050        return Err("im2col requires NCHW input".into());
1051    }
1052    let c_in = input.dim(1).unwrap_static();
1053    let h = input.dim(2).unwrap_static();
1054    let w = input.dim(3).unwrap_static();
1055    let kh = kernel_size[0];
1056    let kw = kernel_size[1];
1057    let h_out = conv2d_spatial_output(h, kh, stride[0], padding[0], dilation[0]);
1058    let w_out = conv2d_spatial_output(w, kw, stride[1], padding[1], dilation[1]);
1059    let k = c_in * kh * kw;
1060    let spatial = h_out * w_out;
1061    let m = match input.dim(0) {
1062        Dim::Static(n) => Dim::Static(n * spatial),
1063        Dim::Dynamic(crate::dynamic::sym::BATCH) | Dim::Dynamic(crate::dynamic::sym::ROWS) => {
1064            Dim::Dynamic(crate::dynamic::sym::ROWS)
1065        }
1066        Dim::Dynamic(_) => Dim::Dynamic(crate::dynamic::sym::ROWS),
1067    };
1068    Ok(Shape::from_dims(&[m, Dim::Static(k)], input.dtype()))
1069}
1070
1071/// Output shape for `conv_transpose2d` (weight `[C_in, C_out/g, kH, kW]`).
1072pub fn conv_transpose2d_output_shape(
1073    input: &Shape,
1074    weight: &Shape,
1075    kernel_size: [usize; 2],
1076    stride: [usize; 2],
1077    padding: [usize; 2],
1078    dilation: [usize; 2],
1079    output_padding: [usize; 2],
1080    groups: usize,
1081) -> Result<Shape, String> {
1082    if input.rank() != 4 || weight.rank() != 4 {
1083        return Err("conv_transpose2d requires NCHW input and 4-D weight".into());
1084    }
1085    // Preserve the batch dim (it may be dynamic) — mirror `conv2d_output_shape`.
1086    let n = input.dim(0);
1087    let c_in = input.dim(1).unwrap_static();
1088    let h = input.dim(2).unwrap_static();
1089    let w = input.dim(3).unwrap_static();
1090    let w_cin = weight.dim(0).unwrap_static();
1091    let c_out_per_g = weight.dim(1).unwrap_static();
1092    if w_cin != c_in {
1093        return Err(format!(
1094            "conv_transpose2d weight C_in={w_cin} != input C={c_in}"
1095        ));
1096    }
1097    let h_out = conv_transpose2d_spatial_output(
1098        h,
1099        kernel_size[0],
1100        stride[0],
1101        padding[0],
1102        dilation[0],
1103        output_padding[0],
1104    );
1105    let w_out = conv_transpose2d_spatial_output(
1106        w,
1107        kernel_size[1],
1108        stride[1],
1109        padding[1],
1110        dilation[1],
1111        output_padding[1],
1112    );
1113    Ok(Shape::from_dims(
1114        &[
1115            n,
1116            Dim::Static(c_out_per_g * groups),
1117            Dim::Static(h_out),
1118            Dim::Static(w_out),
1119        ],
1120        input.dtype(),
1121    ))
1122}
1123
1124/// Output shape for NCDHW `Op::Conv3d` given weight `[C_out, C_in/g, kD, kH, kW]`.
1125/// Reuses the 1-D `conv2d_spatial_output` formula per spatial axis.
1126#[allow(clippy::too_many_arguments)]
1127pub fn conv3d_output_shape(
1128    input: &Shape,
1129    weight: &Shape,
1130    kernel_size: [usize; 3],
1131    stride: [usize; 3],
1132    padding: [usize; 3],
1133    dilation: [usize; 3],
1134    groups: usize,
1135) -> Result<Shape, String> {
1136    if input.rank() != 5 || weight.rank() != 5 {
1137        return Err("conv3d requires NCDHW input and 5-D weight".into());
1138    }
1139    let n = input.dim(0);
1140    let c_in = input.dim(1).unwrap_static();
1141    let d = input.dim(2).unwrap_static();
1142    let h = input.dim(3).unwrap_static();
1143    let w = input.dim(4).unwrap_static();
1144    let c_out = weight.dim(0).unwrap_static();
1145    let w_cin = weight.dim(1).unwrap_static();
1146    if w_cin * groups != c_in {
1147        return Err(format!(
1148            "conv3d weight C_in/g={w_cin} * groups={groups} != input C={c_in}"
1149        ));
1150    }
1151    let d_out = conv2d_spatial_output(d, kernel_size[0], stride[0], padding[0], dilation[0]);
1152    let h_out = conv2d_spatial_output(h, kernel_size[1], stride[1], padding[1], dilation[1]);
1153    let w_out = conv2d_spatial_output(w, kernel_size[2], stride[2], padding[2], dilation[2]);
1154    Ok(Shape::from_dims(
1155        &[
1156            n,
1157            Dim::Static(c_out),
1158            Dim::Static(d_out),
1159            Dim::Static(h_out),
1160            Dim::Static(w_out),
1161        ],
1162        input.dtype(),
1163    ))
1164}
1165
1166/// Output shape for NCDHW `Op::ConvTranspose3d` (weight `[C_in, C_out/g, kD, kH, kW]`).
1167#[allow(clippy::too_many_arguments)]
1168pub fn conv_transpose3d_output_shape(
1169    input: &Shape,
1170    weight: &Shape,
1171    kernel_size: [usize; 3],
1172    stride: [usize; 3],
1173    padding: [usize; 3],
1174    dilation: [usize; 3],
1175    output_padding: [usize; 3],
1176    groups: usize,
1177) -> Result<Shape, String> {
1178    if input.rank() != 5 || weight.rank() != 5 {
1179        return Err("conv_transpose3d requires NCDHW input and 5-D weight".into());
1180    }
1181    let n = input.dim(0);
1182    let c_in = input.dim(1).unwrap_static();
1183    let d = input.dim(2).unwrap_static();
1184    let h = input.dim(3).unwrap_static();
1185    let w = input.dim(4).unwrap_static();
1186    let w_cin = weight.dim(0).unwrap_static();
1187    let c_out_per_g = weight.dim(1).unwrap_static();
1188    if w_cin != c_in {
1189        return Err(format!(
1190            "conv_transpose3d weight C_in={w_cin} != input C={c_in}"
1191        ));
1192    }
1193    let d_out = conv_transpose2d_spatial_output(
1194        d,
1195        kernel_size[0],
1196        stride[0],
1197        padding[0],
1198        dilation[0],
1199        output_padding[0],
1200    );
1201    let h_out = conv_transpose2d_spatial_output(
1202        h,
1203        kernel_size[1],
1204        stride[1],
1205        padding[1],
1206        dilation[1],
1207        output_padding[1],
1208    );
1209    let w_out = conv_transpose2d_spatial_output(
1210        w,
1211        kernel_size[2],
1212        stride[2],
1213        padding[2],
1214        dilation[2],
1215        output_padding[2],
1216    );
1217    Ok(Shape::from_dims(
1218        &[
1219            n,
1220            Dim::Static(c_out_per_g * groups),
1221            Dim::Static(d_out),
1222            Dim::Static(h_out),
1223            Dim::Static(w_out),
1224        ],
1225        input.dtype(),
1226    ))
1227}
1228
1229/// `KvAppend`: write `row` into `cache` at index `pos` along `axis`, and return
1230/// the `[..pos+1]` prefix (which ALIASES the cache buffer).
1231///
1232/// Every operand relationship is checked here rather than left to the backends,
1233/// because none of them can check it: by the time a row write reaches a kernel
1234/// it is a byte offset and a length, and a `pos` past the cache or a row of the
1235/// wrong width is an out-of-bounds store into whatever the memory planner put
1236/// next in the arena. That is a corrupted neighbouring tensor, not a crash —
1237/// wrong logits with no error anywhere. The op is also the one place where a
1238/// caller must size a buffer to a CAPACITY rather than to its current contents
1239/// (`pos` indexes into the spare rows), so an off-by-one here is easy to write
1240/// and invisible afterwards.
1241///
1242/// Dynamic dims are skipped rather than guessed at: a symbolic sequence
1243/// capacity is legitimate, and the check that matters (`pos < cap`) can only be
1244/// made against a static one.
1245pub fn kv_append_shape(
1246    cache: &Shape,
1247    row: &Shape,
1248    axis: usize,
1249    pos: usize,
1250) -> Result<Shape, String> {
1251    if axis >= cache.rank() {
1252        return Err(format!(
1253            "kv_append: axis {axis} is out of range for a rank-{} cache",
1254            cache.rank()
1255        ));
1256    }
1257    if row.rank() != cache.rank() {
1258        return Err(format!(
1259            "kv_append: row rank {} does not match cache rank {} — the row is a \
1260             one-step slice of the cache, not a squeezed vector",
1261            row.rank(),
1262            cache.rank()
1263        ));
1264    }
1265    if row.dtype() != cache.dtype() {
1266        return Err(format!(
1267            "kv_append: row dtype {:?} does not match cache dtype {:?} — the write \
1268             is a raw copy, so a mismatch reinterprets the row's bits",
1269            row.dtype(),
1270            cache.dtype()
1271        ));
1272    }
1273    if let Dim::Static(cap) = cache.dim(axis)
1274        && pos >= cap
1275    {
1276        return Err(format!(
1277            "kv_append: pos {pos} is past the cache's axis-{axis} capacity {cap} — \
1278             the cache must be sized to the CAPACITY it will grow to, not to the \
1279             number of rows it currently holds"
1280        ));
1281    }
1282    if let Dim::Static(n) = row.dim(axis)
1283        && n != 1
1284    {
1285        return Err(format!(
1286            "kv_append: row axis-{axis} extent is {n}, must be 1 (one step)"
1287        ));
1288    }
1289    for i in 0..cache.rank() {
1290        if i == axis {
1291            continue;
1292        }
1293        if let (Dim::Static(c), Dim::Static(r)) = (cache.dim(i), row.dim(i))
1294            && c != r
1295        {
1296            return Err(format!(
1297                "kv_append: row dim {i} is {r}, cache dim {i} is {c} — every axis but \
1298                 {axis} must match or the copy walks off the row"
1299            ));
1300        }
1301    }
1302    Ok(cache.clone().with_dim(axis, Dim::Static(pos + 1)))
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307
1308    /// A `Concat` whose inputs disagree on a NON-concat axis must be rejected,
1309    /// not silently shaped from `inputs[0]`.
1310    ///
1311    /// `rlx-eeginceptionerp` declared its zero-padding as [B,cin,1,l] while the
1312    /// activation was [B,cin,C,T]. The old inference returned [B,cin,1,·],
1313    /// collapsing the channel axis of the first block. CPU/Metal/wgpu/CoreML
1314    /// executed it anyway; only MLX refused, so a malformed graph looked like a
1315    /// backend gap.
1316    #[test]
1317    fn concat_rejects_non_concat_axis_mismatch() {
1318        let pad = Shape::new(&[3, 1, 1, 31], DType::F32);
1319        let act = Shape::new(&[3, 1, 8, 1000], DType::F32);
1320        let err = concat_shape(&[&pad, &act], 3).unwrap_err();
1321        assert!(
1322            err.contains("axis 2 mismatch"),
1323            "expected the channel-axis mismatch to be named, got: {err}"
1324        );
1325
1326        // The well-formed version still infers normally.
1327        let pad_ok = Shape::new(&[3, 1, 8, 31], DType::F32);
1328        let out = concat_shape(&[&pad_ok, &act], 3).expect("matching dims must concat");
1329        assert_eq!(out.dims()[3].unwrap_static(), 1031);
1330        assert_eq!(out.dims()[2].unwrap_static(), 8);
1331    }
1332
1333    /// The concat axis itself is exempt — that is the whole point of it.
1334    #[test]
1335    fn concat_axis_itself_may_differ() {
1336        let a = Shape::new(&[2, 3], DType::F32);
1337        let b = Shape::new(&[5, 3], DType::F32);
1338        let out = concat_shape(&[&a, &b], 0).expect("concat axis may differ");
1339        assert_eq!(out.dims()[0].unwrap_static(), 7);
1340    }
1341
1342    use super::*;
1343
1344    /// A GGUF weight is `[n, k]`. Reading it as `[k, n]` rejects correct graphs
1345    /// — a square weight hides that, so both cases here are non-square.
1346    #[test]
1347    fn dequant_matmul_contracts_the_weights_last_axis() {
1348        let (m, k, n) = (2usize, 64usize, 3usize);
1349        let x = Shape::new(&[m, k], DType::F32);
1350        let w_nk = Shape::new(&[n, k], DType::F32);
1351
1352        assert_eq!(
1353            dequant_matmul_shape(&x, &w_nk).expect("[n,k] weight is the GGUF layout"),
1354            Shape::new(&[m, n], DType::F32),
1355        );
1356
1357        // The plain matmul rule reads the same operands backwards. This is the
1358        // rejection `coreml_quant::dequant_matmul_through_session` used to hit.
1359        assert!(matmul_shape(&x, &w_nk).unwrap_err().contains("K mismatch"));
1360
1361        // Leading batch axes ride along; only the last two participate.
1362        assert_eq!(
1363            dequant_matmul_shape(&Shape::new(&[5, m, k], DType::F32), &w_nk).unwrap(),
1364            Shape::new(&[5, m, n], DType::F32),
1365        );
1366
1367        // A genuinely wrong K is still caught, and says which axis it means.
1368        let err = dequant_matmul_shape(&x, &Shape::new(&[n, k + 1], DType::F32)).unwrap_err();
1369        assert!(err.contains("K mismatch"), "{err}");
1370        assert!(err.contains("last axis"), "{err}");
1371
1372        // A packed byte blob has no `[n, k]` to read; the caller declares the
1373        // output shape instead, and `infer_shape` never reaches this rule.
1374        assert!(dequant_matmul_shape(&x, &Shape::new(&[1024], DType::U8)).is_err());
1375    }
1376
1377    /// The two index forms a gather backward has to accept.
1378    #[test]
1379    fn gather_index_count_handles_both_index_forms() {
1380        // Indexed AT the axis: the count is that dimension.
1381        let batched = Shape::new(&[2, 11], DType::F32);
1382        assert_eq!(batched.gather_index_count(1), 11);
1383        assert_eq!(batched.gather_index_count(0), 2);
1384
1385        // A flat list with no dimension at the axis: its extent IS the count.
1386        // `dim(1)` would panic here, which is exactly the bug this replaced.
1387        let flat = Shape::new(&[11], DType::F32);
1388        assert_eq!(flat.gather_index_count(1), 11);
1389        assert_eq!(flat.gather_index_count(0), 11);
1390        assert_eq!(flat.gather_index_count(3), 11, "any axis beyond rank");
1391
1392        // A scalar index selects exactly one position, never zero.
1393        assert_eq!(Shape::new(&[], DType::F32).gather_index_count(0), 1);
1394    }
1395
1396    #[test]
1397    fn static_shape() {
1398        let s = Shape::new(&[4, 15, 384], DType::F32);
1399        assert_eq!(s.rank(), 3);
1400        assert_eq!(s.num_elements(), Some(4 * 15 * 384));
1401        assert_eq!(s.size_bytes(), Some(4 * 15 * 384 * 4));
1402        assert!(s.is_static());
1403        assert_eq!(format!("{s}"), "[4, 15, 384] f32");
1404    }
1405
1406    // ── GroupedMatMul operand checks ─────────────────────────
1407
1408    #[test]
1409    fn grouped_matmul_derives_m_and_n() {
1410        let input = Shape::new(&[5, 6], DType::F32);
1411        let bank = Shape::new(&[8, 6, 12], DType::F32); // [E, K, N]
1412        let d = grouped_matmul_dims(&input, &bank, Some(&Shape::new(&[5, 12], DType::F32)))
1413            .expect("valid");
1414        assert_eq!(
1415            d,
1416            GroupedMatMulDims {
1417                m: 5,
1418                k: 6,
1419                n: 12,
1420                num_experts: 8
1421            }
1422        );
1423        assert_eq!(
1424            grouped_matmul_shape(&input, &bank).unwrap(),
1425            Shape::new(&[5, 12], DType::F32)
1426        );
1427    }
1428
1429    /// The whole point: a bank still in the checkpoint's `[E, N, K]` order has
1430    /// the right rank and element count, so only the K axis gives it away.
1431    #[test]
1432    fn grouped_matmul_rejects_an_untransposed_bank() {
1433        let input = Shape::new(&[5, 6], DType::F32);
1434        let bank = Shape::new(&[8, 12, 6], DType::F32); // [E, N, K] — wrong way round
1435        let err = grouped_matmul_dims(&input, &bank, None).unwrap_err();
1436        assert!(err.contains("K mismatch"), "unhelpful: {err}");
1437        assert!(err.contains("[E, N, K]"), "should name the cause: {err}");
1438    }
1439
1440    /// `2·inter == hidden` makes both bank layouts the *same shape*, so the K
1441    /// check passes and only the declared output catches it. This is the case
1442    /// that silently under-writes half of every output row.
1443    #[test]
1444    fn grouped_matmul_rejects_a_square_bank_by_output_shape() {
1445        let input = Shape::new(&[4, 12, 12], DType::F32);
1446        let bank = Shape::new(&[8, 12, 12], DType::F32);
1447        // K and N agree, so operand-only validation cannot object…
1448        assert!(grouped_matmul_dims(&input, &bank, None).is_ok());
1449        // …but a declared output of a different width is still caught.
1450        let err = grouped_matmul_dims(&input, &bank, Some(&Shape::new(&[12, 6], DType::F32)))
1451            .unwrap_err();
1452        assert!(err.contains("output shape mismatch"), "unhelpful: {err}");
1453    }
1454
1455    #[test]
1456    fn grouped_matmul_rejects_a_non_bank_weight() {
1457        let input = Shape::new(&[5, 6], DType::F32);
1458        let err = grouped_matmul_dims(&input, &Shape::new(&[6, 12], DType::F32), None).unwrap_err();
1459        assert!(err.contains("rank 3"), "unhelpful: {err}");
1460    }
1461
1462    // ── Shape inference tests ────────────────────────────────
1463
1464    #[test]
1465    fn broadcast_same() {
1466        let a = Shape::new(&[4, 15, 384], DType::F32);
1467        let r = broadcast(&a, &a).unwrap();
1468        assert_eq!(r.dims(), a.dims());
1469    }
1470
1471    #[test]
1472    fn broadcast_bias() {
1473        let a = Shape::new(&[4, 15, 384], DType::F32);
1474        let b = Shape::new(&[384], DType::F32);
1475        let r = broadcast(&a, &b).unwrap();
1476        assert_eq!(r, Shape::new(&[4, 15, 384], DType::F32));
1477    }
1478
1479    #[test]
1480    fn broadcast_scalar() {
1481        let a = Shape::new(&[4, 15, 384], DType::F32);
1482        let b = Shape::scalar(DType::F32);
1483        let r = broadcast(&a, &b).unwrap();
1484        assert_eq!(r, a);
1485    }
1486
1487    #[test]
1488    fn broadcast_mismatch() {
1489        let a = Shape::new(&[4, 15, 384], DType::F32);
1490        let b = Shape::new(&[4, 15, 256], DType::F32);
1491        assert!(broadcast(&a, &b).is_err());
1492    }
1493
1494    #[test]
1495    fn matmul_basic() {
1496        let a = Shape::new(&[4, 15, 384], DType::F32);
1497        let b = Shape::new(&[384, 1536], DType::F32);
1498        let r = matmul_shape(&a, &b).unwrap();
1499        assert_eq!(r, Shape::new(&[4, 15, 1536], DType::F32));
1500    }
1501
1502    #[test]
1503    fn matmul_batched() {
1504        let a = Shape::new(&[4, 15, 384], DType::F32);
1505        let b = Shape::new(&[4, 384, 1536], DType::F32);
1506        let r = matmul_shape(&a, &b).unwrap();
1507        assert_eq!(r, Shape::new(&[4, 15, 1536], DType::F32));
1508    }
1509
1510    #[test]
1511    fn matmul_k_mismatch() {
1512        let a = Shape::new(&[4, 15, 384], DType::F32);
1513        let b = Shape::new(&[512, 1536], DType::F32);
1514        assert!(matmul_shape(&a, &b).is_err());
1515    }
1516
1517    #[test]
1518    fn reduce_keepdim() {
1519        let a = Shape::new(&[4, 15, 384], DType::F32);
1520        let r = reduce_shape(&a, &[2], true).unwrap();
1521        assert_eq!(r, Shape::new(&[4, 15, 1], DType::F32));
1522    }
1523
1524    #[test]
1525    fn reduce_no_keepdim() {
1526        let a = Shape::new(&[4, 15, 384], DType::F32);
1527        let r = reduce_shape(&a, &[2], false).unwrap();
1528        assert_eq!(r, Shape::new(&[4, 15], DType::F32));
1529    }
1530
1531    #[test]
1532    fn concat_basic() {
1533        let a = Shape::new(&[4, 15, 384], DType::F32);
1534        let b = Shape::new(&[4, 15, 384], DType::F32);
1535        let r = concat_shape(&[&a, &b], 2).unwrap();
1536        assert_eq!(r, Shape::new(&[4, 15, 768], DType::F32));
1537    }
1538
1539    #[test]
1540    fn gather_embedding() {
1541        let table = Shape::new(&[30522, 384], DType::F32);
1542        let indices = Shape::new(&[4, 15], DType::I64);
1543        let r = gather_shape(&table, &indices, 0).unwrap();
1544        assert_eq!(
1545            r,
1546            Shape::from_dims(
1547                &[Dim::Static(4), Dim::Static(15), Dim::Static(384)],
1548                DType::F32
1549            )
1550        );
1551    }
1552
1553    #[test]
1554    fn gather_nonzero_axis_keeps_prefix() {
1555        // ONNX Gather output = table[:axis] ++ indices.shape ++ table[axis+1:].
1556        // Regression: axis!=0 must NOT drop the leading dims (a nearest-upsample
1557        // gather `[4,3]` on axis 1 with a `[6]` index → `[4,6]`, not `[6]`).
1558        let table = Shape::new(&[4, 3], DType::F32);
1559        let indices = Shape::new(&[6], DType::I64);
1560        let r = gather_shape(&table, &indices, 1).unwrap();
1561        assert_eq!(r, Shape::new(&[4, 6], DType::F32));
1562        // axis 1 of a rank-3 table, 2-D index.
1563        let table = Shape::new(&[2, 5, 7], DType::F32);
1564        let indices = Shape::new(&[3, 4], DType::I64);
1565        let r = gather_shape(&table, &indices, 1).unwrap();
1566        assert_eq!(r, Shape::new(&[2, 3, 4, 7], DType::F32));
1567    }
1568
1569    #[test]
1570    fn reshape_with_neg1() {
1571        let a = Shape::new(&[4, 15, 384], DType::F32);
1572        let r = reshape_shape(&a, &[60, -1]).unwrap();
1573        assert_eq!(r, Shape::new(&[60, 384], DType::F32));
1574    }
1575
1576    #[test]
1577    fn transpose_basic() {
1578        let a = Shape::new(&[4, 15, 384], DType::F32);
1579        let r = transpose_shape(&a, &[0, 2, 1]).unwrap();
1580        assert_eq!(r, Shape::new(&[4, 384, 15], DType::F32));
1581    }
1582
1583    #[test]
1584    fn narrow_basic() {
1585        let a = Shape::new(&[4, 15, 1152], DType::F32);
1586        let r = narrow_shape(&a, 2, 384).unwrap();
1587        assert_eq!(r, Shape::new(&[4, 15, 384], DType::F32));
1588    }
1589
1590    #[test]
1591    fn compare_bool_output() {
1592        let a = Shape::new(&[4, 15], DType::F32);
1593        let b = Shape::new(&[4, 15], DType::F32);
1594        let r = compare_shape(&a, &b).unwrap();
1595        assert_eq!(r.dtype(), DType::Bool);
1596        assert_eq!(r.rank(), 2);
1597    }
1598
1599    // ── Original tests ──────────────────────────────────────
1600
1601    #[test]
1602    fn dynamic_shape() {
1603        let s = Shape::from_dims(
1604            &[Dim::Dynamic(0), Dim::Dynamic(1), Dim::Static(384)],
1605            DType::F32,
1606        );
1607        assert_eq!(s.rank(), 3);
1608        assert_eq!(s.num_elements(), None);
1609        assert!(!s.is_static());
1610        assert_eq!(format!("{s}"), "[?0, ?1, 384] f32");
1611    }
1612
1613    #[test]
1614    fn dynamic_symbols_lists_distinct_dims() {
1615        let s = Shape::from_dims(
1616            &[
1617                Dim::Dynamic(1),
1618                Dim::Static(384),
1619                Dim::Dynamic(0),
1620                Dim::Dynamic(1),
1621            ],
1622            DType::F32,
1623        );
1624        assert_eq!(s.dynamic_symbols(), vec![0, 1]);
1625    }
1626
1627    #[test]
1628    fn bind_specializes_known_symbols() {
1629        let s = Shape::from_dims(
1630            &[Dim::Dynamic(0), Dim::Dynamic(1), Dim::Static(384)],
1631            DType::F32,
1632        );
1633        let mut b = DimBinding::new();
1634        b.set(0, 8);
1635        b.set(1, 64);
1636        let s2 = s.bind(&b);
1637        assert!(s2.is_static());
1638        assert_eq!(s2.num_elements(), Some(8 * 64 * 384));
1639    }
1640
1641    #[test]
1642    fn bind_leaves_unknown_symbols_alone() {
1643        let s = Shape::from_dims(&[Dim::Dynamic(0), Dim::Dynamic(99)], DType::F32);
1644        let mut b = DimBinding::new();
1645        b.set(0, 4);
1646        let s2 = s.bind(&b);
1647        assert!(!s2.is_static()); // ?99 still dynamic
1648        assert_eq!(s2.dynamic_symbols(), vec![99]);
1649    }
1650}
1651
1652/// Mapping from a dynamic-dim symbol to its concrete size at
1653/// runtime. Plan #54.
1654#[derive(Debug, Clone, Default)]
1655pub struct DimBinding {
1656    map: std::collections::HashMap<u32, usize>,
1657}
1658
1659impl DimBinding {
1660    pub fn new() -> Self {
1661        Self::default()
1662    }
1663    pub fn set(&mut self, symbol: u32, size: usize) -> Option<usize> {
1664        self.map.insert(symbol, size)
1665    }
1666    pub fn get(&self, symbol: u32) -> Option<usize> {
1667        self.map.get(&symbol).copied()
1668    }
1669    pub fn is_empty(&self) -> bool {
1670        self.map.is_empty()
1671    }
1672    pub fn len(&self) -> usize {
1673        self.map.len()
1674    }
1675    pub fn iter(&self) -> impl Iterator<Item = (u32, usize)> + '_ {
1676        self.map.iter().map(|(&s, &n)| (s, n))
1677    }
1678}