Skip to main content

rlx_ir/
op.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//! Operation types — every tensor op in the RLX IR.
6//!
7//! Designed for pattern-matching fusion: ops are grouped by category so
8//! fusion passes can reason about them structurally.
9
10use crate::DType;
11
12/// Unary element-wise activation functions.
13#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Activation {
16    Gelu,
17    GeluApprox,
18    Silu, // SwiGLU gate activation
19    Relu,
20    Sigmoid,
21    Tanh,
22    Exp,
23    Log,
24    Sqrt,
25    Rsqrt,
26    Neg,
27    Abs,
28    /// `sin(x)`. Backward: `dx = upstream · cos(x)`.
29    Sin,
30    /// `cos(x)`. Backward: `dx = -upstream · sin(x)`.
31    Cos,
32    /// `tan(x)`. Backward: `dx = upstream · sec²(x) = upstream · (1 + tan²(x))`.
33    Tan,
34    /// `atan(x)`. Backward: `dx = upstream · (1 / (1 + x²))`.
35    Atan,
36    /// Reciprocal `1 / x`.
37    Recip,
38    /// Round to nearest integer (half-to-even), in f32.
39    /// Forward: `x.round()`. Backward: STE — treats as identity, so
40    /// the gradient passes through unchanged. Useful as a primitive
41    /// for composing custom quantization schemes (Mul-by-recip-scale
42    /// → Round → Clamp → Mul-by-scale = a hand-rolled FakeQuantize
43    /// that the elementwise-region pass can fuse into a single kernel).
44    Round,
45    /// `floor(x)`. Backward: 0 (piecewise-constant).
46    Floor,
47    /// `ceil(x)`. Backward: 0.
48    Ceil,
49    /// `sign(x)` = -1/0/+1 (sign of zero is 0). Backward: 0.
50    Sign,
51    /// `softplus(x) = ln(1 + eˣ)`. Backward: `dx = u·sigmoid(x)`.
52    Softplus,
53    /// ELU (α=1): `x if x>0 else eˣ-1`. Backward: `u·(1 if x>0 else eˣ)`.
54    Elu,
55    /// `erf(x)` (Gauss error function). Backward: `dx = u·(2/√π)·e^(-x²)`.
56    Erf,
57    /// HardSwish `x·clamp(x+3,0,6)/6`. Backward: 0/1/`(2x+3)/6` piecewise.
58    HardSwish,
59    /// HardSigmoid `clamp(x/6+0.5,0,1)`. Backward: `1/6` on `|x|<3` else 0.
60    HardSigmoid,
61    /// Mish `x·tanh(softplus(x))`.
62    Mish,
63    /// Softsign `x/(1+|x|)`. Backward: `1/(1+|x|)²`.
64    Softsign,
65    /// LogSigmoid `log(σ(x)) = min(x,0) − ln(1+e^(−|x|))`. Backward: `σ(−x)`.
66    LogSigmoid,
67}
68
69impl Activation {
70    /// Activations that may be folded into a fused `ElementwiseRegion` /
71    /// `TransformRegion` chain. The newer scalar activations
72    /// (`Floor`/`Ceil`/`Sign`/`Softplus`/`Elu`) run only on the standalone
73    /// kernel path for now — the per-backend fused-region kernels don't yet
74    /// carry their opcodes, and their `default` passthrough would silently
75    /// mis-evaluate them. Keeping them unfused is correct; a fused kernel is a
76    /// perf follow-up.
77    pub fn region_fusable(self) -> bool {
78        !matches!(
79            self,
80            Activation::Floor
81                | Activation::Ceil
82                | Activation::Sign
83                | Activation::Softplus
84                | Activation::Elu
85                | Activation::Erf
86                | Activation::HardSwish
87                | Activation::HardSigmoid
88                | Activation::Mish
89                | Activation::Softsign
90                | Activation::LogSigmoid
91        )
92    }
93}
94
95/// Scale-tracking strategy for `Op::FakeQuantize`. Determines how
96/// the per-channel `s[c]` is computed each forward pass.
97///
98/// * `PerBatch` — recompute `s[c] = max(|x|) / q_max` from the
99///   current data on every call. Simple, no extra inputs, but
100///   noisy for activations (max-abs jumps batch-to-batch).
101///
102/// * `EMA { decay }` — keep a running `s[c]` in a state tensor
103///   (passed as a second op input). On each call, blend the
104///   current per-batch max-abs into the state via
105///   `state' = decay·state + (1-decay)·max_abs`. Smooth scale
106///   over training, makes activation-QAT actually trainable.
107///   Typical `decay = 0.99`.
108///
109/// * `Fixed` — never recompute. The state tensor's value is
110///   used as-is each call (set once at construction or by the
111///   caller). Useful when scales are pre-calibrated.
112#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
113#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
114pub enum ScaleMode {
115    #[default]
116    PerBatch,
117    EMA {
118        decay: f32,
119    },
120    Fixed,
121}
122
123impl Eq for ScaleMode {}
124impl std::hash::Hash for ScaleMode {
125    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
126        match self {
127            ScaleMode::PerBatch => state.write_u8(0),
128            ScaleMode::EMA { decay } => {
129                state.write_u8(1);
130                state.write_u32(decay.to_bits());
131            }
132            ScaleMode::Fixed => state.write_u8(2),
133        }
134    }
135}
136
137/// Straight-through estimator variants for `Op::FakeQuantize`'s
138/// backward. The forward is the same regardless: discrete
139/// `clamp(round(x/s)) * s`. The choice here affects only the
140/// gradient w.r.t. `x` during training.
141///
142/// * `Identity` — `dx = upstream`. The original STE; treats the
143///   round as identity in the backward direction. Simplest, fine
144///   for moderate bit widths (i4 / i8).
145///
146/// * `ClippedIdentity` — `dx = upstream * (|x| ≤ q_max·s)`. Zero
147///   the gradient when the input was outside the quantization
148///   range (i.e. the clamp activated). Stops the optimizer from
149///   pushing weights further into saturation.
150///
151/// * `Tanh` — `dx = upstream * (1 - tanh²(x/s))`. Smooth surrogate
152///   for the round step. Slowly attenuates the gradient as `|x|`
153///   approaches `q_max·s`. Often best on tight bit widths (i2).
154///
155/// * `HardTanh` — `dx = upstream * (1 - |x/(q_max·s)|).max(0)`.
156///   Piecewise-linear cousin of `Tanh`; cheaper to compute and
157///   nearly as effective.
158#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
160pub enum SteKind {
161    #[default]
162    Identity,
163    ClippedIdentity,
164    Tanh,
165    HardTanh,
166}
167
168/// Binary element-wise operations.
169#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum BinaryOp {
172    Add,
173    Sub,
174    Mul,
175    Div,
176    Max,
177    Min,
178    Pow,
179    /// C `fmod` (sign of dividend) — matches Rust `%`. Differentiable in `a`
180    /// (`∂/∂a = 1`); `b` treated as the constant modulus (`∂/∂b = 0`).
181    Mod,
182    /// Bitwise ops on integer-valued operands (GPU: `(int)a op (int)b`).
183    /// Non-differentiable (zero gradient).
184    BitAnd,
185    BitOr,
186    BitXor,
187    /// Left/right shift: `a << b` / `a >> b` on integer-valued operands.
188    Shl,
189    Shr,
190    /// `atan2(a, b)` — quadrant-aware arctangent (angle of the point `(b, a)`,
191    /// i.e. Rust's `a.atan2(b)`). Smooth and differentiable in both operands:
192    /// `∂/∂a = b/(a²+b²)`, `∂/∂b = -a/(a²+b²)`. Standalone kernel only (not
193    /// region-fusable — the fused-region kernels don't carry its opcode).
194    Atan2,
195}
196
197impl BinaryOp {
198    /// Ops that may fold into a fused `ElementwiseRegion`. The newer ops
199    /// (`Mod` + bitwise) run only on the standalone binary kernel for now — the
200    /// per-backend fused-region kernels don't carry their opcodes yet (a
201    /// perf follow-up), and their `default` would silently mis-evaluate them.
202    pub fn region_fusable(self) -> bool {
203        matches!(
204            self,
205            BinaryOp::Add
206                | BinaryOp::Sub
207                | BinaryOp::Mul
208                | BinaryOp::Div
209                | BinaryOp::Max
210                | BinaryOp::Min
211                | BinaryOp::Pow
212        )
213    }
214}
215
216/// Comparison operations (return Bool tensor).
217#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
219pub enum CmpOp {
220    Eq,
221    Ne,
222    Lt,
223    Le,
224    Gt,
225    Ge,
226}
227
228/// Reduction applied when ONNX-style [`Op::ScatterNd`] writes updates.
229///
230/// Matches the ONNX ScatterND `reduction` attribute (opset 16+): `none`
231/// overwrites; `add`/`mul`/`max`/`min` combine with the existing value
232/// (required for duplicate indices).
233#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
235pub enum ScatterNdReduction {
236    #[default]
237    None,
238    Add,
239    Mul,
240    Max,
241    Min,
242}
243
244/// Which factor of a thin SVD `A = U·diag(S)·Vᵀ` an [`Op::Svd`] node yields.
245#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
247pub enum SvdPart {
248    /// `U` `[m, k]` (left singular vectors, `k = min(m,n)`).
249    U,
250    /// `S` `[k]` (singular values, descending).
251    S,
252    /// `Vᵀ` `[k, n]` (right singular vectors, transposed).
253    Vt,
254}
255
256/// Which factor of a thin QR `A = Q·R` an [`Op::Qr`] node yields.
257#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
259pub enum QrPart {
260    /// `Q` `[m, k]` (orthonormal columns, `k = min(m,n)`).
261    Q,
262    /// `R` `[k, n]` (upper-triangular).
263    R,
264}
265
266/// What kind of attention mask the kernel should apply.
267///
268/// Borrowed from MAX's `nn/attention/mha_mask.mojo` pattern: one
269/// attention kernel handles all variants by branching on
270/// the mask kind, instead of forcing every caller to materialize a mask
271/// tensor. The win is two-fold:
272///   1. **`None`** — single unpadded sequence: no mask load, no per-key
273///      compare in the inner loop.
274///   2. **`Causal`** — autoregressive decode: kernel generates the upper-
275///      triangular fill from `(qi, ki)` directly; no `seq²` mask tensor
276///      ever exists.
277///
278/// `Custom` is the existing path — read mask values from the 4th input.
279#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
281pub enum MaskKind {
282    /// No masking — every position attends to every position.
283    None,
284    /// Causal (autoregressive) — position `qi` attends only to `ki <= qi`.
285    Causal,
286    /// Sliding window — position `qi` attends to `ki ∈ [qi - w, qi]`.
287    SlidingWindow(usize),
288    /// Read mask values from the input tensor (default; matches BERT
289    /// padding-mask behavior). Tensor shape `[batch, key_len]` with
290    /// `1.0` = valid, `<0.5` = ignored.
291    Custom,
292    /// Additive per-head, per-query bias tensor
293    /// `[batch, num_heads, query_len, key_len]` added to the
294    /// `QK^T · scale` scores before softmax. Lets DETR-style boxRPB
295    /// and other learned position biases reuse the fast `Op::Attention`
296    /// path instead of decomposing into matmul + add + softmax + matmul.
297    Bias,
298}
299
300/// Which forward input an [`Op::AttentionBackward`] node differentiates.
301#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
303pub enum AttentionBwdWrt {
304    Query,
305    Key,
306    Value,
307}
308
309/// Reduction operations along specified axes.
310#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
312pub enum ReduceOp {
313    Sum,
314    Mean,
315    Max,
316    Min,
317    Prod,
318}
319
320/// PLAN L4: discriminant for each [`Op`] variant. Used by
321/// [`Op::kind`] + the `Backend::supported_ops` trait method to declare
322/// which ops a backend can lower; the `LegalizeForBackend` pass in
323/// `rlx-opt` checks the graph against this set and fails the compile
324/// when an unsupported op is present (instead of silent fallback).
325#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
327pub enum OpKind {
328    Input,
329    Param,
330    Constant,
331    Activation,
332    Cast,
333    StopGradient,
334    Quantize,
335    Dequantize,
336    FakeQuantize,
337    FakeQuantizeLSQ,
338    FakeQuantizeLSQBackwardX,
339    FakeQuantizeLSQBackwardScale,
340    Binary,
341    Compare,
342    Where,
343    Fma,
344    ElementwiseRegion,
345    /// Fused sampling / geometry chain (FKL-style transform region).
346    TransformRegion,
347    /// Same element-wise chain over multiple batch planes (horizontal fusion).
348    BatchElementwiseRegion,
349    MatMul,
350    DotGeneral,
351    DenseSolve,
352    BatchedDenseSolve,
353    Cholesky,
354    TriangularSolve,
355    Det,
356    LogDet,
357    Sort,
358    ArgSort,
359    Svd,
360    Qr,
361    LayerNorm,
362    LayerNorm2d,
363    GroupNorm,
364    BatchNormInference,
365    RmsNorm,
366    ResizeNearest2x,
367    /// Nearest-neighbor resample of NCDHW spatial dims to an explicit size.
368    Interpolate3d,
369    Attention,
370    Rope,
371    AxialRope2d,
372    Reshape,
373    Transpose,
374    Narrow,
375    Concat,
376    KvAppend,
377    Expand,
378    Gather,
379    Reverse,
380    Pad,
381    Slice,
382    Clamp,
383    Tile,
384    Trilu,
385    Reduce,
386    Histogram,
387    Softmax,
388    Cumsum,
389    CumProd,
390    CumMax,
391    ArgMax,
392    ArgMin,
393    TopK,
394    Sample,
395    /// ONNX `RandomNormalLike` — shape from input 0, output filled at runtime.
396    RngNormal,
397    /// ONNX `RandomUniformLike`.
398    RngUniform,
399    Conv,
400    Im2Col,
401    ConvTranspose2d,
402    Conv3d,
403    ConvTranspose3d,
404    Pool,
405    ReluBackward,
406    ActivationBackward,
407    FakeQuantizeBackward,
408    ComplexNormSq,
409    ComplexNormSqBackward,
410    Conjugate,
411    MaxPool2dBackward,
412    Conv2dBackwardInput,
413    Conv2dBackwardWeight,
414    MaxPool3dBackward,
415    Conv3dBackwardInput,
416    Conv3dBackwardWeight,
417    SoftmaxCrossEntropy,
418    SoftmaxCrossEntropyWithLogits,
419    SoftmaxCrossEntropyBackward,
420    AttentionBackward,
421    AttentionBackwardAll,
422    LayerNormBackwardInput,
423    LayerNormBackwardGamma,
424    RmsNormBackwardInput,
425    RmsNormBackwardGamma,
426    RmsNormBackwardBeta,
427    RopeBackward,
428    GroupNormBackwardInput,
429    GroupNormBackwardGamma,
430    GroupNormBackwardBeta,
431    BatchNormInferenceBackwardInput,
432    BatchNormInferenceBackwardGamma,
433    BatchNormInferenceBackwardBeta,
434    CumsumBackward,
435    GatherBackward,
436    GroupedMatMul,
437    DequantGroupedMatMul,
438    DequantGroupedMatMulMlx,
439    DequantMoEWeights,
440    ScaledGroupedMatMul,
441    ScatterAdd,
442    ScatterNd,
443    ScatterElements,
444    GatherNd,
445    GatherElements,
446    LoraMatMul,
447    PartitionedConv,
448    DequantMatMul,
449    SynthMatMul,
450    SynthMatMulBackward,
451    SynthReconstruct,
452    SplineActivation,
453    SplineActivationBackwardX,
454    SplineActivationBackwardCoeff,
455    QMatMul,
456    QConv2d,
457    ScaledMatMul,
458    ScaledQuantize,
459    ScaledQuantScale,
460    ScaledDequantize,
461    SelectiveScan,
462    GatedDeltaNet,
463    Lstm,
464    Gru,
465    Rnn,
466    Mamba2,
467    FusedSwiGLU,
468    FusedMatMulBiasAct,
469    FusedMatMulResidual,
470    FusedConvBiasAct,
471    FusedResidualLN,
472    FusedResidualRmsNorm,
473    FusedAttentionBlock,
474    FusedTransformerLayer,
475    If,
476    While,
477    Scan,
478    ScanBackward,
479    ScanBackwardXs,
480    /// CPU reference 3D Gaussian splat raster (project → bin → sort → raster).
481    /// See [`Op::GaussianSplatRender`].
482    GaussianSplatRender,
483    /// Backward of [`Op::GaussianSplatRender`] — packed scene parameter gradients.
484    GaussianSplatRenderBackward,
485    /// Project + tile bin + sort + ray grid (strict IR splat stage 1).
486    GaussianSplatPrepare,
487    /// Per-pixel raster from prepared buffers (strict IR splat stage 2).
488    GaussianSplatRasterize,
489    /// User-registered op dispatched through `op_registry`. All
490    /// custom ops (Sparse-LU, FFT, eigensolve, ...) share this kind;
491    /// the per-op identity lives in `Op::Custom::name`.
492    Custom,
493    /// User-defined sub-graph with optional override AD rules. See
494    /// [`Op::CustomFn`] / [`crate::Graph::custom_fn`].
495    CustomFn,
496    /// 1D FFT primitive (forward or inverse) — see [`Op::Fft`].
497    Fft,
498    /// Ternary pruned radix-2 butterfly stage — see [`Op::FftButterflyStage`].
499    FftButterflyStage,
500    /// Whisper-style log-mel from block-layout FFT spectrum — see [`Op::LogMel`].
501    LogMel,
502    /// Backward of [`Op::LogMel`] w.r.t. block-layout spectrum input 0.
503    LogMelBackward,
504    /// Welch PSD top-K spikes from block-layout FFT spectra — see [`Op::WelchPeaks`].
505    WelchPeaks,
506
507    // ── Riemannian / SPD-manifold layers ────────────────────────
508    /// SPDNet bilinear mapping — see [`Op::BiMap`].
509    BiMap,
510    /// SPDNet eigenvalue rectification — see [`Op::ReEig`].
511    ReEig,
512    /// SPDNet matrix-log layer — see [`Op::LogEig`].
513    LogEig,
514    /// SPD batch-norm affine transport — see [`Op::SpdBatchNorm`].
515    SpdBatchNorm,
516    /// Batch Fréchet/Karcher mean of SPD matrices — see [`Op::SpdKarcherMean`].
517    SpdKarcherMean,
518    /// Backward of [`Op::ReEig`].
519    ReEigBackward,
520    /// Backward of [`Op::LogEig`].
521    LogEigBackward,
522    /// Backward of [`Op::SpdBatchNorm`] w.r.t. inputs.
523    SpdBatchNormBackwardX,
524    /// Backward of [`Op::SpdBatchNorm`] w.r.t. bias G.
525    SpdBatchNormBackwardG,
526    /// Weighted batch Fréchet/Karcher mean — see [`Op::SpdKarcherMeanWeighted`].
527    SpdKarcherMeanWeighted,
528    /// AIRM logarithm map at an arbitrary base — see [`Op::SpdLogMap`].
529    SpdLogMap,
530    /// AIRM exponential map at an arbitrary base — see [`Op::SpdExpMap`].
531    SpdExpMap,
532    /// AIRM parallel transport of a tangent vector — see [`Op::SpdParallelTransport`].
533    SpdParallelTransport,
534    /// Batched SPD spectral matrix function — see [`Op::SpdMatrixFnBatch`].
535    SpdMatrixFnBatch,
536    /// Backward of [`Op::SpdLogMap`].
537    SpdLogMapBackward,
538    /// Backward of [`Op::SpdExpMap`].
539    SpdExpMapBackward,
540    /// Backward of [`Op::SpdParallelTransport`].
541    SpdParallelTransportBackward,
542    /// Backward of [`Op::SpdMatrixFnBatch`].
543    SpdMatrixFnBatchBackward,
544    /// Symmetric eigendecomposition — see [`Op::Eigh`].
545    Eigh,
546    /// Backward of [`Op::Eigh`].
547    EighBackward,
548    /// Batched symmetric eigendecomposition — see [`Op::EighBatch`].
549    EighBatch,
550    /// Backward of [`Op::EighBatch`].
551    EighBatchBackward,
552
553    // ── Diffusion Transformer (DiT) modulation ──────────────────
554    /// Fused adaptive-norm modulation `norm(x)·(1+scale)+shift` — see
555    /// [`Op::AdaLayerNorm`].
556    AdaLayerNorm,
557    /// Backward of [`Op::AdaLayerNorm`] — packed `[dx ∥ dscale ∥ dshift]`.
558    AdaLayerNormBackward,
559    /// Gated residual `x + gate·y` with broadcast gate — see
560    /// [`Op::GatedResidual`].
561    GatedResidual,
562    /// Backward of [`Op::GatedResidual`] — packed `[dx ∥ dy ∥ dgate]`.
563    GatedResidualBackward,
564}
565
566/// An operand inside a fused [`ChainStep`] — either a graph-level input
567/// to the [`Op::ElementwiseRegion`] (by index 0..num_inputs) or the
568/// result of a previous step in the chain (by index 0..step_position).
569#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
571pub enum ChainOperand {
572    Input(u32),
573    Step(u32),
574}
575
576/// One step in a fused element-wise chain. Each step produces exactly
577/// one scalar result (per element); later steps can refer to it via
578/// [`ChainOperand::Step`]. The whole chain runs per element in registers.
579#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
580#[derive(Debug, Clone, PartialEq)]
581pub enum ChainStep {
582    Activation(Activation, ChainOperand),
583    Cast(DType, ChainOperand),
584    Binary(BinaryOp, ChainOperand, ChainOperand),
585    Compare(CmpOp, ChainOperand, ChainOperand),
586    /// 3-input element-wise select: `cond ? on_true : on_false`. Mirrors
587    /// `Op::Where` inside a chain. `cond` is treated as truthy iff
588    /// non-zero. Lets the optimizer fold attention masks / clamp-style
589    /// patterns into a single region kernel instead of breaking the
590    /// chain at the first `Op::Where`.
591    Where(ChainOperand, ChainOperand, ChainOperand),
592}
593
594/// Pre-region memory transform fused into [`Op::ElementwiseRegion`].
595#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
597pub enum RegionPrologue {
598    #[default]
599    None,
600    /// Input is half-resolution NCHW; output shape is 2× H×W (nearest 2×).
601    ResizeNearest2x,
602}
603
604/// One sampling step in [`Op::TransformRegion`].
605#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
606#[derive(Debug, Clone, PartialEq)]
607pub enum TransformStep {
608    ResizeNearest2x(ChainOperand),
609}
610
611/// Pairing convention for [`Op::Rope`]. Different model families bake RoPE
612/// into their weights differently, so the rotation kernel needs a flavor:
613///
614/// - [`RopeStyle::NeoX`] (default): HF / GPT-NeoX "rotate-half" — dimension `i`
615///   pairs with `i + n_rot/2`. Used by HF-safetensors checkpoints (Llama, Qwen,
616///   Gemma, …).
617/// - [`RopeStyle::GptJ`]: GPT-J / llama.cpp `NORM` — adjacent pairs `(2i, 2i+1)`.
618///   GGUF Llama weights are permuted by the HF→GGUF converter for this layout,
619///   so GGUF-backed Llama inference must rotate with this flavor.
620#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
622pub enum RopeStyle {
623    #[default]
624    NeoX,
625    GptJ,
626}
627
628/// Pre-modulation normalization flavor for [`Op::AdaLayerNorm`] (DiT adaLN-Zero).
629/// Both normalize over the last axis with **no learnable affine** — the affine
630/// comes from the per-conditioning `scale`/`shift` inputs instead.
631///
632/// - [`AdaNormKind::LayerNorm`]: mean-subtract + variance-normalize (DiT / PixArt
633///   / SD3 / FLUX adaLN blocks — `LayerNorm(elementwise_affine=False)`).
634/// - [`AdaNormKind::RmsNorm`]: root-mean-square normalize only (adaLN variants
635///   that pre-norm with RMSNorm).
636#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
637#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
638pub enum AdaNormKind {
639    #[default]
640    LayerNorm,
641    RmsNorm,
642}
643
644/// Which spectral matrix function [`Op::SpdMatrixFnBatch`] applies to each SPD
645/// matrix of a batch — the batched counterparts of `rlx_cpu::spd`'s
646/// `logm` / `expm` / `sqrtm` / `invsqrtm` (spectral: `V·diag(f(λ))·Vᵀ`).
647#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
649pub enum SpdMatFn {
650    /// Matrix logarithm of an SPD matrix.
651    Logm,
652    /// Matrix exponential of a symmetric matrix.
653    Expm,
654    /// Matrix square root of an SPD matrix.
655    Sqrtm,
656    /// Inverse matrix square root of an SPD matrix (`A^{-1/2}`).
657    Invsqrtm,
658}
659
660/// An operation in the RLX IR graph.
661///
662/// Operations are categorized for fusion analysis:
663/// - Element-wise ops fuse with anything reading their output
664/// How [`Op::Pad`] fills the region added around the input.
665///
666/// Semantics match NumPy `pad` / PyTorch `F.pad`:
667/// - `Constant(v)` — fill with the scalar `v`.
668/// - `Reflect` — mirror across the edge, **excluding** the edge element
669///   (`abcd` → `cb|abcd|cb`). Requires `pad < axis_len`.
670/// - `Replicate` — repeat the edge element (`abcd` → `aa|abcd|dd`).
671/// - `Circular` — wrap around (`abcd` → `cd|abcd|ab`). Requires `pad ≤ axis_len`.
672///
673/// Not `Eq`/`Hash` because `Constant` carries an `f32` — mirrors [`Op`], which
674/// already holds `f32` attributes (e.g. `eps`) and is only `PartialEq`.
675#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
676#[derive(Debug, Clone, Copy, PartialEq)]
677pub enum PadMode {
678    /// Fill padded positions with a constant scalar.
679    Constant(f32),
680    /// Mirror across the edge, excluding the edge element.
681    Reflect,
682    /// Repeat the edge element.
683    Replicate,
684    /// Wrap around (periodic).
685    Circular,
686}
687
688/// Weight-synthesis strategy for [`Op::SynthMatMul`] — how a compact
689/// stored representation is expanded into weight values *inside* the
690/// matmul inner loop (never materialized in DRAM), generalizing
691/// [`Op::DequantMatMul`] from fixed quant grids to learned functions.
692#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
693#[derive(Debug, Clone, Copy, PartialEq)]
694pub enum SynthKind {
695    /// Single-level vector quantization with a *learned* codebook. The
696    /// weight is stored transposed (`[n, k]`, GGUF "bt" layout); each
697    /// contiguous `entry_dim`-length sub-vector along the contraction
698    /// axis `k` is replaced by a codebook centroid
699    /// `codebook[index] ∈ ℝ^{entry_dim}`. `num_entries` centroids; indices
700    /// are `u8` (so `num_entries ≤ 256`). Requires `k % entry_dim == 0`.
701    Codebook { entry_dim: u32, num_entries: u32 },
702}
703
704/// Which gradient an [`Op::SynthMatMulBackward`] node computes. The synth VJP
705/// (`y = x·Ŵ`, `Ŵ` reconstructed from a codebook) has two differentiable inputs;
706/// keeping each as a first-class backward op (rather than decomposing to
707/// Gather/MatMul/Transpose/ScatterAdd primitives) lets a backend fuse it into a
708/// single dispatch — mirroring [`Op::AttentionBackward`].
709#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
710#[derive(Debug, Clone, Copy, PartialEq)]
711pub enum SynthBwdWrt {
712    /// `dx = upstream · Ŵᵀ` (reconstruct `Ŵᵀ` in-kernel; shape = `x`'s `[m,k]`).
713    Dx,
714    /// `d_codebook = scatter_add(upstreamᵀ·x blocks, indices)` (shape = codebook's
715    /// `[num_entries, entry_dim]`).
716    Codebook,
717}
718
719/// - Matmul/Conv are BLAS-dispatched and form fusion boundaries
720/// - Reductions are fusion roots (drive the loop iteration)
721#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
722#[derive(Debug, Clone, PartialEq)]
723pub enum Op {
724    // ── Graph inputs ────────────────────────────────────────────
725    /// Model input with a name (shape on the Node).
726    Input {
727        name: String,
728    },
729
730    /// Model parameter (weight/bias) with a name.
731    Param {
732        name: String,
733    },
734
735    /// Constant tensor embedded in the graph.
736    Constant {
737        data: Vec<u8>,
738    },
739
740    // ── Element-wise unary ──────────────────────────────────────
741    /// Unary activation: one input, same shape output.
742    Activation(Activation),
743
744    /// Cast to a different dtype.
745    Cast {
746        to: DType,
747    },
748
749    /// Stop-gradient (a.k.a. `detach` / `lax.stop_gradient`). Forward is
750    /// identity; the reverse-mode autodiff rule returns **no** gradient
751    /// contribution for the input. Single input, same shape & dtype
752    /// output. Used to build a Gradient-Reverse-Layer with identity
753    /// forward semantics in user code (see maet-rs `dat_loss`).
754    StopGradient,
755
756    /// INT8 quantization. Input f32; output i8 same shape.
757    ///   `q[i] = saturate_i8(round(x[i] / scale[c]) + zero_point[c])`
758    /// where `c` selects the per-channel scale/zp when `axis = Some(d)`
759    /// (`c = idx[d]`), or always uses index 0 when `axis = None`
760    /// (per-tensor). The `scales` / `zero_points` payload length must
761    /// match `1` for per-tensor and `input.dim(d)` for per-channel.
762    /// Static — typically produced at calibration time and baked
763    /// into the loaded model. Use `Op::Dequantize` for the inverse.
764    Quantize {
765        axis: Option<usize>,
766        scales: Vec<f32>,
767        zero_points: Vec<i32>,
768    },
769
770    /// INT8 dequantization (inverse of `Op::Quantize`). Input i8;
771    /// output f32 same shape.
772    ///   `x[i] = (q[i] - zero_point[c]) · scale[c]`
773    /// where `c` is selected by `axis` exactly as in `Op::Quantize`.
774    Dequantize {
775        axis: Option<usize>,
776        scales: Vec<f32>,
777        zero_points: Vec<i32>,
778    },
779
780    /// "Fake-quantize" op for **quantization-aware training** (QAT).
781    /// Input f32; output f32 same shape. Forward computes a per-axis
782    /// (or per-tensor when `axis = None`) max-abs scale on the fly:
783    ///   `s[c] = max(|x[..., c, ...]|) / q_max(bits)`
784    /// then quantizes-then-dequantizes:
785    ///   `out[i] = clamp(round(x[i] / s[c]), -q_max, q_max) * s[c]`
786    /// where `q_max` is `127` for `bits=8`, `7` for `bits=4`, `1` for
787    /// `bits=2` (ternary). Symmetric only — zero-point is always 0.
788    ///
789    /// The point of this op is to make the SGD optimizer "see" the
790    /// deployment-time rounding during training. Backward is the
791    /// **straight-through estimator** (STE): the gradient passes
792    /// through (variant chosen by `ste`), ignoring the discontinuity
793    /// at the round. Without STE the rounding would have zero
794    /// gradient almost everywhere and learning would stop.
795    ///
796    /// Inserted by the trainer on conv / FC weight tensors when
797    /// `--qat` is on; the existing `Op::Quantize` / packing path at
798    /// the end of training still handles the deployment-side
799    /// conversion to `i8`/`i4`/`i2` codes.
800    FakeQuantize {
801        bits: u8,
802        axis: Option<usize>,
803        ste: SteKind,
804        scale_mode: ScaleMode,
805    },
806
807    /// Learned Step Size Quantization (LSQ; Esser et al. 2020,
808    /// `arXiv:1902.08153`). Like `FakeQuantize` but the per-channel
809    /// `scale` is a *learned parameter*, passed as the second input.
810    /// Forward is identical to `FakeQuantize` with a fixed scale:
811    ///   `out[i] = clamp(round(x[i]/s[c]), -q_max, q_max) * s[c]`
812    /// Backward computes both `dx` (STE) and `dscale[c]` via the
813    /// closed-form gradient:
814    ///   `dscale[c] = sum_i ψ(x[i]/s[c]) · upstream[i]`
815    /// where `ψ(z) = -z + round(z)` if `|z| ≤ q_max` else
816    /// `sign(z) · q_max`. Routinely beats per-batch and EMA at
817    /// tight bit widths (i2 / i3).
818    ///
819    /// Inputs: `[x, scale]`. `scale` is `[chan_dim]` f32 (matches
820    /// `axis`); for `axis = None` it's `[1]`.
821    FakeQuantizeLSQ {
822        bits: u8,
823        axis: Option<usize>,
824    },
825
826    /// Backward pass for `Op::FakeQuantizeLSQ`. Computes BOTH the
827    /// gradient w.r.t. `x` (STE) and the gradient w.r.t. `scale`
828    /// (closed-form). Output shape matches `x`; the `scale` gradient
829    /// is reduced separately by `LsqScaleGradient`.
830    /// Inputs: `[x, scale, dy]`. Output: `dx`, same shape as `x`.
831    FakeQuantizeLSQBackwardX {
832        bits: u8,
833        axis: Option<usize>,
834    },
835
836    /// Companion to `FakeQuantizeLSQBackwardX`: computes the
837    /// `[chan_dim]` per-channel scale gradient. Inputs `[x, scale, dy]`.
838    /// Output shape matches `scale`.
839    FakeQuantizeLSQBackwardScale {
840        bits: u8,
841        axis: Option<usize>,
842    },
843
844    // ── Element-wise binary ─────────────────────────────────────
845    /// Binary op with broadcasting: two inputs, output shape is broadcast result.
846    Binary(BinaryOp),
847
848    // ── Comparison ──────────────────────────────────────────────
849    /// Element-wise comparison: two inputs, Bool output.
850    Compare(CmpOp),
851
852    /// Select elements: cond (Bool), on_true, on_false → output.
853    Where,
854
855    /// Fused multiply-add with a SINGLE rounding: `a*b + c`. Distinct from a
856    /// `mul` then `add` (two roundings) — the single rounding is what makes
857    /// error-free transforms (TwoProduct) and compensated/double-word
858    /// arithmetic possible. Inputs: a, b, c.
859    ///
860    /// The native (single-rounding) kernel is **elementwise on equal-shaped
861    /// operands** — broadcast a scalar to the common shape first. Backends
862    /// without a native FMA fall back to `mul`+`add` via `LowerFma` (two
863    /// roundings, but broadcasts), so this only constrains the precise path.
864    Fma,
865
866    /// Fused element-wise region (PLAN L2). Holds an N-step chain of
867    /// element-wise operations. Inputs are referenced by index 0..num_inputs;
868    /// each step's result can be referenced by later steps via
869    /// `ChainOperand::Step(idx)`. The output is the last step's result.
870    /// Emitted by `MarkElementwiseRegions` in `rlx-opt` from chains of
871    /// Activation/Cast/Binary/Compare/Where ops with single-consumer
872    /// intermediates and broadcast-compatible shapes. Backends that
873    /// don't have a region kernel can decompose back to the original
874    /// chain via `unfuse::unfuse_elementwise_regions`.
875    ///
876    /// `scalar_input_mask` is a per-input bitfield (bit `i` set ⇒
877    /// input `i` is a scalar broadcast — has shape `[1]`). Kept as a
878    /// fast-path indicator that lets kernels skip the modulo entirely
879    /// when they detect a scalar.
880    ///
881    /// `input_modulus[i]` is the per-input element count, used by
882    /// kernels to compute `arena[input_offs[i] + (gid % input_modulus[i])]`
883    /// — the trailing-shape broadcast pattern. `0` means "no broadcast"
884    /// (input matches the output element count; kernel reads `gid`
885    /// directly). `1` means scalar; any other value means the input
886    /// has fewer elements than the output and they tile by modulo.
887    /// The encoder only allows broadcasts where `out_elems % in_elems
888    /// == 0` so the modulo divides cleanly. Lets chains include bias /
889    /// scale / eps / mask factors that previously broke the chain at
890    /// a Binary op with mismatched shapes.
891    ElementwiseRegion {
892        chain: Vec<ChainStep>,
893        num_inputs: u32,
894        scalar_input_mask: u32,
895        input_modulus: [u32; 16],
896        /// FKL-style closed fusion: apply before the element-wise chain.
897        prologue: RegionPrologue,
898        /// External input index that supplies the prologue transform source (default 0).
899        prologue_input: u32,
900    },
901
902    /// Fused transform chain (resize, future crop/color). Decompose via
903    /// [`rlx_fusion::DecomposeFusionRegions`] when no native kernel exists.
904    TransformRegion {
905        steps: Vec<TransformStep>,
906        num_inputs: u32,
907    },
908
909    /// Identical [`Op::ElementwiseRegion`] chain over `num_batch_inputs` tensors
910    /// (horizontal / z-plane fusion). Inputs are separate batch slices.
911    BatchElementwiseRegion {
912        chain: Vec<ChainStep>,
913        num_batch_inputs: u32,
914        scalar_input_mask: u32,
915        input_modulus: [u32; 16],
916        prologue: RegionPrologue,
917        prologue_input: u32,
918    },
919
920    // ── Linear algebra ──────────────────────────────────────────
921    /// Matrix multiply. Inputs: [.., M, K] × [.., K, N] → [.., M, N].
922    /// Batch dimensions are broadcast.
923    MatMul,
924
925    /// Matrix multiply with explicit dimension specification.
926    /// Like XLA's DotGeneral — handles arbitrary batch/contracting dims.
927    DotGeneral {
928        lhs_contracting: Vec<usize>,
929        rhs_contracting: Vec<usize>,
930        lhs_batch: Vec<usize>,
931        rhs_batch: Vec<usize>,
932    },
933
934    /// Batched dense linear solve. Inputs: `A [B, N, N]`,
935    /// `b [B, N]` or `b [B, N, K]`. Output: same shape as `b`.
936    ///
937    /// Per-batch independent solve — each `A[i]` and `b[i]` are
938    /// solved as a separate `Op::DenseSolve`. Emitted by vmap of
939    /// `Op::DenseSolve`. The CPU lowering loops over the batch
940    /// dimension calling `dgesv` per slice (LAPACK doesn't expose a
941    /// batched solve on Accelerate; cuSOLVER does on NVIDIA).
942    BatchedDenseSolve,
943
944    /// Dense linear solve `x = A⁻¹ · b` via LU factorization.
945    /// Inputs: `A [N, N]`, `b [N]` (or `b [N, K]` for multi-RHS).
946    /// Output: same shape as `b`.
947    ///
948    /// VJP via the implicit-function theorem:
949    ///   `dx = solve(Aᵀ, upstream)`
950    ///   `dA = -outer(dx, x)`   (x is the forward output)
951    ///   `db = dx`
952    /// The rule is dtype-agnostic; lowering is per-backend (Accelerate
953    /// `dgesv` / `sgesv`, cuSOLVER, etc.).
954    DenseSolve,
955
956    /// Cholesky factorization of a symmetric positive-definite matrix.
957    /// Input `A` is `[n, n]` (SPD, row-major); output `L` is `[n, n]`
958    /// lower-triangular with the strict upper triangle zeroed, so `A = L·Lᵀ`.
959    /// One input. CPU: LAPACK `potrf` (promoted to f64). Backward (added with
960    /// `TriangularSolve`) is `Ā = Φ(L⁻ᵀ · Φ(Lᵀ L̄) · L⁻¹)` symmetrized.
961    Cholesky,
962
963    /// Solve `op(A)·X = B` for triangular `A` (`[n, n]`), with `B` `[n]` or
964    /// `[n, nrhs]`. `op(A)` is `Aᵀ` when `transpose` else `A`; `A` is read as
965    /// lower- or upper-triangular per `lower` (non-unit diagonal). Two inputs
966    /// `[A, B]`; output `X` matches `B`. CPU: BLAS `trsm`. Differentiable in
967    /// both (grads are themselves triangular solves).
968    TriangularSolve {
969        lower: bool,
970        transpose: bool,
971    },
972
973    /// Determinant of a square matrix `A` `[n, n]` → scalar `[]`. One input.
974    /// CPU: LAPACK LU (`getrf`). Backward: `Ā = (det(A)·ū)·A⁻ᵀ`.
975    Det,
976    /// `log|det(A)|` of a square matrix `A` `[n, n]` → scalar `[]`. One input.
977    /// Numerically stable (sum of `log|Uᵢᵢ|`). Backward: `Ā = ū·A⁻ᵀ`.
978    LogDet,
979
980    /// Sort values along `axis` (ascending, or `descending`). One input; output
981    /// same shape/dtype. Backward scatters `ū` back via the argsort permutation.
982    Sort {
983        axis: usize,
984        descending: bool,
985    },
986    /// Indices that would sort along `axis`, as f32 (like `ArgMax`). One input;
987    /// output same shape. Non-differentiable.
988    ArgSort {
989        axis: usize,
990        descending: bool,
991    },
992
993    /// One factor of a thin SVD `A = U·diag(S)·Vᵀ` (`A` `[m, n]`,
994    /// `k = min(m, n)`), selected by `part`. One input. CPU: LAPACK `gesdd`.
995    /// Only `SvdPart::S` is differentiable (`Ā = U·diag(ū)·Vᵀ`); `U`/`Vt` are
996    /// forward-only.
997    Svd {
998        part: SvdPart,
999    },
1000
1001    /// One factor of a thin QR `A = Q·R` (`A` `[m, n]`, `k = min(m, n)`),
1002    /// selected by `part`: `Q` `[m, k]` or `R` `[k, n]`. One input. CPU: LAPACK
1003    /// `geqrf`+`orgqr`. Differentiable (the QR pullback via `copyltu` + `R⁻ᵀ`).
1004    Qr {
1005        part: QrPart,
1006    },
1007
1008    // ── Normalization ───────────────────────────────────────────
1009    /// Layer normalization: input, gamma, beta → normalized output.
1010    /// `axis` is the feature dimension (usually -1).
1011    LayerNorm {
1012        axis: i32,
1013        eps: f32,
1014    },
1015
1016    /// Group normalization on NCHW tensors: `input`, `gamma`, `beta` → same shape.
1017    /// Normalizes over `(C/num_groups) × H × W` per group.
1018    GroupNorm {
1019        num_groups: usize,
1020        eps: f32,
1021    },
1022
1023    /// LayerNorm2d on NCHW: normalize across the channel axis at each spatial
1024    /// position (candle / SAM `LayerNorm2d` semantics — not PyTorch's H×W norm).
1025    LayerNorm2d {
1026        eps: f32,
1027    },
1028
1029    /// Nearest-neighbor 2× upsample on NCHW (doubles spatial dims 2 and 3).
1030    ResizeNearest2x,
1031
1032    /// Nearest-neighbor resample on NCDHW. `size` is `[D_out, H_out, W_out]`.
1033    /// Source mapping: `src = min(floor(dst * in / out), in - 1)`.
1034    Interpolate3d {
1035        size: Vec<usize>,
1036    },
1037
1038    /// RMS normalization: input, gamma → normalized output.
1039    RmsNorm {
1040        axis: i32,
1041        eps: f32,
1042    },
1043
1044    /// BatchNorm inference with frozen running statistics.
1045    /// Inputs: `x`, `gamma`, `beta`, `running_mean`, `running_var`.
1046    /// Feature dimension is the last axis of `x`; stats are 1-D `[C]`.
1047    BatchNormInference {
1048        eps: f32,
1049    },
1050
1051    // ── Attention ───────────────────────────────────────────────
1052    /// Scaled dot-product attention: Q, K, V, \[mask\] → output.
1053    /// The compiler can lower this to fused SDPA or flash attention.
1054    /// `mask_kind` controls how masking is applied — `Custom` reads from
1055    /// the 4th input tensor; `None` / `Causal` / `SlidingWindow` skip the
1056    /// mask load and apply the mask directly in the inner loop. See
1057    /// `MaskKind` for the rationale.
1058    ///
1059    /// `score_scale`: when `Some(s)`, dot-product scores are multiplied by
1060    /// `s` instead of the default `1/sqrt(head_dim)` (Gemma uses `head_dim^-0.5`
1061    /// explicitly in config). `attn_logit_softcap`: when `Some(c)`, applies
1062    /// `c * tanh(s/c)` to scores before softmax (Gemma 2).
1063    ///
1064    /// ## Operand layout
1065    ///
1066    /// Q/K/V (and the output) carry the heads explicitly and are accepted in two
1067    /// rank-4 layouts — both end in `head_dim`, so backends disambiguate by which
1068    /// axis equals `num_heads`:
1069    ///
1070    /// - **`[B, S, H, D]`** (heads at axis 2) — the dominant convention, produced
1071    ///   by Llama/Moshi-style `reshape([B, S, H, D])` after the QKV projection.
1072    ///   The CPU/Metal/MLX/wgpu kernels attend over axis 1 (`S`); CoreML
1073    ///   transposes to `[B, H, S, D]` first (see `rlx-coreml`).
1074    /// - **`[B, H, S, D]`** (heads at axis 1) — already head-canonical.
1075    ///
1076    /// Rank-3 `[B, S, D]` is single-head and head-canonical. `K`/`V` may have a
1077    /// different sequence length than `Q` (`S_k >= S_q`) for KV-cache decode; the
1078    /// causal mask places the `S_q` queries at the **end** of the `S_k` keys
1079    /// (query `qi` is at absolute position `S_k - S_q + qi`).
1080    Attention {
1081        num_heads: usize,
1082        head_dim: usize,
1083        /// Value/output per-head width. `None` ⇒ same as `head_dim` (the common
1084        /// case). `Some(v)` with `v != head_dim` is asymmetric SDPA — Q/K use
1085        /// `head_dim` for the scores, V is read with `v_head_dim` and the output
1086        /// is `num_heads * v_head_dim` wide. Used by DeepSeek/Kimi **MLA**, which
1087        /// otherwise zero-pads V to `head_dim` and slices the output back.
1088        v_head_dim: Option<usize>,
1089        mask_kind: MaskKind,
1090        score_scale: Option<f32>,
1091        attn_logit_softcap: Option<f32>,
1092    },
1093
1094    /// Rotary position embedding applied to one tensor: x, cos, sin → x_rotated.
1095    /// Apply separately to Q and K. `head_dim` is the per-head width; `n_rot`
1096    /// is how many leading dims get NeoX RoPE (pair offset `n_rot/2`). When
1097    /// `n_rot < head_dim`, trailing dims are copied unchanged (Qwen3.5 MRoPE).
1098    Rope {
1099        head_dim: usize,
1100        n_rot: usize,
1101        /// Rotation pairing convention (default [`RopeStyle::NeoX`]).
1102        style: RopeStyle,
1103    },
1104
1105    /// SAM2 axial 2-D RoPE on `[batch, seq, num_heads * head_dim]`.
1106    AxialRope2d {
1107        end_x: usize,
1108        end_y: usize,
1109        head_dim: usize,
1110        num_heads: usize,
1111        theta: f32,
1112        repeat_factor: usize,
1113    },
1114
1115    // ── Shape manipulation ──────────────────────────────────────
1116    Reshape {
1117        new_shape: Vec<i64>,
1118    },
1119    Transpose {
1120        perm: Vec<usize>,
1121    },
1122    /// Select a contiguous slice along an axis.
1123    Narrow {
1124        axis: usize,
1125        start: usize,
1126        len: usize,
1127    },
1128    /// Strided slice along an axis: output element `j` reads input index
1129    /// `start + j*step` for `j` in `0..len`. `step` may be negative (reverse);
1130    /// `step != 0`. All accessed indices must lie in `[0, axis_len)`. The
1131    /// contiguous `step == 1` case is [`Op::Narrow`]; this covers `x[::2]`,
1132    /// `x[::-1]`, dilation, and general strided indexing. No backend has a
1133    /// native kernel except Metal/CUDA; others legalize to `narrow`/`reverse`/
1134    /// `gather` via `rlx_fusion::LowerSlice`.
1135    Slice {
1136        axis: usize,
1137        start: usize,
1138        len: usize,
1139        step: i64,
1140    },
1141    /// Concatenate along an axis.
1142    Concat {
1143        axis: usize,
1144    },
1145    /// In-place KV-cache append: write `row` (input 1) into `cache` (input 0)
1146    /// at index `pos` along `axis`, and return the `[..pos+1..]` prefix of
1147    /// `cache` — the output **aliases `cache`'s buffer** (no copy). Replaces the
1148    /// decode `concat(past_kv, new_row)` whose full-cache copy is O(context);
1149    /// this writes a single row (O(1)). The planner assigns the output `cache`'s
1150    /// slot (see `collect_view_aliases`); backends encode a 1-row write.
1151    KvAppend {
1152        axis: usize,
1153        pos: usize,
1154    },
1155    /// Expand (broadcast) to a target shape.
1156    Expand {
1157        target_shape: Vec<i64>,
1158    },
1159    /// Gather elements by index along an axis (embedding lookup).
1160    Gather {
1161        axis: usize,
1162    },
1163    /// Reverse (flip) the element order along each listed axis. Output shape
1164    /// equals the input shape; element `[…, i, …]` on a reversed axis of size
1165    /// `d` reads input `[…, d-1-i, …]`. Batch-general — only the listed axes
1166    /// flip, all others pass through unchanged (the right primitive for
1167    /// reversing a `[batch, seq, …]` sequence without a batch=1 assumption).
1168    Reverse {
1169        axes: Vec<usize>,
1170    },
1171    /// Pad each axis by `pads[i] = [before, after]` (aligned to the input rank)
1172    /// per [`PadMode`]. Output axis `i` grows by `before + after`. Single tensor
1173    /// input; the constant fill value lives in the mode, not an input.
1174    ///
1175    /// No backend has a native kernel except Metal/CUDA; every other backend
1176    /// legalizes it to `narrow`/`reverse`/`concat`/`gather`/`full` via
1177    /// `rlx_fusion::LowerPad`, so the decomposition is the semantic oracle.
1178    Pad {
1179        pads: Vec<[usize; 2]>,
1180        mode: PadMode,
1181    },
1182    /// Elementwise `clamp(x, min, max)`. Decomposes to `max`/`min` (native on
1183    /// every backend); no dedicated kernel needed. Backward: pass-through where
1184    /// `min ≤ x ≤ max`, else 0.
1185    Clamp {
1186        min: f32,
1187        max: f32,
1188    },
1189    /// Tile (repeat) the input `reps[i]` times along axis `i` (`reps` aligned to
1190    /// the input rank). Decomposes to `concat`. Backward: reduce-sum the copies.
1191    Tile {
1192        reps: Vec<usize>,
1193    },
1194    /// Keep the upper (`upper=true`) or lower triangle relative to `diagonal`;
1195    /// zero the rest. Operates on the last two axes. Decomposes to a multiply by
1196    /// a constant triangular mask. Backward: same mask.
1197    Trilu {
1198        upper: bool,
1199        diagonal: i64,
1200    },
1201
1202    // ── Reduction ───────────────────────────────────────────────
1203    /// Reduce along specified axes.
1204    Reduce {
1205        op: ReduceOp,
1206        axes: Vec<usize>,
1207        keep_dim: bool,
1208    },
1209
1210    /// Histogram of `x` into `bins` equal-width buckets over `[min, max]`.
1211    /// Output is a 1-D f32 tensor `[bins]` of counts. Elements outside
1212    /// `[min, max]` are dropped; `x == max` lands in the last bin (half-open
1213    /// buckets, closed at the top — matches `numpy.histogram`). Native on CPU;
1214    /// decomposes to `Compare`+`Reduce::Sum` on every other backend (see
1215    /// `LowerHistogram`). Non-differentiable (discrete counts, zero gradient).
1216    /// Introduced for the data-pattern recording harness.
1217    Histogram {
1218        bins: usize,
1219        min: f32,
1220        max: f32,
1221    },
1222
1223    /// Selective scan (plan #15) — Mamba-style state-space model
1224    /// step. The recurrence:
1225    ///   `h[t] = exp(Δ[t] * A) * h[t-1] + Δ[t] * B[t] * x[t]`
1226    ///   `y[t] = C[t] * h[t]`
1227    /// where state `h` has dimension `state_size` and the input has
1228    /// `(batch, seq, hidden)`.
1229    ///
1230    /// Inputs (in order):
1231    ///   `x [b, s, h]`      f32 input
1232    ///   `delta [b, s, h]`  f32 step size (per-position, per-channel)
1233    ///   `a [h, n]`         f32 transition matrix (one per channel)
1234    ///   `b [b, s, n]`      f32 input projection
1235    ///   `c [b, s, n]`      f32 output projection
1236    /// Output: `[b, s, h]` f32. State `h` is implicit; the kernel
1237    /// scans through the seq dimension carrying it.
1238    ///
1239    /// `state_size` = `n` is exposed for the cost model.
1240    SelectiveScan {
1241        state_size: usize,
1242    },
1243
1244    /// Gated DeltaNet linear-attention recurrence — the per-layer
1245    /// kernel used by Qwen3.5/3.6 trunk "linear attention" blocks
1246    /// (and Qwen3-Next, Kimi-Linear). Mirrors
1247    /// `llama.cpp / src/models/delta-net-base.cpp` autoregressive
1248    /// path; chunked + fused variants ride the same op identity.
1249    ///
1250    /// **Math (per token `t`, head `h`, state size `n`):**
1251    /// state matrix `S[h, i, j]` is implicit (reset per batch).
1252    /// ```text
1253    ///   S[h]     *= exp(g[t,h])                     # scalar gate
1254    ///   sk[h,j]   = Σ_i S[h,i,j] * k[t,h,i]
1255    ///   d[h,j]    = (v[t,h,j] - sk[h,j]) * b[t,h]   # b = beta
1256    ///   S[h,i,j] += k[t,h,i] * d[h,j]               # outer-prod
1257    ///   o[t,h,j]  = Σ_i S[h,i,j] * (q[t,h,i] / √n)
1258    /// ```
1259    ///
1260    /// Inputs:
1261    ///   `q     [b, s, h_v, n]`  f32 queries (L2-normed by caller)
1262    ///   `k     [b, s, h_v, n]`  f32 keys    (L2-normed by caller;
1263    ///                            GQA-repeated to match `h_v`)
1264    ///   `v     [b, s, h_v, n]`  f32 values
1265    ///   `g     [b, s, h_v]`     f32 log-gate (exp'd inside kernel)
1266    ///   `beta  [b, s, h_v]`     f32 delta-rule mixing factor
1267    ///
1268    /// Output: `[b, s, h_v, n]` f32.
1269    ///
1270    /// When `carry_state` is true, a sixth input `state [b, h_v, n, n]`
1271    /// provides the initial SSM matrix per head; the kernel updates it
1272    /// in place across the sequence and leaves the final state in the
1273    /// same buffer (same layout as the internal scan state:
1274    /// `state[h, i, j]` row-major over `(n, n)` per head).
1275    ///
1276    /// When `gate_per_channel` is true (Kimi-K3 "KDA"), the `g` input is
1277    /// `[b, s, h_v, n]` (one log-gate per key channel) instead of `[b, s, h_v]`,
1278    /// and the state decay is applied per key-row: `S[i, j] *= exp(g[i])`
1279    /// (vs the scalar `S *= exp(g)` of the per-head gate). Everything else is
1280    /// identical; Qwen3-Next / Qwen3.5 use the per-head gate (`false`).
1281    GatedDeltaNet {
1282        state_size: usize,
1283        carry_state: bool,
1284        gate_per_channel: bool,
1285    },
1286
1287    /// Multi-layer (optionally bidirectional) LSTM over a
1288    /// `[batch, seq, input]` sequence. Gate order i, f, g, o (PyTorch);
1289    /// recurrence per step:
1290    /// ```text
1291    ///   z      = x_t · w_ihᵀ + h_{t-1} · w_hhᵀ + bias
1292    ///   i,f,o  = σ(z_i), σ(z_f), σ(z_o);   g = tanh(z_g)
1293    ///   c_t    = f · c_{t-1} + i · g;      h_t = o · tanh(c_t)
1294    /// ```
1295    /// `D = 2 if bidirectional else 1`. Inputs `[x, w_ih, w_hh, bias]`
1296    /// (`+ [h0, c0]` when `carry`):
1297    ///   * `x`:    `[batch, seq, input]`
1298    ///   * `w_ih`: packed, all `(layer, direction)` blocks concatenated in
1299    ///     `layer`-major then `direction` order. Block `(l,d)` is
1300    ///     `[4*hidden, in_l]` with `in_l = input` for `l=0` else `D*hidden`.
1301    ///   * `w_hh`: packed `L*D` blocks of `[4*hidden, hidden]`.
1302    ///   * `bias`: packed `L*D` blocks of `[4*hidden]` (combined `b_ih+b_hh`).
1303    ///   * `h0`, `c0` (carry only): `[L*D, batch, hidden]`; the final
1304    ///     `hn`/`cn` are written back **in place** (decode threading).
1305    ///
1306    /// Output `y`: `[batch, seq, D*hidden]` — last layer's hidden states
1307    /// (forward ‖ reverse concatenated on the feature axis). With
1308    /// `num_layers = 1, bidirectional = false, carry = false` this is the
1309    /// plain single-layer LSTM and the weight shapes reduce to
1310    /// `[4*hidden, input]`, `[4*hidden, hidden]`, `[4*hidden]`.
1311    Lstm {
1312        hidden_size: usize,
1313        num_layers: usize,
1314        bidirectional: bool,
1315        carry: bool,
1316    },
1317
1318    /// Gated Recurrent Unit (PyTorch). `D = 2 if bidirectional else 1`;
1319    /// gate order r, z, n. Recurrence:
1320    /// ```text
1321    ///   r = σ(x·W_irᵀ + b_ir + h·W_hrᵀ + b_hr)
1322    ///   z = σ(x·W_izᵀ + b_iz + h·W_hzᵀ + b_hz)
1323    ///   n = tanh(x·W_inᵀ + b_in + r ⊙ (h·W_hnᵀ + b_hn))
1324    ///   h' = (1 - z) ⊙ n + z ⊙ h
1325    /// ```
1326    /// The new-gate reset is applied to the hidden term *after* its bias,
1327    /// so `b_ih`/`b_hh` cannot be merged. Inputs `[x, w_ih, w_hh, b_ih, b_hh]`
1328    /// (`+ [h0]` when carry); packing matches [`Op::Lstm`] with `3*hidden`
1329    /// gate rows. `h0` `[L*D, batch, hidden]`. Output `[batch, seq, D*hidden]`.
1330    Gru {
1331        hidden_size: usize,
1332        num_layers: usize,
1333        bidirectional: bool,
1334        carry: bool,
1335    },
1336
1337    /// Elman RNN (PyTorch): `h' = act(x·w_ihᵀ + h·w_hhᵀ + bias)` with
1338    /// `act = ReLU` when `relu` else `tanh`. Packed `w_ih` `[hidden, in_l]`,
1339    /// `w_hh` `[hidden, hidden]`, merged `bias` `[hidden]` (per layer ×
1340    /// direction). Inputs `[x, w_ih, w_hh, bias]` (`+ [h0]` when carry).
1341    /// Output `[batch, seq, D*hidden]`.
1342    Rnn {
1343        hidden_size: usize,
1344        num_layers: usize,
1345        bidirectional: bool,
1346        carry: bool,
1347        relu: bool,
1348    },
1349
1350    /// Mamba-2 / SSD (structured state-space duality) scan — the
1351    /// scalar-decay SSM at the core of Mamba-2, sibling of
1352    /// [`Op::SelectiveScan`] / [`Op::GatedDeltaNet`]. Inputs
1353    /// `[x, dt, a, b, c]` (all `f32`):
1354    ///   * `x`:  `[batch, seq, heads, head_dim]`
1355    ///   * `dt`: `[batch, seq, heads]` — discretization step (already ≥ 0,
1356    ///     e.g. softplus'd by the caller)
1357    ///   * `a`:  `[heads]` — per-head decay rate (`dA = exp(dt·a)`)
1358    ///   * `b`:  `[batch, seq, heads, state_size]`
1359    ///   * `c`:  `[batch, seq, heads, state_size]`
1360    ///
1361    /// State `S [batch, heads, head_dim, state_size]` is zero-initialized
1362    /// per sequence. Recurrence per timestep `t`:
1363    /// ```text
1364    ///   dA_t = exp(dt_t · a)
1365    ///   S_t  = dA_t · S_{t-1} + (dt_t · x_t) ⊗ b_t
1366    ///   y_t  = Σ_n S_t[:, n] · c_t[n]
1367    /// ```
1368    /// Output `y`: `[batch, seq, heads, head_dim]` (same shape as `x`).
1369    Mamba2 {
1370        head_dim: usize,
1371        state_size: usize,
1372    },
1373
1374    /// Fused dequant + matmul (plan #5). The biggest LLM-bandwidth
1375    /// win on Apple Silicon: dequantizes weights inside the matmul
1376    /// inner loop, never materializing f32 weights.
1377    ///
1378    /// **BREAKING CHANGE in 0.2.0:** `num_inputs()` is now
1379    /// scheme-dependent — **4** for legacy Int8 schemes, **2** for
1380    /// the new GGUF K-quant schemes (their scales/mins live inside
1381    /// the packed bytes, so no side-channel `scale` / `zp` tensors
1382    /// are fed in). Callers that assumed a fixed 4-input contract
1383    /// must inspect `scheme.is_gguf()` before reading inputs.
1384    ///
1385    /// Inputs (Int8 schemes — `scheme.is_gguf() == false`):
1386    ///   `x [m, k]`             f32 activations
1387    ///   `w_q [k, n]` packed    quantized weight bytes (i8 per
1388    ///                          element for Int8 schemes; 4-bit
1389    ///                          packed two-per-byte for Int4)
1390    ///   `scale [k/block, n]`   per-block f32 dequant scale
1391    ///   `zp    [k/block, n]`   per-block f32 zero-point
1392    ///                          (zero-tensor if symmetric)
1393    ///
1394    /// Inputs (`Nvfp4Block` — fixed group size 16 along K):
1395    ///   `x [m, k]`             f32 activations
1396    ///   `w_q [k,n/2]` packed   FP4 E2M1 codes (unsigned nibble 0..15)
1397    ///   `scale [k/16, n]` u8   FP8 E4M3 block scales (one byte / group)
1398    ///   `global_scale [1]` f32 per-tensor scale (pass `[1.0]` if unused)
1399    ///
1400    /// Inputs (GGUF schemes — `scheme.is_gguf() == true`):
1401    ///   `x [m, k]`             f32 activations
1402    ///   `packed_w [bytes]`     raw GGUF super-block bytes; the
1403    ///                          dequantizer reads the per-sub-block
1404    ///                          scales / mins / quants directly out
1405    ///                          of the buffer per the K-quant block
1406    ///                          layout (no side tensors).
1407    ///
1408    /// Output: `[m, n]` f32.
1409    ///
1410    /// `block_size` (on the Int8 schemes only) is the number of
1411    /// consecutive elements that share one (scale, zero_point) pair.
1412    /// The Op carries enough metadata that the kernel doesn't need
1413    /// a separate `QuantMap` lookup at run time.
1414    DequantMatMul {
1415        scheme: crate::quant::QuantScheme,
1416    },
1417
1418    /// On-chip **weight synthesis** matmul: reconstruct the weight from a
1419    /// compact learned representation *inside* the matmul inner loop,
1420    /// trading DRAM weight-bandwidth for ALU — the sibling of
1421    /// [`Op::DequantMatMul`], but with a *learned* function rather than a
1422    /// fixed quant grid. Inputs (in order), for `SynthKind::Codebook`:
1423    ///   * `x`        `[m, k]`                   f32 activations
1424    ///   * `indices`  `[n, k/entry_dim]`         u8 codebook indices
1425    ///                                           (weight stored transposed)
1426    ///   * `codebook` `[num_entries, entry_dim]` f32 learned centroids
1427    /// Output `y`: `[m, n]` f32, `y = x · Wᵀ` where
1428    /// `W[n, kb·entry_dim + t] = codebook[indices[n, kb]][t]`.
1429    SynthMatMul {
1430        kind: SynthKind,
1431    },
1432
1433    /// Fused backward of [`Op::SynthMatMul`] — one dispatch per gradient instead
1434    /// of the ~11 primitives the generic VJP would emit (Gather + 2 MatMul +
1435    /// Transpose + ScatterAdd + reshapes). Inputs (in order):
1436    ///   * `x`        `[m, k]`                   f32 activations (forward input)
1437    ///   * `indices`  `[n, k/entry_dim]`         u8 codebook indices
1438    ///   * `codebook` `[num_entries, entry_dim]` f32 centroids
1439    ///   * `upstream` `[m, n]`                   f32 grad w.r.t. the forward output
1440    /// Output is `dx [m,k]` for `wrt=Dx`, or `d_codebook [num_entries,entry_dim]`
1441    /// for `wrt=Codebook`. Backends without a native kernel decompose it via
1442    /// `LowerSynthMatMulBackward` (bit-identical to the old primitive expansion).
1443    SynthMatMulBackward {
1444        kind: SynthKind,
1445        wrt: SynthBwdWrt,
1446    },
1447
1448    /// Reconstruct the dense weight `W [k,n]` from `indices [n, k/entry_dim]` (u8)
1449    /// + `codebook [num_entries, entry_dim]`, in ONE fused kernel (no separate
1450    /// cast/gather/reshape/transpose). Lets a model fold multi-stage VQ + LoRA into
1451    /// one `W_eff = Σ reconstruct + A·Bᵀ` then a single matmul. Output `[k,n]`:
1452    /// `W[kb·entry_dim+t, j] = codebook[indices[j, kb], t]`. Backends without the
1453    /// native kernel decompose via `LowerSynthReconstruct`.
1454    SynthReconstruct {
1455        kind: SynthKind,
1456    },
1457
1458    /// KAN (Kolmogorov–Arnold) learnable spline activation: each channel `c`
1459    /// gets its own univariate function `φ_c` expressed in a fixed Gaussian-RBF
1460    /// basis, with learned per-channel coefficients — "weights as functions".
1461    /// Inputs:
1462    ///   * `x`     `[.., C]`            f32 pre-activations
1463    ///   * `coeff` `[C, num_basis]`     f32 learned basis coefficients
1464    /// Output `y`: same shape as `x`, with
1465    /// `y[.., c] = Σ_g coeff[c, g] · exp(-((x[.., c] − center_g) · inv_h)²)`,
1466    /// where `center_g = grid_min + g·(grid_max−grid_min)/(num_basis−1)` and
1467    /// `inv_h = (num_basis−1)/(grid_max−grid_min)` (adjacent RBFs ~one width
1468    /// apart). Differentiable wrt both `x` and `coeff`.
1469    SplineActivation {
1470        num_basis: u32,
1471        grid_min: f32,
1472        grid_max: f32,
1473    },
1474    /// Fused KAN spline VJP → `dx` (input grad). Inputs: `x`, `coeff`, `upstream`.
1475    /// Builds the RBF basis + derivative in registers (loop over `num_basis`) and
1476    /// emits `dx = upstream·Σ_g coeff[c,g]·B_g'(x)` in one pass — replaces the
1477    /// decomposed exp/mul/reduce chain that materializes the `[.., C, G]` basis.
1478    SplineActivationBackwardX {
1479        num_basis: u32,
1480        grid_min: f32,
1481        grid_max: f32,
1482    },
1483    /// Fused KAN spline VJP → `dcoeff` (coeff grad `[C, num_basis]`). Inputs: `x`,
1484    /// `upstream`. Atomic-accumulates `dcoeff[c,g] = Σ_batch upstream·B_g(x)`.
1485    SplineActivationBackwardCoeff {
1486        num_basis: u32,
1487        grid_min: f32,
1488        grid_max: f32,
1489    },
1490
1491    /// Real INT8-arithmetic matrix multiply with i32 accumulation.
1492    /// Inputs (in order):
1493    ///   `x      [M, K]`  i8 activations (zero-point = `x_zp`)
1494    ///   `w      [K, N]`  i8 weights     (zero-point = `w_zp`)
1495    ///   `bias   [N]`     i32 (in accumulator scale = `x_scale·w_scale`),
1496    ///                    pass a zeros tensor for "no bias"
1497    /// Output:  `[M, N]`  i8 (zero-point = `out_zp`)
1498    ///
1499    /// Per-element compute:
1500    ///   `out[m,n] = requantize(bias[n] + Σₖ (x[m,k]-x_zp)·(w[k,n]-w_zp), mult, out_zp)`
1501    /// where `mult = x_scale · w_scale / out_scale`.
1502    ///
1503    /// This is the same kernel shape `rlx-cortexm/src/dense.rs`
1504    /// uses for on-device int8 inference, lifted into the IR so the
1505    /// rlx-cpu backend can run a quantized graph directly (instead
1506    /// of round-tripping through fake-quant Dequantize → MatMul →
1507    /// Quantize). 2-D only — generalizing to batched comes when a
1508    /// real workload demands it.
1509    QMatMul {
1510        x_zp: i32,
1511        w_zp: i32,
1512        out_zp: i32,
1513        mult: f32,
1514    },
1515
1516    /// Real INT8-arithmetic 2-D convolution with i32 accumulation.
1517    /// Inputs:
1518    ///   `x      [N, C_in, H, W]`              i8 (zero-point = `x_zp`)
1519    ///   `w      [C_out, C_in/groups, kH, kW]` i8 (zero-point = `w_zp`)
1520    ///   `bias   [C_out]`                      i32 in accumulator scale
1521    /// Output: `[N, C_out, H_out, W_out]` i8 (zero-point = `out_zp`).
1522    /// Same NCHW geometry contract as `Op::Conv`; same requantize
1523    /// math as `Op::QMatMul` (per-element `acc·mult` rounded to i8).
1524    QConv2d {
1525        kernel_size: Vec<usize>,
1526        stride: Vec<usize>,
1527        padding: Vec<usize>,
1528        dilation: Vec<usize>,
1529        groups: usize,
1530        x_zp: i32,
1531        w_zp: i32,
1532        out_zp: i32,
1533        mult: f32,
1534    },
1535
1536    /// **Native low-precision tensor-core GEMM** with f32 accumulation —
1537    /// both operands are sub-f16 codes fed *directly* into the matmul, the
1538    /// opposite of `DequantMatMul` (which decodes weights to f32 first and
1539    /// keeps `x` in f32). This is the path that wins on Hopper / Ada /
1540    /// Blackwell (cublasLt FP8/FP4) and CDNA3 / CDNA4 (hipBLASLt). On CPU /
1541    /// Metal it is the decode-and-accumulate *reference* (no fp8 matrix HW).
1542    ///
1543    /// Layout is **TN** — both operands are K-last (`out = lhs · rhsᵀ`) so
1544    /// every block scale runs along the last/contraction axis uniformly, which
1545    /// is also cuBLASLt / hipBLASLt FP8's preferred layout.
1546    ///
1547    /// Inputs (in order):
1548    ///   `lhs   [m, k]`  U8 — packed [`crate::ScaledFormat`] codes (logical dims)
1549    ///   `rhs   [n, k]`  U8 — packed codes (K-last, i.e. weight transposed)
1550    ///   `lhs_s        `  scale tensor, dtype per [`crate::ScaleLayout::scale_dtype`]
1551    ///   `rhs_s        `  scale tensor
1552    ///   `bias  [n]    `  f32, present iff `has_bias`
1553    /// Output: `[m, n]` f32. Reconstruction: `decode(code)·scale(block)`.
1554    ///
1555    /// Operand `Shape`s carry **logical** element dims with `dtype = U8`; the
1556    /// physical byte layout (1 code/byte on the CPU oracle, packed nibbles on
1557    /// GPU) is a backend-paired detail shared by `ScaledQuantize` and the
1558    /// matmul kernel, so it never needs a `DType` variant.
1559    ScaledMatMul {
1560        lhs_format: crate::quant::ScaledFormat,
1561        rhs_format: crate::quant::ScaledFormat,
1562        scale_layout: crate::quant::ScaleLayout,
1563        has_bias: bool,
1564    },
1565
1566    /// Quantize an f32/f16 tensor to packed low-precision codes for
1567    /// [`Op::ScaledMatMul`]. Inputs: `x` (f32) and `scale` (from
1568    /// [`Op::ScaledQuantScale`]); output is `DType::U8` codes of the same
1569    /// logical shape as `x`. `code[i] = encode(format, x[i] / scale(block_of(i)))`.
1570    ScaledQuantize {
1571        format: crate::quant::ScaledFormat,
1572        scale_layout: crate::quant::ScaleLayout,
1573    },
1574
1575    /// Compute the (per-tensor or per-block) scale tensor for a tensor about
1576    /// to be quantized to `format`. Input: `x` (f32). Output dtype follows
1577    /// [`crate::ScaleLayout::scale_dtype`]. Split from `ScaledQuantize` to keep
1578    /// the IR single-output (mirrors the `FakeQuantizeLSQBackwardX/Scale` split).
1579    ScaledQuantScale {
1580        format: crate::quant::ScaledFormat,
1581        scale_layout: crate::quant::ScaleLayout,
1582    },
1583
1584    /// Reconstruct f32 from packed low-precision codes: `decode(code)·scale`.
1585    /// The inverse of [`Op::ScaledQuantize`]. Inputs: `codes` (U8), `scale`;
1586    /// output is f32 with the same logical shape as `codes`. Used by the
1587    /// straight-through VJP of [`Op::ScaledMatMul`] to rebuild operands in the
1588    /// backward graph (and as a standalone dequantizer).
1589    ScaledDequantize {
1590        format: crate::quant::ScaledFormat,
1591        scale_layout: crate::quant::ScaleLayout,
1592    },
1593
1594    /// Fused LoRA matmul: `out = x·W + scale * x·A·B`.
1595    /// Inputs (in order): `x [m, k]`, `w [k, n]`, `a [k, r]`, `b [r, n]`.
1596    /// `r` is the LoRA rank (typically 4-64). `scale` is the
1597    /// per-adapter `alpha / rank` knob.
1598    /// Plan #9: lifts LoRA from "three matmuls + an add" into one
1599    /// kernel that keeps the rank-r intermediate in registers.
1600    LoraMatMul {
1601        scale: f32,
1602    },
1603
1604    /// Uniform-partitioned overlap-save 1-D convolution (FIR / RIR).
1605    /// Inputs `[x, ir]`: signal `x [.., L]` and rank-1 impulse response
1606    /// `ir [M]`; output is the full linear convolution `[.., L + M − 1]`.
1607    /// `block` sets the partition / FFT size (`2·next_pow2(block)` points).
1608    /// Decomposes (via `unfuse`) to `rfft → batched complex matmul over the
1609    /// partition axis → irfft`, so the frequency-domain delay line runs on the
1610    /// native batched-GEMM kernels (cuBLAS / rocBLAS / MPS). See
1611    /// [`crate::Graph::partitioned_conv`].
1612    PartitionedConv {
1613        block: usize,
1614    },
1615
1616    /// Fused sampling kernel: logits → optional top-k filter →
1617    /// optional top-p truncation → softmax → multinomial sample.
1618    /// One f32-encoded sampled token id per batch row (output
1619    /// shape `[batch]`).
1620    ///
1621    /// `temperature == 1.0` matches a plain argmax-of-softmax;
1622    /// lower → sharper, higher → flatter. `top_k == 0` disables.
1623    /// `top_p == 1.0` disables. `seed` is the Philox seed; pass 0
1624    /// for "use process-global counter" (still deterministic
1625    /// given the call order).
1626    /// Borrowed from MAX's nn/sampling.mojo.
1627    /// Latency-critical: never materializes the full softmax
1628    /// distribution on the host.
1629    Sample {
1630        top_k: usize,     // 0 = disabled
1631        top_p: f32,       // 1.0 = disabled
1632        temperature: f32, // 1.0 = neutral
1633        seed: u64,        // 0 = use thread-local counter
1634    },
1635
1636    /// ONNX `RandomNormalLike` / `RandomNormal`: zero or one shape-template
1637    /// input (Like uses the template's shape; `RandomNormal` with a `shape`
1638    /// attribute needs no input). Output shape is fixed on the node.
1639    /// at compile/execute time. Optional ONNX `seed` attribute (f32) overrides
1640    /// the mixed seed on the Ort backend.
1641    RngNormal {
1642        mean: f32,
1643        scale: f32,
1644        key: u64,
1645        op_seed: Option<f32>,
1646    },
1647
1648    /// ONNX `RandomUniformLike`.
1649    RngUniform {
1650        low: f32,
1651        high: f32,
1652        key: u64,
1653        op_seed: Option<f32>,
1654    },
1655
1656    /// Inclusive cumulative sum along an axis. Same shape in/out.
1657    /// Underpins ragged-tensor offsets, sampling (top-p prefix sum),
1658    /// and sequence-position math.
1659    /// `exclusive=true` shifts the result so output\[0\] = 0 (useful
1660    /// for offset arrays where the first segment starts at 0).
1661    Cumsum {
1662        axis: i32,
1663        exclusive: bool,
1664    },
1665
1666    /// Inclusive cumulative product along an axis. Same shape in/out.
1667    /// `exclusive=true` shifts so output\[0\] = 1 (the multiplicative identity).
1668    /// Mirrors [`Op::Cumsum`]; native on CPU/Metal/CUDA, decomposes to a
1669    /// masked reduce-product elsewhere.
1670    CumProd {
1671        axis: i32,
1672        exclusive: bool,
1673    },
1674
1675    /// Inclusive cumulative maximum along an axis. Same shape in/out.
1676    /// `exclusive=true` shifts so output\[0\] = -inf. Native on CPU/Metal/CUDA,
1677    /// decomposes to a masked reduce-max elsewhere.
1678    CumMax {
1679        axis: i32,
1680        exclusive: bool,
1681    },
1682
1683    /// Index of the maximum along `axis`. Output drops that axis (or keeps it
1684    /// as size 1 when `keep_dim`). Indices are **f32-encoded** (rlx is f32 at
1685    /// the I/O boundary, matching [`Op::TopK`]).
1686    ArgMax {
1687        axis: usize,
1688        keep_dim: bool,
1689    },
1690
1691    /// Index of the minimum along `axis`; see [`Op::ArgMax`].
1692    ArgMin {
1693        axis: usize,
1694        keep_dim: bool,
1695    },
1696
1697    /// Softmax along an axis (reduction + element-wise).
1698    Softmax {
1699        axis: i32,
1700    },
1701
1702    /// Top-K **indices** along the last axis. Output shape `[..., k]`,
1703    /// f32-encoded indices (rlx is f32-only at the I/O boundary).
1704    /// To recover the values, follow with a `Gather` against the
1705    /// original tensor — works because Gather already supports any axis.
1706    /// Ties broken by smaller index (matches NumPy / PyTorch
1707    /// `torch.topk(..., largest=True, sorted=True)`).
1708    /// Used by MoE gating; also useful for beam search.
1709    TopK {
1710        k: usize,
1711    },
1712
1713    /// Indexed batched matmul. The MoE GEMM primitive.
1714    /// Inputs: `[input, weight, expert_idx]`
1715    ///   input       : [M, K]                — per-token activations
1716    ///   weight      : [num_experts, K, N]   — stacked expert weights
1717    ///   expert_idx  : \[M\]                   — f32-encoded expert id per token
1718    /// Output         : [M, N]                — output\[i\] = input\[i\] @ weight[expert_idx\[i\]]
1719    /// CPU is a real segmented GEMM: counting-sort tokens by expert, one GEMM per
1720    /// expert, then unpermute. GPU backends dispatch a dedicated grouped kernel.
1721    GroupedMatMul,
1722
1723    /// Fused GGUF K-quant dequant + [`Op::GroupedMatMul`]. Same three
1724    /// inputs as `GroupedMatMul`, but `weight` is a U8 tensor holding
1725    /// `num_experts` contiguous packed expert slabs (GGML layout, expert
1726    /// dimension outermost). Scales live inside the packed bytes.
1727    DequantGroupedMatMul {
1728        scheme: crate::quant::QuantScheme,
1729    },
1730
1731    /// MLX-affine grouped expert matmul — the [`Op::DequantGroupedMatMul`]
1732    /// analogue for mlx-community MoE, whose experts carry SEPARATE `scales`
1733    /// and `biases` tensors (not embedded in the packed bytes). Five inputs:
1734    ///   input   : [M, K]                       — one row per token
1735    ///   weight  : [E · out · (K·bits/32)] U8   — stacked affine code words
1736    ///   scales  : [E, out, n_groups] f32       — per-expert per-group scales
1737    ///   biases  : [E, out, n_groups] f32       — per-expert per-group biases
1738    ///   expert_idx : \[M\]                       — f32-encoded expert id per row
1739    /// Output    : [M, out]                     — output\[i\] = input\[i\] @ dequant(expert_idx\[i\])
1740    DequantGroupedMatMulMlx {
1741        scheme: crate::quant::QuantScheme,
1742    },
1743
1744    /// Dequant a packed MoE expert stack to F32 `[num_experts, K, N]` in
1745    /// GroupedMatMul layout. Input: U8 packed bytes; output shape is
1746    /// declared on the node (`[E, K, N]`).
1747    DequantMoEWeights {
1748        scheme: crate::quant::QuantScheme,
1749    },
1750
1751    /// **Native low-precision *grouped* (MoE) tensor-core GEMM** — the
1752    /// expert-indexed analogue of [`Op::ScaledMatMul`]. Both operands are
1753    /// sub-f16 [`crate::ScaledFormat`] codes fed directly into a segmented
1754    /// GEMM with per-block (`scale_layout`) rescaling, so the MXFP4 path
1755    /// (`F4E2M1` + `BlockMxE8M0`) wins on Blackwell / CDNA4 (native FP4
1756    /// tensor cores) while every other backend runs the decode-and-segment
1757    /// *reference* (see the CPU oracle and `LowerScaledGroupedMatMul`).
1758    ///
1759    /// Layout is **TN** per expert (`out = input · weight[e]ᵀ`) so every
1760    /// block scale runs along the K/contraction axis, matching `ScaledMatMul`.
1761    ///
1762    /// Inputs (in order):
1763    ///   `input      [M, K]`            U8 — packed codes (per-token acts)
1764    ///   `weight     [E, N, K]`         U8 — packed codes, one slab per expert (K-last)
1765    ///   `input_s    [M, ⌈K/block⌉]`    scale tensor, dtype per [`crate::ScaleLayout::scale_dtype`]
1766    ///   `weight_s   [E, N, ⌈K/block⌉]` per-expert scale tensor
1767    ///   `expert_idx [M]`               f32-encoded expert id per token
1768    ///   `bias       [E, N]`            f32, present iff `has_bias` (per-expert)
1769    /// Output: `[M, N]` f32. `out[i] = decode(input[i])·decode(weight[eidx[i]])ᵀ`.
1770    ///
1771    /// Output shape `[M, N]` is derived from `input`/`weight` in `infer_shape`.
1772    ScaledGroupedMatMul {
1773        lhs_format: crate::quant::ScaledFormat,
1774        rhs_format: crate::quant::ScaledFormat,
1775        scale_layout: crate::quant::ScaleLayout,
1776        has_bias: bool,
1777    },
1778
1779    /// Scatter-add into a destination tensor. The "unpermute" half of
1780    /// MoE routing (also useful for embedding gradient updates).
1781    /// Inputs: `[updates, indices]`
1782    ///   updates : [num_updates, trailing]   — values to add
1783    ///   indices : \[num_updates\]             — f32-encoded destination row
1784    /// Output    : [out_dim, trailing]       — output[indices\[i\]] += updates\[i\]
1785    /// `out_dim` is taken from the node's declared output shape.
1786    /// Initial output is zero; multiple updates to the same row
1787    /// accumulate (sequentially on CPU; with atomic-add on Metal).
1788    ScatterAdd,
1789
1790    /// ONNX ScatterND: copy `data`, then write `updates` at multi-index
1791    /// locations from `indices`.
1792    /// Inputs: `[data, indices, updates]`
1793    ///   data    : rank `r` tensor (output shape matches)
1794    ///   indices : rank `q`, trailing dim `k` indexes the first `k` axes of data
1795    ///   updates : shape `indices.shape[:-1] + data.shape[k:]`
1796    /// `reduction` selects overwrite vs accumulate (see [`ScatterNdReduction`]).
1797    ScatterNd {
1798        reduction: ScatterNdReduction,
1799    },
1800
1801    /// ONNX ScatterElements: per-element scatter along `axis`.
1802    /// Inputs: `[data, indices, updates]` — output shape matches `data`.
1803    /// `indices` and `updates` share rank with `data`; along `axis` they
1804    /// select which coordinate to write.
1805    ScatterElements {
1806        axis: i32,
1807        reduction: ScatterNdReduction,
1808    },
1809
1810    /// ONNX GatherND: gather slices from `data` indexed by the trailing
1811    /// axis of `indices`. Inputs: `[data, indices]`.
1812    /// `batch_dims` (default 0) skips leading shared batch axes.
1813    GatherNd {
1814        batch_dims: i32,
1815    },
1816
1817    /// ONNX GatherElements / `take_along_axis`: output shape = `indices`
1818    /// shape; each output element is `data` with the `axis` coordinate
1819    /// replaced by the corresponding index. Inputs: `[data, indices]`.
1820    GatherElements {
1821        axis: i32,
1822    },
1823
1824    // ── Convolution ─────────────────────────────────────────────
1825    /// 2D convolution on NCHW tensors. Also exposed as [`OpKind::Conv`] / `conv2d`.
1826    /// Weight layout: `[C_out, C_in / groups, kH, kW]`.
1827    Conv {
1828        kernel_size: Vec<usize>,
1829        stride: Vec<usize>,
1830        padding: Vec<usize>,
1831        dilation: Vec<usize>,
1832        groups: usize,
1833    },
1834
1835    /// NCHW im2col for conv backward-weight style matmul.
1836    /// Input `[N, C, H, W]`. Output `[M, C·kH·kW]` with
1837    /// `M = N · H_out · W_out`. When batch is [`dynamic::sym::BATCH`],
1838    /// output rows use [`dynamic::sym::ROWS`] (bind `N · H_out · W_out`).
1839    Im2Col {
1840        kernel_size: Vec<usize>,
1841        stride: Vec<usize>,
1842        padding: Vec<usize>,
1843        dilation: Vec<usize>,
1844    },
1845
1846    /// 2D transposed convolution on NCHW. Weight layout (PyTorch):
1847    /// `[C_in, C_out / groups, kH, kW]`.
1848    ConvTranspose2d {
1849        kernel_size: Vec<usize>,
1850        stride: Vec<usize>,
1851        padding: Vec<usize>,
1852        dilation: Vec<usize>,
1853        output_padding: Vec<usize>,
1854        groups: usize,
1855    },
1856
1857    /// 3D convolution on NCDHW tensors (forward / cross-correlation).
1858    /// Weight layout: `[C_out, C_in / groups, kD, kH, kW]`. Kernel size is
1859    /// derived from the weight shape. Mirrors [`Op::Conv`] with a depth axis.
1860    Conv3d {
1861        stride: [usize; 3],
1862        padding: [usize; 3],
1863        dilation: [usize; 3],
1864        groups: usize,
1865    },
1866
1867    /// 3D transposed convolution on NCDHW. Weight layout (PyTorch):
1868    /// `[C_in, C_out / groups, kD, kH, kW]`. Mirrors [`Op::ConvTranspose2d`].
1869    ConvTranspose3d {
1870        stride: [usize; 3],
1871        padding: [usize; 3],
1872        dilation: [usize; 3],
1873        output_padding: [usize; 3],
1874        groups: usize,
1875    },
1876
1877    // ── Pooling ─────────────────────────────────────────────────
1878    Pool {
1879        kind: ReduceOp,
1880        kernel_size: Vec<usize>,
1881        stride: Vec<usize>,
1882        padding: Vec<usize>,
1883    },
1884
1885    // ── Backward / training ops ─────────────────────────────────
1886    //
1887    // Closed-form gradient nodes emitted by `rlx-opt::autodiff`.
1888    // Pairing a forward op with a dedicated backward op (instead of
1889    // composing it from primitives) keeps the gradient kernel simple
1890    // and lets the backend recompute argmax / masks / softmax inline.
1891    /// ReLU backward: `dx = dy where x > 0 else 0`.
1892    /// Inputs: `[x, dy]` — both same shape; output matches.
1893    ReluBackward,
1894
1895    /// Element-wise complex squared-magnitude: `|z|² = z.re² + z.im²`.
1896    /// Input: 1 tensor with `DType::C64`. Output: same shape but
1897    /// `DType::F32`. The natural real-valued loss surface for
1898    /// Wirtinger reverse-mode AD on complex graphs — pair with
1899    /// [`Op::ComplexNormSqBackward`].
1900    ComplexNormSq,
1901
1902    /// Element-wise complex conjugate: `z̄ = z.re - i·z.im` per element.
1903    /// Input: 1 tensor with `DType::C64`. Output: same shape, same dtype.
1904    /// Used by Wirtinger VJP rules on `Op::Binary` over C64 (the rule
1905    /// for `y = a·b` is `dL/dā = upstream · conj(b)`, etc.).
1906    Conjugate,
1907
1908    /// Backward for [`Op::ComplexNormSq`] under Wirtinger calculus.
1909    /// `f(z) = |z|² = z·z̄`, so `∂f/∂z̄ = z`. Given upstream real
1910    /// cotangent `g` (same shape as the forward output), the C64
1911    /// gradient with respect to `z` is `g · z` element-wise, returned
1912    /// in C64 storage `[re_g·re_z, re_g·im_z]` per element.
1913    ///
1914    /// Inputs: `[z (C64), g (F32)]` — both same logical shape; output
1915    /// matches `z` (C64).
1916    ComplexNormSqBackward,
1917
1918    /// LayerNorm backward w.r.t. the input. Computes
1919    ///   `d_x[..., d] = inv_std · (dy·γ - mean(dy·γ) - x̂·mean(dy·γ·x̂))`
1920    /// over the feature axis, where `x̂ = (x - mean)/std` is recomputed
1921    /// inline from `x`. Inputs: `[x, gamma, dy]`; output shape = `x.shape`.
1922    /// Currently lowers axis=-1 only (matches the forward thunk).
1923    LayerNormBackwardInput {
1924        axis: i32,
1925        eps: f32,
1926    },
1927
1928    /// LayerNorm backward w.r.t. gamma. Computes
1929    ///   `d_gamma[d] = Σ_{batch} dy[..., d] · x̂[..., d]`
1930    /// — sums the per-element product of upstream and the (recomputed)
1931    /// normalized input over the leading axes. Inputs: `[x, dy]`;
1932    /// output shape = `gamma.shape` (= 1-D feature axis).
1933    LayerNormBackwardGamma {
1934        axis: i32,
1935        eps: f32,
1936    },
1937
1938    /// RMSNorm backward w.r.t. input. Inputs `[x, gamma, beta, dy]`; output = `x.shape`.
1939    RmsNormBackwardInput {
1940        axis: i32,
1941        eps: f32,
1942    },
1943
1944    /// RMSNorm backward w.r.t. gamma. Inputs `[x, gamma, beta, dy]`; output = `gamma.shape`.
1945    RmsNormBackwardGamma {
1946        axis: i32,
1947        eps: f32,
1948    },
1949
1950    /// RMSNorm backward w.r.t. beta. Inputs `[x, gamma, beta, dy]`; output = `beta.shape`.
1951    RmsNormBackwardBeta {
1952        axis: i32,
1953        eps: f32,
1954    },
1955
1956    /// RoPE backward w.r.t. `x`. Inputs `[dy, cos, sin]`; output = `dy.shape`.
1957    RopeBackward {
1958        head_dim: usize,
1959        n_rot: usize,
1960    },
1961
1962    /// GroupNorm (NCHW) backward w.r.t. input. Inputs `[x, gamma, beta, dy]`.
1963    GroupNormBackwardInput {
1964        num_groups: usize,
1965        eps: f32,
1966    },
1967
1968    /// GroupNorm backward w.r.t. gamma. Inputs `[x, dy]`; output = `gamma.shape`.
1969    GroupNormBackwardGamma {
1970        num_groups: usize,
1971        eps: f32,
1972    },
1973
1974    /// GroupNorm backward w.r.t. beta. Inputs `[x, dy]`; output = `beta.shape`.
1975    GroupNormBackwardBeta {
1976        num_groups: usize,
1977        eps: f32,
1978    },
1979
1980    /// BatchNorm inference backward w.r.t. `x`. Inputs `[x, gamma, mean, var, dy]`.
1981    BatchNormInferenceBackwardInput {
1982        eps: f32,
1983    },
1984
1985    /// BatchNorm inference backward w.r.t. `gamma`. Inputs `[x, mean, var, dy]`.
1986    BatchNormInferenceBackwardGamma {
1987        eps: f32,
1988    },
1989
1990    /// BatchNorm inference backward w.r.t. `beta`. Inputs `[dy]`; output = `beta.shape`.
1991    BatchNormInferenceBackwardBeta,
1992
1993    /// Cumsum backward along `axis`. Inputs `[dy]`; output matches forward input shape.
1994    CumsumBackward {
1995        axis: i32,
1996        exclusive: bool,
1997    },
1998
1999    /// Gather backward (scatter-add into table). Inputs `[dy, indices]`; output = table shape.
2000    /// `axis` matches forward [`Op::Gather`].
2001    GatherBackward {
2002        axis: i32,
2003    },
2004
2005    /// Generic element-wise activation backward. `kind` selects the
2006    /// closed-form derivative `d/dx act(x)`. Inputs: `[x, dy]`; output
2007    /// shape matches `x`. The kernel computes `d/dx · dy` per element.
2008    ///
2009    /// Closed forms (all element-wise):
2010    /// * `Gelu`     — exact derivative of erf-based GELU.
2011    /// * `GeluApprox` — derivative of the tanh approximation
2012    ///   `0.5 x (1 + tanh(√(2/π)(x + 0.044715 x³)))`.
2013    /// * `Silu`     — `σ(x)·(1 + x·(1 - σ(x)))`.
2014    /// * `Sigmoid`  — `σ(x)·(1 - σ(x))`.
2015    /// * `Tanh`     — `1 - tanh(x)²`.
2016    /// * `Exp`      — `exp(x)`.
2017    /// * `Log`      — `1 / x`.
2018    /// * `Sqrt`     — `0.5 / sqrt(x)`.
2019    /// * `Rsqrt`    — `-0.5 · x^(-3/2)`.
2020    /// * `Neg`      — `-1`.
2021    /// * `Abs`      — `sign(x)` (zero at x=0).
2022    /// * `Sin`      — `cos(x)`.
2023    /// * `Cos`      — `-sin(x)`.
2024    /// * `Tan`      — `1 + tan²(x)`.
2025    /// * `Atan`     — `1 / (1 + x²)`.
2026    /// * `Relu`     — kept here for completeness; the dedicated
2027    ///   `ReluBackward` op is preferred for relu and is what the
2028    ///   autodiff pass actually emits.
2029    ActivationBackward {
2030        kind: Activation,
2031    },
2032
2033    /// Backward for `Op::FakeQuantize` under a non-default STE.
2034    /// Inputs `[x, dy]`: the forward input and the upstream
2035    /// gradient. Output `dx` same shape. The `bits`/`axis`/`ste`
2036    /// fields must match the forward op so the kernel computes the
2037    /// same per-channel scale and applies the right STE attenuation.
2038    /// For `SteKind::Identity` this op is unnecessary — autodiff
2039    /// just routes `upstream` through unchanged.
2040    FakeQuantizeBackward {
2041        bits: u8,
2042        axis: Option<usize>,
2043        ste: SteKind,
2044    },
2045
2046    /// 2D max-pool backward. Routes each element of `dy` back into the
2047    /// position in `x`'s window where the forward max was taken.
2048    /// Inputs: `[x, dy]` with `x [N, C, H, W]` and
2049    /// `dy [N, C, H_out, W_out]`. Output: same shape as `x`.
2050    /// Carries the forward pool's geometry so the kernel can recompute
2051    /// the argmax position per window without a saved-indices tensor.
2052    MaxPool2dBackward {
2053        kernel_size: Vec<usize>,
2054        stride: Vec<usize>,
2055        padding: Vec<usize>,
2056    },
2057
2058    /// 2D conv backward w.r.t. input. Computes `dx = conv_transpose(dy, w)`.
2059    /// Inputs: `[dy, w]` with `dy [N, C_out, H_out, W_out]` and
2060    /// `w [C_out, C_in/groups, kH, kW]`. Output: `[N, C_in, H, W]`
2061    /// (declared on the node — caller knows the original input shape).
2062    /// Geometry is the forward conv's parameters, not the transposed
2063    /// conv's.
2064    Conv2dBackwardInput {
2065        kernel_size: Vec<usize>,
2066        stride: Vec<usize>,
2067        padding: Vec<usize>,
2068        dilation: Vec<usize>,
2069        groups: usize,
2070    },
2071
2072    /// 2D conv backward w.r.t. weight. Computes
2073    /// `dw[c_out, c_in, kh, kw] = sum_{n,h_out,w_out} x[n,c_in,...] * dy[n,c_out,h_out,w_out]`.
2074    /// Inputs: `[x, dy]`. Output: `[C_out, C_in/groups, kH, kW]`.
2075    Conv2dBackwardWeight {
2076        kernel_size: Vec<usize>,
2077        stride: Vec<usize>,
2078        padding: Vec<usize>,
2079        dilation: Vec<usize>,
2080        groups: usize,
2081    },
2082
2083    /// 3D max-pool backward on NCDHW. Inputs: `[x, dy]`. Output: same as `x`.
2084    MaxPool3dBackward {
2085        kernel_size: Vec<usize>,
2086        stride: Vec<usize>,
2087        padding: Vec<usize>,
2088    },
2089
2090    /// 3D conv backward w.r.t. input (NCDHW). Inputs: `[dy, w]`.
2091    Conv3dBackwardInput {
2092        kernel_size: Vec<usize>,
2093        stride: Vec<usize>,
2094        padding: Vec<usize>,
2095        dilation: Vec<usize>,
2096        groups: usize,
2097    },
2098
2099    /// 3D conv backward w.r.t. weight (NCDHW). Inputs: `[x, dy]`.
2100    Conv3dBackwardWeight {
2101        kernel_size: Vec<usize>,
2102        stride: Vec<usize>,
2103        padding: Vec<usize>,
2104        dilation: Vec<usize>,
2105        groups: usize,
2106    },
2107
2108    /// Fused softmax + cross-entropy against a **dense** target
2109    /// distribution (soft labels / one-hot probabilities) — the
2110    /// companion to the sparse integer-label
2111    /// [`Op::SoftmaxCrossEntropyWithLogits`]. Per-row output:
2112    /// `loss[n] = logsumexp(logits[n]) - Σ_c targets[n,c]·logits[n,c]`
2113    ///         = -Σ_c targets[n,c]·log_softmax(logits[n])[c]`.
2114    /// Inputs: `[logits, targets]`, both `[N, C]`. Output: `[N]`.
2115    /// Caller does the `Reduce::Mean` if they want a scalar.
2116    /// Numerically stable (max-subtract before exp). Used for label
2117    /// smoothing and knowledge distillation where targets are full
2118    /// probability rows rather than class indices.
2119    SoftmaxCrossEntropy,
2120
2121    /// Fused softmax + cross-entropy loss with integer (f32-encoded)
2122    /// targets — the standard classification loss. Per-row output:
2123    /// `loss[n] = -log(softmax(logits[n])[labels[n]])`.
2124    /// Inputs: `[logits, labels]` with `logits [N, C]` and
2125    /// `labels [N]` (f32-encoded class indices). Output: `[N]`.
2126    /// Caller does the `Reduce::Mean` if they want a scalar.
2127    SoftmaxCrossEntropyWithLogits,
2128
2129    /// Backward of the fused loss above. Emits
2130    /// `dlogits[n,c] = (softmax(logits[n])[c] - one_hot(labels)[n,c]) * d_loss[n]`.
2131    /// Inputs: `[logits, labels, d_loss]`. Output: `[N, C]` (same shape
2132    /// as `logits`). Recomputes the softmax inline rather than threading
2133    /// it through from the forward node.
2134    SoftmaxCrossEntropyBackward,
2135
2136    /// Backward of [`Op::Attention`]. Recomputes scaled `QK^T`, applies
2137    /// the same `mask_kind` as the forward op, softmaxes scores, then
2138    /// emits **one** of `dQ`, `dK`, or `dV` selected by [`AttentionBwdWrt`].
2139    /// Autodiff emits three nodes (one per `wrt`) so each output shape
2140    /// stays a normal single-output MIR node.
2141    ///
2142    /// Inputs: `[q, k, v, dy]` plus optional mask when `mask_kind` is
2143    /// [`MaskKind::Custom`] or [`MaskKind::Bias`] (same convention as
2144    /// forward). Output shape matches `q`, `k`, or `v` respectively.
2145    AttentionBackward {
2146        num_heads: usize,
2147        head_dim: usize,
2148        mask_kind: MaskKind,
2149        wrt: AttentionBwdWrt,
2150    },
2151
2152    /// Fused backward of [`Op::Attention`] computing dQ, dK **and** dV from a
2153    /// single score+softmax recompute (the three [`Op::AttentionBackward`]
2154    /// siblings share `q/k/v/dy`, so recomputing scores 3× is wasteful). Created
2155    /// by a backend-gated fusion pass ([`OpKind::AttentionBackwardAll`]). The
2156    /// packed output is `[3·B, S, H, D]` (dQ‖dK‖dV stacked on the batch axis);
2157    /// three axis-0 `Narrow`s recover the individual gradients. Self-attention
2158    /// only (`q_seq == k_seq`). Inputs match [`Op::AttentionBackward`]:
2159    /// `[q, k, v, dy]` plus an optional mask.
2160    AttentionBackwardAll {
2161        num_heads: usize,
2162        head_dim: usize,
2163        mask_kind: MaskKind,
2164    },
2165
2166    // ── Fused operations (created by optimization passes) ──────
2167    /// Fused matmul + bias + activation. Created from MatMul → Add → Activation.
2168    FusedMatMulBiasAct {
2169        activation: Option<Activation>,
2170    },
2171
2172    /// Fused matmul + full-tensor residual add: `matmul(lhs, rhs) + residual`,
2173    /// single output (same shape as the matmul result). Created from
2174    /// `Add(MatMul(a, b), residual)` when the MatMul feeds only the Add — the
2175    /// transformer's `add(skip, o_proj)` / `add(h, down_proj)` residuals. Unlike
2176    /// [`Op::FusedMatMulBiasAct`] the third input is a *full* `[m, n]` activation
2177    /// tensor (the residual/skip), not a rank-1 broadcast bias.
2178    ///
2179    /// Emitted only for backends that implement the fused epilogue (today:
2180    /// Metal, which folds the add into the sgemm store to save one dispatch on
2181    /// the launch-latency-bound decode). Every other backend never runs the
2182    /// fusion pass (gated by `supports_op`) and decomposes it in `unfuse` back
2183    /// to MatMul + Add, so emitting it is always safe.
2184    ///
2185    /// Inputs: `[lhs, rhs, residual]`. Output: `[m, n]`.
2186    FusedMatMulResidual,
2187
2188    /// Fused convolution + bias + activation. Created from
2189    /// `Conv → Reshape(bias→[1,C,1,1]) → Expand → Add → [Activation]`.
2190    ///
2191    /// Inputs: `[input, weight, bias]` — bias is rank-1 `[C_out]`, mirroring
2192    /// [`Op::QConv2d`]'s bundled-bias convention (not a separate broadcast Add).
2193    /// Fields mirror [`Op::Conv`] plus the epilogue activation.
2194    ///
2195    /// Only backends that can apply the whole epilogue in one dispatch claim
2196    /// this op — today CUDA. Every other backend decomposes it back to
2197    /// primitives in `unfuse`, so the fusion is always safe to emit. On CUDA it
2198    /// lowers to cuDNN's fused `cudnnConvolutionBiasActivationForward`. The
2199    /// fusion pass only emits it for cuDNN-friendly shapes with an
2200    /// identity/ReLU epilogue (the combination that benchmarks faster than
2201    /// unfused); the `conv_bias_act_epilogue` kernel exists only as a
2202    /// correctness fallback when libcudnn is absent at runtime.
2203    ///
2204    /// `has_residual`: when true a 4th input `residual` (full `[N,C,H,W]` tensor)
2205    /// is added before the activation — `act(conv + bias + residual)` — mapping
2206    /// to cuDNN's `z` operand. Used for ResNet `conv→bias→add(residual)→relu`.
2207    FusedConvBiasAct {
2208        kernel_size: Vec<usize>,
2209        stride: Vec<usize>,
2210        padding: Vec<usize>,
2211        dilation: Vec<usize>,
2212        groups: usize,
2213        activation: Option<Activation>,
2214        has_residual: bool,
2215    },
2216
2217    /// Fused residual + optional bias + layer norm.
2218    /// Created from Add(x, residual) → [Add(bias)] → LayerNorm.
2219    FusedResidualLN {
2220        has_bias: bool,
2221        eps: f32,
2222    },
2223
2224    /// Fused residual + optional bias + RMS norm.
2225    /// Created from Add(x, residual) → [Add(bias)] → RmsNorm.
2226    FusedResidualRmsNorm {
2227        has_bias: bool,
2228        eps: f32,
2229    },
2230
2231    /// Fused SwiGLU: split input into up/gate halves, silu(gate) * up.
2232    /// Created from Split → Silu → Mul when fed by a concatenated matmul.
2233    ///
2234    /// `cast_to`: optional output dtype — when `Some(dt)` the kernel casts
2235    /// its result from the input dtype to `dt` in-register, saving a
2236    /// separate cast pass. Reserved for future fp8/fp4 quantization paths;
2237    /// for f32→f16 mixed precision the AutoMixedPrecision pass already
2238    /// inserts a Cast node so this stays `None` in current pipelines.
2239    FusedSwiGLU {
2240        cast_to: Option<DType>,
2241        /// When `true`, the concatenated input stores gate in the low half
2242        /// `[..., 0..N)` and up in the high half `[..., N..2N)` — the layout
2243        /// produced when gate projection is emitted before up in the builder.
2244        /// Default `false`: up @ low, gate @ high (canonical concat order).
2245        gate_first: bool,
2246    },
2247
2248    /// Fused full transformer layer: attention block + residual+LN + FFN + residual+LN.
2249    /// All intermediates resident in registers/threadgroup memory; one kernel
2250    /// per layer instead of ~30 (the CPU's batch=1 win, lifted to IR so any
2251    /// backend can implement it as a monolithic kernel).
2252    ///
2253    /// Inputs: hidden, qkv_w, qkv_b, out_w, out_b,
2254    ///         ln1_g, ln1_b, fc1_w, fc1_b, fc2_w, fc2_b, ln2_g, ln2_b, mask
2255    /// Output: same shape as hidden.
2256    ///
2257    /// **Backend status:** same as FusedAttentionBlock. CPU implements
2258    /// the L1-cache-resident merge at the thunk level. Metal deferred —
2259    /// requires a single MSL kernel for the whole layer to actually
2260    /// beat the unfused path. Multi-day work; revisit when there's a
2261    /// model whose Metal inference is bottlenecked here rather than on
2262    /// the wait latency floor.
2263    FusedTransformerLayer {
2264        num_heads: usize,
2265        head_dim: usize,
2266        intermediate_size: usize,
2267        eps1: f32,
2268        eps2: f32,
2269        activation: Activation,
2270        has_bias: bool,
2271    },
2272
2273    /// Fused attention block: QKV projection → split → \[RoPE\] → SDPA → output projection.
2274    /// Created by FuseAttentionBlock pass when batch*seq is small.
2275    /// All intermediates stay in L1 cache — no arena writes between ops.
2276    ///
2277    /// Inputs (in order):
2278    ///   hidden, qkv_w, out_w, mask,
2279    ///   [qkv_b, out_b]      if has_bias,
2280    ///   [rope_cos, rope_sin] if has_rope
2281    ///
2282    /// **Backend status (Phase C finalize):**
2283    ///   CPU  — implemented at the *thunk* level: the CPU schedule
2284    ///          recognizes the multi-thunk pattern and merges into
2285    ///          a single FusedAttnBlock that keeps Q/K/V in stack
2286    ///          buffers across stages (the L1-cache win).
2287    ///   Metal — **deferred**. A dispatch-wrapper version (chaining
2288    ///          existing kernels) buys nothing the unfused Metal path
2289    ///          doesn't already get, since per-run cost is dominated
2290    ///          by `wait_until_completed` (~150 µs), not encode. The
2291    ///          real win is a single MSL kernel keeping Q/K/V in
2292    ///          threadgroup memory across stages — multi-day work.
2293    ///          Until then, Metal runs the unfused chain (one matmul,
2294    ///          three narrows, two ropes, attention, one matmul) — all
2295    ///          covered in op_coverage and parity_harness.
2296    FusedAttentionBlock {
2297        num_heads: usize,
2298        head_dim: usize,
2299        has_bias: bool,
2300        has_rope: bool,
2301    },
2302
2303    // ── Control flow (subgraphs as op payloads) ─────────────────
2304    //
2305    // Status: IR is defined; helper `run_if` / `run_while` exist in
2306    // rlx-runtime/src/subgraph.rs; **executor wiring is not yet
2307    // implemented** (both CPU thunk and Metal thunk fall through to
2308    // `Thunk::Nop` for these ops). Wiring requires:
2309    //   1. Recursive subgraph compile at parent-compile time.
2310    //   2. Per-subgraph input/output binding through the arena.
2311    //   3. Schedule-level dispatch when the predicate / loop cond is
2312    //      resolved at runtime.
2313    // Estimate: 4–6 hours of focused work + parity tests. Deferred
2314    // because no current in-tree model uses these ops;
2315    // surface area without a validation target invites silent bugs.
2316    /// Conditional: pick between two subgraphs based on a boolean predicate.
2317    /// Inputs: [predicate, ...captures (used inside both branches)].
2318    /// `then_branch` and `else_branch` are sub-graphs that share the
2319    /// captured inputs and must produce identically-shaped outputs.
2320    /// Used for: shape-dependent execution, batched inference of
2321    /// dynamic-length sequences with padding masks.
2322    If {
2323        then_branch: Box<crate::Graph>,
2324        else_branch: Box<crate::Graph>,
2325    },
2326
2327    /// Loop: iterate `body` while `cond` evaluates true.
2328    /// Inputs: [...initial loop-carried values].
2329    /// `cond`'s single output is a Bool scalar.
2330    /// `body`'s outputs become the next iteration's loop-carried inputs.
2331    /// Outputs of While are the values after the final iteration.
2332    /// Used for: KV-cache-driven autoregressive generation, beam search.
2333    While {
2334        cond: Box<crate::Graph>,
2335        body: Box<crate::Graph>,
2336        max_iterations: Option<usize>,
2337    },
2338
2339    /// Bounded-length loop with a fixed-shape carry, optional per-step
2340    /// inputs, and optional stacked output. Mirrors JAX's `lax.scan`.
2341    ///
2342    /// Body signature: `(carry, x_t_0, ..., x_t_{num_xs-1}) → carry_next`
2343    /// — `1 + num_xs` Op::Inputs in NodeId construction order (first
2344    /// declared is the carry; the remaining `num_xs` are per-step
2345    /// slices). Single output (the next carry).
2346    ///
2347    /// Outer Op::Scan inputs (in order):
2348    ///   `[init_carry, xs_0, xs_1, ..., xs_{num_xs-1}]`
2349    /// Each `xs_i` has shape `[length, *per_step_shape_i]`; the body
2350    /// sees `xs_i[t]` (a `per_step_shape_i` slice) on iteration `t`.
2351    ///
2352    /// Outer Op::Scan output:
2353    ///   * `save_trajectory == false` — final carry, shape `*carry`.
2354    ///   * `save_trajectory == true`  — stacked trajectory of carries,
2355    ///     shape `[length, *carry]`. Row `t` is the carry after step
2356    ///     `t+1`, so row `length-1` matches the no-trajectory case.
2357    ///
2358    /// Mirrors JAX's `lax.scan`. Common uses include time-stepping
2359    /// integrators with time-varying drives, Mamba-style SSM scans
2360    /// reading per-step inputs, and RNN-style sequence processing.
2361    Scan {
2362        body: Box<crate::Graph>,
2363        length: u32,
2364        save_trajectory: bool,
2365        /// Number of "broadcast" inputs — values that are constant
2366        /// across iterations. Outer scan inputs in order:
2367        ///   `[init, bcast_0..bcast_{B-1}, xs_0..xs_{X-1}]`
2368        /// Body Op::Inputs in NodeId order:
2369        ///   `[carry, bcast_0..bcast_{B-1}, x_t_0..x_t_{X-1}]`
2370        /// CPU executor fills bcast slots ONCE before the iteration
2371        /// loop (xs slots are filled per-step). The reverse-mode AD
2372        /// pre-pass materialises each bcast into an xs of shape
2373        /// `[length, *bcast]` via broadcast `Mul` so the rest of the
2374        /// VJP / executor pipeline can stay unchanged. `0` (default)
2375        /// keeps the original carry+xs scan shape.
2376        num_bcast: u32,
2377        /// Number of per-step `xs` inputs. Total outer Op::Scan
2378        /// inputs is `1 + num_bcast + num_xs`.
2379        num_xs: u32,
2380        /// Number of trajectory checkpoints when `save_trajectory ==
2381        /// true`. `0` means "save all `length` rows" (default). A
2382        /// positive value `K` means save only `K` evenly-spaced rows
2383        /// at indices `floor(t * length / K)` for `t in 0..K`. Used
2384        /// by recursive checkpointed AD: store O(√T) carries during
2385        /// forward, recompute the rest in the backward pass.
2386        ///
2387        /// When `0` (or `K == length`), the saved trajectory has
2388        /// shape `[length, *carry]` — same as the original behavior.
2389        /// When `0 < K < length`, the saved trajectory has shape
2390        /// `[K, *carry]`.
2391        num_checkpoints: u32,
2392    },
2393
2394    /// Reverse-mode AD companion to `Op::Scan` — extracts the carry
2395    /// gradient `dinit`. Walks `t = length-1 .. 0`, applying `body_vjp`
2396    /// to thread `dcarry` back through the time loop.
2397    ///
2398    /// Inputs (in order):
2399    ///   `[init, trajectory, upstream, xs_0, ..., xs_{num_xs-1}]`
2400    /// Output: `dinit`, shape = carry shape.
2401    ///
2402    /// `body_vjp` is the result of
2403    /// `autodiff::grad(body, [carry_id, xs_0_id, ..., xs_{num_xs-1}_id])`
2404    /// — a graph with `1 + num_xs + 1` Inputs (carry + x_t_i for each
2405    /// xs + `"d_output"`) and `1 + num_xs` outputs
2406    /// (dcarry + dx_t_i for each xs). This op reads `outputs[0]` =
2407    /// dcarry; the sibling [`Self::ScanBackwardXs`] reads the
2408    /// `outputs[1 + xs_idx]` slot for each xs gradient.
2409    ScanBackward {
2410        body_vjp: Box<crate::Graph>,
2411        length: u32,
2412        save_trajectory: bool,
2413        num_xs: u32,
2414        /// When `0` or equal to `length`, the trajectory input has
2415        /// shape `[length, *carry]` — every step's carry is cached
2416        /// (`CheckpointStrategy::All`). When `0 < K < length`, the
2417        /// trajectory input has shape `[K, *carry]` and the executor
2418        /// recomputes intermediate carries via `forward_body` between
2419        /// checkpoints. `forward_body` must be `Some` whenever this
2420        /// is < length.
2421        num_checkpoints: u32,
2422        /// Forward body (the same `body` from the forward Op::Scan).
2423        /// Required when `num_checkpoints > 0 && < length` so the
2424        /// executor can recompute carries between saved checkpoints.
2425        /// `None` for the All strategy (no recompute needed).
2426        forward_body: Option<Box<crate::Graph>>,
2427    },
2428
2429    /// Companion to [`Self::ScanBackward`] that extracts one stacked
2430    /// per-step `dxs_i` (shape `[length, *per_step_xs_i]`). Same inputs
2431    /// and same `body_vjp` graph as ScanBackward — `xs_idx` selects
2432    /// which body_vjp output to stack into the result.
2433    ///
2434    /// Note: each ScanBackwardXs runs its own backward loop. A future
2435    /// optimization can fuse them into a single multi-output backward
2436    /// kernel; for now it's `1 + num_xs` independent sweeps.
2437    ScanBackwardXs {
2438        body_vjp: Box<crate::Graph>,
2439        length: u32,
2440        save_trajectory: bool,
2441        num_xs: u32,
2442        xs_idx: u32,
2443        num_checkpoints: u32,
2444        forward_body: Option<Box<crate::Graph>>,
2445    },
2446
2447    /// CPU reference 3D Gaussian splat forward render.
2448    ///
2449    /// Seven flat F32 inputs (scene buffers + camera/render meta):
2450    ///   0. positions `[N*3]`
2451    ///   1. scales `[N*3]` (log-space)
2452    ///   2. rotations `[N*4]` (xyzw)
2453    ///   3. opacities `[N]` (logit)
2454    ///   4. colors `[N*3]` (linear RGB)
2455    ///   5. sh_coeffs `[N * sh_coeff_count * 3]`
2456    ///   6. meta `[23]` — camera position/target/up/fov/near/far, background RGB,
2457    ///      then width/height/tile_size/radius_scale/alpha_cutoff/max_splat_steps/
2458    ///      transmittance_threshold/max_list_entries as f32 bit-patterns.
2459    ///
2460    /// Output: `[height * width * 4]` linear RGBA (display gamma baked in).
2461    /// Build via [`crate::Graph::gaussian_splat_render`].
2462    ///
2463    /// Differentiable backward is not implemented in v1; autodiff treats this
2464    /// op as non-differentiable (same as [`Op::Sample`]).
2465    GaussianSplatRender {
2466        width: u32,
2467        height: u32,
2468        tile_size: u32,
2469        radius_scale: f32,
2470        alpha_cutoff: f32,
2471        max_splat_steps: u32,
2472        transmittance_threshold: f32,
2473        max_list_entries: u32,
2474    },
2475
2476    /// Backward pass for [`Self::GaussianSplatRender`].
2477    ///
2478    /// Eight inputs: the same seven as forward plus `d_loss_rgba` `[W*H*4]`
2479    /// (only RGB channels are used). Re-runs the training forward internally.
2480    ///
2481    /// Output: packed gradients
2482    /// `[positions(3N) | scales(3N) | rotations(4N) | opacities(N) | colors(3N) | sh(N*sh*3)]`.
2483    /// Unpack via [`crate::ops::splat::unpack_gaussian_splat_packed_grads`].
2484    GaussianSplatRenderBackward {
2485        width: u32,
2486        height: u32,
2487        tile_size: u32,
2488        radius_scale: f32,
2489        alpha_cutoff: f32,
2490        max_splat_steps: u32,
2491        transmittance_threshold: f32,
2492        max_list_entries: u32,
2493        loss_grad_clip: f32,
2494        sh_band: u32,
2495        max_anisotropy: f32,
2496    },
2497
2498    /// Strict IR stage 1: project, bin, sort, build per-pixel rays.
2499    ///
2500    /// Seven inputs (same scene + meta as [`Self::GaussianSplatRender`]). Output: packed
2501    /// prepare buffer (see `rlx_splat::prep_layout::prep_packed_len`).
2502    GaussianSplatPrepare {
2503        width: u32,
2504        height: u32,
2505        tile_size: u32,
2506        radius_scale: f32,
2507        alpha_cutoff: f32,
2508        max_splat_steps: u32,
2509        transmittance_threshold: f32,
2510        max_list_entries: u32,
2511    },
2512
2513    /// Strict IR stage 2: tile raster from [`Self::GaussianSplatPrepare`] output.
2514    ///
2515    /// Inputs: `prep` packed buffer, `meta` `[23]`. Output: `[width * height * 4]` RGBA.
2516    GaussianSplatRasterize {
2517        width: u32,
2518        height: u32,
2519        tile_size: u32,
2520        alpha_cutoff: f32,
2521        max_splat_steps: u32,
2522        transmittance_threshold: f32,
2523        max_list_entries: u32,
2524    },
2525
2526    /// User-registered custom op. `name` keys into the
2527    /// [`crate::op_registry`] for shape inference, autodiff, and
2528    /// per-backend execution. `attrs` is an opaque blob passed
2529    /// through to those callbacks (FFT direction, SparseLU
2530    /// reordering strategy, etc.). `num_inputs` is captured at
2531    /// construction time so [`Op::num_inputs`] stays infallible
2532    /// without a registry lookup. Build via [`crate::Graph::custom_op`].
2533    Custom {
2534        name: String,
2535        num_inputs: u32,
2536        attrs: Vec<u8>,
2537    },
2538
2539    /// 1D Fast Fourier Transform along the last axis.
2540    ///
2541    /// **Layouts**
2542    /// - `F32` / `F64`: 2N real-block — last axis is `[re₀…re_{N-1}, im₀…im_{N-1}]`.
2543    /// - `C64`: interleaved `[re, im]` pairs per complex element along the last axis.
2544    ///
2545    /// **ND transforms** — use `Graph::fftn` / `Graph::ifftn`, which compose
2546    /// `fft_axis` (transpose → Fft → transpose). Multi-axis `fftn` requires
2547    /// `DType::C64`; the 2N-block layout describes a single complex axis.
2548    ///
2549    /// Default (`FftNorm::Backward`) is **unnormalized** on both directions:
2550    ///   `fft(x)[k] = Σ x[n]·exp(-2πi·nk/N)`
2551    ///   `ifft(y)[n] = Σ y[k]·exp(+2πi·nk/N)`
2552    /// so `ifft(fft(x)) = N·x`. Use `FftNorm::Forward` for gpu-fft-style
2553    /// `1/N` scaling on inverse, or `FftNorm::Ortho` for unitary scaling.
2554    ///
2555    /// AD: VJP(`fft`) = `ifft`, VJP(`ifft`) = `fft` when `norm=Backward`;
2556    /// other norms apply the chain rule via output scaling.
2557    Fft {
2558        inverse: bool,
2559        norm: crate::fft::FftNorm,
2560    },
2561
2562    /// Ternary pruned radix-2 butterfly stage on interleaved complex state.
2563    ///
2564    /// Inputs:
2565    ///   0 — state `[batch, n_fft, 2]` (re/im on axis 2)
2566    ///   1 — gate `[half]` — 0 = identity, 1 = run butterfly (`half = n_fft/2`)
2567    ///   2 — rev `[half]` — 0 = forward, 1 = swap outputs when gate=1
2568    ///   3 — tw_re `[half]`
2569    ///   4 — tw_im `[half]`
2570    ///
2571    /// Output: `[batch, n_fft, 2]` same layout. Slots with gate=0 copy inputs
2572    /// without twiddle math.
2573    FftButterflyStage {
2574        stage: u32,
2575        n_fft: u32,
2576    },
2577
2578    /// Log-mel spectrogram from RLX FFT block-layout spectrum.
2579    ///
2580    /// Inputs:
2581    ///   0 — spectrum `[..., 2*n_fft]` (re plane then im plane, same as `Op::Fft` output)
2582    ///   1 — mel filterbank `[n_mels, n_bins]` with `n_bins = n_fft/2 + 1`
2583    ///
2584    /// Output: `[..., n_mels]` with Whisper dynamic-range compression
2585    /// (`log10`, clamp to max−8 dB, `(x+4)/4`).
2586    LogMel,
2587
2588    /// VJP of [`Op::LogMel`] w.r.t. spectrum (input 0).
2589    ///
2590    /// Inputs: spectrum block, mel filters, upstream `dy`.
2591    /// Output: `d_spectrum` (same shape as input 0).
2592    LogMelBackward,
2593
2594    /// Top-K Welch peaks from block-layout segment spectra.
2595    ///
2596    /// Input 0: spectrum `[batch * n_segments, 2*n_fft]` (re ∥ im planes).
2597    /// Output: `[batch, k*2]` interleaved `(bin, power)` per spike.
2598    WelchPeaks {
2599        k: usize,
2600        n_segments: usize,
2601    },
2602
2603    /// User-defined sub-graph with optional override AD rules.
2604    /// Mirrors JAX's `custom_vjp` / `custom_jvp` decorators: the
2605    /// caller wraps a forward computation and supplies its own
2606    /// reverse- and/or forward-mode AD bodies. Useful when:
2607    ///   * The forward is iterative (Newton, fixed-point) and
2608    ///     differentiating through the loop is wasteful — the
2609    ///     vjp_body computes the implicit-function gradient at the
2610    ///     converged point in one shot.
2611    ///   * The math has a closed-form gradient that's much cheaper
2612    ///     than autodiff.
2613    ///   * The forward op is non-differentiable by tracing
2614    ///     (sampling, argmax) and the user wants a smooth surrogate.
2615    ///
2616    /// **fwd_body**: `num_inputs` Op::Inputs in NodeId construction
2617    /// order, one Op::Output (the primal y). Forward execution
2618    /// inlines this body once.
2619    ///
2620    /// **vjp_body** (optional): Op::Inputs are `num_inputs` primal
2621    /// inputs in NodeId order, plus two special-named Inputs —
2622    /// `"primal_output"` (the y from forward) and `"d_output"` (the
2623    /// upstream gradient). Outputs: `num_inputs` tensors in
2624    /// `set_outputs` order, matching the gradients of each primal
2625    /// input. When `None`, reverse-mode AD recurses into fwd_body
2626    /// — same as if the op were inlined.
2627    ///
2628    /// **jvp_body** (optional): Op::Inputs are `num_inputs` primal
2629    /// inputs in NodeId order, `num_inputs` special-named Inputs
2630    /// `"tangent_0"..="tangent_{num_inputs-1}"` carrying each input's
2631    /// tangent, and an optional special-named `"primal_output"` Input
2632    /// (the y from forward, useful when the JVP must be evaluated at
2633    /// a converged / nonlinear point — e.g. IFT-style forward-mode AD
2634    /// of an iterative solver). Output: 1 tensor (the tangent of y).
2635    /// When `None`, forward-mode AD recurses into fwd_body.
2636    ///
2637    /// `num_inputs` is captured so [`Op::num_inputs`] stays
2638    /// infallible. Build via [`crate::Graph::custom_fn`].
2639    CustomFn {
2640        fwd_body: Box<crate::Graph>,
2641        vjp_body: Option<Box<crate::Graph>>,
2642        jvp_body: Option<Box<crate::Graph>>,
2643        num_inputs: u32,
2644    },
2645
2646    // ── Riemannian / SPD-manifold layers ────────────────────────
2647    //
2648    // Building blocks of SPDNet (Huang & Van Gool, AAAI 2017) and
2649    // Riemannian batch-norm (Brooks et al., NeurIPS 2019). They operate
2650    // on symmetric-positive-definite matrices (covariance descriptors:
2651    // EEG/BCI, diffusion tensors, second-order pooling). F64; compute
2652    // bodies live in `rlx_cpu::spd`. Executed natively on CPU and via CPU
2653    // host-fallback on Metal / Vulkan / wgpu / CUDA / ROCm / oneAPI (each
2654    // delegates to `rlx_cpu::spd` through the thunk path — widening its
2655    // f32 arena to f64 and back). Backends that compile to a closed f32
2656    // program (MLX / CoreML / TPU / WebGL / QNN) can't run F64
2657    // eigendecomposition and reject these ops cleanly at legalize
2658    // (→ pin such graphs to `Device::Cpu`, like `Fft`/`DenseSolve`).
2659    /// BiMap (bilinear mapping) SPDNet layer: `Y = W · X · Wᵀ`.
2660    ///
2661    /// Inputs: `[W [m,n], X [n,n]]` (X symmetric SPD). Output: `Y [m,m]`
2662    /// (SPD when W has full row rank). Reduces the manifold dimension.
2663    /// The Stiefel / semi-orthogonality constraint on `W` is the
2664    /// optimizer's responsibility, not this op's. VJP decomposes to
2665    /// matmuls: `dW = (Ḡ+Ḡᵀ)·W·X`, `dX = Wᵀ·Ḡ·W`.
2666    BiMap,
2667
2668    /// ReEig (eigenvalue rectification) SPDNet nonlinearity:
2669    /// `Y = U · max(ε, Σ) · Uᵀ` where `X = U Σ Uᵀ`. Input `[X [n,n]]`
2670    /// symmetric → `Y [n,n]` SPD. The SPD analogue of ReLU: floors the
2671    /// spectrum at `eps` to keep the output well-conditioned.
2672    ReEig {
2673        eps: f32,
2674    },
2675
2676    /// LogEig SPDNet layer: `Y = U · log(Σ) · Uᵀ = logm(X)`. Maps the
2677    /// SPD manifold to the tangent space at the identity (symmetric
2678    /// matrices) so a plain Euclidean head can classify. Input
2679    /// `[X [n,n]]` → `Y [n,n]`. `eps` floors the spectrum before `log`.
2680    LogEig {
2681        eps: f32,
2682    },
2683
2684    /// SPD batch-norm affine transport (Brooks et al. 2019):
2685    /// `Y_i = G^{1/2} · (M^{-1/2} X_i M^{-1/2}) · G^{1/2}`.
2686    ///
2687    /// Inputs: `[X [B,n,n], M [n,n], G [n,n]]` — a batch of SPD matrices,
2688    /// the (detached) Fréchet mean `M`, and the learnable SPD bias `G`.
2689    /// Output: `Y [B,n,n]`. Centers the batch to the identity then biases
2690    /// toward `G`. `M` receives no gradient (detached statistic, like
2691    /// Euclidean batch-norm's running mean); grads flow to `X` and `G`.
2692    SpdBatchNorm {
2693        eps: f32,
2694    },
2695
2696    /// Fréchet / Karcher mean of a batch of SPD matrices under the AIRM
2697    /// metric (iterative tangent averaging). Input `[X [B,n,n]]` →
2698    /// `M [n,n]`. Non-differentiable (a detached batch statistic — its
2699    /// VJP contributes no gradient), mirroring how batch-norm's running
2700    /// statistics live outside the autodiff graph.
2701    SpdKarcherMean {
2702        iters: u32,
2703        tol: f32,
2704    },
2705
2706    /// Weighted Fréchet / Karcher mean of a batch of SPD matrices under the
2707    /// AIRM metric — the barycentre `argmin_M Σᵢ wᵢ · δ²(M, Cᵢ)`. Inputs
2708    /// `[X [B,n,n], w [B]]` → `M [n,n]`. The weighted counterpart of
2709    /// [`Op::SpdKarcherMean`]: the true AIRM barycentre a barycentric-OT
2710    /// projection / soft-clustering / weighted-MDM needs (not the
2711    /// log-Euclidean shortcut `expm(Σ w̄ᵢ logm Cᵢ)`). Non-differentiable — a
2712    /// detached statistic, exactly like [`Op::SpdKarcherMean`].
2713    SpdKarcherMeanWeighted {
2714        iters: u32,
2715        tol: f32,
2716    },
2717
2718    /// AIRM Riemannian **logarithm** at an arbitrary base point:
2719    /// `Log_P(X) = P^{1/2} · logm(P^{-1/2} X P^{-1/2}) · P^{1/2}`. Inputs
2720    /// `[base [n,n], x [n,n]]` → tangent vector `[n,n]`. Generalises
2721    /// [`Op::LogEig`] (which is `Log_I`) to any base point — the Riemannian
2722    /// OT / domain-adaptation primitive (Yair et al. 2019).
2723    SpdLogMap,
2724
2725    /// AIRM Riemannian **exponential** at an arbitrary base point:
2726    /// `Exp_P(V) = P^{1/2} · expm(P^{-1/2} V P^{-1/2}) · P^{1/2}`. Inputs
2727    /// `[base [n,n], v [n,n]]` → SPD point `[n,n]`. Inverse of
2728    /// [`Op::SpdLogMap`].
2729    SpdExpMap,
2730
2731    /// AIRM **parallel transport** of a tangent vector between base points:
2732    /// `Γ_{P→Q}(V) = E · V · Eᵀ`, `E = (Q P^{-1})^{1/2}`. Inputs
2733    /// `[from [n,n], to [n,n], v [n,n]]` → transported tangent vector `[n,n]`.
2734    /// The AIRM isometry that moves tangent vectors between base points for
2735    /// Riemannian OT / domain adaptation (Yair et al. 2019).
2736    SpdParallelTransport,
2737
2738    /// Batched SPD spectral matrix function — applies `kind` (logm / expm /
2739    /// sqrtm / invsqrtm) to each matrix of a batch. Input `[X [B,n,n]]` →
2740    /// `[B,n,n]`. The graph-level, any-backend counterpart of
2741    /// `rlx_cpu::spd::{logm,expm,sqrtm,invsqrtm}_batch`.
2742    SpdMatrixFnBatch {
2743        kind: SpdMatFn,
2744    },
2745
2746    /// Backward of [`Op::ReEig`]: `(X [n,n], dY [n,n]) → dX [n,n]` via
2747    /// the Daleckii–Krein (Loewner) adjoint with `f(λ)=max(λ,ε)`.
2748    ReEigBackward {
2749        eps: f32,
2750    },
2751
2752    /// Backward of [`Op::LogEig`]: `(X, dY) → dX` via the Loewner adjoint
2753    /// with `f=log`, `f′=1/λ`.
2754    LogEigBackward {
2755        eps: f32,
2756    },
2757
2758    /// Backward of [`Op::SpdBatchNorm`] w.r.t. the inputs `X` (mean
2759    /// detached): `(M [n,n], G [n,n], dY [B,n,n]) → dX [B,n,n]`.
2760    SpdBatchNormBackwardX {
2761        eps: f32,
2762    },
2763
2764    /// Backward of [`Op::SpdBatchNorm`] w.r.t. the learnable bias `G`
2765    /// (mean detached): `(X [B,n,n], M [n,n], G [n,n], dY [B,n,n]) →
2766    /// dG [n,n]`.
2767    SpdBatchNormBackwardG {
2768        eps: f32,
2769    },
2770
2771    /// Backward of [`Op::SpdLogMap`]. Inputs `[base [n,n], x [n,n], dY [n,n]]` →
2772    /// packed `[d_base ∥ d_x]` of shape `[2, n, n]` (the builder narrows the two
2773    /// gradients out). Full AIRM adjoint — the base point receives a gradient.
2774    SpdLogMapBackward,
2775
2776    /// Backward of [`Op::SpdExpMap`]. Inputs `[base [n,n], v [n,n], dY [n,n]]` →
2777    /// packed `[d_base ∥ d_v]` of shape `[2, n, n]`.
2778    SpdExpMapBackward,
2779
2780    /// Backward of [`Op::SpdParallelTransport`].
2781    /// Inputs `[from [n,n], to [n,n], v [n,n], dY [n,n]]` → packed
2782    /// `[d_from ∥ d_to ∥ d_v]` of shape `[3, n, n]`.
2783    SpdParallelTransportBackward,
2784
2785    /// Backward of [`Op::SpdMatrixFnBatch`]: per-slice Daleckii–Krein adjoint of
2786    /// `kind`. Inputs `[X [B,n,n], dY [B,n,n]]` → `dX [B,n,n]`.
2787    SpdMatrixFnBatchBackward {
2788        kind: SpdMatFn,
2789    },
2790
2791    /// Symmetric **eigendecomposition** of an `[n,n]` matrix. Output is packed
2792    /// `[λ (n) ∥ U (n²)]` — ascending eigenvalues then the row-major eigenvector
2793    /// matrix (column `j` = eigenvector `j`, so `A = U diag(λ) Uᵀ`). The builder
2794    /// [`Graph::eigh`] narrows the two views out. Differentiable (see
2795    /// [`Op::EighBackward`]). The seam a native batched eigensolver (cuSOLVER
2796    /// `syevjBatched` &c.) plugs into.
2797    Eigh,
2798
2799    /// Backward of [`Op::Eigh`]. Inputs `[fwd [n²+n], bar [n²+n]]` — the packed
2800    /// forward `[λ ∥ U]` and the upstream cotangent `[λ̄ ∥ Ū]` — → `Ā [n,n]`
2801    /// (the standard symmetric-eigendecomposition adjoint).
2802    EighBackward,
2803
2804    /// Batched [`Op::Eigh`]: `[B,n,n] → [B, n²+n]` packed per slice.
2805    EighBatch,
2806
2807    /// Backward of [`Op::EighBatch`]. Inputs `[fwd [B,n²+n], bar [B,n²+n]]` →
2808    /// `Ā [B,n,n]`.
2809    EighBatchBackward,
2810
2811    // ── Diffusion Transformer (DiT) modulation ──────────────────
2812    /// Fused adaptive-norm modulation — the signature DiT / adaLN-Zero op.
2813    ///
2814    /// ```text
2815    /// out = norm(x) * (1 + scale) + shift
2816    /// ```
2817    ///
2818    /// `norm` normalizes over the **last axis** with no learnable affine
2819    /// ([`AdaNormKind`]); the affine is supplied per-conditioning by `scale`
2820    /// and `shift`, which broadcast over every axis except the last (the DiT
2821    /// `[B, 1, D]` modulation over `[B, S, D]`). Folding the broadcast into the
2822    /// op avoids the `Op::Expand` materialization the decomposed form needs
2823    /// (`[B,1,D]→[B,S,D]` can't ride an `ElementwiseRegion`'s trailing-modulo
2824    /// broadcast) and keeps the pre-norm activation off the heap — mirroring
2825    /// [`Op::FusedResidualLN`].
2826    ///
2827    /// Inputs: `[x, scale, shift]`
2828    ///   - `x`:     `[..., D]`
2829    ///   - `scale`: broadcast-compatible with `x`, last dim `D` (e.g. `[B, 1, D]`)
2830    ///   - `shift`: same shape as `scale`
2831    ///
2832    /// Output shape == `x`. Backends without a native kernel decompose via
2833    /// `unfuse` into `norm → mul(1+scale) → add(shift)`.
2834    AdaLayerNorm {
2835        norm: AdaNormKind,
2836        eps: f32,
2837    },
2838
2839    /// Gated residual `out = x + gate * y` (the DiT post-sublayer combine).
2840    ///
2841    /// `gate` broadcasts over every axis except the last (the `[B, 1, D]`
2842    /// modulation gate over `[B, S, D]`), so — like [`Op::AdaLayerNorm`] — the
2843    /// built-in broadcast avoids an `Op::Expand` of the gate. Used for both the
2844    /// attention (`gate_msa`) and MLP (`gate_mlp`) residual joins.
2845    ///
2846    /// Inputs: `[x, y, gate]`
2847    ///   - `x`:    residual stream `[..., D]`
2848    ///   - `y`:    sublayer output, same shape as `x`
2849    ///   - `gate`: broadcast-compatible with `x`, last dim `D` (e.g. `[B, 1, D]`)
2850    ///
2851    /// Output shape == `x`. Backends without a native kernel decompose via
2852    /// `unfuse` into `mul(gate, y) → add(x)`.
2853    GatedResidual,
2854
2855    /// Packed reverse of [`Op::AdaLayerNorm`].
2856    ///
2857    /// Inputs: `[x, scale, shift, dy]` (same shapes as the forward).
2858    /// Output: 1-D `[nx + 2·ns]` packing `dx` (numel `nx`), `dscale` (`ns`),
2859    /// `dshift` (`ns`) in that order — avoids materializing `Expand` of
2860    /// `[B,1,D]` modulation in the backward graph. Unpack with
2861    /// [`crate::ops::backward::unpack_ada_layer_norm_backward`].
2862    AdaLayerNormBackward {
2863        norm: AdaNormKind,
2864        eps: f32,
2865    },
2866
2867    /// Packed reverse of [`Op::GatedResidual`].
2868    ///
2869    /// Inputs: `[x, y, gate, dy]`. Output: 1-D `[nx + ny + ng]` packing
2870    /// `dx`, `dy`, `dgate` (row-major). Unpack with
2871    /// [`crate::ops::backward::unpack_gated_residual_backward`].
2872    GatedResidualBackward,
2873}
2874
2875impl Op {
2876    /// Every nested [`Graph`](crate::Graph) this op carries, in field order.
2877    ///
2878    /// Empty for all but the control-flow / custom-body ops
2879    /// ([`Op::If`], [`Op::While`], [`Op::Scan`], [`Op::ScanBackward`],
2880    /// [`Op::ScanBackwardXs`], [`Op::CustomFn`]). The empty `Vec` does not
2881    /// allocate, so the common path is free.
2882    pub fn subgraphs(&self) -> Vec<&crate::Graph> {
2883        match self {
2884            Op::If {
2885                then_branch,
2886                else_branch,
2887            } => vec![then_branch, else_branch],
2888            Op::While { cond, body, .. } => vec![cond, body],
2889            Op::Scan { body, .. } => vec![body],
2890            Op::ScanBackward {
2891                body_vjp,
2892                forward_body,
2893                ..
2894            }
2895            | Op::ScanBackwardXs {
2896                body_vjp,
2897                forward_body,
2898                ..
2899            } => match forward_body {
2900                Some(fwd) => vec![body_vjp, fwd],
2901                None => vec![body_vjp],
2902            },
2903            Op::CustomFn {
2904                fwd_body,
2905                vjp_body,
2906                jvp_body,
2907                ..
2908            } => {
2909                let mut out = vec![&**fwd_body];
2910                out.extend(vjp_body.as_deref());
2911                out.extend(jvp_body.as_deref());
2912                out
2913            }
2914            _ => Vec::new(),
2915        }
2916    }
2917
2918    /// Mutable counterpart of [`Op::subgraphs`], same order.
2919    pub fn subgraphs_mut(&mut self) -> Vec<&mut crate::Graph> {
2920        match self {
2921            Op::If {
2922                then_branch,
2923                else_branch,
2924            } => vec![then_branch, else_branch],
2925            Op::While { cond, body, .. } => vec![cond, body],
2926            Op::Scan { body, .. } => vec![body],
2927            Op::ScanBackward {
2928                body_vjp,
2929                forward_body,
2930                ..
2931            }
2932            | Op::ScanBackwardXs {
2933                body_vjp,
2934                forward_body,
2935                ..
2936            } => match forward_body {
2937                Some(fwd) => vec![&mut **body_vjp, &mut **fwd],
2938                None => vec![&mut **body_vjp],
2939            },
2940            Op::CustomFn {
2941                fwd_body,
2942                vjp_body,
2943                jvp_body,
2944                ..
2945            } => {
2946                let mut out = vec![&mut **fwd_body];
2947                out.extend(vjp_body.as_deref_mut());
2948                out.extend(jvp_body.as_deref_mut());
2949                out
2950            }
2951            _ => Vec::new(),
2952        }
2953    }
2954
2955    /// PLAN L4: discriminant for backend-supported-set checks.
2956    /// Stable, parameter-free identity per variant — `Op::Activation(_)`
2957    /// and `Op::Activation(Relu)` share the same `OpKind::Activation`.
2958    pub fn kind(&self) -> OpKind {
2959        match self {
2960            Op::Input { .. } => OpKind::Input,
2961            Op::Param { .. } => OpKind::Param,
2962            Op::Constant { .. } => OpKind::Constant,
2963            Op::Activation(_) => OpKind::Activation,
2964            Op::Cast { .. } => OpKind::Cast,
2965            Op::StopGradient => OpKind::StopGradient,
2966            Op::Quantize { .. } => OpKind::Quantize,
2967            Op::Dequantize { .. } => OpKind::Dequantize,
2968            Op::FakeQuantize { .. } => OpKind::FakeQuantize,
2969            Op::FakeQuantizeLSQ { .. } => OpKind::FakeQuantizeLSQ,
2970            Op::FakeQuantizeLSQBackwardX { .. } => OpKind::FakeQuantizeLSQBackwardX,
2971            Op::FakeQuantizeLSQBackwardScale { .. } => OpKind::FakeQuantizeLSQBackwardScale,
2972            Op::Binary(_) => OpKind::Binary,
2973            Op::Compare(_) => OpKind::Compare,
2974            Op::Where => OpKind::Where,
2975            Op::Fma => OpKind::Fma,
2976            Op::ElementwiseRegion { .. } => OpKind::ElementwiseRegion,
2977            Op::TransformRegion { .. } => OpKind::TransformRegion,
2978            Op::BatchElementwiseRegion { .. } => OpKind::BatchElementwiseRegion,
2979            Op::MatMul => OpKind::MatMul,
2980            Op::DotGeneral { .. } => OpKind::DotGeneral,
2981            Op::DenseSolve => OpKind::DenseSolve,
2982            Op::BatchedDenseSolve => OpKind::BatchedDenseSolve,
2983            Op::Cholesky => OpKind::Cholesky,
2984            Op::TriangularSolve { .. } => OpKind::TriangularSolve,
2985            Op::Det => OpKind::Det,
2986            Op::LogDet => OpKind::LogDet,
2987            Op::Sort { .. } => OpKind::Sort,
2988            Op::ArgSort { .. } => OpKind::ArgSort,
2989            Op::Svd { .. } => OpKind::Svd,
2990            Op::Qr { .. } => OpKind::Qr,
2991            Op::LayerNorm { .. } => OpKind::LayerNorm,
2992            Op::LayerNorm2d { .. } => OpKind::LayerNorm2d,
2993            Op::GroupNorm { .. } => OpKind::GroupNorm,
2994            Op::BatchNormInference { .. } => OpKind::BatchNormInference,
2995            Op::RmsNorm { .. } => OpKind::RmsNorm,
2996            Op::ResizeNearest2x => OpKind::ResizeNearest2x,
2997            Op::Interpolate3d { .. } => OpKind::Interpolate3d,
2998            Op::Attention { .. } => OpKind::Attention,
2999            Op::Rope { .. } => OpKind::Rope,
3000            Op::AxialRope2d { .. } => OpKind::AxialRope2d,
3001            Op::Reshape { .. } => OpKind::Reshape,
3002            Op::Transpose { .. } => OpKind::Transpose,
3003            Op::Narrow { .. } => OpKind::Narrow,
3004            Op::Concat { .. } => OpKind::Concat,
3005            Op::KvAppend { .. } => OpKind::KvAppend,
3006            Op::Expand { .. } => OpKind::Expand,
3007            Op::Gather { .. } => OpKind::Gather,
3008            Op::Reverse { .. } => OpKind::Reverse,
3009            Op::Pad { .. } => OpKind::Pad,
3010            Op::Slice { .. } => OpKind::Slice,
3011            Op::Clamp { .. } => OpKind::Clamp,
3012            Op::Tile { .. } => OpKind::Tile,
3013            Op::Trilu { .. } => OpKind::Trilu,
3014            Op::Reduce { .. } => OpKind::Reduce,
3015            Op::Histogram { .. } => OpKind::Histogram,
3016            Op::Softmax { .. } => OpKind::Softmax,
3017            Op::Cumsum { .. } => OpKind::Cumsum,
3018            Op::CumProd { .. } => OpKind::CumProd,
3019            Op::CumMax { .. } => OpKind::CumMax,
3020            Op::ArgMax { .. } => OpKind::ArgMax,
3021            Op::ArgMin { .. } => OpKind::ArgMin,
3022            Op::TopK { .. } => OpKind::TopK,
3023            Op::Sample { .. } => OpKind::Sample,
3024            Op::RngNormal { .. } => OpKind::RngNormal,
3025            Op::RngUniform { .. } => OpKind::RngUniform,
3026            Op::Conv { .. } => OpKind::Conv,
3027            Op::Im2Col { .. } => OpKind::Im2Col,
3028            Op::ConvTranspose2d { .. } => OpKind::ConvTranspose2d,
3029            Op::Conv3d { .. } => OpKind::Conv3d,
3030            Op::ConvTranspose3d { .. } => OpKind::ConvTranspose3d,
3031            Op::Pool { .. } => OpKind::Pool,
3032            Op::ReluBackward => OpKind::ReluBackward,
3033            Op::ActivationBackward { .. } => OpKind::ActivationBackward,
3034            Op::FakeQuantizeBackward { .. } => OpKind::FakeQuantizeBackward,
3035            Op::ComplexNormSq => OpKind::ComplexNormSq,
3036            Op::ComplexNormSqBackward => OpKind::ComplexNormSqBackward,
3037            Op::Conjugate => OpKind::Conjugate,
3038            Op::LayerNormBackwardInput { .. } => OpKind::LayerNormBackwardInput,
3039            Op::LayerNormBackwardGamma { .. } => OpKind::LayerNormBackwardGamma,
3040            Op::RmsNormBackwardInput { .. } => OpKind::RmsNormBackwardInput,
3041            Op::RmsNormBackwardGamma { .. } => OpKind::RmsNormBackwardGamma,
3042            Op::RmsNormBackwardBeta { .. } => OpKind::RmsNormBackwardBeta,
3043            Op::RopeBackward { .. } => OpKind::RopeBackward,
3044            Op::GroupNormBackwardInput { .. } => OpKind::GroupNormBackwardInput,
3045            Op::GroupNormBackwardGamma { .. } => OpKind::GroupNormBackwardGamma,
3046            Op::GroupNormBackwardBeta { .. } => OpKind::GroupNormBackwardBeta,
3047            Op::BatchNormInferenceBackwardInput { .. } => OpKind::BatchNormInferenceBackwardInput,
3048            Op::BatchNormInferenceBackwardGamma { .. } => OpKind::BatchNormInferenceBackwardGamma,
3049            Op::BatchNormInferenceBackwardBeta => OpKind::BatchNormInferenceBackwardBeta,
3050            Op::CumsumBackward { .. } => OpKind::CumsumBackward,
3051            Op::GatherBackward { .. } => OpKind::GatherBackward,
3052            Op::MaxPool2dBackward { .. } => OpKind::MaxPool2dBackward,
3053            Op::Conv2dBackwardInput { .. } => OpKind::Conv2dBackwardInput,
3054            Op::Conv2dBackwardWeight { .. } => OpKind::Conv2dBackwardWeight,
3055            Op::MaxPool3dBackward { .. } => OpKind::MaxPool3dBackward,
3056            Op::Conv3dBackwardInput { .. } => OpKind::Conv3dBackwardInput,
3057            Op::Conv3dBackwardWeight { .. } => OpKind::Conv3dBackwardWeight,
3058            Op::SoftmaxCrossEntropy => OpKind::SoftmaxCrossEntropy,
3059            Op::SoftmaxCrossEntropyWithLogits => OpKind::SoftmaxCrossEntropyWithLogits,
3060            Op::SoftmaxCrossEntropyBackward => OpKind::SoftmaxCrossEntropyBackward,
3061            Op::AttentionBackward { .. } => OpKind::AttentionBackward,
3062            Op::AttentionBackwardAll { .. } => OpKind::AttentionBackwardAll,
3063            Op::GroupedMatMul => OpKind::GroupedMatMul,
3064            Op::DequantGroupedMatMul { .. } => OpKind::DequantGroupedMatMul,
3065            Op::DequantGroupedMatMulMlx { .. } => OpKind::DequantGroupedMatMulMlx,
3066            Op::DequantMoEWeights { .. } => OpKind::DequantMoEWeights,
3067            Op::ScaledGroupedMatMul { .. } => OpKind::ScaledGroupedMatMul,
3068            Op::ScatterAdd => OpKind::ScatterAdd,
3069            Op::ScatterNd { .. } => OpKind::ScatterNd,
3070            Op::ScatterElements { .. } => OpKind::ScatterElements,
3071            Op::GatherNd { .. } => OpKind::GatherNd,
3072            Op::GatherElements { .. } => OpKind::GatherElements,
3073            Op::LoraMatMul { .. } => OpKind::LoraMatMul,
3074            Op::PartitionedConv { .. } => OpKind::PartitionedConv,
3075            Op::DequantMatMul { .. } => OpKind::DequantMatMul,
3076            Op::SynthMatMul { .. } => OpKind::SynthMatMul,
3077            Op::SynthMatMulBackward { .. } => OpKind::SynthMatMulBackward,
3078            Op::SynthReconstruct { .. } => OpKind::SynthReconstruct,
3079            Op::SplineActivation { .. } => OpKind::SplineActivation,
3080            Op::SplineActivationBackwardX { .. } => OpKind::SplineActivationBackwardX,
3081            Op::SplineActivationBackwardCoeff { .. } => OpKind::SplineActivationBackwardCoeff,
3082            Op::QMatMul { .. } => OpKind::QMatMul,
3083            Op::QConv2d { .. } => OpKind::QConv2d,
3084            Op::ScaledMatMul { .. } => OpKind::ScaledMatMul,
3085            Op::ScaledQuantize { .. } => OpKind::ScaledQuantize,
3086            Op::ScaledQuantScale { .. } => OpKind::ScaledQuantScale,
3087            Op::ScaledDequantize { .. } => OpKind::ScaledDequantize,
3088            Op::SelectiveScan { .. } => OpKind::SelectiveScan,
3089            Op::GatedDeltaNet { .. } => OpKind::GatedDeltaNet,
3090            Op::Lstm { .. } => OpKind::Lstm,
3091            Op::Gru { .. } => OpKind::Gru,
3092            Op::Rnn { .. } => OpKind::Rnn,
3093            Op::Mamba2 { .. } => OpKind::Mamba2,
3094            Op::FusedSwiGLU { .. } => OpKind::FusedSwiGLU,
3095            Op::FusedMatMulBiasAct { .. } => OpKind::FusedMatMulBiasAct,
3096            Op::FusedMatMulResidual => OpKind::FusedMatMulResidual,
3097            Op::FusedConvBiasAct { .. } => OpKind::FusedConvBiasAct,
3098            Op::FusedResidualLN { .. } => OpKind::FusedResidualLN,
3099            Op::FusedResidualRmsNorm { .. } => OpKind::FusedResidualRmsNorm,
3100            Op::FusedAttentionBlock { .. } => OpKind::FusedAttentionBlock,
3101            Op::FusedTransformerLayer { .. } => OpKind::FusedTransformerLayer,
3102            Op::If { .. } => OpKind::If,
3103            Op::While { .. } => OpKind::While,
3104            Op::Scan { .. } => OpKind::Scan,
3105            Op::ScanBackward { .. } => OpKind::ScanBackward,
3106            Op::ScanBackwardXs { .. } => OpKind::ScanBackwardXs,
3107            Op::GaussianSplatRender { .. } => OpKind::GaussianSplatRender,
3108            Op::GaussianSplatRenderBackward { .. } => OpKind::GaussianSplatRenderBackward,
3109            Op::GaussianSplatPrepare { .. } => OpKind::GaussianSplatPrepare,
3110            Op::GaussianSplatRasterize { .. } => OpKind::GaussianSplatRasterize,
3111            Op::Custom { .. } => OpKind::Custom,
3112            Op::CustomFn { .. } => OpKind::CustomFn,
3113            Op::Fft { .. } => OpKind::Fft,
3114            Op::FftButterflyStage { .. } => OpKind::FftButterflyStage,
3115            Op::LogMel => OpKind::LogMel,
3116            Op::LogMelBackward => OpKind::LogMelBackward,
3117            Op::WelchPeaks { .. } => OpKind::WelchPeaks,
3118            Op::BiMap => OpKind::BiMap,
3119            Op::ReEig { .. } => OpKind::ReEig,
3120            Op::LogEig { .. } => OpKind::LogEig,
3121            Op::SpdBatchNorm { .. } => OpKind::SpdBatchNorm,
3122            Op::SpdKarcherMean { .. } => OpKind::SpdKarcherMean,
3123            Op::SpdKarcherMeanWeighted { .. } => OpKind::SpdKarcherMeanWeighted,
3124            Op::SpdLogMap => OpKind::SpdLogMap,
3125            Op::SpdExpMap => OpKind::SpdExpMap,
3126            Op::SpdParallelTransport => OpKind::SpdParallelTransport,
3127            Op::SpdMatrixFnBatch { .. } => OpKind::SpdMatrixFnBatch,
3128            Op::ReEigBackward { .. } => OpKind::ReEigBackward,
3129            Op::LogEigBackward { .. } => OpKind::LogEigBackward,
3130            Op::SpdBatchNormBackwardX { .. } => OpKind::SpdBatchNormBackwardX,
3131            Op::SpdBatchNormBackwardG { .. } => OpKind::SpdBatchNormBackwardG,
3132            Op::SpdLogMapBackward => OpKind::SpdLogMapBackward,
3133            Op::SpdExpMapBackward => OpKind::SpdExpMapBackward,
3134            Op::SpdParallelTransportBackward => OpKind::SpdParallelTransportBackward,
3135            Op::SpdMatrixFnBatchBackward { .. } => OpKind::SpdMatrixFnBatchBackward,
3136            Op::Eigh => OpKind::Eigh,
3137            Op::EighBackward => OpKind::EighBackward,
3138            Op::EighBatch => OpKind::EighBatch,
3139            Op::EighBatchBackward => OpKind::EighBatchBackward,
3140            Op::AdaLayerNorm { .. } => OpKind::AdaLayerNorm,
3141            Op::AdaLayerNormBackward { .. } => OpKind::AdaLayerNormBackward,
3142            Op::GatedResidual => OpKind::GatedResidual,
3143            Op::GatedResidualBackward => OpKind::GatedResidualBackward,
3144        }
3145    }
3146
3147    // Capability predicates (`is_elementwise`, `is_blas`,
3148    // `is_fusion_boundary`, `is_reduction`, `is_transform_eligible`, …)
3149    // live in `crate::capability`, which classifies every `OpKind`
3150    // exhaustively instead of via open `matches!` lists.
3151
3152    /// Number of tensor inputs this op expects.
3153    pub fn num_inputs(&self) -> usize {
3154        match self {
3155            Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => 0,
3156            Op::Activation(_)
3157            | Op::Cast { .. }
3158            | Op::StopGradient
3159            | Op::Reshape { .. }
3160            | Op::Quantize { .. }
3161            | Op::Dequantize { .. }
3162            | Op::Transpose { .. }
3163            | Op::Narrow { .. }
3164            | Op::Reverse { .. }
3165            | Op::Pad { .. }
3166            | Op::Slice { .. }
3167            | Op::Clamp { .. }
3168            | Op::Tile { .. }
3169            | Op::Trilu { .. }
3170            | Op::Expand { .. }
3171            | Op::Reduce { .. }
3172            | Op::Histogram { .. }
3173            | Op::Softmax { .. }
3174            | Op::FusedSwiGLU { .. }
3175            | Op::TopK { .. }
3176            | Op::Cumsum { .. }
3177            | Op::CumProd { .. }
3178            | Op::CumMax { .. }
3179            | Op::ArgMax { .. }
3180            | Op::ArgMin { .. }
3181            | Op::Sample { .. }
3182            | Op::ResizeNearest2x
3183            | Op::Interpolate3d { .. } => 1,
3184            Op::RngNormal { .. } | Op::RngUniform { .. } => 0, // 0 or 1 — see verify
3185            // EMA / Fixed scale modes carry a state tensor as a 2nd input;
3186            // PerBatch (default) doesn't need one.
3187            Op::FakeQuantize { scale_mode, .. } => match scale_mode {
3188                ScaleMode::PerBatch => 1,
3189                ScaleMode::EMA { .. } | ScaleMode::Fixed => 2,
3190            },
3191            Op::FakeQuantizeLSQ { .. } => 2, // x, scale (learned param)
3192            Op::FakeQuantizeLSQBackwardX { .. } | Op::FakeQuantizeLSQBackwardScale { .. } => 3, // x, scale, dy
3193            Op::Binary(_) | Op::Compare(_) | Op::Gather { .. } | Op::MatMul | Op::ScatterAdd => 2,
3194            Op::GatherNd { .. } | Op::GatherElements { .. } => 2,
3195            Op::ScatterNd { .. } | Op::ScatterElements { .. } => 3, // data, indices, updates
3196            Op::GroupedMatMul => 3,                                 // input, weight, expert_idx
3197            Op::DequantGroupedMatMul { .. } => 3,                   // input, packed_w, expert_idx
3198            Op::DequantGroupedMatMulMlx { .. } => 5, // input, w, scales, biases, expert_idx
3199            Op::DequantMoEWeights { .. } => 1,       // packed_w
3200            // input, weight, input_s, weight_s, expert_idx (+ per-expert bias)
3201            Op::ScaledGroupedMatMul { has_bias, .. } => 5 + usize::from(*has_bias),
3202            Op::LoraMatMul { .. } => 4,      // x, w, a, b
3203            Op::PartitionedConv { .. } => 2, // x, ir
3204            // x, w_q, scale, zp — or x, packed_w_bytes for GGUF
3205            // schemes (their scales/mins live inside the packed bytes,
3206            // see `QuantScheme::is_gguf`).
3207            Op::DequantMatMul { scheme } => {
3208                if scheme.is_gguf() {
3209                    2
3210                } else if scheme.mxfp4x2_config().is_some() {
3211                    3 // x, w_q (packed [plane0|plane1]), scale ([s0|s1])
3212                } else {
3213                    4
3214                }
3215            }
3216            Op::SynthMatMul { .. } => 3,         // x, indices, codebook
3217            Op::SynthMatMulBackward { .. } => 4, // x, indices, codebook, upstream
3218            Op::SynthReconstruct { .. } => 2,    // indices, codebook
3219            Op::SplineActivation { .. } => 2,    // x, coeff
3220            Op::SplineActivationBackwardX { .. } => 3, // x, coeff, upstream
3221            Op::SplineActivationBackwardCoeff { .. } => 2, // x, upstream
3222            Op::QMatMul { .. } => 3,             // x, w, bias
3223            Op::QConv2d { .. } => 3,             // x, w, bias
3224            // lhs_codes, rhs_codes, lhs_scale, rhs_scale (+ bias)
3225            Op::ScaledMatMul { has_bias, .. } => 4 + usize::from(*has_bias),
3226            Op::ScaledQuantize { .. } => 2,   // x, scale
3227            Op::ScaledQuantScale { .. } => 1, // x
3228            Op::ScaledDequantize { .. } => 2, // codes, scale
3229            Op::SelectiveScan { .. } => 5,    // x, delta, a, b, c
3230            Op::GatedDeltaNet { carry_state, .. } if *carry_state => 6, // + state in/out
3231            Op::GatedDeltaNet { .. } => 5,    // q, k, v, g, beta
3232            Op::Lstm { carry, .. } => {
3233                if *carry { 6 } else { 4 } // x, w_ih, w_hh, bias (+ h0, c0)
3234            }
3235            Op::Gru { carry, .. } => {
3236                if *carry { 6 } else { 5 } // x, w_ih, w_hh, b_ih, b_hh (+ h0)
3237            }
3238            Op::Rnn { carry, .. } => {
3239                if *carry { 5 } else { 4 } // x, w_ih, w_hh, bias (+ h0)
3240            }
3241            Op::Mamba2 { .. } => 5, // x, dt, a, b, c
3242            Op::Where => 3,         // cond, on_true, on_false
3243            Op::Fma => 3,           // a, b, c  (a*b + c)
3244            Op::Attention { mask_kind, .. } => match mask_kind {
3245                MaskKind::Custom | MaskKind::Bias => 4, // Q, K, V, mask
3246                _ => 3,                                 // Q, K, V (mask synthesized in-kernel)
3247            },
3248            Op::AttentionBackward { mask_kind, .. }
3249            | Op::AttentionBackwardAll { mask_kind, .. } => match mask_kind {
3250                MaskKind::Custom | MaskKind::Bias => 5, // q, k, v, dy, mask
3251                _ => 4,                                 // q, k, v, dy
3252            },
3253            Op::Rope { .. } => 3, // x, cos, sin
3254            Op::AxialRope2d { .. } => 1,
3255            Op::LayerNorm { .. }
3256            | Op::LayerNorm2d { .. }
3257            | Op::GroupNorm { .. }
3258            | Op::RmsNorm { .. } => 3, // input, gamma, beta
3259            Op::BatchNormInference { .. } => 5, // x, gamma, beta, mean, var
3260            Op::FusedMatMulBiasAct { .. } => 3, // input, weight, bias
3261            Op::FusedMatMulResidual => 3,       // lhs, rhs, residual
3262            Op::FusedConvBiasAct {
3263                has_residual: true, ..
3264            } => 4, // + residual (z)
3265            Op::FusedConvBiasAct { .. } => 3,   // input, weight, bias
3266            Op::FusedResidualLN { has_bias: true, .. } => 5, // x, residual, bias, gamma, beta
3267            Op::FusedResidualLN {
3268                has_bias: false, ..
3269            } => 4, // x, residual, gamma, beta
3270            Op::FusedResidualRmsNorm { has_bias: true, .. } => 5, // x, residual, bias, gamma, beta
3271            Op::FusedResidualRmsNorm {
3272                has_bias: false, ..
3273            } => 4, // x, residual, gamma, beta
3274            Op::Conv { .. } | Op::ConvTranspose2d { .. } => 2, // input, weight (bias via Add)
3275            Op::Conv3d { .. } | Op::ConvTranspose3d { .. } => 2, // input, weight (bias via Add)
3276            Op::Im2Col { .. } => 1,
3277            Op::Pool { .. } => 1,
3278            Op::ReluBackward => 2,                  // x, dy
3279            Op::ActivationBackward { .. } => 2,     // x, dy
3280            Op::FakeQuantizeBackward { .. } => 2,   // x, dy
3281            Op::ComplexNormSq => 1,                 // z (C64)
3282            Op::ComplexNormSqBackward => 2,         // z, g
3283            Op::Conjugate => 1,                     // z (C64)
3284            Op::LayerNormBackwardInput { .. } => 3, // x, gamma, dy
3285            Op::LayerNormBackwardGamma { .. } => 2, // x, dy
3286            Op::RmsNormBackwardInput { .. } => 4,   // x, gamma, beta, dy
3287            Op::RmsNormBackwardGamma { .. } => 4,
3288            Op::RmsNormBackwardBeta { .. } => 4,
3289            Op::RopeBackward { .. } => 3,           // dy, cos, sin
3290            Op::GroupNormBackwardInput { .. } => 4, // x, gamma, beta, dy
3291            Op::GroupNormBackwardGamma { .. } => 2, // x, dy
3292            Op::GroupNormBackwardBeta { .. } => 2,
3293            Op::BatchNormInferenceBackwardInput { .. } => 5, // x, gamma, mean, var, dy
3294            Op::BatchNormInferenceBackwardGamma { .. } => 4, // x, mean, var, dy
3295            Op::BatchNormInferenceBackwardBeta => 1,         // dy
3296            Op::CumsumBackward { .. } => 1,                  // dy
3297            Op::GatherBackward { .. } => 2,                  // dy, indices
3298            Op::MaxPool2dBackward { .. } => 2,               // x, dy
3299            Op::Conv2dBackwardInput { .. } => 2,             // dy, w
3300            Op::Conv2dBackwardWeight { .. } => 2,            // x, dy
3301            Op::MaxPool3dBackward { .. } => 2,               // x, dy
3302            Op::Conv3dBackwardInput { .. } => 2,             // dy, w
3303            Op::Conv3dBackwardWeight { .. } => 2,            // x, dy
3304            Op::SoftmaxCrossEntropy => 2,                    // logits, targets
3305            Op::SoftmaxCrossEntropyWithLogits => 2,          // logits, labels
3306            Op::SoftmaxCrossEntropyBackward => 3,            // logits, labels, d_loss
3307            Op::Concat { .. } => 0,                          // variadic — checked at graph level
3308            Op::KvAppend { .. } => 2,                        // cache, row
3309            Op::DotGeneral { .. } => 2,
3310            Op::DenseSolve => 2,             // A, b
3311            Op::BatchedDenseSolve => 2,      // A [B,N,N], b [B,N] or [B,N,K]
3312            Op::Cholesky => 1,               // A (SPD)
3313            Op::TriangularSolve { .. } => 2, // A (triangular), B
3314            Op::Det | Op::LogDet => 1,       // A
3315            Op::Sort { .. } | Op::ArgSort { .. } => 1,
3316            Op::Svd { .. } => 1,
3317            Op::Qr { .. } => 1,
3318            Op::FusedAttentionBlock {
3319                has_bias, has_rope, ..
3320            } => 4 + if *has_bias { 2 } else { 0 } + if *has_rope { 2 } else { 0 },
3321            Op::If { .. } => 1,    // predicate (captures handled separately)
3322            Op::While { .. } => 0, // variadic loop-carried; checked at graph level
3323            Op::Scan {
3324                num_bcast, num_xs, ..
3325            } => 1 + *num_bcast as usize + *num_xs as usize,
3326            Op::ScanBackward { num_xs, .. } => 3 + *num_xs as usize, // init, trajectory, upstream, xs_0..
3327            Op::ScanBackwardXs { num_xs, .. } => 3 + *num_xs as usize, // same as ScanBackward
3328            Op::GaussianSplatRender { .. } => 7,
3329            Op::GaussianSplatRenderBackward { .. } => 8,
3330            Op::GaussianSplatPrepare { .. } => 7,
3331            Op::GaussianSplatRasterize { .. } => 2,
3332            Op::FusedTransformerLayer { has_bias, .. } => {
3333                // hidden + qkv_w + out_w + ln1_g + ln1_b + fc1_w + fc2_w + ln2_g + ln2_b + mask = 10
3334                // bias variant adds: qkv_b + out_b + fc1_b + fc2_b = 4 more
3335                10 + if *has_bias { 4 } else { 0 }
3336            }
3337            Op::ElementwiseRegion { num_inputs, .. } => *num_inputs as usize,
3338            Op::TransformRegion { num_inputs, .. } => *num_inputs as usize,
3339            Op::BatchElementwiseRegion {
3340                num_batch_inputs, ..
3341            } => *num_batch_inputs as usize,
3342            Op::Custom { num_inputs, .. } => *num_inputs as usize,
3343            Op::CustomFn { num_inputs, .. } => *num_inputs as usize,
3344            Op::Fft { .. } => 1,
3345            Op::FftButterflyStage { .. } => 5,
3346            Op::LogMel => 2,
3347            Op::LogMelBackward => 3,
3348            Op::WelchPeaks { .. } => 1,
3349            // ── Riemannian / SPD-manifold layers ─────────────────
3350            Op::BiMap => 2,                           // W, X
3351            Op::ReEig { .. } => 1,                    // X
3352            Op::LogEig { .. } => 1,                   // X
3353            Op::SpdBatchNorm { .. } => 3,             // X, mean, G
3354            Op::SpdKarcherMean { .. } => 1,           // X
3355            Op::SpdKarcherMeanWeighted { .. } => 2,   // X, weights
3356            Op::SpdLogMap => 2,                       // base, X
3357            Op::SpdExpMap => 2,                       // base, V
3358            Op::SpdParallelTransport => 3,            // from, to, V
3359            Op::SpdMatrixFnBatch { .. } => 1,         // X
3360            Op::ReEigBackward { .. } => 3,            // λ, U, dY  (reuses fwd eigendecomp)
3361            Op::LogEigBackward { .. } => 3,           // λ, U, dY
3362            Op::SpdBatchNormBackwardX { .. } => 3,    // mean, G, dY
3363            Op::SpdBatchNormBackwardG { .. } => 4,    // X, mean, G, dY
3364            Op::SpdLogMapBackward => 3,               // base, x, dY
3365            Op::SpdExpMapBackward => 3,               // base, v, dY
3366            Op::SpdParallelTransportBackward => 4,    // from, to, v, dY
3367            Op::SpdMatrixFnBatchBackward { .. } => 2, // X, dY
3368            Op::Eigh => 1,                            // A
3369            Op::EighBackward => 2,                    // fwd_packed, bar
3370            Op::EighBatch => 1,                       // A
3371            Op::EighBatchBackward => 2,               // fwd_packed, bar
3372            Op::AdaLayerNorm { .. } => 3,             // x, scale, shift
3373            Op::AdaLayerNormBackward { .. } => 4,     // x, scale, shift, dy
3374            Op::GatedResidual => 3,                   // x, y, gate
3375            Op::GatedResidualBackward => 4,           // x, y, gate, dy
3376        }
3377    }
3378}
3379
3380impl std::fmt::Display for Op {
3381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3382        match self {
3383            Op::Input { name } => write!(f, "input(\"{name}\")"),
3384            Op::Param { name } => write!(f, "param(\"{name}\")"),
3385            Op::Constant { data } => write!(f, "const({}B)", data.len()),
3386            Op::Activation(a) => write!(f, "{a:?}"),
3387            Op::Quantize { axis, scales, .. } => match axis {
3388                None => write!(f, "quantize(s={})", scales[0]),
3389                Some(d) => write!(f, "quantize(axis={d},nch={})", scales.len()),
3390            },
3391            Op::Dequantize { axis, scales, .. } => match axis {
3392                None => write!(f, "dequantize(s={})", scales[0]),
3393                Some(d) => write!(f, "dequantize(axis={d},nch={})", scales.len()),
3394            },
3395            Op::FakeQuantize {
3396                bits,
3397                axis,
3398                ste,
3399                scale_mode,
3400            } => match axis {
3401                None => write!(
3402                    f,
3403                    "fake_quant(bits={bits},ste={ste:?},scale={scale_mode:?})"
3404                ),
3405                Some(d) => write!(
3406                    f,
3407                    "fake_quant(bits={bits},axis={d},ste={ste:?},scale={scale_mode:?})"
3408                ),
3409            },
3410            Op::FakeQuantizeLSQ { bits, axis } => match axis {
3411                None => write!(f, "fake_quant_lsq(bits={bits})"),
3412                Some(d) => write!(f, "fake_quant_lsq(bits={bits},axis={d})"),
3413            },
3414            Op::FakeQuantizeLSQBackwardX { bits, .. } => {
3415                write!(f, "fake_quant_lsq_bwd_x(bits={bits})")
3416            }
3417            Op::FakeQuantizeLSQBackwardScale { bits, .. } => {
3418                write!(f, "fake_quant_lsq_bwd_s(bits={bits})")
3419            }
3420            Op::Cast { to } => write!(f, "cast({to})"),
3421            Op::StopGradient => write!(f, "stop_gradient"),
3422            Op::Binary(op) => write!(f, "{op:?}"),
3423            Op::Compare(op) => write!(f, "{op:?}"),
3424            Op::Where => write!(f, "where"),
3425            Op::Fma => write!(f, "fma"),
3426            Op::MatMul => write!(f, "matmul"),
3427            Op::DotGeneral { .. } => write!(f, "dot_general"),
3428            Op::DenseSolve => write!(f, "dense_solve"),
3429            Op::BatchedDenseSolve => write!(f, "batched_dense_solve"),
3430            Op::Cholesky => write!(f, "cholesky"),
3431            Op::TriangularSolve { lower, transpose } => {
3432                write!(f, "trisolve(lower={lower},trans={transpose})")
3433            }
3434            Op::Det => write!(f, "det"),
3435            Op::LogDet => write!(f, "logdet"),
3436            Op::Sort { axis, descending } => write!(f, "sort(axis={axis},desc={descending})"),
3437            Op::ArgSort { axis, descending } => {
3438                write!(f, "argsort(axis={axis},desc={descending})")
3439            }
3440            Op::Svd { part } => write!(f, "svd({part:?})"),
3441            Op::Qr { part } => write!(f, "qr({part:?})"),
3442            Op::LayerNorm { eps, .. } => write!(f, "layer_norm(eps={eps})"),
3443            Op::GroupNorm { num_groups, eps } => {
3444                write!(f, "group_norm(groups={num_groups},eps={eps})")
3445            }
3446            Op::BatchNormInference { eps } => write!(f, "batch_norm_inference(eps={eps})"),
3447            Op::ResizeNearest2x => write!(f, "resize_nearest_2x"),
3448            Op::Interpolate3d { size } => write!(
3449                f,
3450                "interpolate3d(size=[{},{},{}])",
3451                size.first().copied().unwrap_or(0),
3452                size.get(1).copied().unwrap_or(0),
3453                size.get(2).copied().unwrap_or(0)
3454            ),
3455            Op::RmsNorm { eps, .. } => write!(f, "rms_norm(eps={eps})"),
3456            Op::Attention {
3457                num_heads,
3458                head_dim,
3459                v_head_dim,
3460                mask_kind,
3461                score_scale,
3462                attn_logit_softcap,
3463            } => {
3464                let mut s = match mask_kind {
3465                    MaskKind::Custom => format!("attention(h={num_heads},d={head_dim})"),
3466                    MaskKind::None => format!("attention(h={num_heads},d={head_dim},nomask)"),
3467                    MaskKind::Causal => format!("attention(h={num_heads},d={head_dim},causal)"),
3468                    MaskKind::SlidingWindow(w) => {
3469                        format!("attention(h={num_heads},d={head_dim},sw={w})")
3470                    }
3471                    MaskKind::Bias => format!("attention(h={num_heads},d={head_dim},bias)"),
3472                };
3473                if let Some(vd) = v_head_dim {
3474                    s.push_str(&format!(",vd={vd}"));
3475                }
3476                if let Some(sc) = score_scale {
3477                    s.push_str(&format!(",scale={sc}"));
3478                }
3479                if let Some(cap) = attn_logit_softcap {
3480                    s.push_str(&format!(",softcap={cap}"));
3481                }
3482                write!(f, "{s}")
3483            }
3484            Op::Rope {
3485                head_dim,
3486                n_rot,
3487                style,
3488            } => write!(f, "rope(d={head_dim}, n_rot={n_rot}, style={style:?})"),
3489            Op::AxialRope2d {
3490                end_x,
3491                end_y,
3492                head_dim,
3493                num_heads,
3494                theta,
3495                repeat_factor,
3496            } => write!(
3497                f,
3498                "axial_rope2d({end_x}x{end_y},h={num_heads},d={head_dim},θ={theta},r={repeat_factor})"
3499            ),
3500            Op::Reshape { new_shape } => write!(f, "reshape({new_shape:?})"),
3501            Op::Transpose { perm } => write!(f, "transpose({perm:?})"),
3502            Op::Narrow { axis, start, len } => write!(f, "narrow({axis},{start},{len})"),
3503            Op::Slice {
3504                axis,
3505                start,
3506                len,
3507                step,
3508            } => {
3509                write!(f, "slice({axis},{start},{len},step={step})")
3510            }
3511            Op::Clamp { min, max } => write!(f, "clamp({min},{max})"),
3512            Op::Tile { reps } => write!(f, "tile({reps:?})"),
3513            Op::Trilu { upper, diagonal } => write!(f, "trilu(upper={upper},diag={diagonal})"),
3514            Op::Reverse { axes } => write!(f, "reverse({axes:?})"),
3515            Op::Pad { pads, mode } => write!(f, "pad({pads:?},{mode:?})"),
3516            Op::Concat { axis } => write!(f, "concat(axis={axis})"),
3517            Op::KvAppend { axis, pos } => write!(f, "kv_append(axis={axis}, pos={pos})"),
3518            Op::Expand { .. } => write!(f, "expand"),
3519            Op::Gather { axis } => write!(f, "gather(axis={axis})"),
3520            Op::Reduce { op, axes, .. } => write!(f, "reduce_{op:?}({axes:?})"),
3521            Op::Histogram { bins, min, max } => {
3522                write!(f, "histogram(bins={bins},[{min},{max}])")
3523            }
3524            Op::Softmax { axis } => write!(f, "softmax(axis={axis})"),
3525            Op::Cumsum { axis, exclusive } => {
3526                if *exclusive {
3527                    write!(f, "cumsum(axis={axis},excl)")
3528                } else {
3529                    write!(f, "cumsum(axis={axis})")
3530                }
3531            }
3532            Op::CumProd { axis, exclusive } => {
3533                if *exclusive {
3534                    write!(f, "cumprod(axis={axis},excl)")
3535                } else {
3536                    write!(f, "cumprod(axis={axis})")
3537                }
3538            }
3539            Op::CumMax { axis, exclusive } => {
3540                if *exclusive {
3541                    write!(f, "cummax(axis={axis},excl)")
3542                } else {
3543                    write!(f, "cummax(axis={axis})")
3544                }
3545            }
3546            Op::ArgMax { axis, keep_dim } => write!(f, "argmax(axis={axis},keep={keep_dim})"),
3547            Op::ArgMin { axis, keep_dim } => write!(f, "argmin(axis={axis},keep={keep_dim})"),
3548            Op::Sample {
3549                top_k,
3550                top_p,
3551                temperature,
3552                ..
3553            } => {
3554                write!(f, "sample(t={temperature}")?;
3555                if *top_k > 0 {
3556                    write!(f, ",k={top_k}")?;
3557                }
3558                if *top_p < 1.0 {
3559                    write!(f, ",p={top_p}")?;
3560                }
3561                write!(f, ")")
3562            }
3563            Op::RngNormal {
3564                mean,
3565                scale,
3566                key,
3567                op_seed,
3568            } => {
3569                write!(f, "rng_normal({mean},{scale},key={key}")?;
3570                if let Some(s) = op_seed {
3571                    write!(f, ",seed={s}")?;
3572                }
3573                write!(f, ")")
3574            }
3575            Op::RngUniform {
3576                low,
3577                high,
3578                key,
3579                op_seed,
3580            } => {
3581                write!(f, "rng_uniform({low},{high},key={key}")?;
3582                if let Some(s) = op_seed {
3583                    write!(f, ",seed={s}")?;
3584                }
3585                write!(f, ")")
3586            }
3587            Op::TopK { k } => write!(f, "topk(k={k})"),
3588            Op::GroupedMatMul => write!(f, "grouped_matmul"),
3589            Op::DequantGroupedMatMul { scheme } => {
3590                write!(f, "dequant_grouped_matmul({scheme})")
3591            }
3592            Op::DequantGroupedMatMulMlx { scheme } => {
3593                write!(f, "dequant_grouped_matmul_mlx({scheme})")
3594            }
3595            Op::DequantMoEWeights { scheme } => write!(f, "dequant_moe_weights({scheme})"),
3596            Op::ScaledGroupedMatMul {
3597                lhs_format,
3598                rhs_format,
3599                scale_layout,
3600                has_bias,
3601            } => write!(
3602                f,
3603                "scaled_grouped_matmul({lhs_format}×{rhs_format},{scale_layout}{})",
3604                if *has_bias { ",bias" } else { "" }
3605            ),
3606            Op::LoraMatMul { scale } => write!(f, "lora_matmul(scale={scale})"),
3607            Op::PartitionedConv { block } => write!(f, "partitioned_conv(block={block})"),
3608            Op::DequantMatMul { scheme } => write!(f, "dequant_matmul({scheme})"),
3609            Op::SynthMatMul {
3610                kind:
3611                    SynthKind::Codebook {
3612                        entry_dim,
3613                        num_entries,
3614                    },
3615            } => write!(f, "synth_matmul(codebook:{num_entries}×{entry_dim})"),
3616            Op::SynthMatMulBackward { wrt, .. } => {
3617                write!(f, "synth_matmul_backward({wrt:?})")
3618            }
3619            Op::SynthReconstruct { .. } => write!(f, "synth_reconstruct"),
3620            Op::SplineActivation {
3621                num_basis,
3622                grid_min,
3623                grid_max,
3624            } => write!(
3625                f,
3626                "spline_activation(basis={num_basis},grid=[{grid_min},{grid_max}])"
3627            ),
3628            Op::SplineActivationBackwardX { num_basis, .. } => {
3629                write!(f, "spline_activation_backward_x(basis={num_basis})")
3630            }
3631            Op::SplineActivationBackwardCoeff { num_basis, .. } => {
3632                write!(f, "spline_activation_backward_coeff(basis={num_basis})")
3633            }
3634            Op::QMatMul {
3635                x_zp,
3636                w_zp,
3637                out_zp,
3638                mult,
3639            } => write!(
3640                f,
3641                "q_matmul(x_zp={x_zp},w_zp={w_zp},out_zp={out_zp},mult={mult})"
3642            ),
3643            Op::QConv2d { kernel_size, .. } => write!(f, "q_conv2d({kernel_size:?})"),
3644            Op::ScaledMatMul {
3645                lhs_format,
3646                rhs_format,
3647                scale_layout,
3648                has_bias,
3649            } => write!(
3650                f,
3651                "scaled_matmul({lhs_format}×{rhs_format},{scale_layout}{})",
3652                if *has_bias { ",bias" } else { "" }
3653            ),
3654            Op::ScaledQuantize {
3655                format,
3656                scale_layout,
3657            } => write!(f, "scaled_quantize({format},{scale_layout})"),
3658            Op::ScaledQuantScale {
3659                format,
3660                scale_layout,
3661            } => write!(f, "scaled_quant_scale({format},{scale_layout})"),
3662            Op::ScaledDequantize {
3663                format,
3664                scale_layout,
3665            } => write!(f, "scaled_dequantize({format},{scale_layout})"),
3666            Op::SelectiveScan { state_size } => write!(f, "ssm_scan(n={state_size})"),
3667            Op::GatedDeltaNet {
3668                state_size,
3669                carry_state,
3670                gate_per_channel,
3671            } => {
3672                let gpc = if *gate_per_channel { ",gpc" } else { "" };
3673                if *carry_state {
3674                    write!(f, "gated_delta_net(n={state_size},carry{gpc})")
3675                } else {
3676                    write!(f, "gated_delta_net(n={state_size}{gpc})")
3677                }
3678            }
3679            Op::Lstm {
3680                hidden_size,
3681                num_layers,
3682                bidirectional,
3683                carry,
3684            } => {
3685                let dir = if *bidirectional { "bi" } else { "uni" };
3686                let c = if *carry { ",carry" } else { "" };
3687                write!(f, "lstm(h={hidden_size},layers={num_layers},{dir}{c})")
3688            }
3689            Op::Gru {
3690                hidden_size,
3691                num_layers,
3692                bidirectional,
3693                carry,
3694            } => {
3695                let dir = if *bidirectional { "bi" } else { "uni" };
3696                let c = if *carry { ",carry" } else { "" };
3697                write!(f, "gru(h={hidden_size},layers={num_layers},{dir}{c})")
3698            }
3699            Op::Rnn {
3700                hidden_size,
3701                num_layers,
3702                bidirectional,
3703                carry,
3704                relu,
3705            } => {
3706                let dir = if *bidirectional { "bi" } else { "uni" };
3707                let act = if *relu { "relu" } else { "tanh" };
3708                let c = if *carry { ",carry" } else { "" };
3709                write!(f, "rnn(h={hidden_size},layers={num_layers},{dir},{act}{c})")
3710            }
3711            Op::Mamba2 {
3712                head_dim,
3713                state_size,
3714            } => write!(f, "mamba2(p={head_dim},n={state_size})"),
3715            Op::ScatterAdd => write!(f, "scatter_add"),
3716            Op::ScatterNd { reduction } => write!(f, "scatter_nd({reduction:?})"),
3717            Op::ScatterElements { axis, reduction } => {
3718                write!(f, "scatter_elements(axis={axis},{reduction:?})")
3719            }
3720            Op::GatherNd { batch_dims } => write!(f, "gather_nd(batch_dims={batch_dims})"),
3721            Op::GatherElements { axis } => write!(f, "gather_elements(axis={axis})"),
3722            Op::Conv { kernel_size, .. } => write!(f, "conv2d({kernel_size:?})"),
3723            Op::Im2Col { kernel_size, .. } => write!(f, "im2col({kernel_size:?})"),
3724            Op::ConvTranspose2d { kernel_size, .. } => {
3725                write!(f, "conv_transpose2d({kernel_size:?})")
3726            }
3727            Op::Conv3d { stride, .. } => write!(f, "conv3d(stride={stride:?})"),
3728            Op::ConvTranspose3d { stride, .. } => {
3729                write!(f, "conv_transpose3d(stride={stride:?})")
3730            }
3731            Op::LayerNorm2d { eps } => write!(f, "layer_norm2d(eps={eps})"),
3732            Op::Pool {
3733                kind, kernel_size, ..
3734            } => write!(f, "pool_{kind:?}({kernel_size:?})"),
3735            Op::ReluBackward => write!(f, "relu_backward"),
3736            Op::ActivationBackward { kind } => write!(f, "{kind:?}_backward"),
3737            Op::ComplexNormSq => write!(f, "complex_norm_sq"),
3738            Op::ComplexNormSqBackward => write!(f, "complex_norm_sq_backward"),
3739            Op::Conjugate => write!(f, "conjugate"),
3740            Op::FakeQuantizeBackward { bits, ste, .. } => {
3741                write!(f, "fake_quant_backward(bits={bits},ste={ste:?})")
3742            }
3743            Op::MaxPool2dBackward { kernel_size, .. } => {
3744                write!(f, "maxpool2d_backward({kernel_size:?})")
3745            }
3746            Op::Conv2dBackwardInput { kernel_size, .. } => {
3747                write!(f, "conv2d_backward_input({kernel_size:?})")
3748            }
3749            Op::Conv2dBackwardWeight { kernel_size, .. } => {
3750                write!(f, "conv2d_backward_weight({kernel_size:?})")
3751            }
3752            Op::MaxPool3dBackward { kernel_size, .. } => {
3753                write!(f, "maxpool3d_backward({kernel_size:?})")
3754            }
3755            Op::Conv3dBackwardInput { kernel_size, .. } => {
3756                write!(f, "conv3d_backward_input({kernel_size:?})")
3757            }
3758            Op::Conv3dBackwardWeight { kernel_size, .. } => {
3759                write!(f, "conv3d_backward_weight({kernel_size:?})")
3760            }
3761            Op::SoftmaxCrossEntropy => write!(f, "sce"),
3762            Op::SoftmaxCrossEntropyWithLogits => write!(f, "sce_with_logits"),
3763            Op::SoftmaxCrossEntropyBackward => write!(f, "sce_backward"),
3764            Op::AttentionBackward {
3765                num_heads,
3766                head_dim,
3767                mask_kind,
3768                wrt,
3769            } => match mask_kind {
3770                MaskKind::None => write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},nomask)"),
3771                MaskKind::Causal => {
3772                    write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},causal)")
3773                }
3774                MaskKind::SlidingWindow(w) => {
3775                    write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},sw={w})")
3776                }
3777                MaskKind::Custom => {
3778                    write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},custom)")
3779                }
3780                MaskKind::Bias => write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},bias)"),
3781            },
3782            Op::AttentionBackwardAll {
3783                num_heads,
3784                head_dim,
3785                mask_kind,
3786            } => write!(f, "attn_bwd_all(h={num_heads},d={head_dim},{mask_kind:?})"),
3787            Op::FusedMatMulBiasAct { activation } => {
3788                write!(f, "fused_mm_bias")?;
3789                if let Some(a) = activation {
3790                    write!(f, "_{a:?}")?;
3791                }
3792                Ok(())
3793            }
3794            Op::FusedMatMulResidual => write!(f, "fused_mm_residual"),
3795            Op::FusedConvBiasAct {
3796                activation,
3797                has_residual,
3798                ..
3799            } => {
3800                write!(f, "fused_conv_bias")?;
3801                if *has_residual {
3802                    write!(f, "_res")?;
3803                }
3804                if let Some(a) = activation {
3805                    write!(f, "_{a:?}")?;
3806                }
3807                Ok(())
3808            }
3809            Op::FusedResidualLN { has_bias, eps } => {
3810                write!(f, "fused_residual")?;
3811                if *has_bias {
3812                    write!(f, "_bias")?;
3813                }
3814                write!(f, "_ln(eps={eps})")
3815            }
3816            Op::FusedResidualRmsNorm { has_bias, eps } => {
3817                write!(f, "fused_residual")?;
3818                if *has_bias {
3819                    write!(f, "_bias")?;
3820                }
3821                write!(f, "_rms(eps={eps})")
3822            }
3823            Op::FusedSwiGLU {
3824                cast_to,
3825                gate_first,
3826            } => {
3827                let mut s = match cast_to {
3828                    Some(dt) => format!("fused_swiglu(cast={dt}"),
3829                    None => "fused_swiglu(".to_string(),
3830                };
3831                if *gate_first {
3832                    s.push_str(",gate_first");
3833                }
3834                s.push(')');
3835                write!(f, "{s}")
3836            }
3837            Op::FusedAttentionBlock {
3838                num_heads,
3839                head_dim,
3840                has_bias,
3841                has_rope,
3842            } => {
3843                write!(f, "fused_attn(h={num_heads},d={head_dim}")?;
3844                if *has_bias {
3845                    write!(f, ",bias")?;
3846                }
3847                if *has_rope {
3848                    write!(f, ",rope")?;
3849                }
3850                write!(f, ")")
3851            }
3852            Op::If { .. } => write!(f, "if(...)"),
3853            Op::While { max_iterations, .. } => match max_iterations {
3854                Some(n) => write!(f, "while(...max={n})"),
3855                None => write!(f, "while(...)"),
3856            },
3857            Op::Scan {
3858                length,
3859                save_trajectory,
3860                num_xs,
3861                ..
3862            } => {
3863                let traj = if *save_trajectory { ",traj" } else { "" };
3864                let xs = if *num_xs > 0 {
3865                    format!(",xs={}", num_xs)
3866                } else {
3867                    String::new()
3868                };
3869                write!(f, "scan(len={length}{xs}{traj})")
3870            }
3871            Op::ScanBackward {
3872                length,
3873                save_trajectory,
3874                num_xs,
3875                ..
3876            } => {
3877                let traj = if *save_trajectory { ",traj" } else { "" };
3878                let xs = if *num_xs > 0 {
3879                    format!(",xs={}", num_xs)
3880                } else {
3881                    String::new()
3882                };
3883                write!(f, "scan_bwd(len={length}{xs}{traj})")
3884            }
3885            Op::ScanBackwardXs {
3886                length,
3887                save_trajectory,
3888                num_xs,
3889                xs_idx,
3890                ..
3891            } => {
3892                let traj = if *save_trajectory { ",traj" } else { "" };
3893                write!(
3894                    f,
3895                    "scan_bwd_xs(len={length},xs={num_xs},idx={xs_idx}{traj})"
3896                )
3897            }
3898            Op::FusedTransformerLayer {
3899                num_heads,
3900                head_dim,
3901                intermediate_size,
3902                has_bias,
3903                ..
3904            } => {
3905                write!(
3906                    f,
3907                    "fused_layer(h={num_heads},d={head_dim},int={intermediate_size}"
3908                )?;
3909                if *has_bias {
3910                    write!(f, ",bias")?;
3911                }
3912                write!(f, ")")
3913            }
3914            Op::ElementwiseRegion {
3915                chain,
3916                num_inputs,
3917                scalar_input_mask,
3918                input_modulus: _,
3919                prologue,
3920                prologue_input: _,
3921            } => {
3922                let pro = match prologue {
3923                    RegionPrologue::None => "",
3924                    RegionPrologue::ResizeNearest2x => ",prologue=resize2x",
3925                };
3926                if *scalar_input_mask != 0 {
3927                    write!(
3928                        f,
3929                        "ew_region(in={num_inputs},steps={},scalar_mask=0x{:x}{pro})",
3930                        chain.len(),
3931                        scalar_input_mask
3932                    )
3933                } else {
3934                    write!(f, "ew_region(in={num_inputs},steps={}{pro})", chain.len())
3935                }
3936            }
3937            Op::TransformRegion { steps, num_inputs } => {
3938                write!(f, "transform_region(in={num_inputs},steps={})", steps.len())
3939            }
3940            Op::BatchElementwiseRegion {
3941                chain,
3942                num_batch_inputs,
3943                scalar_input_mask,
3944                prologue,
3945                ..
3946            } => write!(
3947                f,
3948                "batch_ew_region(batch={num_batch_inputs},steps={},mask=0x{:x},prologue={prologue:?})",
3949                chain.len(),
3950                scalar_input_mask
3951            ),
3952            Op::LayerNormBackwardInput { eps, .. } => {
3953                write!(f, "layer_norm_backward_input(eps={eps})")
3954            }
3955            Op::LayerNormBackwardGamma { eps, .. } => {
3956                write!(f, "layer_norm_backward_gamma(eps={eps})")
3957            }
3958            Op::RmsNormBackwardInput { eps, .. } => write!(f, "rms_norm_backward_input(eps={eps})"),
3959            Op::RmsNormBackwardGamma { eps, .. } => write!(f, "rms_norm_backward_gamma(eps={eps})"),
3960            Op::RmsNormBackwardBeta { eps, .. } => write!(f, "rms_norm_backward_beta(eps={eps})"),
3961            Op::RopeBackward { head_dim, n_rot } => {
3962                write!(f, "rope_backward(d={head_dim},n_rot={n_rot})")
3963            }
3964            Op::GroupNormBackwardInput { num_groups, eps } => {
3965                write!(f, "group_norm_backward_input(g={num_groups},eps={eps})")
3966            }
3967            Op::GroupNormBackwardGamma { num_groups, eps } => {
3968                write!(f, "group_norm_backward_gamma(g={num_groups},eps={eps})")
3969            }
3970            Op::GroupNormBackwardBeta { num_groups, eps } => {
3971                write!(f, "group_norm_backward_beta(g={num_groups},eps={eps})")
3972            }
3973            Op::BatchNormInferenceBackwardInput { eps } => {
3974                write!(f, "batch_norm_inference_backward_input(eps={eps})")
3975            }
3976            Op::BatchNormInferenceBackwardGamma { eps } => {
3977                write!(f, "batch_norm_inference_backward_gamma(eps={eps})")
3978            }
3979            Op::BatchNormInferenceBackwardBeta => {
3980                write!(f, "batch_norm_inference_backward_beta")
3981            }
3982            Op::CumsumBackward { axis, exclusive } => {
3983                write!(f, "cumsum_backward(axis={axis},exclusive={exclusive})")
3984            }
3985            Op::GatherBackward { axis } => write!(f, "gather_backward(axis={axis})"),
3986            Op::GaussianSplatRender {
3987                width,
3988                height,
3989                tile_size,
3990                radius_scale,
3991                alpha_cutoff,
3992                max_splat_steps,
3993                transmittance_threshold,
3994                max_list_entries,
3995            } => write!(
3996                f,
3997                "gaussian_splat_render({width}x{height},tile={tile_size},r={radius_scale},a={alpha_cutoff},steps={max_splat_steps},t={transmittance_threshold},list={max_list_entries})"
3998            ),
3999            Op::GaussianSplatRenderBackward {
4000                width,
4001                height,
4002                loss_grad_clip,
4003                sh_band,
4004                ..
4005            } => write!(
4006                f,
4007                "gaussian_splat_render_bwd({width}x{height},clip={loss_grad_clip},sh={sh_band})"
4008            ),
4009            Op::GaussianSplatPrepare {
4010                width,
4011                height,
4012                tile_size,
4013                radius_scale,
4014                alpha_cutoff,
4015                max_splat_steps,
4016                transmittance_threshold,
4017                max_list_entries,
4018                ..
4019            } => write!(
4020                f,
4021                "gaussian_splat_prepare({width}x{height},tile={tile_size},r={radius_scale},a={alpha_cutoff},steps={max_splat_steps},t={transmittance_threshold},list={max_list_entries})"
4022            ),
4023            Op::GaussianSplatRasterize {
4024                width,
4025                height,
4026                tile_size,
4027                alpha_cutoff,
4028                max_splat_steps,
4029                transmittance_threshold,
4030                max_list_entries,
4031                ..
4032            } => write!(
4033                f,
4034                "gaussian_splat_rasterize({width}x{height},tile={tile_size},a={alpha_cutoff},steps={max_splat_steps},t={transmittance_threshold},list={max_list_entries})"
4035            ),
4036            Op::Custom {
4037                name,
4038                num_inputs,
4039                attrs,
4040            } => write!(f, "custom({name},in={num_inputs},attrs={}B)", attrs.len()),
4041            Op::CustomFn {
4042                num_inputs,
4043                vjp_body,
4044                jvp_body,
4045                ..
4046            } => {
4047                let v = if vjp_body.is_some() { ",vjp" } else { "" };
4048                let j = if jvp_body.is_some() { ",jvp" } else { "" };
4049                write!(f, "custom_fn(in={num_inputs}{v}{j})")
4050            }
4051            Op::Fft { inverse, norm } => {
4052                write!(f, "fft(inverse={inverse}, norm={norm:?})")
4053            }
4054            Op::FftButterflyStage { stage, n_fft } => {
4055                write!(f, "fft_butterfly_stage(stage={stage}, n_fft={n_fft})")
4056            }
4057            Op::LogMel => write!(f, "log_mel()"),
4058            Op::LogMelBackward => write!(f, "log_mel_backward()"),
4059            Op::WelchPeaks { k, n_segments } => {
4060                write!(f, "welch_peaks(k={k}, n_segments={n_segments})")
4061            }
4062            Op::BiMap => write!(f, "bimap()"),
4063            Op::ReEig { eps } => write!(f, "reeig(eps={eps})"),
4064            Op::LogEig { eps } => write!(f, "logeig(eps={eps})"),
4065            Op::SpdBatchNorm { eps } => write!(f, "spd_batch_norm(eps={eps})"),
4066            Op::SpdKarcherMean { iters, tol } => {
4067                write!(f, "spd_karcher_mean(iters={iters}, tol={tol})")
4068            }
4069            Op::SpdKarcherMeanWeighted { iters, tol } => {
4070                write!(f, "spd_karcher_mean_weighted(iters={iters}, tol={tol})")
4071            }
4072            Op::SpdLogMap => write!(f, "spd_log_map()"),
4073            Op::SpdExpMap => write!(f, "spd_exp_map()"),
4074            Op::SpdParallelTransport => write!(f, "spd_parallel_transport()"),
4075            Op::SpdMatrixFnBatch { kind } => write!(f, "spd_matrix_fn_batch(kind={kind:?})"),
4076            Op::ReEigBackward { eps } => write!(f, "reeig_backward(eps={eps})"),
4077            Op::LogEigBackward { eps } => write!(f, "logeig_backward(eps={eps})"),
4078            Op::SpdBatchNormBackwardX { eps } => {
4079                write!(f, "spd_batch_norm_backward_x(eps={eps})")
4080            }
4081            Op::SpdBatchNormBackwardG { eps } => {
4082                write!(f, "spd_batch_norm_backward_g(eps={eps})")
4083            }
4084            Op::SpdLogMapBackward => write!(f, "spd_log_map_backward()"),
4085            Op::SpdExpMapBackward => write!(f, "spd_exp_map_backward()"),
4086            Op::SpdParallelTransportBackward => write!(f, "spd_parallel_transport_backward()"),
4087            Op::SpdMatrixFnBatchBackward { kind } => {
4088                write!(f, "spd_matrix_fn_batch_backward(kind={kind:?})")
4089            }
4090            Op::Eigh => write!(f, "eigh()"),
4091            Op::EighBackward => write!(f, "eigh_backward()"),
4092            Op::EighBatch => write!(f, "eigh_batch()"),
4093            Op::EighBatchBackward => write!(f, "eigh_batch_backward()"),
4094            Op::AdaLayerNorm { norm, eps } => write!(f, "ada_layer_norm({norm:?},eps={eps})"),
4095            Op::AdaLayerNormBackward { norm, eps } => {
4096                write!(f, "ada_layer_norm_backward({norm:?},eps={eps})")
4097            }
4098            Op::GatedResidual => write!(f, "gated_residual"),
4099            Op::GatedResidualBackward => write!(f, "gated_residual_backward"),
4100        }
4101    }
4102}
4103
4104#[cfg(test)]
4105mod subgraph_accessor_tests {
4106    use super::*;
4107
4108    /// Number of boxed nested-graph fields declared across the whole [`Op`]
4109    /// enum. Every consumer that recurses into nested IR (structural hashing,
4110    /// verification, capability queries) goes through [`Op::subgraphs`], so a
4111    /// variant missing from the accessor would silently skip its body.
4112    const SUBGRAPH_FIELD_COUNT: usize = 12;
4113
4114    /// Drift guard, in the spirit of the `opcodes.rs` bijection tests: a new
4115    /// variant that gains a nested graph must also appear in [`Op::subgraphs`]
4116    /// / [`Op::subgraphs_mut`], or every recursive consumer silently skips it.
4117    ///
4118    /// The needle is assembled with `concat!` so this test file's own source
4119    /// text does not match it.
4120    #[test]
4121    fn subgraph_accessor_covers_every_nested_graph() {
4122        let src = include_str!("op.rs");
4123        let needle = concat!("Box<crate::", "Graph>");
4124        let declared = src.matches(needle).count();
4125        assert_eq!(
4126            declared, SUBGRAPH_FIELD_COUNT,
4127            "an Op variant gained or lost a nested graph field ({declared} found, \
4128             {SUBGRAPH_FIELD_COUNT} expected): update Op::subgraphs, \
4129             Op::subgraphs_mut and SUBGRAPH_FIELD_COUNT together"
4130        );
4131    }
4132
4133    #[test]
4134    fn ops_without_bodies_report_no_subgraphs() {
4135        assert!(Op::StopGradient.subgraphs().is_empty());
4136        assert!(Op::MatMul.subgraphs().is_empty());
4137    }
4138
4139    #[test]
4140    fn scan_exposes_its_body() {
4141        let body = crate::Graph::new("body");
4142        let op = Op::Scan {
4143            body: Box::new(body),
4144            length: 4,
4145            save_trajectory: false,
4146            num_bcast: 0,
4147            num_xs: 0,
4148            num_checkpoints: 0,
4149        };
4150        let subs = op.subgraphs();
4151        assert_eq!(subs.len(), 1);
4152        assert_eq!(subs[0].name, "body");
4153    }
4154}