Skip to main content

fuel_graph/
registry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! FusedOpRegistry — metadata side. Phase 7.6 step 1 (skeleton).
3//!
4//! Architecture v1.0 splits op identity into two layers:
5//! - the closed [`crate::Op`] enum carries primitive variants exhaustively;
6//! - one arm — `Op::Fused(FusedOpId, FusedOpParams)` — delegates to an
7//!   open registry of fused-op entries populated at process startup.
8//!
9//! This module holds the *graph-side metadata* half of the registry:
10//! identity ([`FusedOpId`], [`FusedOps`]), pattern + decomposition + backward
11//! identity, and shape/dtype rules. The *kernel side* (BackendImpl with
12//! its function-pointer KernelRef, cost estimate, PrecisionGuarantee, caps,
13//! revision hash) lives in `fuel-storage::fused` because it carries
14//! `KernelRef`, which fuel-graph cannot import without inverting the
15//! existing fuel-storage → fuel-graph dependency direction. The two halves
16//! are joined at runtime by [`FusedOpId`].
17//!
18//! See [docs/fused-op-registry.md](../../docs/fused-op-registry.md) for the
19//! full design and [docs/architecture/03-ir.md](../../docs/architecture/03-ir.md)
20//! for the architectural commitment.
21//!
22//! ## Status (step 1)
23//!
24//! Types only. No callers; no behavior change. Subsequent steps:
25//! - Step 2: extend `Op` with `Op::Fused(FusedOpId, FusedOpParams)` arm.
26//! - Step 3: migrate SoftmaxLastDim end-to-end through the registry.
27//! - Step 4: migrate the remaining 12 fused ops.
28//! - Step 5: drop the per-fused-op `Op` variants once nothing emits them.
29
30use crate::{Graph, NodeId};
31use fuel_ir::storage::OutputViewSpec;
32use fuel_ir::{DType, DynScalar, Shape};
33use fuel_kernel_seam_types::PatternNode;
34use std::collections::HashMap;
35
36pub mod awq_matmul;
37pub mod causal_conv1d;
38pub mod conv2d;
39pub mod conv_transpose_2d;
40pub mod flash_attn;
41pub mod flash_attn_backward;
42pub mod fused_linear;
43pub mod fused_softmax_cross_entropy;
44pub mod inplace_affine;
45pub mod layer_norm_last_dim;
46pub mod layer_norm_last_dim_backward;
47pub mod nf4_matmul;
48pub mod paged_attn;
49pub mod powi_backward;
50pub mod qmatmul;
51pub mod reduce_max_to_backward;
52pub mod rms_norm_last_dim;
53pub mod rms_norm_last_dim_backward;
54pub mod rope;
55pub mod selective_scan;
56pub mod softmax_last_dim;
57pub mod softmax_last_dim_backward;
58pub mod ssd_chunk_scan;
59
60/// Stable identifier for a registered fused op. Indexes into
61/// `FusedOpRegistry::entries`. Newtype over `u16` (~65K capacity is
62/// plenty; today's catalog is 13-14 entries).
63///
64/// Constants for the well-known ids are exposed via [`FusedOps`]
65/// associated constants for ergonomic pattern matching in rule code.
66#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
67pub struct FusedOpId(pub u16);
68
69/// Reserved sentinel: an unallocated id. Never appears in a populated
70/// registry; useful as a placeholder during step-2-and-step-3 migration
71/// where a `BackwardKind::Fused(id)` may be written before the backward's
72/// own id is assigned.
73impl FusedOpId {
74    pub const UNASSIGNED: FusedOpId = FusedOpId(0);
75
76    /// The first id reserved for **runtime-registered** (e.g. JIT-synthesized
77    /// or import-time) fused ops. Static build-time ids are dense
78    /// `1..RUNTIME_FUSED_BASE`; runtime ids are allocated from
79    /// `RUNTIME_FUSED_BASE..` so the two spaces never collide and
80    /// [`Self::is_runtime`] is the routing bit. See
81    /// `docs/specs/runtime-fused-op-registration.md`.
82    pub const RUNTIME_FUSED_BASE: u16 = 0x8000;
83
84    /// Whether this id names a runtime-registered fused op — its metadata lives
85    /// in the `runtime_fused` sidecar, not the static [`default_registry`].
86    pub fn is_runtime(self) -> bool {
87        self.0 >= Self::RUNTIME_FUSED_BASE
88    }
89}
90
91/// Per-fused-op metadata. Identity, pattern, decomposition, backward,
92/// shape/dtype rules. The kernel-side metadata (per-backend KernelRef +
93/// cost + PrecisionGuarantee + caps) lives in `fuel-storage::fused` and is
94/// joined to this entry by [`FusedOpEntry::id`].
95pub struct FusedOpEntry {
96    /// Stable id for this fused op.
97    pub id: FusedOpId,
98    /// Stable human-readable name. Shows up in op_short_name, error
99    /// messages, telemetry.
100    pub name: &'static str,
101    /// Categorical bucket. Used by telemetry and for cost-model defaults.
102    pub family: FusedOpFamily,
103    /// Canonical primitive subgraph this fused op represents. Used by
104    /// fusion rules to recognize the pattern in a base map.
105    pub pattern: SubgraphPattern,
106    /// Decompose a fused-op node into its primitive subgraph. Used by
107    /// lowering rules (and by autograd when [`Self::backward`] is
108    /// `BackwardKind::Decompose`).
109    ///
110    /// Contract: the function appends primitive nodes to `graph` and
111    /// returns the [`NodeId`] of the new root that replaces the fused
112    /// node identified by the second argument. The fused node itself
113    /// remains in the arena; the driver rewrites consumer edges to point
114    /// at the returned id.
115    pub decompose: fn(&mut Graph, NodeId, &FusedOpParams) -> NodeId,
116    /// Backward identity for this fused op.
117    pub backward: BackwardKind,
118    /// Output shape rule, computed from input shapes and params.
119    /// For a multi-output op, this must return slot 0's shape (which
120    /// is also the primary `Node::shape` of the producer).
121    pub shape_rule: fn(&[Shape], &FusedOpParams) -> Shape,
122    /// Output dtype rule, computed from input dtypes and params.
123    /// For a multi-output op, this must return slot 0's dtype (which
124    /// is also the primary `Node::dtype` of the producer).
125    pub dtype_rule: fn(&[DType], &FusedOpParams) -> DType,
126    /// Multi-output authoring contract (Option C, Session 2).
127    ///
128    /// `None` for single-output ops (the default) — the op produces
129    /// one logical output whose shape/dtype come from `shape_rule`
130    /// and `dtype_rule`, and the realized storage is a plain
131    /// non-bundled [`fuel_backend_contract::Storage`].
132    ///
133    /// `Some(fn)` for multi-output ops: returns the per-slot specs
134    /// from input shapes + dtypes + params. The Graph and the realized
135    /// storage allocator both consume the same Vec — `set_output_views`
136    /// on `Graph::push` and `allocate_bundled_storage` at realization
137    /// time — so the slot count and per-slot shapes/dtypes are a
138    /// single source of truth.
139    ///
140    /// Contract for multi-output ops:
141    /// - `output_views(...)[0].dtype` MUST equal `dtype_rule(...)`.
142    /// - `output_views(...)[0].shape` MUST equal `shape_rule(...)`.
143    /// - The returned Vec must be non-empty; a "multi-output op" with
144    ///   one slot is just a single-output op (drop `output_views`).
145    ///
146    /// `Graph::push_fused` (the multi-output-aware emit path that
147    /// Session 2 introduces) checks these at graph-build time. The
148    /// raw `Graph::push` + `Graph::set_output_views` route preserves
149    /// the same invariants via `set_output_views`'s validation pass.
150    #[expect(
151        clippy::type_complexity,
152        reason = "optional output-view rule fn-pointer; a type alias would add indirection for a single field"
153    )]
154    pub output_views: Option<fn(&[Shape], &[DType], &FusedOpParams) -> Vec<OutputViewSpec>>,
155
156    /// Which input index this fused op DESTROYS (mutates in place) on execution,
157    /// or `None` if it is non-destructive (produces a fresh output, every input
158    /// stays readable). **Mandatory** — declared here, beside `decompose`/
159    /// `pattern`, so a fused op cannot be registered without stating it (the
160    /// recipe principle extended: a third required property of the same object).
161    /// `Op::destructive_input` reads this via `default_registry().entry(id)`
162    /// instead of a separate hand-maintained match, so the fact lives with the
163    /// thing and cannot drift out of sync. A destructive op needs the scheduler
164    /// to pin it after every non-destructive reader of the destroyed input
165    /// (`opt::derive_ordering`); a wrong `None` here is a silent data race. There
166    /// is deliberately no default: `Option<usize>` is the *value* (which input,
167    /// or none), but the field itself must be set at every construction site.
168    /// GAP-076.
169    pub destructive_input: Option<usize>,
170}
171
172/// Categorical bucket for a fused op. Drives telemetry grouping and
173/// some cost-model defaults; not load-bearing for dispatch.
174#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
175pub enum FusedOpFamily {
176    Forward,
177    Backward,
178    Quantized,
179    Attention,
180    Norm,
181}
182
183/// Per-instance parameters carried by `Op::Fused(id, params)` nodes.
184/// Step 1 ships only the SoftmaxLastDim variant — the proof-of-concept
185/// migration target. Step 4 extends this enum with the remaining 12
186/// fused-op variants, one per migrated op.
187///
188/// The variant tag is implicitly aligned with the registry entry's
189/// [`FusedOpId`] via the convention that registering an id places its
190/// matching variant here. CSE and `op_key` derive a hashable key from
191/// the tuple `(FusedOpId, FusedOpParams)` so identical fused-op nodes
192/// with identical params dedupe.
193#[derive(Debug, Clone, PartialEq)]
194pub enum FusedOpParams {
195    /// SoftmaxLastDim has no per-instance parameters; the last-dim axis
196    /// is implicit in the input shape.
197    SoftmaxLastDim,
198    /// FusedLinear ((a @ b) + bias). No per-instance parameters: the
199    /// matmul shape and bias rank are implicit in the three inputs.
200    FusedLinear,
201    /// RmsNormLastDim — carries the epsilon term used to stabilize the
202    /// division by RMS magnitude. The last-dim axis itself is implicit
203    /// in the input shape.
204    RmsNormLastDim { eps: f64 },
205    /// LayerNormLastDim — carries the epsilon term used to stabilize
206    /// the division by sqrt(variance + eps). Like RmsNormLastDim, the
207    /// last-dim axis is implicit in the input shape.
208    LayerNormLastDim { eps: f64 },
209    /// Rope — rotary position embedding with caller-supplied cos/sin
210    /// tables. No per-instance parameters: `seq` and `head_dim` are
211    /// recovered from the inputs at execution time (see
212    /// `fuel-storage::pipelined::op_to_op_params`).
213    Rope,
214    /// Conv2D — 2-D cross-correlation with stride / padding / groups.
215    /// Spatial dilation is always 1 today; the field is omitted from
216    /// the param payload until backward's dilation-as-stride trick
217    /// forces the primitive variant to grow one (per the comments
218    /// in `NodeHandle::backward`'s `Op::Conv2D` arm).
219    Conv2D {
220        stride: (usize, usize),
221        padding: (usize, usize),
222        groups: usize,
223    },
224    /// SoftmaxLastDimBackward — `s * (g - sum(g * s, last, keepdim))`.
225    /// Two inputs (forward_y, upstream); parameterless. Higher-order
226    /// gradients panic per `NodeHandle::backward`'s MVP behavior.
227    SoftmaxLastDimBackward,
228    /// LayerNormLastDimBackward — recomputes mean/variance from the
229    /// original x. Two inputs (x, upstream) + eps.
230    LayerNormLastDimBackward { eps: f64 },
231    /// RmsNormLastDimBackward — `r_rms · (g − x·s / (n·(mean_sq + eps)))`.
232    /// Two inputs (x, upstream) + eps.
233    RmsNormLastDimBackward { eps: f64 },
234    /// ReduceMaxToBackward — routes upstream to argmax positions
235    /// (fair-share on ties). Two inputs (x, upstream); parameterless.
236    ReduceMaxToBackward,
237    /// PowIBackward — `grad_x = exp · x^(exp-1) · upstream`. Two
238    /// inputs `(x, upstream)`. Carries the same `exp` as the forward
239    /// `Op::PowI(exp)` so the backward kernel can evaluate the
240    /// derivative coefficient directly.
241    PowIBackward { exp: i32 },
242    /// ConvTranspose2D — fractionally-strided 2-D convolution.
243    /// Two inputs `[x, weight]` (no bias). Carries the full
244    /// stride/padding/output_padding/dilation/groups bundle.
245    ConvTranspose2D {
246        stride: (usize, usize),
247        padding: (usize, usize),
248        output_padding: (usize, usize),
249        dilation: (usize, usize),
250        groups: usize,
251    },
252    /// FlashAttn — multi-head scaled-dot-product attention with
253    /// FlashAttention-shaped kernel hooks. 4-5 inputs (q, k, v,
254    /// optional alibi).
255    ///
256    /// `k_len` (Phase D symbolic extents): `None` ⇒ the attended K/V
257    /// length is the full K extent `k.dims()[2]` (today's behavior).
258    /// `Some(dyn)` ⇒ the K/V buffers are a fixed **capacity**
259    /// `[B, Hkv, max_seq, D]` and only the first `dyn.resolve(env)` rows
260    /// are attended (the live prefix), with the causal mask
261    /// bottom-right-aligned at offset `k_len - Sq` (the standard FA2
262    /// convention, = `cached_len` in decode). This is the runtime
263    /// `k_len` decoupled from K's allocated shape that backs flash
264    /// decode over a persistent capacity KV-cache. Build via
265    /// [`NodeHandle::flash_attn_dyn`](crate::NodeHandle::flash_attn_dyn).
266    FlashAttn {
267        softmax_scale: f32,
268        causal: bool,
269        window_size_left: Option<usize>,
270        window_size_right: Option<usize>,
271        softcap: Option<f32>,
272        k_len: Option<DynScalar>,
273    },
274    /// PagedAttn — paged-cache attention. 5-6 inputs (q, k_cache,
275    /// v_cache, block_table, context_lens, optional alibi).
276    PagedAttn {
277        softmax_scale: f32,
278        block_size: usize,
279        softcap: Option<f32>,
280    },
281    /// QMatMul — quantized matrix multiply `C = A @ dequant(W_Q)`.
282    /// Two inputs `[a, w_q_bytes]`. The U32-typed w_q_bytes carries
283    /// a Q-type block stream; `quant_type` selects the dequant
284    /// implementation; `k` / `n` are the logical weight dims.
285    QMatMul {
286        quant_type: crate::QuantType,
287        k: usize,
288        n: usize,
289    },
290    /// InplaceAffine — `x = mul·x + add`, mutating input 0. Single
291    /// input. Destructive on index 0 (marked via
292    /// `Op::destructive_input`). Phase 1 of the in-place ops
293    /// infrastructure.
294    InplaceAffine { mul: f64, add: f64 },
295    /// SsdChunkScan — Mamba-2's State-Space Duality chunked scan
296    /// (forward only). Five inputs:
297    /// - `x`:   `[batch, seqlen, heads, head_dim]`
298    /// - `dt`:  `[batch, seqlen, heads]`
299    /// - `a`:   `[heads]` — per-head scalar log A.
300    /// - `b`/`c`: `[batch, seqlen, heads, state_dim]`
301    ///
302    /// Output: `y: [batch, seqlen, heads, head_dim]`.
303    ///
304    /// `chunk_size` is the SSD block size (typically 256 in Mamba-2).
305    /// It controls GPU parallelism granularity but doesn't affect the
306    /// mathematical result — the CPU kernel runs a sequential scan
307    /// regardless. Any `chunk_size ∈ [1, seqlen]` that divides
308    /// `seqlen` is valid.
309    SsdChunkScan { chunk_size: usize },
310    /// Nf4Matmul — bitsandbytes-style 4-bit NormalFloat quantized
311    /// matrix multiply. Three inputs:
312    /// - `activations`: `[..., M, K]`
313    /// - `w_packed`:    `[N, K/2]` U8 (two 4-bit codes per byte,
314    ///   K must be even).
315    /// - `absmax`:      `[N, K/block_size]` F32 (per-output-row,
316    ///   per-block scales).
317    ///
318    /// Output: `[..., M, N]` matching the activations' dtype.
319    ///
320    /// `block_size` is the NF4 quantization block size (typically
321    /// 64 in bitsandbytes). The NF4 NormalFloat lookup table is
322    /// baked into the kernel — not a runtime input.
323    Nf4Matmul { block_size: usize },
324    /// AwqMatmul — AWQ (mit-han-lab) asymmetric int4 weight-only
325    /// matmul: `dequant(q) = (q - zero_point) * scale`, per-group.
326    /// Three inputs:
327    /// - `activations`: `[..., M, K]`
328    /// - `qweight`:     `[N, K/8]` U32 (eight int4 codes packed
329    ///   per word, K must be a multiple of 8).
330    /// - `qzeros`:      `[K/group_size, N/8]` U32 (eight int4
331    ///   zero-point codes packed per word, N must be a multiple
332    ///   of 8).
333    /// - `scales`:      `[K/group_size, N]` F32 (per-group,
334    ///   per-output-channel scale).
335    ///
336    /// Output: `[..., M, N]` matching the activations' dtype.
337    ///
338    /// `group_size` (∈ {64, 128} per AWQ's format) must evenly
339    /// divide `K`. `split_k_iters` is a CUDA-kernel launch-tuning
340    /// parameter (unused by the decompose path; carried so a future
341    /// fused CUDA dispatch doesn't need a second params shape).
342    ///
343    /// ⚠️ The packed-int4 UNPACK ORDER within each `U32` word
344    /// (`qweight`/`qzeros`) is NOT independently verified against a
345    /// real AutoAWQ/llm-awq checkpoint in this change — see the
346    /// module doc on [`crate::registry::awq_matmul`] for what IS and
347    /// isn't confirmed. Treat dequantized values as unverified
348    /// against real checkpoints until that is checked.
349    AwqMatmul {
350        group_size: usize,
351        split_k_iters: i32,
352    },
353    /// FlashAttnBackward — shared params for the Q/K/V backward
354    /// variants. Carries the same shape parameters as the forward
355    /// `FlashAttn` so the recompute pass produces identical scores.
356    /// All three FusedOpId-marked variants (FLASH_ATTN_BACKWARD_Q/K/V)
357    /// use this single variant; the FusedOpId distinguishes which
358    /// gradient is being computed.
359    FlashAttnBackward {
360        softmax_scale: f32,
361        causal: bool,
362        window_size_left: Option<usize>,
363        window_size_right: Option<usize>,
364        softcap: Option<f32>,
365    },
366    /// SelectiveScan — Mamba-1's selective state-space scan
367    /// (forward only). Five inputs:
368    /// - `u`:     `[batch, seqlen, dim]` — input sequence.
369    /// - `delta`: `[batch, seqlen, dim]` — per-step state-update rate.
370    /// - `a`:     `[dim, dstate]` — recurrence matrix log.
371    /// - `b`:     `[batch, seqlen, dstate]` — selective input matrix.
372    /// - `c`:     `[batch, seqlen, dstate]` — selective output matrix.
373    ///
374    /// Output: `y: [batch, seqlen, dim]` — the scanned sequence.
375    ///
376    /// `delta_softplus` toggles the standard softplus(delta)
377    /// activation before use (matches baracuda's `selective_scan_*_run`
378    /// flag). The optional inputs (d_skip, z, delta_bias) from the
379    /// full Mamba algorithm are NOT exposed in v1 — they're
380    /// mechanical extensions when a consumer needs them.
381    ///
382    /// Multi-output Option C (item 3, 2026-06-01): the op now always
383    /// produces a bundled output with two slots — slot 0 `y` (the
384    /// scanned sequence) and slot 1 `last_state` (the final
385    /// h-state). Consumers project via `Op::View` / `Op::ViewOwned`.
386    /// The earlier `return_state: bool` flag retired here.
387    SelectiveScan { delta_softplus: bool },
388    /// CausalConv1d — depthwise 1-D convolution with causal masking
389    /// (left-pad-only) + optional fused SiLU. Three inputs:
390    /// - `x`: `[batch, channels, seq + (kernel - 1)]` — caller pre-pads
391    ///   with `kernel - 1` zeros on the left for the causal mask.
392    /// - `weight`: `[channels, 1, kernel]` — depthwise, one filter per
393    ///   channel (groups == channels in conv terminology).
394    /// - `bias`: `[channels]`.
395    ///
396    /// Output: `[batch, channels, seq]`, same dtype as inputs.
397    ///
398    /// Designed to mirror baracuda's `causal_conv1d_*_run` signature
399    /// (which already carries a `use_silu` flag) and the Mamba-1 /
400    /// Mamba-2 prefill convolution. See
401    /// [[match-external-convention-for-well-known-ops]] for the
402    /// "match baracuda's contract exactly" reasoning.
403    CausalConv1d { use_silu: bool },
404    /// FusedSoftmaxCrossEntropy — fused softmax + negative log-likelihood
405    /// over class-indexed targets, the standard PyTorch / Liger-Kernel
406    /// training-time loss. Two inputs:
407    /// - `logits: [..., V]` (F32) — pre-computed unnormalized class scores
408    /// - `targets: [...]` (I64) — class indices, matching the rest of
409    ///   PyTorch / baracuda CE convention (see
410    ///   [[match-external-convention-for-well-known-ops]])
411    ///
412    /// Output is F32 regardless of input dtype (loss values are
413    /// accumulated in F32 for stability):
414    /// - `Reduction::Mean` / `Reduction::Sum` → scalar `[]`
415    /// - `Reduction::None` → same shape as `targets` (`[...]`)
416    ///
417    /// `ignore_index` rows are dropped from the loss accumulator and
418    /// from the Mean denominator. The conventional sentinel is `-100`.
419    FusedSoftmaxCrossEntropy {
420        reduction: Reduction,
421        ignore_index: i64,
422    },
423    /// A **runtime-registered** fused op (JIT-synthesized or import-time). The
424    /// op's identity is the runtime [`FusedOpId`] in `Op::Fused`; its recipe is
425    /// the region in the `runtime_fused` sidecar. This variant carries only the
426    /// extracted scalar args (the `extract:` slots, in slot order) — a
427    /// parameterless runtime op carries `scalars: []`. See
428    /// `docs/specs/runtime-fused-op-registration.md`.
429    Runtime { scalars: Vec<f64> },
430}
431
432/// Reduction mode for losses with per-sample outputs. Matches PyTorch's
433/// `reduction` parameter shape (`'mean' | 'sum' | 'none'`).
434#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
435pub enum Reduction {
436    /// Sum the per-sample losses and divide by the count of contributing
437    /// samples (rows not masked by `ignore_index`). Standard training-time
438    /// scalar.
439    Mean,
440    /// Sum the per-sample losses without dividing.
441    Sum,
442    /// No reduction — return the per-sample losses with `targets.shape`.
443    None,
444}
445
446impl Reduction {
447    /// Stable per-variant key for hashable encodings (`FusedOpParams::key`
448    /// and the kernel-side `OpParams` payload). The variant tag space is
449    /// local to whichever enum carries this discriminant.
450    pub(crate) fn key(self) -> i64 {
451        match self {
452            Reduction::Mean => 0,
453            Reduction::Sum => 1,
454            Reduction::None => 2,
455        }
456    }
457}
458
459/// Hashable key for [`FusedOpParams`]. Used by `op_key`/CSE so that two
460/// `Op::Fused(id, params)` nodes with identical params dedupe.
461///
462/// The encoding is variant-tag + payload as a `Vec<u64>` (bit patterns
463/// for floats, repr for ints/usize). Mirrors the existing
464/// [`crate::opt`]-style `OpKey` encoding so future extensions slot in
465/// without rebuilding the CSE map.
466#[derive(Debug, Clone, PartialEq, Eq, Hash)]
467pub struct FusedOpParamsKey {
468    pub tag: u16,
469    pub bits: Vec<u64>,
470    pub ints: Vec<i64>,
471}
472
473impl FusedOpParams {
474    /// Hashable encoding. Step 4 adds variants per-fused-op as each
475    /// migrates; the tag uniquely identifies the variant and the
476    /// bits/ints slots carry any payload (none for parameterless ops
477    /// like SoftmaxLastDim and FusedLinear).
478    pub fn key(&self) -> FusedOpParamsKey {
479        match self {
480            FusedOpParams::SoftmaxLastDim => FusedOpParamsKey {
481                tag: 1,
482                bits: Vec::new(),
483                ints: Vec::new(),
484            },
485            FusedOpParams::FusedLinear => FusedOpParamsKey {
486                tag: 2,
487                bits: Vec::new(),
488                ints: Vec::new(),
489            },
490            FusedOpParams::RmsNormLastDim { eps } => FusedOpParamsKey {
491                tag: 3,
492                // Encode eps as its raw bit pattern so CSE on two
493                // RmsNormLastDim nodes with the same eps dedupes
494                // (and two with different eps don't).
495                bits: vec![eps.to_bits()],
496                ints: Vec::new(),
497            },
498            FusedOpParams::LayerNormLastDim { eps } => FusedOpParamsKey {
499                tag: 4,
500                bits: vec![eps.to_bits()],
501                ints: Vec::new(),
502            },
503            FusedOpParams::Rope => FusedOpParamsKey {
504                tag: 5,
505                bits: Vec::new(),
506                ints: Vec::new(),
507            },
508            FusedOpParams::Conv2D {
509                stride,
510                padding,
511                groups,
512            } => FusedOpParamsKey {
513                tag: 6,
514                bits: Vec::new(),
515                // Stride.{0,1}, padding.{0,1}, groups. Five i64 slots
516                // — enough that two Conv2D nodes with different
517                // strides or paddings hash to distinct CSE keys.
518                ints: vec![
519                    stride.0 as i64,
520                    stride.1 as i64,
521                    padding.0 as i64,
522                    padding.1 as i64,
523                    *groups as i64,
524                ],
525            },
526            FusedOpParams::SoftmaxLastDimBackward => FusedOpParamsKey {
527                tag: 7,
528                bits: Vec::new(),
529                ints: Vec::new(),
530            },
531            FusedOpParams::LayerNormLastDimBackward { eps } => FusedOpParamsKey {
532                tag: 8,
533                bits: vec![eps.to_bits()],
534                ints: Vec::new(),
535            },
536            FusedOpParams::RmsNormLastDimBackward { eps } => FusedOpParamsKey {
537                tag: 9,
538                bits: vec![eps.to_bits()],
539                ints: Vec::new(),
540            },
541            FusedOpParams::ReduceMaxToBackward => FusedOpParamsKey {
542                tag: 10,
543                bits: Vec::new(),
544                ints: Vec::new(),
545            },
546            FusedOpParams::ConvTranspose2D {
547                stride,
548                padding,
549                output_padding,
550                dilation,
551                groups,
552            } => FusedOpParamsKey {
553                tag: 11,
554                bits: Vec::new(),
555                // 9 i64 slots: stride.{0,1}, padding.{0,1},
556                // output_padding.{0,1}, dilation.{0,1}, groups.
557                ints: vec![
558                    stride.0 as i64,
559                    stride.1 as i64,
560                    padding.0 as i64,
561                    padding.1 as i64,
562                    output_padding.0 as i64,
563                    output_padding.1 as i64,
564                    dilation.0 as i64,
565                    dilation.1 as i64,
566                    *groups as i64,
567                ],
568            },
569            FusedOpParams::FlashAttn {
570                softmax_scale,
571                causal,
572                window_size_left,
573                window_size_right,
574                softcap,
575                k_len,
576            } => {
577                // Encode the optional dynamic k_len as (tag, value) so
578                // two flash nodes whose attended-length symbol differs
579                // are NOT CSE-merged. None → (0,0); Concrete(v) → (1,v);
580                // Sym(s) → (2, s.0).
581                let (k_tag, k_val): (i64, i64) = match k_len {
582                    None => (0, 0),
583                    Some(DynScalar::Concrete(v)) => (1, *v as i64),
584                    Some(DynScalar::Sym(s)) => (2, s.0 as i64),
585                };
586                FusedOpParamsKey {
587                    tag: 12,
588                    // softmax_scale f32 → u64 bit pattern; softcap
589                    // f32 → u64 bit pattern (NaN sentinel for None so
590                    // two FlashAttn nodes with no softcap still hash
591                    // equal).
592                    bits: vec![
593                        softmax_scale.to_bits() as u64,
594                        softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
595                    ],
596                    // causal as i64; window sizes carry MIN as the
597                    // "None" sentinel so a real Some(0) still distinguishes.
598                    ints: vec![
599                        if *causal { 1 } else { 0 },
600                        window_size_left.map(|w| w as i64).unwrap_or(i64::MIN),
601                        window_size_right.map(|w| w as i64).unwrap_or(i64::MIN),
602                        k_tag,
603                        k_val,
604                    ],
605                }
606            }
607            FusedOpParams::PagedAttn {
608                softmax_scale,
609                block_size,
610                softcap,
611            } => FusedOpParamsKey {
612                tag: 13,
613                bits: vec![
614                    softmax_scale.to_bits() as u64,
615                    softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
616                ],
617                ints: vec![*block_size as i64],
618            },
619            FusedOpParams::QMatMul { quant_type, k, n } => FusedOpParamsKey {
620                tag: 14,
621                bits: Vec::new(),
622                // QuantType encoded as stable per-variant i64; k, n
623                // as usize.
624                ints: vec![quant_type_key(*quant_type), *k as i64, *n as i64],
625            },
626            FusedOpParams::PowIBackward { exp } => FusedOpParamsKey {
627                tag: 15,
628                bits: Vec::new(),
629                ints: vec![*exp as i64],
630            },
631            FusedOpParams::InplaceAffine { mul, add } => FusedOpParamsKey {
632                tag: 16,
633                bits: vec![mul.to_bits(), add.to_bits()],
634                ints: Vec::new(),
635            },
636            FusedOpParams::FusedSoftmaxCrossEntropy {
637                reduction,
638                ignore_index,
639            } => FusedOpParamsKey {
640                tag: 17,
641                bits: Vec::new(),
642                ints: vec![reduction.key(), *ignore_index],
643            },
644            FusedOpParams::CausalConv1d { use_silu } => FusedOpParamsKey {
645                tag: 18,
646                bits: Vec::new(),
647                ints: vec![*use_silu as i64],
648            },
649            FusedOpParams::SelectiveScan { delta_softplus } => FusedOpParamsKey {
650                tag: 19,
651                bits: Vec::new(),
652                ints: vec![*delta_softplus as i64],
653            },
654            FusedOpParams::SsdChunkScan { chunk_size } => FusedOpParamsKey {
655                tag: 20,
656                bits: Vec::new(),
657                ints: vec![*chunk_size as i64],
658            },
659            FusedOpParams::Nf4Matmul { block_size } => FusedOpParamsKey {
660                tag: 21,
661                bits: Vec::new(),
662                ints: vec![*block_size as i64],
663            },
664            FusedOpParams::AwqMatmul {
665                group_size,
666                split_k_iters,
667            } => FusedOpParamsKey {
668                tag: 23,
669                bits: Vec::new(),
670                ints: vec![*group_size as i64, *split_k_iters as i64],
671            },
672            FusedOpParams::FlashAttnBackward {
673                softmax_scale,
674                causal,
675                window_size_left,
676                window_size_right,
677                softcap,
678            } => FusedOpParamsKey {
679                // Distinct tag from forward FlashAttn (12) so the
680                // forward and backward nodes can't collide in CSE.
681                tag: 22,
682                bits: vec![
683                    softmax_scale.to_bits() as u64,
684                    softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
685                ],
686                ints: vec![
687                    if *causal { 1 } else { 0 },
688                    window_size_left.map(|w| w as i64).unwrap_or(i64::MIN),
689                    window_size_right.map(|w| w as i64).unwrap_or(i64::MIN),
690                ],
691            },
692            // Runtime-registered fused ops share one tag (0xF000); the runtime
693            // FusedOpId in `Op::Fused` disambiguates distinct ops, and the
694            // scalar bits disambiguate instances of the same op.
695            FusedOpParams::Runtime { scalars } => FusedOpParamsKey {
696                tag: 0xF000,
697                bits: scalars.iter().map(|s| s.to_bits()).collect(),
698                ints: Vec::new(),
699            },
700        }
701    }
702}
703
704/// Stable per-variant key for [`crate::QuantType`]. Used by
705/// [`FusedOpParams::key`] so CSE on two `Op::Fused(QMATMUL, _)`
706/// nodes with the same `(quant_type, k, n)` collapses correctly.
707fn quant_type_key(q: crate::QuantType) -> i64 {
708    use crate::QuantType::*;
709    match q {
710        Q4_0 => 1,
711        Q4_1 => 2,
712        Q5_0 => 3,
713        Q5_1 => 4,
714        Q8_0 => 5,
715        Q8_1 => 6,
716        Q2K => 7,
717        Q3K => 8,
718        Q4_K_M => 9,
719        Q5K => 10,
720        Q6K => 11,
721    }
722}
723
724/// What kind of backward this fused op has.
725///
726/// - `Fused(id)` — emit a fused-backward op with the given id. Used by
727///   ops whose backward is awkward to express as a primitive
728///   decomposition (e.g. SoftmaxLastDimBackward's fused subtract-and-
729///   project formula).
730/// - `Decompose` — autograd derives the backward from the primitive
731///   decomposition (lower the fused node, then run autograd over the
732///   primitives).
733/// - `NotDifferentiable` — backward returns Err / panics cleanly.
734///   Matches today's QMatMul / ArgMaxDim treatment.
735#[derive(Copy, Clone, Debug, PartialEq, Eq)]
736pub enum BackwardKind {
737    Fused(FusedOpId),
738    Decompose,
739    NotDifferentiable,
740}
741
742/// Canonical primitive-subgraph pattern that a fused op represents.
743/// Used by fusion rules.
744///
745/// Two reasonable shapes; the registry supports both:
746/// - `Callable` — a closure-style matcher, mirrors PR 3's
747///   `canonical_softmax_pattern`. Maximally flexible (consumer-count
748///   guards, cross-checks); less analyzable.
749/// - `Declarative` — a recursive `PatternTree` that the rule engine
750///   compiles to a matcher. More analyzable; auto-generation from the
751///   registry entry is straightforward.
752///
753/// Step 3 ports SoftmaxLastDim's existing matcher to `Callable`. The
754/// declarative form (see Q1 in `docs/fused-op-registry.md`) is wired up
755/// in step 4 once a second op exercises the abstraction.
756#[expect(
757    clippy::large_enum_variant,
758    reason = "matcher pattern enum; boxing the large variant is deferred to the same follow-up as PatternNode (docs/gaps.md GAP-215) -- a cross-cutting change to construction/match sites, not a lint-commit change"
759)]
760pub enum SubgraphPattern {
761    Declarative(PatternTree),
762    Callable(fn(&Graph, NodeId) -> Option<PatternMatch>),
763}
764
765/// A declarative subgraph pattern: the §3 grammar root plus the
766/// [`FusedOpParams`] to stamp on the matched fused node. The rule engine
767/// ([`crate::opt::FusionRule`] / `PatternKind::Declarative`) walks `root`
768/// against the graph (via [`crate::jit::match_region`]) and emits
769/// `Op::Fused(id, params)`.
770#[derive(Debug, Clone)]
771pub struct PatternTree {
772    /// The region/pattern root (the subgraph SINK, fkc-fusion-patterns §3a.1).
773    pub root: crate::jit::PatternNode,
774    /// The per-instance params stamped on the emitted fused node. A
775    /// parameterless declarative op carries its unit variant; scalar-param ops
776    /// recover their payload via the `extract:` layer (a follow-up) — for now
777    /// the template lives here.
778    pub params: FusedOpParams,
779}
780
781/// Result of a successful pattern match. Carries the bindings — the
782/// concrete `NodeId`s that the pattern's variables matched against in
783/// the host graph — plus the [`FusedOpParams`] payload the fusion rule
784/// should stamp onto the emitted `Op::Fused(id, params)` node.
785///
786/// `bindings` is index-keyed: bindings sorted by index become the
787/// fused-op node's input list (`inputs[0]` ← index 0, `inputs[1]` ←
788/// index 1, …). SoftmaxLastDim's match has one binding `(0, x_id)`
789/// and so emits a single-input node; FusedLinear (3 inputs:
790/// `[a, b, bias]`) emits three bindings indexed 0–2.
791///
792/// `params` is the matcher's authority on the resulting fused-op's
793/// per-instance parameters. The matcher knows what variant of
794/// [`FusedOpParams`] it's recognizing; carrying that decision in the
795/// match result keeps `crate::opt::FusionRule::rewrite` generic
796/// across all registered fused ops.
797#[derive(Debug, Clone)]
798pub struct PatternMatch {
799    /// Variable-id → resolved NodeId. The fusion rule sorts by index
800    /// and uses the resolved ids in order as the emitted node's inputs.
801    pub bindings: Vec<(usize, NodeId)>,
802    /// Per-instance parameters the matcher stamps onto the fused-op
803    /// node it produces. Parameterless ops (SoftmaxLastDim, FusedLinear)
804    /// stamp their unit variant; parameterized ops (RmsNormLastDim,
805    /// FlashAttn) recover their payload from the matched subgraph.
806    pub params: FusedOpParams,
807}
808
809/// The metadata-side registry. Built at process startup, frozen
810/// thereafter (architecture v1.0: no runtime extensibility). Lookups
811/// are by [`FusedOpId`] (O(1)), by name, or by pattern hash.
812#[derive(Default)]
813pub struct FusedOpRegistry {
814    entries: Vec<FusedOpEntry>,
815    by_name: HashMap<&'static str, FusedOpId>,
816    /// Reserved for the declarative-pattern path (step 4). The fusion
817    /// driver hashes a base-map subgraph to a `PatternHash` and looks
818    /// it up here to decide which fused-op to emit. Unused in step 1.
819    ///
820    /// This index is scoped to the STATIC catalog (`entries`/`by_name`
821    /// above) — it is not the recipe-identity index runtime-registered ops
822    /// dedup against. Runtime ops (`crate::runtime_fused`) live in a
823    /// separate global (`RUNTIME_FUSED_OPS`, its own id space above
824    /// `FusedOpId::RUNTIME_FUSED_BASE`), never wrapped in a
825    /// `FusedOpRegistry`, so there is no `FusedOpRegistry` instance for
826    /// them to hash into here. `runtime_fused::register_runtime_fused`
827    /// dedups structurally-identical regions against a sibling
828    /// `HashMap<u64, FusedOpId>` local to that module instead (keyed by
829    /// `opt::base_map_hash`, the same content hash this field's
830    /// `PatternHash` wraps) — see `runtime_fused.rs`'s `hash_index`.
831    #[allow(dead_code)]
832    by_pattern_hash: HashMap<PatternHash, FusedOpId>,
833}
834
835impl FusedOpRegistry {
836    /// Empty registry. Step 3 builds one with `with_entry(...)` calls.
837    pub fn new() -> Self {
838        Self::default()
839    }
840
841    /// Register an entry. Returns self for builder-style chaining.
842    /// Idempotent on (id, name); a duplicate id is a programming bug
843    /// (asserts in debug builds, last-writer-wins in release).
844    ///
845    /// Pattern-hash indexing is reserved for step 4's declarative
846    /// pattern engine; the `Declarative` arm wires into it then.
847    pub fn with_entry(mut self, entry: FusedOpEntry) -> Self {
848        debug_assert!(
849            !self.by_name.contains_key(entry.name),
850            "FusedOpRegistry: duplicate entry name {:?}",
851            entry.name
852        );
853        debug_assert!(
854            entry.id.0 as usize == self.entries.len() + 1 || self.entry(entry.id).is_none(),
855            "FusedOpRegistry: id {:?} already populated",
856            entry.id
857        );
858        self.by_name.insert(entry.name, entry.id);
859        // Grow `entries` so direct id-indexing works. Slot 0 stays
860        // reserved (FusedOpId::UNASSIGNED).
861        let slot = entry.id.0 as usize;
862        if self.entries.len() <= slot {
863            self.entries.reserve(slot + 1 - self.entries.len());
864            // Step 1 doesn't need to fill placeholder slots — production
865            // paths register every id starting from 1 contiguously, so
866            // the Vec stays dense. If a gap arises, the lookup helper
867            // returns `None` for missing ids.
868        }
869        // Insert at the matching index when contiguous; otherwise
870        // append. Step 3's first registration is FusedOpId(1), so the
871        // simple append-with-slot-0-empty path is what we'll exercise.
872        if self.entries.is_empty() {
873            // Reserve slot 0 with a unit placeholder. Direct indexing is
874            // the hot path; we accept one wasted slot.
875            self.entries.push(placeholder_entry());
876        }
877        if slot < self.entries.len() {
878            self.entries[slot] = entry;
879        } else {
880            // Pad if necessary, then push.
881            while self.entries.len() < slot {
882                self.entries.push(placeholder_entry());
883            }
884            self.entries.push(entry);
885        }
886        self
887    }
888
889    /// Look up an entry by id. Returns `None` for `FusedOpId::UNASSIGNED`
890    /// or any unregistered id.
891    pub fn entry(&self, id: FusedOpId) -> Option<&FusedOpEntry> {
892        let slot = id.0 as usize;
893        if slot == 0 || slot >= self.entries.len() {
894            return None;
895        }
896        let e = &self.entries[slot];
897        if e.id == FusedOpId::UNASSIGNED {
898            None
899        } else {
900            Some(e)
901        }
902    }
903
904    /// Look up an entry by name. Returns the [`FusedOpId`] when present.
905    pub fn id_for_name(&self, name: &str) -> Option<FusedOpId> {
906        self.by_name.get(name).copied()
907    }
908
909    /// Iterate over every registered (non-placeholder) entry. Used by
910    /// [`crate::opt::RuleRegistry::default_rules`] to auto-generate
911    /// one [`crate::opt::LoweringRule`] + [`crate::opt::FusionRule`]
912    /// pair per fused op without naming each entry by hand.
913    pub fn entries_iter(&self) -> impl Iterator<Item = &FusedOpEntry> {
914        self.entries
915            .iter()
916            .filter(|e| e.id != FusedOpId::UNASSIGNED)
917    }
918
919    /// Number of registered (non-placeholder) entries.
920    pub fn len(&self) -> usize {
921        self.entries
922            .iter()
923            .filter(|e| e.id != FusedOpId::UNASSIGNED)
924            .count()
925    }
926
927    /// Whether the registry has any registered entries.
928    pub fn is_empty(&self) -> bool {
929        self.len() == 0
930    }
931}
932
933/// Hash of a primitive-subgraph pattern. Reserved for step 4's
934/// declarative pattern engine. Step 1 ships the newtype so the
935/// `by_pattern_hash` index field type-checks; the hashing function
936/// itself is filled in alongside `PatternTree`.
937#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
938pub struct PatternHash(pub u64);
939
940/// Internal placeholder used to keep id-indexed `entries` dense. Never
941/// Decompose the fused node `id` by re-emitting a **data recipe** — the
942/// static-registry bridge onto the one region re-emitter (Increment C
943/// slice 1, T5 / design D6). A migrated entry's `decompose` body becomes:
944///
945/// ```ignore
946/// fn recipe() -> &'static PatternNode              // OnceLock-built data
947/// fn scalars(params: &FusedOpParams) -> Option<Vec<f64>>  // per-entry projection
948/// pub fn decompose(g, id, params) -> NodeId {
949///     decompose_via_recipe(g, id, recipe(), scalars(params))
950/// }
951/// ```
952///
953/// The fused node's `inputs` are the recipe's binds (in bind-index order);
954/// `scalars` fill the recipe's open scalar slots in pattern pre-order (the
955/// per-entry projection maps the params payload — e.g. an `eps` — onto
956/// them). The `FusedOpEntry.decompose` surface is UNCHANGED — imperative
957/// bodies remain legal for param-conditional ops.
958///
959/// **Totality (G2, the recipe principle):** ANY failure returns `id` — the
960/// fused node stays in place as a surfaced opaque-op gap (the lowering
961/// driver records no progress), NEVER a panic:
962/// * `scalars == None` — a wrong params payload for this entry;
963/// * a recipe that fails `crate::runtime_fused::validate_recipe` — a
964///   semantics-absent op token with no primitive re-emission (the
965///   flip-withdrawal posture: unknown/non-registry tokens are surfaced
966///   honest-miss declines, never accepted), non-contiguous binds, a
967///   matcher-only node, or structurally-unresolvable rel attrs;
968/// * a bind-count / node-input arity mismatch;
969/// * a scalar-slot count mismatch;
970/// * a shape-relative resolution decline at THESE input shapes (symbolic
971///   extent, negative result, …);
972/// * anything else — a `catch_unwind` last-resort guard converts an
973///   unanticipated panic into the same fixpoint.
974///
975/// The first four decline BEFORE any emission (no partial nodes). An
976/// emit-time decline can leave already-pushed child nodes behind as
977/// unreferenced dead nodes — inert, the documented push-only-graph posture.
978/// Declines are telemetered on stderr (the surfaced-gap channel until a
979/// structured gap inventory exists).
980/// **A fused node's recipe as portable data — the generic accessor the JIT seam
981/// needs.** Returns the same [`PatternNode`] the op's `decompose` lowers,
982/// *without touching the graph*.
983///
984/// `fuel_kernel_seam::JitRequest::region` is documented as "the recipe's
985/// `decompose` (the primitive subgraph)", so a synthesizer can be asked to build
986/// one kernel for a region the consumer discovered at runtime. Lowering needs
987/// every primitive bound on the target device; **handing the region over needs
988/// none of them** — which is the whole reason this accessor exists.
989///
990/// Dispatches on the fused op id. `None` means "this op does not expose its
991/// recipe as data (yet)", which is a *surfaced gap*, never a crash — the same
992/// G2 posture as a `decompose` fixpoint self-return. Adding an op is one arm.
993///
994/// Today only [`FusedOps::PAGED_ATTN`] implements it, because it is the op that
995/// forced the question: no GPU backend implements the fused form at all, so the
996/// JIT route is the only one that does not depend on every primitive being
997/// pre-registered.
998pub fn recipe_for(graph: &Graph, id: NodeId) -> Option<PatternNode> {
999    let (fused_id, params) = match &graph.node(id).op {
1000        crate::Op::Fused(fid, p) => (*fid, p.clone()),
1001        _ => return None,
1002    };
1003    match fused_id {
1004        FusedOps::PAGED_ATTN => crate::registry::paged_attn::recipe_for(graph, id, &params),
1005        _ => None,
1006    }
1007}
1008
1009pub fn decompose_via_recipe(
1010    graph: &mut Graph,
1011    id: NodeId,
1012    recipe: &PatternNode,
1013    scalars: Option<Vec<f64>>,
1014) -> NodeId {
1015    let Some(scalars) = scalars else {
1016        eprintln!(
1017            "decompose_via_recipe: wrong params payload for node {id:?} — \
1018             typed decline, node stays fused (G2 fixpoint)"
1019        );
1020        return id;
1021    };
1022    if let Err(err) = crate::runtime_fused::validate_recipe(recipe) {
1023        eprintln!(
1024            "decompose_via_recipe: recipe declined for node {id:?}: {err:?} — \
1025             surfaced honest miss, node stays fused (G2 fixpoint)"
1026        );
1027        return id;
1028    }
1029    let inputs = graph.node(id).inputs.clone();
1030    if recipe.bind_indices().len() != inputs.len() {
1031        eprintln!(
1032            "decompose_via_recipe: recipe binds {} != node inputs {} for node {id:?} — \
1033             typed decline, node stays fused (G2 fixpoint)",
1034            recipe.bind_indices().len(),
1035            inputs.len(),
1036        );
1037        return id;
1038    }
1039    if scalars.len() != crate::runtime_fused::count_scalar_slots(recipe) {
1040        eprintln!(
1041            "decompose_via_recipe: {} projected scalars for {} open slots on node {id:?} — \
1042             typed decline, node stays fused (G2 fixpoint)",
1043            scalars.len(),
1044            crate::runtime_fused::count_scalar_slots(recipe),
1045        );
1046        return id;
1047    }
1048    // The resolving emit. A rel-attr decline at these shapes is a
1049    // value-dependent gap (`Err` arm); `catch_unwind` is the never-panic
1050    // last resort for anything validation didn't anticipate (same posture as
1051    // `register_runtime_fused`'s hash guard) — a partial emission left by
1052    // either failure mode is inert dead nodes in the push-only graph.
1053    let emitted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1054        crate::runtime_fused::try_emit_region(graph, recipe, &inputs, &scalars)
1055    }));
1056    match emitted {
1057        Ok(Ok(root)) => root,
1058        Ok(Err(err)) => {
1059            eprintln!(
1060                "decompose_via_recipe: rel-attr resolution declined for node {id:?}: {err:?} — \
1061                 surfaced gap, node stays fused (G2 fixpoint)"
1062            );
1063            id
1064        }
1065        Err(_) => {
1066            eprintln!(
1067                "decompose_via_recipe: emit panicked for node {id:?} — \
1068                 caught (never-panic last resort), node stays fused (G2 fixpoint)"
1069            );
1070            id
1071        }
1072    }
1073}
1074
1075/// returned by [`FusedOpRegistry::entry`].
1076fn placeholder_entry() -> FusedOpEntry {
1077    FusedOpEntry {
1078        destructive_input: None,
1079        id: FusedOpId::UNASSIGNED,
1080        name: "<unassigned>",
1081        family: FusedOpFamily::Forward,
1082        pattern: SubgraphPattern::Callable(|_g, _id| None),
1083        decompose: |_g, id, _p| id,
1084        backward: BackwardKind::NotDifferentiable,
1085        shape_rule: |_shapes, _params| Shape::from_dims(&[]),
1086        dtype_rule: |_dtypes, _params| DType::F32,
1087        output_views: None,
1088    }
1089}
1090
1091/// Associated constants for the well-known fused-op ids. Rule code
1092/// matches `Op::Fused(FusedOps::SOFTMAX_LAST_DIM, _)` — almost as
1093/// ergonomic as today's `Op::SoftmaxLastDim`. Steps 3-4 fill these in
1094/// as each fused op migrates to the registry.
1095pub struct FusedOps;
1096
1097impl FusedOps {
1098    /// SoftmaxLastDim — proof-of-concept migration target (step 3).
1099    pub const SOFTMAX_LAST_DIM: FusedOpId = FusedOpId(1);
1100    /// FusedLinear — first multi-input fused op migrated to the
1101    /// registry (step 4). Three inputs `[a, b, bias]`, output
1102    /// `(a @ b) + bias`. The CUTLASS bias-epilogue integration in the
1103    /// baracuda-cutlass alpha.13 plan registers here.
1104    pub const FUSED_LINEAR: FusedOpId = FusedOpId(2);
1105    /// RmsNormLastDim — `x / sqrt(mean(x²) + eps)` along the last dim.
1106    /// Migrated in step 4 (continued). Single input + eps param.
1107    pub const RMS_NORM_LAST_DIM: FusedOpId = FusedOpId(3);
1108    /// LayerNormLastDim — `(x - mean) / sqrt(var + eps)` along the
1109    /// last dim (no affine). Single input + eps param.
1110    pub const LAYER_NORM_LAST_DIM: FusedOpId = FusedOpId(4);
1111    /// Rope — rotary position embedding with caller-supplied cos/sin
1112    /// tables. Three inputs `[x, cos, sin]`, parameterless (the seq/
1113    /// head_dim shape parameters live in the input shapes).
1114    pub const ROPE: FusedOpId = FusedOpId(5);
1115    /// Conv2D — 2-D cross-correlation. Two or three inputs `[x,
1116    /// weight]` or `[x, weight, bias]`; carries stride / padding /
1117    /// groups in [`FusedOpParams::Conv2D`].
1118    pub const CONV2D: FusedOpId = FusedOpId(6);
1119    /// SoftmaxLastDimBackward — fused backward helper for the
1120    /// SoftmaxLastDim forward. Two inputs `[y, upstream]`,
1121    /// parameterless. Wired through `BackwardKind::Fused` from
1122    /// `SOFTMAX_LAST_DIM`'s entry.
1123    pub const SOFTMAX_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(7);
1124    /// LayerNormLastDimBackward — fused backward helper for the
1125    /// LayerNormLastDim forward. Two inputs `[x, upstream]` + eps.
1126    pub const LAYER_NORM_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(8);
1127    /// RmsNormLastDimBackward — fused backward helper for the
1128    /// RmsNormLastDim forward. Two inputs `[x, upstream]` + eps.
1129    pub const RMS_NORM_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(9);
1130    /// ReduceMaxToBackward — fused backward helper for the
1131    /// `Op::ReduceMaxTo` primitive (routes upstream to argmax
1132    /// positions, fair-share on ties). Two inputs `[x, upstream]`,
1133    /// parameterless. Note this is the backward of a *primitive*
1134    /// (`Op::ReduceMaxTo`), not of a fused forward — there's no
1135    /// `BackwardKind::Fused` edge wired to this helper, autograd
1136    /// reaches it directly from `Op::ReduceMaxTo`'s arm.
1137    pub const REDUCE_MAX_TO_BACKWARD: FusedOpId = FusedOpId(10);
1138    /// ConvTranspose2D — fractionally-strided 2-D convolution.
1139    /// Two inputs `[x, weight]` (no bias); carries
1140    /// stride/padding/output_padding/dilation/groups in
1141    /// [`FusedOpParams::ConvTranspose2D`].
1142    pub const CONV_TRANSPOSE2D: FusedOpId = FusedOpId(11);
1143    /// FlashAttn — multi-head attention with FlashAttention-shaped
1144    /// kernel hooks. 4-5 inputs (q, k, v, optional alibi).
1145    pub const FLASH_ATTN: FusedOpId = FusedOpId(12);
1146    /// PagedAttn — paged-cache attention. 5-6 inputs.
1147    pub const PAGED_ATTN: FusedOpId = FusedOpId(13);
1148    /// QMatMul — quantized matmul `C = A @ dequant(W_Q)`. Two
1149    /// inputs `[a, w_q_bytes]`.
1150    pub const QMATMUL: FusedOpId = FusedOpId(14);
1151    /// PowIBackward — fused backward helper for `Op::PowI(n)`.
1152    /// `grad_x = n · x^(n-1) · upstream`. Two inputs `[x, upstream]`,
1153    /// carries `exp: i32` in [`FusedOpParams::PowIBackward`]. Replaces
1154    /// the autograd primitive decomposition (PowI(n-1) → MulScalar →
1155    /// Mul, 3 nodes) with a single launch of baracuda alpha.31's
1156    /// `unary_powi_backward_*` kernel. Note this is the backward of a
1157    /// primitive (`Op::PowI`), not of a fused forward — autograd
1158    /// reaches it directly from `Op::PowI`'s arm rather than via a
1159    /// `BackwardKind::Fused` edge.
1160    pub const POWI_BACKWARD: FusedOpId = FusedOpId(15);
1161
1162    /// In-place affine: `x = mul·x + add`. Single input (the mutated
1163    /// tensor), single launch into baracuda's `affine_inplace_*`
1164    /// symbol on CUDA. Carries `mul: f64, add: f64` in
1165    /// [`FusedOpParams::InplaceAffine`]. Marked destructive on input
1166    /// 0 via `Op::destructive_input`, so `opt::derive_ordering` pins
1167    /// it to run after every non-destructive reader. Phase 1 of the
1168    /// in-place ops infrastructure
1169    /// (`docs/session-prompts/in-place-ops-infrastructure.md`);
1170    /// dispatch + autograd integration deferred to Phases 3 + 4.
1171    pub const INPLACE_AFFINE: FusedOpId = FusedOpId(16);
1172
1173    /// FusedSoftmaxCrossEntropy — fused softmax + NLL over class-indexed
1174    /// targets. Two inputs `[logits, targets]`; carries
1175    /// `{ reduction, ignore_index }` in
1176    /// [`FusedOpParams::FusedSoftmaxCrossEntropy`]. The forward kernel
1177    /// avoids materializing the `[..., V]` softmax/log-softmax
1178    /// intermediates that the primitive composition does (`fuel-core::
1179    /// train::cross_entropy_with_logits`). Backward via Decompose runs
1180    /// the primitive chain; the in-place Liger-style fused backward is a
1181    /// later session (needs in-place mutation infrastructure).
1182    pub const FUSED_SOFTMAX_CROSS_ENTROPY: FusedOpId = FusedOpId(17);
1183
1184    /// CausalConv1d — depthwise 1-D convolution + causal masking +
1185    /// optional fused SiLU. Three inputs `[x, weight, bias]` where
1186    /// `x: [batch, channels, seq + kernel - 1]`,
1187    /// `weight: [channels, 1, kernel]`, `bias: [channels]`. Carries
1188    /// `use_silu: bool` in [`FusedOpParams::CausalConv1d`]. The forward
1189    /// kernel matches baracuda's `causal_conv1d_*_run` contract.
1190    /// `BackwardKind::NotDifferentiable` for v1 (Mamba inference path
1191    /// today is inference-only).
1192    pub const CAUSAL_CONV1D: FusedOpId = FusedOpId(18);
1193
1194    /// SelectiveScan — Mamba-1's selective state-space scan
1195    /// (forward only). Five inputs `[u, delta, a, b, c]`; one output
1196    /// `y: [batch, seqlen, dim]`. Carries `delta_softplus: bool` in
1197    /// [`FusedOpParams::SelectiveScan`]. v1 drops the optional
1198    /// inputs (d_skip, z, delta_bias) and the `last_state` second
1199    /// output — both are mechanical follow-ups when a consumer needs
1200    /// them. `BackwardKind::NotDifferentiable` for v1.
1201    pub const SELECTIVE_SCAN: FusedOpId = FusedOpId(19);
1202
1203    /// SsdChunkScan — Mamba-2's State-Space Duality chunked scan
1204    /// (forward only). Five inputs `[x, dt, a, b, c]`; one output
1205    /// `y: [batch, seqlen, heads, head_dim]`. Carries
1206    /// `chunk_size: usize` in [`FusedOpParams::SsdChunkScan`]
1207    /// (parallelism granularity for the GPU path; the CPU kernel
1208    /// runs sequential and any positive `chunk_size` dividing
1209    /// `seqlen` produces the same mathematical answer).
1210    /// `BackwardKind::NotDifferentiable` for v1.
1211    pub const SSD_CHUNK_SCAN: FusedOpId = FusedOpId(20);
1212
1213    /// Nf4Matmul — bitsandbytes-style 4-bit NormalFloat quantized
1214    /// matrix multiply. Three inputs `[activations, w_packed, absmax]`
1215    /// where `w_packed: [N, K/2] U8` and `absmax: [N, K/block_size] F32`.
1216    /// Output `[..., M, N]` matching activations' dtype (F32/F16/BF16
1217    /// in v1). Carries `block_size: usize` in
1218    /// [`FusedOpParams::Nf4Matmul`]. `BackwardKind::NotDifferentiable`
1219    /// (NF4 weights are frozen; the dequant-then-matmul fallback is
1220    /// the wrong path for the cases NF4 exists to optimize).
1221    pub const NF4_MATMUL: FusedOpId = FusedOpId(21);
1222
1223    /// FlashAttnBackwardQ — produces dQ from `(q, k, v, do, [alibi])`.
1224    /// See [`fuel_ir::dispatch::OpKind::FlashAttnBackwardQ`]. Three
1225    /// separate FusedOp ids (Q/K/V) is the v1 design — a single
1226    /// multi-output op would share the recompute pass across all three
1227    /// gradients but needs multi-output infrastructure that doesn't
1228    /// exist yet. Reuses [`FusedOpParams::FlashAttnBackward`].
1229    pub const FLASH_ATTN_BACKWARD_Q: FusedOpId = FusedOpId(22);
1230    /// FlashAttnBackwardK — produces dK. See [`Self::FLASH_ATTN_BACKWARD_Q`].
1231    pub const FLASH_ATTN_BACKWARD_K: FusedOpId = FusedOpId(23);
1232    /// FlashAttnBackwardV — produces dV. See [`Self::FLASH_ATTN_BACKWARD_Q`].
1233    pub const FLASH_ATTN_BACKWARD_V: FusedOpId = FusedOpId(24);
1234
1235    /// AwqMatmul — AWQ (mit-han-lab) asymmetric int4 weight-only
1236    /// matmul. Four inputs `[activations, qweight, qzeros, scales]`
1237    /// (one more than NF4's three — AWQ's zero-point is a fourth
1238    /// operand, not baked into the dequant like NF4's codebook) where
1239    /// `qweight: [N, K/8] U32`,
1240    /// `qzeros: [K/group_size, N/8] U32`, `scales: [K/group_size, N]
1241    /// F32`. Output `[..., M, N]` matching activations' dtype
1242    /// (F32/F16/BF16 in v1). Carries `{ group_size, split_k_iters }`
1243    /// in [`FusedOpParams::AwqMatmul`]. `BackwardKind::NotDifferentiable`
1244    /// (AWQ weights are frozen, same reasoning as [`Self::NF4_MATMUL`]).
1245    /// Unlike NF4 (a non-linear codebook with no primitive spelling),
1246    /// AWQ's affine dequant decomposes cleanly into primitives — see
1247    /// the module doc on [`crate::registry::awq_matmul`] for the
1248    /// recipe and its current verification status.
1249    pub const AWQ_MATMUL: FusedOpId = FusedOpId(25);
1250}
1251
1252/// Process-wide default registry: the union of every fused op's
1253/// metadata-side entry. Built once on first access via
1254/// [`std::sync::OnceLock`]; immutable thereafter (architecture v1.0:
1255/// no runtime extensibility).
1256///
1257/// Step 3 populates only SoftmaxLastDim; step 4 fills in the other
1258/// 12 fused ops as they migrate.
1259pub fn default_registry() -> &'static FusedOpRegistry {
1260    use std::sync::OnceLock;
1261    static REGISTRY: OnceLock<FusedOpRegistry> = OnceLock::new();
1262    REGISTRY.get_or_init(|| {
1263        FusedOpRegistry::new()
1264            .with_entry(softmax_last_dim::entry())
1265            .with_entry(fused_linear::entry())
1266            .with_entry(rms_norm_last_dim::entry())
1267            .with_entry(layer_norm_last_dim::entry())
1268            .with_entry(rope::entry())
1269            .with_entry(conv2d::entry())
1270            .with_entry(softmax_last_dim_backward::entry())
1271            .with_entry(layer_norm_last_dim_backward::entry())
1272            .with_entry(rms_norm_last_dim_backward::entry())
1273            .with_entry(reduce_max_to_backward::entry())
1274            .with_entry(conv_transpose_2d::entry())
1275            .with_entry(flash_attn::entry())
1276            .with_entry(flash_attn_backward::entry_q())
1277            .with_entry(flash_attn_backward::entry_k())
1278            .with_entry(flash_attn_backward::entry_v())
1279            .with_entry(paged_attn::entry())
1280            .with_entry(qmatmul::entry())
1281            .with_entry(powi_backward::entry())
1282            .with_entry(inplace_affine::entry())
1283            .with_entry(fused_softmax_cross_entropy::entry())
1284            .with_entry(causal_conv1d::entry())
1285            .with_entry(selective_scan::entry())
1286            .with_entry(ssd_chunk_scan::entry())
1287            .with_entry(nf4_matmul::entry())
1288            .with_entry(awq_matmul::entry())
1289    })
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295
1296    /// Smoke: id newtype is comparable + hashable.
1297    #[test]
1298    fn fused_op_id_basic() {
1299        let a = FusedOpId(1);
1300        let b = FusedOpId(1);
1301        let c = FusedOpId(2);
1302        assert_eq!(a, b);
1303        assert_ne!(a, c);
1304    }
1305
1306    /// Smoke: empty registry has no entries.
1307    #[test]
1308    fn registry_empty() {
1309        let r = FusedOpRegistry::new();
1310        assert!(r.is_empty());
1311        assert_eq!(r.len(), 0);
1312        assert!(r.entry(FusedOpId(1)).is_none());
1313        assert!(r.entry(FusedOpId::UNASSIGNED).is_none());
1314        assert!(r.id_for_name("anything").is_none());
1315    }
1316
1317    /// Smoke: register one entry, look it up by id and by name.
1318    #[test]
1319    fn registry_register_one() {
1320        fn dummy_decompose(_g: &mut Graph, id: NodeId, _p: &FusedOpParams) -> NodeId {
1321            id
1322        }
1323        fn dummy_pattern(_g: &Graph, _id: NodeId) -> Option<PatternMatch> {
1324            None
1325        }
1326        fn dummy_shape(_s: &[Shape], _p: &FusedOpParams) -> Shape {
1327            Shape::from_dims(&[1])
1328        }
1329        fn dummy_dtype(_d: &[DType], _p: &FusedOpParams) -> DType {
1330            DType::F32
1331        }
1332
1333        let r = FusedOpRegistry::new().with_entry(FusedOpEntry {
1334            destructive_input: None,
1335            id: FusedOps::SOFTMAX_LAST_DIM,
1336            name: "SoftmaxLastDim",
1337            family: FusedOpFamily::Forward,
1338            pattern: SubgraphPattern::Callable(dummy_pattern),
1339            decompose: dummy_decompose,
1340            backward: BackwardKind::NotDifferentiable,
1341            shape_rule: dummy_shape,
1342            dtype_rule: dummy_dtype,
1343            output_views: None,
1344        });
1345
1346        assert_eq!(r.len(), 1);
1347        assert!(!r.is_empty());
1348        let entry = r.entry(FusedOps::SOFTMAX_LAST_DIM).expect("registered");
1349        assert_eq!(entry.name, "SoftmaxLastDim");
1350        assert_eq!(entry.family, FusedOpFamily::Forward);
1351        assert_eq!(
1352            r.id_for_name("SoftmaxLastDim"),
1353            Some(FusedOps::SOFTMAX_LAST_DIM)
1354        );
1355    }
1356
1357    /// FusedOpParams::key produces a stable encoding.
1358    #[test]
1359    fn fused_op_params_key_softmax() {
1360        let k1 = FusedOpParams::SoftmaxLastDim.key();
1361        let k2 = FusedOpParams::SoftmaxLastDim.key();
1362        assert_eq!(k1, k2);
1363        assert_eq!(k1.tag, 1);
1364        assert!(k1.bits.is_empty());
1365        assert!(k1.ints.is_empty());
1366    }
1367}