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, ¶ms);
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, ¶ms);
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, ¶ms)
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}