Skip to main content

fuel_graph/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! # fuel-graph
3//!
4//! Lazy computation graph for Phase 6 of the fuel ML framework.
5//!
6//! ## Role
7//!
8//! Phase 6 transitions fuel from eager execution to a lazy computation graph
9//! with an autonomous router. This crate holds the minimal vocabulary that
10//! makes that transition possible:
11//!
12//! - [`Op`] — a closed enum describing every operation that can appear in a
13//!   graph node. New ops are added here and consumed by every backend.
14//! - [`Graph`] — an arena of [`Node`]s. Nodes are immutable once added and
15//!   referenced by index. The arena grows; it never shrinks.
16//! - [`NodeHandle`] — a cheaply-cloneable handle to a `(graph, node_id)` pair.
17//!   Users build computations by calling methods on `NodeHandle`; each method
18//!   appends a node to the underlying graph and returns a new handle.
19//!
20//! Execution is deliberately not implemented here. A separate executor —
21//! `fuel-dispatch`'s `PipelinedExecutor`, dispatching to the per-backend
22//! kernels (`fuel-cpu-backend`, `fuel-cuda-backend`, `fuel-vulkan-backend`) —
23//! walks a `Graph` and produces concrete outputs. (The retired
24//! `fuel-reference-backend` textbook walker was the original executor; the
25//! CPU backend's bit-stable kernels are the correctness oracle now.)
26//!
27//! ## Scope of the MVP
28//!
29//! This is the first landing of the graph types. It is deliberately minimal:
30//!
31//! - Four float dtypes: `f32`, `f64`, `bf16`, `f16`. Integer dtypes will be
32//!   added alongside the reference backend's integer op coverage, not
33//!   speculatively here.
34//! - A small starter `Op` catalog: `Const`, `Add`, `Mul`, `MatMul`,
35//!   `Transpose`, `Relu`, `Sqr`, `Exp`. More ops are added as validation
36//!   tests for them land.
37//! - Unfused backward autograd: [`NodeHandle::backward`] walks the forward
38//!   graph in reverse, applies per-op gradient rules, and emits new graph
39//!   nodes for the gradient of every leaf. Fused backward and symbolic
40//!   graph rewriting are deferred to Phase 6d.
41//! - No fusion, no planner. Those belong to later sub-phases of Phase 6.
42//! - Thread-safe. The graph is wrapped in `Arc<RwLock<_>>` so that
43//!   `fuel_graph::NodeHandle` — the lazy build handle — and any handle that
44//!   embeds it are `Send + Sync`. Borrow access goes through
45//!   `read().unwrap()` / `write().unwrap()`.
46
47pub mod grad;
48pub mod jit;
49pub mod opt;
50pub mod registry;
51pub mod run;
52pub mod runtime_fused;
53pub mod scan;
54mod shape;
55
56#[doc(inline)]
57pub use run::{
58    FEWNESS_THRESHOLD, PickedRoute, Run, branch_density, branch_density_multi,
59    branches_in_topo_order, device_alternating_order, effective_roots, extract_runs,
60    extract_runs_multi, lower_picked_route, lower_picked_route_streaming, lower_run,
61    lower_runs_arm0, passes_fewness_gate, walk_picked_route_streaming,
62};
63
64pub use shape::primitive_shape;
65
66use crate::registry::{FusedOpId, FusedOpParams};
67use fuel_backend_contract::Storage;
68use fuel_ir::{DType, DeviceLocation, DynScalar, Layout, Scalar, Shape, SymId, probe::BackendId};
69use half::{bf16, f16};
70use std::collections::{HashMap, HashSet};
71use std::sync::{Arc, RwLock};
72
73/// Compute the topological order of every node reachable from `root`.
74///
75/// The returned order places inputs before the nodes that depend on them,
76/// so iterating the list and caching each result as you go guarantees every
77/// dependency is available when its dependents are computed. Uses an
78/// explicit stack iterative post-order DFS — no recursion, so arbitrarily
79/// deep graphs are safe.
80///
81/// This is the utility an executor walks to realize a tensor, and the
82/// utility [`NodeHandle::backward`] walks in reverse to construct the backward
83/// graph.
84pub fn topo_order(graph: &Graph, root: NodeId) -> Vec<NodeId> {
85    topo_order_multi(graph, &[root])
86}
87
88/// Compute the topological order of every node reachable from ANY of the
89/// given roots. Each node appears in the output exactly once; the order
90/// still satisfies the dependency-before-dependent property, so walking
91/// it and caching results as you go computes every input before any node
92/// that depends on it.
93///
94/// Used by the multi-output `realize_many` entry points in executors:
95/// KV-cached inference wants the per-step logits AND the updated K/V
96/// tensors for every layer as outputs of a single forward pass. Asking
97/// the executor for them via `topo_order_multi` walks the graph exactly
98/// once for the combined dependency set, then the executor reads each
99/// requested root out of the cache.
100pub fn topo_order_multi(graph: &Graph, roots: &[NodeId]) -> Vec<NodeId> {
101    let mut order = Vec::new();
102    let mut visited: HashSet<NodeId> = HashSet::new();
103    // Each stack entry is (node, processed). `processed = false` means
104    // "push children then revisit me"; `processed = true` means "all my
105    // children are already in `order`, add me now."
106    let mut stack: Vec<(NodeId, bool)> = roots.iter().map(|&r| (r, false)).collect();
107    while let Some((id, processed)) = stack.pop() {
108        if processed {
109            order.push(id);
110            continue;
111        }
112        if visited.contains(&id) {
113            continue;
114        }
115        visited.insert(id);
116        stack.push((id, true));
117        for &child in &graph.node(id).inputs {
118            if !visited.contains(&child) {
119                stack.push((child, false));
120            }
121        }
122    }
123    order
124}
125
126/// A node ID in the arena. Stable for the lifetime of the [`Graph`].
127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
128pub struct NodeId(pub usize);
129
130/// Quantization block format for the `QMatMul` fused op
131/// ([`crate::registry::FusedOpParams::QMatMul`]). Matches the
132/// GGML/GGUF block layouts; each variant implies a fixed bytes-per-block
133/// and elements-per-block. Only variants for which a backend has a
134/// fused dequant-in-kernel matmul are currently exposed.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub enum QuantType {
137    /// 32-element block = 2 bytes f16 scale + 16 bytes packed u4 quants.
138    Q4_0,
139    /// 32-element block = 2 bytes f16 d + 2 bytes f16 m + 16 bytes packed u4 quants.
140    Q4_1,
141    /// 32-element block = 2 bytes f16 d + 4 bytes high-bit + 16 bytes packed u4 quants.
142    Q5_0,
143    /// 32-element block = 2 bytes f16 d + 2 bytes f16 m + 4 bytes high-bit + 16 bytes packed u4.
144    Q5_1,
145    /// 32-element block = 2 bytes f16 scale + 32 bytes i8 quants.
146    Q8_0,
147    /// 32-element block = 2 bytes f16 d + 2 bytes f16 s + 32 bytes i8 quants.
148    Q8_1,
149    /// 256-element super-block = scales + 64-byte 2-bit packed quants + 4 bytes f16 d/dmin.
150    Q2K,
151    /// 256-element super-block = hmask + 64-byte 2-bit packed quants + 12-byte scales + 2 bytes f16 d.
152    Q3K,
153    /// 256-element super-block = 2 bytes f16 d + 2 bytes f16 dmin +
154    /// 12 bytes of 6-bit-packed sub-block scales/mins + 128 bytes
155    /// of 4-bit-packed quants. GGML k-quant "medium" format.
156    #[expect(
157        non_camel_case_types,
158        reason = "GGML k-quant spec identifier (Q4_K_M = 4-bit K-quant Medium); matches the upstream / on-disk format name -- external convention over internal casing"
159    )]
160    Q4_K_M,
161    /// 256-element super-block = d + dmin + 12-byte scales + 32-byte hmask + 128-byte qs.
162    Q5K,
163    /// 256-element super-block = 128-byte ql + 64-byte qh + 16-byte i8 scales + 2 bytes f16 d.
164    Q6K,
165}
166
167impl QuantType {
168    /// Bytes per quantization block. Mirrors `std::mem::size_of::<BlockQ*>()`.
169    pub fn bytes_per_block(self) -> usize {
170        match self {
171            QuantType::Q4_0 => 18,
172            QuantType::Q4_1 => 20,
173            QuantType::Q5_0 => 22,
174            QuantType::Q5_1 => 24,
175            QuantType::Q8_0 => 34,
176            QuantType::Q8_1 => 36,
177            QuantType::Q2K => 84,
178            QuantType::Q3K => 110,
179            QuantType::Q4_K_M => 144,
180            QuantType::Q5K => 176,
181            QuantType::Q6K => 210,
182        }
183    }
184    /// Elements per quantization block.
185    pub fn elements_per_block(self) -> usize {
186        match self {
187            QuantType::Q4_0
188            | QuantType::Q4_1
189            | QuantType::Q5_0
190            | QuantType::Q5_1
191            | QuantType::Q8_0
192            | QuantType::Q8_1 => 32,
193            QuantType::Q2K
194            | QuantType::Q3K
195            | QuantType::Q4_K_M
196            | QuantType::Q5K
197            | QuantType::Q6K => 256,
198        }
199    }
200}
201
202/// Fill mode for [`Op::Pad`]. Only [`PadMode::Constant`] is
203/// implemented in the v1 cut; the other variants are accepted at
204/// the IR level so the enum is forward-stable, but the executor
205/// returns a clean error until they ship.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
207pub enum PadMode {
208    /// Fill padded slots with the constant `value` parameter on
209    /// the [`Op::Pad`] variant.
210    Constant,
211    /// Reflect input around the padded edges (without repeating the
212    /// edge value). Not yet implemented.
213    Reflect,
214    /// Repeat the edge value into the padded region. Not yet
215    /// implemented.
216    Replicate,
217}
218
219/// The closed enum of operations a graph node can represent.
220///
221/// This is the API contract between the graph layer and every backend.
222/// Adding a new op means (1) adding a variant here, (2) teaching every
223/// backend how to execute it, and (3) teaching the reference backend how
224/// to compute its textbook answer.
225// EXHAUSTIVE-BY-DESIGN: `Op` is the CLOSED graph vocabulary; `OpKind`
226// (fuel-ir/src/dispatch.rs) is the deliberately-`#[non_exhaustive]` EXTENSIBLE
227// dispatch key. This marker makes that split — currently stated only in the doc
228// above — explicit and machine-checkable. The forcing functions are in-crate and
229// exhaustive: `op_short_name` (below) and the autograd `backward` match; adding a
230// variant fails to compile until both get an arm, which is the intended
231// enforcement (a new op with no gradient rule must not compile silently). Because
232// those live in fuel-graph, `-p fuel-graph` DOES catch a variant-add here (unlike
233// Scalar/DType, whose exhaustive consumers are cross-crate) — but do NOT add
234// `#[non_exhaustive]`: it would falsely advertise Op as open to external
235// consumers, contradicting the "teach every backend" contract. Downstream
236// dispatch/cost/telemetry consumers legitimately PARTIAL-match Op with sound
237// defaults (`None`/`Err`/skip); execution is gated by `op_to_op_kind` returning
238// `None`, so a new op is never silently mis-executed — that is why a plain
239// wildcard is correct THERE and forbidden in the two forcing functions above.
240// A THIRD in-crate forcing function joined the two above (GAP-076):
241// `destructive_input` (this file) is now exhaustive as well — a new primitive
242// must classify itself there (destructive → `Some(idx)`, otherwise `None`), and
243// a new *fused* op declares destructiveness as a mandatory `FusedOpEntry` field
244// that `Op::destructive_input` reads via `default_registry()`. The old
245// `_ => None` there silently read a new destructive op as non-destructive and
246// dropped its scheduler ordering edges (a data race); it is gone. See GAP-049
247// and GAP-076.
248#[derive(Debug, Clone, PartialEq)]
249pub enum Op {
250    // --- leaves ---
251    /// A concrete constant tensor — a leaf with no input nodes. The
252    /// realized bytes live in the graph's `storage_map` slot for this
253    /// node, populated at construction time. The executor's slot-first
254    /// dispatch returns the slot's `Arc<RwLock<Storage>>` directly.
255    /// (Phase 7.5 work item G2 retired the host-side `ConstData`
256    /// payload — the slot is the sole source of bytes.)
257    Const,
258
259    /// Position-index generator — a leaf (no inputs) producing the 1-D
260    /// `F32` sequence `[0.0, 1.0, …, (len-1) as f32]` of shape `[len]`.
261    /// The integer-valued positions are exact in `f32` for any realistic
262    /// sequence length (`< 2^24`). Reshaped + broadcast, it builds the
263    /// row/column indices that express causal / sliding-window masks and
264    /// alibi position biases (e.g. in FlashAttn's decomposition), so those
265    /// ops can lower to primitives instead of being opaque. Non-differentiable
266    /// (a constant generator; backward drops gradient like `Op::Const`).
267    Iota { len: usize },
268
269    // --- element-wise binary ---
270    /// Element-wise addition.
271    Add,
272    /// Element-wise subtraction (`a - b`).
273    Sub,
274    /// Element-wise multiplication.
275    Mul,
276    /// Element-wise division (`a / b`).
277    Div,
278
279    // --- element-wise unary ---
280    /// Element-wise negation (`-x`).
281    Neg,
282    /// Element-wise square (`x * x`).
283    Sqr,
284    /// Element-wise square root.
285    Sqrt,
286    /// Element-wise exponential (`e^x`).
287    Exp,
288    /// Element-wise natural logarithm.
289    Log,
290    /// Element-wise sine.
291    Sin,
292    /// Element-wise cosine.
293    Cos,
294    /// Element-wise hyperbolic tangent.
295    Tanh,
296    /// Element-wise logistic sigmoid.
297    Sigmoid,
298    /// SiLU activation (x · sigmoid(x)), also called Swish.
299    Silu,
300    /// GELU activation (tanh approximation).
301    GeluTanh,
302    /// Rectified linear unit (`max(0, x)`), element-wise.
303    Relu,
304    /// Heaviside step function: `1` where `x > 0`, `0` otherwise. Serves
305    /// as the derivative of `Relu` and as a building block for other
306    /// comparison-based gradients.
307    Step,
308    /// Element-wise reciprocal (`1 / x`). On `x = 0` the IEEE-754 result
309    /// is `±inf`; the kernel matches the obvious `1.0 / x` expression
310    /// rather than treating the input as an error.
311    Recip,
312    /// Element-wise absolute value (`|x|`).
313    Abs,
314
315    // --- in-place elementwise unary (Phase 1 of the in-place ops
316    // infrastructure landed 2026-05-30; see
317    // `docs/session-prompts/in-place-ops-infrastructure.md`) ---
318    //
319    // Each variant mutates input 0 in place; output aliases input 0.
320    // Pinned to run after every non-destructive reader of input 0 by
321    // `opt::derive_ordering` via the `destructive_input() -> Some(0)`
322    // contract. Backward integration deferred to Phase 4 (the
323    // mutation-safety pass auto-clones tape-tracked inputs).
324    /// In-place Relu: `x = max(0, x)`. Same semantics as `Op::Relu`,
325    /// mutates input 0. Not yet wired through autograd.
326    ReluInplace,
327    /// In-place Silu: `x = x * sigmoid(x)`. Same semantics as
328    /// `Op::Silu`, mutates input 0. Not yet wired through autograd.
329    SiluInplace,
330    /// In-place GeluTanh (tanh approximation): mutates input 0 with the
331    /// same formula as `Op::GeluTanh`. Its backward is the same as `Op::GeluTanh`'s.
332    GeluTanhInplace,
333    /// In-place Tanh: mutates input 0. Same semantics as `Op::Tanh`.
334    /// Not yet wired through autograd.
335    TanhInplace,
336    /// In-place Sigmoid: mutates input 0. Same semantics as
337    /// `Op::Sigmoid`. Not yet wired through autograd.
338    SigmoidInplace,
339
340    // --- in-place elementwise unary, dtype expansion + op family
341    // expansion shipped 2026-05-30. Same `destructive_input() ->
342    // Some(0)` contract as the 5 above; backward emitters mirror
343    // the non-inplace cousin (chain-rule formulas relying on
344    // Phase 4a view-aware ordering for pre-mutation reads of x).
345    /// In-place Neg: `x = -x`. Same semantics as `Op::Neg`.
346    NegInplace,
347    /// In-place Abs: `x = |x|`. Same semantics as `Op::Abs`.
348    AbsInplace,
349    /// In-place Sqr: `x = x²`. Same semantics as `Op::Sqr`.
350    SqrInplace,
351    /// In-place Sqrt: `x = √x`. Same semantics as `Op::Sqrt`.
352    SqrtInplace,
353    /// In-place Rsqrt: `x = 1/√x`. Same semantics as `Op::Rsqrt`.
354    RsqrtInplace,
355    /// In-place Recip: `x = 1/x`. Same semantics as `Op::Recip`.
356    RecipInplace,
357    /// In-place Exp: `x = exp(x)`. Same semantics as `Op::Exp`.
358    ExpInplace,
359    /// In-place Log: `x = ln(x)`. Same semantics as `Op::Log`.
360    LogInplace,
361    /// In-place Sin: `x = sin(x)`. Same semantics as `Op::Sin`.
362    SinInplace,
363    /// In-place Cos: `x = cos(x)`. Same semantics as `Op::Cos`.
364    CosInplace,
365    /// In-place Sign: `x = sign(x)`. Same semantics as `Op::Sign`.
366    /// Backward drops gradient (mirrors `Op::Sign`).
367    SignInplace,
368    /// In-place Floor: `x = ⌊x⌋`. Same semantics as `Op::Floor`.
369    /// Backward drops gradient (mirrors `Op::Floor`).
370    FloorInplace,
371    /// In-place Ceil: `x = ⌈x⌉`. Same semantics as `Op::Ceil`.
372    /// Backward drops gradient (mirrors `Op::Ceil`).
373    CeilInplace,
374    /// In-place Round: `x = round(x)`. Same semantics as `Op::Round`.
375    /// Backward drops gradient (mirrors `Op::Round`).
376    RoundInplace,
377    /// In-place Erf: `x = erf(x)`. Same semantics as `Op::Erf`.
378    ErfInplace,
379    /// In-place exact-GeLU: `x = 0.5 · x · (1 + erf(x/√2))`. Same
380    /// semantics as `Op::Gelu`.
381    GeluInplace,
382    /// In-place clamp: `x = clamp(x, min, max)`. Same semantics as
383    /// `Op::Clamp`. Backward gates the upstream by `(x ≥ min) ∧
384    /// (x ≤ max)` (mirrors Op::Clamp).
385    ClampInplace { min: f64, max: f64 },
386    /// In-place integer-power: `x = x.powi(exp)`. Same semantics as
387    /// `Op::PowI`. Backward emits `exp · upstream · x.powi(exp-1)`
388    /// (mirrors Op::PowI).
389    PowIInplace(i32),
390
391    // --- element-wise comparison (output is Bool mask) ---
392    /// Element-wise equality (`a == b`) producing a `Bool` mask: `1`
393    /// where the inputs are equal, `0` otherwise. Both operands must
394    /// share dtype and shape. NaN follows IEEE-754 (`NaN == NaN` is
395    /// false). Output dtype is always `Bool`, regardless of input dtype
396    /// — the binding-table key is `[T, T, Bool]`.
397    ///
398    /// Non-differentiable: backward returns `None` for both inputs
399    /// (registered via [`crate::grad::NoGradientBinaryRule`]).
400    Equal,
401    /// Element-wise inequality (`a != b`) producing a `Bool` mask: `1`
402    /// where unequal, `0` otherwise. Same shape/dtype contract as
403    /// [`Op::Equal`]. NaN follows IEEE-754 (`NaN != NaN` is true →
404    /// `1`). Non-differentiable.
405    Ne,
406    /// Element-wise strictly-less (`a < b`) producing a `Bool` mask: `1`
407    /// where lhs is strictly less than rhs, `0` otherwise. Same
408    /// shape/dtype contract as [`Op::Equal`]. NaN-on-either-side is
409    /// always `0` (IEEE-754 unordered comparison). Non-differentiable.
410    Lt,
411    /// Element-wise less-or-equal (`a <= b`) producing a `Bool` mask.
412    /// Same shape/dtype contract as [`Op::Equal`]. NaN-on-either-side
413    /// is always `0`. Non-differentiable.
414    Le,
415    /// Element-wise strictly-greater (`a > b`) producing a `Bool` mask.
416    /// Same shape/dtype contract as [`Op::Equal`]. NaN-on-either-side
417    /// is always `0`. Non-differentiable.
418    Gt,
419    /// Element-wise greater-or-equal (`a >= b`) producing a `Bool` mask.
420    /// Same shape/dtype contract as [`Op::Equal`]. NaN-on-either-side
421    /// is always `0`. Non-differentiable. Final variant of the
422    /// comparison family (`Equal`, `Ne`, `Lt`, `Le`, `Gt`, `Ge`).
423    Ge,
424
425    // --- rounding family (non-differentiable) ---
426    /// Element-wise floor (`⌊x⌋`). Same dtype as input. Backward is
427    /// the zero distribution almost everywhere; gradient is dropped
428    /// silently (treated like `Op::Step`'s no-grad backward).
429    Floor,
430    /// Element-wise ceiling (`⌈x⌉`). Same dtype as input. Backward
431    /// drops gradient (mirrors [`Op::Floor`]).
432    Ceil,
433    /// Element-wise round-to-nearest with **banker's rounding**
434    /// (round-half-to-even, IEEE 754 roundeven). 0.5 → 0, 1.5 → 2,
435    /// 2.5 → 2 (NOT 3), 3.5 → 4. Matches NumPy/PyTorch defaults and
436    /// differs from C99 `round()` (half-away-from-zero) at exact ties.
437    /// Backward drops gradient (mirrors [`Op::Floor`]).
438    Round,
439    /// Element-wise sign (`-1` / `0` / `1`). `sign(0) = 0` by
440    /// subgradient convention (same convention `Op::Abs`'s backward
441    /// uses). Same dtype as input. Backward drops gradient (zero
442    /// almost everywhere).
443    Sign,
444    /// Element-wise Gauss error function (`erf(x)`). Same dtype as
445    /// input. Differentiable: `d/dx erf(x) = (2/√π) * exp(-x²)`.
446    /// Backward emitted via existing primitives (Sqr → Neg → Exp →
447    /// MulScalar(2/√π) → Mul(upstream, .)).
448    Erf,
449    /// Element-wise GELU activation, **exact erf formulation**:
450    /// `0.5 * x * (1 + erf(x/√2))`. Distinct from [`Op::GeluTanh`]
451    /// (tanh approximation, faster but slightly less accurate).
452    /// Differentiable; backward decomposes into the standard-normal
453    /// CDF + `x * φ(x)` (PDF) chain via primitives.
454    Gelu,
455    /// Element-wise binary power: `out = pow(a, b)` with real `b`.
456    /// Both inputs share dtype `T` and shape. Distinct from
457    /// [`Op::PowI`] (scalar `i32` exponent). Backward:
458    ///   d/da pow(a,b) = b * pow(a, b-1)
459    ///   d/db pow(a,b) = pow(a,b) * ln(a)
460    /// NaN follows IEEE-754 (`pow(-2, 0.5) = NaN`); ln of negative `a`
461    /// in the d/db term yields NaN at those positions.
462    Pow,
463    /// Element-wise reciprocal square root: `out = 1 / sqrt(x)`.
464    /// Same dtype as input. Distinct from `Sqrt` followed by `Recip`
465    /// (one op instead of two — saves a kernel launch and matches the
466    /// RMSNorm shape `x * rsqrt(mean(x²)+eps)`). Backward:
467    ///   d/dx (x^(-1/2)) = -0.5 * x^(-3/2) = -0.5 * y³
468    /// where `y = 1/sqrt(x)` is the forward output (so the chain
469    /// reuses the forward node and never touches `x` directly —
470    /// safe near x=0).
471    Rsqrt,
472    /// Element-wise remainder, **PyTorch convention**:
473    /// `out = a - floor(a / b) * b`. The result has the sign of the
474    /// divisor (matches `torch.remainder`). Distinct from C99 fmod
475    /// (sign of dividend) and `f32::rem_euclid` (always non-negative).
476    /// Backward:
477    ///   d/da = 1
478    ///   d/db = -floor(a/b)
479    Rem,
480    /// Reverse the order of elements along `dim`. Materializing op
481    /// (not a view) — `Layout`'s strides are `usize` so the
482    /// negative-stride view path isn't representable. Backward is
483    /// `Flip { dim }` itself (involutive).
484    Flip { dim: usize },
485    /// Cyclic shift along `dim` by `shift` positions (positive
486    /// shifts move elements to higher indices, wrapping around).
487    /// Materializing op. Backward is `Roll { dim, -shift }`.
488    Roll { dim: usize, shift: i64 },
489    /// Running cumulative sum along `dim`. Same shape as input.
490    /// Backward: reverse cumsum, expressed as `Flip → CumSum → Flip`
491    /// on the same dim using existing primitives.
492    CumSum { dim: usize },
493    /// Upper triangular: keep `x[..., i, j]` when `j >= i + diagonal`,
494    /// zero otherwise. Operates on the last two dims; leading dims
495    /// are batched. `diagonal` selects which diagonal is the cutoff
496    /// (0 = main diagonal, positive = above, negative = below).
497    /// Same shape as input. Backward is `Triu { diagonal }` itself
498    /// (linear mask; gradient passes through kept positions).
499    Triu { diagonal: i64 },
500    /// Lower triangular: keep `x[..., i, j]` when `j <= i + diagonal`,
501    /// zero otherwise. Operates on the last two dims; leading dims
502    /// are batched. `diagonal` selects which diagonal is the cutoff
503    /// (0 = main diagonal, positive = above, negative = below). The
504    /// canonical causal-attention mask is `tril(diagonal = 0)`.
505    /// Same shape as input. Backward is `Tril { diagonal }` itself.
506    Tril { diagonal: i64 },
507    /// Numerically-stable log-softmax along the last dimension:
508    /// `y_i = x_i - max_j(x_j) - log(sum_j exp(x_j - max_j(x_j)))`.
509    /// Same shape as input. Used in NLL / cross-entropy loss where
510    /// the explicit `log(softmax(x))` decomposition risks
511    /// over/underflow. Backward: see [`Op::LogSoftmaxLastDimBackward`].
512    LogSoftmaxLastDim,
513    /// MaskedFill: `out = where(mask != 0, value, x)`. Inputs:
514    /// `(x, mask)` where `mask` is U8 (any nonzero counts as "fill").
515    /// `value` is a scalar of the same dtype as `x`, stored on the
516    /// op. Output shape == x shape (mask must broadcast to x but the
517    /// simple-shape case requires identical shapes today). Backward
518    /// passes the upstream gradient through `x` at positions where
519    /// `mask == 0`, and contributes zero to `mask` (U8 anyway).
520    MaskedFill { value: Scalar },
521    /// Backward helper for [`Op::Pad`]. Single input is the upstream
522    /// gradient (shape == padded forward output shape); output is the
523    /// gradient with respect to the original input (shape ==
524    /// `in_shape`). Mode determines accumulation behavior:
525    /// - Constant: gradient at padded slots is dropped; the unpadded
526    ///   region is the input gradient.
527    /// - Reflect / Replicate: gradient accumulates from output
528    ///   positions that map back to each input position via the
529    ///   forward index function.
530    ///
531    /// Carries `in_shape` so the kernel can size the output tensor
532    /// (Op output shape isn't enough — Op::Pad's output shape
533    /// derivation needs reversing).
534    PadBackward {
535        in_shape: Shape,
536        padding: Vec<(usize, usize)>,
537        mode: PadMode,
538    },
539
540    /// Multi-dim Pad: per-axis `(before, after)` extends each
541    /// dimension. Output shape: `out[i] = in[i] + padding[i].0 + padding[i].1`.
542    /// `mode` selects the fill behavior; `value` is the fill
543    /// constant for [`PadMode::Constant`] (ignored otherwise — see
544    /// the variant docs for those modes).
545    ///
546    /// Multi-dim shape is the form PyTorch's `F.pad` exposes — a
547    /// single call covers e.g. "pad an image by 1 px on every side"
548    /// (`padding = [(0,0), (0,0), (1,1), (1,1)]` for `[N,C,H,W]`).
549    /// Single-dim is just the special case `padding[i] = (0,0)`
550    /// everywhere except one axis.
551    ///
552    /// Only Constant mode is fully implemented in v1. Reflect /
553    /// Replicate are accepted at the IR level but the executor
554    /// returns a clean "not yet implemented" error.
555    ///
556    /// Backward (Constant): slice along each padded axis to drop
557    /// the padded regions, restoring the input shape.
558    Pad {
559        padding: Vec<(usize, usize)>,
560        mode: PadMode,
561        value: f64,
562    },
563
564    // --- ternary select ---
565    /// Ternary select: `out[i] = if cond[i] != 0 { a[i] } else { b[i] }`.
566    /// Inputs `(cond, a, b)`: `cond` is `U8` (typically the output of
567    /// a comparison op), `a` and `b` share dtype `T` and shape with
568    /// `cond`. Output dtype = `T`, shape = `cond.shape()`.
569    ///
570    /// Differentiable through `a` and `b` (gradient flows only to
571    /// the picked positions); `cond` is non-differentiable. Backward
572    /// rule expressed via `Op::Cast` + scalar arithmetic on existing
573    /// primitives, registered in [`crate::grad::WhereRule`].
574    Where,
575
576    // --- linear algebra and shape ---
577    /// Rank-2 matrix multiply.
578    MatMul,
579    /// Rank-2 transpose.
580    Transpose,
581    /// N-dimensional permutation. The parameter is the new axis order:
582    /// `out.shape[i] = in.shape[axes[i]]`. For a rank-3 input `[a, b, c]`
583    /// with `axes = [2, 0, 1]`, output shape is `[c, a, b]`. The axes
584    /// vector must be a permutation of `0..rank`.
585    Permute(Vec<usize>),
586
587    // --- dtype, shape, and broadcasting ---
588    /// Convert a tensor to a different dtype. The target dtype is carried
589    /// on the op itself; the source dtype is whatever the input node's
590    /// dtype is.
591    Cast(DType),
592    /// Broadcast a tensor to a larger shape. The input shape must be
593    /// broadcast-compatible with the target (NumPy rules). Used directly
594    /// by user code and as the backward rule for `SumAll`, `MeanAll`,
595    /// and axis reductions.
596    BroadcastTo(Shape),
597    /// Reshape a tensor to a new shape with the same element count. Data
598    /// is unchanged; only the shape metadata is replaced. Serves as the
599    /// general building block for arbitrary shape changes;
600    /// see [`Op::Unsqueeze`] for the metadata-only-view variant that
601    /// inserts a size-1 dim without ever materializing.
602    Reshape(Shape),
603    /// Materialize a contiguous copy of the input. Output shape and
604    /// dtype match the input; only the layout changes (strides become
605    /// row-major, start_offset becomes 0). Zero-cost when the input
606    /// is already contiguous + zero-offset — the executor adopts the
607    /// input's Storage Arc unchanged in that case.
608    ///
609    /// First-class IR concern so the optimizer (Phase 2.2) can insert
610    /// layout-fixups before kernels that don't advertise
611    /// `crate::KernelCaps::strided_input` without overloading
612    /// [`Op::Reshape`]'s "change shape" semantics. The executor
613    /// compiles this to the same `WorkItemKind::ContiguizeOf` arm
614    /// `Op::Reshape` uses; the only difference is that
615    /// `Op::Contiguize`'s output shape equals its input shape, so
616    /// the executor's element-count sanity check is trivial.
617    Contiguize,
618    /// Insert a size-1 dimension at position `dim` (range `0..=rank`,
619    /// where `dim == rank` appends to the end). Strictly more efficient
620    /// than [`Op::Reshape`] for the size-1-insertion case: this is a
621    /// metadata-only view op that shares bytes with its input via the
622    /// Layout side-table, so it preserves any upstream
623    /// strided/transposed/broadcast layout instead of triggering an
624    /// auto-Contiguize.
625    Unsqueeze { dim: usize },
626    /// Drop a size-1 dimension at position `dim` (range `0..rank`).
627    /// Inverse of [`Op::Unsqueeze`]; metadata-only view that shares
628    /// bytes with its input via the Layout side-table. Panics at build
629    /// time if the dim's size isn't 1 (mirrors Unsqueeze's bounds
630    /// check — graph-builder validation, not runtime).
631    Squeeze { dim: usize },
632    /// Sum-reduce a tensor to a smaller shape by summing along any dims
633    /// where the source was broadcast against the target. This is the
634    /// backward rule for `BroadcastTo` and is symmetric with it: both ops
635    /// are each other's gradient. For a source shape `[2, 3, 4]` being
636    /// reduced to `[3, 4]`, the sum is along dim 0; for `[2, 3, 4]` to
637    /// `[2, 1, 4]`, the sum is along dim 1 while keeping the dim.
638    ReduceSumTo(Shape),
639    /// Max-reduce a tensor to a smaller shape, the maximum-symmetric
640    /// counterpart of [`Op::ReduceSumTo`]. The target shape must be
641    /// broadcast-compatible into the source: along any axis where the
642    /// padded target is 1 (or absent), the input is reduced via max;
643    /// other axes carry through. Used by lowering rules (notably
644    /// SoftmaxLastDim's max-subtract step) and as the keepdim form of
645    /// per-axis max reductions.
646    ///
647    /// Backward through max-selection routes the upstream gradient
648    /// only to the argmax position, which requires holding onto the
649    /// original input and recomputing the argmax. PR 3.5 ships this
650    /// op without a backward rule (panics cleanly on `.backward()`,
651    /// matching `Op::ArgMaxDim`'s precedent); add the backward when a
652    /// real consumer needs it.
653    ReduceMaxTo(Shape),
654
655    // --- reductions to a scalar ---
656    /// Sum of every element, producing a rank-0 tensor.
657    SumAll,
658    /// Maximum of every element, producing a rank-0 tensor.
659    MaxAll,
660    /// Minimum of every element, producing a rank-0 tensor.
661    MinAll,
662    /// Arithmetic mean of every element, producing a rank-0 tensor.
663    MeanAll,
664
665    // --- reductions along one dimension ---
666    /// Sum along the given dim; the reduced dim is removed from the output.
667    SumDim(usize),
668    /// Max along the given dim; the reduced dim is removed from the output.
669    MaxDim(usize),
670    /// Min along the given dim; the reduced dim is removed from the output.
671    MinDim(usize),
672    /// Mean along the given dim; the reduced dim is removed from the output.
673    MeanDim(usize),
674
675    // --- compositions ---
676    //
677    // Phase 7.6 step 5 (2026-05-11): the per-fused-op primitive variants
678    // (SoftmaxLastDim, LayerNormLastDim { eps }, RmsNormLastDim { eps },
679    // Rope, Conv2D { … }, FusedLinear) plus the four backward-helper
680    // variants (SoftmaxLastDimBackward, LayerNormLastDimBackward,
681    // RmsNormLastDimBackward, ReduceMaxToBackward) have been dropped.
682    // The closed primitive set no longer carries fused-op concepts;
683    // every fused op flows through `Op::Fused(FusedOpId, FusedOpParams)`.
684    // See `docs/fused-op-registry.md` for the migration record.
685
686    // Phase 7.6 step 5 (continued, 2026-05-11): `QMatMul {
687    // quant_type, k, n }` dropped — flows through
688    // `Op::Fused(QMATMUL, FusedOpParams::QMatMul { … })`.
689
690    // --- backward helpers ---
691    //
692    // Phase 7.6 step 5 (2026-05-11): the four migrated backward-helper
693    // variants (SoftmaxLastDimBackward, LayerNormLastDimBackward,
694    // RmsNormLastDimBackward, ReduceMaxToBackward) have been dropped.
695    // They flow through `Op::Fused(FusedOpId, FusedOpParams)` per the
696    // registry split. LogSoftmaxLastDimBackward is the only remaining
697    // backward-helper primitive variant — its forward primitive
698    // (`Op::LogSoftmaxLastDim`, not in scope for step 5) hasn't migrated
699    // to the registry yet, so the backward stays primitive too.
700    //
701    /// LogSoftmax-last-dim backward. Inputs: `(forward_log_softmax_output,
702    /// upstream)`. Output: the gradient of the input to log-softmax.
703    /// Formula: `g - exp(y) * sum(g, last_dim, keepdim=true)` where
704    /// `y` is the forward log-softmax output and `g` is the upstream
705    /// gradient. Independent of `Op::SoftmaxLastDimBackward` because
706    /// the closed-form rule uses `exp(y) = softmax(x)` directly, not
707    /// the upstream-times-softmax composition.
708    LogSoftmaxLastDimBackward,
709
710    // --- integer-producing reductions ---
711    //
712    // These reduce a float tensor along one dim and emit a U32 tensor of
713    // indices. Non-differentiable — they exist for classification
714    // prediction and similar discrete workloads, not as part of any
715    // training graph. Backward panics cleanly if a user builds these
716    // inside a graph they then differentiate.
717    /// Index of the maximum along `dim`. Output: U32 with the reduced
718    /// dim removed.
719    ArgMaxDim(usize),
720    /// Index of the minimum along `dim`. Output: U32 with the reduced
721    /// dim removed.
722    ArgMinDim(usize),
723
724    // --- shape manipulation ---
725    /// Concatenate two tensors along `dim`. Both must have the same rank,
726    /// same dtype, and equal sizes in every dim except `dim`.
727    Concat { dim: usize },
728    /// Slice (narrow) a tensor along `dim`: take elements `[start, start+len)`.
729    /// Output shape: same as input but with `dim` shrunk to `len`.
730    Slice {
731        dim: usize,
732        start: usize,
733        len: usize,
734    },
735
736    // --- scalar-by-tensor ops ---
737    //
738    // These exist to avoid building a full-shape const just to add or
739    // multiply by a constant. Stored as f64 on the op and converted to
740    // the target dtype at realize time.
741    /// Add a scalar to every element.
742    AddScalar(f64),
743    /// Multiply every element by a scalar.
744    MulScalar(f64),
745    /// Raise every element to an integer power via repeated multiplication.
746    /// Works without an extra `log`/`exp` pair so it's numerically clean
747    /// for small integer exponents like squaring or cubing.
748    PowI(i32),
749    /// Clamp every element to the inclusive range `[min, max]`.
750    Clamp { min: f64, max: f64 },
751
752    // --- element-wise max/min between two tensors ---
753    /// Element-wise maximum of two tensors with matching shape.
754    Maximum,
755    /// Element-wise minimum of two tensors with matching shape.
756    Minimum,
757
758    // --- indexing ops ---
759    //
760    // These take a "data" tensor (floats) as their first input and an
761    // "index" tensor (integer) as their second input. The executor
762    // reads both operands from the cache, dispatches on the data
763    // tensor's dtype for the output, and uses the index tensor as-is.
764    /// Index-select along a single dimension with a 1-D index vector.
765    /// Output shape: same as input, but with `dim` replaced by
766    /// `indices.elem_count()`.
767    IndexSelect { dim: usize },
768    /// N-dimensional gather along `dim`. Output shape matches the index
769    /// tensor's shape. `out[..., k, ...] = data[..., indices[..., k, ...], ...]`
770    /// with `dim` being the position where the index value substitutes
771    /// for the output coordinate.
772    Gather { dim: usize },
773    /// Index-add: the backward of `IndexSelect`. Takes a base tensor,
774    /// a 1-D `U32` index vector, and a src tensor, and adds src to base
775    /// at the indexed positions along `dim`. `out[..., indices[i], ...] +=
776    /// src[..., i, ...]`. Non-indexed positions of base are copied
777    /// through unchanged. Works functionally (returns a new tensor).
778    IndexAdd { dim: usize },
779    /// Scatter-add: the backward of `Gather`. Takes a base tensor, an
780    /// N-D `U32` index tensor matching the src shape, and a src tensor,
781    /// and accumulates src values into base at positions given by
782    /// substituting `indices` at position `dim`. `out[p with dim ←
783    /// indices[p]] += src[p]`.
784    ScatterAdd { dim: usize },
785
786    /// Data-determined nonzero-index extraction — the keystone primitive
787    /// for **data-dependent dynamic shapes** (SSM autoregressive decode,
788    /// MoE sparse dispatch, MLA compressed-KV). Given an input value
789    /// tensor, produces the flat indices of its nonzero elements over a
790    /// **fixed-capacity buffer** plus the true runtime count, using the
791    /// multi-output bundle mechanism ([12-multi-output]):
792    ///
793    /// - slot 0 (primary): `indices`, shape `[capacity]`, dtype `U32`,
794    ///   where `capacity = input.elem_count()` (the static worst-case
795    ///   bound). The first `count` entries are the flat (row-major)
796    ///   indices of the input's nonzero elements in ascending order; the
797    ///   tail `[count..capacity]` is unspecified padding.
798    /// - slot 1: `count`, shape `[1]`, dtype `U32` — the number of
799    ///   nonzero elements found.
800    ///
801    /// "Nonzero" means `!= 0` in the input dtype's arithmetic (a `0.0`
802    /// float, an all-zero integer). The runtime `count` is published into
803    /// the per-pass [`SymEnv`](fuel_ir::SymEnv) under `count_sym` by the
804    /// executor *after* this op realizes, so downstream ops consume it as
805    /// a [`DynScalar::Sym`] / dynamic [`Extent`](fuel_ir::shape::Extent) —
806    /// the same host-scalar-extent pattern the KV-cache `cached_len`
807    /// uses. This is the **first op whose output length is determined by
808    /// its input data**, not by build-time shapes (the [`fuel_ir::symbol::SymEnv`] bind is
809    /// the one net-new realize-time seam). Primitive (in the base map by
810    /// construction — no `decompose`). Non-differentiable: discrete
811    /// indices, backward drops gradient like `Op::Gather`'s index path.
812    NonZeroIndices { count_sym: SymId },
813
814    // --- 2-D convolution + fused linear ---
815    //
816    // Phase 7.6 step 5 (2026-05-11): `Conv2D { stride, padding, groups }`
817    // and `FusedLinear` have been dropped — both flow through
818    // `Op::Fused(FusedOpId, FusedOpParams)` per the registry split.
819    // See `fuel-graph/src/registry/{conv2d,fused_linear}.rs` for the
820    // current entry shapes.
821
822    // Phase 7.6 step 5 (continued, 2026-05-11): `FlashAttn`,
823    // `PagedAttn`, and `ConvTranspose2D` dropped — all three flow
824    // through `Op::Fused(<id>, FusedOpParams::<variant>)` per the
825    // registry split. See `fuel-graph/src/registry/{flash_attn,
826    // paged_attn, conv_transpose_2d}.rs`.
827
828    // --- cross-device transfer ---
829    /// Copy the input tensor to a specific device. Source stays
830    /// resident (non-destructive). Source device is implicit from
831    /// `inputs[0]`'s current residency. When source and target
832    /// devices match, the backend is free to turn this into a cheap
833    /// clone; when they differ, the backend must actually transfer
834    /// the data (today: through a host buffer — Phase 3 has no P2P
835    /// fast path yet).
836    ///
837    /// Phase 3 of the unified scheduler work. The scheduler (Phase 4)
838    /// inserts these nodes automatically; users can also construct
839    /// them explicitly via `NodeHandle::copy_to_device`.
840    Copy { target: DeviceLocation },
841
842    /// Release the input tensor's device-resident storage. Produces a
843    /// zero-element marker output (NodeId placeholder for graph
844    /// bookkeeping; no meaningful data). After this op runs, the
845    /// input's device storage is gone; any subsequent reader of the
846    /// input's NodeId must get its data from a different path (a
847    /// sibling Copy output, or re-execution of the producer).
848    ///
849    /// Destructive on `inputs[0]`. Scheduler must pin `Op::Release`
850    /// to run after every other reader of its input via
851    /// [`opt::derive_ordering`].
852    ///
853    /// **Multi-output bundles** (Option C, Session 3): when
854    /// `inputs[0]` is a multi-output producer (its Storage carries a
855    /// [`fuel_ir::storage::OutputView`] bundle), the bundle
856    /// is the single eviction unit — the Release evicts the whole
857    /// bundle, not a single slot. `opt::collect_alias_set` treats
858    /// every `Op::View` of the producer as part of the producer's
859    /// alias set, so `derive_ordering` pins Release after every
860    /// reader of every View; the bundle drops only when the LAST
861    /// View consumer finishes. Op::ViewOwned consumers may run
862    /// freely after Release: their forward memcpy ran before the
863    /// Release fires (data-dep edge), and the resulting standalone
864    /// Storage is independent of the producer's bundle. Per-slot
865    /// eviction (releasing one slot's bytes while another stays
866    /// live) is intentionally a follow-up — v1 keeps the bundle as
867    /// a single unit.
868    Release,
869
870    /// Transfer the input tensor to `target` device, destroying the
871    /// source in the process. Semantically equivalent to
872    /// `Op::Copy { target }` followed by `Op::Release` on the source,
873    /// with the explicit guarantee that backends MAY fast-path the
874    /// fused form as a single transfer (e.g., a zero-copy handoff if
875    /// source and target are the same memory type).
876    ///
877    /// Output is a fresh tensor on `target` with the input's shape
878    /// and dtype. Destructive on `inputs[0]`; the scheduler pins this
879    /// op to run after every non-destructive reader of the source via
880    /// [`opt::derive_ordering`].
881    Move { target: DeviceLocation },
882
883    /// In-place scatter write: copies `inputs[1]`'s bytes into a
884    /// rectangular slab of `inputs[0]`'s storage, defined per-dim by
885    /// `ranges[i] = (start, end)`. After the op runs, `inputs[0]`'s
886    /// bytes inside the slab are replaced; bytes outside the slab are
887    /// untouched.
888    ///
889    /// Shape contract: `inputs[0].rank() == inputs[1].rank() ==
890    /// ranges.len()`. For each axis `i`,
891    /// `inputs[1].dims()[i] == ranges[i].1 - ranges[i].0`, and
892    /// `ranges[i].1 <= inputs[0].dims()[i]`. Dtypes must match.
893    ///
894    /// Output is a marker that adopts `inputs[0]`'s Storage Arc and
895    /// Layout — same shape, same bytes, post-write. Consumers that
896    /// want a sub-extent of the destination compose an explicit
897    /// `Op::Slice` after the write (WriteSlice does not encode
898    /// post-write extents).
899    ///
900    /// Destructive on `inputs[0]` (the destination is consumed; only
901    /// the write op's output NodeId may read its bytes afterward).
902    /// Non-destructive on `inputs[1]`. The scheduler pins this op to
903    /// run after every other reader of the destination via
904    /// [`opt::derive_ordering`].
905    ///
906    /// Phase E.3.2: introduced to back persistent KV-cache writes
907    /// (`InferenceContext` + `KvCache`). Non-differentiable; backward
908    /// panics — KV-cache writes are forward-only.
909    ///
910    /// `dyn_offset` (Phase D symbolic extents): `None` ⇒ fully static
911    /// (`ranges` alone defines the slab — today's behavior). `Some((axis,
912    /// off))` ⇒ the destination start on `axis` is `off.resolve(env)`
913    /// against the per-pass [`fuel_ir::SymEnv`] at realize,
914    /// overriding `ranges[axis].0`; the slab width on that axis stays
915    /// `ranges[axis].1 - ranges[axis].0`. This is the input-determined
916    /// dynamic-offset path (a [`DynScalar`] over the one `SymEnv`),
917    /// distinct from [`Op::WriteSliceRotating`]'s data-determined
918    /// position (a tensor input). It backs the persistent decode
919    /// KV-cache write, whose append offset (`cached_len`) is a per-token
920    /// runtime value over a fixed-capacity buffer, so the graph
921    /// structure stays identical across tokens and the plan is reused.
922    /// Build a node with this set via [`NodeHandle::write_slice_dyn`]; the
923    /// resolved `offset + width` is bounds-checked against the
924    /// destination capacity at realize (a typed error, never a panic).
925    WriteSlice {
926        ranges: Vec<(usize, usize)>,
927        dyn_offset: Option<(usize, DynScalar)>,
928    },
929
930    /// Like [`Op::WriteSlice`] but the `axis` axis wraps modulo
931    /// `modulus`. The dynamic write position comes through `inputs[2]`
932    /// (rank-0 U32); at realize time the kernel computes
933    /// `start = position % modulus` and writes `inputs[1]` into
934    /// `inputs[0]` at the wrapped offset, splitting across the
935    /// boundary if `start + write_len > modulus`.
936    ///
937    /// Inputs:
938    /// - `inputs[0]`: destination tensor (the rotating buffer). Shape
939    ///   on `axis` is the full storage extent — typically equal to
940    ///   `modulus`.
941    /// - `inputs[1]`: source slice. Shape on non-rotating axes is the
942    ///   slab in `ranges`; shape on `axis` is the write length.
943    /// - `inputs[2]`: dynamic write position, rank-0 `U32`. Wrapped
944    ///   modulo `modulus` inside the kernel — callers pass the
945    ///   monotonic logical position (token index, etc.).
946    ///
947    /// `ranges` describes the destination slab on every axis. For the
948    /// rotating axis `axis`, `ranges[axis].0` is ignored (start is
949    /// dynamic) and `ranges[axis].1 - ranges[axis].0` must equal
950    /// `inputs[1].dims()[axis]` (the write length). For other axes
951    /// the same shape contract as `Op::WriteSlice` applies.
952    ///
953    /// Output is a marker that adopts `inputs[0]`'s Storage Arc and
954    /// Layout, post-write. Destructive on `inputs[0]`; scheduled like
955    /// `Op::WriteSlice`.
956    ///
957    /// Phase C of the eager-NodeHandle retirement program. Backs
958    /// sliding-window KV caches (Mistral / Phi-3 sliding-window /
959    /// sliding-window Qwen). Non-differentiable; backward panics.
960    WriteSliceRotating {
961        axis: usize,
962        modulus: usize,
963        ranges: Vec<(usize, usize)>,
964    },
965
966    /// Like [`Op::WriteSlice`] but the start offset on `axis` is a
967    /// **device-resident** scalar read from `inputs[2]` (rank-0 `I64`)
968    /// at kernel-launch time — NOT host-resolved. This is the CUDA-
969    /// graph-capturable append: under capture, the offset buffer is
970    /// updated by a per-token fixed-address H2D memcpy before each
971    /// replay, so the captured graph is reused across decode steps
972    /// while the write lands at the live position. No modulo wrap
973    /// (unlike [`Op::WriteSliceRotating`]); the offset is the raw
974    /// start (`cached_len`).
975    ///
976    /// Inputs:
977    /// - `inputs[0]`: destination tensor (the fixed-capacity buffer).
978    /// - `inputs[1]`: source slab. Shape on non-`axis` axes is the slab
979    ///   in `ranges`; shape on `axis` is the write width.
980    /// - `inputs[2]`: device offset, rank-0 `I64` — the start on `axis`.
981    ///   Overrides `ranges[axis].0` (which is a placeholder ignored by
982    ///   the kernel).
983    ///
984    /// `ranges` describes the destination slab on every axis. For the
985    /// dynamic axis, `ranges[axis].0` is ignored (start is device-
986    /// resident) and `ranges[axis].1 - ranges[axis].0` must equal
987    /// `inputs[1].dims()[axis]` (the write width). For other axes the
988    /// same shape contract as [`Op::WriteSlice`] applies.
989    ///
990    /// **Bounds are the caller's contract**: the kernel reads the start
991    /// device-side and does NOT clamp (clamping would perturb the index
992    /// math vs the static path). `offset + width <= dest_dims[axis]`
993    /// must hold — guaranteed by the caller sizing the buffer's
994    /// capacity (`DecodeSession` sizes `max_seq`).
995    ///
996    /// Output adopts `inputs[0]`'s Storage Arc + Layout, post-write.
997    /// Destructive on `inputs[0]`; scheduled like `Op::WriteSlice`.
998    /// Non-differentiable; backward panics.
999    ///
1000    /// CapturedRun (the decode-latency lever, 2026-07): the device-
1001    /// resident-offset WriteSlice is form-B of baracuda's
1002    /// `write_slice_*_doff` launchers.
1003    WriteSliceDoff {
1004        axis: usize,
1005        ranges: Vec<(usize, usize)>,
1006    },
1007
1008    /// Allocate a fresh, zero-initialized Storage of the node's shape +
1009    /// dtype on `target`. Zero inputs — `Op::Alloc` is a *source* op
1010    /// like [`Op::Const`], but its bytes are computed (zeros) rather
1011    /// than seeded from the host.
1012    ///
1013    /// Bridge-retirement Phase 3a (post-9c). Replaces the per-device-
1014    /// location match in `fuel-tensor::inference_context::alloc_zeroed_on`
1015    /// with a graph-level node the optimizer can see + the executor's
1016    /// `WorkItemKind::Alloc` arm dispatches per-backend.
1017    ///
1018    /// **Device-handle requirement**: the executor's Alloc arm derives
1019    /// the per-backend handle (e.g. `&CudaDevice`, `&Arc<VulkanBackend>`)
1020    /// by searching the input cache for any Storage already on
1021    /// `target`'s backend. Callers must seed the cache with at least
1022    /// one such storage before realizing an Op::Alloc on a non-CPU
1023    /// target. `fuel-tensor::pipelined_bridge::device_seed_storage` is
1024    /// the canonical seeder.
1025    Alloc { target: DeviceLocation },
1026
1027    /// Fill the input tensor's bytes with zero, in place. Aliases
1028    /// `inputs[0]`'s Storage Arc as the output (same Storage; the
1029    /// bytes are mutated). Destructive on `inputs[0]` — readers of
1030    /// the input's NodeId after this op see post-fill bytes; new
1031    /// readers should use this op's NodeId.
1032    ///
1033    /// Bridge-retirement Phase 3a follow-up. Paired with
1034    /// [`Op::Alloc`] to give the architecturally clean "alloc uninit +
1035    /// explicit zero" pipeline. Backends with native device-side
1036    /// fills (CUDA `cuMemsetD8Async`, Vulkan `vkCmdFillBuffer`)
1037    /// dispatch through their kernels; CPU does an
1038    /// `Arc::make_mut`-style in-place memset.
1039    ///
1040    /// Future extension: an `Op::Fill { value: u8 }` (or `Scalar`)
1041    /// for non-zero patterns. ZeroFill is the only variant today
1042    /// because zero is the only init pattern any current caller
1043    /// needs.
1044    ZeroFill,
1045
1046    /// Phase 7.6 single-arm delegate to the open
1047    /// [`crate::registry::FusedOpRegistry`]. The id selects which
1048    /// fused op (SoftmaxLastDim, RmsNormLastDim, FlashAttn, ...) and
1049    /// the params carry per-instance data. The registry's metadata
1050    /// side (decompose, backward identity, shape/dtype rules) lives in
1051    /// `fuel-graph::registry`; the kernel side (per-backend `KernelRef`
1052    /// + cost + precision) lives in `fuel-storage::fused`.
1053    ///
1054    /// Adding a new fused op is one registry entry plus one kernel
1055    /// function per backend that supports it — no new `Op` variant.
1056    /// Step 3 of Phase 7.6 migrates SoftmaxLastDim through this arm
1057    /// as the proof of concept.
1058    Fused(FusedOpId, FusedOpParams),
1059
1060    /// Project one logical output out of a multi-output producer node.
1061    /// The producer's realized [`Storage`] carries a
1062    /// [`fuel_ir::storage::OutputView`] side-table; this op
1063    /// reads slot `slot` of that side-table and exposes its
1064    /// dtype/shape/layout as the View node's output.
1065    ///
1066    /// Single input (the multi-output producer). Output shares the
1067    /// producer's `Arc<RwLock<Storage>>` — zero bytes copied. The
1068    /// bundle stays alive as long as any View consumer (or the
1069    /// original producer handle) holds a clone.
1070    ///
1071    /// At graph-build time, the View's shape + dtype + layout come
1072    /// from the producer's slot specs recorded via
1073    /// [`Graph::set_output_views`]; building a `View { slot }` over a
1074    /// producer that hasn't declared slot specs is a typed error
1075    /// surfaced by the View-emitting builder helper. The bundle's
1076    /// per-slot dtype is independent of the producer node's own
1077    /// `Node::dtype` (which is the bundle's primary/slot-0 dtype).
1078    ///
1079    /// Backward (see `fuel-graph::grad`): the per-slot upstream
1080    /// gradient is scattered into a zero-filled bundle of the
1081    /// producer's overall byte shape; the producer's own backward
1082    /// rule then sees a "bundle gradient" as its upstream. Until a
1083    /// real multi-output op author exists (Session 2), the backward
1084    /// rule emits an [`Op::Const`] zero of the producer's primary
1085    /// shape — the scatter-into-slot primitive lands alongside the
1086    /// first differentiable multi-output op.
1087    View { slot: u32 },
1088
1089    /// Like [`Op::View`], but copies the slot's bytes into a fresh
1090    /// standalone Storage. Costs one allocation + one slot-sized
1091    /// memcpy; the producer's bundle Arc can drop as soon as every
1092    /// outstanding `Op::View` over the same producer also drops.
1093    ///
1094    /// The planner promotes a `View` to `ViewOwned` when a slot's
1095    /// liveness substantially outlasts the rest of the bundle (the
1096    /// classic case: Mamba's `last_state` slot retained across an
1097    /// autoregressive step while `y` is consumed immediately). v1
1098    /// emits the same dispatch shape as `Op::Copy` — backend-side it's
1099    /// `Copy` with byte offset + length pulled from the slot spec.
1100    ///
1101    /// Same shape/dtype/layout inference path as `View`; same backward
1102    /// shape too (the forward already paid the copy).
1103    ViewOwned { slot: u32 },
1104
1105    /// Multi-output autograd primitive (Option C, item 4). Takes two
1106    /// inputs:
1107    ///   - `inputs[0]`: a bundled tensor (typically a zero-bundle)
1108    ///   - `inputs[1]`: the slot's gradient
1109    ///
1110    /// Produces a new bundled tensor identical to `inputs[0]` except
1111    /// that slot `slot`'s byte range is overlaid by `inputs[1]`'s
1112    /// bytes. The output is alias-free with respect to `inputs[0]`
1113    /// (a fresh Storage with the new bytes) so multiple
1114    /// `ScatterIntoSlot` operations on the same `bundle_zero` chain
1115    /// sequentially without aliasing pitfalls.
1116    ///
1117    /// Emitted by `Op::View` / `Op::ViewOwned` backward rules: each
1118    /// View consumer scatters its upstream gradient into a fresh
1119    /// zero-bundle, then the producer's backward receives the
1120    /// composed bundle gradient as its upstream.
1121    ///
1122    /// **Status**: IR-level primitive only in this session. No CPU
1123    /// kernel registration yet. The production multi-output SSM ops
1124    /// (SelectiveScan, SsdChunkScan) are now differentiable via the
1125    /// Op::Scan Phase-2 `lower_scans_for_backward` pre-pass (decompose →
1126    /// `Op::Scan` → `unroll_scan` → node-general autograd), which lowers
1127    /// them to primitives BEFORE the reverse walk — so the autograd
1128    /// differentiates the unrolled `View` projections directly and never
1129    /// reaches a `ScatterIntoSlot` realization on the SSM path.
1130    ScatterIntoSlot { slot: u32 },
1131
1132    /// In-place multi-path (phi/merge) node — the arena representation
1133    /// of the optimized form's divergent-then-reconvergent routes.
1134    ///
1135    /// A `Branch` node's divergent "arms" are encoded in the node's
1136    /// existing `inputs: Vec<NodeId>`: each input is the *exit* of one
1137    /// candidate route, and `reconverge_at` names the explicit node at
1138    /// which those routes merge back into a single value. Because the
1139    /// multi-path structure is an arena fact rather than an overlay, it
1140    /// persists, compacts, graph-walks, and path-filters exactly like
1141    /// any other node; a graph with **zero** `Op::Branch` nodes is
1142    /// exactly today's single-route graph (back-compatible by
1143    /// construction).
1144    ///
1145    /// **Inert until PR-A1.** This variant is the PR-A0 scaffold: the
1146    /// closed `Op` enum carries it and every exhaustive match handles
1147    /// it, but nothing constructs it yet. The `open_branch` /
1148    /// `add_arm` / `finalize_branches` builders (with build-time
1149    /// validation: reconverge must descend from the diverge point, arms
1150    /// internally disjoint, cast-to-uniform at reconverge) land in
1151    /// PR-A1. Until then, accessors return a constant ("branch") and
1152    /// fallible paths (shape inference, autograd, lowering) return a
1153    /// descriptive `Err` rather than panicking.
1154    Branch { reconverge_at: NodeId },
1155
1156    /// Fuel's first sub-graph-carrying primitive: a bounded `lax.scan`-shaped
1157    /// recurrence `scan(body, init_carry, xs, consts) -> (final_carry, ys)`.
1158    ///
1159    /// The body is encoded in this node's own `inputs`, exactly like
1160    /// [`Op::Branch`]'s arms. Node layout:
1161    /// `inputs = [ init_carry_0..init_carry_{n_carries-1}, xs_0..xs_{n_xs-1},
1162    ///            consts..., body_new_carry_0..body_new_carry_{n_carries-1}, body_y ]`
1163    /// — the two body-exit NodeIds are the LAST two inputs, so `base_map_hash`,
1164    /// `topo_order_multi`, and reachability see the body for free. The body
1165    /// references `Op::ScanPlaceholder{Carry,0}` for the per-step carry and
1166    /// `Op::ScanPlaceholder{Elem,i}` for the per-step slice of `xs[i]`; consts
1167    /// are referenced by real NodeId. `n_carries` carries are threaded; the
1168    /// body references `ScanPlaceholder{Carry,k}` for carry `k` (GAP-303 —
1169    /// `n_carries = 1` is the v1 single-carry model).
1170    ///
1171    /// An optimization/verification-time node: it never reaches the executor
1172    /// un-lowered (the SSM decompose emits it, but the executor dispatches the
1173    /// fused op to its kernel; `unroll_scan` materializes primitives on demand
1174    /// for the verify oracle / kernel-absent fallback). No `LoweringRule`
1175    /// matches it — it is a terminal in the base map, which is correct: it IS
1176    /// the primitive. Always a 2-slot bundle (slot 0 = stacked `ys`, slot 1 =
1177    /// final carry). `early_exit` is Phase-2 (see [`ScanPredicate`]).
1178    Scan {
1179        /// How many recurrent carries this scan threads. Symmetric with
1180        /// `n_xs`: the carry NODES live in `inputs` (a positional block, see
1181        /// the layout above); this field is only their COUNT, exactly as
1182        /// `n_xs` is only the count of the `xs` block.
1183        ///
1184        /// GAP-303: `1` reproduces the v1 single-carry model exactly.
1185        n_carries: usize,
1186        n_xs: usize,
1187        bound: usize,
1188        emit: ScanEmit,
1189        early_exit: Option<ScanPredicate>,
1190    },
1191
1192    /// Inert body-hole leaf for [`Op::Scan`] (no inputs). Substituted by
1193    /// `unroll_scan` per step. Never directly realized.
1194    ScanPlaceholder { role: ScanRole, index: usize },
1195}
1196
1197/// What a [`Op::Scan`] emits. `All` stacks the per-step `body_y` over the
1198/// bound (slot 0); `Final` returns only the final carry (slot 1). The op
1199/// always produces the 2-slot bundle; `emit` selects the builder's default
1200/// projection. (`Op::Reduce` is `Op::Scan { emit: Final }` conceptually —
1201/// no separate variant.)
1202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1203pub enum ScanEmit {
1204    All,
1205    Final,
1206}
1207
1208/// Which body hole a [`Op::ScanPlaceholder`] stands in for. `Carry` is the
1209/// per-step recurrent carry (always index 0 in v1's single-carry model);
1210/// `Elem` is the per-step slice of `xs[index]`.
1211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1212pub enum ScanRole {
1213    Carry,
1214    Elem,
1215}
1216
1217/// Phase-1 placeholder for [`Op::Scan`]'s `early_exit` field. The FIELD is
1218/// defined now so the enum shape is final (one `03-ir` MAJOR bump); the
1219/// data-dependent early-exit MECHANISM is Phase 2. It is never `Some` on any
1220/// live Phase-1 path; if a realize/lowering path sees `Some`, that path
1221/// returns a clear `Err` (a surfaced Phase-2 gap), never a panic.
1222#[derive(Debug, Clone, PartialEq)]
1223pub struct ScanPredicate;
1224
1225impl Op {
1226    /// Index into `inputs` that this op destroys on execution. `None`
1227    /// means the op is non-destructive — every input remains readable
1228    /// after the op completes. Destructive ops need the scheduler to
1229    /// pin them to run after all other readers of the destroyed input,
1230    /// via ordering edges derived by [`opt::derive_ordering`].
1231    pub fn destructive_input(&self) -> Option<usize> {
1232        match self {
1233            // --- Destructive: the op writes through input 0 in place, so every
1234            // reader of input 0 (and its view-aliases) must be scheduled BEFORE
1235            // it (`opt::derive_ordering`). ---
1236            Op::Release
1237            | Op::Move { .. }
1238            | Op::WriteSlice { .. }
1239            | Op::WriteSliceRotating { .. }
1240            | Op::WriteSliceDoff { .. }
1241            | Op::ZeroFill
1242            | Op::ReluInplace
1243            | Op::SiluInplace
1244            | Op::GeluTanhInplace
1245            | Op::TanhInplace
1246            | Op::SigmoidInplace
1247            | Op::NegInplace
1248            | Op::AbsInplace
1249            | Op::SqrInplace
1250            | Op::SqrtInplace
1251            | Op::RsqrtInplace
1252            | Op::RecipInplace
1253            | Op::ExpInplace
1254            | Op::LogInplace
1255            | Op::SinInplace
1256            | Op::CosInplace
1257            | Op::SignInplace
1258            | Op::FloorInplace
1259            | Op::CeilInplace
1260            | Op::RoundInplace
1261            | Op::ErfInplace
1262            | Op::GeluInplace
1263            | Op::ClampInplace { .. }
1264            | Op::PowIInplace(_) => Some(0),
1265
1266            // --- Fused ops declare destructiveness as a MANDATORY
1267            // `FusedOpEntry::destructive_input` field (registry-declared, not
1268            // hardcoded here), so the fact lives with the thing and cannot drift.
1269            // Total by construction: a compile-time fused op cannot be registered
1270            // without the field; a runtime-registered fused id is not in the
1271            // compile-time registry and its region is a functional recipe (the
1272            // PatternNode grammar cannot express a destructive op), so `entry` is
1273            // `None` there and the op is non-destructive. GAP-076. ---
1274            Op::Fused(id, _) => crate::registry::default_registry()
1275                .entry(*id)
1276                .and_then(|e| e.destructive_input),
1277
1278            // --- Non-destructive primitives, listed EXHAUSTIVELY (no wildcard)
1279            // so adding an `Op` variant fails to compile here until it is
1280            // classified (`Op` is EXHAUSTIVE-BY-DESIGN — see the marker on
1281            // `enum Op`). A silent `_ => None` was the GAP-076 hazard: a future
1282            // destructive op would read as non-destructive and the scheduler
1283            // would drop its ordering edges — an intermittent data race that
1284            // points nowhere near this function. ---
1285            Op::Const
1286            | Op::Iota { .. }
1287            | Op::Add
1288            | Op::Sub
1289            | Op::Mul
1290            | Op::Div
1291            | Op::Neg
1292            | Op::Sqr
1293            | Op::Sqrt
1294            | Op::Exp
1295            | Op::Log
1296            | Op::Sin
1297            | Op::Cos
1298            | Op::Tanh
1299            | Op::Sigmoid
1300            | Op::Silu
1301            | Op::GeluTanh
1302            | Op::Relu
1303            | Op::Step
1304            | Op::Recip
1305            | Op::Abs
1306            | Op::Equal
1307            | Op::Ne
1308            | Op::Lt
1309            | Op::Le
1310            | Op::Gt
1311            | Op::Ge
1312            | Op::Where
1313            | Op::Floor
1314            | Op::Ceil
1315            | Op::Round
1316            | Op::Sign
1317            | Op::Erf
1318            | Op::Gelu
1319            | Op::Pow
1320            | Op::Rsqrt
1321            | Op::Rem
1322            | Op::Flip { .. }
1323            | Op::Roll { .. }
1324            | Op::CumSum { .. }
1325            | Op::Triu { .. }
1326            | Op::Tril { .. }
1327            | Op::LogSoftmaxLastDim
1328            | Op::MaskedFill { .. }
1329            | Op::Pad { .. }
1330            | Op::PadBackward { .. }
1331            | Op::MatMul
1332            | Op::Transpose
1333            | Op::Permute(_)
1334            | Op::Cast(_)
1335            | Op::BroadcastTo(_)
1336            | Op::Reshape(_)
1337            | Op::Contiguize
1338            | Op::Unsqueeze { .. }
1339            | Op::Squeeze { .. }
1340            | Op::ReduceSumTo(_)
1341            | Op::ReduceMaxTo(_)
1342            | Op::SumAll
1343            | Op::MaxAll
1344            | Op::MinAll
1345            | Op::MeanAll
1346            | Op::SumDim(_)
1347            | Op::MaxDim(_)
1348            | Op::MinDim(_)
1349            | Op::MeanDim(_)
1350            | Op::LogSoftmaxLastDimBackward
1351            | Op::ArgMaxDim(_)
1352            | Op::ArgMinDim(_)
1353            | Op::Concat { .. }
1354            | Op::Slice { .. }
1355            | Op::AddScalar(_)
1356            | Op::MulScalar(_)
1357            | Op::PowI(_)
1358            | Op::Clamp { .. }
1359            | Op::Maximum
1360            | Op::Minimum
1361            | Op::IndexSelect { .. }
1362            | Op::Gather { .. }
1363            | Op::IndexAdd { .. }
1364            | Op::ScatterAdd { .. }
1365            | Op::NonZeroIndices { .. }
1366            | Op::Copy { .. }
1367            | Op::Alloc { .. }
1368            | Op::View { .. }
1369            | Op::ViewOwned { .. }
1370            | Op::ScatterIntoSlot { .. }
1371            | Op::Branch { .. }
1372            | Op::Scan { .. }
1373            | Op::ScanPlaceholder { .. } => None,
1374        }
1375    }
1376
1377    /// Short human-readable name for this op — used by executor error
1378    /// messages to identify the offending graph node without spilling
1379    /// all of `Debug`'s field contents. Keeps panic messages one-liner-
1380    /// friendly while still telling you which kind of op blew up.
1381    pub fn short_name(&self) -> &'static str {
1382        op_short_name(self)
1383    }
1384
1385    /// Whether this op is a metadata-only view: its output shares bytes
1386    /// with its single input, reinterpreting them through new strides
1387    /// and/or offset. Executors realize these without allocating new
1388    /// storage — they wrap the input's Storage in a fresh Layout.
1389    ///
1390    /// Note that `Op::Reshape` is *not* a view op. Reshape produces
1391    /// contiguous output: zero-copy when its input is contiguous, but
1392    /// it materializes via auto-Contiguize when the input is strided.
1393    /// The other shape-altering ops in this set are always view-shaped
1394    /// regardless of the input layout.
1395    pub fn is_view_op(&self) -> bool {
1396        matches!(
1397            self,
1398            Op::Transpose
1399                | Op::Permute(_)
1400                | Op::BroadcastTo(_)
1401                | Op::Slice { .. }
1402                | Op::Unsqueeze { .. }
1403                | Op::Squeeze { .. }
1404                | Op::Flip { .. }
1405        )
1406    }
1407}
1408
1409/// Compute the output [`Layout`] of a metadata-only view op from its
1410/// input layout + the op variant. Returns `Err` if the op variant
1411/// isn't a view op (caller's contract — typically guarded by
1412/// [`Op::is_view_op`]).
1413///
1414/// Used by [`Graph::push`] to auto-populate the layout side-table for
1415/// view-op nodes at construction time, and by lowering rules in
1416/// [`opt`] that emit view-op nodes and need to populate the side-table
1417/// for them. The compiler reads the resulting layouts via
1418/// [`Graph::layout`] without re-deriving.
1419pub fn derive_view_output_layout(op: &Op, input_layout: &Layout) -> Result<Layout, fuel_ir::Error> {
1420    match op {
1421        Op::Transpose => {
1422            let rank = input_layout.shape().rank();
1423            if rank < 2 {
1424                return Err(fuel_ir::Error::Msg(format!(
1425                    "Op::Transpose requires rank >= 2, input rank is {rank}",
1426                ))
1427                .bt());
1428            }
1429            input_layout.transpose(rank - 2, rank - 1)
1430        }
1431        Op::Permute(axes) => input_layout.permute(axes),
1432        Op::BroadcastTo(target_shape) => input_layout.broadcast_as(target_shape.clone()),
1433        Op::Slice { dim, start, len } => input_layout.narrow(*dim, *start, *len),
1434        Op::Unsqueeze { dim } => input_layout.unsqueeze(*dim),
1435        Op::Squeeze { dim } => input_layout.squeeze(*dim),
1436        Op::Flip { dim } => input_layout.flip(*dim),
1437        other => Err(fuel_ir::Error::Msg(format!(
1438            "derive_view_output_layout called with non-view op {other:?}",
1439        ))
1440        .bt()),
1441    }
1442}
1443
1444fn op_short_name(op: &Op) -> &'static str {
1445    match op {
1446        Op::Const => "Const",
1447        Op::Iota { .. } => "Iota",
1448        Op::Add => "Add",
1449        Op::Sub => "Sub",
1450        Op::Mul => "Mul",
1451        Op::Div => "Div",
1452        Op::Neg => "Neg",
1453        Op::Sqr => "Sqr",
1454        Op::Sqrt => "Sqrt",
1455        Op::Exp => "Exp",
1456        Op::Log => "Log",
1457        Op::Sin => "Sin",
1458        Op::Cos => "Cos",
1459        Op::Tanh => "Tanh",
1460        Op::Sigmoid => "Sigmoid",
1461        Op::Silu => "Silu",
1462        Op::GeluTanh => "GeluTanh",
1463        Op::Relu => "Relu",
1464        Op::Step => "Step",
1465        Op::Recip => "Recip",
1466        Op::Abs => "Abs",
1467        Op::ReluInplace => "ReluInplace",
1468        Op::SiluInplace => "SiluInplace",
1469        Op::GeluTanhInplace => "GeluTanhInplace",
1470        Op::TanhInplace => "TanhInplace",
1471        Op::SigmoidInplace => "SigmoidInplace",
1472        Op::NegInplace => "NegInplace",
1473        Op::AbsInplace => "AbsInplace",
1474        Op::SqrInplace => "SqrInplace",
1475        Op::SqrtInplace => "SqrtInplace",
1476        Op::RsqrtInplace => "RsqrtInplace",
1477        Op::RecipInplace => "RecipInplace",
1478        Op::ExpInplace => "ExpInplace",
1479        Op::LogInplace => "LogInplace",
1480        Op::SinInplace => "SinInplace",
1481        Op::CosInplace => "CosInplace",
1482        Op::SignInplace => "SignInplace",
1483        Op::FloorInplace => "FloorInplace",
1484        Op::CeilInplace => "CeilInplace",
1485        Op::RoundInplace => "RoundInplace",
1486        Op::ErfInplace => "ErfInplace",
1487        Op::GeluInplace => "GeluInplace",
1488        Op::ClampInplace { .. } => "ClampInplace",
1489        Op::PowIInplace(_) => "PowIInplace",
1490        Op::Equal => "Equal",
1491        Op::Ne => "Ne",
1492        Op::Lt => "Lt",
1493        Op::Le => "Le",
1494        Op::Gt => "Gt",
1495        Op::Ge => "Ge",
1496        Op::Where => "Where",
1497        Op::Floor => "Floor",
1498        Op::Ceil => "Ceil",
1499        Op::Round => "Round",
1500        Op::Sign => "Sign",
1501        Op::Erf => "Erf",
1502        Op::Gelu => "Gelu",
1503        Op::Pow => "Pow",
1504        Op::Rsqrt => "Rsqrt",
1505        Op::Rem => "Rem",
1506        Op::Flip { .. } => "Flip",
1507        Op::Roll { .. } => "Roll",
1508        Op::CumSum { .. } => "CumSum",
1509        Op::Triu { .. } => "Triu",
1510        Op::Tril { .. } => "Tril",
1511        Op::LogSoftmaxLastDim => "LogSoftmaxLastDim",
1512        Op::MaskedFill { .. } => "MaskedFill",
1513        Op::Pad { .. } => "Pad",
1514        Op::PadBackward { .. } => "PadBackward",
1515        Op::MatMul => "MatMul",
1516        Op::Transpose => "Transpose",
1517        Op::Permute(_) => "Permute",
1518        Op::Cast(_) => "Cast",
1519        Op::BroadcastTo(_) => "BroadcastTo",
1520        Op::Reshape(_) => "Reshape",
1521        Op::Contiguize => "Contiguize",
1522        Op::Unsqueeze { .. } => "Unsqueeze",
1523        Op::Squeeze { .. } => "Squeeze",
1524        Op::ReduceSumTo(_) => "ReduceSumTo",
1525        Op::ReduceMaxTo(_) => "ReduceMaxTo",
1526        Op::SumAll => "SumAll",
1527        Op::MaxAll => "MaxAll",
1528        Op::MinAll => "MinAll",
1529        Op::MeanAll => "MeanAll",
1530        Op::SumDim(_) => "SumDim",
1531        Op::MaxDim(_) => "MaxDim",
1532        Op::MinDim(_) => "MinDim",
1533        Op::MeanDim(_) => "MeanDim",
1534        Op::LogSoftmaxLastDimBackward => "LogSoftmaxLastDimBackward",
1535        Op::ArgMaxDim(_) => "ArgMaxDim",
1536        Op::ArgMinDim(_) => "ArgMinDim",
1537        Op::Concat { .. } => "Concat",
1538        Op::Slice { .. } => "Slice",
1539        Op::AddScalar(_) => "AddScalar",
1540        Op::MulScalar(_) => "MulScalar",
1541        Op::PowI(_) => "PowI",
1542        Op::Clamp { .. } => "Clamp",
1543        Op::Maximum => "Maximum",
1544        Op::Minimum => "Minimum",
1545        Op::IndexSelect { .. } => "IndexSelect",
1546        Op::Gather { .. } => "Gather",
1547        Op::IndexAdd { .. } => "IndexAdd",
1548        Op::ScatterAdd { .. } => "ScatterAdd",
1549        Op::NonZeroIndices { .. } => "NonZeroIndices",
1550        Op::Copy { .. } => "Copy",
1551        Op::Release => "Release",
1552        Op::Move { .. } => "Move",
1553        Op::WriteSlice { .. } => "WriteSlice",
1554        Op::WriteSliceRotating { .. } => "WriteSliceRotating",
1555        Op::WriteSliceDoff { .. } => "WriteSliceDoff",
1556        Op::Alloc { .. } => "Alloc",
1557        Op::ZeroFill => "ZeroFill",
1558        // Phase 7.6: registry-extended fused ops. Step 3 wires per-id
1559        // names through a static lookup; until then, all fused ops
1560        // share one short name. Distinguishing in error messages is
1561        // future work — `id` is in the Debug repr.
1562        Op::Fused(_, _) => "Fused",
1563        Op::View { .. } => "View",
1564        Op::ViewOwned { .. } => "ViewOwned",
1565        Op::ScatterIntoSlot { .. } => "ScatterIntoSlot",
1566        // PR-A0 inert scaffold (the multi-path phi/merge node).
1567        Op::Branch { .. } => "Branch",
1568        Op::Scan { .. } => "Scan",
1569        Op::ScanPlaceholder { .. } => "ScanPlaceholder",
1570    }
1571}
1572
1573/// G2 helper: element count of a `HostBuffer`. Used by NodeHandle's
1574/// constructors to validate that the supplied data matches the
1575/// declared shape's `elem_count` before allocating Storage.
1576fn host_buffer_elem_count(buf: &fuel_ir::HostBuffer) -> usize {
1577    use fuel_ir::HostBuffer;
1578    match buf {
1579        HostBuffer::F32(v) => v.len(),
1580        HostBuffer::F64(v) => v.len(),
1581        HostBuffer::BF16(v) => v.len(),
1582        HostBuffer::F16(v) => v.len(),
1583        HostBuffer::U8(v) => v.len(),
1584        HostBuffer::U32(v) => v.len(),
1585        HostBuffer::I64(v) => v.len(),
1586        other => panic!(
1587            "NodeHandle::from_*: unsupported host-buffer dtype {:?}",
1588            other.dtype(),
1589        ),
1590    }
1591}
1592
1593/// A single node in the graph: an operation and the IDs of its inputs.
1594/// The node also caches its output shape and dtype so downstream builders
1595/// can validate without walking back through the graph.
1596#[derive(Debug, Clone)]
1597pub struct Node {
1598    pub op: Op,
1599    pub inputs: Vec<NodeId>,
1600    pub shape: Shape,
1601    pub dtype: DType,
1602}
1603
1604/// How a node's storage is shared and how long it lives — its **storage
1605/// class** ([03-ir §"Storage classes and sessions"]). A node is a
1606/// structural fact and does not need storage to exist; when storage *is*
1607/// attached, its class governs sharing and lifetime so one optimized graph
1608/// can serve many concurrent sessions (weights shared, session state
1609/// `SessionId`-keyed, activations per-realize scratch).
1610///
1611/// The class is **inferred from the op** ([`infer_storage_class`]) with an
1612/// **explicit override** recorded in the graph's `storage_class` side-table
1613/// — e.g. KV-cache placeholder `Op::Const`s, which are session state, not
1614/// shared weights.
1615///
1616/// Phase D substrate (PR-D1): the classification is recorded but not yet
1617/// consumed by realize. Session keying (D2) and the persistent decode graph
1618/// (D3–D5) build on it.
1619#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1620pub enum StorageClass {
1621    /// Weights / constants: one storage for the whole model, identical
1622    /// across all sessions; keyed by `NodeId`. The op-inferred default for
1623    /// `Op::Const`.
1624    Shared,
1625    /// Per-session durable buffers (KV-caches + explicit cache-write
1626    /// targets): one storage per session, keyed by `(NodeId, SessionId)`.
1627    /// The op-inferred default for the in-place cache-write ops
1628    /// (`Op::WriteSlice` / `Op::WriteSliceRotating`); KV-cache placeholder
1629    /// `Op::Const`s take it via an explicit override.
1630    SessionState,
1631    /// Activations / scratch: ephemeral, allocated per realize and freed
1632    /// after use; never persisted. The op-inferred default for everything
1633    /// else. A transient value may cross devices mid-realize (a D2D
1634    /// `Op::Copy`) but never crosses to disk.
1635    Transient,
1636}
1637
1638/// The op-inferred storage class for a node, **before** any explicit
1639/// override (see [`StorageClass`] and [`Graph::storage_class`]):
1640/// - `Op::Const` → [`StorageClass::Shared`] (weights);
1641/// - `Op::WriteSlice` / `Op::WriteSliceRotating` → [`StorageClass::SessionState`]
1642///   (they write into a per-session durable buffer);
1643/// - everything else → [`StorageClass::Transient`].
1644pub fn infer_storage_class(op: &Op) -> StorageClass {
1645    match op {
1646        Op::Const => StorageClass::Shared,
1647        Op::WriteSlice { .. } | Op::WriteSliceRotating { .. } | Op::WriteSliceDoff { .. } => {
1648            StorageClass::SessionState
1649        }
1650        _ => StorageClass::Transient,
1651    }
1652}
1653
1654/// The graph arena. Stores every node added during a computation-building
1655/// session. Nodes are append-only and indexed by [`NodeId`].
1656///
1657/// Placement metadata (`DeviceLocation` per node) lives in a side-table
1658/// so existing `Node { ... }` construction sites don't need to be
1659/// modified. This is the Phase-1 shape: placements are opt-in, inert
1660/// hints that the executor may validate but does not yet act on.
1661/// `Default` is implemented **manually** (not derived) so that every graph —
1662/// however it is created — mints a fresh [`GraphId`] from the monotonic
1663/// counter. A derived `Default` would hand out a zeroed id, so two
1664/// `Graph::default()` graphs would claim the same identity and affinity
1665/// diagnostics would report nonsense.
1666#[derive(Debug)]
1667pub struct Graph {
1668    nodes: Vec<Node>,
1669    /// Sparse map of per-node placement hints. Entries are only present
1670    /// when explicitly set via [`Graph::set_placement`]. Absent entries
1671    /// mean "inherit from the executor's default device."
1672    placements: HashMap<NodeId, DeviceLocation>,
1673    /// NodeIds the executor must schedule even when their outputs are
1674    /// unreachable from the user's roots. Populated by graph-mutating
1675    /// rules that emit side-effecting ops (e.g. `Op::Release` for
1676    /// residency eviction). See [`Graph::add_side_effect_root`].
1677    side_effect_roots: Vec<NodeId>,
1678    /// Phase 7.5 work item G: graph-owned realized-storage map.
1679    /// Slots are populated for `Op::Const` leaves at construction
1680    /// time and for non-leaf nodes at realize time. Values are
1681    /// `Arc<RwLock<Storage>>` so consumers (NodeHandle handles, executor
1682    /// caches, residency machinery) share Storage cheaply via Arc
1683    /// clones. Lifetime is tied to the Graph; when the graph drops,
1684    /// any slots not held by external Arc clones are freed.
1685    storage_map: HashMap<NodeId, Arc<RwLock<Storage>>>,
1686    /// Phase 7.5 storage-unification B2: per-node dispatch
1687    /// resolution result. Set by the dispatch resolver (Phase B3+)
1688    /// when DAG construction picks which backend will execute the
1689    /// node's op given its inputs' dtype + residency. Sparse like
1690    /// `placements`: absent entries mean "not yet resolved" and the
1691    /// executor falls back to per-op-eval dispatch (today's
1692    /// behavior). After full migration, every Node has an entry.
1693    ///
1694    /// Note: this is a *side-table*, not a `Node` field, so that
1695    /// existing `Node { ... }` constructors don't need to change
1696    /// during the migration. Mirrors the `placements` pattern.
1697    target_backends: HashMap<NodeId, BackendId>,
1698    /// Phase 7.5 storage-unification — Layout-on-Node side-table.
1699    /// Sparse: every Node has a logical [`Layout`], but for the
1700    /// common case (contiguous result of a kernel) we don't store
1701    /// it explicitly — [`Graph::layout`] returns
1702    /// `Layout::contiguous(node.shape)` as the fallback. Entries
1703    /// are written here exclusively by metadata-only view ops
1704    /// (`Op::Transpose`, `Op::Permute`, `Op::BroadcastTo`,
1705    /// 0-copy `Op::Reshape`) where the output is a strided view
1706    /// over an upstream node's Storage.
1707    ///
1708    /// Coherence rule: if `layouts[id]` is present, its
1709    /// `shape()` must equal `nodes[id].shape` — Layout adds
1710    /// strides + offset; the visible shape is identical.
1711    layouts: HashMap<NodeId, Layout>,
1712    /// Per-node multi-output side-table. Populated only for nodes
1713    /// whose op produces a bundled [`Storage`] with N>1 logical
1714    /// outputs (Session 1 of the multi-output-nodes design — see
1715    /// [`OutputView`](fuel_ir::storage::OutputView)). `Op::View`
1716    /// and `Op::ViewOwned` builders read this side-table to derive
1717    /// their own output shape/dtype/layout from the producer's
1718    /// declared slot specs.
1719    ///
1720    /// Coherence rule: when present, slot 0's `dtype` must equal the
1721    /// producer node's `Node::dtype` (which the multi-output op's
1722    /// allocator also uses as the inner backend storage's dtype).
1723    /// The producer's `Node::shape` reflects slot 0's logical shape
1724    /// for back-compat with single-output infrastructure; non-primary
1725    /// slots may have unrelated shapes and dtypes.
1726    node_output_views: HashMap<NodeId, Arc<[fuel_ir::storage::OutputView]>>,
1727    /// Per-node **storage-class override** ([`StorageClass`]). Sparse:
1728    /// entries are present only where the class differs from the op-inferred
1729    /// default ([`infer_storage_class`]) — chiefly KV-cache placeholder
1730    /// `Op::Const`s, which are session state, not shared weights.
1731    /// [`Graph::storage_class`] returns the override if present, else the
1732    /// inferred default, so every node has a well-defined class.
1733    /// Phase D substrate (PR-D1); not yet consumed by realize.
1734    storage_class: HashMap<NodeId, StorageClass>,
1735    /// Per-node **data-determined matmul row count** — the sparse-MoE
1736    /// capacity-buffer support. Present only on `Op::MatMul` nodes whose
1737    /// M (row count) is a runtime value: the node's `Node::shape` carries
1738    /// the row **capacity**, and this side-table carries the [`DynScalar`]
1739    /// that resolves (at compile if input-determined, else at execute from
1740    /// the producer-bound `SymEnv`) to how many rows to actually compute.
1741    /// Absent ⇒ a normal, static-M matmul. A side-table (not an `Op::MatMul`
1742    /// field) so the dense-matmul enum + its every match arm stay untouched.
1743    node_matmul_row_count: HashMap<NodeId, DynScalar>,
1744    /// Process-unique identity for this graph, minted at construction.
1745    ///
1746    /// Tensors are **graph-affine**: every binary op requires both operands to
1747    /// live on the same `Graph`, checked by `Arc::ptr_eq`. Pointer identity is
1748    /// the right *check* but a useless thing to *report* — so each graph also
1749    /// carries a small monotonic id, letting an affinity failure say "lhs is on
1750    /// graph #1, rhs on graph #2" instead of only that the two differ. That
1751    /// turns an abstract invariant into an observation the caller can act on:
1752    /// *you made two graphs.*
1753    id: GraphId,
1754}
1755
1756/// Process-unique identifier for a [`Graph`], minted monotonically by
1757/// [`Graph::new`]. Exists so affinity diagnostics can name *which* graphs
1758/// disagree; it is not a stable address, an index, or anything to persist.
1759#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
1760pub struct GraphId(u64);
1761
1762impl std::fmt::Display for GraphId {
1763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1764        write!(f, "{}", self.0)
1765    }
1766}
1767
1768/// Monotonic source for [`GraphId`]. Starts at 1 so `#0` never appears and a
1769/// zeroed/defaulted id is visibly wrong.
1770static NEXT_GRAPH_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1771
1772impl Default for Graph {
1773    fn default() -> Self {
1774        Self::new()
1775    }
1776}
1777
1778impl Graph {
1779    /// Create an empty graph.
1780    pub fn new() -> Self {
1781        Self {
1782            id: GraphId(NEXT_GRAPH_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)),
1783            nodes: Vec::new(),
1784            placements: HashMap::new(),
1785            side_effect_roots: Vec::new(),
1786            storage_map: HashMap::new(),
1787            target_backends: HashMap::new(),
1788            layouts: HashMap::new(),
1789            node_output_views: HashMap::new(),
1790            storage_class: HashMap::new(),
1791            node_matmul_row_count: HashMap::new(),
1792        }
1793    }
1794
1795    /// This graph's process-unique [`GraphId`]. Used by affinity diagnostics to
1796    /// name *which* graphs disagree; see [`NodeHandle::graph_id`].
1797    pub fn id(&self) -> GraphId {
1798        self.id
1799    }
1800
1801    /// Look up the resolved target backend for a node, if any has
1802    /// been set. Returns `None` if dispatch resolution hasn't
1803    /// happened yet for this node — common during the migration
1804    /// because old op-builder paths don't call the resolver.
1805    ///
1806    /// Phase 7.5 B2.
1807    pub fn target_backend(&self, id: NodeId) -> Option<BackendId> {
1808        self.target_backends.get(&id).copied()
1809    }
1810
1811    /// Record the resolved target backend for `id`. Set once at DAG
1812    /// construction time (or first-realize time, depending on the
1813    /// dispatch policy chosen in Phase B3). Idempotent: calling
1814    /// twice with the same value is fine; calling twice with
1815    /// different values overwrites.
1816    ///
1817    /// Phase 7.5 B2.
1818    pub fn set_target_backend(&mut self, id: NodeId, backend: BackendId) {
1819        assert!(
1820            id.0 < self.nodes.len(),
1821            "set_target_backend: id out of bounds",
1822        );
1823        self.target_backends.insert(id, backend);
1824    }
1825
1826    /// Number of nodes with a resolved target backend. Mostly for
1827    /// tests and diagnostics.
1828    pub fn target_backend_count(&self) -> usize {
1829        self.target_backends.len()
1830    }
1831
1832    /// The storage class of `id`: the explicit override if one was set via
1833    /// [`Graph::set_storage_class`], else the op-inferred default
1834    /// ([`infer_storage_class`]). Every node has a well-defined class.
1835    ///
1836    /// Phase D substrate (PR-D1).
1837    pub fn storage_class(&self, id: NodeId) -> StorageClass {
1838        if let Some(c) = self.storage_class.get(&id).copied() {
1839            return c;
1840        }
1841        infer_storage_class(&self.nodes[id.0].op)
1842    }
1843
1844    /// Record an explicit storage-class override for `id`, replacing the
1845    /// op-inferred default. Used for nodes whose op does not determine the
1846    /// class — chiefly KV-cache placeholder `Op::Const`s, which are session
1847    /// state rather than shared weights.
1848    ///
1849    /// Phase D substrate (PR-D1).
1850    pub fn set_storage_class(&mut self, id: NodeId, class: StorageClass) {
1851        assert!(
1852            id.0 < self.nodes.len(),
1853            "set_storage_class: id out of bounds",
1854        );
1855        self.storage_class.insert(id, class);
1856    }
1857
1858    /// Whether `id` carries an explicit storage-class override (as opposed
1859    /// to relying on the op-inferred default).
1860    pub fn has_storage_class_override(&self, id: NodeId) -> bool {
1861        self.storage_class.contains_key(&id)
1862    }
1863
1864    /// Number of explicit storage-class overrides. Mostly for tests and
1865    /// diagnostics.
1866    pub fn storage_class_override_count(&self) -> usize {
1867        self.storage_class.len()
1868    }
1869
1870    /// The [`Layout`] associated with `id`. If the side-table has
1871    /// no explicit entry, returns `Layout::contiguous(shape)` —
1872    /// every Node logically has a Layout; the side-table is just
1873    /// the sparse storage for the strided / offset cases.
1874    ///
1875    /// Phase 7.5 storage-unification: metadata-only view ops
1876    /// (`Op::Transpose`, `Op::Permute`, `Op::BroadcastTo`,
1877    /// 0-copy `Op::Reshape`) populate this side-table during
1878    /// graph construction. Kernel ops produce contiguous results
1879    /// and rely on the fallback.
1880    pub fn layout(&self, id: NodeId) -> Layout {
1881        if let Some(l) = self.layouts.get(&id) {
1882            return l.clone();
1883        }
1884        Layout::contiguous(self.node(id).shape.clone())
1885    }
1886
1887    /// Record an explicit [`Layout`] for `id`. The Layout's
1888    /// `shape()` must equal `nodes[id].shape` — otherwise the
1889    /// graph becomes incoherent (downstream ops were validated
1890    /// against `node.shape`). Panics in `debug_assertions` mode
1891    /// on mismatch; in release the mismatch is silently kept
1892    /// because the alternative would require this method to
1893    /// return `Result`, which would break call ergonomics in
1894    /// graph-construction code.
1895    ///
1896    /// Set this exactly when an op produces a strided / offset
1897    /// view over upstream Storage. For the common case
1898    /// (contiguous kernel output), don't call this at all —
1899    /// [`Graph::layout`] returns `Layout::contiguous(shape)`
1900    /// as the implicit fallback.
1901    pub fn set_layout(&mut self, id: NodeId, layout: Layout) {
1902        assert!(id.0 < self.nodes.len(), "set_layout: id out of bounds");
1903        debug_assert_eq!(
1904            layout.shape(),
1905            &self.nodes[id.0].shape,
1906            "set_layout: Layout shape disagrees with Node shape \
1907             (Layout sets strides/offset, not the visible shape)",
1908        );
1909        self.layouts.insert(id, layout);
1910    }
1911
1912    /// Whether `id` has an explicit Layout entry in the side-table
1913    /// (i.e. it's a strided / offset view; not the default
1914    /// contiguous case).
1915    pub fn has_explicit_layout(&self, id: NodeId) -> bool {
1916        self.layouts.contains_key(&id)
1917    }
1918
1919    /// Number of nodes with an explicit (non-contiguous) Layout.
1920    /// Mostly for tests and diagnostics.
1921    pub fn explicit_layout_count(&self) -> usize {
1922        self.layouts.len()
1923    }
1924
1925    // -------------------------------------------------------------------
1926    // Multi-output side-table (Op::View / Op::ViewOwned)
1927    // -------------------------------------------------------------------
1928
1929    /// Borrow the per-slot output-view specs for `id`, if it's been
1930    /// declared as a multi-output producer. Returns `None` for
1931    /// single-output nodes (the common case).
1932    ///
1933    /// `Op::View` and `Op::ViewOwned` builders read this to derive
1934    /// the slot's shape/dtype/layout at graph-build time.
1935    pub fn output_views(&self, id: NodeId) -> Option<&[fuel_ir::storage::OutputView]> {
1936        self.node_output_views.get(&id).map(|a| a.as_ref())
1937    }
1938
1939    /// Clone the `Arc` handle to `id`'s per-slot output-view specs.
1940    /// Used by the realization path to attach the same bundle metadata
1941    /// to the realized [`Storage`] without copying the slice.
1942    pub fn output_views_arc(&self, id: NodeId) -> Option<Arc<[fuel_ir::storage::OutputView]>> {
1943        self.node_output_views.get(&id).cloned()
1944    }
1945
1946    /// Declare `id` as a multi-output producer with the given per-slot
1947    /// specs. Validates at graph-build time:
1948    /// 1. `id` is in bounds.
1949    /// 2. `views` is non-empty (a "multi-output" node with zero slots
1950    ///    is a contract bug — use single-output if there's nothing to
1951    ///    project).
1952    /// 3. Slot 0's dtype equals `nodes[id].dtype` (the bundle's
1953    ///    primary dtype convention).
1954    /// 4. Slot 0's shape equals `nodes[id].shape` (slot 0 is the
1955    ///    "primary" slot whose shape the single-output infrastructure
1956    ///    already sees).
1957    /// 5. Each slot's `layout.shape()` equals the slot's `shape`
1958    ///    (Layout adds strides/offset; the visible shape is identical).
1959    ///
1960    /// Idempotent on (id, views). Replacing an existing declaration
1961    /// asserts the new specs are byte-identical to the old in debug
1962    /// builds; in release the last write wins (matches the layouts
1963    /// side-table pattern).
1964    pub fn set_output_views(
1965        &mut self,
1966        id: NodeId,
1967        views: Arc<[fuel_ir::storage::OutputView]>,
1968    ) -> Result<(), fuel_ir::Error> {
1969        if id.0 >= self.nodes.len() {
1970            return Err(fuel_ir::Error::Msg(format!(
1971                "set_output_views: NodeId({}) out of bounds (len={})",
1972                id.0,
1973                self.nodes.len(),
1974            ))
1975            .bt());
1976        }
1977        if views.is_empty() {
1978            return Err(fuel_ir::Error::Msg(
1979                "set_output_views: slot list must be non-empty".into(),
1980            )
1981            .bt());
1982        }
1983        let node = &self.nodes[id.0];
1984        let slot0 = &views[0];
1985        if slot0.dtype != node.dtype {
1986            return Err(fuel_ir::Error::Msg(format!(
1987                "set_output_views: slot 0 dtype {:?} disagrees with \
1988                 Node::dtype {:?} on Node#{} (slot 0 is the primary; \
1989                 dtypes must match)",
1990                slot0.dtype, node.dtype, id.0,
1991            ))
1992            .bt());
1993        }
1994        if slot0.shape != node.shape {
1995            return Err(fuel_ir::Error::Msg(format!(
1996                "set_output_views: slot 0 shape {:?} disagrees with \
1997                 Node::shape {:?} on Node#{} (slot 0 is the primary)",
1998                slot0.shape, node.shape, id.0,
1999            ))
2000            .bt());
2001        }
2002        for (i, v) in views.iter().enumerate() {
2003            if v.layout.shape() != &v.shape {
2004                return Err(fuel_ir::Error::Msg(format!(
2005                    "set_output_views: slot {i} layout.shape() = {:?} \
2006                     disagrees with declared slot shape {:?}",
2007                    v.layout.shape(),
2008                    v.shape,
2009                ))
2010                .bt());
2011            }
2012        }
2013        self.node_output_views.insert(id, views);
2014        Ok(())
2015    }
2016
2017    /// Whether `id` has been declared as a multi-output producer.
2018    pub fn is_multi_output(&self, id: NodeId) -> bool {
2019        self.node_output_views.contains_key(&id)
2020    }
2021
2022    /// Number of nodes declared as multi-output producers. Mostly for
2023    /// tests and diagnostics.
2024    pub fn multi_output_count(&self) -> usize {
2025        self.node_output_views.len()
2026    }
2027
2028    /// Whether some node in this graph *produces* (binds mid-realize) the
2029    /// runtime value `sym` — i.e. `sym` is **data-determined**, computed by
2030    /// an op during the pass rather than supplied up front. Used to tell a
2031    /// data-determined `DynScalar::Sym` (which is legitimately unbound in
2032    /// the compile-time `SymEnv` and must be resolved at *execute* time)
2033    /// apart from a caller error (a truly-unbound input-determined sym,
2034    /// which must surface as an error at realize). Today the only producer
2035    /// of a data-determined symbol is [`Op::NonZeroIndices`] (its
2036    /// `count_sym`); extend this match as more data-determined producers
2037    /// land.
2038    pub fn binds_data_determined_sym(&self, sym: SymId) -> bool {
2039        self.nodes.iter().any(|n| match &n.op {
2040            Op::NonZeroIndices { count_sym } => *count_sym == sym,
2041            _ => false,
2042        })
2043    }
2044
2045    /// The next data-determined [`SymId`] not yet used by any producer in
2046    /// this graph — one past the max `Op::NonZeroIndices` `count_sym`, or
2047    /// `SymId(0)` if none exist. Lets a builder that emits several
2048    /// data-determined producers (e.g. one `NonZeroIndices` per MoE expert,
2049    /// across several stacked layers on the same graph) allocate
2050    /// non-colliding count syms without threading an external
2051    /// [`fuel_ir::SymGen`]: each call reflects the producers already added,
2052    /// so successive layers claim strictly higher ranges.
2053    ///
2054    /// This covers the data-determined space (the only producers of which
2055    /// are `NonZeroIndices` nodes). A graph that *also* mixes
2056    /// input-determined symbolic extents in the same low sym range should
2057    /// thread a shared `SymGen` instead — those live in a separate
2058    /// compile-time `SymEnv` this scan does not see.
2059    pub fn next_data_determined_sym(&self) -> SymId {
2060        let max = self
2061            .nodes
2062            .iter()
2063            .filter_map(|n| match &n.op {
2064                Op::NonZeroIndices { count_sym } => Some(count_sym.0),
2065                _ => None,
2066            })
2067            .max();
2068        match max {
2069            Some(m) => SymId(m + 1),
2070            None => SymId(0),
2071        }
2072    }
2073
2074    /// Attach a data-determined row count to an `Op::MatMul` node — the
2075    /// sparse-MoE capacity-buffer support. The node's `Node::shape` stays
2076    /// the row-capacity output shape; `row_count` (a [`DynScalar`]) is how
2077    /// many rows to actually compute. See [`NodeHandle::matmul_dyn_m`].
2078    pub fn set_matmul_row_count(&mut self, id: NodeId, row_count: DynScalar) {
2079        assert!(
2080            id.0 < self.nodes.len(),
2081            "set_matmul_row_count: id out of bounds"
2082        );
2083        self.node_matmul_row_count.insert(id, row_count);
2084    }
2085
2086    /// The data-determined row count for `id`, if it carries one (i.e. it
2087    /// is a dynamic-M matmul). `None` ⇒ an ordinary static-M matmul.
2088    pub fn matmul_row_count(&self, id: NodeId) -> Option<DynScalar> {
2089        self.node_matmul_row_count.get(&id).copied()
2090    }
2091
2092    /// Look up the realized storage for a node, if any has been
2093    /// registered. Returns an Arc clone — the slot stays in the map
2094    /// after this call.
2095    pub fn storage_for(&self, id: NodeId) -> Option<Arc<RwLock<Storage>>> {
2096        self.storage_map.get(&id).cloned()
2097    }
2098
2099    /// Register a realized-storage slot for `id`. Replaces any
2100    /// existing entry (callers responsible for ensuring this is the
2101    /// intended semantics — e.g. re-realization after eviction).
2102    pub fn set_storage(&mut self, id: NodeId, storage: Arc<RwLock<Storage>>) {
2103        assert!(id.0 < self.nodes.len(), "set_storage: id out of bounds");
2104        self.storage_map.insert(id, storage);
2105    }
2106
2107    /// Convenience: register an owned `Storage` directly. Wraps in
2108    /// `Arc<RwLock<...>>`.
2109    pub fn set_storage_owned(&mut self, id: NodeId, storage: Storage) {
2110        self.set_storage(id, Arc::new(RwLock::new(storage)));
2111    }
2112
2113    /// Remove a slot. Used by eviction / `Op::Release` paths.
2114    /// Returns the Arc if present so callers that want to keep the
2115    /// bytes alive can hold it; otherwise drops on the floor and any
2116    /// outstanding Arc clones still see the bytes.
2117    pub fn remove_storage(&mut self, id: NodeId) -> Option<Arc<RwLock<Storage>>> {
2118        self.storage_map.remove(&id)
2119    }
2120
2121    /// Whether a slot is currently registered for `id`.
2122    pub fn has_storage(&self, id: NodeId) -> bool {
2123        self.storage_map.contains_key(&id)
2124    }
2125
2126    /// Number of registered storage slots.
2127    pub fn storage_len(&self) -> usize {
2128        self.storage_map.len()
2129    }
2130
2131    /// Register a NodeId as a side-effect root — a node whose output
2132    /// isn't reachable from any user-requested root but whose
2133    /// execution the executor must still schedule. Used for
2134    /// destructive ops (e.g. `Op::Release`) emitted by graph-mutating
2135    /// rules: the rule needs Release to run (freeing device memory),
2136    /// but Release's zero-element marker has no consumer.
2137    ///
2138    /// The executor's realize paths walk the user's roots AND these
2139    /// side-effect roots via a single combined `execution_plan` call.
2140    pub fn add_side_effect_root(&mut self, id: NodeId) {
2141        assert!(
2142            id.0 < self.nodes.len(),
2143            "add_side_effect_root: id out of bounds"
2144        );
2145        if !self.side_effect_roots.contains(&id) {
2146            self.side_effect_roots.push(id);
2147        }
2148    }
2149
2150    /// Snapshot the registered side-effect roots. Executor concatenates
2151    /// these with the user's requested roots before computing the plan.
2152    pub fn side_effect_roots(&self) -> &[NodeId] {
2153        &self.side_effect_roots
2154    }
2155
2156    /// The number of nodes currently in the graph.
2157    pub fn len(&self) -> usize {
2158        self.nodes.len()
2159    }
2160
2161    /// Whether the graph has any nodes.
2162    pub fn is_empty(&self) -> bool {
2163        self.nodes.is_empty()
2164    }
2165
2166    /// Borrow a node by ID. Panics if the ID is out of bounds — it should
2167    /// not be possible for a valid `NodeHandle` handle to produce an invalid
2168    /// ID, so any such panic indicates a bug in graph construction.
2169    pub fn node(&self, id: NodeId) -> &Node {
2170        &self.nodes[id.0]
2171    }
2172
2173    /// Produce a one-line human-readable description of a node and its
2174    /// immediate inputs — the identifier string that executor panics
2175    /// prepend so realize-time failures can be localized to the exact
2176    /// graph position that blew up. Format is stable enough for grep
2177    /// but explicitly intended for humans reading panic output, not for
2178    /// parsing.
2179    pub fn describe_node(&self, id: NodeId) -> String {
2180        let n = self.node(id);
2181        // GAP-303: `short_name()` renders every scan as the bare string "Scan",
2182        // so a body built from the WRONG scan was invisible in every dump and
2183        // every executor panic. Baracuda's requirement, adopted verbatim: make
2184        // something PRINT the carry count on the path that constructs the body —
2185        // not assert it. "A control tells you the encoding is sound; only the
2186        // subject's identity tells you what it encoded."
2187        let scan_params = match n.op {
2188            Op::Scan {
2189                n_carries,
2190                n_xs,
2191                bound,
2192                emit,
2193                ref early_exit,
2194            } => format!(
2195                " n_carries={n_carries} n_xs={n_xs} bound={bound} emit={emit:?} early_exit={}",
2196                early_exit.is_some()
2197            ),
2198            Op::ScanPlaceholder { role, index } => format!(" role={role:?} index={index}"),
2199            _ => String::new(),
2200        };
2201        let op_short = n.op.short_name();
2202        if n.inputs.is_empty() {
2203            return format!(
2204                "Node#{} ({op_short}{scan_params}, out shape={:?} dtype={:?})",
2205                id.0, n.shape, n.dtype,
2206            );
2207        }
2208        let inputs: Vec<String> = n
2209            .inputs
2210            .iter()
2211            .map(|&inp| {
2212                let ni = self.node(inp);
2213                let ip = ni.op.short_name();
2214                format!("Node#{}[{ip}, {:?}, {:?}]", inp.0, ni.shape, ni.dtype)
2215            })
2216            .collect();
2217        format!(
2218            "Node#{} ({op_short}{scan_params}, out shape={:?} dtype={:?}, inputs=[{}])",
2219            id.0,
2220            n.shape,
2221            n.dtype,
2222            inputs.join(", "),
2223        )
2224    }
2225
2226    /// Append a node and return its fresh ID. Used by the `NodeHandle`
2227    /// builders, by `opt` passes that canonicalize or rewrite the
2228    /// graph by appending fresh nodes, and by external consumers
2229    /// (fuel-storage's pipelined executor tests, custom-op authors)
2230    /// that need direct graph construction.
2231    ///
2232    /// For metadata-only view ops (`Op::Transpose`, `Op::Permute`,
2233    /// `Op::BroadcastTo`, `Op::Slice`) this also populates the
2234    /// [`layouts`](Self::layout) side-table by deriving the output
2235    /// layout from the input's layout via
2236    /// [`derive_view_output_layout`]. After this call,
2237    /// `graph.layout(new_id)` returns the strided/offset view layout
2238    /// without any further work — the side-table is the single source
2239    /// of truth for layout, with [`Layout::contiguous`] as the
2240    /// fallback for kernel-output nodes.
2241    ///
2242    /// Misuse (malformed Node, dangling input ids, etc.) is caught
2243    /// at execute time as a typed error, not a panic — per project's
2244    /// no-panic-in-production rule.
2245    pub fn push(&mut self, node: Node) -> NodeId {
2246        let id = NodeId(self.nodes.len());
2247        let is_view = node.op.is_view_op();
2248        let view_input = if is_view {
2249            node.inputs.first().copied()
2250        } else {
2251            None
2252        };
2253        let op_for_derive = if is_view { Some(node.op.clone()) } else { None };
2254        self.nodes.push(node);
2255        if let (Some(input_id), Some(op)) = (view_input, op_for_derive)
2256            && input_id.0 < self.nodes.len()
2257        {
2258            let input_layout = self.layout(input_id);
2259            if let Ok(out_layout) = derive_view_output_layout(&op, &input_layout) {
2260                self.set_layout(id, out_layout);
2261            }
2262        }
2263        id
2264    }
2265
2266    /// Rewrite every occurrence of `old_input` in `node`'s input list to
2267    /// `new_input`. In-place mutation — the *only* exception to the
2268    /// otherwise append-only invariant.
2269    ///
2270    /// Callers are responsible for ensuring `new_input` is semantically
2271    /// equivalent to `old_input` at this edge: same shape, same dtype,
2272    /// and produces data the consumer can read without error on the
2273    /// relevant device. Used by transform passes (residency eviction,
2274    /// fusion) that need to redirect a specific consumer's edge.
2275    pub(crate) fn rewrite_input(&mut self, node: NodeId, old_input: NodeId, new_input: NodeId) {
2276        assert!(
2277            node.0 < self.nodes.len(),
2278            "rewrite_input: node out of bounds"
2279        );
2280        let inputs = &mut self.nodes[node.0].inputs;
2281        for inp in inputs.iter_mut() {
2282            if *inp == old_input {
2283                *inp = new_input;
2284            }
2285        }
2286    }
2287
2288    /// **Collapse a decision point to a single baked arm** — the graph-side
2289    /// half of *optimize-time kernel-variant baking* (04-optimization:
2290    /// "kernel-variant choice is largely baked at optimize time"). Given a
2291    /// finalized [`Op::Branch`] and the arm the optimizer chose (`winner_arm`,
2292    /// an index into the branch's `inputs`), this rewrites the graph so the
2293    /// chosen arm is the **realized route** and the branch is no longer a
2294    /// decision point:
2295    ///
2296    /// 1. The `reconverge_at` merge is rewired to read the **winner's exit**
2297    ///    (`inputs[winner_arm]`) in place of arm 0's exit (`inputs[0]`). For
2298    ///    `winner_arm == 0` this is a no-op (the merge already reads arm 0,
2299    ///    the runnability fallback).
2300    /// 2. The branch's `inputs` are reduced to `[winner_exit]` — a **single-arm
2301    ///    branch**, which [`run::extract_runs_multi`] / `non_chosen_arm_nodes` /
2302    ///    the route picker all treat as *no decision* (`arms.len() < 2`), so the
2303    ///    winner's region lowers inline and the non-chosen arms become
2304    ///    orphaned (pruned from the reachable set — nothing reads them once the
2305    ///    merge is rewired). The arm-0 runnability invariant is preserved:
2306    ///    after the reduce, `inputs[0]` is the winner and the merge reads it.
2307    ///
2308    /// This is the "the Branch collapses" resolution the constitution names for
2309    /// same-device **kernel-variant** choices (decomposed-region vs. a fused
2310    /// kernel on the *same* device), as distinct from **placement** choices
2311    /// (arms on *different* devices) which stay live `Op::Branch` decision
2312    /// points for the runtime route picker (06-runtime). The optimizer decides
2313    /// *which* branches are variant branches and computes `winner_arm`; this
2314    /// method only performs the structural collapse.
2315    ///
2316    /// Never-panic: returns [`fuel_ir::Error::InvalidBranch`] on a
2317    /// non-`Op::Branch` node or an out-of-range `winner_arm`, mutating nothing.
2318    /// Idempotent on an already-collapsed (single-arm) branch when
2319    /// `winner_arm == 0`.
2320    pub fn collapse_variant_branch(
2321        &mut self,
2322        branch: NodeId,
2323        winner_arm: usize,
2324    ) -> std::result::Result<(), fuel_ir::Error> {
2325        let invalid = |reason: String| fuel_ir::Error::InvalidBranch { reason };
2326        if branch.0 >= self.nodes.len() {
2327            return Err(invalid(format!(
2328                "collapse_variant_branch: branch Node#{} is out of bounds \
2329                 (graph has {} nodes)",
2330                branch.0,
2331                self.nodes.len(),
2332            )));
2333        }
2334        let reconverge_at = match self.nodes[branch.0].op {
2335            Op::Branch { reconverge_at } => reconverge_at,
2336            ref other => {
2337                return Err(invalid(format!(
2338                    "collapse_variant_branch: Node#{} is not an Op::Branch (got \
2339                     {other:?})",
2340                    branch.0,
2341                )));
2342            }
2343        };
2344        let arms = &self.nodes[branch.0].inputs;
2345        if arms.is_empty() {
2346            return Err(invalid(format!(
2347                "collapse_variant_branch: branch Node#{} has no arms",
2348                branch.0,
2349            )));
2350        }
2351        if winner_arm >= arms.len() {
2352            return Err(invalid(format!(
2353                "collapse_variant_branch: winner_arm {winner_arm} is out of range \
2354                 (branch Node#{} has {} arms)",
2355                branch.0,
2356                arms.len(),
2357            )));
2358        }
2359        let old_arm0 = arms[0];
2360        let winner = arms[winner_arm];
2361
2362        // (1) Rewire the merge to read the winner's exit (no-op for arm 0).
2363        if winner != old_arm0 {
2364            self.rewrite_input(reconverge_at, old_arm0, winner);
2365        }
2366        // (2) Reduce the branch to the single winning arm (now arm 0). A
2367        //     single-arm branch is inert: the run extractor / picker skip it,
2368        //     so the non-chosen arms are pruned and the winner lowers inline.
2369        self.nodes[branch.0].inputs = vec![winner];
2370        Ok(())
2371    }
2372
2373    /// Tag a node with a target device. The executor will validate
2374    /// (post-Phase-1) that the node's op can be evaluated on that
2375    /// device. In Phase 1 the tag is informational only.
2376    pub fn set_placement(&mut self, id: NodeId, loc: DeviceLocation) {
2377        assert!(id.0 < self.nodes.len(), "set_placement: id out of bounds");
2378        self.placements.insert(id, loc);
2379    }
2380
2381    /// Read a node's placement hint, or `None` if the node inherits from
2382    /// the executor default.
2383    pub fn placement(&self, id: NodeId) -> Option<DeviceLocation> {
2384        self.placements.get(&id).copied()
2385    }
2386
2387    /// Open a [`BranchBuilder`] rooted at a shared `diverge` point — the
2388    /// first half of the `open_branch` / `add_arm` / `finalize_branches`
2389    /// trio that constructs an [`Op::Branch`] (phi/merge) node with
2390    /// build-time validation (Phase A PR-A1 of the "plan IS the graph"
2391    /// rebuild).
2392    ///
2393    /// `diverge` names the single node from which every candidate route
2394    /// (arm) departs; each arm's *exit* (added via
2395    /// [`BranchBuilder::add_arm`]) becomes one `inputs[i]` of the emitted
2396    /// `Op::Branch`. The builder borrows nothing — it is a pure
2397    /// accumulator of `NodeId`s — so the graph stays free to keep building
2398    /// arms between `open_branch` and `finalize_branches`. All validation
2399    /// (descendant `reconverge_at`, internally-disjoint arms,
2400    /// cast-to-uniform shape/dtype, arm-0 runnability) happens in
2401    /// [`BranchBuilder::finalize_branches`], which returns a typed
2402    /// [`fuel_ir::error::Error::InvalidBranch`] rather than panicking.
2403    pub fn open_branch(&self, diverge: NodeId) -> BranchBuilder {
2404        BranchBuilder {
2405            diverge,
2406            arms: Vec::new(),
2407        }
2408    }
2409
2410    /// Forward-reachable set (the node and all its transitive *consumers*)
2411    /// from `from`, computed against a precomputed consumer adjacency.
2412    /// Used by branch validation to test descendant-hood and arm
2413    /// containment without re-scanning the whole arena per query.
2414    fn forward_reachable(
2415        consumers: &HashMap<NodeId, Vec<NodeId>>,
2416        from: NodeId,
2417    ) -> HashSet<NodeId> {
2418        let mut seen: HashSet<NodeId> = HashSet::new();
2419        let mut stack = vec![from];
2420        while let Some(n) = stack.pop() {
2421            if !seen.insert(n) {
2422                continue;
2423            }
2424            if let Some(cs) = consumers.get(&n) {
2425                for &c in cs {
2426                    if !seen.contains(&c) {
2427                        stack.push(c);
2428                    }
2429                }
2430            }
2431        }
2432        seen
2433    }
2434
2435    /// Backward-reachable set (the node and all its transitive *inputs*)
2436    /// from `from`. Used to bound an arm to the nodes that actually lie
2437    /// on a path `diverge → exit`.
2438    fn backward_reachable(&self, from: NodeId) -> HashSet<NodeId> {
2439        let mut seen: HashSet<NodeId> = HashSet::new();
2440        let mut stack = vec![from];
2441        while let Some(n) = stack.pop() {
2442            if !seen.insert(n) {
2443                continue;
2444            }
2445            for &inp in &self.nodes[n.0].inputs {
2446                if !seen.contains(&inp) {
2447                    stack.push(inp);
2448                }
2449            }
2450        }
2451        seen
2452    }
2453
2454    // -------------------------------------------------------------------
2455    // Compaction of the append-only arena (PR-B4)
2456    // -------------------------------------------------------------------
2457
2458    /// The set of nodes that constitute the **live structure** of the
2459    /// graph relative to `roots`: everything reachable, following node
2460    /// `inputs`, from the union of
2461    ///
2462    /// - the caller's `roots`,
2463    /// - the `side_effect_roots` (nodes the executor must schedule even
2464    ///   when no output reads them — e.g. `Op::Release` eviction), and
2465    /// - the full multi-path structure: for every reachable `Op::Branch`,
2466    ///   its `reconverge_at` and **all its arms** (the arm exits, which
2467    ///   are the Branch node's `inputs`, and transitively their interiors).
2468    ///   Phase C's route picker needs every arm, so the alternative arms
2469    ///   must survive even though nothing downstream reads them directly
2470    ///   (the `reconverge_at` node reads only arm 0).
2471    ///
2472    /// A finalized `Op::Branch` is itself typically orphaned — nothing
2473    /// downstream reads it (PR-A1's runnability invariant has the merge
2474    /// read arm 0 directly, not the Branch). So a plain forward walk from
2475    /// `roots` would miss it. We therefore mirror [`run::extract_runs_multi`]'s
2476    /// `effective_roots` discipline: scan the arena and seed any Branch
2477    /// whose `reconverge_at` (or any arm exit) is already reachable, to a
2478    /// fixpoint, then take the input-closure of all seeds. The result is
2479    /// the exact set compaction must keep.
2480    fn live_set(&self, roots: &[NodeId]) -> HashSet<NodeId> {
2481        // Seeds: roots + side-effect roots. We grow this set with the
2482        // Branch nodes that participate in the reachable computation, then
2483        // take its full input-closure.
2484        let mut seeds: Vec<NodeId> = Vec::new();
2485        let mut seed_set: HashSet<NodeId> = HashSet::new();
2486        let push_seed = |id: NodeId, seeds: &mut Vec<NodeId>, seed_set: &mut HashSet<NodeId>| {
2487            if id.0 < self.nodes.len() && seed_set.insert(id) {
2488                seeds.push(id);
2489            }
2490        };
2491        for &r in roots {
2492            push_seed(r, &mut seeds, &mut seed_set);
2493        }
2494        for &r in &self.side_effect_roots {
2495            push_seed(r, &mut seeds, &mut seed_set);
2496        }
2497
2498        // Grow the seed set with participating Branch nodes to a fixpoint.
2499        // A Branch "participates" when its merge target or any arm exit is
2500        // already reachable from the current seeds (following inputs). Each
2501        // newly-seeded Branch may pull in further arms whose interiors then
2502        // make another Branch participate, hence the fixpoint.
2503        loop {
2504            let reachable = self.input_closure(&seeds);
2505            let mut added = false;
2506            for idx in 0..self.nodes.len() {
2507                let id = NodeId(idx);
2508                if seed_set.contains(&id) {
2509                    continue;
2510                }
2511                let Op::Branch { reconverge_at } = self.nodes[idx].op else {
2512                    continue;
2513                };
2514                let participates = reachable.contains(&reconverge_at)
2515                    || self.nodes[idx].inputs.iter().any(|a| reachable.contains(a));
2516                if participates {
2517                    seed_set.insert(id);
2518                    seeds.push(id);
2519                    added = true;
2520                }
2521            }
2522            if !added {
2523                break;
2524            }
2525        }
2526
2527        // The live set is the full input-closure of the (roots + side-effect
2528        // + participating-Branch) seeds, plus each reachable Branch's
2529        // explicit `reconverge_at` (which is downstream of the arms, so it
2530        // is not pulled in by following inputs from the Branch). The merge
2531        // node is normally reachable from `roots` already, but seeding it
2532        // explicitly makes the live set self-consistent — a Branch never
2533        // outlives its merge target.
2534        let mut live = self.input_closure(&seeds);
2535        let recon_seeds: Vec<NodeId> = live
2536            .iter()
2537            .filter_map(|&id| match self.nodes[id.0].op {
2538                Op::Branch { reconverge_at } => Some(reconverge_at),
2539                _ => None,
2540            })
2541            .collect();
2542        if !recon_seeds.is_empty() {
2543            for n in self.input_closure(&recon_seeds) {
2544                live.insert(n);
2545            }
2546        }
2547        live
2548    }
2549
2550    /// Every node reachable from any of `from`, following node `inputs`
2551    /// transitively (the backward / input cone). Mirrors
2552    /// [`topo_order_multi`] but returns just the set.
2553    fn input_closure(&self, from: &[NodeId]) -> HashSet<NodeId> {
2554        let mut seen: HashSet<NodeId> = HashSet::new();
2555        let mut stack: Vec<NodeId> = from.to_vec();
2556        while let Some(n) = stack.pop() {
2557            if n.0 >= self.nodes.len() || !seen.insert(n) {
2558                continue;
2559            }
2560            for &inp in &self.nodes[n.0].inputs {
2561                if !seen.contains(&inp) {
2562                    stack.push(inp);
2563                }
2564            }
2565        }
2566        seen
2567    }
2568
2569    /// **Required compaction of the append-only arena** (PR-B4).
2570    ///
2571    /// Drops every node *not* in the live structure relative to `roots`
2572    /// (the exploration debris that pathfinders leave) and rebuilds the
2573    /// arena with contiguous, renumbered [`NodeId`]s, remapping **every**
2574    /// `NodeId` reference in the graph. Returns the [`NodeRemap`] from old
2575    /// to new ids so callers can fix up their own `NodeHandle` handles / roots.
2576    ///
2577    /// The live set follows node `inputs` from: `roots`, the
2578    /// `side_effect_roots`, and the full multi-path structure — for every
2579    /// reachable [`Op::Branch`], its `reconverge_at` **and all its arms**
2580    /// (so the route picker's alternatives survive). See [`Graph::live_set`].
2581    ///
2582    /// What is remapped (the correctness crux — a missed remap is silent
2583    /// corruption). Every NodeId-bearing structure in the graph:
2584    /// - each surviving [`Node`]'s `inputs`;
2585    /// - the `NodeId` carried *inside* an op variant — [`Op::Branch`]'s
2586    ///   `reconverge_at` (the only `Op` variant carrying a `NodeId`);
2587    /// - every NodeId-keyed side-table on [`Graph`]: `placements`,
2588    ///   `target_backends`, `layouts`, `storage_map`, `node_output_views`;
2589    /// - the `side_effect_roots` vector.
2590    ///
2591    /// # Not in the per-realize hot path
2592    ///
2593    /// Compaction renumbers `NodeId`s, which would invalidate a realize
2594    /// already built around the current ids (the dispatch order and the
2595    /// `StorageCache` key are `NodeId`). It is therefore a **standalone
2596    /// pass**, deliberately **not** registered as an `Optimizer` in the
2597    /// per-realize driver / `optimize_graph` loop. It runs at load-time
2598    /// **between optimization rounds** (Phase D) and is **required before
2599    /// finalize-to-disk** (Phase E) so the persisted `.fuel` holds the lean
2600    /// result, not exploration debris. Neither caller exists yet.
2601    ///
2602    /// Orphans never change results, so this is a *size* pass, not a
2603    /// *correctness* one — but the remap it performs must be exhaustive or
2604    /// it would itself introduce corruption, which is the property the
2605    /// born-red tests + [`Graph::verify_no_dangling`] guard.
2606    ///
2607    /// This is the implementation behind the free function [`compact`];
2608    /// call that.
2609    fn compact_in_place(&mut self, roots: &[NodeId]) -> NodeRemap {
2610        let live = self.live_set(roots);
2611        let n = self.nodes.len();
2612
2613        // Assign new contiguous ids in ascending old-id order, so the
2614        // relative order of surviving nodes (and hence any topological
2615        // property over them) is preserved.
2616        let mut old_to_new: Vec<Option<NodeId>> = vec![None; n];
2617        let mut next = 0usize;
2618        #[expect(
2619            clippy::needless_range_loop,
2620            reason = "`idx` is an id-space counter: it builds `NodeId(idx)` for the membership check and indexes `old_to_new` by that id; the bound `n` is the old-id count, not an array to enumerate"
2621        )]
2622        for idx in 0..n {
2623            if live.contains(&NodeId(idx)) {
2624                old_to_new[idx] = Some(NodeId(next));
2625                next += 1;
2626            }
2627        }
2628        let remap = NodeRemap { old_to_new };
2629
2630        // Rebuild the node arena, remapping each surviving node's inputs and
2631        // any op-carried NodeId. Surviving nodes' inputs are themselves
2632        // always live (the live set is input-closed), so every `map` here
2633        // resolves.
2634        let mut new_nodes: Vec<Node> = Vec::with_capacity(next);
2635        for idx in 0..n {
2636            if remap.old_to_new[idx].is_none() {
2637                continue;
2638            }
2639            let mut node = self.nodes[idx].clone();
2640            for inp in node.inputs.iter_mut() {
2641                *inp = remap.expect(*inp);
2642            }
2643            if let Op::Branch { reconverge_at } = &mut node.op {
2644                *reconverge_at = remap.expect(*reconverge_at);
2645            }
2646            new_nodes.push(node);
2647        }
2648        self.nodes = new_nodes;
2649
2650        // Remap every NodeId-keyed side-table. Drop entries for dropped
2651        // nodes; rekey survivors to their new id.
2652        self.placements = remap.rekey(std::mem::take(&mut self.placements));
2653        self.target_backends = remap.rekey(std::mem::take(&mut self.target_backends));
2654        self.layouts = remap.rekey(std::mem::take(&mut self.layouts));
2655        self.storage_map = remap.rekey(std::mem::take(&mut self.storage_map));
2656        self.node_output_views = remap.rekey(std::mem::take(&mut self.node_output_views));
2657        self.storage_class = remap.rekey(std::mem::take(&mut self.storage_class));
2658        self.node_matmul_row_count = remap.rekey(std::mem::take(&mut self.node_matmul_row_count));
2659
2660        // Remap the side-effect-roots vector (every entry is live — they
2661        // were reachability seeds — so each maps).
2662        self.side_effect_roots = self
2663            .side_effect_roots
2664            .iter()
2665            .map(|&id| remap.expect(id))
2666            .collect();
2667
2668        debug_assert!(
2669            self.verify_no_dangling().is_ok(),
2670            "compact left a dangling NodeId reference: {:?}",
2671            self.verify_no_dangling().err(),
2672        );
2673        remap
2674    }
2675
2676    /// Verify that **no** `NodeId` reference in the graph points outside
2677    /// the arena — the post-compaction safety net for a missed remap.
2678    /// Checks every node's `inputs`, the op-carried `Op::Branch`
2679    /// `reconverge_at`, every NodeId-keyed side-table, and the
2680    /// `side_effect_roots` vector. Returns the first offending reference as
2681    /// a typed error (never panics); used by [`crate::compact`]'s
2682    /// `debug_assert` and by tests.
2683    pub fn verify_no_dangling(&self) -> std::result::Result<(), fuel_ir::Error> {
2684        let n = self.nodes.len();
2685        let bad = |what: String| {
2686            Err(fuel_ir::Error::Msg(format!(
2687                "dangling NodeId reference: {what} (arena has {n} nodes)",
2688            )))
2689        };
2690        for (idx, node) in self.nodes.iter().enumerate() {
2691            for &inp in &node.inputs {
2692                if inp.0 >= n {
2693                    return bad(format!("Node#{idx} input Node#{}", inp.0));
2694                }
2695            }
2696            if let Op::Branch { reconverge_at } = node.op
2697                && reconverge_at.0 >= n
2698            {
2699                return bad(format!(
2700                    "Node#{idx} Branch.reconverge_at Node#{}",
2701                    reconverge_at.0
2702                ));
2703            }
2704        }
2705        for id in self.placements.keys() {
2706            if id.0 >= n {
2707                return bad(format!("placements key Node#{}", id.0));
2708            }
2709        }
2710        for id in self.target_backends.keys() {
2711            if id.0 >= n {
2712                return bad(format!("target_backends key Node#{}", id.0));
2713            }
2714        }
2715        for id in self.layouts.keys() {
2716            if id.0 >= n {
2717                return bad(format!("layouts key Node#{}", id.0));
2718            }
2719        }
2720        for id in self.storage_map.keys() {
2721            if id.0 >= n {
2722                return bad(format!("storage_map key Node#{}", id.0));
2723            }
2724        }
2725        for id in self.node_output_views.keys() {
2726            if id.0 >= n {
2727                return bad(format!("node_output_views key Node#{}", id.0));
2728            }
2729        }
2730        for id in self.storage_class.keys() {
2731            if id.0 >= n {
2732                return bad(format!("storage_class key Node#{}", id.0));
2733            }
2734        }
2735        for &id in &self.side_effect_roots {
2736            if id.0 >= n {
2737                return bad(format!("side_effect_root Node#{}", id.0));
2738            }
2739        }
2740        Ok(())
2741    }
2742}
2743
2744/// **Required compaction of the append-only arena** (PR-B4).
2745///
2746/// Drops every node *not* in the live structure relative to `roots` (the
2747/// exploration debris that pathfinders leave) and rebuilds the arena with
2748/// contiguous, renumbered [`NodeId`]s, remapping **every** `NodeId`
2749/// reference in the graph. Returns the [`NodeRemap`] from old → new ids so
2750/// callers can fix up their own roots / `NodeHandle` handles.
2751///
2752/// The live set follows node `inputs` from `roots`, the
2753/// `side_effect_roots`, and the full multi-path structure — for every
2754/// reachable [`Op::Branch`], its `reconverge_at` **and all its arms** (so
2755/// Phase C's route picker keeps its alternatives). Everything else is
2756/// dropped.
2757///
2758/// **Not in the per-realize hot path.** Compaction renumbers `NodeId`s,
2759/// which would invalidate a realize built around the current ids (the
2760/// dispatch order + the `StorageCache` key are `NodeId`). It is a
2761/// standalone pass — deliberately **not** registered as an `Optimizer` in
2762/// the per-realize driver / `optimize_graph` loop — run at load-time
2763/// **between optimization rounds** (Phase D) and **required before
2764/// finalize-to-disk** (Phase E). Neither caller exists yet.
2765///
2766/// See [`Graph::verify_no_dangling`] for the post-condition the
2767/// `debug_assert` checks: no `NodeId` reference (node inputs, the
2768/// op-carried `Op::Branch.reconverge_at`, any side-table, or
2769/// `side_effect_roots`) points outside the new arena.
2770pub fn compact(graph: &mut Graph, roots: &[NodeId]) -> NodeRemap {
2771    graph.compact_in_place(roots)
2772}
2773
2774/// The old → new `NodeId` mapping produced by [`compact`].
2775///
2776/// Indexed by the *old* `NodeId`: `old_to_new[old.0]` is `Some(new)` if the
2777/// node survived compaction, or `None` if it was dropped as unreachable
2778/// debris. Callers use [`NodeRemap::get`] to translate roots / `NodeHandle`
2779/// ids they still hold; passes that know a node must have survived use
2780/// `NodeRemap::expect`.
2781#[derive(Debug, Clone)]
2782pub struct NodeRemap {
2783    old_to_new: Vec<Option<NodeId>>,
2784}
2785
2786impl NodeRemap {
2787    /// The new id for `old`, or `None` if `old` was dropped (or is out of
2788    /// range of the pre-compaction arena).
2789    pub fn get(&self, old: NodeId) -> Option<NodeId> {
2790        self.old_to_new.get(old.0).copied().flatten()
2791    }
2792
2793    /// Whether `old` survived compaction.
2794    pub fn survived(&self, old: NodeId) -> bool {
2795        self.get(old).is_some()
2796    }
2797
2798    /// The new id for `old`, asserting it survived. Used internally when
2799    /// remapping a reference that is known-live (a surviving node's input,
2800    /// a reachable Branch's `reconverge_at`, a side-effect root) — a `None`
2801    /// here would mean the live-set computation was unsound.
2802    fn expect(&self, old: NodeId) -> NodeId {
2803        self.get(old).unwrap_or_else(|| {
2804            panic!(
2805                "NodeRemap::expect: Node#{} was expected to survive compaction \
2806                 but was dropped — the live set is unsound (a referenced node \
2807                 was not kept)",
2808                old.0,
2809            )
2810        })
2811    }
2812
2813    /// Rekey a `NodeId`-keyed side-table: drop entries whose key was
2814    /// dropped, rewrite surviving keys to their new id. Values are moved,
2815    /// not cloned.
2816    fn rekey<V>(&self, table: HashMap<NodeId, V>) -> HashMap<NodeId, V> {
2817        let mut out = HashMap::with_capacity(table.len());
2818        for (old, v) in table {
2819            if let Some(new) = self.get(old) {
2820                out.insert(new, v);
2821            }
2822        }
2823        out
2824    }
2825}
2826
2827/// Accumulates the arms of an in-construction [`Op::Branch`] (phi/merge)
2828/// node before [`finalize_branches`](Self::finalize_branches) validates
2829/// them and emits the node. Created by [`Graph::open_branch`].
2830///
2831/// The multi-path structure is an **arena fact, not an overlay**: a
2832/// `Branch` node's divergent arms are its `inputs` (each input is one
2833/// route's *exit*), and the op carries an explicit `reconverge_at`. The
2834/// builder is a pure `NodeId` accumulator — it holds no graph reference —
2835/// so the graph remains free to keep appending nodes (further arms) while
2836/// the builder is open. Nothing mutates the arena until
2837/// `finalize_branches`, which is the single validation gate.
2838#[derive(Debug, Clone)]
2839pub struct BranchBuilder {
2840    /// The shared node every arm departs from.
2841    diverge: NodeId,
2842    /// Each arm's *exit* node, in priority order. `arms[0]` is the
2843    /// runnability fallback (arm 0): the route a finalized-but-unpicked
2844    /// graph realizes on.
2845    arms: Vec<NodeId>,
2846}
2847
2848impl BranchBuilder {
2849    /// Append one arm by its *exit* node — the node whose value the merge
2850    /// reads for this route. The first arm added is **arm 0**, the
2851    /// runnability fallback: `reconverge_at` must read arm 0's exit so a
2852    /// finalized-but-not-yet-route-picked graph still realizes (validated
2853    /// in [`finalize_branches`](Self::finalize_branches)).
2854    ///
2855    /// Returns `&mut Self` for chaining. Validation is deferred to
2856    /// finalize — `add_arm` never inspects the graph and never fails.
2857    pub fn add_arm(&mut self, exit: NodeId) -> &mut Self {
2858        self.arms.push(exit);
2859        self
2860    }
2861
2862    /// The shared diverge point this branch departs from.
2863    pub fn diverge(&self) -> NodeId {
2864        self.diverge
2865    }
2866
2867    /// The arm exits accumulated so far, in add order (arm 0 first).
2868    pub fn arms(&self) -> &[NodeId] {
2869        &self.arms
2870    }
2871
2872    /// Validate and emit the [`Op::Branch`] node, returning its fresh
2873    /// `NodeId`. This is the single build-time gate; it **never panics**,
2874    /// surfacing every rejection as [`fuel_ir::error::Error::InvalidBranch`].
2875    ///
2876    /// Returns:
2877    /// - `Ok(Some(branch_id))` — a multi-arm branch was emitted.
2878    /// - `Ok(None)` — a **single-arm branch collapsed** to that arm (no
2879    ///   real decision point), leaving the arena untouched.
2880    /// - `Err(Error::InvalidBranch { .. })` — a validation rule failed.
2881    ///
2882    /// Validation rules (all checked before any mutation):
2883    /// 1. At least one arm; `diverge`, `reconverge_at`, and every arm
2884    ///    exit are in-bounds.
2885    /// 2. **Descendant `reconverge_at`** — the merge must be a forward
2886    ///    descendant of (and distinct from) `diverge`.
2887    /// 3. **Internally-disjoint arms** — each arm's interior nodes (the
2888    ///    nodes on paths `diverge → exit`, excluding the shared `diverge`)
2889    ///    must neither be shared with another arm nor read by any node
2890    ///    outside the branch (`reconverge_at` reading an arm exit is the
2891    ///    one allowed external consumer).
2892    /// 4. **Cast-to-uniform** — all arm exits agree on shape & dtype.
2893    ///    PR-A1 *validates* uniformity and errors on mismatch (it does not
2894    ///    insert `Cast` nodes); dtype-lowered alternatives must therefore
2895    ///    cast back to the merge dtype *inside* their arm before the exit.
2896    /// 5. **Arm-0 runnability** — `reconverge_at` must read arm 0's exit,
2897    ///    so a finalized-but-unpicked graph still realizes on arm 0.
2898    pub fn finalize_branches(
2899        self,
2900        graph: &mut Graph,
2901        reconverge_at: NodeId,
2902    ) -> std::result::Result<Option<NodeId>, fuel_ir::Error> {
2903        let n = graph.nodes.len();
2904        let invalid = |reason: String| fuel_ir::Error::InvalidBranch { reason };
2905
2906        // (1a) At least one arm.
2907        if self.arms.is_empty() {
2908            return Err(invalid(
2909                "branch has no arms; at least one route is required".to_string(),
2910            ));
2911        }
2912        // (1b) Bounds-check every referenced node up front so later
2913        // arena indexing cannot panic.
2914        let in_bounds = |id: NodeId| id.0 < n;
2915        if !in_bounds(self.diverge) {
2916            return Err(invalid(format!(
2917                "diverge Node#{} is out of bounds (graph has {n} nodes)",
2918                self.diverge.0,
2919            )));
2920        }
2921        if !in_bounds(reconverge_at) {
2922            return Err(invalid(format!(
2923                "reconverge_at Node#{} is out of bounds (graph has {n} nodes)",
2924                reconverge_at.0,
2925            )));
2926        }
2927        for &arm in &self.arms {
2928            if !in_bounds(arm) {
2929                return Err(invalid(format!(
2930                    "arm exit Node#{} is out of bounds (graph has {n} nodes)",
2931                    arm.0,
2932                )));
2933            }
2934        }
2935
2936        // (d) Single-arm branches collapse back to that arm — no real
2937        // decision point, so no Op::Branch is emitted and the arena is
2938        // left untouched.
2939        if self.arms.len() == 1 {
2940            return Ok(None);
2941        }
2942
2943        // Build a consumer (reverse) adjacency once for the reachability
2944        // queries below.
2945        let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2946        for (idx, node) in graph.nodes.iter().enumerate() {
2947            let consumer = NodeId(idx);
2948            for &inp in &node.inputs {
2949                consumers.entry(inp).or_default().push(consumer);
2950            }
2951        }
2952
2953        // (2) reconverge_at must be a forward descendant of diverge and
2954        // distinct from it.
2955        if reconverge_at == self.diverge {
2956            return Err(invalid(format!(
2957                "reconverge_at Node#{} must be a strict descendant of the \
2958                 diverge point, not the diverge point itself",
2959                reconverge_at.0,
2960            )));
2961        }
2962        let fwd_from_diverge = Graph::forward_reachable(&consumers, self.diverge);
2963        if !fwd_from_diverge.contains(&reconverge_at) {
2964            return Err(invalid(format!(
2965                "reconverge_at Node#{} is not a descendant of diverge Node#{}",
2966                reconverge_at.0, self.diverge.0,
2967            )));
2968        }
2969
2970        // (4) Cast-to-uniform: every arm exit must agree on shape & dtype
2971        // with arm 0 (PR-A1 validates uniformity rather than inserting
2972        // Cast nodes).
2973        let arm0 = self.arms[0];
2974        let (want_shape, want_dtype) = {
2975            let a0 = &graph.nodes[arm0.0];
2976            (a0.shape.clone(), a0.dtype)
2977        };
2978        for &arm in &self.arms[1..] {
2979            let node = &graph.nodes[arm.0];
2980            if node.shape != want_shape || node.dtype != want_dtype {
2981                return Err(invalid(format!(
2982                    "arm exit Node#{} ({:?} {:?}) disagrees with arm 0 Node#{} \
2983                     ({:?} {:?}); arms must cast to a uniform shape & dtype at \
2984                     reconverge",
2985                    arm.0, node.shape, node.dtype, arm0.0, want_shape, want_dtype,
2986                )));
2987            }
2988        }
2989
2990        // (5) Arm-0 runnability: reconverge_at must read arm 0's exit, so
2991        // an unpicked graph realizes on arm 0.
2992        if !graph.nodes[reconverge_at.0].inputs.contains(&arm0) {
2993            return Err(invalid(format!(
2994                "reconverge_at Node#{} does not read arm-0 exit Node#{}; the \
2995                 arm-0 runnability invariant requires the merge to read arm 0 \
2996                 so a not-yet-route-picked graph still realizes",
2997                reconverge_at.0, arm0.0,
2998            )));
2999        }
3000
3001        // (3) Internal disjointness. Each arm's node set is the nodes on
3002        // paths diverge → exit: backward-reachable from the exit AND
3003        // forward-reachable from diverge. The arm *interior* excludes the
3004        // shared diverge point.
3005        let mut arm_interiors: Vec<HashSet<NodeId>> = Vec::with_capacity(self.arms.len());
3006        for &exit in &self.arms {
3007            let back = graph.backward_reachable(exit);
3008            let mut interior: HashSet<NodeId> =
3009                back.intersection(&fwd_from_diverge).copied().collect();
3010            interior.remove(&self.diverge);
3011            // The exit itself is always part of the arm even if (for a
3012            // degenerate arm) it equals the diverge point — but a
3013            // diverge-equals-exit arm has an empty interior, which would
3014            // make the arm indistinguishable from "no route", so reject.
3015            if interior.is_empty() {
3016                return Err(invalid(format!(
3017                    "arm exit Node#{} has no interior strictly after diverge \
3018                     Node#{}; an arm must add at least one node between the \
3019                     diverge point and its exit",
3020                    exit.0, self.diverge.0,
3021                )));
3022            }
3023            arm_interiors.push(interior);
3024        }
3025
3026        // (3a) Arms must not share interior nodes.
3027        for i in 0..arm_interiors.len() {
3028            for j in (i + 1)..arm_interiors.len() {
3029                if let Some(shared) = arm_interiors[i].intersection(&arm_interiors[j]).next() {
3030                    return Err(invalid(format!(
3031                        "arms {i} and {j} share interior Node#{}; arms must be \
3032                         internally disjoint",
3033                        shared.0,
3034                    )));
3035                }
3036            }
3037        }
3038
3039        // (3b) No arm-interior node may be read from outside the branch.
3040        // The branch's universe is the union of all arm interiors plus
3041        // the merge node; reconverge_at reading an arm exit is the one
3042        // legitimate "external" consumer.
3043        let mut branch_universe: HashSet<NodeId> = HashSet::new();
3044        for interior in &arm_interiors {
3045            branch_universe.extend(interior.iter().copied());
3046        }
3047        branch_universe.insert(reconverge_at);
3048        for (i, interior) in arm_interiors.iter().enumerate() {
3049            for &m in interior {
3050                if let Some(cs) = consumers.get(&m) {
3051                    for &c in cs {
3052                        if !branch_universe.contains(&c) {
3053                            return Err(invalid(format!(
3054                                "arm {i} interior Node#{} is read from outside \
3055                                 the branch by Node#{}; arm interiors must not be \
3056                                 reachable from outside the branch",
3057                                m.0, c.0,
3058                            )));
3059                        }
3060                    }
3061                }
3062            }
3063        }
3064
3065        // All validation passed — emit the Branch node. Its inputs ARE the
3066        // arm exits, in priority order (arm 0 first); reconverge_at is
3067        // carried on the op. Shape/dtype mirror arm 0 (the uniform merge
3068        // type validated above).
3069        let branch = graph.push(Node {
3070            op: Op::Branch { reconverge_at },
3071            inputs: self.arms.clone(),
3072            shape: want_shape,
3073            dtype: want_dtype,
3074        });
3075        Ok(Some(branch))
3076    }
3077}
3078
3079/// A shared, mutable graph. Builders clone this cheaply and hand it to
3080/// every derived tensor so that all tensors in one computation point to
3081/// the same arena.
3082pub type SharedGraph = Arc<RwLock<Graph>>;
3083
3084/// A handle to a node in a shared graph. Cheap to clone. Carries the
3085/// graph reference so builder methods have everything they need to append
3086/// a new node.
3087#[derive(Debug, Clone)]
3088pub struct NodeHandle {
3089    graph: SharedGraph,
3090    id: NodeId,
3091}
3092
3093impl NodeHandle {
3094    /// Wrap an existing `(graph, node_id)` pair as a NodeHandle handle.
3095    /// Used by graph-rewriting passes (e.g., `opt::insert_copies`)
3096    /// that produce new root `NodeId`s and need to hand them back to
3097    /// call sites as Tensors.
3098    pub fn from_existing(graph: SharedGraph, id: NodeId) -> Self {
3099        Self { graph, id }
3100    }
3101
3102    /// The node ID this handle points to.
3103    pub fn id(&self) -> NodeId {
3104        self.id
3105    }
3106
3107    /// The shared graph this handle belongs to. Exposed so executors and
3108    /// tests can walk the underlying arena.
3109    pub fn graph(&self) -> &SharedGraph {
3110        &self.graph
3111    }
3112
3113    /// This tensor's graph identity — see [`GraphId`].
3114    ///
3115    /// Two tensors can be combined **iff** their `graph_id`s match. Affinity is
3116    /// still *checked* by pointer identity (`Arc::ptr_eq`, which cannot alias);
3117    /// this is the human-readable form, so a failure can say *which* graphs are
3118    /// involved rather than only that they differ.
3119    pub fn graph_id(&self) -> GraphId {
3120        self.graph.read().unwrap().id()
3121    }
3122
3123    /// The shape of this tensor, read from the underlying node.
3124    pub fn shape(&self) -> Shape {
3125        self.graph.read().unwrap().node(self.id).shape.clone()
3126    }
3127
3128    /// The dtype of this tensor, read from the underlying node.
3129    pub fn dtype(&self) -> DType {
3130        self.graph.read().unwrap().node(self.id).dtype
3131    }
3132
3133    /// Phase 7.5 work item G: look up the realized-storage slot for
3134    /// this tensor's node, if any. Returns an Arc clone so the slot
3135    /// stays in the map after this call and the caller's Arc keeps
3136    /// the bytes alive even if a later eviction removes the map
3137    /// entry. `None` means "not realized yet" — the caller should
3138    /// realize the graph first.
3139    pub fn storage_for(&self) -> Option<Arc<RwLock<Storage>>> {
3140        self.graph.read().unwrap().storage_for(self.id)
3141    }
3142
3143    /// The placement hint for this tensor's node, if one was set. `None`
3144    /// means "inherit from the executor's default device."
3145    pub fn placement(&self) -> Option<DeviceLocation> {
3146        self.graph.read().unwrap().placement(self.id)
3147    }
3148
3149    /// Tag this tensor's node with a target device. The executor will
3150    /// validate (post-Phase-1) that the node's op can be evaluated on
3151    /// that device. Returns the same `NodeHandle` handle so this composes
3152    /// with builder-style graph construction:
3153    ///
3154    /// ```ignore
3155    /// let y = a.matmul(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3156    /// ```
3157    pub fn on_device(self, loc: DeviceLocation) -> Self {
3158        self.graph.write().unwrap().set_placement(self.id, loc);
3159        self
3160    }
3161
3162    /// Release this tensor's device-resident storage. Appends an
3163    /// `Op::Release` node whose output is a zero-element marker (shape
3164    /// `[0]`, same dtype as self). Destructive on `self` — after this
3165    /// op runs, `self`'s storage is gone.
3166    ///
3167    /// The ordering-analysis pass (`opt::derive_ordering`, arriving in
3168    /// a follow-up PR) automatically pins the Release to run after
3169    /// every non-destructive reader of `self`. Callers should NOT
3170    /// invoke this directly in a graph that has other readers of
3171    /// `self` until that pass is in place — today's executor has no
3172    /// mechanism to stop them running in the wrong order.
3173    pub fn release(&self) -> Self {
3174        let dtype = self.dtype();
3175        let id = self.graph.write().unwrap().push(Node {
3176            op: Op::Release,
3177            inputs: vec![self.id],
3178            shape: Shape::from_dims(&[0]),
3179            dtype,
3180        });
3181        NodeHandle {
3182            graph: Arc::clone(&self.graph),
3183            id,
3184        }
3185    }
3186
3187    /// Move this tensor's data to `target` device — destroying the
3188    /// source in the process. Appends an `Op::Move` node. The output
3189    /// has the same shape and dtype as the input and lives on
3190    /// `target`; the source's device storage is freed once the op
3191    /// runs.
3192    ///
3193    /// Semantically equivalent to `copy_to_device(target)` followed
3194    /// by dropping the source. Use this when you KNOW the source
3195    /// won't be needed after the transfer — it lets backends skip
3196    /// the intermediate alloc in fast-path cases and lets the
3197    /// scheduler free the source's device memory immediately.
3198    ///
3199    /// If the source may still be read by other ops, use
3200    /// [`Self::copy_to_device`] instead — Copy leaves the source
3201    /// resident.
3202    ///
3203    /// The ordering-analysis pass (`opt::derive_ordering`)
3204    /// automatically pins this op to run after every non-destructive
3205    /// reader of the source. The caller does NOT need to worry about
3206    /// topo ordering — just emit the Move wherever the data-flow
3207    /// says it should go.
3208    pub fn move_to_device(&self, target: DeviceLocation) -> Self {
3209        let shape = self.shape();
3210        let dtype = self.dtype();
3211        let id = self.graph.write().unwrap().push(Node {
3212            op: Op::Move { target },
3213            inputs: vec![self.id],
3214            shape,
3215            dtype,
3216        });
3217        NodeHandle {
3218            graph: Arc::clone(&self.graph),
3219            id,
3220        }
3221    }
3222
3223    /// Copy this tensor's data to `target` device. Source stays
3224    /// resident. Appends an `Op::Copy` node. The output has the same
3225    /// shape and dtype as the input; only residency changes.
3226    ///
3227    /// Same-device copies are still a legal thing to ask for — the
3228    /// backend can optimize them into a cheap clone (or the scheduler
3229    /// can elide the Copy node entirely). Cross-device copies resolve
3230    /// through the pipelined executor's Copy arm (kernel lookup at
3231    /// `(OpKind::Copy, [dt, dt], source backend)`) — today a host
3232    /// round-trip; P2P comes later.
3233    pub fn copy_to_device(&self, target: DeviceLocation) -> Self {
3234        let shape = self.shape();
3235        let dtype = self.dtype();
3236        let id = self.graph.write().unwrap().push(Node {
3237            op: Op::Copy { target },
3238            inputs: vec![self.id],
3239            shape,
3240            dtype,
3241        });
3242        NodeHandle {
3243            graph: Arc::clone(&self.graph),
3244            id,
3245        }
3246    }
3247
3248    /// Project one logical output out of a multi-output producer node.
3249    ///
3250    /// `self` must have been declared as a multi-output producer via
3251    /// [`Graph::set_output_views`] (Session 2's authoring contract will
3252    /// drive this for real fused ops; Session 1 tests drive it
3253    /// directly). The View's output shape, dtype, and layout come from
3254    /// `output_views(self.id)[slot]`. Zero bytes are moved at
3255    /// realization time — the View shares the producer's bundled
3256    /// `Arc<RwLock<Storage>>` and exposes the slot's typed window.
3257    ///
3258    /// **Returns `Result`**: the producer must have declared its slot
3259    /// specs; `slot` must be in range. Errors at graph-build time
3260    /// (per the `validate-at-graph-build-time` rule); the executor
3261    /// never sees a malformed View node.
3262    pub fn view(&self, slot: u32) -> std::result::Result<NodeHandle, fuel_ir::Error> {
3263        let mut graph_w = self.graph.write().unwrap();
3264        let (slot_shape, slot_dtype, slot_byte_offset, slot_layout) = {
3265            let views = graph_w.output_views(self.id).ok_or_else(|| {
3266                fuel_ir::Error::Msg(format!(
3267                    "NodeHandle::view: Node#{} is not a multi-output \
3268                     producer (no output_views registered)",
3269                    self.id.0,
3270                ))
3271                .bt()
3272            })?;
3273            let idx = slot as usize;
3274            let v = views.get(idx).ok_or_else(|| {
3275                fuel_ir::Error::Msg(format!(
3276                    "NodeHandle::view: slot {idx} out of range \
3277                     (producer has {} slots)",
3278                    views.len(),
3279                ))
3280                .bt()
3281            })?;
3282            (v.shape.clone(), v.dtype, v.byte_offset, v.layout.clone())
3283        };
3284        let id = graph_w.push(Node {
3285            op: Op::View { slot },
3286            inputs: vec![self.id],
3287            shape: slot_shape.clone(),
3288            dtype: slot_dtype,
3289        });
3290        // Compose the slot's intrinsic layout (which carries
3291        // slot-relative strides + start_offset) with the slot's
3292        // byte_offset inside the bundle. The result is the layout a
3293        // downstream kernel sees when treating the producer's bundle
3294        // bytes as `slot_dtype` elements: start_offset is in slot-
3295        // dtype-element units; bundle byte_offset is divided by the
3296        // dtype's size_in_bytes to land at the slot's first byte. The
3297        // bundled allocator (`compose_bundle`) aligns each slot's
3298        // byte_offset to its dtype size — so the division is exact.
3299        let dtype_bytes = slot_dtype.size_in_bytes().max(1);
3300        let extra_offset = slot_byte_offset / dtype_bytes;
3301        let effective_layout = if extra_offset == 0 {
3302            slot_layout
3303        } else {
3304            Layout::new(
3305                slot_layout.shape().clone(),
3306                slot_layout.stride().to_vec().into_iter().collect(),
3307                slot_layout.start_offset() + extra_offset,
3308            )
3309        };
3310        // The side-table is set whenever the effective layout differs
3311        // from `Layout::contiguous(slot_shape)` — i.e. whenever the
3312        // slot has a strided layout OR a non-zero composed offset
3313        // (slot 1+ in any bundle).
3314        if effective_layout != Layout::contiguous(slot_shape) {
3315            graph_w.set_layout(id, effective_layout);
3316        }
3317        drop(graph_w);
3318        Ok(NodeHandle {
3319            graph: Arc::clone(&self.graph),
3320            id,
3321        })
3322    }
3323
3324    /// Owned variant of [`Self::view`] — the slot's bytes are copied
3325    /// into a fresh contiguous Storage at realization time, so the
3326    /// producer's bundle Arc can drop independently. Output layout
3327    /// is always contiguous.
3328    ///
3329    /// Same validation as `view`; same error surface.
3330    pub fn view_owned(&self, slot: u32) -> std::result::Result<NodeHandle, fuel_ir::Error> {
3331        let mut graph_w = self.graph.write().unwrap();
3332        let (slot_shape, slot_dtype) = {
3333            let views = graph_w.output_views(self.id).ok_or_else(|| {
3334                fuel_ir::Error::Msg(format!(
3335                    "NodeHandle::view_owned: Node#{} is not a multi-output \
3336                     producer (no output_views registered)",
3337                    self.id.0,
3338                ))
3339                .bt()
3340            })?;
3341            let idx = slot as usize;
3342            let v = views.get(idx).ok_or_else(|| {
3343                fuel_ir::Error::Msg(format!(
3344                    "NodeHandle::view_owned: slot {idx} out of range \
3345                     (producer has {} slots)",
3346                    views.len(),
3347                ))
3348                .bt()
3349            })?;
3350            (v.shape.clone(), v.dtype)
3351        };
3352        let id = graph_w.push(Node {
3353            op: Op::ViewOwned { slot },
3354            inputs: vec![self.id],
3355            shape: slot_shape,
3356            dtype: slot_dtype,
3357        });
3358        drop(graph_w);
3359        Ok(NodeHandle {
3360            graph: Arc::clone(&self.graph),
3361            id,
3362        })
3363    }
3364
3365    /// Append an `Op::WriteSlice` node — copies `source`'s bytes into
3366    /// `self` at the rectangular slab defined by `ranges`. Per-axis,
3367    /// `ranges[i] = (start, end)` is the half-open destination range;
3368    /// `source.dims()[i]` must equal `end - start`, and `end` must
3369    /// not exceed `self.dims()[i]`. Dtypes must match.
3370    ///
3371    /// Destructive on `self`: after the write, `self`'s NodeId is no
3372    /// longer readable (the scheduler pins this op to run after every
3373    /// other reader of `self` via [`opt::derive_ordering`]). The
3374    /// returned tensor adopts `self`'s Storage and Layout; downstream
3375    /// consumers read post-write bytes through this NodeId.
3376    ///
3377    /// Phase E.3.2: introduced to back persistent KV-cache writes
3378    /// (`InferenceContext` + `KvCache`). Non-differentiable.
3379    ///
3380    /// **Returns `Result`**: rank/shape/range mismatches surface as a
3381    /// typed error.
3382    pub fn write_slice(
3383        &self,
3384        source: &NodeHandle,
3385        ranges: Vec<(usize, usize)>,
3386    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
3387        self.require_same_graph(source, "write_slice", "source")?;
3388        let dest_shape = self.shape();
3389        let dest_dims = dest_shape.dims();
3390        let src_shape = source.shape();
3391        let src_dims = src_shape.dims();
3392        let rank = dest_dims.len();
3393        if ranges.len() != rank {
3394            return Err(fuel_ir::Error::Msg(format!(
3395                "write_slice: ranges.len() ({}) must equal destination rank ({rank})",
3396                ranges.len(),
3397            ))
3398            .bt());
3399        }
3400        if src_dims.len() != rank {
3401            return Err(fuel_ir::Error::Msg(format!(
3402                "write_slice: source rank ({}) must equal destination rank ({rank})",
3403                src_dims.len(),
3404            ))
3405            .bt());
3406        }
3407        for (i, &(start, end)) in ranges.iter().enumerate() {
3408            if end < start {
3409                return Err(fuel_ir::Error::Msg(format!(
3410                    "write_slice: ranges[{i}] = ({start}, {end}) has end < start"
3411                ))
3412                .bt());
3413            }
3414            if end > dest_dims[i] {
3415                return Err(fuel_ir::Error::Msg(format!(
3416                    "write_slice: ranges[{i}].end ({end}) > destination dim {i} ({})",
3417                    dest_dims[i],
3418                ))
3419                .bt());
3420            }
3421            let slab = end - start;
3422            if src_dims[i] != slab {
3423                return Err(fuel_ir::Error::Msg(format!(
3424                    "write_slice: source dim {i} ({}) must equal slab width ({slab}) \
3425                     = ranges[{i}].end - ranges[{i}].start",
3426                    src_dims[i],
3427                ))
3428                .bt());
3429            }
3430        }
3431        if self.dtype() != source.dtype() {
3432            return Err(fuel_ir::Error::Msg(format!(
3433                "write_slice: dtype mismatch — destination {:?} vs source {:?}",
3434                self.dtype(),
3435                source.dtype(),
3436            ))
3437            .bt());
3438        }
3439        let dtype = self.dtype();
3440        let id = self.graph.write().unwrap().push(Node {
3441            op: Op::WriteSlice {
3442                ranges,
3443                dyn_offset: None,
3444            },
3445            inputs: vec![self.id, source.id],
3446            shape: dest_shape,
3447            dtype,
3448        });
3449        Ok(NodeHandle {
3450            graph: Arc::clone(&self.graph),
3451            id,
3452        })
3453    }
3454
3455    /// Append an `Op::WriteSlice` whose start on `dyn_axis` is a runtime
3456    /// value resolved through the per-pass [`fuel_ir::SymEnv`] at
3457    /// realize, rather than the build-time `ranges[dyn_axis].0`.
3458    ///
3459    /// This is the **input-determined** dynamic-offset path (a
3460    /// [`DynScalar`] over the one `SymEnv`), distinct from
3461    /// [`Self::write_slice_rotating`]'s **data-determined** position (a
3462    /// tensor input read mid-pass). It backs the persistent decode
3463    /// KV-cache write: the append offset is `cached_len`, a per-token
3464    /// runtime value over a fixed-capacity buffer, so the graph
3465    /// structure (and every shape) stays identical across tokens and the
3466    /// plan is reused (Phase D symbolic extents).
3467    ///
3468    /// On `dyn_axis`, `ranges[dyn_axis].0` is ignored (the start is
3469    /// dynamic) and `ranges[dyn_axis].1 - ranges[dyn_axis].0` is the slab
3470    /// width, which must equal `source.dims()[dyn_axis]` and not exceed
3471    /// the destination capacity on that axis. On every other axis the
3472    /// same shape contract as [`Self::write_slice`] applies. The runtime
3473    /// `offset + width` is bounds-checked against the destination
3474    /// capacity at realize (a typed error, never a panic).
3475    ///
3476    /// **Returns `Result`**: rank/dtype/axis-bound/width mismatches
3477    /// surface as a typed error at build time.
3478    pub fn write_slice_dyn(
3479        &self,
3480        source: &NodeHandle,
3481        ranges: Vec<(usize, usize)>,
3482        dyn_axis: usize,
3483        offset: DynScalar,
3484    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
3485        self.require_same_graph(source, "write_slice_dyn", "source")?;
3486        let dest_shape = self.shape();
3487        let dest_dims = dest_shape.dims();
3488        let src_shape = source.shape();
3489        let src_dims = src_shape.dims();
3490        let rank = dest_dims.len();
3491        if ranges.len() != rank {
3492            return Err(fuel_ir::Error::Msg(format!(
3493                "write_slice_dyn: ranges.len() ({}) must equal destination rank ({rank})",
3494                ranges.len(),
3495            ))
3496            .bt());
3497        }
3498        if src_dims.len() != rank {
3499            return Err(fuel_ir::Error::Msg(format!(
3500                "write_slice_dyn: source rank ({}) must equal destination rank ({rank})",
3501                src_dims.len(),
3502            ))
3503            .bt());
3504        }
3505        if dyn_axis >= rank {
3506            return Err(fuel_ir::Error::Msg(format!(
3507                "write_slice_dyn: dyn_axis ({dyn_axis}) out of bounds for rank {rank}",
3508            ))
3509            .bt());
3510        }
3511        for (i, &(start, end)) in ranges.iter().enumerate() {
3512            if end < start {
3513                return Err(fuel_ir::Error::Msg(format!(
3514                    "write_slice_dyn: ranges[{i}] = ({start}, {end}) has end < start"
3515                ))
3516                .bt());
3517            }
3518            let slab = end - start;
3519            if src_dims[i] != slab {
3520                return Err(fuel_ir::Error::Msg(format!(
3521                    "write_slice_dyn: source dim {i} ({}) must equal slab width ({slab}) \
3522                     = ranges[{i}].end - ranges[{i}].start",
3523                    src_dims[i],
3524                ))
3525                .bt());
3526            }
3527            if i == dyn_axis {
3528                // The start on `dyn_axis` is dynamic, so `ranges[i].0`/`.1`
3529                // are not bounded by `dest_dims[i]` here — only the slab
3530                // WIDTH must fit the capacity. The runtime `offset + width`
3531                // is bounds-checked against `dest_dims[i]` at realize.
3532                if slab > dest_dims[i] {
3533                    return Err(fuel_ir::Error::Msg(format!(
3534                        "write_slice_dyn: dynamic-axis slab width ({slab}) > destination capacity \
3535                         dim {i} ({})",
3536                        dest_dims[i],
3537                    ))
3538                    .bt());
3539                }
3540            } else if end > dest_dims[i] {
3541                return Err(fuel_ir::Error::Msg(format!(
3542                    "write_slice_dyn: ranges[{i}].end ({end}) > destination dim {i} ({})",
3543                    dest_dims[i],
3544                ))
3545                .bt());
3546            }
3547        }
3548        if self.dtype() != source.dtype() {
3549            return Err(fuel_ir::Error::Msg(format!(
3550                "write_slice_dyn: dtype mismatch — destination {:?} vs source {:?}",
3551                self.dtype(),
3552                source.dtype(),
3553            ))
3554            .bt());
3555        }
3556        let dtype = self.dtype();
3557        let id = self.graph.write().unwrap().push(Node {
3558            op: Op::WriteSlice {
3559                ranges,
3560                dyn_offset: Some((dyn_axis, offset)),
3561            },
3562            inputs: vec![self.id, source.id],
3563            shape: dest_shape,
3564            dtype,
3565        });
3566        Ok(NodeHandle {
3567            graph: Arc::clone(&self.graph),
3568            id,
3569        })
3570    }
3571
3572    /// Append an [`Op::WriteSliceRotating`] node — copies `source`'s
3573    /// bytes into `self` at the rectangular slab defined by `ranges`,
3574    /// with the `axis` axis wrapping modulo `modulus`. The dynamic
3575    /// write position comes through `position` (a rank-0 `U32`
3576    /// tensor); the kernel computes `start = position % modulus` and
3577    /// splits the write across the ring boundary if needed.
3578    ///
3579    /// `ranges[axis].0` is ignored (the rotating-axis start is
3580    /// dynamic). `ranges[axis].1 - ranges[axis].0` is the write
3581    /// length on the rotating axis and must equal `source.dims()[axis]`.
3582    /// On every other axis the same shape contract as `write_slice`
3583    /// applies.
3584    ///
3585    /// Destructive on `self`; scheduled like `write_slice`. Backward
3586    /// panics — sliding-window KV-cache writes are forward-only.
3587    ///
3588    /// **Returns `Result`**: rank/dtype/axis-bound/modulus/range
3589    /// mismatches surface as a typed error at build time.
3590    pub fn write_slice_rotating(
3591        &self,
3592        source: &NodeHandle,
3593        position: &NodeHandle,
3594        axis: usize,
3595        modulus: usize,
3596        ranges: Vec<(usize, usize)>,
3597    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
3598        self.require_same_graph(source, "write_slice_rotating", "source")?;
3599        self.require_same_graph(position, "write_slice_rotating", "position")?;
3600        let dest_shape = self.shape();
3601        let dest_dims = dest_shape.dims();
3602        let src_shape = source.shape();
3603        let src_dims = src_shape.dims();
3604        let rank = dest_dims.len();
3605        if ranges.len() != rank {
3606            return Err(fuel_ir::Error::Msg(format!(
3607                "write_slice_rotating: ranges.len() ({}) must equal destination rank ({rank})",
3608                ranges.len(),
3609            ))
3610            .bt());
3611        }
3612        if src_dims.len() != rank {
3613            return Err(fuel_ir::Error::Msg(format!(
3614                "write_slice_rotating: source rank ({}) must equal destination rank ({rank})",
3615                src_dims.len(),
3616            ))
3617            .bt());
3618        }
3619        if axis >= rank {
3620            return Err(fuel_ir::Error::Msg(format!(
3621                "write_slice_rotating: axis {axis} out of bounds for rank {rank}",
3622            ))
3623            .bt());
3624        }
3625        if modulus == 0 {
3626            return Err(
3627                fuel_ir::Error::Msg("write_slice_rotating: modulus must be >= 1".into()).bt(),
3628            );
3629        }
3630        if modulus > dest_dims[axis] {
3631            return Err(fuel_ir::Error::Msg(format!(
3632                "write_slice_rotating: modulus ({modulus}) must not exceed destination dim {axis} ({})",
3633                dest_dims[axis],
3634            )).bt());
3635        }
3636        for (i, &(start, end)) in ranges.iter().enumerate() {
3637            if end < start {
3638                return Err(fuel_ir::Error::Msg(format!(
3639                    "write_slice_rotating: ranges[{i}] = ({start}, {end}) has end < start"
3640                ))
3641                .bt());
3642            }
3643            let slab = end - start;
3644            if i == axis {
3645                if slab == 0 {
3646                    return Err(fuel_ir::Error::Msg(format!(
3647                        "write_slice_rotating: ranges[axis={axis}] slab is 0",
3648                    ))
3649                    .bt());
3650                }
3651                if slab > modulus {
3652                    return Err(fuel_ir::Error::Msg(format!(
3653                        "write_slice_rotating: rotating-axis write length ({slab}) > modulus ({modulus})",
3654                    )).bt());
3655                }
3656                if src_dims[i] != slab {
3657                    return Err(fuel_ir::Error::Msg(format!(
3658                        "write_slice_rotating: source dim {i} ({}) must equal slab width ({slab})",
3659                        src_dims[i],
3660                    ))
3661                    .bt());
3662                }
3663                // ranges[axis].1 itself isn't bounded by dest_dims[axis] —
3664                // the kernel wraps the start, so the logical end is meaningless.
3665                // We still require .1 <= modulus to make the slab description
3666                // self-consistent inside the window.
3667                if end > modulus {
3668                    return Err(fuel_ir::Error::Msg(format!(
3669                        "write_slice_rotating: ranges[axis={axis}].end ({end}) > modulus ({modulus})",
3670                    )).bt());
3671                }
3672            } else {
3673                if end > dest_dims[i] {
3674                    return Err(fuel_ir::Error::Msg(format!(
3675                        "write_slice_rotating: ranges[{i}].end ({end}) > destination dim {i} ({})",
3676                        dest_dims[i],
3677                    ))
3678                    .bt());
3679                }
3680                if src_dims[i] != slab {
3681                    return Err(fuel_ir::Error::Msg(format!(
3682                        "write_slice_rotating: source dim {i} ({}) must equal slab width ({slab})",
3683                        src_dims[i],
3684                    ))
3685                    .bt());
3686                }
3687            }
3688        }
3689        if self.dtype() != source.dtype() {
3690            return Err(fuel_ir::Error::Msg(format!(
3691                "write_slice_rotating: dtype mismatch — destination {:?} vs source {:?}",
3692                self.dtype(),
3693                source.dtype(),
3694            ))
3695            .bt());
3696        }
3697        if position.dtype() != DType::U32 {
3698            return Err(fuel_ir::Error::Msg(format!(
3699                "write_slice_rotating: position must be U32, got {:?}",
3700                position.dtype(),
3701            ))
3702            .bt());
3703        }
3704        if !position.shape().dims().is_empty() {
3705            return Err(fuel_ir::Error::Msg(format!(
3706                "write_slice_rotating: position must be rank-0 scalar, got {:?}",
3707                position.shape().dims(),
3708            ))
3709            .bt());
3710        }
3711        let dtype = self.dtype();
3712        let id = self.graph.write().unwrap().push(Node {
3713            op: Op::WriteSliceRotating {
3714                axis,
3715                modulus,
3716                ranges,
3717            },
3718            inputs: vec![self.id, source.id, position.id],
3719            shape: dest_shape,
3720            dtype,
3721        });
3722        Ok(NodeHandle {
3723            graph: Arc::clone(&self.graph),
3724            id,
3725        })
3726    }
3727
3728    /// Append an [`Op::WriteSliceDoff`] node — copies `source`'s bytes
3729    /// into `self` at the rectangular slab defined by `ranges`, with
3730    /// the start on `axis` read from `offset` (a rank-0 `I64` tensor)
3731    /// **device-side** at kernel launch. No modulo wrap: `offset` is
3732    /// the raw start on `axis`.
3733    ///
3734    /// `ranges[axis].0` is ignored (the dynamic-axis start is device-
3735    /// resident). `ranges[axis].1 - ranges[axis].0` is the write width
3736    /// on `axis` and must equal `source.dims()[axis]`. On every other
3737    /// axis the same shape contract as [`write_slice`](Self::write_slice)
3738    /// applies.
3739    ///
3740    /// **Bounds are the caller's contract**: `offset` is device-only at
3741    /// build time, so `offset + width <= dest_dims[axis]` cannot be
3742    /// checked here and the kernel does NOT clamp. The caller must size
3743    /// `self`'s capacity on `axis` to cover the largest offset (this is
3744    /// the CUDA-graph-capturable KV-cache append; `DecodeSession` sizes
3745    /// `max_seq`). The static slab width IS checked against capacity.
3746    ///
3747    /// Destructive on `self`; scheduled like `write_slice`. Backward
3748    /// panics — decode-time KV-cache appends are forward-only.
3749    ///
3750    /// **Returns `Result`**: rank / dtype / axis-bound / static-width /
3751    /// offset-dtype mismatches surface as a typed error at build time.
3752    pub fn write_slice_doff(
3753        &self,
3754        source: &NodeHandle,
3755        offset: &NodeHandle,
3756        axis: usize,
3757        ranges: Vec<(usize, usize)>,
3758    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
3759        self.require_same_graph(source, "write_slice_doff", "source")?;
3760        self.require_same_graph(offset, "write_slice_doff", "offset")?;
3761        let dest_shape = self.shape();
3762        let dest_dims = dest_shape.dims();
3763        let src_dims = source.shape().dims().to_vec();
3764        let rank = dest_dims.len();
3765        if ranges.len() != rank {
3766            return Err(fuel_ir::Error::Msg(format!(
3767                "write_slice_doff: ranges.len() ({}) must equal destination rank ({rank})",
3768                ranges.len(),
3769            ))
3770            .bt());
3771        }
3772        if src_dims.len() != rank {
3773            return Err(fuel_ir::Error::Msg(format!(
3774                "write_slice_doff: source rank ({}) must equal destination rank ({rank})",
3775                src_dims.len(),
3776            ))
3777            .bt());
3778        }
3779        if axis >= rank {
3780            return Err(fuel_ir::Error::Msg(format!(
3781                "write_slice_doff: axis {axis} out of bounds for rank {rank}",
3782            ))
3783            .bt());
3784        }
3785        for (i, &(start, end)) in ranges.iter().enumerate() {
3786            if end < start {
3787                return Err(fuel_ir::Error::Msg(format!(
3788                    "write_slice_doff: ranges[{i}] = ({start}, {end}) has end < start"
3789                ))
3790                .bt());
3791            }
3792            let slab = end - start;
3793            if i == axis {
3794                if slab == 0 {
3795                    return Err(fuel_ir::Error::Msg(format!(
3796                        "write_slice_doff: ranges[axis={axis}] slab is 0",
3797                    ))
3798                    .bt());
3799                }
3800                // The write width can't exceed the buffer capacity on
3801                // `axis` even at offset 0 (the offset itself is dynamic
3802                // and bounds-checked device-side by the caller's sizing).
3803                if slab > dest_dims[axis] {
3804                    return Err(fuel_ir::Error::Msg(format!(
3805                        "write_slice_doff: dynamic-axis write width ({slab}) > destination dim {axis} ({})",
3806                        dest_dims[axis],
3807                    )).bt());
3808                }
3809                if src_dims[i] != slab {
3810                    return Err(fuel_ir::Error::Msg(format!(
3811                        "write_slice_doff: source dim {i} ({}) must equal slab width ({slab})",
3812                        src_dims[i],
3813                    ))
3814                    .bt());
3815                }
3816            } else {
3817                if end > dest_dims[i] {
3818                    return Err(fuel_ir::Error::Msg(format!(
3819                        "write_slice_doff: ranges[{i}].end ({end}) > destination dim {i} ({})",
3820                        dest_dims[i],
3821                    ))
3822                    .bt());
3823                }
3824                if src_dims[i] != slab {
3825                    return Err(fuel_ir::Error::Msg(format!(
3826                        "write_slice_doff: source dim {i} ({}) must equal slab width ({slab})",
3827                        src_dims[i],
3828                    ))
3829                    .bt());
3830                }
3831            }
3832        }
3833        if self.dtype() != source.dtype() {
3834            return Err(fuel_ir::Error::Msg(format!(
3835                "write_slice_doff: dtype mismatch — destination {:?} vs source {:?}",
3836                self.dtype(),
3837                source.dtype(),
3838            ))
3839            .bt());
3840        }
3841        if offset.dtype() != DType::I64 {
3842            return Err(fuel_ir::Error::Msg(format!(
3843                "write_slice_doff: offset must be I64 (device-resident start), got {:?}",
3844                offset.dtype(),
3845            ))
3846            .bt());
3847        }
3848        if !offset.shape().dims().is_empty() {
3849            return Err(fuel_ir::Error::Msg(format!(
3850                "write_slice_doff: offset must be rank-0 scalar, got {:?}",
3851                offset.shape().dims(),
3852            ))
3853            .bt());
3854        }
3855        let dtype = self.dtype();
3856        let id = self.graph.write().unwrap().push(Node {
3857            op: Op::WriteSliceDoff { axis, ranges },
3858            inputs: vec![self.id, source.id, offset.id],
3859            shape: dest_shape,
3860            dtype,
3861        });
3862        Ok(NodeHandle {
3863            graph: Arc::clone(&self.graph),
3864            id,
3865        })
3866    }
3867
3868    /// Build a `Const` tensor from an `f32` slice and shape on a fresh
3869    /// graph. The new graph is returned via the tensor's `graph` handle.
3870    ///
3871    /// `data` takes `impl Into<Arc<[f32]>>` so both `Vec<f32>` (one-time
3872    /// conversion at the call site) and `Arc<[f32]>` (free clone that
3873    /// shares buffers across forward passes) work.
3874    ///
3875    /// Phase 7.5 work item G2: `device` is the device on which the
3876    /// realized Storage is allocated. The graph's storage_map slot is
3877    /// populated with that Storage and `Op::Const` is emitted —
3878    /// no host-side `ConstData` payload rides on the node.
3879    pub fn from_f32(
3880        data: impl Into<Arc<[f32]>>,
3881        shape: impl Into<Shape>,
3882        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3883    ) -> Result<Self, fuel_ir::Error> {
3884        let v: Arc<[f32]> = data.into();
3885        let buf = fuel_ir::HostBuffer::F32(v.to_vec());
3886        Self::from_host_buffer(buf, DType::F32, shape, device)
3887    }
3888
3889    /// Build a `Const` tensor from an `f64` slice and shape on a fresh graph.
3890    /// `device` selects where the realized Storage is allocated.
3891    pub fn from_f64(
3892        data: impl Into<Arc<[f64]>>,
3893        shape: impl Into<Shape>,
3894        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3895    ) -> Result<Self, fuel_ir::Error> {
3896        let v: Arc<[f64]> = data.into();
3897        let buf = fuel_ir::HostBuffer::F64(v.to_vec());
3898        Self::from_host_buffer(buf, DType::F64, shape, device)
3899    }
3900
3901    /// Build a `Const` tensor from a `bf16` slice and shape on a fresh graph.
3902    /// `device` selects where the realized Storage is allocated.
3903    pub fn from_bf16(
3904        data: impl Into<Arc<[bf16]>>,
3905        shape: impl Into<Shape>,
3906        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3907    ) -> Result<Self, fuel_ir::Error> {
3908        let v: Arc<[bf16]> = data.into();
3909        let buf = fuel_ir::HostBuffer::BF16(v.to_vec());
3910        Self::from_host_buffer(buf, DType::BF16, shape, device)
3911    }
3912
3913    /// Build a `Const` tensor from an `f16` slice and shape on a fresh graph.
3914    /// `device` selects where the realized Storage is allocated.
3915    pub fn from_f16(
3916        data: impl Into<Arc<[f16]>>,
3917        shape: impl Into<Shape>,
3918        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3919    ) -> Result<Self, fuel_ir::Error> {
3920        let v: Arc<[f16]> = data.into();
3921        let buf = fuel_ir::HostBuffer::F16(v.to_vec());
3922        Self::from_host_buffer(buf, DType::F16, shape, device)
3923    }
3924
3925    /// Build a `Const` tensor from a `u32` slice and shape on a fresh
3926    /// graph. Primarily used to construct index tensors for gather /
3927    /// scatter / index_select. `u32` is the index type all real fuel
3928    /// backends use (Candle CPU, CUDA, Metal), so keeping the reference
3929    /// on the same type means oracle-equivalence tests do not need any
3930    /// index-type translation. `device` selects where the realized
3931    /// Storage is allocated.
3932    pub fn from_u32(
3933        data: impl Into<Arc<[u32]>>,
3934        shape: impl Into<Shape>,
3935        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3936    ) -> Result<Self, fuel_ir::Error> {
3937        let v: Arc<[u32]> = data.into();
3938        let buf = fuel_ir::HostBuffer::U32(v.to_vec());
3939        Self::from_host_buffer(buf, DType::U32, shape, device)
3940    }
3941
3942    /// G2 internal funnel: allocate Storage on `device` from `buf`,
3943    /// register the slot, emit `Op::Const`. Per-dtype `from_*` methods
3944    /// delegate here.
3945    fn from_host_buffer(
3946        buf: fuel_ir::HostBuffer,
3947        dtype: DType,
3948        shape: impl Into<Shape>,
3949        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3950    ) -> Result<Self, fuel_ir::Error> {
3951        let graph = Arc::new(RwLock::new(Graph::new()));
3952        Self::from_host_buffer_on(&graph, buf, dtype, shape, device)
3953    }
3954
3955    /// `from_host_buffer` against an **existing** graph — the shared body of the
3956    /// `from_*_on` family. Identical to `from_host_buffer` except that the caller
3957    /// supplies the graph instead of one being minted.
3958    fn from_host_buffer_on(
3959        graph: &SharedGraph,
3960        buf: fuel_ir::HostBuffer,
3961        dtype: DType,
3962        shape: impl Into<Shape>,
3963        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
3964    ) -> Result<Self, fuel_ir::Error> {
3965        let shape = shape.into();
3966        let n = host_buffer_elem_count(&buf);
3967        // GAP-003: was `assert_eq!`. The message is preserved VERBATIM because
3968        // `docs/gaps.md` and `CLAUDE.md` both anchor on it -- a prose anchor is
3969        // rename-proof, so changing the words here would orphan two citations
3970        // that no identifier rename could have touched.
3971        if n != shape.elem_count() {
3972            return Err(fuel_ir::Error::Msg(format!(
3973                "NodeHandle::from_*: data length {n} does not match shape element count {}",
3974                shape.elem_count(),
3975            )));
3976        }
3977        // GAP-003: was `.expect(...)`. This is the sibling panic the row's
3978        // anchor could not see -- it carries a DIFFERENT message, and a
3979        // message-specific anchor is blind to exactly that.
3980        let backend_storage = device
3981            .storage_from_host_buffer_owned_dyn(buf)
3982            .map_err(|e| {
3983                fuel_ir::Error::Msg(format!(
3984                    "NodeHandle::from_*: device.storage_from_host_buffer_owned_dyn failed: {e}"
3985                ))
3986            })?;
3987        let storage_arc = Arc::new(RwLock::new(Storage::from_dyn(backend_storage)));
3988        let id = {
3989            let mut g = graph.write().unwrap();
3990            let id = g.push(Node {
3991                op: Op::Const,
3992                inputs: vec![],
3993                shape,
3994                dtype,
3995            });
3996            g.set_storage(id, storage_arc);
3997            id
3998        };
3999        Ok(Self {
4000            graph: Arc::clone(graph),
4001            id,
4002        })
4003    }
4004
4005    /// Build an `f32` `Const` leaf **on the graph you pass in**, rather than on a
4006    /// fresh one.
4007    ///
4008    /// This is the non-anchor route for adding a tensor to an existing graph.
4009    /// `NodeHandle::from_f32` mints a NEW graph, so two `from_*` tensors can never be
4010    /// combined; `from_f32_on` puts the new leaf on `graph`, and
4011    /// [`const_f32_like`](Self::const_f32_like) does the same thing when what you
4012    /// have to hand is a sibling *tensor* rather than the graph itself.
4013    ///
4014    /// ```ignore
4015    /// let g: SharedGraph = Arc::new(RwLock::new(Graph::new()));
4016    /// let a = NodeHandle::from_f32_on(&g, a_data, [2, 3], &device);
4017    /// let w = NodeHandle::from_f32_on(&g, w_data, [3, 2], &device);
4018    /// let y = a.matmul(&w);   // same graph ✓
4019    /// ```
4020    pub fn from_f32_on(
4021        graph: &SharedGraph,
4022        data: impl Into<Arc<[f32]>>,
4023        shape: impl Into<Shape>,
4024        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
4025    ) -> Result<Self, fuel_ir::Error> {
4026        let v: Arc<[f32]> = data.into();
4027        let buf = fuel_ir::HostBuffer::F32(v.to_vec());
4028        Self::from_host_buffer_on(graph, buf, DType::F32, shape, device)
4029    }
4030
4031    /// `f64` sibling of [`from_f32_on`](Self::from_f32_on) — builds on the given
4032    /// graph rather than a fresh one.
4033    pub fn from_f64_on(
4034        graph: &SharedGraph,
4035        data: impl Into<Arc<[f64]>>,
4036        shape: impl Into<Shape>,
4037        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
4038    ) -> Result<Self, fuel_ir::Error> {
4039        let v: Arc<[f64]> = data.into();
4040        let buf = fuel_ir::HostBuffer::F64(v.to_vec());
4041        Self::from_host_buffer_on(graph, buf, DType::F64, shape, device)
4042    }
4043
4044    /// `bf16` sibling of [`from_f32_on`](Self::from_f32_on). The common
4045    /// mixed-precision shape — f32 activations, bf16 weights — is exactly this
4046    /// plus [`from_f32_on`](Self::from_f32_on) against one shared graph.
4047    pub fn from_bf16_on(
4048        graph: &SharedGraph,
4049        data: impl Into<Arc<[half::bf16]>>,
4050        shape: impl Into<Shape>,
4051        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
4052    ) -> Result<Self, fuel_ir::Error> {
4053        let v: Arc<[half::bf16]> = data.into();
4054        let buf = fuel_ir::HostBuffer::BF16(v.to_vec());
4055        Self::from_host_buffer_on(graph, buf, DType::BF16, shape, device)
4056    }
4057
4058    /// `f16` sibling of [`from_f32_on`](Self::from_f32_on).
4059    pub fn from_f16_on(
4060        graph: &SharedGraph,
4061        data: impl Into<Arc<[half::f16]>>,
4062        shape: impl Into<Shape>,
4063        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
4064    ) -> Result<Self, fuel_ir::Error> {
4065        let v: Arc<[half::f16]> = data.into();
4066        let buf = fuel_ir::HostBuffer::F16(v.to_vec());
4067        Self::from_host_buffer_on(graph, buf, DType::F16, shape, device)
4068    }
4069
4070    /// `u32` sibling of [`from_f32_on`](Self::from_f32_on) — index tensors for
4071    /// gather/index_select built straight onto the graph they will be used on.
4072    pub fn from_u32_on(
4073        graph: &SharedGraph,
4074        data: impl Into<Arc<[u32]>>,
4075        shape: impl Into<Shape>,
4076        device: &Arc<dyn fuel_backend_contract::DynBackendDevice>,
4077    ) -> Result<Self, fuel_ir::Error> {
4078        let v: Arc<[u32]> = data.into();
4079        let buf = fuel_ir::HostBuffer::U32(v.to_vec());
4080        Self::from_host_buffer_on(graph, buf, DType::U32, shape, device)
4081    }
4082
4083    /// Phase 7.5 work item G2: build a `Const` leaf on a fresh graph
4084    /// whose realized bytes already live in `storage`. The graph's
4085    /// storage_map slot for the new node is populated with `storage`
4086    /// directly. The executor's slot-first dispatch returns the slot's
4087    /// Arc on realize.
4088    pub fn from_storage(
4089        storage: Arc<RwLock<Storage>>,
4090        shape: impl Into<Shape>,
4091        dtype: DType,
4092    ) -> Self {
4093        let shape = shape.into();
4094        // Sanity-check the slot's dtype matches the declared dtype.
4095        // The slot's bytes don't carry a logical shape — that's the
4096        // node's job — so we don't validate elem_count here.
4097        debug_assert_eq!(
4098            storage.read().unwrap().dtype(),
4099            dtype,
4100            "NodeHandle::from_storage: declared dtype {:?} does not match storage dtype {:?}",
4101            dtype,
4102            storage.read().unwrap().dtype(),
4103        );
4104        let graph = Arc::new(RwLock::new(Graph::new()));
4105        let id = {
4106            let mut g = graph.write().unwrap();
4107            let id = g.push(Node {
4108                op: Op::Const,
4109                inputs: vec![],
4110                shape,
4111                dtype,
4112            });
4113            g.set_storage(id, storage);
4114            id
4115        };
4116        Self { graph, id }
4117    }
4118
4119    /// Build a second `Const` tensor that lives on the same graph as
4120    /// `self`. Use this to add more inputs to an existing computation.
4121    ///
4122    /// Phase 7.5 G2: the realized Storage is allocated on the device
4123    /// derived from `self`'s graph (any existing slot's device — the
4124    /// graph always has at least one slot-bearing leaf by the time
4125    /// const_*_like is called). For cross-device const construction,
4126    /// build a fresh graph with [`NodeHandle::from_f32`] and link via
4127    /// [`Op::Move`] / [`Op::Copy`].
4128    pub fn const_f32_like(
4129        &self,
4130        data: impl Into<Arc<[f32]>>,
4131        shape: impl Into<Shape>,
4132    ) -> Result<Self, fuel_ir::Error> {
4133        let v: Arc<[f32]> = data.into();
4134        self.const_like_host_buffer(fuel_ir::HostBuffer::F32(v.to_vec()), DType::F32, shape)
4135    }
4136
4137    /// Build a second `Const f64` tensor on the same graph as `self`.
4138    pub fn const_f64_like(
4139        &self,
4140        data: impl Into<Arc<[f64]>>,
4141        shape: impl Into<Shape>,
4142    ) -> Result<Self, fuel_ir::Error> {
4143        let v: Arc<[f64]> = data.into();
4144        self.const_like_host_buffer(fuel_ir::HostBuffer::F64(v.to_vec()), DType::F64, shape)
4145    }
4146
4147    /// Build a second `Const bf16` tensor on the same graph as `self`.
4148    pub fn const_bf16_like(
4149        &self,
4150        data: impl Into<Arc<[bf16]>>,
4151        shape: impl Into<Shape>,
4152    ) -> Result<Self, fuel_ir::Error> {
4153        let v: Arc<[bf16]> = data.into();
4154        self.const_like_host_buffer(fuel_ir::HostBuffer::BF16(v.to_vec()), DType::BF16, shape)
4155    }
4156
4157    /// Build a second `Const f16` tensor on the same graph as `self`.
4158    pub fn const_f16_like(
4159        &self,
4160        data: impl Into<Arc<[f16]>>,
4161        shape: impl Into<Shape>,
4162    ) -> Result<Self, fuel_ir::Error> {
4163        let v: Arc<[f16]> = data.into();
4164        self.const_like_host_buffer(fuel_ir::HostBuffer::F16(v.to_vec()), DType::F16, shape)
4165    }
4166
4167    /// Build a second `Const u32` (index) tensor on the same graph as `self`.
4168    pub fn const_u32_like(
4169        &self,
4170        data: impl Into<Arc<[u32]>>,
4171        shape: impl Into<Shape>,
4172    ) -> Result<Self, fuel_ir::Error> {
4173        let v: Arc<[u32]> = data.into();
4174        self.const_like_host_buffer(fuel_ir::HostBuffer::U32(v.to_vec()), DType::U32, shape)
4175    }
4176
4177    /// Build a sibling U8 `Const` on the same graph. Used by
4178    /// byte-stream inputs (e.g. NF4 quantized weights packed two
4179    /// codes per byte).
4180    pub fn const_u8_like(
4181        &self,
4182        data: impl Into<Arc<[u8]>>,
4183        shape: impl Into<Shape>,
4184    ) -> Result<Self, fuel_ir::Error> {
4185        let v: Arc<[u8]> = data.into();
4186        self.const_like_host_buffer(fuel_ir::HostBuffer::U8(v.to_vec()), DType::U8, shape)
4187    }
4188
4189    /// Build a sibling I64 `Const` on the same graph. Used by
4190    /// integer-target ops (e.g. cross-entropy with class indices in
4191    /// PyTorch convention).
4192    pub fn const_i64_like(
4193        &self,
4194        data: impl Into<Arc<[i64]>>,
4195        shape: impl Into<Shape>,
4196    ) -> Result<Self, fuel_ir::Error> {
4197        let v: Arc<[i64]> = data.into();
4198        self.const_like_host_buffer(fuel_ir::HostBuffer::I64(v.to_vec()), DType::I64, shape)
4199    }
4200
4201    /// G2 internal funnel for the per-graph const_*_like family. The
4202    /// device is derived from `self`'s graph slot (any existing one)
4203    /// so callers don't have to thread a device through hot paths
4204    /// like RoPE table construction or LoRA application — the const
4205    /// goes on the same device as the graph it joins.
4206    fn const_like_host_buffer(
4207        &self,
4208        buf: fuel_ir::HostBuffer,
4209        dtype: DType,
4210        shape: impl Into<Shape>,
4211    ) -> Result<Self, fuel_ir::Error> {
4212        let shape = shape.into();
4213        let n = host_buffer_elem_count(&buf);
4214        // GAP-003: was `assert_eq!`. The message is preserved VERBATIM -- gaps.md
4215        // and CLAUDE.md both anchor on this prose, and a prose anchor is the one
4216        // an identifier rename cannot reach.
4217        if n != shape.elem_count() {
4218            return Err(fuel_ir::Error::Msg(format!(
4219                "const_*_like: data length {n} does not match shape element count {}",
4220                shape.elem_count(),
4221            )));
4222        }
4223        let device = pick_device_from_graph(&self.graph);
4224        // GAP-003: the sibling panic, converted with it -- the same pair
4225        // `from_host_buffer_on` had, and the second anchor the row named.
4226        let backend_storage = device
4227            .storage_from_host_buffer_owned_dyn(buf)
4228            .map_err(|e| {
4229                fuel_ir::Error::Msg(format!(
4230                    "NodeHandle::const_like: device.storage_from_host_buffer_owned_dyn failed: {e}"
4231                ))
4232            })?;
4233        let storage_arc = Arc::new(RwLock::new(Storage::from_dyn(backend_storage)));
4234        let id = {
4235            let mut g = self.graph.write().unwrap();
4236            let id = g.push(Node {
4237                op: Op::Const,
4238                inputs: vec![],
4239                shape,
4240                dtype,
4241            });
4242            g.set_storage(id, storage_arc);
4243            id
4244        };
4245        Ok(Self {
4246            graph: self.graph.clone(),
4247            id,
4248        })
4249    }
4250
4251    /// Push a `Const` node on the same graph as `self` **without**
4252    /// populating the graph's storage_map. The caller is responsible
4253    /// for binding the storage Arc into the realize call's initial
4254    /// StorageCache, typically via
4255    /// [`InferenceContext::insert`](../fuel_core/inference_context/struct.InferenceContext.html#method.insert).
4256    ///
4257    /// Used by the Phase 7.6 step 9c E.3.3 forward path to bind
4258    /// pre-allocated KV-cache storage Arcs (held as the new
4259    /// `Arc<RwLock<fuel_memory::Storage>>` type, not the legacy
4260    /// `fuel_backend_contract::Storage` that `const_like_from_storage` takes)
4261    /// into a per-step graph without re-uploading or type-converting.
4262    ///
4263    /// **Caller contract**: the same NodeId must appear in the
4264    /// `initial` StorageCache passed to the realize call. If not, the
4265    /// executor surfaces a clean error pointing at the missing slot
4266    /// — there's no implicit fallback to `graph.storage_map`.
4267    pub fn const_placeholder_like(&self, shape: impl Into<Shape>, dtype: DType) -> Self {
4268        let shape = shape.into();
4269        let id = {
4270            let mut g = self.graph.write().unwrap();
4271            g.push(Node {
4272                op: Op::Const,
4273                inputs: vec![],
4274                shape,
4275                dtype,
4276            })
4277        };
4278        Self {
4279            graph: self.graph.clone(),
4280            id,
4281        }
4282    }
4283
4284    /// Phase 7.5 work item G2: build a second `Const` leaf on the same
4285    /// graph as `self` whose realized bytes already live in `storage`.
4286    /// Companion to [`NodeHandle::from_storage`] for the multi-input case.
4287    pub fn const_like_from_storage(
4288        &self,
4289        storage: Arc<RwLock<Storage>>,
4290        shape: impl Into<Shape>,
4291        dtype: DType,
4292    ) -> Self {
4293        let shape = shape.into();
4294        debug_assert_eq!(
4295            storage.read().unwrap().dtype(),
4296            dtype,
4297            "NodeHandle::const_like_from_storage: declared dtype {:?} does not match storage dtype {:?}",
4298            dtype,
4299            storage.read().unwrap().dtype(),
4300        );
4301        let id = {
4302            let mut g = self.graph.write().unwrap();
4303            let id = g.push(Node {
4304                op: Op::Const,
4305                inputs: vec![],
4306                shape,
4307                dtype,
4308            });
4309            g.set_storage(id, storage);
4310            id
4311        };
4312        Self {
4313            graph: self.graph.clone(),
4314            id,
4315        }
4316    }
4317
4318    /// Append an `Add` node `self + other` to the shared graph and return
4319    /// a handle to the result. Requires matching shapes and matching
4320    /// graphs.
4321    ///
4322    /// # Panics
4323    ///
4324    /// Panics if any operand belongs to a different [`Graph`] than the one this
4325    /// builder is building into. Operand identity is graph-scoped and checked by
4326    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4327    /// deliberate and replaces the hang a cross-graph handle used to produce.
4328    /// Build every operand from one graph, or use a `Result`-returning entry
4329    /// point where one exists.
4330    pub fn add(&self, other: &NodeHandle) -> NodeHandle {
4331        self.binary_op("add", Op::Add, other, self.shape())
4332    }
4333
4334    /// Append a `Mul` node `self * other`.
4335    ///
4336    /// # Panics
4337    ///
4338    /// Panics if any operand belongs to a different [`Graph`] than the one this
4339    /// builder is building into. Operand identity is graph-scoped and checked by
4340    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4341    /// deliberate and replaces the hang a cross-graph handle used to produce.
4342    /// Build every operand from one graph, or use a `Result`-returning entry
4343    /// point where one exists.
4344    pub fn mul(&self, other: &NodeHandle) -> NodeHandle {
4345        self.binary_op("mul", Op::Mul, other, self.shape())
4346    }
4347
4348    /// Append a `MatMul` node. Both operands must have rank ≥ 2. The
4349    /// last two dims are the matrix dims; leading dims are batch dims.
4350    ///
4351    /// - If both operands have the same rank, their batch dims must
4352    ///   match exactly.
4353    /// - If one operand is rank-2 and the other is higher rank, the
4354    ///   rank-2 operand is auto-broadcast to match the higher-rank
4355    ///   operand's batch prefix. This is the common "linear layer
4356    ///   across a batch" pattern (`[batch, seq, d_model] @ [d_model, d_out]`).
4357    /// - Any other rank combination panics; users must reshape first.
4358    ///
4359    /// Shape examples:
4360    /// - `[m, k] @ [k, n]` → `[m, n]`
4361    /// - `[batch, m, k] @ [batch, k, n]` → `[batch, m, n]`
4362    /// - `[batch, seq, k] @ [k, n]` → `[batch, seq, n]` (rhs auto-broadcast)
4363    /// - `[k, n]` @ `[batch, n, m]` → `[batch, k, m]` (lhs auto-broadcast)
4364    ///
4365    /// # Panics
4366    ///
4367    /// Panics if any operand belongs to a different [`Graph`] than the one this
4368    /// builder is building into. Operand identity is graph-scoped and checked by
4369    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4370    /// deliberate and replaces the hang a cross-graph handle used to produce.
4371    /// Build every operand from one graph, or use a `Result`-returning entry
4372    /// point where one exists.
4373    pub fn matmul(&self, other: &NodeHandle) -> NodeHandle {
4374        assert!(
4375            Arc::ptr_eq(&self.graph, &other.graph),
4376            "matmul: tensors must live on the same graph — lhs is on graph #{}, rhs on graph \
4377             #{}; each `from_*` constructor mints a NEW graph, so build `other` on this \
4378             tensor's graph with `from_f32_on(self.graph(), ..)` or `const_*_like`",
4379            self.graph_id(),
4380            other.graph_id(),
4381        );
4382        // Mixed-precision matmul: activations stay in their native
4383        // dtype while weights can live as a lower-precision type on
4384        // device. Currently supported: (F32 × BF16) → F32 for bf16-
4385        // quantized LLM serving. Other heterogeneous combinations
4386        // are rejected.
4387        let dtypes_ok = self.dtype() == other.dtype()
4388            || (self.dtype() == DType::F32 && other.dtype() == DType::BF16);
4389        assert!(
4390            dtypes_ok,
4391            "matmul: unsupported dtype combination: lhs={:?}, rhs={:?}",
4392            self.dtype(),
4393            other.dtype(),
4394        );
4395        let l = self.shape();
4396        let r = other.shape();
4397        let ld = l.dims();
4398        let rd = r.dims();
4399        assert!(
4400            ld.len() >= 2 && rd.len() >= 2,
4401            "matmul: both operands must be rank ≥ 2, got {ld:?} and {rd:?}",
4402        );
4403
4404        // Auto-broadcast the rank-2 operand against the other's batch
4405        // prefix. If ranks already match, this is a no-op.
4406        let (lhs, rhs) = if ld.len() == rd.len() {
4407            (self.clone(), other.clone())
4408        } else if ld.len() == 2 && rd.len() > 2 {
4409            let mut bc_shape: Vec<usize> = rd[..rd.len() - 2].to_vec();
4410            bc_shape.push(ld[0]);
4411            bc_shape.push(ld[1]);
4412            (
4413                self.broadcast_to(Shape::from_dims(&bc_shape)),
4414                other.clone(),
4415            )
4416        } else if rd.len() == 2 && ld.len() > 2 {
4417            let mut bc_shape: Vec<usize> = ld[..ld.len() - 2].to_vec();
4418            bc_shape.push(rd[0]);
4419            bc_shape.push(rd[1]);
4420            (
4421                self.clone(),
4422                other.broadcast_to(Shape::from_dims(&bc_shape)),
4423            )
4424        } else {
4425            panic!(
4426                "matmul: unsupported rank combination {} vs {} — only same-rank or (rank-2 × higher-rank) is supported; reshape first",
4427                ld.len(),
4428                rd.len(),
4429            );
4430        };
4431
4432        // Now both operands have the same rank. Validate batch prefix
4433        // and build the output node.
4434        let lhs_shape = lhs.shape();
4435        let rhs_shape = rhs.shape();
4436        let l = lhs_shape.dims();
4437        let r = rhs_shape.dims();
4438        let rank = l.len();
4439        let batch_rank = rank - 2;
4440        for i in 0..batch_rank {
4441            // GQA-style matmul: A may have more batch heads than B
4442            // (e.g. Q[1,32,1,64] @ K^T[1,4,64,S]). The n_rep factor
4443            // is inferred by the backend at dispatch time; at the
4444            // graph level we just validate divisibility.
4445            let (la, ra) = (l[i], r[i]);
4446            let ok = la == ra || (la > ra && ra > 0 && la % ra == 0);
4447            assert!(
4448                ok,
4449                "matmul: batch dim mismatch at axis {i}: {la} vs {ra} (not equal and not a GQA-divisible pair)",
4450            );
4451        }
4452        let k = l[rank - 1];
4453        let k2 = r[rank - 2];
4454        assert_eq!(k, k2, "matmul: inner dim mismatch (lhs k={k}, rhs k={k2})",);
4455        // Output shape + dtype now flow from primitive_shape (the single source
4456        // of truth); the same-rank operands + validated batch prefix / inner
4457        // dim above guarantee it infers cleanly. (The GQA batch-prefix carry
4458        // uses lhs's batch dims, matching primitive_shape's `l[..rank-2]`.)
4459        let (shape, dtype) = crate::shape::primitive_shape(
4460            &Op::MatMul,
4461            &[lhs_shape.clone(), rhs_shape.clone()],
4462            &[lhs.dtype(), rhs.dtype()],
4463        )
4464        .expect("matmul: operands validated (same-rank, inner dim) before primitive_shape");
4465        let id = self.graph.write().unwrap().push(Node {
4466            op: Op::MatMul,
4467            inputs: vec![lhs.id, rhs.id],
4468            shape,
4469            dtype,
4470        });
4471        Self {
4472            graph: self.graph.clone(),
4473            id,
4474        }
4475    }
4476
4477    /// Data-determined-M matmul: like [`Self::matmul`], but the LHS row
4478    /// count `M` is a **runtime** value `row_count` rather than the static
4479    /// `lhs.shape[-2]`. The LHS and output are **capacity buffers** of
4480    /// `lhs.shape[-2]` rows; only `row_count` rows are computed, the rest
4481    /// left zeroed. This is the sparse-MoE expert-FFN primitive — gather an
4482    /// expert's `count_e` routed tokens into a capacity buffer, then
4483    /// compute exactly `count_e` FFN rows (the FLOP saving). `row_count`
4484    /// resolves at compile if input-determined, else at execute from the
4485    /// producer-bound `SymEnv` (e.g. `Op::NonZeroIndices`'s count). The
4486    /// output shape is the capacity shape `[..., lhs.shape[-2], n]`.
4487    ///
4488    /// # Panics
4489    ///
4490    /// Panics if any operand belongs to a different [`Graph`] than the one this
4491    /// builder is building into. Operand identity is graph-scoped and checked by
4492    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4493    /// deliberate and replaces the hang a cross-graph handle used to produce.
4494    /// Build every operand from one graph, or use a `Result`-returning entry
4495    /// point where one exists.
4496    pub fn matmul_dyn_m(&self, other: &NodeHandle, row_count: DynScalar) -> NodeHandle {
4497        self.assert_same_graph(other, "matmul_dyn_m", "other");
4498        // Only the F32 CPU capacity kernel honors a data-determined row
4499        // count today; other dtypes/backends compute all capacity rows.
4500        // Guard here so a dynamic-M matmul can never be built where it
4501        // would silently over-compute.
4502        assert_eq!(
4503            self.dtype(),
4504            DType::F32,
4505            "matmul_dyn_m: data-determined-M is F32-only today (got lhs {:?})",
4506            self.dtype(),
4507        );
4508        let out = self.matmul(other);
4509        self.graph
4510            .write()
4511            .unwrap()
4512            .set_matmul_row_count(out.id, row_count);
4513        out
4514    }
4515
4516    /// Append an [`Op::Fused`] node carrying
4517    /// [`crate::registry::FusedOpParams::QMatMul`] that multiplies `self` (F32
4518    /// activations, shape `[..., M, K]`) with a Q-type quantized
4519    /// weight matrix of logical shape `[N, K]` stored as a raw byte
4520    /// stream (passed in as a U32 tensor; length = n_bytes / 4).
4521    ///
4522    /// Output shape: `[..., M, N]` F32. The backend dequantizes the
4523    /// weight blocks on the fly inside the matmul kernel, so the
4524    /// quantized blocks stay resident at their compressed size.
4525    ///
4526    /// # Panics
4527    ///
4528    /// Panics if any operand belongs to a different [`Graph`] than the one this
4529    /// builder is building into. Operand identity is graph-scoped and checked by
4530    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4531    /// deliberate and replaces the hang a cross-graph handle used to produce.
4532    /// Build every operand from one graph, or use a `Result`-returning entry
4533    /// point where one exists.
4534    pub fn qmatmul(
4535        &self,
4536        weight_bytes: &NodeHandle,
4537        quant_type: QuantType,
4538        k: usize,
4539        n: usize,
4540    ) -> NodeHandle {
4541        assert!(
4542            Arc::ptr_eq(&self.graph, &weight_bytes.graph),
4543            "qmatmul: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
4544        );
4545        assert_eq!(
4546            self.dtype(),
4547            DType::F32,
4548            "qmatmul: activations must be F32, got {:?}",
4549            self.dtype(),
4550        );
4551        assert_eq!(
4552            weight_bytes.dtype(),
4553            DType::U32,
4554            "qmatmul: weight_bytes must be U32 (raw block bytes reinterpreted), got {:?}",
4555            weight_bytes.dtype(),
4556        );
4557        let a_dims = self.shape();
4558        let a_dims = a_dims.dims();
4559        assert!(
4560            a_dims.len() >= 2,
4561            "qmatmul: activations must be rank ≥ 2, got {a_dims:?}",
4562        );
4563        assert_eq!(
4564            a_dims[a_dims.len() - 1],
4565            k,
4566            "qmatmul: last dim of activations ({}) must equal k ({k})",
4567            a_dims[a_dims.len() - 1],
4568        );
4569        assert_eq!(
4570            k % quant_type.elements_per_block(),
4571            0,
4572            "qmatmul: k={k} must be a multiple of {quant_type:?}'s block size ({})",
4573            quant_type.elements_per_block(),
4574        );
4575        // Validate the weight byte count matches [N, K/block_size] blocks.
4576        let expected_bytes =
4577            n * (k / quant_type.elements_per_block()) * quant_type.bytes_per_block();
4578        let expected_u32_elems = expected_bytes / 4;
4579        assert_eq!(
4580            weight_bytes.shape().elem_count(),
4581            expected_u32_elems,
4582            "qmatmul: weight_bytes has {} u32 elements, expected {expected_u32_elems} for N={n}, K={k}, {quant_type:?}",
4583            weight_bytes.shape().elem_count(),
4584        );
4585        let mut out_dims: Vec<usize> = a_dims[..a_dims.len() - 1].to_vec();
4586        out_dims.push(n);
4587        // Phase 7.6 step 4 (final): emits Op::Fused(QMATMUL, _) per
4588        // the registry split.
4589        let id = self.graph.write().unwrap().push(Node {
4590            op: Op::Fused(
4591                crate::registry::FusedOps::QMATMUL,
4592                crate::registry::FusedOpParams::QMatMul { quant_type, k, n },
4593            ),
4594            inputs: vec![self.id, weight_bytes.id],
4595            shape: Shape::from_dims(&out_dims),
4596            dtype: DType::F32,
4597        });
4598        Self {
4599            graph: self.graph.clone(),
4600            id,
4601        }
4602    }
4603
4604    /// Append an [`Op::Fused`] node carrying
4605    /// [`crate::registry::FusedOpParams::Conv2D`]. `self` must be `[N, Cin, H, W]`
4606    /// (rank 4); `weight` must be `[Cout, Cin/groups, Kh, Kw]` (rank
4607    /// 4) and live on the same graph. `bias` is optional — when
4608    /// present it must be rank 1 with length `Cout`. Returns a rank-4
4609    /// tensor of shape `[N, Cout, Hout, Wout]`.
4610    ///
4611    /// Panics if the input ranks don't match, the channel counts are
4612    /// inconsistent with `groups`, or the output spatial dims would
4613    /// be non-positive.
4614    ///
4615    /// # Panics
4616    ///
4617    /// Panics if any operand belongs to a different [`Graph`] than the one this
4618    /// builder is building into. Operand identity is graph-scoped and checked by
4619    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4620    /// deliberate and replaces the hang a cross-graph handle used to produce.
4621    /// Build every operand from one graph, or use a `Result`-returning entry
4622    /// point where one exists.
4623    pub fn conv2d(
4624        &self,
4625        weight: &NodeHandle,
4626        bias: Option<&NodeHandle>,
4627        stride: (usize, usize),
4628        padding: (usize, usize),
4629        groups: usize,
4630    ) -> NodeHandle {
4631        assert!(
4632            Arc::ptr_eq(&self.graph, &weight.graph),
4633            "conv2d: x and weight must live on the same graph; use `const_*_like` to build on an existing graph",
4634        );
4635        if let Some(b) = bias {
4636            assert!(
4637                Arc::ptr_eq(&self.graph, &b.graph),
4638                "conv2d: bias must live on the same graph; use `const_*_like` to build on an existing graph",
4639            );
4640        }
4641        assert!(groups >= 1, "conv2d: groups must be ≥ 1, got {groups}");
4642        let x_dims = self.shape();
4643        let x_dims = x_dims.dims();
4644        let w_dims = weight.shape();
4645        let w_dims = w_dims.dims();
4646        assert_eq!(
4647            x_dims.len(),
4648            4,
4649            "conv2d: x must be rank 4 [N, Cin, H, W], got {x_dims:?}",
4650        );
4651        assert_eq!(
4652            w_dims.len(),
4653            4,
4654            "conv2d: weight must be rank 4 [Cout, Cin/groups, Kh, Kw], got {w_dims:?}",
4655        );
4656        let (n, cin, h_in, w_in) = (x_dims[0], x_dims[1], x_dims[2], x_dims[3]);
4657        let (cout, cin_per_g, kh, kw) = (w_dims[0], w_dims[1], w_dims[2], w_dims[3]);
4658        assert_eq!(
4659            cin,
4660            cin_per_g * groups,
4661            "conv2d: x has {cin} in-channels but weight expects {} ({}·{groups})",
4662            cin_per_g * groups,
4663            cin_per_g,
4664        );
4665        assert_eq!(
4666            cout % groups,
4667            0,
4668            "conv2d: Cout={cout} must be divisible by groups={groups}",
4669        );
4670        if let Some(b) = bias {
4671            let b_dims = b.shape();
4672            let b_dims = b_dims.dims();
4673            assert_eq!(
4674                b_dims,
4675                &[cout],
4676                "conv2d: bias shape {b_dims:?} must match [Cout={cout}]",
4677            );
4678        }
4679        // Hout = (H + 2·pad.0 − Kh) / stride.0 + 1
4680        let (stride_h, stride_w) = stride;
4681        let (pad_h, pad_w) = padding;
4682        assert!(stride_h >= 1 && stride_w >= 1, "conv2d: stride must be ≥ 1");
4683        let h_padded = h_in + 2 * pad_h;
4684        let w_padded = w_in + 2 * pad_w;
4685        assert!(
4686            h_padded >= kh && w_padded >= kw,
4687            "conv2d: padded input ({h_padded}×{w_padded}) smaller than kernel ({kh}×{kw})",
4688        );
4689        let h_out = (h_padded - kh) / stride_h + 1;
4690        let w_out = (w_padded - kw) / stride_w + 1;
4691        let dtype = self.dtype();
4692        let mut inputs = vec![self.id, weight.id];
4693        if let Some(b) = bias {
4694            inputs.push(b.id);
4695        }
4696        // Phase 7.6 step 4 (continued): emit
4697        // `Op::Fused(FusedOps::CONV2D, FusedOpParams::Conv2D { … })`
4698        // through the registry-extended arm. The legacy
4699        // `Op::Conv2D { … }` variant remains in the enum during
4700        // migration (Conv2D's backward still constructs inner
4701        // `Op::Fused(CONV2D, _)` nodes; the legacy variant is reached
4702        // only by direct tests until step 5 drops it).
4703        let id = self.graph.write().unwrap().push(Node {
4704            op: Op::Fused(
4705                crate::registry::FusedOps::CONV2D,
4706                crate::registry::FusedOpParams::Conv2D {
4707                    stride,
4708                    padding,
4709                    groups,
4710                },
4711            ),
4712            inputs,
4713            shape: Shape::from_dims(&[n, cout, h_out, w_out]),
4714            dtype,
4715        });
4716        Self {
4717            graph: self.graph.clone(),
4718            id,
4719        }
4720    }
4721
4722    /// Append an [`Op::Fused`] node carrying
4723    /// [`crate::registry::FusedOpParams::PagedAttn`]. `self` is the Q tensor of shape
4724    /// `[B, Hq, Sq, D]`; `k_cache` and `v_cache` are the paged caches
4725    /// shaped `[num_blocks, block_size, Hkv, D]`; `block_table` is
4726    /// `[B, max_num_blocks_per_seq]` (u32) mapping logical → physical
4727    /// blocks; `context_lens` is `[B]` (u32) of per-sequence lengths;
4728    /// `alibi_slopes` is the optional `[Hq]` per-head bias.
4729    ///
4730    /// Returns a tensor with `q`'s shape `[B, Hq, Sq, D]`.
4731    ///
4732    /// # Panics
4733    ///
4734    /// Panics if any operand belongs to a different [`Graph`] than the one this
4735    /// builder is building into. Operand identity is graph-scoped and checked by
4736    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4737    /// deliberate and replaces the hang a cross-graph handle used to produce.
4738    /// Build every operand from one graph, or use a `Result`-returning entry
4739    /// point where one exists.
4740    pub fn paged_attn(
4741        &self,
4742        k_cache: &NodeHandle,
4743        v_cache: &NodeHandle,
4744        block_table: &NodeHandle,
4745        context_lens: &NodeHandle,
4746        alibi_slopes: Option<&NodeHandle>,
4747        softmax_scale: f32,
4748        block_size: usize,
4749        softcap: Option<f32>,
4750    ) -> NodeHandle {
4751        let g = &self.graph;
4752        assert!(
4753            Arc::ptr_eq(g, &k_cache.graph),
4754            "paged_attn: q + k_cache must share graph"
4755        );
4756        assert!(
4757            Arc::ptr_eq(g, &v_cache.graph),
4758            "paged_attn: q + v_cache must share graph"
4759        );
4760        assert!(
4761            Arc::ptr_eq(g, &block_table.graph),
4762            "paged_attn: q + block_table must share graph"
4763        );
4764        assert!(
4765            Arc::ptr_eq(g, &context_lens.graph),
4766            "paged_attn: q + context_lens must share graph"
4767        );
4768        if let Some(a) = alibi_slopes {
4769            assert!(
4770                Arc::ptr_eq(g, &a.graph),
4771                "paged_attn: alibi_slopes must share graph"
4772            );
4773        }
4774        assert!(block_size >= 1, "paged_attn: block_size must be ≥ 1");
4775
4776        let q_dims = self.shape();
4777        let q_dims = q_dims.dims();
4778        let kc_dims = k_cache.shape();
4779        let kc_dims = kc_dims.dims();
4780        let vc_dims = v_cache.shape();
4781        let vc_dims = vc_dims.dims();
4782        let bt_dims = block_table.shape();
4783        let bt_dims = bt_dims.dims();
4784        let cl_dims = context_lens.shape();
4785        let cl_dims = cl_dims.dims();
4786        assert_eq!(
4787            q_dims.len(),
4788            4,
4789            "paged_attn: q must be rank 4 [B, Hq, Sq, D], got {q_dims:?}"
4790        );
4791        assert_eq!(
4792            kc_dims.len(),
4793            4,
4794            "paged_attn: k_cache must be rank 4 [num_blocks, block_size, Hkv, D], got {kc_dims:?}"
4795        );
4796        assert_eq!(
4797            vc_dims.len(),
4798            4,
4799            "paged_attn: v_cache must be rank 4 [num_blocks, block_size, Hkv, D], got {vc_dims:?}"
4800        );
4801        assert_eq!(
4802            bt_dims.len(),
4803            2,
4804            "paged_attn: block_table must be rank 2 [B, max_blocks], got {bt_dims:?}"
4805        );
4806        assert_eq!(
4807            cl_dims.len(),
4808            1,
4809            "paged_attn: context_lens must be rank 1 [B], got {cl_dims:?}"
4810        );
4811        let (b, hq, _sq, d) = (q_dims[0], q_dims[1], q_dims[2], q_dims[3]);
4812        assert_eq!(
4813            kc_dims[1], block_size,
4814            "paged_attn: k_cache block dim {} != block_size {block_size}",
4815            kc_dims[1]
4816        );
4817        assert_eq!(
4818            vc_dims[1], block_size,
4819            "paged_attn: v_cache block dim {} != block_size {block_size}",
4820            vc_dims[1]
4821        );
4822        let hkv = kc_dims[2];
4823        assert_eq!(
4824            vc_dims[2], hkv,
4825            "paged_attn: Hkv mismatch k_cache vs v_cache"
4826        );
4827        assert_eq!(kc_dims[3], d, "paged_attn: D mismatch q vs k_cache");
4828        assert_eq!(vc_dims[3], d, "paged_attn: D mismatch q vs v_cache");
4829        assert_eq!(
4830            hq % hkv,
4831            0,
4832            "paged_attn: Hq={hq} must be a multiple of Hkv={hkv}"
4833        );
4834        assert_eq!(
4835            bt_dims[0], b,
4836            "paged_attn: block_table batch dim {} != B={b}",
4837            bt_dims[0]
4838        );
4839        assert_eq!(
4840            cl_dims[0], b,
4841            "paged_attn: context_lens len {} != B={b}",
4842            cl_dims[0]
4843        );
4844        assert_eq!(
4845            block_table.dtype(),
4846            crate::DType::U32,
4847            "paged_attn: block_table must be U32"
4848        );
4849        assert_eq!(
4850            context_lens.dtype(),
4851            crate::DType::U32,
4852            "paged_attn: context_lens must be U32"
4853        );
4854        if let Some(a) = alibi_slopes {
4855            let ad = a.shape();
4856            let ad = ad.dims();
4857            assert_eq!(
4858                ad,
4859                &[hq],
4860                "paged_attn: alibi_slopes must be [Hq={hq}], got {ad:?}"
4861            );
4862        }
4863
4864        let dtype = self.dtype();
4865        let mut inputs = vec![
4866            self.id,
4867            k_cache.id,
4868            v_cache.id,
4869            block_table.id,
4870            context_lens.id,
4871        ];
4872        if let Some(a) = alibi_slopes {
4873            inputs.push(a.id);
4874        }
4875        // Phase 7.6 step 4 (final): emits Op::Fused(PAGED_ATTN, _).
4876        let id = self.graph.write().unwrap().push(Node {
4877            op: Op::Fused(
4878                crate::registry::FusedOps::PAGED_ATTN,
4879                crate::registry::FusedOpParams::PagedAttn {
4880                    softmax_scale,
4881                    block_size,
4882                    softcap,
4883                },
4884            ),
4885            inputs,
4886            shape: Shape::from_dims(q_dims),
4887            dtype,
4888        });
4889        Self {
4890            graph: self.graph.clone(),
4891            id,
4892        }
4893    }
4894
4895    /// Append an [`Op::Fused`] node carrying
4896    /// [`crate::registry::FusedOpParams::ConvTranspose2D`]. `self` must be
4897    /// `[N, Cin, H, W]`; `weight` must be `[Cin, Cout/groups, Kh, Kw]`
4898    /// (note transposed channel order vs `conv2d`). Returns a rank-4
4899    /// tensor `[N, Cout, Hout, Wout]`.
4900    ///
4901    /// Panics if ranks don't match, channel counts are inconsistent
4902    /// with `groups`, or output spatial dims would be non-positive.
4903    ///
4904    /// # Panics
4905    ///
4906    /// Panics if any operand belongs to a different [`Graph`] than the one this
4907    /// builder is building into. Operand identity is graph-scoped and checked by
4908    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
4909    /// deliberate and replaces the hang a cross-graph handle used to produce.
4910    /// Build every operand from one graph, or use a `Result`-returning entry
4911    /// point where one exists.
4912    pub fn conv_transpose2d(
4913        &self,
4914        weight: &NodeHandle,
4915        stride: (usize, usize),
4916        padding: (usize, usize),
4917        output_padding: (usize, usize),
4918        dilation: (usize, usize),
4919        groups: usize,
4920    ) -> NodeHandle {
4921        assert!(
4922            Arc::ptr_eq(&self.graph, &weight.graph),
4923            "conv_transpose2d: x and weight must live on the same graph; use `const_*_like` to build on an existing graph",
4924        );
4925        assert!(
4926            groups >= 1,
4927            "conv_transpose2d: groups must be ≥ 1, got {groups}"
4928        );
4929        let x_dims = self.shape();
4930        let x_dims = x_dims.dims();
4931        let w_dims = weight.shape();
4932        let w_dims = w_dims.dims();
4933        assert_eq!(
4934            x_dims.len(),
4935            4,
4936            "conv_transpose2d: x must be rank 4 [N, Cin, H, W], got {x_dims:?}",
4937        );
4938        assert_eq!(
4939            w_dims.len(),
4940            4,
4941            "conv_transpose2d: weight must be rank 4 [Cin, Cout/groups, Kh, Kw], got {w_dims:?}",
4942        );
4943        let (n, cin, h_in, w_in) = (x_dims[0], x_dims[1], x_dims[2], x_dims[3]);
4944        let (cin_w, cout_per_g, kh, kw) = (w_dims[0], w_dims[1], w_dims[2], w_dims[3]);
4945        assert_eq!(
4946            cin, cin_w,
4947            "conv_transpose2d: x has {cin} in-channels but weight has {cin_w}",
4948        );
4949        assert_eq!(
4950            cin % groups,
4951            0,
4952            "conv_transpose2d: Cin={cin} must be divisible by groups={groups}",
4953        );
4954        let cout = cout_per_g * groups;
4955        let (stride_h, stride_w) = stride;
4956        let (pad_h, pad_w) = padding;
4957        let (out_pad_h, out_pad_w) = output_padding;
4958        let (dil_h, dil_w) = dilation;
4959        assert!(
4960            stride_h >= 1 && stride_w >= 1,
4961            "conv_transpose2d: stride must be ≥ 1"
4962        );
4963        assert!(
4964            dil_h >= 1 && dil_w >= 1,
4965            "conv_transpose2d: dilation must be ≥ 1"
4966        );
4967        // Hout = (Hin − 1)·stride − 2·pad + dil·(K − 1) + out_pad + 1
4968        let h_out = (h_in.saturating_sub(1)) * stride_h + dil_h * (kh - 1) + out_pad_h + 1;
4969        let w_out = (w_in.saturating_sub(1)) * stride_w + dil_w * (kw - 1) + out_pad_w + 1;
4970        assert!(
4971            h_out > 2 * pad_h && w_out > 2 * pad_w,
4972            "conv_transpose2d: padding ({pad_h}×{pad_w}) is larger than the produced output dims",
4973        );
4974        let h_out = h_out - 2 * pad_h;
4975        let w_out = w_out - 2 * pad_w;
4976        let dtype = self.dtype();
4977        // Phase 7.6 step 4 (final): emits Op::Fused(CONV_TRANSPOSE2D, _).
4978        let id = self.graph.write().unwrap().push(Node {
4979            op: Op::Fused(
4980                crate::registry::FusedOps::CONV_TRANSPOSE2D,
4981                crate::registry::FusedOpParams::ConvTranspose2D {
4982                    stride,
4983                    padding,
4984                    output_padding,
4985                    dilation,
4986                    groups,
4987                },
4988            ),
4989            inputs: vec![self.id, weight.id],
4990            shape: Shape::from_dims(&[n, cout, h_out, w_out]),
4991            dtype,
4992        });
4993        Self {
4994            graph: self.graph.clone(),
4995            id,
4996        }
4997    }
4998
4999    /// Append a transposed 1D convolution by lifting `self` and
5000    /// `weight` into the rank-4 path and dispatching through
5001    /// [`Self::conv_transpose2d`]. `self` is `[N, Cin, Lin]`;
5002    /// `weight` is `[Cin, Cout/groups, K]` (transposed channel
5003    /// order, matches PyTorch). Returns `[N, Cout, Lout]` where
5004    /// `Lout = (Lin − 1)·stride − 2·pad + dil·(K − 1) + out_pad + 1`.
5005    ///
5006    /// Used by audio codec models (DAC / EnCodec / SNAC / Mimi /
5007    /// Parler-TTS / MetaVoice / CSM) where the decoder upsamples
5008    /// quantized latents back to waveform with strided transposed
5009    /// convs.
5010    ///
5011    /// # Panics
5012    ///
5013    /// Panics if any operand belongs to a different [`Graph`] than the one this
5014    /// builder is building into. Operand identity is graph-scoped and checked by
5015    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
5016    /// deliberate and replaces the hang a cross-graph handle used to produce.
5017    /// Build every operand from one graph, or use a `Result`-returning entry
5018    /// point where one exists.
5019    pub fn conv_transpose1d(
5020        &self,
5021        weight: &NodeHandle,
5022        stride: usize,
5023        padding: usize,
5024        output_padding: usize,
5025        dilation: usize,
5026        groups: usize,
5027    ) -> NodeHandle {
5028        self.assert_same_graph(weight, "conv_transpose1d", "weight");
5029        let x_dims = self.shape();
5030        let x_dims = x_dims.dims();
5031        let w_dims = weight.shape();
5032        let w_dims = w_dims.dims();
5033        assert_eq!(
5034            x_dims.len(),
5035            3,
5036            "conv_transpose1d: x must be rank 3 [N, Cin, Lin], got {x_dims:?}",
5037        );
5038        assert_eq!(
5039            w_dims.len(),
5040            3,
5041            "conv_transpose1d: weight must be rank 3 [Cin, Cout/groups, K], got {w_dims:?}",
5042        );
5043        let (n, cin, l_in) = (x_dims[0], x_dims[1], x_dims[2]);
5044        let (cin_w, cout_per_g, k) = (w_dims[0], w_dims[1], w_dims[2]);
5045        let x4 = self.reshape(Shape::from_dims(&[n, cin, 1, l_in]));
5046        let w4 = weight.reshape(Shape::from_dims(&[cin_w, cout_per_g, 1, k]));
5047        let y4 = x4.conv_transpose2d(
5048            &w4,
5049            (1, stride),
5050            (0, padding),
5051            (0, output_padding),
5052            (1, dilation),
5053            groups,
5054        );
5055        let y_dims = y4.shape();
5056        let y_dims = y_dims.dims();
5057        let (n_o, cout, _h_one, l_out) = (y_dims[0], y_dims[1], y_dims[2], y_dims[3]);
5058        y4.reshape(Shape::from_dims(&[n_o, cout, l_out]))
5059    }
5060
5061    /// Append an [`Op::Fused`] node carrying
5062    /// [`crate::registry::FusedOpParams::FlashAttn`]. `self` is `q` of shape
5063    /// `[B, Hq, Sq, D]`; `k` and `v` are `[B, Hkv, Sk, D]` with
5064    /// `Hq` a multiple of `Hkv` (GQA). `alibi_slopes` (optional) is
5065    /// `[Hq]`. Returns a tensor with `q`'s shape.
5066    /// Build the `flash_attn` node.
5067    ///
5068    /// # Panics
5069    ///
5070    /// Panics if any operand belongs to a different [`Graph`] than the one this
5071    /// builder is building into. Operand identity is graph-scoped and checked by
5072    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
5073    /// deliberate and replaces the hang a cross-graph handle used to produce.
5074    /// Build every operand from one graph, or use a `Result`-returning entry
5075    /// point where one exists.
5076    pub fn flash_attn(
5077        &self,
5078        k: &NodeHandle,
5079        v: &NodeHandle,
5080        alibi_slopes: Option<&NodeHandle>,
5081        softmax_scale: f32,
5082        causal: bool,
5083        window_size_left: Option<usize>,
5084        window_size_right: Option<usize>,
5085        softcap: Option<f32>,
5086    ) -> NodeHandle {
5087        assert!(
5088            Arc::ptr_eq(&self.graph, &k.graph),
5089            "flash_attn: q + k must live on the same graph; use `const_*_like` to build on an existing graph"
5090        );
5091        assert!(
5092            Arc::ptr_eq(&self.graph, &v.graph),
5093            "flash_attn: q + v must live on the same graph; use `const_*_like` to build on an existing graph"
5094        );
5095        if let Some(a) = alibi_slopes {
5096            assert!(
5097                Arc::ptr_eq(&self.graph, &a.graph),
5098                "flash_attn: alibi_slopes must live on the same graph; use `const_*_like` to build on an existing graph"
5099            );
5100        }
5101        let q_dims = self.shape();
5102        let q_dims = q_dims.dims();
5103        let k_dims = k.shape();
5104        let k_dims = k_dims.dims();
5105        let v_dims = v.shape();
5106        let v_dims = v_dims.dims();
5107        assert_eq!(
5108            q_dims.len(),
5109            4,
5110            "flash_attn: q must be rank 4 [B, Hq, Sq, D], got {q_dims:?}"
5111        );
5112        assert_eq!(
5113            k_dims.len(),
5114            4,
5115            "flash_attn: k must be rank 4 [B, Hkv, Sk, D], got {k_dims:?}"
5116        );
5117        assert_eq!(
5118            v_dims.len(),
5119            4,
5120            "flash_attn: v must be rank 4 [B, Hkv, Sk, D], got {v_dims:?}"
5121        );
5122        let (b, hq, sq, d) = (q_dims[0], q_dims[1], q_dims[2], q_dims[3]);
5123        let (bk, hkv, sk, dk) = (k_dims[0], k_dims[1], k_dims[2], k_dims[3]);
5124        let (bv, hkv_v, sk_v, dv) = (v_dims[0], v_dims[1], v_dims[2], v_dims[3]);
5125        assert_eq!(b, bk, "flash_attn: B mismatch q vs k ({b} vs {bk})");
5126        assert_eq!(b, bv, "flash_attn: B mismatch q vs v");
5127        assert_eq!(hkv, hkv_v, "flash_attn: Hkv mismatch k vs v");
5128        assert_eq!(sk, sk_v, "flash_attn: Sk mismatch k vs v");
5129        assert_eq!(d, dk, "flash_attn: head_dim mismatch q vs k");
5130        assert_eq!(d, dv, "flash_attn: head_dim mismatch q vs v");
5131        assert_eq!(
5132            hq % hkv,
5133            0,
5134            "flash_attn: Hq={hq} must be a multiple of Hkv={hkv}"
5135        );
5136        if let Some(a) = alibi_slopes {
5137            let ad = a.shape();
5138            let ad = ad.dims();
5139            assert_eq!(
5140                ad,
5141                &[hq],
5142                "flash_attn: alibi_slopes must be [Hq={hq}], got {ad:?}"
5143            );
5144        }
5145        let dtype = self.dtype();
5146        let mut inputs = vec![self.id, k.id, v.id];
5147        if let Some(a) = alibi_slopes {
5148            inputs.push(a.id);
5149        }
5150        // Phase 7.6 step 4 (final): emits Op::Fused(FLASH_ATTN, _).
5151        let id = self.graph.write().unwrap().push(Node {
5152            op: Op::Fused(
5153                crate::registry::FusedOps::FLASH_ATTN,
5154                crate::registry::FusedOpParams::FlashAttn {
5155                    softmax_scale,
5156                    causal,
5157                    window_size_left,
5158                    window_size_right,
5159                    softcap,
5160                    k_len: None,
5161                },
5162            ),
5163            inputs,
5164            shape: Shape::from_dims(&[b, hq, sq, d]),
5165            dtype,
5166        });
5167        Self {
5168            graph: self.graph.clone(),
5169            id,
5170        }
5171    }
5172
5173    /// Append an [`Op::Fused`] node carrying
5174    /// [`crate::registry::FusedOpParams::FlashAttn`] over a fixed-**capacity** K/V whose
5175    /// attended length is a **runtime** value (`k_len`) resolved through
5176    /// the per-pass [`fuel_ir::SymEnv`] at realize, decoupled
5177    /// from K's allocated shape.
5178    ///
5179    /// `self` is `q` of shape `[B, Hq, Sq, D]`; `k`/`v` are the capacity
5180    /// buffers `[B, Hkv, max_seq, D]` (GQA: `Hq % Hkv == 0`). Only the
5181    /// first `k_len.resolve(env)` rows along the K/V length axis are
5182    /// attended; the causal mask (when `causal`) is bottom-right-aligned
5183    /// at offset `k_len - Sq` — the standard FlashAttention-2 convention,
5184    /// equal to `cached_len` in autoregressive decode. Returns a tensor
5185    /// with `q`'s shape.
5186    ///
5187    /// This is the flash counterpart of [`Self::write_slice_dyn`]: the
5188    /// decode KV-cache write appends at the runtime offset and flash
5189    /// attends the runtime prefix, so the graph structure stays
5190    /// identical across tokens (Phase D symbolic extents).
5191    ///
5192    /// # Panics
5193    ///
5194    /// Panics if any operand belongs to a different [`Graph`] than the one this
5195    /// builder is building into. Operand identity is graph-scoped and checked by
5196    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
5197    /// deliberate and replaces the hang a cross-graph handle used to produce.
5198    /// Build every operand from one graph, or use a `Result`-returning entry
5199    /// point where one exists.
5200    pub fn flash_attn_dyn(
5201        &self,
5202        k: &NodeHandle,
5203        v: &NodeHandle,
5204        alibi_slopes: Option<&NodeHandle>,
5205        softmax_scale: f32,
5206        causal: bool,
5207        window_size_left: Option<usize>,
5208        window_size_right: Option<usize>,
5209        softcap: Option<f32>,
5210        k_len: fuel_ir::DynScalar,
5211    ) -> NodeHandle {
5212        assert!(
5213            Arc::ptr_eq(&self.graph, &k.graph),
5214            "flash_attn_dyn: q + k must live on the same graph; use `const_*_like` to build on an existing graph"
5215        );
5216        assert!(
5217            Arc::ptr_eq(&self.graph, &v.graph),
5218            "flash_attn_dyn: q + v must live on the same graph; use `const_*_like` to build on an existing graph"
5219        );
5220        if let Some(a) = alibi_slopes {
5221            assert!(
5222                Arc::ptr_eq(&self.graph, &a.graph),
5223                "flash_attn_dyn: alibi_slopes must live on the same graph; use `const_*_like` to build on an existing graph"
5224            );
5225        }
5226        let q_dims = self.shape();
5227        let q_dims = q_dims.dims();
5228        let k_dims = k.shape();
5229        let k_dims = k_dims.dims();
5230        let v_dims = v.shape();
5231        let v_dims = v_dims.dims();
5232        assert_eq!(
5233            q_dims.len(),
5234            4,
5235            "flash_attn_dyn: q must be rank 4 [B, Hq, Sq, D], got {q_dims:?}"
5236        );
5237        assert_eq!(
5238            k_dims.len(),
5239            4,
5240            "flash_attn_dyn: k must be rank 4 [B, Hkv, max_seq, D], got {k_dims:?}"
5241        );
5242        assert_eq!(
5243            v_dims.len(),
5244            4,
5245            "flash_attn_dyn: v must be rank 4 [B, Hkv, max_seq, D], got {v_dims:?}"
5246        );
5247        let (b, hq, sq, d) = (q_dims[0], q_dims[1], q_dims[2], q_dims[3]);
5248        let (bk, hkv, sk, dk) = (k_dims[0], k_dims[1], k_dims[2], k_dims[3]);
5249        let (bv, hkv_v, sk_v, dv) = (v_dims[0], v_dims[1], v_dims[2], v_dims[3]);
5250        assert_eq!(b, bk, "flash_attn_dyn: B mismatch q vs k ({b} vs {bk})");
5251        assert_eq!(b, bv, "flash_attn_dyn: B mismatch q vs v");
5252        assert_eq!(hkv, hkv_v, "flash_attn_dyn: Hkv mismatch k vs v");
5253        assert_eq!(
5254            sk, sk_v,
5255            "flash_attn_dyn: capacity (max_seq) mismatch k vs v ({sk} vs {sk_v})"
5256        );
5257        assert_eq!(d, dk, "flash_attn_dyn: head_dim mismatch q vs k");
5258        assert_eq!(d, dv, "flash_attn_dyn: head_dim mismatch q vs v");
5259        assert_eq!(
5260            hq % hkv,
5261            0,
5262            "flash_attn_dyn: Hq={hq} must be a multiple of Hkv={hkv}"
5263        );
5264        // A build-time constant k_len must fit the capacity and cover Sq.
5265        if let fuel_ir::DynScalar::Concrete(kl) = k_len {
5266            assert!(
5267                kl <= sk,
5268                "flash_attn_dyn: k_len ({kl}) exceeds K capacity ({sk})"
5269            );
5270            assert!(
5271                kl >= sq,
5272                "flash_attn_dyn: k_len ({kl}) must be >= Sq ({sq}) for a valid causal prefix"
5273            );
5274        }
5275        if let Some(a) = alibi_slopes {
5276            let ad = a.shape();
5277            let ad = ad.dims();
5278            assert_eq!(
5279                ad,
5280                &[hq],
5281                "flash_attn_dyn: alibi_slopes must be [Hq={hq}], got {ad:?}"
5282            );
5283        }
5284        let dtype = self.dtype();
5285        let mut inputs = vec![self.id, k.id, v.id];
5286        if let Some(a) = alibi_slopes {
5287            inputs.push(a.id);
5288        }
5289        let id = self.graph.write().unwrap().push(Node {
5290            op: Op::Fused(
5291                crate::registry::FusedOps::FLASH_ATTN,
5292                crate::registry::FusedOpParams::FlashAttn {
5293                    softmax_scale,
5294                    causal,
5295                    window_size_left,
5296                    window_size_right,
5297                    softcap,
5298                    k_len: Some(k_len),
5299                },
5300            ),
5301            inputs,
5302            shape: Shape::from_dims(&[b, hq, sq, d]),
5303            dtype,
5304        });
5305        Self {
5306            graph: self.graph.clone(),
5307            id,
5308        }
5309    }
5310
5311    /// Append a `Relu` node `max(0, self)`.
5312    pub fn relu(&self) -> NodeHandle {
5313        self.unary_op(Op::Relu)
5314    }
5315
5316    /// Append a `Sqr` node `self * self`.
5317    pub fn sqr(&self) -> NodeHandle {
5318        self.unary_op(Op::Sqr)
5319    }
5320
5321    /// Append an `Exp` node `e^self`.
5322    pub fn exp(&self) -> NodeHandle {
5323        self.unary_op(Op::Exp)
5324    }
5325
5326    /// Append a `Permute` node rearranging the axes according to `axes`.
5327    /// `out.shape[i] = self.shape[axes[i]]`. The axes vector must be a
5328    /// permutation of `0..rank`. For a rank-3 input with `axes = [2, 0, 1]`,
5329    /// the shape transform is `[a, b, c] → [c, a, b]`.
5330    pub fn permute(&self, axes: &[usize]) -> NodeHandle {
5331        let in_shape = self.shape();
5332        let in_dims = in_shape.dims();
5333        let rank = in_dims.len();
5334        assert_eq!(
5335            axes.len(),
5336            rank,
5337            "permute: axes length {} must equal tensor rank {}",
5338            axes.len(),
5339            rank,
5340        );
5341        // Validate that axes is a permutation of 0..rank.
5342        let mut seen = vec![false; rank];
5343        for &ax in axes {
5344            assert!(
5345                ax < rank,
5346                "permute: axis {ax} out of bounds for rank {rank}"
5347            );
5348            assert!(!seen[ax], "permute: duplicate axis {ax} in axes");
5349            seen[ax] = true;
5350        }
5351        let out_dims: Vec<usize> = axes.iter().map(|&ax| in_dims[ax]).collect();
5352        let dtype = self.dtype();
5353        let id = self.graph.write().unwrap().push(Node {
5354            op: Op::Permute(axes.to_vec()),
5355            inputs: vec![self.id],
5356            shape: Shape::from_dims(&out_dims),
5357            dtype,
5358        });
5359        Self {
5360            graph: self.graph.clone(),
5361            id,
5362        }
5363    }
5364
5365    /// Result-returning sibling of [`Self::permute`]. Surfaces bad
5366    /// axes (wrong length, out-of-bounds entry, or duplicate) as a
5367    /// typed error rather than panicking.
5368    pub fn try_permute(&self, axes: &[usize]) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5369        let in_shape = self.shape();
5370        let rank = in_shape.dims().len();
5371        if axes.len() != rank {
5372            return Err(fuel_ir::Error::Msg(format!(
5373                "permute: axes length {} must equal tensor rank {}",
5374                axes.len(),
5375                rank,
5376            ))
5377            .bt());
5378        }
5379        let mut seen = vec![false; rank];
5380        for &ax in axes {
5381            if ax >= rank {
5382                return Err(fuel_ir::Error::Msg(format!(
5383                    "permute: axis {ax} out of bounds for rank {rank}",
5384                ))
5385                .bt());
5386            }
5387            if seen[ax] {
5388                return Err(
5389                    fuel_ir::Error::Msg(format!("permute: duplicate axis {ax} in axes",)).bt(),
5390                );
5391            }
5392            seen[ax] = true;
5393        }
5394        let op = Op::Permute(axes.to_vec());
5395        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
5396        let id = self.graph.write().unwrap().push(Node {
5397            op,
5398            inputs: vec![self.id],
5399            shape,
5400            dtype,
5401        });
5402        Ok(Self {
5403            graph: self.graph.clone(),
5404            id,
5405        })
5406    }
5407
5408    /// Append a `Transpose` node that swaps the last two dimensions of a
5409    /// tensor of rank ≥ 2. Leading dims are unchanged. For a rank-2
5410    /// tensor this is the ordinary matrix transpose; for higher ranks,
5411    /// every batch slice is transposed independently.
5412    pub fn transpose(&self) -> NodeHandle {
5413        let in_shape = self.shape();
5414        assert!(
5415            in_shape.dims().len() >= 2,
5416            "transpose: input must be rank ≥ 2, got shape {:?}",
5417            in_shape.dims(),
5418        );
5419        let op = Op::Transpose;
5420        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])
5421            .expect("transpose: rank validated above");
5422        let id = self.graph.write().unwrap().push(Node {
5423            op,
5424            inputs: vec![self.id],
5425            shape,
5426            dtype,
5427        });
5428        Self {
5429            graph: self.graph.clone(),
5430            id,
5431        }
5432    }
5433
5434    /// Result-returning sibling of [`Self::transpose`]. Surfaces
5435    /// rank < 2 as a typed error rather than panicking.
5436    pub fn try_transpose(&self) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5437        let in_shape = self.shape();
5438        if in_shape.dims().len() < 2 {
5439            return Err(fuel_ir::Error::Msg(format!(
5440                "transpose: input must be rank ≥ 2, got shape {:?}",
5441                in_shape.dims(),
5442            ))
5443            .bt());
5444        }
5445        let op = Op::Transpose;
5446        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
5447        let id = self.graph.write().unwrap().push(Node {
5448            op,
5449            inputs: vec![self.id],
5450            shape,
5451            dtype,
5452        });
5453        Ok(Self {
5454            graph: self.graph.clone(),
5455            id,
5456        })
5457    }
5458
5459    // --- additional element-wise binary ops ---
5460
5461    /// Append a `Sub` node `self - other`. Requires matching shapes and dtypes.
5462    ///
5463    /// # Panics
5464    ///
5465    /// Panics if any operand belongs to a different [`Graph`] than the one this
5466    /// builder is building into. Operand identity is graph-scoped and checked by
5467    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
5468    /// deliberate and replaces the hang a cross-graph handle used to produce.
5469    /// Build every operand from one graph, or use a `Result`-returning entry
5470    /// point where one exists.
5471    pub fn sub(&self, other: &NodeHandle) -> NodeHandle {
5472        self.binary_op("sub", Op::Sub, other, self.shape())
5473    }
5474
5475    /// Append a `Div` node `self / other`. Requires matching shapes and dtypes.
5476    ///
5477    /// # Panics
5478    ///
5479    /// Panics if any operand belongs to a different [`Graph`] than the one this
5480    /// builder is building into. Operand identity is graph-scoped and checked by
5481    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
5482    /// deliberate and replaces the hang a cross-graph handle used to produce.
5483    /// Build every operand from one graph, or use a `Result`-returning entry
5484    /// point where one exists.
5485    pub fn div(&self, other: &NodeHandle) -> NodeHandle {
5486        self.binary_op("div", Op::Div, other, self.shape())
5487    }
5488
5489    // --- additional element-wise unary ops ---
5490
5491    /// Append a `Neg` node `-self`.
5492    pub fn neg(&self) -> NodeHandle {
5493        self.unary_op(Op::Neg)
5494    }
5495
5496    /// Append a `Sqrt` node `sqrt(self)`.
5497    pub fn sqrt(&self) -> NodeHandle {
5498        self.unary_op(Op::Sqrt)
5499    }
5500
5501    /// Append a `Log` node `ln(self)`.
5502    pub fn log(&self) -> NodeHandle {
5503        self.unary_op(Op::Log)
5504    }
5505
5506    /// Append a `Sin` node.
5507    pub fn sin(&self) -> NodeHandle {
5508        self.unary_op(Op::Sin)
5509    }
5510
5511    /// Append a `Cos` node.
5512    pub fn cos(&self) -> NodeHandle {
5513        self.unary_op(Op::Cos)
5514    }
5515
5516    /// Append a `Tanh` node.
5517    pub fn tanh(&self) -> NodeHandle {
5518        self.unary_op(Op::Tanh)
5519    }
5520
5521    /// Append a `Sigmoid` node.
5522    pub fn sigmoid(&self) -> NodeHandle {
5523        self.unary_op(Op::Sigmoid)
5524    }
5525
5526    /// Append a `Silu` node (SiLU/Swish activation: `x · sigmoid(x)`).
5527    pub fn silu(&self) -> NodeHandle {
5528        self.unary_op(Op::Silu)
5529    }
5530
5531    /// Append a `GeluTanh` node (tanh-approximation GELU).
5532    pub fn gelu_tanh(&self) -> NodeHandle {
5533        self.unary_op(Op::GeluTanh)
5534    }
5535
5536    /// Append a `Step` node (Heaviside step: `1` where `x > 0`, else `0`).
5537    pub fn step(&self) -> NodeHandle {
5538        self.unary_op(Op::Step)
5539    }
5540
5541    /// Append a `Recip` node (`1 / self`).
5542    pub fn recip(&self) -> NodeHandle {
5543        self.unary_op(Op::Recip)
5544    }
5545
5546    /// Append an `Abs` node (`|self|`).
5547    pub fn abs(&self) -> NodeHandle {
5548        self.unary_op(Op::Abs)
5549    }
5550
5551    // ---- In-place unary builders (Phase 2 of the in-place ops
5552    // infrastructure) ----
5553    //
5554    // Each emits an `Op::*Inplace` node whose input is `self`. The
5555    // returned `NodeHandle` shares the underlying graph and is the new
5556    // root; subsequent ops should use the returned handle to make the
5557    // mutation-after-read ordering observable to `derive_ordering`.
5558    //
5559    // Phase 4 (the mutation-safety pass) is what makes these safe to
5560    // call on tape-tracked tensors. Until Phase 4 lands, calling these
5561    // on a tensor that's been saved for backward will panic at
5562    // `NodeHandle::backward` time (clear error, no silent gradient
5563    // corruption).
5564
5565    /// Append a `ReluInplace` node — mutates `self`'s storage with
5566    /// `max(0, self)`. See `NodeHandle::relu` for the functional variant.
5567    pub fn relu_inplace(&self) -> NodeHandle {
5568        self.unary_op(Op::ReluInplace)
5569    }
5570
5571    /// Append a `SiluInplace` node — mutates `self`'s storage with
5572    /// `self * sigmoid(self)`. See `NodeHandle::silu` for the functional
5573    /// variant.
5574    pub fn silu_inplace(&self) -> NodeHandle {
5575        self.unary_op(Op::SiluInplace)
5576    }
5577
5578    /// Append a `GeluTanhInplace` node — mutates `self`'s storage with
5579    /// the tanh-approximation GELU. See `NodeHandle::gelu_tanh` for the
5580    /// functional variant.
5581    pub fn gelu_tanh_inplace(&self) -> NodeHandle {
5582        self.unary_op(Op::GeluTanhInplace)
5583    }
5584
5585    /// Append a `TanhInplace` node — mutates `self`'s storage with
5586    /// `tanh(self)`. See `NodeHandle::tanh` for the functional variant.
5587    pub fn tanh_inplace(&self) -> NodeHandle {
5588        self.unary_op(Op::TanhInplace)
5589    }
5590
5591    /// Append a `SigmoidInplace` node — mutates `self`'s storage
5592    /// with `sigmoid(self)`. See `NodeHandle::sigmoid` for the functional
5593    /// variant.
5594    pub fn sigmoid_inplace(&self) -> NodeHandle {
5595        self.unary_op(Op::SigmoidInplace)
5596    }
5597
5598    /// Append a `NegInplace` node — mutates `self`'s storage with
5599    /// `-self`. See `NodeHandle::neg` for the functional variant.
5600    pub fn neg_inplace(&self) -> NodeHandle {
5601        self.unary_op(Op::NegInplace)
5602    }
5603
5604    /// Append an `AbsInplace` node — mutates `self`'s storage with
5605    /// `|self|`. See `NodeHandle::abs` for the functional variant.
5606    pub fn abs_inplace(&self) -> NodeHandle {
5607        self.unary_op(Op::AbsInplace)
5608    }
5609
5610    /// Append a `SqrInplace` node — mutates `self`'s storage with
5611    /// `self²`. See `NodeHandle::sqr` for the functional variant.
5612    pub fn sqr_inplace(&self) -> NodeHandle {
5613        self.unary_op(Op::SqrInplace)
5614    }
5615
5616    /// Append a `SqrtInplace` node — mutates `self`'s storage with
5617    /// `√self`. See `NodeHandle::sqrt` for the functional variant.
5618    pub fn sqrt_inplace(&self) -> NodeHandle {
5619        self.unary_op(Op::SqrtInplace)
5620    }
5621
5622    /// Append a `RsqrtInplace` node — mutates `self`'s storage with
5623    /// `1/√self`. See `NodeHandle::rsqrt` for the functional variant.
5624    pub fn rsqrt_inplace(&self) -> NodeHandle {
5625        self.unary_op(Op::RsqrtInplace)
5626    }
5627
5628    /// Append a `RecipInplace` node — mutates `self`'s storage with
5629    /// `1/self`. See `NodeHandle::recip` for the functional variant.
5630    pub fn recip_inplace(&self) -> NodeHandle {
5631        self.unary_op(Op::RecipInplace)
5632    }
5633
5634    /// Append an `ExpInplace` node — mutates `self`'s storage with
5635    /// `exp(self)`. See `NodeHandle::exp` for the functional variant.
5636    pub fn exp_inplace(&self) -> NodeHandle {
5637        self.unary_op(Op::ExpInplace)
5638    }
5639
5640    /// Append a `LogInplace` node — mutates `self`'s storage with
5641    /// `ln(self)`. See `NodeHandle::log` for the functional variant.
5642    pub fn log_inplace(&self) -> NodeHandle {
5643        self.unary_op(Op::LogInplace)
5644    }
5645
5646    /// Append a `SinInplace` node — mutates `self`'s storage with
5647    /// `sin(self)`. See `NodeHandle::sin` for the functional variant.
5648    pub fn sin_inplace(&self) -> NodeHandle {
5649        self.unary_op(Op::SinInplace)
5650    }
5651
5652    /// Append a `CosInplace` node — mutates `self`'s storage with
5653    /// `cos(self)`. See `NodeHandle::cos` for the functional variant.
5654    pub fn cos_inplace(&self) -> NodeHandle {
5655        self.unary_op(Op::CosInplace)
5656    }
5657
5658    /// Append a `SignInplace` node — mutates `self`'s storage with
5659    /// `sign(self)`. See `NodeHandle::sign` for the functional variant.
5660    pub fn sign_inplace(&self) -> NodeHandle {
5661        self.unary_op(Op::SignInplace)
5662    }
5663
5664    /// Append a `FloorInplace` node — mutates `self`'s storage with
5665    /// `⌊self⌋`. See `NodeHandle::floor` for the functional variant.
5666    pub fn floor_inplace(&self) -> NodeHandle {
5667        self.unary_op(Op::FloorInplace)
5668    }
5669
5670    /// Append a `CeilInplace` node — mutates `self`'s storage with
5671    /// `⌈self⌉`. See `NodeHandle::ceil` for the functional variant.
5672    pub fn ceil_inplace(&self) -> NodeHandle {
5673        self.unary_op(Op::CeilInplace)
5674    }
5675
5676    /// Append a `RoundInplace` node — mutates `self`'s storage with
5677    /// `round(self)`. See `NodeHandle::round` for the functional variant.
5678    pub fn round_inplace(&self) -> NodeHandle {
5679        self.unary_op(Op::RoundInplace)
5680    }
5681
5682    /// Append an `ErfInplace` node — mutates `self`'s storage with
5683    /// `erf(self)`. See `NodeHandle::erf` for the functional variant.
5684    pub fn erf_inplace(&self) -> NodeHandle {
5685        self.unary_op(Op::ErfInplace)
5686    }
5687
5688    /// Append a `GeluInplace` node — mutates `self`'s storage
5689    /// with the exact-GeLU formula. See `NodeHandle::gelu` for the
5690    /// functional variant.
5691    pub fn gelu_inplace(&self) -> NodeHandle {
5692        self.unary_op(Op::GeluInplace)
5693    }
5694
5695    /// Append a `ClampInplace { min, max }` node — mutates `self`'s
5696    /// storage with `clamp(self, min, max)`. See `NodeHandle::clamp` for
5697    /// the functional variant.
5698    pub fn clamp_inplace(&self, min: f64, max: f64) -> NodeHandle {
5699        self.unary_op(Op::ClampInplace { min, max })
5700    }
5701
5702    /// Append a `PowIInplace(exp)` node — mutates `self`'s storage
5703    /// with `self.powi(exp)`. See `NodeHandle::powi` for the functional
5704    /// variant.
5705    pub fn powi_inplace(&self, exp: i32) -> NodeHandle {
5706        self.unary_op(Op::PowIInplace(exp))
5707    }
5708
5709    /// Append an `Op::Fused(INPLACE_AFFINE, ...)` node — mutates
5710    /// `self`'s storage with `self = mul · self + add`. Single-input
5711    /// fused op (no functional `Op::Affine` exists on
5712    /// `fuel-graph::Op`; the non-inplace equivalent is `self
5713    /// .mul_scalar(mul).add_scalar(add)`). Wired to baracuda's
5714    /// `affine_inplace_*` symbol on CUDA when Phase 3 lands.
5715    pub fn affine_inplace(&self, mul: f64, add: f64) -> NodeHandle {
5716        let shape = self.shape();
5717        let dtype = self.dtype();
5718        let id = self.graph.write().unwrap().push(Node {
5719            op: Op::Fused(
5720                crate::registry::FusedOps::INPLACE_AFFINE,
5721                crate::registry::FusedOpParams::InplaceAffine { mul, add },
5722            ),
5723            inputs: vec![self.id],
5724            shape,
5725            dtype,
5726        });
5727        Self {
5728            graph: self.graph.clone(),
5729            id,
5730        }
5731    }
5732
5733    /// Append a `Floor` node (`⌊self⌋`). Output dtype = input dtype.
5734    /// Backward is the zero distribution almost everywhere; gradient
5735    /// is dropped silently.
5736    pub fn floor(&self) -> NodeHandle {
5737        self.unary_op(Op::Floor)
5738    }
5739
5740    /// Append a `Ceil` node (`⌈self⌉`). Output dtype = input dtype.
5741    /// Backward drops gradient (non-differentiable almost everywhere).
5742    pub fn ceil(&self) -> NodeHandle {
5743        self.unary_op(Op::Ceil)
5744    }
5745
5746    /// Append a `Round` node — banker's rounding (round-half-to-even,
5747    /// IEEE 754 roundeven). 0.5 → 0, 1.5 → 2, 2.5 → 2, 3.5 → 4.
5748    /// Output dtype = input dtype. Backward drops gradient.
5749    pub fn round(&self) -> NodeHandle {
5750        self.unary_op(Op::Round)
5751    }
5752
5753    /// Append a `Sign` node (`-1` / `0` / `1`). `sign(0) = 0` by
5754    /// subgradient convention. Output dtype = input dtype. Backward
5755    /// drops gradient.
5756    pub fn sign(&self) -> NodeHandle {
5757        self.unary_op(Op::Sign)
5758    }
5759
5760    /// Append an `Erf` node (Gauss error function). Output dtype =
5761    /// input dtype. Differentiable.
5762    pub fn erf(&self) -> NodeHandle {
5763        self.unary_op(Op::Erf)
5764    }
5765
5766    /// Append a `Gelu` node — exact-erf formulation of GELU
5767    /// (`0.5 * x * (1 + erf(x/√2))`). Distinct from [`Self::gelu_tanh`]
5768    /// (tanh approximation). Output dtype = input dtype.
5769    /// Differentiable.
5770    pub fn gelu(&self) -> NodeHandle {
5771        self.unary_op(Op::Gelu)
5772    }
5773
5774    /// Append a `Pow` node `pow(self, other)` element-wise (real
5775    /// exponent). Both operands must share dtype and shape. Distinct
5776    /// from [`Self::powi`] (scalar `i32` exponent). NaN follows
5777    /// IEEE-754 (`pow(-2, 0.5) = NaN`).
5778    ///
5779    /// **Returns `Result`**: dtype/shape mismatch surfaces as a
5780    /// typed error, not a panic.
5781    pub fn pow(&self, other: &NodeHandle) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5782        let out_shape = self.shape();
5783        self.try_binary_op("pow", Op::Pow, other, out_shape)
5784    }
5785
5786    /// Append an `Rsqrt` node (`1 / sqrt(self)`). Same dtype as
5787    /// input. Single op rather than `sqrt(x).recip()` — one kernel
5788    /// launch and matches RMSNorm's `x * rsqrt(...)` shape.
5789    /// Differentiable.
5790    pub fn rsqrt(&self) -> NodeHandle {
5791        self.unary_op(Op::Rsqrt)
5792    }
5793
5794    /// Append a `Rem` node `self % other` element-wise (PyTorch
5795    /// convention: `a - floor(a/b) * b`, sign of result matches
5796    /// divisor). Both operands must share dtype and shape.
5797    /// Differentiable.
5798    ///
5799    /// **Returns `Result`**: dtype/shape mismatch surfaces as a
5800    /// typed error, not a panic.
5801    pub fn rem(&self, other: &NodeHandle) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5802        let out_shape = self.shape();
5803        self.try_binary_op("rem", Op::Rem, other, out_shape)
5804    }
5805
5806    /// Append a `Flip` node — reverses element order along `dim`.
5807    /// Output shape == input shape. Materializing op (real byte
5808    /// shuffle; not a metadata-only view). Differentiable
5809    /// (involutive: backward is another Flip on the same dim).
5810    ///
5811    /// **Returns `Result`**: bad `dim` surfaces as a typed error.
5812    pub fn flip(&self, dim: usize) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5813        let in_shape = self.shape();
5814        let rank = in_shape.dims().len();
5815        if dim >= rank {
5816            return Err(fuel_ir::Error::Msg(format!(
5817                "flip: dim {dim} out of bounds for rank {rank}",
5818            ))
5819            .bt());
5820        }
5821        let op = Op::Flip { dim };
5822        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
5823        let id = self.graph.write().unwrap().push(Node {
5824            op,
5825            inputs: vec![self.id],
5826            shape,
5827            dtype,
5828        });
5829        Ok(Self {
5830            graph: self.graph.clone(),
5831            id,
5832        })
5833    }
5834
5835    /// Append a `Roll` node — cyclic shift along `dim` by `shift`
5836    /// positions. Positive `shift` moves elements to higher indices
5837    /// (wrapping); negative the opposite. Output shape == input
5838    /// shape. Differentiable (backward is `Roll { dim, -shift }`).
5839    ///
5840    /// **Returns `Result`**: bad `dim` surfaces as a typed error.
5841    pub fn roll(&self, dim: usize, shift: i64) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5842        let in_shape = self.shape();
5843        let rank = in_shape.dims().len();
5844        if dim >= rank {
5845            return Err(fuel_ir::Error::Msg(format!(
5846                "roll: dim {dim} out of bounds for rank {rank}",
5847            ))
5848            .bt());
5849        }
5850        let op = Op::Roll { dim, shift };
5851        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
5852        let id = self.graph.write().unwrap().push(Node {
5853            op,
5854            inputs: vec![self.id],
5855            shape,
5856            dtype,
5857        });
5858        Ok(Self {
5859            graph: self.graph.clone(),
5860            id,
5861        })
5862    }
5863
5864    /// Append a `CumSum` node — running cumulative sum along `dim`.
5865    /// Output shape == input shape. Differentiable; backward is
5866    /// reverse-cumsum (`Flip → CumSum → Flip`).
5867    ///
5868    /// **Returns `Result`**: bad `dim` surfaces as a typed error.
5869    pub fn cumsum(&self, dim: usize) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5870        let in_shape = self.shape();
5871        let rank = in_shape.dims().len();
5872        if dim >= rank {
5873            return Err(fuel_ir::Error::Msg(format!(
5874                "cumsum: dim {dim} out of bounds for rank {rank}",
5875            ))
5876            .bt());
5877        }
5878        let dtype = self.dtype();
5879        let id = self.graph.write().unwrap().push(Node {
5880            op: Op::CumSum { dim },
5881            inputs: vec![self.id],
5882            shape: in_shape,
5883            dtype,
5884        });
5885        Ok(Self {
5886            graph: self.graph.clone(),
5887            id,
5888        })
5889    }
5890
5891    /// Append a `Triu` node — upper-triangular mask along the last two
5892    /// dims. `diagonal = 0` keeps the main diagonal and above; positive
5893    /// values shift the cutoff up (keeping less); negative shift down
5894    /// (keeping more, including subdiagonals).
5895    ///
5896    /// **Returns `Result`**: rank < 2 surfaces as a typed error.
5897    pub fn triu(&self, diagonal: i64) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5898        let in_shape = self.shape();
5899        let rank = in_shape.dims().len();
5900        if rank < 2 {
5901            return Err(fuel_ir::Error::Msg(format!(
5902                "triu: input must have rank >= 2, got {rank}",
5903            ))
5904            .bt());
5905        }
5906        let dtype = self.dtype();
5907        let id = self.graph.write().unwrap().push(Node {
5908            op: Op::Triu { diagonal },
5909            inputs: vec![self.id],
5910            shape: in_shape,
5911            dtype,
5912        });
5913        Ok(Self {
5914            graph: self.graph.clone(),
5915            id,
5916        })
5917    }
5918
5919    /// Append an [`Op::Scan`] node — a bounded `lax.scan` recurrence.
5920    /// `self` is the `init_carry`. The body must already be built in the same
5921    /// graph, referencing `Op::ScanPlaceholder{Carry,0}` for the per-step
5922    /// carry and `Op::ScanPlaceholder{Elem,i}` for the per-step slice of
5923    /// `xs[i]`; `consts` are referenced by real NodeId. `body_new_carry` /
5924    /// `body_y` are the body's two exit nodes. Always a 2-slot bundle
5925    /// (slot 0 = stacked `ys` `[bound] ++ body_y`, slot 1 = final carry);
5926    /// `emit` selects which slot this call projects. `early_exit` is Phase 2
5927    /// (always `None` here).
5928    ///
5929    /// **Returns `Result`**: a malformed body/carry/bound surfaces as a typed
5930    /// error — never a panic.
5931    pub fn scan(
5932        &self,
5933        xs: &[NodeHandle],
5934        consts: &[NodeHandle],
5935        body_new_carry: &NodeHandle,
5936        body_y: &NodeHandle,
5937        bound: usize,
5938        emit: ScanEmit,
5939    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
5940        let same_graph = |t: &NodeHandle| Arc::ptr_eq(&self.graph, &t.graph);
5941        if !same_graph(body_new_carry)
5942            || !same_graph(body_y)
5943            || !xs.iter().all(same_graph)
5944            || !consts.iter().all(same_graph)
5945        {
5946            return Err(fuel_ir::Error::Msg(
5947                "scan: init_carry, xs, consts, and body exits must live on one graph".into(),
5948            )
5949            .bt());
5950        }
5951        if bound == 0 {
5952            return Err(fuel_ir::Error::Msg("scan: bound must be >= 1".into()).bt());
5953        }
5954        let carry_shape = self.shape();
5955        if body_new_carry.shape().dims() != carry_shape.dims() {
5956            return Err(fuel_ir::Error::Msg(format!(
5957                "scan: body_new_carry shape {:?} must equal init_carry shape {:?}",
5958                body_new_carry.shape().dims(),
5959                carry_shape.dims(),
5960            ))
5961            .bt());
5962        }
5963        // Validate body placeholders at graph-build time (single-carry; every
5964        // Elem index < n_xs) so a malformed body is a typed Err here, not a
5965        // later `elem[index]` panic on the unroll / forward-driver path.
5966        {
5967            let g = self.graph.read().unwrap();
5968            crate::scan::validate_scan_body_placeholders(
5969                &g,
5970                &[body_new_carry.id, body_y.id],
5971                // GAP-303: the BUILDER is still single-carry (see the Op::Scan
5972                // construction below); the IR and the unroll are not.
5973                1,
5974                xs.len(),
5975            )?;
5976        }
5977        let carry_dtype = self.dtype();
5978        let y_dtype = body_y.dtype();
5979
5980        // 2-slot bundle: slot 0 = stacked ys, slot 1 = final carry.
5981        let mut ys_dims: Vec<usize> = Vec::with_capacity(1 + body_y.shape().dims().len());
5982        ys_dims.push(bound);
5983        ys_dims.extend_from_slice(body_y.shape().dims());
5984        let ys_shape = Shape::from_dims(&ys_dims);
5985        let specs = vec![
5986            fuel_ir::storage::OutputViewSpec::contiguous(y_dtype, ys_shape.clone()),
5987            fuel_ir::storage::OutputViewSpec::contiguous(carry_dtype, carry_shape.clone()),
5988        ];
5989        let (_bytes, views) = fuel_ir::storage::compose_bundle(&specs)
5990            .map_err(|e| fuel_ir::Error::Msg(format!("scan: compose_bundle failed: {e}")).bt())?;
5991
5992        let mut inputs: Vec<NodeId> = Vec::with_capacity(2 + xs.len() + consts.len() + 2);
5993        inputs.push(self.id);
5994        inputs.extend(xs.iter().map(|t| t.id));
5995        inputs.extend(consts.iter().map(|t| t.id));
5996        inputs.push(body_new_carry.id);
5997        inputs.push(body_y.id);
5998
5999        let id = {
6000            let mut g = self.graph.write().unwrap();
6001            // Node.shape/dtype are the PRIMARY (slot-0) shape/dtype per the
6002            // multi-output authoring contract.
6003            let id = g.push(Node {
6004                op: Op::Scan {
6005                    // ⚠️ GAP-303 SCOPE LINE: the IR, the validator, `unroll_scan`,
6006                    // `parse_scan_layout` and `build_scan_step` all handle N
6007                    // carries. This BUILDER deliberately still constructs ONE.
6008                    //
6009                    // Widening it means N+1 output-view specs through
6010                    // `compose_bundle` below -- i.e. the bundle arity -- which is
6011                    // GAP-303 row 2 (`Op::ScatterIntoSlot`, kernel-less today) and
6012                    // is NOT this change. A reader must not read "the builder makes
6013                    // one" as "multi-carry does not work".
6014                    n_carries: 1,
6015                    n_xs: xs.len(),
6016                    bound,
6017                    emit,
6018                    early_exit: None,
6019                },
6020                inputs,
6021                shape: ys_shape,
6022                dtype: y_dtype,
6023            });
6024            g.set_output_views(id, Arc::from(views.into_boxed_slice()))
6025                .map_err(|e| {
6026                    fuel_ir::Error::Msg(format!("scan: set_output_views failed: {e}")).bt()
6027                })?;
6028            id
6029        };
6030        let producer = Self {
6031            graph: self.graph.clone(),
6032            id,
6033        };
6034        match emit {
6035            ScanEmit::All => producer.view(0),
6036            ScanEmit::Final => producer.view(1),
6037        }
6038    }
6039
6040    /// Append an early-exit [`Op::Scan`] node — the `Result`-returning sibling
6041    /// of [`NodeHandle::scan`] that carries a convergence predicate as the extra
6042    /// trailing input (`early_exit = Some(ScanPredicate)`). `self` is the
6043    /// `init_carry`; `pred_exit` is a scalar-`U8` sub-DAG over the carry
6044    /// (`Op::ScanPlaceholder{Carry,0}` for the pre-step carry, and the shared
6045    /// `body_new_carry` node for the post-step carry) that stops iteration when
6046    /// it fires. The runtime stop count is data-dependent under the static
6047    /// capacity `bound`.
6048    ///
6049    /// **`consts` must include every const referenced by the body OR the
6050    /// predicate** — the clone/unroll machinery shares only `consts`; a
6051    /// predicate const outside `consts` would be re-cloned dataless and fail at
6052    /// realize.
6053    ///
6054    /// **Returns `Result`**: a malformed body/carry/bound, or a non-scalar /
6055    /// non-`U8` / `Elem`-referencing predicate, surfaces as a typed error —
6056    /// never a panic (validated at graph-build time).
6057    pub fn scan_until(
6058        &self,
6059        xs: &[NodeHandle],
6060        consts: &[NodeHandle],
6061        body_new_carry: &NodeHandle,
6062        body_y: &NodeHandle,
6063        pred_exit: &NodeHandle,
6064        bound: usize,
6065        emit: ScanEmit,
6066    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6067        let same_graph = |t: &NodeHandle| Arc::ptr_eq(&self.graph, &t.graph);
6068        if !same_graph(body_new_carry)
6069            || !same_graph(body_y)
6070            || !same_graph(pred_exit)
6071            || !xs.iter().all(same_graph)
6072            || !consts.iter().all(same_graph)
6073        {
6074            return Err(fuel_ir::Error::Msg(
6075                "scan_until: init_carry, xs, consts, body exits, and pred_exit must live on one graph".into(),
6076            ).bt());
6077        }
6078        if bound == 0 {
6079            return Err(fuel_ir::Error::Msg("scan_until: bound must be >= 1".into()).bt());
6080        }
6081        let carry_shape = self.shape();
6082        if body_new_carry.shape().dims() != carry_shape.dims() {
6083            return Err(fuel_ir::Error::Msg(format!(
6084                "scan_until: body_new_carry shape {:?} must equal init_carry shape {:?}",
6085                body_new_carry.shape().dims(),
6086                carry_shape.dims(),
6087            ))
6088            .bt());
6089        }
6090        // Validate body placeholders at graph-build time (single-carry; every
6091        // Elem index < n_xs) so a malformed body is a typed Err here, not a
6092        // later `elem[index]` panic in the forward driver's build_scan_step.
6093        {
6094            let g = self.graph.read().unwrap();
6095            crate::scan::validate_scan_body_placeholders(
6096                &g,
6097                &[body_new_carry.id, body_y.id],
6098                // GAP-303: the BUILDER is still single-carry (see the Op::Scan
6099                // construction below); the IR and the unroll are not.
6100                1,
6101                xs.len(),
6102            )?;
6103        }
6104        // Predicate must be a scalar Bool (shape [] or product == 1) — a
6105        // convergence flag, not a per-element mask. Validate at build time.
6106        // GAP-168(c): Bool, not U8. A predicate's natural producer is a
6107        // comparison, and those now yield Bool, so requiring U8 here would make
6108        // the ordinary `carry.ge(&thr)` path unusable as a predicate.
6109        if pred_exit.dtype() != fuel_ir::DType::Bool {
6110            return Err(fuel_ir::Error::Msg(format!(
6111                "scan_until: pred_exit must be Bool (a boolean flag), got {:?}",
6112                pred_exit.dtype(),
6113            ))
6114            .bt());
6115        }
6116        let pred_dims = pred_exit.shape();
6117        let pred_numel: usize = pred_dims.dims().iter().product();
6118        if pred_numel != 1 {
6119            return Err(fuel_ir::Error::Msg(format!(
6120                "scan_until: pred_exit must be a scalar (shape [] or [1]/all-ones), got {:?}",
6121                pred_dims.dims(),
6122            ))
6123            .bt());
6124        }
6125        // Every ScanPlaceholder reachable from pred_exit must be Carry{0} — the
6126        // predicate is over the carry, never a per-step Elem slice.
6127        {
6128            let g = self.graph.read().unwrap();
6129            for &id in &crate::topo_order_multi(&g, &[pred_exit.id]) {
6130                if let Op::ScanPlaceholder { role, index } = &g.node(id).op {
6131                    let bad = matches!(role, ScanRole::Elem) || *index != 0;
6132                    if bad {
6133                        return Err(fuel_ir::Error::Msg(format!(
6134                            "scan_until: pred_exit references ScanPlaceholder{{{role:?}, {index}}} — \
6135                             the predicate may only read the carry (Carry, index 0)",
6136                        )).bt());
6137                    }
6138                }
6139            }
6140        }
6141        let carry_dtype = self.dtype();
6142        let y_dtype = body_y.dtype();
6143
6144        // 2-slot bundle: slot 0 = stacked ys, slot 1 = final carry.
6145        let mut ys_dims: Vec<usize> = Vec::with_capacity(1 + body_y.shape().dims().len());
6146        ys_dims.push(bound);
6147        ys_dims.extend_from_slice(body_y.shape().dims());
6148        let ys_shape = Shape::from_dims(&ys_dims);
6149        let specs = vec![
6150            fuel_ir::storage::OutputViewSpec::contiguous(y_dtype, ys_shape.clone()),
6151            fuel_ir::storage::OutputViewSpec::contiguous(carry_dtype, carry_shape.clone()),
6152        ];
6153        let (_bytes, views) = fuel_ir::storage::compose_bundle(&specs).map_err(|e| {
6154            fuel_ir::Error::Msg(format!("scan_until: compose_bundle failed: {e}")).bt()
6155        })?;
6156
6157        let mut inputs: Vec<NodeId> = Vec::with_capacity(3 + xs.len() + consts.len());
6158        inputs.push(self.id);
6159        inputs.extend(xs.iter().map(|t| t.id));
6160        inputs.extend(consts.iter().map(|t| t.id));
6161        inputs.push(body_new_carry.id);
6162        inputs.push(body_y.id);
6163        inputs.push(pred_exit.id); // <-- the C1 trailing predicate
6164
6165        let id = {
6166            let mut g = self.graph.write().unwrap();
6167            let id = g.push(Node {
6168                op: Op::Scan {
6169                    // ⚠️ GAP-303 SCOPE LINE: the IR, the validator, `unroll_scan`,
6170                    // `parse_scan_layout` and `build_scan_step` all handle N
6171                    // carries. This BUILDER deliberately still constructs ONE.
6172                    //
6173                    // Widening it means N+1 output-view specs through
6174                    // `compose_bundle` below -- i.e. the bundle arity -- which is
6175                    // GAP-303 row 2 (`Op::ScatterIntoSlot`, kernel-less today) and
6176                    // is NOT this change. A reader must not read "the builder makes
6177                    // one" as "multi-carry does not work".
6178                    n_carries: 1,
6179                    n_xs: xs.len(),
6180                    bound,
6181                    emit,
6182                    early_exit: Some(ScanPredicate),
6183                },
6184                inputs,
6185                shape: ys_shape,
6186                dtype: y_dtype,
6187            });
6188            g.set_output_views(id, Arc::from(views.into_boxed_slice()))
6189                .map_err(|e| {
6190                    fuel_ir::Error::Msg(format!("scan_until: set_output_views failed: {e}")).bt()
6191                })?;
6192            id
6193        };
6194        let producer = Self {
6195            graph: self.graph.clone(),
6196            id,
6197        };
6198        match emit {
6199            ScanEmit::All => producer.view(0),
6200            ScanEmit::Final => producer.view(1),
6201        }
6202    }
6203
6204    /// Append a `Tril` node — lower-triangular mask along the last two
6205    /// dims. `diagonal = 0` keeps the main diagonal and below.
6206    /// `tril(diagonal = 0)` is the canonical causal-attention mask.
6207    ///
6208    /// **Returns `Result`**: rank < 2 surfaces as a typed error.
6209    pub fn tril(&self, diagonal: i64) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6210        let in_shape = self.shape();
6211        let rank = in_shape.dims().len();
6212        if rank < 2 {
6213            return Err(fuel_ir::Error::Msg(format!(
6214                "tril: input must have rank >= 2, got {rank}",
6215            ))
6216            .bt());
6217        }
6218        let dtype = self.dtype();
6219        let id = self.graph.write().unwrap().push(Node {
6220            op: Op::Tril { diagonal },
6221            inputs: vec![self.id],
6222            shape: in_shape,
6223            dtype,
6224        });
6225        Ok(Self {
6226            graph: self.graph.clone(),
6227            id,
6228        })
6229    }
6230
6231    /// Append a `LogSoftmaxLastDim` node. Output shape == input shape.
6232    /// Numerically stable (max-subtracting formula) — preferred over
6233    /// `log(softmax(x))` for NLL / cross-entropy loss.
6234    ///
6235    /// **Returns `Result`**: rank < 1 surfaces as a typed error.
6236    pub fn log_softmax_last_dim(&self) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6237        let in_shape = self.shape();
6238        if in_shape.dims().is_empty() {
6239            return Err(fuel_ir::Error::Msg(
6240                "log_softmax_last_dim: input must have rank >= 1".to_string(),
6241            )
6242            .bt());
6243        }
6244        let dtype = self.dtype();
6245        let id = self.graph.write().unwrap().push(Node {
6246            op: Op::LogSoftmaxLastDim,
6247            inputs: vec![self.id],
6248            shape: in_shape,
6249            dtype,
6250        });
6251        Ok(Self {
6252            graph: self.graph.clone(),
6253            id,
6254        })
6255    }
6256
6257    /// Append a `MaskedFill` node. `mask` must be `Bool` (GAP-168(c)) and have
6258    /// the same shape as `self`. Every position where `mask` is true gets `value`;
6259    /// every position where `mask == 0` passes `self` through. `value`
6260    /// is a scalar of `self`'s dtype.
6261    ///
6262    /// **Returns `Result`**: shape or dtype mismatches surface as typed
6263    /// errors.
6264    pub fn masked_fill(
6265        &self,
6266        mask: &NodeHandle,
6267        value: Scalar,
6268    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6269        self.require_same_graph(mask, "masked_fill", "mask")?;
6270        if self.shape().dims() != mask.shape().dims() {
6271            return Err(fuel_ir::Error::Msg(format!(
6272                "masked_fill: x.shape={:?} != mask.shape={:?}",
6273                self.shape().dims(),
6274                mask.shape().dims(),
6275            ))
6276            .bt());
6277        }
6278        if mask.dtype() != DType::Bool {
6279            return Err(fuel_ir::Error::Msg(format!(
6280                "masked_fill: mask dtype must be Bool, got {:?} — cast a numeric \
6281                 mask to Bool explicitly (GAP-168(c))",
6282                mask.dtype(),
6283            ))
6284            .bt());
6285        }
6286        // GAP-002 (D5) / GAP-075 (fix B): classify EVERY dtype explicitly — no
6287        // wildcard default. The guard is SEMANTIC: masked_fill needs a
6288        // representable fill value AND a zero (the backward fills with zero), so
6289        // it rejects the packed formats (F4/F6*, no scalar fill) and the
6290        // block-scales (F8E8M0/F8E6M2, no zero), and permits the ordinary
6291        // numeric dtypes. Rejecting on the guard's own terms also keeps the
6292        // `Scalar::zero` Err branch in the MaskedFill `backward` arm provably
6293        // unreachable in a well-formed graph.
6294        //
6295        // Under the OLD denylist (`matches!(reject-set)`, else allow) a FUTURE
6296        // dtype fell through to "permitted" SILENTLY. With new dtypes scheduled
6297        // (GAP-097: e5m2, complex, sub-byte int, …) that would silently accept
6298        // masked_fill on formats whose fill semantics are unsettled. An
6299        // exhaustive match makes a new dtype a COMPILE ERROR here, forcing the
6300        // person who adds it to decide its permission consciously — the compiler
6301        // is the real guard; the tests below protect the existing classification.
6302        //
6303        // NOTE: "permitted" is a graph-build PERMISSION (is masked_fill
6304        // semantically well-defined?), NOT a promise a backend can allocate/run
6305        // the dtype. An unimplemented storage still declines loudly downstream
6306        // (typed `UnsupportedDTypeForOp` at allocation), which is the sound
6307        // composition — a semantically-true guard upstream, a loud capability
6308        // decline downstream.
6309        match self.dtype() {
6310            DType::F4 | DType::F6E2M3 | DType::F6E3M2 | DType::F8E8M0 | DType::F8E6M2 => {
6311                return Err(
6312                    fuel_ir::Error::UnsupportedDTypeForOp(self.dtype(), "masked_fill").bt(),
6313                );
6314            }
6315            DType::U8
6316            | DType::I8
6317            | DType::U32
6318            | DType::I16
6319            | DType::I32
6320            | DType::I64
6321            | DType::BF16
6322            | DType::F16
6323            | DType::F32
6324            | DType::F64
6325            | DType::F8E4M3
6326            // GAP-097: F8E5M2 is PERMITTED, on the guard's own semantic terms.
6327            //
6328            // The reject set above is not "exotic dtypes" — it is two distinct
6329            // NON-VALUE categories: the block-scales (F8E8M0/F8E6M2) ride as
6330            // sibling operands and are not element value dtypes at all, and the
6331            // block-scoped elements (F4/F6*) have no standalone byte layout, so
6332            // a single element is not addressable without its block. Neither has
6333            // a well-defined fill-one-element operation, which is what this op
6334            // needs. F8E5M2 is a value dtype in exactly the way F8E4M3 (already
6335            // permitted, directly above) is: sign + exponent + mantissa, a
6336            // standalone byte layout, a representable zero for the backward, and
6337            // a representable fill value. So permitting it CONTINUES the
6338            // existing classification; rejecting would be the exception.
6339            //
6340            // Weighed and rejected as grounds for the opposite call: F8E5M2
6341            // currently has NO host or CUDA storage (fuel-cpu-backend and
6342            // fuel-cuda-backend both decline it), so this permits a graph the
6343            // backends cannot yet allocate — GAP(GAP-161) tracks that storage
6344            // decline and its expiry. That is the NOTE above working as
6345            // designed — permission is semantic, allocation is a capability, and
6346            // they are different questions. Writing REJECT here would encode the
6347            // claim "F8E5M2 has no representable fill value", which is FALSE and
6348            // would outlive the storage gap that motivated it.
6349            | DType::F8E5M2
6350            // GAP-168(c): Bool is a value dtype with a representable fill value
6351            // (true/false) and a zero for the backward (false) — permitted on
6352            // this guard's own semantic terms, exactly as the FP8 value dtypes
6353            // are. (This is the FILLED tensor's dtype; the MASK dtype is checked
6354            // separately above.)
6355            | DType::Bool => {}
6356        }
6357        if value.dtype() != self.dtype() {
6358            return Err(fuel_ir::Error::Msg(format!(
6359                "masked_fill: value dtype {:?} != x dtype {:?}",
6360                value.dtype(),
6361                self.dtype(),
6362            ))
6363            .bt());
6364        }
6365        let op = Op::MaskedFill { value };
6366        let (shape, dtype) = crate::shape::primitive_shape(
6367            &op,
6368            &[self.shape(), mask.shape()],
6369            &[self.dtype(), mask.dtype()],
6370        )
6371        .expect("masked_fill: shapes/dtypes validated above");
6372        let id = self.graph.write().unwrap().push(Node {
6373            op,
6374            inputs: vec![self.id, mask.id],
6375            shape,
6376            dtype,
6377        });
6378        Ok(Self {
6379            graph: self.graph.clone(),
6380            id,
6381        })
6382    }
6383
6384    /// Append a `Pad` node — multi-dim padding. `padding[i] = (before, after)`
6385    /// for axis `i`; `padding.len()` must equal `self.rank()`.
6386    /// Output shape: `out[i] = in[i] + padding[i].0 + padding[i].1`.
6387    /// `value` is the fill constant for [`PadMode::Constant`]
6388    /// (ignored for other modes).
6389    ///
6390    /// Only Constant mode is wired through the executor in the v1
6391    /// cut; the other modes produce a clean error at realize time.
6392    /// Differentiable for Constant (backward slices the gradient
6393    /// along each padded axis to restore the input shape).
6394    ///
6395    /// **Returns `Result`**: rank mismatch surfaces as a typed error.
6396    pub fn pad(
6397        &self,
6398        padding: Vec<(usize, usize)>,
6399        mode: PadMode,
6400        value: f64,
6401    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6402        let in_shape = self.shape();
6403        let rank = in_shape.dims().len();
6404        if padding.len() != rank {
6405            return Err(fuel_ir::Error::Msg(format!(
6406                "pad: padding.len() ({}) must equal tensor rank ({rank})",
6407                padding.len(),
6408            ))
6409            .bt());
6410        }
6411        let op = Op::Pad {
6412            padding,
6413            mode,
6414            value,
6415        };
6416        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
6417        let id = self.graph.write().unwrap().push(Node {
6418            op,
6419            inputs: vec![self.id],
6420            shape,
6421            dtype,
6422        });
6423        Ok(Self {
6424            graph: self.graph.clone(),
6425            id,
6426        })
6427    }
6428
6429    /// Append an `Equal` node (`self == other`) producing a `Bool` mask.
6430    /// Both operands must share dtype and shape; output dtype is `Bool`
6431    /// (`1` where equal, `0` otherwise). NaN follows IEEE-754
6432    /// (`NaN == NaN` is false). Non-differentiable.
6433    ///
6434    /// # Panics
6435    ///
6436    /// Panics if any operand belongs to a different [`Graph`] than the one this
6437    /// builder is building into. Operand identity is graph-scoped and checked by
6438    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6439    /// deliberate and replaces the hang a cross-graph handle used to produce.
6440    /// Build every operand from one graph, or use a `Result`-returning entry
6441    /// point where one exists.
6442    pub fn eq(&self, other: &NodeHandle) -> NodeHandle {
6443        self.binary_compare_op("eq", Op::Equal, other)
6444    }
6445
6446    /// Append a `Ne` node (`self != other`) producing a `Bool` mask.
6447    /// Same shape/dtype contract as [`Self::eq`]. NaN follows
6448    /// IEEE-754 (`NaN != NaN` is true → `1`). Non-differentiable.
6449    ///
6450    /// # Panics
6451    ///
6452    /// Panics if any operand belongs to a different [`Graph`] than the one this
6453    /// builder is building into. Operand identity is graph-scoped and checked by
6454    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6455    /// deliberate and replaces the hang a cross-graph handle used to produce.
6456    /// Build every operand from one graph, or use a `Result`-returning entry
6457    /// point where one exists.
6458    pub fn ne(&self, other: &NodeHandle) -> NodeHandle {
6459        self.binary_compare_op("ne", Op::Ne, other)
6460    }
6461
6462    /// Append an `Lt` node (`self < other`) producing a `Bool` mask.
6463    /// Same shape/dtype contract as [`Self::eq`]. NaN-on-either-side
6464    /// is always `0` (IEEE-754 unordered). Non-differentiable.
6465    ///
6466    /// # Panics
6467    ///
6468    /// Panics if any operand belongs to a different [`Graph`] than the one this
6469    /// builder is building into. Operand identity is graph-scoped and checked by
6470    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6471    /// deliberate and replaces the hang a cross-graph handle used to produce.
6472    /// Build every operand from one graph, or use a `Result`-returning entry
6473    /// point where one exists.
6474    pub fn lt(&self, other: &NodeHandle) -> NodeHandle {
6475        self.binary_compare_op("lt", Op::Lt, other)
6476    }
6477
6478    /// Append an `Le` node (`self <= other`) producing a `Bool` mask.
6479    /// Same shape/dtype contract as [`Self::eq`]. NaN-on-either-side
6480    /// is always `0`. Non-differentiable.
6481    ///
6482    /// # Panics
6483    ///
6484    /// Panics if any operand belongs to a different [`Graph`] than the one this
6485    /// builder is building into. Operand identity is graph-scoped and checked by
6486    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6487    /// deliberate and replaces the hang a cross-graph handle used to produce.
6488    /// Build every operand from one graph, or use a `Result`-returning entry
6489    /// point where one exists.
6490    pub fn le(&self, other: &NodeHandle) -> NodeHandle {
6491        self.binary_compare_op("le", Op::Le, other)
6492    }
6493
6494    /// Append a `Gt` node (`self > other`) producing a `Bool` mask.
6495    /// Same shape/dtype contract as [`Self::eq`]. NaN-on-either-side
6496    /// is always `0`. Non-differentiable.
6497    ///
6498    /// # Panics
6499    ///
6500    /// Panics if any operand belongs to a different [`Graph`] than the one this
6501    /// builder is building into. Operand identity is graph-scoped and checked by
6502    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6503    /// deliberate and replaces the hang a cross-graph handle used to produce.
6504    /// Build every operand from one graph, or use a `Result`-returning entry
6505    /// point where one exists.
6506    pub fn gt(&self, other: &NodeHandle) -> NodeHandle {
6507        self.binary_compare_op("gt", Op::Gt, other)
6508    }
6509
6510    /// Append a `Ge` node (`self >= other`) producing a `Bool` mask.
6511    /// Same shape/dtype contract as [`Self::eq`]. NaN-on-either-side
6512    /// is always `0`. Non-differentiable.
6513    ///
6514    /// # Panics
6515    ///
6516    /// Panics if any operand belongs to a different [`Graph`] than the one this
6517    /// builder is building into. Operand identity is graph-scoped and checked by
6518    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6519    /// deliberate and replaces the hang a cross-graph handle used to produce.
6520    /// Build every operand from one graph, or use a `Result`-returning entry
6521    /// point where one exists.
6522    pub fn ge(&self, other: &NodeHandle) -> NodeHandle {
6523        self.binary_compare_op("ge", Op::Ge, other)
6524    }
6525
6526    /// Ternary select: `result[i] = if self[i] { a[i] } else { b[i] }`.
6527    /// Receiver is the `cond` mask (must be `DType::Bool`, GAP-168(c)); `a` and `b`
6528    /// must share dtype with each other and shape with `self`. Output
6529    /// dtype matches `a`/`b`. Returning a new tensor where each slot is
6530    /// picked from `a` (cond=1) or `b` (cond=0).
6531    ///
6532    /// Named `where_cond` because `where` is a Rust reserved keyword.
6533    /// Common spelling in Candle/PyTorch families.
6534    ///
6535    /// Differentiable through `a` and `b` only; gradient through the
6536    /// cond mask is `None` (registered in
6537    /// [`crate::grad::WhereRule`]).
6538    ///
6539    /// # Panics
6540    ///
6541    /// Panics if any operand belongs to a different [`Graph`] than the one this
6542    /// builder is building into. Operand identity is graph-scoped and checked by
6543    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
6544    /// deliberate and replaces the hang a cross-graph handle used to produce.
6545    /// Build every operand from one graph, or use a `Result`-returning entry
6546    /// point where one exists.
6547    pub fn where_cond(&self, a: &NodeHandle, b: &NodeHandle) -> NodeHandle {
6548        assert!(
6549            Arc::ptr_eq(&self.graph, &a.graph) && Arc::ptr_eq(&self.graph, &b.graph),
6550            "where_cond: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
6551        );
6552        assert_eq!(
6553            self.dtype(),
6554            DType::Bool,
6555            "where_cond: cond must be Bool (GAP-168(c)), got {:?} — cast a numeric \
6556             mask to Bool explicitly",
6557            self.dtype(),
6558        );
6559        assert_eq!(
6560            a.dtype(),
6561            b.dtype(),
6562            "where_cond: a/b dtype mismatch: a={:?}, b={:?}",
6563            a.dtype(),
6564            b.dtype(),
6565        );
6566        assert_eq!(
6567            self.shape().dims(),
6568            a.shape().dims(),
6569            "where_cond: cond/a shape mismatch: cond={:?}, a={:?}",
6570            self.shape().dims(),
6571            a.shape().dims(),
6572        );
6573        assert_eq!(
6574            self.shape().dims(),
6575            b.shape().dims(),
6576            "where_cond: cond/b shape mismatch: cond={:?}, b={:?}",
6577            self.shape().dims(),
6578            b.shape().dims(),
6579        );
6580        let (shape, dtype) = crate::shape::primitive_shape(
6581            &Op::Where,
6582            &[self.shape(), a.shape(), b.shape()],
6583            &[self.dtype(), a.dtype(), b.dtype()],
6584        )
6585        .expect("where_cond: shapes/dtypes validated above");
6586        let id = self.graph.write().unwrap().push(Node {
6587            op: Op::Where,
6588            inputs: vec![self.id, a.id, b.id],
6589            shape,
6590            dtype,
6591        });
6592        Self {
6593            graph: self.graph.clone(),
6594            id,
6595        }
6596    }
6597
6598    // --- dtype and broadcasting ---
6599
6600    /// Append a `Cast` node converting this tensor's element type to
6601    /// `target`. Shape is preserved. An identity cast (`target` equals the
6602    /// current dtype) is a no-op that the optimizer elides (see
6603    /// `opt::optimize`'s identity-`Cast` rule) — kept here rather than
6604    /// special-cased at construction so every identity cast, however it
6605    /// arises (decompose, autograd, user), is removed in one place.
6606    pub fn cast(&self, target: DType) -> NodeHandle {
6607        let op = Op::Cast(target);
6608        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])
6609            .expect("cast: single input, dtype change always infers");
6610        let id = self.graph.write().unwrap().push(Node {
6611            op,
6612            inputs: vec![self.id],
6613            shape,
6614            dtype,
6615        });
6616        Self {
6617            graph: self.graph.clone(),
6618            id,
6619        }
6620    }
6621
6622    /// Append a `BroadcastTo` node that expands `self` to the given target
6623    /// shape using NumPy broadcasting rules (right-align, pad with 1s,
6624    /// expand size-1 dims). The new tensor has the target shape and the
6625    /// same dtype as `self`.
6626    pub fn broadcast_to(&self, target: impl Into<Shape>) -> NodeHandle {
6627        let target = target.into();
6628        let src_dims = self.shape();
6629        // Validate broadcast compatibility up front so users hear about
6630        // bad target shapes at build time, not realize time.
6631        check_broadcast_compatible(src_dims.dims(), target.dims());
6632        let op = Op::BroadcastTo(target);
6633        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])
6634            .expect("broadcast_to: compatibility validated above");
6635        let id = self.graph.write().unwrap().push(Node {
6636            op,
6637            inputs: vec![self.id],
6638            shape,
6639            dtype,
6640        });
6641        Self {
6642            graph: self.graph.clone(),
6643            id,
6644        }
6645    }
6646
6647    /// Result-returning sibling of [`Self::broadcast_to`]. Surfaces
6648    /// shape incompatibility as a typed error rather than panicking.
6649    pub fn try_broadcast_to(
6650        &self,
6651        target: impl Into<Shape>,
6652    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6653        let target = target.into();
6654        let src_dims = self.shape();
6655        try_check_broadcast_compatible(src_dims.dims(), target.dims())?;
6656        let op = Op::BroadcastTo(target);
6657        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])?;
6658        let id = self.graph.write().unwrap().push(Node {
6659            op,
6660            inputs: vec![self.id],
6661            shape,
6662            dtype,
6663        });
6664        Ok(Self {
6665            graph: self.graph.clone(),
6666            id,
6667        })
6668    }
6669
6670    /// Append an `Unsqueeze` node that inserts a size-1 dimension at
6671    /// position `dim`. `dim` must be in `0..=rank` (where `dim == rank`
6672    /// appends to the end). Pure metadata — the output shares bytes
6673    /// with `self`, with a new size-1 axis layered into the Layout
6674    /// side-table. Strictly more efficient than `reshape` for the
6675    /// size-1-insertion case because non-contiguous (e.g. transposed
6676    /// or broadcast) inputs flow through without auto-Contiguize.
6677    pub fn unsqueeze(&self, dim: usize) -> NodeHandle {
6678        let in_shape = self.shape();
6679        let rank = in_shape.dims().len();
6680        assert!(
6681            dim <= rank,
6682            "unsqueeze: dim {dim} out of bounds for rank {rank} (must be <= rank)",
6683        );
6684        let op = Op::Unsqueeze { dim };
6685        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])
6686            .expect("unsqueeze: dim validated above");
6687        let id = self.graph.write().unwrap().push(Node {
6688            op,
6689            inputs: vec![self.id],
6690            shape,
6691            dtype,
6692        });
6693        Self {
6694            graph: self.graph.clone(),
6695            id,
6696        }
6697    }
6698
6699    /// Result-returning sibling of [`Self::unsqueeze`]. Surfaces
6700    /// `dim > rank` as a typed error rather than panicking.
6701    pub fn try_unsqueeze(&self, dim: usize) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6702        let in_shape = self.shape();
6703        let rank = in_shape.dims().len();
6704        if dim > rank {
6705            return Err(fuel_ir::Error::Msg(format!(
6706                "unsqueeze: dim {dim} out of bounds for rank {rank} (must be <= rank)",
6707            ))
6708            .bt());
6709        }
6710        let op = Op::Unsqueeze { dim };
6711        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
6712        let id = self.graph.write().unwrap().push(Node {
6713            op,
6714            inputs: vec![self.id],
6715            shape,
6716            dtype,
6717        });
6718        Ok(Self {
6719            graph: self.graph.clone(),
6720            id,
6721        })
6722    }
6723
6724    /// Append a `Squeeze` node that drops the size-1 dimension at
6725    /// position `dim` (range `0..rank`). Inverse of [`Self::unsqueeze`].
6726    /// Metadata-only view: the output shares bytes with `self`, with
6727    /// the named dim pruned from the Layout side-table.
6728    ///
6729    /// **Returns `Result`** rather than panicking — production paths
6730    /// can recover from a bad `dim` instead of crashing. Bad `dim`
6731    /// (out of bounds OR `shape[dim] != 1`) surfaces as a typed error.
6732    pub fn squeeze(&self, dim: usize) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6733        let in_shape = self.shape();
6734        let rank = in_shape.dims().len();
6735        if dim >= rank {
6736            return Err(fuel_ir::Error::Msg(format!(
6737                "squeeze: dim {dim} out of bounds for rank {rank} (must be < rank)",
6738            ))
6739            .bt());
6740        }
6741        if in_shape.dims()[dim] != 1 {
6742            return Err(fuel_ir::Error::Msg(format!(
6743                "squeeze: dim {dim} has size {}, expected 1",
6744                in_shape.dims()[dim],
6745            ))
6746            .bt());
6747        }
6748        let op = Op::Squeeze { dim };
6749        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])?;
6750        let id = self.graph.write().unwrap().push(Node {
6751            op,
6752            inputs: vec![self.id],
6753            shape,
6754            dtype,
6755        });
6756        Ok(Self {
6757            graph: self.graph.clone(),
6758            id,
6759        })
6760    }
6761
6762    /// Append an `Op::Contiguize` node that materializes a
6763    /// contiguous copy of `self`. Output shape and dtype match the
6764    /// input; only the layout changes to contiguous + zero-offset.
6765    /// Zero-copy when the input is already contiguous + zero-offset
6766    /// (the executor adopts the input Storage Arc unchanged).
6767    ///
6768    /// First-class IR node so the optimizer (Phase 2.2) can insert
6769    /// layout-fixups before kernels that don't advertise
6770    /// `crate::KernelCaps::strided_input` without overloading
6771    /// [`Self::reshape`]'s "change shape" semantics.
6772    pub fn contiguize(&self) -> NodeHandle {
6773        let shape = self.shape().clone();
6774        let dtype = self.dtype();
6775        let id = self.graph.write().unwrap().push(Node {
6776            op: Op::Contiguize,
6777            inputs: vec![self.id],
6778            shape,
6779            dtype,
6780        });
6781        Self {
6782            graph: self.graph.clone(),
6783            id,
6784        }
6785    }
6786
6787    /// Append a `Reshape` node producing `self`'s data under a new shape.
6788    /// The new shape must have the same total element count as the
6789    /// current shape.
6790    pub fn reshape(&self, target: impl Into<Shape>) -> NodeHandle {
6791        let target = target.into();
6792        assert_eq!(
6793            self.shape().elem_count(),
6794            target.elem_count(),
6795            "reshape: element count mismatch: from {} to {}",
6796            self.shape().elem_count(),
6797            target.elem_count(),
6798        );
6799        let op = Op::Reshape(target);
6800        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])
6801            .expect("reshape: element count validated above");
6802        let id = self.graph.write().unwrap().push(Node {
6803            op,
6804            inputs: vec![self.id],
6805            shape,
6806            dtype,
6807        });
6808        Self {
6809            graph: self.graph.clone(),
6810            id,
6811        }
6812    }
6813
6814    /// Result-returning sibling of [`Self::reshape`]. Surfaces
6815    /// element-count mismatch as a typed error rather than panicking.
6816    pub fn try_reshape(
6817        &self,
6818        target: impl Into<Shape>,
6819    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
6820        let target = target.into();
6821        let from = self.shape().elem_count();
6822        let to = target.elem_count();
6823        if from != to {
6824            return Err(fuel_ir::Error::Msg(format!(
6825                "reshape: element count mismatch: from {from} to {to}",
6826            ))
6827            .bt());
6828        }
6829        let op = Op::Reshape(target);
6830        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])?;
6831        let id = self.graph.write().unwrap().push(Node {
6832            op,
6833            inputs: vec![self.id],
6834            shape,
6835            dtype,
6836        });
6837        Ok(Self {
6838            graph: self.graph.clone(),
6839            id,
6840        })
6841    }
6842
6843    /// Append a `ReduceSumTo` node that sum-reduces `self` to a smaller
6844    /// shape. The target must be reachable from `self.shape()` via
6845    /// reduction of dims (i.e. `self.shape()` could be produced from
6846    /// `target` by broadcasting).
6847    pub fn reduce_sum_to(&self, target: impl Into<Shape>) -> NodeHandle {
6848        let target = target.into();
6849        // Symmetric check: self must be broadcast-compatible FROM the
6850        // smaller target. That is, the target must be broadcast-ready to
6851        // self.shape().
6852        check_broadcast_compatible(target.dims(), self.shape().dims());
6853        let op = Op::ReduceSumTo(target);
6854        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])
6855            .expect("reduce_sum_to: compatibility validated above");
6856        let id = self.graph.write().unwrap().push(Node {
6857            op,
6858            inputs: vec![self.id],
6859            shape,
6860            dtype,
6861        });
6862        Self {
6863            graph: self.graph.clone(),
6864            id,
6865        }
6866    }
6867
6868    /// Append a `ReduceMaxTo` node that max-reduces `self` to a smaller
6869    /// shape — the max-symmetric counterpart of [`Self::reduce_sum_to`].
6870    /// The target must be reachable from `self.shape()` via reduction
6871    /// of dims (i.e. `self.shape()` could be produced from `target` by
6872    /// broadcasting).
6873    pub fn reduce_max_to(&self, target: impl Into<Shape>) -> NodeHandle {
6874        let target = target.into();
6875        check_broadcast_compatible(target.dims(), self.shape().dims());
6876        let op = Op::ReduceMaxTo(target);
6877        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])
6878            .expect("reduce_max_to: compatibility validated above");
6879        let id = self.graph.write().unwrap().push(Node {
6880            op,
6881            inputs: vec![self.id],
6882            shape,
6883            dtype,
6884        });
6885        Self {
6886            graph: self.graph.clone(),
6887            id,
6888        }
6889    }
6890
6891    // --- reductions to a scalar ---
6892
6893    /// Append a `SumAll` node reducing every element to a scalar (rank-0).
6894    pub fn sum_all(&self) -> NodeHandle {
6895        self.scalar_reduction(Op::SumAll)
6896    }
6897
6898    /// Append a `MaxAll` node producing the max element as a scalar.
6899    pub fn max_all(&self) -> NodeHandle {
6900        self.scalar_reduction(Op::MaxAll)
6901    }
6902
6903    /// Append a `MinAll` node producing the min element as a scalar.
6904    pub fn min_all(&self) -> NodeHandle {
6905        self.scalar_reduction(Op::MinAll)
6906    }
6907
6908    /// Append a `MeanAll` node producing the arithmetic mean as a scalar.
6909    pub fn mean_all(&self) -> NodeHandle {
6910        self.scalar_reduction(Op::MeanAll)
6911    }
6912
6913    // --- reductions along one dimension ---
6914
6915    /// Append a `SumDim(dim)` node. Reduces along `dim`; output rank is
6916    /// `input rank - 1` (the reduced dim is removed).
6917    pub fn sum_dim(&self, dim: usize) -> NodeHandle {
6918        self.axis_reduction("sum_dim", Op::SumDim(dim), dim)
6919    }
6920
6921    /// Append a `MaxDim(dim)` node.
6922    pub fn max_dim(&self, dim: usize) -> NodeHandle {
6923        self.axis_reduction("max_dim", Op::MaxDim(dim), dim)
6924    }
6925
6926    /// Append a `MinDim(dim)` node.
6927    pub fn min_dim(&self, dim: usize) -> NodeHandle {
6928        self.axis_reduction("min_dim", Op::MinDim(dim), dim)
6929    }
6930
6931    /// Append a `MeanDim(dim)` node.
6932    pub fn mean_dim(&self, dim: usize) -> NodeHandle {
6933        self.axis_reduction("mean_dim", Op::MeanDim(dim), dim)
6934    }
6935
6936    /// Append an `ArgMaxDim(dim)` node. Produces a U32 tensor with the
6937    /// reduced dim removed, whose values are the index of the maximum
6938    /// along that dim. Non-differentiable — trying to run `backward()`
6939    /// through an ArgMax node will panic.
6940    pub fn argmax_dim(&self, dim: usize) -> NodeHandle {
6941        self.index_reduction("argmax_dim", Op::ArgMaxDim(dim), dim)
6942    }
6943
6944    /// Append an `ArgMinDim(dim)` node. Same semantics as `argmax_dim`
6945    /// but for the minimum.
6946    pub fn argmin_dim(&self, dim: usize) -> NodeHandle {
6947        self.index_reduction("argmin_dim", Op::ArgMinDim(dim), dim)
6948    }
6949
6950    /// Internal helper for index-producing reductions. Validates the
6951    /// dim, builds the reduced shape, and stamps the output with
6952    /// `DType::U32` regardless of the input dtype.
6953    fn index_reduction(&self, name: &'static str, op: Op, dim: usize) -> NodeHandle {
6954        let in_shape = self.shape();
6955        assert!(
6956            dim < in_shape.dims().len(),
6957            "{name}: dim {dim} out of bounds for shape {:?}",
6958            in_shape.dims(),
6959        );
6960        // primitive_shape carries both the rank-reduced shape and the U32
6961        // index dtype for ArgMaxDim/ArgMinDim (the single source of truth).
6962        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])
6963            .expect("index_reduction: dim validated above");
6964        let id = self.graph.write().unwrap().push(Node {
6965            op,
6966            inputs: vec![self.id],
6967            shape,
6968            dtype,
6969        });
6970        Self {
6971            graph: self.graph.clone(),
6972            id,
6973        }
6974    }
6975
6976    // --- compositions ---
6977
6978    /// Append a `SoftmaxLastDim` node. Shape is preserved.
6979    ///
6980    /// Phase 7.6 step 3: emits `Op::Fused(FusedOps::SOFTMAX_LAST_DIM,
6981    /// FusedOpParams::SoftmaxLastDim)` — the registry-extended arm.
6982    /// The legacy `Op::SoftmaxLastDim` variant remains in the enum
6983    /// during the migration; step 5 drops it once nothing emits it.
6984    pub fn softmax_last_dim(&self) -> NodeHandle {
6985        assert!(
6986            !self.shape().dims().is_empty(),
6987            "softmax_last_dim: input must be rank >= 1",
6988        );
6989        self.unary_op(Op::Fused(
6990            crate::registry::FusedOps::SOFTMAX_LAST_DIM,
6991            crate::registry::FusedOpParams::SoftmaxLastDim,
6992        ))
6993    }
6994
6995    /// Append a `CausalConv1d` node — depthwise 1-D causal convolution
6996    /// + bias + optional fused SiLU. Three inputs:
6997    /// - `self` (x): `[batch, channels, seq + kernel - 1]` F32
6998    ///   (caller pre-pads with `kernel - 1` zeros on the left for the
6999    ///   causal mask — matches Mamba-2's prefill convention).
7000    /// - `weight`: `[channels, 1, kernel]` F32 (depthwise — one filter
7001    ///   per channel; `groups == channels`).
7002    /// - `bias`: `[channels]` F32 (required — matches baracuda's
7003    ///   `causal_conv1d_*_run` signature; pass zeros if you don't want
7004    ///   a bias).
7005    ///
7006    /// Output: `[batch, channels, seq]` F32 where `seq = x.dims[2] -
7007    /// (kernel - 1)`.
7008    ///
7009    /// Emits `Op::Fused(FusedOps::CAUSAL_CONV1D,
7010    /// FusedOpParams::CausalConv1d { use_silu })`. See
7011    /// `fuel-graph/src/registry/causal_conv1d.rs` for the registry
7012    /// entry. No primitive decomposition exists (fuel-graph has no
7013    /// `Op::Conv1D`); backends without a native kernel fall through
7014    /// to the executor's cpu_fallback path.
7015    ///
7016    /// # Panics
7017    ///
7018    /// Panics if any operand belongs to a different [`Graph`] than the one this
7019    /// builder is building into. Operand identity is graph-scoped and checked by
7020    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
7021    /// deliberate and replaces the hang a cross-graph handle used to produce.
7022    /// Build every operand from one graph, or use a `Result`-returning entry
7023    /// point where one exists.
7024    pub fn causal_conv1d(
7025        &self,
7026        weight: &NodeHandle,
7027        bias: &NodeHandle,
7028        use_silu: bool,
7029    ) -> NodeHandle {
7030        assert!(
7031            Arc::ptr_eq(&self.graph, &weight.graph) && Arc::ptr_eq(&self.graph, &bias.graph),
7032            "causal_conv1d: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
7033        );
7034        let dtype = self.dtype();
7035        assert!(
7036            matches!(dtype, DType::F32 | DType::F64 | DType::BF16 | DType::F16),
7037            "causal_conv1d: x must be F32/F64/BF16/F16, got {dtype:?}",
7038        );
7039        assert_eq!(
7040            weight.dtype(),
7041            dtype,
7042            "causal_conv1d: weight dtype {:?} must match x dtype {dtype:?}",
7043            weight.dtype(),
7044        );
7045        assert_eq!(
7046            bias.dtype(),
7047            dtype,
7048            "causal_conv1d: bias dtype {:?} must match x dtype {dtype:?}",
7049            bias.dtype(),
7050        );
7051        let x_dims = self.shape();
7052        let x_dims = x_dims.dims();
7053        let w_dims = weight.shape();
7054        let w_dims = w_dims.dims();
7055        let b_dims = bias.shape();
7056        let b_dims = b_dims.dims();
7057        assert_eq!(
7058            x_dims.len(),
7059            3,
7060            "causal_conv1d: x must be rank 3 [batch, channels, seq+pad], got {x_dims:?}",
7061        );
7062        assert_eq!(
7063            w_dims.len(),
7064            3,
7065            "causal_conv1d: weight must be rank 3 [channels, 1, kernel], got {w_dims:?}",
7066        );
7067        assert_eq!(
7068            b_dims.len(),
7069            1,
7070            "causal_conv1d: bias must be rank 1 [channels], got {b_dims:?}",
7071        );
7072        let batch = x_dims[0];
7073        let channels = x_dims[1];
7074        let x_seq = x_dims[2];
7075        let kernel = w_dims[2];
7076        assert_eq!(
7077            w_dims[0], channels,
7078            "causal_conv1d: weight's first dim {} must equal channels {channels}",
7079            w_dims[0],
7080        );
7081        assert_eq!(
7082            w_dims[1], 1,
7083            "causal_conv1d: weight's middle dim must be 1 (depthwise), got {}",
7084            w_dims[1],
7085        );
7086        assert_eq!(
7087            b_dims[0], channels,
7088            "causal_conv1d: bias length {} must equal channels {channels}",
7089            b_dims[0],
7090        );
7091        assert!(
7092            x_seq >= kernel - 1,
7093            "causal_conv1d: x time dim {x_seq} must be ≥ kernel - 1 = {} \
7094             (caller must pre-pad with {} zeros)",
7095            kernel - 1,
7096            kernel - 1,
7097        );
7098        let out_seq = x_seq - (kernel - 1);
7099        let id = self.graph.write().unwrap().push(Node {
7100            op: Op::Fused(
7101                crate::registry::FusedOps::CAUSAL_CONV1D,
7102                crate::registry::FusedOpParams::CausalConv1d { use_silu },
7103            ),
7104            inputs: vec![self.id, weight.id, bias.id],
7105            shape: Shape::from_dims(&[batch, channels, out_seq]),
7106            dtype,
7107        });
7108        Self {
7109            graph: self.graph.clone(),
7110            id,
7111        }
7112    }
7113
7114    /// Append an `Nf4Matmul` node — bitsandbytes-style 4-bit
7115    /// NormalFloat quantized matrix multiply. Three inputs:
7116    /// - `self` (activations): `[..., M, K]`, dtype ∈ {F32, F16, BF16}
7117    /// - `w_packed`: `[N, K/2]` U8 (two 4-bit codes per byte)
7118    /// - `absmax`:   `[N, K/block_size]` F32 (per-output-row, per-block)
7119    ///
7120    /// Output: `[..., M, N]` matching activations' dtype.
7121    ///
7122    /// `block_size` is the NF4 quantization block size (typically 64
7123    /// in bitsandbytes). `K` must be even AND a multiple of
7124    /// `block_size`.
7125    ///
7126    /// Emits `Op::Fused(FusedOps::NF4_MATMUL, FusedOpParams::Nf4Matmul
7127    /// { block_size })`. No primitive decomposition (the dequant +
7128    /// matmul roundtrip is exactly what NF4's fused dequant-in-kernel
7129    /// design avoids); backends without a native kernel fall through
7130    /// to the executor's cpu_fallback path.
7131    ///
7132    /// # Panics
7133    ///
7134    /// Panics if any operand belongs to a different [`Graph`] than the one this
7135    /// builder is building into. Operand identity is graph-scoped and checked by
7136    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
7137    /// deliberate and replaces the hang a cross-graph handle used to produce.
7138    /// Build every operand from one graph, or use a `Result`-returning entry
7139    /// point where one exists.
7140    pub fn nf4_matmul(
7141        &self,
7142        w_packed: &NodeHandle,
7143        absmax: &NodeHandle,
7144        block_size: usize,
7145    ) -> NodeHandle {
7146        assert!(
7147            Arc::ptr_eq(&self.graph, &w_packed.graph) && Arc::ptr_eq(&self.graph, &absmax.graph),
7148            "nf4_matmul: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
7149        );
7150        let act_dtype = self.dtype();
7151        assert!(
7152            matches!(act_dtype, DType::F32 | DType::F16 | DType::BF16),
7153            "nf4_matmul v1: activations must be F32/F16/BF16, got {:?}",
7154            act_dtype,
7155        );
7156        assert_eq!(
7157            w_packed.dtype(),
7158            DType::U8,
7159            "nf4_matmul: w_packed must be U8 (two 4-bit codes per byte), got {:?}",
7160            w_packed.dtype(),
7161        );
7162        assert_eq!(
7163            absmax.dtype(),
7164            DType::F32,
7165            "nf4_matmul: absmax must be F32, got {:?}",
7166            absmax.dtype(),
7167        );
7168        let a_dims = self.shape();
7169        let a_dims = a_dims.dims();
7170        let w_dims = w_packed.shape();
7171        let w_dims = w_dims.dims();
7172        let abs_dims = absmax.shape();
7173        let abs_dims = abs_dims.dims();
7174        assert!(
7175            a_dims.len() >= 2,
7176            "nf4_matmul: activations must be rank ≥ 2, got {a_dims:?}",
7177        );
7178        assert_eq!(
7179            w_dims.len(),
7180            2,
7181            "nf4_matmul: w_packed must be rank 2 [n, k/2], got {w_dims:?}",
7182        );
7183        assert_eq!(
7184            abs_dims.len(),
7185            2,
7186            "nf4_matmul: absmax must be rank 2 [n, k/block_size], got {abs_dims:?}",
7187        );
7188        let _m = a_dims[a_dims.len() - 2];
7189        let k = a_dims[a_dims.len() - 1];
7190        let n = w_dims[0];
7191        assert!(
7192            k.is_multiple_of(2),
7193            "nf4_matmul: k={k} must be even (w_packed holds 2 nibbles per byte along k)",
7194        );
7195        assert!(
7196            block_size > 0 && k.is_multiple_of(block_size),
7197            "nf4_matmul: k={k} must be a positive multiple of block_size={block_size}",
7198        );
7199        assert_eq!(
7200            w_dims[1],
7201            k / 2,
7202            "nf4_matmul: w_packed second dim {} must equal k/2 = {}",
7203            w_dims[1],
7204            k / 2,
7205        );
7206        assert_eq!(
7207            abs_dims,
7208            &[n, k / block_size][..],
7209            "nf4_matmul: absmax {abs_dims:?} must be [n={n}, k/block_size={}]",
7210            k / block_size,
7211        );
7212        let mut out_dims: Vec<usize> = a_dims[..a_dims.len() - 1].to_vec();
7213        out_dims.push(n);
7214        let id = self.graph.write().unwrap().push(Node {
7215            op: Op::Fused(
7216                crate::registry::FusedOps::NF4_MATMUL,
7217                crate::registry::FusedOpParams::Nf4Matmul { block_size },
7218            ),
7219            inputs: vec![self.id, w_packed.id, absmax.id],
7220            shape: Shape::from_dims(&out_dims),
7221            dtype: act_dtype,
7222        });
7223        Self {
7224            graph: self.graph.clone(),
7225            id,
7226        }
7227    }
7228
7229    /// Append an `AwqMatmul` node — AWQ (mit-han-lab) asymmetric int4
7230    /// weight-only matrix multiply. Four inputs, the quantized three in the
7231    /// baracuda W4A16 kernel layout (OC-major; see
7232    /// [`crate::registry::awq_matmul`]):
7233    /// - `self` (activations): `[..., M, K]`, dtype ∈ {F32, F16, BF16}
7234    /// - `weight`: `[N, K/8]` U32 (eight int4 codes per word, sequential
7235    ///   nibble order)
7236    /// - `zeros`:  `[N, zeros_w]` U32 (group `g`'s zero-point is nibble
7237    ///   `g % 8` of word `g / 8`), `zeros_w =`
7238    ///   [`crate::registry::awq_matmul::awq_zeros_w`]`(K, group_size)`
7239    /// - `scales`: `[N, 8 * zeros_w]` F16 (entry `g` is group `g`'s scale)
7240    ///
7241    /// Output: `[..., M, N]` matching activations' dtype.
7242    ///
7243    /// `group_size` must be 64 or 128 and evenly divide `K`; `K` must be a
7244    /// multiple of 8. `split_k_iters` is a CUDA launch-tuning parameter the
7245    /// decompose path ignores.
7246    /// `fuel_quantized::awq_repack::repack_hf_awq_for_kernel` produces the
7247    /// three quantized operands from a HuggingFace `*-AWQ` checkpoint.
7248    ///
7249    /// Emits `Op::Fused(FusedOps::AWQ_MATMUL, FusedOpParams::AwqMatmul
7250    /// { group_size, split_k_iters })`.
7251    ///
7252    /// # Panics
7253    ///
7254    /// Panics if any operand belongs to a different [`Graph`] than the one
7255    /// this builder is building into, or if any shape/dtype precondition
7256    /// above doesn't hold — same discipline as [`Self::nf4_matmul`].
7257    pub fn awq_matmul(
7258        &self,
7259        weight: &NodeHandle,
7260        zeros: &NodeHandle,
7261        scales: &NodeHandle,
7262        group_size: usize,
7263        split_k_iters: i32,
7264    ) -> NodeHandle {
7265        assert!(
7266            Arc::ptr_eq(&self.graph, &weight.graph)
7267                && Arc::ptr_eq(&self.graph, &zeros.graph)
7268                && Arc::ptr_eq(&self.graph, &scales.graph),
7269            "awq_matmul: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
7270        );
7271        let act_dtype = self.dtype();
7272        assert!(
7273            matches!(act_dtype, DType::F32 | DType::F16 | DType::BF16),
7274            "awq_matmul v1: activations must be F32/F16/BF16, got {:?}",
7275            act_dtype,
7276        );
7277        assert_eq!(
7278            weight.dtype(),
7279            DType::U32,
7280            "awq_matmul: weight must be U32 (eight 4-bit codes per word), got {:?}",
7281            weight.dtype(),
7282        );
7283        assert_eq!(
7284            zeros.dtype(),
7285            DType::U32,
7286            "awq_matmul: zeros must be U32 (eight 4-bit codes per word), got {:?}",
7287            zeros.dtype(),
7288        );
7289        assert_eq!(
7290            scales.dtype(),
7291            DType::F16,
7292            "awq_matmul: scales must be F16, got {:?}",
7293            scales.dtype(),
7294        );
7295        let a_dims = self.shape();
7296        let a_dims = a_dims.dims();
7297        let w_dims = weight.shape();
7298        let w_dims = w_dims.dims();
7299        let z_dims = zeros.shape();
7300        let z_dims = z_dims.dims();
7301        let s_dims = scales.shape();
7302        let s_dims = s_dims.dims();
7303        assert!(
7304            a_dims.len() >= 2,
7305            "awq_matmul: activations must be rank ≥ 2, got {a_dims:?}",
7306        );
7307        assert_eq!(
7308            w_dims.len(),
7309            2,
7310            "awq_matmul: weight must be rank 2 [N, K/8], got {w_dims:?}",
7311        );
7312        assert_eq!(
7313            z_dims.len(),
7314            2,
7315            "awq_matmul: zeros must be rank 2 [N, zeros_w], got {z_dims:?}",
7316        );
7317        assert_eq!(
7318            s_dims.len(),
7319            2,
7320            "awq_matmul: scales must be rank 2 [N, 8 * zeros_w], got {s_dims:?}",
7321        );
7322        let k = a_dims[a_dims.len() - 1];
7323        let n = w_dims[0];
7324        assert!(
7325            k.is_multiple_of(8),
7326            "awq_matmul: k={k} must be a multiple of 8 (weight packs 8 codes per word along k)",
7327        );
7328        let Some(zeros_w) = crate::registry::awq_matmul::awq_zeros_w(k, group_size) else {
7329            panic!("awq_matmul: group_size={group_size} must be 64 or 128 and divide k={k}");
7330        };
7331        assert_eq!(
7332            w_dims[1],
7333            k / 8,
7334            "awq_matmul: weight second dim {} must equal k/8 = {}",
7335            w_dims[1],
7336            k / 8,
7337        );
7338        assert_eq!(
7339            z_dims,
7340            &[n, zeros_w][..],
7341            "awq_matmul: zeros {z_dims:?} must be [n={n}, zeros_w={zeros_w}]",
7342        );
7343        assert_eq!(
7344            s_dims,
7345            &[n, 8 * zeros_w][..],
7346            "awq_matmul: scales {s_dims:?} must be [n={n}, 8 * zeros_w={}]",
7347            8 * zeros_w,
7348        );
7349        let mut out_dims: Vec<usize> = a_dims[..a_dims.len() - 1].to_vec();
7350        out_dims.push(n);
7351        let id = self.graph.write().unwrap().push(Node {
7352            op: Op::Fused(
7353                crate::registry::FusedOps::AWQ_MATMUL,
7354                crate::registry::FusedOpParams::AwqMatmul {
7355                    group_size,
7356                    split_k_iters,
7357                },
7358            ),
7359            inputs: vec![self.id, weight.id, zeros.id, scales.id],
7360            shape: Shape::from_dims(&out_dims),
7361            dtype: act_dtype,
7362        });
7363        Self {
7364            graph: self.graph.clone(),
7365            id,
7366        }
7367    }
7368
7369    /// Append an `SsdChunkScan` node — Mamba-2's State-Space
7370    /// Duality chunked scan (forward). Five inputs:
7371    /// - `self` (x): `[batch, seqlen, heads, head_dim]` F32
7372    /// - `dt`:       `[batch, seqlen, heads]` F32
7373    /// - `a`:        `[heads]` F32 (per-head scalar log A)
7374    /// - `b`:        `[batch, seqlen, heads, state_dim]` F32
7375    /// - `c`:        `[batch, seqlen, heads, state_dim]` F32
7376    ///
7377    /// Output: `y: [batch, seqlen, heads, head_dim]` F32 (same as x).
7378    ///
7379    /// `chunk_size` is the SSD block size (typically 256 in Mamba-2).
7380    /// It controls GPU parallelism granularity but the mathematical
7381    /// result is identical to a sequential scan; the CPU kernel runs
7382    /// sequential regardless. Validation: `chunk_size > 0` and
7383    /// `seqlen % chunk_size == 0`.
7384    ///
7385    /// Emits `Op::Fused(FusedOps::SSD_CHUNK_SCAN,
7386    /// FusedOpParams::SsdChunkScan { chunk_size })`. No primitive
7387    /// decomposition; backends without a native kernel fall through
7388    /// to the executor's cpu_fallback path.
7389    /// Internal: build the bundled SsdChunkScan producer node and
7390    /// return its NodeId. Callers project per-slot via
7391    /// [`NodeHandle::view`] / [`NodeHandle::view_owned`].
7392    fn ssd_chunk_scan_producer(
7393        &self,
7394        dt: &NodeHandle,
7395        a: &NodeHandle,
7396        b: &NodeHandle,
7397        c: &NodeHandle,
7398        chunk_size: usize,
7399    ) -> NodeHandle {
7400        assert!(
7401            Arc::ptr_eq(&self.graph, &dt.graph)
7402                && Arc::ptr_eq(&self.graph, &a.graph)
7403                && Arc::ptr_eq(&self.graph, &b.graph)
7404                && Arc::ptr_eq(&self.graph, &c.graph),
7405            "ssd_chunk_scan: all tensors must live on the same graph; use `const_*_like` to build on an existing graph",
7406        );
7407        let dtype = self.dtype();
7408        assert!(
7409            matches!(dtype, DType::F32 | DType::F64 | DType::BF16 | DType::F16),
7410            "ssd_chunk_scan: x must be F32/F64/BF16/F16, got {dtype:?}",
7411        );
7412        for (name, t) in [("dt", dt), ("a", a), ("b", b), ("c", c)] {
7413            assert_eq!(
7414                t.dtype(),
7415                dtype,
7416                "ssd_chunk_scan: {name} dtype {:?} must match x dtype {dtype:?}",
7417                t.dtype(),
7418            );
7419        }
7420        let x_dims = self.shape();
7421        let x_dims = x_dims.dims();
7422        let dt_dims = dt.shape();
7423        let dt_dims = dt_dims.dims();
7424        let a_dims = a.shape();
7425        let a_dims = a_dims.dims();
7426        let b_dims = b.shape();
7427        let b_dims = b_dims.dims();
7428        let c_dims = c.shape();
7429        let c_dims = c_dims.dims();
7430        assert_eq!(
7431            x_dims.len(),
7432            4,
7433            "ssd_chunk_scan: x must be rank 4 [batch, seqlen, heads, head_dim], got {x_dims:?}",
7434        );
7435        let batch = x_dims[0];
7436        let seqlen = x_dims[1];
7437        let heads = x_dims[2];
7438        let head_dim = x_dims[3];
7439        assert_eq!(
7440            dt_dims,
7441            &[batch, seqlen, heads][..],
7442            "ssd_chunk_scan: dt {dt_dims:?} must be [batch={batch}, seqlen={seqlen}, heads={heads}]",
7443        );
7444        assert_eq!(
7445            a_dims,
7446            &[heads][..],
7447            "ssd_chunk_scan: a {a_dims:?} must be [heads={heads}]",
7448        );
7449        assert_eq!(
7450            b_dims.len(),
7451            4,
7452            "ssd_chunk_scan: b must be rank 4 [batch, seqlen, heads, state_dim], got {b_dims:?}",
7453        );
7454        let state_dim = b_dims[3];
7455        assert_eq!(
7456            b_dims,
7457            &[batch, seqlen, heads, state_dim][..],
7458            "ssd_chunk_scan: b {b_dims:?} must be [batch={batch}, seqlen={seqlen}, heads={heads}, state_dim={state_dim}]",
7459        );
7460        assert_eq!(
7461            c_dims,
7462            &[batch, seqlen, heads, state_dim][..],
7463            "ssd_chunk_scan: c {c_dims:?} must match b shape",
7464        );
7465        let params = crate::registry::FusedOpParams::SsdChunkScan { chunk_size };
7466        let input_shapes = [
7467            Shape::from_dims(x_dims),
7468            Shape::from_dims(dt_dims),
7469            Shape::from_dims(a_dims),
7470            Shape::from_dims(b_dims),
7471            Shape::from_dims(c_dims),
7472        ];
7473        let input_dtypes = [dtype, dtype, dtype, dtype, dtype];
7474        let entry = crate::registry::default_registry()
7475            .entry(crate::registry::FusedOps::SSD_CHUNK_SCAN)
7476            .expect("SSD_CHUNK_SCAN registered");
7477        let specs =
7478            (entry.output_views.expect("multi-output"))(&input_shapes, &input_dtypes, &params);
7479        let (_total_bytes, views) =
7480            fuel_ir::storage::compose_bundle(&specs).expect("SsdChunkScan compose_bundle");
7481        let mut g = self.graph.write().unwrap();
7482        let id = g.push(Node {
7483            op: Op::Fused(crate::registry::FusedOps::SSD_CHUNK_SCAN, params),
7484            inputs: vec![self.id, dt.id, a.id, b.id, c.id],
7485            shape: Shape::from_dims(&[batch, seqlen, heads, head_dim]),
7486            dtype,
7487        });
7488        g.set_output_views(id, Arc::from(views.into_boxed_slice()))
7489            .expect("SsdChunkScan set_output_views");
7490        drop(g);
7491        Self {
7492            graph: self.graph.clone(),
7493            id,
7494        }
7495    }
7496
7497    /// Single-output SsdChunkScan: returns the `y` slot of the
7498    /// bundled producer (View(0)). See
7499    /// [`Self::ssd_chunk_scan_bundled`] for the multi-output variant.
7500    ///
7501    /// # Panics
7502    ///
7503    /// Panics if any operand belongs to a different [`Graph`] than the one this
7504    /// builder is building into. Operand identity is graph-scoped and checked by
7505    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
7506    /// deliberate and replaces the hang a cross-graph handle used to produce.
7507    /// Build every operand from one graph, or use a `Result`-returning entry
7508    /// point where one exists.
7509    pub fn ssd_chunk_scan(
7510        &self,
7511        dt: &NodeHandle,
7512        a: &NodeHandle,
7513        b: &NodeHandle,
7514        c: &NodeHandle,
7515        chunk_size: usize,
7516    ) -> NodeHandle {
7517        self.assert_same_graph(dt, "ssd_chunk_scan", "dt");
7518        self.assert_same_graph(a, "ssd_chunk_scan", "a");
7519        self.assert_same_graph(b, "ssd_chunk_scan", "b");
7520        self.assert_same_graph(c, "ssd_chunk_scan", "c");
7521        let producer = self.ssd_chunk_scan_producer(dt, a, b, c, chunk_size);
7522        producer
7523            .view(0)
7524            .expect("SsdChunkScan view(0) must succeed (just registered slots)")
7525    }
7526
7527    /// Append a `SelectiveScan` node — Mamba-1's selective
7528    /// state-space scan (forward). Five inputs:
7529    /// - `self` (u): `[batch, seqlen, dim]` F32
7530    /// - `delta`:    `[batch, seqlen, dim]` F32
7531    /// - `a`:        `[dim, dstate]` F32
7532    /// - `b`:        `[batch, seqlen, dstate]` F32
7533    /// - `c`:        `[batch, seqlen, dstate]` F32
7534    ///
7535    /// Output: `y: [batch, seqlen, dim]` F32.
7536    ///
7537    /// `delta_softplus` toggles the softplus(delta) activation before
7538    /// use (matches baracuda's `selective_scan_*_run` flag). The v1
7539    /// op surface skips the optional `d_skip`, `z`, `delta_bias`
7540    /// inputs and the `last_state` second output — see
7541    /// `fuel-graph/src/registry/selective_scan.rs` for the rationale.
7542    ///
7543    /// Emits `Op::Fused(FusedOps::SELECTIVE_SCAN,
7544    /// FusedOpParams::SelectiveScan { delta_softplus })`. No
7545    /// primitive decomposition exists (the scan is a sequential
7546    /// recurrence); backends without a native kernel fall through to
7547    /// the executor's cpu_fallback path.
7548    /// Internal: build the bundled SelectiveScan producer node and
7549    /// return its NodeId (wrapped in a `NodeHandle`). Callers project
7550    /// per-slot via [`NodeHandle::view`] / [`NodeHandle::view_owned`].
7551    fn selective_scan_producer(
7552        &self,
7553        delta: &NodeHandle,
7554        a: &NodeHandle,
7555        b: &NodeHandle,
7556        c: &NodeHandle,
7557        delta_softplus: bool,
7558    ) -> NodeHandle {
7559        assert!(
7560            Arc::ptr_eq(&self.graph, &delta.graph)
7561                && Arc::ptr_eq(&self.graph, &a.graph)
7562                && Arc::ptr_eq(&self.graph, &b.graph)
7563                && Arc::ptr_eq(&self.graph, &c.graph),
7564            "selective_scan: all tensors must live on the same graph; use `const_*_like` to build on an existing graph",
7565        );
7566        let dtype = self.dtype();
7567        assert!(
7568            matches!(dtype, DType::F32 | DType::F64 | DType::BF16 | DType::F16),
7569            "selective_scan: u must be F32/F64/BF16/F16, got {dtype:?}",
7570        );
7571        for (name, t) in [("delta", delta), ("a", a), ("b", b), ("c", c)] {
7572            assert_eq!(
7573                t.dtype(),
7574                dtype,
7575                "selective_scan: {name} dtype {:?} must match u dtype {dtype:?}",
7576                t.dtype(),
7577            );
7578        }
7579        let u_dims = self.shape();
7580        let u_dims = u_dims.dims();
7581        let delta_dims = delta.shape();
7582        let delta_dims = delta_dims.dims();
7583        let a_dims = a.shape();
7584        let a_dims = a_dims.dims();
7585        let b_dims = b.shape();
7586        let b_dims = b_dims.dims();
7587        let c_dims = c.shape();
7588        let c_dims = c_dims.dims();
7589        assert_eq!(
7590            u_dims.len(),
7591            3,
7592            "selective_scan: u must be rank 3 [batch, seqlen, dim], got {u_dims:?}",
7593        );
7594        assert_eq!(
7595            delta_dims, u_dims,
7596            "selective_scan: delta {delta_dims:?} must match u {u_dims:?}",
7597        );
7598        assert_eq!(
7599            a_dims.len(),
7600            2,
7601            "selective_scan: a must be rank 2 [dim, dstate], got {a_dims:?}",
7602        );
7603        let batch = u_dims[0];
7604        let seqlen = u_dims[1];
7605        let dim = u_dims[2];
7606        let dstate = a_dims[1];
7607        assert_eq!(
7608            a_dims[0], dim,
7609            "selective_scan: a's first dim {} must equal dim {dim}",
7610            a_dims[0],
7611        );
7612        assert_eq!(
7613            b_dims,
7614            &[batch, seqlen, dstate][..],
7615            "selective_scan: b {b_dims:?} must be [batch={batch}, seqlen={seqlen}, dstate={dstate}]",
7616        );
7617        assert_eq!(
7618            c_dims,
7619            &[batch, seqlen, dstate][..],
7620            "selective_scan: c {c_dims:?} must be [batch={batch}, seqlen={seqlen}, dstate={dstate}]",
7621        );
7622        let params = crate::registry::FusedOpParams::SelectiveScan { delta_softplus };
7623        let input_shapes = [
7624            Shape::from_dims(u_dims),
7625            Shape::from_dims(delta_dims),
7626            Shape::from_dims(a_dims),
7627            Shape::from_dims(b_dims),
7628            Shape::from_dims(c_dims),
7629        ];
7630        let input_dtypes = [dtype, dtype, dtype, dtype, dtype];
7631        let entry = crate::registry::default_registry()
7632            .entry(crate::registry::FusedOps::SELECTIVE_SCAN)
7633            .expect("SELECTIVE_SCAN registered");
7634        let specs =
7635            (entry.output_views.expect("multi-output"))(&input_shapes, &input_dtypes, &params);
7636        let (_total_bytes, views) =
7637            fuel_ir::storage::compose_bundle(&specs).expect("SelectiveScan compose_bundle");
7638        let mut g = self.graph.write().unwrap();
7639        let id = g.push(Node {
7640            op: Op::Fused(crate::registry::FusedOps::SELECTIVE_SCAN, params),
7641            inputs: vec![self.id, delta.id, a.id, b.id, c.id],
7642            shape: Shape::from_dims(&[batch, seqlen, dim]),
7643            dtype,
7644        });
7645        g.set_output_views(id, Arc::from(views.into_boxed_slice()))
7646            .expect("SelectiveScan set_output_views");
7647        drop(g);
7648        Self {
7649            graph: self.graph.clone(),
7650            id,
7651        }
7652    }
7653
7654    /// Single-output SelectiveScan: returns the `y` slot of the
7655    /// bundled producer, with the View(0) projection emitted so
7656    /// callers can downstream-realize / copy / etc. transparently.
7657    /// See [`Self::selective_scan_bundled`] for the multi-output
7658    /// variant that also exposes `last_state` for autoregressive
7659    /// resumption.
7660    ///
7661    /// # Panics
7662    ///
7663    /// Panics if any operand belongs to a different [`Graph`] than the one this
7664    /// builder is building into. Operand identity is graph-scoped and checked by
7665    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
7666    /// deliberate and replaces the hang a cross-graph handle used to produce.
7667    /// Build every operand from one graph, or use a `Result`-returning entry
7668    /// point where one exists.
7669    pub fn selective_scan(
7670        &self,
7671        delta: &NodeHandle,
7672        a: &NodeHandle,
7673        b: &NodeHandle,
7674        c: &NodeHandle,
7675        delta_softplus: bool,
7676    ) -> NodeHandle {
7677        self.assert_same_graph(delta, "selective_scan", "delta");
7678        self.assert_same_graph(a, "selective_scan", "a");
7679        self.assert_same_graph(b, "selective_scan", "b");
7680        self.assert_same_graph(c, "selective_scan", "c");
7681        let producer = self.selective_scan_producer(delta, a, b, c, delta_softplus);
7682        producer
7683            .view(0)
7684            .expect("SelectiveScan view(0) must succeed (just registered slots)")
7685    }
7686
7687    /// Multi-output variant of [`Self::selective_scan`]. Returns
7688    /// `(y, last_state)` — both Op::View tensors projected from the
7689    /// shared bundled producer. `y` matches the existing
7690    /// single-output `selective_scan` shape; `last_state` is the
7691    /// final hidden state `[batch, dim, dstate]` for autoregressive
7692    /// resumption.
7693    pub fn selective_scan_bundled(
7694        &self,
7695        delta: &NodeHandle,
7696        a: &NodeHandle,
7697        b: &NodeHandle,
7698        c: &NodeHandle,
7699        delta_softplus: bool,
7700    ) -> std::result::Result<(NodeHandle, NodeHandle), fuel_ir::Error> {
7701        self.require_same_graph(delta, "selective_scan_bundled", "delta")?;
7702        self.require_same_graph(a, "selective_scan_bundled", "a")?;
7703        self.require_same_graph(b, "selective_scan_bundled", "b")?;
7704        self.require_same_graph(c, "selective_scan_bundled", "c")?;
7705        let producer = self.selective_scan_producer(delta, a, b, c, delta_softplus);
7706        let y = producer.view(0)?;
7707        let last_state = producer.view(1)?;
7708        Ok((y, last_state))
7709    }
7710
7711    /// Multi-output variant of [`Self::ssd_chunk_scan`]. Returns
7712    /// `(y, last_state)`.
7713    pub fn ssd_chunk_scan_bundled(
7714        &self,
7715        dt: &NodeHandle,
7716        a: &NodeHandle,
7717        b: &NodeHandle,
7718        c: &NodeHandle,
7719        chunk_size: usize,
7720    ) -> std::result::Result<(NodeHandle, NodeHandle), fuel_ir::Error> {
7721        self.require_same_graph(dt, "ssd_chunk_scan_bundled", "dt")?;
7722        self.require_same_graph(a, "ssd_chunk_scan_bundled", "a")?;
7723        self.require_same_graph(b, "ssd_chunk_scan_bundled", "b")?;
7724        self.require_same_graph(c, "ssd_chunk_scan_bundled", "c")?;
7725        let producer = self.ssd_chunk_scan_producer(dt, a, b, c, chunk_size);
7726        let y = producer.view(0)?;
7727        let last_state = producer.view(1)?;
7728        Ok((y, last_state))
7729    }
7730
7731    /// Append an [`Op::NonZeroIndices`] producer node — the keystone
7732    /// primitive for **data-dependent dynamic shapes**. Returns the
7733    /// bundled producer `NodeHandle` (a `[capacity]` U32 tensor); callers
7734    /// usually want [`Self::nonzero_indices_bundled`] to also get the
7735    /// runtime count slot.
7736    ///
7737    /// `capacity = self.elem_count()` is the static worst-case bound
7738    /// (every element nonzero). `count_sym` is the [`SymId`] the executor
7739    /// binds to the realized nonzero count in the per-pass `SymEnv`; the
7740    /// caller allocates it from their `SymGen` and threads it into any
7741    /// downstream op that consumes the count as a dynamic extent /
7742    /// `DynScalar::Sym` (the KV-cache `cached_len` pattern, generalized to
7743    /// a data-determined count).
7744    pub fn nonzero_indices_producer(&self, count_sym: SymId) -> NodeHandle {
7745        let capacity = self.shape().elem_count();
7746        let indices_shape = Shape::from_dims(&[capacity]);
7747        let count_shape = Shape::from_dims(&[1]);
7748        let specs = vec![
7749            fuel_ir::storage::OutputViewSpec {
7750                dtype: DType::U32,
7751                shape: indices_shape.clone(),
7752                layout: Layout::contiguous(indices_shape.clone()),
7753                name: Some("indices"),
7754            },
7755            fuel_ir::storage::OutputViewSpec {
7756                dtype: DType::U32,
7757                shape: count_shape.clone(),
7758                layout: Layout::contiguous(count_shape),
7759                name: Some("count"),
7760            },
7761        ];
7762        let (_total_bytes, views) =
7763            fuel_ir::storage::compose_bundle(&specs).expect("NonZeroIndices compose_bundle");
7764        let mut g = self.graph.write().unwrap();
7765        let id = g.push(Node {
7766            op: Op::NonZeroIndices { count_sym },
7767            inputs: vec![self.id],
7768            shape: indices_shape,
7769            dtype: DType::U32,
7770        });
7771        g.set_output_views(id, Arc::from(views.into_boxed_slice()))
7772            .expect("NonZeroIndices set_output_views");
7773        drop(g);
7774        Self {
7775            graph: self.graph.clone(),
7776            id,
7777        }
7778    }
7779
7780    /// Multi-output [`Op::NonZeroIndices`]: returns `(indices, count)` —
7781    /// both `Op::View` projections of the shared bundled producer.
7782    /// `indices` is `[capacity]` U32 (first `count` entries valid, in
7783    /// ascending flat order); `count` is `[1]` U32, the runtime nonzero
7784    /// count. The executor also publishes `count`'s value into the
7785    /// per-pass `SymEnv` under the producer's `count_sym`.
7786    pub fn nonzero_indices_bundled(
7787        &self,
7788        count_sym: SymId,
7789    ) -> std::result::Result<(NodeHandle, NodeHandle), fuel_ir::Error> {
7790        let producer = self.nonzero_indices_producer(count_sym);
7791        let indices = producer.view(0)?;
7792        let count = producer.view(1)?;
7793        Ok((indices, count))
7794    }
7795
7796    /// Append a `FusedSoftmaxCrossEntropy` node. Two inputs:
7797    /// - `self` (logits): `[..., V]` F32 / F64 / BF16 / F16
7798    /// - `targets`: `[...]` I64 (class indices; matches PyTorch /
7799    ///   baracuda convention)
7800    ///
7801    /// Output dtype is always F32 (the FSCE declared dtype — losses
7802    /// accumulate in F64 and narrow to F32, matching PyTorch and the
7803    /// baracuda kernel) regardless of the logits dtype. Output shape
7804    /// depends on `reduction`:
7805    /// - `Reduction::Mean` / `Reduction::Sum` → scalar `[]`
7806    /// - `Reduction::None` → same as `targets.shape`
7807    ///
7808    /// `ignore_index` rows are dropped from the loss accumulator and
7809    /// the Mean denominator. The conventional sentinel is `-100`.
7810    ///
7811    /// Emits `Op::Fused(FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY,
7812    /// FusedOpParams::FusedSoftmaxCrossEntropy { reduction, ignore_index })`.
7813    /// See `fuel-graph/src/registry/fused_softmax_cross_entropy.rs` for
7814    /// the registry entry and decompose chain.
7815    ///
7816    /// # Panics
7817    ///
7818    /// Panics if any operand belongs to a different [`Graph`] than the one this
7819    /// builder is building into. Operand identity is graph-scoped and checked by
7820    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
7821    /// deliberate and replaces the hang a cross-graph handle used to produce.
7822    /// Build every operand from one graph, or use a `Result`-returning entry
7823    /// point where one exists.
7824    pub fn fused_softmax_cross_entropy(
7825        &self,
7826        targets: &NodeHandle,
7827        reduction: crate::registry::Reduction,
7828        ignore_index: i64,
7829    ) -> NodeHandle {
7830        assert!(
7831            Arc::ptr_eq(&self.graph, &targets.graph),
7832            "fused_softmax_cross_entropy: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
7833        );
7834        assert!(
7835            matches!(
7836                self.dtype(),
7837                DType::F32 | DType::F64 | DType::BF16 | DType::F16,
7838            ),
7839            "fused_softmax_cross_entropy: logits must be F32/F64/BF16/F16, got {:?}",
7840            self.dtype(),
7841        );
7842        assert_eq!(
7843            targets.dtype(),
7844            DType::I64,
7845            "fused_softmax_cross_entropy: targets must be I64, got {:?}",
7846            targets.dtype(),
7847        );
7848        let logits_dims = self.shape();
7849        let logits_dims = logits_dims.dims();
7850        assert!(
7851            !logits_dims.is_empty(),
7852            "fused_softmax_cross_entropy: logits must have rank ≥ 1",
7853        );
7854        let target_dims = targets.shape();
7855        let target_dims = target_dims.dims();
7856        assert_eq!(
7857            target_dims,
7858            &logits_dims[..logits_dims.len() - 1],
7859            "fused_softmax_cross_entropy: targets shape {target_dims:?} must equal \
7860             logits shape {logits_dims:?} minus the last dim",
7861        );
7862        let out_shape = match reduction {
7863            crate::registry::Reduction::Mean | crate::registry::Reduction::Sum => {
7864                Shape::from_dims(&[])
7865            }
7866            crate::registry::Reduction::None => Shape::from_dims(target_dims),
7867        };
7868        let id = self.graph.write().unwrap().push(Node {
7869            op: Op::Fused(
7870                crate::registry::FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY,
7871                crate::registry::FusedOpParams::FusedSoftmaxCrossEntropy {
7872                    reduction,
7873                    ignore_index,
7874                },
7875            ),
7876            inputs: vec![self.id, targets.id],
7877            shape: out_shape,
7878            dtype: DType::F32,
7879        });
7880        Self {
7881            graph: self.graph.clone(),
7882            id,
7883        }
7884    }
7885
7886    /// Append a `LayerNormLastDim` node with the given epsilon. Shape is
7887    /// preserved.
7888    ///
7889    /// Phase 7.6 step 4 (continued): emits
7890    /// `Op::Fused(FusedOps::LAYER_NORM_LAST_DIM, FusedOpParams::LayerNormLastDim { eps })`
7891    /// through the registry-extended arm. The legacy
7892    /// `Op::LayerNormLastDim { eps }` variant remains in the enum
7893    /// during migration; step 5 drops it once nothing emits it.
7894    pub fn layer_norm_last_dim(&self, eps: f64) -> NodeHandle {
7895        let dims = self.shape();
7896        let d = dims.dims();
7897        assert!(
7898            !d.is_empty() && *d.last().unwrap() > 0,
7899            "layer_norm_last_dim: input must have a non-zero last dim, got {d:?}",
7900        );
7901        self.unary_op(Op::Fused(
7902            crate::registry::FusedOps::LAYER_NORM_LAST_DIM,
7903            crate::registry::FusedOpParams::LayerNormLastDim { eps },
7904        ))
7905    }
7906
7907    /// Apply rotary position embeddings (RoPE) to `self`. The input is
7908    /// expected to have shape `[..., seq, d]` where `seq` is the
7909    /// sequence length and `d` is the per-head feature dim (which must
7910    /// be even). `base` is the frequency base — 10000.0 for LLaMA.
7911    /// `start_pos` lets callers apply RoPE at a non-zero offset, which
7912    /// is what KV-cached inference does when the query for position
7913    /// `n + k` is processed against a cache that already holds
7914    /// positions `0..n+k`.
7915    ///
7916    /// Uses the half-split layout (the one LLaMA and HuggingFace use):
7917    /// pair feature `i` with feature `i + d/2`. Under that layout the
7918    /// rotation reduces to the identity `x' = x·cos + rotate_half(x)·sin`
7919    /// where `rotate_half(x)` concatenates `[-x[d/2:], x[:d/2]]` along
7920    /// the last dim.
7921    ///
7922    /// Implemented as a composition of existing graph primitives —
7923    /// `Slice`, `Neg`, `Concat`, `Reshape`, `BroadcastTo`, `Mul`, `Add`
7924    /// — plus two precomputed `Const` tensors for the cos/sin frequency
7925    /// tables. Every primitive already has a working backward rule, so
7926    /// gradients flow through RoPE without any new backward machinery.
7927    ///
7928    /// Only `f32` is supported today. Extending to other float dtypes
7929    /// is mechanical: build the frequency tables in the target dtype.
7930    pub fn rope(&self, base: f64, start_pos: usize) -> NodeHandle {
7931        let (seq, d) = {
7932            let dims = self.shape();
7933            let v = dims.dims();
7934            let rank = v.len();
7935            assert!(rank >= 2, "rope: input must have rank ≥ 2");
7936            (v[rank - 2], v[rank - 1])
7937        };
7938        let (cos, sin) = build_rope_tables(base, start_pos, seq, d);
7939        // Phase 7.5 G2: const_*_like derives the device from self's
7940        // graph internally — RoPE tables go on the same device as self.
7941        // ⚠️ GAP-003 category 2, in production: the proof EXISTS but is NOT
7942        // LOCAL -- `build_rope_tables` owns the length guarantee and its
7943        // signature `-> (Vec<f32>, Vec<f32>)` does not carry it. So the message
7944        // claims NO proof; it names where the guarantee lives. `rope` returns
7945        // `NodeHandle` and already asserts on rank, so giving it an error
7946        // channel is a signature change -- a different obligation. See #157.
7947        let cos_t = self
7948            .const_f32_like(cos, Shape::from_dims(&[seq, d]))
7949            .expect("rope: build_rope_tables must return seq*d elements for [seq, d]");
7950        let sin_t = self
7951            .const_f32_like(sin, Shape::from_dims(&[seq, d]))
7952            .expect("rope: build_rope_tables must return seq*d elements for [seq, d]");
7953        self.rope_with_tables(&cos_t, &sin_t)
7954    }
7955
7956    /// Apply RoPE using caller-supplied `cos` and `sin` tables. Each
7957    /// table has shape `[seq, head_dim]` and matches the layout
7958    /// [`build_rope_tables`] produces. This is the hot-path entry
7959    /// point: in a transformer forward pass every attention layer
7960    /// applies RoPE to Q and K with the *same* `(start_pos, seq,
7961    /// head_dim)`, so the caller can build the tables once and share
7962    /// the const nodes across all layers rather than re-duplicating
7963    /// them inside each `.rope()` call. The classic [`Self::rope`] entry
7964    /// point funnels through this after building the tables itself.
7965    ///
7966    /// # Panics
7967    ///
7968    /// Panics if any operand belongs to a different [`Graph`] than the one this
7969    /// builder is building into. Operand identity is graph-scoped and checked by
7970    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
7971    /// deliberate and replaces the hang a cross-graph handle used to produce.
7972    /// Build every operand from one graph, or use a `Result`-returning entry
7973    /// point where one exists.
7974    pub fn rope_with_tables(&self, cos: &NodeHandle, sin: &NodeHandle) -> NodeHandle {
7975        self.assert_same_graph(cos, "rope_with_tables", "cos");
7976        self.assert_same_graph(sin, "rope_with_tables", "sin");
7977        assert_eq!(
7978            self.dtype(),
7979            DType::F32,
7980            "rope: only f32 is supported today (cast explicitly for other dtypes)",
7981        );
7982        let in_shape = self.shape();
7983        let dims_vec: Vec<usize> = in_shape.dims().to_vec();
7984        let rank = dims_vec.len();
7985        assert!(
7986            rank >= 2,
7987            "rope: input must have rank ≥ 2, got {dims_vec:?}"
7988        );
7989        let seq = dims_vec[rank - 2];
7990        let d = dims_vec[rank - 1];
7991        assert!(d.is_multiple_of(2), "rope: feature dim {d} must be even",);
7992        let cos_shape = cos.shape();
7993        let sin_shape = sin.shape();
7994        assert_eq!(
7995            cos_shape.dims(),
7996            &[seq, d],
7997            "rope_with_tables: cos shape {:?} does not match [seq, d] = [{seq}, {d}]",
7998            cos_shape.dims(),
7999        );
8000        assert_eq!(
8001            sin_shape.dims(),
8002            &[seq, d],
8003            "rope_with_tables: sin shape {:?} does not match [seq, d] = [{seq}, {d}]",
8004            sin_shape.dims(),
8005        );
8006        let _ = rank;
8007        // Emit a single fused Rope node. The decomposed version
8008        // (slice+neg+concat+broadcast+mul+add) produces ~72 dispatches
8009        // on GPU backends because concat-along-last-dim has a per-row
8010        // host loop. Fused path dispatches once.
8011        //
8012        // Phase 7.6 step 4 (continued): emits
8013        // `Op::Fused(FusedOps::ROPE, FusedOpParams::Rope)` through the
8014        // registry-extended arm. The legacy `Op::Rope` variant remains
8015        // in the enum during migration; step 5 drops it once nothing
8016        // emits it.
8017        let out_shape = in_shape.clone();
8018        let dtype = self.dtype();
8019        let id = self.graph.write().unwrap().push(Node {
8020            op: Op::Fused(
8021                crate::registry::FusedOps::ROPE,
8022                crate::registry::FusedOpParams::Rope,
8023            ),
8024            inputs: vec![self.id, cos.id, sin.id],
8025            shape: out_shape,
8026            dtype,
8027        });
8028        Self {
8029            graph: self.graph.clone(),
8030            id,
8031        }
8032    }
8033
8034    /// The pre-fused decomposition of `rope_with_tables`. Retained so
8035    /// backends without a native `Op::Rope` kernel can synthesize from
8036    /// primitives, and so correctness tests can cross-check the fused
8037    /// path against the primitive path.
8038    ///
8039    /// # Panics
8040    ///
8041    /// Panics if any operand belongs to a different [`Graph`] than the one this
8042    /// builder is building into. Operand identity is graph-scoped and checked by
8043    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8044    /// deliberate and replaces the hang a cross-graph handle used to produce.
8045    /// Build every operand from one graph, or use a `Result`-returning entry
8046    /// point where one exists.
8047    #[doc(hidden)]
8048    pub fn rope_with_tables_decomposed(&self, cos: &NodeHandle, sin: &NodeHandle) -> NodeHandle {
8049        self.assert_same_graph(cos, "rope_with_tables_decomposed", "cos");
8050        self.assert_same_graph(sin, "rope_with_tables_decomposed", "sin");
8051        let in_shape = self.shape();
8052        let dims_vec: Vec<usize> = in_shape.dims().to_vec();
8053        let rank = dims_vec.len();
8054        let seq = dims_vec[rank - 2];
8055        let d = dims_vec[rank - 1];
8056        let half = d / 2;
8057
8058        let mut broadcast_shape: Vec<usize> = vec![1_usize; rank];
8059        broadcast_shape[rank - 2] = seq;
8060        broadcast_shape[rank - 1] = d;
8061        let cos_reshaped = cos.reshape(Shape::from_dims(&broadcast_shape));
8062        let sin_reshaped = sin.reshape(Shape::from_dims(&broadcast_shape));
8063        let cos_bcast = cos_reshaped.broadcast_to(in_shape.clone());
8064        let sin_bcast = sin_reshaped.broadcast_to(in_shape);
8065
8066        let first_half = self.slice(rank - 1, 0, half);
8067        let second_half = self.slice(rank - 1, half, half);
8068        let neg_second = second_half.neg();
8069        let rotated_half = neg_second.concat(&first_half, rank - 1);
8070
8071        let left = self.mul(&cos_bcast);
8072        let right = rotated_half.mul(&sin_bcast);
8073        left.add(&right)
8074    }
8075
8076    /// Root-Mean-Square Normalization along the last dim, without
8077    /// affine parameters. `y = x / sqrt(mean(x², last) + eps)`.
8078    ///
8079    /// This is the normalization layer used by LLaMA-family models.
8080    /// Unlike LayerNorm, RmsNorm does not subtract the mean — it only
8081    /// divides by the root-mean-square, which is both cheaper and
8082    /// empirically just as effective in transformer blocks.
8083    ///
8084    /// Emits a fused RmsNormLastDim node. Backends that have a native
8085    /// implementation dispatch it as one kernel; ones that don't get a
8086    /// CPU fallback via the reference implementation.
8087    ///
8088    /// Phase 7.6 step 4 (continued): emits
8089    /// `Op::Fused(FusedOps::RMS_NORM_LAST_DIM, FusedOpParams::RmsNormLastDim { eps })`
8090    /// through the registry-extended arm. The legacy
8091    /// `Op::RmsNormLastDim { eps }` variant remains in the enum during
8092    /// migration; step 5 drops it once nothing emits it.
8093    ///
8094    /// Use [`rms_norm_last_dim_decomposed`](Self::rms_norm_last_dim_decomposed)
8095    /// instead if you need to differentiate through this op —
8096    /// backward through the fused op is not yet implemented.
8097    pub fn rms_norm_last_dim(&self, eps: f64) -> NodeHandle {
8098        let x_dims_vec: Vec<usize> = self.shape().dims().to_vec();
8099        assert!(
8100            !x_dims_vec.is_empty() && *x_dims_vec.last().unwrap() > 0,
8101            "rms_norm_last_dim: input must have non-zero last dim, got {x_dims_vec:?}",
8102        );
8103        self.unary_op(Op::Fused(
8104            crate::registry::FusedOps::RMS_NORM_LAST_DIM,
8105            crate::registry::FusedOpParams::RmsNormLastDim { eps },
8106        ))
8107    }
8108
8109    /// Decomposed version — emits the (sqr → mean_dim → reshape →
8110    /// add_scalar → sqrt → broadcast_to → div) primitive chain. Has
8111    /// working backward rules through its primitive subgraph. Use
8112    /// this when you need to differentiate through RMSNorm; use
8113    /// [`rms_norm_last_dim`](Self::rms_norm_last_dim) for inference.
8114    pub fn rms_norm_last_dim_decomposed(&self, eps: f64) -> NodeHandle {
8115        let x_shape = self.shape();
8116        let x_dims_vec: Vec<usize> = x_shape.dims().to_vec();
8117        assert!(
8118            !x_dims_vec.is_empty() && *x_dims_vec.last().unwrap() > 0,
8119            "rms_norm_last_dim_decomposed: input must have non-zero last dim, got {x_dims_vec:?}",
8120        );
8121        let last = x_dims_vec.len() - 1;
8122        let mean_sq = self.sqr().mean_dim(last);
8123        let mut keepdim_dims = x_dims_vec.clone();
8124        keepdim_dims[last] = 1;
8125        let mean_sq_kd = mean_sq.reshape(Shape::from_dims(&keepdim_dims));
8126        let denom = mean_sq_kd.add_scalar(eps).sqrt();
8127        let denom_bcast = denom.broadcast_to(x_shape);
8128        self.div(&denom_bcast)
8129    }
8130
8131    // --- indexing ---
8132
8133    /// Append an `IndexSelect` node that picks slices from `self` along
8134    /// `dim` using a 1-D `u32` index tensor. The output has the same
8135    /// shape as `self` except dimension `dim` is replaced by
8136    /// `indices.shape()[0]`.
8137    ///
8138    /// # Panics
8139    ///
8140    /// Panics if any operand belongs to a different [`Graph`] than the one this
8141    /// builder is building into. Operand identity is graph-scoped and checked by
8142    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8143    /// deliberate and replaces the hang a cross-graph handle used to produce.
8144    /// Build every operand from one graph, or use a `Result`-returning entry
8145    /// point where one exists.
8146    pub fn index_select(&self, dim: usize, indices: &NodeHandle) -> NodeHandle {
8147        assert!(
8148            Arc::ptr_eq(&self.graph, &indices.graph),
8149            "index_select: data and index tensors must live on the same graph; use `const_*_like` to build on an existing graph",
8150        );
8151        assert_eq!(
8152            indices.dtype(),
8153            DType::U32,
8154            "index_select: index tensor must be U32, got {:?}",
8155            indices.dtype(),
8156        );
8157        let idx_dims = indices.shape();
8158        assert_eq!(
8159            idx_dims.dims().len(),
8160            1,
8161            "index_select: index tensor must be rank 1, got {:?}",
8162            idx_dims.dims(),
8163        );
8164        let data_dims = self.shape();
8165        assert!(
8166            dim < data_dims.dims().len(),
8167            "index_select: dim {dim} out of bounds for data shape {:?}",
8168            data_dims.dims(),
8169        );
8170        let op = Op::IndexSelect { dim };
8171        let (shape, dtype) = crate::shape::primitive_shape(
8172            &op,
8173            &[data_dims, idx_dims],
8174            &[self.dtype(), indices.dtype()],
8175        )
8176        .expect("index_select: shapes/dims validated above");
8177        let id = self.graph.write().unwrap().push(Node {
8178            op,
8179            inputs: vec![self.id, indices.id],
8180            shape,
8181            dtype,
8182        });
8183        Self {
8184            graph: self.graph.clone(),
8185            id,
8186        }
8187    }
8188
8189    /// Append a `Gather` node that performs an N-dimensional gather along
8190    /// `dim`. The `indices` tensor must be `U32` with the same rank as
8191    /// `self`. Output shape equals `indices.shape()`.
8192    ///
8193    /// # Panics
8194    ///
8195    /// Panics if any operand belongs to a different [`Graph`] than the one this
8196    /// builder is building into. Operand identity is graph-scoped and checked by
8197    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8198    /// deliberate and replaces the hang a cross-graph handle used to produce.
8199    /// Build every operand from one graph, or use a `Result`-returning entry
8200    /// point where one exists.
8201    pub fn gather(&self, dim: usize, indices: &NodeHandle) -> NodeHandle {
8202        assert!(
8203            Arc::ptr_eq(&self.graph, &indices.graph),
8204            "gather: data and index tensors must live on the same graph; use `const_*_like` to build on an existing graph",
8205        );
8206        assert_eq!(
8207            indices.dtype(),
8208            DType::U32,
8209            "gather: index tensor must be U32, got {:?}",
8210            indices.dtype(),
8211        );
8212        let data_rank = self.shape().dims().len();
8213        let idx_rank = indices.shape().dims().len();
8214        assert_eq!(
8215            data_rank, idx_rank,
8216            "gather: data and index must have the same rank, got {data_rank} vs {idx_rank}",
8217        );
8218        assert!(
8219            dim < data_rank,
8220            "gather: dim {dim} out of bounds for rank {data_rank}",
8221        );
8222        let op = Op::Gather { dim };
8223        let (shape, dtype) = crate::shape::primitive_shape(
8224            &op,
8225            &[self.shape(), indices.shape()],
8226            &[self.dtype(), indices.dtype()],
8227        )
8228        .expect("gather: shapes/dims validated above");
8229        let id = self.graph.write().unwrap().push(Node {
8230            op,
8231            inputs: vec![self.id, indices.id],
8232            shape,
8233            dtype,
8234        });
8235        Self {
8236            graph: self.graph.clone(),
8237            id,
8238        }
8239    }
8240
8241    /// Append a `Concat` node joining `self` and `other` along `dim`.
8242    ///
8243    /// # Panics
8244    ///
8245    /// Panics if any operand belongs to a different [`Graph`] than the one this
8246    /// builder is building into. Operand identity is graph-scoped and checked by
8247    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8248    /// deliberate and replaces the hang a cross-graph handle used to produce.
8249    /// Build every operand from one graph, or use a `Result`-returning entry
8250    /// point where one exists.
8251    pub fn concat(&self, other: &NodeHandle, dim: usize) -> NodeHandle {
8252        assert!(
8253            Arc::ptr_eq(&self.graph, &other.graph),
8254            "concat: tensors must live on the same graph; use `const_*_like` to build on an existing graph",
8255        );
8256        assert_eq!(
8257            self.dtype(),
8258            other.dtype(),
8259            "concat: dtype mismatch {:?} vs {:?}",
8260            self.dtype(),
8261            other.dtype(),
8262        );
8263        let a = self.shape();
8264        let b = other.shape();
8265        let ad = a.dims();
8266        let bd = b.dims();
8267        assert_eq!(ad.len(), bd.len(), "concat: rank mismatch");
8268        assert!(dim < ad.len(), "concat: dim out of bounds");
8269        for i in 0..ad.len() {
8270            if i != dim {
8271                assert_eq!(
8272                    ad[i], bd[i],
8273                    "concat: non-dim shapes must match (dim {i}: {} vs {})",
8274                    ad[i], bd[i],
8275                );
8276            }
8277        }
8278        let op = Op::Concat { dim };
8279        let (shape, dtype) =
8280            crate::shape::primitive_shape(&op, &[a, b], &[self.dtype(), other.dtype()])
8281                .expect("concat: shapes/dtypes validated above");
8282        let id = self.graph.write().unwrap().push(Node {
8283            op,
8284            inputs: vec![self.id, other.id],
8285            shape,
8286            dtype,
8287        });
8288        Self {
8289            graph: self.graph.clone(),
8290            id,
8291        }
8292    }
8293
8294    /// Append a `Slice` (narrow) node taking elements `[start, start+len)`
8295    /// along `dim`. The output has the same rank; only `dim` shrinks.
8296    pub fn slice(&self, dim: usize, start: usize, len: usize) -> NodeHandle {
8297        let in_shape = self.shape();
8298        let in_dims = in_shape.dims();
8299        assert!(dim < in_dims.len(), "slice: dim out of bounds");
8300        assert!(
8301            start + len <= in_dims[dim],
8302            "slice: [start={start}, len={len}) exceeds dim size {}",
8303            in_dims[dim],
8304        );
8305        let op = Op::Slice { dim, start, len };
8306        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])
8307            .expect("slice: dim/start/len validated above");
8308        let id = self.graph.write().unwrap().push(Node {
8309            op,
8310            inputs: vec![self.id],
8311            shape,
8312            dtype,
8313        });
8314        Self {
8315            graph: self.graph.clone(),
8316            id,
8317        }
8318    }
8319
8320    /// Append an `AddScalar` node: `y[i] = x[i] + c`. Convenient for
8321    /// avoiding a full-shape const node when you just want to bias a
8322    /// tensor by a constant.
8323    pub fn add_scalar(&self, c: f64) -> NodeHandle {
8324        self.unary_op(Op::AddScalar(c))
8325    }
8326
8327    /// Append a `MulScalar` node: `y[i] = x[i] * c`.
8328    pub fn mul_scalar(&self, c: f64) -> NodeHandle {
8329        self.unary_op(Op::MulScalar(c))
8330    }
8331
8332    /// Append a `PowI` node raising each element to an integer power.
8333    pub fn powi(&self, n: i32) -> NodeHandle {
8334        self.unary_op(Op::PowI(n))
8335    }
8336
8337    /// Append a `Clamp` node restricting each element to `[min, max]`.
8338    pub fn clamp(&self, min: f64, max: f64) -> NodeHandle {
8339        assert!(min <= max, "clamp: min ({min}) must be <= max ({max})",);
8340        self.unary_op(Op::Clamp { min, max })
8341    }
8342
8343    /// Append a `Maximum` node `max(self, other)` element-wise. Matching
8344    /// shapes required.
8345    ///
8346    /// # Panics
8347    ///
8348    /// Panics if any operand belongs to a different [`Graph`] than the one this
8349    /// builder is building into. Operand identity is graph-scoped and checked by
8350    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8351    /// deliberate and replaces the hang a cross-graph handle used to produce.
8352    /// Build every operand from one graph, or use a `Result`-returning entry
8353    /// point where one exists.
8354    pub fn maximum(&self, other: &NodeHandle) -> NodeHandle {
8355        self.binary_op("maximum", Op::Maximum, other, self.shape())
8356    }
8357
8358    /// Append a `Minimum` node `min(self, other)` element-wise.
8359    ///
8360    /// # Panics
8361    ///
8362    /// Panics if any operand belongs to a different [`Graph`] than the one this
8363    /// builder is building into. Operand identity is graph-scoped and checked by
8364    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8365    /// deliberate and replaces the hang a cross-graph handle used to produce.
8366    /// Build every operand from one graph, or use a `Result`-returning entry
8367    /// point where one exists.
8368    pub fn minimum(&self, other: &NodeHandle) -> NodeHandle {
8369        self.binary_op("minimum", Op::Minimum, other, self.shape())
8370    }
8371
8372    /// The IEEE-754 NaN-**suppressing** maximum — the KISS-OPS §6.15-0001
8373    /// `fmax_ieee` op, DISTINCT from [`maximum`](Self::maximum) (which is
8374    /// NaN-**propagating**, torch parity). If exactly one operand is NaN, returns
8375    /// the other; if both are NaN, returns NaN; otherwise the larger.
8376    ///
8377    /// Built by RESOLUTION through the spec-pinned §6.13 decomposition
8378    /// (KISS-OPS-6.15-0001), used VERBATIM — Fuel does not substitute `maximum`
8379    /// for it (that substitution is the §6.15 violation). Because the
8380    /// decomposition is spec-authored, its edge behaviour — including ±0 ordering
8381    /// under `cmp_ge` (`cmp_ge(-0.0, +0.0)` is true, so this returns `-0.0` for
8382    /// that pair) — is the SPEC's to define and a KISS conformance vector's to
8383    /// catch. Fuel resolves; it does not re-derive. (GAP-048.)
8384    pub fn fmax_ieee(&self, other: &NodeHandle) -> NodeHandle {
8385        // select(a != a, b, select(b != b, a, select(a >= b, a, b)))
8386        let a = self;
8387        let b = other;
8388        let plain = a.ge(b).where_cond(a, b); // a >= b ? a : b
8389        let b_not_nan = b.ne(b).where_cond(a, &plain); // b is NaN ? a : plain
8390        a.ne(a).where_cond(b, &b_not_nan) // a is NaN ? b : (above)
8391    }
8392
8393    /// The IEEE-754 NaN-**suppressing** minimum — the KISS-OPS §6.15-0001
8394    /// `fmin_ieee` op, DISTINCT from [`minimum`](Self::minimum) (which is
8395    /// NaN-**propagating**, torch parity). If exactly one operand is NaN, returns
8396    /// the other; if both are NaN, returns NaN; otherwise the smaller.
8397    ///
8398    /// Built by RESOLUTION through the spec-pinned KISS-OPS §6.13 decomposition
8399    /// (KISS-OPS-6.15-0001), used VERBATIM — the `cmp_le` mirror of
8400    /// [`fmax_ieee`](Self::fmax_ieee)'s `cmp_ge` form. Fuel resolves; it does not
8401    /// substitute `minimum` (that substitution is the §6.15 violation) and does
8402    /// not re-derive: the decomposition is KISS's, so its ±0 edge behaviour is the
8403    /// spec's to define and a KISS conformance vector's to catch. (GAP-048.)
8404    pub fn fmin_ieee(&self, other: &NodeHandle) -> NodeHandle {
8405        // select(a != a, b, select(b != b, a, select(a <= b, a, b)))
8406        let a = self;
8407        let b = other;
8408        let plain = a.le(b).where_cond(a, b); // a <= b ? a : b
8409        let b_not_nan = b.ne(b).where_cond(a, &plain); // b is NaN ? a : plain
8410        a.ne(a).where_cond(b, &b_not_nan) // a is NaN ? b : (above)
8411    }
8412
8413    /// Round toward zero — the KISS-OPS §6.3 `trunc` floor op (GAP-300).
8414    ///
8415    /// ⚠️ Unlike [`fmax_ieee`](Self::fmax_ieee)/[`fmin_ieee`](Self::fmin_ieee),
8416    /// which resolve through KISS's OWN §6.13 decompositions, `trunc` is a §6.3
8417    /// *floor* op: KISS-OPS-6.3-0003 forbids the standard from carrying a
8418    /// reference decomposition for a floor op, so THIS expansion is FUEL's, blessed
8419    /// by no spec clause. It IS now checked by KISS vectors: `ops-rounding.json`
8420    /// (vendored at KISS `0af5d3b6`) carries 18 `f32` `trunc` cells (signed zero, halves
8421    /// and ties, 2^23, max finite, ±inf, subnormals), all of which pass through the lazy
8422    /// `Tensor` (`fuel-tensor/tests/kiss_nan_conformance.rs`); `f64` and NaN inputs are
8423    /// not in that file. Beyond it, correctness is Fuel's liability — see GAP-300, whose born-reds assert
8424    /// on `to_bits()` (a value compare `0.0 == -0.0` passes vacuously).
8425    ///
8426    /// `trunc(q) = if q >= 0 { floor(q) } else { ceil(q) }`. Float dtypes only;
8427    /// integer dtypes are already whole, so `trunc` is identity for them (returned
8428    /// unchanged — never a panic on a non-float operand).
8429    ///
8430    /// REJECTED-FOR-NOW alternative: `copysign(floor(abs(q)), q)` is cleaner (no
8431    /// dtype-matched zero; ±0/NaN/inf fall out of the IEEE primitives). Fuel has no
8432    /// `copysign` op today (absent from `Op` and the builder API), so it is not
8433    /// buildable — adopt it IF `copysign` ever lands.
8434    pub fn trunc(&self) -> NodeHandle {
8435        let q = self;
8436        // A scalar zero of q's dtype, broadcast to q's shape — comparisons require
8437        // matching shapes (no implicit broadcast in `binary_compare_op`).
8438        let zero = match q.dtype() {
8439            DType::F32 => q
8440                .const_f32_like(vec![0.0f32], &[1])
8441                .expect("trunc: a 1-element vec with shape [1]")
8442                .broadcast_to(q.shape()),
8443            DType::F64 => q
8444                .const_f64_like(vec![0.0f64], &[1])
8445                .expect("trunc: a 1-element vec with shape [1]")
8446                .broadcast_to(q.shape()),
8447            DType::BF16 => q
8448                .const_bf16_like(vec![half::bf16::from_f32(0.0)], &[1])
8449                .expect("trunc: a 1-element vec with shape [1]")
8450                .broadcast_to(q.shape()),
8451            DType::F16 => q
8452                .const_f16_like(vec![half::f16::from_f32(0.0)], &[1])
8453                .expect("trunc: a 1-element vec with shape [1]")
8454                .broadcast_to(q.shape()),
8455            // Integer/other dtypes are already whole: trunc is identity.
8456            _ => return q.clone(),
8457        };
8458        // q >= 0 ? floor(q) : ceil(q). Note: at ±0 BOTH branches yield the same
8459        // signed zero (`floor(-0.0) == ceil(-0.0) == -0.0`), so trunc's ±0 result
8460        // is robust to a tie-bias in `ge` — but the born-red still checks the bits.
8461        q.ge(&zero).where_cond(&q.floor(), &q.ceil())
8462    }
8463
8464    /// Truncated remainder — the KISS-OPS §6.15-0003 `rem_trunc` op, DISTINCT from
8465    /// [`rem`](Self::rem) (which is FLOORED / PyTorch, sign of the *divisor*).
8466    /// `rem_trunc` takes the sign of the *dividend* (C99 `fmod` / Rust `%`).
8467    ///
8468    /// Built by RESOLUTION through KISS's spec-pinned §6.13 decomposition
8469    /// (KISS-OPS-6.15-0003) — `a - trunc(a / b) * b` — used VERBATIM. Fuel does not
8470    /// substitute `rem` (that substitution is the §6.15 violation); the two diverge
8471    /// whenever the operands have opposite signs (e.g. `-7 rem_trunc 3 = -1`, where
8472    /// `rem` (floored) = `2`). The `trunc` leaf is Fuel's own expansion (GAP-300).
8473    pub fn rem_trunc(&self, other: &NodeHandle) -> NodeHandle {
8474        // a - trunc(a / b) * b
8475        let a = self;
8476        let b = other;
8477        let t = a.div(b).trunc();
8478        a.sub(&t.mul(b))
8479    }
8480
8481    /// Element-wise addition with automatic broadcasting. Unlike `add`,
8482    /// which requires matching shapes, `broadcast_add` computes the
8483    /// broadcast shape of the two operands, inserts explicit
8484    /// `BroadcastTo` nodes as needed, and then emits a regular `Add`.
8485    /// Useful for bias addition (`[batch, hidden] + [hidden]`) without
8486    /// the caller writing the broadcast out explicitly.
8487    ///
8488    /// # Panics
8489    ///
8490    /// Panics if any operand belongs to a different [`Graph`] than the one this
8491    /// builder is building into. Operand identity is graph-scoped and checked by
8492    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8493    /// deliberate and replaces the hang a cross-graph handle used to produce.
8494    /// Build every operand from one graph, or use a `Result`-returning entry
8495    /// point where one exists.
8496    pub fn broadcast_add(&self, other: &NodeHandle) -> NodeHandle {
8497        let (a, b) = self.auto_broadcast_pair("broadcast_add", other);
8498        a.add(&b)
8499    }
8500
8501    /// Element-wise subtraction with automatic broadcasting.
8502    ///
8503    /// # Panics
8504    ///
8505    /// Panics if any operand belongs to a different [`Graph`] than the one this
8506    /// builder is building into. Operand identity is graph-scoped and checked by
8507    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8508    /// deliberate and replaces the hang a cross-graph handle used to produce.
8509    /// Build every operand from one graph, or use a `Result`-returning entry
8510    /// point where one exists.
8511    pub fn broadcast_sub(&self, other: &NodeHandle) -> NodeHandle {
8512        let (a, b) = self.auto_broadcast_pair("broadcast_sub", other);
8513        a.sub(&b)
8514    }
8515
8516    /// Element-wise multiplication with automatic broadcasting.
8517    ///
8518    /// # Panics
8519    ///
8520    /// Panics if any operand belongs to a different [`Graph`] than the one this
8521    /// builder is building into. Operand identity is graph-scoped and checked by
8522    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8523    /// deliberate and replaces the hang a cross-graph handle used to produce.
8524    /// Build every operand from one graph, or use a `Result`-returning entry
8525    /// point where one exists.
8526    pub fn broadcast_mul(&self, other: &NodeHandle) -> NodeHandle {
8527        let (a, b) = self.auto_broadcast_pair("broadcast_mul", other);
8528        a.mul(&b)
8529    }
8530
8531    /// Element-wise division with automatic broadcasting.
8532    ///
8533    /// # Panics
8534    ///
8535    /// Panics if any operand belongs to a different [`Graph`] than the one this
8536    /// builder is building into. Operand identity is graph-scoped and checked by
8537    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8538    /// deliberate and replaces the hang a cross-graph handle used to produce.
8539    /// Build every operand from one graph, or use a `Result`-returning entry
8540    /// point where one exists.
8541    pub fn broadcast_div(&self, other: &NodeHandle) -> NodeHandle {
8542        let (a, b) = self.auto_broadcast_pair("broadcast_div", other);
8543        a.div(&b)
8544    }
8545
8546    /// Build both operands broadcast to their common shape. Used by the
8547    /// `broadcast_*` wrappers above. Inserts explicit `BroadcastTo` nodes
8548    /// on whichever side needs them — either or both may pass through
8549    /// unchanged if already at the target shape.
8550    fn auto_broadcast_pair(
8551        &self,
8552        op: &'static str,
8553        other: &NodeHandle,
8554    ) -> (NodeHandle, NodeHandle) {
8555        assert!(
8556            Arc::ptr_eq(&self.graph, &other.graph),
8557            "{op}: tensors must live on the same graph — lhs is on graph #{}, rhs on graph #{}; \
8558             each `from_*` constructor mints a NEW graph, so build the second operand with \
8559             `from_*_on(lhs.graph(), ..)` or `const_*_like`",
8560            self.graph_id(),
8561            other.graph_id(),
8562        );
8563        assert_eq!(
8564            self.dtype(),
8565            other.dtype(),
8566            "{op}: dtype mismatch {:?} vs {:?}",
8567            self.dtype(),
8568            other.dtype(),
8569        );
8570        let a_shape = self.shape();
8571        let b_shape = other.shape();
8572        let target_dims = compute_broadcast_shape(a_shape.dims(), b_shape.dims());
8573        let target = Shape::from_dims(&target_dims);
8574        let a = if a_shape.dims() == target.dims() {
8575            self.clone()
8576        } else {
8577            self.broadcast_to(target.clone())
8578        };
8579        let b = if b_shape.dims() == target.dims() {
8580            other.clone()
8581        } else {
8582            other.broadcast_to(target)
8583        };
8584        (a, b)
8585    }
8586
8587    /// Append an `IndexAdd` node — the functional inverse of
8588    /// `IndexSelect`. Returns `base` with `src` added at positions given
8589    /// by `indices` along `dim`.
8590    ///
8591    /// # Panics
8592    ///
8593    /// Panics if any operand belongs to a different [`Graph`] than the one this
8594    /// builder is building into. Operand identity is graph-scoped and checked by
8595    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8596    /// deliberate and replaces the hang a cross-graph handle used to produce.
8597    /// Build every operand from one graph, or use a `Result`-returning entry
8598    /// point where one exists.
8599    pub fn index_add(&self, dim: usize, indices: &NodeHandle, src: &NodeHandle) -> NodeHandle {
8600        assert!(
8601            Arc::ptr_eq(&self.graph, &indices.graph) && Arc::ptr_eq(&self.graph, &src.graph),
8602            "index_add: all tensors must live on the same graph; use `const_*_like` to build on an existing graph",
8603        );
8604        assert_eq!(indices.dtype(), DType::U32, "index_add: index must be U32");
8605        assert_eq!(
8606            self.dtype(),
8607            src.dtype(),
8608            "index_add: base and src dtypes must match"
8609        );
8610        let base_dims = self.shape();
8611        let src_dims = src.shape();
8612        assert_eq!(
8613            base_dims.dims().len(),
8614            src_dims.dims().len(),
8615            "index_add: base and src must have the same rank",
8616        );
8617        assert_eq!(
8618            indices.shape().dims().len(),
8619            1,
8620            "index_add: index must be rank 1",
8621        );
8622        assert_eq!(
8623            src_dims.dims()[dim],
8624            indices.shape().dims()[0],
8625            "index_add: src dim {dim} ({}) must match index length ({})",
8626            src_dims.dims()[dim],
8627            indices.shape().dims()[0],
8628        );
8629        let dtype = self.dtype();
8630        let out_shape = base_dims;
8631        let id = self.graph.write().unwrap().push(Node {
8632            op: Op::IndexAdd { dim },
8633            inputs: vec![self.id, indices.id, src.id],
8634            shape: out_shape,
8635            dtype,
8636        });
8637        Self {
8638            graph: self.graph.clone(),
8639            id,
8640        }
8641    }
8642
8643    /// Append a `ScatterAdd` node — the functional inverse of `Gather`.
8644    /// Returns `base` with values from `src` accumulated at positions
8645    /// given by `indices` (with `indices[p]` substituted at `dim`).
8646    ///
8647    /// # Panics
8648    ///
8649    /// Panics if any operand belongs to a different [`Graph`] than the one this
8650    /// builder is building into. Operand identity is graph-scoped and checked by
8651    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
8652    /// deliberate and replaces the hang a cross-graph handle used to produce.
8653    /// Build every operand from one graph, or use a `Result`-returning entry
8654    /// point where one exists.
8655    pub fn scatter_add(&self, dim: usize, indices: &NodeHandle, src: &NodeHandle) -> NodeHandle {
8656        assert!(
8657            Arc::ptr_eq(&self.graph, &indices.graph) && Arc::ptr_eq(&self.graph, &src.graph),
8658            "scatter_add: all tensors must live on the same graph; use `const_*_like` to build on an existing graph",
8659        );
8660        assert_eq!(
8661            indices.dtype(),
8662            DType::U32,
8663            "scatter_add: index must be U32"
8664        );
8665        assert_eq!(
8666            self.dtype(),
8667            src.dtype(),
8668            "scatter_add: base and src dtypes must match"
8669        );
8670        assert_eq!(
8671            indices.shape().dims(),
8672            src.shape().dims(),
8673            "scatter_add: index and src must have the same shape",
8674        );
8675        let dtype = self.dtype();
8676        let out_shape = self.shape();
8677        let id = self.graph.write().unwrap().push(Node {
8678            op: Op::ScatterAdd { dim },
8679            inputs: vec![self.id, indices.id, src.id],
8680            shape: out_shape,
8681            dtype,
8682        });
8683        Self {
8684            graph: self.graph.clone(),
8685            id,
8686        }
8687    }
8688
8689    // --- internal helpers for the new builders ---
8690
8691    fn scalar_reduction(&self, op: Op) -> NodeHandle {
8692        let (shape, dtype) = crate::shape::primitive_shape(&op, &[self.shape()], &[self.dtype()])
8693            .expect("scalar_reduction: single input always infers a rank-0 shape");
8694        let id = self.graph.write().unwrap().push(Node {
8695            op,
8696            inputs: vec![self.id],
8697            shape,
8698            dtype,
8699        });
8700        Self {
8701            graph: self.graph.clone(),
8702            id,
8703        }
8704    }
8705
8706    fn axis_reduction(&self, name: &'static str, op: Op, dim: usize) -> NodeHandle {
8707        let in_shape = self.shape();
8708        assert!(
8709            dim < in_shape.dims().len(),
8710            "{name}: dim {dim} out of bounds for shape {:?}",
8711            in_shape.dims(),
8712        );
8713        let (shape, dtype) = crate::shape::primitive_shape(&op, &[in_shape], &[self.dtype()])
8714            .expect("axis_reduction: dim validated above");
8715        let id = self.graph.write().unwrap().push(Node {
8716            op,
8717            inputs: vec![self.id],
8718            shape,
8719            dtype,
8720        });
8721        Self {
8722            graph: self.graph.clone(),
8723            id,
8724        }
8725    }
8726
8727    // Internal helpers. Both shape-validate and append.
8728
8729    /// Build-time graph-affinity check for a `Result`-returning builder.
8730    ///
8731    /// Node ids index ONE graph's arena, so an operand from a different graph
8732    /// records an id pointing at an unrelated node — a malformed dependency
8733    /// graph whose failure mode is a **realize-time hang**, not a panic or an
8734    /// error. Checking here converts a silent deadlock into a typed build-time
8735    /// error naming both graphs, per "validate at graph-build time".
8736    fn require_same_graph(
8737        &self,
8738        other: &NodeHandle,
8739        op: &str,
8740        role: &str,
8741    ) -> std::result::Result<(), fuel_ir::Error> {
8742        if !Arc::ptr_eq(&self.graph, &other.graph) {
8743            return Err(fuel_ir::Error::Msg(format!(
8744                "{op}: operands must live on the same graph — self is on graph #{}, \
8745                 {role} on graph #{}; each `from_*` constructor mints a NEW graph, so \
8746                 build the other operand with `from_*_on(self.graph(), ..)` or \
8747                 `const_*_like`",
8748                self.graph_id(),
8749                other.graph_id(),
8750            ))
8751            .bt());
8752        }
8753        Ok(())
8754    }
8755
8756    /// Panicking sibling of [`Self::require_same_graph`] for the builders that
8757    /// return `NodeHandle` rather than `Result` — same convention as `matmul`,
8758    /// `qmatmul`, `conv2d` and `paged_attn`. A panic at the build site is far
8759    /// better than the hang it replaces: it names the operand and both graphs.
8760    fn assert_same_graph(&self, other: &NodeHandle, op: &str, role: &str) {
8761        assert!(
8762            Arc::ptr_eq(&self.graph, &other.graph),
8763            "{op}: operands must live on the same graph — self is on graph #{}, \
8764             {role} on graph #{}; each `from_*` constructor mints a NEW graph, so \
8765             build the other operand with `from_*_on(self.graph(), ..)` or \
8766             `const_*_like`",
8767            self.graph_id(),
8768            other.graph_id(),
8769        );
8770    }
8771
8772    fn binary_op(
8773        &self,
8774        name: &'static str,
8775        op: Op,
8776        other: &NodeHandle,
8777        out_shape: Shape,
8778    ) -> NodeHandle {
8779        assert!(
8780            Arc::ptr_eq(&self.graph, &other.graph),
8781            "{name}: tensors must live on the same graph — lhs is on graph #{}, rhs on graph #{}; \
8782                 each `from_*` constructor mints a NEW graph, so build the second operand with \
8783                 `from_*_on(lhs.graph(), ..)` or `const_*_like`",
8784            self.graph_id(),
8785            other.graph_id(),
8786        );
8787        assert_eq!(
8788            self.dtype(),
8789            other.dtype(),
8790            "{name}: dtype mismatch: lhs={:?}, rhs={:?}",
8791            self.dtype(),
8792            other.dtype(),
8793        );
8794        assert_eq!(
8795            self.shape().dims(),
8796            other.shape().dims(),
8797            "{name}: shape mismatch: lhs={:?}, rhs={:?}",
8798            self.shape().dims(),
8799            other.shape().dims(),
8800        );
8801        let dtype = self.dtype();
8802        let id = self.graph.write().unwrap().push(Node {
8803            op,
8804            inputs: vec![self.id, other.id],
8805            shape: out_shape,
8806            dtype,
8807        });
8808        Self {
8809            graph: self.graph.clone(),
8810            id,
8811        }
8812    }
8813
8814    /// Result-returning sibling of [`Self::binary_op`]. Validates the
8815    /// graph/dtype/shape preconditions and returns `Err` rather than
8816    /// panicking — the production-correct shape new builders should
8817    /// use. Existing panicking sites are kept for back-compat; new
8818    /// ops should call this directly.
8819    fn try_binary_op(
8820        &self,
8821        name: &'static str,
8822        op: Op,
8823        other: &NodeHandle,
8824        out_shape: Shape,
8825    ) -> std::result::Result<NodeHandle, fuel_ir::Error> {
8826        if !Arc::ptr_eq(&self.graph, &other.graph) {
8827            return Err(fuel_ir::Error::Msg(format!(
8828                "{name}: tensors must live on the same graph — lhs is on graph #{}, rhs on graph #{}; \
8829                 each `from_*` constructor mints a NEW graph, so build the second operand with \
8830                 `from_*_on(lhs.graph(), ..)` or `const_*_like`",
8831            self.graph_id(),
8832            other.graph_id(),
8833            )).bt());
8834        }
8835        if self.dtype() != other.dtype() {
8836            return Err(fuel_ir::Error::Msg(format!(
8837                "{name}: dtype mismatch: lhs={:?}, rhs={:?}",
8838                self.dtype(),
8839                other.dtype(),
8840            ))
8841            .bt());
8842        }
8843        if self.shape().dims() != other.shape().dims() {
8844            return Err(fuel_ir::Error::Msg(format!(
8845                "{name}: shape mismatch: lhs={:?}, rhs={:?}",
8846                self.shape().dims(),
8847                other.shape().dims(),
8848            ))
8849            .bt());
8850        }
8851        let dtype = self.dtype();
8852        let id = self.graph.write().unwrap().push(Node {
8853            op,
8854            inputs: vec![self.id, other.id],
8855            shape: out_shape,
8856            dtype,
8857        });
8858        Ok(Self {
8859            graph: self.graph.clone(),
8860            id,
8861        })
8862    }
8863
8864    fn unary_op(&self, op: Op) -> NodeHandle {
8865        let shape = self.shape();
8866        let dtype = self.dtype();
8867        let id = self.graph.write().unwrap().push(Node {
8868            op,
8869            inputs: vec![self.id],
8870            shape,
8871            dtype,
8872        });
8873        Self {
8874            graph: self.graph.clone(),
8875            id,
8876        }
8877    }
8878
8879    /// Binary builder for ops whose inputs share dtype/shape but whose
8880    /// output is a `U8` mask (`Op::Equal` and the rest of the
8881    /// comparison family). Differs from [`Self::binary_op`] only in the
8882    /// node's output dtype: always `DType::U8` regardless of input
8883    /// dtype.
8884    fn binary_compare_op(&self, name: &'static str, op: Op, other: &NodeHandle) -> NodeHandle {
8885        assert!(
8886            Arc::ptr_eq(&self.graph, &other.graph),
8887            "{name}: tensors must live on the same graph — lhs is on graph #{}, rhs on graph #{}; \
8888                 each `from_*` constructor mints a NEW graph, so build the second operand with \
8889                 `from_*_on(lhs.graph(), ..)` or `const_*_like`",
8890            self.graph_id(),
8891            other.graph_id(),
8892        );
8893        assert_eq!(
8894            self.dtype(),
8895            other.dtype(),
8896            "{name}: dtype mismatch: lhs={:?}, rhs={:?}",
8897            self.dtype(),
8898            other.dtype(),
8899        );
8900        assert_eq!(
8901            self.shape().dims(),
8902            other.shape().dims(),
8903            "{name}: shape mismatch: lhs={:?}, rhs={:?}",
8904            self.shape().dims(),
8905            other.shape().dims(),
8906        );
8907        let shape = self.shape();
8908        let id = self.graph.write().unwrap().push(Node {
8909            op,
8910            inputs: vec![self.id, other.id],
8911            shape,
8912            // GAP-168(c): comparisons yield a `Bool` mask. This BUILDER path is
8913            // the one eq/ne/lt/le/gt/ge take; it must agree with the
8914            // `primitive_shape` comparison arm (fuel-graph/src/shape.rs), which
8915            // is the OTHER site that decides this dtype (reached on re-lowering).
8916            dtype: DType::Bool,
8917        });
8918        Self {
8919            graph: self.graph.clone(),
8920            id,
8921        }
8922    }
8923
8924    /// Build the backward graph from `self`, treating `self` as the output
8925    /// whose gradient drives the rest of the computation.
8926    ///
8927    /// Walks the forward graph in reverse topological order, applies the
8928    /// per-op gradient rule to each node, and emits new graph nodes for
8929    /// the gradient with respect to every node reachable from `self`. The
8930    /// returned [`GradMap`] lets you look up the gradient tensor for any
8931    /// forward tensor from the same graph.
8932    ///
8933    /// The initial upstream gradient is a ones tensor of the same shape
8934    /// and dtype as `self` — the conventional `dL/dL = 1` seed for
8935    /// backprop. For a scalar loss this is `[1.0]`; for a non-scalar
8936    /// output it is "each element of the output contributes with weight 1
8937    /// to the total derivative."
8938    ///
8939    /// ## Supported ops in the MVP
8940    ///
8941    /// `Const`, `Add`, `Mul`, `MatMul`, `Transpose`, `Sqr`, `Exp` all have
8942    /// gradient rules implemented. `Relu` panics — its gradient needs a
8943    /// `Step`/`Where`/`Sign` op that is not yet in the catalog and will
8944    /// land in a follow-up once an indicator-valued op is defined.
8945    ///
8946    /// ## Ownership and the forward graph
8947    ///
8948    /// Backward extends the same graph that held the forward pass. The
8949    /// gradient nodes live alongside the forward nodes in one arena. After
8950    /// `backward()`, realizing a gradient tensor re-executes whatever part
8951    /// of the forward pass the gradient depends on — the executor walks
8952    /// the combined graph as one.
8953    pub fn backward(&self) -> GradMap {
8954        let graph_handle = self.graph.clone();
8955
8956        // C3 pre-pass (Op::Scan Phase 2): lower every reachable Op::Scan to
8957        // primitives + decompose the SSM fused recipes BEFORE the topo order is
8958        // taken, so the reverse walk sees only differentiable primitives. On a
8959        // scan-free graph it is a near-no-op — two topo scans (Pass 1's SSM
8960        // collect + Pass 2's first iteration, which finds no scans and breaks),
8961        // no rewiring. The (possibly remapped) root replaces self.id below.
8962        let root = lower_scans_for_backward(&graph_handle, self.id);
8963
8964        // Compute topological order of reachable nodes, then reverse it
8965        // so we walk from the root (output) toward the leaves (inputs).
8966        let mut order = topo_order(&graph_handle.read().unwrap(), root);
8967        order.reverse();
8968
8969        // Initial upstream gradient for the root: ones tensor of matching
8970        // shape and dtype. For a scalar loss this is [1.0]; for vector
8971        // outputs it seeds each element with weight 1.
8972        let (root_shape, root_dtype) = {
8973            let g = graph_handle.read().unwrap();
8974            let n = g.node(root);
8975            (n.shape.clone(), n.dtype)
8976        };
8977        let ones_id = build_ones(&graph_handle, root_shape, root_dtype);
8978
8979        let mut upstream: HashMap<NodeId, NodeId> = HashMap::new();
8980        upstream.insert(root, ones_id);
8981
8982        for id in order {
8983            let up_id = match upstream.get(&id).copied() {
8984                Some(v) => v,
8985                None => continue, // unreachable from the root, no gradient to propagate
8986            };
8987            // Snapshot the op and its input IDs so we can drop the read
8988            // borrow before taking a mutable borrow to append new nodes.
8989            let (op, inputs) = {
8990                let g = graph_handle.read().unwrap();
8991                let node = g.node(id);
8992                (node.op.clone(), node.inputs.clone())
8993            };
8994
8995            // Symbolic-autograd dispatch (Phase 6d Track 2). If a
8996            // `GradientRule` is registered for this op, use it. Ops
8997            // that haven't migrated yet fall through to the legacy
8998            // inline `match` below.
8999            if let Some(grads) =
9000                crate::grad::dispatch_gradient(&graph_handle, &op, &inputs, id, up_id)
9001            {
9002                debug_assert_eq!(
9003                    grads.len(),
9004                    inputs.len(),
9005                    "GradientRule for {:?} returned {} gradients but op has {} inputs",
9006                    op.short_name(),
9007                    grads.len(),
9008                    inputs.len(),
9009                );
9010                for (input_id, grad) in inputs.iter().zip(grads.iter()) {
9011                    if let Some(g) = grad {
9012                        accumulate_grad(&mut upstream, *input_id, *g, &graph_handle);
9013                    }
9014                }
9015                continue;
9016            }
9017
9018            match op {
9019                Op::Const => {
9020                    // Leaf. The upstream value has already been stored and
9021                    // is what `GradMap::get` will return for this input.
9022                }
9023                Op::Iota { .. } => {
9024                    // Leaf, non-differentiable: a constant position
9025                    // generator with no inputs — no gradient to propagate.
9026                }
9027                Op::NonZeroIndices { .. } => {
9028                    // Produces discrete indices + a runtime count; both
9029                    // are integer/non-differentiable. No gradient flows to
9030                    // the input. (A differentiated graph that reaches this
9031                    // op surfaces as a usage error in the grad-emitting
9032                    // pass, matching the ArgMaxDim/ArgMinDim precedent.)
9033                }
9034                Op::Add => {
9035                    // d(a + b)/da = 1, d(a + b)/db = 1.
9036                    // Upstream flows unchanged into both inputs.
9037                    accumulate_grad(&mut upstream, inputs[0], up_id, &graph_handle);
9038                    accumulate_grad(&mut upstream, inputs[1], up_id, &graph_handle);
9039                }
9040                Op::Mul => {
9041                    // d(a * b)/da = b, d(a * b)/db = a.
9042                    // Upstream * b goes to a; upstream * a goes to b.
9043                    let a = inputs[0];
9044                    let b = inputs[1];
9045                    let a_shape = node_shape(&graph_handle, a);
9046                    let b_shape = node_shape(&graph_handle, b);
9047                    let dtype = node_dtype(&graph_handle, a);
9048                    let grad_a = push_node(&graph_handle, Op::Mul, vec![up_id, b], a_shape, dtype);
9049                    let grad_b = push_node(&graph_handle, Op::Mul, vec![up_id, a], b_shape, dtype);
9050                    accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
9051                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
9052                }
9053                Op::MatMul => {
9054                    // Forward: Y = A @ B,  A: [m, k], B: [k, n], Y: [m, n]
9055                    // Backward: dA = dY @ B^T,  dB = A^T @ dY.
9056                    let a = inputs[0];
9057                    let b = inputs[1];
9058                    let a_shape = node_shape(&graph_handle, a);
9059                    let b_shape = node_shape(&graph_handle, b);
9060                    let dtype = node_dtype(&graph_handle, a);
9061                    // B^T: shape [n, k]
9062                    let b_t_shape = transposed_shape(&b_shape);
9063                    let b_t = push_node(&graph_handle, Op::Transpose, vec![b], b_t_shape, dtype);
9064                    // dA = upstream @ B^T, shape [m, k] = a_shape
9065                    let grad_a = push_node(
9066                        &graph_handle,
9067                        Op::MatMul,
9068                        vec![up_id, b_t],
9069                        a_shape.clone(),
9070                        dtype,
9071                    );
9072                    // A^T: shape [k, m]
9073                    let a_t_shape = transposed_shape(&a_shape);
9074                    let a_t = push_node(&graph_handle, Op::Transpose, vec![a], a_t_shape, dtype);
9075                    // dB = A^T @ upstream, shape [k, n] = b_shape
9076                    let grad_b =
9077                        push_node(&graph_handle, Op::MatMul, vec![a_t, up_id], b_shape, dtype);
9078                    accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
9079                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
9080                }
9081                Op::Transpose => {
9082                    // d(x^T)/dx is a transpose on the way back.
9083                    let x = inputs[0];
9084                    let x_shape = node_shape(&graph_handle, x);
9085                    let dtype = node_dtype(&graph_handle, x);
9086                    let grad_x =
9087                        push_node(&graph_handle, Op::Transpose, vec![up_id], x_shape, dtype);
9088                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9089                }
9090                Op::Permute(ref axes) => {
9091                    // Backward: apply the inverse permutation to upstream.
9092                    // If forward permutes with `axes` (out[i] = in[axes[i]]),
9093                    // the inverse `inv` satisfies `inv[axes[i]] = i`.
9094                    let x = inputs[0];
9095                    let x_shape = node_shape(&graph_handle, x);
9096                    let dtype = node_dtype(&graph_handle, x);
9097                    let mut inv = vec![0_usize; axes.len()];
9098                    for (i, &ax) in axes.iter().enumerate() {
9099                        inv[ax] = i;
9100                    }
9101                    let grad_x =
9102                        push_node(&graph_handle, Op::Permute(inv), vec![up_id], x_shape, dtype);
9103                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9104                }
9105                Op::Sqr => {
9106                    // d(x^2)/dx = 2x. Expressed as `x + x` to avoid
9107                    // needing a scalar constant broadcast — both sums
9108                    // have the same shape as x, the existing Add op
9109                    // handles them natively.
9110                    let x = inputs[0];
9111                    let x_shape = node_shape(&graph_handle, x);
9112                    let dtype = node_dtype(&graph_handle, x);
9113                    let two_x =
9114                        push_node(&graph_handle, Op::Add, vec![x, x], x_shape.clone(), dtype);
9115                    let grad_x =
9116                        push_node(&graph_handle, Op::Mul, vec![up_id, two_x], x_shape, dtype);
9117                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9118                }
9119                Op::Exp => {
9120                    // d(e^x)/dx = e^x, which is the forward output — i.e.
9121                    // this node `id`. Reuse it directly instead of
9122                    // recomputing.
9123                    let x = inputs[0];
9124                    let out_shape = node_shape(&graph_handle, id);
9125                    let dtype = node_dtype(&graph_handle, id);
9126                    let grad_x =
9127                        push_node(&graph_handle, Op::Mul, vec![up_id, id], out_shape, dtype);
9128                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9129                }
9130                Op::Sub => {
9131                    // d(a - b)/da = 1, d(a - b)/db = -1.
9132                    let a = inputs[0];
9133                    let b = inputs[1];
9134                    accumulate_grad(&mut upstream, a, up_id, &graph_handle);
9135                    let b_shape = node_shape(&graph_handle, b);
9136                    let dtype = node_dtype(&graph_handle, b);
9137                    let neg_up = push_node(&graph_handle, Op::Neg, vec![up_id], b_shape, dtype);
9138                    accumulate_grad(&mut upstream, b, neg_up, &graph_handle);
9139                }
9140                Op::Div => {
9141                    // y = a / b
9142                    // dy/da = 1/b     →  grad_a = upstream / b
9143                    // dy/db = -a/b²   →  grad_b = -(upstream * a / (b*b))
9144                    let a = inputs[0];
9145                    let b = inputs[1];
9146                    let a_shape = node_shape(&graph_handle, a);
9147                    let b_shape = node_shape(&graph_handle, b);
9148                    let dtype = node_dtype(&graph_handle, a);
9149                    // grad_a
9150                    let grad_a = push_node(
9151                        &graph_handle,
9152                        Op::Div,
9153                        vec![up_id, b],
9154                        a_shape.clone(),
9155                        dtype,
9156                    );
9157                    accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
9158                    // grad_b: -(upstream * a / (b*b))
9159                    let b_sq = push_node(&graph_handle, Op::Sqr, vec![b], b_shape.clone(), dtype);
9160                    let up_times_a =
9161                        push_node(&graph_handle, Op::Mul, vec![up_id, a], a_shape, dtype);
9162                    let quotient = push_node(
9163                        &graph_handle,
9164                        Op::Div,
9165                        vec![up_times_a, b_sq],
9166                        b_shape.clone(),
9167                        dtype,
9168                    );
9169                    let grad_b = push_node(&graph_handle, Op::Neg, vec![quotient], b_shape, dtype);
9170                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
9171                }
9172                Op::Neg => {
9173                    // d(-x)/dx = -1. grad_x = -upstream.
9174                    let x = inputs[0];
9175                    let x_shape = node_shape(&graph_handle, x);
9176                    let dtype = node_dtype(&graph_handle, x);
9177                    let grad_x = push_node(&graph_handle, Op::Neg, vec![up_id], x_shape, dtype);
9178                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9179                }
9180                Op::Sqrt => {
9181                    // y = sqrt(x), dy/dx = 1/(2*sqrt(x)) = 1/(2y).
9182                    // grad_x = upstream / (y + y). Reuse the forward node.
9183                    let x = inputs[0];
9184                    let x_shape = node_shape(&graph_handle, x);
9185                    let dtype = node_dtype(&graph_handle, x);
9186                    let two_y =
9187                        push_node(&graph_handle, Op::Add, vec![id, id], x_shape.clone(), dtype);
9188                    let grad_x =
9189                        push_node(&graph_handle, Op::Div, vec![up_id, two_y], x_shape, dtype);
9190                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9191                }
9192                Op::Log => {
9193                    // d(ln(x))/dx = 1/x. grad_x = upstream / x.
9194                    let x = inputs[0];
9195                    let x_shape = node_shape(&graph_handle, x);
9196                    let dtype = node_dtype(&graph_handle, x);
9197                    let grad_x = push_node(&graph_handle, Op::Div, vec![up_id, x], x_shape, dtype);
9198                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9199                }
9200                Op::Sin => {
9201                    // d(sin(x))/dx = cos(x). grad_x = upstream * cos(x).
9202                    let x = inputs[0];
9203                    let x_shape = node_shape(&graph_handle, x);
9204                    let dtype = node_dtype(&graph_handle, x);
9205                    let cos_x = push_node(&graph_handle, Op::Cos, vec![x], x_shape.clone(), dtype);
9206                    let grad_x =
9207                        push_node(&graph_handle, Op::Mul, vec![up_id, cos_x], x_shape, dtype);
9208                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9209                }
9210                Op::Cos => {
9211                    // d(cos(x))/dx = -sin(x). grad_x = -(upstream * sin(x)).
9212                    let x = inputs[0];
9213                    let x_shape = node_shape(&graph_handle, x);
9214                    let dtype = node_dtype(&graph_handle, x);
9215                    let sin_x = push_node(&graph_handle, Op::Sin, vec![x], x_shape.clone(), dtype);
9216                    let up_sin = push_node(
9217                        &graph_handle,
9218                        Op::Mul,
9219                        vec![up_id, sin_x],
9220                        x_shape.clone(),
9221                        dtype,
9222                    );
9223                    let grad_x = push_node(&graph_handle, Op::Neg, vec![up_sin], x_shape, dtype);
9224                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9225                }
9226                Op::Tanh => {
9227                    // y = tanh(x), dy/dx = 1 - y². grad_x = upstream * (1 - y*y).
9228                    // Build: ones_of_y_shape - y*y, then multiply by upstream.
9229                    let x = inputs[0];
9230                    let y_shape = node_shape(&graph_handle, id);
9231                    let dtype = node_dtype(&graph_handle, id);
9232                    let y_sq = push_node(&graph_handle, Op::Sqr, vec![id], y_shape.clone(), dtype);
9233                    let ones = build_ones(&graph_handle, y_shape.clone(), dtype);
9234                    let one_minus_sq = push_node(
9235                        &graph_handle,
9236                        Op::Sub,
9237                        vec![ones, y_sq],
9238                        y_shape.clone(),
9239                        dtype,
9240                    );
9241                    let grad_x = push_node(
9242                        &graph_handle,
9243                        Op::Mul,
9244                        vec![up_id, one_minus_sq],
9245                        y_shape,
9246                        dtype,
9247                    );
9248                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9249                }
9250                Op::Sigmoid => {
9251                    // y = sigmoid(x), dy/dx = y * (1 - y).
9252                    // grad_x = upstream * y * (1 - y).
9253                    let x = inputs[0];
9254                    let y_shape = node_shape(&graph_handle, id);
9255                    let dtype = node_dtype(&graph_handle, id);
9256                    let ones = build_ones(&graph_handle, y_shape.clone(), dtype);
9257                    let one_minus_y = push_node(
9258                        &graph_handle,
9259                        Op::Sub,
9260                        vec![ones, id],
9261                        y_shape.clone(),
9262                        dtype,
9263                    );
9264                    let y_times_1my = push_node(
9265                        &graph_handle,
9266                        Op::Mul,
9267                        vec![id, one_minus_y],
9268                        y_shape.clone(),
9269                        dtype,
9270                    );
9271                    let grad_x = push_node(
9272                        &graph_handle,
9273                        Op::Mul,
9274                        vec![up_id, y_times_1my],
9275                        y_shape,
9276                        dtype,
9277                    );
9278                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9279                }
9280                Op::Silu => {
9281                    // y = x * sigmoid(x)
9282                    // dy/dx = sigmoid(x) + x * sigmoid(x) * (1 - sigmoid(x))
9283                    //       = sigmoid(x) * (1 + x * (1 - sigmoid(x)))
9284                    //
9285                    // Simpler: let s = sigmoid(x). dy/dx = s + y * (1 - s),
9286                    // where y = x * s is the forward output (this node).
9287                    // grad_x = upstream * (s + y * (1 - s)).
9288                    let x = inputs[0];
9289                    let x_shape = node_shape(&graph_handle, x);
9290                    let dtype = node_dtype(&graph_handle, x);
9291                    let s = push_node(&graph_handle, Op::Sigmoid, vec![x], x_shape.clone(), dtype);
9292                    let ones = build_ones(&graph_handle, x_shape.clone(), dtype);
9293                    let one_minus_s = push_node(
9294                        &graph_handle,
9295                        Op::Sub,
9296                        vec![ones, s],
9297                        x_shape.clone(),
9298                        dtype,
9299                    );
9300                    let y_times_1ms = push_node(
9301                        &graph_handle,
9302                        Op::Mul,
9303                        vec![id, one_minus_s],
9304                        x_shape.clone(),
9305                        dtype,
9306                    );
9307                    let inner = push_node(
9308                        &graph_handle,
9309                        Op::Add,
9310                        vec![s, y_times_1ms],
9311                        x_shape.clone(),
9312                        dtype,
9313                    );
9314                    let grad_x =
9315                        push_node(&graph_handle, Op::Mul, vec![up_id, inner], x_shape, dtype);
9316                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9317                }
9318                Op::GeluTanh => {
9319                    let x = inputs[0];
9320                    let grad_x = build_gelu_tanh_grad(&graph_handle, x, up_id);
9321                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9322                }
9323                Op::Relu => {
9324                    // d(relu(x))/dx = step(x). grad_x = upstream * step(x).
9325                    let x = inputs[0];
9326                    let x_shape = node_shape(&graph_handle, x);
9327                    let dtype = node_dtype(&graph_handle, x);
9328                    let step_x =
9329                        push_node(&graph_handle, Op::Step, vec![x], x_shape.clone(), dtype);
9330                    let grad_x =
9331                        push_node(&graph_handle, Op::Mul, vec![up_id, step_x], x_shape, dtype);
9332                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9333                }
9334                Op::Step => {
9335                    // d(step(x))/dx is 0 almost everywhere (and a Dirac
9336                    // delta at 0 which has no finite representation). The
9337                    // standard move is to treat the derivative as 0 and
9338                    // stop propagation — Step's backward is silently a
9339                    // no-op. Callers that need a smooth surrogate should
9340                    // use Sigmoid or Tanh instead.
9341                }
9342                Op::Recip => {
9343                    // y = 1/x, dy/dx = -1/x² = -y².
9344                    // grad_x = -upstream * y * y. Reuse forward output (id)
9345                    // so we don't recompute the reciprocal.
9346                    let x = inputs[0];
9347                    let x_shape = node_shape(&graph_handle, x);
9348                    let dtype = node_dtype(&graph_handle, x);
9349                    let y_sq = push_node(&graph_handle, Op::Sqr, vec![id], x_shape.clone(), dtype);
9350                    let up_y_sq = push_node(
9351                        &graph_handle,
9352                        Op::Mul,
9353                        vec![up_id, y_sq],
9354                        x_shape.clone(),
9355                        dtype,
9356                    );
9357                    let grad_x = push_node(&graph_handle, Op::Neg, vec![up_y_sq], x_shape, dtype);
9358                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9359                }
9360                Op::Abs => {
9361                    // y = |x|, dy/dx = sign(x), with sign(0)=0 by
9362                    // subgradient convention. Simplified to a direct
9363                    // `Op::Sign` use after Op::Sign landed in PR B2;
9364                    // the previous form synthesized `step(x) - step(-x)`
9365                    // (5 backward nodes) but Sign expresses the same
9366                    // function in 1 node, so the chain shrinks to
9367                    // `Mul(upstream, Sign(x))` — 2 nodes total.
9368                    let x = inputs[0];
9369                    let x_shape = node_shape(&graph_handle, x);
9370                    let dtype = node_dtype(&graph_handle, x);
9371                    let sign_x =
9372                        push_node(&graph_handle, Op::Sign, vec![x], x_shape.clone(), dtype);
9373                    let grad_x =
9374                        push_node(&graph_handle, Op::Mul, vec![up_id, sign_x], x_shape, dtype);
9375                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9376                }
9377                Op::Where => {
9378                    // Handled by `WhereRule` via `dispatch_gradient`;
9379                    // this arm only exists for exhaustiveness.
9380                }
9381                Op::Floor | Op::Ceil | Op::Round | Op::Sign => {
9382                    // d(floor(x))/dx, d(ceil(x))/dx, d(round(x))/dx,
9383                    // d(sign(x))/dx are all 0 almost everywhere (with
9384                    // a Dirac train at integer / half-integer / zero
9385                    // that has no finite representation). Treat the
9386                    // derivative as 0 and stop propagation — mirrors
9387                    // `Op::Step`'s backward.
9388                }
9389                Op::Erf => {
9390                    // y = erf(x). dy/dx = (2/√π) * exp(-x²).
9391                    // grad_x = upstream * (2/√π) * exp(-x²).
9392                    // Decomposes to: Sqr(x) → Neg → Exp → MulScalar(2/√π)
9393                    //                → Mul(upstream, .).
9394                    // 2/√π = 1.1283791670955125738961589031...
9395                    const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
9396                    let x = inputs[0];
9397                    let x_shape = node_shape(&graph_handle, x);
9398                    let dtype = node_dtype(&graph_handle, x);
9399                    let x_sq = push_node(&graph_handle, Op::Sqr, vec![x], x_shape.clone(), dtype);
9400                    let neg_x_sq =
9401                        push_node(&graph_handle, Op::Neg, vec![x_sq], x_shape.clone(), dtype);
9402                    let exp_neg_x_sq = push_node(
9403                        &graph_handle,
9404                        Op::Exp,
9405                        vec![neg_x_sq],
9406                        x_shape.clone(),
9407                        dtype,
9408                    );
9409                    let scaled = push_node(
9410                        &graph_handle,
9411                        Op::MulScalar(TWO_OVER_SQRT_PI),
9412                        vec![exp_neg_x_sq],
9413                        x_shape.clone(),
9414                        dtype,
9415                    );
9416                    let grad_x =
9417                        push_node(&graph_handle, Op::Mul, vec![up_id, scaled], x_shape, dtype);
9418                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9419                }
9420                Op::Flip { dim } => {
9421                    // y = flip(x, dim). Backward is another Flip on
9422                    // the same dim — Flip is its own inverse
9423                    // (involutive). One backward node, exact gradient.
9424                    let x = inputs[0];
9425                    let x_shape = node_shape(&graph_handle, x);
9426                    let dtype = node_dtype(&graph_handle, x);
9427                    let grad_x =
9428                        push_node(&graph_handle, Op::Flip { dim }, vec![up_id], x_shape, dtype);
9429                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9430                }
9431                Op::Pad {
9432                    padding,
9433                    mode,
9434                    value: _,
9435                } => {
9436                    // Backward delegates to Op::PadBackward — a single
9437                    // node that handles all three modes uniformly via
9438                    // its kernel. Constant: slice the unpadded region.
9439                    // Reflect/Replicate: accumulate gradient at the
9440                    // mirrored / replicated positions.
9441                    let padding = padding.clone();
9442                    let x = inputs[0];
9443                    let x_shape = node_shape(&graph_handle, x);
9444                    let dtype = node_dtype(&graph_handle, x);
9445                    let grad_x = push_node(
9446                        &graph_handle,
9447                        Op::PadBackward {
9448                            in_shape: x_shape.clone(),
9449                            padding,
9450                            mode,
9451                        },
9452                        vec![up_id],
9453                        x_shape,
9454                        dtype,
9455                    );
9456                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9457                }
9458                Op::PadBackward { .. } => {
9459                    // Backward helper — typically a leaf in autograd
9460                    // (the upstream gradient itself never gets a
9461                    // higher-order backward in v1). No-op arm for
9462                    // exhaustiveness; if higher-order autograd needs
9463                    // PadBackward's backward, that's its own scope.
9464                }
9465                Op::CumSum { dim } => {
9466                    // y[..., i, ...] = sum_{k=0..=i} x[..., k, ...]
9467                    // dL/dx[..., i, ...] = sum_{k=i..n} dL/dy[..., k, ...]
9468                    // i.e. reverse cumsum. Express via Flip → CumSum → Flip.
9469                    let x = inputs[0];
9470                    let x_shape = node_shape(&graph_handle, x);
9471                    let dtype = node_dtype(&graph_handle, x);
9472                    let flip1 = push_node(
9473                        &graph_handle,
9474                        Op::Flip { dim },
9475                        vec![up_id],
9476                        x_shape.clone(),
9477                        dtype,
9478                    );
9479                    let cs = push_node(
9480                        &graph_handle,
9481                        Op::CumSum { dim },
9482                        vec![flip1],
9483                        x_shape.clone(),
9484                        dtype,
9485                    );
9486                    let grad_x =
9487                        push_node(&graph_handle, Op::Flip { dim }, vec![cs], x_shape, dtype);
9488                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9489                }
9490                Op::Roll { dim, shift } => {
9491                    // y = roll(x, dim, shift). Backward is the opposite
9492                    // shift along the same dim. One backward node.
9493                    let x = inputs[0];
9494                    let x_shape = node_shape(&graph_handle, x);
9495                    let dtype = node_dtype(&graph_handle, x);
9496                    let grad_x = push_node(
9497                        &graph_handle,
9498                        Op::Roll { dim, shift: -shift },
9499                        vec![up_id],
9500                        x_shape,
9501                        dtype,
9502                    );
9503                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9504                }
9505                Op::Triu { diagonal } => {
9506                    // y = triu(x, diagonal). Mask is a binary indicator
9507                    // — gradient passes through kept positions and is
9508                    // zero on the masked half. Same op on the upstream.
9509                    let x = inputs[0];
9510                    let x_shape = node_shape(&graph_handle, x);
9511                    let dtype = node_dtype(&graph_handle, x);
9512                    let grad_x = push_node(
9513                        &graph_handle,
9514                        Op::Triu { diagonal },
9515                        vec![up_id],
9516                        x_shape,
9517                        dtype,
9518                    );
9519                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9520                }
9521                Op::Tril { diagonal } => {
9522                    // Mirror of Op::Triu — same mask gradient.
9523                    let x = inputs[0];
9524                    let x_shape = node_shape(&graph_handle, x);
9525                    let dtype = node_dtype(&graph_handle, x);
9526                    let grad_x = push_node(
9527                        &graph_handle,
9528                        Op::Tril { diagonal },
9529                        vec![up_id],
9530                        x_shape,
9531                        dtype,
9532                    );
9533                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9534                }
9535                Op::LogSoftmaxLastDim => {
9536                    // grad_x = upstream - exp(y) * sum(upstream, last_dim, keepdim).
9537                    // Folded into Op::LogSoftmaxLastDimBackward — takes
9538                    // (forward_output, upstream), avoids re-evaluating
9539                    // the softmax outside the kernel.
9540                    let x = inputs[0];
9541                    let x_shape = node_shape(&graph_handle, x);
9542                    let dtype = node_dtype(&graph_handle, x);
9543                    let grad_x = push_node(
9544                        &graph_handle,
9545                        Op::LogSoftmaxLastDimBackward,
9546                        vec![id, up_id],
9547                        x_shape,
9548                        dtype,
9549                    );
9550                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9551                }
9552                Op::LogSoftmaxLastDimBackward => {
9553                    // Backward helper — no higher-order autograd in v1.
9554                }
9555                Op::MaskedFill { value: _ } => {
9556                    // y = mask ? value : x. dy/dx = (mask == 0).
9557                    // grad_x = MaskedFill(upstream, mask, value=0_of_dtype).
9558                    // The mask is integer-only; no gradient flows to it.
9559                    let x = inputs[0];
9560                    let mask = inputs[1];
9561                    let x_shape = node_shape(&graph_handle, x);
9562                    let dtype = node_dtype(&graph_handle, x);
9563                    let zero = match fuel_ir::Scalar::zero(dtype) {
9564                        Ok(z) => z,
9565                        Err(_) => {
9566                            // A packed/scale dtype has no zero scalar. MaskedFill
9567                            // on such a dtype is a build-time bug (a future D5
9568                            // graph-build guard forbids it), so this is
9569                            // unreachable in a well-formed graph. debug_assert
9570                            // catches it in debug; release skips this gradient
9571                            // rather than panicking (never-panic invariant).
9572                            debug_assert!(false, "MaskedFill backward on non-real dtype {dtype:?}");
9573                            continue;
9574                        }
9575                    };
9576                    let grad_x = push_node(
9577                        &graph_handle,
9578                        Op::MaskedFill { value: zero },
9579                        vec![up_id, mask],
9580                        x_shape,
9581                        dtype,
9582                    );
9583                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9584                    // No gradient to mask: it's discrete.
9585                }
9586                Op::Rem => {
9587                    // y = a - floor(a/b) * b (PyTorch convention).
9588                    // d/da = 1 (the floor term is treated as a constant
9589                    //          w.r.t. a almost everywhere; the actual
9590                    //          derivative has a Dirac component at
9591                    //          integer a/b ratios that we drop, matching
9592                    //          PyTorch's autograd convention).
9593                    // d/db = -floor(a/b)
9594                    // grad_a = upstream
9595                    // grad_b = upstream * (-floor(a/b))
9596                    let a = inputs[0];
9597                    let b = inputs[1];
9598                    let a_shape = node_shape(&graph_handle, a);
9599                    let dtype = node_dtype(&graph_handle, a);
9600                    accumulate_grad(&mut upstream, a, up_id, &graph_handle);
9601                    let div_ab =
9602                        push_node(&graph_handle, Op::Div, vec![a, b], a_shape.clone(), dtype);
9603                    let floor_div = push_node(
9604                        &graph_handle,
9605                        Op::Floor,
9606                        vec![div_ab],
9607                        a_shape.clone(),
9608                        dtype,
9609                    );
9610                    let neg_floor = push_node(
9611                        &graph_handle,
9612                        Op::Neg,
9613                        vec![floor_div],
9614                        a_shape.clone(),
9615                        dtype,
9616                    );
9617                    let grad_b = push_node(
9618                        &graph_handle,
9619                        Op::Mul,
9620                        vec![up_id, neg_floor],
9621                        a_shape,
9622                        dtype,
9623                    );
9624                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
9625                }
9626                Op::Rsqrt => {
9627                    // y = x^(-1/2). dy/dx = -0.5 * x^(-3/2).
9628                    // Identity: y² = 1/x, so x^(-3/2) = y/x = y * y² = y³.
9629                    // grad_x = -0.5 * upstream * y³.
9630                    // Reuses the forward output id for y; never touches
9631                    // x directly (avoids the divide-by-zero singularity
9632                    // that the obvious `-0.5 * upstream * y / x` form
9633                    // would have).
9634                    let x = inputs[0];
9635                    let x_shape = node_shape(&graph_handle, x);
9636                    let dtype = node_dtype(&graph_handle, x);
9637                    let y_sq = push_node(&graph_handle, Op::Sqr, vec![id], x_shape.clone(), dtype);
9638                    let y_cu = push_node(
9639                        &graph_handle,
9640                        Op::Mul,
9641                        vec![id, y_sq],
9642                        x_shape.clone(),
9643                        dtype,
9644                    );
9645                    let scaled = push_node(
9646                        &graph_handle,
9647                        Op::MulScalar(-0.5),
9648                        vec![y_cu],
9649                        x_shape.clone(),
9650                        dtype,
9651                    );
9652                    let grad_x =
9653                        push_node(&graph_handle, Op::Mul, vec![up_id, scaled], x_shape, dtype);
9654                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9655                }
9656                Op::Pow => {
9657                    // y = pow(a, b).
9658                    // d/da = b * pow(a, b-1)
9659                    // d/db = y * ln(a)
9660                    // grad_a = upstream * b * pow(a, b-1)
9661                    // grad_b = upstream * y * ln(a)
9662                    // The forward output `id` IS y; we reuse it.
9663                    let a = inputs[0];
9664                    let b = inputs[1];
9665                    let a_shape = node_shape(&graph_handle, a);
9666                    let dtype = node_dtype(&graph_handle, a);
9667                    // d/da branch: pow(a, b - 1)
9668                    let b_minus_one = push_node(
9669                        &graph_handle,
9670                        Op::AddScalar(-1.0),
9671                        vec![b],
9672                        a_shape.clone(),
9673                        dtype,
9674                    );
9675                    let pow_a_bm1 = push_node(
9676                        &graph_handle,
9677                        Op::Pow,
9678                        vec![a, b_minus_one],
9679                        a_shape.clone(),
9680                        dtype,
9681                    );
9682                    let b_times_pow = push_node(
9683                        &graph_handle,
9684                        Op::Mul,
9685                        vec![b, pow_a_bm1],
9686                        a_shape.clone(),
9687                        dtype,
9688                    );
9689                    let grad_a = push_node(
9690                        &graph_handle,
9691                        Op::Mul,
9692                        vec![up_id, b_times_pow],
9693                        a_shape.clone(),
9694                        dtype,
9695                    );
9696                    accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
9697                    // d/db branch: y * ln(a)
9698                    let log_a = push_node(&graph_handle, Op::Log, vec![a], a_shape.clone(), dtype);
9699                    let y_log_a = push_node(
9700                        &graph_handle,
9701                        Op::Mul,
9702                        vec![id, log_a],
9703                        a_shape.clone(),
9704                        dtype,
9705                    );
9706                    let grad_b =
9707                        push_node(&graph_handle, Op::Mul, vec![up_id, y_log_a], a_shape, dtype);
9708                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
9709                }
9710                Op::Gelu => {
9711                    // y = 0.5 * x * (1 + erf(x/√2)).
9712                    // dy/dx = Φ(x) + x · φ(x)
9713                    //       = 0.5 * (1 + erf(x/√2))
9714                    //       + x * (1/√(2π)) * exp(-x²/2)
9715                    // where Φ is the standard-normal CDF (= y/x for x≠0;
9716                    // we recompute it explicitly so the backward stays
9717                    // safe at x=0) and φ is the PDF.
9718                    //
9719                    // Chain (12 nodes):
9720                    //   x_over_sqrt2 = MulScalar(1/√2)(x)
9721                    //   erf_arg      = Erf(x_over_sqrt2)
9722                    //   plus_one     = AddScalar(1.0)(erf_arg)
9723                    //   cdf_term     = MulScalar(0.5)(plus_one)
9724                    //   x_sq         = Sqr(x)
9725                    //   half_x_sq    = MulScalar(0.5)(x_sq)
9726                    //   neg_half_x_sq= Neg(half_x_sq)
9727                    //   exp_term     = Exp(neg_half_x_sq)
9728                    //   phi          = MulScalar(1/√(2π))(exp_term)
9729                    //   pdf_term     = Mul(x, phi)
9730                    //   d_gelu       = Add(cdf_term, pdf_term)
9731                    //   grad_x       = Mul(upstream, d_gelu)
9732                    const INV_SQRT_2: f64 = std::f64::consts::FRAC_1_SQRT_2;
9733                    // 1/√(2π) = 0.39894228040143267793994605993...
9734                    const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7_f64;
9735                    let x = inputs[0];
9736                    let x_shape = node_shape(&graph_handle, x);
9737                    let dtype = node_dtype(&graph_handle, x);
9738                    // CDF half: 0.5 * (1 + erf(x/√2)).
9739                    let x_over_sqrt2 = push_node(
9740                        &graph_handle,
9741                        Op::MulScalar(INV_SQRT_2),
9742                        vec![x],
9743                        x_shape.clone(),
9744                        dtype,
9745                    );
9746                    let erf_arg = push_node(
9747                        &graph_handle,
9748                        Op::Erf,
9749                        vec![x_over_sqrt2],
9750                        x_shape.clone(),
9751                        dtype,
9752                    );
9753                    let plus_one = push_node(
9754                        &graph_handle,
9755                        Op::AddScalar(1.0),
9756                        vec![erf_arg],
9757                        x_shape.clone(),
9758                        dtype,
9759                    );
9760                    let cdf_term = push_node(
9761                        &graph_handle,
9762                        Op::MulScalar(0.5),
9763                        vec![plus_one],
9764                        x_shape.clone(),
9765                        dtype,
9766                    );
9767                    // PDF half: x * (1/√(2π)) * exp(-x²/2).
9768                    let x_sq = push_node(&graph_handle, Op::Sqr, vec![x], x_shape.clone(), dtype);
9769                    let half_x_sq = push_node(
9770                        &graph_handle,
9771                        Op::MulScalar(0.5),
9772                        vec![x_sq],
9773                        x_shape.clone(),
9774                        dtype,
9775                    );
9776                    let neg_half_x_sq = push_node(
9777                        &graph_handle,
9778                        Op::Neg,
9779                        vec![half_x_sq],
9780                        x_shape.clone(),
9781                        dtype,
9782                    );
9783                    let exp_term = push_node(
9784                        &graph_handle,
9785                        Op::Exp,
9786                        vec![neg_half_x_sq],
9787                        x_shape.clone(),
9788                        dtype,
9789                    );
9790                    let phi = push_node(
9791                        &graph_handle,
9792                        Op::MulScalar(INV_SQRT_2PI),
9793                        vec![exp_term],
9794                        x_shape.clone(),
9795                        dtype,
9796                    );
9797                    let pdf_term =
9798                        push_node(&graph_handle, Op::Mul, vec![x, phi], x_shape.clone(), dtype);
9799                    // Sum the two halves and apply upstream.
9800                    let d_gelu = push_node(
9801                        &graph_handle,
9802                        Op::Add,
9803                        vec![cdf_term, pdf_term],
9804                        x_shape.clone(),
9805                        dtype,
9806                    );
9807                    let grad_x =
9808                        push_node(&graph_handle, Op::Mul, vec![up_id, d_gelu], x_shape, dtype);
9809                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9810                }
9811                Op::Equal | Op::Ne | Op::Lt | Op::Le | Op::Gt | Op::Ge => {
9812                    // Comparison family: handled by `NoGradientBinaryRule`
9813                    // via `dispatch_gradient`. The `if let Some(grads) =
9814                    // dispatch_gradient(...) { continue; }` block above
9815                    // intercepts before we get here. This arm exists only
9816                    // for exhaustiveness; it should be unreachable in
9817                    // practice. Keeping it as a no-op rather than
9818                    // `unreachable!()` defends against a future hand-edit
9819                    // that removes the dispatcher arm by mistake — the
9820                    // graph still terminates traversal cleanly.
9821                }
9822                Op::Cast(_) => {
9823                    // Forward: y = cast(x, target_dtype).
9824                    // Backward: dL/dx = cast(upstream, source_dtype).
9825                    let x = inputs[0];
9826                    let x_dtype = node_dtype(&graph_handle, x);
9827                    let x_shape = node_shape(&graph_handle, x);
9828                    let grad_x = push_node(
9829                        &graph_handle,
9830                        Op::Cast(x_dtype),
9831                        vec![up_id],
9832                        x_shape,
9833                        x_dtype,
9834                    );
9835                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9836                }
9837                Op::SumAll => {
9838                    // Forward: y = sum(x), scalar.
9839                    // Backward: dL/dx = broadcast_to(upstream, x.shape).
9840                    let x = inputs[0];
9841                    let x_shape = node_shape(&graph_handle, x);
9842                    let dtype = node_dtype(&graph_handle, x);
9843                    let grad_x = push_node(
9844                        &graph_handle,
9845                        Op::BroadcastTo(x_shape.clone()),
9846                        vec![up_id],
9847                        x_shape,
9848                        dtype,
9849                    );
9850                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9851                }
9852                Op::MeanAll => {
9853                    // Forward: y = mean(x) = sum(x) / n, scalar.
9854                    // Backward: dL/dx = broadcast_to(upstream / n, x.shape).
9855                    // We build `ones / n` as a constant of shape x.shape
9856                    // and multiply by broadcast_to(upstream, x.shape).
9857                    // Simpler: broadcast upstream, then divide by an n-const.
9858                    let x = inputs[0];
9859                    let x_shape = node_shape(&graph_handle, x);
9860                    let dtype = node_dtype(&graph_handle, x);
9861                    let n_elem = x_shape.elem_count();
9862                    let broadcast_up = push_node(
9863                        &graph_handle,
9864                        Op::BroadcastTo(x_shape.clone()),
9865                        vec![up_id],
9866                        x_shape.clone(),
9867                        dtype,
9868                    );
9869                    let n_const =
9870                        build_filled_const(&graph_handle, x_shape.clone(), dtype, n_elem as f64);
9871                    let grad_x = push_node(
9872                        &graph_handle,
9873                        Op::Div,
9874                        vec![broadcast_up, n_const],
9875                        x_shape,
9876                        dtype,
9877                    );
9878                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9879                }
9880                Op::BroadcastTo(_) => {
9881                    // Forward: y = broadcast_to(x, target).
9882                    // Backward: dL/dx = reduce_sum_to(upstream, x.shape) —
9883                    // sum away every dim that was expanded or newly added.
9884                    let x = inputs[0];
9885                    let x_shape = node_shape(&graph_handle, x);
9886                    let dtype = node_dtype(&graph_handle, x);
9887                    let grad_x = push_node(
9888                        &graph_handle,
9889                        Op::ReduceSumTo(x_shape.clone()),
9890                        vec![up_id],
9891                        x_shape,
9892                        dtype,
9893                    );
9894                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9895                }
9896                Op::Reshape(_) => {
9897                    // Forward: y = reshape(x, target). Data unchanged.
9898                    // Backward: reshape upstream back to x.shape.
9899                    let x = inputs[0];
9900                    let x_shape = node_shape(&graph_handle, x);
9901                    let dtype = node_dtype(&graph_handle, x);
9902                    let grad_x = push_node(
9903                        &graph_handle,
9904                        Op::Reshape(x_shape.clone()),
9905                        vec![up_id],
9906                        x_shape,
9907                        dtype,
9908                    );
9909                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9910                }
9911                Op::Contiguize => {
9912                    // Forward: y = contiguize(x). Identity at the
9913                    // value level; only the layout differs. Backward:
9914                    // pass upstream through unchanged. The
9915                    // downstream `accumulate_grad` will sum this
9916                    // into x's gradient regardless of x's own layout.
9917                    let x = inputs[0];
9918                    accumulate_grad(&mut upstream, x, up_id, &graph_handle);
9919                }
9920                Op::Unsqueeze { dim: _ } => {
9921                    // Forward: y = unsqueeze(x, dim) — inserts a size-1
9922                    // axis without touching bytes. Backward: drop that
9923                    // axis. Bytes are unchanged either direction; a
9924                    // Reshape to x.shape is the simplest expression of
9925                    // "same bytes, different rank" that the executor
9926                    // handles natively.
9927                    let x = inputs[0];
9928                    let x_shape = node_shape(&graph_handle, x);
9929                    let dtype = node_dtype(&graph_handle, x);
9930                    let grad_x = push_node(
9931                        &graph_handle,
9932                        Op::Reshape(x_shape.clone()),
9933                        vec![up_id],
9934                        x_shape,
9935                        dtype,
9936                    );
9937                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9938                }
9939                Op::Squeeze { dim } => {
9940                    // Forward: y = squeeze(x, dim) — drops a size-1 axis,
9941                    // metadata-only. Backward: re-insert the axis at the
9942                    // same position via Unsqueeze. Both directions are
9943                    // metadata-only views (no bytes touched).
9944                    let x = inputs[0];
9945                    let x_shape = node_shape(&graph_handle, x);
9946                    let dtype = node_dtype(&graph_handle, x);
9947                    let grad_x = push_node(
9948                        &graph_handle,
9949                        Op::Unsqueeze { dim },
9950                        vec![up_id],
9951                        x_shape,
9952                        dtype,
9953                    );
9954                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9955                }
9956                Op::ReduceSumTo(_) => {
9957                    // Forward: y = reduce_sum_to(x, target).
9958                    // Backward: broadcast upstream back to x.shape.
9959                    let x = inputs[0];
9960                    let x_shape = node_shape(&graph_handle, x);
9961                    let dtype = node_dtype(&graph_handle, x);
9962                    let grad_x = push_node(
9963                        &graph_handle,
9964                        Op::BroadcastTo(x_shape.clone()),
9965                        vec![up_id],
9966                        x_shape,
9967                        dtype,
9968                    );
9969                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
9970                }
9971                Op::MaxAll | Op::MinAll => {
9972                    // For y = max(x) (or min), the gradient is an
9973                    // indicator at the argmax positions, distributed
9974                    // across ties. Expressed without a dedicated argmax
9975                    // op using the Step trick:
9976                    //
9977                    //   broadcasted_y = broadcast_to(y, x.shape)
9978                    //   diff = broadcasted_y - x   (for Max)
9979                    //        | x - broadcasted_y   (for Min)
9980                    //   indicator = ones - step(diff)
9981                    //   grad_x    = broadcast_to(upstream, x.shape) * indicator
9982                    //
9983                    // At argmax positions `diff == 0`, `step(0) == 0`, so
9984                    // the indicator is 1. Elsewhere `diff > 0` so step is
9985                    // 1 and the indicator is 0. Ties share the gradient.
9986                    let x = inputs[0];
9987                    let x_shape = node_shape(&graph_handle, x);
9988                    let dtype = node_dtype(&graph_handle, x);
9989                    let is_max = matches!(op, Op::MaxAll);
9990                    let broadcasted_y = push_node(
9991                        &graph_handle,
9992                        Op::BroadcastTo(x_shape.clone()),
9993                        vec![id],
9994                        x_shape.clone(),
9995                        dtype,
9996                    );
9997                    let diff = if is_max {
9998                        push_node(
9999                            &graph_handle,
10000                            Op::Sub,
10001                            vec![broadcasted_y, x],
10002                            x_shape.clone(),
10003                            dtype,
10004                        )
10005                    } else {
10006                        push_node(
10007                            &graph_handle,
10008                            Op::Sub,
10009                            vec![x, broadcasted_y],
10010                            x_shape.clone(),
10011                            dtype,
10012                        )
10013                    };
10014                    let step_diff =
10015                        push_node(&graph_handle, Op::Step, vec![diff], x_shape.clone(), dtype);
10016                    let ones = build_ones(&graph_handle, x_shape.clone(), dtype);
10017                    let indicator = push_node(
10018                        &graph_handle,
10019                        Op::Sub,
10020                        vec![ones, step_diff],
10021                        x_shape.clone(),
10022                        dtype,
10023                    );
10024                    let broadcast_up = push_node(
10025                        &graph_handle,
10026                        Op::BroadcastTo(x_shape.clone()),
10027                        vec![up_id],
10028                        x_shape.clone(),
10029                        dtype,
10030                    );
10031                    let grad_x = push_node(
10032                        &graph_handle,
10033                        Op::Mul,
10034                        vec![broadcast_up, indicator],
10035                        x_shape,
10036                        dtype,
10037                    );
10038                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10039                }
10040                Op::SumDim(dim) => {
10041                    // Insert a size-1 at `dim` via reshape, then broadcast
10042                    // back to x.shape.
10043                    let x = inputs[0];
10044                    let x_shape = node_shape(&graph_handle, x);
10045                    let dtype = node_dtype(&graph_handle, x);
10046                    let keepdim_shape = reshape_to_keepdim(x_shape.dims(), dim);
10047                    let reshaped = push_node(
10048                        &graph_handle,
10049                        Op::Reshape(keepdim_shape.clone()),
10050                        vec![up_id],
10051                        keepdim_shape,
10052                        dtype,
10053                    );
10054                    let grad_x = push_node(
10055                        &graph_handle,
10056                        Op::BroadcastTo(x_shape.clone()),
10057                        vec![reshaped],
10058                        x_shape,
10059                        dtype,
10060                    );
10061                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10062                }
10063                Op::MeanDim(dim) => {
10064                    // SumDim-style backward, then divide by the reduced dim size.
10065                    let x = inputs[0];
10066                    let x_shape = node_shape(&graph_handle, x);
10067                    let dtype = node_dtype(&graph_handle, x);
10068                    let reduced_size = x_shape.dims()[dim];
10069                    let keepdim_shape = reshape_to_keepdim(x_shape.dims(), dim);
10070                    let reshaped = push_node(
10071                        &graph_handle,
10072                        Op::Reshape(keepdim_shape.clone()),
10073                        vec![up_id],
10074                        keepdim_shape,
10075                        dtype,
10076                    );
10077                    let broadcast = push_node(
10078                        &graph_handle,
10079                        Op::BroadcastTo(x_shape.clone()),
10080                        vec![reshaped],
10081                        x_shape.clone(),
10082                        dtype,
10083                    );
10084                    let n_const = build_filled_const(
10085                        &graph_handle,
10086                        x_shape.clone(),
10087                        dtype,
10088                        reduced_size as f64,
10089                    );
10090                    let grad_x = push_node(
10091                        &graph_handle,
10092                        Op::Div,
10093                        vec![broadcast, n_const],
10094                        x_shape,
10095                        dtype,
10096                    );
10097                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10098                }
10099                Op::MaxDim(dim) | Op::MinDim(dim) => {
10100                    // Same indicator trick as MaxAll/MinAll, but apply the
10101                    // "reshape to keepdim then broadcast" pattern to both
10102                    // the forward output and upstream before multiplying.
10103                    let x = inputs[0];
10104                    let x_shape = node_shape(&graph_handle, x);
10105                    let dtype = node_dtype(&graph_handle, x);
10106                    let is_max = matches!(op, Op::MaxDim(_));
10107                    let keepdim_shape = reshape_to_keepdim(x_shape.dims(), dim);
10108                    let y_keepdim = push_node(
10109                        &graph_handle,
10110                        Op::Reshape(keepdim_shape.clone()),
10111                        vec![id],
10112                        keepdim_shape.clone(),
10113                        dtype,
10114                    );
10115                    let broadcasted_y = push_node(
10116                        &graph_handle,
10117                        Op::BroadcastTo(x_shape.clone()),
10118                        vec![y_keepdim],
10119                        x_shape.clone(),
10120                        dtype,
10121                    );
10122                    let diff = if is_max {
10123                        push_node(
10124                            &graph_handle,
10125                            Op::Sub,
10126                            vec![broadcasted_y, x],
10127                            x_shape.clone(),
10128                            dtype,
10129                        )
10130                    } else {
10131                        push_node(
10132                            &graph_handle,
10133                            Op::Sub,
10134                            vec![x, broadcasted_y],
10135                            x_shape.clone(),
10136                            dtype,
10137                        )
10138                    };
10139                    let step_diff =
10140                        push_node(&graph_handle, Op::Step, vec![diff], x_shape.clone(), dtype);
10141                    let ones = build_ones(&graph_handle, x_shape.clone(), dtype);
10142                    let indicator = push_node(
10143                        &graph_handle,
10144                        Op::Sub,
10145                        vec![ones, step_diff],
10146                        x_shape.clone(),
10147                        dtype,
10148                    );
10149                    let up_keepdim = push_node(
10150                        &graph_handle,
10151                        Op::Reshape(keepdim_shape.clone()),
10152                        vec![up_id],
10153                        keepdim_shape,
10154                        dtype,
10155                    );
10156                    let broadcast_up = push_node(
10157                        &graph_handle,
10158                        Op::BroadcastTo(x_shape.clone()),
10159                        vec![up_keepdim],
10160                        x_shape.clone(),
10161                        dtype,
10162                    );
10163                    let grad_x = push_node(
10164                        &graph_handle,
10165                        Op::Mul,
10166                        vec![broadcast_up, indicator],
10167                        x_shape,
10168                        dtype,
10169                    );
10170                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10171                }
10172                // Phase 7.6 step 5 (2026-05-11): the legacy
10173                // `Op::SoftmaxLastDim` / `Op::LayerNormLastDim` /
10174                // `Op::RmsNormLastDim` / `Op::Rope` /
10175                // `Op::Conv2D` / `Op::FusedLinear` arms have been
10176                // dropped together with the variants themselves; the
10177                // `Op::Fused(fid, _)` arm below dispatches per id and
10178                // is the single source of truth for migrated forward
10179                // ops' backward.
10180                Op::ArgMaxDim(_) | Op::ArgMinDim(_) => {
10181                    // Argmax/argmin produce integer indices, which are
10182                    // non-differentiable. Panic rather than silently
10183                    // zero-propagating — if the user has one of these in
10184                    // their differentiated graph, they've almost
10185                    // certainly made a mistake.
10186                    panic!(
10187                        "backward: ArgMaxDim/ArgMinDim produce integer \
10188                         indices and cannot be differentiated through. \
10189                         Use MaxDim/MinDim if you want a differentiable \
10190                         selection."
10191                    );
10192                }
10193                Op::ReduceMaxTo(_) => {
10194                    // Forward: y = reduce_max_to(x, target).
10195                    // Backward: route upstream to position(s) where
10196                    // x equals its per-window max via the fused
10197                    // backward helper Op::Fused(REDUCE_MAX_TO_BACKWARD,
10198                    // _) (takes (x, upstream) and emits grad_x of
10199                    // x.shape). Tied maxes share the gradient equally
10200                    // (fair-share subgradient). Note this is the
10201                    // backward of a *primitive* (Op::ReduceMaxTo),
10202                    // not a fused forward — no BackwardKind::Fused
10203                    // edge in the registry drives this; the explicit
10204                    // emission is the source of truth.
10205                    let x = inputs[0];
10206                    let x_shape = node_shape(&graph_handle, x);
10207                    let dtype = node_dtype(&graph_handle, x);
10208                    let grad_x = push_node(
10209                        &graph_handle,
10210                        Op::Fused(
10211                            crate::registry::FusedOps::REDUCE_MAX_TO_BACKWARD,
10212                            crate::registry::FusedOpParams::ReduceMaxToBackward,
10213                        ),
10214                        vec![x, up_id],
10215                        x_shape,
10216                        dtype,
10217                    );
10218                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10219                }
10220                // Phase 7.6 step 5 (final, 2026-05-11): legacy
10221                // `Op::QMatMul` arm dropped with the variant; QMatMul
10222                // backward panics via the Op::Fused arm below.
10223                // Phase 7.6 step 5 (2026-05-11): the legacy
10224                // `Op::SoftmaxLastDimBackward | Op::LayerNormLastDimBackward |
10225                // Op::RmsNormLastDimBackward | Op::ReduceMaxToBackward`
10226                // higher-order panic arm has been dropped together
10227                // with the variants themselves. The Op::Fused arm
10228                // below panics for those four ids.
10229                Op::IndexSelect { dim } => {
10230                    // Forward: out = index_select(data, dim, indices).
10231                    // Backward: grad_data = index_add(zeros_like(data), dim, indices, upstream).
10232                    let data = inputs[0];
10233                    let idx = inputs[1];
10234                    let data_shape = node_shape(&graph_handle, data);
10235                    let dtype = node_dtype(&graph_handle, data);
10236                    let zeros = build_filled_const(&graph_handle, data_shape.clone(), dtype, 0.0);
10237                    let grad_data = push_node(
10238                        &graph_handle,
10239                        Op::IndexAdd { dim },
10240                        vec![zeros, idx, up_id],
10241                        data_shape,
10242                        dtype,
10243                    );
10244                    accumulate_grad(&mut upstream, data, grad_data, &graph_handle);
10245                    // Index tensor is itself non-differentiable (integer dtype).
10246                }
10247                Op::Gather { dim } => {
10248                    // Forward: out = gather(data, dim, indices).
10249                    // Backward: grad_data = scatter_add(zeros_like(data), dim, indices, upstream).
10250                    let data = inputs[0];
10251                    let idx = inputs[1];
10252                    let data_shape = node_shape(&graph_handle, data);
10253                    let dtype = node_dtype(&graph_handle, data);
10254                    let zeros = build_filled_const(&graph_handle, data_shape.clone(), dtype, 0.0);
10255                    let grad_data = push_node(
10256                        &graph_handle,
10257                        Op::ScatterAdd { dim },
10258                        vec![zeros, idx, up_id],
10259                        data_shape,
10260                        dtype,
10261                    );
10262                    accumulate_grad(&mut upstream, data, grad_data, &graph_handle);
10263                }
10264                Op::IndexAdd { dim } => {
10265                    // Forward: out = index_add(base, dim, indices, src).
10266                    // Backward:
10267                    //   grad_base = upstream (pass-through — base flows
10268                    //               through the non-indexed positions and
10269                    //               the accumulated positions don't zero
10270                    //               it out, they add to it)
10271                    //   grad_src  = index_select(upstream, dim, indices)
10272                    //               (the pieces of upstream that came from
10273                    //                src in the forward).
10274                    let base = inputs[0];
10275                    let idx = inputs[1];
10276                    let src = inputs[2];
10277                    let src_shape = node_shape(&graph_handle, src);
10278                    let dtype = node_dtype(&graph_handle, src);
10279                    // grad_base: upstream passes through.
10280                    accumulate_grad(&mut upstream, base, up_id, &graph_handle);
10281                    // grad_src: select from upstream at the indices.
10282                    let grad_src = push_node(
10283                        &graph_handle,
10284                        Op::IndexSelect { dim },
10285                        vec![up_id, idx],
10286                        src_shape,
10287                        dtype,
10288                    );
10289                    accumulate_grad(&mut upstream, src, grad_src, &graph_handle);
10290                }
10291                Op::Concat { dim } => {
10292                    // Forward: out = concat(inputs.., dim).
10293                    // Backward: split upstream along `dim` into one slice per
10294                    // input at the cumulative offset, routing each slice to its
10295                    // input. N-ary (unroll_scan stacks N per-step ys into a
10296                    // single Concat; the SSM/Hopfield BPTT unroll relies on
10297                    // this). Duplicate inputs accumulate via accumulate_grad.
10298                    let mut start = 0usize;
10299                    for &inp in &inputs {
10300                        let in_shape = node_shape(&graph_handle, inp);
10301                        let dtype = node_dtype(&graph_handle, inp);
10302                        let len = in_shape.dims()[dim];
10303                        let grad_in = push_node(
10304                            &graph_handle,
10305                            Op::Slice { dim, start, len },
10306                            vec![up_id],
10307                            in_shape,
10308                            dtype,
10309                        );
10310                        accumulate_grad(&mut upstream, inp, grad_in, &graph_handle);
10311                        start += len;
10312                    }
10313                }
10314                Op::Slice { dim, start, len: _ } => {
10315                    // Forward: out = slice(x, dim, start, len).
10316                    // Backward: build a zeros tensor of x.shape, then
10317                    // scatter the upstream into the slice range. We use
10318                    // a concat of three pieces: left zeros, upstream,
10319                    // right zeros (along `dim`).
10320                    let x = inputs[0];
10321                    let x_shape = node_shape(&graph_handle, x);
10322                    let dtype = node_dtype(&graph_handle, x);
10323                    let x_dims = x_shape.dims();
10324                    let dim_size = x_dims[dim];
10325                    let up_len = x_dims[dim].min(node_shape(&graph_handle, up_id).dims()[dim]);
10326                    let left_len = start;
10327                    let right_len = dim_size - start - up_len;
10328                    // Build left zero pad if needed.
10329                    let mut pieces: Vec<NodeId> = Vec::new();
10330                    if left_len > 0 {
10331                        let mut left_dims: Vec<usize> = x_dims.to_vec();
10332                        left_dims[dim] = left_len;
10333                        let left_shape = Shape::from_dims(&left_dims);
10334                        pieces.push(build_filled_const(&graph_handle, left_shape, dtype, 0.0));
10335                    }
10336                    pieces.push(up_id);
10337                    if right_len > 0 {
10338                        let mut right_dims: Vec<usize> = x_dims.to_vec();
10339                        right_dims[dim] = right_len;
10340                        let right_shape = Shape::from_dims(&right_dims);
10341                        pieces.push(build_filled_const(&graph_handle, right_shape, dtype, 0.0));
10342                    }
10343                    // Fold concat-left-to-right.
10344                    let mut current = pieces[0];
10345                    let mut current_dims: Vec<usize> = {
10346                        let n = graph_handle.read().unwrap();
10347                        n.node(current).shape.dims().to_vec()
10348                    };
10349                    for &next in &pieces[1..] {
10350                        let next_dims: Vec<usize> = {
10351                            let n = graph_handle.read().unwrap();
10352                            n.node(next).shape.dims().to_vec()
10353                        };
10354                        let mut combined = current_dims.clone();
10355                        combined[dim] = current_dims[dim] + next_dims[dim];
10356                        let combined_shape = Shape::from_dims(&combined);
10357                        current = push_node(
10358                            &graph_handle,
10359                            Op::Concat { dim },
10360                            vec![current, next],
10361                            combined_shape,
10362                            dtype,
10363                        );
10364                        current_dims = combined;
10365                    }
10366                    accumulate_grad(&mut upstream, x, current, &graph_handle);
10367                }
10368                Op::AddScalar(_) => {
10369                    // y = x + c, dy/dx = 1. Upstream passes through.
10370                    let x = inputs[0];
10371                    accumulate_grad(&mut upstream, x, up_id, &graph_handle);
10372                }
10373                Op::MulScalar(c) => {
10374                    // y = x * c, dy/dx = c. Multiply upstream by c via
10375                    // another MulScalar node.
10376                    let x = inputs[0];
10377                    let x_shape = node_shape(&graph_handle, x);
10378                    let dtype = node_dtype(&graph_handle, x);
10379                    let grad_x =
10380                        push_node(&graph_handle, Op::MulScalar(c), vec![up_id], x_shape, dtype);
10381                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10382                }
10383                Op::PowI(n) => {
10384                    // y = x^n, dy/dx = n * x^(n-1).
10385                    // grad_x = upstream * n * x^(n-1). Emitted as a
10386                    // single fused PowIBackward node; the executor
10387                    // routes it to baracuda's `unary_powi_backward_*`
10388                    // kernel (alpha.31) on CUDA, or falls back to the
10389                    // primitive decomposition (PowI(n-1) → MulScalar →
10390                    // Mul) on backends that haven't registered the
10391                    // fused kernel.
10392                    let x = inputs[0];
10393                    let x_shape = node_shape(&graph_handle, x);
10394                    let dtype = node_dtype(&graph_handle, x);
10395                    let grad_x = push_node(
10396                        &graph_handle,
10397                        Op::Fused(
10398                            crate::registry::FusedOps::POWI_BACKWARD,
10399                            crate::registry::FusedOpParams::PowIBackward { exp: n },
10400                        ),
10401                        vec![x, up_id],
10402                        x_shape,
10403                        dtype,
10404                    );
10405                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10406                }
10407                Op::Clamp { min, max } => {
10408                    // Subgradient: 1 where min <= x <= max, 0 elsewhere.
10409                    // Expressed via (step(x - min) - step(x - max))
10410                    // which is 1 in the interior (x > min and x <= max...
10411                    // actually step(0) = 0, so the right end is a bit
10412                    // tricky). For a reference implementation the
10413                    // simplest approach is a dedicated "clamp backward"
10414                    // indicator. We build it out of the existing ops:
10415                    //
10416                    //   lower_ok = step(x - min)   // 1 where x > min
10417                    //   upper_ok = step(max - x)   // 1 where x < max
10418                    //   indicator = lower_ok * upper_ok
10419                    //   grad_x = upstream * indicator
10420                    //
10421                    // This underestimates the gradient on the exact
10422                    // boundary by a measure-zero amount — standard.
10423                    let x = inputs[0];
10424                    let x_shape = node_shape(&graph_handle, x);
10425                    let dtype = node_dtype(&graph_handle, x);
10426                    let min_shifted = push_node(
10427                        &graph_handle,
10428                        Op::AddScalar(-min),
10429                        vec![x],
10430                        x_shape.clone(),
10431                        dtype,
10432                    );
10433                    let lower_ok = push_node(
10434                        &graph_handle,
10435                        Op::Step,
10436                        vec![min_shifted],
10437                        x_shape.clone(),
10438                        dtype,
10439                    );
10440                    let max_minus_x = push_node(
10441                        &graph_handle,
10442                        Op::MulScalar(-1.0),
10443                        vec![x],
10444                        x_shape.clone(),
10445                        dtype,
10446                    );
10447                    let max_minus_x = push_node(
10448                        &graph_handle,
10449                        Op::AddScalar(max),
10450                        vec![max_minus_x],
10451                        x_shape.clone(),
10452                        dtype,
10453                    );
10454                    let upper_ok = push_node(
10455                        &graph_handle,
10456                        Op::Step,
10457                        vec![max_minus_x],
10458                        x_shape.clone(),
10459                        dtype,
10460                    );
10461                    let indicator = push_node(
10462                        &graph_handle,
10463                        Op::Mul,
10464                        vec![lower_ok, upper_ok],
10465                        x_shape.clone(),
10466                        dtype,
10467                    );
10468                    let grad_x = push_node(
10469                        &graph_handle,
10470                        Op::Mul,
10471                        vec![up_id, indicator],
10472                        x_shape,
10473                        dtype,
10474                    );
10475                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10476                }
10477                Op::Maximum => {
10478                    // y_i = max(a_i, b_i). Gradient flows to whichever
10479                    // input was the max at that position:
10480                    //   mask_a = step(a - b)   (1 where a > b, else 0)
10481                    //   mask_b = 1 - mask_a    (approximately — at ties
10482                    //                          gradient goes to b due to
10483                    //                          step(0) = 0)
10484                    //   grad_a = upstream * mask_a
10485                    //   grad_b = upstream * mask_b
10486                    let a = inputs[0];
10487                    let b = inputs[1];
10488                    let shape = node_shape(&graph_handle, a);
10489                    let dtype = node_dtype(&graph_handle, a);
10490                    let diff = push_node(&graph_handle, Op::Sub, vec![a, b], shape.clone(), dtype);
10491                    let mask_a =
10492                        push_node(&graph_handle, Op::Step, vec![diff], shape.clone(), dtype);
10493                    let ones = build_ones(&graph_handle, shape.clone(), dtype);
10494                    let mask_b = push_node(
10495                        &graph_handle,
10496                        Op::Sub,
10497                        vec![ones, mask_a],
10498                        shape.clone(),
10499                        dtype,
10500                    );
10501                    let grad_a = push_node(
10502                        &graph_handle,
10503                        Op::Mul,
10504                        vec![up_id, mask_a],
10505                        shape.clone(),
10506                        dtype,
10507                    );
10508                    let grad_b =
10509                        push_node(&graph_handle, Op::Mul, vec![up_id, mask_b], shape, dtype);
10510                    accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
10511                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
10512                }
10513                Op::Minimum => {
10514                    // Symmetric to Maximum. mask_a = step(b - a).
10515                    let a = inputs[0];
10516                    let b = inputs[1];
10517                    let shape = node_shape(&graph_handle, a);
10518                    let dtype = node_dtype(&graph_handle, a);
10519                    let diff = push_node(&graph_handle, Op::Sub, vec![b, a], shape.clone(), dtype);
10520                    let mask_a =
10521                        push_node(&graph_handle, Op::Step, vec![diff], shape.clone(), dtype);
10522                    let ones = build_ones(&graph_handle, shape.clone(), dtype);
10523                    let mask_b = push_node(
10524                        &graph_handle,
10525                        Op::Sub,
10526                        vec![ones, mask_a],
10527                        shape.clone(),
10528                        dtype,
10529                    );
10530                    let grad_a = push_node(
10531                        &graph_handle,
10532                        Op::Mul,
10533                        vec![up_id, mask_a],
10534                        shape.clone(),
10535                        dtype,
10536                    );
10537                    let grad_b =
10538                        push_node(&graph_handle, Op::Mul, vec![up_id, mask_b], shape, dtype);
10539                    accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
10540                    accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
10541                }
10542                Op::ScatterAdd { dim } => {
10543                    // Forward: out = scatter_add(base, dim, indices, src).
10544                    // Backward:
10545                    //   grad_base = upstream (pass-through)
10546                    //   grad_src  = gather(upstream, dim, indices)
10547                    let base = inputs[0];
10548                    let idx = inputs[1];
10549                    let src = inputs[2];
10550                    let src_shape = node_shape(&graph_handle, src);
10551                    let dtype = node_dtype(&graph_handle, src);
10552                    accumulate_grad(&mut upstream, base, up_id, &graph_handle);
10553                    let grad_src = push_node(
10554                        &graph_handle,
10555                        Op::Gather { dim },
10556                        vec![up_id, idx],
10557                        src_shape,
10558                        dtype,
10559                    );
10560                    accumulate_grad(&mut upstream, src, grad_src, &graph_handle);
10561                }
10562                Op::Copy { .. } => {
10563                    // Forward: out = copy(x, target). Backward: the
10564                    // gradient flows back to `x`'s original device, so
10565                    // the reverse would be another Copy. The source
10566                    // device is x's residency — we don't have the typed
10567                    // "where was x?" here without tracking it, so we
10568                    // fall back to the graph's placement hint on x (if
10569                    // any) or trust the scheduler to re-insert the
10570                    // correct copy during Phase 4. For Phase 3 we
10571                    // assume inference-only use and pass upstream through.
10572                    let x = inputs[0];
10573                    accumulate_grad(&mut upstream, x, up_id, &graph_handle);
10574                }
10575                Op::Release => {
10576                    // Release has no data output and no gradient to
10577                    // propagate. Input is destroyed; any downstream
10578                    // node that needs gradient through the original
10579                    // tensor must get it from a sibling Copy's backward
10580                    // path, not this one.
10581                }
10582                Op::Move { .. } => {
10583                    // Move is Copy + destroy-source. For backward,
10584                    // treat it the same as Copy — gradient flows back
10585                    // through the move. The Phase 3-style assumption
10586                    // still applies: we pass upstream through and rely
10587                    // on the scheduler to re-insert the correct reverse
10588                    // transfer in a training pass. For inference-only
10589                    // use (the current case), this is a no-op.
10590                    let x = inputs[0];
10591                    accumulate_grad(&mut upstream, x, up_id, &graph_handle);
10592                }
10593                Op::WriteSlice { .. } => {
10594                    // KV-cache writes are forward-only — the slab being
10595                    // written *into* gets mutated in place, and the
10596                    // source slab is read non-destructively. There is
10597                    // no meaningful gradient through a mutating write
10598                    // in fuel's tape-based autograd model. If a use
10599                    // case needs differentiable scatter, the gradient
10600                    // path is a `Gather` (read the slab back) plus an
10601                    // accumulating `IndexAdd` on the destination —
10602                    // express that explicitly in forward.
10603                    panic!(
10604                        "NodeHandle::backward: Op::WriteSlice is non-differentiable. \
10605                         Use Gather + IndexAdd if you need a differentiable scatter."
10606                    );
10607                }
10608                Op::WriteSliceRotating { .. } => {
10609                    // Same forward-only contract as WriteSlice — the
10610                    // ring buffer's bytes are mutated in place at a
10611                    // dynamic offset. Sliding-window KV caches don't
10612                    // need a gradient path; a differentiable scatter
10613                    // is expressible as Gather + IndexAdd in forward.
10614                    panic!(
10615                        "NodeHandle::backward: Op::WriteSliceRotating is non-differentiable. \
10616                         Use Gather + IndexAdd if you need a differentiable scatter."
10617                    );
10618                }
10619                Op::WriteSliceDoff { .. } => {
10620                    // Forward-only, like WriteSlice/WriteSliceRotating —
10621                    // the KV-cache buffer is mutated in place at a
10622                    // device-resident offset. Decode-time append; no
10623                    // gradient path.
10624                    panic!(
10625                        "NodeHandle::backward: Op::WriteSliceDoff is non-differentiable. \
10626                         Use Gather + IndexAdd if you need a differentiable scatter."
10627                    );
10628                }
10629                // Phase 7.6 step 5 (2026-05-11): the legacy
10630                // `Op::Conv2D { stride, padding, groups }` backward
10631                // arm has been dropped together with the variant;
10632                // `Op::Fused(CONV2D, _)` below carries the full
10633                // backward logic (including the inner dW Conv2D
10634                // emission, which itself goes through Op::Fused).
10635                // Phase 7.6 step 4 (final): the builders for
10636                // ConvTranspose2D, FlashAttn, and PagedAttn now emit
10637                // `Op::Fused(<id>, _)`; these legacy backward arms
10638                // still fire for direct legacy-variant constructions
10639                // Phase 7.6 step 5 (final, 2026-05-11): the legacy
10640                // `Op::ConvTranspose2D` / `Op::FlashAttn` /
10641                // `Op::PagedAttn` / `Op::FusedLinear` backward arms
10642                // have all been dropped together with the variants;
10643                // `Op::Fused(<id>, _)` below panics for each id.
10644                Op::Fused(fid, params) => {
10645                    // Per-id backward dispatch. Each migrated fused op
10646                    // gets a branch here that emits the appropriate
10647                    // gradient subgraph.
10648                    if fid == crate::registry::FusedOps::SOFTMAX_LAST_DIM {
10649                        // grad_x = softmax_last_dim_backward(y, upstream).
10650                        // Phase 7.6 step 4 (backward-helper batch):
10651                        // emits Op::Fused(SOFTMAX_LAST_DIM_BACKWARD, _)
10652                        // — the architecturally-correct registry form,
10653                        // matching the BackwardKind::Fused edge on
10654                        // SOFTMAX_LAST_DIM's entry.
10655                        let x = inputs[0];
10656                        let y_shape = node_shape(&graph_handle, id);
10657                        let dtype = node_dtype(&graph_handle, id);
10658                        let grad_x = push_node(
10659                            &graph_handle,
10660                            Op::Fused(
10661                                crate::registry::FusedOps::SOFTMAX_LAST_DIM_BACKWARD,
10662                                crate::registry::FusedOpParams::SoftmaxLastDimBackward,
10663                            ),
10664                            vec![id, up_id],
10665                            y_shape,
10666                            dtype,
10667                        );
10668                        accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10669                    } else if fid == crate::registry::FusedOps::LAYER_NORM_LAST_DIM {
10670                        // grad_x = layer_norm_last_dim_backward(x, upstream, eps).
10671                        // Phase 7.6 step 4 (backward-helper batch): emits
10672                        // registry form Op::Fused(LAYER_NORM_LAST_DIM_BACKWARD, _).
10673                        let eps = match params {
10674                            crate::registry::FusedOpParams::LayerNormLastDim { eps } => eps,
10675                            _ => panic!(
10676                                "NodeHandle::backward: Op::Fused(LAYER_NORM_LAST_DIM, _) \
10677                                 expected FusedOpParams::LayerNormLastDim, got {params:?}",
10678                            ),
10679                        };
10680                        let x = inputs[0];
10681                        let x_shape = node_shape(&graph_handle, x);
10682                        let dtype = node_dtype(&graph_handle, x);
10683                        let grad_x = push_node(
10684                            &graph_handle,
10685                            Op::Fused(
10686                                crate::registry::FusedOps::LAYER_NORM_LAST_DIM_BACKWARD,
10687                                crate::registry::FusedOpParams::LayerNormLastDimBackward { eps },
10688                            ),
10689                            vec![x, up_id],
10690                            x_shape,
10691                            dtype,
10692                        );
10693                        accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10694                    } else if fid == crate::registry::FusedOps::RMS_NORM_LAST_DIM {
10695                        // grad_x = rms_norm_last_dim_backward(x, upstream, eps).
10696                        // Phase 7.6 step 4 (backward-helper batch): emits
10697                        // registry form Op::Fused(RMS_NORM_LAST_DIM_BACKWARD, _).
10698                        let eps = match params {
10699                            crate::registry::FusedOpParams::RmsNormLastDim { eps } => eps,
10700                            _ => panic!(
10701                                "NodeHandle::backward: Op::Fused(RMS_NORM_LAST_DIM, _) \
10702                                 expected FusedOpParams::RmsNormLastDim, got {params:?}",
10703                            ),
10704                        };
10705                        let x = inputs[0];
10706                        let x_shape = node_shape(&graph_handle, x);
10707                        let dtype = node_dtype(&graph_handle, x);
10708                        let grad_x = push_node(
10709                            &graph_handle,
10710                            Op::Fused(
10711                                crate::registry::FusedOps::RMS_NORM_LAST_DIM_BACKWARD,
10712                                crate::registry::FusedOpParams::RmsNormLastDimBackward { eps },
10713                            ),
10714                            vec![x, up_id],
10715                            x_shape,
10716                            dtype,
10717                        );
10718                        accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10719                    } else if fid == crate::registry::FusedOps::ROPE {
10720                        // grad_x = rope(upstream, cos, -sin) — same
10721                        // formula as the legacy Op::Rope arm above.
10722                        // Emit the registry form for the backward
10723                        // Rope node so the gradient subgraph routes
10724                        // through the same Op::Fused dispatch as the
10725                        // forward.
10726                        let x = inputs[0];
10727                        let cos = inputs[1];
10728                        let sin = inputs[2];
10729                        let x_shape = node_shape(&graph_handle, x);
10730                        let sin_shape = node_shape(&graph_handle, sin);
10731                        let dtype = node_dtype(&graph_handle, x);
10732                        let neg_sin =
10733                            push_node(&graph_handle, Op::Neg, vec![sin], sin_shape, dtype);
10734                        let grad_x = push_node(
10735                            &graph_handle,
10736                            Op::Fused(
10737                                crate::registry::FusedOps::ROPE,
10738                                crate::registry::FusedOpParams::Rope,
10739                            ),
10740                            vec![up_id, cos, neg_sin],
10741                            x_shape,
10742                            dtype,
10743                        );
10744                        accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10745                    } else if fid == crate::registry::FusedOps::FUSED_LINEAR {
10746                        // (a @ b) + bias backward — same three-grad
10747                        // decomposition as the legacy Op::FusedLinear
10748                        // arm above. We reuse MatMul backward (no
10749                        // intermediate matmul output is cached, so the
10750                        // grad computes from upstream + a, b directly).
10751                        let a = inputs[0];
10752                        let b = inputs[1];
10753                        let bias = inputs[2];
10754                        let a_shape = node_shape(&graph_handle, a);
10755                        let b_shape = node_shape(&graph_handle, b);
10756                        let bias_shape = node_shape(&graph_handle, bias);
10757                        let dtype = node_dtype(&graph_handle, a);
10758                        let b_t_shape = transposed_shape(&b_shape);
10759                        let b_t =
10760                            push_node(&graph_handle, Op::Transpose, vec![b], b_t_shape, dtype);
10761                        let grad_a = push_node(
10762                            &graph_handle,
10763                            Op::MatMul,
10764                            vec![up_id, b_t],
10765                            a_shape.clone(),
10766                            dtype,
10767                        );
10768                        let a_t_shape = transposed_shape(&a_shape);
10769                        let a_t =
10770                            push_node(&graph_handle, Op::Transpose, vec![a], a_t_shape, dtype);
10771                        let grad_b =
10772                            push_node(&graph_handle, Op::MatMul, vec![a_t, up_id], b_shape, dtype);
10773                        let grad_bias = push_node(
10774                            &graph_handle,
10775                            Op::ReduceSumTo(bias_shape.clone()),
10776                            vec![up_id],
10777                            bias_shape,
10778                            dtype,
10779                        );
10780                        accumulate_grad(&mut upstream, a, grad_a, &graph_handle);
10781                        accumulate_grad(&mut upstream, b, grad_b, &graph_handle);
10782                        accumulate_grad(&mut upstream, bias, grad_bias, &graph_handle);
10783                    } else if fid == crate::registry::FusedOps::CONV2D {
10784                        // Same backward formulation as the legacy
10785                        // Op::Conv2D arm above. The inner Conv2D node
10786                        // emitted for grad_w also goes through the
10787                        // registry (Op::Fused(CONV2D, _)) so the
10788                        // gradient subgraph routes consistently. The
10789                        // ConvTranspose2D node stays as a primitive
10790                        // variant until that op migrates in its own
10791                        // step-4 commit.
10792                        let (stride, padding, groups) = match params {
10793                            crate::registry::FusedOpParams::Conv2D {
10794                                stride,
10795                                padding,
10796                                groups,
10797                            } => (stride, padding, groups),
10798                            _ => panic!(
10799                                "NodeHandle::backward: Op::Fused(CONV2D, _) \
10800                                 expected FusedOpParams::Conv2D, got {params:?}",
10801                            ),
10802                        };
10803                        let x = inputs[0];
10804                        let weight = inputs[1];
10805                        let x_shape = node_shape(&graph_handle, x);
10806                        let w_shape = node_shape(&graph_handle, weight);
10807                        let dtype = node_dtype(&graph_handle, x);
10808
10809                        // dX via transposed conv (see legacy arm for derivation).
10810                        let x_dims = x_shape.dims();
10811                        let w_dims = w_shape.dims();
10812                        let dy_dims = node_shape(&graph_handle, id).dims().to_vec();
10813                        let (sh, sw) = stride;
10814                        let (ph, pw) = padding;
10815                        let (kh, kw) = (w_dims[2], w_dims[3]);
10816                        let (h_in, w_in) = (x_dims[2], x_dims[3]);
10817                        let (dy_h, dy_w) = (dy_dims[2], dy_dims[3]);
10818                        let base_h = (dy_h - 1) * sh + (kh - 1) + 1;
10819                        let base_w = (dy_w - 1) * sw + (kw - 1) + 1;
10820                        let want_h = h_in + 2 * ph;
10821                        let want_w = w_in + 2 * pw;
10822                        let out_pad_h = want_h.saturating_sub(base_h);
10823                        let out_pad_w = want_w.saturating_sub(base_w);
10824                        if groups != 1 {
10825                            panic!(
10826                                "NodeHandle::backward: Op::Fused(CONV2D) groups>1 \
10827                                 backward not yet implemented (got groups={groups}). \
10828                                 The stride/padding/groups=1 case is wired; grouped \
10829                                 backward needs a per-group weight reshape.",
10830                            );
10831                        }
10832                        let perm_01 = vec![1usize, 0, 2, 3];
10833                        // Phase 7.6 step 4 (final): emit registry form
10834                        // for the inner dX node so the gradient subgraph
10835                        // routes consistently through Op::Fused dispatch.
10836                        let grad_x = push_node(
10837                            &graph_handle,
10838                            Op::Fused(
10839                                crate::registry::FusedOps::CONV_TRANSPOSE2D,
10840                                crate::registry::FusedOpParams::ConvTranspose2D {
10841                                    stride,
10842                                    padding,
10843                                    output_padding: (out_pad_h, out_pad_w),
10844                                    dilation: (1, 1),
10845                                    groups,
10846                                },
10847                            ),
10848                            vec![up_id, weight],
10849                            x_shape.clone(),
10850                            dtype,
10851                        );
10852                        accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
10853
10854                        // dW via correlation expressed as a conv2d.
10855                        if sh != 1 || sw != 1 {
10856                            panic!(
10857                                "NodeHandle::backward: Op::Fused(CONV2D) stride>1 \
10858                                 backward not yet implemented (got stride={stride:?}). \
10859                                 Conv2D needs a `dilation` field to express this \
10860                                 without composing extra ops.",
10861                            );
10862                        }
10863                        let x_swapped_shape = {
10864                            let d = x_dims;
10865                            Shape::from_dims(&[d[1], d[0], d[2], d[3]])
10866                        };
10867                        let x_swapped = push_node(
10868                            &graph_handle,
10869                            Op::Permute(perm_01.clone()),
10870                            vec![x],
10871                            x_swapped_shape,
10872                            dtype,
10873                        );
10874                        let dy_swapped_shape =
10875                            Shape::from_dims(&[dy_dims[1], dy_dims[0], dy_dims[2], dy_dims[3]]);
10876                        let dy_swapped = push_node(
10877                            &graph_handle,
10878                            Op::Permute(perm_01.clone()),
10879                            vec![up_id],
10880                            dy_swapped_shape,
10881                            dtype,
10882                        );
10883                        let conv_out_shape =
10884                            Shape::from_dims(&[w_dims[1], w_dims[0], w_dims[2], w_dims[3]]);
10885                        let grad_w_pre_transpose = push_node(
10886                            &graph_handle,
10887                            Op::Fused(
10888                                crate::registry::FusedOps::CONV2D,
10889                                crate::registry::FusedOpParams::Conv2D {
10890                                    stride: (1, 1),
10891                                    padding,
10892                                    groups: 1,
10893                                },
10894                            ),
10895                            vec![x_swapped, dy_swapped],
10896                            conv_out_shape,
10897                            dtype,
10898                        );
10899                        let grad_w = push_node(
10900                            &graph_handle,
10901                            Op::Permute(perm_01),
10902                            vec![grad_w_pre_transpose],
10903                            w_shape,
10904                            dtype,
10905                        );
10906                        accumulate_grad(&mut upstream, weight, grad_w, &graph_handle);
10907
10908                        // Bias (if present): sum dY over N, H, W via
10909                        // ReduceSumTo([1, Cout, 1, 1]) then reshape to
10910                        // [Cout]. Same path as the legacy arm.
10911                        if inputs.len() >= 3 {
10912                            let bias = inputs[2];
10913                            let bias_shape = node_shape(&graph_handle, bias);
10914                            let cout_4d = Shape::from_dims(&[1, dy_dims[1], 1, 1]);
10915                            let reduced = push_node(
10916                                &graph_handle,
10917                                Op::ReduceSumTo(cout_4d.clone()),
10918                                vec![up_id],
10919                                cout_4d,
10920                                dtype,
10921                            );
10922                            let grad_b = push_node(
10923                                &graph_handle,
10924                                Op::Reshape(bias_shape.clone()),
10925                                vec![reduced],
10926                                bias_shape,
10927                                dtype,
10928                            );
10929                            accumulate_grad(&mut upstream, bias, grad_b, &graph_handle);
10930                        }
10931                    } else if fid == crate::registry::FusedOps::SOFTMAX_LAST_DIM_BACKWARD
10932                        || fid == crate::registry::FusedOps::LAYER_NORM_LAST_DIM_BACKWARD
10933                        || fid == crate::registry::FusedOps::RMS_NORM_LAST_DIM_BACKWARD
10934                        || fid == crate::registry::FusedOps::REDUCE_MAX_TO_BACKWARD
10935                    {
10936                        // Higher-order gradients through backward
10937                        // helpers panic.
10938                        panic!(
10939                            "backward: higher-order gradients through \
10940                             softmax/layer_norm/rms_norm/reduce_max_to backward \
10941                             helpers are not yet supported in the MVP."
10942                        );
10943                    } else if fid == crate::registry::FusedOps::CONV_TRANSPOSE2D {
10944                        // Higher-order grad of the transposed conv
10945                        // isn't needed for Conv2D's backward (which
10946                        // only consumes the forward output). Adding
10947                        // it requires the same dilation-as-stride
10948                        // trick as Conv2D's dW. Punt until a real
10949                        // consumer asks for it.
10950                        panic!(
10951                            "NodeHandle::backward: ConvTranspose2D does \
10952                             not yet have its own gradient rule \
10953                             (only used in the forward path of \
10954                             Conv2D's backward).",
10955                        );
10956                    } else if fid == crate::registry::FusedOps::FLASH_ATTN {
10957                        // Emit three backward nodes: dQ via
10958                        // FLASH_ATTN_BACKWARD_Q, dK via _K, dV via _V.
10959                        // Each takes (q, k, v, dO, [alibi]) and
10960                        // recomputes the softmax state independently
10961                        // in CPU's math-definition path. GPU kernels
10962                        // (when they ship) can fuse the recompute.
10963                        let q = inputs[0];
10964                        let k = inputs[1];
10965                        let v = inputs[2];
10966                        let alibi = inputs.get(3).copied();
10967                        let q_shape = node_shape(&graph_handle, q);
10968                        let k_shape = node_shape(&graph_handle, k);
10969                        let v_shape = node_shape(&graph_handle, v);
10970                        let dtype = node_dtype(&graph_handle, q);
10971                        let (scale, causal, wl, wr, sc) = match params {
10972                            crate::registry::FusedOpParams::FlashAttn {
10973                                softmax_scale,
10974                                causal,
10975                                window_size_left,
10976                                window_size_right,
10977                                softcap,
10978                                // Backward over the static (full-K) flash
10979                                // only; the runtime-k_len capacity form is
10980                                // forward-only (decode). k_len is ignored
10981                                // here.
10982                                k_len: _,
10983                            } => (
10984                                softmax_scale,
10985                                causal,
10986                                window_size_left,
10987                                window_size_right,
10988                                softcap,
10989                            ),
10990                            other => panic!(
10991                                "NodeHandle::backward: FlashAttn node carries \
10992                                 unexpected params {other:?}",
10993                            ),
10994                        };
10995                        let bw_params = crate::registry::FusedOpParams::FlashAttnBackward {
10996                            softmax_scale: scale,
10997                            causal,
10998                            window_size_left: wl,
10999                            window_size_right: wr,
11000                            softcap: sc,
11001                        };
11002                        let mut bw_inputs = vec![q, k, v, up_id];
11003                        if let Some(a) = alibi {
11004                            bw_inputs.push(a);
11005                        }
11006                        let grad_q = push_node(
11007                            &graph_handle,
11008                            Op::Fused(
11009                                crate::registry::FusedOps::FLASH_ATTN_BACKWARD_Q,
11010                                bw_params.clone(),
11011                            ),
11012                            bw_inputs.clone(),
11013                            q_shape,
11014                            dtype,
11015                        );
11016                        let grad_k = push_node(
11017                            &graph_handle,
11018                            Op::Fused(
11019                                crate::registry::FusedOps::FLASH_ATTN_BACKWARD_K,
11020                                bw_params.clone(),
11021                            ),
11022                            bw_inputs.clone(),
11023                            k_shape,
11024                            dtype,
11025                        );
11026                        let grad_v = push_node(
11027                            &graph_handle,
11028                            Op::Fused(crate::registry::FusedOps::FLASH_ATTN_BACKWARD_V, bw_params),
11029                            bw_inputs,
11030                            v_shape,
11031                            dtype,
11032                        );
11033                        accumulate_grad(&mut upstream, q, grad_q, &graph_handle);
11034                        accumulate_grad(&mut upstream, k, grad_k, &graph_handle);
11035                        accumulate_grad(&mut upstream, v, grad_v, &graph_handle);
11036                    } else if fid == crate::registry::FusedOps::PAGED_ATTN {
11037                        // Paged attention is decode-side only by
11038                        // construction (variable-length KV cache, no
11039                        // training pass writes through it). No
11040                        // gradient rule.
11041                        panic!(
11042                            "NodeHandle::backward: PagedAttn is \
11043                             decode-only; no gradient rule exists.",
11044                        );
11045                    } else if fid == crate::registry::FusedOps::QMATMUL {
11046                        // Quantized matmul: weights are frozen
11047                        // bytes; activation gradient isn't
11048                        // implemented today. Dequantize-then-matmul
11049                        // is the workaround if differentiable
11050                        // quantized inference is needed.
11051                        panic!(
11052                            "backward: QMatMul is not differentiable \
11053                             (quantized weights are frozen). Use a \
11054                             dequantize + standard matmul if you \
11055                             need gradients through this input."
11056                        );
11057                    } else {
11058                        panic!(
11059                            "NodeHandle::backward: Op::Fused id {fid:?} has no \
11060                             backward arm wired yet. This is a programming \
11061                             bug — extend the match when the op migrates \
11062                             to the registry.",
11063                        );
11064                    }
11065                }
11066                Op::Alloc { .. } => {
11067                    // Op::Alloc is a source op (zero inputs) that
11068                    // produces a fresh zero-init buffer. No upstream
11069                    // gradient to propagate — the alloc's "input" is
11070                    // the abstract notion of fresh memory, which has
11071                    // no derivative. Mirrors Op::Const's treatment.
11072                    // (Phase 3a of bridge-retirement; see
11073                    // `WorkItemKind::Alloc` in fuel-storage.)
11074                }
11075                Op::ZeroFill => {
11076                    // Op::ZeroFill writes deterministic constants
11077                    // (zeros) to its input's bytes. The output is
11078                    // a constant tensor; gradient w.r.t. it is
11079                    // meaningless. Like Op::Const / Op::Alloc, no
11080                    // gradient propagates back.
11081                }
11082                Op::ScatterIntoSlot { .. } => {
11083                    // Item 4 IR-only scaffold — the backward graph
11084                    // emits this op as the producer's gradient
11085                    // assembler, but `ScatterIntoSlot` itself has no
11086                    // backward (it's a structural op composing
11087                    // already-differentiated slot gradients). No
11088                    // gradient propagates back; matches the
11089                    // Op::Const / Op::Alloc / Op::ZeroFill treatment.
11090                }
11091                Op::Branch { .. } => {
11092                    // PR-A0 inert scaffold. `Op::Branch` is the
11093                    // multi-path phi/merge node; it is never constructed
11094                    // in A0, so this arm is unreachable today. When the
11095                    // PR-A1 builders land, gradient flow through a Branch
11096                    // routes via its arms (each an `inputs[i]` route exit)
11097                    // and reconverges at `reconverge_at` — not through the
11098                    // merge node itself. `backward` is infallible
11099                    // (`-> GradMap`), so the no-panic inert handling is to
11100                    // drop the gradient at the structural node, matching
11101                    // the Op::Const / Op::ScatterIntoSlot treatment above.
11102                    // The real per-arm routing lands with the builders.
11103                }
11104                Op::Scan { .. } => {
11105                    // Defensive `panic!` — `backward() -> GradMap` is infallible
11106                    // (no Err channel), so this is an internal-error guard in the
11107                    // same infallible walk that already panics for QMatMul /
11108                    // PagedAttn, NOT a recoverable typed error. It is unreachable
11109                    // via the public builders: the C3 pre-pass
11110                    // (lower_scans_for_backward) unrolls every Op::Scan to
11111                    // primitives before the topo order is taken, and the builders
11112                    // always emit the View consumers the pre-pass's remap +
11113                    // never-hang guard lower away. Reaching here means the
11114                    // pre-pass did not run (an internal bug).
11115                    panic!(
11116                        "NodeHandle::backward: Op::Scan reached the reverse walk un-lowered — \
11117                            the C3 lower_scans_for_backward pre-pass did not run (internal bug)."
11118                    );
11119                }
11120                Op::ScanPlaceholder { .. } => {
11121                    // Inert body-hole leaf (no inputs) — never a real
11122                    // differentiation target. Drop gradient like Op::Const /
11123                    // Op::Iota; a live backward never reaches it (the body is
11124                    // materialized by unroll_scan before any realize).
11125                }
11126                Op::View { slot } | Op::ViewOwned { slot } => {
11127                    // Multi-output projection — item 4 backward
11128                    // composition (Option C, 2026-06-01).
11129                    //
11130                    // Forward (Op::View): shares the producer's
11131                    // bundled Storage Arc and exposes one slot's
11132                    // typed window. Forward (Op::ViewOwned): memcpys
11133                    // the slot's bytes into a fresh Storage.
11134                    //
11135                    // Backward: scatter the upstream gradient into
11136                    // the slot's byte range of a fresh zero-bundle of
11137                    // the producer's primary shape, then accumulate
11138                    // that partial-bundle gradient as the producer's
11139                    // input gradient. Multiple View consumers of the
11140                    // same producer accumulate via the standard
11141                    // sum-of-partials path:
11142                    //   bundle_grad =
11143                    //     ScatterIntoSlot{0}(0, g_y)  +
11144                    //     ScatterIntoSlot{1}(0, g_state) + ...
11145                    //
11146                    // **Status (item 4)**: IR-level wiring only —
11147                    // emits the scatter chain in the backward graph,
11148                    // but no Op::ScatterIntoSlot kernel is registered.
11149                    // The production multi-output producers
11150                    // (SelectiveScan, SsdChunkScan) are
11151                    // `BackwardKind::NotDifferentiable`, so autograd
11152                    // panics at the producer before this scatter is
11153                    // realized. When a differentiable multi-output op
11154                    // materializes (Mamba training, FSCE loss+grad
11155                    // fused), it lights up the ScatterIntoSlot CPU
11156                    // kernel + tests alongside its own backward.
11157                    let producer = inputs[0];
11158                    let producer_shape = node_shape(&graph_handle, producer);
11159                    let producer_dtype = node_dtype(&graph_handle, producer);
11160                    let zero = build_filled_const(
11161                        &graph_handle,
11162                        producer_shape.clone(),
11163                        producer_dtype,
11164                        0.0,
11165                    );
11166                    let scattered = push_node(
11167                        &graph_handle,
11168                        Op::ScatterIntoSlot { slot },
11169                        vec![zero, up_id],
11170                        producer_shape,
11171                        producer_dtype,
11172                    );
11173                    accumulate_grad(&mut upstream, producer, scattered, &graph_handle);
11174                }
11175                Op::ReluInplace => {
11176                    // Same backward as Op::Relu — d(relu(x))/dx = step(x).
11177                    // The view-aware `derive_ordering` (Phase 4a) pins
11178                    // `Op::Step(x)` to run BEFORE Op::ReluInplace, so the
11179                    // step kernel sees x's original bytes.
11180                    let x = inputs[0];
11181                    let x_shape = node_shape(&graph_handle, x);
11182                    let dtype = node_dtype(&graph_handle, x);
11183                    let step_x =
11184                        push_node(&graph_handle, Op::Step, vec![x], x_shape.clone(), dtype);
11185                    let grad_x =
11186                        push_node(&graph_handle, Op::Mul, vec![up_id, step_x], x_shape, dtype);
11187                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11188                }
11189                Op::SiluInplace => {
11190                    // Same backward as Op::Silu — grad_x = upstream * (s + y*(1-s))
11191                    // where s = sigmoid(x) and y = silu(x) = this in-place node's
11192                    // output. The post-mutation bytes at `id` ARE silu(x) (the
11193                    // in-place op runs after the Sigmoid(x) read because of the
11194                    // alias-aware ordering pass). `Op::Sigmoid(x)` reads x's
11195                    // pre-mutation bytes; `Op::Mul(id, ...)` reads post-mutation
11196                    // bytes — both correct.
11197                    let x = inputs[0];
11198                    let x_shape = node_shape(&graph_handle, x);
11199                    let dtype = node_dtype(&graph_handle, x);
11200                    let s = push_node(&graph_handle, Op::Sigmoid, vec![x], x_shape.clone(), dtype);
11201                    let ones = build_ones(&graph_handle, x_shape.clone(), dtype);
11202                    let one_minus_s = push_node(
11203                        &graph_handle,
11204                        Op::Sub,
11205                        vec![ones, s],
11206                        x_shape.clone(),
11207                        dtype,
11208                    );
11209                    let y_times_1ms = push_node(
11210                        &graph_handle,
11211                        Op::Mul,
11212                        vec![id, one_minus_s],
11213                        x_shape.clone(),
11214                        dtype,
11215                    );
11216                    let inner = push_node(
11217                        &graph_handle,
11218                        Op::Add,
11219                        vec![s, y_times_1ms],
11220                        x_shape.clone(),
11221                        dtype,
11222                    );
11223                    let grad_x =
11224                        push_node(&graph_handle, Op::Mul, vec![up_id, inner], x_shape, dtype);
11225                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11226                }
11227                Op::TanhInplace => {
11228                    // Same backward as Op::Tanh — grad_x = upstream * (1 - y²)
11229                    // where y = tanh(x) = this in-place node's output. `Op::Sqr(id)`
11230                    // reads post-mutation bytes (= tanh(x) by definition of the
11231                    // in-place op); no need to reference x at all.
11232                    let x = inputs[0];
11233                    let y_shape = node_shape(&graph_handle, id);
11234                    let dtype = node_dtype(&graph_handle, id);
11235                    let y_sq = push_node(&graph_handle, Op::Sqr, vec![id], y_shape.clone(), dtype);
11236                    let ones = build_ones(&graph_handle, y_shape.clone(), dtype);
11237                    let one_minus_sq = push_node(
11238                        &graph_handle,
11239                        Op::Sub,
11240                        vec![ones, y_sq],
11241                        y_shape.clone(),
11242                        dtype,
11243                    );
11244                    let grad_x = push_node(
11245                        &graph_handle,
11246                        Op::Mul,
11247                        vec![up_id, one_minus_sq],
11248                        y_shape,
11249                        dtype,
11250                    );
11251                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11252                }
11253                Op::SigmoidInplace => {
11254                    // Same backward as Op::Sigmoid — grad_x = upstream * y * (1 - y)
11255                    // where y = sigmoid(x) = this in-place node's output.
11256                    let x = inputs[0];
11257                    let y_shape = node_shape(&graph_handle, id);
11258                    let dtype = node_dtype(&graph_handle, id);
11259                    let ones = build_ones(&graph_handle, y_shape.clone(), dtype);
11260                    let one_minus_y = push_node(
11261                        &graph_handle,
11262                        Op::Sub,
11263                        vec![ones, id],
11264                        y_shape.clone(),
11265                        dtype,
11266                    );
11267                    let y_times_1my = push_node(
11268                        &graph_handle,
11269                        Op::Mul,
11270                        vec![id, one_minus_y],
11271                        y_shape.clone(),
11272                        dtype,
11273                    );
11274                    let grad_x = push_node(
11275                        &graph_handle,
11276                        Op::Mul,
11277                        vec![up_id, y_times_1my],
11278                        y_shape,
11279                        dtype,
11280                    );
11281                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11282                }
11283                Op::GeluTanhInplace => {
11284                    // Same backward as Op::GeluTanh. The formula reads only the
11285                    // pre-mutation input `x` (like SiluInplace's Sigmoid(x) read),
11286                    // which the alias-aware ordering pass keeps ahead of the
11287                    // in-place write.
11288                    let x = inputs[0];
11289                    let grad_x = build_gelu_tanh_grad(&graph_handle, x, up_id);
11290                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11291                }
11292                Op::NegInplace => {
11293                    // Same backward as Op::Neg — grad_x = -upstream. No
11294                    // dependency on x or y; Phase 4a ordering not needed.
11295                    let x = inputs[0];
11296                    let x_shape = node_shape(&graph_handle, x);
11297                    let dtype = node_dtype(&graph_handle, x);
11298                    let grad_x = push_node(&graph_handle, Op::Neg, vec![up_id], x_shape, dtype);
11299                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11300                }
11301                Op::AbsInplace => {
11302                    // Same backward as Op::Abs — grad_x = sign(x) * upstream.
11303                    // `Op::Sign(x)` reads x's pre-mutation bytes (Phase 4a
11304                    // pins the in-place op after the Sign read).
11305                    let x = inputs[0];
11306                    let x_shape = node_shape(&graph_handle, x);
11307                    let dtype = node_dtype(&graph_handle, x);
11308                    let sign_x =
11309                        push_node(&graph_handle, Op::Sign, vec![x], x_shape.clone(), dtype);
11310                    let grad_x =
11311                        push_node(&graph_handle, Op::Mul, vec![up_id, sign_x], x_shape, dtype);
11312                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11313                }
11314                Op::SqrInplace => {
11315                    // Same backward as Op::Sqr — grad_x = 2x * upstream,
11316                    // expressed as `x + x` to avoid a scalar broadcast.
11317                    // `x + x` reads pre-mutation bytes (Phase 4a).
11318                    let x = inputs[0];
11319                    let x_shape = node_shape(&graph_handle, x);
11320                    let dtype = node_dtype(&graph_handle, x);
11321                    let two_x =
11322                        push_node(&graph_handle, Op::Add, vec![x, x], x_shape.clone(), dtype);
11323                    let grad_x =
11324                        push_node(&graph_handle, Op::Mul, vec![up_id, two_x], x_shape, dtype);
11325                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11326                }
11327                Op::SqrtInplace => {
11328                    // Same backward as Op::Sqrt — y = √x, grad_x = upstream/(2y).
11329                    // Reads only the forward output `id`, which is post-mutation =
11330                    // √x by definition; no pre-mutation x access needed.
11331                    let x = inputs[0];
11332                    let x_shape = node_shape(&graph_handle, x);
11333                    let dtype = node_dtype(&graph_handle, x);
11334                    let two_y =
11335                        push_node(&graph_handle, Op::Add, vec![id, id], x_shape.clone(), dtype);
11336                    let grad_x =
11337                        push_node(&graph_handle, Op::Div, vec![up_id, two_y], x_shape, dtype);
11338                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11339                }
11340                Op::RsqrtInplace => {
11341                    // Same backward as Op::Rsqrt — y = x^(-1/2),
11342                    // grad_x = -0.5 * upstream * y³. Reads forward output (id)
11343                    // only — post-mutation = y by definition.
11344                    let x = inputs[0];
11345                    let x_shape = node_shape(&graph_handle, x);
11346                    let dtype = node_dtype(&graph_handle, x);
11347                    let y_sq = push_node(&graph_handle, Op::Sqr, vec![id], x_shape.clone(), dtype);
11348                    let y_cu = push_node(
11349                        &graph_handle,
11350                        Op::Mul,
11351                        vec![id, y_sq],
11352                        x_shape.clone(),
11353                        dtype,
11354                    );
11355                    let scaled = push_node(
11356                        &graph_handle,
11357                        Op::MulScalar(-0.5),
11358                        vec![y_cu],
11359                        x_shape.clone(),
11360                        dtype,
11361                    );
11362                    let grad_x =
11363                        push_node(&graph_handle, Op::Mul, vec![up_id, scaled], x_shape, dtype);
11364                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11365                }
11366                Op::RecipInplace => {
11367                    // Same backward as Op::Recip — y = 1/x, grad_x = -upstream * y².
11368                    // Reads forward output (id) only — post-mutation = 1/x.
11369                    let x = inputs[0];
11370                    let x_shape = node_shape(&graph_handle, x);
11371                    let dtype = node_dtype(&graph_handle, x);
11372                    let y_sq = push_node(&graph_handle, Op::Sqr, vec![id], x_shape.clone(), dtype);
11373                    let up_y_sq = push_node(
11374                        &graph_handle,
11375                        Op::Mul,
11376                        vec![up_id, y_sq],
11377                        x_shape.clone(),
11378                        dtype,
11379                    );
11380                    let grad_x = push_node(&graph_handle, Op::Neg, vec![up_y_sq], x_shape, dtype);
11381                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11382                }
11383                Op::ExpInplace => {
11384                    // Same backward as Op::Exp — d(exp(x))/dx = exp(x) = y = id.
11385                    // grad_x = upstream * y. Reads forward output (id) only.
11386                    let x = inputs[0];
11387                    let out_shape = node_shape(&graph_handle, id);
11388                    let dtype = node_dtype(&graph_handle, id);
11389                    let grad_x =
11390                        push_node(&graph_handle, Op::Mul, vec![up_id, id], out_shape, dtype);
11391                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11392                }
11393                Op::LogInplace => {
11394                    // Same backward as Op::Log — grad_x = upstream / x.
11395                    // `Op::Div(up, x)` reads pre-mutation bytes of x (Phase 4a).
11396                    let x = inputs[0];
11397                    let x_shape = node_shape(&graph_handle, x);
11398                    let dtype = node_dtype(&graph_handle, x);
11399                    let grad_x = push_node(&graph_handle, Op::Div, vec![up_id, x], x_shape, dtype);
11400                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11401                }
11402                Op::SinInplace => {
11403                    // Same backward as Op::Sin — grad_x = upstream * cos(x).
11404                    // `Op::Cos(x)` reads pre-mutation x via Phase 4a ordering.
11405                    let x = inputs[0];
11406                    let x_shape = node_shape(&graph_handle, x);
11407                    let dtype = node_dtype(&graph_handle, x);
11408                    let cos_x = push_node(&graph_handle, Op::Cos, vec![x], x_shape.clone(), dtype);
11409                    let grad_x =
11410                        push_node(&graph_handle, Op::Mul, vec![up_id, cos_x], x_shape, dtype);
11411                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11412                }
11413                Op::CosInplace => {
11414                    // Same backward as Op::Cos — grad_x = -(upstream * sin(x)).
11415                    let x = inputs[0];
11416                    let x_shape = node_shape(&graph_handle, x);
11417                    let dtype = node_dtype(&graph_handle, x);
11418                    let sin_x = push_node(&graph_handle, Op::Sin, vec![x], x_shape.clone(), dtype);
11419                    let up_sin = push_node(
11420                        &graph_handle,
11421                        Op::Mul,
11422                        vec![up_id, sin_x],
11423                        x_shape.clone(),
11424                        dtype,
11425                    );
11426                    let grad_x = push_node(&graph_handle, Op::Neg, vec![up_sin], x_shape, dtype);
11427                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11428                }
11429                Op::SignInplace | Op::FloorInplace | Op::CeilInplace | Op::RoundInplace => {
11430                    // Same backward as Op::{Sign, Floor, Ceil, Round} —
11431                    // derivative is 0 almost everywhere; drop gradient
11432                    // (no accumulation, mirroring Op::Step's pattern).
11433                }
11434                Op::ErfInplace => {
11435                    // Same backward as Op::Erf — grad_x = upstream * (2/√π) * exp(-x²).
11436                    // `Op::Sqr(x)` reads pre-mutation x via Phase 4a ordering.
11437                    const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
11438                    let x = inputs[0];
11439                    let x_shape = node_shape(&graph_handle, x);
11440                    let dtype = node_dtype(&graph_handle, x);
11441                    let x_sq = push_node(&graph_handle, Op::Sqr, vec![x], x_shape.clone(), dtype);
11442                    let neg_x_sq =
11443                        push_node(&graph_handle, Op::Neg, vec![x_sq], x_shape.clone(), dtype);
11444                    let exp_neg = push_node(
11445                        &graph_handle,
11446                        Op::Exp,
11447                        vec![neg_x_sq],
11448                        x_shape.clone(),
11449                        dtype,
11450                    );
11451                    let scaled = push_node(
11452                        &graph_handle,
11453                        Op::MulScalar(TWO_OVER_SQRT_PI),
11454                        vec![exp_neg],
11455                        x_shape.clone(),
11456                        dtype,
11457                    );
11458                    let grad_x =
11459                        push_node(&graph_handle, Op::Mul, vec![up_id, scaled], x_shape, dtype);
11460                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11461                }
11462                Op::ClampInplace { min, max } => {
11463                    // Same backward as Op::Clamp — indicator is 1 where
11464                    // min ≤ x ≤ max, 0 elsewhere. Reads pre-mutation x via
11465                    // Phase 4a ordering.
11466                    let x = inputs[0];
11467                    let x_shape = node_shape(&graph_handle, x);
11468                    let dtype = node_dtype(&graph_handle, x);
11469                    let min_shifted = push_node(
11470                        &graph_handle,
11471                        Op::AddScalar(-min),
11472                        vec![x],
11473                        x_shape.clone(),
11474                        dtype,
11475                    );
11476                    let lower_ok = push_node(
11477                        &graph_handle,
11478                        Op::Step,
11479                        vec![min_shifted],
11480                        x_shape.clone(),
11481                        dtype,
11482                    );
11483                    let neg_x = push_node(
11484                        &graph_handle,
11485                        Op::MulScalar(-1.0),
11486                        vec![x],
11487                        x_shape.clone(),
11488                        dtype,
11489                    );
11490                    let max_minus_x = push_node(
11491                        &graph_handle,
11492                        Op::AddScalar(max),
11493                        vec![neg_x],
11494                        x_shape.clone(),
11495                        dtype,
11496                    );
11497                    let upper_ok = push_node(
11498                        &graph_handle,
11499                        Op::Step,
11500                        vec![max_minus_x],
11501                        x_shape.clone(),
11502                        dtype,
11503                    );
11504                    let indicator = push_node(
11505                        &graph_handle,
11506                        Op::Mul,
11507                        vec![lower_ok, upper_ok],
11508                        x_shape.clone(),
11509                        dtype,
11510                    );
11511                    let grad_x = push_node(
11512                        &graph_handle,
11513                        Op::Mul,
11514                        vec![up_id, indicator],
11515                        x_shape,
11516                        dtype,
11517                    );
11518                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11519                }
11520                Op::PowIInplace(n) => {
11521                    // Same backward as Op::PowI — single fused
11522                    // POWI_BACKWARD node `(x, upstream) → exp · x^(exp-1) ·
11523                    // upstream`. Reads pre-mutation x via Phase 4a ordering.
11524                    let x = inputs[0];
11525                    let x_shape = node_shape(&graph_handle, x);
11526                    let dtype = node_dtype(&graph_handle, x);
11527                    let grad_x = push_node(
11528                        &graph_handle,
11529                        Op::Fused(
11530                            crate::registry::FusedOps::POWI_BACKWARD,
11531                            crate::registry::FusedOpParams::PowIBackward { exp: n },
11532                        ),
11533                        vec![x, up_id],
11534                        x_shape,
11535                        dtype,
11536                    );
11537                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11538                }
11539                Op::GeluInplace => {
11540                    // Same backward as Op::Gelu — exact-GELU derivative
11541                    // is Φ(x) + x·φ(x). Both halves read pre-mutation x via
11542                    // Phase 4a ordering. Identical 12-node chain to Op::Gelu.
11543                    const INV_SQRT_2: f64 = std::f64::consts::FRAC_1_SQRT_2;
11544                    const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7_f64;
11545                    let x = inputs[0];
11546                    let x_shape = node_shape(&graph_handle, x);
11547                    let dtype = node_dtype(&graph_handle, x);
11548                    let x_over_sqrt2 = push_node(
11549                        &graph_handle,
11550                        Op::MulScalar(INV_SQRT_2),
11551                        vec![x],
11552                        x_shape.clone(),
11553                        dtype,
11554                    );
11555                    let erf_arg = push_node(
11556                        &graph_handle,
11557                        Op::Erf,
11558                        vec![x_over_sqrt2],
11559                        x_shape.clone(),
11560                        dtype,
11561                    );
11562                    let plus_one = push_node(
11563                        &graph_handle,
11564                        Op::AddScalar(1.0),
11565                        vec![erf_arg],
11566                        x_shape.clone(),
11567                        dtype,
11568                    );
11569                    let cdf_term = push_node(
11570                        &graph_handle,
11571                        Op::MulScalar(0.5),
11572                        vec![plus_one],
11573                        x_shape.clone(),
11574                        dtype,
11575                    );
11576                    let x_sq = push_node(&graph_handle, Op::Sqr, vec![x], x_shape.clone(), dtype);
11577                    let half_x_sq = push_node(
11578                        &graph_handle,
11579                        Op::MulScalar(0.5),
11580                        vec![x_sq],
11581                        x_shape.clone(),
11582                        dtype,
11583                    );
11584                    let neg_half = push_node(
11585                        &graph_handle,
11586                        Op::Neg,
11587                        vec![half_x_sq],
11588                        x_shape.clone(),
11589                        dtype,
11590                    );
11591                    let exp_term = push_node(
11592                        &graph_handle,
11593                        Op::Exp,
11594                        vec![neg_half],
11595                        x_shape.clone(),
11596                        dtype,
11597                    );
11598                    let phi = push_node(
11599                        &graph_handle,
11600                        Op::MulScalar(INV_SQRT_2PI),
11601                        vec![exp_term],
11602                        x_shape.clone(),
11603                        dtype,
11604                    );
11605                    let pdf_term =
11606                        push_node(&graph_handle, Op::Mul, vec![x, phi], x_shape.clone(), dtype);
11607                    let d_gelu = push_node(
11608                        &graph_handle,
11609                        Op::Add,
11610                        vec![cdf_term, pdf_term],
11611                        x_shape.clone(),
11612                        dtype,
11613                    );
11614                    let grad_x =
11615                        push_node(&graph_handle, Op::Mul, vec![up_id, d_gelu], x_shape, dtype);
11616                    accumulate_grad(&mut upstream, x, grad_x, &graph_handle);
11617                }
11618            }
11619        }
11620
11621        GradMap {
11622            graph: graph_handle,
11623            forward_to_grad: upstream,
11624        }
11625    }
11626}
11627
11628/// Map from forward-graph node IDs to the IDs of their accumulated
11629/// backward-graph gradient nodes, returned by [`NodeHandle::backward`].
11630///
11631/// All gradient nodes live on the same graph as the forward nodes. Getting
11632/// a gradient by calling [`GradMap::get`] returns a new [`NodeHandle`] handle
11633/// pointing at the gradient node so callers can realize it, plug it into
11634/// further computation, or back-propagate through it again.
11635pub struct GradMap {
11636    graph: SharedGraph,
11637    forward_to_grad: HashMap<NodeId, NodeId>,
11638}
11639
11640impl GradMap {
11641    /// Get the gradient tensor for a forward tensor from the same graph.
11642    ///
11643    /// Returns `None` if the forward tensor was not reachable from the
11644    /// root passed to `backward` (i.e. not part of the computation being
11645    /// differentiated).
11646    ///
11647    /// # Panics
11648    ///
11649    /// Panics if any operand belongs to a different [`Graph`] than the one this
11650    /// builder is building into. Operand identity is graph-scoped and checked by
11651    /// pointer identity (`Arc::ptr_eq`), which cannot alias; the panic is
11652    /// deliberate and replaces the hang a cross-graph handle used to produce.
11653    /// Build every operand from one graph, or use a `Result`-returning entry
11654    /// point where one exists.
11655    pub fn get(&self, forward: &NodeHandle) -> Option<NodeHandle> {
11656        assert!(
11657            Arc::ptr_eq(&self.graph, &forward.graph),
11658            "GradMap::get: tensor is from a different graph",
11659        );
11660        let &grad_id = self.forward_to_grad.get(&forward.id)?;
11661        Some(NodeHandle {
11662            graph: self.graph.clone(),
11663            id: grad_id,
11664        })
11665    }
11666
11667    /// Number of forward nodes that have a recorded gradient.
11668    pub fn len(&self) -> usize {
11669        self.forward_to_grad.len()
11670    }
11671
11672    /// Whether this map has no gradients (e.g. a backward over a lone const).
11673    pub fn is_empty(&self) -> bool {
11674        self.forward_to_grad.is_empty()
11675    }
11676}
11677
11678// -------- internal helpers shared by the backward pass --------------------
11679
11680/// Does the registry declare this fused op's backward to be "lower it via its
11681/// total `decompose`, then differentiate the primitives"?
11682fn lowers_for_backward(fid: crate::registry::FusedOpId) -> bool {
11683    crate::registry::default_registry()
11684        .entry(fid)
11685        .is_some_and(|e| e.backward == crate::registry::BackwardKind::Decompose)
11686}
11687
11688/// The generic decompose-backward hook (Op::Scan Phase 2, C3): before the
11689/// reverse walk takes its topo order, lower every reachable `Op::Scan` to
11690/// `bound` primitives and decompose the two SSM `Op::Fused` recipes into their
11691/// `Op::Scan` form, rewiring consumers so the walk sees only differentiable
11692/// primitives. BPTT differentiates the static-`bound` unroll (the runtime
11693/// early-exit predicate is a forward-only optimization, ignored here — spec C3
11694/// static-horizon note). Operates in place on the shared graph (a `backward()`
11695/// caller is a training step that wants the unrolled activations anyway).
11696/// Returns the (possibly remapped) root NodeId to walk from.
11697fn lower_scans_for_backward(graph: &SharedGraph, root: NodeId) -> NodeId {
11698    use std::collections::HashMap;
11699    let mut root = root;
11700    // helper: apply a remap to every consumer's inputs + the root, in place.
11701    fn apply_remap(graph: &SharedGraph, remap: &HashMap<NodeId, NodeId>, root: &mut NodeId) {
11702        if remap.is_empty() {
11703            return;
11704        }
11705        let mut g = graph.write().unwrap();
11706        let n = g.len();
11707        for nid in 0..n {
11708            let ilen = g.node(NodeId(nid)).inputs.len();
11709            for i in 0..ilen {
11710                let cur = g.node(NodeId(nid)).inputs[i];
11711                if let Some(&new) = remap.get(&cur) {
11712                    g.rewrite_input(NodeId(nid), cur, new);
11713                }
11714            }
11715        }
11716        if let Some(&new) = remap.get(root) {
11717            *root = new;
11718        }
11719    }
11720
11721    // --- Pass 1: decompose every reachable Op::Fused whose registry entry declares
11722    // `BackwardKind::Decompose` (the SSM recipes into Op::Scan, which pass 2 then
11723    // unrolls; single-output recipes such as NF4/AWQ matmul and causal conv1d
11724    // straight to primitives). The registry is the source of truth: an id with
11725    // a hand-written backward arm in the reverse walk is `Fused(..)` or
11726    // `NotDifferentiable` there, never `Decompose`.
11727    {
11728        let targets: Vec<(
11729            NodeId,
11730            crate::registry::FusedOpId,
11731            crate::registry::FusedOpParams,
11732        )> = {
11733            let g = graph.read().unwrap();
11734            crate::topo_order(&g, root)
11735                .into_iter()
11736                .filter_map(|id| match &g.node(id).op {
11737                    Op::Fused(fid, params) if lowers_for_backward(*fid) => {
11738                        Some((id, *fid, params.clone()))
11739                    }
11740                    _ => None,
11741                })
11742                .collect()
11743        };
11744        let mut remap = HashMap::new();
11745        for (id, fid, params) in targets {
11746            if let Some(entry) = crate::registry::default_registry().entry(fid) {
11747                let y = {
11748                    let mut g = graph.write().unwrap();
11749                    (entry.decompose)(&mut g, id, &params)
11750                };
11751                if y != id {
11752                    remap.insert(id, y);
11753                    // Collapse the fused node's slot-0 View consumers DIRECTLY to
11754                    // the decompose output. Slot 0 is the primary value (the
11755                    // multi-output authoring contract: Node.shape == slot-0
11756                    // shape), and the SSM decompose re-presents its primary AS
11757                    // slot 0, so `View{0}(y) == y`. Without this collapse the
11758                    // outer View{0} survives into the reverse walk and emits an
11759                    // `Op::ScatterIntoSlot` (which has no CPU realize kernel),
11760                    // so realizing the gradient would fail. Slot-1 (last_state)
11761                    // consumers keep their `View{1}(y)` re-projection via the
11762                    // default input rewrite (remap[id] = y) and are not
11763                    // differentiated on this path.
11764                    let g = graph.read().unwrap();
11765                    for nid in 0..g.len() {
11766                        let node = g.node(NodeId(nid));
11767                        if let Op::View { slot: 0 } | Op::ViewOwned { slot: 0 } = node.op
11768                            && node.inputs.first() == Some(&id)
11769                        {
11770                            remap.insert(NodeId(nid), y);
11771                        }
11772                    }
11773                }
11774            }
11775        }
11776        apply_remap(graph, &remap, &mut root);
11777    }
11778
11779    // --- Pass 2: unroll every reachable Op::Scan to `bound` primitives.
11780    loop {
11781        let scans: Vec<(NodeId, usize, ScanEmit)> = {
11782            let g = graph.read().unwrap();
11783            crate::topo_order(&g, root)
11784                .into_iter()
11785                .filter_map(|id| match &g.node(id).op {
11786                    Op::Scan { bound, emit, .. } => Some((id, *bound, *emit)),
11787                    _ => None,
11788                })
11789                .collect()
11790        };
11791        if scans.is_empty() {
11792            break;
11793        }
11794        let mut remap = HashMap::new();
11795        for (scan_id, bound, emit) in scans {
11796            let (a, b) = {
11797                let mut g = graph.write().unwrap();
11798                match crate::scan::unroll_scan(&mut g, scan_id, bound) {
11799                    Ok(pair) => pair,
11800                    Err(_) => continue, // malformed scan: leave it; the C4 guard describes it.
11801                }
11802            };
11803            // Normalize to (slot 0 = stacked_ys, slots 1.. = final carries).
11804            //
11805            // GAP-303: `unroll_scan` now returns (selected, complementary) as
11806            // VECS. The `ys` side is always exactly one node; the carry side is
11807            // `n_carries` wide. At `n_carries == 1` this is byte-for-byte the old
11808            // behaviour -- one ys, one carry, slot 1 the only carry slot.
11809            let (ys_v, carries) = match emit {
11810                ScanEmit::All => (a, b),
11811                ScanEmit::Final => (b, a),
11812            };
11813            let Some(&slot0) = ys_v.first() else {
11814                continue; // malformed: no stacked ys; the C4 guard describes it.
11815            };
11816            // Remap the scan's View{slot}/ViewOwned{slot} consumers to the unroll outputs.
11817            let g = graph.read().unwrap();
11818            for nid in 0..g.len() {
11819                let node = g.node(NodeId(nid));
11820                if let Op::View { slot } | Op::ViewOwned { slot } = node.op
11821                    && node.inputs.first() == Some(&scan_id)
11822                {
11823                    // ⚠️ Slot 0 is `ys`; slot k>=1 is carry k-1. The old form was
11824                    // `if slot == 0 { slot0 } else { slot1 }`, which folded EVERY
11825                    // slot above 0 onto the single carry -- a fifth site of the
11826                    // 2-slot assumption. Out-of-range slots are LEFT UNMAPPED for
11827                    // the C4 guard rather than indexed (never-panic).
11828                    let target = if slot == 0 {
11829                        Some(slot0)
11830                    } else {
11831                        carries.get(slot as usize - 1).copied()
11832                    };
11833                    if let Some(t) = target {
11834                        remap.insert(NodeId(nid), t);
11835                    }
11836                }
11837            }
11838        }
11839        // Defensive never-hang guard: if a full pass lowered no scan to a
11840        // consumer remap (e.g. a malformed scan that unroll rejects, or a raw
11841        // scan producer with no View consumers), stop rather than re-scanning
11842        // the same unchanged graph forever. Any residual Op::Scan then reaches
11843        // the C4 defensive `panic!` guard (backward is infallible, so it cannot
11844        // return an error) — an internal-bug signal, never an infinite loop.
11845        if remap.is_empty() {
11846            break;
11847        }
11848        apply_remap(graph, &remap, &mut root);
11849        // Unrolled graphs contain no Op::Scan, so a well-formed graph terminates
11850        // in <= 2 iterations (the loop re-scans in case a decomposed body itself
11851        // contained a scan; the current SSM decompose does not).
11852    }
11853    root
11854}
11855
11856/// Read a node's shape without holding the borrow past the call site.
11857fn node_shape(graph: &SharedGraph, id: NodeId) -> Shape {
11858    graph.read().unwrap().node(id).shape.clone()
11859}
11860
11861/// Read a node's dtype without holding the borrow past the call site.
11862fn node_dtype(graph: &SharedGraph, id: NodeId) -> DType {
11863    graph.read().unwrap().node(id).dtype
11864}
11865
11866/// Shape with its last two dims swapped. Used by MatMul's backward rule
11867/// to construct transpose-node output shapes for any rank ≥ 2.
11868fn transposed_shape(shape: &Shape) -> Shape {
11869    let d = shape.dims();
11870    assert!(
11871        d.len() >= 2,
11872        "transposed_shape: expected rank ≥ 2, got {d:?}",
11873    );
11874    let rank = d.len();
11875    let mut out: Vec<usize> = d.to_vec();
11876    out.swap(rank - 2, rank - 1);
11877    Shape::from_dims(&out)
11878}
11879
11880/// Given a full tensor shape and a dim that was reduced away, produce the
11881/// keepdim shape — i.e. the full shape with size 1 at `dim`. Used by
11882/// axis-reduction backward rules to reshape the reduced upstream back to
11883/// a shape that broadcasts against the original input.
11884fn reshape_to_keepdim(full_dims: &[usize], dim: usize) -> Shape {
11885    assert!(
11886        dim < full_dims.len(),
11887        "reshape_to_keepdim: dim {dim} out of bounds for {full_dims:?}",
11888    );
11889    let mut out: Vec<usize> = full_dims.to_vec();
11890    out[dim] = 1;
11891    Shape::from_dims(&out)
11892}
11893
11894/// Validate that `src` can be broadcast to `dst` using NumPy rules:
11895/// right-align, pad the shorter shape with 1s on the left, and for each
11896/// aligned dimension the src size must equal the dst size or be 1.
11897fn check_broadcast_compatible(src: &[usize], dst: &[usize]) {
11898    assert!(
11899        src.len() <= dst.len(),
11900        "broadcast_to: source rank {} exceeds target rank {}",
11901        src.len(),
11902        dst.len(),
11903    );
11904    let pad = dst.len() - src.len();
11905    for (i, &s) in src.iter().enumerate() {
11906        let d = dst[pad + i];
11907        assert!(
11908            s == d || s == 1,
11909            "broadcast_to: dim {i} of source ({s}) is incompatible with dim {} of target ({d})",
11910            pad + i,
11911        );
11912    }
11913}
11914
11915/// Result-returning sibling of [`check_broadcast_compatible`].
11916fn try_check_broadcast_compatible(
11917    src: &[usize],
11918    dst: &[usize],
11919) -> std::result::Result<(), fuel_ir::Error> {
11920    if src.len() > dst.len() {
11921        return Err(fuel_ir::Error::Msg(format!(
11922            "broadcast_to: source rank {} exceeds target rank {}",
11923            src.len(),
11924            dst.len(),
11925        ))
11926        .bt());
11927    }
11928    let pad = dst.len() - src.len();
11929    for (i, &s) in src.iter().enumerate() {
11930        let d = dst[pad + i];
11931        if s != d && s != 1 {
11932            return Err(fuel_ir::Error::Msg(format!(
11933                "broadcast_to: dim {i} of source ({s}) is incompatible with dim {} of target ({d})",
11934                pad + i,
11935            ))
11936            .bt());
11937        }
11938    }
11939    Ok(())
11940}
11941
11942/// Compute the NumPy broadcast shape of two input shapes. Right-align,
11943/// pad the shorter with leading 1s, and take the element-wise max at
11944/// each position. Panics if the shapes are incompatible (neither can
11945/// expand to match the other at some dim).
11946fn compute_broadcast_shape(a: &[usize], b: &[usize]) -> Vec<usize> {
11947    let n = a.len().max(b.len());
11948    let a_pad = n - a.len();
11949    let b_pad = n - b.len();
11950    let mut out = Vec::with_capacity(n);
11951    for i in 0..n {
11952        let ai = if i < a_pad { 1 } else { a[i - a_pad] };
11953        let bi = if i < b_pad { 1 } else { b[i - b_pad] };
11954        let oi = if ai == bi {
11955            ai
11956        } else if ai == 1 {
11957            bi
11958        } else if bi == 1 {
11959            ai
11960        } else {
11961            panic!(
11962                "compute_broadcast_shape: incompatible shapes {a:?} vs {b:?} at axis {i}: {ai} vs {bi}",
11963            );
11964        };
11965        out.push(oi);
11966    }
11967    out
11968}
11969
11970/// Push a node to the graph and return its ID. Used by backward to append
11971/// gradient-computing nodes without going through the public builders,
11972/// which would re-validate shapes the backward pass has already derived.
11973fn push_node(
11974    graph: &SharedGraph,
11975    op: Op,
11976    inputs: Vec<NodeId>,
11977    shape: Shape,
11978    dtype: DType,
11979) -> NodeId {
11980    graph.write().unwrap().push(Node {
11981        op,
11982        inputs,
11983        shape,
11984        dtype,
11985    })
11986}
11987
11988/// Build a ones tensor of the given shape and dtype as a `Const` node.
11989/// Used to seed the initial upstream gradient at the root of the backward
11990/// pass (`dL/dL = 1`).
11991/// Backward of the tanh-approximation GELU, `y = 0.5 x (1 + tanh(u))` with
11992/// `u = c (x + k x^3)`, `c = sqrt(2/pi)`, `k = 0.044715`:
11993///
11994/// `dy/dx = 0.5 (1 + t) + 0.5 x (1 - t^2) c (1 + 3 k x^2)`, `t = tanh(u)`.
11995///
11996/// Returns `upstream * dy/dx` as a new node. Built from existing primitives
11997/// (no new Op); `t` is recomputed from `x` so the rule is safe at `x = 0`.
11998fn build_gelu_tanh_grad(graph: &SharedGraph, x: NodeId, upstream: NodeId) -> NodeId {
11999    // sqrt(2/pi), the constant the forward kernels use.
12000    const C: f64 = 0.797_884_560_802_865_4;
12001    const K: f64 = 0.044_715;
12002    let shape = node_shape(graph, x);
12003    let dtype = node_dtype(graph, x);
12004    let un = |op: Op, input: NodeId| push_node(graph, op, vec![input], shape.clone(), dtype);
12005    let x_sq = un(Op::Sqr, x);
12006    let x_cu = push_node(graph, Op::Mul, vec![x_sq, x], shape.clone(), dtype);
12007    let k_x_cu = un(Op::MulScalar(K), x_cu);
12008    let inner = push_node(graph, Op::Add, vec![x, k_x_cu], shape.clone(), dtype);
12009    let u = un(Op::MulScalar(C), inner);
12010    let t = un(Op::Tanh, u);
12011    // 0.5 (1 + t)
12012    let t_plus_one = un(Op::AddScalar(1.0), t);
12013    let half_one_plus_t = un(Op::MulScalar(0.5), t_plus_one);
12014    // 1 - t^2
12015    let t_sq = un(Op::Sqr, t);
12016    let ones = build_ones(graph, shape.clone(), dtype);
12017    let one_minus_t_sq = push_node(graph, Op::Sub, vec![ones, t_sq], shape.clone(), dtype);
12018    // du/dx = c + 3 c k x^2
12019    let scaled_x_sq = un(Op::MulScalar(3.0 * C * K), x_sq);
12020    let du = un(Op::AddScalar(C), scaled_x_sq);
12021    // 0.5 x (1 - t^2) du/dx
12022    let dt = push_node(
12023        graph,
12024        Op::Mul,
12025        vec![one_minus_t_sq, du],
12026        shape.clone(),
12027        dtype,
12028    );
12029    let x_dt = push_node(graph, Op::Mul, vec![x, dt], shape.clone(), dtype);
12030    let half_x_dt = un(Op::MulScalar(0.5), x_dt);
12031    let d_gelu = push_node(
12032        graph,
12033        Op::Add,
12034        vec![half_one_plus_t, half_x_dt],
12035        shape.clone(),
12036        dtype,
12037    );
12038    push_node(graph, Op::Mul, vec![upstream, d_gelu], shape, dtype)
12039}
12040
12041fn build_ones(graph: &SharedGraph, shape: Shape, dtype: DType) -> NodeId {
12042    build_filled_const(graph, shape, dtype, 1.0)
12043}
12044
12045/// Build a constant tensor of the given shape and dtype filled with
12046/// `value` (expressed as `f64` and converted to the target dtype). Used
12047/// by backward rules that need a scalar-valued broadcast, for example
12048/// `MeanAll`'s `1/n` factor expressed as "divide by an n-filled const."
12049///
12050/// Build RoPE cos/sin tables for a given `(base, start_pos, seq,
12051/// head_dim)`. Each returned `Vec<f32>` has shape `[seq, head_dim]`
12052/// (row-major) in the half-split layout [`NodeHandle::rope_with_tables`]
12053/// expects: positions `[:, :half]` and `[:, half:]` hold the same
12054/// value for the same `(p, i)`, so a single elementwise multiply with
12055/// `self` does the right thing alongside `rotate_half(self) * sin`.
12056///
12057/// Exposed as a free function so callers can share the tables across
12058/// many RoPE applications (e.g. every attention layer in a transformer
12059/// forward pass).
12060pub fn build_rope_tables(
12061    base: f64,
12062    start_pos: usize,
12063    seq: usize,
12064    head_dim: usize,
12065) -> (Vec<f32>, Vec<f32>) {
12066    assert!(
12067        head_dim.is_multiple_of(2),
12068        "build_rope_tables: head_dim {head_dim} must be even",
12069    );
12070    let half = head_dim / 2;
12071    let inv_freq: Vec<f64> = (0..half)
12072        .map(|i| base.powf(-2.0 * (i as f64) / (head_dim as f64)))
12073        .collect();
12074    build_rope_tables_with_inv_freq(&inv_freq, start_pos, seq, head_dim)
12075}
12076
12077/// [`build_rope_tables`] with the inverse frequencies supplied directly rather
12078/// than derived from a single `base`.
12079///
12080/// Every RoPE variant Fuel supports differs from the default in **exactly** this
12081/// vector: LLaMA-3.1's long-context scaling is a per-dimension transform of the
12082/// same `head_dim / 2` values, and the position loop below is bit-identical
12083/// either way. Splitting the frequency choice out is what lets a scaled model
12084/// reuse the whole decode path — the KV-cache forward, the held-graph persistent
12085/// forward, and their per-token rebinds — instead of needing a parallel copy of
12086/// each.
12087///
12088/// `inv_freq.len()` must be `head_dim / 2`.
12089pub fn build_rope_tables_with_inv_freq(
12090    inv_freq: &[f64],
12091    start_pos: usize,
12092    seq: usize,
12093    head_dim: usize,
12094) -> (Vec<f32>, Vec<f32>) {
12095    assert!(
12096        head_dim.is_multiple_of(2),
12097        "build_rope_tables_with_inv_freq: head_dim {head_dim} must be even",
12098    );
12099    let half = head_dim / 2;
12100    assert_eq!(
12101        inv_freq.len(),
12102        half,
12103        "build_rope_tables_with_inv_freq: inv_freq len {} != head_dim/2 {half}",
12104        inv_freq.len(),
12105    );
12106    let mut cos_data = vec![0.0_f32; seq * head_dim];
12107    let mut sin_data = vec![0.0_f32; seq * head_dim];
12108    for p in 0..seq {
12109        let pos = (start_pos + p) as f64;
12110        for (i, &freq) in inv_freq.iter().enumerate() {
12111            let theta = pos * freq;
12112            let c = theta.cos() as f32;
12113            let s = theta.sin() as f32;
12114            cos_data[p * head_dim + i] = c;
12115            cos_data[p * head_dim + i + half] = c;
12116            sin_data[p * head_dim + i] = s;
12117            sin_data[p * head_dim + i + half] = s;
12118        }
12119    }
12120    (cos_data, sin_data)
12121}
12122
12123/// Only float dtypes are supported here — integer/index tensors never
12124/// appear as gradients, so a `U32` call indicates a bug in a backward
12125/// rule rather than a missing feature.
12126///
12127/// Phase 7.5 G2: gradient consts are slot-populating Op::Const
12128/// nodes. Device is picked from any existing slot (the forward pass
12129/// always populated at least one slot-bearing leaf by the time
12130/// backward runs). For a CPU-rooted graph the device's
12131/// `storage_from_host_buffer_owned_dyn` is a zero-copy wrap; for
12132/// GPU-rooted, an H2D upload (matching today's eval_const path on
12133/// first realize).
12134fn build_filled_const(graph: &SharedGraph, shape: Shape, dtype: DType, value: f64) -> NodeId {
12135    let n = shape.elem_count();
12136    let buf = match dtype {
12137        DType::F32 => fuel_ir::HostBuffer::F32(vec![value as f32; n]),
12138        DType::F64 => fuel_ir::HostBuffer::F64(vec![value; n]),
12139        DType::BF16 => fuel_ir::HostBuffer::BF16(vec![bf16::from_f64(value); n]),
12140        DType::F16 => fuel_ir::HostBuffer::F16(vec![f16::from_f64(value); n]),
12141        other => panic!(
12142            "backward: build_filled_const: unsupported dtype {other:?} \
12143             (gradients are always floats — this would indicate a bug in \
12144             a backward rule that tried to differentiate through an \
12145             integer tensor)",
12146        ),
12147    };
12148    let device = pick_device_from_graph(graph);
12149    let backend_storage = device
12150        .storage_from_host_buffer_owned_dyn(buf)
12151        .expect("build_filled_const: storage_from_host_buffer_owned_dyn failed");
12152    let storage_arc = Arc::new(RwLock::new(Storage::from_dyn(backend_storage)));
12153    let id = push_node(graph, Op::Const, vec![], shape, dtype);
12154    graph.write().unwrap().set_storage(id, storage_arc);
12155    id
12156}
12157
12158/// G2 helper: walk the graph for any populated slot and return its
12159/// device. Used by gradient builders that don't have an explicit
12160/// device parameter — the graph always has at least one slot-bearing
12161/// Const leaf by the time backward() runs (the forward pass's inputs
12162/// are slot-rooted Const leaves), so this can be relied on.
12163fn pick_device_from_graph(graph: &SharedGraph) -> Arc<dyn fuel_backend_contract::DynBackendDevice> {
12164    let g = graph.read().unwrap();
12165    for i in 0..g.len() {
12166        if let Some(slot_arc) = g.storage_for(NodeId(i)) {
12167            return slot_arc.read().unwrap().device();
12168        }
12169    }
12170    panic!(
12171        "build_filled_const: graph has no populated storage slots; backward \
12172         requires at least one slot-bearing Const leaf to pick a gradient \
12173         device from. Was the forward pass built with G2-migrated \
12174         constructors (from_f32/etc. with explicit &Device)?"
12175    )
12176}
12177
12178/// Accumulate a new gradient contribution into the upstream map.
12179///
12180/// If `forward_id` already has an upstream gradient, emit an `Add` node to
12181/// sum the two contributions. If not, record the new one as-is. This is
12182/// the multi-variable chain rule in its operational form: when a forward
12183/// node has multiple downstream consumers, the gradients from each must
12184/// be summed.
12185fn accumulate_grad(
12186    upstream: &mut HashMap<NodeId, NodeId>,
12187    forward_id: NodeId,
12188    new_grad_id: NodeId,
12189    graph: &SharedGraph,
12190) {
12191    match upstream.get(&forward_id).copied() {
12192        None => {
12193            upstream.insert(forward_id, new_grad_id);
12194        }
12195        Some(existing_id) => {
12196            let shape = node_shape(graph, forward_id);
12197            let dtype = node_dtype(graph, forward_id);
12198            let combined = push_node(graph, Op::Add, vec![existing_id, new_grad_id], shape, dtype);
12199            upstream.insert(forward_id, combined);
12200        }
12201    }
12202}
12203
12204#[cfg(test)]
12205mod tests {
12206    use super::*;
12207
12208    /// Phase 7.5 G2: tests need a real device to allocate slot
12209    /// storage through the new constructor API. `cpu_dev()` returns a
12210    /// stable singleton CpuBackendDevice handle so every call site can
12211    /// just pass `cpu_dev()` without per-test boilerplate.
12212    fn cpu_dev() -> &'static Arc<dyn fuel_backend_contract::DynBackendDevice> {
12213        static D: std::sync::OnceLock<Arc<dyn fuel_backend_contract::DynBackendDevice>> =
12214            std::sync::OnceLock::new();
12215        D.get_or_init(|| Arc::new(fuel_cpu_backend::dyn_impl::CpuBackendDevice))
12216    }
12217
12218    /// Operands for a `[2, 64]` activation, `N = 8`, `group_size = 64`
12219    /// (`zeros_w = 2`): weight `[8, 8]` U32, zeros `[8, 2]` U32, scales
12220    /// `[8, 16]` of `scale_dtype`-compatible data.
12221    fn awq_builder_operands(
12222        act: &NodeHandle,
12223        weight_f32: bool,
12224    ) -> (NodeHandle, NodeHandle, NodeHandle) {
12225        let weight = if weight_f32 {
12226            act.const_f32_like(vec![0.0_f32; 64], Shape::from_dims(&[8, 8]))
12227                .unwrap()
12228        } else {
12229            act.const_u32_like(vec![0u32; 64], Shape::from_dims(&[8, 8]))
12230                .unwrap()
12231        };
12232        let zeros = act
12233            .const_u32_like(vec![0u32; 16], Shape::from_dims(&[8, 2]))
12234            .unwrap();
12235        let scales = act
12236            .const_f16_like(vec![f16::ONE; 128], Shape::from_dims(&[8, 16]))
12237            .unwrap();
12238        (weight, zeros, scales)
12239    }
12240
12241    /// `Tensor::awq_matmul`'s public builder end-to-end: a well-formed call
12242    /// produces a `Fused(AWQ_MATMUL, ..)` node with the right output
12243    /// shape/dtype. The decompose MATH is tested at the registry level
12244    /// (`registry::awq_matmul::tests`); this just confirms the builder's
12245    /// own validation + node construction work from the public API.
12246    #[test]
12247    fn awq_matmul_builder_produces_expected_node() {
12248        let act =
12249            NodeHandle::from_f32(vec![0.0_f32; 2 * 64], Shape::from_dims(&[2, 64]), cpu_dev())
12250                .unwrap();
12251        let (weight, zeros, scales) = awq_builder_operands(&act, false);
12252        let out = act.awq_matmul(&weight, &zeros, &scales, 64, 8);
12253        assert_eq!(out.shape().dims(), &[2, 8]);
12254        assert_eq!(out.dtype(), DType::F32);
12255        assert!(matches!(
12256            out.graph.read().unwrap().node(out.id).op,
12257            Op::Fused(id, _) if id == crate::registry::FusedOps::AWQ_MATMUL
12258        ));
12259    }
12260
12261    /// Born-red: a non-U32 `weight` must panic with a clear message, not
12262    /// silently misinterpret bytes.
12263    #[test]
12264    #[should_panic(expected = "weight must be U32")]
12265    fn awq_matmul_builder_panics_on_wrong_weight_dtype() {
12266        let act =
12267            NodeHandle::from_f32(vec![0.0_f32; 2 * 64], Shape::from_dims(&[2, 64]), cpu_dev())
12268                .unwrap();
12269        let (weight, zeros, scales) = awq_builder_operands(&act, true);
12270        let _ = act.awq_matmul(&weight, &zeros, &scales, 64, 8);
12271    }
12272
12273    /// Born-red: F32 `scales` (the previous layout) must be refused, since the
12274    /// kernel reads F16.
12275    #[test]
12276    #[should_panic(expected = "scales must be F16")]
12277    fn awq_matmul_builder_panics_on_f32_scales() {
12278        let act =
12279            NodeHandle::from_f32(vec![0.0_f32; 2 * 64], Shape::from_dims(&[2, 64]), cpu_dev())
12280                .unwrap();
12281        let (weight, zeros, _) = awq_builder_operands(&act, false);
12282        let scales = act
12283            .const_f32_like(vec![1.0_f32; 128], Shape::from_dims(&[8, 16]))
12284            .unwrap();
12285        let _ = act.awq_matmul(&weight, &zeros, &scales, 64, 8);
12286    }
12287
12288    /// Born-red: `zeros` at the un-padded width (`ceil(groups / 8) = 1`, the
12289    /// kernel pads to 2) must be refused.
12290    #[test]
12291    #[should_panic(expected = "zeros [8, 1] must be")]
12292    fn awq_matmul_builder_panics_on_unpadded_zeros() {
12293        let act =
12294            NodeHandle::from_f32(vec![0.0_f32; 2 * 64], Shape::from_dims(&[2, 64]), cpu_dev())
12295                .unwrap();
12296        let (weight, _, scales) = awq_builder_operands(&act, false);
12297        let zeros = act
12298            .const_u32_like(vec![0u32; 8], Shape::from_dims(&[8, 1]))
12299            .unwrap();
12300        let _ = act.awq_matmul(&weight, &zeros, &scales, 64, 8);
12301    }
12302
12303    #[test]
12304    fn builders_agree_with_primitive_shape() {
12305        // No-drift guard (Convergence A, Task 6): every routed builder's output
12306        // shape/dtype must equal primitive_shape's answer for the same Op. If a
12307        // builder's inline math ever diverged from primitive_shape, this fails.
12308        let x = NodeHandle::from_f32(
12309            vec![0.0_f32; 2 * 3 * 4],
12310            Shape::from_dims(&[2, 3, 4]),
12311            cpu_dev(),
12312        )
12313        .unwrap();
12314        let checks: Vec<(NodeHandle, Op)> = vec![
12315            (
12316                x.slice(1, 0, 2),
12317                Op::Slice {
12318                    dim: 1,
12319                    start: 0,
12320                    len: 2,
12321                },
12322            ),
12323            (x.mean_dim(2), Op::MeanDim(2)),
12324            (
12325                x.try_permute(&[2, 0, 1]).unwrap(),
12326                Op::Permute(vec![2, 0, 1]),
12327            ),
12328            (x.cast(DType::F16), Op::Cast(DType::F16)),
12329        ];
12330        for (t, op) in checks {
12331            let (ps_shape, ps_dtype) =
12332                crate::shape::primitive_shape(&op, &[x.shape()], &[x.dtype()]).unwrap();
12333            assert_eq!(t.shape(), ps_shape, "shape drift for {op:?}");
12334            assert_eq!(t.dtype(), ps_dtype, "dtype drift for {op:?}");
12335        }
12336    }
12337
12338    #[test]
12339    fn destructive_input_release_is_some_zero() {
12340        assert_eq!(Op::Release.destructive_input(), Some(0));
12341    }
12342
12343    /// Phase 7.5 storage-unification B2: target_backend side-table
12344    /// is sparse — empty by default, set explicitly per node.
12345    #[test]
12346    fn target_backend_side_table_starts_empty() {
12347        let mut g = Graph::new();
12348        let id = g.push(Node {
12349            op: Op::Const,
12350            inputs: vec![],
12351            shape: Shape::from_dims(&[1]),
12352            dtype: DType::F32,
12353        });
12354        assert_eq!(g.target_backend(id), None);
12355        assert_eq!(g.target_backend_count(), 0);
12356    }
12357
12358    /// set_target_backend then read returns the same value.
12359    #[test]
12360    fn target_backend_set_and_read() {
12361        let mut g = Graph::new();
12362        let id = g.push(Node {
12363            op: Op::Const,
12364            inputs: vec![],
12365            shape: Shape::from_dims(&[1]),
12366            dtype: DType::F32,
12367        });
12368        g.set_target_backend(id, BackendId::Cpu);
12369        assert_eq!(g.target_backend(id), Some(BackendId::Cpu));
12370        assert_eq!(g.target_backend_count(), 1);
12371    }
12372
12373    /// set_target_backend overwrites prior value.
12374    #[test]
12375    fn target_backend_overwrite() {
12376        let mut g = Graph::new();
12377        let id = g.push(Node {
12378            op: Op::Const,
12379            inputs: vec![],
12380            shape: Shape::from_dims(&[1]),
12381            dtype: DType::F32,
12382        });
12383        g.set_target_backend(id, BackendId::Cpu);
12384        g.set_target_backend(id, BackendId::Cuda);
12385        assert_eq!(g.target_backend(id), Some(BackendId::Cuda));
12386        assert_eq!(g.target_backend_count(), 1);
12387    }
12388
12389    /// Layouts side-table: default fallback returns
12390    /// `Layout::contiguous(node.shape)` for any node without an
12391    /// explicit entry.
12392    #[test]
12393    fn layout_default_is_contiguous_over_node_shape() {
12394        let mut g = Graph::new();
12395        let id = g.push(Node {
12396            op: Op::Const,
12397            inputs: vec![],
12398            shape: Shape::from_dims(&[2, 3]),
12399            dtype: DType::F32,
12400        });
12401        assert!(!g.has_explicit_layout(id));
12402        assert_eq!(g.explicit_layout_count(), 0);
12403        let l = g.layout(id);
12404        assert_eq!(l.shape().dims(), &[2, 3]);
12405        assert!(l.is_contiguous());
12406        assert_eq!(l.start_offset(), 0);
12407    }
12408
12409    /// set_layout records a strided Layout (e.g. for a transpose
12410    /// view). Subsequent reads return the explicit entry.
12411    #[test]
12412    fn layout_explicit_strided_is_remembered() {
12413        let mut g = Graph::new();
12414        let id = g.push(Node {
12415            op: Op::Transpose,
12416            inputs: vec![],
12417            shape: Shape::from_dims(&[3, 2]),
12418            dtype: DType::F32,
12419        });
12420        // Strided view: shape stays [3, 2] (the post-transpose view),
12421        // strides are [1, 3] (row-of-output is column-of-source).
12422        let l = Layout::new(
12423            Shape::from_dims(&[3, 2]),
12424            fuel_ir::StrideVec::from_slice(&[1_isize, 3]),
12425            0,
12426        );
12427        g.set_layout(id, l);
12428        assert!(g.has_explicit_layout(id));
12429        assert_eq!(g.explicit_layout_count(), 1);
12430        assert_eq!(g.layout(id).stride(), &[1, 3]);
12431        assert!(!g.layout(id).is_contiguous());
12432    }
12433
12434    /// set_layout twice with different layouts overwrites.
12435    #[test]
12436    fn layout_set_overwrites() {
12437        use fuel_ir::StrideVec;
12438        let mut g = Graph::new();
12439        let id = g.push(Node {
12440            op: Op::Transpose,
12441            inputs: vec![],
12442            shape: Shape::from_dims(&[2]),
12443            dtype: DType::F32,
12444        });
12445        g.set_layout(id, Layout::contiguous(Shape::from_dims(&[2])));
12446        g.set_layout(
12447            id,
12448            Layout::new(Shape::from_dims(&[2]), StrideVec::from_slice(&[3_isize]), 1),
12449        );
12450        assert_eq!(g.layout(id).start_offset(), 1);
12451        assert_eq!(g.explicit_layout_count(), 1);
12452    }
12453
12454    /// Phase 7.5 work item G — empty-map invariants. End-to-end
12455    /// tests of the storage map (with real `Storage` values) live in
12456    /// fuel-core's `tensor::node_handle_tests`, since constructing a
12457    /// real `Storage` requires a `DynBackendStorage` implementation
12458    /// from a backend crate, and fuel-graph deliberately depends only
12459    /// on fuel-core-types.
12460    #[test]
12461    fn graph_storage_map_starts_empty() {
12462        let g = Graph::new();
12463        assert_eq!(g.storage_len(), 0);
12464        assert!(!g.has_storage(NodeId(0)));
12465        assert!(g.storage_for(NodeId(0)).is_none());
12466    }
12467
12468    #[test]
12469    fn conv2d_builder_emits_conv2d_node_with_right_shape() {
12470        // k=3 s=1 p=1 keeps H and W.
12471        let x = NodeHandle::from_f32(
12472            vec![0.0_f32; 2 * 4 * 4],
12473            Shape::from_dims(&[1, 2, 4, 4]),
12474            cpu_dev(),
12475        )
12476        .unwrap();
12477        let w = x
12478            .const_f32_like(
12479                vec![0.0_f32; 3 * 2 * 3 * 3],
12480                Shape::from_dims(&[3, 2, 3, 3]),
12481            )
12482            .unwrap();
12483        let b = x
12484            .const_f32_like(vec![0.0_f32; 3], Shape::from_dims(&[3]))
12485            .unwrap();
12486        let y = x.conv2d(&w, Some(&b), (1, 1), (1, 1), 1);
12487        assert_eq!(y.shape().dims(), &[1, 3, 4, 4]);
12488    }
12489
12490    #[test]
12491    fn conv2d_builder_stride_and_no_padding() {
12492        // k=3 s=2 p=0 on H=W=8 gives (8-3)/2+1 = 3.
12493        let x = NodeHandle::from_f32(
12494            vec![0.0_f32; 2 * 8 * 8],
12495            Shape::from_dims(&[1, 2, 8, 8]),
12496            cpu_dev(),
12497        )
12498        .unwrap();
12499        let w = x
12500            .const_f32_like(
12501                vec![0.0_f32; 4 * 2 * 3 * 3],
12502                Shape::from_dims(&[4, 2, 3, 3]),
12503            )
12504            .unwrap();
12505        let y = x.conv2d(&w, None, (2, 2), (0, 0), 1);
12506        assert_eq!(y.shape().dims(), &[1, 4, 3, 3]);
12507    }
12508
12509    #[test]
12510    fn conv2d_builder_depthwise_groups() {
12511        // groups=Cin=Cout=4 is the depthwise case. Weight per channel is [Cin/groups=1, kH, kW].
12512        let x = NodeHandle::from_f32(
12513            vec![0.0_f32; 4 * 4 * 4],
12514            Shape::from_dims(&[1, 4, 4, 4]),
12515            cpu_dev(),
12516        )
12517        .unwrap();
12518        let w = x
12519            .const_f32_like(vec![0.0_f32; 4 * 3 * 3], Shape::from_dims(&[4, 1, 3, 3]))
12520            .unwrap();
12521        let y = x.conv2d(&w, None, (1, 1), (1, 1), 4);
12522        assert_eq!(y.shape().dims(), &[1, 4, 4, 4]);
12523    }
12524
12525    #[test]
12526    fn conv_transpose2d_builder_emits_node_with_right_shape() {
12527        // Hin=4, Kh=3, s=2, pad=1, out_pad=1 → Hout = (4-1)*2 + (3-1) + 1 + 1 - 2 = 8.
12528        let x = NodeHandle::from_f32(
12529            vec![0.0_f32; 2 * 4 * 4],
12530            Shape::from_dims(&[1, 2, 4, 4]),
12531            cpu_dev(),
12532        )
12533        .unwrap();
12534        let w = x
12535            .const_f32_like(
12536                vec![0.0_f32; 2 * 3 * 3 * 3],
12537                Shape::from_dims(&[2, 3, 3, 3]),
12538            )
12539            .unwrap();
12540        let y = x.conv_transpose2d(&w, (2, 2), (1, 1), (1, 1), (1, 1), 1);
12541        assert_eq!(y.shape().dims(), &[1, 3, 8, 8]);
12542    }
12543
12544    #[test]
12545    fn conv_transpose1d_builder_shape_stride_2_pad_1() {
12546        // Lin=4, K=3, s=2, pad=1, out_pad=1, dil=1
12547        // Lout = (4-1)*2 + (3-1) + 1 + 1 - 2 = 8.
12548        let x = NodeHandle::from_f32(
12549            vec![0.0_f32; 2 * 4],
12550            Shape::from_dims(&[1, 2, 4]),
12551            cpu_dev(),
12552        )
12553        .unwrap();
12554        let w = x
12555            .const_f32_like(vec![0.0_f32; 2 * 3 * 3], Shape::from_dims(&[2, 3, 3]))
12556            .unwrap();
12557        let y = x.conv_transpose1d(&w, 2, 1, 1, 1, 1);
12558        assert_eq!(y.shape().dims(), &[1, 3, 8]);
12559    }
12560
12561    #[test]
12562    fn conv_transpose1d_builder_shape_stride_4_no_pad() {
12563        // Lin=2, K=4, s=4, pad=0, out_pad=0, dil=1
12564        // Lout = (2-1)*4 + (4-1) + 0 + 1 - 0 = 8.
12565        let x = NodeHandle::from_f32(vec![0.0_f32; 2], Shape::from_dims(&[1, 1, 2]), cpu_dev())
12566            .unwrap();
12567        let w = x
12568            .const_f32_like(vec![0.0_f32; 4], Shape::from_dims(&[1, 1, 4]))
12569            .unwrap();
12570        let y = x.conv_transpose1d(&w, 4, 0, 0, 1, 1);
12571        assert_eq!(y.shape().dims(), &[1, 1, 8]);
12572    }
12573
12574    #[test]
12575    fn conv_transpose1d_builder_with_groups() {
12576        // groups=2: input Cin=4 splits into 2 groups of 2; weight
12577        // Cin=4 first dim matches input; Cout/group=3 → total Cout=6.
12578        // Lin=3, K=3, s=1, pad=0, out_pad=0, dil=1 → Lout = (3-1)*1 + 2 + 0 + 1 = 5.
12579        let x = NodeHandle::from_f32(
12580            vec![0.0_f32; 4 * 3],
12581            Shape::from_dims(&[1, 4, 3]),
12582            cpu_dev(),
12583        )
12584        .unwrap();
12585        let w = x
12586            .const_f32_like(vec![0.0_f32; 4 * 3 * 3], Shape::from_dims(&[4, 3, 3]))
12587            .unwrap();
12588        let y = x.conv_transpose1d(&w, 1, 0, 0, 1, 2);
12589        assert_eq!(y.shape().dims(), &[1, 6, 5]);
12590    }
12591
12592    #[test]
12593    fn conv2d_backward_grads_have_input_shapes() {
12594        // Forward Y = conv2d(X, W) with stride=1, pad=1, groups=1 keeps H,W.
12595        // Backward should produce dX with X's shape and dW with W's shape.
12596        let x = NodeHandle::from_f32(
12597            (0..(2 * 4 * 4))
12598                .map(|i| (i as f32) * 0.05 - 0.5)
12599                .collect::<Vec<f32>>(),
12600            Shape::from_dims(&[1, 2, 4, 4]),
12601            cpu_dev(),
12602        )
12603        .unwrap();
12604        let w = x
12605            .const_f32_like(
12606                (0..(3 * 2 * 3 * 3))
12607                    .map(|i| (i as f32) * 0.07 - 0.4)
12608                    .collect::<Vec<f32>>(),
12609                Shape::from_dims(&[3, 2, 3, 3]),
12610            )
12611            .unwrap();
12612        let y = x.conv2d(&w, None, (1, 1), (1, 1), 1);
12613        let scalar_out = y.sum_all();
12614        let grads = scalar_out.backward();
12615        let dx = grads.get(&x).expect("conv2d backward produced no dX");
12616        let dw = grads.get(&w).expect("conv2d backward produced no dW");
12617        assert_eq!(dx.shape().dims(), x.shape().dims());
12618        assert_eq!(dw.shape().dims(), w.shape().dims());
12619    }
12620
12621    #[test]
12622    fn destructive_input_move_is_some_zero() {
12623        assert_eq!(
12624            Op::Move {
12625                target: DeviceLocation::Cpu
12626            }
12627            .destructive_input(),
12628            Some(0)
12629        );
12630    }
12631
12632    #[test]
12633    fn destructive_input_non_destructive_ops_are_none() {
12634        assert_eq!(
12635            Op::Copy {
12636                target: DeviceLocation::Cpu
12637            }
12638            .destructive_input(),
12639            None
12640        );
12641        assert_eq!(Op::Add.destructive_input(), None);
12642        assert_eq!(Op::Mul.destructive_input(), None);
12643        assert_eq!(Op::MatMul.destructive_input(), None);
12644        assert_eq!(Op::Relu.destructive_input(), None);
12645    }
12646
12647    #[test]
12648    fn move_to_device_emits_op_move_node() {
12649        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
12650        let moved = a.move_to_device(DeviceLocation::Vulkan { gpu_id: 0 });
12651        let g = moved.graph().read().unwrap();
12652        match &g.node(moved.id()).op {
12653            Op::Move { target } => {
12654                assert_eq!(*target, DeviceLocation::Vulkan { gpu_id: 0 });
12655            }
12656            other => panic!("expected Op::Move, got {other:?}"),
12657        }
12658        assert_eq!(g.node(moved.id()).inputs, vec![a.id()]);
12659        // Output has the input's shape (residency changes, data doesn't).
12660        assert_eq!(g.node(moved.id()).shape.elem_count(), 2);
12661    }
12662
12663    #[test]
12664    fn copy_to_device_emits_op_copy_node() {
12665        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
12666        let b = a.copy_to_device(DeviceLocation::Vulkan { gpu_id: 0 });
12667        let g = b.graph().read().unwrap();
12668        match &g.node(b.id()).op {
12669            Op::Copy { target } => {
12670                assert_eq!(*target, DeviceLocation::Vulkan { gpu_id: 0 });
12671            }
12672            other => panic!("expected Op::Copy, got {other:?}"),
12673        }
12674        // Source stays resident: the original `a` node still in the graph
12675        // and is the input of the Copy node.
12676        assert_eq!(g.node(b.id()).inputs, vec![a.id()]);
12677    }
12678
12679    #[test]
12680    fn release_builder_emits_op_release_node() {
12681        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
12682        let released = a.release();
12683        let g = released.graph().read().unwrap();
12684        assert!(matches!(g.node(released.id()).op, Op::Release));
12685        assert_eq!(g.node(released.id()).inputs, vec![a.id()]);
12686        // Output is a zero-element marker.
12687        assert_eq!(g.node(released.id()).shape.elem_count(), 0);
12688    }
12689
12690    #[test]
12691    fn placement_is_none_by_default() {
12692        let a =
12693            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12694        assert_eq!(a.placement(), None);
12695    }
12696
12697    #[test]
12698    fn on_device_sets_placement_hint() {
12699        let a =
12700            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12701        let b = a
12702            .const_f32_like(vec![4.0, 5.0, 6.0], Shape::from_dims(&[3]))
12703            .unwrap();
12704        // Only tag the Add node; the const leaves remain unplaced.
12705        let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
12706        assert_eq!(c.placement(), Some(DeviceLocation::Vulkan { gpu_id: 0 }));
12707        assert_eq!(a.placement(), None);
12708        assert_eq!(b.placement(), None);
12709    }
12710
12711    #[test]
12712    fn placement_survives_graph_re_reads() {
12713        let a = NodeHandle::from_f32(vec![1.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
12714        let tagged = a.clone().on_device(DeviceLocation::Cpu);
12715        // Re-read from a fresh borrow — round-trips through the side-table.
12716        assert_eq!(
12717            tagged.graph().read().unwrap().placement(tagged.id()),
12718            Some(DeviceLocation::Cpu)
12719        );
12720    }
12721
12722    #[test]
12723    fn from_f32_creates_single_const_node() {
12724        let a =
12725            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12726        assert_eq!(a.graph().read().unwrap().len(), 1);
12727        assert_eq!(a.shape().dims(), &[3]);
12728        assert_eq!(a.dtype(), DType::F32);
12729        let node = a.graph().read().unwrap().node(a.id()).clone();
12730        // Phase 7.5 G2: factory now emits Op::Const + slot.
12731        assert!(matches!(node.op, Op::Const));
12732        assert!(node.inputs.is_empty());
12733        // The slot is populated with F32 storage.
12734        let slot = a.graph().read().unwrap().storage_for(a.id()).unwrap();
12735        assert_eq!(slot.read().unwrap().dtype(), DType::F32);
12736    }
12737
12738    #[test]
12739    fn add_appends_a_node_and_tracks_inputs() {
12740        let a =
12741            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12742        let b = a
12743            .const_f32_like(vec![4.0, 5.0, 6.0], Shape::from_dims(&[3]))
12744            .unwrap();
12745        let c = a.add(&b);
12746        assert_eq!(c.graph().read().unwrap().len(), 3); // const, const, add
12747        let node = c.graph().read().unwrap().node(c.id()).clone();
12748        assert!(matches!(node.op, Op::Add));
12749        assert_eq!(node.inputs.len(), 2);
12750        assert_eq!(node.inputs[0], a.id());
12751        assert_eq!(node.inputs[1], b.id());
12752        assert_eq!(c.shape().dims(), &[3]);
12753    }
12754
12755    #[test]
12756    fn chained_ops_all_share_one_graph() {
12757        let a =
12758            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12759        let b = a
12760            .const_f32_like(vec![4.0, 5.0, 6.0], Shape::from_dims(&[3]))
12761            .unwrap();
12762        let c = a.add(&b).mul(&a).sqr().relu();
12763        assert_eq!(c.graph().read().unwrap().len(), 6); // 2 consts + add + mul + sqr + relu
12764        assert_eq!(c.shape().dims(), &[3]);
12765    }
12766
12767    #[test]
12768    fn matmul_validates_shapes_and_produces_correct_output_shape() {
12769        let a = NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
12770        let b = a
12771            .const_f32_like(vec![1.0; 12], Shape::from_dims(&[3, 4]))
12772            .unwrap();
12773        let c = a.matmul(&b);
12774        assert_eq!(c.shape().dims(), &[2, 4]);
12775    }
12776
12777    #[test]
12778    #[should_panic(expected = "shape mismatch")]
12779    fn add_panics_on_shape_mismatch() {
12780        let a =
12781            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12782        let b = a
12783            .const_f32_like(vec![1.0, 2.0], Shape::from_dims(&[2]))
12784            .unwrap();
12785        let _ = a.add(&b);
12786    }
12787
12788    #[test]
12789    #[should_panic(expected = "inner dim mismatch")]
12790    fn matmul_panics_on_inner_dim_mismatch() {
12791        let a = NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
12792        let b = a
12793            .const_f32_like(vec![1.0; 8], Shape::from_dims(&[4, 2]))
12794            .unwrap();
12795        let _ = a.matmul(&b);
12796    }
12797
12798    #[test]
12799    #[should_panic(expected = "must live on the same graph")]
12800    fn cross_graph_op_is_rejected() {
12801        let a =
12802            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12803        let b =
12804            NodeHandle::from_f32(vec![4.0, 5.0, 6.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12805        let _ = a.add(&b);
12806    }
12807
12808    // ----- graph identity + graph-scoped construction -----
12809    //
12810    // Two changes land together: (1) every Graph carries a monotonic GraphId so
12811    // an affinity failure can name WHICH graphs rather than only that they
12812    // differ; (2) constructors accept an existing graph, so a consumer never has
12813    // to discover `const_*_like` to build a second tensor.
12814
12815    #[test]
12816    fn each_new_graph_gets_a_distinct_id() {
12817        let a = NodeHandle::from_f32(vec![1.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
12818        let b = NodeHandle::from_f32(vec![2.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
12819        assert_ne!(a.graph_id(), b.graph_id());
12820    }
12821
12822    #[test]
12823    fn same_graph_tensors_share_one_id() {
12824        let a = NodeHandle::from_f32(vec![1.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
12825        let b = a.const_f32_like(vec![2.0], Shape::from_dims(&[1])).unwrap();
12826        assert_eq!(a.graph_id(), b.graph_id());
12827    }
12828
12829    #[test]
12830    fn affinity_panic_names_both_graph_ids() {
12831        let a = NodeHandle::from_f32(vec![1.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
12832        let b = NodeHandle::from_f32(vec![2.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
12833        let (ia, ib) = (a.graph_id(), b.graph_id());
12834        let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| a.add(&b)))
12835            .expect_err("cross-graph add must panic");
12836        let text = err
12837            .downcast_ref::<String>()
12838            .cloned()
12839            .or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string()))
12840            .expect("panic payload is a string");
12841        assert!(
12842            text.contains(&format!("#{ia}")) && text.contains(&format!("#{ib}")),
12843            "affinity panic must name BOTH graph ids so the caller can see it made two; got: {text}",
12844        );
12845    }
12846
12847    #[test]
12848    fn from_f32_on_builds_into_the_given_graph() {
12849        let root =
12850            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12851        let w = NodeHandle::from_f32_on(
12852            root.graph(),
12853            vec![4.0, 5.0, 6.0],
12854            Shape::from_dims(&[3]),
12855            cpu_dev(),
12856        )
12857        .unwrap();
12858        assert_eq!(root.graph_id(), w.graph_id());
12859        assert!(Arc::ptr_eq(root.graph(), w.graph()));
12860    }
12861
12862    #[test]
12863    fn graph_scoped_construction_composes_without_an_anchor() {
12864        // The program that could not previously be written: build two tensors
12865        // and combine them, without discovering `const_*_like`.
12866        let g: SharedGraph = Arc::new(RwLock::new(Graph::new()));
12867        let a = NodeHandle::from_f32_on(&g, vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev())
12868            .unwrap();
12869        let b = NodeHandle::from_f32_on(&g, vec![1.0; 6], Shape::from_dims(&[3, 2]), cpu_dev())
12870            .unwrap();
12871        let y = a.matmul(&b);
12872        assert_eq!(y.shape().dims(), &[2, 2]);
12873        assert_eq!(y.graph_id(), a.graph_id());
12874    }
12875
12876    #[test]
12877    fn graph_scoped_constructors_cover_every_dtype() {
12878        let g: SharedGraph = Arc::new(RwLock::new(Graph::new()));
12879        let s = Shape::from_dims(&[1]);
12880        let f32t = NodeHandle::from_f32_on(&g, vec![1.0f32], s.clone(), cpu_dev()).unwrap();
12881        let f64t = NodeHandle::from_f64_on(&g, vec![1.0f64], s.clone(), cpu_dev()).unwrap();
12882        let bf16t =
12883            NodeHandle::from_bf16_on(&g, vec![half::bf16::from_f32(1.0)], s.clone(), cpu_dev())
12884                .unwrap();
12885        let f16t =
12886            NodeHandle::from_f16_on(&g, vec![half::f16::from_f32(1.0)], s.clone(), cpu_dev())
12887                .unwrap();
12888        let u32t = NodeHandle::from_u32_on(&g, vec![1u32], s, cpu_dev()).unwrap();
12889        for t in [&f64t, &bf16t, &f16t, &u32t] {
12890            assert_eq!(
12891                t.graph_id(),
12892                f32t.graph_id(),
12893                "all must share the given graph"
12894            );
12895        }
12896        assert_eq!(f64t.dtype(), DType::F64);
12897        assert_eq!(bf16t.dtype(), DType::BF16);
12898        assert_eq!(f16t.dtype(), DType::F16);
12899        assert_eq!(u32t.dtype(), DType::U32);
12900    }
12901
12902    // ----- multi-dtype graph builders -----
12903
12904    #[test]
12905    fn from_f64_tags_node_with_f64_dtype() {
12906        let a =
12907            NodeHandle::from_f64(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12908        assert_eq!(a.dtype(), DType::F64);
12909        let node = a.graph().read().unwrap().node(a.id()).clone();
12910        // Phase 7.5 G2: slot-rooted Const, dtype validated via slot.
12911        assert!(matches!(node.op, Op::Const));
12912        let slot = a.graph().read().unwrap().storage_for(a.id()).unwrap();
12913        assert_eq!(slot.read().unwrap().dtype(), DType::F64);
12914    }
12915
12916    #[test]
12917    fn from_bf16_tags_node_with_bf16_dtype() {
12918        let a = NodeHandle::from_bf16(
12919            vec![bf16::from_f32(1.0), bf16::from_f32(2.0)],
12920            Shape::from_dims(&[2]),
12921            cpu_dev(),
12922        )
12923        .unwrap();
12924        assert_eq!(a.dtype(), DType::BF16);
12925        let node = a.graph().read().unwrap().node(a.id()).clone();
12926        assert!(matches!(node.op, Op::Const));
12927        let slot = a.graph().read().unwrap().storage_for(a.id()).unwrap();
12928        assert_eq!(slot.read().unwrap().dtype(), DType::BF16);
12929    }
12930
12931    #[test]
12932    fn from_f16_tags_node_with_f16_dtype() {
12933        let a = NodeHandle::from_f16(
12934            vec![f16::from_f32(1.0), f16::from_f32(2.0)],
12935            Shape::from_dims(&[2]),
12936            cpu_dev(),
12937        )
12938        .unwrap();
12939        assert_eq!(a.dtype(), DType::F16);
12940        let node = a.graph().read().unwrap().node(a.id()).clone();
12941        assert!(matches!(node.op, Op::Const));
12942        let slot = a.graph().read().unwrap().storage_for(a.id()).unwrap();
12943        assert_eq!(slot.read().unwrap().dtype(), DType::F16);
12944    }
12945
12946    #[test]
12947    #[should_panic(expected = "dtype mismatch")]
12948    fn add_panics_on_mixed_dtype() {
12949        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
12950        let b = a
12951            .const_f64_like(vec![1.0, 2.0], Shape::from_dims(&[2]))
12952            .unwrap();
12953        let _ = a.add(&b);
12954    }
12955
12956    // ----- transpose -----
12957
12958    #[test]
12959    fn transpose_swaps_shape_dims() {
12960        let a = NodeHandle::from_f32(
12961            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
12962            Shape::from_dims(&[2, 3]),
12963            cpu_dev(),
12964        )
12965        .unwrap();
12966        let t = a.transpose();
12967        assert_eq!(t.shape().dims(), &[3, 2]);
12968        let node = t.graph().read().unwrap().node(t.id()).clone();
12969        assert!(matches!(node.op, Op::Transpose));
12970        assert_eq!(node.inputs, vec![a.id()]);
12971    }
12972
12973    #[test]
12974    #[should_panic(expected = "rank ≥ 2")]
12975    fn transpose_rejects_rank_1() {
12976        let a =
12977            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
12978        let _ = a.transpose();
12979    }
12980
12981    #[test]
12982    fn transpose_on_rank_3_swaps_last_two_dims() {
12983        // [2, 3, 4] → [2, 4, 3]
12984        let a = NodeHandle::from_f32(vec![0.0_f32; 24], Shape::from_dims(&[2, 3, 4]), cpu_dev())
12985            .unwrap();
12986        let t = a.transpose();
12987        assert_eq!(t.shape().dims(), &[2, 4, 3]);
12988    }
12989
12990    // ----- additional builder validation tests -----
12991
12992    #[test]
12993    fn matmul_rank_3_batched_shape() {
12994        // [2, 3, 4] @ [2, 4, 5] → [2, 3, 5]
12995        let a =
12996            NodeHandle::from_f32(vec![0.0; 24], Shape::from_dims(&[2, 3, 4]), cpu_dev()).unwrap();
12997        let b = a
12998            .const_f32_like(vec![0.0; 40], Shape::from_dims(&[2, 4, 5]))
12999            .unwrap();
13000        let c = a.matmul(&b);
13001        assert_eq!(c.shape().dims(), &[2, 3, 5]);
13002    }
13003
13004    #[test]
13005    #[should_panic(expected = "batch dim mismatch")]
13006    fn matmul_rank_3_rejects_batch_dim_mismatch() {
13007        let a =
13008            NodeHandle::from_f32(vec![0.0; 24], Shape::from_dims(&[2, 3, 4]), cpu_dev()).unwrap();
13009        let b = a
13010            .const_f32_like(vec![0.0; 60], Shape::from_dims(&[3, 4, 5]))
13011            .unwrap();
13012        let _ = a.matmul(&b);
13013    }
13014
13015    #[test]
13016    fn matmul_auto_broadcasts_rank_2_rhs_against_batched_lhs() {
13017        // [batch=2, seq=3, k=4] @ [k=4, n=5] → [2, 3, 5]. This is the
13018        // canonical "linear layer across a batch" pattern and should
13019        // Just Work without an explicit broadcast_to on the RHS.
13020        let a =
13021            NodeHandle::from_f32(vec![0.0; 24], Shape::from_dims(&[2, 3, 4]), cpu_dev()).unwrap();
13022        let b = a
13023            .const_f32_like(vec![0.0; 20], Shape::from_dims(&[4, 5]))
13024            .unwrap();
13025        let c = a.matmul(&b);
13026        assert_eq!(c.shape().dims(), &[2, 3, 5]);
13027    }
13028
13029    #[test]
13030    fn matmul_auto_broadcasts_rank_2_lhs_against_batched_rhs() {
13031        // [m=3, k=4] @ [batch=2, k=4, n=5] → [2, 3, 5].
13032        let a = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13033        let b = a
13034            .const_f32_like(vec![0.0; 40], Shape::from_dims(&[2, 4, 5]))
13035            .unwrap();
13036        let c = a.matmul(&b);
13037        assert_eq!(c.shape().dims(), &[2, 3, 5]);
13038    }
13039
13040    #[test]
13041    fn concat_output_shape_sums_along_dim() {
13042        // [2, 3] concat [2, 4] along dim 1 → [2, 7]
13043        let a = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13044        let b = a
13045            .const_f32_like(vec![0.0; 8], Shape::from_dims(&[2, 4]))
13046            .unwrap();
13047        let c = a.concat(&b, 1);
13048        assert_eq!(c.shape().dims(), &[2, 7]);
13049    }
13050
13051    #[test]
13052    #[should_panic(expected = "non-dim shapes")]
13053    fn concat_rejects_nondim_shape_mismatch() {
13054        let a = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13055        let b = a
13056            .const_f32_like(vec![0.0; 12], Shape::from_dims(&[3, 4]))
13057            .unwrap();
13058        let _ = a.concat(&b, 1);
13059    }
13060
13061    #[test]
13062    #[should_panic(expected = "rank mismatch")]
13063    fn concat_rejects_rank_mismatch() {
13064        let a = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13065        let b = a
13066            .const_f32_like(vec![0.0; 6], Shape::from_dims(&[6]))
13067            .unwrap();
13068        let _ = a.concat(&b, 0);
13069    }
13070
13071    #[test]
13072    fn slice_shrinks_only_the_slice_dim() {
13073        // [3, 4] slice dim 1, start 1, len 2 → [3, 2]
13074        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13075        let s = x.slice(1, 1, 2);
13076        assert_eq!(s.shape().dims(), &[3, 2]);
13077    }
13078
13079    #[test]
13080    #[should_panic(expected = "exceeds dim size")]
13081    fn slice_rejects_out_of_bounds_range() {
13082        let x = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13083        let _ = x.slice(1, 1, 3); // start=1, len=3 → would need dim>=4
13084    }
13085
13086    #[test]
13087    fn broadcast_add_shape_promotes_to_common_shape() {
13088        // [4, 1] + [1, 3] → [4, 3]
13089        let a = NodeHandle::from_f32(vec![0.0; 4], Shape::from_dims(&[4, 1]), cpu_dev()).unwrap();
13090        let b = a
13091            .const_f32_like(vec![0.0; 3], Shape::from_dims(&[1, 3]))
13092            .unwrap();
13093        let c = a.broadcast_add(&b);
13094        assert_eq!(c.shape().dims(), &[4, 3]);
13095    }
13096
13097    #[test]
13098    fn broadcast_sub_pads_shorter_shape_with_leading_ones() {
13099        // [3] - [2, 3] → [2, 3]
13100        let a = NodeHandle::from_f32(vec![0.0; 3], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13101        let b = a
13102            .const_f32_like(vec![0.0; 6], Shape::from_dims(&[2, 3]))
13103            .unwrap();
13104        let c = a.broadcast_sub(&b);
13105        assert_eq!(c.shape().dims(), &[2, 3]);
13106    }
13107
13108    #[test]
13109    #[should_panic(expected = "incompatible shapes")]
13110    fn broadcast_add_rejects_incompatible_shapes() {
13111        let a = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13112        let b = a
13113            .const_f32_like(vec![0.0; 8], Shape::from_dims(&[2, 4]))
13114            .unwrap();
13115        let _ = a.broadcast_add(&b);
13116    }
13117
13118    #[test]
13119    fn argmax_dim_is_u32_and_removes_reduced_dim() {
13120        let x = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13121        let am = x.argmax_dim(1);
13122        assert_eq!(am.dtype(), DType::U32);
13123        assert_eq!(am.shape().dims(), &[2]);
13124    }
13125
13126    #[test]
13127    #[should_panic(expected = "out of bounds")]
13128    fn argmax_dim_rejects_bad_dim() {
13129        let x = NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13130        let _ = x.argmax_dim(5);
13131    }
13132
13133    #[test]
13134    fn index_add_shape_validation() {
13135        let base = NodeHandle::from_f32(vec![0.0; 10], Shape::from_dims(&[10]), cpu_dev()).unwrap();
13136        let idx = base
13137            .const_u32_like(vec![1, 3, 5], Shape::from_dims(&[3]))
13138            .unwrap();
13139        let src = base
13140            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]))
13141            .unwrap();
13142        let out = base.index_add(0, &idx, &src);
13143        assert_eq!(out.shape().dims(), &[10]);
13144    }
13145
13146    #[test]
13147    #[should_panic(expected = "dtypes must match")]
13148    fn index_add_rejects_dtype_mismatch() {
13149        let base = NodeHandle::from_f32(vec![0.0; 5], Shape::from_dims(&[5]), cpu_dev()).unwrap();
13150        let idx = base
13151            .const_u32_like(vec![0, 2], Shape::from_dims(&[2]))
13152            .unwrap();
13153        let src = base
13154            .const_f64_like(vec![1.0, 2.0], Shape::from_dims(&[2]))
13155            .unwrap();
13156        let _ = base.index_add(0, &idx, &src);
13157    }
13158
13159    #[test]
13160    fn scatter_add_validates_index_matches_src() {
13161        let base =
13162            NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13163        let idx = base
13164            .const_u32_like(vec![0, 2, 1, 0], Shape::from_dims(&[2, 2]))
13165            .unwrap();
13166        let src = base
13167            .const_f32_like(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[2, 2]))
13168            .unwrap();
13169        let out = base.scatter_add(1, &idx, &src);
13170        assert_eq!(out.shape().dims(), &[2, 3]);
13171    }
13172
13173    #[test]
13174    #[should_panic(expected = "same shape")]
13175    fn scatter_add_rejects_index_src_shape_mismatch() {
13176        let base =
13177            NodeHandle::from_f32(vec![0.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13178        let idx = base
13179            .const_u32_like(vec![0, 1], Shape::from_dims(&[2]))
13180            .unwrap();
13181        let src = base
13182            .const_f32_like(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[2, 2]))
13183            .unwrap();
13184        let _ = base.scatter_add(1, &idx, &src);
13185    }
13186
13187    #[test]
13188    fn reduce_sum_to_validates_compatibility() {
13189        // [3, 4] can reduce to [4] (sum along dim 0) or [3, 1] (sum along dim 1).
13190        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13191        let r1 = x.reduce_sum_to(Shape::from_dims(&[4]));
13192        assert_eq!(r1.shape().dims(), &[4]);
13193        let r2 = x.reduce_sum_to(Shape::from_dims(&[3, 1]));
13194        assert_eq!(r2.shape().dims(), &[3, 1]);
13195    }
13196
13197    #[test]
13198    #[should_panic(expected = "incompatible")]
13199    fn reduce_sum_to_rejects_non_broadcast_target() {
13200        // [3, 4] cannot reduce to [3, 2] — target must be broadcast-into-source.
13201        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13202        let _ = x.reduce_sum_to(Shape::from_dims(&[3, 2]));
13203    }
13204
13205    #[test]
13206    fn unsqueeze_inserts_size_one_dim() {
13207        let x = NodeHandle::from_f32(
13208            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
13209            Shape::from_dims(&[2, 3]),
13210            cpu_dev(),
13211        )
13212        .unwrap();
13213        // Insert at position 0: [2, 3] -> [1, 2, 3]
13214        let y0 = x.unsqueeze(0);
13215        assert_eq!(y0.shape().dims(), &[1, 2, 3]);
13216        // Insert at position 1: [2, 3] -> [2, 1, 3]
13217        let y1 = x.unsqueeze(1);
13218        assert_eq!(y1.shape().dims(), &[2, 1, 3]);
13219        // Insert at the end (rank): [2, 3] -> [2, 3, 1]
13220        let y2 = x.unsqueeze(2);
13221        assert_eq!(y2.shape().dims(), &[2, 3, 1]);
13222        // Op variant correctness.
13223        let g = x.graph().read().unwrap();
13224        assert!(matches!(g.node(y0.id()).op, Op::Unsqueeze { dim: 0 }));
13225        assert!(matches!(g.node(y1.id()).op, Op::Unsqueeze { dim: 1 }));
13226        assert!(matches!(g.node(y2.id()).op, Op::Unsqueeze { dim: 2 }));
13227    }
13228
13229    #[test]
13230    #[should_panic(expected = "out of bounds")]
13231    fn unsqueeze_rejects_dim_above_rank() {
13232        let x = NodeHandle::from_f32(vec![1.0; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
13233        // dim=2 > rank=1 → panic.
13234        let _ = x.unsqueeze(2);
13235    }
13236
13237    #[test]
13238    fn unsqueeze_layout_side_table_populated() {
13239        // After Graph::push auto-derives the Layout side-table for view
13240        // ops, an unsqueeze node should have an explicit layout entry
13241        // with a stride-0 axis at the inserted position.
13242        let x = NodeHandle::from_f32(
13243            vec![1.0, 2.0, 3.0, 4.0],
13244            Shape::from_dims(&[2, 2]),
13245            cpu_dev(),
13246        )
13247        .unwrap();
13248        let y = x.unsqueeze(1);
13249        let g = x.graph().read().unwrap();
13250        assert!(
13251            g.has_explicit_layout(y.id()),
13252            "Graph::push should auto-populate layout for view ops"
13253        );
13254        let l = g.layout(y.id());
13255        assert_eq!(l.shape().dims(), &[2, 1, 2]);
13256        // Stride at the inserted axis should be 0 (per the convention
13257        // chosen in Layout::unsqueeze).
13258        assert_eq!(l.stride()[1], 0);
13259    }
13260
13261    #[test]
13262    fn reduce_max_to_validates_compatibility() {
13263        // [3, 4] can reduce to [4] (max along dim 0) or [3, 1] (max along dim 1).
13264        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13265        let r1 = x.reduce_max_to(Shape::from_dims(&[4]));
13266        assert_eq!(r1.shape().dims(), &[4]);
13267        assert!(matches!(
13268            r1.graph().read().unwrap().node(r1.id()).op,
13269            Op::ReduceMaxTo(_)
13270        ));
13271        let r2 = x.reduce_max_to(Shape::from_dims(&[3, 1]));
13272        assert_eq!(r2.shape().dims(), &[3, 1]);
13273    }
13274
13275    #[test]
13276    #[should_panic(expected = "incompatible")]
13277    fn reduce_max_to_rejects_non_broadcast_target() {
13278        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13279        let _ = x.reduce_max_to(Shape::from_dims(&[3, 2]));
13280    }
13281
13282    #[test]
13283    fn reshape_preserves_element_count() {
13284        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13285        let r = x.reshape(Shape::from_dims(&[2, 6]));
13286        assert_eq!(r.shape().dims(), &[2, 6]);
13287    }
13288
13289    #[test]
13290    #[should_panic(expected = "element count mismatch")]
13291    fn reshape_rejects_different_element_count() {
13292        let x = NodeHandle::from_f32(vec![0.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13293        let _ = x.reshape(Shape::from_dims(&[3, 3]));
13294    }
13295
13296    #[test]
13297    #[should_panic(expected = "same graph")]
13298    fn concat_across_graphs_panics() {
13299        let a = NodeHandle::from_f32(vec![0.0; 3], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13300        let b = NodeHandle::from_f32(vec![0.0; 3], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13301        let _ = a.concat(&b, 0);
13302    }
13303
13304    #[test]
13305    fn scalar_ops_preserve_shape_and_dtype() {
13306        let x =
13307            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13308        let y = x.add_scalar(5.0).mul_scalar(2.0).powi(2).clamp(0.0, 100.0);
13309        assert_eq!(y.shape().dims(), &[3]);
13310        assert_eq!(y.dtype(), DType::F32);
13311    }
13312
13313    #[test]
13314    fn maximum_requires_matching_shapes() {
13315        let a =
13316            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13317        let b = a
13318            .const_f32_like(vec![4.0, 1.0, 5.0], Shape::from_dims(&[3]))
13319            .unwrap();
13320        let m = a.maximum(&b);
13321        assert_eq!(m.shape().dims(), &[3]);
13322    }
13323
13324    #[test]
13325    #[should_panic(expected = "shape mismatch")]
13326    fn maximum_rejects_shape_mismatch() {
13327        let a =
13328            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13329        let b = a
13330            .const_f32_like(vec![1.0, 2.0], Shape::from_dims(&[2]))
13331            .unwrap();
13332        let _ = a.maximum(&b);
13333    }
13334
13335    // ----- topo_order -----
13336
13337    #[test]
13338    fn topo_order_places_inputs_before_dependents() {
13339        // Build: c = (a + b) * a
13340        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13341        let b = a
13342            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
13343            .unwrap();
13344        let sum = a.add(&b);
13345        let c = sum.mul(&a);
13346        let order = topo_order(&c.graph().read().unwrap(), c.id());
13347        // The order should contain exactly 4 nodes (a, b, sum, c) and
13348        // place each input strictly before its dependents.
13349        assert_eq!(order.len(), 4);
13350        let pos = |id: NodeId| order.iter().position(|&x| x == id).unwrap();
13351        assert!(pos(a.id()) < pos(sum.id()));
13352        assert!(pos(b.id()) < pos(sum.id()));
13353        assert!(pos(sum.id()) < pos(c.id()));
13354        assert!(pos(a.id()) < pos(c.id()));
13355    }
13356
13357    #[test]
13358    fn topo_order_visits_each_node_once_when_shared() {
13359        // (a + a) — `a` appears twice in the Add's inputs but the topo
13360        // pass should still visit it exactly once.
13361        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13362        let double = a.add(&a);
13363        let order = topo_order(&double.graph().read().unwrap(), double.id());
13364        assert_eq!(order.len(), 2);
13365        assert_eq!(order[0], a.id());
13366        assert_eq!(order[1], double.id());
13367    }
13368
13369    #[test]
13370    fn topo_order_multi_unions_multiple_roots() {
13371        // Build a DAG where two "output" nodes share a common input.
13372        //
13373        //      a ─┬─> add1 (= a + b)  ← root_1
13374        //         └─> add2 (= a + c)  ← root_2
13375        //      b ───> add1
13376        //      c ───> add2
13377        //
13378        // topo_order_multi(&[add1, add2]) must contain all 5 nodes
13379        // (a, b, c, add1, add2) with a before add1/add2 and b before
13380        // add1 and c before add2.
13381        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13382        let b = a
13383            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
13384            .unwrap();
13385        let c = a
13386            .const_f32_like(vec![5.0, 6.0], Shape::from_dims(&[2]))
13387            .unwrap();
13388        let add1 = a.add(&b);
13389        let add2 = a.add(&c);
13390        let order = topo_order_multi(&add1.graph().read().unwrap(), &[add1.id(), add2.id()]);
13391        assert_eq!(order.len(), 5);
13392        let pos = |id: NodeId| order.iter().position(|&x| x == id).unwrap();
13393        assert!(pos(a.id()) < pos(add1.id()));
13394        assert!(pos(a.id()) < pos(add2.id()));
13395        assert!(pos(b.id()) < pos(add1.id()));
13396        assert!(pos(c.id()) < pos(add2.id()));
13397    }
13398
13399    // ----- backward -----
13400
13401    #[test]
13402    fn backward_of_lone_const_seeds_ones() {
13403        // backward(a) gives a = 1s (the root's upstream is a ones tensor,
13404        // which is the gradient stored for the root itself).
13405        let a =
13406            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13407        let grads = a.backward();
13408        let g_a = grads.get(&a).expect("root gets a seed gradient");
13409        // The seed is a Const ones node of matching shape. Phase 7.5
13410        // G2: gradient consts are slot-rooted Op::Const — no
13411        // host-side ConstData on the node.
13412        let node = g_a.graph().read().unwrap().node(g_a.id()).clone();
13413        assert!(matches!(node.op, Op::Const));
13414        let slot = g_a.graph().read().unwrap().storage_for(g_a.id()).unwrap();
13415        assert_eq!(slot.read().unwrap().dtype(), DType::F32);
13416        assert_eq!(g_a.shape().dims(), &[3]);
13417    }
13418
13419    #[test]
13420    fn backward_of_add_passes_upstream_through() {
13421        // c = a + b  ⇒  dc/da = 1, dc/db = 1.
13422        // Upstream seed is a ones tensor. So grad_a and grad_b are both
13423        // the same ones node (no new math emitted for Add's backward).
13424        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13425        let b = a
13426            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
13427            .unwrap();
13428        let c = a.add(&b);
13429        let grads = c.backward();
13430        let g_a = grads.get(&a).unwrap();
13431        let g_b = grads.get(&b).unwrap();
13432        assert_eq!(g_a.id(), g_b.id(), "Add backward routes upstream unchanged");
13433        assert_eq!(g_a.shape().dims(), &[2]);
13434    }
13435
13436    #[test]
13437    fn backward_of_mul_emits_two_mul_nodes() {
13438        // c = a * b  ⇒  dc/da = b, dc/db = a (upstream is 1s).
13439        // The backward pass should emit two new Mul nodes (upstream * b,
13440        // upstream * a).
13441        let a = NodeHandle::from_f32(vec![2.0, 3.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13442        let b = a
13443            .const_f32_like(vec![5.0, 7.0], Shape::from_dims(&[2]))
13444            .unwrap();
13445        let c = a.mul(&b);
13446        let nodes_before = c.graph().read().unwrap().len();
13447        let grads = c.backward();
13448        let nodes_after = grads.graph.read().unwrap().len();
13449        // Backward adds: one ones const + two Mul nodes = 3 new nodes.
13450        assert_eq!(nodes_after - nodes_before, 3);
13451        let g_a = grads.get(&a).unwrap();
13452        let g_b = grads.get(&b).unwrap();
13453        // Both gradient nodes should be Mul nodes.
13454        let node_a = g_a.graph().read().unwrap().node(g_a.id()).clone();
13455        let node_b = g_b.graph().read().unwrap().node(g_b.id()).clone();
13456        assert!(matches!(node_a.op, Op::Mul));
13457        assert!(matches!(node_b.op, Op::Mul));
13458    }
13459
13460    #[test]
13461    fn backward_accumulates_when_node_used_twice() {
13462        // c = a * a  ⇒  dc/da = 2a via two separate contributions.
13463        // After backward, the gradient for a should be an Add node
13464        // combining the two Mul contributions (one from each input slot
13465        // of the forward Mul).
13466        let a = NodeHandle::from_f32(vec![3.0, 5.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13467        let c = a.mul(&a);
13468        let grads = c.backward();
13469        let g_a = grads.get(&a).unwrap();
13470        let node = g_a.graph().read().unwrap().node(g_a.id()).clone();
13471        // The final gradient for `a` is an Add combining the two
13472        // upstream-times-other-input contributions.
13473        assert!(
13474            matches!(node.op, Op::Add),
13475            "expected accumulated gradient to be an Add node, got {:?}",
13476            node.op,
13477        );
13478    }
13479
13480    #[test]
13481    fn backward_of_matmul_emits_transpose_and_matmul_nodes() {
13482        // Forward: Y = A @ B,  A:[2,3], B:[3,4], Y:[2,4].
13483        // Backward: dA = dY @ B^T (shape [2,3]),  dB = A^T @ dY (shape [3,4]).
13484        let a = NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13485        let b = a
13486            .const_f32_like(vec![1.0; 12], Shape::from_dims(&[3, 4]))
13487            .unwrap();
13488        let y = a.matmul(&b);
13489        let grads = y.backward();
13490        let g_a = grads.get(&a).unwrap();
13491        let g_b = grads.get(&b).unwrap();
13492        // Gradient shapes must match the forward shapes.
13493        assert_eq!(g_a.shape().dims(), &[2, 3]);
13494        assert_eq!(g_b.shape().dims(), &[3, 4]);
13495        // Both should be MatMul nodes (the outermost op of each gradient).
13496        let node_a = g_a.graph().read().unwrap().node(g_a.id()).clone();
13497        let node_b = g_b.graph().read().unwrap().node(g_b.id()).clone();
13498        assert!(matches!(node_a.op, Op::MatMul));
13499        assert!(matches!(node_b.op, Op::MatMul));
13500    }
13501
13502    // ----- new builder validation -----
13503
13504    #[test]
13505    fn cast_tags_node_with_target_dtype() {
13506        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13507        let b = a.cast(DType::F64);
13508        assert_eq!(b.dtype(), DType::F64);
13509        assert_eq!(b.shape().dims(), &[2]);
13510        let node = b.graph().read().unwrap().node(b.id()).clone();
13511        assert!(matches!(node.op, Op::Cast(DType::F64)));
13512    }
13513
13514    #[test]
13515    fn broadcast_to_accepts_right_aligned_expansion() {
13516        // [3] broadcasts to [2, 3]: pad with leading 1, expand.
13517        let a =
13518            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13519        let b = a.broadcast_to(Shape::from_dims(&[2, 3]));
13520        assert_eq!(b.shape().dims(), &[2, 3]);
13521    }
13522
13523    #[test]
13524    fn broadcast_to_accepts_size_one_expansion() {
13525        // [3, 1] broadcasts to [3, 4]: size-1 dim expands.
13526        let a = NodeHandle::from_f32(vec![10.0, 20.0, 30.0], Shape::from_dims(&[3, 1]), cpu_dev())
13527            .unwrap();
13528        let b = a.broadcast_to(Shape::from_dims(&[3, 4]));
13529        assert_eq!(b.shape().dims(), &[3, 4]);
13530    }
13531
13532    #[test]
13533    #[should_panic(expected = "incompatible")]
13534    fn broadcast_to_rejects_incompatible_dim() {
13535        // [3] cannot broadcast to [2, 4] — the source dim 3 must match 4.
13536        let a =
13537            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13538        let _ = a.broadcast_to(Shape::from_dims(&[2, 4]));
13539    }
13540
13541    #[test]
13542    fn sum_all_produces_rank_zero_output() {
13543        let a =
13544            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13545        let s = a.sum_all();
13546        assert_eq!(s.shape().dims(), &[] as &[usize]);
13547    }
13548
13549    #[test]
13550    fn sum_dim_removes_reduced_dim_from_shape() {
13551        let a =
13552            NodeHandle::from_f32(vec![1.0; 24], Shape::from_dims(&[2, 3, 4]), cpu_dev()).unwrap();
13553        // Reducing dim 1 should give shape [2, 4].
13554        let s = a.sum_dim(1);
13555        assert_eq!(s.shape().dims(), &[2, 4]);
13556    }
13557
13558    #[test]
13559    #[should_panic(expected = "out of bounds")]
13560    fn sum_dim_rejects_bad_dim() {
13561        let a =
13562            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13563        let _ = a.sum_dim(5);
13564    }
13565
13566    #[test]
13567    fn softmax_and_layer_norm_preserve_shape() {
13568        let a = NodeHandle::from_f32(
13569            vec![1.0, 2.0, 3.0, 4.0],
13570            Shape::from_dims(&[2, 2]),
13571            cpu_dev(),
13572        )
13573        .unwrap();
13574        assert_eq!(a.softmax_last_dim().shape().dims(), &[2, 2]);
13575        assert_eq!(a.layer_norm_last_dim(1e-5).shape().dims(), &[2, 2]);
13576    }
13577
13578    #[test]
13579    fn neg_sub_div_sqrt_log_sin_cos_tanh_sigmoid_all_build() {
13580        // Smoke test: every new builder produces a node with the expected
13581        // shape and dtype. Numerical correctness is exercised in exec.rs.
13582        let a =
13583            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13584        let b = a
13585            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]))
13586            .unwrap();
13587        for tensor in [
13588            a.neg(),
13589            a.sub(&b),
13590            a.div(&b),
13591            a.sqrt(),
13592            a.log(),
13593            a.sin(),
13594            a.cos(),
13595            a.tanh(),
13596            a.sigmoid(),
13597            a.step(),
13598        ] {
13599            assert_eq!(tensor.shape().dims(), &[3]);
13600            assert_eq!(tensor.dtype(), DType::F32);
13601        }
13602    }
13603
13604    // ----- index tensors and indexing ops -----
13605
13606    #[test]
13607    fn from_u32_tags_node_with_u32_dtype() {
13608        let a = NodeHandle::from_u32(vec![1, 2, 3], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13609        assert_eq!(a.dtype(), DType::U32);
13610        let node = a.graph().read().unwrap().node(a.id()).clone();
13611        // Phase 7.5 G2: slot-rooted Const, dtype validated via slot.
13612        assert!(matches!(node.op, Op::Const));
13613        let slot = a.graph().read().unwrap().storage_for(a.id()).unwrap();
13614        assert_eq!(slot.read().unwrap().dtype(), DType::U32);
13615    }
13616
13617    #[test]
13618    fn index_select_produces_shape_with_dim_replaced() {
13619        let data =
13620            NodeHandle::from_f32(vec![1.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13621        let idx = data
13622            .const_u32_like(vec![0, 2, 1, 0, 2], Shape::from_dims(&[5]))
13623            .unwrap();
13624        let out = data.index_select(0, &idx);
13625        assert_eq!(out.shape().dims(), &[5, 4]);
13626        assert_eq!(out.dtype(), DType::F32);
13627    }
13628
13629    #[test]
13630    #[should_panic(expected = "must be U32")]
13631    fn index_select_rejects_float_index() {
13632        let data =
13633            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13634        let bad = data
13635            .const_f32_like(vec![0.0, 1.0], Shape::from_dims(&[2]))
13636            .unwrap();
13637        let _ = data.index_select(0, &bad);
13638    }
13639
13640    #[test]
13641    #[should_panic(expected = "must be rank 1")]
13642    fn index_select_rejects_multi_dim_index() {
13643        let data =
13644            NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13645        let idx = data
13646            .const_u32_like(vec![0, 1, 0, 1], Shape::from_dims(&[2, 2]))
13647            .unwrap();
13648        let _ = data.index_select(0, &idx);
13649    }
13650
13651    #[test]
13652    fn gather_output_shape_matches_index_shape() {
13653        let data =
13654            NodeHandle::from_f32(vec![1.0; 12], Shape::from_dims(&[3, 4]), cpu_dev()).unwrap();
13655        // Index shape [2, 5] — same rank as data (rank 2).
13656        let idx = data
13657            .const_u32_like(vec![0; 10], Shape::from_dims(&[2, 5]))
13658            .unwrap();
13659        let out = data.gather(1, &idx);
13660        assert_eq!(out.shape().dims(), &[2, 5]);
13661    }
13662
13663    #[test]
13664    #[should_panic(expected = "same rank")]
13665    fn gather_rejects_rank_mismatch() {
13666        let data =
13667            NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13668        // Rank-1 index for rank-2 data → error.
13669        let idx = data
13670            .const_u32_like(vec![0, 1, 0], Shape::from_dims(&[3]))
13671            .unwrap();
13672        let _ = data.gather(1, &idx);
13673    }
13674
13675    #[test]
13676    fn backward_of_relu_emits_step_node() {
13677        // Before: this used to panic. After adding Step + Relu backward,
13678        // it should successfully emit a backward graph rooted in a Mul
13679        // whose second input is a Step node.
13680        let a =
13681            NodeHandle::from_f32(vec![-1.0, 2.0, -3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13682        let y = a.relu();
13683        let grads = y.backward();
13684        let g_a = grads.get(&a).unwrap();
13685        let node = g_a.graph().read().unwrap().node(g_a.id()).clone();
13686        assert!(matches!(node.op, Op::Mul));
13687        // Find a Step node somewhere in the node's inputs.
13688        let any_step = node
13689            .inputs
13690            .iter()
13691            .any(|&id| matches!(g_a.graph().read().unwrap().node(id).op, Op::Step,));
13692        assert!(any_step, "Relu backward must reference a Step node");
13693    }
13694
13695    #[test]
13696    fn recip_and_abs_builders_produce_unary_nodes() {
13697        // Smoke test: NodeHandle::recip()/abs() build single-input nodes
13698        // with the expected op variant and shape passthrough.
13699        let a =
13700            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13701        let r = a.recip();
13702        let b = a.abs();
13703        assert_eq!(r.shape().dims(), &[3]);
13704        assert_eq!(b.shape().dims(), &[3]);
13705        let r_node = r.graph().read().unwrap().node(r.id()).clone();
13706        let b_node = b.graph().read().unwrap().node(b.id()).clone();
13707        assert!(matches!(r_node.op, Op::Recip));
13708        assert!(matches!(b_node.op, Op::Abs));
13709        assert_eq!(r_node.inputs, vec![a.id()]);
13710        assert_eq!(b_node.inputs, vec![a.id()]);
13711    }
13712
13713    #[test]
13714    fn backward_of_recip_is_neg_of_upstream_times_y_squared() {
13715        // y = 1/x ⇒ dy/dx = -y². The backward graph should be a Neg
13716        // wrapping a Mul whose inputs include a Sqr node — and the Sqr
13717        // should reference the forward Recip output, not a fresh recompute.
13718        let a = NodeHandle::from_f32(vec![2.0, 4.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13719        let y = a.recip();
13720        let y_id = y.id();
13721        let grads = y.backward();
13722        let g_a = grads.get(&a).unwrap();
13723        let g = g_a.graph().read().unwrap();
13724        let neg_node = g.node(g_a.id()).clone();
13725        assert!(
13726            matches!(neg_node.op, Op::Neg),
13727            "outermost backward op must be Neg"
13728        );
13729        let mul_id = neg_node.inputs[0];
13730        let mul_node = g.node(mul_id).clone();
13731        assert!(matches!(mul_node.op, Op::Mul));
13732        // One of the Mul's inputs is a Sqr node fed by the forward Recip.
13733        let sqr_id = mul_node
13734            .inputs
13735            .iter()
13736            .copied()
13737            .find(|&id| matches!(g.node(id).op, Op::Sqr))
13738            .expect("backward chain must contain a Sqr node");
13739        let sqr_node = g.node(sqr_id).clone();
13740        assert_eq!(
13741            sqr_node.inputs,
13742            vec![y_id],
13743            "Sqr's input must be the forward Recip output, not a recompute"
13744        );
13745    }
13746
13747    #[test]
13748    fn backward_of_abs_emits_sign_mul() {
13749        // y = |x|, dy/dx = sign(x). After PR B2 landed Op::Sign, the
13750        // backward chain shrinks from 5 nodes (step+neg+step+sub+mul)
13751        // to 2 (sign+mul): grad_x = upstream * sign(x), where Sign is
13752        // a single primitive that returns -1/0/1 directly.
13753        let a = NodeHandle::from_f32(vec![-2.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
13754        let y = a.abs();
13755        let grads = y.backward();
13756        let g_a = grads.get(&a).unwrap();
13757        let g = g_a.graph().read().unwrap();
13758        let mul_node = g.node(g_a.id()).clone();
13759        assert!(matches!(mul_node.op, Op::Mul));
13760        // Exactly one input of the Mul is a Sign node (the other is
13761        // the upstream gradient — a Const-rooted ones-tensor here).
13762        let sign_count = mul_node
13763            .inputs
13764            .iter()
13765            .filter(|&&id| matches!(g.node(id).op, Op::Sign))
13766            .count();
13767        assert_eq!(
13768            sign_count, 1,
13769            "Abs backward must reference exactly 1 Sign node, got {sign_count}"
13770        );
13771        // The Sign node should feed off the original input `a`.
13772        let sign_id = mul_node
13773            .inputs
13774            .iter()
13775            .copied()
13776            .find(|&id| matches!(g.node(id).op, Op::Sign))
13777            .unwrap();
13778        assert_eq!(
13779            g.node(sign_id).inputs,
13780            vec![a.id()],
13781            "Sign's input must be the forward input `a`"
13782        );
13783    }
13784
13785    #[test]
13786    fn eq_builder_produces_bool_output_with_input_shape() {
13787        // GAP-168(c): NodeHandle::eq builds a binary node whose dtype is Bool
13788        // regardless of input dtype (CireSnave's ruling — "Fuel uses Bool
13789        // where possible"), with shape == lhs.shape() (and == rhs.shape()).
13790        let a =
13791            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13792        let b = a
13793            .const_f32_like(vec![1.0, 5.0, 3.0], Shape::from_dims(&[3]))
13794            .unwrap();
13795        let m = a.eq(&b);
13796        assert_eq!(m.shape().dims(), &[3]);
13797        assert_eq!(m.dtype(), DType::Bool, "eq output must be Bool");
13798        let m_node = m.graph().read().unwrap().node(m.id()).clone();
13799        assert!(matches!(m_node.op, Op::Equal));
13800        assert_eq!(m_node.inputs, vec![a.id(), b.id()]);
13801    }
13802
13803    #[test]
13804    fn all_six_comparisons_produce_bool() {
13805        // GAP-168(c): eq/ne/lt/le/gt/ge every one returns DType::Bool, not the
13806        // legacy U8 mask. Shape is preserved (== lhs == rhs). One assertion per
13807        // operator so a regression names the operator that drifted.
13808        let a =
13809            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13810        let b = a
13811            .const_f32_like(vec![1.0, 5.0, 3.0], Shape::from_dims(&[3]))
13812            .unwrap();
13813        for (name, m) in [
13814            ("eq", a.eq(&b)),
13815            ("ne", a.ne(&b)),
13816            ("lt", a.lt(&b)),
13817            ("le", a.le(&b)),
13818            ("gt", a.gt(&b)),
13819            ("ge", a.ge(&b)),
13820        ] {
13821            assert_eq!(m.dtype(), DType::Bool, "{name} output must be Bool");
13822            assert_eq!(m.shape().dims(), &[3], "{name} preserves input shape");
13823        }
13824    }
13825
13826    #[test]
13827    fn try_unsqueeze_returns_err_on_dim_above_rank() {
13828        // try_unsqueeze surfaces bad dim as Err; unsqueeze panics
13829        // on the same input. Both share the validation message.
13830        let a =
13831            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13832        let err = a.try_unsqueeze(5).expect_err("dim above rank must error");
13833        assert!(
13834            format!("{err:?}").contains("out of bounds"),
13835            "error must mention bounds, got: {err:?}"
13836        );
13837        // Happy path still works.
13838        let ok = a.try_unsqueeze(0).expect("dim=0 valid for any rank");
13839        assert_eq!(ok.shape().dims(), &[1, 3]);
13840    }
13841
13842    #[test]
13843    fn try_reshape_returns_err_on_count_mismatch() {
13844        let a = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
13845            .unwrap();
13846        let err = a
13847            .try_reshape(Shape::from_dims(&[3]))
13848            .expect_err("count mismatch must error");
13849        assert!(
13850            format!("{err:?}").contains("element count mismatch"),
13851            "error must mention count mismatch, got: {err:?}"
13852        );
13853        let ok = a
13854            .try_reshape(Shape::from_dims(&[2, 2]))
13855            .expect("matching count is ok");
13856        assert_eq!(ok.shape().dims(), &[2, 2]);
13857    }
13858
13859    #[test]
13860    fn try_broadcast_to_returns_err_on_incompatible_shape() {
13861        let a =
13862            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13863        let err = a
13864            .try_broadcast_to(Shape::from_dims(&[2, 4]))
13865            .expect_err("source dim 3 cannot broadcast to dim 4");
13866        assert!(
13867            format!("{err:?}").contains("incompatible"),
13868            "error must mention incompatibility, got: {err:?}"
13869        );
13870        let ok = a
13871            .try_broadcast_to(Shape::from_dims(&[2, 3]))
13872            .expect("dim 3 broadcasts to dim 3");
13873        assert_eq!(ok.shape().dims(), &[2, 3]);
13874    }
13875
13876    #[test]
13877    fn try_transpose_returns_err_on_rank_below_2() {
13878        let a =
13879            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13880        let err = a
13881            .try_transpose()
13882            .expect_err("rank-1 input cannot transpose");
13883        assert!(
13884            format!("{err:?}").contains("rank ≥ 2"),
13885            "error must mention rank requirement, got: {err:?}"
13886        );
13887        let m = NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13888        let mt = m.try_transpose().expect("rank-2 transposes ok");
13889        assert_eq!(mt.shape().dims(), &[3, 2]);
13890    }
13891
13892    #[test]
13893    fn try_permute_returns_err_on_bad_axes() {
13894        let a = NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13895        // Wrong axes length:
13896        let err = a.try_permute(&[0]).expect_err("axes length mismatch");
13897        assert!(
13898            format!("{err:?}").contains("must equal tensor rank"),
13899            "error must mention length, got: {err:?}"
13900        );
13901        // Out-of-bounds axis:
13902        let err = a.try_permute(&[0, 5]).expect_err("axis 5 out of rank 2");
13903        assert!(format!("{err:?}").contains("out of bounds"));
13904        // Duplicate axis:
13905        let err = a.try_permute(&[0, 0]).expect_err("duplicate axis");
13906        assert!(format!("{err:?}").contains("duplicate"));
13907        // Happy path:
13908        let ok = a.try_permute(&[1, 0]).expect("valid permutation");
13909        assert_eq!(ok.shape().dims(), &[3, 2]);
13910    }
13911
13912    #[test]
13913    fn squeeze_drops_size_one_dim_metadata_only() {
13914        // Build [2, 1, 3] → squeeze(1) → [2, 3]. Op::Squeeze, view-op,
13915        // shape pruned, dtype preserved, single input slot.
13916        let a =
13917            NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 1, 3]), cpu_dev()).unwrap();
13918        let s = a.squeeze(1).expect("squeeze on size-1 dim");
13919        assert_eq!(s.shape().dims(), &[2, 3]);
13920        assert_eq!(s.dtype(), DType::F32);
13921        let node = s.graph().read().unwrap().node(s.id()).clone();
13922        assert!(matches!(node.op, Op::Squeeze { dim: 1 }));
13923        assert_eq!(node.inputs, vec![a.id()]);
13924        // Squeeze joins the view-op set so the Layout side-table is
13925        // populated automatically.
13926        assert!(
13927            node.op.is_view_op(),
13928            "Op::Squeeze must register as a view op"
13929        );
13930    }
13931
13932    #[test]
13933    fn squeeze_rejects_non_size_one_dim() {
13934        // Result-returning: bad dim surfaces as Err, not panic.
13935        let a = NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
13936        let err = a
13937            .squeeze(1)
13938            .expect_err("squeezing a non-size-1 dim must error");
13939        assert!(
13940            format!("{err:?}").contains("expected 1"),
13941            "error message should mention the size-1 expectation, got: {err:?}"
13942        );
13943    }
13944
13945    #[test]
13946    fn squeeze_rejects_dim_above_rank() {
13947        let a =
13948            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13949        let err = a.squeeze(5).expect_err("dim above rank must error");
13950        assert!(
13951            format!("{err:?}").contains("out of bounds"),
13952            "error message should mention bounds, got: {err:?}"
13953        );
13954    }
13955
13956    #[test]
13957    fn backward_through_squeeze_emits_unsqueeze() {
13958        // y = squeeze(x, 1). Backward: re-insert dim 1 via Unsqueeze.
13959        // Gradient shape must equal x.shape exactly.
13960        let a =
13961            NodeHandle::from_f32(vec![1.0; 6], Shape::from_dims(&[2, 1, 3]), cpu_dev()).unwrap();
13962        let y = a.squeeze(1).expect("squeeze on size-1 dim");
13963        let grads = y.backward();
13964        let g_a = grads.get(&a).expect("gradient for a");
13965        assert_eq!(
13966            g_a.shape().dims(),
13967            &[2, 1, 3],
13968            "Squeeze backward gradient must restore the original shape"
13969        );
13970        // The chain is: ones-seed (post-squeeze, shape [2,3])
13971        //               → Unsqueeze(dim=1) → shape [2, 1, 3].
13972        let g_node = g_a.graph().read().unwrap().node(g_a.id()).clone();
13973        assert!(
13974            matches!(g_node.op, Op::Unsqueeze { dim: 1 }),
13975            "Squeeze backward must be Unsqueeze at the same dim"
13976        );
13977    }
13978
13979    #[test]
13980    fn floor_builder_produces_unary_node_same_dtype() {
13981        // NodeHandle::floor builds a unary node preserving shape + dtype.
13982        let a =
13983            NodeHandle::from_f32(vec![1.5, 2.5, 3.5], Shape::from_dims(&[3]), cpu_dev()).unwrap();
13984        let f = a.floor();
13985        assert_eq!(f.shape().dims(), &[3]);
13986        assert_eq!(f.dtype(), DType::F32);
13987        let node = f.graph().read().unwrap().node(f.id()).clone();
13988        assert!(matches!(node.op, Op::Floor));
13989        assert_eq!(node.inputs, vec![a.id()]);
13990    }
13991
13992    #[test]
13993    fn backward_through_floor_drops_gradient_silently() {
13994        // Op::Floor has zero gradient almost everywhere; the inline
13995        // backward arm is a no-op. backward() must succeed and the
13996        // input gets no gradient (the input's upstream is never
13997        // populated through the Floor node).
13998        let a =
13999            NodeHandle::from_f32(vec![1.5, 2.5, 3.5], Shape::from_dims(&[3]), cpu_dev()).unwrap();
14000        let y = a.floor();
14001        let grads = y.backward();
14002        // a's gradient is dropped — no entry in the GradMap.
14003        assert!(
14004            grads.get(&a).is_none(),
14005            "Floor must not propagate gradient to its input"
14006        );
14007    }
14008
14009    #[test]
14010    fn where_cond_builder_produces_ternary_with_a_dtype() {
14011        // self is a Bool cond (GAP-168(c)); a/b are F32. Output dtype = F32
14012        // (= a's dtype), shape = self.shape() (= a.shape() = b.shape()).
14013        // Op::Where node carries 3 inputs in order (cond, a, b).
14014        let a =
14015            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
14016        let b = a
14017            .const_f32_like(vec![10.0, 20.0, 30.0], Shape::from_dims(&[3]))
14018            .unwrap();
14019        let eq_a_b = a.eq(&b); // Bool mask
14020        let picked = eq_a_b.where_cond(&a, &b);
14021        assert_eq!(picked.shape().dims(), &[3]);
14022        assert_eq!(picked.dtype(), DType::F32, "Where output dtype = a/b dtype");
14023        let node = picked.graph().read().unwrap().node(picked.id()).clone();
14024        assert!(matches!(node.op, Op::Where));
14025        assert_eq!(node.inputs.len(), 3);
14026        assert_eq!(
14027            node.inputs,
14028            vec![eq_a_b.id(), a.id(), b.id()],
14029            "Where input order: (cond, a, b)"
14030        );
14031    }
14032
14033    #[test]
14034    fn backward_reuses_exp_forward_output() {
14035        // Exp's backward rule uses the forward output directly. The
14036        // gradient for x should be a Mul whose inputs include the
14037        // forward Exp node (not a new Exp node).
14038        let a = NodeHandle::from_f32(vec![0.0, 1.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
14039        let e = a.exp();
14040        let exp_forward_id = e.id();
14041        let grads = e.backward();
14042        let g_a = grads.get(&a).unwrap();
14043        let node = g_a.graph().read().unwrap().node(g_a.id()).clone();
14044        assert!(matches!(node.op, Op::Mul));
14045        // One of the Mul's inputs should be the original forward Exp node.
14046        assert!(
14047            node.inputs.contains(&exp_forward_id),
14048            "Exp backward should reference the forward output ({exp_forward_id:?}), \
14049             got inputs {:?}",
14050            node.inputs,
14051        );
14052    }
14053
14054    // ---- Op::WriteSlice (Phase E.3.2) ---------------------------------------
14055
14056    #[test]
14057    fn write_slice_destructive_input_is_zero() {
14058        let op = Op::WriteSlice {
14059            ranges: vec![(0, 1), (0, 32), (0, 128)],
14060            dyn_offset: None,
14061        };
14062        assert_eq!(op.destructive_input(), Some(0));
14063    }
14064
14065    #[test]
14066    fn write_slice_short_name() {
14067        let op = Op::WriteSlice {
14068            ranges: vec![(0, 1)],
14069            dyn_offset: None,
14070        };
14071        assert_eq!(op.short_name(), "WriteSlice");
14072    }
14073
14074    #[test]
14075    fn write_slice_emits_op_writeslice_node() {
14076        // dest shape [4, 3]; source shape [1, 3]; write at row 2.
14077        let dest =
14078            NodeHandle::from_f32(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]), cpu_dev()).unwrap();
14079        let src = dest
14080            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[1, 3]))
14081            .unwrap();
14082        let out = dest
14083            .write_slice(&src, vec![(2, 3), (0, 3)])
14084            .expect("write_slice should accept matching shapes");
14085        let g = out.graph().read().unwrap();
14086        match &g.node(out.id()).op {
14087            Op::WriteSlice { ranges, dyn_offset } => {
14088                assert_eq!(ranges, &vec![(2, 3), (0, 3)]);
14089                assert_eq!(dyn_offset, &None);
14090            }
14091            other => panic!("expected Op::WriteSlice, got {other:?}"),
14092        }
14093        assert_eq!(g.node(out.id()).inputs, vec![dest.id(), src.id()]);
14094        // Output shape == destination shape; bytes are post-write same buffer.
14095        assert_eq!(g.node(out.id()).shape.dims(), &[4, 3]);
14096    }
14097
14098    #[test]
14099    fn write_slice_dyn_records_dynamic_offset() {
14100        use fuel_ir::{DynScalar, SymId};
14101        // dest capacity [8, 3]; source [1, 3]; dynamic start on axis 0.
14102        let dest =
14103            NodeHandle::from_f32(vec![0.0_f32; 24], Shape::from_dims(&[8, 3]), cpu_dev()).unwrap();
14104        let src = dest
14105            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[1, 3]))
14106            .unwrap();
14107        let sym = SymId(0);
14108        // ranges[0].0 is ignored (start is dynamic); width = 1 - 0 = 1
14109        // matches source dim 0. Axis 1 is static, full width.
14110        let out = dest
14111            .write_slice_dyn(&src, vec![(0, 1), (0, 3)], 0, DynScalar::Sym(sym))
14112            .expect("write_slice_dyn should accept matching widths");
14113        let g = out.graph().read().unwrap();
14114        match &g.node(out.id()).op {
14115            Op::WriteSlice { ranges, dyn_offset } => {
14116                assert_eq!(ranges, &vec![(0, 1), (0, 3)]);
14117                assert_eq!(dyn_offset, &Some((0, DynScalar::Sym(sym))));
14118            }
14119            other => panic!("expected Op::WriteSlice, got {other:?}"),
14120        }
14121        assert_eq!(g.node(out.id()).inputs, vec![dest.id(), src.id()]);
14122        assert_eq!(g.node(out.id()).shape.dims(), &[8, 3]);
14123    }
14124
14125    #[test]
14126    fn write_slice_dyn_rejects_slab_wider_than_capacity() {
14127        use fuel_ir::{DynScalar, SymId};
14128        // dest capacity on axis 0 is 2, but the dynamic-axis slab is
14129        // width 4 — can never fit regardless of the runtime offset.
14130        let dest =
14131            NodeHandle::from_f32(vec![0.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
14132        let src = dest
14133            .const_f32_like(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]))
14134            .unwrap();
14135        let err = dest.write_slice_dyn(&src, vec![(0, 4), (0, 3)], 0, DynScalar::Sym(SymId(0)));
14136        assert!(
14137            err.is_err(),
14138            "dynamic-axis slab wider than capacity must error at build"
14139        );
14140    }
14141
14142    #[test]
14143    fn write_slice_dyn_rejects_axis_out_of_bounds() {
14144        use fuel_ir::{DynScalar, SymId};
14145        let dest =
14146            NodeHandle::from_f32(vec![0.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
14147        let src = dest
14148            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[1, 3]))
14149            .unwrap();
14150        let err = dest.write_slice_dyn(&src, vec![(0, 1), (0, 3)], 5, DynScalar::Sym(SymId(0)));
14151        assert!(err.is_err(), "dyn_axis past rank must error");
14152    }
14153
14154    #[test]
14155    fn flash_attn_dyn_records_runtime_k_len() {
14156        use fuel_ir::{DynScalar, SymId};
14157        // q [1, 2, 3, 4]; K/V capacity [1, 1, 8, 4] (GQA Hq=2, Hkv=1).
14158        let q = NodeHandle::from_f32(
14159            vec![0.0_f32; 2 * 3 * 4],
14160            Shape::from_dims(&[1, 2, 3, 4]),
14161            cpu_dev(),
14162        )
14163        .unwrap();
14164        let k = q
14165            .const_f32_like(vec![0.0_f32; 8 * 4], Shape::from_dims(&[1, 1, 8, 4]))
14166            .unwrap();
14167        let v = q
14168            .const_f32_like(vec![0.0_f32; 8 * 4], Shape::from_dims(&[1, 1, 8, 4]))
14169            .unwrap();
14170        let sym = SymId(0);
14171        let out = q.flash_attn_dyn(
14172            &k,
14173            &v,
14174            None,
14175            /*scale*/ 0.5,
14176            /*causal*/ true,
14177            None,
14178            None,
14179            None,
14180            DynScalar::Sym(sym),
14181        );
14182        // Output adopts q's shape, not the capacity.
14183        assert_eq!(out.shape().dims(), &[1, 2, 3, 4]);
14184        let g = out.graph().read().unwrap();
14185        match &g.node(out.id()).op {
14186            Op::Fused(fid, crate::registry::FusedOpParams::FlashAttn { causal, k_len, .. })
14187                if *fid == crate::registry::FusedOps::FLASH_ATTN =>
14188            {
14189                assert!(*causal);
14190                assert_eq!(k_len, &Some(DynScalar::Sym(sym)));
14191            }
14192            other => panic!("expected Op::Fused(FLASH_ATTN, FlashAttn), got {other:?}"),
14193        }
14194        assert_eq!(g.node(out.id()).inputs, vec![q.id(), k.id(), v.id()]);
14195    }
14196
14197    #[test]
14198    #[should_panic(expected = "k_len")]
14199    fn flash_attn_dyn_concrete_k_len_exceeding_capacity_panics() {
14200        use fuel_ir::DynScalar;
14201        let q = NodeHandle::from_f32(
14202            vec![0.0_f32; 2 * 4],
14203            Shape::from_dims(&[1, 1, 2, 4]),
14204            cpu_dev(),
14205        )
14206        .unwrap();
14207        let k = q
14208            .const_f32_like(vec![0.0_f32; 4 * 4], Shape::from_dims(&[1, 1, 4, 4]))
14209            .unwrap();
14210        let v = q
14211            .const_f32_like(vec![0.0_f32; 4 * 4], Shape::from_dims(&[1, 1, 4, 4]))
14212            .unwrap();
14213        // Concrete k_len=5 > capacity 4 → build-time panic.
14214        let _ = q.flash_attn_dyn(
14215            &k,
14216            &v,
14217            None,
14218            1.0,
14219            true,
14220            None,
14221            None,
14222            None,
14223            DynScalar::Concrete(5),
14224        );
14225    }
14226
14227    #[test]
14228    fn write_slice_rejects_rank_mismatch() {
14229        let dest =
14230            NodeHandle::from_f32(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]), cpu_dev()).unwrap();
14231        let src = dest
14232            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]))
14233            .unwrap();
14234        // ranges has rank 2 (matches dest) but source has rank 1.
14235        let err = dest.write_slice(&src, vec![(0, 1), (0, 3)]);
14236        assert!(err.is_err(), "rank mismatch must error");
14237    }
14238
14239    // ---- cross-graph operands must fail at BUILD time, never hang -----------
14240    //
14241    // Node ids are indices into ONE graph's arena, so an operand built on a
14242    // DIFFERENT graph records an id that indexes an unrelated node (or none),
14243    // producing a malformed dependency graph. The failure surfaces as neither
14244    // a panic nor an error: the executor **HANGS at realize**, waiting on a
14245    // dependency that can never resolve — low CPU, no stack, no message. That
14246    // is strictly worse than a panic, and it violates two rules at once:
14247    // "validate at graph-build time" and never-hang.
14248    //
14249    // `matmul`, `qmatmul`, `conv2d`, `paged_attn`, and everything routed via
14250    // `binary_op` already assert this. These builders did not — a real cost: a
14251    // peer session spent a long bisect localizing exactly this on `write_slice`.
14252    //
14253    // These tests only BUILD, never realize, so before the fix they fail fast
14254    // instead of hanging the suite.
14255
14256    #[test]
14257    fn write_slice_rejects_cross_graph_source() {
14258        let dest =
14259            NodeHandle::from_f32(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]), cpu_dev()).unwrap();
14260        // A SECOND `from_*` mints a second graph — the easy mistake to make,
14261        // and currently the un-diagnosable one.
14262        let foreign =
14263            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[1, 3]), cpu_dev())
14264                .unwrap();
14265        assert_ne!(
14266            dest.graph_id(),
14267            foreign.graph_id(),
14268            "fixture must be cross-graph"
14269        );
14270
14271        let err = dest.write_slice(&foreign, vec![(2, 3), (0, 3)]);
14272        assert!(
14273            err.is_err(),
14274            "cross-graph source must be a build-time Err, not a realize-time hang",
14275        );
14276        let msg = format!("{}", err.unwrap_err());
14277        assert!(
14278            msg.contains("same graph"),
14279            "error should say the operands must share a graph, got: {msg}",
14280        );
14281    }
14282
14283    #[test]
14284    fn masked_fill_rejects_cross_graph_mask() {
14285        let x = NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
14286        let foreign_mask =
14287            NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
14288        assert_ne!(
14289            x.graph_id(),
14290            foreign_mask.graph_id(),
14291            "fixture must be cross-graph"
14292        );
14293
14294        let err = x.masked_fill(&foreign_mask, Scalar::F32(0.0));
14295        assert!(
14296            err.is_err(),
14297            "cross-graph mask must be a build-time Err, not a realize-time hang",
14298        );
14299        // MUST assert the reason, not just is_err(): this fixture also has a
14300        // dtype mismatch, so a bare `is_err()` passes for the WRONG reason and
14301        // would stay green even with no graph check at all.
14302        let msg = format!("{}", err.unwrap_err());
14303        assert!(
14304            msg.contains("same graph"),
14305            "must fail on GRAPH affinity, not incidentally on dtype, got: {msg}",
14306        );
14307    }
14308
14309    #[test]
14310    fn masked_fill_rejects_scale_dtype_at_build() {
14311        // F8E8M0 is a block-scale dtype: it HAS a scalar value (so the
14312        // value-dtype check passes) but no zero, so the MaskedFill backward
14313        // cannot produce a gradient. Reject at build time — and this must fail
14314        // on the dtype guard specifically: graph / shape / mask-Bool / value-dtype
14315        // are all satisfied, so a bare `is_err()` can only be the guard firing.
14316        let x = NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
14317        let scale = x.cast(DType::F8E8M0);
14318        let mask = x.cast(DType::Bool);
14319        let value = Scalar::one(DType::F8E8M0).unwrap();
14320
14321        let err = scale.masked_fill(&mask, value);
14322        assert!(
14323            err.is_err(),
14324            "masked_fill on a scale dtype must be a build-time Err"
14325        );
14326        let msg = format!("{}", err.unwrap_err());
14327        assert!(
14328            msg.contains("unsupported dtype") && msg.contains("masked_fill"),
14329            "must fail on the packed/scale dtype guard, got: {msg}",
14330        );
14331    }
14332
14333    /// GAP-075 fix B: pin the REJECT verdict for every unfillable dtype
14334    /// individually (not a bucket count). The guard is semantic — packed
14335    /// formats (F4/F6*) have no representable fill value; block-scales
14336    /// (F8E8M0/F8E6M2) have no zero for the zero-filling backward — so each must
14337    /// be a build-time Err on the dtype GUARD specifically (its message names
14338    /// the op). Per-dtype pinning protects the classification: a future dtype
14339    /// misfiled into the allow arm can't hide behind a passing tally, and a new
14340    /// dtype with no verdict is caught by the compiler (the match is exhaustive,
14341    /// no wildcard). Paired with the allow test below (opposite assertion), the
14342    /// two are mutually non-vacuous.
14343    #[test]
14344    fn masked_fill_rejects_every_unfillable_dtype() {
14345        for dt in [
14346            DType::F4,
14347            DType::F6E2M3,
14348            DType::F6E3M2,
14349            DType::F8E8M0,
14350            DType::F8E6M2,
14351        ] {
14352            let x =
14353                NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
14354            let t = x.cast(dt);
14355            let mask = x.cast(DType::Bool);
14356            // The dtype guard fires before the value-dtype check, so a dummy
14357            // value is fine; the message assertion proves it was the guard.
14358            let err = t.masked_fill(&mask, Scalar::F32(0.0));
14359            assert!(err.is_err(), "masked_fill must reject {dt:?} at build time");
14360            let msg = format!("{}", err.unwrap_err());
14361            assert!(
14362                msg.contains("unsupported dtype") && msg.contains("masked_fill"),
14363                "{dt:?} must fail on the semantic dtype guard, got: {msg}",
14364            );
14365        }
14366    }
14367
14368    /// GAP-075 fix B: the allow arm is a graph-build PERMISSION (masked_fill is
14369    /// semantically well-defined — a fill value + a zero), NOT a claim a backend
14370    /// can allocate/run the dtype. F32 is the live consumer path (attention
14371    /// masking); I32 is an allowed verdict no consumer currently exercises —
14372    /// locking it preserves today's answer without asserting integer masked_fill
14373    /// is known to compute correctly (a backend lacking I32 storage still
14374    /// declines loudly at allocation, downstream of this permission).
14375    #[test]
14376    fn masked_fill_permits_fillable_dtypes_as_permission_not_capability() {
14377        let x = NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
14378        let mask = x.cast(DType::Bool);
14379        assert!(
14380            x.masked_fill(&mask, Scalar::F32(0.0)).is_ok(),
14381            "F32 masked_fill must build (the live consumer path)",
14382        );
14383        let xi = x.cast(DType::I32);
14384        assert!(
14385            xi.masked_fill(&mask, Scalar::one(DType::I32).unwrap())
14386                .is_ok(),
14387            "I32 masked_fill must build — permission preserved, not a correctness claim",
14388        );
14389    }
14390
14391    #[test]
14392    #[should_panic(expected = "same graph")]
14393    fn rope_with_tables_panics_on_cross_graph_tables() {
14394        // Returns `NodeHandle`, so it follows the convention of the already-checked
14395        // builders (`matmul`, `paged_attn`) and asserts. A panic names the
14396        // problem at the build site; the status quo hangs later saying nothing.
14397        let x = NodeHandle::from_f32(vec![0.0_f32; 8], Shape::from_dims(&[1, 1, 2, 4]), cpu_dev())
14398            .unwrap();
14399        let cos =
14400            NodeHandle::from_f32(vec![1.0_f32; 8], Shape::from_dims(&[2, 4]), cpu_dev()).unwrap();
14401        let sin = x
14402            .const_f32_like(vec![0.0_f32; 8], Shape::from_dims(&[2, 4]))
14403            .unwrap();
14404        let _ = x.rope_with_tables(&cos, &sin);
14405    }
14406
14407    #[test]
14408    fn write_slice_rejects_slab_width_mismatch() {
14409        let dest =
14410            NodeHandle::from_f32(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]), cpu_dev()).unwrap();
14411        // Source has 2 elements along axis 0, but slab is width 1.
14412        let src = dest
14413            .const_f32_like(vec![1.0_f32; 6], Shape::from_dims(&[2, 3]))
14414            .unwrap();
14415        let err = dest.write_slice(&src, vec![(2, 3), (0, 3)]);
14416        assert!(err.is_err(), "slab-width mismatch must error");
14417    }
14418
14419    #[test]
14420    fn write_slice_rejects_range_past_dest_extent() {
14421        let dest =
14422            NodeHandle::from_f32(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]), cpu_dev()).unwrap();
14423        let src = dest
14424            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[1, 3]))
14425            .unwrap();
14426        // axis 0: dest extent is 4; range [4, 5) is out of bounds.
14427        let err = dest.write_slice(&src, vec![(4, 5), (0, 3)]);
14428        assert!(err.is_err(), "range past dest extent must error");
14429    }
14430
14431    #[test]
14432    fn write_slice_derives_ordering_after_other_readers() {
14433        // Graph:
14434        //   dest = ...; src = ...
14435        //   ro = dest.relu()                          (non-destructive reader of dest)
14436        //   w  = dest.write_slice(&src, [(0,1),(0,3)])  (destructive on dest)
14437        // Expected ordering: w must run after ro.
14438        let dest =
14439            NodeHandle::from_f32(vec![0.0_f32; 12], Shape::from_dims(&[4, 3]), cpu_dev()).unwrap();
14440        let src = dest
14441            .const_f32_like(vec![1.0, 2.0, 3.0], Shape::from_dims(&[1, 3]))
14442            .unwrap();
14443        let ro = dest.relu();
14444        let w = dest.write_slice(&src, vec![(0, 1), (0, 3)]).unwrap();
14445        let ord = crate::opt::derive_ordering(&dest.graph().read().unwrap(), &[ro.id(), w.id()]);
14446        let deps = ord.deps_of(w.id());
14447        assert_eq!(
14448            deps.len(),
14449            1,
14450            "write_slice should have one ordering dep (the relu)"
14451        );
14452        assert_eq!(
14453            deps[0],
14454            ro.id(),
14455            "write_slice must run after the non-destructive reader"
14456        );
14457    }
14458
14459    // ---- In-place ops Phase 1 -----------------------------------------------
14460    //
14461    // Smoke tests for the 5 new in-place unary variants
14462    // (`Op::ReluInplace`, `Op::SiluInplace`, `Op::GeluTanhInplace`,
14463    // `Op::TanhInplace`, `Op::SigmoidInplace`) and `FusedOps::INPLACE_AFFINE`.
14464    // Phase 1 only ships the structural plumbing (Op IR + destructive_input +
14465    // short_name + scheduler integration); dispatch + autograd land in Phases
14466    // 3 + 4. See `docs/session-prompts/in-place-ops-infrastructure.md`.
14467
14468    #[test]
14469    fn inplace_unary_destructive_input_is_zero() {
14470        for op in [
14471            Op::ReluInplace,
14472            Op::SiluInplace,
14473            Op::GeluTanhInplace,
14474            Op::TanhInplace,
14475            Op::SigmoidInplace,
14476            Op::NegInplace,
14477            Op::AbsInplace,
14478            Op::SqrInplace,
14479            Op::SqrtInplace,
14480            Op::RsqrtInplace,
14481            Op::RecipInplace,
14482            Op::ExpInplace,
14483            Op::LogInplace,
14484            Op::SinInplace,
14485            Op::CosInplace,
14486            Op::SignInplace,
14487            Op::FloorInplace,
14488            Op::CeilInplace,
14489            Op::RoundInplace,
14490            Op::ErfInplace,
14491            Op::GeluInplace,
14492        ] {
14493            assert_eq!(
14494                op.destructive_input(),
14495                Some(0),
14496                "in-place unary {op:?} must declare input 0 destructive",
14497            );
14498        }
14499    }
14500
14501    #[test]
14502    fn inplace_unary_short_names_round_trip() {
14503        assert_eq!(Op::ReluInplace.short_name(), "ReluInplace");
14504        assert_eq!(Op::SiluInplace.short_name(), "SiluInplace");
14505        assert_eq!(Op::GeluTanhInplace.short_name(), "GeluTanhInplace");
14506        assert_eq!(Op::TanhInplace.short_name(), "TanhInplace");
14507        assert_eq!(Op::SigmoidInplace.short_name(), "SigmoidInplace");
14508        assert_eq!(Op::NegInplace.short_name(), "NegInplace");
14509        assert_eq!(Op::AbsInplace.short_name(), "AbsInplace");
14510        assert_eq!(Op::SqrInplace.short_name(), "SqrInplace");
14511        assert_eq!(Op::SqrtInplace.short_name(), "SqrtInplace");
14512        assert_eq!(Op::RsqrtInplace.short_name(), "RsqrtInplace");
14513        assert_eq!(Op::RecipInplace.short_name(), "RecipInplace");
14514        assert_eq!(Op::ExpInplace.short_name(), "ExpInplace");
14515        assert_eq!(Op::LogInplace.short_name(), "LogInplace");
14516        assert_eq!(Op::SinInplace.short_name(), "SinInplace");
14517        assert_eq!(Op::CosInplace.short_name(), "CosInplace");
14518        assert_eq!(Op::SignInplace.short_name(), "SignInplace");
14519        assert_eq!(Op::FloorInplace.short_name(), "FloorInplace");
14520        assert_eq!(Op::CeilInplace.short_name(), "CeilInplace");
14521        assert_eq!(Op::RoundInplace.short_name(), "RoundInplace");
14522        assert_eq!(Op::ErfInplace.short_name(), "ErfInplace");
14523        assert_eq!(Op::GeluInplace.short_name(), "GeluInplace");
14524        assert_eq!(
14525            Op::ClampInplace { min: 0.0, max: 1.0 }.short_name(),
14526            "ClampInplace"
14527        );
14528        assert_eq!(Op::PowIInplace(3).short_name(), "PowIInplace");
14529    }
14530
14531    #[test]
14532    fn inplace_affine_fused_op_destructive_input_is_zero() {
14533        let op = Op::Fused(
14534            crate::registry::FusedOps::INPLACE_AFFINE,
14535            crate::registry::FusedOpParams::InplaceAffine { mul: 2.0, add: 1.0 },
14536        );
14537        assert_eq!(op.destructive_input(), Some(0));
14538    }
14539
14540    #[test]
14541    fn destructive_input_for_fused_is_registry_declared() {
14542        // GAP-076: a fused op's destructiveness is a MANDATORY declared field on
14543        // its registry entry, and `Op::destructive_input` reads that field via
14544        // `default_registry()` rather than a hardcoded id match that could drift
14545        // out of sync. This locks BOTH directions of the declaration plus the
14546        // routing that reads it. Regression lock, not a born-red: no op was
14547        // misclassified before the fix — its value is that a future wrong
14548        // declaration (or a reverted routing) reddens HERE instead of surfacing
14549        // as an intermittent scheduler data race that points nowhere near this
14550        // code. The exhaustive match in `Op::destructive_input` and the
14551        // mandatory struct field are the compile-time seams; this is the belt.
14552        use crate::registry::{FusedOps, default_registry};
14553        let reg = default_registry();
14554        assert_eq!(
14555            reg.entry(FusedOps::INPLACE_AFFINE)
14556                .expect("INPLACE_AFFINE is registered")
14557                .destructive_input,
14558            Some(0),
14559            "InplaceAffine must declare itself destructive on input 0",
14560        );
14561        assert_eq!(
14562            reg.entry(FusedOps::SOFTMAX_LAST_DIM)
14563                .expect("SOFTMAX_LAST_DIM is registered")
14564                .destructive_input,
14565            None,
14566            "a functional fused op must declare itself non-destructive",
14567        );
14568        // Routing reads the declared field (not a hardcoded id match).
14569        let op = Op::Fused(
14570            FusedOps::INPLACE_AFFINE,
14571            crate::registry::FusedOpParams::InplaceAffine { mul: 1.0, add: 0.0 },
14572        );
14573        assert_eq!(op.destructive_input(), Some(0));
14574    }
14575
14576    #[test]
14577    fn inplace_affine_params_key_dedupe_on_same_mul_add() {
14578        // Two `InplaceAffine` params with identical (mul, add) hash to the
14579        // same key; differing values produce distinct keys. Lets CSE / the
14580        // existing op_key infrastructure collapse identical in-place affines
14581        // (when callers wire them through the equivalent graph machinery).
14582        let a = crate::registry::FusedOpParams::InplaceAffine { mul: 2.0, add: 1.0 };
14583        let b = crate::registry::FusedOpParams::InplaceAffine { mul: 2.0, add: 1.0 };
14584        let c = crate::registry::FusedOpParams::InplaceAffine { mul: 2.0, add: 0.5 };
14585        assert_eq!(a.key(), b.key(), "identical mul/add must dedupe");
14586        assert_ne!(a.key(), c.key(), "different add must produce distinct key");
14587    }
14588
14589    // ---- Phase 4 — autograd through in-place ops ----
14590    //
14591    // The view-aware `derive_ordering` (Phase 4a) ensures backward
14592    // grad nodes that read forward inputs run BEFORE any in-place
14593    // mutation of those inputs. The backward arms in
14594    // `NodeHandle::backward` (Phase 4b) emit the same gradient graph as
14595    // the non-inplace cousins. These tests prove that calling
14596    // `.backward()` through an in-place node:
14597    //   (1) does NOT panic (was previously guarded by the Phase 1
14598    //       defensive panic);
14599    //   (2) emits the same gradient-node structure as the non-inplace
14600    //       cousin (matching node counts + op types as a structural
14601    //       proxy for numerical equivalence — the actual numerics are
14602    //       exercised by the live oracle tests in fuel-storage).
14603
14604    #[test]
14605    fn backward_through_relu_inplace_does_not_panic() {
14606        // y = x.relu_inplace(); loss = y.sum_all(); loss.backward()
14607        // Phase 1's defensive panic is removed; backward emits
14608        // Op::Step(x) + Op::Mul(upstream, step) — same as Op::Relu.
14609        let x =
14610            NodeHandle::from_f32(vec![1.0, -2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).unwrap();
14611        let y = x.relu_inplace();
14612        let loss = y.sum_all();
14613        let grads = loss.backward();
14614        let g_x = grads.get(&x).expect("gradient for x should be present");
14615        // Same structural form as Op::Relu's backward: gradient node
14616        // is an Op::Mul (upstream × step(x)).
14617        let g_node = g_x.graph().read().unwrap().node(g_x.id()).clone();
14618        assert!(
14619            matches!(g_node.op, Op::Mul),
14620            "relu backward grad is Mul; got {:?}",
14621            g_node.op
14622        );
14623    }
14624
14625    #[test]
14626    fn backward_through_sigmoid_inplace_does_not_panic() {
14627        let x = NodeHandle::from_f32(vec![0.5, -0.5], Shape::from_dims(&[2]), cpu_dev()).unwrap();
14628        let y = x.sigmoid_inplace();
14629        let loss = y.sum_all();
14630        let grads = loss.backward();
14631        let g_x = grads.get(&x);
14632        assert!(g_x.is_some());
14633    }
14634
14635    #[test]
14636    fn backward_through_tanh_inplace_does_not_panic() {
14637        let x =
14638            NodeHandle::from_f32(vec![0.1, 0.2, 0.3], Shape::from_dims(&[3]), cpu_dev()).unwrap();
14639        let y = x.tanh_inplace();
14640        let loss = y.sum_all();
14641        let grads = loss.backward();
14642        let g_x = grads.get(&x);
14643        assert!(g_x.is_some());
14644    }
14645
14646    #[test]
14647    fn backward_through_silu_inplace_does_not_panic() {
14648        let x = NodeHandle::from_f32(vec![0.1, 0.2], Shape::from_dims(&[2]), cpu_dev()).unwrap();
14649        let y = x.silu_inplace();
14650        let loss = y.sum_all();
14651        let grads = loss.backward();
14652        let g_x = grads.get(&x);
14653        assert!(g_x.is_some());
14654    }
14655
14656    /// The reverse walk has hand-written arms for some fused ids and lowers the
14657    /// rest through their total `decompose` first; WHICH ids take the second path
14658    /// is whatever the registry declares as `BackwardKind::Decompose`. Pin that
14659    /// set so adding or flipping one is a deliberate change that also adds its
14660    /// finite-difference test (SSM recipes: `fuel-tensor` scan tests;
14661    /// FusedSoftmaxCrossEntropy: `fuel-training`; NF4/AWQ matmul and causal
14662    /// conv1d: `fuel-tensor` `*_gradient_matches_finite_difference`).
14663    #[test]
14664    fn decompose_declared_fused_ops_are_exactly_the_tested_set() {
14665        use crate::registry::{BackwardKind, FusedOps, default_registry};
14666        let declared: Vec<_> = default_registry()
14667            .entries_iter()
14668            .filter(|e| e.backward == BackwardKind::Decompose)
14669            .map(|e| e.id)
14670            .collect();
14671        let expected = [
14672            FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY,
14673            FusedOps::SELECTIVE_SCAN,
14674            FusedOps::SSD_CHUNK_SCAN,
14675            FusedOps::NF4_MATMUL,
14676            FusedOps::AWQ_MATMUL,
14677            FusedOps::CAUSAL_CONV1D,
14678        ];
14679        assert_eq!(declared.len(), expected.len(), "declared: {declared:?}");
14680        for id in expected {
14681            assert!(declared.contains(&id), "{id:?} lost its Decompose backward");
14682            assert!(
14683                lowers_for_backward(id),
14684                "{id:?} not lowered by the pre-pass"
14685            );
14686        }
14687        // Control: a hand-armed op is not lowered.
14688        assert!(!lowers_for_backward(FusedOps::SOFTMAX_LAST_DIM));
14689    }
14690
14691    #[test]
14692    fn backward_through_gelu_tanh_inplace_does_not_panic() {
14693        // Numerics are checked against finite differences in fuel-tensor
14694        // (`gelu_tanh_inplace_backward_matches_finite_difference`).
14695        let x = NodeHandle::from_f32(vec![0.1], Shape::from_dims(&[1]), cpu_dev()).unwrap();
14696        let y = x.gelu_tanh_inplace();
14697        let loss = y.sum_all();
14698        let grads = loss.backward();
14699        assert!(grads.get(&x).is_some());
14700    }
14701
14702    #[test]
14703    fn backward_through_gelu_tanh_does_not_panic() {
14704        let x = NodeHandle::from_f32(vec![0.1], Shape::from_dims(&[1]), cpu_dev()).unwrap();
14705        let loss = x.gelu_tanh().sum_all();
14706        let grads = loss.backward();
14707        assert!(grads.get(&x).is_some());
14708    }
14709
14710    #[test]
14711    fn backward_through_relu_inplace_emits_same_graph_shape_as_functional() {
14712        // Structural parity: relu_inplace backward emits the same node
14713        // count + same op variants as relu backward, since the
14714        // gradient formula is identical.
14715        let x_inp =
14716            NodeHandle::from_f32(vec![1.0, -1.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
14717        let y_inp = x_inp.relu_inplace();
14718        let loss_inp = y_inp.sum_all();
14719        let nodes_before_inp = loss_inp.graph().read().unwrap().len();
14720        let _ = loss_inp.backward();
14721        let added_inp = loss_inp.graph().read().unwrap().len() - nodes_before_inp;
14722
14723        let x_fn =
14724            NodeHandle::from_f32(vec![1.0, -1.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
14725        let y_fn = x_fn.relu();
14726        let loss_fn = y_fn.sum_all();
14727        let nodes_before_fn = loss_fn.graph().read().unwrap().len();
14728        let _ = loss_fn.backward();
14729        let added_fn = loss_fn.graph().read().unwrap().len() - nodes_before_fn;
14730
14731        assert_eq!(
14732            added_inp, added_fn,
14733            "ReluInplace backward should add the same number of nodes as Relu backward ({added_fn}), got {added_inp}",
14734        );
14735    }
14736
14737    #[test]
14738    fn relu_inplace_builder_emits_op_reluinplace() {
14739        let x = NodeHandle::from_f32(
14740            vec![1.0_f32, -1.0, 2.0, -2.0],
14741            Shape::from_dims(&[4]),
14742            cpu_dev(),
14743        )
14744        .unwrap();
14745        let y = x.relu_inplace();
14746        let g = y.graph().read().unwrap();
14747        let node = g.node(y.id());
14748        assert!(matches!(node.op, Op::ReluInplace));
14749        assert_eq!(node.inputs, vec![x.id()]);
14750        assert_eq!(node.shape.dims(), &[4]);
14751        assert_eq!(node.dtype, DType::F32);
14752    }
14753
14754    #[test]
14755    fn affine_inplace_builder_emits_fused_inplace_affine() {
14756        let x = NodeHandle::from_f32(
14757            vec![1.0_f32, 2.0, 3.0, 4.0],
14758            Shape::from_dims(&[4]),
14759            cpu_dev(),
14760        )
14761        .unwrap();
14762        let y = x.affine_inplace(2.0, 0.5);
14763        let g = y.graph().read().unwrap();
14764        let node = g.node(y.id());
14765        match &node.op {
14766            Op::Fused(id, crate::registry::FusedOpParams::InplaceAffine { mul, add }) => {
14767                assert_eq!(*id, crate::registry::FusedOps::INPLACE_AFFINE);
14768                assert_eq!(*mul, 2.0);
14769                assert_eq!(*add, 0.5);
14770            }
14771            other => panic!("expected Op::Fused(INPLACE_AFFINE, InplaceAffine), got {other:?}"),
14772        }
14773        assert_eq!(node.inputs, vec![x.id()]);
14774        assert_eq!(node.shape.dims(), &[4]);
14775    }
14776
14777    #[test]
14778    fn all_inplace_unary_builders_round_trip_through_op_variants() {
14779        // One smoke per variant — each builder emits exactly the
14780        // matching `Op::*Inplace` variant, single input, shape +
14781        // dtype unchanged.
14782        let x = NodeHandle::from_f32(vec![0.5_f32; 8], Shape::from_dims(&[8]), cpu_dev()).unwrap();
14783        fn check(y: NodeHandle, x_id: NodeId, expect: fn(&Op) -> bool) {
14784            let g = y.graph().read().unwrap();
14785            let node = g.node(y.id());
14786            assert!(expect(&node.op), "wrong Op variant: {:?}", node.op);
14787            assert_eq!(node.inputs, vec![x_id]);
14788            assert_eq!(node.shape.dims(), &[8]);
14789            assert_eq!(node.dtype, DType::F32);
14790        }
14791        check(x.relu_inplace(), x.id(), |o| matches!(o, Op::ReluInplace));
14792        check(x.silu_inplace(), x.id(), |o| matches!(o, Op::SiluInplace));
14793        check(x.gelu_tanh_inplace(), x.id(), |o| {
14794            matches!(o, Op::GeluTanhInplace)
14795        });
14796        check(x.tanh_inplace(), x.id(), |o| matches!(o, Op::TanhInplace));
14797        check(x.sigmoid_inplace(), x.id(), |o| {
14798            matches!(o, Op::SigmoidInplace)
14799        });
14800        check(x.neg_inplace(), x.id(), |o| matches!(o, Op::NegInplace));
14801        check(x.abs_inplace(), x.id(), |o| matches!(o, Op::AbsInplace));
14802        check(x.sqr_inplace(), x.id(), |o| matches!(o, Op::SqrInplace));
14803        check(x.sqrt_inplace(), x.id(), |o| matches!(o, Op::SqrtInplace));
14804        check(x.rsqrt_inplace(), x.id(), |o| matches!(o, Op::RsqrtInplace));
14805        check(x.recip_inplace(), x.id(), |o| matches!(o, Op::RecipInplace));
14806        check(x.exp_inplace(), x.id(), |o| matches!(o, Op::ExpInplace));
14807        check(x.log_inplace(), x.id(), |o| matches!(o, Op::LogInplace));
14808        check(x.sin_inplace(), x.id(), |o| matches!(o, Op::SinInplace));
14809        check(x.cos_inplace(), x.id(), |o| matches!(o, Op::CosInplace));
14810        check(x.sign_inplace(), x.id(), |o| matches!(o, Op::SignInplace));
14811        check(x.floor_inplace(), x.id(), |o| matches!(o, Op::FloorInplace));
14812        check(x.ceil_inplace(), x.id(), |o| matches!(o, Op::CeilInplace));
14813        check(x.round_inplace(), x.id(), |o| matches!(o, Op::RoundInplace));
14814        check(x.erf_inplace(), x.id(), |o| matches!(o, Op::ErfInplace));
14815        check(x.gelu_inplace(), x.id(), |o| matches!(o, Op::GeluInplace));
14816        check(x.clamp_inplace(-1.0, 1.0), x.id(), |o| {
14817            matches!(o, Op::ClampInplace { .. })
14818        });
14819        check(x.powi_inplace(3), x.id(), |o| {
14820            matches!(o, Op::PowIInplace(3))
14821        });
14822    }
14823
14824    /// Backward smoke for the expanded in-place op family — each variant
14825    /// (except zero-grad ones) must produce a gradient node for x without
14826    /// panicking. Zero-grad variants (Sign/Floor/Ceil/Round) drop the
14827    /// gradient entirely (no entry in the GradMap), mirroring their
14828    /// non-inplace cousins.
14829    #[test]
14830    fn backward_through_expanded_inplace_unary_emits_grad() {
14831        fn check_emits_grad(make: impl FnOnce(&NodeHandle) -> NodeHandle) {
14832            let x =
14833                NodeHandle::from_f32(vec![0.5_f32, 1.5, 2.5], Shape::from_dims(&[3]), cpu_dev())
14834                    .unwrap();
14835            let y = make(&x);
14836            let loss = y.sum_all();
14837            let grads = loss.backward();
14838            assert!(grads.get(&x).is_some(), "backward must emit gradient for x");
14839        }
14840        check_emits_grad(|x| x.neg_inplace());
14841        check_emits_grad(|x| x.abs_inplace());
14842        check_emits_grad(|x| x.sqr_inplace());
14843        check_emits_grad(|x| x.sqrt_inplace());
14844        check_emits_grad(|x| x.rsqrt_inplace());
14845        check_emits_grad(|x| x.recip_inplace());
14846        check_emits_grad(|x| x.exp_inplace());
14847        check_emits_grad(|x| x.log_inplace());
14848        check_emits_grad(|x| x.sin_inplace());
14849        check_emits_grad(|x| x.cos_inplace());
14850        check_emits_grad(|x| x.erf_inplace());
14851        check_emits_grad(|x| x.gelu_inplace());
14852        check_emits_grad(|x| x.clamp_inplace(-1.0, 2.0));
14853        check_emits_grad(|x| x.powi_inplace(3));
14854    }
14855
14856    #[test]
14857    fn backward_through_zero_grad_inplace_drops_gradient() {
14858        fn check_drops(make: impl FnOnce(&NodeHandle) -> NodeHandle) {
14859            let x =
14860                NodeHandle::from_f32(vec![1.5_f32, 2.5, -3.5], Shape::from_dims(&[3]), cpu_dev())
14861                    .unwrap();
14862            let y = make(&x);
14863            let loss = y.sum_all();
14864            let grads = loss.backward();
14865            assert!(
14866                grads.get(&x).is_none(),
14867                "zero-grad in-place op must not emit gradient"
14868            );
14869        }
14870        check_drops(|x| x.sign_inplace());
14871        check_drops(|x| x.floor_inplace());
14872        check_drops(|x| x.ceil_inplace());
14873        check_drops(|x| x.round_inplace());
14874    }
14875
14876    #[test]
14877    fn inplace_unary_derives_ordering_after_non_destructive_readers() {
14878        // Graph:
14879        //   x   = const f32 [4]
14880        //   y_a = x.relu()                  (non-destructive reader of x)
14881        //   y_b = manually-pushed Op::ReluInplace on x  (destructive)
14882        // Expected: y_b must run after y_a.
14883        let x = NodeHandle::from_f32(
14884            vec![1.0_f32, -1.0, 2.0, -2.0],
14885            Shape::from_dims(&[4]),
14886            cpu_dev(),
14887        )
14888        .unwrap();
14889        let y_a = x.relu();
14890        let y_b_id = x.graph().write().unwrap().push(Node {
14891            op: Op::ReluInplace,
14892            inputs: vec![x.id()],
14893            shape: Shape::from_dims(&[4]),
14894            dtype: DType::F32,
14895        });
14896        let ord = crate::opt::derive_ordering(&x.graph().read().unwrap(), &[y_a.id(), y_b_id]);
14897        let deps = ord.deps_of(y_b_id);
14898        assert_eq!(deps.len(), 1, "in-place unary should have one ordering dep");
14899        assert_eq!(
14900            deps[0],
14901            y_a.id(),
14902            "in-place unary must run after the non-destructive reader"
14903        );
14904    }
14905
14906    // ------------------------------------------------------------------
14907    // Multi-output nodes (Option C, Session 1)
14908    // ------------------------------------------------------------------
14909
14910    use fuel_backend_contract::storage::Storage as CoreStorage;
14911    use fuel_ir::storage::OutputView;
14912
14913    /// Build a real CPU `Storage` of `n` F32 zeros so `Storage`-level
14914    /// tests can attach + read bundle metadata against a live backend
14915    /// allocator. The dtype is fixed at F32 because every existing
14916    /// `OutputView` test below uses F32 for slot 0; bundle dtype is
14917    /// validated at `with_bundle` time against this primary dtype.
14918    fn cpu_f32_storage(n: usize) -> CoreStorage {
14919        let dev = cpu_dev();
14920        let buf = fuel_ir::HostBuffer::F32(vec![0.0_f32; n]);
14921        let inner = dev
14922            .storage_from_host_buffer_owned_dyn(buf)
14923            .expect("test fixture: F32 storage_from_host_buffer_owned_dyn failed");
14924        CoreStorage::from_dyn(inner)
14925    }
14926
14927    /// Build a 2-slot bundle: slot 0 `[2, 3]` F32 `y`-shape, slot 1
14928    /// `[2, 4]` F32 `last_state`-shape. Returns the views and the
14929    /// total element count (used to size the backing CPU buffer so
14930    /// slot 1's byte range stays inside the bundle's bytes).
14931    fn two_slot_views() -> (Vec<OutputView>, usize) {
14932        let s0 = Shape::from_dims(&[2, 3]);
14933        let s1 = Shape::from_dims(&[2, 4]);
14934        let v0 = OutputView {
14935            byte_offset: 0,
14936            len_elements: s0.elem_count(),
14937            dtype: DType::F32,
14938            shape: s0.clone(),
14939            layout: Layout::contiguous(s0),
14940            name: Some("y"),
14941        };
14942        let v1 = OutputView {
14943            byte_offset: v0.len_bytes(),
14944            len_elements: s1.elem_count(),
14945            dtype: DType::F32,
14946            shape: s1.clone(),
14947            layout: Layout::contiguous(s1),
14948            name: Some("last_state"),
14949        };
14950        let total = v0.len_elements + v1.len_elements;
14951        (vec![v0, v1], total)
14952    }
14953
14954    /// `Storage::is_bundled` is false by default; `slot_count` is 1;
14955    /// `bundle` / `bundle_arc` / `slot_view` / `slot_dtype` all return
14956    /// `None` for the non-multi-output case.
14957    #[test]
14958    fn storage_single_output_defaults() {
14959        let s = cpu_f32_storage(8);
14960        assert!(!s.is_bundled());
14961        assert_eq!(s.slot_count(), 1);
14962        assert!(s.bundle().is_none());
14963        assert!(s.bundle_arc().is_none());
14964        assert!(s.slot_view(0).is_none());
14965        assert_eq!(s.slot_dtype(0), None);
14966        assert_eq!(s.primary_dtype(), DType::F32);
14967        assert_eq!(s.dtype(), DType::F32);
14968    }
14969
14970    /// `Storage::with_bundle` attaches the side-table and per-slot
14971    /// lookups report the declared shape/dtype.
14972    #[test]
14973    fn storage_with_bundle_attaches_views() {
14974        let (views, total) = two_slot_views();
14975        let s = cpu_f32_storage(total)
14976            .with_bundle(views.clone().into())
14977            .expect("with_bundle should accept matching slot-0 dtype");
14978        assert!(s.is_bundled());
14979        assert_eq!(s.slot_count(), 2);
14980        let slot0 = s.slot_view(0).unwrap();
14981        assert_eq!(slot0.shape, Shape::from_dims(&[2, 3]));
14982        assert_eq!(slot0.dtype, DType::F32);
14983        assert_eq!(slot0.byte_offset, 0);
14984        assert_eq!(slot0.name, Some("y"));
14985        let slot1 = s.slot_view(1).unwrap();
14986        assert_eq!(slot1.shape, Shape::from_dims(&[2, 4]));
14987        assert_eq!(slot1.byte_offset, 24); // 6 F32s = 24 bytes
14988        assert_eq!(slot1.name, Some("last_state"));
14989        // Out-of-range slot reads `None`, doesn't panic.
14990        assert!(s.slot_view(2).is_none());
14991        assert_eq!(s.slot_dtype(2), None);
14992    }
14993
14994    /// `Storage::with_bundle` rejects an empty bundle slice (a
14995    /// zero-slot bundle is a contract bug — use a single-output
14996    /// Storage if there's nothing to project).
14997    #[test]
14998    fn storage_with_bundle_rejects_empty() {
14999        let s = cpu_f32_storage(1);
15000        let empty: Arc<[OutputView]> = Arc::from(Vec::<OutputView>::new().into_boxed_slice());
15001        let err = s
15002            .with_bundle(empty)
15003            .expect_err("empty bundle slice must error");
15004        let msg = format!("{err}");
15005        assert!(msg.contains("non-empty"), "error message: {msg}");
15006    }
15007
15008    /// `Storage::with_bundle` rejects slot 0 dtype that disagrees with
15009    /// the inner backend dtype — the bundle invariant "slot 0 dtype ==
15010    /// primary_dtype()" must hold.
15011    #[test]
15012    fn storage_with_bundle_rejects_dtype_mismatch() {
15013        let s = cpu_f32_storage(8);
15014        let bad = OutputView {
15015            byte_offset: 0,
15016            len_elements: 4,
15017            dtype: DType::F64,
15018            shape: Shape::from_dims(&[2, 2]),
15019            layout: Layout::contiguous(Shape::from_dims(&[2, 2])),
15020            name: None,
15021        };
15022        let err = s
15023            .with_bundle(Arc::from(vec![bad].into_boxed_slice()))
15024            .expect_err("slot 0 dtype mismatch must error");
15025        let msg = format!("{err}");
15026        assert!(msg.contains("dtype"), "error message: {msg}");
15027    }
15028
15029    /// `Graph::output_views` is `None` for ordinary single-output
15030    /// nodes; `multi_output_count` is 0.
15031    #[test]
15032    fn graph_output_views_defaults() {
15033        let mut g = Graph::new();
15034        let id = g.push(Node {
15035            op: Op::Const,
15036            inputs: vec![],
15037            shape: Shape::from_dims(&[2, 3]),
15038            dtype: DType::F32,
15039        });
15040        assert!(g.output_views(id).is_none());
15041        assert!(!g.is_multi_output(id));
15042        assert_eq!(g.multi_output_count(), 0);
15043    }
15044
15045    /// `Graph::set_output_views` round-trips: read returns what was
15046    /// written; `is_multi_output` flips.
15047    #[test]
15048    fn graph_set_output_views_roundtrip() {
15049        let mut g = Graph::new();
15050        let id = g.push(Node {
15051            op: Op::Const,
15052            inputs: vec![],
15053            shape: Shape::from_dims(&[2, 3]),
15054            dtype: DType::F32,
15055        });
15056        let (views, _) = two_slot_views();
15057        g.set_output_views(id, views.clone().into())
15058            .expect("valid 2-slot specs");
15059        assert!(g.is_multi_output(id));
15060        assert_eq!(g.multi_output_count(), 1);
15061        let read = g.output_views(id).expect("entry present");
15062        assert_eq!(read.len(), 2);
15063        assert_eq!(read[1].shape, Shape::from_dims(&[2, 4]));
15064    }
15065
15066    /// `Graph::set_output_views` rejects slot 0 dtype/shape that
15067    /// disagree with the producer node's primary shape/dtype.
15068    #[test]
15069    fn graph_set_output_views_validates_primary() {
15070        let mut g = Graph::new();
15071        let id = g.push(Node {
15072            op: Op::Const,
15073            inputs: vec![],
15074            shape: Shape::from_dims(&[2, 3]),
15075            dtype: DType::F32,
15076        });
15077        // dtype mismatch on slot 0
15078        let bad_dtype = OutputView {
15079            byte_offset: 0,
15080            len_elements: 6,
15081            dtype: DType::F64,
15082            shape: Shape::from_dims(&[2, 3]),
15083            layout: Layout::contiguous(Shape::from_dims(&[2, 3])),
15084            name: None,
15085        };
15086        let err = g
15087            .set_output_views(id, vec![bad_dtype].into())
15088            .expect_err("primary dtype mismatch must error");
15089        let msg = format!("{err}");
15090        assert!(msg.contains("dtype"), "error message: {msg}");
15091        // shape mismatch on slot 0
15092        let bad_shape = OutputView {
15093            byte_offset: 0,
15094            len_elements: 4,
15095            dtype: DType::F32,
15096            shape: Shape::from_dims(&[2, 2]),
15097            layout: Layout::contiguous(Shape::from_dims(&[2, 2])),
15098            name: None,
15099        };
15100        let err = g
15101            .set_output_views(id, vec![bad_shape].into())
15102            .expect_err("primary shape mismatch must error");
15103        let msg = format!("{err}");
15104        assert!(msg.contains("shape"), "error message: {msg}");
15105    }
15106
15107    /// `NodeHandle::view(slot)` against a producer that hasn't been
15108    /// declared multi-output returns Err — fails fast at build time.
15109    #[test]
15110    fn tensor_view_on_non_multi_output_errors() {
15111        let graph: SharedGraph = Arc::new(RwLock::new(Graph::new()));
15112        let id = graph.write().unwrap().push(Node {
15113            op: Op::Const,
15114            inputs: vec![],
15115            shape: Shape::from_dims(&[2, 3]),
15116            dtype: DType::F32,
15117        });
15118        let producer = NodeHandle::from_existing(Arc::clone(&graph), id);
15119        let err = producer
15120            .view(0)
15121            .expect_err("view on non-multi-output producer must error");
15122        let msg = format!("{err}");
15123        assert!(msg.contains("multi-output"), "error message: {msg}");
15124    }
15125
15126    /// `NodeHandle::view(slot)` with slot OOB returns Err.
15127    #[test]
15128    fn tensor_view_oob_slot_errors() {
15129        let graph: SharedGraph = Arc::new(RwLock::new(Graph::new()));
15130        let producer_id = {
15131            let mut g = graph.write().unwrap();
15132            let id = g.push(Node {
15133                op: Op::Const,
15134                inputs: vec![],
15135                shape: Shape::from_dims(&[2, 3]),
15136                dtype: DType::F32,
15137            });
15138            let (views, _) = two_slot_views();
15139            g.set_output_views(id, views.into())
15140                .expect("valid 2-slot specs");
15141            id
15142        };
15143        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15144        let err = producer
15145            .view(2)
15146            .expect_err("slot 2 of a 2-slot producer must error");
15147        let msg = format!("{err}");
15148        assert!(msg.contains("out of range"), "error message: {msg}");
15149    }
15150
15151    /// `NodeHandle::view(slot)` produces a tensor whose shape/dtype match
15152    /// the slot spec; producer's primary shape stays as slot 0's. The
15153    /// View node holds the producer in `inputs[0]`.
15154    #[test]
15155    fn tensor_view_shape_dtype_from_slot() {
15156        let graph: SharedGraph = Arc::new(RwLock::new(Graph::new()));
15157        let producer_id = {
15158            let mut g = graph.write().unwrap();
15159            let id = g.push(Node {
15160                op: Op::Const,
15161                inputs: vec![],
15162                shape: Shape::from_dims(&[2, 3]),
15163                dtype: DType::F32,
15164            });
15165            let (views, _) = two_slot_views();
15166            g.set_output_views(id, views.into())
15167                .expect("valid 2-slot specs");
15168            id
15169        };
15170        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15171        let v0 = producer.view(0).expect("slot 0 view");
15172        assert_eq!(v0.shape(), Shape::from_dims(&[2, 3]));
15173        assert_eq!(v0.dtype(), DType::F32);
15174        let v1 = producer.view(1).expect("slot 1 view");
15175        assert_eq!(v1.shape(), Shape::from_dims(&[2, 4]));
15176        assert_eq!(v1.dtype(), DType::F32);
15177        // View node carries the producer in inputs[0] and the slot
15178        // index in the Op variant — verify the Graph state directly.
15179        let g = graph.read().unwrap();
15180        let v1_node = g.node(v1.id());
15181        assert_eq!(v1_node.inputs, vec![producer_id]);
15182        assert!(matches!(v1_node.op, Op::View { slot: 1 }));
15183        // Slot 0 starts at byte_offset 0 + contiguous → layout
15184        // matches Layout::contiguous(slot0.shape) → no side-table
15185        // entry (the contiguous fallback covers it).
15186        assert!(!g.has_explicit_layout(v0.id()));
15187        // Slot 1 starts at byte_offset 24 (= 6 F32 elements past
15188        // slot 0). Session 4 bakes that into the layout's
15189        // start_offset so a downstream kernel reading the producer's
15190        // bytes as F32 elements lands on slot 1's first byte. Side-
15191        // table entry is mandatory.
15192        assert!(g.has_explicit_layout(v1.id()));
15193        let l1 = g.layout(v1.id());
15194        assert_eq!(
15195            l1.start_offset(),
15196            6,
15197            "slot 1 byte_offset 24 / 4 = 6 F32 elements"
15198        );
15199        assert_eq!(l1.shape(), &Shape::from_dims(&[2, 4]));
15200    }
15201
15202    /// `NodeHandle::view(slot)` over a slot whose layout is non-contiguous
15203    /// (strided) populates the Graph's layout side-table with the
15204    /// slot's layout — so downstream consumers see the strided view.
15205    #[test]
15206    fn tensor_view_propagates_non_contiguous_slot_layout() {
15207        let graph: SharedGraph = Arc::new(RwLock::new(Graph::new()));
15208        let strided_shape = Shape::from_dims(&[2, 3]);
15209        let strided_layout = Layout::contiguous(strided_shape.clone())
15210            .transpose(0, 1)
15211            .expect("transpose [2,3] -> [3,2]");
15212        // Slot 0 = primary (contiguous, matches producer's Node shape).
15213        let s0 = OutputView {
15214            byte_offset: 0,
15215            len_elements: strided_shape.elem_count(),
15216            dtype: DType::F32,
15217            shape: strided_shape.clone(),
15218            layout: Layout::contiguous(strided_shape.clone()),
15219            name: Some("primary"),
15220        };
15221        // Slot 1 = strided projection. Same byte range as slot 0; the
15222        // shape after transpose is [3, 2]; layout's start offset stays
15223        // within the slot.
15224        let s1 = OutputView {
15225            byte_offset: 0,
15226            len_elements: strided_shape.elem_count(),
15227            dtype: DType::F32,
15228            shape: Shape::from_dims(&[3, 2]),
15229            layout: strided_layout.clone(),
15230            name: Some("strided_view"),
15231        };
15232        let producer_id = {
15233            let mut g = graph.write().unwrap();
15234            let id = g.push(Node {
15235                op: Op::Const,
15236                inputs: vec![],
15237                shape: strided_shape.clone(),
15238                dtype: DType::F32,
15239            });
15240            g.set_output_views(id, vec![s0, s1].into())
15241                .expect("valid 2-slot strided spec");
15242            id
15243        };
15244        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15245        let v1 = producer.view(1).expect("strided slot view");
15246        let g = graph.read().unwrap();
15247        assert!(
15248            g.has_explicit_layout(v1.id()),
15249            "strided slot must populate Graph::layouts side-table",
15250        );
15251        let read_layout = g.layout(v1.id());
15252        assert_eq!(read_layout.shape(), strided_layout.shape());
15253        assert_eq!(read_layout.stride(), strided_layout.stride());
15254    }
15255
15256    /// `NodeHandle::view_owned(slot)` produces the same shape/dtype as
15257    /// `NodeHandle::view(slot)` but its output layout is ALWAYS contiguous
15258    /// — the forward memcpy produces a fresh standalone buffer, so
15259    /// the slot's (possibly strided) layout is not propagated.
15260    #[test]
15261    fn tensor_view_owned_layout_is_contiguous() {
15262        let graph: SharedGraph = Arc::new(RwLock::new(Graph::new()));
15263        let strided_shape = Shape::from_dims(&[2, 3]);
15264        let strided_layout = Layout::contiguous(strided_shape.clone())
15265            .transpose(0, 1)
15266            .expect("transpose [2,3] -> [3,2]");
15267        let s0 = OutputView {
15268            byte_offset: 0,
15269            len_elements: strided_shape.elem_count(),
15270            dtype: DType::F32,
15271            shape: strided_shape.clone(),
15272            layout: Layout::contiguous(strided_shape.clone()),
15273            name: None,
15274        };
15275        let s1 = OutputView {
15276            byte_offset: 0,
15277            len_elements: strided_shape.elem_count(),
15278            dtype: DType::F32,
15279            shape: Shape::from_dims(&[3, 2]),
15280            layout: strided_layout,
15281            name: None,
15282        };
15283        let producer_id = {
15284            let mut g = graph.write().unwrap();
15285            let id = g.push(Node {
15286                op: Op::Const,
15287                inputs: vec![],
15288                shape: strided_shape,
15289                dtype: DType::F32,
15290            });
15291            g.set_output_views(id, vec![s0, s1].into())
15292                .expect("valid 2-slot specs");
15293            id
15294        };
15295        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15296        let v1 = producer.view_owned(1).expect("ViewOwned slot 1");
15297        assert_eq!(v1.shape(), Shape::from_dims(&[3, 2]));
15298        assert_eq!(v1.dtype(), DType::F32);
15299        let g = graph.read().unwrap();
15300        // ViewOwned is a fresh allocation — no explicit layout entry,
15301        // contiguous layout falls through.
15302        assert!(!g.has_explicit_layout(v1.id()));
15303        let owned_node = g.node(v1.id());
15304        assert!(matches!(owned_node.op, Op::ViewOwned { slot: 1 }));
15305    }
15306
15307    // ------------------------------------------------------------------
15308    // Session 2: bundled allocator + FusedOpEntry::output_views +
15309    // View-vs-ViewOwned planner pass. The tests use the bundled
15310    // storage allocator and a synthetic 2-output FusedOp entry — no
15311    // real fused-op author migrates in Session 2; the
15312    // selective-scan-ssd-chunk-multi-output-followup session lights
15313    // up the first real consumer.
15314    // ------------------------------------------------------------------
15315
15316    use fuel_backend_contract::storage::allocate_bundled_storage;
15317    use fuel_ir::storage::OutputViewSpec;
15318
15319    /// `allocate_bundled_storage` allocates one Storage on the device,
15320    /// attaches the bundle, and slot lookups report the spec-derived
15321    /// byte_offsets.
15322    #[test]
15323    fn allocate_bundled_storage_two_slot_roundtrip() {
15324        let dev = cpu_dev();
15325        let specs = vec![
15326            OutputViewSpec::contiguous(DType::F32, Shape::from_dims(&[2, 3])),
15327            OutputViewSpec::contiguous(DType::F32, Shape::from_dims(&[2, 4])),
15328        ];
15329        let s =
15330            allocate_bundled_storage(dev.as_ref(), &specs).expect("two-slot F32 alloc succeeds");
15331        assert!(s.is_bundled());
15332        assert_eq!(s.slot_count(), 2);
15333        assert_eq!(s.primary_dtype(), DType::F32);
15334        let v0 = s.slot_view(0).unwrap();
15335        let v1 = s.slot_view(1).unwrap();
15336        assert_eq!(v0.byte_offset, 0);
15337        assert_eq!(v0.shape, Shape::from_dims(&[2, 3]));
15338        assert_eq!(v1.byte_offset, 24);
15339        assert_eq!(v1.shape, Shape::from_dims(&[2, 4]));
15340    }
15341
15342    /// `allocate_bundled_storage` handles mixed F32 + F64 slots — slot
15343    /// 0 is primary (F32), slot 1's bytes are aligned to its F64
15344    /// boundary, and the underlying allocation has enough flat F32
15345    /// elements to cover the total bundle bytes.
15346    #[test]
15347    fn allocate_bundled_storage_mixed_dtype() {
15348        let dev = cpu_dev();
15349        let specs = vec![
15350            OutputViewSpec::contiguous(DType::F32, Shape::from_dims(&[6])),
15351            OutputViewSpec::contiguous(DType::F64, Shape::from_dims(&[3])),
15352        ];
15353        let s = allocate_bundled_storage(dev.as_ref(), &specs).expect("mixed-dtype alloc succeeds");
15354        assert_eq!(s.primary_dtype(), DType::F32);
15355        let v0 = s.slot_view(0).unwrap();
15356        let v1 = s.slot_view(1).unwrap();
15357        assert_eq!(v0.dtype, DType::F32);
15358        assert_eq!(v1.dtype, DType::F64);
15359        assert_eq!(v0.byte_offset, 0);
15360        // F32[6] = 24 bytes; 24 is already multiple of 8, so v1
15361        // starts at 24 without further padding.
15362        assert_eq!(v1.byte_offset, 24);
15363    }
15364
15365    /// `allocate_bundled_storage` propagates compose_bundle errors —
15366    /// an empty spec list errors out before touching the device.
15367    #[test]
15368    fn allocate_bundled_storage_rejects_empty() {
15369        let dev = cpu_dev();
15370        let err = allocate_bundled_storage(dev.as_ref(), &[]).expect_err("empty specs error");
15371        assert!(format!("{err}").contains("non-empty"));
15372    }
15373
15374    /// The two SelectiveScan / SsdChunkScan migrations from item 3
15375    /// (2026-06-01) are the first `default_registry` entries to
15376    /// declare `output_views`. Any new multi-output op author should
15377    /// extend the expected set here.
15378    #[test]
15379    fn default_registry_multi_output_entries() {
15380        let reg = crate::registry::default_registry();
15381        let mut multi_out_names: Vec<&'static str> = reg
15382            .entries_iter()
15383            .filter(|e| e.output_views.is_some())
15384            .map(|e| e.name)
15385            .collect();
15386        multi_out_names.sort();
15387        assert_eq!(
15388            multi_out_names,
15389            vec!["SelectiveScan", "SsdChunkScan"],
15390            "the multi-output registry set should match the item-3 \
15391             migrations (SelectiveScan + SsdChunkScan); add new \
15392             multi-output ops to this list as they migrate",
15393        );
15394    }
15395
15396    /// Authoring-contract invariant: for every multi-output entry in
15397    /// the process-wide registry, `shape_rule(inputs)` MUST equal
15398    /// `output_views(inputs)[0].shape` and `dtype_rule(inputs)` MUST
15399    /// equal `output_views(inputs)[0].dtype`. Slot 0 is the primary
15400    /// and the bundled-allocation contract assumes the producer's
15401    /// `Node.shape`/`Node.dtype` reflect it.
15402    #[test]
15403    fn multi_output_entries_slot_0_matches_primary_rules() {
15404        use crate::registry::FusedOpParams;
15405        let reg = crate::registry::default_registry();
15406
15407        // Each multi-output entry needs a representative input batch
15408        // (shapes + dtypes + params) that satisfies its
15409        // shape_rule/dtype_rule preconditions. The check below runs
15410        // once per entry using these fixtures.
15411        struct Fixture {
15412            shapes: Vec<Shape>,
15413            dtypes: Vec<DType>,
15414            params: FusedOpParams,
15415        }
15416        fn selective_scan_fixture() -> Fixture {
15417            Fixture {
15418                shapes: vec![
15419                    Shape::from_dims(&[2, 4, 8]),  // u: [batch, seqlen, dim]
15420                    Shape::from_dims(&[2, 4, 8]),  // delta: same
15421                    Shape::from_dims(&[8, 16]),    // a: [dim, dstate]
15422                    Shape::from_dims(&[2, 4, 16]), // b: [batch, seqlen, dstate]
15423                    Shape::from_dims(&[2, 4, 16]), // c: same
15424                ],
15425                dtypes: vec![DType::F32; 5],
15426                params: FusedOpParams::SelectiveScan {
15427                    delta_softplus: false,
15428                },
15429            }
15430        }
15431        fn ssd_chunk_scan_fixture() -> Fixture {
15432            Fixture {
15433                shapes: vec![
15434                    Shape::from_dims(&[2, 4, 3, 8]), // x: [batch, seqlen, heads, head_dim]
15435                    Shape::from_dims(&[2, 4, 3]),    // dt: [batch, seqlen, heads]
15436                    Shape::from_dims(&[3]),          // a: [heads]
15437                    Shape::from_dims(&[2, 4, 3, 16]), // b: [batch, seqlen, heads, state_dim]
15438                    Shape::from_dims(&[2, 4, 3, 16]), // c: same
15439                ],
15440                dtypes: vec![DType::F32; 5],
15441                params: FusedOpParams::SsdChunkScan { chunk_size: 4 },
15442            }
15443        }
15444
15445        for entry in reg.entries_iter().filter(|e| e.output_views.is_some()) {
15446            let fx = match entry.name {
15447                "SelectiveScan" => selective_scan_fixture(),
15448                "SsdChunkScan" => ssd_chunk_scan_fixture(),
15449                other => panic!(
15450                    "multi_output_entries_slot_0_matches_primary_rules: \
15451                     no fixture for new multi-output entry {other:?}. \
15452                     Add one to this test as you register a new op.",
15453                ),
15454            };
15455            let primary_shape = (entry.shape_rule)(&fx.shapes, &fx.params);
15456            let primary_dtype = (entry.dtype_rule)(&fx.dtypes, &fx.params);
15457            let specs = entry.output_views.unwrap()(&fx.shapes, &fx.dtypes, &fx.params);
15458            assert!(
15459                !specs.is_empty(),
15460                "{}: output_views returned empty Vec (multi-output ops \
15461                 must have ≥ 1 slot)",
15462                entry.name,
15463            );
15464            assert_eq!(
15465                specs[0].shape, primary_shape,
15466                "{}: output_views[0].shape {:?} must equal shape_rule \
15467                 {:?} (slot 0 is the primary; Node::shape reflects it)",
15468                entry.name, specs[0].shape, primary_shape,
15469            );
15470            assert_eq!(
15471                specs[0].dtype, primary_dtype,
15472                "{}: output_views[0].dtype {:?} must equal dtype_rule \
15473                 {:?} (slot 0 is the primary; Node::dtype reflects it)",
15474                entry.name, specs[0].dtype, primary_dtype,
15475            );
15476            // Layout-shape coherence: each slot's layout shape must
15477            // equal its declared shape (the same invariant
15478            // set_output_views enforces at graph-build time).
15479            for (i, spec) in specs.iter().enumerate() {
15480                assert_eq!(
15481                    spec.layout.shape(),
15482                    &spec.shape,
15483                    "{}: slot {i} layout.shape() {:?} disagrees with \
15484                     declared spec.shape {:?}",
15485                    entry.name,
15486                    spec.layout.shape(),
15487                    spec.shape,
15488                );
15489            }
15490        }
15491    }
15492
15493    /// Build a synthetic multi-output `FusedOpEntry` to exercise the
15494    /// authoring contract end-to-end without polluting the production
15495    /// registry. The output_views fn returns a 2-slot spec list whose
15496    /// slot 0 matches shape_rule / dtype_rule (the primary).
15497    fn synthetic_two_output_entry() -> crate::registry::FusedOpEntry {
15498        fn shape(_: &[Shape], _: &crate::registry::FusedOpParams) -> Shape {
15499            Shape::from_dims(&[2, 3])
15500        }
15501        fn dtype(_: &[DType], _: &crate::registry::FusedOpParams) -> DType {
15502            DType::F32
15503        }
15504        fn ovs(
15505            _: &[Shape],
15506            _: &[DType],
15507            _: &crate::registry::FusedOpParams,
15508        ) -> Vec<OutputViewSpec> {
15509            vec![
15510                OutputViewSpec::contiguous(DType::F32, Shape::from_dims(&[2, 3])),
15511                OutputViewSpec::contiguous(DType::F32, Shape::from_dims(&[2, 4])),
15512            ]
15513        }
15514        crate::registry::FusedOpEntry {
15515            destructive_input: None,
15516            id: crate::registry::FusedOps::SOFTMAX_LAST_DIM, // any id; not registered
15517            name: "<synthetic-2-output>",
15518            family: crate::registry::FusedOpFamily::Forward,
15519            pattern: crate::registry::SubgraphPattern::Callable(|_g, _id| None),
15520            decompose: |_g, id, _p| id,
15521            backward: crate::registry::BackwardKind::NotDifferentiable,
15522            shape_rule: shape,
15523            dtype_rule: dtype,
15524            output_views: Some(ovs),
15525        }
15526    }
15527
15528    /// The synthetic entry's `output_views` matches the
15529    /// `shape_rule`/`dtype_rule` contract on slot 0.
15530    #[test]
15531    fn synthetic_entry_output_views_matches_primary_contract() {
15532        let entry = synthetic_two_output_entry();
15533        let ovs_fn = entry.output_views.expect("synthetic entry is multi-output");
15534        let specs = ovs_fn(&[], &[], &crate::registry::FusedOpParams::SoftmaxLastDim);
15535        assert_eq!(specs.len(), 2);
15536        assert_eq!(
15537            specs[0].dtype,
15538            (entry.dtype_rule)(&[], &crate::registry::FusedOpParams::SoftmaxLastDim)
15539        );
15540        assert_eq!(
15541            specs[0].shape,
15542            (entry.shape_rule)(&[], &crate::registry::FusedOpParams::SoftmaxLastDim)
15543        );
15544    }
15545
15546    /// Build a producer Const node and declare it multi-output via
15547    /// `Graph::set_output_views`. Returns the producer's NodeId and a
15548    /// SharedGraph handle.
15549    fn multi_output_producer(slots: Vec<OutputView>) -> (SharedGraph, NodeId) {
15550        let graph: SharedGraph = Arc::new(RwLock::new(Graph::new()));
15551        let id = {
15552            let mut g = graph.write().unwrap();
15553            let id = g.push(Node {
15554                op: Op::Const,
15555                inputs: vec![],
15556                shape: slots[0].shape.clone(),
15557                dtype: slots[0].dtype,
15558            });
15559            g.set_output_views(id, slots.into())
15560                .expect("valid synthetic specs");
15561            id
15562        };
15563        (graph, id)
15564    }
15565
15566    /// Two slots, both consumed at the same topo depth → planner does
15567    /// nothing (symmetric lifetimes, bundle drops naturally).
15568    #[test]
15569    fn promote_views_symmetric_lifetimes_is_noop() {
15570        let slot_specs = vec![
15571            OutputView {
15572                byte_offset: 0,
15573                len_elements: 6,
15574                dtype: DType::F32,
15575                shape: Shape::from_dims(&[2, 3]),
15576                layout: Layout::contiguous(Shape::from_dims(&[2, 3])),
15577                name: Some("y"),
15578            },
15579            OutputView {
15580                byte_offset: 24,
15581                len_elements: 8,
15582                dtype: DType::F32,
15583                shape: Shape::from_dims(&[2, 4]),
15584                layout: Layout::contiguous(Shape::from_dims(&[2, 4])),
15585                name: Some("state"),
15586            },
15587        ];
15588        let (graph, producer_id) = multi_output_producer(slot_specs);
15589        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15590        // Each slot has exactly one View, consumed at the same depth
15591        // (a no-op Relu downstream of each).
15592        let v0 = producer.view(0).expect("slot 0 view");
15593        let v1 = producer.view(1).expect("slot 1 view");
15594        let (v0_id, v0_shape, v0_dtype) = (v0.id(), v0.shape(), v0.dtype());
15595        let (v1_id, v1_shape, v1_dtype) = (v1.id(), v1.shape(), v1.dtype());
15596        let roots = {
15597            let mut g = graph.write().unwrap();
15598            let r0 = g.push(Node {
15599                op: Op::Relu,
15600                inputs: vec![v0_id],
15601                shape: v0_shape,
15602                dtype: v0_dtype,
15603            });
15604            let r1 = g.push(Node {
15605                op: Op::Relu,
15606                inputs: vec![v1_id],
15607                shape: v1_shape,
15608                dtype: v1_dtype,
15609            });
15610            vec![r0, r1]
15611        };
15612        let promotions = {
15613            let mut g = graph.write().unwrap();
15614            crate::opt::promote_views_for_liveness(&mut g, &roots)
15615        };
15616        assert_eq!(
15617            promotions, 0,
15618            "symmetric lifetimes shouldn't promote anything"
15619        );
15620    }
15621
15622    /// Asymmetric lifetimes: slot 0 consumed early, slot 1 consumed
15623    /// late → planner promotes slot 1's View to ViewOwned.
15624    #[test]
15625    fn promote_views_asymmetric_promotes_long_lived_slot() {
15626        let slot_specs = vec![
15627            OutputView {
15628                byte_offset: 0,
15629                len_elements: 6,
15630                dtype: DType::F32,
15631                shape: Shape::from_dims(&[2, 3]),
15632                layout: Layout::contiguous(Shape::from_dims(&[2, 3])),
15633                name: Some("y"),
15634            },
15635            OutputView {
15636                byte_offset: 24,
15637                len_elements: 6,
15638                dtype: DType::F32,
15639                shape: Shape::from_dims(&[2, 3]),
15640                layout: Layout::contiguous(Shape::from_dims(&[2, 3])),
15641                name: Some("state"),
15642            },
15643        ];
15644        let (graph, producer_id) = multi_output_producer(slot_specs);
15645        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15646        let v_y = producer.view(0).expect("slot 0 view");
15647        let v_state = producer.view(1).expect("slot 1 view");
15648        // Slot 0 ("y") consumed at depth 1 (single Relu).
15649        // Slot 1 ("state") consumed at a much deeper chain — Relu →
15650        // Relu → Relu. Topo positions push state's last-use later
15651        // than y's, triggering promotion of state's View.
15652        let (v_y_id, v_y_shape, v_y_dtype) = (v_y.id(), v_y.shape(), v_y.dtype());
15653        let (v_state_id, v_state_shape, v_state_dtype) =
15654            (v_state.id(), v_state.shape(), v_state.dtype());
15655        let roots = {
15656            let mut g = graph.write().unwrap();
15657            let y_relu = g.push(Node {
15658                op: Op::Relu,
15659                inputs: vec![v_y_id],
15660                shape: v_y_shape,
15661                dtype: v_y_dtype,
15662            });
15663            let s1 = g.push(Node {
15664                op: Op::Relu,
15665                inputs: vec![v_state_id],
15666                shape: v_state_shape.clone(),
15667                dtype: v_state_dtype,
15668            });
15669            let s2 = g.push(Node {
15670                op: Op::Relu,
15671                inputs: vec![s1],
15672                shape: v_state_shape.clone(),
15673                dtype: v_state_dtype,
15674            });
15675            let s3 = g.push(Node {
15676                op: Op::Relu,
15677                inputs: vec![s2],
15678                shape: v_state_shape,
15679                dtype: v_state_dtype,
15680            });
15681            vec![y_relu, s3]
15682        };
15683        let promotions = {
15684            let mut g = graph.write().unwrap();
15685            crate::opt::promote_views_for_liveness(&mut g, &roots)
15686        };
15687        assert_eq!(
15688            promotions, 1,
15689            "exactly the long-lived slot 1 View should promote"
15690        );
15691        // Slot 1's consumer (the first Relu in the long chain) now
15692        // reads a fresh Op::ViewOwned node, not the old Op::View.
15693        let g = graph.read().unwrap();
15694        // Find the new ViewOwned node — it was pushed AFTER the
15695        // existing nodes, so it's the last node in the arena. Its op
15696        // must be Op::ViewOwned { slot: 1 } and its input must be the
15697        // producer.
15698        let last_id = NodeId(g.len() - 1);
15699        let last_node = g.node(last_id);
15700        assert!(matches!(last_node.op, Op::ViewOwned { slot: 1 }));
15701        assert_eq!(last_node.inputs, vec![producer_id]);
15702        // The slot 0 View is untouched — still Op::View.
15703        let v_y_node = g.node(v_y_id);
15704        assert!(matches!(v_y_node.op, Op::View { slot: 0 }));
15705        // The old slot 1 View stays in the arena (orphaned). No
15706        // consumer points at it anymore.
15707        let v_state_node = g.node(v_state_id);
15708        assert!(matches!(v_state_node.op, Op::View { slot: 1 }));
15709    }
15710
15711    /// Running the pass twice on the same graph promotes nothing on
15712    /// the second call — `Op::ViewOwned` is a fixpoint (skipped by
15713    /// the planner because `is_owned` is true).
15714    #[test]
15715    fn promote_views_idempotent() {
15716        let slot_specs = vec![
15717            OutputView {
15718                byte_offset: 0,
15719                len_elements: 4,
15720                dtype: DType::F32,
15721                shape: Shape::from_dims(&[2, 2]),
15722                layout: Layout::contiguous(Shape::from_dims(&[2, 2])),
15723                name: None,
15724            },
15725            OutputView {
15726                byte_offset: 16,
15727                len_elements: 4,
15728                dtype: DType::F32,
15729                shape: Shape::from_dims(&[2, 2]),
15730                layout: Layout::contiguous(Shape::from_dims(&[2, 2])),
15731                name: None,
15732            },
15733        ];
15734        let (graph, producer_id) = multi_output_producer(slot_specs);
15735        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15736        let v_y = producer.view(0).expect("slot 0 view");
15737        let v_state = producer.view(1).expect("slot 1 view");
15738        let (v_y_id, v_y_shape, v_y_dtype) = (v_y.id(), v_y.shape(), v_y.dtype());
15739        let (v_state_id, v_state_shape, v_state_dtype) =
15740            (v_state.id(), v_state.shape(), v_state.dtype());
15741        let roots = {
15742            let mut g = graph.write().unwrap();
15743            let y_r = g.push(Node {
15744                op: Op::Relu,
15745                inputs: vec![v_y_id],
15746                shape: v_y_shape,
15747                dtype: v_y_dtype,
15748            });
15749            let s1 = g.push(Node {
15750                op: Op::Relu,
15751                inputs: vec![v_state_id],
15752                shape: v_state_shape.clone(),
15753                dtype: v_state_dtype,
15754            });
15755            let s2 = g.push(Node {
15756                op: Op::Relu,
15757                inputs: vec![s1],
15758                shape: v_state_shape,
15759                dtype: v_state_dtype,
15760            });
15761            vec![y_r, s2]
15762        };
15763        let first = {
15764            let mut g = graph.write().unwrap();
15765            crate::opt::promote_views_for_liveness(&mut g, &roots)
15766        };
15767        let second = {
15768            let mut g = graph.write().unwrap();
15769            crate::opt::promote_views_for_liveness(&mut g, &roots)
15770        };
15771        assert_eq!(first, 1, "first call promotes the long-lived slot");
15772        assert_eq!(
15773            second, 0,
15774            "second call is idempotent — no further promotions"
15775        );
15776    }
15777
15778    /// Graph with no multi-output producers: pass returns 0 and
15779    /// touches nothing.
15780    #[test]
15781    fn promote_views_noop_on_graph_without_multi_output() {
15782        let mut g = Graph::new();
15783        let a = g.push(Node {
15784            op: Op::Const,
15785            inputs: vec![],
15786            shape: Shape::from_dims(&[2, 3]),
15787            dtype: DType::F32,
15788        });
15789        let r = g.push(Node {
15790            op: Op::Relu,
15791            inputs: vec![a],
15792            shape: Shape::from_dims(&[2, 3]),
15793            dtype: DType::F32,
15794        });
15795        let n_before = g.len();
15796        let promotions = crate::opt::promote_views_for_liveness(&mut g, &[r]);
15797        assert_eq!(promotions, 0);
15798        assert_eq!(g.len(), n_before, "no node should have been added");
15799    }
15800
15801    // ------------------------------------------------------------------
15802    // Session 3: scheduler / destructive-cleanup integration —
15803    // bundle-aware liveness. A destructive op on a multi-output
15804    // producer (or any of its Views) must run after every reader of
15805    // every other View, because all Views share the bundle's storage
15806    // Arc. Op::ViewOwned is excluded — it forks a fresh standalone
15807    // Storage at execute time.
15808    // ------------------------------------------------------------------
15809
15810    /// Helper: build two contiguous slot specs for a producer of
15811    /// `Node::shape = [2, 3] F32`. Slot 0 is the primary (matches the
15812    /// producer's Node::shape/dtype, required by set_output_views);
15813    /// slot 1 carries a sibling shape that doesn't collide with the
15814    /// primary.
15815    fn two_slot_views_2x3_and_2x4() -> Vec<OutputView> {
15816        vec![
15817            OutputView {
15818                byte_offset: 0,
15819                len_elements: 6,
15820                dtype: DType::F32,
15821                shape: Shape::from_dims(&[2, 3]),
15822                layout: Layout::contiguous(Shape::from_dims(&[2, 3])),
15823                name: Some("y"),
15824            },
15825            OutputView {
15826                byte_offset: 24,
15827                len_elements: 8,
15828                dtype: DType::F32,
15829                shape: Shape::from_dims(&[2, 4]),
15830                layout: Layout::contiguous(Shape::from_dims(&[2, 4])),
15831                name: Some("state"),
15832            },
15833        ]
15834    }
15835
15836    /// Op::Release targeting a multi-output producer pins after every
15837    /// consumer of any View of that producer. Without bundle-aware
15838    /// aliasing the Views' downstream Relu nodes would be missed and
15839    /// could race the Release.
15840    #[test]
15841    fn derive_ordering_release_of_bundled_producer_pins_after_view_consumers() {
15842        let (graph, producer_id) = multi_output_producer(two_slot_views_2x3_and_2x4());
15843        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15844        let v0 = producer.view(0).expect("slot 0 view");
15845        let v1 = producer.view(1).expect("slot 1 view");
15846        let (v0_id, v0_shape, v0_dtype) = (v0.id(), v0.shape(), v0.dtype());
15847        let (v1_id, v1_shape, v1_dtype) = (v1.id(), v1.shape(), v1.dtype());
15848        let (r0, r1, release_id) = {
15849            let mut g = graph.write().unwrap();
15850            let r0 = g.push(Node {
15851                op: Op::Relu,
15852                inputs: vec![v0_id],
15853                shape: v0_shape,
15854                dtype: v0_dtype,
15855            });
15856            let r1 = g.push(Node {
15857                op: Op::Relu,
15858                inputs: vec![v1_id],
15859                shape: v1_shape,
15860                dtype: v1_dtype,
15861            });
15862            let release = g.push(Node {
15863                op: Op::Release,
15864                inputs: vec![producer_id],
15865                shape: Shape::from_dims(&[0]),
15866                dtype: DType::F32,
15867            });
15868            (r0, r1, release)
15869        };
15870        let ord = crate::opt::derive_ordering(&graph.read().unwrap(), &[r0, r1, release_id]);
15871        let deps = ord.deps_of(release_id);
15872        let dep_set: std::collections::HashSet<NodeId> = deps.iter().copied().collect();
15873        assert!(
15874            dep_set.contains(&r0),
15875            "Release must run after the slot-0 View's consumer (r0)",
15876        );
15877        assert!(
15878            dep_set.contains(&r1),
15879            "Release must run after the slot-1 View's consumer (r1) — \
15880             both Views share the bundle Arc",
15881        );
15882    }
15883
15884    /// Destructive op targeting one of a bundle's Op::View nodes
15885    /// pins after every sibling View's consumers too — every View
15886    /// of the producer shares the bundle's Arc, so writing through
15887    /// one of them clobbers the bytes the others read.
15888    #[test]
15889    fn derive_ordering_destructive_on_view_pins_sibling_view_consumers() {
15890        let (graph, producer_id) = multi_output_producer(two_slot_views_2x3_and_2x4());
15891        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15892        let v0 = producer.view(0).expect("slot 0 view");
15893        let v1 = producer.view(1).expect("slot 1 view");
15894        let (v0_id, _v0_shape, _v0_dtype) = (v0.id(), v0.shape(), v0.dtype());
15895        let (v1_id, v1_shape, v1_dtype) = (v1.id(), v1.shape(), v1.dtype());
15896        // Destructive op (Release) targets v0 directly — its
15897        // alias set must reach v1's consumer via the producer-sibling
15898        // hop in collect_alias_set.
15899        let (r1, release_id) = {
15900            let mut g = graph.write().unwrap();
15901            let r1 = g.push(Node {
15902                op: Op::Relu,
15903                inputs: vec![v1_id],
15904                shape: v1_shape,
15905                dtype: v1_dtype,
15906            });
15907            let release = g.push(Node {
15908                op: Op::Release,
15909                inputs: vec![v0_id],
15910                shape: Shape::from_dims(&[0]),
15911                dtype: DType::F32,
15912            });
15913            (r1, release)
15914        };
15915        let ord = crate::opt::derive_ordering(&graph.read().unwrap(), &[r1, release_id]);
15916        let deps = ord.deps_of(release_id);
15917        let dep_set: std::collections::HashSet<NodeId> = deps.iter().copied().collect();
15918        assert!(
15919            dep_set.contains(&r1),
15920            "Release targeting slot-0 View must pin after sibling slot-1 \
15921             View's consumer (r1) — both share the bundle's storage Arc",
15922        );
15923    }
15924
15925    /// Op::ViewOwned does NOT extend the alias set: its forward
15926    /// memcpy produces an independent Storage. derive_ordering must
15927    /// still pin Release after the ViewOwned itself (via the standard
15928    /// data-dependency: ViewOwned consumes the producer), but
15929    /// downstream consumers of the ViewOwned are NOT in the alias
15930    /// set's reader sweep — they read independent bytes and may run
15931    /// freely after the Release.
15932    #[test]
15933    fn derive_ordering_view_owned_is_not_alias_extending() {
15934        let (graph, producer_id) = multi_output_producer(two_slot_views_2x3_and_2x4());
15935        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
15936        // Slot 0 → View (shared Arc); slot 1 → ViewOwned (independent).
15937        let v_view = producer.view(0).expect("slot 0 view");
15938        let v_owned = producer.view_owned(1).expect("slot 1 viewowned");
15939        let (v_view_id, _, _) = (v_view.id(), v_view.shape(), v_view.dtype());
15940        let (v_owned_id, v_owned_shape, v_owned_dtype) =
15941            (v_owned.id(), v_owned.shape(), v_owned.dtype());
15942        let (consumer_of_owned, release_id) = {
15943            let mut g = graph.write().unwrap();
15944            let consumer = g.push(Node {
15945                op: Op::Relu,
15946                inputs: vec![v_owned_id],
15947                shape: v_owned_shape,
15948                dtype: v_owned_dtype,
15949            });
15950            let release = g.push(Node {
15951                op: Op::Release,
15952                inputs: vec![producer_id],
15953                shape: Shape::from_dims(&[0]),
15954                dtype: DType::F32,
15955            });
15956            (consumer, release)
15957        };
15958        let ord =
15959            crate::opt::derive_ordering(&graph.read().unwrap(), &[consumer_of_owned, release_id]);
15960        let deps = ord.deps_of(release_id);
15961        let dep_set: std::collections::HashSet<NodeId> = deps.iter().copied().collect();
15962        // Release MUST wait for ViewOwned to run (ViewOwned reads
15963        // the producer's bytes during its memcpy).
15964        assert!(
15965            dep_set.contains(&v_owned_id),
15966            "Release must pin after ViewOwned — ViewOwned reads the \
15967             producer's bundle during its memcpy phase",
15968        );
15969        // Release must NOT depend on the ViewOwned's downstream
15970        // consumer — that consumer reads ViewOwned's independent
15971        // bytes, not the producer's bundle.
15972        assert!(
15973            !dep_set.contains(&consumer_of_owned),
15974            "Release must NOT pin after consumers of ViewOwned — \
15975             ViewOwned forks a fresh Storage; its consumers don't \
15976             read the producer's bundle",
15977        );
15978        // Op::View's consumers stay reached via the alias chain; here
15979        // there are none (v_view has no consumer), so v_view itself
15980        // is the only alias-set entry beyond the producer.
15981        let _ = v_view_id;
15982    }
15983
15984    /// Long-tail bundle-aware schedule: a producer with three Views,
15985    /// each consumed by a chain of different depth, and a destructive
15986    /// Release on the producer. The Release must end up after every
15987    /// terminal consumer of every chain — bundle-as-single-eviction-
15988    /// unit semantics fall out of derive_ordering's alias-set walk
15989    /// reaching each chain's leaf via the producer-then-View hop.
15990    #[test]
15991    fn derive_ordering_bundle_release_pins_after_all_chains() {
15992        // Build a 3-slot bundle. Slot 0 primary [2,3] F32 satisfies
15993        // the producer's Node::shape/dtype constraint; slots 1 + 2
15994        // add sibling shapes.
15995        let slot_specs = vec![
15996            OutputView {
15997                byte_offset: 0,
15998                len_elements: 6,
15999                dtype: DType::F32,
16000                shape: Shape::from_dims(&[2, 3]),
16001                layout: Layout::contiguous(Shape::from_dims(&[2, 3])),
16002                name: Some("a"),
16003            },
16004            OutputView {
16005                byte_offset: 24,
16006                len_elements: 4,
16007                dtype: DType::F32,
16008                shape: Shape::from_dims(&[2, 2]),
16009                layout: Layout::contiguous(Shape::from_dims(&[2, 2])),
16010                name: Some("b"),
16011            },
16012            OutputView {
16013                byte_offset: 40,
16014                len_elements: 8,
16015                dtype: DType::F32,
16016                shape: Shape::from_dims(&[2, 4]),
16017                layout: Layout::contiguous(Shape::from_dims(&[2, 4])),
16018                name: Some("c"),
16019            },
16020        ];
16021        let (graph, producer_id) = multi_output_producer(slot_specs);
16022        let producer = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
16023        let v0 = producer.view(0).unwrap();
16024        let v1 = producer.view(1).unwrap();
16025        let v2 = producer.view(2).unwrap();
16026        let (v0_id, v0_shape, v0_dtype) = (v0.id(), v0.shape(), v0.dtype());
16027        let (v1_id, v1_shape, v1_dtype) = (v1.id(), v1.shape(), v1.dtype());
16028        let (v2_id, v2_shape, v2_dtype) = (v2.id(), v2.shape(), v2.dtype());
16029        let (leaf_a, leaf_b, leaf_c, release_id) = {
16030            let mut g = graph.write().unwrap();
16031            let leaf_a = g.push(Node {
16032                op: Op::Relu,
16033                inputs: vec![v0_id],
16034                shape: v0_shape,
16035                dtype: v0_dtype,
16036            });
16037            let b1 = g.push(Node {
16038                op: Op::Relu,
16039                inputs: vec![v1_id],
16040                shape: v1_shape.clone(),
16041                dtype: v1_dtype,
16042            });
16043            let leaf_b = g.push(Node {
16044                op: Op::Relu,
16045                inputs: vec![b1],
16046                shape: v1_shape,
16047                dtype: v1_dtype,
16048            });
16049            let c1 = g.push(Node {
16050                op: Op::Relu,
16051                inputs: vec![v2_id],
16052                shape: v2_shape.clone(),
16053                dtype: v2_dtype,
16054            });
16055            let c2 = g.push(Node {
16056                op: Op::Relu,
16057                inputs: vec![c1],
16058                shape: v2_shape.clone(),
16059                dtype: v2_dtype,
16060            });
16061            let leaf_c = g.push(Node {
16062                op: Op::Relu,
16063                inputs: vec![c2],
16064                shape: v2_shape,
16065                dtype: v2_dtype,
16066            });
16067            let release = g.push(Node {
16068                op: Op::Release,
16069                inputs: vec![producer_id],
16070                shape: Shape::from_dims(&[0]),
16071                dtype: DType::F32,
16072            });
16073            (leaf_a, leaf_b, leaf_c, release)
16074        };
16075        let ord = crate::opt::derive_ordering(
16076            &graph.read().unwrap(),
16077            &[leaf_a, leaf_b, leaf_c, release_id],
16078        );
16079        let deps = ord.deps_of(release_id);
16080        let dep_set: std::collections::HashSet<NodeId> = deps.iter().copied().collect();
16081        // The direct readers of any View are the FIRST relu in each
16082        // chain — those are the nodes that read the bundle's bytes.
16083        // Every consumer further down reads its predecessor's
16084        // standalone output Storage, so they aren't in the alias-set
16085        // reader sweep. Verify the Release pins on the direct
16086        // readers — sufficient for bundle-single-eviction safety
16087        // because the chains are themselves data-dep ordered after
16088        // their direct readers.
16089        assert!(
16090            dep_set.contains(&leaf_a),
16091            "single-Relu chain a: leaf is the direct reader",
16092        );
16093        // For the b/c chains, the direct readers are the first Relus
16094        // (b1 / c1), not the leaves. The Release must include those.
16095        // Find them by walking inputs from leaves.
16096        let g = graph.read().unwrap();
16097        let b1 = g.node(leaf_b).inputs[0];
16098        let (c2, c1) = {
16099            let c2 = g.node(leaf_c).inputs[0];
16100            let c1 = g.node(c2).inputs[0];
16101            (c2, c1)
16102        };
16103        let _ = c2;
16104        assert!(
16105            dep_set.contains(&b1),
16106            "two-Relu chain b: first Relu is the direct bundle reader",
16107        );
16108        assert!(
16109            dep_set.contains(&c1),
16110            "three-Relu chain c: first Relu is the direct bundle reader",
16111        );
16112    }
16113
16114    // ------------------------------------------------------------------
16115    // Item 4 — Op::View / Op::ViewOwned autograd emits Op::ScatterIntoSlot
16116    // ------------------------------------------------------------------
16117
16118    /// The View's backward arm pushes an `Op::ScatterIntoSlot { slot }`
16119    /// node into the gradient graph as the producer's input gradient.
16120    /// Synthetic test: build a 2-slot producer + Op::View(slot 1) +
16121    /// a Relu downstream + invoke NodeHandle::backward + walk the produced
16122    /// gradient graph and assert a ScatterIntoSlot was emitted.
16123    #[test]
16124    fn op_view_backward_emits_scatter_into_slot() {
16125        let producer_t =
16126            NodeHandle::from_f32(vec![0.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
16127        let graph: SharedGraph = Arc::clone(producer_t.graph());
16128        let producer_id = producer_t.id();
16129        let view_id = {
16130            let mut g = graph.write().unwrap();
16131            let specs = two_slot_views_2x3_and_2x4();
16132            g.set_output_views(producer_id, specs.into())
16133                .expect("output_views");
16134            drop(g);
16135            let v1 = producer_t.view(1).expect("slot 1 view");
16136            v1.id()
16137        };
16138        // Build a tiny tape-tracked loss = sum(relu(v1)).
16139        let v1_tensor = NodeHandle::from_existing(Arc::clone(&graph), view_id);
16140        let relu_t = v1_tensor.relu();
16141        let loss_t = relu_t.sum_all();
16142
16143        // Take backward — exercises the View's backward arm.
16144        let grads = loss_t.backward();
16145
16146        // The producer's input gradient should be reachable via a
16147        // chain that includes an Op::ScatterIntoSlot { slot: 1 } node.
16148        let producer_tensor = NodeHandle::from_existing(Arc::clone(&graph), producer_id);
16149        let grad_for_producer = grads
16150            .get(&producer_tensor)
16151            .expect("producer has an accumulated gradient");
16152        let grad_id = grad_for_producer.id();
16153        let g = graph.read().unwrap();
16154        let scatter_present = matches!(g.node(grad_id).op, Op::ScatterIntoSlot { slot: 1 }) || {
16155            // Walk one hop back through `Op::Add` accumulations
16156            // if any.
16157            let n = g.node(grad_id);
16158            n.inputs
16159                .iter()
16160                .any(|inp| matches!(g.node(*inp).op, Op::ScatterIntoSlot { slot: 1 }))
16161        };
16162        assert!(
16163            scatter_present,
16164            "View(slot=1) backward must emit an Op::ScatterIntoSlot {{ slot: 1 }} \
16165             into the gradient graph; got grad node op = {:?}",
16166            g.node(grad_id).op,
16167        );
16168    }
16169
16170    // ----- Op::Branch inert scaffold (Phase A PR-A0 of the
16171    // "plan IS the graph" rebuild) -----
16172
16173    /// Back-compat by construction: a graph with ZERO `Op::Branch`
16174    /// nodes builds with exactly the arena it had before the variant
16175    /// existed. The locked design guarantees "a graph with zero
16176    /// Op::Branch nodes is exactly today's single-route graph" — this
16177    /// test pins that: the same builder calls produce the same node
16178    /// count, the same topo order, and not a single `Op::Branch` node.
16179    /// GAP-003 STEP 1: the panic IS reachable from the public API today.
16180    ///
16181    /// `NodeHandle::from_f32` returns `Self`, so a length/shape mismatch has
16182    /// nowhere to go but `assert_eq!` inside `from_host_buffer_on`. This test
16183    /// PASSES today and is deleted by the fix -- it exists to prove the defect
16184    /// is live from a caller's seat, not merely present in the source.
16185    /// GAP-003: a length/shape mismatch DECLINES instead of panicking.
16186    ///
16187    /// ⚠️ This started life as a `#[should_panic]` probe proving the defect was
16188    /// reachable from the public API. The bulk `.unwrap()` pass then appended
16189    /// `.unwrap()` to it, and it KEPT PASSING -- for the wrong reason: unwrapping
16190    /// an `Err` panics, and the `Err` message contains the expected substring, so
16191    /// the assertion could no longer tell "the API panics" from "I unwrapped a
16192    /// Result". A vacuous green produced by the very edit that fixed the defect.
16193    ///
16194    /// It now asserts the `Err` DIRECTLY, with no `unwrap`, and matches on the
16195    /// LENGTH-MISMATCH message specifically -- not merely on `is_err()`. That
16196    /// distinction is load-bearing: `from_host_buffer_on` has a second failure
16197    /// path (`storage_from_host_buffer_owned_dyn`), so a bare `is_err()` could
16198    /// not tell this decline from that one.
16199    #[test]
16200    fn gap003_from_f32_declines_a_length_mismatch() {
16201        // 2 elements declared as a 3-element shape.
16202        let err = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[3]), cpu_dev())
16203            .expect_err("a length/shape mismatch must DECLINE, not construct");
16204        let msg = err.to_string();
16205        assert!(
16206            msg.contains("does not match shape element count"),
16207            "the decline must name the LENGTH MISMATCH specifically, got: {msg}"
16208        );
16209        assert!(
16210            msg.contains('2') && msg.contains('3'),
16211            "and both operands: {msg}"
16212        );
16213
16214        // CONTROL: a MATCHING length still constructs, so the guard is not
16215        // "this constructor always fails".
16216        assert!(
16217            NodeHandle::from_f32(vec![1.0, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev()).is_ok()
16218        );
16219    }
16220
16221    #[test]
16222    fn zero_branch_graph_builds_unchanged() {
16223        // Build: c = (a + b) * a — the same DAG the topo-order tests
16224        // use, exercised here purely to prove the arena is untouched by
16225        // the new variant.
16226        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
16227        let b = a
16228            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
16229            .unwrap();
16230        let sum = a.add(&b);
16231        let c = sum.mul(&a);
16232
16233        let g = c.graph();
16234        let guard = g.read().unwrap();
16235
16236        // Exactly four nodes (a, b, sum, c) — no implicit Branch
16237        // anywhere.
16238        assert_eq!(
16239            guard.len(),
16240            4,
16241            "zero-Branch graph must have the same node count as before"
16242        );
16243
16244        // Topo order is unchanged and contains zero Branch nodes.
16245        let order = topo_order(&guard, c.id());
16246        assert_eq!(order.len(), 4);
16247        let branch_count = order
16248            .iter()
16249            .filter(|&&id| matches!(guard.node(id).op, Op::Branch { .. }))
16250            .count();
16251        assert_eq!(branch_count, 0, "no builder constructs Op::Branch in PR-A0");
16252
16253        // The ops are exactly the ones the builders emitted (no
16254        // structural rewrite slipped in a merge node).
16255        assert!(matches!(guard.node(a.id()).op, Op::Const));
16256        assert!(matches!(guard.node(b.id()).op, Op::Const));
16257        assert!(matches!(guard.node(sum.id()).op, Op::Add));
16258        assert!(matches!(guard.node(c.id()).op, Op::Mul));
16259    }
16260
16261    /// The variant exists and its infallible accessor behaves: a
16262    /// hand-constructed `Op::Branch` reports `short_name() == "Branch"`,
16263    /// and the multi-path encoding (arms = `inputs`, explicit
16264    /// `reconverge_at`) round-trips through the arena. This is the only
16265    /// place in PR-A0 that constructs the variant — the production
16266    /// builders land in PR-A1.
16267    #[test]
16268    fn branch_variant_exists_and_short_name_works() {
16269        // short_name / the Op::short_name() facade both return the
16270        // stable "Branch" constant.
16271        let reconverge = NodeId(7);
16272        let branch_op = Op::Branch {
16273            reconverge_at: reconverge,
16274        };
16275        assert_eq!(branch_op.short_name(), "Branch");
16276        assert_eq!(op_short_name(&branch_op), "Branch");
16277
16278        // Structural nodes carry no destructive input (matches the
16279        // other structural ops: Const / View / ScatterIntoSlot).
16280        assert_eq!(branch_op.destructive_input(), None);
16281
16282        // Encode a 2-arm merge directly on the arena: two route exits
16283        // as `inputs`, the reconvergence node named explicitly. Prove
16284        // the encoding survives a push + read-back unchanged.
16285        let mut g = Graph::new();
16286        let arm0 = g.push(Node {
16287            op: Op::Const,
16288            inputs: vec![],
16289            shape: Shape::from_dims(&[2]),
16290            dtype: DType::F32,
16291        });
16292        let arm1 = g.push(Node {
16293            op: Op::Const,
16294            inputs: vec![],
16295            shape: Shape::from_dims(&[2]),
16296            dtype: DType::F32,
16297        });
16298        let merged = g.push(Node {
16299            op: Op::Const,
16300            inputs: vec![],
16301            shape: Shape::from_dims(&[2]),
16302            dtype: DType::F32,
16303        });
16304        let branch = g.push(Node {
16305            op: Op::Branch {
16306                reconverge_at: merged,
16307            },
16308            inputs: vec![arm0, arm1],
16309            shape: Shape::from_dims(&[2]),
16310            dtype: DType::F32,
16311        });
16312
16313        let node = g.node(branch);
16314        assert_eq!(node.op.short_name(), "Branch");
16315        // Arms are the node's inputs (each input = one route's exit).
16316        assert_eq!(node.inputs, vec![arm0, arm1]);
16317        // The explicit reconvergence node is carried on the op.
16318        match node.op {
16319            Op::Branch { reconverge_at } => assert_eq!(reconverge_at, merged),
16320            ref other => panic!("expected Op::Branch, got {other:?}"),
16321        }
16322    }
16323
16324    // ----- Op::Branch builders + build-time validation
16325    // (Phase A PR-A1 of the "plan IS the graph" rebuild) -----
16326
16327    /// Hand-build a 2-arm diamond directly on the arena and return the
16328    /// pieces the branch builders will stitch together:
16329    /// `diverge` → {`arm0`, `arm1`} → `reconverge`. The reconverge node
16330    /// already reads arm0 as input 0, which is exactly the runnability
16331    /// invariant the builders must preserve (a finalized-but-unpicked
16332    /// branch still realizes on arm 0). Every node is `[2] f32` so the
16333    /// cast-to-uniform check has agreeing shape/dtype across arms.
16334    fn diamond_2arm() -> (Graph, NodeId, NodeId, NodeId, NodeId) {
16335        let mut g = Graph::new();
16336        let diverge = g.push(Node {
16337            op: Op::Const,
16338            inputs: vec![],
16339            shape: Shape::from_dims(&[2]),
16340            dtype: DType::F32,
16341        });
16342        // arm0 = relu(diverge), arm1 = silu(diverge): two interior nodes,
16343        // each reachable only through its own arm from the diverge point.
16344        let arm0 = g.push(Node {
16345            op: Op::Relu,
16346            inputs: vec![diverge],
16347            shape: Shape::from_dims(&[2]),
16348            dtype: DType::F32,
16349        });
16350        let arm1 = g.push(Node {
16351            op: Op::Silu,
16352            inputs: vec![diverge],
16353            shape: Shape::from_dims(&[2]),
16354            dtype: DType::F32,
16355        });
16356        // reconverge reads arm0 (runnability: arm 0 is always input 0).
16357        let reconverge = g.push(Node {
16358            op: Op::Relu,
16359            inputs: vec![arm0],
16360            shape: Shape::from_dims(&[2]),
16361            dtype: DType::F32,
16362        });
16363        (g, diverge, arm0, arm1, reconverge)
16364    }
16365
16366    /// (a) A 2-arm diamond round-trips through
16367    /// `open_branch`/`add_arm`/`finalize_branches`: it produces exactly
16368    /// one valid `Op::Branch` node whose `inputs` are the arm exits in
16369    /// order and whose `reconverge_at` is the named merge node.
16370    #[test]
16371    fn two_arm_diamond_round_trips_to_branch_node() {
16372        let (mut g, diverge, arm0, arm1, reconverge) = diamond_2arm();
16373        let before = g.len();
16374
16375        let mut b = g.open_branch(diverge);
16376        b.add_arm(arm0);
16377        b.add_arm(arm1);
16378        let branch = b
16379            .finalize_branches(&mut g, reconverge)
16380            .expect("well-formed 2-arm branch must finalize")
16381            .expect("2 arms survive — not collapsed to a single arm");
16382
16383        // Exactly one node was appended (the Branch); no orphan debris.
16384        assert_eq!(g.len(), before + 1);
16385
16386        let node = g.node(branch);
16387        assert!(matches!(node.op, Op::Branch { .. }));
16388        // Arms are the node's inputs, in add order: arm0 first.
16389        assert_eq!(node.inputs, vec![arm0, arm1]);
16390        match node.op {
16391            Op::Branch { reconverge_at } => assert_eq!(reconverge_at, reconverge),
16392            ref other => panic!("expected Op::Branch, got {other:?}"),
16393        }
16394        // The merge node still carries arm0's shape/dtype (cast-to-uniform
16395        // validated, not rewritten).
16396        assert_eq!(node.shape, g.node(arm0).shape);
16397        assert_eq!(node.dtype, g.node(arm0).dtype);
16398    }
16399
16400    /// (b) A `reconverge_at` that is NOT a descendant of the diverge
16401    /// point returns `Error::InvalidBranch` — never a panic.
16402    #[test]
16403    fn reconverge_not_descendant_of_diverge_is_typed_err() {
16404        let (mut g, diverge, arm0, arm1, _reconverge) = diamond_2arm();
16405        // A sibling node that does NOT descend from `diverge`.
16406        let unrelated = g.push(Node {
16407            op: Op::Const,
16408            inputs: vec![],
16409            shape: Shape::from_dims(&[2]),
16410            dtype: DType::F32,
16411        });
16412
16413        let mut b = g.open_branch(diverge);
16414        b.add_arm(arm0);
16415        b.add_arm(arm1);
16416        let err = b
16417            .finalize_branches(&mut g, unrelated)
16418            .expect_err("reconverge that doesn't descend from diverge must be rejected");
16419        assert!(
16420            matches!(err, fuel_ir::Error::InvalidBranch { .. }),
16421            "expected InvalidBranch, got {err:?}",
16422        );
16423    }
16424
16425    /// (c) A non-disjoint arm — an arm interior node reachable from
16426    /// outside that arm (shared with the rest of the graph) — returns
16427    /// `Error::InvalidBranch`.
16428    #[test]
16429    fn non_disjoint_arm_is_typed_err() {
16430        let (mut g, diverge, arm0, arm1, reconverge) = diamond_2arm();
16431        // Add a reader OUTSIDE the branch that consumes arm0's interior.
16432        // arm0 is now reachable from a node that isn't part of the merge,
16433        // so the arm is not internally disjoint.
16434        let _external_reader = g.push(Node {
16435            op: Op::Relu,
16436            inputs: vec![arm0],
16437            shape: Shape::from_dims(&[2]),
16438            dtype: DType::F32,
16439        });
16440
16441        let mut b = g.open_branch(diverge);
16442        b.add_arm(arm0);
16443        b.add_arm(arm1);
16444        let err = b
16445            .finalize_branches(&mut g, reconverge)
16446            .expect_err("an arm interior read from outside the branch must be rejected");
16447        assert!(
16448            matches!(err, fuel_ir::Error::InvalidBranch { .. }),
16449            "expected InvalidBranch, got {err:?}",
16450        );
16451    }
16452
16453    /// (c') Arms that disagree on shape/dtype at their exits violate
16454    /// cast-to-uniform and return `Error::InvalidBranch` (validate-and-Err
16455    /// — no implicit Cast insertion in PR-A1).
16456    #[test]
16457    fn arms_disagreeing_on_dtype_is_typed_err() {
16458        let (mut g, diverge, arm0, _arm1, reconverge) = diamond_2arm();
16459        // A second arm with a DIFFERENT dtype than arm0.
16460        let arm1_f16 = g.push(Node {
16461            op: Op::Silu,
16462            inputs: vec![diverge],
16463            shape: Shape::from_dims(&[2]),
16464            dtype: DType::F16,
16465        });
16466
16467        let mut b = g.open_branch(diverge);
16468        b.add_arm(arm0);
16469        b.add_arm(arm1_f16);
16470        let err = b
16471            .finalize_branches(&mut g, reconverge)
16472            .expect_err("arms with mismatched dtype must be rejected (cast-to-uniform)");
16473        assert!(
16474            matches!(err, fuel_ir::Error::InvalidBranch { .. }),
16475            "expected InvalidBranch, got {err:?}",
16476        );
16477    }
16478
16479    /// (d) `finalize_branches` drops a single-arm branch: with only one
16480    /// surviving arm there is no real decision point, so no `Op::Branch`
16481    /// node is emitted (`Ok(None)`), the arena is left untouched, and the
16482    /// graph collapses back to that arm.
16483    #[test]
16484    fn single_arm_branch_is_dropped() {
16485        let (mut g, diverge, arm0, _arm1, reconverge) = diamond_2arm();
16486        let before = g.len();
16487
16488        let mut b = g.open_branch(diverge);
16489        b.add_arm(arm0);
16490        let result = b
16491            .finalize_branches(&mut g, reconverge)
16492            .expect("a single-arm branch is valid — it just collapses");
16493        assert!(
16494            result.is_none(),
16495            "single-arm branch must NOT emit an Op::Branch"
16496        );
16497        // No node appended: the lone arm is the graph, unchanged.
16498        assert_eq!(
16499            g.len(),
16500            before,
16501            "dropping a single-arm branch leaves the arena untouched"
16502        );
16503        let branch_count = (0..g.len())
16504            .filter(|&i| matches!(g.node(NodeId(i)).op, Op::Branch { .. }))
16505            .count();
16506        assert_eq!(branch_count, 0);
16507    }
16508
16509    /// (e) The runnability invariant: the emitted `Op::Branch`'s inputs
16510    /// always include arm-0's exit as input 0, so a finalized-but-not-yet-
16511    /// route-picked graph still realizes on arm 0 (a graph with a
16512    /// finalized branch must still be executable). The named reconverge
16513    /// node reads arm-0's exit, so realizing on arm 0 alone produces a
16514    /// defined value at the merge.
16515    #[test]
16516    fn finalized_branch_preserves_arm0_runnability() {
16517        let (mut g, diverge, arm0, arm1, reconverge) = diamond_2arm();
16518
16519        let mut b = g.open_branch(diverge);
16520        b.add_arm(arm0);
16521        b.add_arm(arm1);
16522        let branch = b
16523            .finalize_branches(&mut g, reconverge)
16524            .expect("well-formed branch finalizes")
16525            .expect("2 arms survive");
16526
16527        // arm 0 is input 0 of the Branch — the always-realizable route.
16528        assert_eq!(g.node(branch).inputs.first().copied(), Some(arm0));
16529        // And the named reconverge node reads arm0, so realizing on
16530        // arm 0 alone produces a defined value at the merge.
16531        assert!(
16532            g.node(reconverge).inputs.contains(&arm0),
16533            "reconverge must read arm-0's exit so the unpicked graph runs on arm 0",
16534        );
16535    }
16536
16537    // ===================================================================
16538    // PR-B4: required compaction of the append-only arena.
16539    // ===================================================================
16540
16541    use crate::run::{extract_runs, lower_runs_arm0};
16542
16543    /// Build the same 2-arm diamond the run.rs tests use, finalized into a
16544    /// real `Op::Branch`. Returns `(g, diverge, arm0, arm1, reconverge,
16545    /// branch, post)`. `post` reads `reconverge`, so the post-merge region
16546    /// is a non-empty run and `post` is a stable root.
16547    fn diamond_with_branch_b4() -> (Graph, NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
16548        let mk = |g: &mut Graph, op: Op, inputs: Vec<NodeId>| {
16549            g.push(Node {
16550                op,
16551                inputs,
16552                shape: Shape::from_dims(&[2]),
16553                dtype: DType::F32,
16554            })
16555        };
16556        let mut g = Graph::new();
16557        let pre = mk(&mut g, Op::Const, vec![]);
16558        let diverge = mk(&mut g, Op::Relu, vec![pre]);
16559        let arm0 = mk(&mut g, Op::Silu, vec![diverge]);
16560        let arm1 = mk(&mut g, Op::GeluTanh, vec![diverge]);
16561        let reconverge = mk(&mut g, Op::Relu, vec![arm0]);
16562        let mut b = g.open_branch(diverge);
16563        b.add_arm(arm0);
16564        b.add_arm(arm1);
16565        let branch = b
16566            .finalize_branches(&mut g, reconverge)
16567            .expect("well-formed 2-arm branch finalizes")
16568            .expect("2 arms survive");
16569        let post = mk(&mut g, Op::Tanh, vec![reconverge]);
16570        (g, diverge, arm0, arm1, reconverge, branch, post)
16571    }
16572
16573    /// PR-D1 storage-class substrate: the op-inferred class is correct
16574    /// (`Op::Const` → Shared, `Op::WriteSlice` → SessionState, other →
16575    /// Transient), an explicit override beats the inferred default (a
16576    /// KV-cache placeholder `Op::Const` marked SessionState), and `compact`
16577    /// carries the override to the node's new id while inferred defaults
16578    /// still hold. Born-red: fails if classification, override precedence,
16579    /// or the compaction remap is wrong.
16580    #[test]
16581    fn storage_class_inference_override_and_compaction() {
16582        let mut g = Graph::new();
16583        // A shared weight Const, a session-state KV placeholder Const, a
16584        // transient activation, and an in-place cache-write target.
16585        let weight = g.push(Node {
16586            op: Op::Const,
16587            inputs: vec![],
16588            shape: Shape::from_dims(&[2]),
16589            dtype: DType::F32,
16590        });
16591        let kv = g.push(Node {
16592            op: Op::Const,
16593            inputs: vec![],
16594            shape: Shape::from_dims(&[2]),
16595            dtype: DType::F32,
16596        });
16597        let act = g.push(Node {
16598            op: Op::Relu,
16599            inputs: vec![weight],
16600            shape: Shape::from_dims(&[2]),
16601            dtype: DType::F32,
16602        });
16603        let write = g.push(Node {
16604            op: Op::WriteSlice {
16605                ranges: vec![(0, 2)],
16606                dyn_offset: None,
16607            },
16608            inputs: vec![kv, act],
16609            shape: Shape::from_dims(&[2]),
16610            dtype: DType::F32,
16611        });
16612
16613        // Op-inferred defaults.
16614        assert_eq!(
16615            g.storage_class(weight),
16616            StorageClass::Shared,
16617            "Const → Shared"
16618        );
16619        assert_eq!(
16620            g.storage_class(act),
16621            StorageClass::Transient,
16622            "activation → Transient"
16623        );
16624        assert_eq!(
16625            g.storage_class(write),
16626            StorageClass::SessionState,
16627            "WriteSlice → SessionState"
16628        );
16629        // No overrides set yet.
16630        assert_eq!(
16631            g.storage_class(kv),
16632            StorageClass::Shared,
16633            "KV placeholder Const infers Shared before override"
16634        );
16635        assert!(!g.has_storage_class_override(kv));
16636        assert_eq!(g.storage_class_override_count(), 0);
16637
16638        // Explicit override: the KV placeholder is session state, not a
16639        // shared weight.
16640        g.set_storage_class(kv, StorageClass::SessionState);
16641        assert_eq!(
16642            g.storage_class(kv),
16643            StorageClass::SessionState,
16644            "override beats inferred default"
16645        );
16646        assert!(g.has_storage_class_override(kv));
16647        assert_eq!(g.storage_class_override_count(), 1);
16648
16649        // Compaction carries the override to the new id; inferred defaults
16650        // (no side-table entry) still resolve from the op.
16651        let remap = compact(&mut g, &[write]);
16652        let n_kv = remap.get(kv).expect("kv survives (input of write)");
16653        let n_weight = remap
16654            .get(weight)
16655            .expect("weight survives (transitively under act)");
16656        let n_write = remap.get(write).expect("write is the root");
16657        assert_eq!(
16658            g.storage_class(n_kv),
16659            StorageClass::SessionState,
16660            "override remapped by compact"
16661        );
16662        assert!(g.has_storage_class_override(n_kv));
16663        assert_eq!(
16664            g.storage_class_override_count(),
16665            1,
16666            "exactly the one override survives"
16667        );
16668        assert_eq!(
16669            g.storage_class(n_weight),
16670            StorageClass::Shared,
16671            "inferred Shared still holds post-compact"
16672        );
16673        assert_eq!(
16674            g.storage_class(n_write),
16675            StorageClass::SessionState,
16676            "inferred SessionState still holds post-compact"
16677        );
16678        assert!(g.verify_no_dangling().is_ok());
16679    }
16680
16681    /// (a) A deliberately-orphaned node (pushed, referenced by nothing) is
16682    /// dropped by `compact`; the node count shrinks by exactly the orphan
16683    /// count, and every still-reachable node survives.
16684    #[test]
16685    fn compact_drops_orphan_debris() {
16686        let mut g = Graph::new();
16687        let a = g.push(Node {
16688            op: Op::Const,
16689            inputs: vec![],
16690            shape: Shape::from_dims(&[2]),
16691            dtype: DType::F32,
16692        });
16693        let b = g.push(Node {
16694            op: Op::Relu,
16695            inputs: vec![a],
16696            shape: Shape::from_dims(&[2]),
16697            dtype: DType::F32,
16698        });
16699        let c = g.push(Node {
16700            op: Op::Silu,
16701            inputs: vec![b],
16702            shape: Shape::from_dims(&[2]),
16703            dtype: DType::F32,
16704        });
16705        // Two pieces of exploration debris: reachable from nothing in the
16706        // live structure (no root, no side-effect, no branch references them).
16707        let _orphan0 = g.push(Node {
16708            op: Op::Const,
16709            inputs: vec![],
16710            shape: Shape::from_dims(&[2]),
16711            dtype: DType::F32,
16712        });
16713        let _orphan1 = g.push(Node {
16714            op: Op::Tanh,
16715            inputs: vec![_orphan0],
16716            shape: Shape::from_dims(&[2]),
16717            dtype: DType::F32,
16718        });
16719
16720        let before = g.len();
16721        let remap = compact(&mut g, &[c]);
16722
16723        assert_eq!(
16724            g.len(),
16725            before - 2,
16726            "compaction drops exactly the two orphan-debris nodes",
16727        );
16728        // The three reachable nodes all survived (have new ids).
16729        assert!(remap.get(a).is_some());
16730        assert!(remap.get(b).is_some());
16731        assert!(remap.get(c).is_some());
16732        // The orphans are gone.
16733        assert!(remap.get(_orphan0).is_none());
16734        assert!(remap.get(_orphan1).is_none());
16735        // The surviving chain still resolves: c' reads b' reads a'.
16736        let (na, nb, nc) = (
16737            remap.get(a).unwrap(),
16738            remap.get(b).unwrap(),
16739            remap.get(c).unwrap(),
16740        );
16741        assert_eq!(g.node(nc).inputs, vec![nb]);
16742        assert_eq!(g.node(nb).inputs, vec![na]);
16743        assert_eq!(g.node(na).inputs, Vec::<NodeId>::new());
16744        assert_no_dangling(&g);
16745    }
16746
16747    /// (b) Branches preserved: a graph with a finalized `Op::Branch` keeps
16748    /// every arm + `reconverge_at` after compaction (remapped), and the
16749    /// arm-0 lowering yields the same logical route.
16750    #[test]
16751    fn compact_preserves_branch_arms_and_reconverge() {
16752        let (mut g, _diverge, arm0, arm1, reconverge, branch, post) = diamond_with_branch_b4();
16753
16754        // Inject orphan debris so compaction has something to drop and the
16755        // surviving-branch claim is non-trivial.
16756        let _debris = g.push(Node {
16757            op: Op::Const,
16758            inputs: vec![],
16759            shape: Shape::from_dims(&[2]),
16760            dtype: DType::F32,
16761        });
16762
16763        // Baseline arm-0 route over old ids, mapped to the op sequence.
16764        let before_ops: Vec<&'static str> = lower_runs_arm0(&g, &[post])
16765            .iter()
16766            .map(|&id| g.node(id).op.short_name())
16767            .collect();
16768
16769        let remap = compact(&mut g, &[post]);
16770
16771        // Every branch participant survived.
16772        let narm0 = remap.get(arm0).expect("arm0 survives");
16773        let narm1 = remap
16774            .get(arm1)
16775            .expect("arm1 (the non-fallback arm) survives");
16776        let nrecon = remap.get(reconverge).expect("reconverge survives");
16777        let nbranch = remap.get(branch).expect("the Branch node itself survives");
16778        let npost = remap.get(post).expect("post survives");
16779
16780        // The Branch's inputs are the remapped arm exits (arm0 first), and
16781        // its reconverge_at is the remapped reconverge — it still resolves.
16782        let bn = g.node(nbranch);
16783        assert!(matches!(bn.op, Op::Branch { .. }));
16784        assert_eq!(
16785            bn.inputs,
16786            vec![narm0, narm1],
16787            "arm exits remapped, arm0 first"
16788        );
16789        match bn.op {
16790            Op::Branch { reconverge_at } => assert_eq!(reconverge_at, nrecon),
16791            ref other => panic!("expected Op::Branch, got {other:?}"),
16792        }
16793        // reconverge still reads arm0 (runnability preserved through remap).
16794        assert!(g.node(nrecon).inputs.contains(&narm0));
16795        // The arm-0 route is logically identical (same op sequence).
16796        let after_ops: Vec<&'static str> = lower_runs_arm0(&g, &[npost])
16797            .iter()
16798            .map(|&id| g.node(id).op.short_name())
16799            .collect();
16800        assert_eq!(before_ops, after_ops, "arm-0 route is logically unchanged");
16801        assert_no_dangling(&g);
16802    }
16803
16804    /// (c) Base map preserved: a branchless, fully-reachable graph compacts
16805    /// to itself — no node dropped; ids may renumber but the structure is
16806    /// identical.
16807    #[test]
16808    fn compact_branchless_reachable_is_identity_in_structure() {
16809        let mut g = Graph::new();
16810        let a = g.push(Node {
16811            op: Op::Const,
16812            inputs: vec![],
16813            shape: Shape::from_dims(&[2]),
16814            dtype: DType::F32,
16815        });
16816        let b = g.push(Node {
16817            op: Op::Relu,
16818            inputs: vec![a],
16819            shape: Shape::from_dims(&[2]),
16820            dtype: DType::F32,
16821        });
16822        let c = g.push(Node {
16823            op: Op::Silu,
16824            inputs: vec![b],
16825            shape: Shape::from_dims(&[2]),
16826            dtype: DType::F32,
16827        });
16828        let d = g.push(Node {
16829            op: Op::Tanh,
16830            inputs: vec![c],
16831            shape: Shape::from_dims(&[2]),
16832            dtype: DType::F32,
16833        });
16834
16835        let before_ops: Vec<&'static str> = extract_runs(&g, d)
16836            .iter()
16837            .flat_map(|r| {
16838                r.members
16839                    .iter()
16840                    .map(|&id| g.node(id).op.short_name())
16841                    .collect::<Vec<_>>()
16842            })
16843            .collect();
16844        let before_len = g.len();
16845
16846        let remap = compact(&mut g, &[d]);
16847
16848        assert_eq!(g.len(), before_len, "a fully-reachable graph drops nothing");
16849        for n in [a, b, c, d] {
16850            assert!(remap.get(n).is_some(), "every node survives");
16851        }
16852        // Structure identical: same op sequence over the compacted graph.
16853        let nd = remap.get(d).unwrap();
16854        let after_ops: Vec<&'static str> = extract_runs(&g, nd)
16855            .iter()
16856            .flat_map(|r| {
16857                r.members
16858                    .iter()
16859                    .map(|&id| g.node(id).op.short_name())
16860                    .collect::<Vec<_>>()
16861            })
16862            .collect();
16863        assert_eq!(before_ops, after_ops);
16864        assert_no_dangling(&g);
16865    }
16866
16867    /// (d) No dangling + round-trip: after `compact`, NO NodeId reference
16868    /// (node inputs, op-carried `reconverge_at`, or any side-table) points
16869    /// outside the new arena, AND `extract_runs` / arm-0 lowering on the
16870    /// compacted graph yields the identical logical result, including for
16871    /// graphs carrying every side-table (placements, target_backends,
16872    /// layouts, storage_map, node_output_views, side_effect_roots).
16873    #[test]
16874    fn compact_no_dangling_and_round_trip_with_side_tables() {
16875        let (mut g, diverge, arm0, _arm1, reconverge, _branch, post) = diamond_with_branch_b4();
16876
16877        // Populate side-tables on surviving nodes so the remap must carry
16878        // them. (target_backend on a couple nodes; placement on one.)
16879        g.set_target_backend(arm0, BackendId::Cpu);
16880        g.set_target_backend(reconverge, BackendId::Cpu);
16881        g.set_placement(diverge, DeviceLocation::Cpu);
16882        // A side-effect root that is itself orphan-from-roots: a Release-like
16883        // op the executor must keep even though no output reads it.
16884        let se = g.push(Node {
16885            op: Op::Const,
16886            inputs: vec![post],
16887            shape: Shape::from_dims(&[2]),
16888            dtype: DType::F32,
16889        });
16890        g.add_side_effect_root(se);
16891
16892        // Orphan debris (referenced by nothing live) to force a real drop.
16893        let _debris = g.push(Node {
16894            op: Op::Const,
16895            inputs: vec![],
16896            shape: Shape::from_dims(&[2]),
16897            dtype: DType::F32,
16898        });
16899
16900        let before_run_ops: Vec<&'static str> = lower_runs_arm0(&g, &[post])
16901            .iter()
16902            .map(|&id| g.node(id).op.short_name())
16903            .collect();
16904
16905        let remap = compact(&mut g, &[post]);
16906
16907        // No dangling references anywhere.
16908        assert_no_dangling(&g);
16909
16910        // The side-effect root survived (a side-effect-only node is a
16911        // reachability seed) and its side-table entry was remapped.
16912        let nse = remap.get(se).expect("side-effect root survives compaction");
16913        assert!(
16914            g.side_effect_roots().contains(&nse),
16915            "the remapped side-effect root is registered post-compaction",
16916        );
16917        // The surviving target_backend side-table is keyed by the new ids.
16918        assert_eq!(
16919            g.target_backend(remap.get(arm0).unwrap()),
16920            Some(BackendId::Cpu)
16921        );
16922        assert_eq!(
16923            g.target_backend(remap.get(reconverge).unwrap()),
16924            Some(BackendId::Cpu)
16925        );
16926        assert_eq!(
16927            g.placement(remap.get(diverge).unwrap()),
16928            Some(DeviceLocation::Cpu)
16929        );
16930
16931        // Round-trip: the arm-0 route is logically identical.
16932        let npost = remap.get(post).unwrap();
16933        let after_run_ops: Vec<&'static str> = lower_runs_arm0(&g, &[npost])
16934            .iter()
16935            .map(|&id| g.node(id).op.short_name())
16936            .collect();
16937        assert_eq!(
16938            before_run_ops, after_run_ops,
16939            "compaction preserves the realized arm-0 route exactly",
16940        );
16941    }
16942
16943    /// Helper: assert NO NodeId reference in the graph points outside the
16944    /// arena. Covers node `inputs`, the op-carried `Op::Branch.reconverge_at`,
16945    /// every NodeId-keyed side-table, and the `side_effect_roots` vec — the
16946    /// exhaustive no-dangling safety net for a missed remap.
16947    fn assert_no_dangling(g: &Graph) {
16948        let n = g.len();
16949        let ok = |id: NodeId| id.0 < n;
16950        for i in 0..n {
16951            let node = g.node(NodeId(i));
16952            for &inp in &node.inputs {
16953                assert!(ok(inp), "Node#{i} input {:?} dangles (len={n})", inp);
16954            }
16955            if let Op::Branch { reconverge_at } = node.op {
16956                assert!(
16957                    ok(reconverge_at),
16958                    "Node#{i} Branch.reconverge_at {:?} dangles (len={n})",
16959                    reconverge_at
16960                );
16961            }
16962        }
16963        for &r in g.side_effect_roots() {
16964            assert!(ok(r), "side_effect_root {:?} dangles (len={n})", r);
16965        }
16966        // Use the graph's own verification entry point as the load-bearing
16967        // assert (it covers every side-table by reading the private fields).
16968        assert!(
16969            g.verify_no_dangling().is_ok(),
16970            "Graph::verify_no_dangling found a dangling reference: {:?}",
16971            g.verify_no_dangling().err(),
16972        );
16973    }
16974
16975    #[test]
16976    fn scan_variants_construct_and_name() {
16977        use crate::{Graph, Node, Op, ScanEmit, ScanPredicate, ScanRole};
16978        use fuel_ir::{DType, Shape};
16979        let mut g = Graph::new();
16980        let s = Shape::from_dims(&[1, 1, 1]);
16981        let carry = g.push(Node {
16982            op: Op::Const,
16983            inputs: vec![],
16984            shape: s.clone(),
16985            dtype: DType::F32,
16986        });
16987        // A ScanPlaceholder leaf (the per-step carry hole).
16988        let hole = g.push(Node {
16989            op: Op::ScanPlaceholder {
16990                role: ScanRole::Carry,
16991                index: 0,
16992            },
16993            inputs: vec![],
16994            shape: s.clone(),
16995            dtype: DType::F32,
16996        });
16997        // A trivial body: new_carry = hole (identity); body_y = hole.
16998        // Op::Scan node: inputs = [init_carry, <no xs>, <no consts>, body_new_carry, body_y].
16999        let scan = g.push(Node {
17000            op: Op::Scan {
17001                n_carries: 1,
17002                n_xs: 0,
17003                bound: 1,
17004                emit: ScanEmit::All,
17005                early_exit: None,
17006            },
17007            inputs: vec![carry, hole, hole],
17008            shape: s.clone(),
17009            dtype: DType::F32,
17010        });
17011        assert_eq!(g.node(scan).op.short_name(), "Scan");
17012        assert_eq!(g.node(hole).op.short_name(), "ScanPlaceholder");
17013        // early_exit is constructible (guarded elsewhere) but never Some on a live path.
17014        let _guarded = Op::Scan {
17015            n_carries: 1,
17016            n_xs: 0,
17017            bound: 1,
17018            emit: ScanEmit::Final,
17019            early_exit: Some(ScanPredicate),
17020        };
17021        // derive_view_output_layout: Scan is NOT a view op -> Err (handled by the catch-all).
17022        let lay = fuel_ir::Layout::contiguous(s.clone());
17023        let derived = crate::derive_view_output_layout(
17024            &Op::Scan {
17025                n_carries: 1,
17026                n_xs: 0,
17027                bound: 1,
17028                emit: ScanEmit::All,
17029                early_exit: None,
17030            },
17031            &lay,
17032        );
17033        assert!(
17034            derived.is_err(),
17035            "Op::Scan must not be treated as a view op"
17036        );
17037        let _ = scan;
17038    }
17039
17040    #[test]
17041    fn scan_builder_all_and_final_shapes() {
17042        use crate::{Graph, Node, NodeHandle, Op, ScanEmit, ScanRole};
17043        use fuel_ir::{DType, Shape};
17044        use std::sync::{Arc, RwLock};
17045        let graph = Arc::new(RwLock::new(Graph::new()));
17046        let cs = Shape::from_dims(&[2]); // carry shape
17047        let ys = Shape::from_dims(&[2]); // per-step y shape
17048        let (carry, hole, new_carry, body_y) = {
17049            let mut g = graph.write().unwrap();
17050            let carry = g.push(Node {
17051                op: Op::Const,
17052                inputs: vec![],
17053                shape: cs.clone(),
17054                dtype: DType::F32,
17055            });
17056            let hole = g.push(Node {
17057                op: Op::ScanPlaceholder {
17058                    role: ScanRole::Carry,
17059                    index: 0,
17060                },
17061                inputs: vec![],
17062                shape: cs.clone(),
17063                dtype: DType::F32,
17064            });
17065            let new_carry = g.push(Node {
17066                op: Op::MulScalar(2.0),
17067                inputs: vec![hole],
17068                shape: cs.clone(),
17069                dtype: DType::F32,
17070            });
17071            let body_y = g.push(Node {
17072                op: Op::AddScalar(1.0),
17073                inputs: vec![new_carry],
17074                shape: ys.clone(),
17075                dtype: DType::F32,
17076            });
17077            (carry, hole, new_carry, body_y)
17078        };
17079        let t = |id| NodeHandle {
17080            graph: graph.clone(),
17081            id,
17082        }; // same-crate access to private fields
17083        let init = t(carry);
17084        let (nc, by) = (t(new_carry), t(body_y));
17085        let bound = 4usize;
17086
17087        let all = init
17088            .scan(&[], &[], &nc, &by, bound, ScanEmit::All)
17089            .expect("scan All");
17090        assert_eq!(
17091            all.shape().dims(),
17092            &[bound, 2],
17093            "emit=All -> [bound] ++ body_y"
17094        );
17095
17096        // Structural guard (Task-2 review finding): the Op::Scan node's
17097        // inputs MUST end with [.., body_new_carry, body_y] in that
17098        // order — base_map_hash recurses into the last two inputs to
17099        // hash the body, so a reordering would silently hash the wrong
17100        // subgraph. `all` is a View(0) over the producer; its own
17101        // single input is the producer NodeId.
17102        {
17103            let g = graph.read().unwrap();
17104            let producer_id = g.node(all.id()).inputs[0];
17105            let producer_inputs = &g.node(producer_id).inputs;
17106            assert_eq!(
17107                producer_inputs[producer_inputs.len() - 2],
17108                nc.id(),
17109                "Op::Scan inputs[len-2] must be body_new_carry",
17110            );
17111            assert_eq!(
17112                producer_inputs[producer_inputs.len() - 1],
17113                by.id(),
17114                "Op::Scan inputs[len-1] must be body_y",
17115            );
17116        }
17117
17118        let fin = init
17119            .scan(&[], &[], &nc, &by, bound, ScanEmit::Final)
17120            .expect("scan Final");
17121        assert_eq!(fin.shape().dims(), &[2], "emit=Final -> carry shape");
17122        let _ = hole;
17123    }
17124
17125    #[test]
17126    fn scan_builder_rejects_zero_bound() {
17127        use crate::{Graph, Node, NodeHandle, Op, ScanEmit, ScanRole};
17128        use fuel_ir::{DType, Shape};
17129        use std::sync::{Arc, RwLock};
17130        let graph = Arc::new(RwLock::new(Graph::new()));
17131        let cs = Shape::from_dims(&[1]);
17132        let (carry, new_carry, body_y) = {
17133            let mut g = graph.write().unwrap();
17134            let carry = g.push(Node {
17135                op: Op::Const,
17136                inputs: vec![],
17137                shape: cs.clone(),
17138                dtype: DType::F32,
17139            });
17140            let hole = g.push(Node {
17141                op: Op::ScanPlaceholder {
17142                    role: ScanRole::Carry,
17143                    index: 0,
17144                },
17145                inputs: vec![],
17146                shape: cs.clone(),
17147                dtype: DType::F32,
17148            });
17149            let nc = g.push(Node {
17150                op: Op::MulScalar(2.0),
17151                inputs: vec![hole],
17152                shape: cs.clone(),
17153                dtype: DType::F32,
17154            });
17155            (carry, nc, nc)
17156        };
17157        let t = |id| NodeHandle {
17158            graph: graph.clone(),
17159            id,
17160        };
17161        let r = t(carry).scan(&[], &[], &t(new_carry), &t(body_y), 0, ScanEmit::All);
17162        assert!(r.is_err(), "bound == 0 must be a typed Err, not a panic");
17163    }
17164
17165    /// Task 5 (C6): every "opt-in" classifier/dispatch site over `Op` must
17166    /// treat the new `Op::Scan` / `Op::ScanPlaceholder` variants via its
17167    /// existing safe-conservative wildcard/allow-list — no site should need
17168    /// a bespoke Scan arm in Phase 1. This is a regression LOCK, not a
17169    /// born-red test: every assertion here is expected to pass immediately
17170    /// because the wildcards already exist; a red assertion means that site
17171    /// silently mishandles Scan and needs a conservative arm added.
17172    ///
17173    /// Also confirms reachability: `Op::Scan`'s body lives in its own
17174    /// `inputs` (like `Op::Branch`'s arms), so the ordinary input-closure
17175    /// walk (`topo_order_multi`) sees body nodes for free — no
17176    /// `effective_roots` / `live_set` edit is needed.
17177    #[test]
17178    fn scan_opt_in_sites_are_safe_defaults() {
17179        use crate::{Graph, Node, Op, ScanEmit, ScanRole};
17180        use fuel_ir::{DType, Shape};
17181        let scan = Op::Scan {
17182            n_carries: 1,
17183            n_xs: 0,
17184            bound: 2,
17185            emit: ScanEmit::All,
17186            early_exit: None,
17187        };
17188        let hole = Op::ScanPlaceholder {
17189            role: ScanRole::Carry,
17190            index: 0,
17191        };
17192        // Classifier defaults.
17193        assert_eq!(scan.destructive_input(), None);
17194        assert_eq!(hole.destructive_input(), None);
17195        assert!(!scan.is_view_op());
17196        assert!(!hole.is_view_op());
17197        assert_eq!(
17198            crate::infer_storage_class(&scan),
17199            crate::StorageClass::Transient
17200        );
17201        assert_eq!(crate::jit::op_to_tag(&scan), None);
17202        // Reachability: build a graph with an Op::Scan whose body references a hole;
17203        // the hole (a body node) is reachable from the Scan via inputs, no seeding.
17204        let mut g = Graph::new();
17205        let s = Shape::from_dims(&[1]);
17206        let carry = g.push(Node {
17207            op: Op::Const,
17208            inputs: vec![],
17209            shape: s.clone(),
17210            dtype: DType::F32,
17211        });
17212        let h = g.push(Node {
17213            op: hole.clone(),
17214            inputs: vec![],
17215            shape: s.clone(),
17216            dtype: DType::F32,
17217        });
17218        let nc = g.push(Node {
17219            op: Op::MulScalar(2.0),
17220            inputs: vec![h],
17221            shape: s.clone(),
17222            dtype: DType::F32,
17223        });
17224        let sc = g.push(Node {
17225            op: scan.clone(),
17226            inputs: vec![carry, nc, nc],
17227            shape: Shape::from_dims(&[2, 1]),
17228            dtype: DType::F32,
17229        });
17230        let reachable = crate::topo_order_multi(&g, &[sc]);
17231        assert!(
17232            reachable.contains(&nc) && reachable.contains(&h),
17233            "body nodes must be reachable from Op::Scan via inputs (no effective_roots edit needed)"
17234        );
17235    }
17236}