Skip to main content

rlx_autodiff/
autodiff.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Reverse-mode automatic differentiation (VJP transform).
17//!
18//! Takes a forward graph that produces a single scalar output (the
19//! loss) and returns a new graph whose outputs are
20//! `[loss, grad_w_param0, grad_w_param1, ...]` for the parameters
21//! listed in `wrt`. Running the returned graph through any backend
22//! gives the loss and all parameter gradients in one pass.
23//!
24//! ## Implementation
25//!
26//! Standard reverse-mode AD: walk the forward graph in reverse topo
27//! order; for each visited node, emit gradient nodes that contribute
28//! to the gradients of its inputs. Multiple consumers' contributions
29//! are summed via `BinaryOp::Add`.
30//!
31//! For ops with a closed-form gradient kernel (`ReluBackward`,
32//! `MaxPool2dBackward`, `Conv2dBackwardInput/Weight`,
33//! `AttentionBackward`, `SoftmaxCrossEntropyBackward` — added in the
34//! rlx-ir backward-op
35//! family), the VJP emits the dedicated kernel rather than composing
36//! the gradient from primitives.
37//!
38//! ## Broadcast handling
39//!
40//! Forward broadcasts (e.g. `[N, C] + [C]` → `[N, C]`) require the
41//! reverse pass to *un-broadcast* the gradient back to the broadcast
42//! input's smaller shape via a `Reduce::Sum` over the inserted /
43//! tiled axes. `unbroadcast` does this; both `Op::Binary` and
44//! `Op::Expand` VJPs use it.
45//!
46//! ## Coverage
47//!
48//! Element-wise: `Binary(Add/Sub/Mul/Div/Min/Max/Pow)`,
49//! `Activation(*)` (Relu via dedicated `ReluBackward`, others via
50//! generic `ActivationBackward`), `Compare` (zero gradient),
51//! `Where`, `Cast`, `Quantize/Dequantize` (straight-through).
52//!
53//! Linear / reductions: `MatMul`, `Conv`, `Pool{Max,Mean}`,
54//! `Reduce{Sum,Mean,Min,Max,Prod}`, `Softmax`, `LayerNorm`
55//! (dedicated kernels), `RmsNorm` (composed), `Rope` (composed
56//! via negated sin), `SoftmaxCrossEntropyWithLogits`.
57//!
58//! Shape: `Reshape`, `Transpose`, `Expand`, `Concat`, `Narrow`,
59//! `Gather` (axis=0), `ScatterAdd`.
60//!
61//! Attention: `Op::Attention` → three [`Op::AttentionBackward`]
62//! nodes (`dQ` / `dK` / `dV`) for all mask kinds. Causal /
63//! SlidingWindow masks are applied inside the backward kernel (no
64//! mask tensor). `Custom` / `Bias` pass the forward mask input.
65//! before softmax; Custom uses the user-provided mask tensor;
66//! None is the no-mask path.
67//!
68//! Pre-pass: `UnfuseElementwiseRegions` runs automatically before
69//! the gradient walk so `Op::ElementwiseRegion` decomposes into
70//! its primitive chain (covered op-by-op above).
71//!
72//! Sampling-style (`TopK`, `Sample`): non-differentiable — emit no
73//! gradient (forward is a discrete selector / draw).
74//!
75//! Pre-pass: [`crate::prepare_ad::prepare_graph_for_ad`] runs before
76//! the gradient walk (also exposed as [`PrepareForAutodiff`] pass).
77//! It unfuses elementwise regions and tier-2 fused ops
78//! (`FusedMatMulBiasAct`, `FusedResidualLN`, `FusedResidualRmsNorm`,
79//! `FusedSwiGLU`, `FusedAttentionBlock`, `FusedTransformerLayer`,
80//! `GatedDeltaNet`, `SelectiveScan`, …), lowers `DotGeneral`, inlines
81//! `If` / unrolls `While`, inlines `CustomFn` without `vjp_body`, and
82//! rewrites scans for trajectory AD.
83//!
84//! For HIR builders, [`rlx_ir::hir::FusionPolicy::for_autodiff`] lowers
85//! to primitive MIR; [`grad_with_loss_module`] accepts HIR or MIR
86//! [`GraphModule`] stages (not LIR).
87//!
88//! Cumsum: backward composed via matmul with a constant
89//! upper-triangular ones matrix (avoids needing a new `Op::Flip`
90//! primitive across all backends). Fine for typical sequence
91//! lengths; an L×L matmul where L is the sequence size.
92//!
93//! Quantized / MoE: `Op::DequantMatMul` (QAT straight-through),
94//! `Op::QMatMul`, `Op::QConv2d`, and `Op::GroupedMatMul` are all
95//! supported via composed straight-through VJPs. Plain
96//! `Op::Quantize/Dequantize` straight-through covers the typical
97//! fake-quant fp32 training path.
98//!
99//! Coverage today: every op in the IR has a VJP rule or a
100//! pre-pass that rewrites it into ones that do. SelectiveScan
101//! (Mamba SSM step) and GatedDeltaNet (Qwen3.5 linear-attention
102//! scan) decompose by unrolling the time loop into Mul / Add /
103//! MatMul / Activation::Exp / Concat / Narrow / Reshape / Expand
104//! primitives — same shape as the rlx-mlx lowering, just emitted
105//! as IR nodes instead of MLX arrays.
106//! FusedTransformerLayer / FusedAttentionBlock / FusedSwiGLU /
107//! LoraMatMul / FusedMatMulBiasAct / FusedResidualLN are all
108//! decomposed by `rlx_fusion::unfuse_fused_for_autodiff` likewise. Op::If
109//! is rewritten to `Where(predicate, then, else)` with both
110//! branches inlined; Op::While is bounded-unrolled up to
111//! `max_iterations`.
112
113use rlx_ir::op::*;
114use rlx_ir::shape::Dim;
115use rlx_ir::*;
116use std::collections::HashMap;
117
118pub use crate::prepare_ad::{
119    AutodiffError, PrepareForAutodiff, grad_with_loss_module, jvp_module, prepare_graph_for_ad,
120    prepare_mir_for_ad, prepare_module_for_ad,
121};
122
123/// Compute the reverse-mode gradient graph and the loss value.
124///
125/// Returns a graph whose outputs are
126/// `[loss, aux₁, aux₂, …, grad_wrt[0], grad_wrt[1], …]`.
127///
128/// The **first** forward output is treated as the scalar loss and is
129/// differentiated. Any **additional** forward outputs (`aux₁ …`) are
130/// mirrored from the forward graph and emitted unchanged — gradients are
131/// not propagated through them. This is the canonical hook for emitting
132/// training-side statistics (BatchNorm batch mean/variance, debug probes,
133/// …) alongside the loss in a single forward+backward pass.
134///
135/// The returned graph contains a copy of the entire forward graph so
136/// activations needed by gradient kernels are recomputed from inputs;
137/// it also exposes a new `Op::Input` named `"d_output"` which the
138/// caller seeds with the upstream gradient of the loss (typically a
139/// scalar `1.0` for "differentiate the loss directly"). Auxiliary outputs
140/// have no `d_output`-equivalent — by construction they don't contribute
141/// to the gradient path.
142///
143/// ## Limitations
144/// - Forward graph must have **≥ 1** output. The first is the loss.
145/// - All ops in the forward graph must have an implemented VJP rule.
146///   Hitting an op without one is a panic, not a silent miscompute.
147/// Options for [`grad_with_loss_opts`].
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct GradWithLossOptions {
150    /// When true, parameters in `wrt` with no gradient path receive an
151    /// explicit zero tensor instead of panicking (e.g. unused `logit_bias`).
152    pub zero_missing_wrt: bool,
153}
154
155impl GradWithLossOptions {
156    pub const STRICT: Self = Self {
157        zero_missing_wrt: false,
158    };
159    pub const TRAINING: Self = Self {
160        zero_missing_wrt: true,
161    };
162}
163
164/// Build a backward graph with scalar loss + gradients w.r.t. `wrt`.
165///
166/// Panics if any `wrt` parameter receives no gradient (use
167/// [`grad_with_loss_opts`] with [`GradWithLossOptions::TRAINING`] to
168/// zero-fill instead).
169pub fn grad_with_loss(forward: &Graph, wrt: &[NodeId]) -> Graph {
170    grad_with_loss_opts(forward, wrt, GradWithLossOptions::STRICT)
171}
172
173/// Like [`grad_with_loss`] with configurable unused-parameter handling.
174pub fn grad_with_loss_opts(forward: &Graph, wrt: &[NodeId], opts: GradWithLossOptions) -> Graph {
175    assert!(
176        !forward.outputs.is_empty(),
177        "grad_with_loss: forward must have at least one output (the loss)"
178    );
179
180    // Pre-autodiff unfuse: decompose fused ops back to primitives so
181    // the per-op VJP rules cover them. Two layers:
182    //   1. `UnfuseElementwiseRegions` — splits the chain back to
183    //      Activation/Cast/Binary/Compare/Where ops.
184    //   2. `rlx_fusion::unfuse_fused_for_autodiff` (below) — handles the
185    //      tier-2 fused ops with closed-form decompositions:
186    //      FusedMatMulBiasAct, FusedResidualLN, LoraMatMul.
187    //
188    // FusedSwiGLU / FusedAttentionBlock / FusedTransformerLayer
189    // are all decomposed by `rlx_fusion::unfuse_fused_for_autodiff` (each is
190    // a multi-stage sub-graph; mirrors what `rlx-tpu/src/unfuse.rs`
191    // does for HLO emission).
192    let forward_owned = crate::prepare_ad::prepare_graph_for_ad(forward.clone());
193    let forward = &forward_owned;
194
195    let mut bwd = Graph::new(format!("{}_grad", forward.name));
196
197    // Mirror every forward node into bwd. The activations needed by
198    // gradient kernels (`x` for ReluBackward, `logits` for
199    // SoftmaxCrossEntropyBackward, etc.) are looked up by these
200    // mirrored ids.
201    let mut fwd_to_bwd: HashMap<NodeId, NodeId> = HashMap::new();
202    for node in forward.nodes() {
203        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| fwd_to_bwd[i]).collect();
204        let new_id = bwd.add_node(node.op.clone(), inputs, node.shape.clone());
205        fwd_to_bwd.insert(node.id, new_id);
206    }
207
208    // Seed: the gradient of the loss w.r.t. itself is an external
209    // input the caller provides (typically `[1.0]` for a scalar loss).
210    let loss_fwd = forward.outputs[0];
211    let loss_bwd = fwd_to_bwd[&loss_fwd];
212    let loss_shape = forward.node(loss_fwd).shape.clone();
213    let d_output = bwd.input("d_output", loss_shape);
214
215    let mut grads: HashMap<NodeId, NodeId> = HashMap::new();
216    grads.insert(loss_bwd, d_output);
217
218    for fwd_node in forward.nodes().iter().rev() {
219        let bwd_id = fwd_to_bwd[&fwd_node.id];
220        let upstream = match grads.get(&bwd_id) {
221            Some(g) => *g,
222            None => continue,
223        };
224        let input_grads = vjp(fwd_node, upstream, &fwd_to_bwd, &mut bwd);
225        for (idx, grad_id) in input_grads {
226            let target = fwd_node.inputs[idx];
227            let bwd_target = fwd_to_bwd[&target];
228            // Per-consumer gradients accumulate (`+=`).
229            let new_grad = if let Some(&prev) = grads.get(&bwd_target) {
230                let shape = bwd.node(prev).shape.clone();
231                bwd.binary(BinaryOp::Add, prev, grad_id, shape)
232            } else {
233                grad_id
234            };
235            grads.insert(bwd_target, new_grad);
236        }
237    }
238
239    let n_aux = forward.outputs.len().saturating_sub(1);
240    let mut outputs = Vec::with_capacity(1 + n_aux + wrt.len());
241    outputs.push(loss_bwd);
242    // Auxiliary forward outputs (everything past `outputs[0]`): mirrored
243    // from the forward graph, no gradient propagation.
244    for &aux in &forward.outputs[1..] {
245        outputs.push(fwd_to_bwd[&aux]);
246    }
247    for &id in wrt {
248        let g = match grads.get(&fwd_to_bwd[&id]).copied() {
249            Some(g) => g,
250            None if opts.zero_missing_wrt => {
251                let shape = forward.node(id).shape.clone();
252                let n = shape.num_elements().unwrap_or(0);
253                let data: Vec<u8> = (0..n).flat_map(|_| 0.0f32.to_le_bytes()).collect();
254                bwd.add_node(Op::Constant { data }, vec![], shape)
255            }
256            None => {
257                panic!(
258                    "no gradient flowed to {id} — \
259                    either the forward graph doesn't depend on it, or one \
260                    of its consumer ops has no VJP rule"
261                )
262            }
263        };
264        outputs.push(g);
265    }
266    bwd.set_outputs(outputs);
267    bwd
268}
269
270/// Backwards-compatible single-output alias (parameter gradients only,
271/// no loss). Kept for the existing tests; prefer `grad_with_loss` for
272/// training.
273pub fn grad(forward: &Graph, wrt: &[NodeId]) -> Graph {
274    let g = grad_with_loss(forward, wrt);
275    let mut g = g;
276    // Drop the loss output, keep only gradients.
277    let outs = g.outputs.iter().skip(1).copied().collect();
278    g.set_outputs(outs);
279    g
280}
281
282/// Project a gradient back to a smaller shape it was broadcasted from.
283/// `target_shape` is the broadcast *source* shape (e.g. `[C]` for a
284/// bias added to `[N, C, H, W]`). Sums over leading prepended axes
285/// and over any axis where target was 1 but the gradient is larger.
286/// Then reshapes to drop the size-1 axes if the rank shrunk.
287/// Returns `Some(bits)` if `node_id` is the output of an
288/// `Op::FakeQuantize { bits, .. }` (or `FakeQuantizeLSQ`) in the
289/// forward graph. Used by the autodiff Conv backward to detect the
290/// QAT pattern and emit a specialized weight-grad kernel that can
291/// skip dead bins (weights that round to the same code share the
292/// gradient). Today only the detection is exposed — the
293/// specialization is a follow-up commit.
294pub fn quantized_weight_bits(forward: &Graph, node_id: NodeId) -> Option<u8> {
295    match &forward.node(node_id).op {
296        Op::FakeQuantize { bits, .. } => Some(*bits),
297        Op::FakeQuantizeLSQ { bits, .. } => Some(*bits),
298        _ => None,
299    }
300}
301
302fn unbroadcast(grad: NodeId, target_shape: &Shape, bwd: &mut Graph) -> NodeId {
303    let grad_shape = bwd.node(grad).shape.clone();
304    if grad_shape == *target_shape {
305        return grad;
306    }
307    let g_rank = grad_shape.rank();
308    let t_rank = target_shape.rank();
309    let extra = g_rank.saturating_sub(t_rank);
310
311    // Axes (in grad's coordinate system) that need summing.
312    let mut axes: Vec<usize> = (0..extra).collect();
313    for i in 0..t_rank {
314        let g_dim = grad_shape.dim(extra + i);
315        let t_dim = target_shape.dim(i);
316        if matches!(t_dim, Dim::Static(1)) && !matches!(g_dim, Dim::Static(1)) {
317            axes.push(extra + i);
318        }
319    }
320
321    let mut current = grad;
322    if !axes.is_empty() {
323        // The CPU `Op::Reduce` thunk only supports a *single contiguous*
324        // range of axes — `[0, 2, 3]` (the canonical conv-bias-gradient
325        // pattern) would silently fall through to a `Nop`. Decompose into
326        // a chain of single-axis reductions with `keep_dim=true` so rank
327        // stays at `g_rank` and earlier axis indices remain valid; the
328        // rank shrink (if any) happens in the reshape step below.
329        let mut running_dims: Vec<Dim> = (0..g_rank).map(|i| grad_shape.dim(i)).collect();
330        for &ax in &axes {
331            running_dims[ax] = Dim::Static(1);
332            let step_shape = Shape::from_dims(&running_dims, target_shape.dtype());
333            current = bwd.add_node(
334                Op::Reduce {
335                    op: ReduceOp::Sum,
336                    axes: vec![ax],
337                    keep_dim: true,
338                },
339                vec![current],
340                step_shape,
341            );
342        }
343    }
344
345    // Drop leading 1-axes via Reshape if the target rank is smaller.
346    if bwd.node(current).shape.rank() != t_rank {
347        let new_shape: Vec<i64> = target_shape
348            .dims()
349            .iter()
350            .map(|d| match d {
351                Dim::Static(n) => *n as i64,
352                Dim::Dynamic(_) => -1,
353            })
354            .collect();
355        current = bwd.add_node(
356            Op::Reshape { new_shape },
357            vec![current],
358            target_shape.clone(),
359        );
360    }
361    current
362}
363
364/// Reshape a gradient to a target shape (used by Reshape / Mean VJPs).
365fn reshape_to(grad: NodeId, target_shape: &Shape, bwd: &mut Graph) -> NodeId {
366    if bwd.node(grad).shape == *target_shape {
367        return grad;
368    }
369    let new_shape: Vec<i64> = target_shape
370        .dims()
371        .iter()
372        .map(|d| match d {
373            Dim::Static(n) => *n as i64,
374            Dim::Dynamic(_) => -1,
375        })
376        .collect();
377    bwd.add_node(Op::Reshape { new_shape }, vec![grad], target_shape.clone())
378}
379
380/// VJP for `Op::GroupedMatMul` / dequantized MoE matmul (`dx`, `dw`).
381fn grouped_matmul_vjp(
382    bwd: &mut Graph,
383    upstream: NodeId,
384    x_bwd: NodeId,
385    w_bwd: NodeId,
386    expert_bwd: NodeId,
387    x_shape: &Shape,
388    w_shape: &Shape,
389) -> (NodeId, NodeId) {
390    let dtype = x_shape.dtype();
391    let m = x_shape.dim(0);
392    let k = x_shape.dim(1);
393    let e = w_shape.dim(0);
394    let n_out = w_shape.dim(2);
395    let m_static = match m {
396        Dim::Static(v) => v,
397        _ => panic!("GroupedMatMul VJP: M must be static"),
398    };
399    let k_static = match k {
400        Dim::Static(v) => v,
401        _ => panic!("GroupedMatMul VJP: K must be static"),
402    };
403    let n_static = match n_out {
404        Dim::Static(v) => v,
405        _ => panic!("GroupedMatMul VJP: N must be static"),
406    };
407
408    let w_per = bwd.add_node(
409        Op::Gather { axis: 0 },
410        vec![w_bwd, expert_bwd],
411        Shape::from_dims(&[m, k, n_out], dtype),
412    );
413
414    let up_3d_shape = Shape::from_dims(&[m, Dim::Static(1), n_out], dtype);
415    let up_3d = bwd.reshape(
416        upstream,
417        vec![m_static as i64, 1, n_static as i64],
418        up_3d_shape,
419    );
420    let w_per_t = bwd.add_node(
421        Op::Transpose {
422            perm: vec![0, 2, 1],
423        },
424        vec![w_per],
425        Shape::from_dims(&[m, n_out, k], dtype),
426    );
427    let dx_3d_shape = Shape::from_dims(&[m, Dim::Static(1), k], dtype);
428    let dx_3d = bwd.matmul(up_3d, w_per_t, dx_3d_shape);
429    let dx = bwd.reshape(
430        dx_3d,
431        vec![m_static as i64, k_static as i64],
432        x_shape.clone(),
433    );
434
435    let x_3d = bwd.reshape(
436        x_bwd,
437        vec![m_static as i64, k_static as i64, 1],
438        Shape::from_dims(&[m, k, Dim::Static(1)], dtype),
439    );
440    let up_for_outer = bwd.reshape(
441        upstream,
442        vec![m_static as i64, 1, n_static as i64],
443        Shape::from_dims(&[m, Dim::Static(1), n_out], dtype),
444    );
445    let dw_per = bwd.matmul(x_3d, up_for_outer, Shape::from_dims(&[m, k, n_out], dtype));
446    let dw = bwd.add_node(
447        Op::ScatterAdd,
448        vec![dw_per, expert_bwd],
449        Shape::from_dims(&[e, k, n_out], dtype),
450    );
451    (dx, dw)
452}
453
454/// Build a scalar f32 `Op::Constant` node.
455fn scalar_const(value: f32, bwd: &mut Graph) -> NodeId {
456    let bytes = value.to_le_bytes().to_vec();
457    let shape = Shape::from_dims(&[Dim::Static(1)], DType::F32);
458    bwd.add_node(Op::Constant { data: bytes }, vec![], shape)
459}
460
461/// Per-op VJP rule. Returns (input_index, gradient_node_id) pairs;
462/// inputs not listed receive no gradient (e.g. the labels argument
463/// of `SoftmaxCrossEntropyWithLogits` is non-differentiable).
464#[allow(unused_variables)]
465fn vjp(
466    node: &Node,
467    upstream: NodeId,
468    fwd_map: &HashMap<NodeId, NodeId>,
469    bwd: &mut Graph,
470) -> Vec<(usize, NodeId)> {
471    let upstream_shape = bwd.node(upstream).shape.clone();
472    match &node.op {
473        // Leaves — no inputs → no gradients to attribute.
474        Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => vec![],
475
476        Op::Binary(BinaryOp::Add) => vjp_binary_add(node, upstream, upstream_shape, fwd_map, bwd),
477        Op::Binary(BinaryOp::Sub) => vjp_binary_sub(node, upstream, upstream_shape, fwd_map, bwd),
478        Op::Binary(BinaryOp::Mul) => vjp_binary_mul(node, upstream, upstream_shape, fwd_map, bwd),
479        Op::Activation(kind) => vjp_activation(node, upstream, upstream_shape, fwd_map, bwd),
480        Op::MatMul => vjp_mat_mul(node, upstream, upstream_shape, fwd_map, bwd),
481        Op::DenseSolve => vjp_dense_solve(node, upstream, upstream_shape, fwd_map, bwd),
482        Op::BatchedDenseSolve => {
483            vjp_batched_dense_solve(node, upstream, upstream_shape, fwd_map, bwd)
484        }
485        Op::Scan {
486            body,
487            length,
488            save_trajectory,
489            num_bcast: _,
490            num_xs,
491            num_checkpoints,
492        } => vjp_scan(node, upstream, upstream_shape, fwd_map, bwd),
493        Op::Conv {
494            kernel_size,
495            stride,
496            padding,
497            dilation,
498            groups,
499        } => vjp_conv(node, upstream, upstream_shape, fwd_map, bwd),
500        Op::Pool {
501            kind: ReduceOp::Max,
502            kernel_size,
503            stride,
504            padding,
505        } => vjp_pool(node, upstream, upstream_shape, fwd_map, bwd),
506        Op::SoftmaxCrossEntropyWithLogits => {
507            vjp_softmax_cross_entropy_with_logits(node, upstream, upstream_shape, fwd_map, bwd)
508        }
509        Op::SoftmaxCrossEntropy => {
510            vjp_softmax_cross_entropy(node, upstream, upstream_shape, fwd_map, bwd)
511        }
512        Op::Reduce {
513            op: ReduceOp::Sum,
514            axes,
515            keep_dim,
516        } => vjp_reduce(node, upstream, upstream_shape, fwd_map, bwd),
517        Op::Reduce {
518            op: ReduceOp::Mean,
519            axes,
520            keep_dim,
521        } => vjp_reduce_2(node, upstream, upstream_shape, fwd_map, bwd),
522        Op::Reshape { .. } => vjp_reshape(node, upstream, upstream_shape, fwd_map, bwd),
523        Op::ComplexNormSq => vjp_complex_norm_sq(node, upstream, upstream_shape, fwd_map, bwd),
524        Op::Conjugate => vjp_conjugate(node, upstream, upstream_shape, fwd_map, bwd),
525        Op::Cast { .. } => vjp_cast(node, upstream, upstream_shape, fwd_map, bwd),
526        Op::StopGradient => vjp_stop_gradient(node, upstream, upstream_shape, fwd_map, bwd),
527        // Straight-through estimator: forward simulates the lossy
528        // round-trip (x → q → x'), backward pretends it was an
529        // identity. `dx = upstream` for both ops. The upstream is the
530        // f32 gradient computed by the consumer; the int8 dtype on
531        // the input/output is ignored for the gradient — we treat
532        // the entire Q/DQ chain as a real-valued no-op for autodiff
533        // purposes. This is the foundation for QAT (quantization-
534        // aware training): the model trains in fp32 but every
535        // forward pass tastes the int8 round-tripped activations,
536        // so the learned weights are robust to deployment-time
537        // quantization.
538        Op::Quantize { .. } | Op::Dequantize { .. } => {
539            vec![(0, upstream)]
540        }
541
542        Op::FakeQuantizeLSQ { bits, axis } => {
543            vjp_fake_quantize_l_s_q(node, upstream, upstream_shape, fwd_map, bwd)
544        }
545        Op::FakeQuantize {
546            bits, axis, ste, ..
547        } => vjp_fake_quantize(node, upstream, upstream_shape, fwd_map, bwd),
548        Op::Expand { .. } => vjp_expand(node, upstream, upstream_shape, fwd_map, bwd),
549        Op::BatchNormInference { eps } => {
550            vjp_batch_norm_inference(node, upstream, upstream_shape, fwd_map, bwd)
551        }
552        Op::LayerNorm { axis, eps } => vjp_layer_norm(node, upstream, upstream_shape, fwd_map, bwd),
553        Op::Softmax { axis } => vjp_softmax(node, upstream, upstream_shape, fwd_map, bwd),
554        Op::Transpose { perm } => vjp_transpose(node, upstream, upstream_shape, fwd_map, bwd),
555        Op::Concat { axis } => vjp_concat(node, upstream, upstream_shape, fwd_map, bwd),
556        Op::Narrow { axis, start, len } => vjp_narrow(node, upstream, upstream_shape, fwd_map, bwd),
557        Op::Gather { axis } => vjp_gather(node, upstream, upstream_shape, fwd_map, bwd),
558        Op::Compare(_) => vjp_compare(node, upstream, upstream_shape, fwd_map, bwd),
559        Op::Where => vjp_where(node, upstream, upstream_shape, fwd_map, bwd),
560        Op::Binary(BinaryOp::Div) => vjp_binary_div(node, upstream, upstream_shape, fwd_map, bwd),
561        // ── Reductions: gradient flows to where the reduction "saw" ──
562        Op::Reduce {
563            op: ReduceOp::Max,
564            axes,
565            keep_dim,
566        }
567        | Op::Reduce {
568            op: ReduceOp::Min,
569            axes,
570            keep_dim,
571        } => {
572            // d_x[i] = upstream where x[i] equals the (broadcast)
573            // reduce result, else 0. Composed via
574            // expand(upstream) * (compare(x, expand(y), Eq) → 1.0).
575            let is_max = matches!(
576                node.op,
577                Op::Reduce {
578                    op: ReduceOp::Max,
579                    ..
580                }
581            );
582            let _ = is_max;
583            let x_bwd = fwd_map[&node.inputs[0]];
584            let y_bwd = fwd_map[&node.id];
585            let x_shape = bwd.node(x_bwd).shape.clone();
586            let y_expanded = expand_to(y_bwd, &x_shape, axes, *keep_dim, bwd);
587            let mask_bool = bwd.add_node(
588                Op::Compare(CmpOp::Eq),
589                vec![x_bwd, y_expanded],
590                Shape::from_dims(x_shape.dims(), DType::Bool),
591            );
592            // Convert bool→f32 via Cast (the IR encodes bool/PRED as
593            // F32 in our backends already; this is a no-op cast on
594            // most paths).
595            let mask_f32 = bwd.add_node(
596                Op::Cast {
597                    to: x_shape.dtype(),
598                },
599                vec![mask_bool],
600                x_shape.clone(),
601            );
602            let upstream_expanded = expand_to(upstream, &x_shape, axes, *keep_dim, bwd);
603            let dx = bwd.binary(BinaryOp::Mul, upstream_expanded, mask_f32, x_shape);
604            vec![(0, dx)]
605        }
606
607        Op::Rope {
608            head_dim, n_rot, ..
609        } => vjp_rope(node, upstream, upstream_shape, fwd_map, bwd),
610        Op::RmsNorm { axis, eps } => vjp_rms_norm(node, upstream, upstream_shape, fwd_map, bwd),
611        Op::GroupNorm { num_groups, eps } => {
612            vjp_group_norm(node, upstream, upstream_shape, fwd_map, bwd)
613        }
614        Op::Attention {
615            num_heads,
616            head_dim,
617            mask_kind,
618            score_scale: _,
619            attn_logit_softcap: _,
620        } => vjp_attention(node, upstream, upstream_shape, fwd_map, bwd),
621        Op::Reduce {
622            op: ReduceOp::Prod,
623            axes,
624            keep_dim,
625        } => vjp_reduce_3(node, upstream, upstream_shape, fwd_map, bwd),
626        Op::Pool {
627            kind: ReduceOp::Mean,
628            kernel_size,
629            stride,
630            padding,
631        } => vjp_pool_2(node, upstream, upstream_shape, fwd_map, bwd),
632        // ── Binary(Min/Max) ─────────────────────────────────────
633        //
634        // Element-wise min/max: gradient flows to whichever input
635        // was selected (ties go to the first operand by convention).
636        //   da = where(a == out, upstream, 0)
637        //   db = where(a == out, 0, upstream)   ← exclusive
638        Op::Binary(BinaryOp::Min) | Op::Binary(BinaryOp::Max) => {
639            let a_bwd = fwd_map[&node.inputs[0]];
640            let b_bwd = fwd_map[&node.inputs[1]];
641            let y_bwd = fwd_map[&node.id];
642            let a_shape = bwd.node(a_bwd).shape.clone();
643            let b_shape = bwd.node(b_bwd).shape.clone();
644            let dtype = upstream_shape.dtype();
645
646            let bool_shape = Shape::from_dims(upstream_shape.dims(), DType::Bool);
647            let mask_pred = bwd.add_node(Op::Compare(CmpOp::Eq), vec![a_bwd, y_bwd], bool_shape);
648            let mask_f32 = bwd.add_node(
649                Op::Cast { to: dtype },
650                vec![mask_pred],
651                upstream_shape.clone(),
652            );
653            let zero_bytes = vec![
654                0u8;
655                upstream_shape
656                    .num_elements()
657                    .expect("Min/Max VJP: dyn shape")
658                    * 4
659            ];
660            let zero = bwd.add_node(
661                Op::Constant { data: zero_bytes },
662                vec![],
663                upstream_shape.clone(),
664            );
665            let g_a_full = bwd.add_node(
666                Op::Where,
667                vec![mask_f32, upstream, zero],
668                upstream_shape.clone(),
669            );
670            let g_b_full = bwd.add_node(Op::Where, vec![mask_f32, zero, upstream], upstream_shape);
671            let g_a = unbroadcast(g_a_full, &a_shape, bwd);
672            let g_b = unbroadcast(g_b_full, &b_shape, bwd);
673            vec![(0, g_a), (1, g_b)]
674        }
675
676        Op::Binary(BinaryOp::Pow) => vjp_binary_pow(node, upstream, upstream_shape, fwd_map, bwd),
677        Op::ScaledMatMul {
678            lhs_format,
679            rhs_format,
680            scale_layout,
681            has_bias,
682        } => vjp_scaled_mat_mul(node, upstream, upstream_shape, fwd_map, bwd),
683        Op::ScaledQuantize { .. } => {
684            vjp_scaled_quantize(node, upstream, upstream_shape, fwd_map, bwd)
685        }
686        Op::ScaledQuantScale { .. } => {
687            vjp_scaled_quant_scale(node, upstream, upstream_shape, fwd_map, bwd)
688        }
689        Op::DequantMatMul { scheme: _ } => {
690            vjp_dequant_mat_mul(node, upstream, upstream_shape, fwd_map, bwd)
691        }
692        Op::ScatterAdd => vjp_scatter_add(node, upstream, upstream_shape, fwd_map, bwd),
693        Op::Cumsum { axis, exclusive } => vjp_cumsum(node, upstream, upstream_shape, fwd_map, bwd),
694        Op::GroupedMatMul => vjp_grouped_mat_mul(node, upstream, upstream_shape, fwd_map, bwd),
695        Op::DequantGroupedMatMul { scheme } => {
696            vjp_dequant_grouped_mat_mul(node, upstream, upstream_shape, fwd_map, bwd)
697        }
698        Op::QMatMul {
699            x_zp,
700            w_zp,
701            out_zp: _,
702            mult,
703        } => vjp_q_mat_mul(node, upstream, upstream_shape, fwd_map, bwd),
704        Op::QConv2d {
705            kernel_size,
706            stride,
707            padding,
708            dilation,
709            groups,
710            x_zp,
711            w_zp,
712            out_zp: _,
713            mult,
714        } => vjp_q_conv2d(node, upstream, upstream_shape, fwd_map, bwd),
715        // ── Sampling-style ops: non-differentiable ──
716        Op::TopK { .. } | Op::Sample { .. } | Op::RngNormal { .. } | Op::RngUniform { .. } => {
717            // TopK selects; Sample multinomial-draws. Gradient w.r.t.
718            // the input distribution is undefined / zero in the
719            // standard sense. Skip propagation.
720            vec![]
721        }
722
723        Op::GaussianSplatRender {
724            width,
725            height,
726            tile_size,
727            radius_scale,
728            alpha_cutoff,
729            max_splat_steps,
730            transmittance_threshold,
731            max_list_entries,
732            ..
733        } => vjp_gaussian_splat_render(node, upstream, upstream_shape, fwd_map, bwd),
734        Op::GaussianSplatRenderBackward { .. } => {
735            vjp_gaussian_splat_render_backward(node, upstream, upstream_shape, fwd_map, bwd)
736        }
737        Op::GaussianSplatPrepare { .. } | Op::GaussianSplatRasterize { .. } => {
738            panic!(
739                "autodiff: decomposed splat ops must be fused before AD — \
740                 `prepare_graph_for_ad` rewrites Prepare→Rasterize into \
741                 `GaussianSplatRender`, or use `Op::GaussianSplatRender` directly"
742            );
743        }
744
745        Op::CustomFn {
746            vjp_body: Some(vjp_body),
747            num_inputs,
748            ..
749        } => vjp_custom_fn(node, upstream, upstream_shape, fwd_map, bwd),
750        Op::CustomFn { vjp_body: None, .. } => {
751            vjp_custom_fn_2(node, upstream, upstream_shape, fwd_map, bwd)
752        }
753        Op::Custom { name, .. } => vjp_custom(node, upstream, upstream_shape, fwd_map, bwd),
754        Op::Conv2dBackwardInput {
755            kernel_size,
756            stride,
757            padding,
758            dilation,
759            groups,
760        } => vjp_conv2d_backward_input(node, upstream, upstream_shape, fwd_map, bwd),
761        Op::Conv2dBackwardWeight {
762            kernel_size,
763            stride,
764            padding,
765            dilation,
766            groups,
767        } => vjp_conv2d_backward_weight(node, upstream, upstream_shape, fwd_map, bwd),
768        Op::Fft { inverse, norm } => vjp_fft(node, upstream, upstream_shape, fwd_map, bwd),
769        Op::LogMel => vjp_log_mel(node, upstream, upstream_shape, fwd_map, bwd),
770        // The catch-all below remains as a safety net: if a
771        // future op is added without a VJP rule, this panic
772        // names it for the implementer.
773        other => panic!(
774            "autodiff: no VJP rule for {other}. See the matching \
775             entry in rlx-opt/src/autodiff.rs (catch-all panic) for \
776             a pointer to what's needed to differentiate this op.",
777        ),
778    }
779}
780
781#[allow(unused_variables)]
782fn vjp_binary_add(
783    node: &Node,
784    upstream: NodeId,
785    upstream_shape: Shape,
786    fwd_map: &HashMap<NodeId, NodeId>,
787    bwd: &mut Graph,
788) -> Vec<(usize, NodeId)> {
789    let Op::Binary(BinaryOp::Add) = &node.op else {
790        unreachable!()
791    };
792    {
793        let a_bwd = fwd_map[&node.inputs[0]];
794        let b_bwd = fwd_map[&node.inputs[1]];
795        let a_shape = bwd.node(a_bwd).shape.clone();
796        let b_shape = bwd.node(b_bwd).shape.clone();
797        let g_a = unbroadcast(upstream, &a_shape, bwd);
798        let g_b = unbroadcast(upstream, &b_shape, bwd);
799        vec![(0, g_a), (1, g_b)]
800    }
801}
802
803#[allow(unused_variables)]
804fn vjp_binary_sub(
805    node: &Node,
806    upstream: NodeId,
807    upstream_shape: Shape,
808    fwd_map: &HashMap<NodeId, NodeId>,
809    bwd: &mut Graph,
810) -> Vec<(usize, NodeId)> {
811    let Op::Binary(BinaryOp::Sub) = &node.op else {
812        unreachable!()
813    };
814    {
815        let a_bwd = fwd_map[&node.inputs[0]];
816        let b_bwd = fwd_map[&node.inputs[1]];
817        let a_shape = bwd.node(a_bwd).shape.clone();
818        let b_shape = bwd.node(b_bwd).shape.clone();
819        let neg = bwd.activation(Activation::Neg, upstream, upstream_shape.clone());
820        let g_a = unbroadcast(upstream, &a_shape, bwd);
821        let g_b = unbroadcast(neg, &b_shape, bwd);
822        vec![(0, g_a), (1, g_b)]
823    }
824}
825
826#[allow(unused_variables)]
827fn vjp_binary_mul(
828    node: &Node,
829    upstream: NodeId,
830    upstream_shape: Shape,
831    fwd_map: &HashMap<NodeId, NodeId>,
832    bwd: &mut Graph,
833) -> Vec<(usize, NodeId)> {
834    let Op::Binary(BinaryOp::Mul) = &node.op else {
835        unreachable!()
836    };
837    {
838        let a_bwd = fwd_map[&node.inputs[0]];
839        let b_bwd = fwd_map[&node.inputs[1]];
840        let a_shape = bwd.node(a_bwd).shape.clone();
841        let b_shape = bwd.node(b_bwd).shape.clone();
842        // Wirtinger over C64: y = a·b → dL/dā = upstream · conj(b),
843        // dL/db̄ = upstream · conj(a). The conjugates turn the
844        // standard real Mul rule into the correct complex one
845        // without changing the kernel — `BinaryFullC64` does the
846        // native complex multiply on whatever inputs we hand it.
847        let is_c64 = upstream_shape.dtype() == DType::C64;
848        let b_for_a = if is_c64 { bwd.conjugate(b_bwd) } else { b_bwd };
849        let a_for_b = if is_c64 { bwd.conjugate(a_bwd) } else { a_bwd };
850        let g_a_full = bwd.binary(BinaryOp::Mul, upstream, b_for_a, upstream_shape.clone());
851        let g_b_full = bwd.binary(BinaryOp::Mul, upstream, a_for_b, upstream_shape);
852        let g_a = unbroadcast(g_a_full, &a_shape, bwd);
853        let g_b = unbroadcast(g_b_full, &b_shape, bwd);
854        vec![(0, g_a), (1, g_b)]
855    }
856}
857
858#[allow(unused_variables)]
859fn vjp_activation(
860    node: &Node,
861    upstream: NodeId,
862    upstream_shape: Shape,
863    fwd_map: &HashMap<NodeId, NodeId>,
864    bwd: &mut Graph,
865) -> Vec<(usize, NodeId)> {
866    let Op::Activation(kind) = &node.op else {
867        unreachable!()
868    };
869    {
870        let x_bwd = fwd_map[&node.inputs[0]];
871        // Dedicated `ReluBackward` kernel for the most common case
872        // (avoids the per-element kind-dispatch in
873        // `ActivationBackward`'s match). Every other activation
874        // family hits the generic kernel.
875        let dx = match kind {
876            Activation::Relu => bwd.relu_backward(x_bwd, upstream),
877            _ => bwd.activation_backward(*kind, x_bwd, upstream),
878        };
879        vec![(0, dx)]
880    }
881}
882
883#[allow(unused_variables)]
884fn vjp_mat_mul(
885    node: &Node,
886    upstream: NodeId,
887    upstream_shape: Shape,
888    fwd_map: &HashMap<NodeId, NodeId>,
889    bwd: &mut Graph,
890) -> Vec<(usize, NodeId)> {
891    let Op::MatMul = &node.op else { unreachable!() };
892    {
893        // y [..batch, M, N] = a [..batch_a, M, K] @ b [..batch_b, K, N]
894        //   da = upstream @ b^T   (shape [..batch, M, K])
895        //   db = a^T   @ upstream (shape [..batch, K, N])
896        //
897        // The forward shape inference broadcasts `batch_a` and
898        // `batch_b` to a common `batch`; if either side was
899        // broadcasted, we sum the corresponding gradient back
900        // down via `unbroadcast` so it matches the param's actual
901        // shape. The transpose swaps the *last two* dims only,
902        // leaving batch untouched.
903        let a_bwd = fwd_map[&node.inputs[0]];
904        let b_bwd = fwd_map[&node.inputs[1]];
905        let a_shape = bwd.node(a_bwd).shape.clone();
906        let b_shape = bwd.node(b_bwd).shape.clone();
907        assert!(
908            a_shape.rank() >= 2 && b_shape.rank() >= 2,
909            "MatMul VJP: rank must be ≥ 2, got {} and {}",
910            a_shape.rank(),
911            b_shape.rank()
912        );
913        let dtype = upstream_shape.dtype();
914
915        // Transpose-last-two helper.
916        let trans_last_two = |bwd: &mut Graph, x: NodeId| -> NodeId {
917            let s = bwd.node(x).shape.clone();
918            let r = s.rank();
919            let mut perm: Vec<usize> = (0..r).collect();
920            perm.swap(r - 2, r - 1);
921            let mut dims: Vec<Dim> = s.dims().to_vec();
922            dims.swap(r - 2, r - 1);
923            let new_shape = Shape::from_dims(&dims, s.dtype());
924            bwd.add_node(Op::Transpose { perm }, vec![x], new_shape)
925        };
926
927        // Build the matmul output shape [..upstream_batch, M_or_K, K_or_N]
928        // by swapping in the trailing dims for each gradient.
929        let upstream_dims: Vec<Dim> = upstream_shape.dims().to_vec();
930        let r_up = upstream_dims.len();
931
932        // C64 Wirtinger (∂L/∂z̄ convention, matching Mul/Div): the
933        // gradient conjugates the *other* operand —
934        //   dA = upstream @ conj(B)ᵀ,  dB = conj(A)ᵀ @ upstream.
935        // `conj` and transpose commute elementwise, so we conjugate the
936        // transposed operand. No-op for real dtypes.
937        let is_c64 = dtype == DType::C64;
938
939        // ── grad-a = upstream @ b^T (output shape [..up_batch, M, K]) ──
940        let b_t = trans_last_two(bwd, b_bwd);
941        let b_t = if is_c64 { bwd.conjugate(b_t) } else { b_t };
942        let mut ga_dims = upstream_dims.clone();
943        ga_dims[r_up - 1] = a_shape.dim(a_shape.rank() - 1); // K
944        let ga_shape = Shape::from_dims(&ga_dims, dtype);
945        let g_a_full = bwd.matmul(upstream, b_t, ga_shape);
946        let g_a = unbroadcast(g_a_full, &a_shape, bwd);
947
948        // ── grad-b = a^T @ upstream (output shape [..up_batch, K, N]) ──
949        let a_t = trans_last_two(bwd, a_bwd);
950        let a_t = if is_c64 { bwd.conjugate(a_t) } else { a_t };
951        let mut gb_dims = upstream_dims.clone();
952        gb_dims[r_up - 2] = a_shape.dim(a_shape.rank() - 1); // K
953        let gb_shape = Shape::from_dims(&gb_dims, dtype);
954        let g_b_full = bwd.matmul(a_t, upstream, gb_shape);
955        let g_b = unbroadcast(g_b_full, &b_shape, bwd);
956
957        vec![(0, g_a), (1, g_b)]
958    }
959}
960
961#[allow(unused_variables)]
962fn vjp_dense_solve(
963    node: &Node,
964    upstream: NodeId,
965    upstream_shape: Shape,
966    fwd_map: &HashMap<NodeId, NodeId>,
967    bwd: &mut Graph,
968) -> Vec<(usize, NodeId)> {
969    let Op::DenseSolve = &node.op else {
970        unreachable!()
971    };
972    {
973        // X = solve(A, B) ⇒ implicit-function VJP:
974        //   dB = solve(Aᵀ, upstream)        same shape as B / X
975        //   dA = -dB · Xᵀ                   [N, N]
976        //
977        // Rank-1 (b: [N]) and rank-2 (B: [N, K]) follow the same
978        // formula; rank-1 needs a reshape-to-column trick because
979        // we don't have a vector-outer-product op (matmul is
980        // matrix-only). Rank-2 is direct matmul.
981        let a_bwd = fwd_map[&node.inputs[0]];
982        let x_bwd = fwd_map[&node.id];
983        let a_shape = bwd.node(a_bwd).shape.clone();
984        let x_shape = bwd.node(x_bwd).shape.clone();
985        assert_eq!(a_shape.rank(), 2, "DenseSolve VJP: A must be 2-D");
986        let n = match a_shape.dim(0) {
987            Dim::Static(n) => n,
988            Dim::Dynamic(_) => panic!("DenseSolve VJP: dynamic N not supported"),
989        };
990        let dtype = a_shape.dtype();
991
992        // Aᵀ — shape [N, N] (square, transpose is just a perm).
993        let mut a_t_dims: Vec<Dim> = a_shape.dims().to_vec();
994        a_t_dims.swap(0, 1);
995        let a_t_shape = Shape::from_dims(&a_t_dims, dtype);
996        let a_t = bwd.add_node(Op::Transpose { perm: vec![1, 0] }, vec![a_bwd], a_t_shape);
997
998        // dB = solve(Aᵀ, upstream). Same shape as the original B.
999        let d_b = bwd.dense_solve(a_t, upstream, x_shape.clone());
1000
1001        // dA = -dB · Xᵀ.
1002        let neg_outer = match x_shape.rank() {
1003            1 => {
1004                // b: [N]. Reshape both vectors to matrices for matmul.
1005                let col_shape = Shape::from_dims(&[Dim::Static(n), Dim::Static(1)], dtype);
1006                let row_shape = Shape::from_dims(&[Dim::Static(1), Dim::Static(n)], dtype);
1007                let db_col = bwd.add_node(
1008                    Op::Reshape {
1009                        new_shape: vec![n as i64, 1],
1010                    },
1011                    vec![d_b],
1012                    col_shape,
1013                );
1014                let x_row = bwd.add_node(
1015                    Op::Reshape {
1016                        new_shape: vec![1, n as i64],
1017                    },
1018                    vec![x_bwd],
1019                    row_shape,
1020                );
1021                let outer = bwd.matmul(db_col, x_row, a_shape.clone());
1022                bwd.activation(Activation::Neg, outer, a_shape)
1023            }
1024            2 => {
1025                // B: [N, K]. dA = -MatMul(dB, Xᵀ) where Xᵀ: [K, N].
1026                let k = match x_shape.dim(1) {
1027                    Dim::Static(k) => k,
1028                    Dim::Dynamic(_) => panic!("DenseSolve VJP: dynamic K not supported"),
1029                };
1030                let xt_dims = vec![Dim::Static(k), Dim::Static(n)];
1031                let xt_shape = Shape::from_dims(&xt_dims, dtype);
1032                let x_t = bwd.add_node(Op::Transpose { perm: vec![1, 0] }, vec![x_bwd], xt_shape);
1033                let outer = bwd.matmul(d_b, x_t, a_shape.clone());
1034                bwd.activation(Activation::Neg, outer, a_shape)
1035            }
1036            r => panic!("DenseSolve VJP: B must be rank 1 or 2, got rank {r}"),
1037        };
1038
1039        vec![(0, neg_outer), (1, d_b)]
1040    }
1041}
1042
1043#[allow(unused_variables)]
1044fn vjp_batched_dense_solve(
1045    node: &Node,
1046    upstream: NodeId,
1047    upstream_shape: Shape,
1048    fwd_map: &HashMap<NodeId, NodeId>,
1049    bwd: &mut Graph,
1050) -> Vec<(usize, NodeId)> {
1051    let Op::BatchedDenseSolve = &node.op else {
1052        unreachable!()
1053    };
1054    {
1055        // Per-batch independent. Same implicit-function VJP as
1056        // DenseSolve, lifted with a leading B axis throughout:
1057        //   dB = batched_solve(Aᵀ, upstream)        same shape as B/X
1058        //   dA = -batched_matmul(dB, Xᵀ)            shape [B, N, N]
1059        // where `Aᵀ` swaps the LAST TWO axes (perm = [0, 2, 1]).
1060        let a_bwd = fwd_map[&node.inputs[0]];
1061        let x_bwd = fwd_map[&node.id];
1062        let a_shape = bwd.node(a_bwd).shape.clone();
1063        let x_shape = bwd.node(x_bwd).shape.clone();
1064        assert_eq!(
1065            a_shape.rank(),
1066            3,
1067            "BatchedDenseSolve VJP: A must be rank-3 [B, N, N]"
1068        );
1069        let b_dim = match a_shape.dim(0) {
1070            Dim::Static(b) => b,
1071            Dim::Dynamic(_) => panic!("BatchedDenseSolve VJP: dynamic B not supported"),
1072        };
1073        let n = match a_shape.dim(1) {
1074            Dim::Static(n) => n,
1075            Dim::Dynamic(_) => panic!("BatchedDenseSolve VJP: dynamic N not supported"),
1076        };
1077        let dtype = a_shape.dtype();
1078
1079        // Aᵀ across last two dims — perm = [0, 2, 1]. Output shape
1080        // is [B, N, N] (same as A; transpose of square is square).
1081        let a_t = bwd.add_node(
1082            Op::Transpose {
1083                perm: vec![0, 2, 1],
1084            },
1085            vec![a_bwd],
1086            a_shape.clone(),
1087        );
1088
1089        // dB = batched_solve(Aᵀ, upstream).
1090        let d_b = bwd.batched_dense_solve(a_t, upstream, x_shape.clone());
1091
1092        // dA = -batched_matmul(dB, Xᵀ).
1093        let neg_outer = match x_shape.rank() {
1094            2 => {
1095                // b is [B, N]. Reshape to [B, N, 1] (column) for dB
1096                // and [B, 1, N] (row) for X, then batched matmul.
1097                let col_shape =
1098                    Shape::from_dims(&[Dim::Static(b_dim), Dim::Static(n), Dim::Static(1)], dtype);
1099                let row_shape =
1100                    Shape::from_dims(&[Dim::Static(b_dim), Dim::Static(1), Dim::Static(n)], dtype);
1101                let db_col = bwd.add_node(
1102                    Op::Reshape {
1103                        new_shape: vec![b_dim as i64, n as i64, 1],
1104                    },
1105                    vec![d_b],
1106                    col_shape,
1107                );
1108                let x_row = bwd.add_node(
1109                    Op::Reshape {
1110                        new_shape: vec![b_dim as i64, 1, n as i64],
1111                    },
1112                    vec![x_bwd],
1113                    row_shape,
1114                );
1115                let outer = bwd.matmul(db_col, x_row, a_shape.clone());
1116                bwd.activation(Activation::Neg, outer, a_shape)
1117            }
1118            3 => {
1119                // b is [B, N, K]. dA = -MatMul(dB, Xᵀ) with
1120                // Xᵀ = Transpose(perm=[0, 2, 1]) so [B, K, N].
1121                let k = match x_shape.dim(2) {
1122                    Dim::Static(k) => k,
1123                    Dim::Dynamic(_) => panic!("BatchedDenseSolve VJP: dynamic K not supported"),
1124                };
1125                let xt_shape =
1126                    Shape::from_dims(&[Dim::Static(b_dim), Dim::Static(k), Dim::Static(n)], dtype);
1127                let x_t = bwd.add_node(
1128                    Op::Transpose {
1129                        perm: vec![0, 2, 1],
1130                    },
1131                    vec![x_bwd],
1132                    xt_shape,
1133                );
1134                let outer = bwd.matmul(d_b, x_t, a_shape.clone());
1135                bwd.activation(Activation::Neg, outer, a_shape)
1136            }
1137            r => panic!("BatchedDenseSolve VJP: b must be rank 2 or 3, got rank {r}"),
1138        };
1139
1140        vec![(0, neg_outer), (1, d_b)]
1141    }
1142}
1143
1144#[allow(unused_variables)]
1145fn vjp_scan(
1146    node: &Node,
1147    upstream: NodeId,
1148    upstream_shape: Shape,
1149    fwd_map: &HashMap<NodeId, NodeId>,
1150    bwd: &mut Graph,
1151) -> Vec<(usize, NodeId)> {
1152    let Op::Scan {
1153        body,
1154        length,
1155        save_trajectory,
1156        num_bcast: _,
1157        num_xs,
1158        num_checkpoints,
1159    } = &node.op
1160    else {
1161        unreachable!()
1162    };
1163    {
1164        // After `convert_scans_for_ad`, every scan reaching the AD
1165        // walk carries its trajectory. Compile body's VJP once
1166        // — w.r.t. carry AND every xs — so we can extract dinit
1167        // (Op::ScanBackward) plus dxs_i for each xs
1168        // (Op::ScanBackwardXs). Each variant runs its own backward
1169        // sweep; this is `1 + num_xs` independent sweeps. A future
1170        // optimization can fuse them via packed multi-output.
1171        let init_bwd = fwd_map[&node.inputs[0]];
1172        let traj_bwd = fwd_map[&node.id];
1173        let init_shape = bwd.node(init_bwd).shape.clone();
1174
1175        // Body Inputs in NodeId order: first = carry, rest = x_t_i.
1176        let mut body_input_ids: Vec<NodeId> = body
1177            .nodes()
1178            .iter()
1179            .filter(|n| matches!(n.op, Op::Input { .. }))
1180            .map(|n| n.id)
1181            .collect();
1182        body_input_ids.sort();
1183
1184        let body_vjp = grad(body, &body_input_ids);
1185
1186        let xs_bwd: Vec<NodeId> = (0..*num_xs as usize)
1187            .map(|i| fwd_map[&node.inputs[1 + i]])
1188            .collect();
1189
1190        // Recursive checkpointing: when num_checkpoints is set on
1191        // the forward Scan, propagate it (and the forward body) to
1192        // each emitted ScanBackward / ScanBackwardXs so the
1193        // executor knows to recompute carries via `forward_body`
1194        // between checkpoints.
1195        let is_checkpointed = *num_checkpoints != 0 && *num_checkpoints != *length;
1196        let forward_body_for_bwd = if is_checkpointed {
1197            Some((**body).clone())
1198        } else {
1199            None
1200        };
1201
1202        let dinit = bwd.scan_backward_with_checkpoints(
1203            init_bwd,
1204            traj_bwd,
1205            upstream,
1206            &xs_bwd,
1207            body_vjp.clone(),
1208            *length,
1209            *save_trajectory,
1210            *num_checkpoints,
1211            forward_body_for_bwd.clone(),
1212            init_shape,
1213        );
1214
1215        let mut grads: Vec<(usize, NodeId)> = vec![(0, dinit)];
1216        for i in 0..*num_xs as usize {
1217            let outer_xs_id = node.inputs[1 + i];
1218            let xs_shape = bwd.node(fwd_map[&outer_xs_id]).shape.clone();
1219            let dxs_i = bwd.scan_backward_xs_with_checkpoints(
1220                init_bwd,
1221                traj_bwd,
1222                upstream,
1223                &xs_bwd,
1224                body_vjp.clone(),
1225                *length,
1226                *save_trajectory,
1227                i as u32,
1228                *num_checkpoints,
1229                forward_body_for_bwd.clone(),
1230                xs_shape,
1231            );
1232            grads.push((1 + i, dxs_i));
1233        }
1234        grads
1235    }
1236}
1237
1238#[allow(unused_variables)]
1239fn vjp_conv(
1240    node: &Node,
1241    upstream: NodeId,
1242    upstream_shape: Shape,
1243    fwd_map: &HashMap<NodeId, NodeId>,
1244    bwd: &mut Graph,
1245) -> Vec<(usize, NodeId)> {
1246    let Op::Conv {
1247        kernel_size,
1248        stride,
1249        padding,
1250        dilation,
1251        groups,
1252    } = &node.op
1253    else {
1254        unreachable!()
1255    };
1256    {
1257        let x_bwd = fwd_map[&node.inputs[0]];
1258        let w_bwd = fwd_map[&node.inputs[1]];
1259        let x_shape = bwd.node(x_bwd).shape.clone();
1260        let w_shape = bwd.node(w_bwd).shape.clone();
1261        let dx = bwd.conv2d_backward_input(
1262            upstream,
1263            w_bwd,
1264            x_shape,
1265            kernel_size.clone(),
1266            stride.clone(),
1267            padding.clone(),
1268            dilation.clone(),
1269            *groups,
1270        );
1271        // Detect the QAT pattern (`Conv` reading from a
1272        // `FakeQuantize` weight) so a follow-up specialization
1273        // can skip dead bins (weights that round to the same
1274        // code share the gradient). For now we still emit the
1275        // generic backward — the helper just exposes the bits
1276        // for a future kernel variant.
1277        // QAT-bits detection requires the forward graph, which isn't
1278        // threaded through `vjp`. Skip for now; the generic backward
1279        // is used unconditionally.
1280        let _qat_bits: Option<u8> = None;
1281        let dw = bwd.conv2d_backward_weight(
1282            x_bwd,
1283            upstream,
1284            w_shape,
1285            kernel_size.clone(),
1286            stride.clone(),
1287            padding.clone(),
1288            dilation.clone(),
1289            *groups,
1290        );
1291        vec![(0, dx), (1, dw)]
1292    }
1293}
1294
1295#[allow(unused_variables)]
1296fn vjp_pool(
1297    node: &Node,
1298    upstream: NodeId,
1299    upstream_shape: Shape,
1300    fwd_map: &HashMap<NodeId, NodeId>,
1301    bwd: &mut Graph,
1302) -> Vec<(usize, NodeId)> {
1303    let Op::Pool {
1304        kind: ReduceOp::Max,
1305        kernel_size,
1306        stride,
1307        padding,
1308    } = &node.op
1309    else {
1310        unreachable!()
1311    };
1312    {
1313        let x_bwd = fwd_map[&node.inputs[0]];
1314        let dx = bwd.maxpool2d_backward(
1315            x_bwd,
1316            upstream,
1317            kernel_size.clone(),
1318            stride.clone(),
1319            padding.clone(),
1320        );
1321        vec![(0, dx)]
1322    }
1323}
1324
1325#[allow(unused_variables)]
1326fn vjp_softmax_cross_entropy_with_logits(
1327    node: &Node,
1328    upstream: NodeId,
1329    upstream_shape: Shape,
1330    fwd_map: &HashMap<NodeId, NodeId>,
1331    bwd: &mut Graph,
1332) -> Vec<(usize, NodeId)> {
1333    let Op::SoftmaxCrossEntropyWithLogits = &node.op else {
1334        unreachable!()
1335    };
1336    {
1337        let logits_bwd = fwd_map[&node.inputs[0]];
1338        let labels_bwd = fwd_map[&node.inputs[1]];
1339        let dlogits = bwd.softmax_cross_entropy_backward(logits_bwd, labels_bwd, upstream);
1340        // labels has no gradient.
1341        vec![(0, dlogits)]
1342    }
1343}
1344
1345#[allow(unused_variables)]
1346fn vjp_softmax_cross_entropy(
1347    node: &Node,
1348    upstream: NodeId,
1349    upstream_shape: Shape,
1350    fwd_map: &HashMap<NodeId, NodeId>,
1351    bwd: &mut Graph,
1352) -> Vec<(usize, NodeId)> {
1353    let Op::SoftmaxCrossEntropy = &node.op else {
1354        unreachable!()
1355    };
1356    {
1357        // loss[n] = lse(logits[n]) - Σ_c targets[n,c]·logits[n,c].
1358        // dlogits = (softmax(logits) - targets) * d_loss[n]
1359        // dtargets = -logits * d_loss[n]
1360        // Decomposed into primitives — `softmax` is a peak-perf kernel on
1361        // every backend, so no dedicated dense-backward op is needed.
1362        let logits_bwd = fwd_map[&node.inputs[0]];
1363        let targets_bwd = fwd_map[&node.inputs[1]];
1364        let logits_shape = bwd.node(logits_bwd).shape.clone();
1365        // upstream is [N]; reshape to [N, 1] so it broadcasts over C.
1366        let upstream_2d = bwd.reshape_(upstream, vec![-1, 1]);
1367        let sm = bwd.softmax(logits_bwd, -1, logits_shape.clone());
1368        let diff = bwd.sub(sm, targets_bwd);
1369        let dlogits = bwd.mul(diff, upstream_2d);
1370        let neg_logits = bwd.neg(logits_bwd);
1371        let dtargets = bwd.mul(neg_logits, upstream_2d);
1372        vec![(0, dlogits), (1, dtargets)]
1373    }
1374}
1375
1376#[allow(unused_variables)]
1377fn vjp_reduce(
1378    node: &Node,
1379    upstream: NodeId,
1380    upstream_shape: Shape,
1381    fwd_map: &HashMap<NodeId, NodeId>,
1382    bwd: &mut Graph,
1383) -> Vec<(usize, NodeId)> {
1384    let Op::Reduce {
1385        op: ReduceOp::Sum,
1386        axes,
1387        keep_dim,
1388    } = &node.op
1389    else {
1390        unreachable!()
1391    };
1392    {
1393        let x_bwd = fwd_map[&node.inputs[0]];
1394        let x_shape = bwd.node(x_bwd).shape.clone();
1395        let g = expand_to(upstream, &x_shape, axes, *keep_dim, bwd);
1396        vec![(0, g)]
1397    }
1398}
1399
1400#[allow(unused_variables)]
1401fn vjp_reduce_2(
1402    node: &Node,
1403    upstream: NodeId,
1404    upstream_shape: Shape,
1405    fwd_map: &HashMap<NodeId, NodeId>,
1406    bwd: &mut Graph,
1407) -> Vec<(usize, NodeId)> {
1408    let Op::Reduce {
1409        op: ReduceOp::Mean,
1410        axes,
1411        keep_dim,
1412    } = &node.op
1413    else {
1414        unreachable!()
1415    };
1416    {
1417        // Mean = Sum / N. Do the Sum-style expansion first, then
1418        // multiply the broadcast result by 1/N. Multiplying after
1419        // the expand keeps the broadcast cleanly anchored at the
1420        // full input shape and sidesteps the rank-promotion when
1421        // the reduced output is a scalar (shape `[]`).
1422        let x_bwd = fwd_map[&node.inputs[0]];
1423        let x_shape = bwd.node(x_bwd).shape.clone();
1424        let count: usize = axes
1425            .iter()
1426            .map(|&a| match x_shape.dim(a) {
1427                Dim::Static(n) => n,
1428                _ => panic!("Reduce::Mean VJP requires static reduced dims"),
1429            })
1430            .product();
1431        let expanded = expand_to(upstream, &x_shape, axes, *keep_dim, bwd);
1432        let inv_count = scalar_const(1.0 / count as f32, bwd);
1433        let g = bwd.binary(BinaryOp::Mul, expanded, inv_count, x_shape);
1434        vec![(0, g)]
1435    }
1436}
1437
1438#[allow(unused_variables)]
1439fn vjp_reshape(
1440    node: &Node,
1441    upstream: NodeId,
1442    upstream_shape: Shape,
1443    fwd_map: &HashMap<NodeId, NodeId>,
1444    bwd: &mut Graph,
1445) -> Vec<(usize, NodeId)> {
1446    let Op::Reshape { .. } = &node.op else {
1447        unreachable!()
1448    };
1449    {
1450        let x_bwd = fwd_map[&node.inputs[0]];
1451        let x_shape = bwd.node(x_bwd).shape.clone();
1452        let dx = reshape_to(upstream, &x_shape, bwd);
1453        vec![(0, dx)]
1454    }
1455}
1456
1457#[allow(unused_variables)]
1458fn vjp_complex_norm_sq(
1459    node: &Node,
1460    upstream: NodeId,
1461    upstream_shape: Shape,
1462    fwd_map: &HashMap<NodeId, NodeId>,
1463    bwd: &mut Graph,
1464) -> Vec<(usize, NodeId)> {
1465    let Op::ComplexNormSq = &node.op else {
1466        unreachable!()
1467    };
1468    {
1469        // Wirtinger: ∂|z|²/∂z̄ = z. Cotangent g (real) maps to
1470        // dz = g·z (complex, element-wise).
1471        let z_bwd = fwd_map[&node.inputs[0]];
1472        let dz = bwd.complex_norm_sq_backward(z_bwd, upstream);
1473        vec![(0, dz)]
1474    }
1475}
1476
1477#[allow(unused_variables)]
1478fn vjp_conjugate(
1479    node: &Node,
1480    upstream: NodeId,
1481    upstream_shape: Shape,
1482    fwd_map: &HashMap<NodeId, NodeId>,
1483    bwd: &mut Graph,
1484) -> Vec<(usize, NodeId)> {
1485    let Op::Conjugate = &node.op else {
1486        unreachable!()
1487    };
1488    {
1489        // For w = conj(z): under the JAX-style cotangent (carrying
1490        // ∂L/∂z̄ for a real-valued L), the rule reduces to
1491        // cotangent_z = conj(cotangent_w). So the VJP of Conjugate
1492        // is Conjugate itself. Symmetric — second-order derivatives
1493        // through complex graphs stay consistent.
1494        let dz = bwd.conjugate(upstream);
1495        vec![(0, dz)]
1496    }
1497}
1498
1499#[allow(unused_variables)]
1500fn vjp_cast(
1501    node: &Node,
1502    upstream: NodeId,
1503    upstream_shape: Shape,
1504    fwd_map: &HashMap<NodeId, NodeId>,
1505    bwd: &mut Graph,
1506) -> Vec<(usize, NodeId)> {
1507    let Op::Cast { .. } = &node.op else {
1508        unreachable!()
1509    };
1510    {
1511        let x_bwd = fwd_map[&node.inputs[0]];
1512        let x_shape = bwd.node(x_bwd).shape.clone();
1513        let dx = bwd.add_node(
1514            Op::Cast {
1515                to: x_shape.dtype(),
1516            },
1517            vec![upstream],
1518            x_shape,
1519        );
1520        vec![(0, dx)]
1521    }
1522}
1523
1524// Stop-gradient (a.k.a. `detach`): forward identity, **no**
1525// gradient contribution to the input. Returning an empty list
1526// here keeps the reverse-mode walker from accumulating any
1527// upstream into `node.inputs[0]`, which is the whole point.
1528#[allow(unused_variables)]
1529fn vjp_stop_gradient(
1530    node: &Node,
1531    upstream: NodeId,
1532    upstream_shape: Shape,
1533    fwd_map: &HashMap<NodeId, NodeId>,
1534    bwd: &mut Graph,
1535) -> Vec<(usize, NodeId)> {
1536    let Op::StopGradient = &node.op else {
1537        unreachable!()
1538    };
1539    vec![]
1540}
1541
1542#[allow(unused_variables)]
1543fn vjp_fake_quantize_l_s_q(
1544    node: &Node,
1545    upstream: NodeId,
1546    upstream_shape: Shape,
1547    fwd_map: &HashMap<NodeId, NodeId>,
1548    bwd: &mut Graph,
1549) -> Vec<(usize, NodeId)> {
1550    let Op::FakeQuantizeLSQ { bits, axis } = &node.op else {
1551        unreachable!()
1552    };
1553    {
1554        // LSQ has TWO gradients: dx (STE-clipped) and dscale
1555        // (closed-form). Route them to inputs[0] (x) and
1556        // inputs[1] (scale) respectively.
1557        let x_bwd = fwd_map[&node.inputs[0]];
1558        let scale_bwd = fwd_map[&node.inputs[1]];
1559        let x_shape = bwd.node(x_bwd).shape.clone();
1560        let scale_shape = bwd.node(scale_bwd).shape.clone();
1561        let dx = bwd.add_node(
1562            Op::FakeQuantizeLSQBackwardX {
1563                bits: *bits,
1564                axis: *axis,
1565            },
1566            vec![x_bwd, scale_bwd, upstream],
1567            x_shape,
1568        );
1569        let dscale = bwd.add_node(
1570            Op::FakeQuantizeLSQBackwardScale {
1571                bits: *bits,
1572                axis: *axis,
1573            },
1574            vec![x_bwd, scale_bwd, upstream],
1575            scale_shape,
1576        );
1577        vec![(0, dx), (1, dscale)]
1578    }
1579}
1580
1581// FakeQuantize backward depends on the STE variant. The
1582// default `Identity` is a clean passthrough; the others
1583// attenuate the gradient based on `x` and the per-channel
1584// scale, so we emit a dedicated `FakeQuantizeBackward` op.
1585#[allow(unused_variables)]
1586fn vjp_fake_quantize(
1587    node: &Node,
1588    upstream: NodeId,
1589    upstream_shape: Shape,
1590    fwd_map: &HashMap<NodeId, NodeId>,
1591    bwd: &mut Graph,
1592) -> Vec<(usize, NodeId)> {
1593    let Op::FakeQuantize {
1594        bits, axis, ste, ..
1595    } = &node.op
1596    else {
1597        unreachable!()
1598    };
1599    {
1600        use rlx_ir::op::SteKind;
1601        match ste {
1602            SteKind::Identity => vec![(0, upstream)],
1603            _ => {
1604                let x_bwd = fwd_map[&node.inputs[0]];
1605                let x_shape = bwd.node(x_bwd).shape.clone();
1606                let dx = bwd.add_node(
1607                    Op::FakeQuantizeBackward {
1608                        bits: *bits,
1609                        axis: *axis,
1610                        ste: *ste,
1611                    },
1612                    vec![x_bwd, upstream],
1613                    x_shape,
1614                );
1615                vec![(0, dx)]
1616            }
1617        }
1618    }
1619}
1620
1621#[allow(unused_variables)]
1622fn vjp_expand(
1623    node: &Node,
1624    upstream: NodeId,
1625    upstream_shape: Shape,
1626    fwd_map: &HashMap<NodeId, NodeId>,
1627    bwd: &mut Graph,
1628) -> Vec<(usize, NodeId)> {
1629    let Op::Expand { .. } = &node.op else {
1630        unreachable!()
1631    };
1632    {
1633        let x_bwd = fwd_map[&node.inputs[0]];
1634        let x_shape = bwd.node(x_bwd).shape.clone();
1635        let dx = unbroadcast(upstream, &x_shape, bwd);
1636        vec![(0, dx)]
1637    }
1638}
1639
1640#[allow(unused_variables)]
1641fn vjp_batch_norm_inference(
1642    node: &Node,
1643    upstream: NodeId,
1644    upstream_shape: Shape,
1645    fwd_map: &HashMap<NodeId, NodeId>,
1646    bwd: &mut Graph,
1647) -> Vec<(usize, NodeId)> {
1648    let Op::BatchNormInference { eps } = &node.op else {
1649        unreachable!()
1650    };
1651    {
1652        let x_bwd = fwd_map[&node.inputs[0]];
1653        let gamma_bwd = fwd_map[&node.inputs[1]];
1654        let _beta_bwd = fwd_map[&node.inputs[2]];
1655        let mean_bwd = fwd_map[&node.inputs[3]];
1656        let var_bwd = fwd_map[&node.inputs[4]];
1657        let gamma_shape = bwd.node(gamma_bwd).shape.clone();
1658        let dx = bwd.batch_norm_inference_backward_input(
1659            x_bwd, gamma_bwd, mean_bwd, var_bwd, upstream, *eps,
1660        );
1661        let dgamma = bwd.batch_norm_inference_backward_gamma(
1662            x_bwd,
1663            mean_bwd,
1664            var_bwd,
1665            upstream,
1666            gamma_shape.clone(),
1667            *eps,
1668        );
1669        let dbeta = bwd.batch_norm_inference_backward_beta(upstream, gamma_shape);
1670        // mean/var are frozen — no gradients.
1671        vec![(0, dx), (1, dgamma), (2, dbeta)]
1672    }
1673}
1674
1675#[allow(unused_variables)]
1676fn vjp_layer_norm(
1677    node: &Node,
1678    upstream: NodeId,
1679    upstream_shape: Shape,
1680    fwd_map: &HashMap<NodeId, NodeId>,
1681    bwd: &mut Graph,
1682) -> Vec<(usize, NodeId)> {
1683    let Op::LayerNorm { axis, eps } = &node.op else {
1684        unreachable!()
1685    };
1686    {
1687        // y = LayerNorm(x, gamma, beta) over the feature axis.
1688        // d_x via the dedicated `LayerNormBackwardInput` kernel
1689        // (closed-form, recomputes mean/var/x̂ inline).
1690        // d_gamma via `LayerNormBackwardGamma` (sums over batch axes).
1691        // d_beta = sum(upstream) over batch axes — composable with
1692        // an unbroadcast back to gamma's shape (gamma and beta share shape).
1693        let x_bwd = fwd_map[&node.inputs[0]];
1694        let gamma_bwd = fwd_map[&node.inputs[1]];
1695        let _beta_bwd = fwd_map[&node.inputs[2]];
1696        let gamma_shape = bwd.node(gamma_bwd).shape.clone();
1697
1698        let dx = bwd.layer_norm_backward_input(x_bwd, gamma_bwd, upstream, *axis, *eps);
1699        let dgamma =
1700            bwd.layer_norm_backward_gamma(x_bwd, upstream, gamma_shape.clone(), *axis, *eps);
1701        let dbeta = unbroadcast(upstream, &gamma_shape, bwd);
1702        vec![(0, dx), (1, dgamma), (2, dbeta)]
1703    }
1704}
1705
1706#[allow(unused_variables)]
1707fn vjp_softmax(
1708    node: &Node,
1709    upstream: NodeId,
1710    upstream_shape: Shape,
1711    fwd_map: &HashMap<NodeId, NodeId>,
1712    bwd: &mut Graph,
1713) -> Vec<(usize, NodeId)> {
1714    let Op::Softmax { axis } = &node.op else {
1715        unreachable!()
1716    };
1717    {
1718        // y = softmax(x, axis)  →  dy/dx[i] = y[i] · (g[i] - Σⱼ y[j]·g[j])
1719        // where the Σⱼ is over the softmax axis. Compose from existing
1720        // primitives:  yg = y * upstream
1721        //              s  = reduce_sum(yg, axis, keep_dim=true)
1722        //              s' = expand(s, target=y.shape)
1723        //              dx = y * (upstream - s')
1724        //
1725        // The forward `y` lives at `fwd_to_bwd[node.id]` — bwd
1726        // graph mirrors every forward node so its slot survives
1727        // through this VJP. We *explicitly* expand `s` to `y.shape`
1728        // before the Sub rather than relying on `Op::Binary`'s
1729        // broadcast (which has a known shape-confusion bug for the
1730        // `[..., 1]` keep-dim case — see the rlx-cpu thunk
1731        // dispatch). Going through `Op::Expand` runs the
1732        // dedicated stride-aware broadcast thunk, which is correct.
1733        let y_bwd = fwd_map[&node.id];
1734        let y_shape = bwd.node(y_bwd).shape.clone();
1735        let dtype = y_shape.dtype();
1736        let rank = y_shape.rank();
1737        let axis_pos = if *axis < 0 {
1738            (rank as i32 + *axis) as usize
1739        } else {
1740            *axis as usize
1741        };
1742
1743        let yg = bwd.binary(BinaryOp::Mul, y_bwd, upstream, y_shape.clone());
1744
1745        let mut kept_dims: Vec<Dim> = y_shape.dims().to_vec();
1746        kept_dims[axis_pos] = Dim::Static(1);
1747        let kept_shape = Shape::from_dims(&kept_dims, dtype);
1748        let s = bwd.add_node(
1749            Op::Reduce {
1750                op: ReduceOp::Sum,
1751                axes: vec![axis_pos],
1752                keep_dim: true,
1753            },
1754            vec![yg],
1755            kept_shape,
1756        );
1757
1758        let target_dims: Vec<i64> = y_shape
1759            .dims()
1760            .iter()
1761            .map(|d| match d {
1762                Dim::Static(n) => *n as i64,
1763                Dim::Dynamic(_) => -1,
1764            })
1765            .collect();
1766        let s_expanded = bwd.add_node(
1767            Op::Expand {
1768                target_shape: target_dims,
1769            },
1770            vec![s],
1771            y_shape.clone(),
1772        );
1773
1774        let diff = bwd.binary(BinaryOp::Sub, upstream, s_expanded, y_shape.clone());
1775        let dx = bwd.binary(BinaryOp::Mul, y_bwd, diff, y_shape);
1776        vec![(0, dx)]
1777    }
1778}
1779
1780// ── Shape ops: just route the upstream gradient through ──
1781#[allow(unused_variables)]
1782fn vjp_transpose(
1783    node: &Node,
1784    upstream: NodeId,
1785    upstream_shape: Shape,
1786    fwd_map: &HashMap<NodeId, NodeId>,
1787    bwd: &mut Graph,
1788) -> Vec<(usize, NodeId)> {
1789    let Op::Transpose { perm } = &node.op else {
1790        unreachable!()
1791    };
1792    {
1793        // Inverse permutation: if forward maps axis i → perm[i],
1794        // backward maps perm[i] → i.
1795        let inv: Vec<usize> = {
1796            let mut v = vec![0usize; perm.len()];
1797            for (i, &p) in perm.iter().enumerate() {
1798                v[p] = i;
1799            }
1800            v
1801        };
1802        let x_bwd = fwd_map[&node.inputs[0]];
1803        let x_shape = bwd.node(x_bwd).shape.clone();
1804        let dx = bwd.add_node(Op::Transpose { perm: inv }, vec![upstream], x_shape);
1805        vec![(0, dx)]
1806    }
1807}
1808
1809#[allow(unused_variables)]
1810fn vjp_concat(
1811    node: &Node,
1812    upstream: NodeId,
1813    upstream_shape: Shape,
1814    fwd_map: &HashMap<NodeId, NodeId>,
1815    bwd: &mut Graph,
1816) -> Vec<(usize, NodeId)> {
1817    let Op::Concat { axis } = &node.op else {
1818        unreachable!()
1819    };
1820    {
1821        // Split upstream along the concat axis: each input gets
1822        // `Narrow(upstream, axis, offset, x_i.dim(axis))`.
1823        let mut grads = Vec::with_capacity(node.inputs.len());
1824        let mut offset: usize = 0;
1825        for (i, &input_id) in node.inputs.iter().enumerate() {
1826            let x_bwd = fwd_map[&input_id];
1827            let x_shape = bwd.node(x_bwd).shape.clone();
1828            let len = match x_shape.dim(*axis) {
1829                Dim::Static(n) => n,
1830                _ => panic!("Concat VJP: dynamic concat dim"),
1831            };
1832            let dx = bwd.add_node(
1833                Op::Narrow {
1834                    axis: *axis,
1835                    start: offset,
1836                    len,
1837                },
1838                vec![upstream],
1839                x_shape,
1840            );
1841            grads.push((i, dx));
1842            offset += len;
1843        }
1844        grads
1845    }
1846}
1847
1848#[allow(unused_variables)]
1849fn vjp_narrow(
1850    node: &Node,
1851    upstream: NodeId,
1852    upstream_shape: Shape,
1853    fwd_map: &HashMap<NodeId, NodeId>,
1854    bwd: &mut Graph,
1855) -> Vec<(usize, NodeId)> {
1856    let Op::Narrow { axis, start, len } = &node.op else {
1857        unreachable!()
1858    };
1859    {
1860        // Inverse of slicing: pad upstream with zeros on both
1861        // sides along `axis` so the result matches input shape.
1862        // Build via Concat[zeros_pre, upstream, zeros_post].
1863        let x_bwd = fwd_map[&node.inputs[0]];
1864        let x_shape = bwd.node(x_bwd).shape.clone();
1865        let full_n = match x_shape.dim(*axis) {
1866            Dim::Static(n) => n,
1867            _ => panic!("Narrow VJP: dynamic axis"),
1868        };
1869        let pre = *start;
1870        let post = full_n - *start - *len;
1871
1872        let zero_buf = |bwd: &mut Graph, len_axis: usize| -> NodeId {
1873            if len_axis == 0 {
1874                return upstream; // sentinel, never used (filtered below)
1875            }
1876            let dtype = x_shape.dtype();
1877            let mut dims: Vec<Dim> = x_shape.dims().to_vec();
1878            dims[*axis] = Dim::Static(len_axis);
1879            let s = Shape::from_dims(&dims, dtype);
1880            let n_elems = dims.iter().fold(1usize, |a, d| match d {
1881                Dim::Static(k) => a * k,
1882                _ => a,
1883            });
1884            // Bytes per element scales with dtype; bytewise-zero is
1885            // a valid zero at any precision (IEEE +0.0 / signed 0 /
1886            // unsigned 0), so a vec of zero bytes is safe.
1887            let bytes = vec![0u8; n_elems * dtype.size_bytes()];
1888            bwd.add_node(Op::Constant { data: bytes }, vec![], s)
1889        };
1890
1891        let mut parts: Vec<NodeId> = Vec::new();
1892        if pre > 0 {
1893            parts.push(zero_buf(bwd, pre));
1894        }
1895        parts.push(upstream);
1896        if post > 0 {
1897            parts.push(zero_buf(bwd, post));
1898        }
1899
1900        let dx = if parts.len() == 1 {
1901            parts[0]
1902        } else {
1903            bwd.add_node(Op::Concat { axis: *axis }, parts, x_shape)
1904        };
1905        vec![(0, dx)]
1906    }
1907}
1908
1909#[allow(unused_variables)]
1910fn vjp_gather(
1911    node: &Node,
1912    upstream: NodeId,
1913    upstream_shape: Shape,
1914    fwd_map: &HashMap<NodeId, NodeId>,
1915    bwd: &mut Graph,
1916) -> Vec<(usize, NodeId)> {
1917    let Op::Gather { axis } = &node.op else {
1918        unreachable!()
1919    };
1920    {
1921        let table_bwd = fwd_map[&node.inputs[0]];
1922        let indices_bwd = fwd_map[&node.inputs[1]];
1923        let table_shape = bwd.node(table_bwd).shape.clone();
1924        if *axis == 0 {
1925            let dtable = bwd.add_node(Op::ScatterAdd, vec![upstream, indices_bwd], table_shape);
1926            vec![(0, dtable)]
1927        } else {
1928            let dtable = bwd.gather_backward(
1929                upstream,
1930                indices_bwd,
1931                table_shape,
1932                (*axis).try_into().unwrap(),
1933            );
1934            vec![(0, dtable)]
1935        }
1936    }
1937}
1938
1939// ── Non-differentiable predicates / selectors ──
1940#[allow(unused_variables)]
1941fn vjp_compare(
1942    node: &Node,
1943    upstream: NodeId,
1944    upstream_shape: Shape,
1945    fwd_map: &HashMap<NodeId, NodeId>,
1946    bwd: &mut Graph,
1947) -> Vec<(usize, NodeId)> {
1948    let Op::Compare(_) = &node.op else {
1949        unreachable!()
1950    };
1951    {
1952        // Compare returns a boolean tensor; gradient w.r.t.
1953        // continuous inputs is zero almost everywhere. We don't
1954        // propagate (caller will see zero grads for any path
1955        // that flows through a Compare alone).
1956        vec![]
1957    }
1958}
1959
1960#[allow(unused_variables)]
1961fn vjp_where(
1962    node: &Node,
1963    upstream: NodeId,
1964    upstream_shape: Shape,
1965    fwd_map: &HashMap<NodeId, NodeId>,
1966    bwd: &mut Graph,
1967) -> Vec<(usize, NodeId)> {
1968    let Op::Where = &node.op else { unreachable!() };
1969    {
1970        // out = where(cond, a, b). Cond has zero gradient
1971        // (it's a predicate); a's gradient is `where(cond,
1972        // upstream, 0)`; b's gradient is `where(cond, 0, upstream)`.
1973        let cond = fwd_map[&node.inputs[0]];
1974        let a_bwd = fwd_map[&node.inputs[1]];
1975        let b_bwd = fwd_map[&node.inputs[2]];
1976        let a_shape = bwd.node(a_bwd).shape.clone();
1977        let b_shape = bwd.node(b_bwd).shape.clone();
1978        let out_shape = upstream_shape.clone();
1979
1980        let zero_a_bytes = vec![0u8; a_shape.num_elements().expect("Where VJP: dynamic a") * 4];
1981        let zero_b_bytes = vec![0u8; b_shape.num_elements().expect("Where VJP: dynamic b") * 4];
1982        let zero_a = bwd.add_node(Op::Constant { data: zero_a_bytes }, vec![], a_shape.clone());
1983        let zero_b = bwd.add_node(Op::Constant { data: zero_b_bytes }, vec![], b_shape.clone());
1984        // Need to match shapes for Op::Where (cond, a, b same).
1985        // Upstream shape == out_shape == broadcast of a/b.
1986        let zero_a_bcast = unbroadcast_inverse(zero_a, &out_shape, bwd);
1987        let zero_b_bcast = unbroadcast_inverse(zero_b, &out_shape, bwd);
1988        let g_a_full = bwd.add_node(
1989            Op::Where,
1990            vec![cond, upstream, zero_a_bcast],
1991            out_shape.clone(),
1992        );
1993        let g_b_full = bwd.add_node(Op::Where, vec![cond, zero_b_bcast, upstream], out_shape);
1994        let g_a = unbroadcast(g_a_full, &a_shape, bwd);
1995        let g_b = unbroadcast(g_b_full, &b_shape, bwd);
1996        vec![(1, g_a), (2, g_b)]
1997    }
1998}
1999
2000// ── Element-wise binary ops ──
2001#[allow(unused_variables)]
2002fn vjp_binary_div(
2003    node: &Node,
2004    upstream: NodeId,
2005    upstream_shape: Shape,
2006    fwd_map: &HashMap<NodeId, NodeId>,
2007    bwd: &mut Graph,
2008) -> Vec<(usize, NodeId)> {
2009    let Op::Binary(BinaryOp::Div) = &node.op else {
2010        unreachable!()
2011    };
2012    {
2013        // Real:  d/da (a/b) = 1/b,        d/db (a/b) = -a/b² = -y/b
2014        // C64 (Wirtinger):
2015        //        d/dā = upstream / conj(b)
2016        //        d/db̄ = -upstream · conj(y) / conj(b)
2017        // Substituting `b ↦ conj(b)` and `y ↦ conj(y)` in the real
2018        // rule recovers the complex one — the kernel itself is
2019        // unchanged.
2020        let a_bwd = fwd_map[&node.inputs[0]];
2021        let b_bwd = fwd_map[&node.inputs[1]];
2022        let y_bwd = fwd_map[&node.id];
2023        let a_shape = bwd.node(a_bwd).shape.clone();
2024        let b_shape = bwd.node(b_bwd).shape.clone();
2025        let is_c64 = upstream_shape.dtype() == DType::C64;
2026
2027        let b_term = if is_c64 { bwd.conjugate(b_bwd) } else { b_bwd };
2028        let y_term = if is_c64 { bwd.conjugate(y_bwd) } else { y_bwd };
2029
2030        // d/da: upstream / b_term
2031        let g_a_full = bwd.binary(BinaryOp::Div, upstream, b_term, upstream_shape.clone());
2032        let g_a = unbroadcast(g_a_full, &a_shape, bwd);
2033
2034        // d/db: -upstream * y_term / b_term
2035        let neg_up = bwd.activation(Activation::Neg, upstream, upstream_shape.clone());
2036        let neg_up_y = bwd.binary(BinaryOp::Mul, neg_up, y_term, upstream_shape.clone());
2037        let g_b_full = bwd.binary(BinaryOp::Div, neg_up_y, b_term, upstream_shape);
2038        let g_b = unbroadcast(g_b_full, &b_shape, bwd);
2039
2040        vec![(0, g_a), (1, g_b)]
2041    }
2042}
2043
2044// ── Rope: backward is forward with negated sin ──
2045//
2046//   forward:  out = x * cos + rotate(x) * sin
2047//   reverse:  dx  = dy * cos + rotate(dy) * (-sin)
2048//         =  rope(dy, cos, neg(sin))
2049#[allow(unused_variables)]
2050fn vjp_rope(
2051    node: &Node,
2052    upstream: NodeId,
2053    upstream_shape: Shape,
2054    fwd_map: &HashMap<NodeId, NodeId>,
2055    bwd: &mut Graph,
2056) -> Vec<(usize, NodeId)> {
2057    let Op::Rope {
2058        head_dim, n_rot, ..
2059    } = &node.op
2060    else {
2061        unreachable!()
2062    };
2063    {
2064        let cos = fwd_map[&node.inputs[1]];
2065        let sin = fwd_map[&node.inputs[2]];
2066        let dx = bwd.rope_backward(upstream, cos, sin, *head_dim, *n_rot);
2067        vec![(0, dx)]
2068    }
2069}
2070
2071#[allow(unused_variables)]
2072fn vjp_rms_norm(
2073    node: &Node,
2074    upstream: NodeId,
2075    upstream_shape: Shape,
2076    fwd_map: &HashMap<NodeId, NodeId>,
2077    bwd: &mut Graph,
2078) -> Vec<(usize, NodeId)> {
2079    let Op::RmsNorm { axis, eps } = &node.op else {
2080        unreachable!()
2081    };
2082    {
2083        let x = fwd_map[&node.inputs[0]];
2084        let gamma = fwd_map[&node.inputs[1]];
2085        let beta = fwd_map[&node.inputs[2]];
2086        let dx = bwd.rms_norm_backward_input(x, gamma, beta, upstream, *axis, *eps);
2087        let dgamma = bwd.rms_norm_backward_gamma(x, gamma, beta, upstream, *axis, *eps);
2088        let dbeta = bwd.rms_norm_backward_beta(x, gamma, beta, upstream, *axis, *eps);
2089        vec![(0, dx), (1, dgamma), (2, dbeta)]
2090    }
2091}
2092
2093#[allow(unused_variables)]
2094fn vjp_group_norm(
2095    node: &Node,
2096    upstream: NodeId,
2097    upstream_shape: Shape,
2098    fwd_map: &HashMap<NodeId, NodeId>,
2099    bwd: &mut Graph,
2100) -> Vec<(usize, NodeId)> {
2101    let Op::GroupNorm { num_groups, eps } = &node.op else {
2102        unreachable!()
2103    };
2104    {
2105        let x = fwd_map[&node.inputs[0]];
2106        let gamma = fwd_map[&node.inputs[1]];
2107        let beta = fwd_map[&node.inputs[2]];
2108        let gamma_shape = bwd.node(gamma).shape.clone();
2109        let beta_shape = bwd.node(beta).shape.clone();
2110        let dx = bwd.group_norm_backward_input(x, gamma, beta, upstream, *num_groups, *eps);
2111        let dgamma = bwd.group_norm_backward_gamma(x, upstream, gamma_shape, *num_groups, *eps);
2112        let dbeta = bwd.group_norm_backward_beta(x, upstream, beta_shape, *num_groups, *eps);
2113        vec![(0, dx), (1, dgamma), (2, dbeta)]
2114    }
2115}
2116
2117// ── Attention → dedicated backward kernels ──────────────
2118#[allow(unused_variables)]
2119fn vjp_attention(
2120    node: &Node,
2121    upstream: NodeId,
2122    upstream_shape: Shape,
2123    fwd_map: &HashMap<NodeId, NodeId>,
2124    bwd: &mut Graph,
2125) -> Vec<(usize, NodeId)> {
2126    let Op::Attention {
2127        num_heads,
2128        head_dim,
2129        mask_kind,
2130        score_scale: _,
2131        attn_logit_softcap: _,
2132    } = &node.op
2133    else {
2134        unreachable!()
2135    };
2136    {
2137        let q = fwd_map[&node.inputs[0]];
2138        let k = fwd_map[&node.inputs[1]];
2139        let v = fwd_map[&node.inputs[2]];
2140        let mask = match mask_kind {
2141            MaskKind::Custom | MaskKind::Bias => Some(fwd_map[&node.inputs[3]]),
2142            _ => None,
2143        };
2144        let (dq, dk, dv) =
2145            bwd.attention_backward_all(q, k, v, upstream, *num_heads, *head_dim, *mask_kind, mask);
2146        vec![(0, dq), (1, dk), (2, dv)]
2147    }
2148}
2149
2150// ── Reduce(Prod) ────────────────────────────────────────
2151//
2152// Forward: y[axes_reduced] = ∏ x[axes_reduced…]
2153// Backward: dx[i] = upstream · y / x[i]   (per-row).
2154// (Numerically dicey when any x[i] = 0; production users
2155//  needing zero-safe Prod-grad should pre-mask.)
2156#[allow(unused_variables)]
2157fn vjp_reduce_3(
2158    node: &Node,
2159    upstream: NodeId,
2160    upstream_shape: Shape,
2161    fwd_map: &HashMap<NodeId, NodeId>,
2162    bwd: &mut Graph,
2163) -> Vec<(usize, NodeId)> {
2164    let Op::Reduce {
2165        op: ReduceOp::Prod,
2166        axes,
2167        keep_dim,
2168    } = &node.op
2169    else {
2170        unreachable!()
2171    };
2172    {
2173        let x_bwd = fwd_map[&node.inputs[0]];
2174        let y_bwd = fwd_map[&node.id];
2175        let x_shape = bwd.node(x_bwd).shape.clone();
2176        let y_expanded = expand_to(y_bwd, &x_shape, axes, *keep_dim, bwd);
2177        let upstream_expanded = expand_to(upstream, &x_shape, axes, *keep_dim, bwd);
2178        // dx = upstream_b · y_b / x
2179        let num = bwd.binary(
2180            BinaryOp::Mul,
2181            upstream_expanded,
2182            y_expanded,
2183            x_shape.clone(),
2184        );
2185        let dx = bwd.binary(BinaryOp::Div, num, x_bwd, x_shape);
2186        vec![(0, dx)]
2187    }
2188}
2189
2190// ── Pool(Mean) ──────────────────────────────────────────
2191//
2192// Forward: y[..., h_out, w_out] = mean(window).
2193// Backward: dx[i] = upstream[output_pos(i)] / |window|
2194//   distributed across each pool window.
2195//
2196// Compose via a Conv2dBackwardInput with a constant
2197// 1/|window| kernel of shape [C, 1, kH, kW] and groups=C
2198// (depthwise — no channel mixing). This gives the correct
2199// "spread upstream over window" behavior including stride
2200// and padding handling.
2201#[allow(unused_variables)]
2202fn vjp_pool_2(
2203    node: &Node,
2204    upstream: NodeId,
2205    upstream_shape: Shape,
2206    fwd_map: &HashMap<NodeId, NodeId>,
2207    bwd: &mut Graph,
2208) -> Vec<(usize, NodeId)> {
2209    let Op::Pool {
2210        kind: ReduceOp::Mean,
2211        kernel_size,
2212        stride,
2213        padding,
2214    } = &node.op
2215    else {
2216        unreachable!()
2217    };
2218    {
2219        assert_eq!(kernel_size.len(), 2, "Pool(Mean) VJP: 2-D pool only");
2220        let x_bwd = fwd_map[&node.inputs[0]];
2221        let x_shape = bwd.node(x_bwd).shape.clone();
2222        let dtype = x_shape.dtype();
2223        // Channels = x_shape.dim(1).
2224        let c = match x_shape.dim(1) {
2225            Dim::Static(n) => n,
2226            _ => panic!("Pool(Mean) VJP: dynamic channel dim"),
2227        };
2228        let kh = kernel_size[0];
2229        let kw = kernel_size[1];
2230        let inv_n = 1.0_f32 / (kh as f32 * kw as f32);
2231        let kernel_n = c * kh * kw;
2232        let mut bytes: Vec<u8> = Vec::with_capacity(kernel_n * 4);
2233        for _ in 0..kernel_n {
2234            bytes.extend_from_slice(&inv_n.to_le_bytes());
2235        }
2236        let kernel_shape = Shape::from_dims(
2237            &[
2238                Dim::Static(c),
2239                Dim::Static(1),
2240                Dim::Static(kh),
2241                Dim::Static(kw),
2242            ],
2243            dtype,
2244        );
2245        let kernel = bwd.add_node(Op::Constant { data: bytes }, vec![], kernel_shape);
2246        let dx = bwd.conv2d_backward_input(
2247            upstream,
2248            kernel,
2249            x_shape,
2250            kernel_size.clone(),
2251            stride.clone(),
2252            padding.clone(),
2253            vec![1, 1],
2254            c, // groups = c → depthwise
2255        );
2256        vec![(0, dx)]
2257    }
2258}
2259
2260// ── Binary(Pow) ─────────────────────────────────────────
2261//
2262//   d/da (aᵇ) = b · a^(b-1)
2263//   d/db (aᵇ) = aᵇ · ln(a)
2264//
2265// We don't have a `Pow` activation, but `pow(a, b)` for
2266// positive base equals `exp(b · ln(a))`, and the derivative
2267// simplifies. Express via `Activation::Log / Exp` and `Mul`.
2268#[allow(unused_variables)]
2269fn vjp_binary_pow(
2270    node: &Node,
2271    upstream: NodeId,
2272    upstream_shape: Shape,
2273    fwd_map: &HashMap<NodeId, NodeId>,
2274    bwd: &mut Graph,
2275) -> Vec<(usize, NodeId)> {
2276    let Op::Binary(BinaryOp::Pow) = &node.op else {
2277        unreachable!()
2278    };
2279    {
2280        let a_bwd = fwd_map[&node.inputs[0]];
2281        let b_bwd = fwd_map[&node.inputs[1]];
2282        let y_bwd = fwd_map[&node.id]; // a^b
2283        let a_shape = bwd.node(a_bwd).shape.clone();
2284        let b_shape = bwd.node(b_bwd).shape.clone();
2285
2286        // d/da: upstream · y / a = upstream · b · a^(b-1).
2287        // Easier route: upstream · y · b / a.
2288        let yb = bwd.binary(BinaryOp::Mul, y_bwd, b_bwd, upstream_shape.clone());
2289        let yb_over_a = bwd.binary(BinaryOp::Div, yb, a_bwd, upstream_shape.clone());
2290        let g_a_full = bwd.binary(BinaryOp::Mul, upstream, yb_over_a, upstream_shape.clone());
2291        let g_a = unbroadcast(g_a_full, &a_shape, bwd);
2292
2293        // d/db: upstream · y · ln(a)
2294        let ln_a = bwd.activation(Activation::Log, a_bwd, a_shape);
2295        let ln_a_b = unbroadcast_inverse(ln_a, &upstream_shape, bwd);
2296        let yln = bwd.binary(BinaryOp::Mul, y_bwd, ln_a_b, upstream_shape.clone());
2297        let g_b_full = bwd.binary(BinaryOp::Mul, upstream, yln, upstream_shape);
2298        let g_b = unbroadcast(g_b_full, &b_shape, bwd);
2299
2300        vec![(0, g_a), (1, g_b)]
2301    }
2302}
2303
2304// ── DequantMatMul (QAT-style straight-through) ─────────
2305//
2306// Forward (Int8BlockAsym):
2307//   w_dq = (cast<f32>(w_q) - zp_b) * scale_b
2308//   y    = x @ w_dq
2309//
2310// Backward (QAT convention — scale and zp are typically
2311// frozen during fine-tuning; w_q's int8 cast is treated as
2312// a no-op for the gradient via straight-through):
2313//   dx     = upstream @ w_dq^T
2314//   dw_q   = x^T @ upstream * scale_b   (straight-through;
2315//            the user's optimizer would project back to
2316//            int8 after the step)
2317//   dscale = 0   (frozen)
2318//   dzp    = 0   (frozen)
2319//
2320// For full QAT with learnable scale/zp, replace the zero
2321// gradients with the closed-form ∂y/∂scale / ∂y/∂zp.
2322// Native low-precision GEMM — straight-through QAT VJP. We rebuild the
2323// (quantized) f32 operands with `ScaledDequantize` and run the ordinary
2324// matmul backward; the gradient is routed to the U8 code inputs as if
2325// they were those f32 operands, and `ScaledQuantize`'s STE VJP forwards
2326// it to the original f32 source. TN forward: out[m,n] = lhs[m,k]·rhs[n,k]ᵀ
2327//   d_lhs = upstream @ rhs_recon          ([m,n]·[n,k] → [m,k])
2328//   d_rhs = upstreamᵀ @ lhs_recon         ([n,m]·[m,k] → [n,k])
2329#[allow(unused_variables)]
2330fn vjp_scaled_mat_mul(
2331    node: &Node,
2332    upstream: NodeId,
2333    upstream_shape: Shape,
2334    fwd_map: &HashMap<NodeId, NodeId>,
2335    bwd: &mut Graph,
2336) -> Vec<(usize, NodeId)> {
2337    let Op::ScaledMatMul {
2338        lhs_format,
2339        rhs_format,
2340        scale_layout,
2341        has_bias,
2342    } = &node.op
2343    else {
2344        unreachable!()
2345    };
2346    {
2347        let lhs_codes = fwd_map[&node.inputs[0]];
2348        let rhs_codes = fwd_map[&node.inputs[1]];
2349        let lhs_scale = fwd_map[&node.inputs[2]];
2350        let rhs_scale = fwd_map[&node.inputs[3]];
2351        let lhs_shape = bwd.node(lhs_codes).shape.clone();
2352        let rhs_shape = bwd.node(rhs_codes).shape.clone();
2353        let m = lhs_shape.dim(0);
2354        let k = lhs_shape.dim(1);
2355        let n = rhs_shape.dim(0);
2356        let f32 = DType::F32;
2357
2358        let lhs_recon = bwd.add_node(
2359            Op::ScaledDequantize {
2360                format: *lhs_format,
2361                scale_layout: *scale_layout,
2362            },
2363            vec![lhs_codes, lhs_scale],
2364            Shape::from_dims(&[m, k], f32),
2365        );
2366        let rhs_recon = bwd.add_node(
2367            Op::ScaledDequantize {
2368                format: *rhs_format,
2369                scale_layout: *scale_layout,
2370            },
2371            vec![rhs_codes, rhs_scale],
2372            Shape::from_dims(&[n, k], f32),
2373        );
2374
2375        let d_lhs = bwd.matmul(upstream, rhs_recon, Shape::from_dims(&[m, k], f32));
2376        let up_t = bwd.add_node(
2377            Op::Transpose { perm: vec![1, 0] },
2378            vec![upstream],
2379            Shape::from_dims(&[n, m], f32),
2380        );
2381        let d_rhs = bwd.matmul(up_t, lhs_recon, Shape::from_dims(&[n, k], f32));
2382
2383        let mut grads = vec![(0usize, d_lhs), (1usize, d_rhs)];
2384        if *has_bias {
2385            let d_bias = bwd.add_node(
2386                Op::Reduce {
2387                    op: ReduceOp::Sum,
2388                    axes: vec![0],
2389                    keep_dim: false,
2390                },
2391                vec![upstream],
2392                Shape::from_dims(&[n], f32),
2393            );
2394            grads.push((4usize, d_bias));
2395        }
2396        grads
2397    }
2398}
2399
2400// Straight-through: quantize is identity for the gradient (the f32
2401// operand receives the cotangent of the codes); the scale is detached.
2402#[allow(unused_variables)]
2403fn vjp_scaled_quantize(
2404    node: &Node,
2405    upstream: NodeId,
2406    upstream_shape: Shape,
2407    fwd_map: &HashMap<NodeId, NodeId>,
2408    bwd: &mut Graph,
2409) -> Vec<(usize, NodeId)> {
2410    let Op::ScaledQuantize { .. } = &node.op else {
2411        unreachable!()
2412    };
2413    vec![(0, upstream)]
2414}
2415
2416// The scale is a detached statistic (amax) — no gradient to its input.
2417#[allow(unused_variables)]
2418fn vjp_scaled_quant_scale(
2419    node: &Node,
2420    upstream: NodeId,
2421    upstream_shape: Shape,
2422    fwd_map: &HashMap<NodeId, NodeId>,
2423    bwd: &mut Graph,
2424) -> Vec<(usize, NodeId)> {
2425    let Op::ScaledQuantScale { .. } = &node.op else {
2426        unreachable!()
2427    };
2428    vec![]
2429}
2430
2431#[allow(unused_variables)]
2432fn vjp_dequant_mat_mul(
2433    node: &Node,
2434    upstream: NodeId,
2435    upstream_shape: Shape,
2436    fwd_map: &HashMap<NodeId, NodeId>,
2437    bwd: &mut Graph,
2438) -> Vec<(usize, NodeId)> {
2439    let Op::DequantMatMul { scheme: _ } = &node.op else {
2440        unreachable!()
2441    };
2442    {
2443        let x_bwd = fwd_map[&node.inputs[0]];
2444        let w_q_bwd = fwd_map[&node.inputs[1]];
2445        let scale_bwd = fwd_map[&node.inputs[2]];
2446        let zp_bwd = fwd_map[&node.inputs[3]];
2447        let x_shape = bwd.node(x_bwd).shape.clone();
2448        let w_shape = bwd.node(w_q_bwd).shape.clone();
2449        let scale_shape = bwd.node(scale_bwd).shape.clone();
2450        let zp_shape = bwd.node(zp_bwd).shape.clone();
2451
2452        // dx = upstream @ w_dq^T. Recompute w_dq inline.
2453        // w_q is int8 in the IR — cast to f32 for the matmul
2454        // backward graph (straight-through equivalent).
2455        let dtype = x_shape.dtype();
2456        let w_q_f32 = bwd.add_node(
2457            Op::Cast { to: dtype },
2458            vec![w_q_bwd],
2459            Shape::from_dims(w_shape.dims(), dtype),
2460        );
2461        // Broadcast scale/zp to w_shape before subtract/mul.
2462        let scale_b = unbroadcast_inverse(scale_bwd, &Shape::from_dims(w_shape.dims(), dtype), bwd);
2463        let zp_b = unbroadcast_inverse(zp_bwd, &Shape::from_dims(w_shape.dims(), dtype), bwd);
2464        let w_centered = bwd.binary(
2465            BinaryOp::Sub,
2466            w_q_f32,
2467            zp_b,
2468            Shape::from_dims(w_shape.dims(), dtype),
2469        );
2470        let w_dq = bwd.binary(
2471            BinaryOp::Mul,
2472            w_centered,
2473            scale_b,
2474            Shape::from_dims(w_shape.dims(), dtype),
2475        );
2476
2477        // Transpose w_dq's last two dims for dx = upstream @ w_dq^T.
2478        let w_rank = w_shape.rank();
2479        let mut perm: Vec<usize> = (0..w_rank).collect();
2480        perm.swap(w_rank - 2, w_rank - 1);
2481        let mut wdt_dims: Vec<Dim> = w_shape.dims().to_vec();
2482        wdt_dims.swap(w_rank - 2, w_rank - 1);
2483        let w_dq_t_shape = Shape::from_dims(&wdt_dims, dtype);
2484        let w_dq_t = bwd.add_node(Op::Transpose { perm }, vec![w_dq], w_dq_t_shape);
2485        let dx = bwd.matmul(upstream, w_dq_t, x_shape.clone());
2486
2487        // dw_q = (x^T @ upstream) * scale_b   (straight-through).
2488        // The result is in the int8-weight space — caller's
2489        // optimizer is expected to project back. We emit it as
2490        // f32 here and let downstream cast.
2491        let x_rank = x_shape.rank();
2492        let mut x_perm: Vec<usize> = (0..x_rank).collect();
2493        x_perm.swap(x_rank - 2, x_rank - 1);
2494        let mut x_t_dims: Vec<Dim> = x_shape.dims().to_vec();
2495        x_t_dims.swap(x_rank - 2, x_rank - 1);
2496        let x_t = bwd.add_node(
2497            Op::Transpose { perm: x_perm },
2498            vec![x_bwd],
2499            Shape::from_dims(&x_t_dims, dtype),
2500        );
2501        let dw_unscaled = bwd.matmul(x_t, upstream, Shape::from_dims(w_shape.dims(), dtype));
2502        let dw_q_f32 = bwd.binary(
2503            BinaryOp::Mul,
2504            dw_unscaled,
2505            scale_b,
2506            Shape::from_dims(w_shape.dims(), dtype),
2507        );
2508        // Cast back to the IR's int8 dtype convention.
2509        let dw_q = bwd.add_node(
2510            Op::Cast {
2511                to: w_shape.dtype(),
2512            },
2513            vec![dw_q_f32],
2514            w_shape,
2515        );
2516
2517        // scale and zp: zero gradients (frozen QAT convention).
2518        let zero_scale_bytes =
2519            vec![0u8; scale_shape.num_elements().expect("DQMM VJP: dyn scale") * 4];
2520        let zero_zp_bytes = vec![0u8; zp_shape.num_elements().expect("DQMM VJP: dyn zp") * 4];
2521        let dscale = bwd.add_node(
2522            Op::Constant {
2523                data: zero_scale_bytes,
2524            },
2525            vec![],
2526            scale_shape,
2527        );
2528        let dzp = bwd.add_node(
2529            Op::Constant {
2530                data: zero_zp_bytes,
2531            },
2532            vec![],
2533            zp_shape,
2534        );
2535
2536        vec![(0, dx), (1, dw_q), (2, dscale), (3, dzp)]
2537    }
2538}
2539
2540// ── ScatterAdd ──────────────────────────────────────────
2541//
2542// Forward: out[indices[i], ...] += updates[i, ...].
2543// Backward: d_updates[i, ...] = upstream[indices[i], ...]  (gather).
2544//   Indices are non-differentiable.
2545#[allow(unused_variables)]
2546fn vjp_scatter_add(
2547    node: &Node,
2548    upstream: NodeId,
2549    upstream_shape: Shape,
2550    fwd_map: &HashMap<NodeId, NodeId>,
2551    bwd: &mut Graph,
2552) -> Vec<(usize, NodeId)> {
2553    let Op::ScatterAdd = &node.op else {
2554        unreachable!()
2555    };
2556    {
2557        let updates_bwd = fwd_map[&node.inputs[0]];
2558        let indices_bwd = fwd_map[&node.inputs[1]];
2559        let updates_shape = bwd.node(updates_bwd).shape.clone();
2560        let dupdates = bwd.add_node(
2561            Op::Gather { axis: 0 },
2562            vec![upstream, indices_bwd],
2563            updates_shape,
2564        );
2565        vec![(0, dupdates)]
2566    }
2567}
2568
2569// ── Cumsum ──────────────────────────────────────────────
2570//
2571#[allow(unused_variables)]
2572fn vjp_cumsum(
2573    node: &Node,
2574    upstream: NodeId,
2575    upstream_shape: Shape,
2576    fwd_map: &HashMap<NodeId, NodeId>,
2577    bwd: &mut Graph,
2578) -> Vec<(usize, NodeId)> {
2579    let Op::Cumsum { axis, exclusive } = &node.op else {
2580        unreachable!()
2581    };
2582    {
2583        let x_bwd = fwd_map[&node.inputs[0]];
2584        let x_shape = bwd.node(x_bwd).shape.clone();
2585        let dx = bwd.cumsum_backward(upstream, x_shape, *axis, *exclusive);
2586        vec![(0, dx)]
2587    }
2588}
2589
2590// ── GroupedMatMul (MoE primitive) ──────────────────────
2591//
2592// Forward: y[i] = x[i] @ w[expert[i]]
2593//   x        [M, K]
2594//   w        [E, K, N]
2595//   expert   [M] (f32-encoded indices)
2596//   y        [M, N]
2597//
2598// Backward (composed via Gather + batched-MatMul + ScatterAdd):
2599//   dx[i] = upstream[i] @ w[expert[i]]^T
2600//   dw[e, k, n] = sum_{i : expert[i]=e} x[i,k] · upstream[i,n]
2601//   dexpert: zero (non-differentiable index input).
2602#[allow(unused_variables)]
2603fn vjp_grouped_mat_mul(
2604    node: &Node,
2605    upstream: NodeId,
2606    upstream_shape: Shape,
2607    fwd_map: &HashMap<NodeId, NodeId>,
2608    bwd: &mut Graph,
2609) -> Vec<(usize, NodeId)> {
2610    let Op::GroupedMatMul = &node.op else {
2611        unreachable!()
2612    };
2613    {
2614        let x_bwd = fwd_map[&node.inputs[0]];
2615        let w_bwd = fwd_map[&node.inputs[1]];
2616        let expert_bwd = fwd_map[&node.inputs[2]];
2617        let x_shape = bwd.node(x_bwd).shape.clone();
2618        let w_shape = bwd.node(w_bwd).shape.clone();
2619        let (dx, dw) =
2620            grouped_matmul_vjp(bwd, upstream, x_bwd, w_bwd, expert_bwd, &x_shape, &w_shape);
2621        vec![(0, dx), (1, dw)]
2622    }
2623}
2624
2625// ── DequantGroupedMatMul (frozen GGUF MoE weights) ─────
2626//
2627// Materialize w_dq via `Op::DequantMoEWeights`, then reuse the
2628// GroupedMatMul VJP. Packed U8 weights and expert indices are
2629// non-differentiable (inference / QAT-frozen convention).
2630#[allow(unused_variables)]
2631fn vjp_dequant_grouped_mat_mul(
2632    node: &Node,
2633    upstream: NodeId,
2634    upstream_shape: Shape,
2635    fwd_map: &HashMap<NodeId, NodeId>,
2636    bwd: &mut Graph,
2637) -> Vec<(usize, NodeId)> {
2638    let Op::DequantGroupedMatMul { scheme } = &node.op else {
2639        unreachable!()
2640    };
2641    {
2642        let x_bwd = fwd_map[&node.inputs[0]];
2643        let w_packed = fwd_map[&node.inputs[1]];
2644        let expert_bwd = fwd_map[&node.inputs[2]];
2645        let x_shape = bwd.node(x_bwd).shape.clone();
2646        let w_packed_shape = bwd.node(w_packed).shape.clone();
2647        let dtype = x_shape.dtype();
2648        let k = x_shape.dim(1);
2649        let n_out = node.shape.dim(node.shape.rank() - 1);
2650        let k_static = match k {
2651            Dim::Static(v) => v,
2652            _ => panic!("DequantGroupedMatMul VJP: K must be static"),
2653        };
2654        let n_static = match n_out {
2655            Dim::Static(v) => v,
2656            _ => panic!("DequantGroupedMatMul VJP: N must be static"),
2657        };
2658        let block_elems = scheme.gguf_block_size() as usize;
2659        let block_bytes = scheme.gguf_block_bytes() as usize;
2660        let slab_bytes = (k_static * n_static) / block_elems * block_bytes;
2661        let total_bytes = w_packed_shape
2662            .num_elements()
2663            .expect("DequantGroupedMatMul VJP: dyn packed");
2664        let e_static = total_bytes / slab_bytes.max(1);
2665        let w_shape = Shape::from_dims(
2666            &[
2667                Dim::Static(e_static),
2668                Dim::Static(k_static),
2669                Dim::Static(n_static),
2670            ],
2671            dtype,
2672        );
2673        let w_dq = bwd.add_node(
2674            Op::DequantMoEWeights { scheme: *scheme },
2675            vec![w_packed],
2676            w_shape.clone(),
2677        );
2678        let (dx, _dw) =
2679            grouped_matmul_vjp(bwd, upstream, x_bwd, w_dq, expert_bwd, &x_shape, &w_shape);
2680        vec![(0, dx)]
2681    }
2682}
2683
2684// ── QMatMul / QConv2d (straight-through INT8 backward) ──
2685//
2686// Real INT8 inference kernels. The forward applies
2687//   out = clamp(round((x − x_zp) · (w − w_zp) · mult + bias)
2688//               + out_zp, [-128, 127])
2689// and outputs i8. For training, the standard QAT recipe
2690// treats the round/clamp/quantize as straight-through, so
2691// the gradient is what a plain f32 MatMul (or Conv) backward
2692// would give applied to the dequantized representations.
2693// Zero-points and `mult` are typically frozen (calibration
2694// outputs); we emit zero gradients for them. Bias gets the
2695// standard sum-over-batch gradient.
2696#[allow(unused_variables)]
2697fn vjp_q_mat_mul(
2698    node: &Node,
2699    upstream: NodeId,
2700    upstream_shape: Shape,
2701    fwd_map: &HashMap<NodeId, NodeId>,
2702    bwd: &mut Graph,
2703) -> Vec<(usize, NodeId)> {
2704    let Op::QMatMul {
2705        x_zp,
2706        w_zp,
2707        out_zp: _,
2708        mult,
2709    } = &node.op
2710    else {
2711        unreachable!()
2712    };
2713    {
2714        let x_bwd = fwd_map[&node.inputs[0]];
2715        let w_bwd = fwd_map[&node.inputs[1]];
2716        let bias_bwd = fwd_map[&node.inputs[2]];
2717        let x_shape = bwd.node(x_bwd).shape.clone();
2718        let w_shape = bwd.node(w_bwd).shape.clone();
2719        let bias_shape = bwd.node(bias_bwd).shape.clone();
2720        let dtype = upstream_shape.dtype();
2721
2722        // Promote x and w to f32 (straight-through); subtract zps.
2723        let x_f32 = bwd.add_node(
2724            Op::Cast { to: dtype },
2725            vec![x_bwd],
2726            Shape::from_dims(x_shape.dims(), dtype),
2727        );
2728        let w_f32 = bwd.add_node(
2729            Op::Cast { to: dtype },
2730            vec![w_bwd],
2731            Shape::from_dims(w_shape.dims(), dtype),
2732        );
2733        let xzp_c = scalar_const(*x_zp as f32, bwd);
2734        let xzp_b = unbroadcast_inverse(xzp_c, &Shape::from_dims(x_shape.dims(), dtype), bwd);
2735        let _ = bwd.binary(
2736            BinaryOp::Sub,
2737            x_f32,
2738            xzp_b,
2739            Shape::from_dims(x_shape.dims(), dtype),
2740        );
2741        let wzp_c = scalar_const(*w_zp as f32, bwd);
2742        let wzp_b = unbroadcast_inverse(wzp_c, &Shape::from_dims(w_shape.dims(), dtype), bwd);
2743        let w_centered = bwd.binary(
2744            BinaryOp::Sub,
2745            w_f32,
2746            wzp_b,
2747            Shape::from_dims(w_shape.dims(), dtype),
2748        );
2749
2750        // mult scaling.
2751        let mult_c = scalar_const(*mult, bwd);
2752        let mult_b = unbroadcast_inverse(mult_c, &upstream_shape, bwd);
2753        let upstream_scaled = bwd.binary(BinaryOp::Mul, upstream, mult_b, upstream_shape.clone());
2754
2755        // dx = upstream_scaled @ w_centered^T   (still i8 dtype
2756        //  on the input side; cast the gradient back).
2757        let w_rank = w_shape.rank();
2758        let mut perm: Vec<usize> = (0..w_rank).collect();
2759        perm.swap(w_rank - 2, w_rank - 1);
2760        let mut wt_dims: Vec<Dim> = w_shape.dims().to_vec();
2761        wt_dims.swap(w_rank - 2, w_rank - 1);
2762        let w_t = bwd.add_node(
2763            Op::Transpose { perm },
2764            vec![w_centered],
2765            Shape::from_dims(&wt_dims, dtype),
2766        );
2767        let dx_f32 = bwd.matmul(
2768            upstream_scaled,
2769            w_t,
2770            Shape::from_dims(x_shape.dims(), dtype),
2771        );
2772        let dx = bwd.add_node(
2773            Op::Cast {
2774                to: x_shape.dtype(),
2775            },
2776            vec![dx_f32],
2777            x_shape.clone(),
2778        );
2779
2780        // dw = x_centered^T @ upstream_scaled  (similarly cast).
2781        let x_rank = x_shape.rank();
2782        let mut x_perm: Vec<usize> = (0..x_rank).collect();
2783        x_perm.swap(x_rank - 2, x_rank - 1);
2784        let mut xt_dims: Vec<Dim> = x_shape.dims().to_vec();
2785        xt_dims.swap(x_rank - 2, x_rank - 1);
2786        // Need to pull x_centered into scope — recompute inline.
2787        let x_f32_2 = bwd.add_node(
2788            Op::Cast { to: dtype },
2789            vec![x_bwd],
2790            Shape::from_dims(x_shape.dims(), dtype),
2791        );
2792        let x_centered = bwd.binary(
2793            BinaryOp::Sub,
2794            x_f32_2,
2795            xzp_b,
2796            Shape::from_dims(x_shape.dims(), dtype),
2797        );
2798        let x_t = bwd.add_node(
2799            Op::Transpose { perm: x_perm },
2800            vec![x_centered],
2801            Shape::from_dims(&xt_dims, dtype),
2802        );
2803        let dw_f32 = bwd.matmul(
2804            x_t,
2805            upstream_scaled,
2806            Shape::from_dims(w_shape.dims(), dtype),
2807        );
2808        let dw = bwd.add_node(
2809            Op::Cast {
2810                to: w_shape.dtype(),
2811            },
2812            vec![dw_f32],
2813            w_shape,
2814        );
2815
2816        // dbias = sum upstream_scaled over batch axes (matches
2817        // f32 MatMul-with-bias backward shape).
2818        let bias_rank = bias_shape.rank();
2819        let reduce_axes: Vec<usize> = (0..upstream_shape.rank())
2820            .filter(|&i| i + bias_rank < upstream_shape.rank() || i == 0)
2821            .collect();
2822        let dbias_f32 = bwd.add_node(
2823            Op::Reduce {
2824                op: ReduceOp::Sum,
2825                axes: reduce_axes,
2826                keep_dim: false,
2827            },
2828            vec![upstream_scaled],
2829            Shape::from_dims(bias_shape.dims(), dtype),
2830        );
2831        let dbias = bwd.add_node(
2832            Op::Cast {
2833                to: bias_shape.dtype(),
2834            },
2835            vec![dbias_f32],
2836            bias_shape,
2837        );
2838
2839        vec![(0, dx), (1, dw), (2, dbias)]
2840    }
2841}
2842
2843#[allow(unused_variables)]
2844fn vjp_q_conv2d(
2845    node: &Node,
2846    upstream: NodeId,
2847    upstream_shape: Shape,
2848    fwd_map: &HashMap<NodeId, NodeId>,
2849    bwd: &mut Graph,
2850) -> Vec<(usize, NodeId)> {
2851    let Op::QConv2d {
2852        kernel_size,
2853        stride,
2854        padding,
2855        dilation,
2856        groups,
2857        x_zp,
2858        w_zp,
2859        out_zp: _,
2860        mult,
2861    } = &node.op
2862    else {
2863        unreachable!()
2864    };
2865    {
2866        // Same straight-through pattern as QMatMul, lifted to
2867        // 2-D conv via the existing Conv2dBackwardInput / Weight
2868        // kernels.
2869        let x_bwd = fwd_map[&node.inputs[0]];
2870        let w_bwd = fwd_map[&node.inputs[1]];
2871        let bias_bwd = fwd_map[&node.inputs[2]];
2872        let x_shape = bwd.node(x_bwd).shape.clone();
2873        let w_shape = bwd.node(w_bwd).shape.clone();
2874        let bias_shape = bwd.node(bias_bwd).shape.clone();
2875        let dtype = upstream_shape.dtype();
2876
2877        // Promote and dequantize.
2878        let x_f32 = bwd.add_node(
2879            Op::Cast { to: dtype },
2880            vec![x_bwd],
2881            Shape::from_dims(x_shape.dims(), dtype),
2882        );
2883        let w_f32 = bwd.add_node(
2884            Op::Cast { to: dtype },
2885            vec![w_bwd],
2886            Shape::from_dims(w_shape.dims(), dtype),
2887        );
2888        let xzp_c = scalar_const(*x_zp as f32, bwd);
2889        let xzp_b = unbroadcast_inverse(xzp_c, &Shape::from_dims(x_shape.dims(), dtype), bwd);
2890        let x_centered = bwd.binary(
2891            BinaryOp::Sub,
2892            x_f32,
2893            xzp_b,
2894            Shape::from_dims(x_shape.dims(), dtype),
2895        );
2896        let wzp_c = scalar_const(*w_zp as f32, bwd);
2897        let wzp_b = unbroadcast_inverse(wzp_c, &Shape::from_dims(w_shape.dims(), dtype), bwd);
2898        let w_centered = bwd.binary(
2899            BinaryOp::Sub,
2900            w_f32,
2901            wzp_b,
2902            Shape::from_dims(w_shape.dims(), dtype),
2903        );
2904
2905        // mult scaling on upstream.
2906        let mult_c = scalar_const(*mult, bwd);
2907        let mult_b = unbroadcast_inverse(mult_c, &upstream_shape, bwd);
2908        let upstream_scaled = bwd.binary(BinaryOp::Mul, upstream, mult_b, upstream_shape.clone());
2909
2910        // dx, dw via the existing conv-backward kernels.
2911        let dx_f32 = bwd.conv2d_backward_input(
2912            upstream_scaled,
2913            w_centered,
2914            Shape::from_dims(x_shape.dims(), dtype),
2915            kernel_size.clone(),
2916            stride.clone(),
2917            padding.clone(),
2918            dilation.clone(),
2919            *groups,
2920        );
2921        let dx = bwd.add_node(
2922            Op::Cast {
2923                to: x_shape.dtype(),
2924            },
2925            vec![dx_f32],
2926            x_shape,
2927        );
2928        let dw_f32 = bwd.conv2d_backward_weight(
2929            x_centered,
2930            upstream_scaled,
2931            Shape::from_dims(w_shape.dims(), dtype),
2932            kernel_size.clone(),
2933            stride.clone(),
2934            padding.clone(),
2935            dilation.clone(),
2936            *groups,
2937        );
2938        let dw = bwd.add_node(
2939            Op::Cast {
2940                to: w_shape.dtype(),
2941            },
2942            vec![dw_f32],
2943            w_shape,
2944        );
2945
2946        // dbias = sum upstream_scaled over (N, H_out, W_out) keeping C_out.
2947        let dbias_f32 = bwd.add_node(
2948            Op::Reduce {
2949                op: ReduceOp::Sum,
2950                axes: vec![0, 2, 3],
2951                keep_dim: false,
2952            },
2953            vec![upstream_scaled],
2954            Shape::from_dims(bias_shape.dims(), dtype),
2955        );
2956        let dbias = bwd.add_node(
2957            Op::Cast {
2958                to: bias_shape.dtype(),
2959            },
2960            vec![dbias_f32],
2961            bias_shape,
2962        );
2963
2964        vec![(0, dx), (1, dw), (2, dbias)]
2965    }
2966}
2967
2968#[allow(unused_variables)]
2969fn vjp_gaussian_splat_render(
2970    node: &Node,
2971    upstream: NodeId,
2972    upstream_shape: Shape,
2973    fwd_map: &HashMap<NodeId, NodeId>,
2974    bwd: &mut Graph,
2975) -> Vec<(usize, NodeId)> {
2976    let Op::GaussianSplatRender {
2977        width,
2978        height,
2979        tile_size,
2980        radius_scale,
2981        alpha_cutoff,
2982        max_splat_steps,
2983        transmittance_threshold,
2984        max_list_entries,
2985        ..
2986    } = &node.op
2987    else {
2988        unreachable!()
2989    };
2990    {
2991        use rlx_ir::ops::splat::{
2992            GaussianSplatBackwardParams, GaussianSplatInputs, GaussianSplatRenderParams,
2993            unpack_gaussian_splat_packed_grads,
2994        };
2995        let render = GaussianSplatRenderParams {
2996            width: *width,
2997            height: *height,
2998            tile_size: *tile_size,
2999            radius_scale: *radius_scale,
3000            alpha_cutoff: *alpha_cutoff,
3001            max_splat_steps: *max_splat_steps,
3002            transmittance_threshold: *transmittance_threshold,
3003            max_list_entries: *max_list_entries,
3004        };
3005        let inputs = GaussianSplatInputs {
3006            positions: fwd_map[&node.inputs[0]],
3007            scales: fwd_map[&node.inputs[1]],
3008            rotations: fwd_map[&node.inputs[2]],
3009            opacities: fwd_map[&node.inputs[3]],
3010            colors: fwd_map[&node.inputs[4]],
3011            sh_coeffs: fwd_map[&node.inputs[5]],
3012            meta: fwd_map[&node.inputs[6]],
3013        };
3014        let count = bwd.shape(inputs.positions).num_elements().unwrap_or(0) / 3;
3015        let sh_len = bwd.shape(inputs.sh_coeffs).num_elements().unwrap_or(0);
3016        let meta_shape = bwd.shape(inputs.meta).clone();
3017        let packed = bwd.gaussian_splat_render_backward(
3018            inputs,
3019            upstream,
3020            GaussianSplatBackwardParams {
3021                render,
3022                loss_grad_clip: 1.0,
3023                sh_band: 0,
3024                max_anisotropy: 10.0,
3025            },
3026        );
3027        let sh_coeff_count = if count == 0 {
3028            1
3029        } else {
3030            (sh_len / (count * 3)).max(1)
3031        };
3032        let grads = unpack_gaussian_splat_packed_grads(bwd, packed, count, sh_coeff_count);
3033        let meta_n = meta_shape.num_elements().unwrap_or(0);
3034        let zero_meta = bwd.add_node(
3035            Op::Constant {
3036                data: vec![0u8; meta_n * meta_shape.dtype().size_bytes()],
3037            },
3038            vec![],
3039            meta_shape,
3040        );
3041        vec![
3042            (0, grads.positions),
3043            (1, grads.scales),
3044            (2, grads.rotations),
3045            (3, grads.opacities),
3046            (4, grads.colors),
3047            (5, grads.sh_coeffs),
3048            (6, zero_meta),
3049        ]
3050    }
3051}
3052
3053#[allow(unused_variables)]
3054fn vjp_gaussian_splat_render_backward(
3055    node: &Node,
3056    upstream: NodeId,
3057    upstream_shape: Shape,
3058    fwd_map: &HashMap<NodeId, NodeId>,
3059    bwd: &mut Graph,
3060) -> Vec<(usize, NodeId)> {
3061    let Op::GaussianSplatRenderBackward { .. } = &node.op else {
3062        unreachable!()
3063    };
3064    {
3065        // Scene/meta inputs are not differentiated through this op in v1.
3066        vec![]
3067    }
3068}
3069
3070// ── Anything else: explicit panic with op name ──
3071//
3072// All ops in the IR have either a per-op VJP rule above
3073// or a pre-pass rewrite that decomposes them into ops
3074// that do:
3075//   * Op::If → control_flow::inline_if (Where + inlined
3076//     branches).
3077//   * Op::While → control_flow::unroll_while (bounded
3078//     unroll up to max_iterations).
3079//   * Op::SelectiveScan / Op::FusedTransformerLayer /
3080//     Op::FusedAttentionBlock / Op::FusedSwiGLU /
3081//     Op::LoraMatMul / Op::FusedMatMulBiasAct /
3082//     Op::FusedResidualLN → rlx_fusion::unfuse_fused_for_autodiff.
3083//
3084// User-defined sub-graph (Op::CustomFn) with override AD.
3085// When `vjp_body` is supplied, inline it into `bwd`: each
3086// primal Op::Input maps to the outer forward NodeId for that
3087// primal; the special-named "primal_output" Input maps to the
3088// forward NodeId of this CustomFn node; "d_output" maps to
3089// `upstream`. The body's N outputs become this op's N input
3090// gradients in declaration order.
3091#[allow(unused_variables)]
3092fn vjp_custom_fn(
3093    node: &Node,
3094    upstream: NodeId,
3095    upstream_shape: Shape,
3096    fwd_map: &HashMap<NodeId, NodeId>,
3097    bwd: &mut Graph,
3098) -> Vec<(usize, NodeId)> {
3099    let Op::CustomFn {
3100        vjp_body: Some(vjp_body),
3101        num_inputs,
3102        ..
3103    } = &node.op
3104    else {
3105        unreachable!()
3106    };
3107    {
3108        // Map vjp_body NodeIds → bwd NodeIds.
3109        let mut sub_to_bwd: HashMap<NodeId, NodeId> = HashMap::new();
3110
3111        // Collect primal-input NodeIds from vjp_body (excluding
3112        // special names), sorted by NodeId. Position k in this list
3113        // matches the outer node's input k.
3114        let mut primal_input_ids: Vec<NodeId> = vjp_body
3115            .nodes()
3116            .iter()
3117            .filter_map(|n| match &n.op {
3118                Op::Input { name } if name != "primal_output" && name != "d_output" => Some(n.id),
3119                _ => None,
3120            })
3121            .collect();
3122        primal_input_ids.sort();
3123        assert_eq!(primal_input_ids.len(), *num_inputs as usize);
3124
3125        // Walk vjp_body in declaration order, cloning each non-Input
3126        // node into bwd with input remapping.
3127        for sub_node in vjp_body.nodes() {
3128            let new_id = match &sub_node.op {
3129                Op::Input { name } if name == "primal_output" => fwd_map[&node.id],
3130                Op::Input { name } if name == "d_output" => upstream,
3131                Op::Input { .. } => {
3132                    // Find this Input's index in primal_input_ids.
3133                    let idx = primal_input_ids
3134                        .iter()
3135                        .position(|&id| id == sub_node.id)
3136                        .expect(
3137                            "custom_fn vjp_body: primal Input \
3138                                     not found in primal list",
3139                        );
3140                    fwd_map[&node.inputs[idx]]
3141                }
3142                _ => {
3143                    let new_inputs: Vec<NodeId> =
3144                        sub_node.inputs.iter().map(|i| sub_to_bwd[i]).collect();
3145                    bwd.add_node(sub_node.op.clone(), new_inputs, sub_node.shape.clone())
3146                }
3147            };
3148            sub_to_bwd.insert(sub_node.id, new_id);
3149        }
3150
3151        // Collect outputs in set_outputs order — each maps to a
3152        // primal-input gradient.
3153        let mut grads: Vec<(usize, NodeId)> = Vec::with_capacity(*num_inputs as usize);
3154        for (i, out_id) in vjp_body.outputs.iter().enumerate() {
3155            grads.push((i, sub_to_bwd[out_id]));
3156        }
3157        grads
3158    }
3159}
3160
3161// CustomFn without vjp_body is inlined by `inline_custom_fn_for_autodiff`
3162// before the reverse walk — reaching here means the pre-pass missed it.
3163#[allow(unused_variables)]
3164fn vjp_custom_fn_2(
3165    node: &Node,
3166    upstream: NodeId,
3167    upstream_shape: Shape,
3168    fwd_map: &HashMap<NodeId, NodeId>,
3169    bwd: &mut Graph,
3170) -> Vec<(usize, NodeId)> {
3171    let Op::CustomFn { vjp_body: None, .. } = &node.op else {
3172        unreachable!()
3173    };
3174    {
3175        panic!(
3176            "autodiff: Op::CustomFn has no vjp_body and was not inlined. \
3177                 This is an internal error in inline_custom_fn_for_autodiff."
3178        )
3179    }
3180}
3181
3182// User-registered custom op — dispatch the VJP through the
3183// op registry. The impl emits gradient nodes via the same
3184// `bwd` builder built-in arms use; default impl returns
3185// `vec![]` (non-differentiable).
3186#[allow(unused_variables)]
3187fn vjp_custom(
3188    node: &Node,
3189    upstream: NodeId,
3190    upstream_shape: Shape,
3191    fwd_map: &HashMap<NodeId, NodeId>,
3192    bwd: &mut Graph,
3193) -> Vec<(usize, NodeId)> {
3194    let Op::Custom { name, .. } = &node.op else {
3195        unreachable!()
3196    };
3197    {
3198        let ext = rlx_ir::lookup_op(name).unwrap_or_else(|| {
3199            panic!(
3200                "autodiff: Op::Custom('{name}') is not registered \
3201                        in the op registry — register it via \
3202                        rlx_ir::register_op before compiling the graph"
3203            )
3204        });
3205        let mut ctx = rlx_ir::VjpContext {
3206            upstream,
3207            fwd_map,
3208            bwd,
3209        };
3210        ext.vjp(node, &mut ctx)
3211    }
3212}
3213
3214#[allow(unused_variables)]
3215fn vjp_conv2d_backward_input(
3216    node: &Node,
3217    upstream: NodeId,
3218    upstream_shape: Shape,
3219    fwd_map: &HashMap<NodeId, NodeId>,
3220    bwd: &mut Graph,
3221) -> Vec<(usize, NodeId)> {
3222    let Op::Conv2dBackwardInput {
3223        kernel_size,
3224        stride,
3225        padding,
3226        dilation,
3227        groups,
3228    } = &node.op
3229    else {
3230        unreachable!()
3231    };
3232    {
3233        let dy_bwd = fwd_map[&node.inputs[0]];
3234        let w_bwd = fwd_map[&node.inputs[1]];
3235        let dy_shape = bwd.node(dy_bwd).shape.clone();
3236        let _x_shape = node.shape.clone();
3237        let d_dy = bwd.add_node(
3238            Op::Conv {
3239                kernel_size: kernel_size.clone(),
3240                stride: stride.clone(),
3241                padding: padding.clone(),
3242                dilation: dilation.clone(),
3243                groups: *groups,
3244            },
3245            vec![upstream, w_bwd],
3246            dy_shape,
3247        );
3248        vec![(0, d_dy)]
3249    }
3250}
3251
3252#[allow(unused_variables)]
3253fn vjp_conv2d_backward_weight(
3254    node: &Node,
3255    upstream: NodeId,
3256    upstream_shape: Shape,
3257    fwd_map: &HashMap<NodeId, NodeId>,
3258    bwd: &mut Graph,
3259) -> Vec<(usize, NodeId)> {
3260    let Op::Conv2dBackwardWeight {
3261        kernel_size,
3262        stride,
3263        padding,
3264        dilation,
3265        groups,
3266    } = &node.op
3267    else {
3268        unreachable!()
3269    };
3270    {
3271        let x_bwd = fwd_map[&node.inputs[0]];
3272        let dy_bwd = fwd_map[&node.inputs[1]];
3273        let x_shape = bwd.node(x_bwd).shape.clone();
3274        let dy_shape = bwd.node(dy_bwd).shape.clone();
3275        let d_x = bwd.conv2d_backward_input(
3276            dy_bwd,
3277            upstream,
3278            x_shape,
3279            kernel_size.clone(),
3280            stride.clone(),
3281            padding.clone(),
3282            dilation.clone(),
3283            *groups,
3284        );
3285        let d_dy = bwd.add_node(
3286            Op::Conv {
3287                kernel_size: kernel_size.clone(),
3288                stride: stride.clone(),
3289                padding: padding.clone(),
3290                dilation: dilation.clone(),
3291                groups: *groups,
3292            },
3293            vec![x_bwd, upstream],
3294            dy_shape,
3295        );
3296        vec![(0, d_x), (1, d_dy)]
3297    }
3298}
3299
3300// 1D FFT: y = fft(x; inverse). Both forward and inverse are
3301// unnormalized linear operators on the 2N real-block layout,
3302// and the DFT matrix's transpose (over the real-block view)
3303// equals the unnormalized inverse DFT. So:
3304//   VJP(fft)  = ifft(upstream)
3305//   VJP(ifft) = fft(upstream)
3306// No scaling — the choice to leave both directions unnormalized
3307// makes the chain rule a flag flip and nothing else.
3308#[allow(unused_variables)]
3309fn vjp_fft(
3310    node: &Node,
3311    upstream: NodeId,
3312    upstream_shape: Shape,
3313    fwd_map: &HashMap<NodeId, NodeId>,
3314    bwd: &mut Graph,
3315) -> Vec<(usize, NodeId)> {
3316    let Op::Fft { inverse, norm } = &node.op else {
3317        unreachable!()
3318    };
3319    {
3320        let n = rlx_ir::fft::fft_meta(bwd.shape(node.inputs[0])).n_complex;
3321        let s = norm.output_scale(n, *inverse) as f32;
3322        let z = if s != 1.0 {
3323            let sc = scalar_const(s, bwd);
3324            bwd.mul(upstream, sc)
3325        } else {
3326            upstream
3327        };
3328        let dx = bwd.fft(z, !*inverse);
3329        vec![(0, dx)]
3330    }
3331}
3332
3333#[allow(unused_variables)]
3334fn vjp_log_mel(
3335    node: &Node,
3336    upstream: NodeId,
3337    upstream_shape: Shape,
3338    fwd_map: &HashMap<NodeId, NodeId>,
3339    bwd: &mut Graph,
3340) -> Vec<(usize, NodeId)> {
3341    let Op::LogMel = &node.op else { unreachable!() };
3342    {
3343        let spec_bwd = fwd_map[&node.inputs[0]];
3344        let filt_bwd = fwd_map[&node.inputs[1]];
3345        let dx = bwd.log_mel_backward(spec_bwd, filt_bwd, upstream);
3346        vec![(0, dx)]
3347    }
3348}
3349
3350/// Decompose tier-2 fused ops back to their primitive components so
3351/// the per-op VJP rules cover them. Conceptually identical to what a
3352/// "training-aware compile" pipeline would do as a pre-pass: avoid
3353/// running `FuseMatMulBiasAct` / `FuseResidualLN` / `FuseSwiGLU` /
3354/// `FuseAttentionBlock` if you plan to autodiff afterward. This
3355/// helper handles the case where they're already in the graph (e.g.
3356/// from a re-trained inference model).
3357///
3358/// Decomposed today: `FusedMatMulBiasAct`, `FusedResidualLN`,
3359/// `LoraMatMul`, `FusedSwiGLU`, `FusedAttentionBlock`,
3360/// `FusedTransformerLayer`, and `SelectiveScan` / `GatedDeltaNet` —
3361/// each rewritten back to its primitive chain (matmul / narrow / attention /
3362/// layer_norm / residual / activation, plus reduce-sum / concat /
3363/// Pre-AD pass: convert every `Op::Scan { save_trajectory: false }`
3364/// into `Op::Scan { save_trajectory: true }` followed by `Narrow` +
3365/// `Reshape` to extract the final carry. After this rewrite, every
3366/// scan in the graph carries its full trajectory — which is what the
3367/// VJP rule needs to compute backward through time. The user-facing
3368/// shape is unchanged (Narrow + Reshape collapse [length, *carry]
3369/// back down to *carry).
3370///
3371/// Memory cost: trajectory storage is now `O(length × carry_size)`
3372/// for the duration of the forward + backward pass. For Diffrax-style
3373/// transients this is the same as Diffrax's `RecursiveCheckpointAdjoint::All`
3374/// strategy. Recursive checkpointing is a future pass.
3375/// Pre-AD pass: rewrite `Op::Scan` nodes with `num_bcast > 0` into
3376/// equivalent `num_bcast = 0` scans by materialising each broadcast
3377/// input `b` of shape `*bcast` into a per-step xs of shape
3378/// `[length, *bcast]` (built as `ones([length, *bcast]) × b`). The
3379/// reverse-mode AD walk and the rest of `convert_scans_for_ad` then
3380/// see only carry+xs scans — the bcast channel is a forward-only
3381/// memory optimisation, transparent to backward.
3382fn materialize_bcasts_for_ad(g: Graph) -> Graph {
3383    use rlx_ir::op::BinaryOp;
3384
3385    let needs = g.nodes().iter().any(|n| {
3386        matches!(
3387            &n.op, Op::Scan { num_bcast, .. } if *num_bcast > 0
3388        )
3389    });
3390    if !needs {
3391        return g;
3392    }
3393
3394    let mut out = Graph::new(g.name.clone());
3395    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
3396
3397    for node in g.nodes() {
3398        let new_inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
3399        match &node.op {
3400            Op::Scan {
3401                body,
3402                length,
3403                save_trajectory,
3404                num_bcast,
3405                num_xs,
3406                num_checkpoints,
3407            } if *num_bcast > 0 => {
3408                // Each bcast input gets multiplied by an
3409                // `[length, 1, ..., 1]` ones constant of matching dtype
3410                // (broadcast against the bcast's natural shape) to
3411                // produce a `[length, *bcast]` materialised xs.
3412                let bcast_base = 1;
3413                let xs_base = 1 + *num_bcast as usize;
3414
3415                let mut new_scan_inputs = vec![new_inputs[0]];
3416
3417                // Original xs first remain xs.
3418                let mut materialised_xs: Vec<NodeId> = Vec::new();
3419                for i in 0..*num_bcast as usize {
3420                    let b_id = new_inputs[bcast_base + i];
3421                    let b_shape = out.node(b_id).shape.clone();
3422                    let dtype = b_shape.dtype();
3423
3424                    // ones with shape [length, 1, 1, ...] (matching b's rank
3425                    // beyond the leading axis we're prepending). Broadcast
3426                    // against b of shape [*bcast] gives [length, *bcast].
3427                    let mut ones_dims: Vec<rlx_ir::Dim> =
3428                        vec![rlx_ir::Dim::Static(*length as usize)];
3429                    for _ in 0..b_shape.rank() {
3430                        ones_dims.push(rlx_ir::Dim::Static(1));
3431                    }
3432                    let ones_shape = rlx_ir::Shape::from_dims(&ones_dims, dtype);
3433                    let n_elems: usize = ones_dims
3434                        .iter()
3435                        .map(|d| match d {
3436                            rlx_ir::Dim::Static(n) => *n,
3437                            rlx_ir::Dim::Dynamic(_) => 1,
3438                        })
3439                        .product();
3440                    let elem_size = dtype.size_bytes();
3441                    let mut data = Vec::with_capacity(n_elems * elem_size);
3442                    match dtype {
3443                        rlx_ir::DType::F64 => {
3444                            for _ in 0..n_elems {
3445                                data.extend_from_slice(&1.0_f64.to_le_bytes());
3446                            }
3447                        }
3448                        rlx_ir::DType::F32 => {
3449                            for _ in 0..n_elems {
3450                                data.extend_from_slice(&1.0_f32.to_le_bytes());
3451                            }
3452                        }
3453                        other => {
3454                            panic!("materialize_bcasts_for_ad: unsupported bcast dtype {other:?}")
3455                        }
3456                    }
3457                    let ones = out.add_node(Op::Constant { data }, vec![], ones_shape);
3458
3459                    // Output shape of broadcast Mul: [length, *bcast].
3460                    let mut xs_dims: Vec<rlx_ir::Dim> = vec![rlx_ir::Dim::Static(*length as usize)];
3461                    for i in 0..b_shape.rank() {
3462                        xs_dims.push(b_shape.dim(i));
3463                    }
3464                    let xs_shape = rlx_ir::Shape::from_dims(&xs_dims, dtype);
3465                    let xs_id = out.add_node(Op::Binary(BinaryOp::Mul), vec![ones, b_id], xs_shape);
3466                    materialised_xs.push(xs_id);
3467                }
3468
3469                new_scan_inputs.extend_from_slice(&materialised_xs);
3470                for i in 0..*num_xs as usize {
3471                    new_scan_inputs.push(new_inputs[xs_base + i]);
3472                }
3473
3474                let new_id = out.add_node(
3475                    Op::Scan {
3476                        body: body.clone(),
3477                        length: *length,
3478                        save_trajectory: *save_trajectory,
3479                        num_bcast: 0,
3480                        num_xs: *num_bcast + *num_xs,
3481                        num_checkpoints: *num_checkpoints,
3482                    },
3483                    new_scan_inputs,
3484                    node.shape.clone(),
3485                );
3486                id_map.insert(node.id, new_id);
3487            }
3488            _ => {
3489                let new_id = out.add_node(node.op.clone(), new_inputs, node.shape.clone());
3490                id_map.insert(node.id, new_id);
3491            }
3492        }
3493    }
3494
3495    let new_outputs: Vec<NodeId> = g.outputs.iter().map(|o| id_map[o]).collect();
3496    out.set_outputs(new_outputs);
3497    out
3498}
3499
3500pub fn convert_scans_for_ad(g: Graph) -> Graph {
3501    use rlx_ir::shape::Shape as IrShape;
3502
3503    // First, materialise broadcast inputs into per-step xs. The AD
3504    // walk and the rest of this pre-pass don't know about bcasts
3505    // (forward-only memory optimisation); after this rewrite the bwd
3506    // graph treats them as regular xs.
3507    let g = materialize_bcasts_for_ad(g);
3508
3509    // Quick check: does any scan need rewriting? Avoid a full graph
3510    // rebuild when the input is already trajectory-only.
3511    let needs = g.nodes().iter().any(|n| {
3512        matches!(
3513            &n.op,
3514            Op::Scan {
3515                save_trajectory: false,
3516                ..
3517            }
3518        )
3519    });
3520    if !needs {
3521        return g;
3522    }
3523
3524    let mut out = Graph::new(g.name.clone());
3525    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
3526
3527    for node in g.nodes() {
3528        let new_inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
3529        match &node.op {
3530            Op::Scan {
3531                body,
3532                length,
3533                save_trajectory: false,
3534                num_xs,
3535                num_checkpoints,
3536                ..
3537            } => {
3538                let carry_shape = node.shape.clone();
3539                // Trajectory shape: [length, *carry_shape].
3540                //
3541                // NB: when `num_checkpoints` is set (recursive
3542                // checkpointing), the executor only writes `K` rows
3543                // into this buffer (the saved checkpoints, indexed by
3544                // k=0..K-1 at offsets 0..K·cb). Rows K..length-1 stay
3545                // zero. The Narrow + Reshape below extracts row
3546                // `length-1`, which is **zero** in checkpointed mode
3547                // — i.e. the rewritten forward output is wrong (the
3548                // FORWARD value of `scan_checkpointed` followed by a
3549                // direct read is not currently supported).
3550                //
3551                // Backward gradients are still correct: Narrow's VJP
3552                // scatters the upstream into row `length-1` of the
3553                // gradient tensor, ScanBackward reads upstream[t·cb]
3554                // for t in 0..length finds zero everywhere except at
3555                // t=length-1 where it picks up `d_loss`, and the
3556                // segment-cached recompute uses the K saved
3557                // checkpoints (at offsets 0..K·cb) plus the forward
3558                // body to reconstruct intermediate carries.
3559                let mut traj_dims: Vec<Dim> = Vec::with_capacity(carry_shape.rank() + 1);
3560                traj_dims.push(Dim::Static(*length as usize));
3561                for i in 0..carry_shape.rank() {
3562                    traj_dims.push(carry_shape.dim(i));
3563                }
3564                let traj_shape = IrShape::from_dims(&traj_dims, carry_shape.dtype());
3565                let traj = out.add_node(
3566                    Op::Scan {
3567                        body: body.clone(),
3568                        length: *length,
3569                        save_trajectory: true,
3570                        num_bcast: 0,
3571                        num_xs: *num_xs,
3572                        num_checkpoints: *num_checkpoints,
3573                    },
3574                    new_inputs,
3575                    traj_shape,
3576                );
3577                // Narrow last row → [1, *carry].
3578                let mut narrow_dims: Vec<Dim> = Vec::with_capacity(carry_shape.rank() + 1);
3579                narrow_dims.push(Dim::Static(1));
3580                for i in 0..carry_shape.rank() {
3581                    narrow_dims.push(carry_shape.dim(i));
3582                }
3583                let narrow_shape = IrShape::from_dims(&narrow_dims, carry_shape.dtype());
3584                let narrowed = out.add_node(
3585                    Op::Narrow {
3586                        axis: 0,
3587                        start: (*length as usize).saturating_sub(1),
3588                        len: 1,
3589                    },
3590                    vec![traj],
3591                    narrow_shape,
3592                );
3593                // Reshape to drop the leading 1 → carry_shape.
3594                let new_shape: Vec<i64> = (0..carry_shape.rank())
3595                    .map(|i| match carry_shape.dim(i) {
3596                        Dim::Static(n) => n as i64,
3597                        Dim::Dynamic(_) => -1,
3598                    })
3599                    .collect();
3600                let final_id = out.add_node(Op::Reshape { new_shape }, vec![narrowed], carry_shape);
3601                id_map.insert(node.id, final_id);
3602            }
3603            _ => {
3604                let new_id = out.add_node(node.op.clone(), new_inputs, node.shape.clone());
3605                id_map.insert(node.id, new_id);
3606            }
3607        }
3608    }
3609
3610    let new_outputs: Vec<NodeId> = g.outputs.iter().map(|o| id_map[o]).collect();
3611    out.set_outputs(new_outputs);
3612    out
3613}
3614
3615/// Pre-AD pass: inline `Op::CustomFn` nodes that have neither a
3616/// `vjp_body` nor a `jvp_body` by expanding their `fwd_body` into the
3617/// parent graph. When either override body is present, keep the
3618/// `CustomFn` wrapper so reverse- / forward-mode AD can dispatch to it.
3619pub fn inline_custom_fn_for_autodiff(g: Graph) -> Graph {
3620    use rlx_fusion::control_flow::inline_subgraph_into;
3621
3622    let mut out = Graph::new(g.name.clone());
3623    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
3624    let nodes: Vec<rlx_ir::Node> = g.nodes().to_vec();
3625
3626    for node in &nodes {
3627        let new_inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
3628        let new_id = match &node.op {
3629            Op::CustomFn {
3630                vjp_body: None,
3631                jvp_body: None,
3632                fwd_body,
3633                num_inputs,
3634                ..
3635            } => {
3636                assert_eq!(
3637                    new_inputs.len(),
3638                    *num_inputs as usize,
3639                    "custom_fn: outer input count mismatch"
3640                );
3641                inline_subgraph_into(fwd_body, &new_inputs, &mut out)
3642            }
3643            _ => out.add_node(node.op.clone(), new_inputs, node.shape.clone()),
3644        };
3645        id_map.insert(node.id, new_id);
3646    }
3647
3648    let new_outputs: Vec<NodeId> = g.outputs.iter().map(|i| id_map[i]).collect();
3649    out.set_outputs(new_outputs);
3650    out
3651}
3652
3653/// Inverse of `unbroadcast`: broadcast a small tensor up to a target
3654/// shape via `Op::Expand`. Convenience wrapper for the few VJPs that
3655/// need it.
3656pub(crate) fn unbroadcast_inverse(x: NodeId, target: &Shape, bwd: &mut Graph) -> NodeId {
3657    let target_dims: Vec<i64> = target
3658        .dims()
3659        .iter()
3660        .map(|d| match d {
3661            Dim::Static(n) => *n as i64,
3662            Dim::Dynamic(_) => -1,
3663        })
3664        .collect();
3665    bwd.add_node(
3666        Op::Expand {
3667            target_shape: target_dims,
3668        },
3669        vec![x],
3670        target.clone(),
3671    )
3672}
3673
3674/// Expand a gradient back to its pre-reduction shape: optionally
3675/// reshape to insert size-1 axes (when forward had `keep_dim=false`),
3676/// then `Op::Expand` to broadcast to `x_shape`. The reverse of
3677/// `Reduce::Sum`.
3678fn expand_to(
3679    grad: NodeId,
3680    x_shape: &Shape,
3681    axes: &[usize],
3682    keep_dim: bool,
3683    bwd: &mut Graph,
3684) -> NodeId {
3685    let mut current = grad;
3686    if !keep_dim {
3687        // Insert size-1 axes at the reduced positions so the rank
3688        // matches x_shape and Expand can broadcast cleanly.
3689        let kept_dims: Vec<Dim> = (0..x_shape.rank())
3690            .map(|i| {
3691                if axes.contains(&i) {
3692                    Dim::Static(1)
3693                } else {
3694                    x_shape.dim(i)
3695                }
3696            })
3697            .collect();
3698        let kept = Shape::from_dims(&kept_dims, x_shape.dtype());
3699        current = reshape_to(current, &kept, bwd);
3700    }
3701    let target_shape: Vec<i64> = x_shape
3702        .dims()
3703        .iter()
3704        .map(|d| match d {
3705            Dim::Static(n) => *n as i64,
3706            Dim::Dynamic(_) => -1,
3707        })
3708        .collect();
3709    bwd.add_node(Op::Expand { target_shape }, vec![current], x_shape.clone())
3710}
3711
3712#[cfg(test)]
3713mod tests {
3714    use super::*;
3715
3716    #[test]
3717    fn grad_of_add_is_identity() {
3718        let mut g = Graph::new("test");
3719        let x = g.input("x", Shape::new(&[4], DType::F32));
3720        let y = g.input("y", Shape::new(&[4], DType::F32));
3721        let z = g.binary(BinaryOp::Add, x, y, Shape::new(&[4], DType::F32));
3722        g.set_outputs(vec![z]);
3723
3724        let bwd = grad(&g, &[x, y]);
3725        // bwd graph should expose two outputs: dz/dx and dz/dy, both = d_output.
3726        assert_eq!(bwd.outputs.len(), 2);
3727    }
3728
3729    #[test]
3730    fn grad_of_mul_uses_other_operand() {
3731        let mut g = Graph::new("test");
3732        let x = g.input("x", Shape::new(&[4], DType::F32));
3733        let y = g.input("y", Shape::new(&[4], DType::F32));
3734        let z = g.binary(BinaryOp::Mul, x, y, Shape::new(&[4], DType::F32));
3735        g.set_outputs(vec![z]);
3736
3737        let bwd = grad(&g, &[x, y]);
3738        // bwd should contain Mul nodes (upstream * y, upstream * x).
3739        assert!(
3740            bwd.nodes()
3741                .iter()
3742                .filter(|n| matches!(n.op, Op::Binary(BinaryOp::Mul)))
3743                .count()
3744                >= 2
3745        );
3746    }
3747
3748    #[test]
3749    fn grad_with_loss_returns_loss_first() {
3750        let mut g = Graph::new("loss");
3751        let x = g.input("x", Shape::new(&[4], DType::F32));
3752        let y = g.input("y", Shape::new(&[4], DType::F32));
3753        let z = g.binary(BinaryOp::Add, x, y, Shape::new(&[4], DType::F32));
3754        g.set_outputs(vec![z]);
3755
3756        let bwd = grad_with_loss(&g, &[x, y]);
3757        // [loss, dz/dx, dz/dy] — three outputs.
3758        assert_eq!(bwd.outputs.len(), 3);
3759    }
3760
3761    #[test]
3762    fn grad_of_dense_solve_emits_implicit_function_rule() {
3763        // Forward:
3764        //   A      : Param [2,2]
3765        //   b      : Input [2]
3766        //   x      = solve(A, b)
3767        //   loss   = sum(x)         (scalar)
3768        //
3769        // Backward must contain:
3770        //   - a Transpose of A
3771        //   - a second DenseSolve (dx_int = solve(Aᵀ, upstream))
3772        //   - a MatMul (the outer product dx_int · xᵀ)
3773        //   - a Neg (the −outer)
3774        //
3775        // Outputs are [loss, dA, db].
3776        let mut g = Graph::new("solve_test");
3777        let a = g.param("A", Shape::new(&[2, 2], DType::F32));
3778        let b = g.input("b", Shape::new(&[2], DType::F32));
3779        let x = g.dense_solve(a, b, Shape::new(&[2], DType::F32));
3780        let loss = g.reduce(
3781            x,
3782            ReduceOp::Sum,
3783            vec![0],
3784            false,
3785            Shape::new(&[1], DType::F32),
3786        );
3787        g.set_outputs(vec![loss]);
3788
3789        let bwd = grad_with_loss(&g, &[a, b]);
3790        assert_eq!(bwd.outputs.len(), 3, "expect [loss, dA, db]");
3791
3792        let count =
3793            |pred: fn(&Op) -> bool| -> usize { bwd.nodes().iter().filter(|n| pred(&n.op)).count() };
3794
3795        // Forward is mirrored into bwd, so we expect 1 + 1 = 2 DenseSolves
3796        // (forward copy + reverse).
3797        assert!(
3798            count(|o| matches!(o, Op::DenseSolve)) >= 2,
3799            "expected ≥2 DenseSolve nodes (forward mirror + reverse), got\n{bwd}"
3800        );
3801        assert!(
3802            count(|o| matches!(o, Op::Transpose { .. })) >= 1,
3803            "expected a Transpose for Aᵀ, got\n{bwd}"
3804        );
3805        assert!(
3806            count(|o| matches!(o, Op::MatMul)) >= 1,
3807            "expected a MatMul for the outer product, got\n{bwd}"
3808        );
3809        assert!(
3810            count(|o| matches!(o, Op::Activation(Activation::Neg))) >= 1,
3811            "expected a Neg for −outer, got\n{bwd}"
3812        );
3813    }
3814
3815    #[test]
3816    fn inline_if_replaces_with_where() {
3817        // Build a parent graph:
3818        //   x       : Input
3819        //   pred    : Input (scalar bool)
3820        //   then_b  : sub-graph with Input(0) → Activation(Relu)
3821        //   else_b  : sub-graph with Input(0) → Activation(Sigmoid)
3822        //   out     : If(pred, [x] -> then_b, else_b)
3823        let s = Shape::new(&[4], DType::F32);
3824        let pred_s = Shape::new(&[1], DType::F32);
3825
3826        let mut then_g = Graph::new("then_branch");
3827        let then_in = then_g.input("captured", s.clone());
3828        let then_out = then_g.activation(Activation::Relu, then_in, s.clone());
3829        then_g.set_outputs(vec![then_out]);
3830
3831        let mut else_g = Graph::new("else_branch");
3832        let else_in = else_g.input("captured", s.clone());
3833        let else_out = else_g.activation(Activation::Sigmoid, else_in, s.clone());
3834        else_g.set_outputs(vec![else_out]);
3835
3836        let mut g = Graph::new("parent");
3837        let x = g.input("x", s.clone());
3838        let pred = g.input("pred", pred_s);
3839        let if_out = g.add_node(
3840            Op::If {
3841                then_branch: Box::new(then_g),
3842                else_branch: Box::new(else_g),
3843            },
3844            vec![pred, x],
3845            s,
3846        );
3847        g.set_outputs(vec![if_out]);
3848
3849        let inlined = rlx_fusion::control_flow::inline_if(g);
3850
3851        // After inlining: no Op::If, exactly one Op::Where, one
3852        // Activation(Relu), one Activation(Sigmoid). Inputs (x,
3853        // pred) and the original output count are preserved.
3854        let has_if = inlined
3855            .nodes()
3856            .iter()
3857            .any(|n| matches!(n.op, Op::If { .. }));
3858        let has_where = inlined.nodes().iter().any(|n| matches!(n.op, Op::Where));
3859        let has_relu = inlined
3860            .nodes()
3861            .iter()
3862            .any(|n| matches!(n.op, Op::Activation(Activation::Relu)));
3863        let has_sigmoid = inlined
3864            .nodes()
3865            .iter()
3866            .any(|n| matches!(n.op, Op::Activation(Activation::Sigmoid)));
3867        assert!(!has_if, "Op::If should be inlined away");
3868        assert!(has_where, "Op::Where should replace the Op::If");
3869        assert!(has_relu, "then_branch's Activation(Relu) should be inlined");
3870        assert!(
3871            has_sigmoid,
3872            "else_branch's Activation(Sigmoid) should be inlined"
3873        );
3874        assert_eq!(inlined.outputs.len(), 1);
3875    }
3876
3877    #[test]
3878    fn grad_through_if_propagates() {
3879        // Sanity: autodiff a graph with Op::If and confirm it
3880        // produces a gradient (the Where VJP handles the join).
3881        let s = Shape::new(&[4], DType::F32);
3882        let pred_s = Shape::new(&[1], DType::F32);
3883
3884        let mut then_g = Graph::new("th");
3885        let ti = then_g.input("c", s.clone());
3886        let to = then_g.binary(BinaryOp::Mul, ti, ti, s.clone());
3887        then_g.set_outputs(vec![to]);
3888
3889        let mut else_g = Graph::new("el");
3890        let ei = else_g.input("c", s.clone());
3891        let eo = else_g.activation(Activation::Relu, ei, s.clone());
3892        else_g.set_outputs(vec![eo]);
3893
3894        let mut g = Graph::new("parent");
3895        let x = g.input("x", s.clone());
3896        let pred = g.input("pred", pred_s);
3897        let z = g.add_node(
3898            Op::If {
3899                then_branch: Box::new(then_g),
3900                else_branch: Box::new(else_g),
3901            },
3902            vec![pred, x],
3903            s,
3904        );
3905        g.set_outputs(vec![z]);
3906
3907        let bwd = grad_with_loss(&g, &[x]);
3908        // [loss, dz/dx] — two outputs.
3909        assert_eq!(bwd.outputs.len(), 2, "expected loss + 1 grad output");
3910    }
3911
3912    #[test]
3913    fn unroll_while_replicates_body_n_times() {
3914        // Build a parent graph:
3915        //   x   : Input
3916        //   out : While(cond=trivial, body=Activation(Relu), N=3)
3917        // After unrolling we expect zero Op::While, three Activation
3918        // (Relu) nodes (one per replica).
3919        let s = Shape::new(&[4], DType::F32);
3920        let bool_s = Shape::new(&[1], DType::F32);
3921
3922        let mut cond_g = Graph::new("cond");
3923        let ci = cond_g.input("c", s.clone());
3924        // dummy bool: just feed input through (cond is not evaluated
3925        // by the unroll pass, so its body doesn't matter).
3926        cond_g.set_outputs(vec![ci]);
3927        // Replace output shape: cond's output is logically a scalar
3928        // bool — but the unroll pass never inspects it.
3929        let _ = bool_s;
3930
3931        let mut body_g = Graph::new("body");
3932        let bi = body_g.input("c", s.clone());
3933        let bo = body_g.activation(Activation::Relu, bi, s.clone());
3934        body_g.set_outputs(vec![bo]);
3935
3936        let mut g = Graph::new("parent");
3937        let x = g.input("x", s.clone());
3938        let w = g.add_node(
3939            Op::While {
3940                cond: Box::new(cond_g),
3941                body: Box::new(body_g),
3942                max_iterations: Some(3),
3943            },
3944            vec![x],
3945            s,
3946        );
3947        g.set_outputs(vec![w]);
3948
3949        let unrolled = rlx_fusion::control_flow::unroll_while(g);
3950
3951        let has_while = unrolled
3952            .nodes()
3953            .iter()
3954            .any(|n| matches!(n.op, Op::While { .. }));
3955        let relu_count = unrolled
3956            .nodes()
3957            .iter()
3958            .filter(|n| matches!(n.op, Op::Activation(Activation::Relu)))
3959            .count();
3960        assert!(!has_while, "Op::While should be unrolled away");
3961        assert_eq!(
3962            relu_count, 3,
3963            "body's Activation(Relu) should appear once per iteration"
3964        );
3965        assert_eq!(unrolled.outputs.len(), 1);
3966    }
3967
3968    #[test]
3969    fn grad_through_while_propagates() {
3970        // Sanity: autodiff a graph with Op::While and confirm the
3971        // gradient pipeline produces a result (the unroll pass turns
3972        // it into a chain of body replicas before the gradient walk).
3973        let s = Shape::new(&[4], DType::F32);
3974
3975        let mut cond_g = Graph::new("cond");
3976        let ci = cond_g.input("c", s.clone());
3977        cond_g.set_outputs(vec![ci]);
3978
3979        let mut body_g = Graph::new("body");
3980        let bi = body_g.input("c", s.clone());
3981        let bo = body_g.binary(BinaryOp::Mul, bi, bi, s.clone());
3982        body_g.set_outputs(vec![bo]);
3983
3984        let mut g = Graph::new("parent");
3985        let x = g.input("x", s.clone());
3986        let w = g.add_node(
3987            Op::While {
3988                cond: Box::new(cond_g),
3989                body: Box::new(body_g),
3990                max_iterations: Some(2),
3991            },
3992            vec![x],
3993            s,
3994        );
3995        g.set_outputs(vec![w]);
3996
3997        let bwd = grad_with_loss(&g, &[x]);
3998        assert_eq!(bwd.outputs.len(), 2, "expected loss + 1 grad output");
3999    }
4000
4001    /// Build a tiny BERT-style FTL graph with the given bias mode.
4002    /// Returns (graph, hidden_input_id, all_param_ids).
4003    fn build_ftl_graph(has_bias: bool) -> (Graph, NodeId, Vec<NodeId>) {
4004        // B=1, S=2, hidden=4, heads=2, head_dim=2, intermediate=8.
4005        let mut g = Graph::new("ftl_test");
4006        let h_shape = Shape::new(&[1, 2, 4], DType::F32);
4007        let h = g.input("h", h_shape.clone());
4008        let qkv_w = g.param("qkv_w", Shape::new(&[4, 12], DType::F32));
4009        let out_w = g.param("out_w", Shape::new(&[4, 4], DType::F32));
4010        let ln1_g = g.param("ln1_g", Shape::new(&[4], DType::F32));
4011        let fc1_w = g.param("fc1_w", Shape::new(&[4, 8], DType::F32));
4012        let fc2_w = g.param("fc2_w", Shape::new(&[8, 4], DType::F32));
4013        let ln2_g = g.param("ln2_g", Shape::new(&[4], DType::F32));
4014        let mask = g.input("mask", Shape::new(&[1, 2, 2, 2], DType::F32));
4015
4016        let (inputs, params) = if has_bias {
4017            let qkv_b = g.param("qkv_b", Shape::new(&[12], DType::F32));
4018            let out_b = g.param("out_b", Shape::new(&[4], DType::F32));
4019            let ln1_b = g.param("ln1_b", Shape::new(&[4], DType::F32));
4020            let fc1_b = g.param("fc1_b", Shape::new(&[8], DType::F32));
4021            let fc2_b = g.param("fc2_b", Shape::new(&[4], DType::F32));
4022            let ln2_b = g.param("ln2_b", Shape::new(&[4], DType::F32));
4023            (
4024                vec![
4025                    h, qkv_w, qkv_b, out_w, out_b, ln1_g, ln1_b, fc1_w, fc1_b, fc2_w, fc2_b, ln2_g,
4026                    ln2_b, mask,
4027                ],
4028                vec![
4029                    qkv_w, qkv_b, out_w, out_b, ln1_g, ln1_b, fc1_w, fc1_b, fc2_w, fc2_b, ln2_g,
4030                    ln2_b,
4031                ],
4032            )
4033        } else {
4034            (
4035                vec![h, qkv_w, out_w, ln1_g, fc1_w, fc2_w, ln2_g, mask],
4036                vec![qkv_w, out_w, ln1_g, fc1_w, fc2_w, ln2_g],
4037            )
4038        };
4039        let y = g.add_node(
4040            Op::FusedTransformerLayer {
4041                num_heads: 2,
4042                head_dim: 2,
4043                intermediate_size: 8,
4044                eps1: 1e-5,
4045                eps2: 1e-5,
4046                activation: rlx_ir::op::Activation::Gelu,
4047                has_bias,
4048            },
4049            inputs,
4050            h_shape,
4051        );
4052        g.set_outputs(vec![y]);
4053        (g, h, params)
4054    }
4055
4056    #[test]
4057    fn unfuse_decomposes_fused_transformer_layer() {
4058        // After rlx_fusion::unfuse_fused_for_autodiff, the FTL node is gone and
4059        // primitives appear: at least 4 MatMul (qkv, out, fc1, fc2),
4060        // 1 Attention, 2 LayerNorm, plus narrows / adds / activation.
4061        let (g, _h, _params) = build_ftl_graph(true);
4062        let unfused = rlx_fusion::unfuse_fused_for_autodiff(g);
4063
4064        let has_ftl = unfused
4065            .nodes()
4066            .iter()
4067            .any(|n| matches!(n.op, Op::FusedTransformerLayer { .. }));
4068        assert!(!has_ftl, "Op::FusedTransformerLayer should be unfused");
4069
4070        let count = |pred: fn(&Op) -> bool| -> usize {
4071            unfused.nodes().iter().filter(|n| pred(&n.op)).count()
4072        };
4073        assert!(
4074            count(|o| matches!(o, Op::MatMul)) >= 4,
4075            "expected >=4 MatMul after FTL unfuse"
4076        );
4077        assert_eq!(
4078            count(|o| matches!(o, Op::Attention { .. })),
4079            1,
4080            "expected exactly 1 Attention after FTL unfuse"
4081        );
4082        assert_eq!(
4083            count(|o| matches!(o, Op::LayerNorm { .. })),
4084            2,
4085            "expected exactly 2 LayerNorm after FTL unfuse"
4086        );
4087        assert!(
4088            count(|o| matches!(o, Op::Narrow { .. })) >= 3,
4089            "expected >=3 Narrow (Q/K/V split) after FTL unfuse"
4090        );
4091        assert_eq!(
4092            count(|o| matches!(o, Op::Activation(_))),
4093            1,
4094            "expected exactly 1 Activation (FFN) after FTL unfuse"
4095        );
4096    }
4097
4098    #[test]
4099    fn grad_through_fused_transformer_layer_propagates() {
4100        // End-to-end: grad_with_loss through an FTL graph returns
4101        // [loss, ...grads]. Confirms every primitive emitted by the
4102        // unfuse has a VJP rule on the gradient walk.
4103        let (g, _h, params) = build_ftl_graph(true);
4104        let bwd = grad_with_loss(&g, &params);
4105        assert_eq!(
4106            bwd.outputs.len(),
4107            1 + params.len(),
4108            "expected loss + {} param grads",
4109            params.len()
4110        );
4111    }
4112
4113    #[test]
4114    fn grad_through_fused_transformer_layer_no_bias() {
4115        // No-bias variant exercises the synthesized zero-beta path
4116        // for both LayerNorms.
4117        let (g, _h, params) = build_ftl_graph(false);
4118        let bwd = grad_with_loss(&g, &params);
4119        assert_eq!(
4120            bwd.outputs.len(),
4121            1 + params.len(),
4122            "expected loss + {} param grads (no-bias)",
4123            params.len()
4124        );
4125    }
4126
4127    /// Build a tiny SelectiveScan graph: B=1, S=3, H=2, N=4.
4128    /// Returns (graph, [x, delta, a, b, c]).
4129    fn build_ssm_graph() -> (Graph, NodeId, Vec<NodeId>) {
4130        let mut g = Graph::new("ssm_test");
4131        let bsh = Shape::new(&[1, 3, 2], DType::F32);
4132        let hn = Shape::new(&[2, 4], DType::F32);
4133        let bsn = Shape::new(&[1, 3, 4], DType::F32);
4134
4135        let x = g.input("x", bsh.clone());
4136        let delta = g.input("delta", bsh.clone());
4137        let a = g.param("a", hn);
4138        let b = g.input("b", bsn.clone());
4139        let c = g.input("c", bsn);
4140        let y = g.selective_scan(x, delta, a, b, c, 4, bsh);
4141        g.set_outputs(vec![y]);
4142        (g, x, vec![a])
4143    }
4144
4145    #[test]
4146    fn unfuse_decomposes_selective_scan() {
4147        // After unfuse, no Op::SelectiveScan; instead we see Concat
4148        // (one for S>1), per-step Reduce(Sum), per-step Activation::Exp,
4149        // and many Mul / Add / Narrow / Reshape / Expand nodes.
4150        // S=3 → 3 timesteps.
4151        let (g, _x, _params) = build_ssm_graph();
4152        let unfused = rlx_fusion::unfuse_fused_for_autodiff(g);
4153
4154        let has_ssm = unfused
4155            .nodes()
4156            .iter()
4157            .any(|n| matches!(n.op, Op::SelectiveScan { .. }));
4158        assert!(!has_ssm, "Op::SelectiveScan should be unfused");
4159
4160        let count = |pred: fn(&Op) -> bool| -> usize {
4161            unfused.nodes().iter().filter(|n| pred(&n.op)).count()
4162        };
4163        assert_eq!(
4164            count(|o| matches!(o, Op::Concat { .. })),
4165            1,
4166            "expected 1 Concat (over the 3 time steps)"
4167        );
4168        assert_eq!(
4169            count(|o| matches!(
4170                o,
4171                Op::Reduce {
4172                    op: ReduceOp::Sum,
4173                    ..
4174                }
4175            )),
4176            3,
4177            "expected one Reduce(Sum) per time step (S=3)"
4178        );
4179        assert_eq!(
4180            count(|o| matches!(o, Op::Activation(Activation::Exp))),
4181            3,
4182            "expected one exp(δA) per time step (S=3)"
4183        );
4184        assert!(
4185            count(|o| matches!(o, Op::Narrow { .. })) >= 12,
4186            "expected >=12 Narrows (4 per step × 3 steps)"
4187        );
4188    }
4189
4190    #[test]
4191    fn grad_through_selective_scan_propagates() {
4192        // End-to-end: grad_with_loss through SelectiveScan returns
4193        // [loss, da] — confirms every primitive emitted by the
4194        // unroll has a VJP rule on the gradient walk (Mul, Add,
4195        // Activation::Exp, Reduce::Sum, Concat, Narrow, Reshape,
4196        // Expand).
4197        let (g, _x, params) = build_ssm_graph();
4198        let bwd = grad_with_loss(&g, &params);
4199        assert_eq!(
4200            bwd.outputs.len(),
4201            1 + params.len(),
4202            "expected loss + {} param grads",
4203            params.len()
4204        );
4205    }
4206
4207    /// Tiny GatedDeltaNet: B=1, S=3, H=2, N=4.
4208    fn build_gdn_graph() -> (Graph, NodeId, Vec<NodeId>) {
4209        let (b, s, h, n) = (1usize, 3, 2, 4);
4210        let mut g = Graph::new("gdn_test");
4211        let bshn = Shape::new(&[b, s, h, n], DType::F32);
4212        let bsh = Shape::new(&[b, s, h], DType::F32);
4213        let q = g.input("q", bshn.clone());
4214        let k = g.input("k", bshn.clone());
4215        let v = g.input("v", bshn.clone());
4216        let g_in = g.input("g", bsh.clone());
4217        let beta = g.input("beta", bsh);
4218        let y = g.gated_delta_net(q, k, v, g_in, beta, n, bshn);
4219        g.set_outputs(vec![y]);
4220        (g, q, vec![q, k, v, g_in, beta])
4221    }
4222
4223    #[test]
4224    fn unfuse_decomposes_gated_delta_net() {
4225        let (g, _q, _params) = build_gdn_graph();
4226        let unfused = rlx_fusion::unfuse_fused_for_autodiff(g);
4227
4228        let has_gdn = unfused
4229            .nodes()
4230            .iter()
4231            .any(|n| matches!(n.op, Op::GatedDeltaNet { .. }));
4232        assert!(!has_gdn, "Op::GatedDeltaNet should be unfused");
4233
4234        let count = |pred: fn(&Op) -> bool| -> usize {
4235            unfused.nodes().iter().filter(|n| pred(&n.op)).count()
4236        };
4237        assert_eq!(
4238            count(|o| matches!(o, Op::Concat { .. })),
4239            1,
4240            "expected 1 Concat over S=3 steps"
4241        );
4242        assert!(
4243            count(|o| matches!(o, Op::MatMul)) >= 3,
4244            "expected >=3 MatMul per step (sk + out) × S=3"
4245        );
4246        assert_eq!(
4247            count(|o| matches!(o, Op::Activation(Activation::Exp))),
4248            3,
4249            "expected one exp(g) per time step"
4250        );
4251    }
4252
4253    #[test]
4254    fn grad_through_gated_delta_net_propagates() {
4255        let (g, _q, params) = build_gdn_graph();
4256        let bwd = grad_with_loss(&g, &params);
4257        assert_eq!(
4258            bwd.outputs.len(),
4259            1 + params.len(),
4260            "expected loss + {} input grads",
4261            params.len()
4262        );
4263    }
4264
4265    #[test]
4266    fn custom_fn_vjp_body_is_inlined_into_bwd() {
4267        // Forward: y = x² via custom_fn (fwd_body = Mul(x, x)).
4268        // Override VJP to return Activation::Sin(d_output) — a unique
4269        // marker that natural autodiff of Mul would never emit. If
4270        // grad_with_loss inlines the override correctly, the bwd graph
4271        // must contain a Sin node; if it falls back to recursing into
4272        // fwd_body, it would emit two Muls (upstream·x + x·upstream)
4273        // and no Sin.
4274        let n = 4usize;
4275        let shape = Shape::new(&[n], DType::F32);
4276
4277        // fwd_body: x → x · x.
4278        let mut fwd_body = Graph::new("square_fwd");
4279        let xb = fwd_body.input("x", shape.clone());
4280        let yb = fwd_body.binary(BinaryOp::Mul, xb, xb, shape.clone());
4281        fwd_body.set_outputs(vec![yb]);
4282
4283        // vjp_body: (x, primal_output, d_output) → sin(d_output).
4284        let mut vjp_body = Graph::new("square_vjp");
4285        let _vx = vjp_body.input("x", shape.clone());
4286        let _vp = vjp_body.input("primal_output", shape.clone());
4287        let vd = vjp_body.input("d_output", shape.clone());
4288        let dx = vjp_body.activation(Activation::Sin, vd, shape.clone());
4289        vjp_body.set_outputs(vec![dx]);
4290
4291        let mut g = Graph::new("custom_fn_test");
4292        let x = g.input("x", shape.clone());
4293        let y = g.custom_fn(vec![x], fwd_body, Some(vjp_body), None);
4294        let loss = g.reduce(
4295            y,
4296            ReduceOp::Sum,
4297            vec![0],
4298            false,
4299            Shape::new(&[1], DType::F32),
4300        );
4301        g.set_outputs(vec![loss]);
4302
4303        let bwd = grad_with_loss(&g, &[x]);
4304        assert_eq!(bwd.outputs.len(), 2, "expect [loss, dx]");
4305        let sin_count = bwd
4306            .nodes()
4307            .iter()
4308            .filter(|n| matches!(n.op, Op::Activation(Activation::Sin)))
4309            .count();
4310        assert!(
4311            sin_count >= 1,
4312            "expected the vjp_body's Sin to be inlined into bwd, got\n{bwd}"
4313        );
4314    }
4315
4316    #[test]
4317    fn custom_fn_without_vjp_inlines_fwd_body_for_autodiff() {
4318        // Forward: y = x² via custom_fn without vjp_body. After the
4319        // inline pre-pass, autodiff should recurse into Mul and emit
4320        // dx = 2·x·d_output (two Mul nodes in the backward graph).
4321        let n = 4usize;
4322        let shape = Shape::new(&[n], DType::F32);
4323
4324        let mut fwd_body = Graph::new("square_fwd");
4325        let xb = fwd_body.input("x", shape.clone());
4326        let yb = fwd_body.binary(BinaryOp::Mul, xb, xb, shape.clone());
4327        fwd_body.set_outputs(vec![yb]);
4328
4329        let mut g = Graph::new("custom_fn_no_vjp");
4330        let x = g.input("x", shape.clone());
4331        let y = g.custom_fn(vec![x], fwd_body, None, None);
4332        let loss = g.reduce(
4333            y,
4334            ReduceOp::Sum,
4335            vec![0],
4336            false,
4337            Shape::new(&[1], DType::F32),
4338        );
4339        g.set_outputs(vec![loss]);
4340
4341        let bwd = grad_with_loss(&g, &[x]);
4342        assert_eq!(bwd.outputs.len(), 2, "expect [loss, dx]");
4343        let custom_fn_count = bwd
4344            .nodes()
4345            .iter()
4346            .filter(|n| matches!(n.op, Op::CustomFn { .. }))
4347            .count();
4348        assert_eq!(
4349            custom_fn_count, 0,
4350            "CustomFn should be inlined away before autodiff"
4351        );
4352        let mul_count = bwd
4353            .nodes()
4354            .iter()
4355            .filter(|n| matches!(n.op, Op::Binary(BinaryOp::Mul)))
4356            .count();
4357        assert!(mul_count >= 2, "expected Mul-based VJP for x², got\n{bwd}");
4358    }
4359
4360    #[test]
4361    fn convert_scans_for_ad_forces_save_trajectory_true() {
4362        // grad_with_loss runs `convert_scans_for_ad` as a pre-pass: any
4363        // forward Op::Scan with `save_trajectory: false` is rewritten
4364        // to `save_trajectory: true` followed by Narrow + Reshape so
4365        // the reverse pass has the trajectory it needs. This test
4366        // verifies the rewrite happens — the bwd graph should contain
4367        // at least one Scan with save_trajectory == true.
4368        let n = 2usize;
4369        let length = 3u32;
4370        let carry = Shape::new(&[n], DType::F32);
4371        let xs_shape = Shape::new(&[length as usize, n], DType::F32);
4372
4373        // body: (carry, x_t) → carry + x_t. One primal Input each.
4374        let mut body = Graph::new("scan_body");
4375        let bc = body.input("carry", carry.clone());
4376        let bx = body.input("x_t", carry.clone());
4377        let by = body.binary(BinaryOp::Add, bc, bx, carry.clone());
4378        body.set_outputs(vec![by]);
4379
4380        let mut g = Graph::new("scan_save_false");
4381        let init = g.input("init", carry.clone());
4382        let xs = g.input("xs", xs_shape);
4383        let scan_out = g.add_node(
4384            Op::Scan {
4385                body: Box::new(body),
4386                length,
4387                save_trajectory: false,
4388                num_bcast: 0,
4389                num_xs: 1,
4390                num_checkpoints: 0,
4391            },
4392            vec![init, xs],
4393            carry.clone(),
4394        );
4395        let loss = g.reduce(
4396            scan_out,
4397            ReduceOp::Sum,
4398            vec![0],
4399            false,
4400            Shape::new(&[1], DType::F32),
4401        );
4402        g.set_outputs(vec![loss]);
4403
4404        let bwd = grad_with_loss(&g, &[init, xs]);
4405        let saved_traj = bwd.nodes().iter().any(|n| {
4406            matches!(
4407                &n.op,
4408                Op::Scan {
4409                    save_trajectory: true,
4410                    ..
4411                }
4412            )
4413        });
4414        assert!(
4415            saved_traj,
4416            "convert_scans_for_ad should rewrite save_trajectory=false → \
4417             save_trajectory=true in the AD-prepared graph; got\n{bwd}"
4418        );
4419    }
4420}