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