fuel_graph/runtime_fused.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Runtime fused-op registration — the Tier-2 sidecar
3//! (`docs/specs/runtime-fused-op-registration.md`).
4//!
5//! A runtime-registered (JIT-synthesized or import-time) fused op **is** its
6//! region: its identity is a runtime [`FusedOpId`], its recipe is the §3
7//! [`PatternNode`] region kept here, and its `decompose` is that region
8//! re-emitted as primitives — so the recipe principle (total / never-panic /
9//! primitive→self) holds for free, since [`OpTag`] is the functional-primitive
10//! vocabulary only. No kernel field: the kernel binding lives in fuel-dispatch's
11//! `FusedKernelRegistry` (Tier-1 extensible); this sidecar holds only the
12//! graph-side recipe + the optimizer rules built from it.
13//!
14//! v1 scope: **same-shape elementwise** regions (the synthesizer's increment-1
15//! epilogues). Interior shape inference for broadcast/reduction regions is a
16//! follow-up — a re-emitted node takes its first operand's shape/dtype, exact
17//! for type-preserving same-shape ops and rejected-at-registration otherwise.
18
19use std::collections::HashMap;
20use std::sync::RwLock;
21
22use fuel_kernel_seam_types::{OpAttrs, OpTag, PatternNode, matmul_roles};
23
24use crate::registry::{FusedOpId, FusedOpParams};
25use crate::{Graph, Node, NodeId, Op};
26
27/// A runtime-registered fused op's metadata (the graph-side recipe).
28#[derive(Clone, Debug)]
29pub struct RuntimeFusedOpEntry {
30 /// The allocated runtime id (`>= FusedOpId::RUNTIME_FUSED_BASE`).
31 pub id: FusedOpId,
32 /// A human/telemetry name (e.g. `"jit::relu_add::sm89::<hash>"`).
33 pub name: String,
34 /// The §3 region (the subgraph sink) — the op's primitive recipe.
35 pub region: PatternNode,
36}
37
38/// A registration failure — never a panic (build-time validation).
39#[derive(Clone, Debug, PartialEq)]
40pub enum RuntimeFusedError {
41 /// The region's bind indices don't form a contiguous `[0, n)` (the op's
42 /// external inputs).
43 NonContiguousBinds(Vec<u8>),
44 /// The region carries an op with no primitive re-emission (outside the v1
45 /// re-emit vocabulary) — it could not decompose, so we refuse to register
46 /// it (the totality guard).
47 UnRepresentable(OpTag),
48 /// The region contains a matcher-only node (`Any`/`SeeThrough`) — a
49 /// concrete region must be `Op`/`Bind` only.
50 NonConcreteRegion,
51 /// The runtime id space (`u16` above `RUNTIME_FUSED_BASE`) is exhausted.
52 IdSpaceExhausted,
53 /// A shape-relative attr (D2) that can never resolve at ANY shape — a
54 /// STRUCTURAL authoring error caught at registration: a rel field and its
55 /// concrete sibling both set, a bind reference outside the region's bind
56 /// space, `axis_last` on an axis-less tag, or a `Param` reference (no
57 /// param threading until C-4). Value-dependent declines (a `Negative` or
58 /// symbolic-extent result at some particular shape) do NOT reject
59 /// registration — they surface at emit time as a decompose fixpoint (G2).
60 InvalidRelAttrs { tag: OpTag, error: RelAttrError },
61}
62
63static RUNTIME_FUSED_OPS: RwLock<Vec<RuntimeFusedOpEntry>> = RwLock::new(Vec::new());
64
65/// The recipe-identity index for runtime-registered ops: base-map content
66/// hash ([`crate::opt::base_map_hash`]) → the [`FusedOpId`] that first
67/// registered a region hashing to it. A **sibling** to `RUNTIME_FUSED_OPS`,
68/// not a reuse of any index on the STATIC catalog (`FusedOpRegistry`, an
69/// `OnceLock`-frozen struct built at process startup for build-time-known
70/// ids `1..RUNTIME_FUSED_BASE`); runtime ops never populate a
71/// `FusedOpRegistry` instance at all, they live in this module's own
72/// `RUNTIME_FUSED_OPS` global with the disjoint `RUNTIME_FUSED_BASE..` id
73/// space. This index is the
74/// natural home for runtime-region dedup: same lifetime/global-ness as
75/// `RUNTIME_FUSED_OPS`, cleared in the same breath by
76/// `clear_runtime_fused_for_tests`.
77///
78/// `HashMap::new()` isn't `const`, so this can't be a plain
79/// `static … : RwLock<HashMap<..>> = RwLock::new(HashMap::new())` the way
80/// `RUNTIME_FUSED_OPS` is a plain `RwLock::new(Vec::new())` — `Vec::new()`
81/// is `const`, `HashMap::new()` is not. `OnceLock` lazy-inits it instead
82/// (same pattern as `registry.rs`'s `static REGISTRY: OnceLock<..>` and the
83/// per-function `OnceLock` CPU-device singletons in `opt.rs`/`grad.rs`).
84fn hash_index() -> &'static RwLock<HashMap<u64, FusedOpId>> {
85 static IDX: std::sync::OnceLock<RwLock<HashMap<u64, FusedOpId>>> = std::sync::OnceLock::new();
86 IDX.get_or_init(|| RwLock::new(HashMap::new()))
87}
88
89/// Push `arity` uniform placeholder leaves (`Op::Const`, F32 `[1]`, no
90/// storage) onto `g` and return their ids. Uniform + storage-free is
91/// load-bearing: two independently-built graphs' leaves must hash
92/// IDENTICALLY under [`crate::opt::base_map_hash`] (which folds a const's
93/// shape/dtype and silently no-ops on an unpopulated storage slot) for the
94/// dedup comparison to be meaningful. Mirrors
95/// `fuel_dispatch::jit_ingest::push_placeholder_leaves` — that crate
96/// depends on this one (not the other way around), so the few-line helper
97/// is duplicated here rather than shared.
98fn push_placeholder_leaves(graph: &mut Graph, arity: usize) -> Vec<NodeId> {
99 (0..arity)
100 .map(|_| {
101 graph.push(Node {
102 op: Op::Const,
103 inputs: vec![],
104 shape: fuel_ir::Shape::from_dims(&[1]),
105 dtype: fuel_ir::DType::F32,
106 })
107 })
108 .collect()
109}
110
111/// `region`'s structural-identity hash: emit it onto placeholder leaves
112/// (via [`emit_region`]), lower to the primitive base map
113/// ([`crate::opt::lower_to_base_map`]), hash the result
114/// ([`crate::opt::base_map_hash`]). `None` on any structural failure (a
115/// poisoned lock, a rel-attr resolution decline at the placeholder shapes,
116/// or an empty lowering result) — the caller
117/// (`register_runtime_fused`) treats `None` as "hash unavailable" and skips
118/// dedup for this registration, never blocking it.
119///
120/// Every caller in this module runs this AFTER `validate_representable`
121/// already passed for the same region, so `emit_region`'s own panic risks
122/// (an unrepresentable `OpTag`, a `Bind` index out of range) are already
123/// ruled out here — `register_runtime_fused` still wraps the call in
124/// `catch_unwind` as the never-panic contract's last-resort guard for
125/// anything this doesn't anticipate.
126fn region_base_map_hash(region: &PatternNode) -> Option<u64> {
127 let n_binds = region.bind_indices().len();
128 let scalars = vec![0.0; count_scalar_slots(region)];
129 let graph: crate::SharedGraph = std::sync::Arc::new(RwLock::new(Graph::new()));
130 let sink = {
131 let mut g = graph.write().ok()?;
132 let inputs = push_placeholder_leaves(&mut g, n_binds);
133 // Fallible entry: a rel-attr region that declines at the rank-1 `[1]`
134 // placeholder shapes yields `None` — "hash unavailable", dedup skipped
135 // for this registration (allocate-fresh), never a panic.
136 try_emit_region(&mut g, region, &inputs, &scalars).ok()?
137 };
138 let roots = crate::opt::lower_to_base_map(&graph, &[sink]);
139 let root = *roots.first()?;
140 let g = graph.read().ok()?;
141 Some(crate::opt::base_map_hash(&g, root))
142}
143
144/// Register a runtime fused op for `region`, returning its runtime
145/// [`FusedOpId`]. Validates **before** allocating that the region's bind
146/// indices form the op's input list and that every op re-emits to
147/// primitives (totality) — a non-decomposable region is rejected, never
148/// registered.
149///
150/// **Dedup (recipe identity):** a region that is structurally identical
151/// (same [`crate::opt::base_map_hash`] over its primitive lowering) to an
152/// already-registered region resolves to the EXISTING [`FusedOpId`] instead
153/// of minting a duplicate — two calls with the same shape but different
154/// `name`s return the same id, and only the first call's `name`/region is
155/// kept in `RUNTIME_FUSED_OPS`. Never-panic: hashing runs inside
156/// `catch_unwind`; any failure (a poisoned lock, an unanticipated panic) is
157/// treated as "hash unavailable" and simply skips the dedup check —
158/// registration always proceeds to today's allocate-fresh path either way.
159pub fn register_runtime_fused(
160 name: impl Into<String>,
161 region: PatternNode,
162) -> Result<FusedOpId, RuntimeFusedError> {
163 let name = name.into();
164 validate_recipe(®ion)?;
165
166 let hash = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
167 region_base_map_hash(®ion)
168 }))
169 .unwrap_or(None);
170 if hash.is_none() {
171 eprintln!(
172 "register_runtime_fused: base-map hash unavailable for {name:?}; \
173 registering without dedup (allocate-fresh fallback)"
174 );
175 }
176
177 // Hold the hash index's write lock across the whole check-then-insert
178 // sequence below (not read-then-separately-write) so two concurrent
179 // registrations of the same NEW region can't both miss the lookup and
180 // each mint their own id: the second caller blocks on this lock and,
181 // once it acquires it, observes the first caller's insert.
182 let mut idx = hash_index().write().unwrap();
183 if let Some(h) = hash
184 && let Some(&existing) = idx.get(&h)
185 {
186 return Ok(existing);
187 }
188
189 // The Vec length under the write lock is the allocator: id = BASE + index,
190 // so the index is always `id - BASE` with no allocate/push race.
191 let mut w = RUNTIME_FUSED_OPS.write().unwrap();
192 let raw = FusedOpId::RUNTIME_FUSED_BASE as usize + w.len();
193 if raw > u16::MAX as usize {
194 return Err(RuntimeFusedError::IdSpaceExhausted);
195 }
196 let id = FusedOpId(raw as u16);
197 w.push(RuntimeFusedOpEntry { id, name, region });
198 drop(w);
199
200 if let Some(h) = hash {
201 idx.insert(h, id);
202 }
203
204 Ok(id)
205}
206
207/// The region (recipe) for a runtime fused op, or `None` if `id` is not a
208/// registered runtime op.
209pub fn runtime_region(id: FusedOpId) -> Option<PatternNode> {
210 if !id.is_runtime() {
211 return None;
212 }
213 let idx = (id.0 - FusedOpId::RUNTIME_FUSED_BASE) as usize;
214 RUNTIME_FUSED_OPS
215 .read()
216 .unwrap()
217 .get(idx)
218 .map(|e| e.region.clone())
219}
220
221/// A runtime op's name (telemetry / `op_short_name` routing).
222pub fn runtime_name(id: FusedOpId) -> Option<String> {
223 if !id.is_runtime() {
224 return None;
225 }
226 let idx = (id.0 - FusedOpId::RUNTIME_FUSED_BASE) as usize;
227 RUNTIME_FUSED_OPS
228 .read()
229 .unwrap()
230 .get(idx)
231 .map(|e| e.name.clone())
232}
233
234/// All registered runtime ops — the optimizer iterates this to build a fusion
235/// rule + a lowering rule per runtime op (`RuleRegistry::default_rules` /
236/// `lowering_only`).
237pub fn runtime_entries() -> Vec<RuntimeFusedOpEntry> {
238 RUNTIME_FUSED_OPS.read().unwrap().clone()
239}
240
241/// **TEST-ONLY.** Clear the metadata sidecar AND the recipe-identity
242/// `hash_index` in the same breath. Because the Vec length *is* the id
243/// allocator (`id = BASE + index`), clearing restarts allocation — any
244/// sidecar keyed by prior runtime ids MUST be cleared alongside it or a
245/// reused id resolves stale data. This was already true for
246/// `fuel_dispatch::runtime_fused_kernels::clear_runtime_fused_for_tests`'s
247/// kernel sidecar (call that one, not this, from dispatch-level tests) and
248/// is now ALSO true for `hash_index`: leaving a stale `hash → old_id`
249/// entry after a clear would let a later registration's dedup lookup
250/// return an id that no longer names the region it was hashed from (the
251/// slot at that index now holds whatever the NEXT registration after the
252/// clear pushed there). Adopting tests share one process, so callers must
253/// also serialize with any other adopting test (dd-shapes coordination,
254/// 2026-07-08: the hook alone races). `#[doc(hidden)] pub` rather than
255/// `#[cfg(test)]` because adopting tests live in downstream crates, which
256/// compile this crate without `cfg(test)`.
257#[doc(hidden)]
258pub fn clear_runtime_fused_for_tests() {
259 RUNTIME_FUSED_OPS.write().unwrap().clear();
260 hash_index().write().unwrap().clear();
261}
262
263// ---- the region → primitive re-emit (the runtime op's `decompose`) ---------
264
265/// Project a region [`OpTag`] (+ its [`OpAttrs`]) back to a primitive [`Op`].
266/// The inverse of `jit::op_to_tag`, over the **full first-order re-emit
267/// vocabulary** (Convergence Increment A): every non-basis-gap, non-`Fused`
268/// op — elementwise, comparison, `Where`, `Cast`, shape/layout
269/// (Transpose/Permute/Reshape/BroadcastTo/(Un)squeeze/Slice/Concat/Flip/Roll/
270/// Pad/Triu/Tril), reductions (SumDim/MaxDim/MeanDim/ReduceSumTo/ReduceMaxTo/
271/// CumSum/SumAll/MaxAll/MinAll/MeanAll), `MatMul`, `Iota`, and indexing (IndexSelect/
272/// Gather/IndexAdd/ScatterAdd) — plus the **`Op::Scan` multi-output structural
273/// terminal** (Increment C, B1): `Scan` (params ride the `scan_*` carriers, body
274/// sub-graph rides the operands), its `ScanPlaceholder` body holes, and the
275/// `View` slot projection (`view_slot`) that reads the scan's `output_views`
276/// bundle at emit time — and the scalar-carrying `MaskedFill` (Increment C
277/// carriers, A2 — fill value on `attrs.scalars[0]`, dtype on `cast_dtype` when
278/// present else a provisional F32 that `emit` re-resolves to operand[0]'s dtype)
279/// and `PowI` (A3 — the i32 exponent on `scalars[0]`). Structural params are
280/// decoded from the (extended) [`OpAttrs`]. Returns `None` (an honest miss,
281/// rejected at registration) for ops with no first-order re-emission: `Clamp`
282/// (no two-scalar carrier yet), fused/basis-gap tags, and any tag whose required
283/// attrs are unset (e.g. `Iota` with no `target_shape`, `Scan` with no
284/// `scan_bound`, `View` with no `view_slot`, `PowI` with no exponent, or
285/// `MaskedFill` with no fill value / a dtype `Scalar` cannot represent).
286fn tag_to_op(tag: OpTag, attrs: &OpAttrs) -> Option<Op> {
287 use OpTag as T;
288 use fuel_ir::DType;
289 use std::str::FromStr;
290 Some(match tag {
291 T::Add => Op::Add,
292 T::Sub => Op::Sub,
293 T::Mul => Op::Mul,
294 T::Div => Op::Div,
295 T::Maximum => Op::Maximum,
296 T::Minimum => Op::Minimum,
297 T::Pow => Op::Pow,
298 T::Rem => Op::Rem,
299 T::Neg => Op::Neg,
300 T::Abs => Op::Abs,
301 T::Sqr => Op::Sqr,
302 T::Sqrt => Op::Sqrt,
303 T::Rsqrt => Op::Rsqrt,
304 T::Recip => Op::Recip,
305 T::Exp => Op::Exp,
306 T::Log => Op::Log,
307 T::Sin => Op::Sin,
308 T::Cos => Op::Cos,
309 T::Tanh => Op::Tanh,
310 T::Sigmoid => Op::Sigmoid,
311 T::Silu => Op::Silu,
312 T::GeluTanh => Op::GeluTanh,
313 T::Gelu => Op::Gelu,
314 T::Relu => Op::Relu,
315 T::Erf => Op::Erf,
316 T::Step => Op::Step,
317 T::Floor => Op::Floor,
318 T::Ceil => Op::Ceil,
319 T::Round => Op::Round,
320 T::Sign => Op::Sign,
321 // Scalar-param ops: the value rides `attrs.scalars` (the slot snapshot;
322 // live-value substitution via the `extract:` path is a follow-up).
323 T::AddScalar => Op::AddScalar(*attrs.scalars.first()?),
324 T::MulScalar => Op::MulScalar(*attrs.scalars.first()?),
325 // PowI (Increment C carriers, A3): the i32 exponent rides
326 // `attrs.scalars[0]` as an f64 — an EXACT round-trip for every i32
327 // (`|n| < 2^53`), reconstructed via `as i32`. This is the same carrier
328 // the §6.19 wire already commits to (the `to_canonical_bytes` PowI arm
329 // serializes `scalars`) and mirrors `MaskedFill`'s baked-scalar posture:
330 // the exponent is a BAKED pattern constant, NOT an open cursor slot
331 // (`scalar_slot_arity(PowI) == 0`), so the recipe author supplies it and
332 // the emitter never draws it from the params projection. An unset value
333 // is an honest miss (`None`), never a defaulted `PowI(0)`.
334 T::PowI => Op::PowI(*attrs.scalars.first()? as i32),
335
336 // --- Convergence Increment A: the full first-order set ---
337 // Comparison (dtype→U8 handled by primitive_shape, not here).
338 T::Equal => Op::Equal,
339 T::Ne => Op::Ne,
340 T::Lt => Op::Lt,
341 T::Le => Op::Le,
342 T::Gt => Op::Gt,
343 T::Ge => Op::Ge,
344 // Ternary select.
345 T::Where => Op::Where,
346 // Dtype-changing: target dtype rides `cast_dtype` (the stable name).
347 T::Cast => Op::Cast(DType::from_str(attrs.cast_dtype.as_deref()?).ok()?),
348 // MatMul: the LOCKED role-vector contraction cell (§5/D5). Empty roles
349 // = the rank-polymorphic recipe form → implicit-accept (unchanged from
350 // today; recipes keep matmul implicit). Explicit roles must match the
351 // canonical cell EXACTLY — same-rank ≥ 2, leading Batch, lhs=[..,FreeM,
352 // ContractedK], rhs=[..,ContractedK,FreeN] — checked by role POSITION,
353 // not extent (so GQA-divisible batch stays all-Batch). Any other config
354 // (transposed / multi-ContractedK / FreeN-before-K / rank mismatch) is a
355 // SURFACED honest miss (`None`, rejected at registration), never a crash.
356 T::MatMul => {
357 if attrs.lhs_roles.is_empty() && attrs.rhs_roles.is_empty() {
358 Op::MatMul
359 } else {
360 let (canon_lhs, canon_rhs) =
361 matmul_roles(attrs.lhs_roles.len(), attrs.rhs_roles.len());
362 if attrs.lhs_roles.len() == attrs.rhs_roles.len()
363 && attrs.lhs_roles.len() >= 2
364 && attrs.lhs_roles == canon_lhs
365 && attrs.rhs_roles == canon_rhs
366 {
367 Op::MatMul
368 } else {
369 return None;
370 }
371 }
372 }
373 T::LogSoftmaxLastDim => Op::LogSoftmaxLastDim,
374 // Shape / layout.
375 T::Transpose => Op::Transpose,
376 T::Permute => Op::Permute(attrs.perm.iter().map(|&x| x as usize).collect()),
377 T::Reshape => Op::Reshape(shape_from_attr(attrs)?),
378 T::BroadcastTo => Op::BroadcastTo(shape_from_attr(attrs)?),
379 T::ReduceSumTo => Op::ReduceSumTo(shape_from_attr(attrs)?),
380 T::ReduceMaxTo => Op::ReduceMaxTo(shape_from_attr(attrs)?),
381 T::Unsqueeze => Op::Unsqueeze {
382 dim: *attrs.dims.first()? as usize,
383 },
384 T::Squeeze => Op::Squeeze {
385 dim: *attrs.dims.first()? as usize,
386 },
387 T::Slice => Op::Slice {
388 dim: attrs.axis? as usize,
389 start: attrs.slice_start? as usize,
390 len: attrs.slice_len? as usize,
391 },
392 T::Concat => Op::Concat {
393 dim: attrs.axis? as usize,
394 },
395 T::Flip => Op::Flip {
396 dim: attrs.axis? as usize,
397 },
398 T::Roll => Op::Roll {
399 dim: attrs.axis? as usize,
400 shift: attrs.roll_shift?,
401 },
402 T::Pad => Op::Pad {
403 padding: attrs
404 .pad_amounts
405 .iter()
406 .map(|&(b, e)| (b as usize, e as usize))
407 .collect(),
408 mode: match attrs.pad_mode? {
409 0 => crate::PadMode::Constant,
410 1 => crate::PadMode::Reflect,
411 2 => crate::PadMode::Replicate,
412 _ => return None,
413 },
414 value: attrs.pad_value.unwrap_or(0.0),
415 },
416 T::Triu => Op::Triu {
417 diagonal: attrs.axis?,
418 },
419 T::Tril => Op::Tril {
420 diagonal: attrs.axis?,
421 },
422 // Reductions (dim rides `axis`; keepdim reductions ride `target_shape`).
423 T::SumDim => Op::SumDim(attrs.axis? as usize),
424 T::MaxDim => Op::MaxDim(attrs.axis? as usize),
425 T::MeanDim => Op::MeanDim(attrs.axis? as usize),
426 T::SumAll => Op::SumAll,
427 T::MaxAll => Op::MaxAll,
428 T::MinAll => Op::MinAll,
429 T::MeanAll => Op::MeanAll,
430 T::CumSum => Op::CumSum {
431 dim: attrs.axis? as usize,
432 },
433 // Value source leaf (len rides `target_shape` as a 1-element shape).
434 T::Iota => Op::Iota {
435 len: *attrs.target_shape.first()? as usize,
436 },
437 // Indexing (dim rides `axis`).
438 T::IndexSelect => Op::IndexSelect {
439 dim: attrs.axis? as usize,
440 },
441 T::Gather => Op::Gather {
442 dim: attrs.axis? as usize,
443 },
444 T::IndexAdd => Op::IndexAdd {
445 dim: attrs.axis? as usize,
446 },
447 T::ScatterAdd => Op::ScatterAdd {
448 dim: attrs.axis? as usize,
449 },
450
451 // --- Op::Scan structural re-emit (Increment C, B1) ---
452 // A scan's body sub-graph rides the node's operands (the trailing
453 // inputs); this arm reconstructs only the `Op::Scan` params. The body
454 // holes re-emit through the `ScanPlaceholder` arm below. `Op::Scan`
455 // stays a base-map terminal (no native kernel, no `LoweringRule`) — the
456 // recipe just re-emits it so a decompose can round-trip through data.
457 T::Scan => Op::Scan {
458 // GAP-303: `?` (an honest miss) rather than `unwrap_or(1)`. A recipe
459 // that does not state its carry count is not a single-carry scan --
460 // it is a recipe this vocabulary cannot reconstruct, and defaulting
461 // would silently rebuild a DIFFERENT graph.
462 n_carries: attrs.scan_n_carries? as usize,
463 n_xs: attrs.scan_n_xs? as usize,
464 bound: attrs.scan_bound? as usize,
465 emit: match attrs.scan_emit? {
466 0 => crate::ScanEmit::All,
467 1 => crate::ScanEmit::Final,
468 _ => return None,
469 },
470 // Phase-1 `ScanPredicate` is a marker (the predicate DAG rides the
471 // trailing `pred_exit` operand); `Some(true)` re-emits the marker.
472 early_exit: if attrs.scan_early_exit.unwrap_or(false) {
473 Some(crate::ScanPredicate)
474 } else {
475 None
476 },
477 },
478 // A body hole of `Op::Scan` (a childless leaf): `role` + `index` ride
479 // the dedicated `scan_role`/`scan_index` carriers.
480 T::ScanPlaceholder => Op::ScanPlaceholder {
481 role: match attrs.scan_role? {
482 fuel_kernel_seam_types::SCAN_ROLE_CARRY => crate::ScanRole::Carry,
483 fuel_kernel_seam_types::SCAN_ROLE_ELEM => crate::ScanRole::Elem,
484 _ => return None,
485 },
486 index: attrs.scan_index? as usize,
487 },
488 // Multi-output slot projection. The slot's shape/dtype are NOT decoded
489 // here (they come from the producer's `output_views` bundle at emit
490 // time, mirroring `Graph::view`); this arm reconstructs only the op.
491 T::View => Op::View {
492 slot: attrs.view_slot?,
493 },
494
495 // Nested fused op carried AS-IS (Increment C, C-T2, mechanism 2a). The
496 // `fused_op` selector names the registry entry (mirroring `cast_dtype`'s
497 // name-string precedent); resolve it to a `FusedOpId` and reconstruct
498 // the param-less `Op::Fused(fid, params)` via the small `fid -> params`
499 // map. An unset selector, an unknown name, or a param-carrying id (not in
500 // the map — its per-instance params can't be recovered from a name alone)
501 // is a surfaced honest miss (`None`), never a crash. The nested node's
502 // shape/dtype are computed at emit time from the entry's shape/dtype
503 // rules (`primitive_shape` honest-misses `Fused`); this arm reconstructs
504 // only the op. Fuel-INTERNAL — a `Fused` node never reaches the §6.19 wire.
505 T::Fused => {
506 let fid =
507 crate::registry::default_registry().id_for_name(attrs.fused_op.as_deref()?)?;
508 Op::Fused(fid, fused_params_for(fid)?)
509 }
510
511 // MaskedFill (Increment C carriers, A2): the fill VALUE rides
512 // `attrs.scalars[0]` (the `op_to_attrs` projection); its dtype rides
513 // `cast_dtype` when present (a concrete round-trip), else a provisional
514 // F32 that `emit` re-resolves to operand[0]'s dtype (the
515 // dtype-polymorphic recipe path — the byte executor derives `fill_bytes`
516 // at the filled tensor's width, so the Scalar dtype must match). An
517 // unset value or a dtype `Scalar` cannot represent is an honest miss.
518 T::MaskedFill => {
519 let v = *attrs.scalars.first()?;
520 let dtype = match attrs.cast_dtype.as_deref() {
521 Some(name) => DType::from_str(name).ok()?,
522 None => DType::F32,
523 };
524 Op::MaskedFill {
525 value: masked_fill_scalar(v, dtype)?,
526 }
527 }
528
529 // Honest misses (rejected at registration): Clamp (no two-scalar
530 // carrier yet), and any tag whose required attrs are unset or that is
531 // added to OpTag later.
532 _ => return None,
533 })
534}
535
536/// The `fid -> params` map for the nested fused ops a recipe carries as-is
537/// (Increment C, C-T2, mechanism 2a): the two **param-less** attention-trio
538/// nested softmaxes — `SoftmaxLastDim` (forward, used by `flash_attn` /
539/// `paged_attn`) and `SoftmaxLastDimBackward` (used by `flash_attn_backward`).
540/// A param-carrying fused op is NOT recoverable from the name-only `fused_op`
541/// selector — its per-instance params (softmax_scale, block_size, …) have no
542/// carrier here — so any other id is a surfaced honest miss (`None`), never a
543/// crash. Extend this map only when a new *param-less* nested fused op needs to
544/// ride a recipe.
545fn fused_params_for(fid: FusedOpId) -> Option<FusedOpParams> {
546 use crate::registry::FusedOps;
547 Some(match fid {
548 FusedOps::SOFTMAX_LAST_DIM => FusedOpParams::SoftmaxLastDim,
549 FusedOps::SOFTMAX_LAST_DIM_BACKWARD => FusedOpParams::SoftmaxLastDimBackward,
550 _ => return None,
551 })
552}
553
554/// Reconstruct a `MaskedFill` fill [`fuel_ir::Scalar`] from its `f64` value +
555/// the FILLED tensor's dtype (the A2 carrier). `None` for a dtype a `Scalar`
556/// cannot represent (the sub-byte dummy quant formats) — an honest miss, never
557/// a panic (so [`fuel_ir::Scalar::from_f64`]'s dummy-dtype panic is unreachable
558/// through this guard). The value rides `attrs.scalars[0]`; the dtype is the
559/// filled tensor's (operand[0]) dtype, resolved at emit time.
560fn masked_fill_scalar(value: f64, dtype: fuel_ir::DType) -> Option<fuel_ir::Scalar> {
561 // Honest miss for any dtype without a scalar rep; the Result collapses
562 // the old hand-written dummy-dtype guard.
563 fuel_ir::Scalar::from_f64(value, dtype).ok()
564}
565
566/// Decode a target [`fuel_ir::Shape`] from `attrs.target_shape` (the shared
567/// LOGICAL-shape carrier for Reshape/BroadcastTo/ReduceSumTo/ReduceMaxTo).
568/// `None` for an unset (empty) target — an honest miss — UNLESS the
569/// [`OpAttrs::rank0_target`] marker is set, which denotes the intentional
570/// rank-0 (`[]`, scalar) shape (C-T1: a reduce-to-scalar loss tail such as
571/// FSCE's `ReduceSumTo([])`). The marker is what distinguishes an authored
572/// rank-0 target from the empty-`target_shape` wildcard/unset state.
573fn shape_from_attr(attrs: &OpAttrs) -> Option<fuel_ir::Shape> {
574 if attrs.target_shape.is_empty() {
575 if attrs.rank0_target {
576 return Some(fuel_ir::Shape::from_dims(&[]));
577 }
578 return None;
579 }
580 let dims: Vec<usize> = attrs.target_shape.iter().map(|&d| d as usize).collect();
581 Some(fuel_ir::Shape::from_dims(&dims))
582}
583
584/// How many scalar values `tag` consumes from `attrs.scalars` when re-emitted.
585/// The slot machinery (extraction, validation dummy-fill, decompose fill) is
586/// keyed on this; extend alongside `tag_to_op` when a new scalar-param op joins
587/// the v1 vocabulary.
588fn scalar_slot_arity(tag: OpTag) -> usize {
589 matches!(tag, OpTag::AddScalar | OpTag::MulScalar) as usize
590}
591
592// ---- shape-relative attr resolution (Increment C slice 1, T2/D2) -----------
593
594/// A shape-relative attr resolution failure — a typed decline, never a panic.
595/// The emit-integration caller (T3) surfaces any of these as a decompose
596/// fixpoint (`return id`, G2); the registration-validation caller rejects the
597/// region. The `field` names the CONCRETE sibling the rel field resolves into
598/// (`"target_shape"`, `"slice_start"`, `"slice_len"`, `"axis"`, `"dims"`).
599#[derive(Clone, Debug, PartialEq, Eq)]
600pub enum RelAttrError {
601 /// A rel field and its concrete sibling are BOTH set — ambiguous authoring
602 /// (rel XOR abs per field), refused rather than given a silent precedence.
603 RelAbsConflict { field: &'static str },
604 /// The underlying shape-expression evaluation declined (bind out of range,
605 /// axis out of range, divide-by-zero, `Param` with no param threading, …).
606 Expr(fuel_kernel_seam_types::shape_expr::ShapeExprError),
607 /// The expression evaluated over a SYMBOLIC bind extent → a surfaced gap
608 /// (§6.20-0004): a rel attr cannot resolve concrete at emit time.
609 SymbolicGap { field: &'static str },
610 /// The expression produced a negative value where a non-negative
611 /// extent/offset is required.
612 Negative { field: &'static str, value: i64 },
613 /// `axis_last` on a tag with no axis carrier (e.g. `Add`) — meaningless,
614 /// refused (build-time validation, never silently ignored).
615 AxisLastUnsupported { tag: OpTag },
616 /// `axis_last` with no child operand — no rank to resolve LAST against.
617 NoChildOperand,
618 /// The region's Bind-space broadcast **frame** is assembled by per-axis max
619 /// across ≥2 binds (`a[N,1] ⊗ b[1,M] → [N,M]`), so NO single operand carries
620 /// it — and `SameAs { operand }`, the §6.20 EXPRESSION kind's only
621 /// whole-shape constructor, therefore cannot express it. Accepting the
622 /// `SameAs` would SILENTLY resolve a `BroadcastTo` target to a PARTIAL frame
623 /// (`[N,1]` or `[1,M]`) and emit the wrong graph, so it is refused instead
624 /// (Baracuda's §6.20 finding). A **Dims-class gap**: `missing_ctor` is the
625 /// reserved wire tag of the constructor that WOULD express it
626 /// ([`fuel_kernel_seam_types::shape_expr::TAG_DIMS`] = `0x0B`, a §6.20-0002
627 /// extension-registry entrant — proposal filed KISS #80). `frame` is the
628 /// computed per-axis-max frame, for telemetry.
629 FrameNotExpressible {
630 field: &'static str,
631 frame: Vec<i64>,
632 missing_ctor: u8,
633 },
634}
635
636/// Whether `attrs` carries any shape-RELATIVE field (D2) — the emit fast-path
637/// guard: rel-free attrs skip resolution entirely (zero behavior change for
638/// existing concrete regions).
639fn has_rel_attrs(attrs: &OpAttrs) -> bool {
640 attrs.target_shape_rel.is_some()
641 || attrs.slice_start_rel.is_some()
642 || attrs.slice_len_rel.is_some()
643 || attrs.axis_last
644 || attrs.scalar_rel.is_some()
645}
646
647/// The ONE rel-XOR-abs mutual-exclusion oracle (shared by
648/// [`resolve_rel_attrs`] and the registration rel-probe — no second copy to
649/// drift). Returns the first conflicted field name in canonical field order
650/// (`target_shape`, `slice_start`, `slice_len`, then the `axis_last` carrier —
651/// `dims` for Squeeze/Unsqueeze, `axis` otherwise), or `None`. Note the
652/// `axis_last` arm reports a carrier conflict even for a tag the resolver
653/// would refuse as [`RelAttrError::AxisLastUnsupported`] — both are typed
654/// authoring declines, and both-set is checked first.
655fn rel_abs_conflict_field(tag: OpTag, attrs: &OpAttrs) -> Option<&'static str> {
656 if attrs.target_shape_rel.is_some() && !attrs.target_shape.is_empty() {
657 return Some("target_shape");
658 }
659 if attrs.slice_start_rel.is_some() && attrs.slice_start.is_some() {
660 return Some("slice_start");
661 }
662 if attrs.slice_len_rel.is_some() && attrs.slice_len.is_some() {
663 return Some("slice_len");
664 }
665 if attrs.scalar_rel.is_some() && !attrs.scalars.is_empty() {
666 return Some("scalars");
667 }
668 if attrs.axis_last {
669 match tag {
670 OpTag::Unsqueeze | OpTag::Squeeze => {
671 if !attrs.dims.is_empty() {
672 return Some("dims");
673 }
674 }
675 _ => {
676 if attrs.axis.is_some() {
677 return Some("axis");
678 }
679 }
680 }
681 }
682 None
683}
684
685/// The region's Bind-space broadcast **frame**: the NumPy right-aligned
686/// per-axis max over EVERY bind shape (`[N,1] ⊗ [1,M] → [N,M]`) — the shape an
687/// elementwise consumer of all the binds produces. `None` when the binds carry
688/// no joint elementwise frame at all: no binds, a SYMBOLIC extent (the frame is
689/// itself a gap — the `SameAs` arm already declines those as
690/// [`RelAttrError::SymbolicGap`]), or mutually broadcast-INcompatible binds (a
691/// matmul/gather region, where per-axis max is meaningless). Pure and total —
692/// unlike the graph-builder `compute_broadcast_shape`, incompatibility is
693/// `None`, never a panic.
694fn bind_broadcast_frame(bind_shapes: &[Vec<i64>]) -> Option<Vec<i64>> {
695 use fuel_kernel_seam_types::shape_expr::SYMBOLIC;
696 let rank = bind_shapes.iter().map(Vec::len).max()?;
697 let mut frame = vec![1i64; rank];
698 for s in bind_shapes {
699 if s.iter().any(|&e| e == SYMBOLIC || e < 0) {
700 return None;
701 }
702 let pad = rank - s.len(); // right-aligned: pad the shorter with leading 1s
703 for (i, &e) in s.iter().enumerate() {
704 let f = &mut frame[pad + i];
705 if *f == e || e == 1 {
706 continue;
707 }
708 if *f == 1 {
709 *f = e;
710 continue;
711 }
712 return None; // incompatible at this axis ⇒ no joint frame
713 }
714 }
715 Some(frame)
716}
717
718/// The SameAs **degradation guard** (I1, Baracuda's §6.20 finding). Even an
719/// ELEMENTWISE output shape is not always expressible as `SameAs(operand)`:
720/// when the region's broadcast frame is assembled by per-axis max across TWO
721/// binds (`a[N,1] ⊗ b[1,M] → [N,M]`) no single operand carries the full frame,
722/// so every `SameAs` spelling resolves to a PARTIAL frame. `BroadcastTo` is the
723/// recipe's frame carrier (its target IS the elementwise output shape), so a
724/// `SameAs` target is accepted only when SOME bind does carry the whole frame;
725/// otherwise the frame is surfaced as a typed Dims-class gap rather than
726/// silently resolved to one operand's partial shape.
727///
728/// Deliberately narrow — it fires ONLY when a joint frame exists and NO bind
729/// equals it. Sub-frame broadcasts (bind1 `[T,D]` inside a `[B,T,D]` region)
730/// and frame-less regions (matmul binds) are untouched, and all 5 slice-1
731/// migrated recipes are safe by construction (their `SameAs` target is bind 0,
732/// which carries the frame).
733fn same_as_frame_guard(
734 tag: OpTag,
735 se: &fuel_kernel_seam_types::shape_expr::ShapeExpr,
736 bind_shapes: &[Vec<i64>],
737) -> Result<(), RelAttrError> {
738 use fuel_kernel_seam_types::shape_expr::{ShapeExpr, TAG_DIMS};
739 // Exhaustive on purpose: the Dims/WithDim extension entrants (KISS #80)
740 // express the max-frame DIRECTLY (a whole-shape ctor), so they must NOT be
741 // routed through this partial-`SameAs`-frame guard — they return early.
742 match se {
743 ShapeExpr::SameAs { .. } => {}
744 ShapeExpr::WithDim { .. } | ShapeExpr::Dims(_) => return Ok(()),
745 }
746 if tag != OpTag::BroadcastTo {
747 return Ok(());
748 }
749 let Some(frame) = bind_broadcast_frame(bind_shapes) else {
750 return Ok(()); // no joint elementwise frame at play
751 };
752 if bind_shapes.contains(&frame) {
753 return Ok(()); // some operand carries the whole frame ⇒ expressible
754 }
755 Err(RelAttrError::FrameNotExpressible {
756 field: "target_shape",
757 frame,
758 missing_ctor: TAG_DIMS,
759 })
760}
761
762/// Resolve `attrs`' shape-RELATIVE fields (`target_shape_rel`,
763/// `slice_start_rel`/`slice_len_rel`, `axis_last` — D2) into their concrete
764/// siblings, returning a fully-concrete [`OpAttrs`] ready for the unchanged
765/// `tag_to_op` → `primitive_shape` path. Pure: no graph access.
766///
767/// * `bind_shapes` — the region's **Bind-space** shapes, `bind_shapes[i]` =
768/// `Bind { index: i }`'s shape. This is what `ShapeExpr::SameAs { operand }`
769/// and `Dim::Extent { operand, .. }` index (the recipe-interior reference
770/// convention, same as the merged KISS shape-oracle RFC's contract roles).
771/// * `child_shapes` — THIS op's direct operand shapes (the already-emitted
772/// children), which `axis_last` resolves its rank against — a region
773/// interior node's shape generally matches NO bind.
774///
775/// Evaluation reuses `shape_expr::eval_dim`/`eval_shape`/`resolve_axis` — the
776/// single §6.20 evaluator, no second one. `Dim::Param` declines with a typed
777/// [`fuel_kernel_seam_types::shape_expr::ShapeExprError::ParamOutOfRange`] until param threading lands (C-4);
778/// symbolic bind extents decline as [`RelAttrError::SymbolicGap`]. Rel fields
779/// are CLEARED in the output (rel+abs both set in the RESULT would trip the
780/// mutual-exclusion check on a second resolve).
781pub fn resolve_rel_attrs(
782 tag: OpTag,
783 attrs: &OpAttrs,
784 bind_shapes: &[Vec<i64>],
785 child_shapes: &[Vec<i64>],
786) -> Result<OpAttrs, RelAttrError> {
787 use fuel_kernel_seam_types::shape_expr::{self, Dim, DimValue, LAST, ShapeValue, resolve_axis};
788 // Mutual exclusion FIRST, for every field, before any evaluation — so a
789 // value-dependent decline in an earlier field can't mask a rel+abs
790 // authoring conflict in a later one (the registration probe relies on
791 // this completeness).
792 if let Some(field) = rel_abs_conflict_field(tag, attrs) {
793 return Err(RelAttrError::RelAbsConflict { field });
794 }
795 let mut out = attrs.clone();
796
797 // target_shape_rel → target_shape (SameAs over the Bind space).
798 if let Some(se) = &attrs.target_shape_rel {
799 match shape_expr::eval_shape(se, bind_shapes, &[]).map_err(RelAttrError::Expr)? {
800 ShapeValue::Concrete(s) => {
801 if let Some(&bad) = s.iter().find(|&&e| e < 0) {
802 return Err(RelAttrError::Negative {
803 field: "target_shape",
804 value: bad,
805 });
806 }
807 // I1: refuse a `SameAs` target whose region frame no operand
808 // carries — a silent PARTIAL frame otherwise (see
809 // [`same_as_frame_guard`]). Runs AFTER evaluation so the
810 // structural declines (`OperandOutOfRange`, `SymbolicGap`)
811 // keep their existing precedence.
812 same_as_frame_guard(tag, se, bind_shapes)?;
813 out.target_shape = s;
814 }
815 ShapeValue::Gap => {
816 return Err(RelAttrError::SymbolicGap {
817 field: "target_shape",
818 });
819 }
820 }
821 out.target_shape_rel = None;
822 }
823
824 // slice_{start,len}_rel → slice_{start,len} (DimExpr over the Bind space).
825 let eval_dim_field = |d: &Dim, field: &'static str| -> Result<u64, RelAttrError> {
826 match shape_expr::eval_dim(d, bind_shapes, &[]).map_err(RelAttrError::Expr)? {
827 DimValue::Concrete(v) if v < 0 => Err(RelAttrError::Negative { field, value: v }),
828 DimValue::Concrete(v) => Ok(v as u64),
829 DimValue::Gap => Err(RelAttrError::SymbolicGap { field }),
830 }
831 };
832 if let Some(d) = &attrs.slice_start_rel {
833 out.slice_start = Some(eval_dim_field(d, "slice_start")?);
834 out.slice_start_rel = None;
835 }
836 if let Some(d) = &attrs.slice_len_rel {
837 out.slice_len = Some(eval_dim_field(d, "slice_len")?);
838 out.slice_len_rel = None;
839 }
840
841 // scalar_rel → scalars (DimExpr over the Bind space; the reduced_count
842 // concept — `Dim::Extent { operand, axis: LAST }` = n = extent of that
843 // operand's last axis, the norm-backward `MulScalar(n)` divisor). Rides the
844 // SAME §6.20 `eval_dim`/`resolve_axis` evaluator as the slice_* rel fields,
845 // resolved against the Bind space (not this op's children — a scalar rides no
846 // operand's rank). Unlike an extent/offset, a scalar may be negative, so only
847 // a symbolic Gap declines (§6.20-0004); every concrete value flows through.
848 if let Some(d) = &attrs.scalar_rel {
849 match shape_expr::eval_dim(d, bind_shapes, &[]).map_err(RelAttrError::Expr)? {
850 DimValue::Concrete(v) => out.scalars = vec![v as f64],
851 DimValue::Gap => return Err(RelAttrError::SymbolicGap { field: "scalars" }),
852 }
853 out.scalar_rel = None;
854 }
855
856 // axis_last → the per-tag axis carrier, resolved against operand[0]'s rank.
857 if attrs.axis_last {
858 let rank = child_shapes
859 .first()
860 .ok_or(RelAttrError::NoChildOperand)?
861 .len();
862 use OpTag as T;
863 match tag {
864 // `axis`-carrier tags: this op's LAST = rank − 1 via the shared
865 // §6.20 resolver (typed AxisOutOfRange on a rank-0 operand).
866 T::SumDim
867 | T::MaxDim
868 | T::MeanDim
869 | T::CumSum
870 | T::Concat
871 | T::Flip
872 | T::Slice
873 | T::Roll
874 | T::IndexSelect
875 | T::Gather
876 | T::IndexAdd
877 | T::ScatterAdd => {
878 let a = resolve_axis(LAST, rank).map_err(RelAttrError::Expr)?;
879 out.axis = Some(a as i64);
880 }
881 // `dims`-carrier: Unsqueeze APPENDS — dim == rank (`primitive_shape`
882 // permits `dim == rank`; keepdim-restore spelling, D3).
883 T::Unsqueeze => {
884 out.dims = vec![rank as u8];
885 }
886 // `dims`-carrier: Squeeze drops the trailing axis = rank − 1.
887 T::Squeeze => {
888 let a = resolve_axis(LAST, rank).map_err(RelAttrError::Expr)?;
889 out.dims = vec![a as u8];
890 }
891 other => return Err(RelAttrError::AxisLastUnsupported { tag: other }),
892 }
893 out.axis_last = false;
894 }
895
896 Ok(out)
897}
898
899/// Count the region's open scalar **slots** in pattern pre-order — scalar-param
900/// ops whose `attrs.scalars` is empty (a baked value is a pattern constant, not
901/// a slot). This is the length of the `scalars` vec `match_region_extract`
902/// returns for a match, and of the `FusedOpParams::Runtime { scalars }` the
903/// fused node must carry for [`decompose_region`] to fill the re-emit.
904pub fn count_scalar_slots(node: &PatternNode) -> usize {
905 match node {
906 PatternNode::Op {
907 op,
908 operands,
909 attrs,
910 } => {
911 // A `scalar_rel` node is filled from an input SHAPE at emit time, NOT
912 // from the params cursor — so it is never an open slot (mirrors a
913 // baked-value node). Only an empty-`scalars`, no-`scalar_rel`
914 // scalar-param op is a cursor slot.
915 let own = if attrs.scalars.is_empty() && attrs.scalar_rel.is_none() {
916 scalar_slot_arity(*op)
917 } else {
918 0
919 };
920 own + operands.iter().map(count_scalar_slots).sum::<usize>()
921 }
922 _ => 0,
923 }
924}
925
926/// The ONE recipe-validation oracle, shared by [`register_runtime_fused`]
927/// (runtime Tier-2 registration) and the static-registry
928/// [`crate::registry::decompose_via_recipe`] bridge (T5): bind indices form a
929/// contiguous `[0, n)` AND every op re-emits to primitives (totality — incl.
930/// the rel-attr probe). A recipe carrying a semantics-absent op token (no
931/// primitive re-emission — the flip-withdrawal posture: unknown/non-registry
932/// tokens are surfaced honest-miss declines, never accepted, never a crash)
933/// is a typed [`RuntimeFusedError::UnRepresentable`] decline here.
934pub(crate) fn validate_recipe(region: &PatternNode) -> Result<(), RuntimeFusedError> {
935 let binds = region.bind_indices();
936 let n = binds.len() as u8;
937 if binds != (0..n).collect::<Vec<_>>() {
938 return Err(RuntimeFusedError::NonContiguousBinds(binds));
939 }
940 validate_representable(region)
941}
942
943fn validate_representable(region: &PatternNode) -> Result<(), RuntimeFusedError> {
944 let n_binds = region.bind_indices().len();
945 validate_node(region, n_binds)
946}
947
948fn validate_node(node: &PatternNode, n_binds: usize) -> Result<(), RuntimeFusedError> {
949 match node {
950 PatternNode::Op {
951 op,
952 operands,
953 attrs,
954 } => {
955 // A rel-attr op is a SHAPE-POLYMORPHIC template — probe-resolve it
956 // (T3, mirror of the scalar slot dummy-fill below) so the
957 // `tag_to_op` representability check can run on concrete attrs.
958 // Structural authoring errors reject the region with a typed
959 // decline; value-dependent declines at the probe shape register
960 // fine and surface at emit time as a decompose fixpoint.
961 let probed;
962 let attrs = if has_rel_attrs(attrs) {
963 probed = rel_probe(*op, attrs, n_binds)
964 .map_err(|error| RuntimeFusedError::InvalidRelAttrs { tag: *op, error })?;
965 &probed
966 } else {
967 attrs
968 };
969 // A scalar-param op with empty scalars is a SLOT template —
970 // validate re-emittability with a dummy fill (the live value is
971 // substituted from the fused node's `Runtime { scalars }` at
972 // decompose time).
973 let representable = if attrs.scalars.is_empty() && scalar_slot_arity(*op) > 0 {
974 let mut filled = attrs.clone();
975 filled.scalars = vec![0.0; scalar_slot_arity(*op)];
976 tag_to_op(*op, &filled).is_some()
977 } else {
978 tag_to_op(*op, attrs).is_some()
979 };
980 if !representable {
981 return Err(RuntimeFusedError::UnRepresentable(*op));
982 }
983 for o in operands {
984 validate_node(o, n_binds)?;
985 }
986 Ok(())
987 }
988 PatternNode::Bind { .. } => Ok(()),
989 PatternNode::Any | PatternNode::SeeThrough { .. } => {
990 Err(RuntimeFusedError::NonConcreteRegion)
991 }
992 }
993}
994
995/// The registration-time rel-attr probe: resolve `attrs` against a fixed
996/// `[2, 4]` probe shape (every bind + the child) through the ONE resolver.
997/// * `Ok(resolved)` — fully-concrete attrs for the `tag_to_op` probe.
998/// * `Err` — a STRUCTURAL authoring error that can never resolve at ANY
999/// shape: rel+abs both set, a bind/`Param` reference out of range,
1000/// `axis_last` on an axis-less tag.
1001/// * A VALUE-dependent decline at the probe shape (`Negative`,
1002/// `AxisOutOfRange` against the probe rank, `DivideByZero` through a
1003/// derived extent, …) is NOT an authoring error — the attrs get a dummy
1004/// concrete fill instead (the emit-time resolver is the real gate; its
1005/// decline there is a G2 fixpoint).
1006fn rel_probe(tag: OpTag, attrs: &OpAttrs, n_binds: usize) -> Result<OpAttrs, RelAttrError> {
1007 use fuel_kernel_seam_types::shape_expr::ShapeExprError as E;
1008 let probe: Vec<i64> = vec![2, 4];
1009 let bind_shapes = vec![probe.clone(); n_binds];
1010 match resolve_rel_attrs(tag, attrs, &bind_shapes, std::slice::from_ref(&probe)) {
1011 Ok(resolved) => Ok(resolved),
1012 Err(
1013 e @ (RelAttrError::RelAbsConflict { .. }
1014 | RelAttrError::AxisLastUnsupported { .. }
1015 | RelAttrError::Expr(E::OperandOutOfRange { .. } | E::ParamOutOfRange { .. })),
1016 ) => Err(e),
1017 Err(_) => Ok(dummy_fill_rel(tag, attrs)),
1018 }
1019}
1020
1021/// Clear `attrs`' rel fields and dummy-fill their concrete siblings (only
1022/// where the sibling is unset — a rel+abs conflict never reaches here, the
1023/// probe rejects it first) so `tag_to_op` representability can be checked.
1024fn dummy_fill_rel(tag: OpTag, attrs: &OpAttrs) -> OpAttrs {
1025 let mut out = attrs.clone();
1026 if out.target_shape_rel.take().is_some() && out.target_shape.is_empty() {
1027 out.target_shape = vec![1];
1028 }
1029 if out.slice_start_rel.take().is_some() && out.slice_start.is_none() {
1030 out.slice_start = Some(0);
1031 }
1032 if out.slice_len_rel.take().is_some() && out.slice_len.is_none() {
1033 out.slice_len = Some(1);
1034 }
1035 if out.scalar_rel.take().is_some() && out.scalars.is_empty() {
1036 out.scalars = vec![1.0];
1037 }
1038 if out.axis_last {
1039 out.axis_last = false;
1040 match tag {
1041 OpTag::Unsqueeze | OpTag::Squeeze => {
1042 if out.dims.is_empty() {
1043 out.dims = vec![0];
1044 }
1045 }
1046 _ => {
1047 if out.axis.is_none() {
1048 out.axis = Some(0);
1049 }
1050 }
1051 }
1052 }
1053 out
1054}
1055
1056/// Decompose a runtime `Op::Fused(id, Runtime { .. })` node by re-emitting its
1057/// region as primitives, returning the new root (the re-emitted sink). If `id`
1058/// is not a registered runtime op the node is returned unchanged (a fixpoint —
1059/// no recipe, G2). The matched node's inputs are the region's bound external
1060/// inputs in bind-index order.
1061pub fn decompose_region(graph: &mut Graph, node_id: NodeId) -> NodeId {
1062 let (fid, node_scalars) = match &graph.node(node_id).op {
1063 Op::Fused(id, FusedOpParams::Runtime { scalars }) => (*id, scalars.clone()),
1064 Op::Fused(id, _) => (*id, Vec::new()),
1065 _ => return node_id,
1066 };
1067 let region = match runtime_region(fid) {
1068 Some(r) => r,
1069 None => return node_id,
1070 };
1071 // The node's live scalars must fill the region's slots exactly (pattern
1072 // pre-order, the same canon `match_region_extract` produced them in). A
1073 // mismatch is a malformed fused node — surfaced as a no-op fixpoint (the
1074 // lowering driver records no progress), never a crash (G2).
1075 if node_scalars.len() != count_scalar_slots(®ion) {
1076 return node_id;
1077 }
1078 let inputs = graph.node(node_id).inputs.clone();
1079 let bind_shapes = bind_operand_shapes(graph, &inputs);
1080 let mut cursor = node_scalars.as_slice();
1081 // A shape-relative attr that fails to resolve at THESE input shapes (a
1082 // symbolic extent, a negative result, …) is a typed decline surfaced as a
1083 // no-op fixpoint (G2) — same posture as the slot-count mismatch above,
1084 // never a panic. Any child nodes emitted before the decline stay in the
1085 // push-only graph as unreferenced dead nodes (inert).
1086 emit(
1087 graph,
1088 ®ion,
1089 &inputs,
1090 &bind_shapes,
1091 &mut cursor,
1092 &mut Vec::new(),
1093 )
1094 .unwrap_or(node_id)
1095}
1096
1097/// Re-emit a validated region on the given external input nodes (public entry
1098/// for callers holding a raw [`PatternNode`] + input [`NodeId`]s — e.g. the
1099/// reference realization during candidate-kernel verification, which has a raw
1100/// region and freshly-pushed `Op::Const` input nodes rather than a Fused node
1101/// already in the graph). `scalars` fill the region's open scalar slots in
1102/// pre-order (the canonical order `match_region_extract` recorded them in);
1103/// pass `&[]` for a parameterless region. Thin wrapper over the private
1104/// `emit`; the same re-emittability caveat applies (a non-re-emittable
1105/// `OpTag` panics inside `emit` — validated decomposes never carry one).
1106/// Second panic risk: `emit`'s scalar-cursor fill (`scalars.split_at(arity)`)
1107/// panics if `scalars` is shorter than the region's total open-slot count.
1108/// [`decompose_region`] (the node-driven caller) guards this with its own
1109/// length check before ever calling `emit`; `emit_region` deliberately does
1110/// NOT — it's a thin wrapper, so validating the length is the caller's job.
1111/// Callers must pass a `scalars` slice at least as long as the region's
1112/// open-slot count. Third (T3): a shape-RELATIVE attr (D2) that fails to
1113/// resolve at these input shapes panics through the wrapper's `expect` —
1114/// rel-attr callers use [`try_emit_region`], which surfaces it as a typed
1115/// [`RelAttrError`] instead.
1116pub fn emit_region(
1117 graph: &mut Graph,
1118 region: &PatternNode,
1119 inputs: &[NodeId],
1120 scalars: &[f64],
1121) -> NodeId {
1122 try_emit_region(graph, region, inputs, scalars).expect(
1123 "rel-attr resolution failed — emit_region callers pass concrete-attr or \
1124 shape-compatible pre-validated regions; fallible callers use try_emit_region",
1125 )
1126}
1127
1128/// The FALLIBLE re-emit entry (Increment C slice 1, T3): like [`emit_region`]
1129/// but surfacing a shape-relative attr resolution failure (D2) as a typed
1130/// [`RelAttrError`] instead of a panic. This is the resolving entry the
1131/// registry `decompose_via_recipe` bridge calls (any failure ⇒ `return id`,
1132/// the G2 fixpoint). Concrete-attr regions can never hit the `Err` arm — for
1133/// them this is exactly the legacy `emit_region`. The `emit_region` panic
1134/// caveats (non-re-emittable `OpTag`, short `scalars` slice) apply unchanged.
1135pub fn try_emit_region(
1136 graph: &mut Graph,
1137 region: &PatternNode,
1138 inputs: &[NodeId],
1139 scalars: &[f64],
1140) -> Result<NodeId, RelAttrError> {
1141 let bind_shapes = bind_operand_shapes(graph, inputs);
1142 let mut cursor = scalars;
1143 emit(
1144 graph,
1145 region,
1146 inputs,
1147 &bind_shapes,
1148 &mut cursor,
1149 &mut Vec::new(),
1150 )
1151}
1152
1153/// A graph [`fuel_ir::Shape`] as a §6.20 evaluator operand: per-axis extents
1154/// with a bounded-symbolic (`Extent::Range`) axis mapped to the
1155/// [`shape_expr::SYMBOLIC`] sentinel — so a rel attr over a symbolic extent
1156/// declines as [`RelAttrError::SymbolicGap`] (surfaced gap, §6.20-0004)
1157/// instead of silently resolving against the capacity bound.
1158fn shape_expr_operand(shape: &fuel_ir::Shape) -> Vec<i64> {
1159 use fuel_kernel_seam_types::shape_expr::SYMBOLIC;
1160 (0..shape.rank())
1161 .map(|a| {
1162 if shape.extent(a).is_dynamic() {
1163 SYMBOLIC
1164 } else {
1165 shape.dims()[a] as i64
1166 }
1167 })
1168 .collect()
1169}
1170
1171/// The region's **Bind-space** shapes (`bind_shapes[i]` = `inputs[i]`'s shape)
1172/// in §6.20 operand form — what `ShapeExpr::SameAs`/`Dim::Extent` index.
1173fn bind_operand_shapes(graph: &Graph, inputs: &[NodeId]) -> Vec<Vec<i64>> {
1174 inputs
1175 .iter()
1176 .map(|&id| shape_expr_operand(&graph.node(id).shape))
1177 .collect()
1178}
1179
1180/// The recursive re-emit core. `memo` is the per-emit-call identity-share
1181/// table (T5): a REPEATED slot-free subtree — the tree spelling of a DAG
1182/// recipe's shared interior (e.g. softmax's `e = Exp(..)`, consumed by both
1183/// the denominator reduce and the final Div) — emits ONCE, so the emitted
1184/// graph is the DAG, not a duplicated-compute tree. Lookup is by structural
1185/// equality (`PatternNode: PartialEq`; regions are tiny, a linear scan is
1186/// fine) and is sound because, within one call, `inputs`/`bind_shapes` are
1187/// fixed and emission is deterministic — equal subtrees emit equal nodes.
1188/// Subtrees with OPEN scalar slots are NEVER shared: each occurrence takes
1189/// its own value(s) from the pre-order cursor. (The flat-DAG node table with
1190/// real CSE is slice 3; this is only within-call identity-share.)
1191fn emit<'r>(
1192 graph: &mut Graph,
1193 node: &'r PatternNode,
1194 inputs: &[NodeId],
1195 bind_shapes: &[Vec<i64>],
1196 scalars: &mut &[f64],
1197 memo: &mut Vec<(&'r PatternNode, NodeId)>,
1198) -> Result<NodeId, RelAttrError> {
1199 match node {
1200 PatternNode::Bind { index } => Ok(inputs[*index as usize]),
1201 PatternNode::Op {
1202 op,
1203 operands,
1204 attrs,
1205 } => {
1206 // Identity-share: a slot-free subtree already emitted in THIS call
1207 // re-uses its node (see the fn doc). Checked before the cursor
1208 // fill — a slot-free subtree never moves the cursor, so a hit
1209 // cannot misalign later slots.
1210 let sharable = count_scalar_slots(node) == 0;
1211 if sharable && let Some(&(_, id)) = memo.iter().find(|(p, _)| *p == node) {
1212 return Ok(id);
1213 }
1214 // Fill an open scalar slot from the cursor in PRE-order (before
1215 // descending into operands) — the same canonical order
1216 // `match_region_extract` recorded the live values in. (T3 note:
1217 // children are now EMITTED before the attrs are USED, but the
1218 // cursor fill stays right here, before the descent — the cursor
1219 // order is authoring order, not emission order.)
1220 // A `scalar_rel` node is filled from an input shape by the rel-attr
1221 // resolver below (NOT the params cursor) — matching
1222 // `count_scalar_slots`, which does not count it as a slot, so it must
1223 // not consume a cursor value here either.
1224 let arity = scalar_slot_arity(*op);
1225 let filled;
1226 let attrs = if attrs.scalars.is_empty() && arity > 0 && attrs.scalar_rel.is_none() {
1227 let (take, rest) = scalars.split_at(arity);
1228 *scalars = rest;
1229 filled = OpAttrs {
1230 scalars: take.to_vec(),
1231 ..attrs.clone()
1232 };
1233 &filled
1234 } else {
1235 attrs
1236 };
1237 // Children FIRST (T3 reorder): their emitted shapes feed the
1238 // rel-attr resolver (`axis_last`'s rank, D4's pad decision).
1239 let mut child_ids = Vec::with_capacity(operands.len());
1240 for o in operands {
1241 child_ids.push(emit(graph, o, inputs, bind_shapes, scalars, memo)?);
1242 }
1243 let mut child_shapes: Vec<fuel_ir::Shape> = child_ids
1244 .iter()
1245 .map(|&c| graph.node(c).shape.clone())
1246 .collect();
1247 let child_dtypes: Vec<fuel_ir::DType> =
1248 child_ids.iter().map(|&c| graph.node(c).dtype).collect();
1249 // Shape-RELATIVE attrs (D2) resolve to fully-concrete siblings
1250 // against the region's Bind space + this op's operand shapes; the
1251 // unchanged tag_to_op → primitive_shape path then runs on the
1252 // result. A failure is a typed decline the caller surfaces
1253 // (`decompose_region` ⇒ fixpoint, `try_emit_region` ⇒ `Err`) —
1254 // never a panic. Nodes already pushed for the children stay in the
1255 // graph as unreferenced (dead) nodes: `Graph` is push-only and
1256 // base-map extraction walks from roots, so they are inert.
1257 let resolved;
1258 let attrs = if has_rel_attrs(attrs) {
1259 let child_ops: Vec<Vec<i64>> =
1260 child_shapes.iter().map(shape_expr_operand).collect();
1261 resolved = resolve_rel_attrs(*op, attrs, bind_shapes, &child_ops)?;
1262 &resolved
1263 } else {
1264 attrs
1265 };
1266 let mut prim =
1267 tag_to_op(*op, attrs).expect("region validated re-emittable at registration");
1268 // A `MaskedFill` fill Scalar must carry the FILLED tensor's
1269 // (operand[0]) dtype — the byte executor derives `fill_bytes` at
1270 // that width. A recipe authors the value dtype-polymorphically (no
1271 // `cast_dtype`), so re-resolve the Scalar at operand[0]'s emitted
1272 // dtype here, mirroring the imperative `Scalar::one(dtype)`. Concrete
1273 // round-trips already carry the matching dtype (identity); a dtype a
1274 // Scalar cannot represent leaves the provisional value untouched (an
1275 // inert dead node, never a panic — the executor rejects it later).
1276 if let Op::MaskedFill { value } = &prim
1277 && let Some(&dt) = child_dtypes.first()
1278 && value.dtype() != dt
1279 && let Some(fixed) = masked_fill_scalar(value.to_f64(), dt)
1280 {
1281 prim = Op::MaskedFill { value: fixed };
1282 }
1283 // D4: a `BroadcastTo` whose target rank EXCEEDS its operand's rank
1284 // first materializes the legacy `Reshape` pad (1-padded left,
1285 // right-aligned — byte-identical to `registry::rope`'s hand-built
1286 // broadcast prep, since `check_broadcast_compatible` is
1287 // right-aligned). Recipes stay free of rank-dependent nodes while
1288 // the emitted graph matches the legacy imperative builders.
1289 // Applied uniformly (rel-resolved AND absolute targets); an
1290 // equal-rank broadcast is unchanged (no pad).
1291 if let Op::BroadcastTo(target) = &prim
1292 && let Some(cs) = child_shapes.first()
1293 && target.rank() > cs.rank()
1294 {
1295 let mut padded: Vec<usize> = vec![1; target.rank() - cs.rank()];
1296 padded.extend_from_slice(cs.dims());
1297 let pad_shape = fuel_ir::Shape::from_dims(&padded);
1298 let pad = graph.push(Node {
1299 op: Op::Reshape(pad_shape.clone()),
1300 inputs: vec![child_ids[0]],
1301 shape: pad_shape.clone(),
1302 dtype: child_dtypes[0],
1303 });
1304 child_ids[0] = pad;
1305 child_shapes[0] = pad_shape;
1306 }
1307 // Shape/dtype for the emitted node. Most ops use the single source
1308 // of truth (`primitive_shape`) — a pure function of operand shapes,
1309 // correct for shape-changing/reducing/dtype-changing ops. Two
1310 // structural multi-output terminals are NOT such a function and are
1311 // resolved here with the graph in hand, mirroring `NodeHandle::scan` /
1312 // `Graph::view` (Increment C, B1):
1313 // * `Op::Scan` — the node's PRIMARY (slot-0) shape is the stacked
1314 // ys `[bound] ++ body_y`, and its 2-slot `output_views` bundle
1315 // (slot 0 = stacked ys, slot 1 = final carry) is attached AFTER
1316 // the push so downstream `Op::View`s can read it.
1317 // * `Op::View` — the slot's shape/dtype come from the producer's
1318 // `output_views[slot]` (set when the producing `Scan` emitted).
1319 // Both fall back to operand[0]'s shape/dtype on a malformed authored
1320 // region (never a panic) — the same posture as the primitive_shape
1321 // fallback (`.first()`, never `[0]`, guards a zero-operand leaf).
1322 fn fallback_sd(
1323 cs: &[fuel_ir::Shape],
1324 cd: &[fuel_ir::DType],
1325 ) -> (fuel_ir::Shape, fuel_ir::DType) {
1326 (
1327 cs.first()
1328 .cloned()
1329 .unwrap_or_else(|| fuel_ir::Shape::from_dims(&[])),
1330 cd.first().copied().unwrap_or(fuel_ir::DType::F32),
1331 )
1332 }
1333 let mut scan_bundle: Option<Vec<fuel_ir::storage::OutputViewSpec>> = None;
1334 let (s, d) = match &prim {
1335 Op::Scan {
1336 n_carries: _,
1337 n_xs,
1338 bound,
1339 early_exit,
1340 ..
1341 } => {
1342 // inputs = [init_carry, xs(n_xs), consts.., body_new_carry,
1343 // body_y, [pred_exit]] — the Phase-1 lax.scan encoding.
1344 let n_trailing = if early_exit.is_some() { 3 } else { 2 };
1345 let len = child_ids.len();
1346 if len >= 1 + *n_xs + n_trailing {
1347 let carry_shape = child_shapes[0].clone();
1348 let carry_dtype = child_dtypes[0];
1349 let body_y_i = len - n_trailing + 1;
1350 let y_shape = &child_shapes[body_y_i];
1351 let y_dtype = child_dtypes[body_y_i];
1352 let mut ys_dims = Vec::with_capacity(1 + y_shape.rank());
1353 ys_dims.push(*bound);
1354 ys_dims.extend_from_slice(y_shape.dims());
1355 let ys_shape = fuel_ir::Shape::from_dims(&ys_dims);
1356 scan_bundle = Some(vec![
1357 fuel_ir::storage::OutputViewSpec::contiguous(y_dtype, ys_shape.clone()),
1358 fuel_ir::storage::OutputViewSpec::contiguous(carry_dtype, carry_shape),
1359 ]);
1360 (ys_shape, y_dtype)
1361 } else {
1362 fallback_sd(&child_shapes, &child_dtypes)
1363 }
1364 }
1365 Op::View { slot } => child_ids
1366 .first()
1367 .and_then(|&producer| {
1368 graph
1369 .output_views(producer)
1370 .and_then(|v| v.get(*slot as usize))
1371 .map(|spec| (spec.shape.clone(), spec.dtype))
1372 })
1373 .unwrap_or_else(|| fallback_sd(&child_shapes, &child_dtypes)),
1374 // A nested fused node carried as-is (Increment C, C-T2, mechanism
1375 // 2a). `primitive_shape` honest-misses `Fused` — a fused op is not
1376 // a first-order shape-inferable primitive — so its output frame
1377 // comes from the named registry entry's OWN `shape_rule` /
1378 // `dtype_rule` (e.g. softmax passes both through). Falls back to
1379 // operand[0] on an unregistered id (never a panic), the same
1380 // posture as the primitive_shape fallback.
1381 Op::Fused(fid, params) => match crate::registry::default_registry().entry(*fid) {
1382 Some(e) => (
1383 (e.shape_rule)(&child_shapes, params),
1384 (e.dtype_rule)(&child_dtypes, params),
1385 ),
1386 None => fallback_sd(&child_shapes, &child_dtypes),
1387 },
1388 // A childless scan-body hole (`Op::ScanPlaceholder`). Its shape
1389 // is DECLARED on the recipe node (`target_shape` /
1390 // `target_shape_rel`, resolved above) rather than inferred from
1391 // (absent) operands, so the re-emitted body carries the same
1392 // per-step shapes the imperative `selective_scan`/`ssd_chunk_scan`
1393 // decompose stamps on its placeholders. This is load-bearing:
1394 // `unroll_scan` CLONES body interior nodes with their STORED
1395 // shapes, so a rank-0 fallback here would poison the unrolled
1396 // graph (e.g. `du = Mul(d_t, u_t)` would come out rank-0). Dtype =
1397 // the scan's working dtype = bind 0's (uniform-dtype scan inputs).
1398 // Both fall back to the operand/F32 default on a shapeless
1399 // authored placeholder (B1's round-trip recipes), never a panic.
1400 Op::ScanPlaceholder { .. } => (
1401 shape_from_attr(attrs)
1402 .unwrap_or_else(|| fallback_sd(&child_shapes, &child_dtypes).0),
1403 inputs
1404 .first()
1405 .map(|&b| graph.node(b).dtype)
1406 .unwrap_or_else(|| fallback_sd(&child_shapes, &child_dtypes).1),
1407 ),
1408 _ => crate::shape::primitive_shape(&prim, &child_shapes, &child_dtypes)
1409 .unwrap_or_else(|_| fallback_sd(&child_shapes, &child_dtypes)),
1410 };
1411 let out = graph.push(Node {
1412 op: prim,
1413 inputs: child_ids,
1414 shape: s,
1415 dtype: d,
1416 });
1417 // Attach the scan's 2-slot bundle (mirrors `NodeHandle::scan`). A
1418 // malformed spec (compose/validate failure) leaves the producer
1419 // bundle-less — a later `View` then falls back — never a panic.
1420 if let Some(specs) = scan_bundle
1421 && let Ok((_bytes, views)) = fuel_ir::storage::compose_bundle(&specs)
1422 {
1423 let _ = graph.set_output_views(out, std::sync::Arc::from(views.into_boxed_slice()));
1424 }
1425 if sharable {
1426 memo.push((node, out));
1427 }
1428 Ok(out)
1429 }
1430 PatternNode::Any | PatternNode::SeeThrough { .. } => {
1431 unreachable!("region validated concrete (Op/Bind) at registration")
1432 }
1433 }
1434}
1435
1436/// A [`crate::opt::LoweringRule`]-shaped `decompose` for runtime ops: re-emit
1437/// the region. The scalar `extract:` substitution rides on the NODE (its
1438/// `FusedOpParams::Runtime { scalars }` fills the region's open slots inside
1439/// [`decompose_region`]), so the rule-shaped `params` argument stays unused.
1440pub fn runtime_lowering_decompose(
1441 graph: &mut Graph,
1442 node_id: NodeId,
1443 _params: &FusedOpParams,
1444) -> NodeId {
1445 decompose_region(graph, node_id)
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450 use super::*;
1451 use fuel_ir::{DType, Shape};
1452
1453 fn relu_add_region() -> PatternNode {
1454 PatternNode::Op {
1455 op: OpTag::Relu,
1456 attrs: OpAttrs::default(),
1457 operands: vec![PatternNode::Op {
1458 op: OpTag::Add,
1459 attrs: OpAttrs::default(),
1460 operands: vec![
1461 PatternNode::Bind { index: 0 },
1462 PatternNode::Bind { index: 1 },
1463 ],
1464 }],
1465 }
1466 }
1467
1468 /// Structurally DISTINCT from `relu_add_region()` (`Mul` inner op, not
1469 /// `Add`) — used only by
1470 /// `register_allocates_a_runtime_id_and_keeps_the_region`, whose
1471 /// assertion on `runtime_name` needs a region no OTHER test in this
1472 /// module also registers. Since Task 7's dedup (`register_runtime_fused`
1473 /// above) resolves any two structurally-identical regions registered
1474 /// anywhere in the process to the SAME id — and `RUNTIME_FUSED_OPS` /
1475 /// `hash_index` are process-global statics shared by every `#[test]` in
1476 /// this binary, which `cargo test` runs concurrently by default — a
1477 /// `runtime_name` assertion tied to one specific registration call would
1478 /// be racy against any other test using `relu_add_region()` (both
1479 /// `decompose_region_re_emits_relu_add` and
1480 /// `register_runtime_fused_dedups_structurally_identical_regions` do):
1481 /// whichever call reaches the shared hash slot FIRST wins the name, and
1482 /// thread scheduling decides which that is. Those other two tests never
1483 /// assert on `runtime_name`, so they're unaffected by dedup either way;
1484 /// this one needs its own hash to stay deterministic.
1485 fn relu_mul_region() -> PatternNode {
1486 PatternNode::Op {
1487 op: OpTag::Relu,
1488 attrs: OpAttrs::default(),
1489 operands: vec![PatternNode::Op {
1490 op: OpTag::Mul,
1491 attrs: OpAttrs::default(),
1492 operands: vec![
1493 PatternNode::Bind { index: 0 },
1494 PatternNode::Bind { index: 1 },
1495 ],
1496 }],
1497 }
1498 }
1499
1500 #[test]
1501 fn register_allocates_a_runtime_id_and_keeps_the_region() {
1502 let id = register_runtime_fused("test::relu_mul", relu_mul_region()).unwrap();
1503 assert!(id.is_runtime(), "allocated id is in the runtime range");
1504 assert_eq!(runtime_region(id), Some(relu_mul_region()));
1505 assert_eq!(runtime_name(id).as_deref(), Some("test::relu_mul"));
1506 }
1507
1508 #[test]
1509 fn register_runtime_fused_dedups_structurally_identical_regions() {
1510 let id1 = register_runtime_fused("dedup::a", relu_add_region()).unwrap();
1511 let id2 = register_runtime_fused("dedup::b", relu_add_region()).unwrap(); // same region, different name
1512 assert_eq!(
1513 id1, id2,
1514 "an identical region must resolve to the same FusedOpId, not a duplicate"
1515 );
1516 }
1517
1518 #[test]
1519 fn register_rejects_non_contiguous_binds() {
1520 // bind indices {0, 2} — missing 1.
1521 let region = PatternNode::Op {
1522 op: OpTag::Add,
1523 attrs: OpAttrs::default(),
1524 operands: vec![
1525 PatternNode::Bind { index: 0 },
1526 PatternNode::Bind { index: 2 },
1527 ],
1528 };
1529 assert_eq!(
1530 register_runtime_fused("bad", region),
1531 Err(RuntimeFusedError::NonContiguousBinds(vec![0, 2]))
1532 );
1533 }
1534
1535 #[test]
1536 fn register_rejects_unrepresentable_region() {
1537 // Convergence A made MatMul/shape/reduction ops representable, and
1538 // Increment C carriers made MaskedFill (A2) + PowI (A3) representable;
1539 // `Clamp` stays an honest miss (no two-scalar carrier yet), so it is the
1540 // current canonical still-unrepresentable tag.
1541 let region = PatternNode::Op {
1542 op: OpTag::Clamp,
1543 attrs: OpAttrs::default(),
1544 operands: vec![PatternNode::Bind { index: 0 }],
1545 };
1546 assert_eq!(
1547 register_runtime_fused("bad", region),
1548 Err(RuntimeFusedError::UnRepresentable(OpTag::Clamp))
1549 );
1550 }
1551
1552 #[test]
1553 fn tag_to_op_reconstructs_shape_changing_ops() {
1554 use fuel_ir::Shape;
1555 // Slice{dim:1,start:2,len:3}
1556 let attrs = OpAttrs {
1557 axis: Some(1),
1558 slice_start: Some(2),
1559 slice_len: Some(3),
1560 ..OpAttrs::default()
1561 };
1562 assert!(matches!(
1563 super::tag_to_op(OpTag::Slice, &attrs),
1564 Some(Op::Slice {
1565 dim: 1,
1566 start: 2,
1567 len: 3
1568 })
1569 ));
1570 // Concat{dim:0}
1571 let attrs = OpAttrs {
1572 axis: Some(0),
1573 ..OpAttrs::default()
1574 };
1575 assert!(matches!(
1576 super::tag_to_op(OpTag::Concat, &attrs),
1577 Some(Op::Concat { dim: 0 })
1578 ));
1579 // Reshape([6])
1580 let attrs = OpAttrs {
1581 target_shape: vec![6],
1582 ..OpAttrs::default()
1583 };
1584 assert_eq!(
1585 super::tag_to_op(OpTag::Reshape, &attrs),
1586 Some(Op::Reshape(Shape::from_dims(&[6])))
1587 );
1588 // BroadcastTo([2,3])
1589 let attrs = OpAttrs {
1590 target_shape: vec![2, 3],
1591 ..OpAttrs::default()
1592 };
1593 assert_eq!(
1594 super::tag_to_op(OpTag::BroadcastTo, &attrs),
1595 Some(Op::BroadcastTo(Shape::from_dims(&[2, 3])))
1596 );
1597 // ReduceMaxTo([2,1])
1598 let attrs = OpAttrs {
1599 target_shape: vec![2, 1],
1600 ..OpAttrs::default()
1601 };
1602 assert_eq!(
1603 super::tag_to_op(OpTag::ReduceMaxTo, &attrs),
1604 Some(Op::ReduceMaxTo(Shape::from_dims(&[2, 1])))
1605 );
1606 }
1607
1608 #[test]
1609 fn shape_target_ops_represent_rank0_via_the_marker() {
1610 // C-T1: an INTENTIONAL rank-0 (`[]`) reduce/reshape/broadcast target.
1611 // A rank-0 shape has empty `target_shape` — the same empty state as an
1612 // unset/wildcard target — so the `rank0_target` marker disambiguates.
1613 use fuel_ir::Shape;
1614 let empty = Shape::from_dims(&[]);
1615 // Marker SET → the concrete rank-0 shape (RED before the shape_from_attr
1616 // rank0 arm: an empty `target_shape` honest-missed to `None`).
1617 let rank0 = OpAttrs {
1618 rank0_target: true,
1619 ..OpAttrs::default()
1620 };
1621 assert_eq!(super::shape_from_attr(&rank0), Some(empty.clone()));
1622 assert_eq!(
1623 super::tag_to_op(OpTag::ReduceSumTo, &rank0),
1624 Some(Op::ReduceSumTo(empty.clone()))
1625 );
1626 assert_eq!(
1627 super::tag_to_op(OpTag::ReduceMaxTo, &rank0),
1628 Some(Op::ReduceMaxTo(empty.clone()))
1629 );
1630 assert_eq!(
1631 super::tag_to_op(OpTag::Reshape, &rank0),
1632 Some(Op::Reshape(empty.clone()))
1633 );
1634 assert_eq!(
1635 super::tag_to_op(OpTag::BroadcastTo, &rank0),
1636 Some(Op::BroadcastTo(empty))
1637 );
1638 // Marker UNSET + empty target_shape stays an honest miss (wildcard).
1639 assert_eq!(super::shape_from_attr(&OpAttrs::default()), None);
1640 assert_eq!(
1641 super::tag_to_op(OpTag::ReduceSumTo, &OpAttrs::default()),
1642 None
1643 );
1644 }
1645
1646 #[test]
1647 fn tag_to_op_reconstructs_reductions_dtype_and_matmul() {
1648 use fuel_ir::DType;
1649 assert!(matches!(
1650 super::tag_to_op(
1651 OpTag::MeanDim,
1652 &OpAttrs {
1653 axis: Some(1),
1654 ..OpAttrs::default()
1655 }
1656 ),
1657 Some(Op::MeanDim(1))
1658 ));
1659 assert!(matches!(
1660 super::tag_to_op(OpTag::MatMul, &OpAttrs::default()),
1661 Some(Op::MatMul)
1662 ));
1663 // Cast target dtype via name.
1664 let attrs = OpAttrs {
1665 cast_dtype: Some("f16".into()),
1666 ..OpAttrs::default()
1667 };
1668 assert_eq!(
1669 super::tag_to_op(OpTag::Cast, &attrs),
1670 Some(Op::Cast(DType::F16))
1671 );
1672 // Comparison.
1673 assert!(matches!(
1674 super::tag_to_op(OpTag::Lt, &OpAttrs::default()),
1675 Some(Op::Lt)
1676 ));
1677 }
1678
1679 #[test]
1680 fn tag_to_op_reconstructs_masked_fill() {
1681 // A2 (Increment C carriers): the MaskedFill re-emit carrier. Value on
1682 // `scalars[0]`, dtype on `cast_dtype` (the `op_to_attrs` projection) —
1683 // RED before A2 (MaskedFill was the `_ => return None` honest miss).
1684 use fuel_ir::Scalar;
1685 let attrs = OpAttrs {
1686 scalars: vec![-1.0],
1687 cast_dtype: Some("f16".into()),
1688 ..OpAttrs::default()
1689 };
1690 match super::tag_to_op(OpTag::MaskedFill, &attrs) {
1691 Some(Op::MaskedFill { value }) => {
1692 assert_eq!(value.dtype(), DType::F16, "dtype rides cast_dtype");
1693 assert_eq!(
1694 value,
1695 Scalar::from_f64(-1.0, DType::F16).unwrap(),
1696 "value rides scalars[0]"
1697 );
1698 }
1699 other => panic!("expected MaskedFill, got {other:?}"),
1700 }
1701 // No fill value ⇒ honest miss (None), never a defaulted 0.
1702 assert_eq!(
1703 super::tag_to_op(OpTag::MaskedFill, &OpAttrs::default()),
1704 None
1705 );
1706 // A dtype `Scalar` cannot represent (sub-byte dummy) ⇒ honest miss.
1707 let dummy = OpAttrs {
1708 scalars: vec![1.0],
1709 cast_dtype: Some("f4".into()),
1710 ..OpAttrs::default()
1711 };
1712 assert_eq!(super::tag_to_op(OpTag::MaskedFill, &dummy), None);
1713 // dtype-polymorphic authoring (no cast_dtype) reconstructs a provisional
1714 // F32 that `emit` later re-resolves to operand[0]'s dtype.
1715 let poly = OpAttrs {
1716 scalars: vec![1.0],
1717 ..OpAttrs::default()
1718 };
1719 assert!(matches!(
1720 super::tag_to_op(OpTag::MaskedFill, &poly),
1721 Some(Op::MaskedFill { .. })
1722 ));
1723 // The fill value is a BAKED pattern constant, not a cursor slot.
1724 assert_eq!(super::scalar_slot_arity(OpTag::MaskedFill), 0);
1725 }
1726
1727 #[test]
1728 fn tag_to_op_reconstructs_powi() {
1729 // A3 (Increment C carriers): the PowI i32-exponent re-emit carrier. The
1730 // exponent rides `scalars[0]` as an f64 (an EXACT round-trip for every
1731 // i32 — |n| < 2^53), reconstructed via `as i32`. RED before A3 (PowI was
1732 // in the `_ => return None` honest-miss set).
1733 assert!(matches!(
1734 super::tag_to_op(
1735 OpTag::PowI,
1736 &OpAttrs {
1737 scalars: vec![3.0],
1738 ..OpAttrs::default()
1739 }
1740 ),
1741 Some(Op::PowI(3))
1742 ));
1743 // Negative exponent (e.g. PowI(-1) = reciprocal — the exp==0 backward arm).
1744 assert!(matches!(
1745 super::tag_to_op(
1746 OpTag::PowI,
1747 &OpAttrs {
1748 scalars: vec![-1.0],
1749 ..OpAttrs::default()
1750 }
1751 ),
1752 Some(Op::PowI(-1))
1753 ));
1754 // No exponent ⇒ honest miss (None), never a defaulted PowI(0).
1755 assert_eq!(super::tag_to_op(OpTag::PowI, &OpAttrs::default()), None);
1756 // The exponent is a BAKED pattern constant (like MaskedFill's fill), not
1757 // a cursor slot — so it never draws from the params projection.
1758 assert_eq!(super::scalar_slot_arity(OpTag::PowI), 0);
1759 }
1760
1761 #[test]
1762 fn masked_fill_region_registers_and_emits_with_operand_dtype() {
1763 // Before A2 this region was `UnRepresentable(MaskedFill)`; now it
1764 // registers, and `emit` resolves the fill Scalar to operand[0]'s dtype
1765 // (dtype-polymorphic — the imperative `Scalar::one(dtype)` behaviour).
1766 // No `clear_runtime_fused_for_tests()` here: this test asserts on the
1767 // EMITTED op (dtype/value), never on the registry state or the minted
1768 // id, so the clear served nothing — and calling it unserialized in this
1769 // parallel #[test] binary raced every other registry-touching test by
1770 // wiping ids mid-run (GAP-219; the fn's own doc requires callers to
1771 // serialize). Registering a unique region without a clean slate is fine.
1772 let region = PatternNode::Op {
1773 op: OpTag::MaskedFill,
1774 attrs: OpAttrs {
1775 scalars: vec![1.0],
1776 ..OpAttrs::default()
1777 },
1778 operands: vec![
1779 PatternNode::Bind { index: 0 },
1780 PatternNode::Bind { index: 1 },
1781 ],
1782 };
1783 register_runtime_fused("mf::poly", region.clone())
1784 .expect("MaskedFill region registers (was UnRepresentable before A2)");
1785 // Emit over an F16 tensor + U8 mask ⇒ MaskedFill with an F16 fill Scalar.
1786 let mut g = Graph::new();
1787 let x = g.push(Node {
1788 op: Op::Const,
1789 inputs: vec![],
1790 shape: Shape::from_dims(&[2, 3]),
1791 dtype: DType::F16,
1792 });
1793 let mask = g.push(Node {
1794 op: Op::Const,
1795 inputs: vec![],
1796 shape: Shape::from_dims(&[2, 3]),
1797 dtype: DType::U8,
1798 });
1799 let root = emit_region(&mut g, ®ion, &[x, mask], &[]);
1800 match &g.node(root).op {
1801 Op::MaskedFill { value } => {
1802 assert_eq!(
1803 value.dtype(),
1804 DType::F16,
1805 "fill Scalar re-resolved to operand[0] dtype"
1806 );
1807 assert_eq!(
1808 value.to_f64(),
1809 1.0,
1810 "fill VALUE preserved through the re-resolution"
1811 );
1812 }
1813 other => panic!("expected MaskedFill, got {other:?}"),
1814 }
1815 assert_eq!(g.node(root).dtype, DType::F16);
1816 }
1817
1818 #[test]
1819 fn powi_region_registers_and_emits() {
1820 // Before A3 a bare PowI region was `UnRepresentable(PowI)`; now it
1821 // registers, and `emit` reconstructs the i32 exponent from `scalars[0]`.
1822 // No `clear_runtime_fused_for_tests()` here — see the masked_fill test
1823 // above: this test asserts on the emitted op, not registry state, and
1824 // an unserialized clear in a parallel binary raced other tests (GAP-219).
1825 let region = PatternNode::Op {
1826 op: OpTag::PowI,
1827 attrs: OpAttrs {
1828 scalars: vec![3.0],
1829 ..OpAttrs::default()
1830 },
1831 operands: vec![PatternNode::Bind { index: 0 }],
1832 };
1833 register_runtime_fused("powi::cube", region.clone())
1834 .expect("PowI region registers (was UnRepresentable before A3)");
1835 let mut g = Graph::new();
1836 let x = g.push(Node {
1837 op: Op::Const,
1838 inputs: vec![],
1839 shape: Shape::from_dims(&[2, 3]),
1840 dtype: DType::F32,
1841 });
1842 let root = emit_region(&mut g, ®ion, &[x], &[]);
1843 assert!(
1844 matches!(g.node(root).op, Op::PowI(3)),
1845 "emit reconstructs Op::PowI(3) from scalars[0]",
1846 );
1847 assert_eq!(
1848 g.node(root).shape,
1849 Shape::from_dims(&[2, 3]),
1850 "PowI is shape-preserving"
1851 );
1852 assert_eq!(g.node(root).dtype, DType::F32);
1853 }
1854
1855 #[test]
1856 fn tag_to_op_matmul_resolves_canonical_roles() {
1857 // T9 (D5): explicit CANONICAL role vectors resolve to Op::MatMul. The
1858 // resolver checks role POSITIONS against the locked cell, not extents.
1859 let attrs = OpAttrs {
1860 lhs_roles: vec![1, 3],
1861 rhs_roles: vec![3, 2],
1862 ..OpAttrs::default()
1863 };
1864 assert!(matches!(
1865 super::tag_to_op(OpTag::MatMul, &attrs),
1866 Some(Op::MatMul)
1867 ));
1868 // Rank-4 canonical (leading Batch dims) also resolves — GQA-divisible
1869 // batch extents stay all-Batch (positions, not extents).
1870 let attrs4 = OpAttrs {
1871 lhs_roles: vec![0, 0, 1, 3],
1872 rhs_roles: vec![0, 0, 3, 2],
1873 ..OpAttrs::default()
1874 };
1875 assert!(matches!(
1876 super::tag_to_op(OpTag::MatMul, &attrs4),
1877 Some(Op::MatMul)
1878 ));
1879 }
1880
1881 #[test]
1882 fn tag_to_op_matmul_empty_roles_implicit_accept() {
1883 // Empty roles = the rank-polymorphic recipe form → implicit-accept
1884 // (unchanged from today; recipes keep matmul implicit).
1885 assert!(matches!(
1886 super::tag_to_op(OpTag::MatMul, &OpAttrs::default()),
1887 Some(Op::MatMul)
1888 ));
1889 }
1890
1891 #[test]
1892 fn tag_to_op_matmul_rejects_noncanonical_roles() {
1893 // Non-canonical role configs are a SURFACED honest miss (typed decline at
1894 // registration), never a crash.
1895 // (1) transposed lhs = [ContractedK, FreeM] = [3,1] instead of [1,3].
1896 let transposed = OpAttrs {
1897 lhs_roles: vec![3, 1],
1898 rhs_roles: vec![3, 2],
1899 ..OpAttrs::default()
1900 };
1901 assert_eq!(super::tag_to_op(OpTag::MatMul, &transposed), None);
1902 // (2) multi-ContractedK on lhs = [3,3].
1903 let multi_k = OpAttrs {
1904 lhs_roles: vec![3, 3],
1905 rhs_roles: vec![3, 2],
1906 ..OpAttrs::default()
1907 };
1908 assert_eq!(super::tag_to_op(OpTag::MatMul, &multi_k), None);
1909 // (3) FreeN-before-K on rhs = [FreeN, ContractedK] = [2,3] instead of [3,2].
1910 let freen_before_k = OpAttrs {
1911 lhs_roles: vec![1, 3],
1912 rhs_roles: vec![2, 3],
1913 ..OpAttrs::default()
1914 };
1915 assert_eq!(super::tag_to_op(OpTag::MatMul, &freen_before_k), None);
1916 }
1917
1918 #[test]
1919 fn tag_to_op_reconstructs_max_dim() {
1920 // T4 (Increment C slice 1): OpTag::MaxDim → Op::MaxDim(axis), the
1921 // axis riding `attrs.axis` exactly like SumDim/MeanDim.
1922 assert!(matches!(
1923 super::tag_to_op(
1924 OpTag::MaxDim,
1925 &OpAttrs {
1926 axis: Some(1),
1927 ..OpAttrs::default()
1928 }
1929 ),
1930 Some(Op::MaxDim(1))
1931 ));
1932 // An unset axis is an honest miss (typed decline at registration),
1933 // never a defaulted axis.
1934 assert_eq!(super::tag_to_op(OpTag::MaxDim, &OpAttrs::default()), None);
1935 // Not a scalar-param op: zero scalar slots.
1936 assert_eq!(super::scalar_slot_arity(OpTag::MaxDim), 0);
1937 }
1938
1939 #[test]
1940 fn max_dim_axis_last_resolves_to_rank_minus_one() {
1941 // D3 consumer: migrated recipes spell keepdim as MaxDim(axis_last) +
1942 // Unsqueeze(append), so MaxDim must be an `axis`-carrier tag for the
1943 // rel-attr resolver (rank − 1 via the shared §6.20 LAST resolver).
1944 let attrs = OpAttrs {
1945 axis_last: true,
1946 ..OpAttrs::default()
1947 };
1948 let resolved =
1949 super::resolve_rel_attrs(OpTag::MaxDim, &attrs, &[vec![2, 3, 4]], &[vec![2, 3, 4]])
1950 .expect("axis_last must resolve on MaxDim, not AxisLastUnsupported");
1951 assert_eq!(resolved.axis, Some(2), "rank-3 operand → LAST = axis 2");
1952 assert!(
1953 !resolved.axis_last,
1954 "rel carrier must be cleared post-resolve"
1955 );
1956 }
1957
1958 #[test]
1959 fn tag_to_op_still_rejects_basis_gap_and_scan() {
1960 // qmatmul/conv flow through Op::Fused (no OpTag); Scan is higher-order.
1961 assert_eq!(
1962 super::tag_to_op(OpTag::Iota, &OpAttrs::default()),
1963 None,
1964 "Iota needs a len (target_shape) — empty attrs is a miss"
1965 );
1966 }
1967
1968 // ---- C-T2 (Increment C): the nested-fused re-emit carrier (mechanism 2a) --
1969
1970 /// `OpTag::Fused` reconstructs the param-less nested fused ops from the
1971 /// `fused_op` selector (name → fid → params), and honest-misses anything
1972 /// else. Born-red before the C-T2 `tag_to_op` arm: `Fused` falls to the
1973 /// `_ => return None` catch-all, so every `Some(..)` assertion below fails.
1974 #[test]
1975 fn tag_to_op_reconstructs_nested_fused() {
1976 use crate::registry::{FusedOpParams, FusedOps};
1977 let sm = OpAttrs {
1978 fused_op: Some("SoftmaxLastDim".into()),
1979 ..OpAttrs::default()
1980 };
1981 assert_eq!(
1982 super::tag_to_op(OpTag::Fused, &sm),
1983 Some(Op::Fused(
1984 FusedOps::SOFTMAX_LAST_DIM,
1985 FusedOpParams::SoftmaxLastDim
1986 )),
1987 "SoftmaxLastDim selector reconstructs the param-less nested fused op",
1988 );
1989 let smb = OpAttrs {
1990 fused_op: Some("SoftmaxLastDimBackward".into()),
1991 ..OpAttrs::default()
1992 };
1993 assert_eq!(
1994 super::tag_to_op(OpTag::Fused, &smb),
1995 Some(Op::Fused(
1996 FusedOps::SOFTMAX_LAST_DIM_BACKWARD,
1997 FusedOpParams::SoftmaxLastDimBackward,
1998 )),
1999 "SoftmaxLastDimBackward selector reconstructs its param-less nested fused op",
2000 );
2001 // Honest misses (never a crash): unset selector, unknown name, and a
2002 // param-carrying fused op (not round-trippable through the name-only
2003 // selector — its params can't be reconstructed from the map).
2004 assert_eq!(
2005 super::tag_to_op(OpTag::Fused, &OpAttrs::default()),
2006 None,
2007 "an unset fused_op selector is an honest miss",
2008 );
2009 let bogus = OpAttrs {
2010 fused_op: Some("NotARealFusedOp".into()),
2011 ..OpAttrs::default()
2012 };
2013 assert_eq!(
2014 super::tag_to_op(OpTag::Fused, &bogus),
2015 None,
2016 "an unknown name is an honest miss"
2017 );
2018 let paged = OpAttrs {
2019 fused_op: Some("PagedAttn".into()),
2020 ..OpAttrs::default()
2021 };
2022 assert_eq!(
2023 super::tag_to_op(OpTag::Fused, &paged),
2024 None,
2025 "a registered but param-carrying fused op is an honest miss (not in the fid->params map)",
2026 );
2027 }
2028
2029 /// A hand-built recipe carrying a nested `Op::Fused(SOFTMAX_LAST_DIM)` node
2030 /// validates as re-emittable and round-trips through `emit`: the emitted node
2031 /// is the real nested fused op, with shape/dtype from the registry entry's
2032 /// `shape_rule`/`dtype_rule` (softmax passes both through) since
2033 /// `primitive_shape` honest-misses `Fused`. Born-red before the C-T2 arms:
2034 /// `validate_recipe` rejects the region `UnRepresentable`.
2035 #[test]
2036 fn emit_reconstructs_nested_fused_shape_and_dtype() {
2037 use crate::registry::{FusedOpParams, FusedOps};
2038 use fuel_ir::{DType, Shape};
2039 let region = PatternNode::Op {
2040 op: OpTag::Fused,
2041 attrs: OpAttrs {
2042 fused_op: Some("SoftmaxLastDim".into()),
2043 ..OpAttrs::default()
2044 },
2045 operands: vec![PatternNode::Bind { index: 0 }],
2046 };
2047 assert!(
2048 super::validate_recipe(®ion).is_ok(),
2049 "the nested-fused recipe validates as re-emittable (C-T2 tag_to_op arm)",
2050 );
2051 let mut g = Graph::new();
2052 let x = g.push(Node {
2053 op: Op::Const,
2054 inputs: vec![],
2055 shape: Shape::from_dims(&[2, 3, 4]),
2056 dtype: DType::F32,
2057 });
2058 let root = emit_region(&mut g, ®ion, &[x], &[]);
2059 assert_eq!(
2060 g.node(root).op,
2061 Op::Fused(FusedOps::SOFTMAX_LAST_DIM, FusedOpParams::SoftmaxLastDim),
2062 "the nested fused node is reconstructed as-is (2a)",
2063 );
2064 assert_eq!(
2065 g.node(root).shape,
2066 Shape::from_dims(&[2, 3, 4]),
2067 "shape from softmax shape_rule (passthrough), NOT primitive_shape (which misses Fused)",
2068 );
2069 assert_eq!(
2070 g.node(root).dtype,
2071 DType::F32,
2072 "dtype from softmax dtype_rule (passthrough)"
2073 );
2074 assert_eq!(
2075 g.node(root).inputs,
2076 vec![x],
2077 "the fused node's single operand is the bound input"
2078 );
2079 }
2080
2081 #[test]
2082 fn validate_representable_now_accepts_a_slice_region() {
2083 // Region: Concat{0}(Neg(Slice{...}(bind0)), bind0) — the rope rotate-half shape.
2084 let region = PatternNode::Op {
2085 op: OpTag::Concat,
2086 attrs: OpAttrs {
2087 axis: Some(0),
2088 ..OpAttrs::default()
2089 },
2090 operands: vec![
2091 PatternNode::Op {
2092 op: OpTag::Neg,
2093 attrs: OpAttrs::default(),
2094 operands: vec![PatternNode::Op {
2095 op: OpTag::Slice,
2096 attrs: OpAttrs {
2097 axis: Some(0),
2098 slice_start: Some(0),
2099 slice_len: Some(1),
2100 ..OpAttrs::default()
2101 },
2102 operands: vec![PatternNode::Bind { index: 0 }],
2103 }],
2104 },
2105 PatternNode::Bind { index: 0 },
2106 ],
2107 };
2108 assert!(
2109 super::validate_representable(®ion).is_ok(),
2110 "slice/concat region must now validate"
2111 );
2112 }
2113
2114 #[test]
2115 fn emit_gets_shape_right_for_a_reduction_region() {
2116 use fuel_ir::{DType, Shape};
2117 // Region: ReduceSumTo([2,1])(bind0). Input [2,5] → output [2,1].
2118 let region = PatternNode::Op {
2119 op: OpTag::ReduceSumTo,
2120 attrs: OpAttrs {
2121 target_shape: vec![2, 1],
2122 ..OpAttrs::default()
2123 },
2124 operands: vec![PatternNode::Bind { index: 0 }],
2125 };
2126 let mut g = Graph::new();
2127 let x = g.push(Node {
2128 op: Op::Const,
2129 inputs: vec![],
2130 shape: Shape::from_dims(&[2, 5]),
2131 dtype: DType::F32,
2132 });
2133 let root = emit_region(&mut g, ®ion, &[x], &[]);
2134 assert!(matches!(g.node(root).op, Op::ReduceSumTo(_)));
2135 assert_eq!(
2136 g.node(root).shape,
2137 Shape::from_dims(&[2, 1]),
2138 "emit must use the reduced shape, not operand[0]"
2139 );
2140 assert_eq!(g.node(root).dtype, DType::F32);
2141 }
2142
2143 #[test]
2144 fn emit_gets_dtype_right_for_a_cast_region() {
2145 use fuel_ir::{DType, Shape};
2146 // Region: Cast(F16)(bind0). Input F32 → output F16, same shape.
2147 let region = PatternNode::Op {
2148 op: OpTag::Cast,
2149 attrs: OpAttrs {
2150 cast_dtype: Some("f16".into()),
2151 ..OpAttrs::default()
2152 },
2153 operands: vec![PatternNode::Bind { index: 0 }],
2154 };
2155 let mut g = Graph::new();
2156 let x = g.push(Node {
2157 op: Op::Const,
2158 inputs: vec![],
2159 shape: Shape::from_dims(&[3, 3]),
2160 dtype: DType::F32,
2161 });
2162 let root = emit_region(&mut g, ®ion, &[x], &[]);
2163 assert!(matches!(g.node(root).op, Op::Cast(DType::F16)));
2164 assert_eq!(
2165 g.node(root).dtype,
2166 DType::F16,
2167 "emit must take Cast's target dtype, not operand[0]'s"
2168 );
2169 assert_eq!(g.node(root).shape, Shape::from_dims(&[3, 3]));
2170 }
2171
2172 #[test]
2173 fn emit_zero_operand_representable_region_is_panic_free() {
2174 // M-1 never-panic hardening: a MALFORMED region — a binary op given ZERO
2175 // operands. `validate_representable` accepts it (it checks
2176 // `tag_to_op(op).is_some()`, NOT arity), and `emit_region` is a public
2177 // raw-region entry (candidate-kernel verification) that does not
2178 // re-validate. `primitive_shape(Add, [], [])` errs, so the fallback runs
2179 // with an EMPTY child_shapes — it must NOT index-panic. emit stays total:
2180 // it returns a node (with a degenerate rank-0 shape), never a panic.
2181 let region = PatternNode::Op {
2182 op: OpTag::Add,
2183 attrs: OpAttrs::default(),
2184 operands: vec![],
2185 };
2186 let mut g = Graph::new();
2187 let root = emit_region(&mut g, ®ion, &[], &[]);
2188 assert!(
2189 matches!(g.node(root).op, Op::Add),
2190 "emit returns a node, not a panic"
2191 );
2192 }
2193
2194 #[test]
2195 fn decompose_region_re_emits_relu_add() {
2196 let id = register_runtime_fused("test::relu_add::decompose", relu_add_region()).unwrap();
2197 let mut g = Graph::new();
2198 let s = Shape::from_dims(&[4]);
2199 let a = g.push(Node {
2200 op: Op::Const,
2201 inputs: vec![],
2202 shape: s.clone(),
2203 dtype: DType::F32,
2204 });
2205 let b = g.push(Node {
2206 op: Op::Const,
2207 inputs: vec![],
2208 shape: s.clone(),
2209 dtype: DType::F32,
2210 });
2211 let fused = g.push(Node {
2212 op: Op::Fused(id, FusedOpParams::Runtime { scalars: vec![] }),
2213 inputs: vec![a, b],
2214 shape: s.clone(),
2215 dtype: DType::F32,
2216 });
2217
2218 let root = decompose_region(&mut g, fused);
2219
2220 // The re-emitted sink is Relu over Add(a, b) — the region, on primitives.
2221 assert!(matches!(g.node(root).op, Op::Relu));
2222 let add_id = g.node(root).inputs[0];
2223 assert!(matches!(g.node(add_id).op, Op::Add));
2224 assert_eq!(g.node(add_id).inputs, vec![a, b]);
2225 // Shapes propagated from the leaves (same-shape elementwise).
2226 assert_eq!(g.node(root).shape, s);
2227 assert_eq!(g.node(add_id).shape, s);
2228 }
2229
2230 // ---- scalar slots (the `extract:` substitution) ---------------------
2231
2232 /// tanh(mul_scalar(a)) with the scalar left OPEN (a slot template).
2233 fn tanh_mul_scalar_slot_region() -> PatternNode {
2234 PatternNode::Op {
2235 op: OpTag::Tanh,
2236 attrs: OpAttrs::default(),
2237 operands: vec![PatternNode::Op {
2238 op: OpTag::MulScalar,
2239 attrs: OpAttrs::default(), // empty scalars = an open slot
2240 operands: vec![PatternNode::Bind { index: 0 }],
2241 }],
2242 }
2243 }
2244
2245 #[test]
2246 fn slot_template_registers_and_counts() {
2247 // Born-red before slot support: validation rejected an AddScalar/
2248 // MulScalar pattern node with no baked value.
2249 let id =
2250 register_runtime_fused("test::tanh_mul_scalar::slot", tanh_mul_scalar_slot_region())
2251 .expect("a slot template is registrable");
2252 let region = runtime_region(id).unwrap();
2253 assert_eq!(count_scalar_slots(®ion), 1, "one open slot");
2254 }
2255
2256 #[test]
2257 fn decompose_fills_slots_from_the_node_scalars() {
2258 let id =
2259 register_runtime_fused("test::tanh_mul_scalar::fill", tanh_mul_scalar_slot_region())
2260 .unwrap();
2261 let mut g = Graph::new();
2262 let s = Shape::from_dims(&[4]);
2263 let a = g.push(Node {
2264 op: Op::Const,
2265 inputs: vec![],
2266 shape: s.clone(),
2267 dtype: DType::F32,
2268 });
2269 let fused = g.push(Node {
2270 op: Op::Fused(id, FusedOpParams::Runtime { scalars: vec![2.5] }),
2271 inputs: vec![a],
2272 shape: s.clone(),
2273 dtype: DType::F32,
2274 });
2275
2276 let root = decompose_region(&mut g, fused);
2277
2278 // tanh(mul_scalar(a, 2.5)) — the LIVE value filled the slot.
2279 assert!(matches!(g.node(root).op, Op::Tanh));
2280 let ms = g.node(root).inputs[0];
2281 assert!(
2282 matches!(g.node(ms).op, Op::MulScalar(v) if v == 2.5),
2283 "slot filled with the node's live scalar, got {:?}",
2284 g.node(ms).op,
2285 );
2286 assert_eq!(g.node(ms).inputs, vec![a]);
2287 }
2288
2289 #[test]
2290 fn decompose_slot_count_mismatch_is_a_fixpoint_not_a_crash() {
2291 let id = register_runtime_fused(
2292 "test::tanh_mul_scalar::mismatch",
2293 tanh_mul_scalar_slot_region(),
2294 )
2295 .unwrap();
2296 let mut g = Graph::new();
2297 let s = Shape::from_dims(&[4]);
2298 let a = g.push(Node {
2299 op: Op::Const,
2300 inputs: vec![],
2301 shape: s.clone(),
2302 dtype: DType::F32,
2303 });
2304 // One slot, but the node carries NO scalars — malformed; must be a
2305 // no-op fixpoint (G2), never a panic.
2306 let fused = g.push(Node {
2307 op: Op::Fused(id, FusedOpParams::Runtime { scalars: vec![] }),
2308 inputs: vec![a],
2309 shape: s.clone(),
2310 dtype: DType::F32,
2311 });
2312 assert_eq!(
2313 decompose_region(&mut g, fused),
2314 fused,
2315 "mismatch ⇒ fixpoint"
2316 );
2317 }
2318
2319 // ---- shape-derived scalar slot (A1: the reduced_count live-emission) -----
2320
2321 /// `mul_scalar(a)` whose scalar is a SHAPE-DERIVED value: `scalar_rel =
2322 /// Extent(operand 0, LAST)` — the reduced_count of bind 0's last axis, filled
2323 /// from the input shape at emit time (NOT from the params cursor). This is
2324 /// the narrow carrier a norm-backward's `MulScalar(n = dims[last])` needs.
2325 fn mul_scalar_reduced_count_region() -> PatternNode {
2326 use fuel_kernel_seam_types::shape_expr::{Dim, LAST};
2327 PatternNode::Op {
2328 op: OpTag::MulScalar,
2329 attrs: OpAttrs {
2330 scalar_rel: Some(Dim::Extent {
2331 operand: 0,
2332 axis: LAST,
2333 }),
2334 ..OpAttrs::default()
2335 },
2336 operands: vec![PatternNode::Bind { index: 0 }],
2337 }
2338 }
2339
2340 /// A1 born-red: a `scalar_rel` node is filled from an input SHAPE, so it is
2341 /// NOT a params-cursor slot (`count_scalar_slots == 0`) and its emitted value
2342 /// is the reduced_count of the resolving input's last axis. RED before the
2343 /// wiring: `count_scalar_slots` counts the empty-`scalars` MulScalar as one
2344 /// open slot, and `emit` ignores `scalar_rel` (fills from the cursor).
2345 #[test]
2346 fn scalar_rel_is_shape_derived_not_a_cursor_slot() {
2347 let region = mul_scalar_reduced_count_region();
2348 assert_eq!(
2349 count_scalar_slots(®ion),
2350 0,
2351 "a scalar_rel node is shape-derived, never a params-cursor slot",
2352 );
2353 // Emit over x[3,5]: n = last extent = 5, with NO cursor scalars.
2354 let mut g = Graph::new();
2355 let x = g.push(Node {
2356 op: Op::Const,
2357 inputs: vec![],
2358 shape: Shape::from_dims(&[3, 5]),
2359 dtype: DType::F32,
2360 });
2361 let root = try_emit_region(&mut g, ®ion, &[x], &[])
2362 .expect("scalar_rel resolves against the input shape");
2363 assert!(
2364 matches!(g.node(root).op, Op::MulScalar(v) if v == 5.0),
2365 "scalar_rel = Extent(0, LAST) over x[3,5] ⇒ MulScalar(5.0), got {:?}",
2366 g.node(root).op,
2367 );
2368 assert_eq!(g.node(root).inputs, vec![x]);
2369 // Rank-polymorphic: the SAME datum resolves to 7 over x[..,7].
2370 let mut g3 = Graph::new();
2371 let x3 = g3.push(Node {
2372 op: Op::Const,
2373 inputs: vec![],
2374 shape: Shape::from_dims(&[2, 4, 7]),
2375 dtype: DType::F32,
2376 });
2377 let root3 = try_emit_region(&mut g3, ®ion, &[x3], &[]).unwrap();
2378 assert!(
2379 matches!(g3.node(root3).op, Op::MulScalar(v) if v == 7.0),
2380 "same datum, x[2,4,7] ⇒ MulScalar(7.0), got {:?}",
2381 g3.node(root3).op,
2382 );
2383 }
2384
2385 /// A1: a `scalar_rel` set together with a non-empty `scalars` is a typed
2386 /// rel-XOR-abs authoring conflict at emit (never a silent precedence).
2387 #[test]
2388 fn scalar_rel_and_baked_scalar_together_is_a_typed_conflict() {
2389 use fuel_kernel_seam_types::shape_expr::{Dim, LAST};
2390 let region = PatternNode::Op {
2391 op: OpTag::MulScalar,
2392 attrs: OpAttrs {
2393 scalars: vec![2.0],
2394 scalar_rel: Some(Dim::Extent {
2395 operand: 0,
2396 axis: LAST,
2397 }),
2398 ..OpAttrs::default()
2399 },
2400 operands: vec![PatternNode::Bind { index: 0 }],
2401 };
2402 let mut g = Graph::new();
2403 let x = g.push(Node {
2404 op: Op::Const,
2405 inputs: vec![],
2406 shape: Shape::from_dims(&[3, 5]),
2407 dtype: DType::F32,
2408 });
2409 assert_eq!(
2410 try_emit_region(&mut g, ®ion, &[x], &[]),
2411 Err(RelAttrError::RelAbsConflict { field: "scalars" }),
2412 "scalar_rel XOR scalars — both set is a typed decline",
2413 );
2414 }
2415
2416 // ---- Task 5: byte-for-byte emit == registry::*::decompose parity --------
2417 //
2418 // The A.4 acceptance gate: express each hand-written decompose as a
2419 // PatternNode region, re-emit it via the grown `emit`, and assert the
2420 // result is structurally identical (op + shape + dtype at every node) to
2421 // the decompose-oracle output — the migration oracle. Since T5, `emit`
2422 // identity-shares repeated slot-free subtrees within one call, so a
2423 // shared oracle node compares against an equally-shared emitted node;
2424 // `assert_structural_eq` is recursive + order-sensitive (no commutative
2425 // canonicalization — stricter than `base_map_hash`), catching an
2426 // operand-swap the hash would mask.
2427
2428 fn op_node(op: OpTag, attrs: OpAttrs, operands: Vec<PatternNode>) -> PatternNode {
2429 PatternNode::Op {
2430 op,
2431 attrs,
2432 operands,
2433 }
2434 }
2435 fn bind(i: u8) -> PatternNode {
2436 PatternNode::Bind { index: i }
2437 }
2438
2439 /// Recursively assert two subgraphs are identical: same Op, shape, dtype,
2440 /// arity, and recursively-equal inputs. Shared leaves (same NodeId) match
2441 /// by identity. This is the "byte-for-byte" node-structure check.
2442 fn assert_structural_eq(g: &Graph, a: NodeId, b: NodeId) {
2443 if a == b {
2444 return; // shared leaf (bound external input)
2445 }
2446 let na = g.node(a);
2447 let nb = g.node(b);
2448 assert_eq!(na.op, nb.op, "op mismatch: {:?} vs {:?}", na.op, nb.op);
2449 assert_eq!(
2450 na.shape, nb.shape,
2451 "shape mismatch at {:?} vs {:?}",
2452 na.op, nb.op
2453 );
2454 assert_eq!(na.dtype, nb.dtype, "dtype mismatch at {:?}", na.op);
2455 assert_eq!(
2456 na.inputs.len(),
2457 nb.inputs.len(),
2458 "arity mismatch at {:?}",
2459 na.op
2460 );
2461 for (&ia, &ib) in na.inputs.iter().zip(nb.inputs.iter()) {
2462 assert_structural_eq(g, ia, ib);
2463 }
2464 }
2465
2466 /// FROZEN copy of the pre-migration imperative
2467 /// `registry::softmax_last_dim::decompose` (the legacy 7-node
2468 /// `ReduceMaxTo`/`ReduceSumTo` keepdim spelling), copied VERBATIM from
2469 /// that module @ `af4b7dd4` before T5 replaced the live body with the
2470 /// data recipe. Two consumers: the T5 numeric-parity oracle
2471 /// (`recipe_bridge` below) and `emit_matches_softmax_last_dim_decompose`
2472 /// (whose oracle was repointed here — the live decompose no longer emits
2473 /// this spelling).
2474 fn frozen_legacy_softmax_decompose(
2475 graph: &mut Graph,
2476 id: NodeId,
2477 _params: &FusedOpParams,
2478 ) -> NodeId {
2479 let (x_id, x_shape, dtype) = {
2480 let n = graph.node(id);
2481 (n.inputs[0], n.shape.clone(), n.dtype)
2482 };
2483 let dims = x_shape.dims().to_vec();
2484 let rank = dims.len();
2485 let last = rank - 1;
2486
2487 let mut keepdim_dims = dims.clone();
2488 keepdim_dims[last] = 1;
2489 let keepdim_shape = Shape::from_dims(&keepdim_dims);
2490
2491 let m_id = graph.push(Node {
2492 op: Op::ReduceMaxTo(keepdim_shape.clone()),
2493 inputs: vec![x_id],
2494 shape: keepdim_shape.clone(),
2495 dtype,
2496 });
2497 let mb_id = graph.push(Node {
2498 op: Op::BroadcastTo(x_shape.clone()),
2499 inputs: vec![m_id],
2500 shape: x_shape.clone(),
2501 dtype,
2502 });
2503 let s_id = graph.push(Node {
2504 op: Op::Sub,
2505 inputs: vec![x_id, mb_id],
2506 shape: x_shape.clone(),
2507 dtype,
2508 });
2509 let e_id = graph.push(Node {
2510 op: Op::Exp,
2511 inputs: vec![s_id],
2512 shape: x_shape.clone(),
2513 dtype,
2514 });
2515 let d_id = graph.push(Node {
2516 op: Op::ReduceSumTo(keepdim_shape.clone()),
2517 inputs: vec![e_id],
2518 shape: keepdim_shape,
2519 dtype,
2520 });
2521 let db_id = graph.push(Node {
2522 op: Op::BroadcastTo(x_shape.clone()),
2523 inputs: vec![d_id],
2524 shape: x_shape.clone(),
2525 dtype,
2526 });
2527
2528 graph.push(Node {
2529 op: Op::Div,
2530 inputs: vec![e_id, db_id],
2531 shape: x_shape,
2532 dtype,
2533 })
2534 }
2535
2536 /// FROZEN copy of the pre-migration imperative
2537 /// `registry::rope::decompose` (the legacy 11-node spelling with two
2538 /// leading-1-padded `Reshape` prep nodes), copied VERBATIM from that
2539 /// module @ `af4b7dd4` before T6 replaced the live body with the data
2540 /// recipe. Two consumers: `emit_matches_rope_decompose` (whose oracle was
2541 /// repointed here — the live decompose now emits the recipe, which at
2542 /// EQUAL rank elides the no-op prep `Reshape`, D4) and the T6
2543 /// numeric/structural parity oracle (`rope_recipe` below).
2544 fn frozen_legacy_rope_decompose(
2545 graph: &mut Graph,
2546 id: NodeId,
2547 _params: &FusedOpParams,
2548 ) -> NodeId {
2549 let (x_id, cos_id, sin_id, x_shape, dtype) = {
2550 let n = graph.node(id);
2551 (
2552 n.inputs[0],
2553 n.inputs[1],
2554 n.inputs[2],
2555 n.shape.clone(),
2556 n.dtype,
2557 )
2558 };
2559 let dims = x_shape.dims().to_vec();
2560 let rank = dims.len();
2561 let seq = dims[rank - 2];
2562 let d = dims[rank - 1];
2563 let half = d / 2;
2564 let last = rank - 1;
2565
2566 let mut broadcast_shape_dims: Vec<usize> = vec![1usize; rank];
2567 broadcast_shape_dims[rank - 2] = seq;
2568 broadcast_shape_dims[rank - 1] = d;
2569 let broadcast_shape = Shape::from_dims(&broadcast_shape_dims);
2570
2571 let cos_reshaped_id = graph.push(Node {
2572 op: Op::Reshape(broadcast_shape.clone()),
2573 inputs: vec![cos_id],
2574 shape: broadcast_shape.clone(),
2575 dtype,
2576 });
2577 let sin_reshaped_id = graph.push(Node {
2578 op: Op::Reshape(broadcast_shape.clone()),
2579 inputs: vec![sin_id],
2580 shape: broadcast_shape,
2581 dtype,
2582 });
2583 let cos_bcast_id = graph.push(Node {
2584 op: Op::BroadcastTo(x_shape.clone()),
2585 inputs: vec![cos_reshaped_id],
2586 shape: x_shape.clone(),
2587 dtype,
2588 });
2589 let sin_bcast_id = graph.push(Node {
2590 op: Op::BroadcastTo(x_shape.clone()),
2591 inputs: vec![sin_reshaped_id],
2592 shape: x_shape.clone(),
2593 dtype,
2594 });
2595
2596 let mut half_dims = dims.clone();
2597 half_dims[last] = half;
2598 let half_shape = Shape::from_dims(&half_dims);
2599
2600 let first_half_id = graph.push(Node {
2601 op: Op::Slice {
2602 dim: last,
2603 start: 0,
2604 len: half,
2605 },
2606 inputs: vec![x_id],
2607 shape: half_shape.clone(),
2608 dtype,
2609 });
2610 let second_half_id = graph.push(Node {
2611 op: Op::Slice {
2612 dim: last,
2613 start: half,
2614 len: half,
2615 },
2616 inputs: vec![x_id],
2617 shape: half_shape.clone(),
2618 dtype,
2619 });
2620 let neg_second_id = graph.push(Node {
2621 op: Op::Neg,
2622 inputs: vec![second_half_id],
2623 shape: half_shape,
2624 dtype,
2625 });
2626 let rotated_half_id = graph.push(Node {
2627 op: Op::Concat { dim: last },
2628 inputs: vec![neg_second_id, first_half_id],
2629 shape: x_shape.clone(),
2630 dtype,
2631 });
2632
2633 let left_id = graph.push(Node {
2634 op: Op::Mul,
2635 inputs: vec![x_id, cos_bcast_id],
2636 shape: x_shape.clone(),
2637 dtype,
2638 });
2639 let right_id = graph.push(Node {
2640 op: Op::Mul,
2641 inputs: vec![rotated_half_id, sin_bcast_id],
2642 shape: x_shape.clone(),
2643 dtype,
2644 });
2645
2646 graph.push(Node {
2647 op: Op::Add,
2648 inputs: vec![left_id, right_id],
2649 shape: x_shape,
2650 dtype,
2651 })
2652 }
2653
2654 /// FROZEN copy of the pre-migration imperative
2655 /// `registry::rms_norm_last_dim::decompose` (the legacy 7-node
2656 /// `MeanDim → Reshape(keepdim) → AddScalar(eps)` spelling), copied VERBATIM
2657 /// from that module before T7 replaced the live body with the data recipe.
2658 /// Consumer: the T7 numeric-parity oracle (`norm_recipe` below). The live
2659 /// decompose now emits the D3 shrink-via-swap spelling (`Unsqueeze` append
2660 /// in place of `Reshape(keepdim)`), so the parity test evaluates BOTH
2661 /// through the shared reference interpreter and asserts bit-exact
2662 /// equivalence (the swap is metadata-only).
2663 fn frozen_legacy_rms_norm_decompose(
2664 graph: &mut Graph,
2665 id: NodeId,
2666 params: &FusedOpParams,
2667 ) -> NodeId {
2668 let (x_id, x_shape, dtype) = {
2669 let n = graph.node(id);
2670 (n.inputs[0], n.shape.clone(), n.dtype)
2671 };
2672 let eps = match params {
2673 FusedOpParams::RmsNormLastDim { eps } => *eps,
2674 _ => return id,
2675 };
2676 let dims = x_shape.dims().to_vec();
2677 let rank = dims.len();
2678 let last = rank - 1;
2679
2680 let mut keepdim_dims = dims.clone();
2681 keepdim_dims[last] = 1;
2682 let keepdim_shape = Shape::from_dims(&keepdim_dims);
2683 let mut reduced_dims = dims.clone();
2684 reduced_dims.remove(last);
2685 let reduced_shape = Shape::from_dims(&reduced_dims);
2686
2687 let sq_id = graph.push(Node {
2688 op: Op::Sqr,
2689 inputs: vec![x_id],
2690 shape: x_shape.clone(),
2691 dtype,
2692 });
2693 let mean_id = graph.push(Node {
2694 op: Op::MeanDim(last),
2695 inputs: vec![sq_id],
2696 shape: reduced_shape,
2697 dtype,
2698 });
2699 let mean_kd_id = graph.push(Node {
2700 op: Op::Reshape(keepdim_shape.clone()),
2701 inputs: vec![mean_id],
2702 shape: keepdim_shape.clone(),
2703 dtype,
2704 });
2705 let denom_sq_id = graph.push(Node {
2706 op: Op::AddScalar(eps),
2707 inputs: vec![mean_kd_id],
2708 shape: keepdim_shape.clone(),
2709 dtype,
2710 });
2711 let denom_id = graph.push(Node {
2712 op: Op::Sqrt,
2713 inputs: vec![denom_sq_id],
2714 shape: keepdim_shape,
2715 dtype,
2716 });
2717 let denom_bcast_id = graph.push(Node {
2718 op: Op::BroadcastTo(x_shape.clone()),
2719 inputs: vec![denom_id],
2720 shape: x_shape.clone(),
2721 dtype,
2722 });
2723 graph.push(Node {
2724 op: Op::Div,
2725 inputs: vec![x_id, denom_bcast_id],
2726 shape: x_shape,
2727 dtype,
2728 })
2729 }
2730
2731 /// FROZEN copy of the pre-migration imperative
2732 /// `registry::layer_norm_last_dim::decompose` (the legacy 11-node spelling
2733 /// with two `Reshape(keepdim)` restores and the `centered` subterm shared
2734 /// between `Sqr` and the final `Div`), copied VERBATIM from that module
2735 /// before T7 replaced the live body with the data recipe. Two consumers:
2736 /// `emit_matches_layer_norm_last_dim_decompose` (whose oracle was repointed
2737 /// here — the live decompose now emits the `Unsqueeze` D3 spelling) and the
2738 /// T7 numeric-parity oracle (`norm_recipe` below).
2739 fn frozen_legacy_layer_norm_decompose(
2740 graph: &mut Graph,
2741 id: NodeId,
2742 params: &FusedOpParams,
2743 ) -> NodeId {
2744 let (x_id, x_shape, dtype) = {
2745 let n = graph.node(id);
2746 (n.inputs[0], n.shape.clone(), n.dtype)
2747 };
2748 let eps = match params {
2749 FusedOpParams::LayerNormLastDim { eps } => *eps,
2750 _ => return id,
2751 };
2752 let dims = x_shape.dims().to_vec();
2753 let rank = dims.len();
2754 let last = rank - 1;
2755
2756 let mut keepdim_dims = dims.clone();
2757 keepdim_dims[last] = 1;
2758 let keepdim_shape = Shape::from_dims(&keepdim_dims);
2759 let mut reduced_dims = dims.clone();
2760 reduced_dims.remove(last);
2761 let reduced_shape = Shape::from_dims(&reduced_dims);
2762
2763 let mean_id = graph.push(Node {
2764 op: Op::MeanDim(last),
2765 inputs: vec![x_id],
2766 shape: reduced_shape.clone(),
2767 dtype,
2768 });
2769 let mean_kd_id = graph.push(Node {
2770 op: Op::Reshape(keepdim_shape.clone()),
2771 inputs: vec![mean_id],
2772 shape: keepdim_shape.clone(),
2773 dtype,
2774 });
2775 let mean_bcast_id = graph.push(Node {
2776 op: Op::BroadcastTo(x_shape.clone()),
2777 inputs: vec![mean_kd_id],
2778 shape: x_shape.clone(),
2779 dtype,
2780 });
2781 let centered_id = graph.push(Node {
2782 op: Op::Sub,
2783 inputs: vec![x_id, mean_bcast_id],
2784 shape: x_shape.clone(),
2785 dtype,
2786 });
2787 let centered_sq_id = graph.push(Node {
2788 op: Op::Sqr,
2789 inputs: vec![centered_id],
2790 shape: x_shape.clone(),
2791 dtype,
2792 });
2793 let var_id = graph.push(Node {
2794 op: Op::MeanDim(last),
2795 inputs: vec![centered_sq_id],
2796 shape: reduced_shape,
2797 dtype,
2798 });
2799 let var_kd_id = graph.push(Node {
2800 op: Op::Reshape(keepdim_shape.clone()),
2801 inputs: vec![var_id],
2802 shape: keepdim_shape.clone(),
2803 dtype,
2804 });
2805 let var_eps_id = graph.push(Node {
2806 op: Op::AddScalar(eps),
2807 inputs: vec![var_kd_id],
2808 shape: keepdim_shape.clone(),
2809 dtype,
2810 });
2811 let denom_id = graph.push(Node {
2812 op: Op::Sqrt,
2813 inputs: vec![var_eps_id],
2814 shape: keepdim_shape,
2815 dtype,
2816 });
2817 let denom_bcast_id = graph.push(Node {
2818 op: Op::BroadcastTo(x_shape.clone()),
2819 inputs: vec![denom_id],
2820 shape: x_shape.clone(),
2821 dtype,
2822 });
2823 graph.push(Node {
2824 op: Op::Div,
2825 inputs: vec![centered_id, denom_bcast_id],
2826 shape: x_shape,
2827 dtype,
2828 })
2829 }
2830
2831 /// FROZEN copy of the pre-migration imperative
2832 /// `registry::layer_norm_last_dim_backward::decompose` (the legacy ~20-node
2833 /// `MeanDim`/`Reshape(keepdim)`/`BroadcastTo` recompute of
2834 /// `grad_x = istd · (g − mean(g) − xhat·mean(g·xhat))`), copied VERBATIM
2835 /// from that module @ `b967bdb1` before slice-2 replaced the live body with
2836 /// the data recipe. Sole consumer: the slice-2 numeric-parity oracle
2837 /// (`layer_norm_backward_recipe` below). Reads `inputs[0] = x`, `inputs[1] =
2838 /// g` (the upstream gradient) off the node — the order the autograd
2839 /// `BackwardKind::Fused(LAYER_NORM_LAST_DIM_BACKWARD)` edge emits.
2840 fn frozen_legacy_layer_norm_backward_decompose(
2841 graph: &mut Graph,
2842 id: NodeId,
2843 params: &FusedOpParams,
2844 ) -> NodeId {
2845 let (x_id, g_id, x_shape, dtype) = {
2846 let n = graph.node(id);
2847 (n.inputs[0], n.inputs[1], n.shape.clone(), n.dtype)
2848 };
2849 let eps = match params {
2850 FusedOpParams::LayerNormLastDimBackward { eps } => *eps,
2851 // G2: total + never-panic — impossible params; return self.
2852 _ => return id,
2853 };
2854 let dims = x_shape.dims().to_vec();
2855 let last = dims.len() - 1;
2856 let mut kd = dims.clone();
2857 kd[last] = 1;
2858 let keepdim = Shape::from_dims(&kd);
2859 let mut rd = dims.clone();
2860 rd.remove(last);
2861 let reduced = Shape::from_dims(&rd);
2862
2863 // reduce-mean over the last dim, keepdim, broadcast back to x_shape.
2864 let mean_b = |graph: &mut Graph, src: NodeId| -> NodeId {
2865 let m = graph.push(Node {
2866 op: Op::MeanDim(last),
2867 inputs: vec![src],
2868 shape: reduced.clone(),
2869 dtype,
2870 });
2871 let m_kd = graph.push(Node {
2872 op: Op::Reshape(keepdim.clone()),
2873 inputs: vec![m],
2874 shape: keepdim.clone(),
2875 dtype,
2876 });
2877 graph.push(Node {
2878 op: Op::BroadcastTo(x_shape.clone()),
2879 inputs: vec![m_kd],
2880 shape: x_shape.clone(),
2881 dtype,
2882 })
2883 };
2884
2885 // xhat = (x − mean(x)) · istd ; istd = rsqrt(var + eps).
2886 let mean_x = mean_b(graph, x_id);
2887 let xc = graph.push(Node {
2888 op: Op::Sub,
2889 inputs: vec![x_id, mean_x],
2890 shape: x_shape.clone(),
2891 dtype,
2892 });
2893 let xc_sq = graph.push(Node {
2894 op: Op::Sqr,
2895 inputs: vec![xc],
2896 shape: x_shape.clone(),
2897 dtype,
2898 });
2899 let var = mean_b(graph, xc_sq);
2900 let var_eps = graph.push(Node {
2901 op: Op::AddScalar(eps),
2902 inputs: vec![var],
2903 shape: x_shape.clone(),
2904 dtype,
2905 });
2906 let istd = graph.push(Node {
2907 op: Op::Rsqrt,
2908 inputs: vec![var_eps],
2909 shape: x_shape.clone(),
2910 dtype,
2911 });
2912 let xhat = graph.push(Node {
2913 op: Op::Mul,
2914 inputs: vec![xc, istd],
2915 shape: x_shape.clone(),
2916 dtype,
2917 });
2918
2919 // grad_x = istd · (g − mean(g) − xhat·mean(g·xhat)).
2920 let mean_g = mean_b(graph, g_id);
2921 let g_xhat = graph.push(Node {
2922 op: Op::Mul,
2923 inputs: vec![g_id, xhat],
2924 shape: x_shape.clone(),
2925 dtype,
2926 });
2927 let mean_gxh = mean_b(graph, g_xhat);
2928 let t1 = graph.push(Node {
2929 op: Op::Sub,
2930 inputs: vec![g_id, mean_g],
2931 shape: x_shape.clone(),
2932 dtype,
2933 });
2934 let t2 = graph.push(Node {
2935 op: Op::Mul,
2936 inputs: vec![xhat, mean_gxh],
2937 shape: x_shape.clone(),
2938 dtype,
2939 });
2940 let inner = graph.push(Node {
2941 op: Op::Sub,
2942 inputs: vec![t1, t2],
2943 shape: x_shape.clone(),
2944 dtype,
2945 });
2946 graph.push(Node {
2947 op: Op::Mul,
2948 inputs: vec![istd, inner],
2949 shape: x_shape,
2950 dtype,
2951 })
2952 }
2953
2954 /// FROZEN copy of the pre-migration imperative
2955 /// `registry::rms_norm_last_dim_backward::decompose` (the legacy ~22-node
2956 /// `MeanDim`/`SumDim`/`Reshape(keepdim)`/`AddScalar`/`MulScalar(n)`/`Rsqrt`/
2957 /// `BroadcastTo`/`Mul`/`Sub`/`Div` spelling, with the `n = dims[last]`
2958 /// reduced-count baked as an absolute `MulScalar`), copied VERBATIM from that
2959 /// module @ `9d7a1380` before A1 replaced the live body with the shape-
2960 /// polymorphic data recipe. Sole consumer: the A1 numeric-parity oracle
2961 /// (`rms_norm_backward_recipe` below). Reads `inputs = [x, upstream]` + the
2962 /// output shape off the node — the convention the autograd
2963 /// `BackwardKind::Fused` edge emits.
2964 fn frozen_legacy_rms_norm_backward_decompose(
2965 graph: &mut Graph,
2966 id: NodeId,
2967 params: &FusedOpParams,
2968 ) -> NodeId {
2969 let (x_id, up_id, x_shape, dtype) = {
2970 let n = graph.node(id);
2971 (n.inputs[0], n.inputs[1], n.shape.clone(), n.dtype)
2972 };
2973 let eps = match params {
2974 FusedOpParams::RmsNormLastDimBackward { eps } => *eps,
2975 // G2: total + never-panic — impossible params; return self.
2976 _ => return id,
2977 };
2978 let dims = x_shape.dims().to_vec();
2979 let last = dims.len() - 1;
2980 let n = dims[last] as f64;
2981 let mut kd = dims.clone();
2982 kd[last] = 1;
2983 let keepdim = Shape::from_dims(&kd);
2984 let mut rd = dims.clone();
2985 rd.remove(last);
2986 let reduced = Shape::from_dims(&rd);
2987
2988 // denom = mean(x²) + eps (keepdim).
2989 let sq = graph.push(Node {
2990 op: Op::Sqr,
2991 inputs: vec![x_id],
2992 shape: x_shape.clone(),
2993 dtype,
2994 });
2995 let mean = graph.push(Node {
2996 op: Op::MeanDim(last),
2997 inputs: vec![sq],
2998 shape: reduced.clone(),
2999 dtype,
3000 });
3001 let mean_kd = graph.push(Node {
3002 op: Op::Reshape(keepdim.clone()),
3003 inputs: vec![mean],
3004 shape: keepdim.clone(),
3005 dtype,
3006 });
3007 let denom_kd = graph.push(Node {
3008 op: Op::AddScalar(eps),
3009 inputs: vec![mean_kd],
3010 shape: keepdim.clone(),
3011 dtype,
3012 });
3013 // r_rms = rsqrt(denom), broadcast.
3014 let rrms_kd = graph.push(Node {
3015 op: Op::Rsqrt,
3016 inputs: vec![denom_kd],
3017 shape: keepdim.clone(),
3018 dtype,
3019 });
3020 let rrms_b = graph.push(Node {
3021 op: Op::BroadcastTo(x_shape.clone()),
3022 inputs: vec![rrms_kd],
3023 shape: x_shape.clone(),
3024 dtype,
3025 });
3026 // s = sum(g·x, last) (keepdim).
3027 let gx = graph.push(Node {
3028 op: Op::Mul,
3029 inputs: vec![up_id, x_id],
3030 shape: x_shape.clone(),
3031 dtype,
3032 });
3033 let s = graph.push(Node {
3034 op: Op::SumDim(last),
3035 inputs: vec![gx],
3036 shape: reduced,
3037 dtype,
3038 });
3039 let s_kd = graph.push(Node {
3040 op: Op::Reshape(keepdim.clone()),
3041 inputs: vec![s],
3042 shape: keepdim.clone(),
3043 dtype,
3044 });
3045 let s_b = graph.push(Node {
3046 op: Op::BroadcastTo(x_shape.clone()),
3047 inputs: vec![s_kd],
3048 shape: x_shape.clone(),
3049 dtype,
3050 });
3051 // term = x·s / (n·denom).
3052 let ndenom_kd = graph.push(Node {
3053 op: Op::MulScalar(n),
3054 inputs: vec![denom_kd],
3055 shape: keepdim.clone(),
3056 dtype,
3057 });
3058 let ndenom_b = graph.push(Node {
3059 op: Op::BroadcastTo(x_shape.clone()),
3060 inputs: vec![ndenom_kd],
3061 shape: x_shape.clone(),
3062 dtype,
3063 });
3064 let xs = graph.push(Node {
3065 op: Op::Mul,
3066 inputs: vec![x_id, s_b],
3067 shape: x_shape.clone(),
3068 dtype,
3069 });
3070 let term = graph.push(Node {
3071 op: Op::Div,
3072 inputs: vec![xs, ndenom_b],
3073 shape: x_shape.clone(),
3074 dtype,
3075 });
3076 // grad_x = r_rms · (g − term).
3077 let inner = graph.push(Node {
3078 op: Op::Sub,
3079 inputs: vec![up_id, term],
3080 shape: x_shape.clone(),
3081 dtype,
3082 });
3083 graph.push(Node {
3084 op: Op::Mul,
3085 inputs: vec![rrms_b, inner],
3086 shape: x_shape,
3087 dtype,
3088 })
3089 }
3090
3091 /// FROZEN copy of the pre-migration imperative
3092 /// `registry::reduce_max_to_backward::decompose` (the legacy 9-node
3093 /// `ReduceMaxTo`→`BroadcastTo`→`Equal(U8)`→`MaskedFill`→`ReduceSumTo`→`Div`→
3094 /// `BroadcastTo`→`Mul` spelling, sharing the single `mask_f`), copied VERBATIM
3095 /// from that module @ `94c69ec7` before A2 replaced the live body with the
3096 /// data recipe. Sole consumer: the A2 numeric-parity oracle
3097 /// (`reduce_max_to_backward_recipe` below). Reads `inputs[0] = x` and
3098 /// `inputs[1] = up` (the upstream gradient) off the node — the order autograd
3099 /// emits. The `MaskedFill` fill is `Scalar::one(dtype)` at the node's dtype.
3100 fn frozen_legacy_reduce_max_to_backward_decompose(
3101 graph: &mut Graph,
3102 id: NodeId,
3103 _params: &FusedOpParams,
3104 ) -> NodeId {
3105 use fuel_ir::Scalar;
3106 let (x_id, up_id, x_shape, dtype) = {
3107 let n = graph.node(id);
3108 (n.inputs[0], n.inputs[1], n.shape.clone(), n.dtype)
3109 };
3110 let target = graph.node(up_id).shape.clone();
3111
3112 // y = per-window max, broadcast back to x's shape.
3113 let y = graph.push(Node {
3114 op: Op::ReduceMaxTo(target.clone()),
3115 inputs: vec![x_id],
3116 shape: target.clone(),
3117 dtype,
3118 });
3119 let y_b = graph.push(Node {
3120 op: Op::BroadcastTo(x_shape.clone()),
3121 inputs: vec![y],
3122 shape: x_shape.clone(),
3123 dtype,
3124 });
3125 // U8 mask = (x == max), then a float mask = MaskedFill(1.0 into zeros).
3126 let mask_u8 = graph.push(Node {
3127 op: Op::Equal,
3128 inputs: vec![x_id, y_b],
3129 shape: x_shape.clone(),
3130 dtype: DType::U8,
3131 });
3132 let zeros = graph.push(Node {
3133 op: Op::MulScalar(0.0),
3134 inputs: vec![x_id],
3135 shape: x_shape.clone(),
3136 dtype,
3137 });
3138 // A reduce-max backward over a non-real (packed/scale) dtype is not a
3139 // thing this frozen oracle handles; self-return the node unchanged
3140 // (decompose-fixpoint / surfaced-gap convention) rather than panic.
3141 let fill = match Scalar::one(dtype) {
3142 Ok(s) => s,
3143 Err(_) => return id,
3144 };
3145 let mask_f = graph.push(Node {
3146 op: Op::MaskedFill { value: fill },
3147 inputs: vec![zeros, mask_u8],
3148 shape: x_shape.clone(),
3149 dtype,
3150 });
3151 // ties = count per window; share = upstream / ties (fair share for ties).
3152 let ties = graph.push(Node {
3153 op: Op::ReduceSumTo(target.clone()),
3154 inputs: vec![mask_f],
3155 shape: target.clone(),
3156 dtype,
3157 });
3158 let share = graph.push(Node {
3159 op: Op::Div,
3160 inputs: vec![up_id, ties],
3161 shape: target,
3162 dtype,
3163 });
3164 let share_b = graph.push(Node {
3165 op: Op::BroadcastTo(x_shape.clone()),
3166 inputs: vec![share],
3167 shape: x_shape.clone(),
3168 dtype,
3169 });
3170 graph.push(Node {
3171 op: Op::Mul,
3172 inputs: vec![mask_f, share_b],
3173 shape: x_shape,
3174 dtype,
3175 })
3176 }
3177
3178 /// FROZEN copy of the pre-migration imperative
3179 /// `registry::powi_backward::decompose` (the legacy 3-node
3180 /// `PowI(exp-1)`→`MulScalar(exp)`→`Mul` spelling), copied VERBATIM from that
3181 /// module before A3 replaced the live body with the param-derived data
3182 /// recipe. Sole consumer: the A3 numeric-parity oracle (`powi_backward_recipe`
3183 /// below). Reads `inputs[0] = x` and `inputs[1] = up` (the upstream gradient)
3184 /// off the node — the order autograd emits — plus the output shape/dtype;
3185 /// `exp` rides the params.
3186 fn frozen_legacy_powi_backward_decompose(
3187 graph: &mut Graph,
3188 id: NodeId,
3189 params: &FusedOpParams,
3190 ) -> NodeId {
3191 let (x_id, up_id, shape, dtype) = {
3192 let n = graph.node(id);
3193 (n.inputs[0], n.inputs[1], n.shape.clone(), n.dtype)
3194 };
3195 let exp = match params {
3196 FusedOpParams::PowIBackward { exp } => *exp,
3197 // G2: total + never-panic — impossible params; return self.
3198 _ => return id,
3199 };
3200 let pow = graph.push(Node {
3201 op: Op::PowI(exp - 1),
3202 inputs: vec![x_id],
3203 shape: shape.clone(),
3204 dtype,
3205 });
3206 let scaled = graph.push(Node {
3207 op: Op::MulScalar(exp as f64),
3208 inputs: vec![pow],
3209 shape: shape.clone(),
3210 dtype,
3211 });
3212 graph.push(Node {
3213 op: Op::Mul,
3214 inputs: vec![scaled, up_id],
3215 shape,
3216 dtype,
3217 })
3218 }
3219
3220 /// FROZEN copy of the pre-migration imperative
3221 /// `registry::fused_linear::decompose` (the legacy 3-node `MatMul` +
3222 /// `BroadcastTo(rank-1 bias)` + `Add` spelling, broadcasting the rank-1
3223 /// bias DIRECTLY with no leading-1 pad), copied VERBATIM from that module @
3224 /// `b967bdb1` before slice-2 replaced the live body with the WithDim data
3225 /// recipe. Sole consumer: the slice-2 numeric-parity oracle
3226 /// (`fused_linear_recipe` below). Reads `inputs = [a, b, bias]` + the
3227 /// matmul output shape off the node.
3228 fn frozen_legacy_fused_linear_decompose(
3229 graph: &mut Graph,
3230 id: NodeId,
3231 _params: &FusedOpParams,
3232 ) -> NodeId {
3233 let (a_id, b_id, bias_id, out_shape, dtype) = {
3234 let n = graph.node(id);
3235 (
3236 n.inputs[0],
3237 n.inputs[1],
3238 n.inputs[2],
3239 n.shape.clone(),
3240 n.dtype,
3241 )
3242 };
3243 let mm_id = graph.push(Node {
3244 op: Op::MatMul,
3245 inputs: vec![a_id, b_id],
3246 shape: out_shape.clone(),
3247 dtype,
3248 });
3249 let bias_bcst_id = graph.push(Node {
3250 op: Op::BroadcastTo(out_shape.clone()),
3251 inputs: vec![bias_id],
3252 shape: out_shape.clone(),
3253 dtype,
3254 });
3255 graph.push(Node {
3256 op: Op::Add,
3257 inputs: vec![mm_id, bias_bcst_id],
3258 shape: out_shape,
3259 dtype,
3260 })
3261 }
3262
3263 /// FROZEN copy of the pre-migration imperative
3264 /// `registry::softmax_last_dim_backward::decompose` (the legacy 5-node
3265 /// `Mul`/`ReduceSumTo(keepdim)`/`BroadcastTo`/`Sub`/`Mul` spelling), copied
3266 /// VERBATIM from that module @ `aa2eee3c` before T8 replaced the live body
3267 /// with the data recipe. Sole consumer: the T8 numeric-parity oracle
3268 /// (`softmax_backward_recipe` below). Reads `inputs[0] = s` (the forward
3269 /// softmax output) and `inputs[1] = g` (the upstream gradient) off the node
3270 /// — the same convention the autograd `BackwardKind::Fused` edge emits
3271 /// (`lib.rs` softmax-backward arm: `vec![id, up_id]`).
3272 fn frozen_legacy_softmax_backward_decompose(
3273 graph: &mut Graph,
3274 id: NodeId,
3275 _params: &FusedOpParams,
3276 ) -> NodeId {
3277 let (s_id, g_id, x_shape, dtype) = {
3278 let n = graph.node(id);
3279 (n.inputs[0], n.inputs[1], n.shape.clone(), n.dtype)
3280 };
3281 // keepdim shape: last dim → 1.
3282 let mut kd = x_shape.dims().to_vec();
3283 let last = kd.len() - 1;
3284 kd[last] = 1;
3285 let keepdim = Shape::from_dims(&kd);
3286
3287 let gs = graph.push(Node {
3288 op: Op::Mul,
3289 inputs: vec![g_id, s_id],
3290 shape: x_shape.clone(),
3291 dtype,
3292 });
3293 let summed = graph.push(Node {
3294 op: Op::ReduceSumTo(keepdim.clone()),
3295 inputs: vec![gs],
3296 shape: keepdim,
3297 dtype,
3298 });
3299 let summed_b = graph.push(Node {
3300 op: Op::BroadcastTo(x_shape.clone()),
3301 inputs: vec![summed],
3302 shape: x_shape.clone(),
3303 dtype,
3304 });
3305 let sub = graph.push(Node {
3306 op: Op::Sub,
3307 inputs: vec![g_id, summed_b],
3308 shape: x_shape.clone(),
3309 dtype,
3310 });
3311 graph.push(Node {
3312 op: Op::Mul,
3313 inputs: vec![s_id, sub],
3314 shape: x_shape,
3315 dtype,
3316 })
3317 }
3318
3319 #[test]
3320 fn emit_matches_softmax_last_dim_decompose() {
3321 use fuel_ir::{DType, Shape};
3322 let mut g = Graph::new();
3323 let sh = Shape::from_dims(&[2, 4]);
3324 let x = g.push(Node {
3325 op: Op::Const,
3326 inputs: vec![],
3327 shape: sh.clone(),
3328 dtype: DType::F32,
3329 });
3330 // Oracle: the FROZEN legacy builder (reads inputs[0] + shape + dtype
3331 // off the node). T5 repointed this from the live registry decompose —
3332 // which now emits the 9-node recipe spelling — so this test keeps
3333 // pinning the Increment-A guarantee it always pinned: the grown
3334 // `emit` reconstructs the LEGACY imperative structure from the legacy
3335 // region datum.
3336 let fused = g.push(Node {
3337 op: Op::Const,
3338 inputs: vec![x],
3339 shape: sh.clone(),
3340 dtype: DType::F32,
3341 });
3342 let oracle = frozen_legacy_softmax_decompose(&mut g, fused, &FusedOpParams::SoftmaxLastDim);
3343
3344 // keepdim shape [2,1]; full shape [2,4].
3345 let kd = OpAttrs {
3346 target_shape: vec![2, 1],
3347 ..OpAttrs::default()
3348 };
3349 let full = OpAttrs {
3350 target_shape: vec![2, 4],
3351 ..OpAttrs::default()
3352 };
3353 // e = Exp(Sub(x, BroadcastTo(ReduceMaxTo(x)))) — mirrors decompose order
3354 // `Sub{[x, mb]}` exactly; built fresh each call so numerator and the
3355 // denominator's ReduceSumTo input are identical subtrees.
3356 let softmax_e = |kd: &OpAttrs, full: &OpAttrs| {
3357 op_node(
3358 OpTag::Exp,
3359 OpAttrs::default(),
3360 vec![op_node(
3361 OpTag::Sub,
3362 OpAttrs::default(),
3363 vec![
3364 bind(0),
3365 op_node(
3366 OpTag::BroadcastTo,
3367 full.clone(),
3368 vec![op_node(OpTag::ReduceMaxTo, kd.clone(), vec![bind(0)])],
3369 ),
3370 ],
3371 )],
3372 )
3373 };
3374 // out = Div(e, BroadcastTo(ReduceSumTo(e))) — mirrors `Div{[e, db]}`.
3375 let region = op_node(
3376 OpTag::Div,
3377 OpAttrs::default(),
3378 vec![
3379 softmax_e(&kd, &full),
3380 op_node(
3381 OpTag::BroadcastTo,
3382 full.clone(),
3383 vec![op_node(
3384 OpTag::ReduceSumTo,
3385 kd.clone(),
3386 vec![softmax_e(&kd, &full)],
3387 )],
3388 ),
3389 ],
3390 );
3391 let emitted = emit_region(&mut g, ®ion, &[x], &[]);
3392 assert_structural_eq(&g, oracle, emitted);
3393 }
3394
3395 #[test]
3396 fn emit_matches_rope_decompose() {
3397 use fuel_ir::{DType, Shape};
3398 let mut g = Graph::new();
3399 let sh = Shape::from_dims(&[2, 4]); // seq=2, d=4, half=2
3400 let x = g.push(Node {
3401 op: Op::Const,
3402 inputs: vec![],
3403 shape: sh.clone(),
3404 dtype: DType::F32,
3405 });
3406 let cos = g.push(Node {
3407 op: Op::Const,
3408 inputs: vec![],
3409 shape: sh.clone(),
3410 dtype: DType::F32,
3411 });
3412 let sin = g.push(Node {
3413 op: Op::Const,
3414 inputs: vec![],
3415 shape: sh.clone(),
3416 dtype: DType::F32,
3417 });
3418 let fused = g.push(Node {
3419 op: Op::Const,
3420 inputs: vec![x, cos, sin],
3421 shape: sh.clone(),
3422 dtype: DType::F32,
3423 });
3424 // Oracle: the FROZEN legacy builder. T6 repointed this from the live
3425 // registry decompose — which now emits the data recipe (byte-identical
3426 // to legacy where a rank-raise occurs, but at EQUAL rank the recipe
3427 // elides the legacy's no-op prep `Reshape`, D4). This test keeps
3428 // pinning the Increment-A guarantee it always pinned: the grown `emit`
3429 // reconstructs the LEGACY imperative structure from a legacy-spelled
3430 // region datum.
3431 let oracle = frozen_legacy_rope_decompose(&mut g, fused, &FusedOpParams::Rope);
3432
3433 // decompose's broadcast_shape for rank-2 [2,4] is [seq,d] = [2,4]; half slices along last dim.
3434 let full = OpAttrs {
3435 target_shape: vec![2, 4],
3436 ..OpAttrs::default()
3437 };
3438 let sl_first = OpAttrs {
3439 axis: Some(1),
3440 slice_start: Some(0),
3441 slice_len: Some(2),
3442 ..OpAttrs::default()
3443 };
3444 let sl_second = OpAttrs {
3445 axis: Some(1),
3446 slice_start: Some(2),
3447 slice_len: Some(2),
3448 ..OpAttrs::default()
3449 };
3450 let cat = OpAttrs {
3451 axis: Some(1),
3452 ..OpAttrs::default()
3453 };
3454 let bcast_reshape = |full: &OpAttrs, i: u8| {
3455 op_node(
3456 OpTag::BroadcastTo,
3457 full.clone(),
3458 vec![op_node(OpTag::Reshape, full.clone(), vec![bind(i)])],
3459 )
3460 };
3461 // left = Mul(x, cos_bcast); right = Mul(rotated_half, sin_bcast); out = Add(left, right).
3462 // rotated_half = Concat{dim:1}(Neg(second_half), first_half).
3463 let rotated = op_node(
3464 OpTag::Concat,
3465 cat,
3466 vec![
3467 op_node(
3468 OpTag::Neg,
3469 OpAttrs::default(),
3470 vec![op_node(OpTag::Slice, sl_second, vec![bind(0)])],
3471 ),
3472 op_node(OpTag::Slice, sl_first, vec![bind(0)]),
3473 ],
3474 );
3475 let left = op_node(
3476 OpTag::Mul,
3477 OpAttrs::default(),
3478 vec![bind(0), bcast_reshape(&full, 1)],
3479 );
3480 let right = op_node(
3481 OpTag::Mul,
3482 OpAttrs::default(),
3483 vec![rotated, bcast_reshape(&full, 2)],
3484 );
3485 let region = op_node(OpTag::Add, OpAttrs::default(), vec![left, right]);
3486
3487 let emitted = emit_region(&mut g, ®ion, &[x, cos, sin], &[]);
3488 assert_structural_eq(&g, oracle, emitted);
3489 }
3490
3491 #[test]
3492 fn emit_matches_layer_norm_last_dim_decompose() {
3493 use fuel_ir::{DType, Shape};
3494 let mut g = Graph::new();
3495 let sh = Shape::from_dims(&[2, 4]); // last=1, reduced [2], keepdim [2,1]
3496 let x = g.push(Node {
3497 op: Op::Const,
3498 inputs: vec![],
3499 shape: sh.clone(),
3500 dtype: DType::F32,
3501 });
3502 let fused = g.push(Node {
3503 op: Op::Const,
3504 inputs: vec![x],
3505 shape: sh.clone(),
3506 dtype: DType::F32,
3507 });
3508 // Oracle: the FROZEN legacy builder (the `Reshape(keepdim)` spelling).
3509 // T7 repointed this from the live registry decompose — which now emits
3510 // the D3 `Unsqueeze` swap — so this test keeps pinning the Increment-A
3511 // guarantee it always pinned: the grown `emit` reconstructs the LEGACY
3512 // imperative structure from a legacy-spelled (Reshape) region datum.
3513 let oracle = frozen_legacy_layer_norm_decompose(
3514 &mut g,
3515 fused,
3516 &FusedOpParams::LayerNormLastDim { eps: 1e-5 },
3517 );
3518
3519 let kd = OpAttrs {
3520 target_shape: vec![2, 1],
3521 ..OpAttrs::default()
3522 };
3523 let full = OpAttrs {
3524 target_shape: vec![2, 4],
3525 ..OpAttrs::default()
3526 };
3527 let md = OpAttrs {
3528 axis: Some(1),
3529 ..OpAttrs::default()
3530 };
3531 let eps_attrs = OpAttrs {
3532 scalars: vec![1e-5],
3533 ..OpAttrs::default()
3534 }; // BAKED constant, not a slot
3535 // centered = Sub(x, BroadcastTo(Reshape(MeanDim(x)))) — shared subterm.
3536 let centered = op_node(
3537 OpTag::Sub,
3538 OpAttrs::default(),
3539 vec![
3540 bind(0),
3541 op_node(
3542 OpTag::BroadcastTo,
3543 full.clone(),
3544 vec![op_node(
3545 OpTag::Reshape,
3546 kd.clone(),
3547 vec![op_node(OpTag::MeanDim, md.clone(), vec![bind(0)])],
3548 )],
3549 ),
3550 ],
3551 );
3552 // denom_bcast = BroadcastTo(Sqrt(AddScalar(eps)(Reshape(MeanDim(Sqr(centered)))))).
3553 let denom_bcast = op_node(
3554 OpTag::BroadcastTo,
3555 full.clone(),
3556 vec![op_node(
3557 OpTag::Sqrt,
3558 OpAttrs::default(),
3559 vec![op_node(
3560 OpTag::AddScalar,
3561 eps_attrs,
3562 vec![op_node(
3563 OpTag::Reshape,
3564 kd.clone(),
3565 vec![op_node(
3566 OpTag::MeanDim,
3567 md.clone(),
3568 vec![op_node(
3569 OpTag::Sqr,
3570 OpAttrs::default(),
3571 vec![centered.clone()],
3572 )],
3573 )],
3574 )],
3575 )],
3576 )],
3577 );
3578 // out = Div(centered, denom_bcast).
3579 let region = op_node(OpTag::Div, OpAttrs::default(), vec![centered, denom_bcast]);
3580
3581 let emitted = emit_region(&mut g, ®ion, &[x], &[]);
3582 assert_structural_eq(&g, oracle, emitted);
3583 }
3584
3585 // ---- T2 (Increment C slice 1): shape-relative attr resolution ----------
3586 //
3587 // `resolve_rel_attrs` is the PURE resolver behind recipe polymorphism: it
3588 // turns the shape-relative interior fields (`target_shape_rel` /
3589 // `slice_{start,len}_rel` / `axis_last`, D2) into the concrete sibling
3590 // fields against the given bind/child shapes, reusing `shape_expr`'s
3591 // evaluator (no second evaluator). Every failure is a typed
3592 // [`RelAttrError`], never a panic.
3593
3594 mod resolve_rel {
3595 use super::super::{RelAttrError, resolve_rel_attrs};
3596 use fuel_kernel_seam_types::shape_expr::{
3597 Dim, LAST, SYMBOLIC, ShapeExpr, ShapeExprError, TAG_DIMS,
3598 };
3599 use fuel_kernel_seam_types::{OpAttrs, OpTag};
3600
3601 fn half_of_bind0_last() -> Dim {
3602 Dim::Div(
3603 Box::new(Dim::Extent {
3604 operand: 0,
3605 axis: LAST,
3606 }),
3607 Box::new(Dim::Const(2)),
3608 )
3609 }
3610
3611 #[test]
3612 fn same_as_bind0_tracks_the_bind_shape() {
3613 // The polymorphism seed: ONE recipe datum, two shapes, two targets.
3614 let attrs = OpAttrs {
3615 target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
3616 ..OpAttrs::default()
3617 };
3618 let r = resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &[vec![2, 3]], &[vec![2, 1]])
3619 .expect("resolves");
3620 assert_eq!(r.target_shape, vec![2, 3]);
3621 assert!(
3622 r.target_shape_rel.is_none(),
3623 "resolved attrs are fully concrete"
3624 );
3625 let r = resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &[vec![4, 5]], &[vec![4, 1]])
3626 .expect("resolves");
3627 assert_eq!(r.target_shape, vec![4, 5]);
3628 }
3629
3630 #[test]
3631 fn slice_bounds_track_the_bind_extent() {
3632 // start = len = Extent(bind0, LAST) / 2 — the rope-half worked example.
3633 let attrs = OpAttrs {
3634 axis: Some(1),
3635 slice_start_rel: Some(half_of_bind0_last()),
3636 slice_len_rel: Some(half_of_bind0_last()),
3637 ..OpAttrs::default()
3638 };
3639 let r = resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 4]], &[vec![2, 4]])
3640 .expect("resolves at d=4");
3641 assert_eq!(r.slice_start, Some(2));
3642 assert_eq!(r.slice_len, Some(2));
3643 assert!(r.slice_start_rel.is_none() && r.slice_len_rel.is_none());
3644 let r = resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 8]], &[vec![2, 8]])
3645 .expect("resolves at d=8");
3646 assert_eq!(r.slice_start, Some(4));
3647 assert_eq!(r.slice_len, Some(4));
3648 }
3649
3650 #[test]
3651 fn axis_last_resolves_per_tag() {
3652 let attrs = OpAttrs {
3653 axis_last: true,
3654 ..OpAttrs::default()
3655 };
3656 // Reduce family (axis carrier): LAST = rank − 1.
3657 let r = resolve_rel_attrs(OpTag::SumDim, &attrs, &[], &[vec![2, 4]]).expect("rank 2");
3658 assert_eq!(r.axis, Some(1));
3659 assert!(!r.axis_last, "resolved attrs are fully concrete");
3660 let r =
3661 resolve_rel_attrs(OpTag::SumDim, &attrs, &[], &[vec![2, 3, 4]]).expect("rank 3");
3662 assert_eq!(r.axis, Some(2));
3663 // Concat rides the same axis carrier.
3664 let r =
3665 resolve_rel_attrs(OpTag::Concat, &attrs, &[], &[vec![2, 3, 4]]).expect("concat");
3666 assert_eq!(r.axis, Some(2));
3667 // Unsqueeze (dims carrier): APPEND — dim == rank (`primitive_shape`
3668 // permits `dim == rank`).
3669 let r =
3670 resolve_rel_attrs(OpTag::Unsqueeze, &attrs, &[], &[vec![2, 4]]).expect("unsqueeze");
3671 assert_eq!(r.dims, vec![2]);
3672 assert!(!r.axis_last);
3673 // Squeeze (dims carrier): LAST = rank − 1.
3674 let r =
3675 resolve_rel_attrs(OpTag::Squeeze, &attrs, &[], &[vec![2, 4, 1]]).expect("squeeze");
3676 assert_eq!(r.dims, vec![2]);
3677 }
3678
3679 #[test]
3680 fn bind_out_of_range_is_a_typed_decline() {
3681 let attrs = OpAttrs {
3682 target_shape_rel: Some(ShapeExpr::SameAs { operand: 3 }),
3683 ..OpAttrs::default()
3684 };
3685 assert_eq!(
3686 resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &[vec![2, 3]], &[vec![2, 3]]),
3687 Err(RelAttrError::Expr(ShapeExprError::OperandOutOfRange {
3688 operand: 3,
3689 operands: 1
3690 })),
3691 );
3692 let attrs = OpAttrs {
3693 axis: Some(1),
3694 slice_start_rel: Some(Dim::Extent {
3695 operand: 7,
3696 axis: LAST,
3697 }),
3698 ..OpAttrs::default()
3699 };
3700 assert_eq!(
3701 resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 4]], &[vec![2, 4]]),
3702 Err(RelAttrError::Expr(ShapeExprError::OperandOutOfRange {
3703 operand: 7,
3704 operands: 1
3705 })),
3706 );
3707 }
3708
3709 #[test]
3710 fn rel_and_abs_both_set_is_a_typed_conflict() {
3711 // target_shape XOR target_shape_rel.
3712 let attrs = OpAttrs {
3713 target_shape: vec![2, 3],
3714 target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
3715 ..OpAttrs::default()
3716 };
3717 assert_eq!(
3718 resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &[vec![2, 3]], &[vec![2, 3]]),
3719 Err(RelAttrError::RelAbsConflict {
3720 field: "target_shape"
3721 }),
3722 );
3723 // slice_start XOR slice_start_rel.
3724 let attrs = OpAttrs {
3725 axis: Some(1),
3726 slice_start: Some(0),
3727 slice_start_rel: Some(half_of_bind0_last()),
3728 ..OpAttrs::default()
3729 };
3730 assert_eq!(
3731 resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 4]], &[vec![2, 4]]),
3732 Err(RelAttrError::RelAbsConflict {
3733 field: "slice_start"
3734 }),
3735 );
3736 // slice_len XOR slice_len_rel.
3737 let attrs = OpAttrs {
3738 axis: Some(1),
3739 slice_len: Some(2),
3740 slice_len_rel: Some(half_of_bind0_last()),
3741 ..OpAttrs::default()
3742 };
3743 assert_eq!(
3744 resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 4]], &[vec![2, 4]]),
3745 Err(RelAttrError::RelAbsConflict { field: "slice_len" }),
3746 );
3747 // axis XOR axis_last.
3748 let attrs = OpAttrs {
3749 axis: Some(0),
3750 axis_last: true,
3751 ..OpAttrs::default()
3752 };
3753 assert_eq!(
3754 resolve_rel_attrs(OpTag::SumDim, &attrs, &[], &[vec![2, 4]]),
3755 Err(RelAttrError::RelAbsConflict { field: "axis" }),
3756 );
3757 // dims XOR axis_last (Unsqueeze's carrier is `dims`).
3758 let attrs = OpAttrs {
3759 dims: vec![0],
3760 axis_last: true,
3761 ..OpAttrs::default()
3762 };
3763 assert_eq!(
3764 resolve_rel_attrs(OpTag::Unsqueeze, &attrs, &[], &[vec![2, 4]]),
3765 Err(RelAttrError::RelAbsConflict { field: "dims" }),
3766 );
3767 }
3768
3769 #[test]
3770 fn negative_result_is_a_typed_decline() {
3771 // 0 − 2 = −2: a negative slice offset is malformed, not a wrap.
3772 let neg = Dim::Sub(Box::new(Dim::Const(0)), Box::new(Dim::Const(2)));
3773 let attrs = OpAttrs {
3774 axis: Some(1),
3775 slice_start_rel: Some(neg),
3776 ..OpAttrs::default()
3777 };
3778 assert_eq!(
3779 resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 4]], &[vec![2, 4]]),
3780 Err(RelAttrError::Negative {
3781 field: "slice_start",
3782 value: -2
3783 }),
3784 );
3785 }
3786
3787 #[test]
3788 fn symbolic_extent_is_a_surfaced_gap_decline() {
3789 // A symbolic bind extent → the expression evaluates to Gap → typed
3790 // decline (the emit caller surfaces it as a fixpoint, G2).
3791 let attrs = OpAttrs {
3792 axis: Some(1),
3793 slice_len_rel: Some(half_of_bind0_last()),
3794 ..OpAttrs::default()
3795 };
3796 assert_eq!(
3797 resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, SYMBOLIC]], &[vec![2, 4]]),
3798 Err(RelAttrError::SymbolicGap { field: "slice_len" }),
3799 );
3800 let attrs = OpAttrs {
3801 target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
3802 ..OpAttrs::default()
3803 };
3804 assert_eq!(
3805 resolve_rel_attrs(
3806 OpTag::BroadcastTo,
3807 &attrs,
3808 &[vec![2, SYMBOLIC]],
3809 &[vec![2, 4]]
3810 ),
3811 Err(RelAttrError::SymbolicGap {
3812 field: "target_shape"
3813 }),
3814 );
3815 }
3816
3817 #[test]
3818 fn two_operand_max_frame_declines_instead_of_a_partial_shape() {
3819 // I1 (Baracuda §6.20 finding): an ELEMENTWISE output frame is not
3820 // always expressible as `SameAs(operand)`. When the frame is
3821 // assembled by per-axis max across TWO binds — `a[2,1] ⊗ b[1,3] →
3822 // [2,3]` — NO single operand carries it, so BOTH spellings resolve
3823 // to a PARTIAL frame ([2,1] / [1,3]) and would silently emit the
3824 // wrong `BroadcastTo` target. Must be a typed decline.
3825 // The decline is typed and names the Dims-class constructor that
3826 // WOULD express it (reserved tag 0x0B, KISS #80) — a surfaced gap,
3827 // never a panic and never a wrong shape.
3828 let binds = vec![vec![2, 1], vec![1, 3]]; // frame = [2,3], carried by neither
3829 for operand in [0u8, 1] {
3830 let attrs = OpAttrs {
3831 target_shape_rel: Some(ShapeExpr::SameAs { operand }),
3832 ..OpAttrs::default()
3833 };
3834 let child = binds[operand as usize].clone();
3835 assert_eq!(
3836 resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &binds, &[child]),
3837 Err(RelAttrError::FrameNotExpressible {
3838 field: "target_shape",
3839 frame: vec![2, 3],
3840 missing_ctor: TAG_DIMS,
3841 }),
3842 "SameAs {{ operand: {operand} }} must not resolve to a PARTIAL frame",
3843 );
3844 }
3845 }
3846
3847 #[test]
3848 fn frame_guard_does_not_fire_when_an_operand_carries_the_frame() {
3849 // The guard is deliberately narrow. It must NOT degrade the cases
3850 // the 5 migrated recipes actually use.
3851 let attrs = OpAttrs {
3852 target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
3853 ..OpAttrs::default()
3854 };
3855 // (a) bind0 IS the frame (softmax/rope/rms-norm/layer-norm shape).
3856 let binds = vec![vec![2, 3, 4], vec![4]];
3857 let r = resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &binds, &[vec![4]])
3858 .expect("bind0 carries the frame");
3859 assert_eq!(r.target_shape, vec![2, 3, 4]);
3860 // (b) a SUB-frame target is legitimate: the frame is bind0's, and
3861 // naming bind1's smaller shape is an ordinary interior broadcast.
3862 let sub = OpAttrs {
3863 target_shape_rel: Some(ShapeExpr::SameAs { operand: 1 }),
3864 ..OpAttrs::default()
3865 };
3866 let r = resolve_rel_attrs(OpTag::BroadcastTo, &sub, &binds, &[vec![4]])
3867 .expect("sub-frame broadcast stays expressible");
3868 assert_eq!(r.target_shape, vec![4]);
3869 // (c) binds with NO joint elementwise frame (a matmul region) —
3870 // per-axis max is meaningless there, so the guard stays out.
3871 let mm = vec![vec![8, 4096], vec![4096, 1024]];
3872 let r = resolve_rel_attrs(OpTag::BroadcastTo, &attrs, &mm, &[vec![8, 1]])
3873 .expect("no joint frame ⇒ no guard");
3874 assert_eq!(r.target_shape, vec![8, 4096]);
3875 // (d) a NON-frame-carrier tag is untouched: only `BroadcastTo`'s
3876 // target IS the elementwise output frame.
3877 let two = vec![vec![2, 1], vec![1, 3]];
3878 let r = resolve_rel_attrs(OpTag::Reshape, &attrs, &two, &[vec![2, 1]])
3879 .expect("Reshape's target is not a frame claim");
3880 assert_eq!(r.target_shape, vec![2, 1]);
3881 }
3882
3883 #[test]
3884 fn axis_last_on_an_axisless_tag_or_without_a_child_declines() {
3885 let attrs = OpAttrs {
3886 axis_last: true,
3887 ..OpAttrs::default()
3888 };
3889 // Add has no axis carrier — axis_last is meaningless, a typed decline
3890 // (build-time validation posture: never silently ignore).
3891 assert_eq!(
3892 resolve_rel_attrs(OpTag::Add, &attrs, &[], &[vec![2, 4], vec![2, 4]]),
3893 Err(RelAttrError::AxisLastUnsupported { tag: OpTag::Add }),
3894 );
3895 // No child operand → no rank to resolve LAST against.
3896 assert_eq!(
3897 resolve_rel_attrs(OpTag::SumDim, &attrs, &[], &[]),
3898 Err(RelAttrError::NoChildOperand),
3899 );
3900 // Rank-0 child: LAST has no axis → the shared resolve_axis decline.
3901 assert_eq!(
3902 resolve_rel_attrs(OpTag::SumDim, &attrs, &[], &[vec![]]),
3903 Err(RelAttrError::Expr(ShapeExprError::AxisOutOfRange {
3904 axis: LAST,
3905 rank: 0
3906 })),
3907 );
3908 }
3909
3910 #[test]
3911 fn rel_free_attrs_pass_through_unchanged() {
3912 // The no-rel fast path: absolute attrs resolve to themselves.
3913 let attrs = OpAttrs {
3914 axis: Some(1),
3915 slice_start: Some(2),
3916 slice_len: Some(3),
3917 ..OpAttrs::default()
3918 };
3919 let r = resolve_rel_attrs(OpTag::Slice, &attrs, &[vec![2, 4]], &[vec![2, 4]])
3920 .expect("no-op");
3921 assert_eq!(r, attrs);
3922 }
3923 }
3924
3925 // ---- T3 (Increment C slice 1): resolving emit + D4 pad + rel validation ----
3926 //
3927 // The emit integration behind recipe polymorphism: children are emitted
3928 // FIRST (their shapes feed the rel-attr resolver), `resolve_rel_attrs`
3929 // produces fully-concrete attrs, then the unchanged tag_to_op →
3930 // primitive_shape path runs. A resolved `BroadcastTo` whose target rank
3931 // exceeds its operand's materializes the legacy `Reshape` pad (D4).
3932 // `validate_representable` accepts rel-attr regions via a probe-resolve
3933 // (mirror of the scalar slot dummy-fill) and rejects structural authoring
3934 // errors (rel+abs conflict, bind out of range) with a typed decline.
3935
3936 mod emit_rel {
3937 use super::super::*;
3938 use super::{assert_structural_eq, bind, op_node};
3939 use fuel_ir::{DType, Shape};
3940 use fuel_kernel_seam_types::shape_expr::{Dim, ShapeExpr};
3941
3942 fn cst(g: &mut Graph, dims: &[usize]) -> NodeId {
3943 g.push(Node {
3944 op: Op::Const,
3945 inputs: vec![],
3946 shape: Shape::from_dims(dims),
3947 dtype: DType::F32,
3948 })
3949 }
3950
3951 fn bcast_same_as_0() -> OpAttrs {
3952 OpAttrs {
3953 target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
3954 ..OpAttrs::default()
3955 }
3956 }
3957
3958 #[test]
3959 fn rel_region_emits_polymorphically_across_shapes() {
3960 // The headline polymorphism: ONE region datum —
3961 // Add(bind0, BroadcastTo{SameAs{0}}(bind1)) — emitted at two
3962 // different shapes produces the correct target BOTH times
3963 // (impossible with absolute attrs: a baked target matches exactly
3964 // one shape).
3965 let region = op_node(
3966 OpTag::Add,
3967 OpAttrs::default(),
3968 vec![
3969 bind(0),
3970 op_node(OpTag::BroadcastTo, bcast_same_as_0(), vec![bind(1)]),
3971 ],
3972 );
3973 let mut g = Graph::new();
3974 let x1 = cst(&mut g, &[2, 3]);
3975 let t1 = cst(&mut g, &[1, 3]);
3976 let r1 = emit_region(&mut g, ®ion, &[x1, t1], &[]);
3977 assert!(matches!(g.node(r1).op, Op::Add));
3978 assert_eq!(g.node(r1).shape, Shape::from_dims(&[2, 3]));
3979 let b1 = g.node(r1).inputs[1];
3980 assert_eq!(g.node(b1).op, Op::BroadcastTo(Shape::from_dims(&[2, 3])));
3981 assert_eq!(g.node(b1).shape, Shape::from_dims(&[2, 3]));
3982
3983 // The SAME region datum at different shapes → a different target.
3984 let x2 = cst(&mut g, &[4, 5]);
3985 let t2 = cst(&mut g, &[1, 5]);
3986 let r2 = emit_region(&mut g, ®ion, &[x2, t2], &[]);
3987 assert_eq!(g.node(r2).shape, Shape::from_dims(&[4, 5]));
3988 let b2 = g.node(r2).inputs[1];
3989 assert_eq!(g.node(b2).op, Op::BroadcastTo(Shape::from_dims(&[4, 5])));
3990 }
3991
3992 #[test]
3993 fn two_operand_max_frame_region_declines_through_emit() {
3994 // I1 end-to-end: the ONE region spelling that WANTS the per-axis-max
3995 // frame — Mul(BroadcastTo(a[2,1]), BroadcastTo(b[1,3])) → [2,3],
3996 // which Fuel's primitive `Mul` requires explicitly (`primitive_shape`
3997 // takes in[0]'s shape, it does not broadcast). `SameAs` cannot name
3998 // [2,3], so the resolving emit surfaces the typed Dims-class gap
3999 // instead of emitting a partial-frame BroadcastTo.
4000 let region = op_node(
4001 OpTag::Mul,
4002 OpAttrs::default(),
4003 vec![
4004 op_node(OpTag::BroadcastTo, bcast_same_as_0(), vec![bind(0)]),
4005 op_node(OpTag::BroadcastTo, bcast_same_as_0(), vec![bind(1)]),
4006 ],
4007 );
4008 let mut g = Graph::new();
4009 let a = cst(&mut g, &[2, 1]);
4010 let b = cst(&mut g, &[1, 3]);
4011 assert_eq!(
4012 try_emit_region(&mut g, ®ion, &[a, b], &[]),
4013 Err(RelAttrError::FrameNotExpressible {
4014 field: "target_shape",
4015 frame: vec![2, 3],
4016 missing_ctor: fuel_kernel_seam_types::shape_expr::TAG_DIMS,
4017 }),
4018 );
4019 }
4020
4021 #[test]
4022 fn broadcast_rank_raise_materializes_the_legacy_reshape_pad() {
4023 // D4: a rank-1 bind1 broadcast to rank-3 bind0's shape — the
4024 // resolver must first push the legacy `Reshape` (1-padded left,
4025 // right-aligned; `registry::rope`'s hand-built broadcast prep).
4026 let region = op_node(
4027 OpTag::Mul,
4028 OpAttrs::default(),
4029 vec![
4030 bind(0),
4031 op_node(OpTag::BroadcastTo, bcast_same_as_0(), vec![bind(1)]),
4032 ],
4033 );
4034 let mut g = Graph::new();
4035 let x = cst(&mut g, &[2, 3, 4]);
4036 let t = cst(&mut g, &[4]);
4037 // Hand-built legacy reference:
4038 // Reshape([1,1,4])(t) → BroadcastTo([2,3,4]) → Mul(x, ·).
4039 let pad_shape = Shape::from_dims(&[1, 1, 4]);
4040 let full = Shape::from_dims(&[2, 3, 4]);
4041 let pad = g.push(Node {
4042 op: Op::Reshape(pad_shape.clone()),
4043 inputs: vec![t],
4044 shape: pad_shape,
4045 dtype: DType::F32,
4046 });
4047 let bc = g.push(Node {
4048 op: Op::BroadcastTo(full.clone()),
4049 inputs: vec![pad],
4050 shape: full.clone(),
4051 dtype: DType::F32,
4052 });
4053 let reference = g.push(Node {
4054 op: Op::Mul,
4055 inputs: vec![x, bc],
4056 shape: full,
4057 dtype: DType::F32,
4058 });
4059
4060 let emitted = emit_region(&mut g, ®ion, &[x, t], &[]);
4061 assert_structural_eq(&g, reference, emitted);
4062 }
4063
4064 #[test]
4065 fn concrete_broadcast_rank_raise_also_pads() {
4066 // D4 applies uniformly: an ABSOLUTE rank-raising BroadcastTo also
4067 // materializes the pad (deterministic emission, matches the graph
4068 // builders' right-aligned rank-raising semantics). Equal-rank
4069 // broadcasts stay pad-free (pinned by the softmax parity oracle).
4070 let region = op_node(
4071 OpTag::BroadcastTo,
4072 OpAttrs {
4073 target_shape: vec![2, 3, 4],
4074 ..OpAttrs::default()
4075 },
4076 vec![bind(0)],
4077 );
4078 let mut g = Graph::new();
4079 let t = cst(&mut g, &[3, 4]);
4080 let emitted = emit_region(&mut g, ®ion, &[t], &[]);
4081 assert_eq!(
4082 g.node(emitted).op,
4083 Op::BroadcastTo(Shape::from_dims(&[2, 3, 4]))
4084 );
4085 let pad = g.node(emitted).inputs[0];
4086 assert_eq!(
4087 g.node(pad).op,
4088 Op::Reshape(Shape::from_dims(&[1, 3, 4])),
4089 "rank-raise inserts the legacy 1-padded-left Reshape",
4090 );
4091 assert_eq!(g.node(pad).inputs, vec![t]);
4092 }
4093
4094 #[test]
4095 fn scalar_cursor_fill_stays_pre_order_after_the_reorder() {
4096 // Risk-2 guard: children are now EMITTED first, but the scalar
4097 // cursor fill stays PRE-order (parent before descent) — the
4098 // canonical authoring order `match_region_extract` records.
4099 let region = op_node(
4100 OpTag::AddScalar,
4101 OpAttrs::default(),
4102 vec![op_node(OpTag::MulScalar, OpAttrs::default(), vec![bind(0)])],
4103 );
4104 let mut g = Graph::new();
4105 let x = cst(&mut g, &[4]);
4106 let root = emit_region(&mut g, ®ion, &[x], &[10.0, 20.0]);
4107 assert!(
4108 matches!(g.node(root).op, Op::AddScalar(v) if v == 10.0),
4109 "parent takes scalars[0] (pre-order), got {:?}",
4110 g.node(root).op,
4111 );
4112 let child = g.node(root).inputs[0];
4113 assert!(
4114 matches!(g.node(child).op, Op::MulScalar(v) if v == 20.0),
4115 "child takes scalars[1], got {:?}",
4116 g.node(child).op,
4117 );
4118 }
4119
4120 #[test]
4121 fn validate_accepts_a_rel_attr_region() {
4122 // Born-red: today tag_to_op(BroadcastTo, {empty target_shape}) →
4123 // None → UnRepresentable. The rel-probe must accept the template.
4124 let region = op_node(
4125 OpTag::Add,
4126 OpAttrs::default(),
4127 vec![
4128 bind(0),
4129 op_node(OpTag::BroadcastTo, bcast_same_as_0(), vec![bind(1)]),
4130 ],
4131 );
4132 register_runtime_fused("t3::rel_bcast", region)
4133 .expect("a rel-attr region is registrable");
4134 }
4135
4136 #[test]
4137 fn validate_rejects_rel_abs_conflict_and_bind_out_of_range() {
4138 // rel+abs both set → a typed authoring reject, never a silent
4139 // precedence.
4140 let conflicted = op_node(
4141 OpTag::BroadcastTo,
4142 OpAttrs {
4143 target_shape: vec![2, 3],
4144 target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
4145 ..OpAttrs::default()
4146 },
4147 vec![bind(0)],
4148 );
4149 assert_eq!(
4150 register_runtime_fused("t3::conflict", conflicted),
4151 Err(RuntimeFusedError::InvalidRelAttrs {
4152 tag: OpTag::BroadcastTo,
4153 error: RelAttrError::RelAbsConflict {
4154 field: "target_shape"
4155 },
4156 }),
4157 );
4158 // A bind reference outside the region's bind space can never
4159 // resolve at ANY shape → a typed authoring reject.
4160 let oob = op_node(
4161 OpTag::BroadcastTo,
4162 OpAttrs {
4163 target_shape_rel: Some(ShapeExpr::SameAs { operand: 7 }),
4164 ..OpAttrs::default()
4165 },
4166 vec![bind(0)],
4167 );
4168 assert_eq!(
4169 register_runtime_fused("t3::oob", oob),
4170 Err(RuntimeFusedError::InvalidRelAttrs {
4171 tag: OpTag::BroadcastTo,
4172 error: RelAttrError::Expr(
4173 fuel_kernel_seam_types::shape_expr::ShapeExprError::OperandOutOfRange {
4174 operand: 7,
4175 operands: 1,
4176 },
4177 ),
4178 }),
4179 );
4180 }
4181
4182 #[test]
4183 fn decompose_rel_resolution_failure_is_a_fixpoint_not_a_crash() {
4184 // slice_start_rel = 0 − 2 → Negative at emit time. Registration
4185 // TOLERATES it (a value-dependent decline at the probe shape is
4186 // not an authoring error); the decompose-path resolution failure
4187 // surfaces as a no-op fixpoint (G2), never a panic.
4188 let neg = Dim::Sub(Box::new(Dim::Const(0)), Box::new(Dim::Const(2)));
4189 let region = op_node(
4190 OpTag::Slice,
4191 OpAttrs {
4192 axis: Some(1),
4193 slice_start_rel: Some(neg),
4194 slice_len: Some(1),
4195 ..OpAttrs::default()
4196 },
4197 vec![bind(0)],
4198 );
4199 let id = register_runtime_fused("t3::neg_slice", region)
4200 .expect("a value-dependent decline still registers");
4201 let mut g = Graph::new();
4202 let x = cst(&mut g, &[2, 4]);
4203 let fused = g.push(Node {
4204 op: Op::Fused(id, FusedOpParams::Runtime { scalars: vec![] }),
4205 inputs: vec![x],
4206 shape: Shape::from_dims(&[2, 4]),
4207 dtype: DType::F32,
4208 });
4209 assert_eq!(
4210 decompose_region(&mut g, fused),
4211 fused,
4212 "resolution decline ⇒ fixpoint"
4213 );
4214 }
4215
4216 #[test]
4217 fn try_emit_region_surfaces_typed_resolution_errors() {
4218 // Negative → typed error from the fallible entry, never a panic.
4219 let neg = Dim::Sub(Box::new(Dim::Const(0)), Box::new(Dim::Const(2)));
4220 let region = op_node(
4221 OpTag::Slice,
4222 OpAttrs {
4223 axis: Some(1),
4224 slice_start_rel: Some(neg),
4225 slice_len: Some(1),
4226 ..OpAttrs::default()
4227 },
4228 vec![bind(0)],
4229 );
4230 let mut g = Graph::new();
4231 let x = cst(&mut g, &[2, 4]);
4232 assert_eq!(
4233 try_emit_region(&mut g, ®ion, &[x], &[]),
4234 Err(RelAttrError::Negative {
4235 field: "slice_start",
4236 value: -2
4237 }),
4238 );
4239 // A graph-side SYMBOLIC bind extent maps to the §6.20 SYMBOLIC
4240 // sentinel → SymbolicGap (the surfaced-gap posture, §6.20-0004).
4241 let region = op_node(OpTag::BroadcastTo, bcast_same_as_0(), vec![bind(0)]);
4242 let dyn_shape = Shape::from_dims(&[2, 8]).with_dynamic_axis(1, 0, fuel_ir::SymId(0));
4243 let d = g.push(Node {
4244 op: Op::Const,
4245 inputs: vec![],
4246 shape: dyn_shape,
4247 dtype: DType::F32,
4248 });
4249 assert_eq!(
4250 try_emit_region(&mut g, ®ion, &[d], &[]),
4251 Err(RelAttrError::SymbolicGap {
4252 field: "target_shape"
4253 }),
4254 );
4255 }
4256 }
4257
4258 // ---- T5 (Increment C slice 1): identity-share of repeated subtrees ----
4259 //
4260 // A `PatternNode` recipe is a TREE; a DAG recipe (softmax's shared
4261 // `e = Exp(..)` interior, consumed by both the denominator reduce and the
4262 // final Div) is spelled by REPEATING the subtree. `emit` must emit a
4263 // repeated slot-free subtree ONCE per emit call (identity-share), so the
4264 // emitted graph is the DAG, not a duplicated-compute tree. Subtrees with
4265 // OPEN scalar slots are never shared — each occurrence takes its own
4266 // cursor value. (The flat-DAG node table with real CSE is slice 3.)
4267
4268 #[test]
4269 fn emit_shares_repeated_slot_free_subtrees() {
4270 let region = op_node(
4271 OpTag::Add,
4272 OpAttrs::default(),
4273 vec![
4274 op_node(OpTag::Exp, OpAttrs::default(), vec![bind(0)]),
4275 op_node(OpTag::Exp, OpAttrs::default(), vec![bind(0)]),
4276 ],
4277 );
4278 let mut g = Graph::new();
4279 let x = g.push(Node {
4280 op: Op::Const,
4281 inputs: vec![],
4282 shape: Shape::from_dims(&[4]),
4283 dtype: DType::F32,
4284 });
4285 let root = emit_region(&mut g, ®ion, &[x], &[]);
4286 let add = g.node(root);
4287 assert!(matches!(add.op, Op::Add));
4288 assert_eq!(
4289 add.inputs[0], add.inputs[1],
4290 "structurally-equal slot-free subtrees must share ONE emitted node",
4291 );
4292 }
4293
4294 #[test]
4295 fn emit_does_not_share_subtrees_with_open_scalar_slots() {
4296 // Two open MulScalar slots take DIFFERENT cursor values (pre-order
4297 // fill) — sharing them would silently drop the second live value.
4298 let region = op_node(
4299 OpTag::Add,
4300 OpAttrs::default(),
4301 vec![
4302 op_node(OpTag::MulScalar, OpAttrs::default(), vec![bind(0)]),
4303 op_node(OpTag::MulScalar, OpAttrs::default(), vec![bind(0)]),
4304 ],
4305 );
4306 let mut g = Graph::new();
4307 let x = g.push(Node {
4308 op: Op::Const,
4309 inputs: vec![],
4310 shape: Shape::from_dims(&[4]),
4311 dtype: DType::F32,
4312 });
4313 let root = emit_region(&mut g, ®ion, &[x], &[2.0, 3.0]);
4314 let a = g.node(root).inputs[0];
4315 let b = g.node(root).inputs[1];
4316 assert_ne!(a, b, "open-slot subtrees are never shared");
4317 assert!(matches!(g.node(a).op, Op::MulScalar(v) if v == 2.0));
4318 assert!(matches!(g.node(b).op, Op::MulScalar(v) if v == 3.0));
4319 }
4320
4321 #[test]
4322 fn emit_matches_cast_over_add_reference() {
4323 // Exercises the dtype path through assert_structural_eq: a hand-built
4324 // two-node reference `Cast(F16)(Add(a, b))` vs the emitted region.
4325 use fuel_ir::{DType, Shape};
4326 let mut g = Graph::new();
4327 let sh = Shape::from_dims(&[4]);
4328 let a = g.push(Node {
4329 op: Op::Const,
4330 inputs: vec![],
4331 shape: sh.clone(),
4332 dtype: DType::F32,
4333 });
4334 let b = g.push(Node {
4335 op: Op::Const,
4336 inputs: vec![],
4337 shape: sh.clone(),
4338 dtype: DType::F32,
4339 });
4340 // Reference graph.
4341 let add = g.push(Node {
4342 op: Op::Add,
4343 inputs: vec![a, b],
4344 shape: sh.clone(),
4345 dtype: DType::F32,
4346 });
4347 let reference = g.push(Node {
4348 op: Op::Cast(DType::F16),
4349 inputs: vec![add],
4350 shape: sh.clone(),
4351 dtype: DType::F16,
4352 });
4353
4354 let region = op_node(
4355 OpTag::Cast,
4356 OpAttrs {
4357 cast_dtype: Some("f16".into()),
4358 ..OpAttrs::default()
4359 },
4360 vec![op_node(
4361 OpTag::Add,
4362 OpAttrs::default(),
4363 vec![bind(0), bind(1)],
4364 )],
4365 );
4366 let emitted = emit_region(&mut g, ®ion, &[a, b], &[]);
4367 assert_structural_eq(&g, reference, emitted);
4368 }
4369
4370 // ---- T5 (Increment C slice 1): decompose_via_recipe bridge + the
4371 // softmax_last_dim pilot migration --------------------------------------
4372 //
4373 // The registry bridge (`crate::registry::decompose_via_recipe`, design
4374 // D6) makes a static entry's `decompose` a re-emit of portable
4375 // `PatternNode` DATA: node inputs are the binds, a per-entry projection
4376 // supplies the open-slot scalars, the resolving emit does the rest. ANY
4377 // failure — wrong params payload, a semantics-absent op token (the
4378 // flip-withdrawal posture: unknown/non-registry tokens are surfaced
4379 // honest-miss declines, NEVER accepted, NEVER a crash), a bind/arity or
4380 // slot-count mismatch, a rel-resolution decline at these shapes — returns
4381 // `id` (fixpoint, G2), never panics.
4382
4383 mod recipe_bridge {
4384 use super::super::*;
4385 use super::{bind, frozen_legacy_softmax_decompose, op_node};
4386 use crate::registry::{FusedOps, decompose_via_recipe};
4387 use fuel_ir::{DType, Shape};
4388 use std::collections::HashMap;
4389
4390 /// Tiny f64 reference interpreter over the primitive vocabulary the
4391 /// two softmax spellings use (Const leaves, last-axis reduces, keepdim
4392 /// restores, last-dim broadcast, elementwise). BOTH parity sides run
4393 /// through it, with in-order accumulation per row — so the bit-exact
4394 /// assert isolates recipe STRUCTURE; float noise can't differ between
4395 /// two evaluations of the same interpreter. (Not code evaluation: a
4396 /// closed match over our own `Op` enum — no dynamic execution.)
4397 fn eval(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
4398 let node = g.node(id);
4399 match &node.op {
4400 Op::Const => leaves.get(&id).expect("leaf data provided").clone(),
4401 Op::Exp => eval(g, node.inputs[0], leaves)
4402 .iter()
4403 .map(|v| v.exp())
4404 .collect(),
4405 Op::Sub => {
4406 let a = eval(g, node.inputs[0], leaves);
4407 let b = eval(g, node.inputs[1], leaves);
4408 a.iter().zip(&b).map(|(x, y)| x - y).collect()
4409 }
4410 Op::Div => {
4411 let a = eval(g, node.inputs[0], leaves);
4412 let b = eval(g, node.inputs[1], leaves);
4413 a.iter().zip(&b).map(|(x, y)| x / y).collect()
4414 }
4415 // Last-axis reduces — one arm per spelling pair, identical
4416 // in-order fold.
4417 Op::MaxDim(_) | Op::ReduceMaxTo(_) => {
4418 let input = eval(g, node.inputs[0], leaves);
4419 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
4420 input
4421 .chunks(last)
4422 .map(|row| row.iter().copied().fold(f64::NEG_INFINITY, f64::max))
4423 .collect()
4424 }
4425 Op::SumDim(_) | Op::ReduceSumTo(_) => {
4426 let input = eval(g, node.inputs[0], leaves);
4427 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
4428 input.chunks(last).map(|row| row.iter().sum()).collect()
4429 }
4430 // Metadata-only keepdim restores.
4431 Op::Unsqueeze { .. } | Op::Reshape(_) => eval(g, node.inputs[0], leaves),
4432 // Broadcast a keepdim/reduced tensor back along the last axis.
4433 Op::BroadcastTo(target) => {
4434 let input = eval(g, node.inputs[0], leaves);
4435 let out_n: usize = target.dims().iter().product();
4436 let last = *target.dims().last().unwrap();
4437 assert_eq!(
4438 input.len() * last,
4439 out_n,
4440 "broadcast is a last-dim repeat in these graphs",
4441 );
4442 input
4443 .iter()
4444 .flat_map(|&v| std::iter::repeat_n(v, last))
4445 .collect()
4446 }
4447 other => panic!("eval: unhandled op {other:?}"),
4448 }
4449 }
4450
4451 fn softmax_fused_node(g: &mut Graph, dims: &[usize]) -> (NodeId, NodeId) {
4452 let sh = Shape::from_dims(dims);
4453 let x = g.push(Node {
4454 op: Op::Const,
4455 inputs: vec![],
4456 shape: sh.clone(),
4457 dtype: DType::F32,
4458 });
4459 let fused = g.push(Node {
4460 op: Op::Fused(FusedOps::SOFTMAX_LAST_DIM, FusedOpParams::SoftmaxLastDim),
4461 inputs: vec![x],
4462 shape: sh,
4463 dtype: DType::F32,
4464 });
4465 (x, fused)
4466 }
4467
4468 /// T5 red (a): ONE recipe datum decomposes at BOTH rank 2 and rank 3
4469 /// (the polymorphism the baked-shape legacy body never had), and its
4470 /// numerics match the FROZEN legacy builder bit-exactly under the
4471 /// shared reference interpreter.
4472 #[test]
4473 fn softmax_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
4474 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
4475 let mut g = Graph::new();
4476 let (x, fused) = softmax_fused_node(&mut g, &dims);
4477 let sh = Shape::from_dims(&dims);
4478 let new_root = crate::registry::softmax_last_dim::decompose(
4479 &mut g,
4480 fused,
4481 &FusedOpParams::SoftmaxLastDim,
4482 );
4483 assert_ne!(new_root, fused, "recipe decompose must fire at {dims:?}");
4484 assert_eq!(g.node(new_root).shape, sh, "softmax is shape-preserving");
4485 assert_eq!(g.node(new_root).dtype, DType::F32);
4486
4487 let legacy_root =
4488 frozen_legacy_softmax_decompose(&mut g, fused, &FusedOpParams::SoftmaxLastDim);
4489
4490 let n: usize = dims.iter().product();
4491 let data: Vec<f64> = (0..n)
4492 .map(|i| ((i as f64) * 0.37).sin() * 3.0 - 0.5)
4493 .collect();
4494 let mut leaves = HashMap::new();
4495 leaves.insert(x, data);
4496 let got = eval(&g, new_root, &leaves);
4497 let want = eval(&g, legacy_root, &leaves);
4498 assert_eq!(got.len(), want.len());
4499 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
4500 assert_eq!(
4501 a.to_bits(),
4502 b.to_bits(),
4503 "softmax[{i}] at {dims:?}: recipe={a} vs legacy={b}",
4504 );
4505 }
4506 }
4507 }
4508
4509 /// T5 red (b): the structural golden — the ratified D3 shrink-via-swap
4510 /// spelling, 9 op nodes with the `e = Exp(..)` interior SHARED (node
4511 /// identity) between the denominator reduce and the final Div.
4512 #[test]
4513 fn softmax_recipe_emits_the_nine_node_shared_spelling() {
4514 let mut g = Graph::new();
4515 let (x, fused) = softmax_fused_node(&mut g, &[2, 4]);
4516 let sh = Shape::from_dims(&[2, 4]);
4517 let root = crate::registry::softmax_last_dim::decompose(
4518 &mut g,
4519 fused,
4520 &FusedOpParams::SoftmaxLastDim,
4521 );
4522
4523 // out = Div(e, db)
4524 assert!(matches!(g.node(root).op, Op::Div));
4525 let e = g.node(root).inputs[0];
4526 let db = g.node(root).inputs[1];
4527 assert!(matches!(g.node(e).op, Op::Exp));
4528 assert_eq!(g.node(db).op, Op::BroadcastTo(sh.clone()));
4529 // db = BroadcastTo(Unsqueeze(SumDim(e))) — the SAME e node.
4530 let u2 = g.node(db).inputs[0];
4531 assert!(matches!(g.node(u2).op, Op::Unsqueeze { dim: 1 }));
4532 assert_eq!(g.node(u2).shape, Shape::from_dims(&[2, 1]));
4533 let d = g.node(u2).inputs[0];
4534 assert!(matches!(g.node(d).op, Op::SumDim(1)));
4535 assert_eq!(g.node(d).shape, Shape::from_dims(&[2]));
4536 assert_eq!(
4537 g.node(d).inputs[0],
4538 e,
4539 "the denominator reduces the SHARED Exp node — identity-share, not a duplicate",
4540 );
4541 // e = Exp(Sub(x, mb)); mb = BroadcastTo(Unsqueeze(MaxDim(x))).
4542 let s = g.node(e).inputs[0];
4543 assert!(matches!(g.node(s).op, Op::Sub));
4544 assert_eq!(g.node(s).inputs[0], x);
4545 let mb = g.node(s).inputs[1];
4546 assert_eq!(g.node(mb).op, Op::BroadcastTo(sh.clone()));
4547 let u1 = g.node(mb).inputs[0];
4548 assert!(matches!(g.node(u1).op, Op::Unsqueeze { dim: 1 }));
4549 let m = g.node(u1).inputs[0];
4550 assert!(matches!(g.node(m).op, Op::MaxDim(1)));
4551 assert_eq!(g.node(m).inputs[0], x);
4552 // 9 op nodes + the x leaf = 10 reachable (NO duplicated interior).
4553 assert_eq!(
4554 crate::topo_order_multi(&g, &[root]).len(),
4555 10,
4556 "MaxDim/Unsqueeze/Bcast/Sub/Exp/SumDim/Unsqueeze/Bcast/Div + leaf",
4557 );
4558 }
4559
4560 /// T5 red (c): totality — a wrong params payload is a typed decline
4561 /// surfaced as a fixpoint (G2), never a panic, and declines BEFORE any
4562 /// emission (no partial nodes).
4563 #[test]
4564 fn softmax_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
4565 let mut g = Graph::new();
4566 let (_x, fused) = softmax_fused_node(&mut g, &[2, 4]);
4567 let before = g.len();
4568 let out =
4569 crate::registry::softmax_last_dim::decompose(&mut g, fused, &FusedOpParams::Rope);
4570 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
4571 assert_eq!(g.len(), before, "declined before any emission");
4572 }
4573
4574 /// INJECTED item (2), the flip-withdrawal posture (Baracuda #68 /
4575 /// KISS-Ops closed registry): an op token with no registry semantics —
4576 /// the in-memory analog of the withdrawn reverse-scan "flip" spelling —
4577 /// must surface as a typed honest-miss decline: the node stays fused
4578 /// (fixpoint), NEVER accepted, NEVER a crash. The fabricated recipe
4579 /// stands in for a foreign token by carrying `OpTag::Clamp` — a tag
4580 /// with NO primitive re-emission today (`tag_to_op` → `None`), exactly
4581 /// the semantics-absent posture an unregistered op name resolves to.
4582 /// (This stand-in was `OpTag::PowI` until Increment C carriers A3 gave
4583 /// PowI its i32-exponent carrier; `Clamp` — still awaiting a two-scalar
4584 /// carrier — is the current canonical carrier-less token.) If/when the
4585 /// token registers, it becomes a NAMED-op resolution case — semantics
4586 /// arrive via registration, never via silent acceptance here.
4587 #[test]
4588 fn decompose_via_recipe_declines_an_unknown_token_recipe() {
4589 let fabricated = op_node(
4590 OpTag::Clamp,
4591 OpAttrs {
4592 scalars: vec![0.0, 1.0],
4593 ..OpAttrs::default()
4594 },
4595 vec![bind(0)],
4596 );
4597 let mut g = Graph::new();
4598 let (_x, fused) = softmax_fused_node(&mut g, &[2, 4]);
4599 let before = g.len();
4600 let out = decompose_via_recipe(&mut g, fused, &fabricated, Some(Vec::new()));
4601 assert_eq!(out, fused, "semantics-absent token ⇒ honest-miss fixpoint");
4602 assert_eq!(
4603 g.len(),
4604 before,
4605 "declined BEFORE any emission — no partial nodes"
4606 );
4607 }
4608
4609 /// The bridge's bind/input arity guard: a recipe over 2 binds cannot
4610 /// decompose a 1-input node — fixpoint, not a crash (and not a
4611 /// misbound emission).
4612 #[test]
4613 fn decompose_via_recipe_bind_arity_mismatch_is_a_fixpoint() {
4614 let recipe = op_node(OpTag::Add, OpAttrs::default(), vec![bind(0), bind(1)]);
4615 let mut g = Graph::new();
4616 let (_x, fused) = softmax_fused_node(&mut g, &[2, 4]);
4617 let before = g.len();
4618 let out = decompose_via_recipe(&mut g, fused, &recipe, Some(Vec::new()));
4619 assert_eq!(out, fused, "bind/input arity mismatch ⇒ fixpoint");
4620 assert_eq!(g.len(), before, "declined before any emission");
4621 }
4622 }
4623
4624 // rope migration (Increment C slice 1, T6) ------------------------------
4625 //
4626 // Rope's 11-node imperative body becomes a 9-node portable `PatternNode`
4627 // DATA recipe: cos/sin broadcasts carry `SameAs { operand: 0 }`, the two
4628 // half-slices carry `DimExpr` start/len over the Bind space (the
4629 // reference-doc worked example: `start=Const(0), len=Div(E,2)` /
4630 // `start=Div(E,2), len=Sub(E, Div(E,2))`), the last-axis Concat carries
4631 // `axis_last`, and the two leading-1-padded prep `Reshape`s are NOT in the
4632 // datum — the emit resolver MATERIALIZES them (D4) only where the
4633 // broadcast target out-ranks its operand. Consequence: at a rank-RAISING
4634 // broadcast (the real attention consumer: cos/sin `[seq,d]`, x `[..,seq,d]`
4635 // rank ≥ 3) emission is BYTE-IDENTICAL to legacy (11 nodes, both pads); at
4636 // EQUAL rank (x itself rank 2 = `[seq,d]`) the recipe emits the 9-node
4637 // form, eliding legacy's no-op `Reshape([seq,d]→[seq,d])` — numerically
4638 // identical, structurally leaner. D4 is shared with softmax/norms and MUST
4639 // NOT add reshapes at equal rank (that would break the softmax parity
4640 // oracle), so the equal-rank elision is intrinsic, not a defect.
4641 mod rope_recipe {
4642 use super::super::*;
4643 use super::frozen_legacy_rope_decompose;
4644 use crate::registry::{FusedOps, rope};
4645 use fuel_ir::{DType, Shape};
4646 use std::collections::HashMap;
4647
4648 /// Tiny f64 reference interpreter over the rope primitive vocabulary
4649 /// (Const leaves, metadata-only rank-pad Reshape, leading-dim
4650 /// BroadcastTo, last-axis Slice/Concat, Neg, elementwise Mul/Add).
4651 /// BOTH parity sides run through it in identical in-order arithmetic —
4652 /// so a bit-exact assert isolates recipe STRUCTURE. (Not code
4653 /// evaluation: a closed match over our own `Op` enum.)
4654 fn eval_rope(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
4655 let node = g.node(id);
4656 match &node.op {
4657 Op::Const => leaves.get(&id).expect("leaf data provided").clone(),
4658 Op::Neg => eval_rope(g, node.inputs[0], leaves)
4659 .iter()
4660 .map(|v| -v)
4661 .collect(),
4662 Op::Mul => {
4663 let a = eval_rope(g, node.inputs[0], leaves);
4664 let b = eval_rope(g, node.inputs[1], leaves);
4665 a.iter().zip(&b).map(|(x, y)| x * y).collect()
4666 }
4667 Op::Add => {
4668 let a = eval_rope(g, node.inputs[0], leaves);
4669 let b = eval_rope(g, node.inputs[1], leaves);
4670 a.iter().zip(&b).map(|(x, y)| x + y).collect()
4671 }
4672 // Metadata-only leading-1 rank-pad: same row-major order.
4673 Op::Reshape(_) => eval_rope(g, node.inputs[0], leaves),
4674 // Broadcast a `[1,..,1,seq,d]` inner block over leading dims:
4675 // the block (all of `input`, since the leading dims are 1)
4676 // tiles to fill the target — row-major `input[i % block]`.
4677 Op::BroadcastTo(target) => {
4678 let input = eval_rope(g, node.inputs[0], leaves);
4679 let out_n: usize = target.dims().iter().product();
4680 (0..out_n).map(|i| input[i % input.len()]).collect()
4681 }
4682 Op::Slice { dim, start, len } => {
4683 let input = eval_rope(g, node.inputs[0], leaves);
4684 let in_dims = g.node(node.inputs[0]).shape.dims().to_vec();
4685 let last = in_dims.len() - 1;
4686 assert_eq!(*dim, last, "rope slices along the last axis");
4687 let row = in_dims[last];
4688 input
4689 .chunks(row)
4690 .flat_map(|r| r[*start..*start + *len].to_vec())
4691 .collect()
4692 }
4693 Op::Concat { dim } => {
4694 let a = eval_rope(g, node.inputs[0], leaves);
4695 let b = eval_rope(g, node.inputs[1], leaves);
4696 let a_last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
4697 let b_last = *g.node(node.inputs[1]).shape.dims().last().unwrap();
4698 let last = g.node(node.inputs[0]).shape.dims().len() - 1;
4699 assert_eq!(*dim, last, "rope concats along the last axis");
4700 let mut out = Vec::with_capacity(a.len() + b.len());
4701 let mut ai = a.chunks(a_last);
4702 let mut bi = b.chunks(b_last);
4703 loop {
4704 match (ai.next(), bi.next()) {
4705 (Some(ra), Some(rb)) => {
4706 out.extend_from_slice(ra);
4707 out.extend_from_slice(rb);
4708 }
4709 (None, None) => break,
4710 _ => panic!("concat row-count mismatch"),
4711 }
4712 }
4713 out
4714 }
4715 other => panic!("eval_rope: unhandled op {other:?}"),
4716 }
4717 }
4718
4719 /// Build a fused Rope node over `x [..,seq,d]`, `cos [seq,d]`,
4720 /// `sin [seq,d]`. Returns `(x, cos, sin, fused)`.
4721 fn rope_fused_node(g: &mut Graph, x_dims: &[usize]) -> (NodeId, NodeId, NodeId, NodeId) {
4722 let rank = x_dims.len();
4723 let table_dims = [x_dims[rank - 2], x_dims[rank - 1]];
4724 let x_sh = Shape::from_dims(x_dims);
4725 let t_sh = Shape::from_dims(&table_dims);
4726 let x = g.push(Node {
4727 op: Op::Const,
4728 inputs: vec![],
4729 shape: x_sh.clone(),
4730 dtype: DType::F32,
4731 });
4732 let cos = g.push(Node {
4733 op: Op::Const,
4734 inputs: vec![],
4735 shape: t_sh.clone(),
4736 dtype: DType::F32,
4737 });
4738 let sin = g.push(Node {
4739 op: Op::Const,
4740 inputs: vec![],
4741 shape: t_sh,
4742 dtype: DType::F32,
4743 });
4744 let fused = g.push(Node {
4745 op: Op::Fused(FusedOps::ROPE, FusedOpParams::Rope),
4746 inputs: vec![x, cos, sin],
4747 shape: x_sh,
4748 dtype: DType::F32,
4749 });
4750 (x, cos, sin, fused)
4751 }
4752
4753 /// T6 red (a): ONE recipe datum decomposes at BOTH rank 2 and rank 4
4754 /// — the shape/rank polymorphism the baked-shape legacy body never had
4755 /// — and its numerics match the FROZEN legacy builder bit-exactly
4756 /// under the shared reference interpreter.
4757 #[test]
4758 fn rope_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
4759 for x_dims in [vec![2usize, 4], vec![1, 2, 3, 8]] {
4760 let mut g = Graph::new();
4761 let (x, cos, sin, fused) = rope_fused_node(&mut g, &x_dims);
4762 let x_sh = Shape::from_dims(&x_dims);
4763 let rank = x_dims.len();
4764 let seq = x_dims[rank - 2];
4765 let d = x_dims[rank - 1];
4766
4767 let new_root = rope::decompose(&mut g, fused, &FusedOpParams::Rope);
4768 assert_ne!(new_root, fused, "recipe decompose must fire at {x_dims:?}");
4769 assert_eq!(g.node(new_root).shape, x_sh, "rope is shape-preserving");
4770 assert_eq!(g.node(new_root).dtype, DType::F32);
4771
4772 let legacy_root = frozen_legacy_rope_decompose(&mut g, fused, &FusedOpParams::Rope);
4773
4774 // Distinct, deterministic leaf data for x / cos / sin.
4775 let x_n: usize = x_dims.iter().product();
4776 let t_n = seq * d;
4777 let x_data: Vec<f64> = (0..x_n)
4778 .map(|i| ((i as f64) * 0.31).sin() * 2.0 - 0.4)
4779 .collect();
4780 let cos_data: Vec<f64> =
4781 (0..t_n).map(|i| ((i as f64) * 0.17 + 0.5).cos()).collect();
4782 let sin_data: Vec<f64> =
4783 (0..t_n).map(|i| ((i as f64) * 0.23 - 0.2).sin()).collect();
4784 let mut leaves = HashMap::new();
4785 leaves.insert(x, x_data);
4786 leaves.insert(cos, cos_data);
4787 leaves.insert(sin, sin_data);
4788
4789 let got = eval_rope(&g, new_root, &leaves);
4790 let want = eval_rope(&g, legacy_root, &leaves);
4791 assert_eq!(got.len(), want.len(), "element count at {x_dims:?}");
4792 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
4793 assert_eq!(
4794 a.to_bits(),
4795 b.to_bits(),
4796 "rope[{i}] at {x_dims:?}: recipe={a} vs legacy={b}",
4797 );
4798 }
4799 }
4800 }
4801
4802 /// T6 red (b): at a rank-RAISING broadcast (rank 4 — the real attention
4803 /// consumer's shape), the recipe emission is BYTE-IDENTICAL to legacy:
4804 /// D4 materializes both leading-1-padded prep `Reshape`s, so the whole
4805 /// 11-node DAG matches node-for-node (op, shape, dtype, wiring). This
4806 /// is the byte-identity guarantee that retires all backend risk for the
4807 /// live rope-decompose path.
4808 #[test]
4809 fn rope_recipe_is_byte_identical_to_legacy_at_rank_raise() {
4810 let mut g = Graph::new();
4811 let (_x, _cos, _sin, fused) = rope_fused_node(&mut g, &[1, 2, 3, 8]);
4812 let recipe_root = rope::decompose(&mut g, fused, &FusedOpParams::Rope);
4813 let legacy_root = frozen_legacy_rope_decompose(&mut g, fused, &FusedOpParams::Rope);
4814 super::assert_structural_eq(&g, recipe_root, legacy_root);
4815 // Both leading-1 prep Reshapes are present (byte-identical, 11 ops).
4816 let reachable = crate::topo_order_multi(&g, &[recipe_root]);
4817 let reshapes = reachable
4818 .iter()
4819 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
4820 .count();
4821 assert_eq!(
4822 reshapes, 2,
4823 "rank-raise materializes both legacy prep Reshapes (D4)"
4824 );
4825 }
4826
4827 /// T6 red (c): at EQUAL rank (x itself `[seq,d]`) the recipe emits the
4828 /// 9-node form — the resolver adds NO pad `Reshape` (D4 pads only on a
4829 /// rank-raise), eliding legacy's no-op `Reshape([seq,d]→[seq,d])`.
4830 /// Numerically identical (covered by the parity test), structurally
4831 /// leaner: 9 op nodes + 3 leaves, zero `Reshape`.
4832 #[test]
4833 fn rope_recipe_elides_the_noop_prep_reshape_at_equal_rank() {
4834 let mut g = Graph::new();
4835 let (_x, _cos, _sin, fused) = rope_fused_node(&mut g, &[2, 4]);
4836 let root = rope::decompose(&mut g, fused, &FusedOpParams::Rope);
4837 assert_ne!(root, fused, "recipe decompose fires");
4838 let reachable = crate::topo_order_multi(&g, &[root]);
4839 let reshapes = reachable
4840 .iter()
4841 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
4842 .count();
4843 assert_eq!(
4844 reshapes, 0,
4845 "equal-rank broadcast needs no prep Reshape (D4)"
4846 );
4847 let op_nodes = reachable
4848 .iter()
4849 .filter(|&&n| !matches!(g.node(n).op, Op::Const))
4850 .count();
4851 assert_eq!(
4852 op_nodes, 9,
4853 "the 9-node rope recipe (2×Bcast/2×Slice/Neg/Concat/2×Mul/Add)"
4854 );
4855 }
4856
4857 /// T6 red (d): totality (G2) — a wrong params payload is a typed
4858 /// decline surfaced as a fixpoint, never a panic, and declines BEFORE
4859 /// any emission (no partial nodes). The legacy imperative body ignored
4860 /// `params` entirely and always decomposed; the recipe bridge gates on
4861 /// the projection, so a non-`Rope` payload now correctly no-ops.
4862 #[test]
4863 fn rope_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
4864 let mut g = Graph::new();
4865 let (_x, _cos, _sin, fused) = rope_fused_node(&mut g, &[2, 4]);
4866 let before = g.len();
4867 let out = rope::decompose(&mut g, fused, &FusedOpParams::SoftmaxLastDim);
4868 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
4869 assert_eq!(g.len(), before, "declined before any emission");
4870 }
4871 }
4872
4873 // rms_norm + layer_norm migration (Increment C slice 1, T7) --------------
4874 //
4875 // Both norms' imperative bodies become portable `PatternNode` DATA recipes.
4876 // Two forces at play beyond softmax/rope:
4877 //
4878 // * `eps` is an OPEN scalar slot. The recipe's `AddScalar` carries EMPTY
4879 // `scalars`, so it is a slot template; the per-entry projection
4880 // (`RmsNormLastDim { eps } → vec![eps]` / `LayerNormLastDim { eps } →
4881 // vec![eps]`) supplies the live value, and the resolving emit fills the
4882 // slot in pre-order. The eps-wiring tests below decompose the SAME op at
4883 // two eps values and assert the realized outputs DIFFER accordingly — the
4884 // proof that eps rides the projection→slot path, not a baked constant.
4885 //
4886 // * The keepdim restore is the RATIFIED D3 shrink-via-swap: `Reshape(keepdim)`
4887 // → `Unsqueeze(axis_last = append)` (a node-TYPE change, metadata-only, so
4888 // numerically bit-exact). `MeanDim(axis_last)` stays a rank-reducing mean.
4889 // The parity tests evaluate the new recipe emission and the FROZEN legacy
4890 // builder through one reference interpreter (which treats `Reshape` and
4891 // `Unsqueeze` identically) and assert bit-exact equivalence at two ranks.
4892 //
4893 // Neither norm ever trips D4 (the keepdim restore rebuilds rank BEFORE the
4894 // broadcast, so every `BroadcastTo` operand already matches its target's
4895 // rank — no leading-1 pad `Reshape` is materialized).
4896 mod norm_recipe {
4897 use super::super::*;
4898 use super::{frozen_legacy_layer_norm_decompose, frozen_legacy_rms_norm_decompose};
4899 use crate::registry::{FusedOps, layer_norm_last_dim, rms_norm_last_dim};
4900 use fuel_ir::{DType, Shape};
4901 use std::collections::HashMap;
4902
4903 /// Tiny f64 reference interpreter over the norm primitive vocabulary
4904 /// (Const leaves, `Sqr`, last-axis `MeanDim`, metadata-only keepdim
4905 /// restores `Unsqueeze`/`Reshape`, `AddScalar`, `Sqrt`, last-dim
4906 /// `BroadcastTo`, elementwise `Sub`/`Div`). BOTH parity sides run
4907 /// through it with identical in-order arithmetic, so a bit-exact assert
4908 /// isolates recipe STRUCTURE (the `Unsqueeze`-vs-`Reshape` swap can't
4909 /// perturb it). Not code evaluation: a closed match over our own `Op`.
4910 fn eval_norm(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
4911 let node = g.node(id);
4912 match &node.op {
4913 Op::Const => leaves.get(&id).expect("leaf data provided").clone(),
4914 Op::Sqr => eval_norm(g, node.inputs[0], leaves)
4915 .iter()
4916 .map(|v| v * v)
4917 .collect(),
4918 Op::Sqrt => eval_norm(g, node.inputs[0], leaves)
4919 .iter()
4920 .map(|v| v.sqrt())
4921 .collect(),
4922 Op::AddScalar(e) => eval_norm(g, node.inputs[0], leaves)
4923 .iter()
4924 .map(|v| v + e)
4925 .collect(),
4926 Op::Sub => {
4927 let a = eval_norm(g, node.inputs[0], leaves);
4928 let b = eval_norm(g, node.inputs[1], leaves);
4929 a.iter().zip(&b).map(|(x, y)| x - y).collect()
4930 }
4931 Op::Div => {
4932 let a = eval_norm(g, node.inputs[0], leaves);
4933 let b = eval_norm(g, node.inputs[1], leaves);
4934 a.iter().zip(&b).map(|(x, y)| x / y).collect()
4935 }
4936 // Last-axis mean — rank-reducing; identical fold both spellings.
4937 Op::MeanDim(_) => {
4938 let input = eval_norm(g, node.inputs[0], leaves);
4939 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
4940 input
4941 .chunks(last)
4942 .map(|row| row.iter().sum::<f64>() / last as f64)
4943 .collect()
4944 }
4945 // Metadata-only keepdim restores (the D3 swap and its legacy
4946 // twin evaluate identically here).
4947 Op::Unsqueeze { .. } | Op::Reshape(_) => eval_norm(g, node.inputs[0], leaves),
4948 // Broadcast a keepdim `[.., 1]` tensor back along the last axis.
4949 Op::BroadcastTo(target) => {
4950 let input = eval_norm(g, node.inputs[0], leaves);
4951 let out_n: usize = target.dims().iter().product();
4952 let last = *target.dims().last().unwrap();
4953 assert_eq!(
4954 input.len() * last,
4955 out_n,
4956 "broadcast is a last-dim repeat in these graphs",
4957 );
4958 input
4959 .iter()
4960 .flat_map(|&v| std::iter::repeat_n(v, last))
4961 .collect()
4962 }
4963 other => panic!("eval_norm: unhandled op {other:?}"),
4964 }
4965 }
4966
4967 /// Build a fused RmsNormLastDim node over `x [dims]`, carrying `eps`.
4968 /// Returns `(x, fused)`.
4969 fn rms_norm_fused_node(g: &mut Graph, dims: &[usize], eps: f64) -> (NodeId, NodeId) {
4970 let sh = Shape::from_dims(dims);
4971 let x = g.push(Node {
4972 op: Op::Const,
4973 inputs: vec![],
4974 shape: sh.clone(),
4975 dtype: DType::F32,
4976 });
4977 let fused = g.push(Node {
4978 op: Op::Fused(
4979 FusedOps::RMS_NORM_LAST_DIM,
4980 FusedOpParams::RmsNormLastDim { eps },
4981 ),
4982 inputs: vec![x],
4983 shape: sh,
4984 dtype: DType::F32,
4985 });
4986 (x, fused)
4987 }
4988
4989 /// Build a fused LayerNormLastDim node over `x [dims]`, carrying `eps`.
4990 /// Returns `(x, fused)`.
4991 fn layer_norm_fused_node(g: &mut Graph, dims: &[usize], eps: f64) -> (NodeId, NodeId) {
4992 let sh = Shape::from_dims(dims);
4993 let x = g.push(Node {
4994 op: Op::Const,
4995 inputs: vec![],
4996 shape: sh.clone(),
4997 dtype: DType::F32,
4998 });
4999 let fused = g.push(Node {
5000 op: Op::Fused(
5001 FusedOps::LAYER_NORM_LAST_DIM,
5002 FusedOpParams::LayerNormLastDim { eps },
5003 ),
5004 inputs: vec![x],
5005 shape: sh,
5006 dtype: DType::F32,
5007 });
5008 (x, fused)
5009 }
5010
5011 /// T7 red (a, rms): ONE recipe datum decomposes at BOTH rank 2 and rank
5012 /// 3 (the polymorphism the baked-shape legacy body never had), and its
5013 /// numerics match the FROZEN legacy builder bit-exactly.
5014 #[test]
5015 fn rms_norm_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
5016 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
5017 let mut g = Graph::new();
5018 let (x, fused) = rms_norm_fused_node(&mut g, &dims, 1e-5);
5019 let sh = Shape::from_dims(&dims);
5020 let new_root = rms_norm_last_dim::decompose(
5021 &mut g,
5022 fused,
5023 &FusedOpParams::RmsNormLastDim { eps: 1e-5 },
5024 );
5025 assert_ne!(new_root, fused, "recipe decompose must fire at {dims:?}");
5026 assert_eq!(g.node(new_root).shape, sh, "rms_norm is shape-preserving");
5027 assert_eq!(g.node(new_root).dtype, DType::F32);
5028
5029 let legacy_root = frozen_legacy_rms_norm_decompose(
5030 &mut g,
5031 fused,
5032 &FusedOpParams::RmsNormLastDim { eps: 1e-5 },
5033 );
5034
5035 let n: usize = dims.iter().product();
5036 let data: Vec<f64> = (0..n)
5037 .map(|i| ((i as f64) * 0.37).sin() * 3.0 - 0.5)
5038 .collect();
5039 let mut leaves = HashMap::new();
5040 leaves.insert(x, data);
5041 let got = eval_norm(&g, new_root, &leaves);
5042 let want = eval_norm(&g, legacy_root, &leaves);
5043 assert_eq!(got.len(), want.len());
5044 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
5045 assert_eq!(
5046 a.to_bits(),
5047 b.to_bits(),
5048 "rms_norm[{i}] at {dims:?}: recipe={a} vs legacy={b}",
5049 );
5050 }
5051 }
5052 }
5053
5054 /// T7 red (a, layer): same polymorphism + bit-exact parity for the
5055 /// 11-node layer-norm recipe (with the `centered` subterm identity-
5056 /// shared between `Sqr` and the final `Div`).
5057 #[test]
5058 fn layer_norm_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
5059 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
5060 let mut g = Graph::new();
5061 let (x, fused) = layer_norm_fused_node(&mut g, &dims, 1e-5);
5062 let sh = Shape::from_dims(&dims);
5063 let new_root = layer_norm_last_dim::decompose(
5064 &mut g,
5065 fused,
5066 &FusedOpParams::LayerNormLastDim { eps: 1e-5 },
5067 );
5068 assert_ne!(new_root, fused, "recipe decompose must fire at {dims:?}");
5069 assert_eq!(g.node(new_root).shape, sh, "layer_norm is shape-preserving");
5070 assert_eq!(g.node(new_root).dtype, DType::F32);
5071
5072 let legacy_root = frozen_legacy_layer_norm_decompose(
5073 &mut g,
5074 fused,
5075 &FusedOpParams::LayerNormLastDim { eps: 1e-5 },
5076 );
5077
5078 let n: usize = dims.iter().product();
5079 let data: Vec<f64> = (0..n)
5080 .map(|i| ((i as f64) * 0.29).cos() * 2.0 + 0.3)
5081 .collect();
5082 let mut leaves = HashMap::new();
5083 leaves.insert(x, data);
5084 let got = eval_norm(&g, new_root, &leaves);
5085 let want = eval_norm(&g, legacy_root, &leaves);
5086 assert_eq!(got.len(), want.len());
5087 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
5088 assert_eq!(
5089 a.to_bits(),
5090 b.to_bits(),
5091 "layer_norm[{i}] at {dims:?}: recipe={a} vs legacy={b}",
5092 );
5093 }
5094 }
5095 }
5096
5097 /// T7 red (structural, rms): the keepdim restore is the D3 shrink-via-
5098 /// swap — `Unsqueeze` append, NOT a baked `Reshape(keepdim)`. This is
5099 /// the crisp discriminator against the pre-migration imperative body.
5100 #[test]
5101 fn rms_norm_recipe_uses_the_unsqueeze_keepdim_swap() {
5102 let mut g = Graph::new();
5103 let (_x, fused) = rms_norm_fused_node(&mut g, &[2, 4], 1e-5);
5104 let root = rms_norm_last_dim::decompose(
5105 &mut g,
5106 fused,
5107 &FusedOpParams::RmsNormLastDim { eps: 1e-5 },
5108 );
5109 assert_ne!(root, fused, "recipe decompose fires");
5110 let reachable = crate::topo_order_multi(&g, &[root]);
5111 let unsqueezes = reachable
5112 .iter()
5113 .filter(|&&n| matches!(g.node(n).op, Op::Unsqueeze { .. }))
5114 .count();
5115 let reshapes = reachable
5116 .iter()
5117 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
5118 .count();
5119 assert_eq!(
5120 unsqueezes, 1,
5121 "keepdim restored via Unsqueeze append (D3 swap)"
5122 );
5123 assert_eq!(reshapes, 0, "no baked keepdim Reshape after the D3 swap");
5124 }
5125
5126 /// T7 red (structural, layer): both keepdim restores are `Unsqueeze`
5127 /// appends; zero `Reshape` (the equal-rank broadcasts add no D4 pad).
5128 #[test]
5129 fn layer_norm_recipe_uses_the_unsqueeze_keepdim_swap() {
5130 let mut g = Graph::new();
5131 let (_x, fused) = layer_norm_fused_node(&mut g, &[2, 4], 1e-5);
5132 let root = layer_norm_last_dim::decompose(
5133 &mut g,
5134 fused,
5135 &FusedOpParams::LayerNormLastDim { eps: 1e-5 },
5136 );
5137 assert_ne!(root, fused, "recipe decompose fires");
5138 let reachable = crate::topo_order_multi(&g, &[root]);
5139 let unsqueezes = reachable
5140 .iter()
5141 .filter(|&&n| matches!(g.node(n).op, Op::Unsqueeze { .. }))
5142 .count();
5143 let reshapes = reachable
5144 .iter()
5145 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
5146 .count();
5147 assert_eq!(
5148 unsqueezes, 2,
5149 "both keepdim restores via Unsqueeze append (D3 swap)"
5150 );
5151 assert_eq!(
5152 reshapes, 0,
5153 "no baked keepdim Reshape / no D4 pad after the swap"
5154 );
5155 // The `centered` Sub is SHARED (Sqr input == final Div numerator):
5156 // 11 op nodes + 1 leaf = 12 reachable, not the 12-op unshared tree.
5157 let op_nodes = reachable
5158 .iter()
5159 .filter(|&&n| !matches!(g.node(n).op, Op::Const))
5160 .count();
5161 assert_eq!(op_nodes, 11, "11 op nodes with `centered` identity-shared");
5162 }
5163
5164 /// T7 red (eps-wiring, rms): the eps rides the projection→open-slot
5165 /// path. Decomposing the SAME op at two eps values yields DIFFERENT
5166 /// realized outputs — impossible if eps were dropped or baked to a
5167 /// single constant. Small `x` (so `mean(x²) ≈ eps`) makes the eps
5168 /// choice materially move every element.
5169 #[test]
5170 fn rms_norm_recipe_eps_flows_through_the_open_slot() {
5171 let dims = [2usize, 4];
5172 let n: usize = dims.iter().product();
5173 let data: Vec<f64> = (0..n)
5174 .map(|i| 0.001 * (((i as f64) * 0.37).sin() + 1.2))
5175 .collect();
5176
5177 let realize = |eps: f64| -> Vec<f64> {
5178 let mut g = Graph::new();
5179 let (x, fused) = rms_norm_fused_node(&mut g, &dims, eps);
5180 let root = rms_norm_last_dim::decompose(
5181 &mut g,
5182 fused,
5183 &FusedOpParams::RmsNormLastDim { eps },
5184 );
5185 let mut leaves = HashMap::new();
5186 leaves.insert(x, data.clone());
5187 eval_norm(&g, root, &leaves)
5188 };
5189 let a = realize(1e-5);
5190 let b = realize(1e-6);
5191 assert_eq!(a.len(), b.len());
5192 assert_ne!(
5193 a, b,
5194 "different eps must change the output — proves projection→slot, not a baked constant",
5195 );
5196 }
5197
5198 /// T7 red (eps-wiring, layer): same proof for layer-norm's open slot.
5199 #[test]
5200 fn layer_norm_recipe_eps_flows_through_the_open_slot() {
5201 let dims = [2usize, 4];
5202 let n: usize = dims.iter().product();
5203 // Near-constant rows so the variance ≈ eps and the eps choice moves
5204 // the output materially.
5205 let data: Vec<f64> = (0..n)
5206 .map(|i| 1.0 + 0.001 * ((i as f64) * 0.37).sin())
5207 .collect();
5208
5209 let realize = |eps: f64| -> Vec<f64> {
5210 let mut g = Graph::new();
5211 let (x, fused) = layer_norm_fused_node(&mut g, &dims, eps);
5212 let root = layer_norm_last_dim::decompose(
5213 &mut g,
5214 fused,
5215 &FusedOpParams::LayerNormLastDim { eps },
5216 );
5217 let mut leaves = HashMap::new();
5218 leaves.insert(x, data.clone());
5219 eval_norm(&g, root, &leaves)
5220 };
5221 let a = realize(1e-5);
5222 let b = realize(1e-6);
5223 assert_eq!(a.len(), b.len());
5224 assert_ne!(
5225 a, b,
5226 "different eps must change the output — proves projection→slot, not a baked constant",
5227 );
5228 }
5229
5230 /// T7 red (totality, rms): a wrong params payload is a typed decline
5231 /// surfaced as a fixpoint (G2), never a panic, declining BEFORE any
5232 /// emission (no partial nodes).
5233 #[test]
5234 fn rms_norm_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
5235 let mut g = Graph::new();
5236 let (_x, fused) = rms_norm_fused_node(&mut g, &[2, 4], 1e-5);
5237 let before = g.len();
5238 let out = rms_norm_last_dim::decompose(&mut g, fused, &FusedOpParams::SoftmaxLastDim);
5239 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
5240 assert_eq!(g.len(), before, "declined before any emission");
5241 }
5242
5243 /// T7 red (totality, layer): same fixpoint posture for layer-norm.
5244 #[test]
5245 fn layer_norm_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
5246 let mut g = Graph::new();
5247 let (_x, fused) = layer_norm_fused_node(&mut g, &[2, 4], 1e-5);
5248 let before = g.len();
5249 let out = layer_norm_last_dim::decompose(&mut g, fused, &FusedOpParams::SoftmaxLastDim);
5250 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
5251 assert_eq!(g.len(), before, "declined before any emission");
5252 }
5253 }
5254
5255 // softmax_last_dim_backward migration (Increment C slice 1, T8) ----------
5256 //
5257 // The 5-node imperative backward body `s · (g − sum(g·s, last, keepdim))`
5258 // becomes a portable `PatternNode` DATA recipe. Bind space: `0 = s` (the
5259 // forward softmax output), `1 = g` (the upstream gradient) — the order the
5260 // autograd `BackwardKind::Fused(SOFTMAX_LAST_DIM_BACKWARD)` edge emits. The
5261 // keepdim restore is the ratified D3 shrink-via-swap
5262 // (`ReduceSumTo(keepdim)` → `SumDim(axis_last)` + `Unsqueeze(axis_last =
5263 // append)`, node-TYPE change, numerically bit-exact) and the broadcast
5264 // targets `SameAs { operand: 0 }` over the Bind space (D2). D4 never fires
5265 // (the `Unsqueeze` rebuilds rank BEFORE the broadcast, so the broadcast
5266 // operand already matches its target's rank — no leading-1 pad `Reshape`).
5267 // This activates the registry's backward-helper edge END-TO-END on a data
5268 // recipe for the first time.
5269 mod softmax_backward_recipe {
5270 use super::super::*;
5271 use super::frozen_legacy_softmax_backward_decompose;
5272 use crate::registry::{FusedOps, softmax_last_dim_backward};
5273 use fuel_ir::{DType, Shape};
5274 use std::collections::HashMap;
5275
5276 /// Tiny f64 reference interpreter over the softmax-backward primitive
5277 /// vocabulary (leaf-lookup FIRST, then `Mul`/`Sub`, last-axis
5278 /// `SumDim`/`ReduceSumTo`, metadata-only keepdim restore
5279 /// `Unsqueeze`/`Reshape`, last-dim `BroadcastTo`). Leaf-first lets ANY
5280 /// node stand in as a bound input — a `Const`, or the autograd path's
5281 /// forward-softmax (`Op::Fused`) and upstream nodes. BOTH parity sides
5282 /// run through it with identical in-order arithmetic, so a bit-exact
5283 /// assert isolates recipe STRUCTURE (the `SumDim`+`Unsqueeze`-vs-
5284 /// `ReduceSumTo` swap can't perturb it). Not code evaluation: a closed
5285 /// match over our own `Op`.
5286 fn eval_bwd(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
5287 if let Some(v) = leaves.get(&id) {
5288 return v.clone();
5289 }
5290 let node = g.node(id);
5291 match &node.op {
5292 Op::Mul => {
5293 let a = eval_bwd(g, node.inputs[0], leaves);
5294 let b = eval_bwd(g, node.inputs[1], leaves);
5295 a.iter().zip(&b).map(|(x, y)| x * y).collect()
5296 }
5297 Op::Sub => {
5298 let a = eval_bwd(g, node.inputs[0], leaves);
5299 let b = eval_bwd(g, node.inputs[1], leaves);
5300 a.iter().zip(&b).map(|(x, y)| x - y).collect()
5301 }
5302 // Last-axis sum — one arm per spelling pair, identical fold.
5303 Op::SumDim(_) | Op::ReduceSumTo(_) => {
5304 let input = eval_bwd(g, node.inputs[0], leaves);
5305 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
5306 input.chunks(last).map(|row| row.iter().sum()).collect()
5307 }
5308 // Metadata-only keepdim restores (the D3 swap and its legacy
5309 // twin evaluate identically here).
5310 Op::Unsqueeze { .. } | Op::Reshape(_) => eval_bwd(g, node.inputs[0], leaves),
5311 // Broadcast a keepdim/reduced tensor back along the last axis.
5312 Op::BroadcastTo(target) => {
5313 let input = eval_bwd(g, node.inputs[0], leaves);
5314 let out_n: usize = target.dims().iter().product();
5315 let last = *target.dims().last().unwrap();
5316 assert_eq!(
5317 input.len() * last,
5318 out_n,
5319 "broadcast is a last-dim repeat in these graphs",
5320 );
5321 input
5322 .iter()
5323 .flat_map(|&v| std::iter::repeat_n(v, last))
5324 .collect()
5325 }
5326 other => panic!("eval_bwd: unhandled op {other:?}"),
5327 }
5328 }
5329
5330 /// Build a fused SoftmaxLastDimBackward node over `s [dims]` (input 0,
5331 /// the forward output) and `g [dims]` (input 1, the upstream gradient).
5332 /// Returns `(s, g, fused)`.
5333 fn softmax_backward_fused_node(g: &mut Graph, dims: &[usize]) -> (NodeId, NodeId, NodeId) {
5334 let sh = Shape::from_dims(dims);
5335 let s = g.push(Node {
5336 op: Op::Const,
5337 inputs: vec![],
5338 shape: sh.clone(),
5339 dtype: DType::F32,
5340 });
5341 let up = g.push(Node {
5342 op: Op::Const,
5343 inputs: vec![],
5344 shape: sh.clone(),
5345 dtype: DType::F32,
5346 });
5347 let fused = g.push(Node {
5348 op: Op::Fused(
5349 FusedOps::SOFTMAX_LAST_DIM_BACKWARD,
5350 FusedOpParams::SoftmaxLastDimBackward,
5351 ),
5352 inputs: vec![s, up],
5353 shape: sh,
5354 dtype: DType::F32,
5355 });
5356 (s, up, fused)
5357 }
5358
5359 /// T8 red (a): ONE recipe datum decomposes at BOTH rank 2 and rank 3
5360 /// (the polymorphism the baked-shape legacy body never had), and its
5361 /// numerics match the FROZEN legacy builder bit-exactly under the
5362 /// shared reference interpreter.
5363 #[test]
5364 fn softmax_backward_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
5365 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
5366 let mut g = Graph::new();
5367 let (s, up, fused) = softmax_backward_fused_node(&mut g, &dims);
5368 let sh = Shape::from_dims(&dims);
5369 let new_root = softmax_last_dim_backward::decompose(
5370 &mut g,
5371 fused,
5372 &FusedOpParams::SoftmaxLastDimBackward,
5373 );
5374 assert_ne!(new_root, fused, "recipe decompose must fire at {dims:?}");
5375 assert_eq!(
5376 g.node(new_root).shape,
5377 sh,
5378 "softmax backward is shape-preserving"
5379 );
5380 assert_eq!(g.node(new_root).dtype, DType::F32);
5381
5382 let legacy_root = frozen_legacy_softmax_backward_decompose(
5383 &mut g,
5384 fused,
5385 &FusedOpParams::SoftmaxLastDimBackward,
5386 );
5387
5388 let n: usize = dims.iter().product();
5389 let s_data: Vec<f64> = (0..n)
5390 .map(|i| ((i as f64) * 0.29).sin() * 0.5 + 0.5)
5391 .collect();
5392 let g_data: Vec<f64> = (0..n)
5393 .map(|i| ((i as f64) * 0.53).cos() * 2.0 - 0.3)
5394 .collect();
5395 let mut leaves = HashMap::new();
5396 leaves.insert(s, s_data);
5397 leaves.insert(up, g_data);
5398 let got = eval_bwd(&g, new_root, &leaves);
5399 let want = eval_bwd(&g, legacy_root, &leaves);
5400 assert_eq!(got.len(), want.len());
5401 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
5402 assert_eq!(
5403 a.to_bits(),
5404 b.to_bits(),
5405 "softmax_backward[{i}] at {dims:?}: recipe={a} vs legacy={b}",
5406 );
5407 }
5408 }
5409 }
5410
5411 /// T8 red (structural): the keepdim restore is the D3 shrink-via-swap —
5412 /// `SumDim(last)` + `Unsqueeze` append, NOT the baked
5413 /// `ReduceSumTo(keepdim)`. The crisp discriminator against the
5414 /// pre-migration imperative body; the backward root is the outer `Mul`.
5415 #[test]
5416 fn softmax_backward_recipe_uses_the_sumdim_unsqueeze_swap() {
5417 let mut g = Graph::new();
5418 let (_s, _up, fused) = softmax_backward_fused_node(&mut g, &[2, 4]);
5419 let root = softmax_last_dim_backward::decompose(
5420 &mut g,
5421 fused,
5422 &FusedOpParams::SoftmaxLastDimBackward,
5423 );
5424 assert_ne!(root, fused, "recipe decompose fires");
5425 assert!(
5426 matches!(g.node(root).op, Op::Mul),
5427 "backward root is the outer Mul"
5428 );
5429 let reachable = crate::topo_order_multi(&g, &[root]);
5430 let sumdims = reachable
5431 .iter()
5432 .filter(|&&n| matches!(g.node(n).op, Op::SumDim(_)))
5433 .count();
5434 let unsqueezes = reachable
5435 .iter()
5436 .filter(|&&n| matches!(g.node(n).op, Op::Unsqueeze { .. }))
5437 .count();
5438 let reduce_sum_tos = reachable
5439 .iter()
5440 .filter(|&&n| matches!(g.node(n).op, Op::ReduceSumTo(_)))
5441 .count();
5442 assert_eq!(sumdims, 1, "the reduce is SumDim(last) — the D3 swap");
5443 assert_eq!(unsqueezes, 1, "keepdim restored via Unsqueeze append");
5444 assert_eq!(
5445 reduce_sum_tos, 0,
5446 "no baked keepdim ReduceSumTo after the swap"
5447 );
5448 }
5449
5450 /// T8 red (totality): a wrong params payload is a typed decline
5451 /// surfaced as a fixpoint (G2), never a panic, declining BEFORE any
5452 /// emission. (The pre-migration imperative body IGNORED params and
5453 /// always decomposed; the recipe bridge's `scalars(params)` projection
5454 /// is what makes a wrong payload a fixpoint.)
5455 #[test]
5456 fn softmax_backward_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
5457 let mut g = Graph::new();
5458 let (_s, _up, fused) = softmax_backward_fused_node(&mut g, &[2, 4]);
5459 let before = g.len();
5460 let out = softmax_last_dim_backward::decompose(&mut g, fused, &FusedOpParams::Rope);
5461 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
5462 assert_eq!(g.len(), before, "declined before any emission");
5463 }
5464
5465 /// T8 red (autograd path): the `BackwardKind::Fused(SOFTMAX_LAST_DIM_BACKWARD)`
5466 /// edge exercised END-TO-END. Build a softmax forward, backprop; the
5467 /// input gradient node is `Op::Fused(SOFTMAX_LAST_DIM_BACKWARD)` over
5468 /// `[y, upstream]`; decomposing it fires the MIGRATED recipe (the D3
5469 /// SumDim spelling) and matches the frozen legacy numerically — the
5470 /// "realize" leg, via the reference interpreter feeding synthetic leaf
5471 /// data on the two bound inputs (`y = s`, `upstream = g`).
5472 #[test]
5473 fn softmax_backward_reaches_the_recipe_through_autograd() {
5474 let dev: std::sync::Arc<dyn fuel_backend_contract::DynBackendDevice> =
5475 std::sync::Arc::new(fuel_cpu_backend::dyn_impl::CpuBackendDevice);
5476 let x = crate::NodeHandle::from_f32(
5477 vec![0.1f32, -0.2, 0.3, 0.4, -0.5, 0.6],
5478 Shape::from_dims(&[2, 3]),
5479 &dev,
5480 )
5481 .unwrap();
5482 let y = x.softmax_last_dim();
5483 let y_id = y.id();
5484 let grads = y.backward();
5485 let g_x = grads.get(&x).expect("softmax has an input gradient");
5486 let handle = g_x.graph();
5487 let bwd_id = g_x.id();
5488
5489 // The input-gradient node IS the registry backward fused op over
5490 // `[y, upstream]` (x feeds only the softmax, so no accumulation Add).
5491 let up_id = {
5492 let gr = handle.read().unwrap();
5493 let node = gr.node(bwd_id).clone();
5494 match node.op {
5495 Op::Fused(fid, params) => {
5496 assert_eq!(
5497 fid,
5498 FusedOps::SOFTMAX_LAST_DIM_BACKWARD,
5499 "autograd emits the registry backward fused op",
5500 );
5501 assert!(matches!(params, FusedOpParams::SoftmaxLastDimBackward));
5502 }
5503 other => panic!("expected the backward fused op, got {other:?}"),
5504 }
5505 assert_eq!(
5506 node.inputs[0], y_id,
5507 "backward input 0 = the forward softmax output"
5508 );
5509 node.inputs[1]
5510 };
5511
5512 // Decompose the SAME autograd backward node both ways (push-only
5513 // graph — the fused node survives), then compare numerically.
5514 let (new_root, legacy_root, sh) = {
5515 let mut gr = handle.write().unwrap();
5516 let sh = gr.node(bwd_id).shape.clone();
5517 let new_root = softmax_last_dim_backward::decompose(
5518 &mut gr,
5519 bwd_id,
5520 &FusedOpParams::SoftmaxLastDimBackward,
5521 );
5522 let legacy_root = frozen_legacy_softmax_backward_decompose(
5523 &mut gr,
5524 bwd_id,
5525 &FusedOpParams::SoftmaxLastDimBackward,
5526 );
5527 (new_root, legacy_root, sh)
5528 };
5529
5530 let gr = handle.read().unwrap();
5531 assert_ne!(
5532 new_root, bwd_id,
5533 "the autograd backward node decomposes via the recipe"
5534 );
5535 assert_eq!(gr.node(new_root).shape, sh, "shape-preserving");
5536 let reachable = crate::topo_order_multi(&gr, &[new_root]);
5537 assert!(
5538 reachable
5539 .iter()
5540 .any(|&n| matches!(gr.node(n).op, Op::SumDim(_))),
5541 "the autograd path reaches the D3 SumDim spelling",
5542 );
5543
5544 // Numeric parity (leaf-first interpreter over `[y = s, up = g]`).
5545 let n: usize = sh.dims().iter().product();
5546 let s_data: Vec<f64> = (0..n)
5547 .map(|i| ((i as f64) * 0.31).sin() * 0.5 + 0.5)
5548 .collect();
5549 let g_data: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.47).cos() - 0.1).collect();
5550 let mut leaves = HashMap::new();
5551 leaves.insert(y_id, s_data);
5552 leaves.insert(up_id, g_data);
5553 let got = eval_bwd(&gr, new_root, &leaves);
5554 let want = eval_bwd(&gr, legacy_root, &leaves);
5555 assert_eq!(got.len(), want.len());
5556 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
5557 assert_eq!(
5558 a.to_bits(),
5559 b.to_bits(),
5560 "autograd softmax_backward[{i}]: recipe={a} vs legacy={b}",
5561 );
5562 }
5563 }
5564 }
5565
5566 // layer_norm_last_dim_backward migration (Increment C slice 2, S2-1) ------
5567 //
5568 // The ~20-node imperative backward body
5569 // `grad_x = istd · (g − mean(g) − xhat·mean(g·xhat))` (mean over the last
5570 // dim, `xhat = (x − mean(x))·istd`, `istd = rsqrt(var + eps)`) becomes a
5571 // portable `PatternNode` DATA recipe. Bind space: `0 = x`, `1 = g` (the
5572 // upstream gradient) — the order the autograd
5573 // `BackwardKind::Fused(LAYER_NORM_LAST_DIM_BACKWARD)` edge emits. The four
5574 // keepdim restores are the ratified D3 shrink-via-swap (`Reshape(keepdim)`
5575 // → `MeanDim(axis_last)` shrink + `Unsqueeze(axis_last = append)`,
5576 // node-TYPE change, numerically bit-exact); each broadcast targets
5577 // `SameAs { operand: 0 }` (x's full shape) over the Bind space (D2). One
5578 // `eps` open scalar slot. D4 never fires at equal rank (the `Unsqueeze`
5579 // rebuilds rank before every broadcast). RISK-A: `xhat`/`istd` carry the
5580 // eps slot, so emit's slot-free identity-share does NOT dedup them — see
5581 // `..._open_slot_xhat_is_recomputed_...` below.
5582 mod layer_norm_backward_recipe {
5583 use super::super::*;
5584 use super::frozen_legacy_layer_norm_backward_decompose;
5585 use crate::registry::{FusedOps, layer_norm_last_dim_backward};
5586 use fuel_ir::{DType, Shape};
5587 use std::collections::HashMap;
5588
5589 /// Tiny f64 reference interpreter over the layer-norm-backward
5590 /// primitive vocabulary (leaf-lookup FIRST, then elementwise
5591 /// `Mul`/`Sub`, unary `Sqr`/`Rsqrt`, `AddScalar`, last-axis `MeanDim`,
5592 /// metadata-only keepdim restore `Unsqueeze`/`Reshape`, last-dim
5593 /// `BroadcastTo`). BOTH parity sides run through it with identical
5594 /// in-order arithmetic, so a bit-exact assert isolates recipe STRUCTURE
5595 /// — neither the `Reshape`→`Unsqueeze` D3 swap NOR the open-slot
5596 /// `xhat`/`istd` recompute can perturb it (recompute is deterministic).
5597 fn eval_lnb(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
5598 if let Some(v) = leaves.get(&id) {
5599 return v.clone();
5600 }
5601 let node = g.node(id);
5602 match &node.op {
5603 Op::Mul => {
5604 let a = eval_lnb(g, node.inputs[0], leaves);
5605 let b = eval_lnb(g, node.inputs[1], leaves);
5606 a.iter().zip(&b).map(|(x, y)| x * y).collect()
5607 }
5608 Op::Sub => {
5609 let a = eval_lnb(g, node.inputs[0], leaves);
5610 let b = eval_lnb(g, node.inputs[1], leaves);
5611 a.iter().zip(&b).map(|(x, y)| x - y).collect()
5612 }
5613 Op::Sqr => eval_lnb(g, node.inputs[0], leaves)
5614 .iter()
5615 .map(|v| v * v)
5616 .collect(),
5617 Op::Rsqrt => eval_lnb(g, node.inputs[0], leaves)
5618 .iter()
5619 .map(|v| 1.0 / v.sqrt())
5620 .collect(),
5621 Op::AddScalar(e) => eval_lnb(g, node.inputs[0], leaves)
5622 .iter()
5623 .map(|v| v + e)
5624 .collect(),
5625 // Last-axis mean — rank-reducing; identical fold both spellings.
5626 Op::MeanDim(_) => {
5627 let input = eval_lnb(g, node.inputs[0], leaves);
5628 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
5629 input
5630 .chunks(last)
5631 .map(|row| row.iter().sum::<f64>() / last as f64)
5632 .collect()
5633 }
5634 // Metadata-only keepdim restores (the D3 swap and its legacy
5635 // twin evaluate identically here).
5636 Op::Unsqueeze { .. } | Op::Reshape(_) => eval_lnb(g, node.inputs[0], leaves),
5637 // Broadcast a keepdim `[.., 1]` tensor back along the last axis.
5638 Op::BroadcastTo(target) => {
5639 let input = eval_lnb(g, node.inputs[0], leaves);
5640 let out_n: usize = target.dims().iter().product();
5641 let last = *target.dims().last().unwrap();
5642 assert_eq!(
5643 input.len() * last,
5644 out_n,
5645 "broadcast is a last-dim repeat in these graphs",
5646 );
5647 input
5648 .iter()
5649 .flat_map(|&v| std::iter::repeat_n(v, last))
5650 .collect()
5651 }
5652 other => panic!("eval_lnb: unhandled op {other:?}"),
5653 }
5654 }
5655
5656 /// Build a fused LayerNormLastDimBackward node over `x [dims]` (input 0)
5657 /// and `g [dims]` (input 1, the upstream gradient), carrying `eps`.
5658 /// Returns `(x, g, fused)`.
5659 fn ln_backward_fused_node(
5660 g: &mut Graph,
5661 dims: &[usize],
5662 eps: f64,
5663 ) -> (NodeId, NodeId, NodeId) {
5664 let sh = Shape::from_dims(dims);
5665 let x = g.push(Node {
5666 op: Op::Const,
5667 inputs: vec![],
5668 shape: sh.clone(),
5669 dtype: DType::F32,
5670 });
5671 let up = g.push(Node {
5672 op: Op::Const,
5673 inputs: vec![],
5674 shape: sh.clone(),
5675 dtype: DType::F32,
5676 });
5677 let fused = g.push(Node {
5678 op: Op::Fused(
5679 FusedOps::LAYER_NORM_LAST_DIM_BACKWARD,
5680 FusedOpParams::LayerNormLastDimBackward { eps },
5681 ),
5682 inputs: vec![x, up],
5683 shape: sh,
5684 dtype: DType::F32,
5685 });
5686 (x, up, fused)
5687 }
5688
5689 /// S2-1 (a): ONE recipe datum decomposes at BOTH rank 2 and rank 3 (the
5690 /// polymorphism the baked-shape legacy body never had), and its
5691 /// numerics match the FROZEN legacy builder bit-exactly under the
5692 /// shared reference interpreter.
5693 #[test]
5694 fn layer_norm_backward_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
5695 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
5696 let mut g = Graph::new();
5697 let (x, up, fused) = ln_backward_fused_node(&mut g, &dims, 1e-5);
5698 let sh = Shape::from_dims(&dims);
5699 let new_root = layer_norm_last_dim_backward::decompose(
5700 &mut g,
5701 fused,
5702 &FusedOpParams::LayerNormLastDimBackward { eps: 1e-5 },
5703 );
5704 assert_ne!(new_root, fused, "recipe decompose must fire at {dims:?}");
5705 assert_eq!(
5706 g.node(new_root).shape,
5707 sh,
5708 "layer_norm backward is shape-preserving"
5709 );
5710 assert_eq!(g.node(new_root).dtype, DType::F32);
5711
5712 let legacy_root = frozen_legacy_layer_norm_backward_decompose(
5713 &mut g,
5714 fused,
5715 &FusedOpParams::LayerNormLastDimBackward { eps: 1e-5 },
5716 );
5717
5718 let n: usize = dims.iter().product();
5719 let x_data: Vec<f64> = (0..n)
5720 .map(|i| ((i as f64) * 0.29).sin() * 2.0 + 0.3)
5721 .collect();
5722 let g_data: Vec<f64> = (0..n)
5723 .map(|i| ((i as f64) * 0.53).cos() * 1.5 - 0.2)
5724 .collect();
5725 let mut leaves = HashMap::new();
5726 leaves.insert(x, x_data);
5727 leaves.insert(up, g_data);
5728 let got = eval_lnb(&g, new_root, &leaves);
5729 let want = eval_lnb(&g, legacy_root, &leaves);
5730 assert_eq!(got.len(), want.len());
5731 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
5732 assert_eq!(
5733 a.to_bits(),
5734 b.to_bits(),
5735 "layer_norm_backward[{i}] at {dims:?}: recipe={a} vs legacy={b}",
5736 );
5737 }
5738 }
5739 }
5740
5741 /// S2-1 (structural, born-red): every keepdim restore is the D3
5742 /// shrink-via-swap `Unsqueeze` append, NOT the baked `Reshape(keepdim)`
5743 /// — the crisp discriminator against the pre-migration imperative body
5744 /// (which emits `Reshape`, so this test is RED until migration). Zero
5745 /// `Reshape` also confirms no D4 pad fires (equal rank throughout). The
5746 /// backward root is the outer `Mul(istd, inner)`.
5747 #[test]
5748 fn layer_norm_backward_recipe_uses_the_unsqueeze_keepdim_swap() {
5749 let mut g = Graph::new();
5750 let (_x, _up, fused) = ln_backward_fused_node(&mut g, &[2, 4], 1e-5);
5751 let root = layer_norm_last_dim_backward::decompose(
5752 &mut g,
5753 fused,
5754 &FusedOpParams::LayerNormLastDimBackward { eps: 1e-5 },
5755 );
5756 assert_ne!(root, fused, "recipe decompose fires");
5757 assert!(
5758 matches!(g.node(root).op, Op::Mul),
5759 "backward root is the outer Mul(istd, inner)"
5760 );
5761 let reachable = crate::topo_order_multi(&g, &[root]);
5762 let unsqueezes = reachable
5763 .iter()
5764 .filter(|&&n| matches!(g.node(n).op, Op::Unsqueeze { .. }))
5765 .count();
5766 let reshapes = reachable
5767 .iter()
5768 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
5769 .count();
5770 assert!(
5771 unsqueezes >= 1,
5772 "keepdim restored via Unsqueeze append (D3 swap)"
5773 );
5774 assert_eq!(
5775 reshapes, 0,
5776 "no baked keepdim Reshape / no D4 pad after the D3 swap"
5777 );
5778 }
5779
5780 /// S2-1 (RISK-A): `xhat = Mul(xc, istd)` and `istd = Rsqrt(AddScalar[
5781 /// eps](...))` carry the eps OPEN slot, so emit's slot-free
5782 /// identity-share does NOT dedup them — the recipe emits them once per
5783 /// occurrence, yielding a STRICTLY HEAVIER base map than the legacy DAG
5784 /// (which shares them). This is the ACCEPTED redundancy documented on
5785 /// `recipe()` (numerically identical — deterministic recompute — and no
5786 /// open-slot-sharing emit extension is built in this slice). The
5787 /// downstream full-optimizer CSE re-collapses the duplicates, which this
5788 /// pins as the mitigation.
5789 #[test]
5790 fn layer_norm_backward_recipe_open_slot_xhat_is_recomputed_then_cse_recollapses() {
5791 let count_ops = |g: &Graph, root: NodeId| -> usize {
5792 crate::topo_order_multi(g, &[root])
5793 .iter()
5794 .filter(|&&n| !matches!(g.node(n).op, Op::Const))
5795 .count()
5796 };
5797
5798 // Recipe emission (base map — no fused ops remain to lower).
5799 let mut gr = Graph::new();
5800 let (_x, _up, fused) = ln_backward_fused_node(&mut gr, &[2, 4], 1e-5);
5801 let recipe_root = layer_norm_last_dim_backward::decompose(
5802 &mut gr,
5803 fused,
5804 &FusedOpParams::LayerNormLastDimBackward { eps: 1e-5 },
5805 );
5806 let recipe_n = count_ops(&gr, recipe_root);
5807
5808 // Legacy DAG (shares xhat/istd via let-bindings).
5809 let mut gl = Graph::new();
5810 let (_x2, _up2, fused2) = ln_backward_fused_node(&mut gl, &[2, 4], 1e-5);
5811 let legacy_root = frozen_legacy_layer_norm_backward_decompose(
5812 &mut gl,
5813 fused2,
5814 &FusedOpParams::LayerNormLastDimBackward { eps: 1e-5 },
5815 );
5816 let legacy_n = count_ops(&gl, legacy_root);
5817
5818 assert!(
5819 recipe_n > legacy_n,
5820 "RISK-A: emit's slot-free share does NOT dedup the eps-slot-carrying \
5821 xhat/istd — recipe base map {recipe_n} > legacy {legacy_n}",
5822 );
5823
5824 // Downstream full-optimizer CSE re-collapses the redundant recompute.
5825 let shared: crate::SharedGraph = std::sync::Arc::new(std::sync::RwLock::new(gr));
5826 let cse_roots = crate::opt::optimize(&shared, &[recipe_root]);
5827 let gr = shared.read().unwrap();
5828 let cse_n = count_ops(&gr, cse_roots[0]);
5829 assert!(
5830 cse_n < recipe_n,
5831 "downstream CSE re-collapses the redundant recompute: {cse_n} < {recipe_n}",
5832 );
5833 assert_eq!(
5834 cse_n, legacy_n,
5835 "CSE collapses the recipe base map back to the legacy DAG node count",
5836 );
5837 }
5838
5839 /// S2-1 (totality): a wrong params payload is a typed decline surfaced
5840 /// as a fixpoint (G2), never a panic, declining BEFORE any emission.
5841 #[test]
5842 fn layer_norm_backward_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
5843 let mut g = Graph::new();
5844 let (_x, _up, fused) = ln_backward_fused_node(&mut g, &[2, 4], 1e-5);
5845 let before = g.len();
5846 let out = layer_norm_last_dim_backward::decompose(&mut g, fused, &FusedOpParams::Rope);
5847 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
5848 assert_eq!(g.len(), before, "declined before any emission");
5849 }
5850 }
5851
5852 // rms_norm_last_dim_backward migration (Increment C carriers, A1) ---------
5853 //
5854 // The ~22-node imperative closed-form
5855 // `grad_x = r_rms · (g − x·s / (n·(mean_sq + eps)))` becomes a portable
5856 // `PatternNode` DATA recipe — the FIRST recipe to drive a SHAPE-DERIVED
5857 // scalar through live emit: `n = dims[last]` (the reduced_count) is a
5858 // `MulScalar(scalar_rel = Extent(0, LAST))`, resolved from x's shape at emit
5859 // (not baked, not a params slot). Sibling of the layer-norm-backward recipe:
5860 // `MeanDim(axis_last)` + `Unsqueeze(append)` keepdim (D3 swap) + broadcast to
5861 // `SameAs 0`; the `eps` `AddScalar` is an OPEN slot filled by the projection.
5862 // Binds: `0 = x`, `1 = upstream`. RISK-A: `denom_kd` (which carries the eps
5863 // slot) is consumed by BOTH the `Rsqrt` and the `MulScalar(n)`, so emit's
5864 // slot-free identity-share does NOT dedup it — the recipe emits it twice
5865 // (numerically identical deterministic recompute; the legacy DAG shared it),
5866 // and downstream CSE re-collapses. The parity oracle runs both sides through
5867 // a toy f64 interpreter (Reshape/Unsqueeze = metadata passthrough) so neither
5868 // the D3 swap nor the recompute can perturb the bit-exact assert.
5869 mod rms_norm_backward_recipe {
5870 use super::super::*;
5871 use super::frozen_legacy_rms_norm_backward_decompose;
5872 use crate::registry::{FusedOps, rms_norm_last_dim_backward};
5873 use fuel_ir::{DType, Shape};
5874 use std::collections::HashMap;
5875
5876 /// Tiny f64 reference interpreter over the rms-norm-backward primitive
5877 /// vocabulary (leaf-lookup FIRST, then elementwise `Mul`/`Sub`/`Div`,
5878 /// unary `Sqr`/`Rsqrt`, `AddScalar`/`MulScalar`, last-axis
5879 /// `MeanDim`/`SumDim`, metadata-only keepdim restore
5880 /// `Unsqueeze`/`Reshape`, last-dim `BroadcastTo`). BOTH parity sides run
5881 /// through it with identical in-order arithmetic, so a bit-exact assert
5882 /// isolates recipe STRUCTURE — neither the `Reshape`→`Unsqueeze` D3 swap
5883 /// NOR the open-slot `denom_kd` recompute can perturb it.
5884 fn eval_rnb(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
5885 if let Some(v) = leaves.get(&id) {
5886 return v.clone();
5887 }
5888 let node = g.node(id);
5889 match &node.op {
5890 Op::Mul => {
5891 let a = eval_rnb(g, node.inputs[0], leaves);
5892 let b = eval_rnb(g, node.inputs[1], leaves);
5893 a.iter().zip(&b).map(|(x, y)| x * y).collect()
5894 }
5895 Op::Sub => {
5896 let a = eval_rnb(g, node.inputs[0], leaves);
5897 let b = eval_rnb(g, node.inputs[1], leaves);
5898 a.iter().zip(&b).map(|(x, y)| x - y).collect()
5899 }
5900 Op::Div => {
5901 let a = eval_rnb(g, node.inputs[0], leaves);
5902 let b = eval_rnb(g, node.inputs[1], leaves);
5903 a.iter().zip(&b).map(|(x, y)| x / y).collect()
5904 }
5905 Op::Sqr => eval_rnb(g, node.inputs[0], leaves)
5906 .iter()
5907 .map(|v| v * v)
5908 .collect(),
5909 Op::Rsqrt => eval_rnb(g, node.inputs[0], leaves)
5910 .iter()
5911 .map(|v| 1.0 / v.sqrt())
5912 .collect(),
5913 Op::AddScalar(e) => eval_rnb(g, node.inputs[0], leaves)
5914 .iter()
5915 .map(|v| v + e)
5916 .collect(),
5917 Op::MulScalar(m) => eval_rnb(g, node.inputs[0], leaves)
5918 .iter()
5919 .map(|v| v * m)
5920 .collect(),
5921 // Last-axis reductions — rank-reducing; identical fold both
5922 // spellings.
5923 Op::MeanDim(_) => {
5924 let input = eval_rnb(g, node.inputs[0], leaves);
5925 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
5926 input
5927 .chunks(last)
5928 .map(|row| row.iter().sum::<f64>() / last as f64)
5929 .collect()
5930 }
5931 Op::SumDim(_) => {
5932 let input = eval_rnb(g, node.inputs[0], leaves);
5933 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
5934 input
5935 .chunks(last)
5936 .map(|row| row.iter().sum::<f64>())
5937 .collect()
5938 }
5939 // Metadata-only keepdim restores (the D3 swap and its legacy twin
5940 // evaluate identically here).
5941 Op::Unsqueeze { .. } | Op::Reshape(_) => eval_rnb(g, node.inputs[0], leaves),
5942 // Broadcast a keepdim `[.., 1]` tensor back along the last axis.
5943 Op::BroadcastTo(target) => {
5944 let input = eval_rnb(g, node.inputs[0], leaves);
5945 let out_n: usize = target.dims().iter().product();
5946 let last = *target.dims().last().unwrap();
5947 assert_eq!(
5948 input.len() * last,
5949 out_n,
5950 "broadcast is a last-dim repeat in these graphs",
5951 );
5952 input
5953 .iter()
5954 .flat_map(|&v| std::iter::repeat_n(v, last))
5955 .collect()
5956 }
5957 other => panic!("eval_rnb: unhandled op {other:?}"),
5958 }
5959 }
5960
5961 /// Build a fused RmsNormLastDimBackward node over `x [dims]` (input 0)
5962 /// and `g [dims]` (input 1, the upstream gradient), carrying `eps`.
5963 /// Returns `(x, g, fused)`.
5964 fn rms_backward_fused_node(
5965 g: &mut Graph,
5966 dims: &[usize],
5967 eps: f64,
5968 ) -> (NodeId, NodeId, NodeId) {
5969 let sh = Shape::from_dims(dims);
5970 let x = g.push(Node {
5971 op: Op::Const,
5972 inputs: vec![],
5973 shape: sh.clone(),
5974 dtype: DType::F32,
5975 });
5976 let up = g.push(Node {
5977 op: Op::Const,
5978 inputs: vec![],
5979 shape: sh.clone(),
5980 dtype: DType::F32,
5981 });
5982 let fused = g.push(Node {
5983 op: Op::Fused(
5984 FusedOps::RMS_NORM_LAST_DIM_BACKWARD,
5985 FusedOpParams::RmsNormLastDimBackward { eps },
5986 ),
5987 inputs: vec![x, up],
5988 shape: sh,
5989 dtype: DType::F32,
5990 });
5991 (x, up, fused)
5992 }
5993
5994 /// A1 (a): ONE recipe datum decomposes at BOTH rank 2 and rank 3 (the
5995 /// polymorphism the baked-shape legacy body never had — including the
5996 /// shape-derived `MulScalar(n)`), and its numerics match the FROZEN
5997 /// legacy builder bit-exactly under the shared reference interpreter.
5998 #[test]
5999 fn rms_norm_backward_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
6000 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
6001 let mut g = Graph::new();
6002 let (x, up, fused) = rms_backward_fused_node(&mut g, &dims, 1e-5);
6003 let sh = Shape::from_dims(&dims);
6004 let new_root = rms_norm_last_dim_backward::decompose(
6005 &mut g,
6006 fused,
6007 &FusedOpParams::RmsNormLastDimBackward { eps: 1e-5 },
6008 );
6009 assert_ne!(new_root, fused, "recipe decompose must fire at {dims:?}");
6010 assert_eq!(
6011 g.node(new_root).shape,
6012 sh,
6013 "rms_norm backward is shape-preserving"
6014 );
6015 assert_eq!(g.node(new_root).dtype, DType::F32);
6016
6017 let legacy_root = frozen_legacy_rms_norm_backward_decompose(
6018 &mut g,
6019 fused,
6020 &FusedOpParams::RmsNormLastDimBackward { eps: 1e-5 },
6021 );
6022
6023 let n: usize = dims.iter().product();
6024 let x_data: Vec<f64> = (0..n)
6025 .map(|i| ((i as f64) * 0.41).sin() * 2.0 + 0.7)
6026 .collect();
6027 let g_data: Vec<f64> = (0..n)
6028 .map(|i| ((i as f64) * 0.61).cos() * 1.3 - 0.15)
6029 .collect();
6030 let mut leaves = HashMap::new();
6031 leaves.insert(x, x_data);
6032 leaves.insert(up, g_data);
6033 let got = eval_rnb(&g, new_root, &leaves);
6034 let want = eval_rnb(&g, legacy_root, &leaves);
6035 assert_eq!(got.len(), want.len());
6036 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
6037 assert_eq!(
6038 a.to_bits(),
6039 b.to_bits(),
6040 "rms_norm_backward[{i}] at {dims:?}: recipe={a} vs legacy={b}",
6041 );
6042 }
6043 }
6044 }
6045
6046 /// A1 (structural, born-red): every keepdim restore is the D3
6047 /// shrink-via-swap `Unsqueeze` append, NOT the baked `Reshape(keepdim)`
6048 /// (the crisp discriminator against the pre-migration imperative body,
6049 /// which emits `Reshape` — so this is RED until migration), and the
6050 /// reduced-count divisor is a live `MulScalar(n = dims[last])` resolved
6051 /// from x's shape by the `scalar_rel` carrier (evidence the shape-derived
6052 /// scalar reached emit). The backward root is the outer `Mul(rrms, inner)`.
6053 #[test]
6054 fn rms_norm_backward_recipe_uses_the_unsqueeze_keepdim_swap_and_shape_scalar() {
6055 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
6056 let mut g = Graph::new();
6057 let (_x, _up, fused) = rms_backward_fused_node(&mut g, &dims, 1e-5);
6058 let root = rms_norm_last_dim_backward::decompose(
6059 &mut g,
6060 fused,
6061 &FusedOpParams::RmsNormLastDimBackward { eps: 1e-5 },
6062 );
6063 assert_ne!(root, fused, "recipe decompose fires at {dims:?}");
6064 assert!(
6065 matches!(g.node(root).op, Op::Mul),
6066 "backward root is the outer Mul(rrms, inner)"
6067 );
6068 let reachable = crate::topo_order_multi(&g, &[root]);
6069 let unsqueezes = reachable
6070 .iter()
6071 .filter(|&&n| matches!(g.node(n).op, Op::Unsqueeze { .. }))
6072 .count();
6073 let reshapes = reachable
6074 .iter()
6075 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
6076 .count();
6077 assert!(
6078 unsqueezes >= 1,
6079 "keepdim restored via Unsqueeze append (D3 swap) at {dims:?}"
6080 );
6081 assert_eq!(
6082 reshapes, 0,
6083 "no baked keepdim Reshape / no D4 pad after the D3 swap at {dims:?}"
6084 );
6085 // The reduced-count divisor resolved to n = dims[last] from x's
6086 // shape (the scalar_rel carrier), at whatever rank.
6087 let n = *dims.last().unwrap() as f64;
6088 let mul_scalars: Vec<f64> = reachable
6089 .iter()
6090 .filter_map(|&nid| match g.node(nid).op {
6091 Op::MulScalar(v) => Some(v),
6092 _ => None,
6093 })
6094 .collect();
6095 assert!(
6096 mul_scalars.contains(&n),
6097 "a MulScalar(n={n}) resolved from x's last-axis extent at {dims:?}, got {mul_scalars:?}",
6098 );
6099 }
6100 }
6101 }
6102
6103 // fused_linear migration (Increment C slice 2, S2-2) ----------------------
6104 //
6105 // The 3-node imperative `Add(MatMul(a, b), BroadcastTo(rank-1 bias))`
6106 // becomes a portable `PatternNode` DATA recipe, and the FIRST recipe to
6107 // drive `WithDim` through live emit: the bias broadcast targets
6108 // `WithDim { operand: 0, axis: LAST, dim: Extent { operand: 1, axis: LAST } }`
6109 // — a's shape with its LAST axis (K) replaced by b's LAST extent (N) = the
6110 // matmul output `[..batch, M, N]`. This is Fuel-INTERNAL shape resolution
6111 // (not §6.19 wire emission), so it is NOT gated on KISS #86. Binds:
6112 // `0 = a`, `1 = b`, `2 = bias`. The rank-1 bias rank-raises to the matmul
6113 // output, so emit materializes a leading-1 pad `Reshape` (D4) the imperative
6114 // body never had — the numeric-parity oracle below runs both sides through a
6115 // toy interpreter (Reshape = metadata passthrough) to isolate it. The
6116 // `canonical_pattern` see-through of that pad (RISK-C) + the lower→fuse
6117 // round-trip live in `registry/fused_linear.rs`'s own tests.
6118 mod fused_linear_recipe {
6119 use super::super::*;
6120 use super::frozen_legacy_fused_linear_decompose;
6121 use crate::registry::{FusedOps, fused_linear};
6122 use fuel_ir::{DType, Shape};
6123 use std::collections::HashMap;
6124
6125 /// Right-aligned NumPy broadcast of `input` (shape `in_shape`) to
6126 /// `target` — a size-1 or padded leading dim contributes stride 0.
6127 /// General on purpose: fused_linear's bias broadcast tiles along the
6128 /// LEADING `M`/batch dims (`[1,N] → [M,N]`), unlike the norm recipes'
6129 /// last-dim keepdim repeat.
6130 fn broadcast(input: &[f64], in_shape: &[usize], target: &[usize]) -> Vec<f64> {
6131 let rank = target.len();
6132 let pad = rank - in_shape.len();
6133 let mut real_strides = vec![0isize; in_shape.len()];
6134 let mut s = 1isize;
6135 for i in (0..in_shape.len()).rev() {
6136 real_strides[i] = s;
6137 s *= in_shape[i] as isize;
6138 }
6139 let mut in_strides = vec![0isize; rank];
6140 for (i, stride) in in_strides.iter_mut().enumerate() {
6141 if i >= pad {
6142 let id = i - pad;
6143 *stride = if in_shape[id] == 1 {
6144 0
6145 } else {
6146 real_strides[id]
6147 };
6148 }
6149 }
6150 let out_n: usize = target.iter().product();
6151 let mut out = Vec::with_capacity(out_n);
6152 let mut idx = vec![0usize; rank];
6153 for _ in 0..out_n {
6154 let fi: isize = (0..rank).map(|i| idx[i] as isize * in_strides[i]).sum();
6155 out.push(input[fi as usize]);
6156 for i in (0..rank).rev() {
6157 idx[i] += 1;
6158 if idx[i] < target[i] {
6159 break;
6160 }
6161 idx[i] = 0;
6162 }
6163 }
6164 out
6165 }
6166
6167 /// Deterministic batched f64 matmul `a[..batch, M, K] · b[..batch, K, N]`
6168 /// (same-rank ≥ 2, aligned batch). BOTH parity sides share the SAME
6169 /// `MatMul(a, b)` binds, so identical accumulation order ⇒ identical
6170 /// bits.
6171 fn matmul(a: &[f64], ash: &[usize], b: &[f64], bsh: &[usize]) -> Vec<f64> {
6172 let r = ash.len();
6173 let (m, k, n) = (ash[r - 2], ash[r - 1], bsh[r - 1]);
6174 let batch: usize = ash[..r - 2].iter().product();
6175 let (a_bs, b_bs, o_bs) = (m * k, k * n, m * n);
6176 let mut out = vec![0.0f64; batch * o_bs];
6177 for bi in 0..batch {
6178 for i in 0..m {
6179 for j in 0..n {
6180 let mut acc = 0.0f64;
6181 for kk in 0..k {
6182 acc += a[bi * a_bs + i * k + kk] * b[bi * b_bs + kk * n + j];
6183 }
6184 out[bi * o_bs + i * n + j] = acc;
6185 }
6186 }
6187 }
6188 out
6189 }
6190
6191 /// Tiny f64 reference interpreter over the fused-linear primitive
6192 /// vocabulary (leaf-lookup FIRST, then `MatMul`, metadata-only
6193 /// `Reshape` pad, right-aligned `BroadcastTo`, elementwise `Add`). BOTH
6194 /// parity sides run through it with identical arithmetic, so a bit-exact
6195 /// assert isolates recipe STRUCTURE — the D4 pad `Reshape` (recipe only)
6196 /// is a passthrough here.
6197 fn eval_fl(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
6198 if let Some(v) = leaves.get(&id) {
6199 return v.clone();
6200 }
6201 let node = g.node(id);
6202 match &node.op {
6203 Op::MatMul => {
6204 let a = eval_fl(g, node.inputs[0], leaves);
6205 let b = eval_fl(g, node.inputs[1], leaves);
6206 let ash: Vec<usize> = g.node(node.inputs[0]).shape.dims().to_vec();
6207 let bsh: Vec<usize> = g.node(node.inputs[1]).shape.dims().to_vec();
6208 matmul(&a, &ash, &b, &bsh)
6209 }
6210 // Metadata-only leading-1 pad (recipe's D4) — values unchanged.
6211 Op::Reshape(_) => eval_fl(g, node.inputs[0], leaves),
6212 Op::BroadcastTo(target) => {
6213 let input = eval_fl(g, node.inputs[0], leaves);
6214 let in_shape: Vec<usize> = g.node(node.inputs[0]).shape.dims().to_vec();
6215 broadcast(&input, &in_shape, target.dims())
6216 }
6217 Op::Add => {
6218 let a = eval_fl(g, node.inputs[0], leaves);
6219 let b = eval_fl(g, node.inputs[1], leaves);
6220 a.iter().zip(&b).map(|(x, y)| x + y).collect()
6221 }
6222 other => panic!("eval_fl: unhandled op {other:?}"),
6223 }
6224 }
6225
6226 /// Build a fused FusedLinear node over `a [a_dims]`, `b [b_dims]`, and a
6227 /// rank-1 `bias [N]` (N = b's last dim). Returns `(a, b, bias, fused)`.
6228 fn fused_linear_fused_node(
6229 g: &mut Graph,
6230 a_dims: &[usize],
6231 b_dims: &[usize],
6232 ) -> (NodeId, NodeId, NodeId, NodeId) {
6233 let ar = a_dims.len();
6234 let n = b_dims[b_dims.len() - 1];
6235 let mut out_dims = a_dims[..ar - 2].to_vec();
6236 out_dims.push(a_dims[ar - 2]);
6237 out_dims.push(n);
6238 let a = g.push(Node {
6239 op: Op::Const,
6240 inputs: vec![],
6241 shape: Shape::from_dims(a_dims),
6242 dtype: DType::F32,
6243 });
6244 let b = g.push(Node {
6245 op: Op::Const,
6246 inputs: vec![],
6247 shape: Shape::from_dims(b_dims),
6248 dtype: DType::F32,
6249 });
6250 let bias = g.push(Node {
6251 op: Op::Const,
6252 inputs: vec![],
6253 shape: Shape::from_dims(&[n]),
6254 dtype: DType::F32,
6255 });
6256 let fused = g.push(Node {
6257 op: Op::Fused(FusedOps::FUSED_LINEAR, FusedOpParams::FusedLinear),
6258 inputs: vec![a, b, bias],
6259 shape: Shape::from_dims(&out_dims),
6260 dtype: DType::F32,
6261 });
6262 (a, b, bias, fused)
6263 }
6264
6265 /// S2-2 (a): ONE recipe datum decomposes at BOTH rank-2 and rank-3
6266 /// (batched) activations — the WithDim-derived broadcast target tracks
6267 /// the matmul output shape — and its numerics match the FROZEN legacy
6268 /// builder bit-exactly under the shared reference interpreter.
6269 #[test]
6270 fn fused_linear_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
6271 for (a_dims, b_dims) in [
6272 (vec![2usize, 3], vec![3usize, 4]),
6273 (vec![2, 2, 3], vec![2, 3, 4]),
6274 ] {
6275 let mut g = Graph::new();
6276 let (a, b, bias, fused) = fused_linear_fused_node(&mut g, &a_dims, &b_dims);
6277 let out_sh = g.node(fused).shape.clone();
6278 let new_root = fused_linear::decompose(&mut g, fused, &FusedOpParams::FusedLinear);
6279 assert_ne!(new_root, fused, "recipe decompose fires at a={a_dims:?}");
6280 assert_eq!(
6281 g.node(new_root).shape,
6282 out_sh,
6283 "output = matmul output shape"
6284 );
6285 assert_eq!(g.node(new_root).dtype, DType::F32);
6286
6287 let legacy_root = frozen_legacy_fused_linear_decompose(
6288 &mut g,
6289 fused,
6290 &FusedOpParams::FusedLinear,
6291 );
6292
6293 let an: usize = a_dims.iter().product();
6294 let bn: usize = b_dims.iter().product();
6295 let bias_n = b_dims[b_dims.len() - 1];
6296 let a_data: Vec<f64> = (0..an)
6297 .map(|i| ((i as f64) * 0.31).sin() * 1.5 - 0.2)
6298 .collect();
6299 let b_data: Vec<f64> = (0..bn)
6300 .map(|i| ((i as f64) * 0.47).cos() * 0.8 + 0.1)
6301 .collect();
6302 let bias_data: Vec<f64> = (0..bias_n)
6303 .map(|i| ((i as f64) * 0.7).sin() * 2.0)
6304 .collect();
6305 let mut leaves = HashMap::new();
6306 leaves.insert(a, a_data);
6307 leaves.insert(b, b_data);
6308 leaves.insert(bias, bias_data);
6309 let got = eval_fl(&g, new_root, &leaves);
6310 let want = eval_fl(&g, legacy_root, &leaves);
6311 assert_eq!(got.len(), want.len());
6312 for (i, (x, y)) in got.iter().zip(want.iter()).enumerate() {
6313 assert_eq!(
6314 x.to_bits(),
6315 y.to_bits(),
6316 "fused_linear[{i}] a={a_dims:?}: recipe={x} vs legacy={y}",
6317 );
6318 }
6319 }
6320 }
6321
6322 /// S2-2 (structural, born-red): the WithDim-derived broadcast target
6323 /// rank-raises the rank-1 bias to the matmul output `[M,N]`, so emit
6324 /// materializes exactly one leading-1 pad `Reshape` (the D4 path, driven
6325 /// LIVE by WithDim). The imperative body broadcast the rank-1 bias
6326 /// directly (zero Reshape) — so this is RED until migration. The bias
6327 /// broadcast target equals the matmul output shape.
6328 #[test]
6329 fn fused_linear_recipe_drives_withdim_broadcast_with_a_d4_pad() {
6330 let mut g = Graph::new();
6331 let (_a, _b, _bias, fused) = fused_linear_fused_node(&mut g, &[2, 3], &[3, 4]);
6332 let root = fused_linear::decompose(&mut g, fused, &FusedOpParams::FusedLinear);
6333 assert_ne!(root, fused, "recipe decompose fires");
6334 assert!(
6335 matches!(g.node(root).op, Op::Add),
6336 "root is Add(mm, bias_bcst)"
6337 );
6338 let reachable = crate::topo_order_multi(&g, &[root]);
6339 let reshapes = reachable
6340 .iter()
6341 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
6342 .count();
6343 assert_eq!(
6344 reshapes, 1,
6345 "WithDim rank-raise materializes exactly one D4 pad Reshape on the bias",
6346 );
6347 let bcst = reachable
6348 .iter()
6349 .find(|&&n| matches!(g.node(n).op, Op::BroadcastTo(_)))
6350 .expect("bias broadcast present");
6351 assert_eq!(
6352 g.node(*bcst).shape.dims(),
6353 &[2, 4],
6354 "bias broadcast target = the matmul output shape (WithDim resolved)",
6355 );
6356 }
6357
6358 /// S2-2 (totality): a wrong params payload is a typed decline surfaced
6359 /// as a fixpoint (G2), never a panic, declining BEFORE any emission.
6360 #[test]
6361 fn fused_linear_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
6362 let mut g = Graph::new();
6363 let (_a, _b, _bias, fused) = fused_linear_fused_node(&mut g, &[2, 3], &[3, 4]);
6364 let before = g.len();
6365 let out = fused_linear::decompose(&mut g, fused, &FusedOpParams::Rope);
6366 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
6367 assert_eq!(g.len(), before, "declined before any emission");
6368 }
6369 }
6370
6371 /// Increment C, B1 — the `Op::Scan` structural re-emit machinery. These
6372 /// tests prove a hand-built `Op::Scan` recipe (an `OpTag::Scan` node whose
6373 /// body sub-graph — `ScanPlaceholder` holes + body ops — rides its trailing
6374 /// operands, per the Phase-1 lax.scan encoding) round-trips through
6375 /// `tag_to_op`/`emit` to a graph whose base map is bit-identical to the
6376 /// same scan built imperatively (the way `selective_scan::decompose` emits
6377 /// it). `Op::Scan` stays a base-map terminal — the recipe just RE-EMITS it.
6378 ///
6379 /// Structure-preserving: the recipe mirrors the imperative
6380 /// `unroll_scan`-shaped scan node exactly (same op types, same params, same
6381 /// body structure, same const-sharing via a shared `Bind`), so the
6382 /// `base_map_hash` equality is a structural-identity proof. Neither
6383 /// `Op::Scan` nor `Op::ScanPlaceholder` folds its own node shape into
6384 /// `base_map_hash` (op_key tags 210/211 fold only their params + the body
6385 /// via child recursion), so emit's fallback interior shapes are inert for
6386 /// the identity — the base map matches by construction.
6387 mod scan_recipe_roundtrip {
6388 use super::super::*;
6389 use super::{bind, op_node};
6390 use crate::opt::base_map_hash;
6391 use crate::{ScanEmit, ScanPredicate, ScanRole};
6392 use fuel_ir::storage::{OutputViewSpec, compose_bundle};
6393 use fuel_ir::{DType, Shape};
6394 use fuel_kernel_seam_types::{SCAN_ROLE_CARRY, SCAN_ROLE_ELEM};
6395 use std::sync::Arc;
6396
6397 fn placeholder(role: u8, index: u32) -> PatternNode {
6398 PatternNode::Op {
6399 op: OpTag::ScanPlaceholder,
6400 attrs: OpAttrs {
6401 scan_role: Some(role),
6402 scan_index: Some(index),
6403 ..OpAttrs::default()
6404 },
6405 operands: vec![],
6406 }
6407 }
6408
6409 /// The recipe: `scan(n_xs=1, bound=3, emit=All)` over
6410 /// `[init(B0), xs0(B1), const(B2), body_new_carry, body_y]` where
6411 /// `body_new_carry = Add(carry_hole, elem_hole)` and
6412 /// `body_y = Mul(body_new_carry, const)`. The `Add(carry,elem)` sub-tree
6413 /// appears in BOTH body exits — emit's slot-free identity-share collapses
6414 /// them to one node, matching the imperative `sum` referenced twice.
6415 fn scan_recipe() -> PatternNode {
6416 let sum = || {
6417 op_node(
6418 OpTag::Add,
6419 OpAttrs::default(),
6420 vec![
6421 placeholder(SCAN_ROLE_CARRY, 0),
6422 placeholder(SCAN_ROLE_ELEM, 0),
6423 ],
6424 )
6425 };
6426 PatternNode::Op {
6427 op: OpTag::Scan,
6428 attrs: OpAttrs {
6429 scan_n_carries: Some(1),
6430 scan_n_xs: Some(1),
6431 scan_bound: Some(3),
6432 scan_emit: Some(0), // All
6433 scan_early_exit: None,
6434 ..OpAttrs::default()
6435 },
6436 operands: vec![
6437 bind(0), // init_carry
6438 bind(1), // xs0
6439 bind(2), // const
6440 sum(), // body_new_carry
6441 op_node(OpTag::Mul, OpAttrs::default(), vec![sum(), bind(2)]), // body_y
6442 ],
6443 }
6444 }
6445
6446 /// Build the reference scan imperatively (the shape/layout
6447 /// `selective_scan::decompose` + `unroll_scan` assume) and return the
6448 /// scan NodeId. Leaves are unpopulated `Op::Const`s — same shape/dtype
6449 /// as the emitted recipe's, so they hash identically.
6450 fn reference_scan(g: &mut Graph) -> NodeId {
6451 let cs = Shape::from_dims(&[1]);
6452 let xs = Shape::from_dims(&[3, 1]);
6453 let init = g.push(Node {
6454 op: Op::Const,
6455 inputs: vec![],
6456 shape: cs.clone(),
6457 dtype: DType::F32,
6458 });
6459 let xs0 = g.push(Node {
6460 op: Op::Const,
6461 inputs: vec![],
6462 shape: xs,
6463 dtype: DType::F32,
6464 });
6465 let konst = g.push(Node {
6466 op: Op::Const,
6467 inputs: vec![],
6468 shape: cs.clone(),
6469 dtype: DType::F32,
6470 });
6471 let carry = g.push(Node {
6472 op: Op::ScanPlaceholder {
6473 role: ScanRole::Carry,
6474 index: 0,
6475 },
6476 inputs: vec![],
6477 shape: cs.clone(),
6478 dtype: DType::F32,
6479 });
6480 let elem = g.push(Node {
6481 op: Op::ScanPlaceholder {
6482 role: ScanRole::Elem,
6483 index: 0,
6484 },
6485 inputs: vec![],
6486 shape: cs.clone(),
6487 dtype: DType::F32,
6488 });
6489 let sum = g.push(Node {
6490 op: Op::Add,
6491 inputs: vec![carry, elem],
6492 shape: cs.clone(),
6493 dtype: DType::F32,
6494 });
6495 let y = g.push(Node {
6496 op: Op::Mul,
6497 inputs: vec![sum, konst],
6498 shape: cs.clone(),
6499 dtype: DType::F32,
6500 });
6501 g.push(Node {
6502 op: Op::Scan {
6503 n_carries: 1,
6504 n_xs: 1,
6505 bound: 3,
6506 emit: ScanEmit::All,
6507 early_exit: None,
6508 },
6509 inputs: vec![init, xs0, konst, sum, y],
6510 shape: Shape::from_dims(&[3, 1]),
6511 dtype: DType::F32,
6512 })
6513 }
6514
6515 /// `tag_to_op` reconstructs the `Op::Scan` params + `ScanPlaceholder`
6516 /// role/index verbatim from the carriers.
6517 #[test]
6518 fn tag_to_op_reconstructs_scan_and_placeholder() {
6519 let scan_attrs = OpAttrs {
6520 scan_n_carries: Some(1),
6521 scan_n_xs: Some(1),
6522 scan_bound: Some(3),
6523 scan_emit: Some(0),
6524 scan_early_exit: None,
6525 ..OpAttrs::default()
6526 };
6527 assert!(matches!(
6528 tag_to_op(OpTag::Scan, &scan_attrs),
6529 Some(Op::Scan {
6530 n_carries: 1,
6531 n_xs: 1,
6532 bound: 3,
6533 emit: ScanEmit::All,
6534 early_exit: None
6535 }),
6536 ));
6537 let fin = OpAttrs {
6538 scan_emit: Some(1),
6539 scan_early_exit: Some(true),
6540 ..scan_attrs.clone()
6541 };
6542 assert!(matches!(
6543 tag_to_op(OpTag::Scan, &fin),
6544 Some(Op::Scan {
6545 emit: ScanEmit::Final,
6546 early_exit: Some(ScanPredicate),
6547 ..
6548 }),
6549 ));
6550 // A scan node with no bound is an honest miss (unset required attr).
6551 assert!(tag_to_op(OpTag::Scan, &OpAttrs::default()).is_none());
6552 let carry = OpAttrs {
6553 scan_role: Some(SCAN_ROLE_CARRY),
6554 scan_index: Some(0),
6555 ..OpAttrs::default()
6556 };
6557 assert!(matches!(
6558 tag_to_op(OpTag::ScanPlaceholder, &carry),
6559 Some(Op::ScanPlaceholder {
6560 role: ScanRole::Carry,
6561 index: 0
6562 }),
6563 ));
6564 let elem = OpAttrs {
6565 scan_role: Some(SCAN_ROLE_ELEM),
6566 scan_index: Some(2),
6567 ..OpAttrs::default()
6568 };
6569 assert!(matches!(
6570 tag_to_op(OpTag::ScanPlaceholder, &elem),
6571 Some(Op::ScanPlaceholder {
6572 role: ScanRole::Elem,
6573 index: 2
6574 }),
6575 ));
6576 assert!(tag_to_op(OpTag::ScanPlaceholder, &OpAttrs::default()).is_none());
6577 }
6578
6579 /// The recipe is a valid, representable region: contiguous binds `[0,1,2]`
6580 /// and every op (incl. `Scan`/`ScanPlaceholder`) re-emits to a primitive.
6581 #[test]
6582 fn scan_recipe_validates_as_representable() {
6583 assert_eq!(scan_recipe().bind_indices(), vec![0, 1, 2]);
6584 assert!(
6585 validate_recipe(&scan_recipe()).is_ok(),
6586 "scan recipe is a total, representable region"
6587 );
6588 }
6589
6590 /// THE round-trip: emit the recipe onto fresh input leaves and assert its
6591 /// base map is bit-identical to the imperatively-built scan.
6592 #[test]
6593 fn scan_recipe_reemits_to_the_same_base_map_as_the_imperative_scan() {
6594 // Reference.
6595 let mut gr = Graph::new();
6596 let ref_scan = reference_scan(&mut gr);
6597 let want = base_map_hash(&gr, ref_scan);
6598
6599 // Emitted from the recipe onto identical leaves.
6600 let mut ge = Graph::new();
6601 let cs = Shape::from_dims(&[1]);
6602 let init = ge.push(Node {
6603 op: Op::Const,
6604 inputs: vec![],
6605 shape: cs.clone(),
6606 dtype: DType::F32,
6607 });
6608 let xs0 = ge.push(Node {
6609 op: Op::Const,
6610 inputs: vec![],
6611 shape: Shape::from_dims(&[3, 1]),
6612 dtype: DType::F32,
6613 });
6614 let konst = ge.push(Node {
6615 op: Op::Const,
6616 inputs: vec![],
6617 shape: cs,
6618 dtype: DType::F32,
6619 });
6620 let root = emit_region(&mut ge, &scan_recipe(), &[init, xs0, konst], &[]);
6621 assert!(
6622 matches!(
6623 ge.node(root).op,
6624 Op::Scan {
6625 n_carries: 1,
6626 n_xs: 1,
6627 bound: 3,
6628 emit: ScanEmit::All,
6629 early_exit: None
6630 }
6631 ),
6632 "recipe re-emits an Op::Scan terminal"
6633 );
6634 let got = base_map_hash(&ge, root);
6635
6636 assert_eq!(
6637 got, want,
6638 "scan recipe re-emit base map == imperative scan base map"
6639 );
6640 }
6641
6642 // ---- The View / output_views bundle half (B1) ----------------------
6643 //
6644 // A scan recipe whose root is an `Op::View{slot}` must re-emit the scan,
6645 // attach the 2-slot `output_views` bundle (slot 0 = stacked ys, slot 1 =
6646 // final carry — the SelectiveScan bundle contract), and project the
6647 // slot — round-tripping to the same base map AND the correct slot shape
6648 // as the imperative scan+view. Real shapes throughout (init/const via
6649 // Binds carry real shapes; `body_y = Mul(const, sum)` takes the const's
6650 // shape) so the emitted bundle is byte-faithful.
6651
6652 /// `view(slot)(scan(n_xs=1, bound=3, emit=All))` over binds `[init[2],
6653 /// xs0[3,2], const[2]]`. `body_y = Mul(const, sum)` is const-first so its
6654 /// re-emitted shape is `[2]` (the const's), making slot 0 = `[3,2]` and
6655 /// slot 1 = `[2]`.
6656 fn view_scan_recipe(slot: u32) -> PatternNode {
6657 let sum = || {
6658 op_node(
6659 OpTag::Add,
6660 OpAttrs::default(),
6661 vec![
6662 placeholder(SCAN_ROLE_CARRY, 0),
6663 placeholder(SCAN_ROLE_ELEM, 0),
6664 ],
6665 )
6666 };
6667 let scan = PatternNode::Op {
6668 op: OpTag::Scan,
6669 attrs: OpAttrs {
6670 scan_n_carries: Some(1),
6671 scan_n_xs: Some(1),
6672 scan_bound: Some(3),
6673 scan_emit: Some(0),
6674 scan_early_exit: None,
6675 ..OpAttrs::default()
6676 },
6677 operands: vec![
6678 bind(0), // init_carry [2]
6679 bind(1), // xs0 [3,2]
6680 bind(2), // const [2]
6681 sum(), // body_new_carry
6682 op_node(OpTag::Mul, OpAttrs::default(), vec![bind(2), sum()]), // body_y = Mul(const, sum)
6683 ],
6684 };
6685 op_node(
6686 OpTag::View,
6687 OpAttrs {
6688 view_slot: Some(slot),
6689 ..OpAttrs::default()
6690 },
6691 vec![scan],
6692 )
6693 }
6694
6695 /// The imperative reference: the scan + its composed 2-slot bundle + the
6696 /// slot projection (mirrors `NodeHandle::scan` + `Graph::view`).
6697 fn reference_view(g: &mut Graph, slot: u32) -> NodeId {
6698 let two = Shape::from_dims(&[2]);
6699 let init = g.push(Node {
6700 op: Op::Const,
6701 inputs: vec![],
6702 shape: two.clone(),
6703 dtype: DType::F32,
6704 });
6705 let xs0 = g.push(Node {
6706 op: Op::Const,
6707 inputs: vec![],
6708 shape: Shape::from_dims(&[3, 2]),
6709 dtype: DType::F32,
6710 });
6711 let konst = g.push(Node {
6712 op: Op::Const,
6713 inputs: vec![],
6714 shape: two.clone(),
6715 dtype: DType::F32,
6716 });
6717 let carry = g.push(Node {
6718 op: Op::ScanPlaceholder {
6719 role: ScanRole::Carry,
6720 index: 0,
6721 },
6722 inputs: vec![],
6723 shape: two.clone(),
6724 dtype: DType::F32,
6725 });
6726 let elem = g.push(Node {
6727 op: Op::ScanPlaceholder {
6728 role: ScanRole::Elem,
6729 index: 0,
6730 },
6731 inputs: vec![],
6732 shape: two.clone(),
6733 dtype: DType::F32,
6734 });
6735 let sum = g.push(Node {
6736 op: Op::Add,
6737 inputs: vec![carry, elem],
6738 shape: two.clone(),
6739 dtype: DType::F32,
6740 });
6741 let body_y = g.push(Node {
6742 op: Op::Mul,
6743 inputs: vec![konst, sum],
6744 shape: two.clone(),
6745 dtype: DType::F32,
6746 });
6747 let ys = Shape::from_dims(&[3, 2]);
6748 let scan = g.push(Node {
6749 op: Op::Scan {
6750 n_carries: 1,
6751 n_xs: 1,
6752 bound: 3,
6753 emit: ScanEmit::All,
6754 early_exit: None,
6755 },
6756 inputs: vec![init, xs0, konst, sum, body_y],
6757 shape: ys.clone(),
6758 dtype: DType::F32,
6759 });
6760 let specs = vec![
6761 OutputViewSpec::contiguous(DType::F32, ys),
6762 OutputViewSpec::contiguous(DType::F32, two),
6763 ];
6764 let (_bytes, views) = compose_bundle(&specs).expect("compose_bundle");
6765 g.set_output_views(scan, Arc::from(views.into_boxed_slice()))
6766 .expect("set_output_views");
6767 let (sh, dt) = {
6768 let v = g.output_views(scan).expect("bundle set");
6769 let s = &v[slot as usize];
6770 (s.shape.clone(), s.dtype)
6771 };
6772 g.push(Node {
6773 op: Op::View { slot },
6774 inputs: vec![scan],
6775 shape: sh,
6776 dtype: dt,
6777 })
6778 }
6779
6780 fn roundtrip_view(slot: u32, want_shape: &[usize]) {
6781 // Reference.
6782 let mut gr = Graph::new();
6783 let ref_view = reference_view(&mut gr, slot);
6784 let want = base_map_hash(&gr, ref_view);
6785
6786 // Emitted from the recipe onto identical leaves.
6787 let mut ge = Graph::new();
6788 let two = Shape::from_dims(&[2]);
6789 let init = ge.push(Node {
6790 op: Op::Const,
6791 inputs: vec![],
6792 shape: two.clone(),
6793 dtype: DType::F32,
6794 });
6795 let xs0 = ge.push(Node {
6796 op: Op::Const,
6797 inputs: vec![],
6798 shape: Shape::from_dims(&[3, 2]),
6799 dtype: DType::F32,
6800 });
6801 let konst = ge.push(Node {
6802 op: Op::Const,
6803 inputs: vec![],
6804 shape: two,
6805 dtype: DType::F32,
6806 });
6807 let root = emit_region(&mut ge, &view_scan_recipe(slot), &[init, xs0, konst], &[]);
6808
6809 // The View projects the requested slot with the bundle-derived shape.
6810 assert!(
6811 matches!(ge.node(root).op, Op::View { slot: s } if s == slot),
6812 "recipe root re-emits Op::View{{slot={slot}}}"
6813 );
6814 assert_eq!(
6815 ge.node(root).shape.dims(),
6816 want_shape,
6817 "view slot {slot} shape comes from the re-attached output_views bundle"
6818 );
6819 // The producing scan carries the faithful 2-slot bundle.
6820 let producer = ge.node(root).inputs[0];
6821 assert!(
6822 matches!(ge.node(producer).op, Op::Scan { .. }),
6823 "producer is the scan"
6824 );
6825 assert_eq!(
6826 ge.output_views(producer).map(|v| v.len()),
6827 Some(2),
6828 "emit re-attaches the 2-slot bundle so slot>=1 views resolve"
6829 );
6830
6831 let got = base_map_hash(&ge, root);
6832 assert_eq!(
6833 got, want,
6834 "view+scan recipe re-emit base map == imperative scan+view base map"
6835 );
6836 }
6837
6838 /// Slot 0 (stacked ys): its shape equals the scan node's primary shape,
6839 /// so even the child-fallback would find it — but it must come through
6840 /// the bundle for the layout/offset to be right.
6841 #[test]
6842 fn scan_view_slot0_reemits_to_the_same_base_map() {
6843 roundtrip_view(0, &[3, 2]);
6844 }
6845
6846 /// Slot 1 (final carry / last_state) is the load-bearing case: its shape
6847 /// (`[2]`) lives ONLY in the bundle and differs from the scan's primary
6848 /// (`[3,2]`). `op_key(View) = None` folds the node shape into the hash,
6849 /// so a wrong slot-1 shape would break the round-trip — this proves the
6850 /// bundle is re-attached and read.
6851 #[test]
6852 fn scan_view_slot1_reemits_to_the_same_base_map() {
6853 roundtrip_view(1, &[2]);
6854 }
6855
6856 /// A view recipe validates as a representable region (contiguous binds,
6857 /// every op — incl. `View`/`Scan`/`ScanPlaceholder` — re-emittable).
6858 #[test]
6859 fn view_scan_recipe_validates_as_representable() {
6860 assert_eq!(view_scan_recipe(1).bind_indices(), vec![0, 1, 2]);
6861 assert!(validate_recipe(&view_scan_recipe(1)).is_ok());
6862 // tag_to_op reconstructs the View op from `view_slot`.
6863 assert!(matches!(
6864 tag_to_op(
6865 OpTag::View,
6866 &OpAttrs {
6867 view_slot: Some(1),
6868 ..OpAttrs::default()
6869 }
6870 ),
6871 Some(Op::View { slot: 1 }),
6872 ));
6873 assert!(tag_to_op(OpTag::View, &OpAttrs::default()).is_none());
6874 }
6875
6876 /// B2 emit carrier (born-red without it): a `ScanPlaceholder` that
6877 /// declares its per-step shape via `target_shape_rel` emits with THAT
6878 /// shape, not the rank-0 `primitive_shape` fallback. This is
6879 /// load-bearing for the SSM scan recipes — `unroll_scan` clones body
6880 /// interior nodes with their STORED shapes, so a rank-0 placeholder
6881 /// would poison the unrolled body (e.g. `du = Mul(d_t, u_t)` rank-0).
6882 /// Dtype comes from bind 0 (the uniform-dtype scan inputs).
6883 #[test]
6884 fn scan_placeholder_recipe_emits_its_declared_shape() {
6885 use fuel_kernel_seam_types::shape_expr::{Dim, ShapeExpr};
6886 let dims2 = || {
6887 ShapeExpr::Dims(vec![
6888 Dim::Extent {
6889 operand: 0,
6890 axis: 0,
6891 },
6892 Dim::Extent {
6893 operand: 0,
6894 axis: 1,
6895 },
6896 ])
6897 };
6898 let ph = |index: u32| PatternNode::Op {
6899 op: OpTag::ScanPlaceholder,
6900 attrs: OpAttrs {
6901 scan_role: Some(SCAN_ROLE_ELEM),
6902 scan_index: Some(index),
6903 target_shape_rel: Some(dims2()),
6904 ..OpAttrs::default()
6905 },
6906 operands: vec![],
6907 };
6908 // Mul takes operand[0]'s shape, so its shape mirrors the placeholder.
6909 // A `Bind { 0 }` operand supplies the frame the placeholder's Dims
6910 // reference (operand 0) — so the recipe carries a valid bind.
6911 let recipe = op_node(OpTag::Mul, OpAttrs::default(), vec![ph(0), bind(0)]);
6912 assert!(validate_recipe(&recipe).is_ok());
6913 let mut g = Graph::new();
6914 let bind0 = g.push(Node {
6915 op: Op::Const,
6916 inputs: vec![],
6917 shape: Shape::from_dims(&[2, 3]),
6918 dtype: DType::F32,
6919 });
6920 let root = emit_region(&mut g, &recipe, &[bind0], &[]);
6921 let ph_id = g.node(root).inputs[0];
6922 assert!(matches!(
6923 g.node(ph_id).op,
6924 Op::ScanPlaceholder {
6925 role: ScanRole::Elem,
6926 index: 0
6927 }
6928 ));
6929 assert_eq!(
6930 g.node(ph_id).shape.dims(),
6931 &[2, 3],
6932 "placeholder emits its declared target_shape_rel shape, not rank-0",
6933 );
6934 assert_eq!(
6935 g.node(ph_id).dtype,
6936 DType::F32,
6937 "placeholder dtype = bind 0's"
6938 );
6939 assert_eq!(
6940 g.node(root).shape.dims(),
6941 &[2, 3],
6942 "Mul(placeholder,..) inherits the declared shape"
6943 );
6944 }
6945 }
6946
6947 // reduce_max_to_backward migration (Increment C carriers, A2) -------------
6948 //
6949 // The 9-node imperative fair-share max subgradient (ReduceMaxTo→BroadcastTo→
6950 // Equal(U8)→MaskedFill→ReduceSumTo→Div→BroadcastTo→Mul, sharing the single
6951 // `mask_f`) becomes a portable `PatternNode` DATA recipe, and the FIRST recipe
6952 // to carry a `MaskedFill` — driven LIVE by the A2 fill-Scalar re-emit carrier
6953 // (the fill dtype re-resolves to x's dtype, matching `Scalar::one(dtype)`).
6954 // A DIRECT structural mirror (no D3 keepdim swap, no D4 pad), so the parity
6955 // oracle runs both sides through a toy f64 interpreter and asserts BIT-EXACT
6956 // structure — the discriminating power is confirmed by the sabotage-calibrated
6957 // negative in `reduce_max_to_backward_recipe_shares_mask_and_has_no_reshape`
6958 // (identity-share ⇒ ONE MaskedFill node across its two uses). Binds:
6959 // `0 = x`, `1 = up`. The reduce/count targets are `SameAs 1` (upstream's
6960 // shape); the broadcasts are `SameAs 0` (x's shape).
6961 mod reduce_max_to_backward_recipe {
6962 use super::super::*;
6963 use super::frozen_legacy_reduce_max_to_backward_decompose;
6964 use crate::registry::{FusedOps, reduce_max_to_backward};
6965 use fuel_ir::{DType, Shape};
6966 use std::collections::HashMap;
6967
6968 /// Right-aligned NumPy broadcast of `input` (shape `in_shape`) to
6969 /// `target` — a size-1 or padded leading dim contributes stride 0. General
6970 /// on purpose (both `bcast_x` sites tile a `[..,1]` keepdim back to x).
6971 fn broadcast(input: &[f64], in_shape: &[usize], target: &[usize]) -> Vec<f64> {
6972 let rank = target.len();
6973 let pad = rank - in_shape.len();
6974 let mut real_strides = vec![0isize; in_shape.len()];
6975 let mut s = 1isize;
6976 for i in (0..in_shape.len()).rev() {
6977 real_strides[i] = s;
6978 s *= in_shape[i] as isize;
6979 }
6980 let mut in_strides = vec![0isize; rank];
6981 for (i, stride) in in_strides.iter_mut().enumerate() {
6982 if i >= pad {
6983 let id = i - pad;
6984 *stride = if in_shape[id] == 1 {
6985 0
6986 } else {
6987 real_strides[id]
6988 };
6989 }
6990 }
6991 let out_n: usize = target.iter().product();
6992 let mut out = Vec::with_capacity(out_n);
6993 let mut idx = vec![0usize; rank];
6994 for _ in 0..out_n {
6995 let fi: isize = (0..rank).map(|i| idx[i] as isize * in_strides[i]).sum();
6996 out.push(input[fi as usize]);
6997 for i in (0..rank).rev() {
6998 idx[i] += 1;
6999 if idx[i] < target[i] {
7000 break;
7001 }
7002 idx[i] = 0;
7003 }
7004 }
7005 out
7006 }
7007
7008 /// Tiny f64 reference interpreter over the reduce-max-backward primitive
7009 /// vocabulary (leaf-lookup FIRST, then elementwise `Mul`/`Div`,
7010 /// `MulScalar`, `Equal` (1.0/0.0), the `MaskedFill` fill from the op's
7011 /// `Scalar`, last-axis `ReduceMaxTo`/`ReduceSumTo`, right-aligned
7012 /// `BroadcastTo`). BOTH parity sides run through it with identical in-order
7013 /// arithmetic, so a bit-exact assert isolates recipe STRUCTURE. Test dims
7014 /// reduce the LAST axis (`up = [.., 1]`), so the reduces are a last-axis
7015 /// chunk fold.
7016 fn eval_rmb(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
7017 if let Some(v) = leaves.get(&id) {
7018 return v.clone();
7019 }
7020 let node = g.node(id);
7021 match &node.op {
7022 Op::Mul => {
7023 let a = eval_rmb(g, node.inputs[0], leaves);
7024 let b = eval_rmb(g, node.inputs[1], leaves);
7025 a.iter().zip(&b).map(|(x, y)| x * y).collect()
7026 }
7027 Op::Div => {
7028 let a = eval_rmb(g, node.inputs[0], leaves);
7029 let b = eval_rmb(g, node.inputs[1], leaves);
7030 a.iter().zip(&b).map(|(x, y)| x / y).collect()
7031 }
7032 Op::MulScalar(m) => eval_rmb(g, node.inputs[0], leaves)
7033 .iter()
7034 .map(|v| v * m)
7035 .collect(),
7036 Op::Equal => {
7037 let a = eval_rmb(g, node.inputs[0], leaves);
7038 let b = eval_rmb(g, node.inputs[1], leaves);
7039 a.iter()
7040 .zip(&b)
7041 .map(|(x, y)| if x == y { 1.0 } else { 0.0 })
7042 .collect()
7043 }
7044 Op::MaskedFill { value } => {
7045 let input = eval_rmb(g, node.inputs[0], leaves);
7046 let mask = eval_rmb(g, node.inputs[1], leaves);
7047 let fill = value.to_f64();
7048 input
7049 .iter()
7050 .zip(&mask)
7051 .map(|(x, m)| if *m != 0.0 { fill } else { *x })
7052 .collect()
7053 }
7054 // Last-axis reduce-to-shape (up = [.., 1]); identical fold both
7055 // parity sides.
7056 Op::ReduceMaxTo(_) => {
7057 let input = eval_rmb(g, node.inputs[0], leaves);
7058 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
7059 input
7060 .chunks(last)
7061 .map(|row| row.iter().cloned().fold(f64::NEG_INFINITY, f64::max))
7062 .collect()
7063 }
7064 Op::ReduceSumTo(_) => {
7065 let input = eval_rmb(g, node.inputs[0], leaves);
7066 let last = *g.node(node.inputs[0]).shape.dims().last().unwrap();
7067 input
7068 .chunks(last)
7069 .map(|row| row.iter().sum::<f64>())
7070 .collect()
7071 }
7072 Op::BroadcastTo(target) => {
7073 let input = eval_rmb(g, node.inputs[0], leaves);
7074 let in_shape: Vec<usize> = g.node(node.inputs[0]).shape.dims().to_vec();
7075 broadcast(&input, &in_shape, target.dims())
7076 }
7077 other => panic!("eval_rmb: unhandled op {other:?}"),
7078 }
7079 }
7080
7081 /// Build a fused ReduceMaxToBackward node over `x [x_dims]` (input 0) and
7082 /// `up [up_dims]` (input 1, the upstream gradient = the forward reduce
7083 /// target). Returns `(x, up, fused)`.
7084 fn rmb_fused_node(
7085 g: &mut Graph,
7086 x_dims: &[usize],
7087 up_dims: &[usize],
7088 ) -> (NodeId, NodeId, NodeId) {
7089 let xsh = Shape::from_dims(x_dims);
7090 let x = g.push(Node {
7091 op: Op::Const,
7092 inputs: vec![],
7093 shape: xsh.clone(),
7094 dtype: DType::F32,
7095 });
7096 let up = g.push(Node {
7097 op: Op::Const,
7098 inputs: vec![],
7099 shape: Shape::from_dims(up_dims),
7100 dtype: DType::F32,
7101 });
7102 let fused = g.push(Node {
7103 op: Op::Fused(
7104 FusedOps::REDUCE_MAX_TO_BACKWARD,
7105 FusedOpParams::ReduceMaxToBackward,
7106 ),
7107 inputs: vec![x, up],
7108 shape: xsh,
7109 dtype: DType::F32,
7110 });
7111 (x, up, fused)
7112 }
7113
7114 /// A2 (a): ONE recipe datum decomposes at BOTH rank 2 and rank 3 (the
7115 /// shape polymorphism the baked-shape legacy body never had), and its
7116 /// numerics match the FROZEN legacy builder bit-exactly under the shared
7117 /// reference interpreter — the MaskedFill carrier driving live emission.
7118 #[test]
7119 fn reduce_max_to_backward_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
7120 for (x_dims, up_dims) in [
7121 (vec![2usize, 4], vec![2usize, 1]),
7122 (vec![3, 5, 7], vec![3, 5, 1]),
7123 ] {
7124 let mut g = Graph::new();
7125 let (x, up, fused) = rmb_fused_node(&mut g, &x_dims, &up_dims);
7126 let xsh = Shape::from_dims(&x_dims);
7127 let new_root = reduce_max_to_backward::decompose(
7128 &mut g,
7129 fused,
7130 &FusedOpParams::ReduceMaxToBackward,
7131 );
7132 assert_ne!(new_root, fused, "recipe decompose must fire at {x_dims:?}");
7133 assert_eq!(
7134 g.node(new_root).shape,
7135 xsh,
7136 "reduce_max backward is x-shaped"
7137 );
7138 assert_eq!(g.node(new_root).dtype, DType::F32);
7139
7140 let legacy_root = frozen_legacy_reduce_max_to_backward_decompose(
7141 &mut g,
7142 fused,
7143 &FusedOpParams::ReduceMaxToBackward,
7144 );
7145
7146 let xn: usize = x_dims.iter().product();
7147 let upn: usize = up_dims.iter().product();
7148 let x_data: Vec<f64> = (0..xn)
7149 .map(|i| ((i as f64) * 0.37).sin() * 2.0 + 0.3)
7150 .collect();
7151 let up_data: Vec<f64> = (0..upn)
7152 .map(|i| ((i as f64) * 0.53).cos() * 1.1 - 0.2)
7153 .collect();
7154 let mut leaves = HashMap::new();
7155 leaves.insert(x, x_data);
7156 leaves.insert(up, up_data);
7157 let got = eval_rmb(&g, new_root, &leaves);
7158 let want = eval_rmb(&g, legacy_root, &leaves);
7159 assert_eq!(got.len(), want.len());
7160 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
7161 assert_eq!(
7162 a.to_bits(),
7163 b.to_bits(),
7164 "reduce_max_to_backward[{i}] at {x_dims:?}: recipe={a} vs legacy={b}",
7165 );
7166 }
7167 }
7168 }
7169
7170 /// A2 (structural): the single `mask_f` is IDENTITY-SHARED across its two
7171 /// use sites (`ReduceSumTo` and the final `Mul`) — exactly ONE MaskedFill
7172 /// node reachable, the same single-compute DAG the imperative body had (a
7173 /// duplicated emit would break this). A direct structural mirror: no D3/D4
7174 /// `Reshape`, and the MaskedFill fill Scalar re-resolved to x's (F32)
7175 /// dtype (evidence the A2 carrier reached emit).
7176 #[test]
7177 fn reduce_max_to_backward_recipe_shares_mask_and_has_no_reshape() {
7178 for (x_dims, up_dims) in [
7179 (vec![2usize, 4], vec![2usize, 1]),
7180 (vec![3, 5, 7], vec![3, 5, 1]),
7181 ] {
7182 let mut g = Graph::new();
7183 let (_x, _up, fused) = rmb_fused_node(&mut g, &x_dims, &up_dims);
7184 let root = reduce_max_to_backward::decompose(
7185 &mut g,
7186 fused,
7187 &FusedOpParams::ReduceMaxToBackward,
7188 );
7189 assert_ne!(root, fused, "recipe decompose fires at {x_dims:?}");
7190 assert!(
7191 matches!(g.node(root).op, Op::Mul),
7192 "root is Mul(mask_f, share_b)"
7193 );
7194 let reachable = crate::topo_order_multi(&g, &[root]);
7195 let masked = reachable
7196 .iter()
7197 .filter(|&&n| matches!(g.node(n).op, Op::MaskedFill { .. }))
7198 .count();
7199 assert_eq!(
7200 masked, 1,
7201 "mask_f identity-shared across its two uses at {x_dims:?} (ONE MaskedFill)",
7202 );
7203 let reshapes = reachable
7204 .iter()
7205 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
7206 .count();
7207 assert_eq!(
7208 reshapes, 0,
7209 "direct structural mirror — no D3/D4 Reshape at {x_dims:?}"
7210 );
7211 let fill_dtype = reachable
7212 .iter()
7213 .find_map(|&n| match &g.node(n).op {
7214 Op::MaskedFill { value } => Some(value.dtype()),
7215 _ => None,
7216 })
7217 .expect("a MaskedFill is present");
7218 assert_eq!(
7219 fill_dtype,
7220 DType::F32,
7221 "the A2 carrier re-resolved the fill Scalar to x's dtype at {x_dims:?}",
7222 );
7223 }
7224 }
7225
7226 /// A2 (totality): a wrong params payload is a typed decline surfaced as a
7227 /// fixpoint (G2), never a panic, declining BEFORE any emission.
7228 #[test]
7229 fn reduce_max_to_backward_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
7230 let mut g = Graph::new();
7231 let (_x, _up, fused) = rmb_fused_node(&mut g, &[2, 4], &[2, 1]);
7232 let before = g.len();
7233 let out = reduce_max_to_backward::decompose(&mut g, fused, &FusedOpParams::Rope);
7234 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
7235 assert_eq!(g.len(), before, "declined before any emission");
7236 }
7237 }
7238
7239 // powi_backward migration (Increment C carriers, A3) ----------------------
7240 //
7241 // The 3-node imperative closed-form gradient `grad_x = exp · x^(exp-1) ·
7242 // upstream` (PowI(exp-1)→MulScalar(exp)→Mul) becomes a portable `PatternNode`
7243 // DATA recipe, and the FIRST recipe to carry a `PowI` — driven LIVE by the A3
7244 // i32-exponent re-emit carrier (the exponent rides scalars[0]) — and the first
7245 // whose STRUCTURE is param-derived (both `exp` and `exp-1` are constant-folded
7246 // into the datum at build time, the minimal "param-derived const in the
7247 // recipe" C-4 posture, NOT a restructure to dodge the missing thread). A
7248 // DIRECT structural mirror (no D3 keepdim swap, no D4 pad), so the parity
7249 // oracle runs both sides through a toy f64 interpreter and asserts BIT-EXACT
7250 // structure across ranks AND exponents. Binds: `0 = x`, `1 = up`.
7251 mod powi_backward_recipe {
7252 use super::super::*;
7253 use super::frozen_legacy_powi_backward_decompose;
7254 use crate::registry::{FusedOps, powi_backward};
7255 use fuel_ir::{DType, Shape};
7256 use std::collections::HashMap;
7257
7258 /// Tiny f64 reference interpreter over the powi-backward primitive
7259 /// vocabulary (leaf-lookup FIRST, then elementwise `Mul`, `MulScalar`,
7260 /// and integer-power `PowI` via `f64::powi`). BOTH parity sides run
7261 /// through it with identical in-order arithmetic, so a bit-exact assert
7262 /// isolates recipe STRUCTURE — the exponent transported by the A3 carrier
7263 /// and the baked `MulScalar`. `x` and `up` are the same shape (`PowI` is
7264 /// elementwise), so every op is a flat elementwise map.
7265 fn eval_pb(g: &Graph, id: NodeId, leaves: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
7266 if let Some(v) = leaves.get(&id) {
7267 return v.clone();
7268 }
7269 let node = g.node(id);
7270 match &node.op {
7271 Op::Mul => {
7272 let a = eval_pb(g, node.inputs[0], leaves);
7273 let b = eval_pb(g, node.inputs[1], leaves);
7274 a.iter().zip(&b).map(|(x, y)| x * y).collect()
7275 }
7276 Op::MulScalar(m) => eval_pb(g, node.inputs[0], leaves)
7277 .iter()
7278 .map(|v| v * m)
7279 .collect(),
7280 Op::PowI(n) => eval_pb(g, node.inputs[0], leaves)
7281 .iter()
7282 .map(|v| v.powi(*n))
7283 .collect(),
7284 other => panic!("eval_pb: unhandled op {other:?}"),
7285 }
7286 }
7287
7288 /// Build a fused PowIBackward node over `x [dims]` (input 0) and `up
7289 /// [dims]` (input 1, the upstream gradient — same shape as x), carrying
7290 /// `exp`. Returns `(x, up, fused)`.
7291 fn pb_fused_node(g: &mut Graph, dims: &[usize], exp: i32) -> (NodeId, NodeId, NodeId) {
7292 let sh = Shape::from_dims(dims);
7293 let x = g.push(Node {
7294 op: Op::Const,
7295 inputs: vec![],
7296 shape: sh.clone(),
7297 dtype: DType::F32,
7298 });
7299 let up = g.push(Node {
7300 op: Op::Const,
7301 inputs: vec![],
7302 shape: sh.clone(),
7303 dtype: DType::F32,
7304 });
7305 let fused = g.push(Node {
7306 op: Op::Fused(FusedOps::POWI_BACKWARD, FusedOpParams::PowIBackward { exp }),
7307 inputs: vec![x, up],
7308 shape: sh,
7309 dtype: DType::F32,
7310 });
7311 (x, up, fused)
7312 }
7313
7314 /// A3 (a): ONE recipe datum decomposes at BOTH rank 2 and rank 3 and
7315 /// across several exponents, and its numerics match the FROZEN legacy
7316 /// builder bit-exactly under the shared reference interpreter — the A3
7317 /// PowI carrier driving live emission of `Op::PowI(exp-1)`.
7318 #[test]
7319 fn powi_backward_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
7320 // x strictly positive so exp==0's PowI(-1) reciprocal is finite/clean;
7321 // the exponents span +, the exp==1 identity, exp==0 (MulScalar(0)=0),
7322 // and a negative exponent (PowI(-3)).
7323 for dims in [vec![2usize, 4], vec![3, 5, 7]] {
7324 for exp in [3i32, 2, 5, 1, 0, -2] {
7325 let mut g = Graph::new();
7326 let (x, up, fused) = pb_fused_node(&mut g, &dims, exp);
7327 let sh = Shape::from_dims(&dims);
7328 let new_root = powi_backward::decompose(
7329 &mut g,
7330 fused,
7331 &FusedOpParams::PowIBackward { exp },
7332 );
7333 assert_ne!(
7334 new_root, fused,
7335 "recipe decompose must fire at {dims:?} exp={exp}"
7336 );
7337 assert_eq!(
7338 g.node(new_root).shape,
7339 sh,
7340 "powi backward is shape-preserving"
7341 );
7342 assert_eq!(g.node(new_root).dtype, DType::F32);
7343
7344 let legacy_root = frozen_legacy_powi_backward_decompose(
7345 &mut g,
7346 fused,
7347 &FusedOpParams::PowIBackward { exp },
7348 );
7349
7350 let n: usize = dims.iter().product();
7351 let x_data: Vec<f64> = (0..n)
7352 .map(|i| ((i as f64) * 0.41).sin() * 0.5 + 1.5)
7353 .collect();
7354 let up_data: Vec<f64> = (0..n)
7355 .map(|i| ((i as f64) * 0.61).cos() * 1.3 - 0.15)
7356 .collect();
7357 let mut leaves = HashMap::new();
7358 leaves.insert(x, x_data);
7359 leaves.insert(up, up_data);
7360 let got = eval_pb(&g, new_root, &leaves);
7361 let want = eval_pb(&g, legacy_root, &leaves);
7362 assert_eq!(got.len(), want.len());
7363 for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() {
7364 assert_eq!(
7365 a.to_bits(),
7366 b.to_bits(),
7367 "powi_backward[{i}] at {dims:?} exp={exp}: recipe={a} vs legacy={b}",
7368 );
7369 }
7370 }
7371 }
7372 }
7373
7374 /// A3 (structural): a DIRECT structural mirror — the root is `Mul(scaled,
7375 /// up)`, the A3 carrier resolves the exponent to `Op::PowI(exp-1)`, the
7376 /// scale is a baked `Op::MulScalar(exp as f64)`, and NO D3/D4 `Reshape` is
7377 /// materialized (evidence the i32 carrier + baked scale reached emit at the
7378 /// right values).
7379 #[test]
7380 fn powi_backward_recipe_is_a_direct_mirror_with_carrier_exponent() {
7381 for exp in [3i32, 0, -2] {
7382 let mut g = Graph::new();
7383 let (_x, _up, fused) = pb_fused_node(&mut g, &[2, 4], exp);
7384 let root =
7385 powi_backward::decompose(&mut g, fused, &FusedOpParams::PowIBackward { exp });
7386 assert_ne!(root, fused, "recipe decompose fires at exp={exp}");
7387 assert!(
7388 matches!(g.node(root).op, Op::Mul),
7389 "root is Mul(scaled, up)"
7390 );
7391 let reachable = crate::topo_order_multi(&g, &[root]);
7392 // The A3 carrier reconstructed exactly one Op::PowI(exp-1).
7393 let powis: Vec<i32> = reachable
7394 .iter()
7395 .filter_map(|&n| match g.node(n).op {
7396 Op::PowI(k) => Some(k),
7397 _ => None,
7398 })
7399 .collect();
7400 assert_eq!(
7401 powis,
7402 vec![exp - 1],
7403 "the A3 carrier resolved Op::PowI(exp-1) at exp={exp}"
7404 );
7405 // The scale is a single baked MulScalar(exp as f64).
7406 let mul_scalars: Vec<f64> = reachable
7407 .iter()
7408 .filter_map(|&n| match g.node(n).op {
7409 Op::MulScalar(v) => Some(v),
7410 _ => None,
7411 })
7412 .collect();
7413 assert_eq!(
7414 mul_scalars,
7415 vec![exp as f64],
7416 "baked MulScalar(exp) at exp={exp}"
7417 );
7418 // Direct mirror — no keepdim/pad Reshape.
7419 let reshapes = reachable
7420 .iter()
7421 .filter(|&&n| matches!(g.node(n).op, Op::Reshape(_)))
7422 .count();
7423 assert_eq!(
7424 reshapes, 0,
7425 "direct structural mirror — no D3/D4 Reshape at exp={exp}"
7426 );
7427 }
7428 }
7429
7430 /// A3 (totality): a wrong params payload is a typed decline surfaced as a
7431 /// fixpoint (G2), never a panic, declining BEFORE any emission.
7432 #[test]
7433 fn powi_backward_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
7434 let mut g = Graph::new();
7435 let (_x, _up, fused) = pb_fused_node(&mut g, &[2, 4], 3);
7436 let before = g.len();
7437 let out = powi_backward::decompose(&mut g, fused, &FusedOpParams::Rope);
7438 assert_eq!(out, fused, "wrong params ⇒ typed decline ⇒ fixpoint");
7439 assert_eq!(g.len(), before, "declined before any emission");
7440 }
7441 }
7442}