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