Skip to main content

onnx_runtime_optimizer/
fusion.rs

1//! Operator fusion: match a connected op-sequence and replace it with a single
2//! fused op (see `docs/ORT2.md` §18.2).
3//!
4//! ## Matching model
5//!
6//! A [`FusionPattern`] is an ordered op sequence plus a replacement op type.
7//! **Structural** patterns (MatMul+Add, MatMul+Add+Relu) use
8//! [`FusionPattern::try_match_from`], which walks the graph forward from each
9//! candidate start node following producer→consumer ("spine") edges: node `i+1`
10//! of the match must consume an output of node `i`. Extra data edges *back* to
11//! already-matched nodes are allowed.
12//!
13//! The **LayerNorm** rewrite instead uses a dedicated DAG-aware matcher
14//! ([`FusionPattern::try_match_layernorm`]): a real LayerNorm decomposition is a
15//! diamond whose `mean` feeds both a variance branch and a numerator branch, and
16//! some exporters emit two distinct `Sub(x, mean)` nodes rather than reusing one
17//! `diff`, so a single linear successor-walk can't express it. The matcher
18//! anchors on the `mean` `ReduceMean` and follows both branches to the final
19//! `Add`, accepting both the canonical 9-op (shared `Sub`) and the 10-op
20//! split-`Sub` shapes.
21//!
22//! ## Safety rule (never change numerics-visible semantics)
23//!
24//! A match is only fused when **every intermediate output is consumed solely
25//! within the matched set** — i.e. no matched node except the last has an
26//! output that escapes to an outside consumer or to a graph output. This is the
27//! generalization of "single-consumer chain": internal reuse is fine, external
28//! escape is not. It guarantees fusion cannot delete a value another part of
29//! the graph still observes.
30//!
31//! [`FusionPattern::apply_fusion`] removes the matched nodes and inserts the
32//! replacement, reusing the final output value id so external wiring and graph
33//! outputs are preserved automatically. External inputs are collected in
34//! first-seen order across the matched nodes.
35//!
36//! ## Kernel note
37//!
38//! The optimizer-produced fused op types (`LayerNormalization`,
39//! `FusedMatMulBias`, `FusedGemm`) are emitted in the private contrib domain
40//! [`CONTRIB_DOMAIN`] (`com.microsoft`), **not** the reserved default ONNX
41//! domain. `FusedMatMulBias`/`FusedGemm` are invented (non-standard) ops, so
42//! putting them in `ai.onnx` would collide with standard-op opset validation and
43//! make kernel dispatch ambiguous; a private contrib domain is the only
44//! unambiguous key. `com.microsoft` is the established ONNX-ecosystem contrib
45//! domain (where the `FusedMatMul`/`LayerNormalization` contrib variants live),
46//! so our IR stays interoperable with ORT-exported models and wider tooling.
47//!
48//! Kernel dispatch (`onnx-runtime-ep-cpu`) binds these by `(domain, op_type)`.
49//! `LayerNormalization`, `FusedMatMulBias` and `FusedGemm` all have CPU kernels
50//! (registered under the contrib domain). `FusedGemm` (MatMul+Add+Relu) is not
51//! exercised by the current model-level validation target (BERT uses GELU/Erf,
52//! not Relu), so it is instead validated by the synthetic end-to-end parity
53//! test in `crates/onnx-runtime-session/tests/fused_gemm_parity.rs`, which
54//! builds a MatMul→Add→Relu graph and checks the fused single-pass output
55//! against the unfused reference.
56//!
57//! ## Schema-aware rewrites
58//!
59//! Most patterns use a *structural* rewrite: the fused node's inputs are the
60//! matched region's external inputs in first-seen order, which happens to match
61//! the kernel signature for `FusedMatMulBias` (`[A, B, bias]`). The LayerNorm
62//! fusion is instead **schema-aware** (see [`RewriteKind::LayerNorm`]): it emits
63//! a node with inputs exactly `[X, Scale, B]` and synthesizes the `axis` /
64//! `epsilon` attributes the kernel reads, extracting them from the matched
65//! subgraph (the `ReduceMean` axes and the `var + eps` constant).
66
67use std::collections::{BTreeSet, HashMap, HashSet};
68
69use onnx_runtime_ir::{Attribute, DataType, Graph, Node, NodeId, ValueId, WeightRef};
70
71use crate::error::Result;
72use crate::pass::{OptimizationPass, PassContext};
73
74/// The private contrib domain under which the optimizer emits every fused op.
75///
76/// `com.microsoft` is the established ONNX-ecosystem contrib domain; keeping our
77/// fused ops there (rather than the reserved `""`/`ai.onnx` domain) avoids
78/// colliding with standard-op opset validation, keeps kernel dispatch keyed
79/// unambiguously on `(domain, op_type)`, and stays interoperable with
80/// ORT-exported models. This is model-agnostic: it is a property of the op
81/// *domain*, independent of any particular model.
82pub const CONTRIB_DOMAIN: &str = "com.microsoft";
83
84/// `√2`, the exact-GELU inner divisor (`Erf(X / √2)`).
85const SQRT_2: f32 = std::f32::consts::SQRT_2;
86/// `1/√2`, the equivalent inner *multiplier* encoding (`Mul(X, 1/√2)`).
87const FRAC_1_SQRT_2: f32 = std::f32::consts::FRAC_1_SQRT_2;
88
89/// Whether `a` matches an expected exact-GELU structural constant. The GELU
90/// constants (`0.5`, `1.0`, `√2`, `1/√2`, `2.0`) are all small and exactly
91/// representable-ish in f32; the tolerance only absorbs f32 rounding of `√2`
92/// / `1/√2`, never a numerically different coefficient — an off constant
93/// **declines** rather than silently fuses a wrong decomposition.
94fn approx(a: f32, expected: f32) -> bool {
95    (a - expected).abs() <= 1e-6 * expected.abs().max(1.0)
96}
97
98/// The inputs and attributes of a fused node: `(inputs, attributes)`.
99type FusedNodeSpec = (Vec<Option<ValueId>>, HashMap<String, Attribute>);
100
101/// A matched occurrence of a [`FusionPattern`] in a graph.#[derive(Clone, Debug)]
102pub struct PatternMatch {
103    /// Matched node ids, in op-sequence order.
104    pub nodes: Vec<NodeId>,
105    /// Values consumed by the matched region but produced outside it
106    /// (graph inputs, initializers, or outputs of non-matched nodes), in
107    /// first-seen order.
108    pub external_inputs: Vec<ValueId>,
109    /// The single output of the last matched node — reused as the fused node's
110    /// output so downstream wiring is preserved.
111    pub output: ValueId,
112}
113
114/// How a matched pattern is rewritten into its fused node.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum RewriteKind {
117    /// The fused node's inputs are the matched region's external inputs in
118    /// first-seen order (e.g. `MatMul(A,B)+bias` → `FusedMatMulBias[A, B, bias]`).
119    Structural,
120    /// Schema-aware LayerNorm rewrite: emit `[X, Scale, B]` plus the `axis` and
121    /// `epsilon` attributes the kernel reads, extracted from the matched
122    /// 9-op decomposition (see [`FusionPattern::layernorm_spec`]).
123    LayerNorm,
124    /// Schema-aware SDPA rewrite: emit `[Q, K, V]` (+ optional `[mask]`) plus
125    /// the concrete `scale` and `k_transposed` attributes, extracted from the
126    /// matched `MatMul → (Mul|Div) → [Add] → Softmax → MatMul` core (see
127    /// [`FusionPattern::attention_spec`]).
128    Attention,
129    /// Schema-aware exact-GELU rewrite: emit `[X]` with no attributes, extracted
130    /// from the matched Erf decomposition
131    /// `0.5·X · (1 + Erf(X / √2))` — a diamond whose single external input `X`
132    /// feeds both the `Erf` branch and the outer half-scale (see
133    /// [`FusionPattern::gelu_spec`]). Only the exact (`Erf`) form is recognized;
134    /// the `tanh`-approximation FastGelu is out of scope.
135    Gelu,
136}
137
138/// A fusion rule: an op-type sequence rewritten to a single replacement op.
139#[derive(Clone, Debug)]
140pub struct FusionPattern {
141    name: String,
142    ops: Vec<String>,
143    replacement: String,
144    #[cfg(test)]
145    replacement_domain: String,
146    kind: RewriteKind,
147}
148
149impl FusionPattern {
150    /// A new *structural* pattern matching `ops` in sequence, replaced by
151    /// `replacement`. The fused node's inputs are the matched region's external
152    /// inputs in first-seen order.
153    pub fn new(name: &str, ops: &[&str], replacement: &str) -> Self {
154        assert!(!ops.is_empty(), "fusion pattern must have at least one op");
155        Self {
156            name: name.to_string(),
157            ops: ops.iter().map(|s| s.to_string()).collect(),
158            replacement: replacement.to_string(),
159            #[cfg(test)]
160            replacement_domain: CONTRIB_DOMAIN.to_string(),
161            kind: RewriteKind::Structural,
162        }
163    }
164
165    /// The schema-aware LayerNorm pattern: the canonical 9-op decomposition
166    /// (`ReduceMean, Sub, Pow, ReduceMean, Add, Sqrt, Div, Mul, Add`) rewritten
167    /// to a `com.microsoft::LayerNormalization` node with inputs `[X, Scale, B]`
168    /// and synthesized `axis`/`epsilon` attributes.
169    pub fn layernorm() -> Self {
170        Self {
171            name: "LayerNorm".to_string(),
172            ops: [
173                "ReduceMean",
174                "Sub",
175                "Pow",
176                "ReduceMean",
177                "Add",
178                "Sqrt",
179                "Div",
180                "Mul",
181                "Add",
182            ]
183            .iter()
184            .map(|s| s.to_string())
185            .collect(),
186            replacement: "LayerNormalization".to_string(),
187            #[cfg(test)]
188            replacement_domain: CONTRIB_DOMAIN.to_string(),
189            kind: RewriteKind::LayerNorm,
190        }
191    }
192
193    /// This pattern's rewrite kind.
194    pub fn kind(&self) -> RewriteKind {
195        self.kind
196    }
197
198    /// The schema-aware SDPA-core pattern, rewritten to a
199    /// `com.microsoft::FusedAttention` node with inputs `[Q, K, V]` (+ optional
200    /// `[mask]`) and synthesized `scale`/`k_transposed` attributes. Anchored on
201    /// the `Softmax` (see [`Self::try_match_attention`]).
202    pub fn attention() -> Self {
203        Self {
204            name: "Attention".to_string(),
205            // The op list is descriptive only; the DAG-aware matcher does the
206            // real recognition. Softmax is the anchor.
207            ops: ["Softmax"].iter().map(|s| s.to_string()).collect(),
208            replacement: "FusedAttention".to_string(),
209            #[cfg(test)]
210            replacement_domain: CONTRIB_DOMAIN.to_string(),
211            kind: RewriteKind::Attention,
212        }
213    }
214
215    /// The schema-aware exact-GELU pattern: the `Erf` decomposition
216    /// `0.5·X · (1 + Erf(X / √2))` rewritten to a `com.microsoft::Gelu` node
217    /// with the single input `[X]` and no attributes. Anchored on the `Erf`
218    /// (see [`Self::try_match_gelu`]).
219    pub fn gelu() -> Self {
220        Self {
221            name: "Gelu".to_string(),
222            // Descriptive only; the DAG-aware matcher does the real recognition.
223            // `Erf` is the anchor.
224            ops: ["Erf"].iter().map(|s| s.to_string()).collect(),
225            replacement: "Gelu".to_string(),
226            #[cfg(test)]
227            replacement_domain: CONTRIB_DOMAIN.to_string(),
228            kind: RewriteKind::Gelu,
229        }
230    }
231
232    #[cfg(test)]
233    fn with_replacement_domain(mut self, domain: &str) -> Self {
234        self.replacement_domain = domain.to_string();
235        self
236    }
237
238    /// This pattern's name.
239    pub fn pattern_name(&self) -> &str {
240        &self.name
241    }
242
243    /// Find the next occurrence of this pattern, scanning nodes in id order.
244    ///
245    /// [`RewriteKind::LayerNorm`] uses a dedicated DAG-aware matcher
246    /// ([`Self::try_match_layernorm`]) because a real LayerNorm decomposition is
247    /// a diamond DAG whose `mean` feeds two branches (variance + numerator) and
248    /// may even use two distinct `Sub(x, mean)` nodes; the linear successor-walk
249    /// used by the structural patterns can't express that. All structural
250    /// patterns (MatMul+Add, MatMul+Add+Relu) keep the linear-chain matcher.
251    pub fn find_match(&self, graph: &Graph) -> Option<PatternMatch> {
252        for start in graph.nodes.keys() {
253            if let Some(m) = self.try_match_at(graph, start) {
254                return Some(m);
255            }
256        }
257        None
258    }
259
260    fn try_match_at(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
261        match self.kind {
262            RewriteKind::LayerNorm => self.try_match_layernorm(graph, start),
263            RewriteKind::Attention => self.try_match_attention(graph, start),
264            RewriteKind::Gelu => self.try_match_gelu(graph, start),
265            RewriteKind::Structural => self.try_match_from(graph, start),
266        }
267    }
268
269    /// Candidate starts whose match result may be affected when `matched` is
270    /// replaced. The replacement is always a contrib-domain op, so it cannot
271    /// itself satisfy any standard-domain pattern step. Existing producers can
272    /// still observe changed consumer adjacency, so conservatively revisit them
273    /// and the bounded predecessor chains from which this pattern could reach
274    /// them.
275    fn affected_candidate_starts(&self, graph: &Graph, matched: &PatternMatch) -> Vec<NodeId> {
276        let max_depth = match self.kind {
277            RewriteKind::LayerNorm => 10,
278            RewriteKind::Attention => 6,
279            RewriteKind::Gelu => 5,
280            RewriteKind::Structural => self.ops.len(),
281        };
282        let mut affected = HashSet::new();
283        let mut frontier: Vec<(NodeId, usize)> = matched
284            .external_inputs
285            .iter()
286            .filter_map(|&value| graph.value(value).producer)
287            .map(|producer| (producer, 0))
288            .collect();
289
290        while let Some((node_id, depth)) = frontier.pop() {
291            if !affected.insert(node_id) || depth >= max_depth.saturating_sub(1) {
292                continue;
293            }
294            frontier.extend(
295                graph
296                    .node(node_id)
297                    .input_values()
298                    .filter_map(|value| graph.value(value).producer)
299                    .map(|producer| (producer, depth + 1)),
300            );
301        }
302        affected.into_iter().collect()
303    }
304
305    /// Whether `node` is a standard-domain op named `op`.
306    fn op_matches(node: &Node, op: &str) -> bool {
307        node.op_type == op && matches!(node.domain.as_str(), "" | "ai.onnx")
308    }
309
310    /// The first consumer of `value` whose op is `op` (standard domain).
311    fn find_consumer(graph: &Graph, value: ValueId, op: &str) -> Option<NodeId> {
312        graph
313            .consumers(value)
314            .into_iter()
315            .find(|&c| Self::op_matches(graph.node(c), op))
316    }
317
318    /// DAG-aware LayerNorm matcher anchored on the *mean* `ReduceMean` node.
319    ///
320    /// Real LayerNorm decompositions are a diamond, not a chain: the mean feeds
321    /// both the variance branch (`Sub → Pow → ReduceMean → Add(eps) → Sqrt`) and
322    /// the numerator branch (`Sub → Div`). Some exporters (e.g. the one that
323    /// produced `bert_toy`) emit **two distinct `Sub(x, mean)` nodes** — one per
324    /// branch — instead of reusing a single `diff`, so the region is 10 ops and
325    /// the shared `mean` value is consumed by two Subs. Both shapes are matched
326    /// here; the canonical single-`Sub` diamond is the 9-op special case where
327    /// the two branches share one `Sub`.
328    ///
329    /// The returned [`PatternMatch::nodes`] are in a fixed canonical order the
330    /// schema extractor relies on:
331    /// `[mean_rm, sub_pow, pow, var_rm, add_eps, sqrt, div, mul, final_add]`,
332    /// with `sub_div` appended as a 10th node only when the numerator uses a
333    /// distinct `Sub`. Fusion is declined (via [`Self::layernorm_spec`]) unless
334    /// every schema assumption (single concrete `axis`, constant f32 `epsilon`,
335    /// interior data-flow) is provable.
336    fn try_match_layernorm(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
337        let mean_rm = graph.try_node(start)?;
338        if !Self::op_matches(mean_rm, "ReduceMean") || mean_rm.outputs.len() != 1 {
339            return None;
340        }
341        let mean = mean_rm.outputs[0];
342
343        // Every `Sub` that consumes `mean` (i.e. computes `x - mean`). One in the
344        // canonical diamond, two in the split-diff variant.
345        let subs: Vec<NodeId> = graph
346            .consumers(mean)
347            .into_iter()
348            .filter(|&c| {
349                let n = graph.node(c);
350                Self::op_matches(n, "Sub") && n.input_values().any(|v| v == mean)
351            })
352            .collect();
353
354        // Try each Sub as the *variance* diff source (feeding `Pow`).
355        for &sub_pow in &subs {
356            let sp = graph.node(sub_pow);
357            if sp.outputs.len() != 1 {
358                continue;
359            }
360            let diff_pow = sp.outputs[0];
361            // Variance branch: Pow → ReduceMean → Add(eps) → Sqrt.
362            let Some(pow) = Self::find_consumer(graph, diff_pow, "Pow") else {
363                continue;
364            };
365            let sq = graph.node(pow).outputs[0];
366            let Some(var_rm) = Self::find_consumer(graph, sq, "ReduceMean") else {
367                continue;
368            };
369            let var = graph.node(var_rm).outputs[0];
370            let Some(add_eps) = Self::find_consumer(graph, var, "Add") else {
371                continue;
372            };
373            let vare = graph.node(add_eps).outputs[0];
374            let Some(sqrt) = Self::find_consumer(graph, vare, "Sqrt") else {
375                continue;
376            };
377            let std = graph.node(sqrt).outputs[0];
378            // Numerator branch: Div(diff, std) → Mul(scale) → Add(bias).
379            let Some(div) = Self::find_consumer(graph, std, "Div") else {
380                continue;
381            };
382            let dn = graph.node(div);
383            // The numerator is the Div operand that isn't `std`; it must be the
384            // output of a `Sub(x, mean)` (the same or a sibling of `sub_pow`).
385            let Some(num) = dn.input_values().find(|&v| v != std) else {
386                continue;
387            };
388            let Some(&sub_div) = subs.iter().find(|&&s| graph.node(s).outputs[0] == num) else {
389                continue;
390            };
391            let norm = dn.outputs[0];
392            let Some(mul) = Self::find_consumer(graph, norm, "Mul") else {
393                continue;
394            };
395            let scaled = graph.node(mul).outputs[0];
396            let Some(final_add) = Self::find_consumer(graph, scaled, "Add") else {
397                continue;
398            };
399
400            // Canonical node order (see doc). Append `sub_div` iff distinct.
401            let mut nodes = vec![
402                start, sub_pow, pow, var_rm, add_eps, sqrt, div, mul, final_add,
403            ];
404            if sub_div != sub_pow {
405                nodes.push(sub_div);
406            }
407            let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
408            // All matched nodes must be distinct (no accidental aliasing).
409            if matched_set.len() != nodes.len() {
410                continue;
411            }
412
413            // Safety rule: no matched node except `final_add` may have an output
414            // that escapes the matched set (external consumer or graph output).
415            let escapes = nodes.iter().any(|&nid| {
416                nid != final_add
417                    && graph.node(nid).outputs.iter().any(|&out| {
418                        graph.outputs.contains(&out)
419                            || graph
420                                .consumers(out)
421                                .into_iter()
422                                .any(|consumer| !matched_set.contains(&consumer))
423                    })
424            });
425            if escapes {
426                continue;
427            }
428
429            // The fused node reuses `final_add`'s single output; it must survive
430            // removal (graph output or an external consumer).
431            let fa = graph.node(final_add);
432            if fa.outputs.len() != 1 {
433                continue;
434            }
435            let output = fa.outputs[0];
436            let survives = graph.outputs.contains(&output)
437                || graph
438                    .consumers(output)
439                    .into_iter()
440                    .any(|consumer| !matched_set.contains(&consumer));
441            if !survives {
442                continue;
443            }
444
445            // External inputs in first-seen order (X, Scale, B, plus constants).
446            let produced: HashSet<ValueId> = nodes
447                .iter()
448                .flat_map(|&n| graph.node(n).outputs.iter().copied())
449                .collect();
450            let mut external = Vec::new();
451            let mut seen = HashSet::new();
452            for &nid in &nodes {
453                for iv in graph.node(nid).input_values() {
454                    if produced.contains(&iv) {
455                        continue;
456                    }
457                    if seen.insert(iv) {
458                        external.push(iv);
459                    }
460                }
461            }
462
463            let matched = PatternMatch {
464                nodes,
465                external_inputs: external,
466                output,
467            };
468
469            // Decline unless every schema assumption is provable.
470            if self.layernorm_spec(graph, &matched).is_none() {
471                continue;
472            }
473            return Some(matched);
474        }
475        None
476    }
477
478    /// DAG-aware SDPA-core matcher anchored on the `Softmax`.
479    ///
480    /// Recognizes the scaled-dot-product-attention core
481    /// `MatMul(Q, Kside) → (Mul|Div by scalar) → [Add(mask)] → Softmax(axis=-1)
482    /// → MatMul(probs, V)` and rewrites it to a single
483    /// `com.microsoft::FusedAttention[Q, K, V, (mask)]`. All recognition and
484    /// every decline guard live in [`Self::try_parse_attention`]; this wrapper
485    /// just packages the parsed pieces into a [`PatternMatch`].
486    fn try_match_attention(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
487        let p = self.try_parse_attention(graph, start)?;
488        Some(PatternMatch {
489            nodes: p.nodes,
490            external_inputs: p.external_inputs,
491            output: p.output,
492        })
493    }
494
495    /// DAG-aware exact-GELU matcher anchored on the `Erf` node. Packages the
496    /// parsed pieces from [`Self::try_parse_gelu`] into a [`PatternMatch`].
497    fn try_match_gelu(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
498        let p = self.try_parse_gelu(graph, start)?;
499        Some(PatternMatch {
500            nodes: p.nodes,
501            external_inputs: p.external_inputs,
502            output: p.output,
503        })
504    }
505
506    /// Parse (and fully validate) the SDPA core anchored on the `Softmax` node
507    /// `sm_start`, or `None` to **decline-to-fuse** when any structural or
508    /// numeric assumption cannot be proven from the graph. Model-agnostic:
509    /// purely structural / constant checks, no model-specific names.
510    ///
511    /// Decline guards (each returns `None`):
512    /// * anchor is not a single-in/single-out `Softmax`, or its `axis` is not
513    ///   provably the **last** axis (absent axis or non-last → decline; never
514    ///   guess the opset default);
515    /// * the softmax output is not the **left** operand of a following `MatMul`
516    ///   (the `probs · V` product);
517    /// * the score scaling is not a `Mul`/`Div` by a **concrete scalar f32
518    ///   constant** whose other operand is a `MatMul` output;
519    /// * an intervening `Add` (mask) whose scaled-scores branch can't be
520    ///   uniquely identified (both or neither operand parse as the score
521    ///   scaling);
522    /// * any interior value escapes the matched region (consumed outside it or
523    ///   is a graph output), or the matched nodes are not all distinct, or the
524    ///   fused output would not survive removal.
525    fn try_parse_attention(&self, graph: &Graph, sm_start: NodeId) -> Option<AttnParts> {
526        // Anchor: a Softmax normalizing over its LAST axis.
527        let sm = graph.try_node(sm_start)?;
528        if !Self::op_matches(sm, "Softmax") || sm.inputs.len() != 1 || sm.outputs.len() != 1 {
529            return None;
530        }
531        let sm_in = sm.inputs[0]?;
532        let sm_out = sm.outputs[0];
533        let rank = graph.value(sm_in).shape.len();
534        if rank == 0 {
535            return None;
536        }
537        // Require an explicit `axis` that resolves to the last dim. An absent
538        // axis is the opset default (1 for ≤12, -1 for ≥13) — not provably the
539        // last axis on a >2-D tensor — so we decline rather than guess.
540        let axis = sm.attr("axis").and_then(Attribute::as_int)?;
541        let axis = if axis < 0 { axis + rank as i64 } else { axis };
542        if axis != rank as i64 - 1 {
543            return None;
544        }
545
546        // Forward: out = probs · V. `sm_out` must be the LEFT operand of a
547        // following MatMul (matmul is not commutative; a right-operand softmax
548        // would be `V · probs`, a different op → decline).
549        let out_mm = graph.consumers(sm_out).into_iter().find(|&c| {
550            let n = graph.node(c);
551            Self::op_matches(n, "MatMul") && n.inputs.first() == Some(&Some(sm_out))
552        })?;
553        let out_mm_node = graph.node(out_mm);
554        if out_mm_node.inputs.len() != 2 || out_mm_node.outputs.len() != 1 {
555            return None;
556        }
557        let v = out_mm_node.inputs[1]?;
558        let output = out_mm_node.outputs[0];
559
560        // Backward: the Softmax input is produced either directly by the score
561        // scaling, or by a mask `Add` sitting between the scaling and Softmax.
562        let sm_in_prod = graph.value(sm_in).producer?;
563        let prod = graph.node(sm_in_prod);
564        let (scale_out, mask, mask_add) = if Self::op_matches(prod, "Add") && prod.inputs.len() == 2
565        {
566            let a = prod.inputs[0]?;
567            let b = prod.inputs[1]?;
568            // The scaled-scores operand is the one whose producer parses as
569            // the score scaling (`Mul`/`Div` scalar of a MatMul output);
570            // the other operand is the additive mask. Exactly one must
571            // qualify — otherwise the dataflow is ambiguous → decline.
572            let a_scale = graph
573                .value(a)
574                .producer
575                .is_some_and(|p| Self::parse_scale(graph, p).is_some());
576            let b_scale = graph
577                .value(b)
578                .producer
579                .is_some_and(|p| Self::parse_scale(graph, p).is_some());
580            match (a_scale, b_scale) {
581                (true, false) => (a, Some(b), Some(sm_in_prod)),
582                (false, true) => (b, Some(a), Some(sm_in_prod)),
583                _ => return None,
584            }
585        } else {
586            (sm_in, None, None)
587        };
588
589        // Score scaling: `scores * c` (Mul) or `scores / c` (Div), c a concrete
590        // scalar f32 constant, `scores` a MatMul output.
591        let scale_node_id = graph.value(scale_out).producer?;
592        let scale_node = graph.node(scale_node_id);
593        if scale_node.outputs.len() != 1 || scale_node.outputs[0] != scale_out {
594            return None;
595        }
596        let (scores_out, scale) = Self::parse_scale(graph, scale_node_id)?;
597
598        // Score MatMul: scores = Q · Kside. `parse_scale` already proved the
599        // producer is a MatMul; re-fetch it and read its operands.
600        let score_mm_id = graph.value(scores_out).producer?;
601        let score_mm = graph.node(score_mm_id);
602        if !Self::op_matches(score_mm, "MatMul")
603            || score_mm.inputs.len() != 2
604            || score_mm.outputs.len() != 1
605            || score_mm.outputs[0] != scores_out
606        {
607            return None;
608        }
609        let q = score_mm.inputs[0]?;
610        let k_side = score_mm.inputs[1]?;
611
612        // K handling: optionally absorb a clean single-consumer last-two-axis
613        // `Transpose` that produced Kᵀ; otherwise pass Kside through as an
614        // already-transposed K.
615        let (k, k_transposed, transpose_node) = Self::attention_k(graph, k_side, score_mm_id);
616
617        // Matched nodes, canonical order (anchor first): the four core ops then
618        // the optional mask `Add` and optional absorbed `Transpose`.
619        let mut nodes = vec![sm_start, score_mm_id, scale_node_id, out_mm];
620        if let Some(ma) = mask_add {
621            nodes.push(ma);
622        }
623        if let Some(t) = transpose_node {
624            nodes.push(t);
625        }
626        let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
627        if matched_set.len() != nodes.len() {
628            return None;
629        }
630
631        // Safety rule: every matched node except `out_mm` must have all outputs
632        // consumed solely within the matched set (no external consumer, no
633        // graph output) — fusion must not delete a value observed elsewhere.
634        let escapes = nodes.iter().any(|&nid| {
635            nid != out_mm
636                && graph.node(nid).outputs.iter().any(|&o| {
637                    graph.outputs.contains(&o)
638                        || graph
639                            .consumers(o)
640                            .into_iter()
641                            .any(|consumer| !matched_set.contains(&consumer))
642                })
643        });
644        if escapes {
645            return None;
646        }
647
648        // The fused output (out_mm's single output) must survive removal.
649        let survives = graph.outputs.contains(&output)
650            || graph
651                .consumers(output)
652                .into_iter()
653                .any(|consumer| !matched_set.contains(&consumer));
654        if !survives {
655            return None;
656        }
657
658        // Schema-order external inputs: [Q, K, V] (+ mask).
659        let mut external = vec![q, k, v];
660        if let Some(m) = mask {
661            external.push(m);
662        }
663
664        Some(AttnParts {
665            nodes,
666            q,
667            k,
668            v,
669            mask,
670            scale,
671            k_transposed,
672            output,
673            external_inputs: external,
674        })
675    }
676
677    /// Parse a score-scaling node into `(scores_value, scale_multiplier)`, or
678    /// `None` if it is not a `Mul`/`Div` by a **concrete scalar f32 constant**
679    /// whose other operand is produced by a `MatMul`. `Div(scores, c)` yields
680    /// `1/c` (declining `c == 0`); `Mul` yields `c`. The scores-must-be-a-MatMul
681    /// check is what disambiguates the scaled branch from the mask branch (a
682    /// mask precompute is often itself a `Mul`, but not of a MatMul output).
683    fn parse_scale(graph: &Graph, node_id: NodeId) -> Option<(ValueId, f32)> {
684        let n = graph.node(node_id);
685        if n.inputs.len() != 2 || n.outputs.len() != 1 {
686            return None;
687        }
688        let (scores_out, scale) = if Self::op_matches(n, "Div") {
689            let num = n.inputs[0]?;
690            let den = n.inputs[1]?;
691            let c = read_scalar_const_f32(graph, den)?;
692            if c == 0.0 {
693                return None;
694            }
695            (num, 1.0 / c)
696        } else if Self::op_matches(n, "Mul") {
697            let x = n.inputs[0]?;
698            let y = n.inputs[1]?;
699            match (
700                read_scalar_const_f32(graph, x),
701                read_scalar_const_f32(graph, y),
702            ) {
703                (None, Some(c)) => (x, c),
704                (Some(c), None) => (y, c),
705                // both const (fold elsewhere) or neither const → not a scale.
706                _ => return None,
707            }
708        } else {
709            return None;
710        };
711        // The scaled operand must be a MatMul output (the score product).
712        let prod = graph.value(scores_out).producer?;
713        if !Self::op_matches(graph.node(prod), "MatMul") {
714            return None;
715        }
716        Some((scores_out, scale))
717    }
718
719    /// Decide the fused node's `K` input and `k_transposed` flag. If `k_side`
720    /// (the score MatMul's second operand) is produced by a clean last-two-axis
721    /// `Transpose` consumed **only** by the score MatMul, absorb it: `K` becomes
722    /// the transpose's input in `[…, seq_k, head_dim]` layout and the kernel
723    /// transposes internally (`k_transposed = false`, transpose node removed).
724    /// Otherwise `K = k_side` is used as-is as an already-transposed Kᵀ
725    /// (`k_transposed = true`, nothing absorbed).
726    fn attention_k(
727        graph: &Graph,
728        k_side: ValueId,
729        score_mm_id: NodeId,
730    ) -> (ValueId, bool, Option<NodeId>) {
731        if let Some(t_id) = graph.value(k_side).producer {
732            let t = graph.node(t_id);
733            if Self::op_matches(t, "Transpose")
734                && t.inputs.len() == 1
735                && t.outputs.len() == 1
736                && t.outputs[0] == k_side
737                && graph.consumers(k_side) == [score_mm_id]
738                && let Some(perm) = t.attr("perm").and_then(Attribute::as_ints)
739                && is_last2_swap_perm(perm)
740                && let Some(kin) = t.inputs[0]
741            {
742                return (kin, false, Some(t_id));
743            }
744        }
745        (k_side, true, None)
746    }
747
748    /// Extract the `[Q, K, V]` (+ optional `[mask]`) inputs and the
749    /// `scale`/`k_transposed` attributes for a matched SDPA core, or `None` to
750    /// decline. Re-parses from the anchor (`m.nodes[0]`, the Softmax) so the
751    /// spec is single-sourced with the matcher, and confirms the re-parse
752    /// covers exactly the same node set.
753    fn attention_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
754        let start = *m.nodes.first()?;
755        let p = self.try_parse_attention(graph, start)?;
756        if p.nodes != m.nodes {
757            return None;
758        }
759        let mut inputs: Vec<Option<ValueId>> = vec![Some(p.q), Some(p.k), Some(p.v)];
760        if let Some(mask) = p.mask {
761            inputs.push(Some(mask));
762        }
763        let mut attributes = HashMap::new();
764        attributes.insert("scale".to_string(), Attribute::Float(p.scale));
765        attributes.insert(
766            "k_transposed".to_string(),
767            Attribute::Int(if p.k_transposed { 1 } else { 0 }),
768        );
769        Some((inputs, attributes))
770    }
771
772    /// Parse (and fully validate) the exact-GELU `Erf` decomposition anchored on
773    /// the `Erf` node `erf_start`, or `None` to **decline-to-fuse** when any
774    /// structural or numeric assumption cannot be proven from the graph.
775    /// Model-agnostic: purely structural / constant checks.
776    ///
777    /// Recognizes the diamond `out = (0.5·X) · (1 + Erf(X / √2))`, i.e.
778    /// `X → Div(X, √2) → Erf → Add(·, 1) → Mul(0.5·X, ·)` where the SAME `X`
779    /// also feeds `0.5·X = Mul(X, 0.5)`. The equivalent constant encodings
780    /// (`Mul(X, 1/√2)` for the inner scale, `Div(X, 2)` for the half scale) are
781    /// accepted too, since they are numerically identical.
782    ///
783    /// Decline guards (each returns `None`):
784    /// * anchor is not a single-in/single-out `Erf`;
785    /// * the `Erf` input is not `X / √2` (`Div(X, √2)` or `Mul(X, 1/√2)` with a
786    ///   concrete scalar f32 constant);
787    /// * the `Erf` output is not consumed by an `Add(erf, 1.0)` (`1.0` a
788    ///   concrete scalar constant);
789    /// * that `Add`'s output is not consumed by a `Mul` whose other operand is
790    ///   `0.5·X` (`Mul(X, 0.5)` or `Div(X, 2.0)`);
791    /// * the `0.5·X` operand's `X` is **not the same value** that feeds the
792    ///   `Erf` branch (the diamond is not closed);
793    /// * any interior value escapes the matched region, the matched nodes are
794    ///   not all distinct, or the fused output would not survive removal.
795    fn try_parse_gelu(&self, graph: &Graph, erf_start: NodeId) -> Option<GeluParts> {
796        // Anchor: a single-in/single-out `Erf`.
797        let erf = graph.try_node(erf_start)?;
798        if !Self::op_matches(erf, "Erf") || erf.inputs.len() != 1 || erf.outputs.len() != 1 {
799            return None;
800        }
801        let erf_in = erf.inputs[0]?;
802        let erf_out = erf.outputs[0];
803
804        // Backward: `erf_in = X / √2`, via `Div(X, √2)` or `Mul(X, 1/√2)`.
805        let inner_id = graph.value(erf_in).producer?;
806        let inner = graph.node(inner_id);
807        if inner.outputs.first() != Some(&erf_in) {
808            return None;
809        }
810        let x = Self::parse_scaled(graph, inner, &[("Div", SQRT_2), ("Mul", FRAC_1_SQRT_2)])?;
811
812        // Forward: `erf_out` consumed by `Add(erf_out, 1.0)`.
813        let add1_id = Self::find_consumer(graph, erf_out, "Add")?;
814        let add1 = graph.node(add1_id);
815        if add1.inputs.len() != 2 || add1.outputs.len() != 1 {
816            return None;
817        }
818        let one = add1.input_values().find(|&v| v != erf_out)?;
819        if !approx(read_scalar_const_f32(graph, one)?, 1.0) {
820            return None;
821        }
822        let add1_out = add1.outputs[0];
823
824        // Forward: `add1_out` consumed by `Mul(0.5·X, add1_out)`.
825        let outer_id = Self::find_consumer(graph, add1_out, "Mul")?;
826        let outer = graph.node(outer_id);
827        if outer.inputs.len() != 2 || outer.outputs.len() != 1 {
828            return None;
829        }
830        let half = outer.input_values().find(|&v| v != add1_out)?;
831        let output = outer.outputs[0];
832
833        // The half-scale operand must be `0.5·X` (`Mul(X, 0.5)` or `Div(X, 2.0)`)
834        // over the SAME `X` that feeds the `Erf` branch — this closes the
835        // diamond and confirms a real GELU, not a coincidental op sequence.
836        let half_id = graph.value(half).producer?;
837        let half_node = graph.node(half_id);
838        if half_node.outputs.first() != Some(&half) {
839            return None;
840        }
841        let x2 = Self::parse_scaled(graph, half_node, &[("Mul", 0.5), ("Div", 2.0)])?;
842        if x2 != x {
843            return None;
844        }
845
846        // Canonical node order (anchor first): [Erf, inner, Add, outer, half].
847        let nodes = vec![erf_start, inner_id, add1_id, outer_id, half_id];
848        let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
849        if matched_set.len() != nodes.len() {
850            return None;
851        }
852
853        // Safety rule: every matched node except the final `outer` `Mul` must
854        // have all outputs consumed solely within the matched set (no external
855        // consumer, no graph output).
856        let escapes = nodes.iter().any(|&nid| {
857            nid != outer_id
858                && graph.node(nid).outputs.iter().any(|&o| {
859                    graph.outputs.contains(&o)
860                        || graph
861                            .consumers(o)
862                            .into_iter()
863                            .any(|consumer| !matched_set.contains(&consumer))
864                })
865        });
866        if escapes {
867            return None;
868        }
869
870        // The fused output (outer's single output) must survive removal.
871        let survives = graph.outputs.contains(&output)
872            || graph
873                .consumers(output)
874                .into_iter()
875                .any(|consumer| !matched_set.contains(&consumer));
876        if !survives {
877            return None;
878        }
879
880        Some(GeluParts {
881            nodes,
882            x,
883            output,
884            external_inputs: vec![x],
885        })
886    }
887
888    /// If `node` computes `x · k` (`Mul`) or `x / k` (`Div`) for one of the
889    /// allowed `(op_type, constant)` forms, return the data operand `x`. The
890    /// constant must be a **strict scalar** f32 initializer approximately equal
891    /// to the expected value. `Mul` is commutative (the constant may be either
892    /// operand); `Div` is not (the constant must be the divisor). Any other
893    /// shape → `None`.
894    fn parse_scaled(graph: &Graph, node: &Node, forms: &[(&str, f32)]) -> Option<ValueId> {
895        if node.inputs.len() != 2 || node.outputs.len() != 1 {
896            return None;
897        }
898        let a = node.inputs[0]?;
899        let b = node.inputs[1]?;
900        for &(op, k) in forms {
901            if !Self::op_matches(node, op) {
902                continue;
903            }
904            // The scalar constant is valid as the second operand for both forms
905            // (the `Div` divisor, or a `Mul` factor); `Mul` is commutative, so
906            // it may additionally be the first operand.
907            if read_scalar_const_f32(graph, b).is_some_and(|c| approx(c, k)) {
908                return Some(a);
909            }
910            if op == "Mul" && read_scalar_const_f32(graph, a).is_some_and(|c| approx(c, k)) {
911                return Some(b);
912            }
913        }
914        None
915    }
916
917    /// Extract the schema-conformant `[X]` input (no attributes) for a matched
918    /// exact-GELU decomposition, or `None` to decline. Re-parses from the anchor
919    /// (`m.nodes[0]`, the `Erf`) so the spec is single-sourced with the matcher,
920    /// and confirms the re-parse covers exactly the same node set.
921    fn gelu_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
922        let start = *m.nodes.first()?;
923        let p = self.try_parse_gelu(graph, start)?;
924        if p.nodes != m.nodes {
925            return None;
926        }
927        Some((vec![Some(p.x)], HashMap::new()))
928    }
929
930    /// Attempt to grow a match whose first node is `start`.
931    fn try_match_from(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
932        let start_node = graph.try_node(start)?;
933        if !Self::op_matches(start_node, &self.ops[0]) {
934            return None;
935        }
936
937        let mut chain = vec![start];
938        let mut chain_set: HashSet<NodeId> = HashSet::from([start]);
939
940        for op in &self.ops[1..] {
941            let prev = *chain.last().unwrap();
942            // Deterministic: pick the lowest-id successor of `prev` that has the
943            // required op type and is not already in the chain.
944            let mut succ_ids = graph.successors(prev);
945            succ_ids.sort_by_key(|n| n.0);
946            let next = succ_ids
947                .into_iter()
948                .find(|&s| !chain_set.contains(&s) && Self::op_matches(graph.node(s), op))?;
949            chain.push(next);
950            chain_set.insert(next);
951        }
952
953        // Safety rule: no non-final matched node may have an output that escapes
954        // the matched set (external consumer or graph output).
955        for &nid in &chain[..chain.len() - 1] {
956            for &out in &graph.node(nid).outputs {
957                if graph.outputs.contains(&out) {
958                    return None;
959                }
960                if graph
961                    .consumers(out)
962                    .into_iter()
963                    .any(|consumer| !chain_set.contains(&consumer))
964                {
965                    return None;
966                }
967            }
968        }
969
970        // The fused node reuses the last node's single output.
971        let last = *chain.last().unwrap();
972        let last_node = graph.node(last);
973        if last_node.outputs.len() != 1 {
974            return None;
975        }
976        let output = last_node.outputs[0];
977
978        // The output must survive removal of the matched nodes: it is either a
979        // graph output or has a consumer outside the matched set.
980        let survives = graph.outputs.contains(&output)
981            || graph
982                .consumers(output)
983                .into_iter()
984                .any(|consumer| !chain_set.contains(&consumer));
985        if !survives {
986            return None;
987        }
988
989        // Collect external inputs in first-seen order.
990        let produced: HashSet<ValueId> = chain
991            .iter()
992            .flat_map(|&n| graph.node(n).outputs.iter().copied())
993            .collect();
994        let mut external = Vec::new();
995        let mut seen = HashSet::new();
996        for &nid in &chain {
997            for iv in graph.node(nid).input_values() {
998                if produced.contains(&iv) {
999                    continue;
1000                }
1001                if seen.insert(iv) {
1002                    external.push(iv);
1003                }
1004            }
1005        }
1006
1007        let matched = PatternMatch {
1008            nodes: chain,
1009            external_inputs: external,
1010            output,
1011        };
1012
1013        // Decline-to-fuse: never return a match whose rewrite assumptions can't
1014        // be *proven* from the graph. Declining here (rather than erroring later
1015        // in `apply_fusion`) leaves the original ops in place and lets the
1016        // fixpoint loop skip this occurrence instead of aborting the whole pass.
1017        if !self.match_is_fusable(graph, &matched) {
1018            return None;
1019        }
1020
1021        Some(matched)
1022    }
1023
1024    /// Whether a matched occurrence may be fused, or must **decline-to-fuse**
1025    /// because a rewrite assumption can't be proven from the graph. Model-
1026    /// agnostic: purely structural / shape checks, no model-specific logic.
1027    fn match_is_fusable(&self, graph: &Graph, m: &PatternMatch) -> bool {
1028        match self.kind {
1029            RewriteKind::LayerNorm => self.layernorm_spec(graph, m).is_some(),
1030            RewriteKind::Attention => self.attention_spec(graph, m).is_some(),
1031            RewriteKind::Gelu => self.gelu_spec(graph, m).is_some(),
1032            RewriteKind::Structural => {
1033                // The MatMul+Add → FusedMatMulBias and MatMul+Add+Relu →
1034                // FusedGemm rewrites both need a bias broadcast guard (the
1035                // trailing Relu is elementwise and shape-neutral); other
1036                // structural rewrites are unconstrained.
1037                if self.replacement == "FusedMatMulBias" || self.replacement == "FusedGemm" {
1038                    self.matmul_bias_broadcast_ok(graph, m)
1039                } else {
1040                    true
1041                }
1042            }
1043        }
1044    }
1045
1046    /// Decline the `MatMul + Add → FusedMatMulBias` (and
1047    /// `MatMul + Add + Relu → FusedGemm`) fusion unless the `Add`'s non-matmul
1048    /// (bias) operand broadcasts *into* the MatMul output shape **without
1049    /// expanding it** — i.e. the bias is a valid trailing broadcast of the
1050    /// matmul output (`[N]`, `[1, N]`, same-shape, scalar, …). The optional
1051    /// trailing `Relu` is elementwise and shape-neutral, so the same guard
1052    /// applies to both fusions.
1053    ///
1054    /// A standalone `Add` broadcasts *both* operands up to their joint shape, so
1055    /// a bias with extra leading dims, or a batch axis where the output is
1056    /// extent-1, would grow the semantic result. The fused kernel and shape rule
1057    /// instead assume the output equals the *matmul* shape and right-align the
1058    /// bias, silently truncating the excess — wrong values *and* a too-small
1059    /// output. We therefore only fuse when every overlapping axis is provably
1060    /// non-expanding (identical dim, or bias extent 1). Any unknown/symbolic dim
1061    /// that can't be proven equal makes us decline conservatively.
1062    fn matmul_bias_broadcast_ok(&self, graph: &Graph, m: &PatternMatch) -> bool {
1063        // The matched pattern starts with `[MatMul, Add, ...]` (an optional
1064        // trailing `Relu` for FusedGemm). The MatMul output is the intermediate
1065        // value the Add consumes, and the other Add operand is bias.
1066        let (Some(&matmul), Some(&add)) = (m.nodes.first(), m.nodes.get(1)) else {
1067            return false;
1068        };
1069        let mm_out = graph.node(matmul).outputs[0];
1070        let Some(bias) = graph.node(add).input_values().find(|&v| v != mm_out) else {
1071            return false;
1072        };
1073        let mm_shape = &graph.value(mm_out).shape;
1074        let bias_shape = &graph.value(bias).shape;
1075
1076        // More bias dims than the output → leading dims would expand the result.
1077        if bias_shape.len() > mm_shape.len() {
1078            return false;
1079        }
1080        // Right-align the bias against the output; every overlapping axis must be
1081        // provably non-expanding: identical extent, or bias extent 1 (which just
1082        // broadcasts up into the existing output dim).
1083        let offset = mm_shape.len() - bias_shape.len();
1084        for (i, &bdim) in bias_shape.iter().enumerate() {
1085            let mdim = mm_shape[offset + i];
1086            if bdim == mdim {
1087                continue;
1088            }
1089            if bdim.as_static() == Some(1) {
1090                continue;
1091            }
1092            return false;
1093        }
1094        true
1095    }
1096
1097    /// Apply a match: remove the matched nodes and insert the replacement,
1098    /// reusing `m.output` so downstream consumers and graph outputs stay wired.
1099    pub fn apply_fusion(&self, graph: &mut Graph, m: &PatternMatch) -> Result<()> {
1100        self.apply_fusion_returning_id(graph, m).map(|_| ())
1101    }
1102
1103    fn apply_fusion_returning_id(&self, graph: &mut Graph, m: &PatternMatch) -> Result<NodeId> {
1104        let output = m.output;
1105
1106        // For schema-aware rewrites, extract the kernel-signature inputs and
1107        // attributes *before* the matched nodes are removed.
1108        let (inputs, attributes) = match self.kind {
1109            RewriteKind::Structural => (
1110                m.external_inputs.iter().map(|&v| Some(v)).collect(),
1111                HashMap::new(),
1112            ),
1113            RewriteKind::LayerNorm => self
1114                .layernorm_spec(graph, m)
1115                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1116            RewriteKind::Attention => self
1117                .attention_spec(graph, m)
1118                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1119            RewriteKind::Gelu => self
1120                .gelu_spec(graph, m)
1121                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1122        };
1123
1124        // Remove in reverse (last-first): a node's consumers are gone before it,
1125        // so intermediate values are cleanly garbage-collected. `output` itself
1126        // survives because it is a graph output or has an external consumer.
1127        for &nid in m.nodes.iter().rev() {
1128            graph.remove_node(nid);
1129        }
1130
1131        if graph.try_value(output).is_none() {
1132            return Err(crate::error::OptimizerError::Fusion(self.name.clone()));
1133        }
1134
1135        let mut fused = Node::new(NodeId(0), self.replacement.clone(), inputs, vec![output]);
1136        fused.attributes = attributes;
1137        // Production patterns emit in the private contrib domain. Unit tests
1138        // can override it to exercise a replacement that can match again.
1139        #[cfg(not(test))]
1140        {
1141            fused.domain = CONTRIB_DOMAIN.to_string();
1142        }
1143        #[cfg(test)]
1144        {
1145            fused.domain = self.replacement_domain.clone();
1146        }
1147        Ok(graph.insert_node(fused))
1148    }
1149
1150    /// Extract the schema-conformant `[X, Scale, B]` inputs and the
1151    /// `axis`/`epsilon` attributes for a matched LayerNorm decomposition, or
1152    /// `None` if any schema-aware assumption can't be proven — in which case the
1153    /// pattern **declines to fuse** and the original ops are kept intact.
1154    ///
1155    /// The matched nodes are in the canonical order produced by
1156    /// [`Self::try_match_layernorm`]:
1157    /// `0:ReduceMean(x) → mean`, `1:Sub(x, mean) → diff_pow`,
1158    /// `2:Pow(diff_pow, 2) → sq`, `3:ReduceMean(sq) → var`,
1159    /// `4:Add(var, eps) → vare`, `5:Sqrt → std`, `6:Div(diff_div, std) → norm`,
1160    /// `7:Mul(norm, Scale) → scaled`, `8:Add(scaled, B) → out`, and an optional
1161    /// `9:Sub(x, mean) → diff_div` — present only when the numerator uses a
1162    /// **second, distinct** `Sub` (the `bert_toy`-style split-diff variant). In
1163    /// the canonical 9-op diamond the single `Sub` feeds both branches, so
1164    /// `diff_div == diff_pow`.
1165    ///
1166    /// * **X** is the (shared) `Sub` operand that is not `mean`; **Scale** the
1167    ///   `Mul` operand that is not the `Div` output; **B** the final `Add`
1168    ///   operand that is not the `Mul` output. Order-independent disambiguation.
1169    /// * **axis** must resolve to a *single concrete* axis read from the first
1170    ///   `ReduceMean`'s `axes` **attribute** (axes-as-input / multi-axis / absent
1171    ///   → decline; never silently assume `-1`).
1172    /// * **epsilon** must be readable as a concrete f32 scalar constant (else
1173    ///   decline; never silently assume `1e-5`).
1174    fn layernorm_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
1175        let nodes = &m.nodes;
1176        if nodes.len() != 9 && nodes.len() != 10 {
1177            return None;
1178        }
1179        let rm1 = graph.node(nodes[0]);
1180        let sub_pow = graph.node(nodes[1]);
1181        let pow = graph.node(nodes[2]);
1182        let rm2 = graph.node(nodes[3]);
1183        let add_eps = graph.node(nodes[4]);
1184        let div = graph.node(nodes[6]);
1185        let mul = graph.node(nodes[7]);
1186        let final_add = graph.node(nodes[8]);
1187        // The numerator `Sub` is a distinct 10th node in the split-diff variant,
1188        // otherwise it is the same `Sub` that feeds the variance branch.
1189        let sub_div = if nodes.len() == 10 {
1190            graph.node(nodes[9])
1191        } else {
1192            sub_pow
1193        };
1194
1195        let mean = rm1.outputs[0];
1196        let diff_pow = sub_pow.outputs[0];
1197        let diff_div = sub_div.outputs[0];
1198        let var = rm2.outputs[0];
1199        let norm = div.outputs[0];
1200        let scaled = mul.outputs[0];
1201
1202        // Positive structural guard: confirm the interior data-flow really is the
1203        // LayerNorm decomposition, not just a coincidental op-type sequence. Each
1204        // consumer must actually read the interior tensor it is meant to consume.
1205        if !sub_pow.input_values().any(|v| v == mean)
1206            || !sub_div.input_values().any(|v| v == mean)
1207            || !pow.input_values().any(|v| v == diff_pow)
1208            || !div.input_values().any(|v| v == diff_div)
1209            || !mul.input_values().any(|v| v == norm)
1210            || !final_add.input_values().any(|v| v == scaled)
1211        {
1212            return None;
1213        }
1214
1215        // Order-independent X/Scale/B disambiguation: each picks the operand that
1216        // is NOT the matched interior tensor. Both `Sub`s must subtract `mean`
1217        // from the *same* `X`.
1218        let x = sub_pow.input_values().find(|&v| v != mean)?;
1219        if !sub_div.input_values().any(|v| v == x) {
1220            return None;
1221        }
1222
1223        // Operand-ORDER guard: each centering `Sub` must compute `diff = x - mean`
1224        // (minuend `x` first, subtrahend `mean` second), NOT `mean - x`. Membership
1225        // alone (checked above) would accept a reversed `Sub(mean, x)` and silently
1226        // rewrite it to a sign-flipped LayerNormalization. `Sub` is exactly binary,
1227        // so require input[0] == X and input[1] == mean on BOTH the variance-branch
1228        // and numerator-branch Subs. Ambiguous arity (not exactly two inputs) → decline.
1229        let subtracts_x_minus_mean = |sub: &Node| -> bool {
1230            matches!(sub.inputs.as_slice(), [Some(a), Some(b)] if *a == x && *b == mean)
1231        };
1232        if !subtracts_x_minus_mean(sub_pow) || !subtracts_x_minus_mean(sub_div) {
1233            return None;
1234        }
1235        let scale = mul.input_values().find(|&v| v != norm)?;
1236        let bias = final_add.input_values().find(|&v| v != scaled)?;
1237
1238        // epsilon guard: must be a concrete f32 scalar constant (no 1e-5 default).
1239        let eps_val = add_eps.input_values().find(|&v| v != var)?;
1240        let epsilon = read_scalar_f32(graph, eps_val)?;
1241
1242        // axis guard: a single concrete axis from the ReduceMean `axes` ATTRIBUTE.
1243        // Absent (axes-as-input at opset ≥ 18, or reduce-all) or multi-axis →
1244        // decline rather than silently defaulting to `-1`.
1245        let axes = rm1.attr("axes").and_then(Attribute::as_ints)?;
1246        let [axis] = axes else {
1247            return None;
1248        };
1249
1250        let mut attributes = HashMap::new();
1251        attributes.insert("axis".to_string(), Attribute::Int(*axis));
1252        attributes.insert("epsilon".to_string(), Attribute::Float(epsilon));
1253
1254        Some((vec![Some(x), Some(scale), Some(bias)], attributes))
1255    }
1256}
1257
1258/// Read a scalar (or leading) f32 element from an inline float initializer, if
1259/// `value` is backed by one. Used to fold a constant `epsilon` into an attribute.
1260fn read_scalar_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1261    match graph.initializers.get(&value)? {
1262        WeightRef::Inline(t) if t.dtype == DataType::Float32 && t.data.len() >= 4 => {
1263            Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1264        }
1265        _ => None,
1266    }
1267}
1268
1269/// The parsed pieces of a matched SDPA core (see
1270/// [`FusionPattern::try_parse_attention`]).
1271#[derive(Clone, Debug)]
1272struct AttnParts {
1273    /// All matched node ids, canonical order (anchor first):
1274    /// `[softmax, score_mm, scale_node, out_mm]` then optional `mask_add` and
1275    /// optional absorbed `transpose`.
1276    nodes: Vec<NodeId>,
1277    q: ValueId,
1278    k: ValueId,
1279    v: ValueId,
1280    mask: Option<ValueId>,
1281    scale: f32,
1282    k_transposed: bool,
1283    output: ValueId,
1284    external_inputs: Vec<ValueId>,
1285}
1286
1287/// The parsed pieces of a matched exact-GELU decomposition (see
1288/// [`FusionPattern::try_parse_gelu`]).
1289#[derive(Clone, Debug)]
1290struct GeluParts {
1291    /// All matched node ids, canonical order (anchor first):
1292    /// `[erf, inner_scale, add_one, outer_mul, half_scale]`.
1293    nodes: Vec<NodeId>,
1294    /// The single external input `X` (feeds both the `Erf` branch and `0.5·X`).
1295    x: ValueId,
1296    /// The fused node's output (the outer `Mul`'s single output).
1297    output: ValueId,
1298    external_inputs: Vec<ValueId>,
1299}
1300/// `None`. Stricter than [`read_scalar_f32`]: the score scale must be a genuine
1301/// scalar, so a multi-element initializer (whose first element we'd otherwise
1302/// silently read) is declined.
1303fn read_scalar_const_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1304    match graph.initializers.get(&value)? {
1305        WeightRef::Inline(t) if t.dtype == DataType::Float32 => {
1306            let numel: usize = t.dims.iter().product();
1307            if numel != 1 || t.data.len() < 4 {
1308                return None;
1309            }
1310            Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1311        }
1312        _ => None,
1313    }
1314}
1315
1316/// Whether `perm` is a clean "swap the last two axes" permutation
1317/// (`[0, 1, …, r-3, r-1, r-2]`) for a rank-`perm.len()` tensor. Any other
1318/// permutation (including one that also moves batch/head axes) is not a plain
1319/// Kᵀ and is left un-absorbed.
1320fn is_last2_swap_perm(perm: &[i64]) -> bool {
1321    let r = perm.len();
1322    if r < 2 {
1323        return false;
1324    }
1325    for (i, &p) in perm.iter().enumerate().take(r - 2) {
1326        if p != i as i64 {
1327            return false;
1328        }
1329    }
1330    perm[r - 2] == (r - 1) as i64 && perm[r - 1] == (r - 2) as i64
1331}
1332
1333/// The default device-independent fusion patterns.
1334///
1335/// Ordered most-specific-first so `MatMul+Add+Relu` is captured before the
1336/// shorter `MatMul+Add`. `Residual+LayerNorm` remains deferred to Phase 2b/3.
1337pub fn default_fusion_patterns() -> Vec<FusionPattern> {
1338    vec![
1339        // Attention first: the SDPA core consumes plain MatMul/Softmax nodes, so
1340        // recognize it before the MatMul+Add(+Relu) rewrites can claim any of
1341        // its MatMuls.
1342        FusionPattern::attention(),
1343        FusionPattern::new("MatMul+Bias+Relu", &["MatMul", "Add", "Relu"], "FusedGemm"),
1344        FusionPattern::layernorm(),
1345        FusionPattern::gelu(),
1346        FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias"),
1347    ]
1348}
1349
1350/// The op-fusion pass: applies each [`FusionPattern`] to fixpoint.
1351#[derive(Clone, Debug)]
1352pub struct OpFusion {
1353    patterns: Vec<FusionPattern>,
1354}
1355
1356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1357enum ScanCandidateSource {
1358    Initial,
1359    Revisit,
1360}
1361
1362impl Default for OpFusion {
1363    fn default() -> Self {
1364        Self::new()
1365    }
1366}
1367
1368impl OpFusion {
1369    /// The pass with the default pattern set.
1370    pub fn new() -> Self {
1371        Self {
1372            patterns: default_fusion_patterns(),
1373        }
1374    }
1375
1376    /// The pass with a custom pattern set (used by tests / future callers).
1377    pub fn with_patterns(patterns: Vec<FusionPattern>) -> Self {
1378        Self { patterns }
1379    }
1380
1381    fn run_resumable(
1382        &self,
1383        graph: &mut Graph,
1384        mut observe_fusion: impl FnMut(&str, ScanCandidateSource, NodeId, &[NodeId], &[NodeId], NodeId),
1385    ) -> Result<()> {
1386        for pattern in &self.patterns {
1387            let candidates: Vec<u32> = graph.nodes.keys().map(|id| id.0).collect();
1388            let mut cursor = 0;
1389            let mut revisits = BTreeSet::new();
1390            loop {
1391                let initial = candidates.get(cursor).copied();
1392                let revisit = revisits.first().copied();
1393                let (raw_id, source) = match (initial, revisit) {
1394                    (None, None) => break,
1395                    (Some(id), None) => {
1396                        cursor += 1;
1397                        (id, ScanCandidateSource::Initial)
1398                    }
1399                    (None, Some(_)) => {
1400                        (revisits.pop_first().unwrap(), ScanCandidateSource::Revisit)
1401                    }
1402                    (Some(id), Some(revisit)) if id <= revisit => {
1403                        cursor += 1;
1404                        if id == revisit {
1405                            revisits.pop_first();
1406                        }
1407                        (id, ScanCandidateSource::Initial)
1408                    }
1409                    (Some(_), Some(_)) => {
1410                        (revisits.pop_first().unwrap(), ScanCandidateSource::Revisit)
1411                    }
1412                };
1413                let start = NodeId(raw_id);
1414                let Some(matched) = pattern.try_match_at(graph, start) else {
1415                    continue;
1416                };
1417
1418                let affected = pattern.affected_candidate_starts(graph, &matched);
1419                let fused_id = pattern.apply_fusion_returning_id(graph, &matched)?;
1420                observe_fusion(
1421                    pattern.pattern_name(),
1422                    source,
1423                    start,
1424                    &matched.nodes,
1425                    &affected,
1426                    fused_id,
1427                );
1428
1429                // The ordered set is the source of truth for resolution order:
1430                // any lower affected start is reconsidered before an untouched
1431                // higher-id candidate, exactly like a restart from arena slot 0.
1432                revisits.insert(fused_id.0);
1433                for candidate in affected {
1434                    if graph.try_node(candidate).is_some() {
1435                        revisits.insert(candidate.0);
1436                    }
1437                }
1438            }
1439        }
1440        Ok(())
1441    }
1442
1443    #[cfg(test)]
1444    fn run_with_fusion_observer(
1445        &self,
1446        graph: &mut Graph,
1447        observe_fusion: impl FnMut(&str, ScanCandidateSource, NodeId, &[NodeId], &[NodeId], NodeId),
1448    ) -> Result<()> {
1449        self.run_resumable(graph, observe_fusion)
1450    }
1451}
1452
1453impl OptimizationPass for OpFusion {
1454    fn name(&self) -> &str {
1455        "OpFusion"
1456    }
1457
1458    fn run(&self, graph: &mut Graph, _ctx: &PassContext) -> Result<()> {
1459        self.run_resumable(graph, |_, _, _, _, _, _| {})
1460    }
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466    use onnx_runtime_ir::{DataType, Node, NodeId, TensorData, static_shape};
1467
1468    fn val(g: &mut Graph, name: &str) -> ValueId {
1469        g.create_named_value(name, DataType::Float32, static_shape([4]))
1470    }
1471
1472    /// Build a linear MatMul+Add ending in a graph output.
1473    /// Returns (graph, matmul_out_value).
1474    fn matmul_add_graph() -> Graph {
1475        let mut g = Graph::new();
1476        g.opset_imports.insert(String::new(), 17);
1477        let a = val(&mut g, "a");
1478        let w = val(&mut g, "w");
1479        let bias = val(&mut g, "bias");
1480        g.add_input(a);
1481        g.add_input(w);
1482        g.add_input(bias);
1483
1484        let m = val(&mut g, "m");
1485        g.insert_node(Node::new(
1486            NodeId(0),
1487            "MatMul",
1488            vec![Some(a), Some(w)],
1489            vec![m],
1490        ));
1491        let out = val(&mut g, "out");
1492        g.insert_node(Node::new(
1493            NodeId(0),
1494            "Add",
1495            vec![Some(m), Some(bias)],
1496            vec![out],
1497        ));
1498        g.add_output(out);
1499        g
1500    }
1501
1502    #[test]
1503    fn fuses_matmul_add() {
1504        let mut g = matmul_add_graph();
1505        assert_eq!(g.num_nodes(), 2);
1506        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1507        assert_eq!(g.num_nodes(), 1);
1508        let fused = g.nodes.values().next().unwrap();
1509        assert_eq!(fused.op_type, "FusedMatMulBias");
1510        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1511        // Inputs are [a, w, bias].
1512        assert_eq!(fused.inputs.len(), 3);
1513        assert!(g.validate().is_ok());
1514        // Output still a graph output.
1515        assert_eq!(g.outputs.len(), 1);
1516        assert_eq!(fused.outputs, g.outputs);
1517    }
1518
1519    #[test]
1520    fn fuses_matmul_add_relu_before_matmul_add() {
1521        let mut g = Graph::new();
1522        g.opset_imports.insert(String::new(), 17);
1523        let a = val(&mut g, "a");
1524        let w = val(&mut g, "w");
1525        let bias = val(&mut g, "bias");
1526        g.add_input(a);
1527        g.add_input(w);
1528        g.add_input(bias);
1529        let m = val(&mut g, "m");
1530        g.insert_node(Node::new(
1531            NodeId(0),
1532            "MatMul",
1533            vec![Some(a), Some(w)],
1534            vec![m],
1535        ));
1536        let s = val(&mut g, "s");
1537        g.insert_node(Node::new(
1538            NodeId(0),
1539            "Add",
1540            vec![Some(m), Some(bias)],
1541            vec![s],
1542        ));
1543        let out = val(&mut g, "out");
1544        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(s)], vec![out]));
1545        g.add_output(out);
1546
1547        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1548        assert_eq!(g.num_nodes(), 1);
1549        let fused = g.nodes.values().next().unwrap();
1550        assert_eq!(fused.op_type, "FusedGemm");
1551        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1552        assert!(g.validate().is_ok());
1553    }
1554
1555    #[test]
1556    fn does_not_fuse_when_intermediate_has_second_consumer() {
1557        // MatMul -> m ; Add(m, bias) -> out ; and m also feeds a second Relu.
1558        let mut g = matmul_add_graph();
1559        // Find `m` (produced by MatMul, consumed by Add).
1560        let m = g
1561            .values
1562            .iter()
1563            .find(|(_, v)| v.name.as_deref() == Some("m"))
1564            .map(|(id, _)| id)
1565            .unwrap();
1566        let side = val(&mut g, "side");
1567        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(m)], vec![side]));
1568        g.add_output(side);
1569
1570        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1571        // MatMul's output escapes to the side Relu, so no fusion.
1572        assert!(
1573            g.nodes.values().any(|n| n.op_type == "MatMul"),
1574            "MatMul must remain — its output has a second consumer"
1575        );
1576        assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
1577        assert!(g.validate().is_ok());
1578    }
1579
1580    #[test]
1581    fn no_match_returns_none() {
1582        let mut g = Graph::new();
1583        g.opset_imports.insert(String::new(), 17);
1584        let a = val(&mut g, "a");
1585        g.add_input(a);
1586        let out = val(&mut g, "out");
1587        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(a)], vec![out]));
1588        g.add_output(out);
1589        let p = FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias");
1590        assert!(p.find_match(&g).is_none());
1591    }
1592
1593    /// Build the canonical 9-op LayerNorm decomposition over `x`.
1594    ///
1595    /// `eps` is an inline f32 initializer (as it would be after `ConstantFolding`
1596    /// materializes the `var + eps` constant) so the schema-aware rewrite can
1597    /// fold it into the `epsilon` attribute; the `ReduceMean` nodes carry an
1598    /// `axes = [-1]` attribute so `axis` extraction is exercised too.
1599    fn layernorm_graph() -> Graph {
1600        const EPS: f32 = 1e-12;
1601        let mut g = Graph::new();
1602        g.opset_imports.insert(String::new(), 17);
1603        let x = val(&mut g, "x");
1604        let two = val(&mut g, "two");
1605        let eps = val(&mut g, "eps");
1606        let scale = val(&mut g, "scale");
1607        let bias = val(&mut g, "bias");
1608        g.add_input(x);
1609        g.add_input(two);
1610        g.set_initializer(
1611            eps,
1612            WeightRef::Inline(TensorData::from_raw(
1613                DataType::Float32,
1614                vec![],
1615                EPS.to_le_bytes().to_vec(),
1616            )),
1617        );
1618        g.add_input(scale);
1619        g.add_input(bias);
1620
1621        let reduce_mean = |g: &mut Graph, input: ValueId, out: ValueId| {
1622            let mut n = Node::new(NodeId(0), "ReduceMean", vec![Some(input)], vec![out]);
1623            n.attributes
1624                .insert("axes".into(), Attribute::Ints(vec![-1]));
1625            n.attributes.insert("keepdims".into(), Attribute::Int(1));
1626            g.insert_node(n);
1627        };
1628
1629        let mean = val(&mut g, "mean");
1630        reduce_mean(&mut g, x, mean);
1631        let diff = val(&mut g, "diff");
1632        g.insert_node(Node::new(
1633            NodeId(0),
1634            "Sub",
1635            vec![Some(x), Some(mean)],
1636            vec![diff],
1637        ));
1638        let sq = val(&mut g, "sq");
1639        g.insert_node(Node::new(
1640            NodeId(0),
1641            "Pow",
1642            vec![Some(diff), Some(two)],
1643            vec![sq],
1644        ));
1645        let var = val(&mut g, "var");
1646        reduce_mean(&mut g, sq, var);
1647        let vare = val(&mut g, "vare");
1648        g.insert_node(Node::new(
1649            NodeId(0),
1650            "Add",
1651            vec![Some(var), Some(eps)],
1652            vec![vare],
1653        ));
1654        let std = val(&mut g, "std");
1655        g.insert_node(Node::new(NodeId(0), "Sqrt", vec![Some(vare)], vec![std]));
1656        let norm = val(&mut g, "norm");
1657        g.insert_node(Node::new(
1658            NodeId(0),
1659            "Div",
1660            vec![Some(diff), Some(std)],
1661            vec![norm],
1662        ));
1663        let scaled = val(&mut g, "scaled");
1664        g.insert_node(Node::new(
1665            NodeId(0),
1666            "Mul",
1667            vec![Some(norm), Some(scale)],
1668            vec![scaled],
1669        ));
1670        let out = val(&mut g, "out");
1671        g.insert_node(Node::new(
1672            NodeId(0),
1673            "Add",
1674            vec![Some(scaled), Some(bias)],
1675            vec![out],
1676        ));
1677        g.add_output(out);
1678        g
1679    }
1680
1681    #[test]
1682    fn fuses_layernorm_chain() {
1683        let mut g = layernorm_graph();
1684        assert_eq!(g.num_nodes(), 9);
1685        assert!(g.validate().is_ok());
1686
1687        // Record the value ids the schema-aware rewrite must reference.
1688        let vid = |name: &str| {
1689            g.values
1690                .iter()
1691                .find(|(_, v)| v.name.as_deref() == Some(name))
1692                .map(|(id, _)| id)
1693                .unwrap()
1694        };
1695        let x = vid("x");
1696        let scale = vid("scale");
1697        let bias = vid("bias");
1698
1699        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1700
1701        assert_eq!(g.num_nodes(), 1, "9-op chain collapses to one node");
1702        let fused = g.nodes.values().next().unwrap();
1703        assert_eq!(fused.op_type, "LayerNormalization");
1704        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1705        // Schema-conformant inputs: exactly [X, Scale, B] — NOT the intermediate
1706        // pow-exponent / epsilon tensors.
1707        assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1708        // Synthesized attributes read by the kernel.
1709        assert_eq!(
1710            fused.attr("axis").and_then(Attribute::as_int),
1711            Some(-1),
1712            "axis extracted from ReduceMean axes"
1713        );
1714        let eps = fused
1715            .attr("epsilon")
1716            .and_then(Attribute::as_float)
1717            .expect("epsilon attribute present");
1718        assert!(
1719            (eps - 1e-12).abs() < 1e-18,
1720            "epsilon extracted from the var+eps constant, got {eps}"
1721        );
1722        assert_eq!(fused.outputs, g.outputs);
1723        assert!(g.validate().is_ok());
1724    }
1725
1726    #[test]
1727    fn layernorm_count_bookkeeping() {
1728        let mut g = layernorm_graph();
1729        let ln_before = g
1730            .nodes
1731            .values()
1732            .filter(|n| n.op_type == "LayerNormalization")
1733            .count();
1734        let rm_before = g
1735            .nodes
1736            .values()
1737            .filter(|n| n.op_type == "ReduceMean")
1738            .count();
1739        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1740        let ln_after = g
1741            .nodes
1742            .values()
1743            .filter(|n| n.op_type == "LayerNormalization")
1744            .count();
1745        let rm_after = g
1746            .nodes
1747            .values()
1748            .filter(|n| n.op_type == "ReduceMean")
1749            .count();
1750        assert_eq!(ln_before, 0);
1751        assert_eq!(ln_after, 1);
1752        assert_eq!(rm_before, 2);
1753        assert_eq!(rm_after, 0);
1754    }
1755
1756    /// Build the 10-op split-diff LayerNorm decomposition over `x` (the
1757    /// `bert_toy`-style variant): the variance branch and the numerator branch
1758    /// each get their **own** distinct `Sub` node instead of sharing one `diff`.
1759    /// `mean` therefore fans out to two Subs and `x` to two Subs. When
1760    /// `reverse_num_sub` is true the numerator `Sub` is emitted reversed as
1761    /// `Sub(mean, x)` (an adversarial sign-flip) to exercise the operand-order
1762    /// guard.
1763    fn layernorm_split_graph(reverse_num_sub: bool) -> Graph {
1764        const EPS: f32 = 1e-12;
1765        let mut g = Graph::new();
1766        g.opset_imports.insert(String::new(), 17);
1767        let x = val(&mut g, "x");
1768        let two = val(&mut g, "two");
1769        let eps = val(&mut g, "eps");
1770        let scale = val(&mut g, "scale");
1771        let bias = val(&mut g, "bias");
1772        g.add_input(x);
1773        g.add_input(two);
1774        g.set_initializer(
1775            eps,
1776            WeightRef::Inline(TensorData::from_raw(
1777                DataType::Float32,
1778                vec![],
1779                EPS.to_le_bytes().to_vec(),
1780            )),
1781        );
1782        g.add_input(scale);
1783        g.add_input(bias);
1784
1785        let reduce_mean = |g: &mut Graph, input: ValueId, out: ValueId| {
1786            let mut n = Node::new(NodeId(0), "ReduceMean", vec![Some(input)], vec![out]);
1787            n.attributes
1788                .insert("axes".into(), Attribute::Ints(vec![-1]));
1789            n.attributes.insert("keepdims".into(), Attribute::Int(1));
1790            g.insert_node(n);
1791        };
1792
1793        let mean = val(&mut g, "mean");
1794        reduce_mean(&mut g, x, mean);
1795        // Variance-branch Sub: always the canonical `x - mean`.
1796        let diff_pow = val(&mut g, "diff_pow");
1797        g.insert_node(Node::new(
1798            NodeId(0),
1799            "Sub",
1800            vec![Some(x), Some(mean)],
1801            vec![diff_pow],
1802        ));
1803        // Numerator-branch Sub: a SECOND, distinct node. Reversed operands when
1804        // `reverse_num_sub` (adversarial `mean - x`), else canonical `x - mean`.
1805        let diff_div = val(&mut g, "diff_div");
1806        let num_inputs = if reverse_num_sub {
1807            vec![Some(mean), Some(x)]
1808        } else {
1809            vec![Some(x), Some(mean)]
1810        };
1811        g.insert_node(Node::new(NodeId(0), "Sub", num_inputs, vec![diff_div]));
1812
1813        let sq = val(&mut g, "sq");
1814        g.insert_node(Node::new(
1815            NodeId(0),
1816            "Pow",
1817            vec![Some(diff_pow), Some(two)],
1818            vec![sq],
1819        ));
1820        let var = val(&mut g, "var");
1821        reduce_mean(&mut g, sq, var);
1822        let vare = val(&mut g, "vare");
1823        g.insert_node(Node::new(
1824            NodeId(0),
1825            "Add",
1826            vec![Some(var), Some(eps)],
1827            vec![vare],
1828        ));
1829        let std = val(&mut g, "std");
1830        g.insert_node(Node::new(NodeId(0), "Sqrt", vec![Some(vare)], vec![std]));
1831        let norm = val(&mut g, "norm");
1832        g.insert_node(Node::new(
1833            NodeId(0),
1834            "Div",
1835            vec![Some(diff_div), Some(std)],
1836            vec![norm],
1837        ));
1838        let scaled = val(&mut g, "scaled");
1839        g.insert_node(Node::new(
1840            NodeId(0),
1841            "Mul",
1842            vec![Some(norm), Some(scale)],
1843            vec![scaled],
1844        ));
1845        let out = val(&mut g, "out");
1846        g.insert_node(Node::new(
1847            NodeId(0),
1848            "Add",
1849            vec![Some(scaled), Some(bias)],
1850            vec![out],
1851        ));
1852        g.add_output(out);
1853        g
1854    }
1855
1856    #[test]
1857    fn fuses_layernorm_split_chain() {
1858        // Isolated optimizer-layer coverage for the 10-op split-diff shape
1859        // (previously only exercised end-to-end via the bert_toy model).
1860        let mut g = layernorm_split_graph(false);
1861        assert_eq!(g.num_nodes(), 10, "split-diff shape has two distinct Subs");
1862        assert!(g.validate().is_ok());
1863
1864        let vid = |name: &str| {
1865            g.values
1866                .iter()
1867                .find(|(_, v)| v.name.as_deref() == Some(name))
1868                .map(|(id, _)| id)
1869                .unwrap()
1870        };
1871        let x = vid("x");
1872        let scale = vid("scale");
1873        let bias = vid("bias");
1874
1875        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1876
1877        assert_eq!(g.num_nodes(), 1, "10-op split chain collapses to one node");
1878        let fused = g.nodes.values().next().unwrap();
1879        assert_eq!(fused.op_type, "LayerNormalization");
1880        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1881        // Schema-conformant inputs: exactly [X, Scale, B].
1882        assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1883        assert_eq!(
1884            fused.attr("axis").and_then(Attribute::as_int),
1885            Some(-1),
1886            "axis extracted from ReduceMean axes"
1887        );
1888        let eps = fused
1889            .attr("epsilon")
1890            .and_then(Attribute::as_float)
1891            .expect("epsilon attribute present");
1892        assert!(
1893            (eps - 1e-12).abs() < 1e-18,
1894            "epsilon extracted from the var+eps constant, got {eps}"
1895        );
1896        assert_eq!(fused.outputs, g.outputs);
1897        assert!(g.validate().is_ok());
1898    }
1899
1900    #[test]
1901    fn declines_layernorm_when_numerator_sub_reversed() {
1902        // A-CHEW-1 adversarial: the numerator diamond centers with a REVERSED
1903        // `Sub(mean, x)` = -(x - mean). Membership of {x, mean} still holds, but
1904        // the operand-order guard must DECLINE (else the rewrite silently produces
1905        // a sign-flipped LayerNormalization). Ops must be left untouched.
1906        let mut g = layernorm_split_graph(true);
1907        assert_eq!(g.num_nodes(), 10);
1908        assert!(g.validate().is_ok());
1909
1910        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1911
1912        assert!(
1913            g.nodes.values().all(|n| n.op_type != "LayerNormalization"),
1914            "reversed Sub(mean, x) must NOT fuse — sign-flip over-match"
1915        );
1916        assert_eq!(g.num_nodes(), 10, "all 10 ops remain (declined)");
1917        assert_eq!(
1918            g.nodes.values().filter(|n| n.op_type == "Sub").count(),
1919            2,
1920            "both centering Subs preserved"
1921        );
1922        assert!(g.validate().is_ok());
1923    }
1924
1925    #[test]
1926    fn does_not_fuse_partial_layernorm() {
1927        // A LayerNorm chain missing its final Add must not fuse.
1928        let mut g = layernorm_graph();
1929        // Remove the last Add by rebuilding: easier to just check a shorter
1930        // pattern doesn't accidentally match — assert Sub alone isn't fused.
1931        let p = FusionPattern::layernorm();
1932        // Break the chain: give `diff` an external consumer so the safety rule
1933        // trips (Sub is a non-final matched node).
1934        let diff = g
1935            .values
1936            .iter()
1937            .find(|(_, v)| v.name.as_deref() == Some("diff"))
1938            .map(|(id, _)| id)
1939            .unwrap();
1940        let side = val(&mut g, "side");
1941        g.insert_node(Node::new(NodeId(0), "Neg", vec![Some(diff)], vec![side]));
1942        g.add_output(side);
1943        assert!(
1944            p.find_match(&g).is_none(),
1945            "external consumer on `diff` blocks the fusion"
1946        );
1947    }
1948
1949    #[test]
1950    fn fuses_two_independent_matmul_adds() {
1951        let mut g = Graph::new();
1952        g.opset_imports.insert(String::new(), 17);
1953        for i in 0..2 {
1954            let a = val(&mut g, &format!("a{i}"));
1955            let w = val(&mut g, &format!("w{i}"));
1956            let bias = val(&mut g, &format!("bias{i}"));
1957            g.add_input(a);
1958            g.add_input(w);
1959            g.add_input(bias);
1960            let m = val(&mut g, &format!("m{i}"));
1961            g.insert_node(Node::new(
1962                NodeId(0),
1963                "MatMul",
1964                vec![Some(a), Some(w)],
1965                vec![m],
1966            ));
1967            let out = val(&mut g, &format!("out{i}"));
1968            g.insert_node(Node::new(
1969                NodeId(0),
1970                "Add",
1971                vec![Some(m), Some(bias)],
1972                vec![out],
1973            ));
1974            g.add_output(out);
1975        }
1976        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1977        assert_eq!(g.num_nodes(), 2);
1978        assert!(g.nodes.values().all(|n| n.op_type == "FusedMatMulBias"));
1979        assert!(g.validate().is_ok());
1980    }
1981
1982    #[test]
1983    fn find_match_reports_correct_shape() {
1984        let g = matmul_add_graph();
1985        let p = FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias");
1986        let m = p.find_match(&g).expect("should match");
1987        assert_eq!(m.nodes.len(), 2);
1988        assert_eq!(m.external_inputs.len(), 3);
1989        assert_eq!(p.pattern_name(), "MatMul+Bias");
1990    }
1991
1992    #[test]
1993    fn declines_layernorm_when_axes_is_input() {
1994        // Opset-18 style: `ReduceMean` takes `axes` as an INPUT, not an
1995        // attribute. The axis can't be pinned to a single concrete value from an
1996        // attribute, so the fusion must DECLINE and leave all 9 ops intact
1997        // (never silently assume axis = -1).
1998        let mut g = layernorm_graph();
1999        let mean = g
2000            .values
2001            .iter()
2002            .find(|(_, v)| v.name.as_deref() == Some("mean"))
2003            .map(|(id, _)| id)
2004            .unwrap();
2005        let rm1 = g.value(mean).producer.unwrap();
2006        // Drop the `axes` attribute and feed axes in as an initializer INPUT.
2007        g.node_mut(rm1).attributes.remove("axes");
2008        let axes_in = g.create_named_value("axes_in", DataType::Int64, static_shape([1]));
2009        g.set_initializer(
2010            axes_in,
2011            WeightRef::Inline(TensorData::from_raw(
2012                DataType::Int64,
2013                vec![1],
2014                (-1i64).to_le_bytes().to_vec(),
2015            )),
2016        );
2017        let input_index = g.node(rm1).inputs.len();
2018        g.node_mut(rm1).inputs.push(None);
2019        g.replace_input(rm1, input_index, Some(axes_in));
2020        assert!(g.validate().is_ok());
2021
2022        assert_eq!(g.num_nodes(), 9);
2023        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2024        assert_eq!(
2025            g.num_nodes(),
2026            9,
2027            "axes-as-input LayerNorm must NOT fuse — all original ops kept"
2028        );
2029        assert!(
2030            g.nodes.values().all(|n| n.op_type != "LayerNormalization"),
2031            "no fused LayerNormalization must be emitted"
2032        );
2033        assert_eq!(
2034            g.nodes
2035                .values()
2036                .filter(|n| n.op_type == "ReduceMean")
2037                .count(),
2038            2,
2039            "both ReduceMean ops remain"
2040        );
2041        assert!(g.validate().is_ok());
2042    }
2043
2044    #[test]
2045    fn declines_layernorm_when_epsilon_not_constant() {
2046        // If epsilon is a runtime graph INPUT (not a folded f32 initializer) it
2047        // can't be read as a concrete scalar → DECLINE rather than silently
2048        // substituting the ONNX default 1e-5.
2049        let mut g = layernorm_graph();
2050        let eps = g
2051            .values
2052            .iter()
2053            .find(|(_, v)| v.name.as_deref() == Some("eps"))
2054            .map(|(id, _)| id)
2055            .unwrap();
2056        // Turn the eps initializer into a plain runtime graph input.
2057        g.initializers.remove(&eps);
2058        g.add_input(eps);
2059        assert!(g.validate().is_ok());
2060
2061        assert_eq!(g.num_nodes(), 9);
2062        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2063        assert_eq!(
2064            g.num_nodes(),
2065            9,
2066            "non-constant epsilon LayerNorm must NOT fuse"
2067        );
2068        assert!(g.nodes.values().all(|n| n.op_type != "LayerNormalization"));
2069        assert!(g.validate().is_ok());
2070    }
2071
2072    #[test]
2073    fn declines_matmul_add_when_bias_expands() {
2074        // MatMul output is [4]; the Add's bias is [2, 4], whose extra leading dim
2075        // would broadcast the result UP to [2, 4]. The fused kernel/shape rule
2076        // assume the output equals the matmul shape and would silently truncate,
2077        // so the fusion must DECLINE and keep the original MatMul + Add.
2078        let mut g = Graph::new();
2079        g.opset_imports.insert(String::new(), 17);
2080        let a = g.create_named_value("a", DataType::Float32, static_shape([4, 4]));
2081        let w = g.create_named_value("w", DataType::Float32, static_shape([4]));
2082        let bias = g.create_named_value("bias", DataType::Float32, static_shape([2, 4]));
2083        g.add_input(a);
2084        g.add_input(w);
2085        g.add_input(bias);
2086        let m = g.create_named_value("m", DataType::Float32, static_shape([4]));
2087        g.insert_node(Node::new(
2088            NodeId(0),
2089            "MatMul",
2090            vec![Some(a), Some(w)],
2091            vec![m],
2092        ));
2093        let out = g.create_named_value("out", DataType::Float32, static_shape([2, 4]));
2094        g.insert_node(Node::new(
2095            NodeId(0),
2096            "Add",
2097            vec![Some(m), Some(bias)],
2098            vec![out],
2099        ));
2100        g.add_output(out);
2101
2102        assert_eq!(g.num_nodes(), 2);
2103        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2104        assert_eq!(g.num_nodes(), 2, "expanding bias must NOT fuse");
2105        assert!(g.nodes.values().any(|n| n.op_type == "MatMul"));
2106        assert!(g.nodes.values().any(|n| n.op_type == "Add"));
2107        assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
2108        assert!(g.validate().is_ok());
2109    }
2110
2111    #[test]
2112    fn fuses_matmul_add_with_trailing_broadcast_bias() {
2113        // A `[1, 4]` bias broadcasts INTO a `[3, 4]` matmul output without
2114        // expanding it, so the guard must still allow this common case to fuse.
2115        let mut g = Graph::new();
2116        g.opset_imports.insert(String::new(), 17);
2117        let a = g.create_named_value("a", DataType::Float32, static_shape([3, 4]));
2118        let w = g.create_named_value("w", DataType::Float32, static_shape([4, 4]));
2119        let bias = g.create_named_value("bias", DataType::Float32, static_shape([1, 4]));
2120        g.add_input(a);
2121        g.add_input(w);
2122        g.add_input(bias);
2123        let m = g.create_named_value("m", DataType::Float32, static_shape([3, 4]));
2124        g.insert_node(Node::new(
2125            NodeId(0),
2126            "MatMul",
2127            vec![Some(a), Some(w)],
2128            vec![m],
2129        ));
2130        let out = g.create_named_value("out", DataType::Float32, static_shape([3, 4]));
2131        g.insert_node(Node::new(
2132            NodeId(0),
2133            "Add",
2134            vec![Some(m), Some(bias)],
2135            vec![out],
2136        ));
2137        g.add_output(out);
2138
2139        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2140        assert_eq!(g.num_nodes(), 1, "trailing-broadcast bias must fuse");
2141        assert_eq!(g.nodes.values().next().unwrap().op_type, "FusedMatMulBias");
2142        assert!(g.validate().is_ok());
2143    }
2144
2145    #[test]
2146    fn declines_matmul_add_when_shape_unknown() {
2147        // If the matmul output shape can't be resolved (empty/unknown), the guard
2148        // can't prove the bias is non-expanding → DECLINE conservatively.
2149        let mut g = Graph::new();
2150        g.opset_imports.insert(String::new(), 17);
2151        let a = g.create_named_value("a", DataType::Float32, Vec::new());
2152        let w = g.create_named_value("w", DataType::Float32, Vec::new());
2153        let bias = g.create_named_value("bias", DataType::Float32, static_shape([4]));
2154        g.add_input(a);
2155        g.add_input(w);
2156        g.add_input(bias);
2157        // `m` has an unknown (empty) shape.
2158        let m = g.create_named_value("m", DataType::Float32, Vec::new());
2159        g.insert_node(Node::new(
2160            NodeId(0),
2161            "MatMul",
2162            vec![Some(a), Some(w)],
2163            vec![m],
2164        ));
2165        let out = g.create_named_value("out", DataType::Float32, static_shape([4]));
2166        g.insert_node(Node::new(
2167            NodeId(0),
2168            "Add",
2169            vec![Some(m), Some(bias)],
2170            vec![out],
2171        ));
2172        g.add_output(out);
2173
2174        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2175        assert_eq!(g.num_nodes(), 2, "unknown matmul shape must NOT fuse");
2176        assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
2177        assert!(g.validate().is_ok());
2178    }
2179
2180    #[test]
2181    fn declines_fused_gemm_when_bias_expands() {
2182        // Roy's FusedGemm review advisory, locked in: a MatMul+Add+Relu whose
2183        // bias EXPANDS the matmul output (extra leading/batch dim) must DECLINE
2184        // to FusedGemm exactly like the FusedMatMulBias case — the trailing Relu
2185        // is shape-neutral, so the same non-expanding-bias guard applies. MatMul
2186        // output is [4]; bias [2, 4] would broadcast the result up to [2, 4].
2187        let mut g = Graph::new();
2188        g.opset_imports.insert(String::new(), 17);
2189        let a = g.create_named_value("a", DataType::Float32, static_shape([4, 4]));
2190        let w = g.create_named_value("w", DataType::Float32, static_shape([4]));
2191        let bias = g.create_named_value("bias", DataType::Float32, static_shape([2, 4]));
2192        g.add_input(a);
2193        g.add_input(w);
2194        g.add_input(bias);
2195        let m = g.create_named_value("m", DataType::Float32, static_shape([4]));
2196        g.insert_node(Node::new(
2197            NodeId(0),
2198            "MatMul",
2199            vec![Some(a), Some(w)],
2200            vec![m],
2201        ));
2202        let biased = g.create_named_value("biased", DataType::Float32, static_shape([2, 4]));
2203        g.insert_node(Node::new(
2204            NodeId(0),
2205            "Add",
2206            vec![Some(m), Some(bias)],
2207            vec![biased],
2208        ));
2209        let out = g.create_named_value("out", DataType::Float32, static_shape([2, 4]));
2210        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(biased)], vec![out]));
2211        g.add_output(out);
2212
2213        assert_eq!(g.num_nodes(), 3);
2214        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2215        assert_eq!(
2216            g.num_nodes(),
2217            3,
2218            "expanding bias must NOT fuse to FusedGemm"
2219        );
2220        assert!(g.nodes.values().any(|n| n.op_type == "MatMul"));
2221        assert!(g.nodes.values().any(|n| n.op_type == "Add"));
2222        assert!(g.nodes.values().any(|n| n.op_type == "Relu"));
2223        assert!(g.nodes.values().all(|n| n.op_type != "FusedGemm"));
2224        assert!(g.validate().is_ok());
2225    }
2226
2227    // --- AttentionFusion (SDPA core) ------------------------------------------
2228
2229    /// Add a strict-scalar f32 initializer, returning its value id.
2230    fn scalar_init(g: &mut Graph, name: &str, v: f32) -> ValueId {
2231        let vid = g.create_named_value(name, DataType::Float32, Vec::new());
2232        g.set_initializer(
2233            vid,
2234            WeightRef::Inline(TensorData::from_raw(
2235                DataType::Float32,
2236                vec![],
2237                v.to_le_bytes().to_vec(),
2238            )),
2239        );
2240        vid
2241    }
2242
2243    fn fval(g: &mut Graph, name: &str, dims: &[usize]) -> ValueId {
2244        g.create_named_value(name, DataType::Float32, static_shape(dims.iter().copied()))
2245    }
2246
2247    /// Look up a value id by name (test convenience).
2248    fn value_id(g: &Graph, name: &str) -> ValueId {
2249        g.values
2250            .iter()
2251            .find(|(_, v)| v.name.as_deref() == Some(name))
2252            .map(|(id, _)| id)
2253            .unwrap_or_else(|| panic!("no value named {name}"))
2254    }
2255
2256    /// Build an SDPA core graph `Softmax((Q·Kᵀ)/c [+ mask], axis=-1) · V` with
2257    /// rank-4 `[1, 2, seq, dim]` tensors, K supplied pre-transposed as
2258    /// `[1, 2, d, sk]` (so `k_transposed` should resolve to 1). `masked` adds an
2259    /// additive mask; `axis` is the Softmax reduction axis.
2260    fn sdpa_graph(masked: bool, axis: i64) -> Graph {
2261        let mut g = Graph::new();
2262        g.opset_imports.insert(String::new(), 12);
2263        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2264        let kt = fval(&mut g, "K", &[1, 2, 4, 3]); // pre-transposed [d=4, sk=3]
2265        let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2266        g.add_input(q);
2267        g.add_input(kt);
2268        g.add_input(v);
2269        let c = scalar_init(&mut g, "scale_c", 2.0);
2270
2271        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2272        g.insert_node(Node::new(
2273            NodeId(0),
2274            "MatMul",
2275            vec![Some(q), Some(kt)],
2276            vec![scores],
2277        ));
2278        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2279        g.insert_node(Node::new(
2280            NodeId(0),
2281            "Div",
2282            vec![Some(scores), Some(c)],
2283            vec![scaled],
2284        ));
2285
2286        let sm_in = if masked {
2287            let mask = fval(&mut g, "mask", &[1, 1, 3, 3]);
2288            g.add_input(mask);
2289            let masked_v = fval(&mut g, "masked", &[1, 2, 3, 3]);
2290            g.insert_node(Node::new(
2291                NodeId(0),
2292                "Add",
2293                vec![Some(scaled), Some(mask)],
2294                vec![masked_v],
2295            ));
2296            masked_v
2297        } else {
2298            scaled
2299        };
2300
2301        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2302        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(sm_in)], vec![probs]);
2303        sm.attributes.insert("axis".into(), Attribute::Int(axis));
2304        g.insert_node(sm);
2305        let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2306        g.insert_node(Node::new(
2307            NodeId(0),
2308            "MatMul",
2309            vec![Some(probs), Some(v)],
2310            vec![out],
2311        ));
2312        g.add_output(out);
2313        g
2314    }
2315
2316    fn fused_attention_node(g: &Graph) -> Option<&Node> {
2317        g.nodes.values().find(|n| n.op_type == "FusedAttention")
2318    }
2319
2320    #[test]
2321    fn fuses_sdpa_unmasked_pretransposed_k() {
2322        let mut g = sdpa_graph(false, 3);
2323        let q = value_id(&g, "Q");
2324        let k = value_id(&g, "K");
2325        let v = value_id(&g, "V");
2326        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2327
2328        // Exactly one FusedAttention; no surviving Softmax/MatMul/Div.
2329        assert_eq!(
2330            g.nodes
2331                .values()
2332                .filter(|n| n.op_type == "FusedAttention")
2333                .count(),
2334            1
2335        );
2336        assert!(g.nodes.values().all(|n| n.op_type != "Softmax"));
2337        assert!(g.nodes.values().all(|n| n.op_type != "MatMul"));
2338        assert!(g.nodes.values().all(|n| n.op_type != "Div"));
2339
2340        let fa = fused_attention_node(&g).unwrap();
2341        assert_eq!(fa.domain, CONTRIB_DOMAIN);
2342        assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v)]);
2343        // scale = 1/c = 1/2 = 0.5; K used as-is → k_transposed = 1.
2344        assert_eq!(fa.attr("scale").and_then(Attribute::as_float), Some(0.5));
2345        assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(1));
2346        assert!(g.validate().is_ok());
2347    }
2348
2349    #[test]
2350    fn fuses_sdpa_masked() {
2351        let mut g = sdpa_graph(true, 3);
2352        let (q, k, v, mask) = (
2353            value_id(&g, "Q"),
2354            value_id(&g, "K"),
2355            value_id(&g, "V"),
2356            value_id(&g, "mask"),
2357        );
2358        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2359
2360        assert_eq!(
2361            g.nodes
2362                .values()
2363                .filter(|n| n.op_type == "FusedAttention")
2364                .count(),
2365            1
2366        );
2367        assert!(g.nodes.values().all(|n| n.op_type != "Softmax"));
2368        assert!(g.nodes.values().all(|n| n.op_type != "Add"));
2369        let fa = fused_attention_node(&g).unwrap();
2370        // Mask appended as the 4th input.
2371        assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v), Some(mask)]);
2372        assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(1));
2373        assert!(g.validate().is_ok());
2374    }
2375
2376    #[test]
2377    fn fuses_sdpa_absorbing_clean_transpose_sets_k_transposed_0() {
2378        // K is supplied in natural [1,2,3,4] layout and transposed to Kᵀ by a
2379        // clean last-two-axis Transpose (perm [0,1,3,2]) consumed only by the
2380        // score MatMul. The matcher absorbs it: K input becomes the natural K
2381        // and k_transposed = 0 (kernel transposes internally).
2382        let mut g = Graph::new();
2383        g.opset_imports.insert(String::new(), 12);
2384        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2385        let k = fval(&mut g, "K", &[1, 2, 3, 4]); // natural [sk=3, d=4]
2386        let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2387        g.add_input(q);
2388        g.add_input(k);
2389        g.add_input(v);
2390        let c = scalar_init(&mut g, "scale_c", 4.0);
2391
2392        let kt = fval(&mut g, "Kt", &[1, 2, 4, 3]);
2393        let mut tr = Node::new(NodeId(0), "Transpose", vec![Some(k)], vec![kt]);
2394        tr.attributes
2395            .insert("perm".into(), Attribute::Ints(vec![0, 1, 3, 2]));
2396        g.insert_node(tr);
2397        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2398        g.insert_node(Node::new(
2399            NodeId(0),
2400            "MatMul",
2401            vec![Some(q), Some(kt)],
2402            vec![scores],
2403        ));
2404        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2405        g.insert_node(Node::new(
2406            NodeId(0),
2407            "Div",
2408            vec![Some(scores), Some(c)],
2409            vec![scaled],
2410        ));
2411        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2412        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2413        sm.attributes.insert("axis".into(), Attribute::Int(-1));
2414        g.insert_node(sm);
2415        let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2416        g.insert_node(Node::new(
2417            NodeId(0),
2418            "MatMul",
2419            vec![Some(probs), Some(v)],
2420            vec![out],
2421        ));
2422        g.add_output(out);
2423
2424        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2425        assert!(
2426            g.nodes.values().all(|n| n.op_type != "Transpose"),
2427            "clean Kᵀ Transpose absorbed"
2428        );
2429        let fa = fused_attention_node(&g).unwrap();
2430        assert_eq!(
2431            fa.inputs,
2432            vec![Some(q), Some(k), Some(v)],
2433            "K input is the natural (un-transposed) K"
2434        );
2435        assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(0));
2436        // scale = 1/4 = 0.25.
2437        assert_eq!(fa.attr("scale").and_then(Attribute::as_float), Some(0.25));
2438        assert!(g.validate().is_ok());
2439    }
2440
2441    #[test]
2442    fn declines_sdpa_when_softmax_axis_not_last() {
2443        // axis 1 on a rank-4 score tensor is not the last axis → decline.
2444        let mut g = sdpa_graph(false, 1);
2445        let before = g.num_nodes();
2446        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2447        assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2448        assert!(g.nodes.values().any(|n| n.op_type == "Softmax"));
2449        assert_eq!(g.num_nodes(), before, "no fusion when axis is not last");
2450    }
2451
2452    #[test]
2453    fn declines_sdpa_when_scale_is_not_scalar_constant() {
2454        // The score-scaling divisor is a runtime graph input (not a constant),
2455        // so the scale can't be folded to a concrete f32 → decline.
2456        let mut g = Graph::new();
2457        g.opset_imports.insert(String::new(), 12);
2458        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2459        let kt = fval(&mut g, "K", &[1, 2, 4, 3]);
2460        let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2461        let c = fval(&mut g, "scale_c", &[]); // runtime input, NOT an initializer
2462        g.add_input(q);
2463        g.add_input(kt);
2464        g.add_input(v);
2465        g.add_input(c);
2466        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2467        g.insert_node(Node::new(
2468            NodeId(0),
2469            "MatMul",
2470            vec![Some(q), Some(kt)],
2471            vec![scores],
2472        ));
2473        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2474        g.insert_node(Node::new(
2475            NodeId(0),
2476            "Div",
2477            vec![Some(scores), Some(c)],
2478            vec![scaled],
2479        ));
2480        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2481        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2482        sm.attributes.insert("axis".into(), Attribute::Int(3));
2483        g.insert_node(sm);
2484        let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2485        g.insert_node(Node::new(
2486            NodeId(0),
2487            "MatMul",
2488            vec![Some(probs), Some(v)],
2489            vec![out],
2490        ));
2491        g.add_output(out);
2492
2493        let before = g.num_nodes();
2494        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2495        assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2496        assert_eq!(g.num_nodes(), before, "non-constant scale must NOT fuse");
2497    }
2498
2499    #[test]
2500    fn declines_sdpa_when_softmax_is_right_operand_of_output_matmul() {
2501        // out = V · probs (softmax output is the RIGHT operand) is not `probs·V`
2502        // SDPA — the matcher requires the softmax output be the LEFT operand.
2503        let mut g = Graph::new();
2504        g.opset_imports.insert(String::new(), 12);
2505        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2506        let kt = fval(&mut g, "K", &[1, 2, 4, 3]);
2507        let v = fval(&mut g, "V", &[1, 2, 3, 3]);
2508        g.add_input(q);
2509        g.add_input(kt);
2510        g.add_input(v);
2511        let c = scalar_init(&mut g, "scale_c", 2.0);
2512        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2513        g.insert_node(Node::new(
2514            NodeId(0),
2515            "MatMul",
2516            vec![Some(q), Some(kt)],
2517            vec![scores],
2518        ));
2519        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2520        g.insert_node(Node::new(
2521            NodeId(0),
2522            "Div",
2523            vec![Some(scores), Some(c)],
2524            vec![scaled],
2525        ));
2526        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2527        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2528        sm.attributes.insert("axis".into(), Attribute::Int(3));
2529        g.insert_node(sm);
2530        let out = fval(&mut g, "out", &[1, 2, 3, 3]);
2531        // Reversed operand order: V · probs.
2532        g.insert_node(Node::new(
2533            NodeId(0),
2534            "MatMul",
2535            vec![Some(v), Some(probs)],
2536            vec![out],
2537        ));
2538        g.add_output(out);
2539
2540        let before = g.num_nodes();
2541        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2542        assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2543        assert!(g.nodes.values().any(|n| n.op_type == "Softmax"));
2544        assert_eq!(g.num_nodes(), before);
2545    }
2546
2547    /// Build the exact-GELU `Erf` decomposition `0.5·x·(1 + erf(x / √2))` over a
2548    /// single graph input `x`, with the constants materialized as scalar
2549    /// initializers. `inner`/`half` select the constant encoding to emit so the
2550    /// equivalent forms can be exercised.
2551    fn gelu_graph(inner_div_sqrt2: bool, half_mul: bool) -> Graph {
2552        let mut g = Graph::new();
2553        g.opset_imports.insert(String::new(), 17);
2554        let x = val(&mut g, "x");
2555        g.add_input(x);
2556
2557        // half = 0.5 * x  (via Mul(x, 0.5) or Div(x, 2.0)).
2558        let half = val(&mut g, "half");
2559        if half_mul {
2560            let c = scalar_init(&mut g, "c_half", 0.5);
2561            g.insert_node(Node::new(
2562                NodeId(0),
2563                "Mul",
2564                vec![Some(x), Some(c)],
2565                vec![half],
2566            ));
2567        } else {
2568            let c = scalar_init(&mut g, "c_two", 2.0);
2569            g.insert_node(Node::new(
2570                NodeId(0),
2571                "Div",
2572                vec![Some(x), Some(c)],
2573                vec![half],
2574            ));
2575        }
2576
2577        // scaled = x / √2  (via Div(x, √2) or Mul(x, 1/√2)).
2578        let scaled = val(&mut g, "scaled");
2579        if inner_div_sqrt2 {
2580            let c = scalar_init(&mut g, "c_sqrt2", std::f32::consts::SQRT_2);
2581            g.insert_node(Node::new(
2582                NodeId(0),
2583                "Div",
2584                vec![Some(x), Some(c)],
2585                vec![scaled],
2586            ));
2587        } else {
2588            let c = scalar_init(&mut g, "c_isqrt2", std::f32::consts::FRAC_1_SQRT_2);
2589            g.insert_node(Node::new(
2590                NodeId(0),
2591                "Mul",
2592                vec![Some(x), Some(c)],
2593                vec![scaled],
2594            ));
2595        }
2596
2597        let e = val(&mut g, "e");
2598        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2599        let one = scalar_init(&mut g, "c_one", 1.0);
2600        let a = val(&mut g, "a");
2601        g.insert_node(Node::new(
2602            NodeId(0),
2603            "Add",
2604            vec![Some(e), Some(one)],
2605            vec![a],
2606        ));
2607        let out = val(&mut g, "out");
2608        g.insert_node(Node::new(
2609            NodeId(0),
2610            "Mul",
2611            vec![Some(half), Some(a)],
2612            vec![out],
2613        ));
2614        g.add_output(out);
2615        g
2616    }
2617
2618    #[test]
2619    fn fuses_gelu_div_sqrt2() {
2620        let mut g = gelu_graph(true, true);
2621        assert_eq!(g.num_nodes(), 5);
2622        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2623        let gelu: Vec<_> = g.nodes.values().filter(|n| n.op_type == "Gelu").collect();
2624        assert_eq!(gelu.len(), 1, "the Erf decomposition must fuse to one Gelu");
2625        let fused = gelu[0];
2626        assert_eq!(fused.domain, CONTRIB_DOMAIN);
2627        assert_eq!(fused.inputs.len(), 1, "Gelu takes the single input x");
2628        assert!(fused.attributes.is_empty(), "exact Gelu has no attributes");
2629        // Single input is the graph input `x`.
2630        let x = g
2631            .values
2632            .iter()
2633            .find(|(_, v)| v.name.as_deref() == Some("x"))
2634            .map(|(id, _)| id)
2635            .unwrap();
2636        assert_eq!(fused.inputs[0], Some(x));
2637        assert_eq!(fused.outputs, g.outputs);
2638        assert!(g.nodes.values().all(|n| n.op_type != "Erf"));
2639        assert!(g.validate().is_ok());
2640    }
2641
2642    #[test]
2643    fn fuses_gelu_mul_reciprocal_and_div_two() {
2644        // Equivalent encodings: inner Mul(x, 1/√2), half Div(x, 2.0).
2645        let mut g = gelu_graph(false, false);
2646        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2647        assert_eq!(
2648            g.nodes.values().filter(|n| n.op_type == "Gelu").count(),
2649            1,
2650            "the reciprocal/half-divisor encoding must also fuse"
2651        );
2652        assert!(g.validate().is_ok());
2653    }
2654
2655    #[test]
2656    fn declines_gelu_wrong_inner_constant() {
2657        // Div by 2.0 instead of √2 is not x/√2 → decline.
2658        let mut g = Graph::new();
2659        g.opset_imports.insert(String::new(), 17);
2660        let x = val(&mut g, "x");
2661        g.add_input(x);
2662        let half = val(&mut g, "half");
2663        let ch = scalar_init(&mut g, "c_half", 0.5);
2664        g.insert_node(Node::new(
2665            NodeId(0),
2666            "Mul",
2667            vec![Some(x), Some(ch)],
2668            vec![half],
2669        ));
2670        let scaled = val(&mut g, "scaled");
2671        let cbad = scalar_init(&mut g, "c_bad", 2.0);
2672        g.insert_node(Node::new(
2673            NodeId(0),
2674            "Div",
2675            vec![Some(x), Some(cbad)],
2676            vec![scaled],
2677        ));
2678        let e = val(&mut g, "e");
2679        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2680        let one = scalar_init(&mut g, "c_one", 1.0);
2681        let a = val(&mut g, "a");
2682        g.insert_node(Node::new(
2683            NodeId(0),
2684            "Add",
2685            vec![Some(e), Some(one)],
2686            vec![a],
2687        ));
2688        let out = val(&mut g, "out");
2689        g.insert_node(Node::new(
2690            NodeId(0),
2691            "Mul",
2692            vec![Some(half), Some(a)],
2693            vec![out],
2694        ));
2695        g.add_output(out);
2696
2697        let before = g.num_nodes();
2698        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2699        assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2700        assert!(g.nodes.values().any(|n| n.op_type == "Erf"));
2701        assert_eq!(g.num_nodes(), before);
2702    }
2703
2704    #[test]
2705    fn declines_gelu_wrong_half_constant() {
2706        // Mul(x, 0.4) instead of 0.5 → decline.
2707        let mut g = gelu_graph(true, true);
2708        // Rewrite the half Mul's constant initializer to 0.4.
2709        let ch = g
2710            .values
2711            .iter()
2712            .find(|(_, v)| v.name.as_deref() == Some("c_half"))
2713            .map(|(id, _)| id)
2714            .unwrap();
2715        g.set_initializer(
2716            ch,
2717            WeightRef::Inline(TensorData::from_raw(
2718                DataType::Float32,
2719                vec![],
2720                0.4f32.to_le_bytes().to_vec(),
2721            )),
2722        );
2723        let before = g.num_nodes();
2724        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2725        assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2726        assert_eq!(g.num_nodes(), before);
2727    }
2728
2729    #[test]
2730    fn declines_gelu_when_half_uses_different_x() {
2731        // The `0.5··` operand uses a DIFFERENT value than the Erf branch, so the
2732        // diamond is not closed → decline.
2733        let mut g = Graph::new();
2734        g.opset_imports.insert(String::new(), 17);
2735        let x = val(&mut g, "x");
2736        let y = val(&mut g, "y");
2737        g.add_input(x);
2738        g.add_input(y);
2739        let half = val(&mut g, "half");
2740        let ch = scalar_init(&mut g, "c_half", 0.5);
2741        // half = 0.5 * y   (NOT x)
2742        g.insert_node(Node::new(
2743            NodeId(0),
2744            "Mul",
2745            vec![Some(y), Some(ch)],
2746            vec![half],
2747        ));
2748        let scaled = val(&mut g, "scaled");
2749        let cs = scalar_init(&mut g, "c_sqrt2", std::f32::consts::SQRT_2);
2750        g.insert_node(Node::new(
2751            NodeId(0),
2752            "Div",
2753            vec![Some(x), Some(cs)],
2754            vec![scaled],
2755        ));
2756        let e = val(&mut g, "e");
2757        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2758        let one = scalar_init(&mut g, "c_one", 1.0);
2759        let a = val(&mut g, "a");
2760        g.insert_node(Node::new(
2761            NodeId(0),
2762            "Add",
2763            vec![Some(e), Some(one)],
2764            vec![a],
2765        ));
2766        let out = val(&mut g, "out");
2767        g.insert_node(Node::new(
2768            NodeId(0),
2769            "Mul",
2770            vec![Some(half), Some(a)],
2771            vec![out],
2772        ));
2773        g.add_output(out);
2774
2775        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2776        assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2777        assert!(g.nodes.values().any(|n| n.op_type == "Erf"));
2778    }
2779
2780    #[test]
2781    fn declines_gelu_when_interior_escapes() {
2782        // The Erf output feeds an extra external consumer, so fusing would
2783        // delete an observed value → decline.
2784        let mut g = gelu_graph(true, true);
2785        let e = g
2786            .values
2787            .iter()
2788            .find(|(_, v)| v.name.as_deref() == Some("e"))
2789            .map(|(id, _)| id)
2790            .unwrap();
2791        let side = val(&mut g, "side");
2792        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(e)], vec![side]));
2793        g.add_output(side);
2794
2795        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2796        assert!(
2797            g.nodes.values().all(|n| n.op_type != "Gelu"),
2798            "must not fuse when an interior value escapes"
2799        );
2800    }
2801
2802    fn run_restart_reference(patterns: &[FusionPattern], graph: &mut Graph) {
2803        for pattern in patterns {
2804            while let Some(matched) = pattern.find_match(graph) {
2805                pattern.apply_fusion(graph, &matched).unwrap();
2806            }
2807        }
2808    }
2809
2810    fn serialized_graph_bytes(mut graph: Graph) -> Vec<u8> {
2811        use std::fmt::Write;
2812
2813        let mut snapshot = String::new();
2814        writeln!(&mut snapshot, "inputs={:?}", graph.inputs).unwrap();
2815        writeln!(&mut snapshot, "outputs={:?}", graph.outputs).unwrap();
2816
2817        let mut initializers: Vec<_> = graph.initializers.iter().collect();
2818        initializers.sort_by_key(|(id, _)| id.0);
2819        writeln!(&mut snapshot, "initializers={initializers:?}").unwrap();
2820        let mut constraints: Vec<_> = graph.symbol_constraints.iter().collect();
2821        constraints.sort_by_key(|(id, _)| id.0);
2822        writeln!(&mut snapshot, "constraints={constraints:?}").unwrap();
2823        let mut opsets: Vec<_> = graph.opset_imports.iter().collect();
2824        opsets.sort_by_key(|(domain, _)| *domain);
2825        writeln!(&mut snapshot, "opsets={opsets:?}").unwrap();
2826        let mut subgraphs: Vec<_> = graph.subgraphs.iter().collect();
2827        subgraphs.sort_by_key(|((id, name), _)| (id.0, name.as_str()));
2828        writeln!(&mut snapshot, "subgraphs={subgraphs:?}").unwrap();
2829
2830        for (id, node) in graph.nodes.iter() {
2831            let mut attributes: Vec<_> = node.attributes.iter().collect();
2832            attributes.sort_by_key(|(name, _)| *name);
2833            writeln!(
2834                &mut snapshot,
2835                "node={id:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{attributes:?}|{:?}|{:?}|{:?}",
2836                node.id,
2837                node.name,
2838                node.op_type,
2839                node.domain,
2840                node.inputs,
2841                node.outputs,
2842                node.doc_string,
2843                node.device,
2844                node.exec_order,
2845            )
2846            .unwrap();
2847        }
2848        for (id, value) in graph.values.iter() {
2849            writeln!(&mut snapshot, "value={id:?}|{value:?}").unwrap();
2850        }
2851        writeln!(
2852            &mut snapshot,
2853            "topological_order={:?}",
2854            graph.topological_order().unwrap()
2855        )
2856        .unwrap();
2857
2858        // Arena slots/free-list order are private IR details, but their complete
2859        // observable state is the sequence of IDs returned by future inserts.
2860        // The generated graphs are far smaller than this probe count.
2861        for _ in 0..128 {
2862            let node =
2863                graph.insert_node(Node::new(NodeId(0), "ArenaProbe", Vec::new(), Vec::new()));
2864            let value = graph.create_value(DataType::Float32, static_shape([1]));
2865            writeln!(&mut snapshot, "probe={node:?}|{value:?}").unwrap();
2866        }
2867        snapshot.into_bytes()
2868    }
2869
2870    fn assert_fusion_graphs_byte_identical(actual: Graph, expected: Graph, trial: usize) {
2871        assert_eq!(
2872            serialized_graph_bytes(actual),
2873            serialized_graph_bytes(expected),
2874            "restart and resumable fixpoints differ byte-for-byte on trial {trial}"
2875        );
2876    }
2877
2878    struct FusionTestRng(u64);
2879
2880    impl FusionTestRng {
2881        fn next(&mut self) -> u64 {
2882            self.0 ^= self.0 << 13;
2883            self.0 ^= self.0 >> 7;
2884            self.0 ^= self.0 << 17;
2885            self.0
2886        }
2887
2888        fn usize(&mut self, upper: usize) -> usize {
2889            (self.next() as usize) % upper
2890        }
2891
2892        fn coin(&mut self) -> bool {
2893            self.next() & 1 == 0
2894        }
2895
2896        fn shuffle<T>(&mut self, values: &mut [T]) {
2897            for i in (1..values.len()).rev() {
2898                values.swap(i, self.usize(i + 1));
2899            }
2900        }
2901    }
2902
2903    struct DifferentialGraphBuilder {
2904        graph: Graph,
2905        pending: Vec<Node>,
2906        next_name: usize,
2907    }
2908
2909    impl DifferentialGraphBuilder {
2910        fn new() -> Self {
2911            let mut graph = Graph::new();
2912            graph.opset_imports.insert(String::new(), 17);
2913            Self {
2914                graph,
2915                pending: Vec::new(),
2916                next_name: 0,
2917            }
2918        }
2919
2920        fn value(&mut self, prefix: &str, dims: &[usize]) -> ValueId {
2921            let name = format!("{prefix}_{}", self.next_name);
2922            self.next_name += 1;
2923            self.graph.create_named_value(
2924                name,
2925                DataType::Float32,
2926                static_shape(dims.iter().copied()),
2927            )
2928        }
2929
2930        fn input(&mut self, prefix: &str, dims: &[usize]) -> ValueId {
2931            let value = self.value(prefix, dims);
2932            self.graph.add_input(value);
2933            value
2934        }
2935
2936        fn scalar(&mut self, prefix: &str, value: f32) -> ValueId {
2937            let id = self.value(prefix, &[]);
2938            self.graph.set_initializer(
2939                id,
2940                WeightRef::Inline(TensorData::from_raw(
2941                    DataType::Float32,
2942                    vec![],
2943                    value.to_le_bytes().to_vec(),
2944                )),
2945            );
2946            id
2947        }
2948
2949        fn node(
2950            &mut self,
2951            name: impl Into<String>,
2952            op: &str,
2953            inputs: Vec<ValueId>,
2954            output: ValueId,
2955        ) -> &mut Node {
2956            let mut node = Node::new(
2957                NodeId(0),
2958                op,
2959                inputs.into_iter().map(Some).collect(),
2960                vec![output],
2961            );
2962            node.name = name.into();
2963            self.pending.push(node);
2964            self.pending.last_mut().unwrap()
2965        }
2966
2967        fn output(&mut self, value: ValueId) {
2968            self.graph.add_output(value);
2969        }
2970
2971        fn add_matmul_bias(&mut self, rng: &mut FusionTestRng, relu: bool, overlap: bool) {
2972            let a = self.input("mm_a", &[4]);
2973            let w0 = self.input("mm_w0", &[4]);
2974            let m0 = self.value("mm_m0", &[4]);
2975            self.node("mm_left", "MatMul", vec![a, w0], m0);
2976
2977            let bias = if overlap {
2978                let w1 = self.input("mm_w1", &[4]);
2979                let m1 = self.value("mm_m1", &[4]);
2980                self.node("mm_right", "MatMul", vec![a, w1], m1);
2981                m1
2982            } else {
2983                self.input("mm_bias", &[4])
2984            };
2985
2986            let add = self.value("mm_add", &[4]);
2987            let add_inputs = if rng.coin() {
2988                vec![m0, bias]
2989            } else {
2990                vec![bias, m0]
2991            };
2992            self.node("mm_add_node", "Add", add_inputs, add);
2993            if relu {
2994                let output = self.value("mm_relu", &[4]);
2995                self.node("mm_relu_node", "Relu", vec![add], output);
2996                self.output(output);
2997            } else {
2998                self.output(add);
2999            }
3000        }
3001
3002        fn add_layernorm(&mut self, split_diff: bool) {
3003            let x = self.input("ln_x", &[4]);
3004            let two = self.input("ln_two", &[4]);
3005            let eps = self.scalar("ln_eps", 1e-12);
3006            let scale = self.input("ln_scale", &[4]);
3007            let bias = self.input("ln_bias", &[4]);
3008
3009            let mean = self.value("ln_mean", &[4]);
3010            let rm1 = self.node("ln_mean_node", "ReduceMean", vec![x], mean);
3011            rm1.attributes
3012                .insert("axes".into(), Attribute::Ints(vec![-1]));
3013            rm1.attributes.insert("keepdims".into(), Attribute::Int(1));
3014
3015            let diff_pow = self.value("ln_diff_pow", &[4]);
3016            self.node("ln_sub_pow", "Sub", vec![x, mean], diff_pow);
3017            let diff_div = if split_diff {
3018                let value = self.value("ln_diff_div", &[4]);
3019                self.node("ln_sub_div", "Sub", vec![x, mean], value);
3020                value
3021            } else {
3022                diff_pow
3023            };
3024            let sq = self.value("ln_sq", &[4]);
3025            self.node("ln_pow", "Pow", vec![diff_pow, two], sq);
3026            let var = self.value("ln_var", &[4]);
3027            let rm2 = self.node("ln_var_node", "ReduceMean", vec![sq], var);
3028            rm2.attributes
3029                .insert("axes".into(), Attribute::Ints(vec![-1]));
3030            rm2.attributes.insert("keepdims".into(), Attribute::Int(1));
3031            let vare = self.value("ln_vare", &[4]);
3032            self.node("ln_add_eps", "Add", vec![var, eps], vare);
3033            let std = self.value("ln_std", &[4]);
3034            self.node("ln_sqrt", "Sqrt", vec![vare], std);
3035            let norm = self.value("ln_norm", &[4]);
3036            self.node("ln_div", "Div", vec![diff_div, std], norm);
3037            let scaled = self.value("ln_scaled", &[4]);
3038            self.node("ln_mul", "Mul", vec![norm, scale], scaled);
3039            let output = self.value("ln_output", &[4]);
3040            self.node("ln_add_bias", "Add", vec![scaled, bias], output);
3041            self.output(output);
3042        }
3043
3044        fn add_gelu(&mut self, rng: &mut FusionTestRng) {
3045            let x = self.input("gelu_x", &[4]);
3046            let half = self.value("gelu_half", &[4]);
3047            if rng.coin() {
3048                let c = self.scalar("gelu_half_c", 0.5);
3049                let inputs = if rng.coin() { vec![x, c] } else { vec![c, x] };
3050                self.node("gelu_half_node", "Mul", inputs, half);
3051            } else {
3052                let c = self.scalar("gelu_two_c", 2.0);
3053                self.node("gelu_half_node", "Div", vec![x, c], half);
3054            }
3055
3056            let scaled = self.value("gelu_scaled", &[4]);
3057            if rng.coin() {
3058                let c = self.scalar("gelu_sqrt2", std::f32::consts::SQRT_2);
3059                self.node("gelu_inner", "Div", vec![x, c], scaled);
3060            } else {
3061                let c = self.scalar("gelu_isqrt2", std::f32::consts::FRAC_1_SQRT_2);
3062                let inputs = if rng.coin() { vec![x, c] } else { vec![c, x] };
3063                self.node("gelu_inner", "Mul", inputs, scaled);
3064            }
3065            let erf = self.value("gelu_erf", &[4]);
3066            self.node("gelu_erf_node", "Erf", vec![scaled], erf);
3067            let one = self.scalar("gelu_one", 1.0);
3068            let plus_one = self.value("gelu_plus_one", &[4]);
3069            let inputs = if rng.coin() {
3070                vec![erf, one]
3071            } else {
3072                vec![one, erf]
3073            };
3074            self.node("gelu_add", "Add", inputs, plus_one);
3075            let output = self.value("gelu_output", &[4]);
3076            let inputs = if rng.coin() {
3077                vec![half, plus_one]
3078            } else {
3079                vec![plus_one, half]
3080            };
3081            self.node("gelu_outer", "Mul", inputs, output);
3082            self.output(output);
3083        }
3084
3085        fn add_attention(&mut self, rng: &mut FusionTestRng) {
3086            let q = self.input("attn_q", &[1, 2, 3, 4]);
3087            let k = self.input("attn_k", &[1, 2, 3, 4]);
3088            let v = self.input("attn_v", &[1, 2, 3, 4]);
3089            let k_side = if rng.coin() {
3090                let kt = self.value("attn_kt", &[1, 2, 4, 3]);
3091                let transpose = self.node("attn_transpose", "Transpose", vec![k], kt);
3092                transpose
3093                    .attributes
3094                    .insert("perm".into(), Attribute::Ints(vec![0, 1, 3, 2]));
3095                kt
3096            } else {
3097                k
3098            };
3099            let scores = self.value("attn_scores", &[1, 2, 3, 3]);
3100            self.node("attn_score_mm", "MatMul", vec![q, k_side], scores);
3101            let scale_const = if rng.coin() {
3102                self.scalar("attn_divisor", 2.0)
3103            } else {
3104                self.scalar("attn_multiplier", 0.5)
3105            };
3106            let scaled = self.value("attn_scaled", &[1, 2, 3, 3]);
3107            if self
3108                .graph
3109                .value(scale_const)
3110                .name
3111                .as_deref()
3112                .unwrap()
3113                .contains("divisor")
3114            {
3115                self.node("attn_scale", "Div", vec![scores, scale_const], scaled);
3116            } else {
3117                let inputs = if rng.coin() {
3118                    vec![scores, scale_const]
3119                } else {
3120                    vec![scale_const, scores]
3121                };
3122                self.node("attn_scale", "Mul", inputs, scaled);
3123            }
3124            let softmax_input = if rng.coin() {
3125                let mask = self.input("attn_mask", &[1, 1, 3, 3]);
3126                let masked = self.value("attn_masked", &[1, 2, 3, 3]);
3127                let inputs = if rng.coin() {
3128                    vec![scaled, mask]
3129                } else {
3130                    vec![mask, scaled]
3131                };
3132                self.node("attn_mask_add", "Add", inputs, masked);
3133                masked
3134            } else {
3135                scaled
3136            };
3137            let probs = self.value("attn_probs", &[1, 2, 3, 3]);
3138            let softmax = self.node("attn_softmax", "Softmax", vec![softmax_input], probs);
3139            softmax.attributes.insert(
3140                "axis".into(),
3141                Attribute::Int(if rng.coin() { -1 } else { 3 }),
3142            );
3143            let output = self.value("attn_output", &[1, 2, 3, 4]);
3144            self.node("attn_output_mm", "MatMul", vec![probs, v], output);
3145            self.output(output);
3146        }
3147
3148        fn add_resumable_chain(&mut self, rng: &mut FusionTestRng) {
3149            let input = self.input("chain_input", &[4]);
3150            let first = self.value("chain_start", &[4]);
3151            self.node("chain_0", "ChainStart", vec![input], first);
3152            let mut value = first;
3153            for index in 1..(4 + rng.usize(6)) {
3154                let output = self.value("chain_link", &[4]);
3155                self.node(format!("chain_{index}"), "ChainLink", vec![value], output);
3156                value = output;
3157            }
3158            self.output(value);
3159        }
3160
3161        fn add_noise(&mut self, rng: &mut FusionTestRng) {
3162            for index in 0..rng.usize(8) {
3163                let input = self.input("noise_input", &[4]);
3164                let output = self.value("noise_output", &[4]);
3165                let op = ["Abs", "Neg", "Identity", "Tanh"][rng.usize(4)];
3166                self.node(format!("noise_{index}"), op, vec![input], output);
3167                self.output(output);
3168            }
3169        }
3170
3171        fn finish(mut self, rng: &mut FusionTestRng) -> Graph {
3172            // Seed and remove one dummy per real node. Random removal order
3173            // randomizes the arena free-list; independently shuffling real-node
3174            // insertion then decouples logical/topological order from NodeId.
3175            let mut slots = Vec::with_capacity(self.pending.len());
3176            for _ in 0..self.pending.len() {
3177                slots.push(self.graph.insert_node(Node::new(
3178                    NodeId(0),
3179                    "IdSeed",
3180                    Vec::new(),
3181                    Vec::new(),
3182                )));
3183            }
3184            rng.shuffle(&mut slots);
3185            for id in slots {
3186                self.graph.remove_node(id);
3187            }
3188            rng.shuffle(&mut self.pending);
3189            for node in self.pending {
3190                self.graph.insert_node(node);
3191            }
3192            self.graph
3193        }
3194    }
3195
3196    fn randomized_fusion_graph(rng: &mut FusionTestRng) -> Graph {
3197        let mut builder = DifferentialGraphBuilder::new();
3198        // Every trial contains every registered matcher. The two structural
3199        // motifs deliberately have two MatMul starts sharing the Add (and Relu),
3200        // so lowest-NodeId overlap resolution affects the exact replacement.
3201        builder.add_attention(rng);
3202        builder.add_matmul_bias(rng, true, true);
3203        builder.add_layernorm(rng.coin());
3204        builder.add_gelu(rng);
3205        builder.add_matmul_bias(rng, false, true);
3206
3207        // Add extra independent registered motifs for structural diversity.
3208        for _ in 0..rng.usize(4) {
3209            match rng.usize(5) {
3210                0 => builder.add_attention(rng),
3211                1 => {
3212                    let overlap = rng.coin();
3213                    builder.add_matmul_bias(rng, true, overlap);
3214                }
3215                2 => builder.add_layernorm(rng.coin()),
3216                3 => builder.add_gelu(rng),
3217                _ => {
3218                    let overlap = rng.coin();
3219                    builder.add_matmul_bias(rng, false, overlap);
3220                }
3221            }
3222        }
3223        builder.add_resumable_chain(rng);
3224        builder.add_noise(rng);
3225        builder.finish(rng)
3226    }
3227
3228    fn differential_patterns() -> Vec<FusionPattern> {
3229        let mut patterns = default_fusion_patterns();
3230        // Unlike production replacements, this test-only standard-domain op can
3231        // immediately match the next ChainLink. Its NodeId has already been
3232        // passed by the ascending cursor, so correctness requires a lower-id
3233        // revisit after every fusion until the chain reaches its fixpoint.
3234        patterns.push(
3235            FusionPattern::new("ResumableChain", &["ChainStart", "ChainLink"], "ChainStart")
3236                .with_replacement_domain(""),
3237        );
3238        patterns
3239    }
3240
3241    struct AffectedRevisitCase {
3242        graph: Graph,
3243        lower_start: NodeId,
3244        later_start: NodeId,
3245        first_middle: NodeId,
3246        first_tail: NodeId,
3247        final_tail: NodeId,
3248    }
3249
3250    fn affected_revisit_case(seed: u64) -> AffectedRevisitCase {
3251        let mut rng = FusionTestRng(seed ^ 0xbb67_ae85_84ca_a73b);
3252        let noise_count = 3 + rng.usize(6);
3253        let slot_count = 5 + noise_count;
3254        let later_start = NodeId((slot_count - 1) as u32);
3255        let mut lower_ids: Vec<u32> = (0..later_start.0).collect();
3256        rng.shuffle(&mut lower_ids);
3257        let lower_start = NodeId(lower_ids[0]);
3258        let first_middle = NodeId(lower_ids[1]);
3259        let first_tail = NodeId(lower_ids[2]);
3260        let final_tail = NodeId(lower_ids[3]);
3261
3262        let mut graph = Graph::new();
3263        graph.opset_imports.insert(String::new(), 17);
3264        let lower_input = graph.create_named_value(
3265            format!("lower_input_{seed}"),
3266            DataType::Float32,
3267            static_shape([4]),
3268        );
3269        let later_input = graph.create_named_value(
3270            format!("later_input_{seed}"),
3271            DataType::Float32,
3272            static_shape([4]),
3273        );
3274        graph.add_input(lower_input);
3275        graph.add_input(later_input);
3276        let lower_value = graph.create_named_value(
3277            format!("lower_value_{seed}"),
3278            DataType::Float32,
3279            static_shape([4]),
3280        );
3281        let middle_value = graph.create_named_value(
3282            format!("middle_value_{seed}"),
3283            DataType::Float32,
3284            static_shape([4]),
3285        );
3286        let first_output = graph.create_named_value(
3287            format!("first_output_{seed}"),
3288            DataType::Float32,
3289            static_shape([4]),
3290        );
3291        let first_tail_output = graph.create_named_value(
3292            format!("first_tail_output_{seed}"),
3293            DataType::Float32,
3294            static_shape([4]),
3295        );
3296        let final_output = graph.create_named_value(
3297            format!("final_output_{seed}"),
3298            DataType::Float32,
3299            static_shape([4]),
3300        );
3301        graph.add_output(final_output);
3302
3303        let mut placements = vec![
3304            (
3305                lower_start,
3306                Node::new(
3307                    NodeId(0),
3308                    "AdversaryStart",
3309                    vec![Some(lower_input)],
3310                    vec![lower_value],
3311                ),
3312            ),
3313            (
3314                later_start,
3315                Node::new(
3316                    NodeId(0),
3317                    "AdversaryStart",
3318                    vec![Some(later_input)],
3319                    vec![middle_value],
3320                ),
3321            ),
3322            (
3323                first_middle,
3324                Node::new(
3325                    NodeId(0),
3326                    "AdversaryMiddle",
3327                    vec![Some(middle_value)],
3328                    vec![first_output],
3329                ),
3330            ),
3331            (
3332                first_tail,
3333                Node::new(
3334                    NodeId(0),
3335                    "AdversaryTail",
3336                    vec![Some(first_output), Some(lower_value)],
3337                    vec![first_tail_output],
3338                ),
3339            ),
3340            (
3341                final_tail,
3342                Node::new(
3343                    NodeId(0),
3344                    "AdversaryTail",
3345                    vec![Some(first_tail_output)],
3346                    vec![final_output],
3347                ),
3348            ),
3349        ];
3350        let core_ids = HashSet::from([
3351            lower_start,
3352            later_start,
3353            first_middle,
3354            first_tail,
3355            final_tail,
3356        ]);
3357        let mut noise_index = 0;
3358        for raw_id in 0..slot_count as u32 {
3359            let id = NodeId(raw_id);
3360            if core_ids.contains(&id) {
3361                continue;
3362            }
3363            let input = graph.create_named_value(
3364                format!("noise_input_{seed}_{noise_index}"),
3365                DataType::Float32,
3366                static_shape([4]),
3367            );
3368            let output = graph.create_named_value(
3369                format!("noise_output_{seed}_{noise_index}"),
3370                DataType::Float32,
3371                static_shape([4]),
3372            );
3373            graph.add_input(input);
3374            graph.add_output(output);
3375            placements.push((
3376                id,
3377                Node::new(
3378                    NodeId(0),
3379                    ["Abs", "Neg", "Identity", "Tanh"][rng.usize(4)],
3380                    vec![Some(input)],
3381                    vec![output],
3382                ),
3383            ));
3384            noise_index += 1;
3385        }
3386
3387        rng.shuffle(&mut placements);
3388        for _ in 0..slot_count {
3389            graph.insert_node(Node::new(NodeId(0), "IdSeed", Vec::new(), Vec::new()));
3390        }
3391        for &(target, _) in placements.iter().rev() {
3392            graph.remove_node(target);
3393        }
3394        for (expected, node) in placements {
3395            assert_eq!(graph.insert_node(node), expected);
3396        }
3397
3398        AffectedRevisitCase {
3399            graph,
3400            lower_start,
3401            later_start,
3402            first_middle,
3403            first_tail,
3404            final_tail,
3405        }
3406    }
3407
3408    #[test]
3409    fn affected_candidate_starts_revisits_newly_eligible_lower_ids() {
3410        const TRIALS: usize = 5_000;
3411        let pattern = FusionPattern::new(
3412            "AffectedBehindCursor",
3413            &["AdversaryStart", "AdversaryMiddle", "AdversaryTail"],
3414            "AdversaryMiddle",
3415        )
3416        .with_replacement_domain("");
3417        let patterns = vec![pattern.clone()];
3418        let mut reclaimable_low_slot_trials = 0;
3419        let mut affected_scheduled = 0;
3420        let mut affected_revisit_hits = 0;
3421
3422        for trial in 0..TRIALS {
3423            let case = affected_revisit_case(trial as u64);
3424            assert!(case.graph.validate().is_ok(), "invalid trial {trial}");
3425            assert!(case.lower_start.0 < case.later_start.0);
3426            assert!(case.first_middle.0 < case.later_start.0);
3427            assert!(case.first_tail.0 < case.later_start.0);
3428            assert!(
3429                pattern
3430                    .try_match_at(&case.graph, case.lower_start)
3431                    .is_none()
3432            );
3433            let first_match = pattern
3434                .try_match_at(&case.graph, case.later_start)
3435                .expect("later start must be the first eligible match");
3436            assert_eq!(
3437                first_match.nodes,
3438                vec![case.later_start, case.first_middle, case.first_tail]
3439            );
3440
3441            // Reverse removal followed by LIFO insertion always reuses the
3442            // match-start slot. The lower interior slots remain reclaimable.
3443            let mut reclaim_probe = case.graph.clone();
3444            let first_fused = pattern
3445                .apply_fusion_returning_id(&mut reclaim_probe, &first_match)
3446                .unwrap();
3447            assert_eq!(first_fused, case.later_start);
3448            let probe_id = reclaim_probe.insert_node(Node::new(
3449                NodeId(0),
3450                "ReclaimProbe",
3451                Vec::new(),
3452                Vec::new(),
3453            ));
3454            assert_eq!(probe_id, case.first_middle);
3455            reclaimable_low_slot_trials += 1;
3456
3457            let mut reference = case.graph.clone();
3458            run_restart_reference(&patterns, &mut reference);
3459            let mut actual = case.graph;
3460            let mut lower_was_scheduled = false;
3461            let mut trial_hits = 0;
3462            OpFusion::with_patterns(patterns.clone())
3463                .run_with_fusion_observer(
3464                    &mut actual,
3465                    |name, source, start, matched, affected, fused_id| {
3466                        if name != "AffectedBehindCursor" {
3467                            return;
3468                        }
3469                        assert_eq!(
3470                            fused_id, matched[0],
3471                            "replacement must reuse the just-freed match-start slot"
3472                        );
3473                        if start == case.later_start {
3474                            assert_eq!(source, ScanCandidateSource::Initial);
3475                            assert_eq!(
3476                                matched,
3477                                &[case.later_start, case.first_middle, case.first_tail]
3478                            );
3479                            assert!(affected.contains(&case.lower_start));
3480                            assert_ne!(fused_id, case.lower_start);
3481                            lower_was_scheduled = true;
3482                            affected_scheduled += 1;
3483                        } else if start == case.lower_start {
3484                            assert!(lower_was_scheduled);
3485                            assert_eq!(source, ScanCandidateSource::Revisit);
3486                            assert_eq!(
3487                                matched,
3488                                &[case.lower_start, case.later_start, case.final_tail]
3489                            );
3490                            trial_hits += 1;
3491                            affected_revisit_hits += 1;
3492                        }
3493                    },
3494                )
3495                .unwrap();
3496
3497            assert_eq!(trial_hits, 1, "affected revisit not hit on trial {trial}");
3498            assert!(actual.validate().is_ok(), "invalid result on trial {trial}");
3499            assert_fusion_graphs_byte_identical(actual, reference, trial);
3500        }
3501
3502        assert_eq!(reclaimable_low_slot_trials, TRIALS);
3503        assert_eq!(affected_scheduled, TRIALS);
3504        assert_eq!(affected_revisit_hits, TRIALS);
3505        eprintln!(
3506            "affected behind-cursor revisit hits: {affected_revisit_hits}/{TRIALS} \
3507             ({}%); reclaimable lower slots present: {reclaimable_low_slot_trials}/{TRIALS}",
3508            affected_revisit_hits * 100 / TRIALS
3509        );
3510    }
3511
3512    fn assert_overlapping_structural_candidates(graph: &Graph, patterns: &[FusionPattern]) {
3513        let mut saw_gemm_overlap = false;
3514        let mut saw_bias_overlap = false;
3515        for (add_id, add) in graph.nodes.iter().filter(|(_, node)| node.op_type == "Add") {
3516            let starts: Vec<_> = add
3517                .input_values()
3518                .filter_map(|value| graph.value(value).producer)
3519                .filter(|&producer| graph.node(producer).op_type == "MatMul")
3520                .collect();
3521            if starts.len() != 2 {
3522                continue;
3523            }
3524            let has_relu = graph
3525                .successors(add_id)
3526                .iter()
3527                .any(|&successor| graph.node(successor).op_type == "Relu");
3528            let pattern = if has_relu { &patterns[1] } else { &patterns[4] };
3529            assert!(
3530                starts
3531                    .iter()
3532                    .all(|&start| pattern.try_match_at(graph, start).is_some()),
3533                "both MatMul starts must be eligible for the shared structural tail"
3534            );
3535            saw_gemm_overlap |= has_relu;
3536            saw_bias_overlap |= !has_relu;
3537        }
3538        assert!(
3539            saw_gemm_overlap,
3540            "missing shared MatMul+Add+Relu candidates"
3541        );
3542        assert!(saw_bias_overlap, "missing shared MatMul+Add candidates");
3543    }
3544
3545    #[test]
3546    fn resumable_scan_matches_restart_reference_on_randomized_graphs() {
3547        const TRIALS: usize = 5_000;
3548        const REGISTERED_PATTERNS: usize = 5;
3549        let mut rng = FusionTestRng(0x6a09_e667_f3bc_c909);
3550        let patterns = differential_patterns();
3551        assert_eq!(default_fusion_patterns().len(), REGISTERED_PATTERNS);
3552
3553        let mut saw_non_topological_ids = false;
3554        for trial in 0..TRIALS {
3555            let mut graph = randomized_fusion_graph(&mut rng);
3556            assert!(
3557                graph.validate().is_ok(),
3558                "invalid input graph on trial {trial}"
3559            );
3560
3561            for pattern in &patterns[..REGISTERED_PATTERNS] {
3562                assert!(
3563                    pattern.find_match(&graph).is_some(),
3564                    "{} was not exercised on trial {trial}",
3565                    pattern.pattern_name()
3566                );
3567            }
3568            assert_overlapping_structural_candidates(&graph, &patterns);
3569            assert!(
3570                graph
3571                    .nodes
3572                    .values()
3573                    .filter(|node| node.op_type == "ChainLink")
3574                    .count()
3575                    >= 3,
3576                "chained replacement adversary must require multiple revisits"
3577            );
3578            saw_non_topological_ids |=
3579                graph.topological_order().unwrap() != graph.nodes.keys().collect::<Vec<_>>();
3580
3581            let mut reference = graph.clone();
3582            run_restart_reference(&patterns, &mut reference);
3583            OpFusion::with_patterns(patterns.clone())
3584                .run(&mut graph, &PassContext::new())
3585                .unwrap();
3586
3587            assert!(
3588                graph.validate().is_ok(),
3589                "invalid result graph on trial {trial}"
3590            );
3591            assert!(
3592                graph.nodes.values().all(|node| node.op_type != "ChainLink"),
3593                "resumable chain did not reach its fixpoint on trial {trial}"
3594            );
3595            assert_fusion_graphs_byte_identical(graph, reference, trial);
3596        }
3597        assert!(
3598            saw_non_topological_ids,
3599            "randomized insertion must decouple NodeId from topological order"
3600        );
3601    }
3602}