Skip to main content

rlx_ir/ops/
special.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Specialised builders: SSM selective scan + space for future
6//! exotic ops (plan #53).
7
8use crate::shape::Dim;
9use crate::{DType, Graph, NodeId, Op, Shape};
10
11/// Handle to a multi-output [`Op::CustomFn`] built via
12/// [`Graph::custom_fn_multi`]. Internally the op produces a flat 1-D
13/// concatenated output; this handle remembers each sub-output's
14/// offset + original shape so [`Self::output`] can materialize
15/// component `i` lazily via `Op::Narrow` + `Op::Reshape`.
16#[derive(Debug, Clone)]
17pub struct MultiOutputHandle {
18    /// NodeId of the wrapped Op::CustomFn (1-D F32, length =
19    /// `Σ sub_shapes[i].num_elements`).
20    pub source: NodeId,
21    /// Original per-sub-output shapes (in declaration order).
22    pub sub_shapes: Vec<Shape>,
23    /// Per-sub-output start offsets into `source` (element-counted,
24    /// not byte-counted).
25    pub offsets: Vec<usize>,
26}
27
28impl MultiOutputHandle {
29    /// Number of sub-outputs.
30    pub fn n_outputs(&self) -> usize {
31        self.sub_shapes.len()
32    }
33
34    /// Materialize sub-output `idx` as an outer-graph NodeId.
35    /// Internally: `Op::Narrow(source, axis=0, start=offsets[idx],
36    /// len=numel(sub_shapes[idx]))` → `Op::Reshape` back to the
37    /// declared shape.
38    pub fn output(&self, g: &mut Graph, idx: usize) -> NodeId {
39        assert!(idx < self.sub_shapes.len(), "output index out of range");
40        let sub = &self.sub_shapes[idx];
41        let n_elems: usize = sub
42            .dims()
43            .iter()
44            .map(|d| match d {
45                Dim::Static(k) => *k,
46                Dim::Dynamic(_) => panic!("dynamic sub-output dim"),
47            })
48            .product();
49        let flat_shape = Shape::from_dims(&[Dim::Static(n_elems)], sub.dtype());
50        let narrowed = g.add_node(
51            Op::Narrow {
52                axis: 0,
53                start: self.offsets[idx],
54                len: n_elems,
55            },
56            vec![self.source],
57            flat_shape,
58        );
59        if sub.rank() == 1 {
60            // Already the right shape.
61            narrowed
62        } else {
63            let dims: Vec<i64> = sub
64                .dims()
65                .iter()
66                .map(|d| match d {
67                    Dim::Static(k) => *k as i64,
68                    Dim::Dynamic(_) => unreachable!(),
69                })
70                .collect();
71            g.add_node(Op::Reshape { new_shape: dims }, vec![narrowed], sub.clone())
72        }
73    }
74}
75
76impl Graph {
77    /// Mamba-style selective scan: y = SSM(x, Δ, A, B, C).
78    /// Inputs: x \[b,s,h\], delta \[b,s,h\], a \[h,n\], b \[b,s,n\], c \[b,s,n\].
79    /// Output \[b,s,h\]. n is the state size.
80    pub fn selective_scan(
81        &mut self,
82        x: NodeId,
83        delta: NodeId,
84        a: NodeId,
85        b: NodeId,
86        c: NodeId,
87        state_size: usize,
88        shape: Shape,
89    ) -> NodeId {
90        self.push(
91            Op::SelectiveScan { state_size },
92            vec![x, delta, a, b, c],
93            shape,
94            None,
95        )
96    }
97
98    /// Gated DeltaNet linear-attention scan (Qwen3.5/3.6 trunk,
99    /// Qwen3-Next, Kimi-Linear). See [`Op::GatedDeltaNet`] for the
100    /// recurrence math. All five inputs are `f32`. Shapes:
101    /// `q,k,v`: `[b, s, h_v, n]`; `g,beta`: `[b, s, h_v]`. Output:
102    /// `[b, s, h_v, n]`. State is implicit (reset per batch) unless
103    /// `carry_state` is set — then pass `state` as a sixth input.
104    pub fn gated_delta_net(
105        &mut self,
106        q: NodeId,
107        k: NodeId,
108        v: NodeId,
109        g: NodeId,
110        beta: NodeId,
111        state_size: usize,
112        shape: Shape,
113    ) -> NodeId {
114        self.push(
115            Op::GatedDeltaNet {
116                state_size,
117                carry_state: false,
118                gate_per_channel: false,
119            },
120            vec![q, k, v, g, beta],
121            shape,
122            None,
123        )
124    }
125
126    /// Same as [`Self::gated_delta_net`] but with a **per-channel** log-gate
127    /// (`g` is `[b, s, h, n]`) — Kimi-K3 KDA.
128    pub fn gated_delta_net_pc(
129        &mut self,
130        q: NodeId,
131        k: NodeId,
132        v: NodeId,
133        g: NodeId,
134        beta: NodeId,
135        state_size: usize,
136        shape: Shape,
137    ) -> NodeId {
138        self.push(
139            Op::GatedDeltaNet {
140                state_size,
141                carry_state: false,
142                gate_per_channel: true,
143            },
144            vec![q, k, v, g, beta],
145            shape,
146            None,
147        )
148    }
149
150    /// Same as [`Self::gated_delta_net`] but threads `state`
151    /// `[b, h_v, n, n]` in/out for decode-mode recurrence.
152    pub fn gated_delta_net_carry(
153        &mut self,
154        q: NodeId,
155        k: NodeId,
156        v: NodeId,
157        g: NodeId,
158        beta: NodeId,
159        state: NodeId,
160        state_size: usize,
161        shape: Shape,
162    ) -> NodeId {
163        self.push(
164            Op::GatedDeltaNet {
165                state_size,
166                carry_state: true,
167                gate_per_channel: false,
168            },
169            vec![q, k, v, g, beta, state],
170            shape,
171            None,
172        )
173    }
174
175    /// Carry variant with a **per-channel** log-gate (KDA).
176    #[allow(clippy::too_many_arguments)]
177    pub fn gated_delta_net_carry_pc(
178        &mut self,
179        q: NodeId,
180        k: NodeId,
181        v: NodeId,
182        g: NodeId,
183        beta: NodeId,
184        state: NodeId,
185        state_size: usize,
186        shape: Shape,
187    ) -> NodeId {
188        self.push(
189            Op::GatedDeltaNet {
190                state_size,
191                carry_state: true,
192                gate_per_channel: true,
193            },
194            vec![q, k, v, g, beta, state],
195            shape,
196            None,
197        )
198    }
199
200    /// Multi-layer (optionally bidirectional) LSTM with packed weights.
201    /// See [`Op::Lstm`] for the gate math, weight packing, and shapes.
202    /// Output `shape` = `[batch, seq, D*hidden]` (`D = 2` iff
203    /// `bidirectional`). Initial `h0`/`c0` are zero.
204    #[allow(clippy::too_many_arguments)]
205    pub fn lstm(
206        &mut self,
207        x: NodeId,
208        w_ih: NodeId,
209        w_hh: NodeId,
210        bias: NodeId,
211        hidden_size: usize,
212        num_layers: usize,
213        bidirectional: bool,
214        shape: Shape,
215    ) -> NodeId {
216        self.push(
217            Op::Lstm {
218                hidden_size,
219                num_layers,
220                bidirectional,
221                carry: false,
222            },
223            vec![x, w_ih, w_hh, bias],
224            shape,
225            None,
226        )
227    }
228
229    /// Same as [`Self::lstm`] but threads decode state: `h0`/`c0`
230    /// `[L*D, batch, hidden]` in, `hn`/`cn` written back in place.
231    #[allow(clippy::too_many_arguments)]
232    pub fn lstm_carry(
233        &mut self,
234        x: NodeId,
235        w_ih: NodeId,
236        w_hh: NodeId,
237        bias: NodeId,
238        h0: NodeId,
239        c0: NodeId,
240        hidden_size: usize,
241        num_layers: usize,
242        bidirectional: bool,
243        shape: Shape,
244    ) -> NodeId {
245        self.push(
246            Op::Lstm {
247                hidden_size,
248                num_layers,
249                bidirectional,
250                carry: true,
251            },
252            vec![x, w_ih, w_hh, bias, h0, c0],
253            shape,
254            None,
255        )
256    }
257
258    /// Multi-layer (optionally bidirectional) GRU. Inputs
259    /// `[x, w_ih, w_hh, b_ih, b_hh]` (`+ [h0]` when `carry`). See
260    /// [`Op::Gru`]. Output `shape` = `[batch, seq, D*hidden]`.
261    #[allow(clippy::too_many_arguments)]
262    pub fn gru(
263        &mut self,
264        x: NodeId,
265        w_ih: NodeId,
266        w_hh: NodeId,
267        b_ih: NodeId,
268        b_hh: NodeId,
269        h0: Option<NodeId>,
270        hidden_size: usize,
271        num_layers: usize,
272        bidirectional: bool,
273        shape: Shape,
274    ) -> NodeId {
275        let mut inputs = vec![x, w_ih, w_hh, b_ih, b_hh];
276        let carry = h0.is_some();
277        if let Some(h) = h0 {
278            inputs.push(h);
279        }
280        self.push(
281            Op::Gru {
282                hidden_size,
283                num_layers,
284                bidirectional,
285                carry,
286            },
287            inputs,
288            shape,
289            None,
290        )
291    }
292
293    /// Multi-layer (optionally bidirectional) Elman RNN. Inputs
294    /// `[x, w_ih, w_hh, bias]` (`+ [h0]` when `carry`). `relu` selects the
295    /// activation (else tanh). See [`Op::Rnn`]. Output `[batch, seq, D*hidden]`.
296    #[allow(clippy::too_many_arguments)]
297    pub fn rnn(
298        &mut self,
299        x: NodeId,
300        w_ih: NodeId,
301        w_hh: NodeId,
302        bias: NodeId,
303        h0: Option<NodeId>,
304        hidden_size: usize,
305        num_layers: usize,
306        bidirectional: bool,
307        relu: bool,
308        shape: Shape,
309    ) -> NodeId {
310        let mut inputs = vec![x, w_ih, w_hh, bias];
311        let carry = h0.is_some();
312        if let Some(h) = h0 {
313            inputs.push(h);
314        }
315        self.push(
316            Op::Rnn {
317                hidden_size,
318                num_layers,
319                bidirectional,
320                carry,
321                relu,
322            },
323            inputs,
324            shape,
325            None,
326        )
327    }
328
329    /// Mamba-2 / SSD scalar-decay SSM scan. See [`Op::Mamba2`]. Inputs
330    /// `[x, dt, a, b, c]`; output `shape` = `x` shape `[B,S,H,P]`.
331    pub fn mamba2(
332        &mut self,
333        x: NodeId,
334        dt: NodeId,
335        a: NodeId,
336        b: NodeId,
337        c: NodeId,
338        head_dim: usize,
339        state_size: usize,
340        shape: Shape,
341    ) -> NodeId {
342        self.push(
343            Op::Mamba2 {
344                head_dim,
345                state_size,
346            },
347            vec![x, dt, a, b, c],
348            shape,
349            None,
350        )
351    }
352
353    /// Bounded scan returning the final carry. Body must have exactly
354    /// one `Op::Input` (the carry) and one output, both same shape as
355    /// `init`. Output shape matches `init`.
356    pub fn scan(&mut self, init: NodeId, body: Graph, length: u32) -> NodeId {
357        let init_shape = self.shape(init).clone();
358        self.push(
359            Op::Scan {
360                body: Box::new(body),
361                length,
362                save_trajectory: false,
363                num_bcast: 0,
364                num_xs: 0,
365                num_checkpoints: 0,
366            },
367            vec![init],
368            init_shape,
369            None,
370        )
371    }
372
373    /// Bounded scan with recursive checkpointing for memory-bounded
374    /// backward AD. Equivalent to [`Self::scan`] for the forward
375    /// computation, but during backward only `num_checkpoints` carry
376    /// values are cached; intermediate carries are recomputed via the
377    /// body. Memory: `O(num_checkpoints · carry_size)`. Time: forward
378    /// unchanged; backward `O(length)` (segment-cached).
379    ///
380    /// The AD pre-pass propagates `num_checkpoints` into the rewritten
381    /// trajectory-saving Scan and into the emitted ScanBackward, so a
382    /// single call to [`crate::Graph::scan_checkpointed`] is enough
383    /// to enable the memory bound across the whole forward+backward
384    /// pipeline.
385    pub fn scan_checkpointed(
386        &mut self,
387        init: NodeId,
388        body: Graph,
389        length: u32,
390        num_checkpoints: u32,
391    ) -> NodeId {
392        assert!(
393            num_checkpoints > 0 && num_checkpoints <= length,
394            "scan_checkpointed: num_checkpoints={num_checkpoints} \
395             must be in 1..=length={length}"
396        );
397        let init_shape = self.shape(init).clone();
398        self.push(
399            Op::Scan {
400                body: Box::new(body),
401                length,
402                save_trajectory: false,
403                num_bcast: 0,
404                num_xs: 0,
405                num_checkpoints,
406            },
407            vec![init],
408            init_shape,
409            None,
410        )
411    }
412
413    /// Bounded scan with broadcast and per-step inputs.
414    ///
415    /// Body `Op::Input`s in NodeId order: `[carry, bcast_0..bcast_{B-1},
416    /// x_t_0..x_t_{X-1}]`. Bcast inputs keep their natural shape (the
417    /// CPU executor fills them once before the scan loop). xs\[i\] has
418    /// shape `[length, *per_step]` and the body sees `xs[i][t]` per
419    /// iteration. Output shape matches `init`.
420    pub fn scan_with_bcasts_and_xs(
421        &mut self,
422        init: NodeId,
423        bcasts: &[NodeId],
424        xs: &[NodeId],
425        body: Graph,
426        length: u32,
427    ) -> NodeId {
428        let init_shape = self.shape(init).clone();
429        let mut inputs = vec![init];
430        inputs.extend_from_slice(bcasts);
431        inputs.extend_from_slice(xs);
432        self.push(
433            Op::Scan {
434                body: Box::new(body),
435                length,
436                save_trajectory: false,
437                num_bcast: bcasts.len() as u32,
438                num_xs: xs.len() as u32,
439                num_checkpoints: 0,
440            },
441            inputs,
442            init_shape,
443            None,
444        )
445    }
446
447    /// Bounded scan with per-step `xs` inputs returning the final carry.
448    /// Body has `1 + xs.len()` Op::Inputs in NodeId construction order
449    /// (first declared is the carry; the remaining match `xs` in order).
450    /// Each `xs[i]` has shape `[length, *per_step_shape_i]`; the body
451    /// sees a `per_step_shape_i` slice on iteration `t`.
452    pub fn scan_with_xs(
453        &mut self,
454        init: NodeId,
455        xs: &[NodeId],
456        body: Graph,
457        length: u32,
458    ) -> NodeId {
459        let init_shape = self.shape(init).clone();
460        let mut inputs = vec![init];
461        inputs.extend_from_slice(xs);
462        self.push(
463            Op::Scan {
464                body: Box::new(body),
465                length,
466                save_trajectory: false,
467                num_bcast: 0,
468                num_xs: xs.len() as u32,
469                num_checkpoints: 0,
470            },
471            inputs,
472            init_shape,
473            None,
474        )
475    }
476
477    /// Bounded scan with per-step `xs` inputs returning the STACKED
478    /// trajectory (`save_trajectory = true`). Body input convention
479    /// matches [`Self::scan_with_xs`]: `1 + xs.len()` `Op::Input`s in
480    /// NodeId order (carry first, then the xs in order). Each `xs[i]`
481    /// has shape `[length, *per_step_shape_i]`; the body sees the
482    /// `per_step_shape_i` slice on iteration `t`. Output shape is
483    /// `[length, *init.shape]` — row `t` is the carry after step
484    /// `t+1`, so this is `scan_with_xs` that keeps every step.
485    pub fn scan_trajectory_with_xs(
486        &mut self,
487        init: NodeId,
488        xs: &[NodeId],
489        body: Graph,
490        length: u32,
491    ) -> NodeId {
492        let init_shape = self.shape(init).clone();
493        let mut traj_dims: Vec<crate::Dim> = Vec::with_capacity(init_shape.rank() + 1);
494        traj_dims.push(crate::Dim::Static(length as usize));
495        for i in 0..init_shape.rank() {
496            traj_dims.push(init_shape.dim(i));
497        }
498        let traj_shape = crate::Shape::from_dims(&traj_dims, init_shape.dtype());
499        let mut inputs = vec![init];
500        inputs.extend_from_slice(xs);
501        self.push(
502            Op::Scan {
503                body: Box::new(body),
504                length,
505                save_trajectory: true,
506                num_bcast: 0,
507                num_xs: xs.len() as u32,
508                num_checkpoints: 0,
509            },
510            inputs,
511            traj_shape,
512            None,
513        )
514    }
515
516    /// Reverse-mode AD companion to [`Self::scan`] /
517    /// [`Self::scan_trajectory`]. Typically constructed by the
518    /// autodiff pass, not by hand.
519    ///
520    /// `xs` is the list of per-step input tensors (must match the
521    /// forward Op::Scan's xs in count, order, and per-step shape).
522    /// Body_vjp's `1 + xs.len() + 1` Op::Inputs match the forward
523    /// body's inputs plus a fresh `"d_output"` Input.
524    pub fn scan_backward(
525        &mut self,
526        init: NodeId,
527        trajectory: NodeId,
528        upstream: NodeId,
529        xs: &[NodeId],
530        body_vjp: Graph,
531        length: u32,
532        save_trajectory: bool,
533        out_shape: Shape,
534    ) -> NodeId {
535        self.scan_backward_with_checkpoints(
536            init,
537            trajectory,
538            upstream,
539            xs,
540            body_vjp,
541            length,
542            save_trajectory,
543            0,
544            None,
545            out_shape,
546        )
547    }
548
549    /// Lower-level `scan_backward` with explicit checkpointing config.
550    /// `num_checkpoints == 0` (default) means no checkpointing — the
551    /// trajectory cache holds every step's carry. `0 < K < length`
552    /// enables segment-cached recompute via `forward_body` (must be
553    /// `Some`).
554    #[allow(clippy::too_many_arguments)]
555    pub fn scan_backward_with_checkpoints(
556        &mut self,
557        init: NodeId,
558        trajectory: NodeId,
559        upstream: NodeId,
560        xs: &[NodeId],
561        body_vjp: Graph,
562        length: u32,
563        save_trajectory: bool,
564        num_checkpoints: u32,
565        forward_body: Option<Graph>,
566        out_shape: Shape,
567    ) -> NodeId {
568        let mut inputs = vec![init, trajectory, upstream];
569        inputs.extend_from_slice(xs);
570        self.push(
571            Op::ScanBackward {
572                body_vjp: Box::new(body_vjp),
573                length,
574                save_trajectory,
575                num_xs: xs.len() as u32,
576                num_checkpoints,
577                forward_body: forward_body.map(Box::new),
578            },
579            inputs,
580            out_shape,
581            None,
582        )
583    }
584
585    /// Per-step xs gradient companion to [`Self::scan_backward`].
586    /// Same inputs and same `body_vjp` graph, plus an `xs_idx`
587    /// selecting which body_vjp output to stack into the result.
588    /// Output shape is `[length, *per_step_xs_shape]`.
589    pub fn scan_backward_xs(
590        &mut self,
591        init: NodeId,
592        trajectory: NodeId,
593        upstream: NodeId,
594        xs: &[NodeId],
595        body_vjp: Graph,
596        length: u32,
597        save_trajectory: bool,
598        xs_idx: u32,
599        out_shape: Shape,
600    ) -> NodeId {
601        self.scan_backward_xs_with_checkpoints(
602            init,
603            trajectory,
604            upstream,
605            xs,
606            body_vjp,
607            length,
608            save_trajectory,
609            xs_idx,
610            0,
611            None,
612            out_shape,
613        )
614    }
615
616    #[allow(clippy::too_many_arguments)]
617    pub fn scan_backward_xs_with_checkpoints(
618        &mut self,
619        init: NodeId,
620        trajectory: NodeId,
621        upstream: NodeId,
622        xs: &[NodeId],
623        body_vjp: Graph,
624        length: u32,
625        save_trajectory: bool,
626        xs_idx: u32,
627        num_checkpoints: u32,
628        forward_body: Option<Graph>,
629        out_shape: Shape,
630    ) -> NodeId {
631        let mut inputs = vec![init, trajectory, upstream];
632        inputs.extend_from_slice(xs);
633        self.push(
634            Op::ScanBackwardXs {
635                body_vjp: Box::new(body_vjp),
636                length,
637                save_trajectory,
638                num_xs: xs.len() as u32,
639                xs_idx,
640                num_checkpoints,
641                forward_body: forward_body.map(Box::new),
642            },
643            inputs,
644            out_shape,
645            None,
646        )
647    }
648
649    /// User-defined sub-graph with optional override AD rules.
650    /// JAX-shaped `custom_vjp` / `custom_jvp` — see [`Op::CustomFn`].
651    ///
652    /// `inputs.len()` must equal the number of `Op::Input` nodes in
653    /// `fwd_body`. Output shape is inferred from `fwd_body`'s declared
654    /// output. When supplied, `vjp_body` and `jvp_body` must follow the
655    /// conventions documented on [`Op::CustomFn`] (special-named
656    /// `"primal_output"` / `"d_output"` / `"tangent_*"` Inputs).
657    pub fn custom_fn(
658        &mut self,
659        inputs: Vec<NodeId>,
660        fwd_body: Graph,
661        vjp_body: Option<Graph>,
662        jvp_body: Option<Graph>,
663    ) -> NodeId {
664        let n_in = inputs.len();
665        // Count fwd_body's primal Inputs (no special names — fwd has none).
666        let fwd_inputs: usize = fwd_body
667            .nodes()
668            .iter()
669            .filter(|n| matches!(n.op, Op::Input { .. }))
670            .count();
671        assert_eq!(
672            fwd_inputs, n_in,
673            "custom_fn: fwd_body has {fwd_inputs} Op::Input(s); outer call \
674             provides {n_in}. Counts must match.",
675        );
676        let fwd_out_id = fwd_body
677            .outputs
678            .first()
679            .copied()
680            .expect("custom_fn: fwd_body must declare exactly one output");
681        let out_shape = fwd_body.node(fwd_out_id).shape.clone();
682
683        if let Some(vjp) = vjp_body.as_ref() {
684            let primal_count = vjp
685                .nodes()
686                .iter()
687                .filter(|n| {
688                    matches!(&n.op,
689                    Op::Input { name } if name != "primal_output" && name != "d_output")
690                })
691                .count();
692            assert_eq!(
693                primal_count, n_in,
694                "custom_fn: vjp_body has {primal_count} primal Op::Input(s) \
695                 (excluding 'primal_output' / 'd_output'); expected {n_in}",
696            );
697            let has_primal_out = vjp
698                .nodes()
699                .iter()
700                .any(|n| matches!(&n.op, Op::Input { name } if name == "primal_output"));
701            let has_d_output = vjp
702                .nodes()
703                .iter()
704                .any(|n| matches!(&n.op, Op::Input { name } if name == "d_output"));
705            assert!(
706                has_primal_out,
707                "custom_fn: vjp_body must declare an Op::Input named 'primal_output'"
708            );
709            assert!(
710                has_d_output,
711                "custom_fn: vjp_body must declare an Op::Input named 'd_output'"
712            );
713            assert_eq!(
714                vjp.outputs.len(),
715                n_in,
716                "custom_fn: vjp_body has {} outputs; expected {n_in} \
717                 (one gradient per primal input)",
718                vjp.outputs.len(),
719            );
720        }
721        if let Some(jvp) = jvp_body.as_ref() {
722            let primal_count = jvp
723                .nodes()
724                .iter()
725                .filter(|n| {
726                    matches!(&n.op,
727                    Op::Input { name }
728                        if !name.starts_with("tangent_") && name != "primal_output")
729                })
730                .count();
731            assert_eq!(
732                primal_count, n_in,
733                "custom_fn: jvp_body has {primal_count} primal Op::Input(s) \
734                 (excluding 'primal_output' / 'tangent_*'); expected {n_in}",
735            );
736            for i in 0..n_in {
737                let want = format!("tangent_{i}");
738                let has = jvp
739                    .nodes()
740                    .iter()
741                    .any(|n| matches!(&n.op, Op::Input { name } if name == &want));
742                assert!(
743                    has,
744                    "custom_fn: jvp_body must declare an Op::Input named '{want}'"
745                );
746            }
747            assert_eq!(
748                jvp.outputs.len(),
749                1,
750                "custom_fn: jvp_body has {} outputs; expected 1 (output tangent)",
751                jvp.outputs.len(),
752            );
753        }
754
755        self.push(
756            Op::CustomFn {
757                fwd_body: Box::new(fwd_body),
758                vjp_body: vjp_body.map(Box::new),
759                jvp_body: jvp_body.map(Box::new),
760                num_inputs: n_in as u32,
761            },
762            inputs,
763            out_shape,
764            None,
765        )
766    }
767
768    /// Multi-output `custom_fn` via the **concat-with-Narrow** design:
769    /// rewrites `fwd_body` to flatten + concat its `K` declared outputs
770    /// into a single 1-D F32 output, wraps that as [`Op::CustomFn`],
771    /// and returns a [`MultiOutputHandle`] the caller uses to extract
772    /// each sub-output via `Op::Narrow` + `Op::Reshape`.
773    ///
774    /// Per PLAN line 484, this avoids rewriting rlx's "1 Op = 1 output"
775    /// IR contract: the wrapped Op::CustomFn still has one output (the
776    /// flat concat), and `MultiOutputHandle::output(g, i)` materializes
777    /// component `i` lazily on the outer graph.
778    ///
779    /// Constraints (MVP):
780    /// - All sub-outputs must be `DType::F32`. Tuples-of-mixed-dtype
781    ///   need either a per-dtype split or a future tuple-type
782    ///   extension.
783    /// - All sub-output shapes must be statically known (no
784    ///   `Dim::Dynamic`).
785    /// - `vjp_body` / `jvp_body` aren't yet rewritten through the
786    ///   concat — caller must provide bodies that already expect
787    ///   the flat-concat output convention if they need custom AD.
788    pub fn custom_fn_multi(
789        &mut self,
790        inputs: Vec<NodeId>,
791        mut fwd_body: Graph,
792    ) -> MultiOutputHandle {
793        use crate::op::BinaryOp;
794        // Snapshot the original outputs + their shapes BEFORE
795        // appending concat ops. Outputs land at the end of the graph;
796        // we'll replace them.
797        let original_outputs = fwd_body.outputs.clone();
798        assert!(
799            !original_outputs.is_empty(),
800            "custom_fn_multi: fwd_body must have ≥ 1 declared output"
801        );
802        let mut sub_shapes: Vec<Shape> = Vec::with_capacity(original_outputs.len());
803        let mut offsets: Vec<usize> = Vec::with_capacity(original_outputs.len());
804        let mut total_len: usize = 0;
805        for &out_id in &original_outputs {
806            let s = fwd_body.node(out_id).shape.clone();
807            assert_eq!(
808                s.dtype(),
809                DType::F32,
810                "custom_fn_multi MVP: all sub-outputs must be F32, got {:?} \
811                 (sub-output #{})",
812                s.dtype(),
813                sub_shapes.len()
814            );
815            let n_elems: usize = s
816                .dims()
817                .iter()
818                .map(|d| match d {
819                    Dim::Static(k) => *k,
820                    Dim::Dynamic(_) => {
821                        panic!("custom_fn_multi MVP: dynamic dims not supported")
822                    }
823                })
824                .product();
825            offsets.push(total_len);
826            total_len += n_elems;
827            sub_shapes.push(s);
828        }
829        // Flatten each sub-output to [n_elems] and concat along axis 0.
830        let mut flats: Vec<NodeId> = Vec::with_capacity(original_outputs.len());
831        for (out_id, sh) in original_outputs.iter().zip(sub_shapes.iter()) {
832            let n: usize = sh
833                .dims()
834                .iter()
835                .map(|d| match d {
836                    Dim::Static(k) => *k,
837                    Dim::Dynamic(_) => unreachable!(),
838                })
839                .product();
840            let flat_shape = Shape::from_dims(&[Dim::Static(n)], DType::F32);
841            let flat = fwd_body.add_node(
842                Op::Reshape {
843                    new_shape: vec![n as i64],
844                },
845                vec![*out_id],
846                flat_shape,
847            );
848            flats.push(flat);
849        }
850        let concat_shape = Shape::from_dims(&[Dim::Static(total_len)], DType::F32);
851        let concat = fwd_body.add_node(Op::Concat { axis: 0 }, flats.clone(), concat_shape);
852        let _ = BinaryOp::Add; // import preserved if we extend later
853        fwd_body.set_outputs(vec![concat]);
854
855        // Now build the outer custom_fn with the rewritten body. Reuses
856        // the single-output asserts; flat concat satisfies them.
857        let source = self.custom_fn(inputs, fwd_body, None, None);
858
859        MultiOutputHandle {
860            source,
861            sub_shapes,
862            offsets,
863        }
864    }
865
866    /// Bounded scan returning the stacked trajectory.
867    /// Output shape is `[length, *init.shape]` — row `t` is the carry
868    /// after step `t+1`, so row `length-1` equals the result of plain
869    /// [`Self::scan`].
870    pub fn scan_trajectory(&mut self, init: NodeId, body: Graph, length: u32) -> NodeId {
871        let init_shape = self.shape(init).clone();
872        let mut traj_dims: Vec<crate::Dim> = Vec::with_capacity(init_shape.rank() + 1);
873        traj_dims.push(crate::Dim::Static(length as usize));
874        for i in 0..init_shape.rank() {
875            traj_dims.push(init_shape.dim(i));
876        }
877        let traj_shape = crate::Shape::from_dims(&traj_dims, init_shape.dtype());
878        self.push(
879            Op::Scan {
880                body: Box::new(body),
881                length,
882                save_trajectory: true,
883                num_xs: 0,
884                num_bcast: 0,
885                num_checkpoints: 0,
886            },
887            vec![init],
888            traj_shape,
889            None,
890        )
891    }
892}