Skip to main content

fuel_graph/
opt.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Graph-level, backend-agnostic optimization passes.
3//!
4//! Transforms a `fuel-graph` computation graph before execution. Every
5//! backend benefits from these passes because they operate purely on
6//! the abstract op graph — no backend-specific knowledge required.
7//!
8//! **Passes today:**
9//!
10//! - **CSE** (common subexpression elimination): if the graph contains
11//!   two structurally-identical nodes — same op, same inputs (after
12//!   prior simplification), same shape/dtype — consumers of the
13//!   duplicate are redirected to the canonical node. The duplicate
14//!   becomes unreferenced and is silently dropped by the executor's
15//!   topo-walk. Commutative ops (`Add`, `Mul`) are keyed on sorted input
16//!   IDs so `a + b` and `b + a` fold to the same canonical node.
17//!   `Maximum`/`Minimum` are NOT in that set: commutative in value, not in
18//!   bits, because the NaN tie-break returns the FIRST operand. See GAP-271.
19//!
20//! - **Algebraic simplification**: a handful of identity/zero rules
21//!   that eliminate no-op ops:
22//!   - `AddScalar(0.0)(x)` → `x`
23//!   - `MulScalar(1.0)(x)` → `x`
24//!   - `Neg(Neg(x))` → `x`
25//!   - `Reshape(Reshape(x, _), s)` → `Reshape(x, s)`
26//!
27//!   These rarely appear in hand-written user code but show up
28//!   routinely in autograd-generated backward graphs and in
29//!   generic transformer building blocks that sometimes collapse.
30//!
31//! **Design:** graphs are append-only, so rewrites don't mutate
32//! existing nodes. Instead, the pass walks topologically, builds a
33//! remap `HashMap<NodeId, NodeId>` of old → canonical, and appends
34//! newly-canonicalized nodes to the same graph. Unreferenced originals
35//! remain in the arena but are never visited during realize. This is
36//! effectively a combined CSE + simplification + free DCE pass.
37//!
38//! **Return value:** the function takes the roots the caller cares
39//! about and returns the rewritten roots. Callers use these to update
40//! their `NodeHandle` handles.
41
42use crate::registry::{FusedOpEntry, FusedOpId, FusedOpParams, SubgraphPattern, default_registry};
43use crate::{Graph, Node, NodeId, Op, ScanEmit, ScanRole, SharedGraph, topo_order_multi};
44use fuel_ir::{DType, DeviceLocation};
45use std::collections::HashMap;
46
47// ---- Rule registry framework ----------------------------------------------
48//
49// Optimization is a pipeline of rule-driven graph rewrites. Two rule
50// families:
51//
52// - `RuleFamily::Lowering` — high-level op → primitive subgraph. Exposes
53//   fusion opportunities and lets backends without a fused kernel run
54//   the composition through whichever primitives they DO have.
55// - `RuleFamily::Fusion` — recognized primitive subgraph → fused op.
56//   Recovers (or improves on) the original-flavour kernel where one
57//   exists.
58//
59// A `Rule` is `(matcher, rewriter)`. The driver in
60// [`RuleRegistry::optimize_to_fixpoint`] runs lowering rules to fixpoint
61// first, then fusion rules to fixpoint. End state is post-fusion: for
62// SoftmaxLastDim (whose lower and fuse rules are inverses of each
63// other) the round-trip is identity. For lowering rules that ship
64// without a fusion partner — RmsNorm, LayerNorm, Affine, Clamp — the
65// post-pipeline graph is the lowered form.
66//
67// PR 3 ships the framework + a single (lower, fuse) rule pair for
68// `Op::SoftmaxLastDim`. The locked design (see ROADMAP.md → Phase 7.5)
69// also covers transactional snapshots, in-flight switching, and budget
70// modes; those are subsequent PRs that wrap this synchronous loop
71// without rewriting it.
72
73/// Which family a [`Rule`] belongs to. Drives the phase ordering in
74/// [`RuleRegistry::optimize_to_fixpoint`]: lowering runs to fixpoint
75/// first, then fusion. This ordering ensures inverse rule pairs
76/// converge cleanly — fusion has the last word, and its output is
77/// what the executor runs.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum RuleFamily {
80    /// High-level op → primitive subgraph. Always grows the graph.
81    Lowering,
82    /// Recognized primitive subgraph → fused op. Always shrinks the
83    /// graph (the fused op replaces the canonical subgraph's output;
84    /// the orphaned subgraph nodes remain in the arena but are
85    /// unreachable from the user's roots after consumer rewiring).
86    Fusion,
87    /// Algebraic identity rewrites that don't fit the lowering or
88    /// fusion shapes: cast elimination (`Cast(src→dst) → Op(dst)`
89    /// becomes `Op(src)` when the consumer supports the source
90    /// dtype), constant folding, identity elimination, common-
91    /// subexpression hoisting. Runs after fusion so cast-elimination
92    /// can't break fusion-pattern dtype expectations.
93    Algebraic,
94}
95
96/// One graph-rewrite rule. A rule pairs a structural matcher with a
97/// rewriter that emits replacement nodes and records the
98/// `(old_id → new_id)` remapping so the driver can rewrite consumer
99/// input edges to point at the replacement.
100///
101/// Rules don't need to update the layout side-table for view nodes
102/// they insert: [`Graph::push`] auto-populates layout for view ops
103/// from the input's layout.
104///
105/// Rules don't express ordering edges for destructive ops they emit
106/// (e.g. `Op::Release`); [`derive_ordering`] does that automatically
107/// at execution-plan time.
108pub trait Rule: Send + Sync {
109    /// Stable, human-readable name. Shows up in panic messages and
110    /// debug traces.
111    fn name(&self) -> &'static str;
112
113    /// Which family this rule belongs to. Determines pass ordering.
114    fn family(&self) -> RuleFamily;
115
116    /// Whether this rule can rewrite the node at `id`. Should be
117    /// pure — same inputs, same answer. Called once per node per
118    /// pass; the first matching rule wins.
119    fn matches(&self, graph: &Graph, id: NodeId) -> bool;
120
121    /// Apply the rewrite: append fresh nodes to the graph, declare
122    /// any side-effect roots if the rewrite emits side-effecting
123    /// ops, and write `remap.insert(id, replacement_id)` so the
124    /// driver can redirect consumer edges. Calling this method
125    /// implies [`matches`](Self::matches) returned `true` for the
126    /// same `(graph, id)` — rewriters can rely on the structure
127    /// the matcher checked for.
128    fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>);
129}
130
131/// A registry of [`Rule`]s. Drives optimize-to-fixpoint via
132/// [`Self::optimize_to_fixpoint`]: lowering rules run to fixpoint,
133/// then fusion rules run to fixpoint. The locked design (ROADMAP.md
134/// Phase 7.5) intends this entry point to be wrapped in transactions
135/// in subsequent PRs without rewriting the loop body — keep this API
136/// stable for that future evolution.
137#[derive(Default)]
138pub struct RuleRegistry {
139    rules: Vec<Box<dyn Rule>>,
140}
141
142impl RuleRegistry {
143    /// Empty registry. Use [`Self::with_rule`] to add rules, or
144    /// [`Self::default_rules`] / [`Self::lowering_only`] for the
145    /// shipped configurations.
146    pub fn new() -> Self {
147        Self { rules: Vec::new() }
148    }
149
150    /// Append a rule. Returns self for builder-style chaining.
151    pub fn with_rule(mut self, rule: Box<dyn Rule>) -> Self {
152        self.rules.push(rule);
153        self
154    }
155
156    /// Lower + fuse rules for every fused op registered in the
157    /// process-wide [`default_registry`]. Round-trip is identity for
158    /// canonical SoftmaxLastDim graphs (the lowered subgraph is
159    /// structurally re-fused). Use this in production realize paths.
160    ///
161    /// Phase 7.6 step 3: PR 3's hand-written
162    /// `SoftmaxLastDimLowerRule`/`SoftmaxLastDimFuseRule` are deleted;
163    /// equivalent behavior is auto-generated from the SoftmaxLastDim
164    /// registry entry. Subsequent fused-op migrations in step 4 add
165    /// their entries to the same registry; `default_rules` picks them
166    /// up automatically.
167    pub fn default_rules() -> Self {
168        Self::capability_gated_rules(|_| true).0
169    }
170
171    /// Build lower + fuse rules, but **fuse** a fused op only when
172    /// `has_kernel(id)` holds — the capability gate
173    /// (`runtime-fused-op-registration.md` §6). Every fused op (static +
174    /// runtime) still gets a *lowering* rule (so any already-fused node
175    /// decomposes); an op WITHOUT a kernel gets **no fusion rule**, so a
176    /// kernel-absent fused node never forms in the first place — the miss is
177    /// caught at the pattern-match step, not repaired afterward.
178    ///
179    /// Returns the rule set plus the ids that were **gated out of fusion** — the
180    /// miss candidates feeding the JIT work-order signal. The dispatch layer
181    /// supplies `has_kernel` from its `FusedKernelRegistry` + the target
182    /// backend; [`Self::default_rules`] is the all-available case.
183    pub fn capability_gated_rules(
184        has_kernel: impl Fn(FusedOpId) -> bool,
185    ) -> (Self, Vec<FusedOpId>) {
186        let registry = default_registry();
187        let mut r = Self::new();
188        let mut gated_out = Vec::new();
189        for entry in registry.entries_iter() {
190            r = r.with_rule(Box::new(LoweringRule::from_entry(entry)));
191            if has_kernel(entry.id) {
192                r = r.with_rule(Box::new(FusionRule::from_entry(entry)));
193            } else {
194                gated_out.push(entry.id);
195            }
196        }
197        // Runtime-registered fused ops (Tier-2), same gate. A runtime op with no
198        // kernel yet (JIT cold start) is gated out + reported as a miss — the
199        // synthesize work-order — and its region stays primitive this pass.
200        for e in crate::runtime_fused::runtime_entries() {
201            r = r.with_rule(Box::new(LoweringRule::runtime(e.id)));
202            if has_kernel(e.id) {
203                r = r.with_rule(Box::new(FusionRule::declarative(
204                    e.id,
205                    crate::registry::PatternTree {
206                        root: e.region,
207                        params: FusedOpParams::Runtime {
208                            scalars: Vec::new(),
209                        },
210                    },
211                )));
212            } else {
213                gated_out.push(e.id);
214            }
215        }
216        (r, gated_out)
217    }
218
219    /// Lowering rules only — no fusion. Use this when the test or
220    /// caller wants the lowered form to be the post-pipeline state
221    /// (e.g. equivalence-testing the composed-math path on a backend
222    /// that has the primitives but not the fused kernel).
223    pub fn lowering_only() -> Self {
224        let registry = default_registry();
225        let mut r = Self::new();
226        for entry in registry.entries_iter() {
227            r = r.with_rule(Box::new(LoweringRule::from_entry(entry)));
228        }
229        // Runtime ops decompose to their region here too — this is the
230        // kernel-absent path (a backend without the synthesized kernel runs the
231        // primitive recipe).
232        for e in crate::runtime_fused::runtime_entries() {
233            r = r.with_rule(Box::new(LoweringRule::runtime(e.id)));
234        }
235        r
236    }
237
238    /// Number of registered rules.
239    pub fn len(&self) -> usize {
240        self.rules.len()
241    }
242
243    /// Whether the registry has any rules.
244    pub fn is_empty(&self) -> bool {
245        self.rules.is_empty()
246    }
247
248    /// Run lowering rules to fixpoint, then fusion rules to fixpoint.
249    /// Returns the (possibly-rewritten) roots — callers use these to
250    /// replace their `NodeHandle` handles.
251    ///
252    /// The two-phase ordering is important: rules that are inverses
253    /// of each other (e.g. SoftmaxLastDim's lower + fuse pair)
254    /// would oscillate in a single mixed-family fixpoint loop.
255    /// Phase ordering breaks the oscillation and gives fusion the
256    /// last word.
257    pub fn optimize_to_fixpoint(&self, graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
258        let mut roots = roots.to_vec();
259        if self.rules.is_empty() {
260            return roots;
261        }
262        // Phase 1: lowering to fixpoint.
263        loop {
264            let any = self.run_pass(RuleFamily::Lowering, graph, &mut roots);
265            if !any {
266                break;
267            }
268        }
269        // Phase 2: fusion to fixpoint.
270        loop {
271            let any = self.run_pass(RuleFamily::Fusion, graph, &mut roots);
272            if !any {
273                break;
274            }
275        }
276        // Phase 3: algebraic identities to fixpoint. Runs last so it
277        // can't disturb fusion patterns by eliminating casts that
278        // fusion was about to match on.
279        loop {
280            let any = self.run_pass(RuleFamily::Algebraic, graph, &mut roots);
281            if !any {
282                break;
283            }
284        }
285        roots
286    }
287
288    /// One pass over the graph for a single rule family. Returns
289    /// `true` if any rule fired during the pass — caller loops until
290    /// `false` for fixpoint.
291    fn run_pass(&self, family: RuleFamily, graph: &SharedGraph, roots: &mut [NodeId]) -> bool {
292        let order = {
293            let g = graph.read().unwrap();
294            topo_order_multi(&g, roots)
295        };
296
297        // Iterate this pass's family-rules in the order they were
298        // registered. First match wins per node.
299        let family_rules: Vec<&Box<dyn Rule>> =
300            self.rules.iter().filter(|r| r.family() == family).collect();
301        if family_rules.is_empty() {
302            return false;
303        }
304
305        let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
306        let mut any_fired = false;
307
308        for id in order {
309            // Skip nodes that an earlier rule in this pass already
310            // rewrote — the consumer-edge rewrite happens after the
311            // pass, so the original node's structure still looks
312            // matchable mid-pass; we use `remap` as a "this pass
313            // already handled it" marker.
314            if remap.contains_key(&id) {
315                continue;
316            }
317
318            let firing_rule_idx = {
319                let g = graph.read().unwrap();
320                family_rules.iter().position(|r| r.matches(&g, id))
321            };
322            if let Some(idx) = firing_rule_idx {
323                let mut g = graph.write().unwrap();
324                let before = remap.len();
325                family_rules[idx].rewrite(&mut g, id, &mut remap);
326                // A rule that matched but recorded no remap entry made no
327                // progress — e.g. a `decompose` that returned self (G2's
328                // fixpoint signal: this op can't decompose further). Only
329                // count real rewrites, so the fixpoint loop terminates
330                // instead of spinning on a no-op match.
331                if remap.len() != before {
332                    any_fired = true;
333                }
334            }
335        }
336
337        if !any_fired {
338            return false;
339        }
340
341        // Apply remap to every consumer's input list, in place. This
342        // is the moment the rewritten nodes become the live ones —
343        // any node whose inputs referenced a remapped id now reads
344        // from its replacement.
345        {
346            let mut g = graph.write().unwrap();
347            let n_nodes = g.len();
348            for nid in 0..n_nodes {
349                let inputs_len = g.node(NodeId(nid)).inputs.len();
350                for i in 0..inputs_len {
351                    let cur = g.node(NodeId(nid)).inputs[i];
352                    if let Some(&new) = remap.get(&cur) {
353                        g.rewrite_input(NodeId(nid), cur, new);
354                    }
355                }
356            }
357        }
358
359        // And update the user's roots so the next phase / the caller
360        // sees the canonical post-rewrite ids.
361        for r in roots.iter_mut() {
362            if let Some(&new) = remap.get(r) {
363                *r = new;
364            }
365        }
366
367        true
368    }
369}
370
371/// Lower every reachable fused op in `graph` (rooted at `roots`) to its
372/// primitive base map — the fixpoint of `decompose` over every node. A thin
373/// named wrapper over [`RuleRegistry::lowering_only`] + `optimize_to_fixpoint`
374/// (the machinery every fused op — static-registry AND runtime — already flows
375/// through). Never panics: a self-returning `decompose` is a clean fixpoint
376/// (the never-panic total-decompose contract), not a loop.
377pub fn lower_to_base_map(graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
378    RuleRegistry::lowering_only().optimize_to_fixpoint(graph, roots)
379}
380
381/// `NodeId`-independent content hash of the subgraph rooted at `root`.
382///
383/// Recursively hashes each node as `(op identity, child hashes)`, folding in
384/// each child's *hash* rather than its `NodeId` — so two independently-built
385/// graphs (different arenas, different `NodeId` numbering) that are
386/// structurally identical hash equal. Reuses this module's existing
387/// canonicalization primitives rather than duplicating them:
388///
389/// - `op_key` for op identity when available (covers every primitive +
390///   `Op::Fused` variant CSE already relies on).
391/// - `is_commutative` to sort child hashes for `Add`/`Mul`, so `a + b` and
392///   `b + a` hash equal (mirrors CSE's own commutative-operand
393///   canonicalization). `Maximum`/`Minimum` are excluded -- see GAP-271.
394///
395/// `op_key` deliberately excludes `Op::Const` (its payload lives in the
396/// graph's `storage_map` slot, not the `Op` enum) and returns `None`
397/// for a handful of other ops (in-place,
398/// indexing, and anything else not explicitly listed). For those, this
399/// function falls back to `(discriminant, shape, dtype)` — and, for
400/// `Op::Const` specifically, additionally folds the constant's real bytes
401/// when they're readable (see `fold_const_bytes` below) so two
402/// independently-built recipes with equal constants hash equal, not just
403/// equal-shaped placeholders.
404///
405/// This is a cheap structural *pre-filter* for cross-graph recipe identity:
406/// structurally-equal-up-to-commutative-reordering-and-fusion-depth base
407/// maps hash equal. It does NOT canonicalize associativity or
408/// distributivity (`(a + b) + c` vs `a + (b + c)` hash differently) — a
409/// numeric verify pass covers that residual. Hashes are only stable within
410/// one process / `DefaultHasher` instance; never persist or compare them
411/// across processes.
412pub fn base_map_hash(graph: &Graph, root: NodeId) -> u64 {
413    use std::collections::hash_map::DefaultHasher;
414    use std::hash::{Hash, Hasher};
415
416    // Fold a `Const` node's real bytes into `hasher`, when they're
417    // readable without blocking. Const bytes live in the graph's
418    // `storage_map` slot (`lib.rs:218-223`), populated via
419    // `Graph::set_storage`/`storage_for` — NOT in the `Op` enum, so
420    // `op_key` can't see them (by design; see `OpKey`'s doc comment).
421    //
422    // Falls back to a silent no-op (the caller has already folded this
423    // node's shape/dtype via the `op_key -> None` branch) when:
424    // - the slot is unpopulated (e.g. a bare `Node { op: Op::Const, .. }`
425    //   pushed without ever calling `set_storage` — as the unit tests
426    //   below do for their placeholder consts), or
427    // - the slot's `RwLock` is held by a writer (`try_read` avoids
428    //   blocking a hash — a structural pre-filter — on live graph
429    //   mutation), or
430    // - the backend can't produce a host buffer right now (e.g.
431    //   device-only storage mid-capture).
432    //
433    // Documented limitation: in the fallback case, two *different*
434    // device-only (or unpopulated) consts of the same shape/dtype hash
435    // equal. `base_map_hash` is a pre-filter, not a full identity
436    // decision — a numeric verify pass is the source of truth when bytes
437    // aren't available at hash time.
438    fn fold_const_bytes(graph: &Graph, id: NodeId, hasher: &mut DefaultHasher) {
439        let Some(slot) = graph.storage_for(id) else {
440            return;
441        };
442        let Ok(guard) = slot.try_read() else { return };
443        let Ok(host) = guard.to_cpu_storage() else {
444            return;
445        };
446        fold_host_buffer(&host, hasher);
447    }
448
449    // Bridge every `HostBuffer` element dtype to something `Hash`. Integer
450    // variants (including the raw-byte-backed sub-byte dummy dtypes)
451    // already implement `Hash` directly; float variants don't (NaN
452    // equality is ambiguous under `Eq`, so std withholds `Hash`) — for
453    // those we fold each element's bit pattern via `to_bits()`, which is
454    // exact (including NaN payload bits) and matches the bit-pattern
455    // encoding `op_key` already uses for scalar payloads (e.g.
456    // `Op::AddScalar`'s `f64::to_bits()`).
457    fn fold_host_buffer(buf: &fuel_ir::HostBuffer, hasher: &mut DefaultHasher) {
458        use fuel_ir::HostBuffer as H;
459        match buf {
460            H::U8(v) | H::F6E2M3(v) | H::F6E3M2(v) | H::F4(v) | H::F8E8M0(v) | H::Bool(v) => {
461                v.hash(hasher)
462            }
463            H::I8(v) => v.hash(hasher),
464            H::U32(v) => v.hash(hasher),
465            H::I16(v) => v.hash(hasher),
466            H::I32(v) => v.hash(hasher),
467            H::I64(v) => v.hash(hasher),
468            H::BF16(v) => {
469                for x in v {
470                    x.to_bits().hash(hasher);
471                }
472            }
473            H::F16(v) => {
474                for x in v {
475                    x.to_bits().hash(hasher);
476                }
477            }
478            H::F32(v) => {
479                for x in v {
480                    x.to_bits().hash(hasher);
481                }
482            }
483            H::F64(v) => {
484                for x in v {
485                    x.to_bits().hash(hasher);
486                }
487            }
488            H::F8E4M3(v) => {
489                for x in v {
490                    x.to_bits().hash(hasher);
491                }
492            }
493        }
494    }
495
496    fn go(graph: &Graph, id: NodeId, memo: &mut HashMap<NodeId, u64>) -> u64 {
497        if let Some(&h) = memo.get(&id) {
498            return h;
499        }
500        let n = graph.node(id);
501        let mut hasher = DefaultHasher::new();
502        match op_key(&n.op) {
503            Some(k) => k.hash(&mut hasher),
504            None => {
505                std::mem::discriminant(&n.op).hash(&mut hasher);
506                n.shape.dims().hash(&mut hasher);
507                n.dtype.hash(&mut hasher);
508                if matches!(n.op, Op::Const) {
509                    fold_const_bytes(graph, id, &mut hasher);
510                }
511            }
512        }
513        let mut child_hashes: Vec<u64> = n.inputs.iter().map(|&c| go(graph, c, memo)).collect();
514        if is_commutative(&n.op) {
515            child_hashes.sort_unstable();
516        }
517        child_hashes.hash(&mut hasher);
518        let h = hasher.finish();
519        memo.insert(id, h);
520        h
521    }
522
523    go(graph, root, &mut HashMap::new())
524}
525
526// ---- Auto-generated lowering + fusion rules from FusedOpRegistry ----------
527//
528// Phase 7.6 step 3: PR 3's hand-written `SoftmaxLastDimLowerRule` and
529// `SoftmaxLastDimFuseRule` are replaced by [`LoweringRule`] +
530// [`FusionRule`] generic abstractions that read the per-fused-op
531// metadata (decompose function, pattern matcher) from a
532// [`FusedOpEntry`] in `crate::registry::default_registry`. Each rule
533// fires on `Op::Fused(id, _)` for lowering or on the pattern's
534// canonical primitive subgraph for fusion.
535//
536// Lowered form (7 nodes for SoftmaxLastDim, symmetric across max/sum):
537//
538//   m   = ReduceMaxTo([..., 1])(x)   # max-keepdim in one node
539//   mb  = BroadcastTo([..., last])(m)
540//   s   = Sub(x, mb)                 # numerically-stable shift
541//   e   = Exp(s)
542//   d   = ReduceSumTo([..., 1])(e)   # sum-keepdim in one node
543//   db  = BroadcastTo([..., last])(d)
544//   out = Div(e, db)
545//
546// The compile_one auto-Contiguize at the executor catches any backend
547// that doesn't support the strided BroadcastTo output as a kernel
548// input. PR 2-wide lets CUDA binary F32 kernels consume the broadcast
549// strides directly without materialization.
550
551/// Auto-generated lowering rule from a [`FusedOpEntry`]. Matches
552/// `Op::Fused(id, _)` and decomposes via the entry's `decompose`
553/// function. Each rule corresponds to one fused-op id in the registry.
554///
555/// Step 3 also fires on the legacy per-fused-op `Op` variants (e.g.
556/// `Op::SoftmaxLastDim`) so emission sites that haven't yet migrated
557/// to `Op::Fused` continue to lower correctly. Step 5 deletes those
558/// variants and the legacy match arm with them.
559pub struct LoweringRule {
560    id: FusedOpId,
561    decompose: fn(&mut Graph, NodeId, &FusedOpParams) -> NodeId,
562}
563
564impl LoweringRule {
565    pub fn from_entry(entry: &FusedOpEntry) -> Self {
566        Self {
567            id: entry.id,
568            decompose: entry.decompose,
569        }
570    }
571
572    /// Lowering rule for a runtime-registered fused op: decompose by re-emitting
573    /// its `runtime_fused` sidecar region as primitives. One per runtime id.
574    pub fn runtime(id: FusedOpId) -> Self {
575        Self {
576            id,
577            decompose: crate::runtime_fused::runtime_lowering_decompose,
578        }
579    }
580}
581
582impl Rule for LoweringRule {
583    fn name(&self) -> &'static str {
584        // Step 3 ships only SoftmaxLastDim; step 4 generalizes per-id.
585        // Until then, all auto-generated lowering rules share one name —
586        // the registry id distinguishes them at the structural level.
587        "FusedOpLowering"
588    }
589
590    fn family(&self) -> RuleFamily {
591        RuleFamily::Lowering
592    }
593
594    fn matches(&self, graph: &Graph, id: NodeId) -> bool {
595        let node = graph.node(id);
596        // Arity is per-fused-op; the decompose function is the
597        // authority. We don't gate on `node.inputs.len()` here.
598        match &node.op {
599            Op::Fused(fid, _) if *fid == self.id => true,
600            // Phase 7.6 step 5 (2026-05-11): legacy-variant
601            // fallthroughs have been dropped — every migrated fused
602            // op now flows through `Op::Fused(id, _)`. Step 5 dropped
603            // the per-fused-op `Op` variants together with these
604            // matcher arms.
605            _ => false,
606        }
607    }
608
609    fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>) {
610        // Read params from the matched `Op::Fused(_, p)` node; the
611        // legacy primitive-variant fallthroughs were dropped in
612        // step 5.
613        let params = match &graph.node(id).op {
614            Op::Fused(_, p) => p.clone(),
615            other => unreachable!(
616                "LoweringRule::rewrite reached with non-matching op {other:?} \
617                 — matcher contract violated"
618            ),
619        };
620        let new_id = (self.decompose)(graph, id, &params);
621        // G2 (2026-06-20): a `decompose` that returns the SAME node is at
622        // its fixpoint — the op can't decompose further (a primitive, or a
623        // coarse op whose recipe/primitives don't exist yet, which returns
624        // self). That is not a rewrite, so we record no remap entry; the
625        // driver (`run_pass`) then sees the unchanged remap and does not
626        // count it as progress, so the lowering fixpoint terminates instead
627        // of oscillating. The node is left as `Op::Fused` — a surfaced
628        // opaque-op gap that an inventory pass can find.
629        if new_id != id {
630            remap.insert(id, new_id);
631        }
632    }
633}
634
635/// Auto-generated fusion rule from a [`FusedOpEntry`]. Matches the
636/// canonical primitive-subgraph pattern carried by the entry's
637/// [`SubgraphPattern`] and emits a single `Op::Fused(id, params)`
638/// node. Round-trips with [`LoweringRule`] for the same id.
639///
640/// Currently params are reconstructed by-id: SoftmaxLastDim's variant
641/// is parameterless, so the fusion rule emits
642/// `FusedOpParams::SoftmaxLastDim`. Step 4 extends [`crate::registry::PatternMatch`]
643/// (or adds a sibling `extract_params` field on `FusedOpEntry`) so
644/// param-bearing fused ops can recover their parameters from the
645/// matched subgraph.
646pub struct FusionRule {
647    id: FusedOpId,
648    pattern: PatternKind,
649}
650
651/// Internal: the runtime-flavor of a registry entry's pattern. We
652/// keep a function-pointer copy here rather than holding a reference
653/// into the (process-wide) registry so the rule is `'static`.
654#[expect(
655    clippy::large_enum_variant,
656    reason = "matcher pattern enum; boxing the large variant is deferred to the same follow-up as PatternNode (docs/gaps.md GAP-215) -- a cross-cutting change to construction/match sites, not a lint-commit change"
657)]
658enum PatternKind {
659    Callable(fn(&Graph, NodeId) -> Option<crate::registry::PatternMatch>),
660    /// The declarative form (fkc-fusion-patterns §3): carries the owned
661    /// `PatternTree` (the §3 grammar root + the params to stamp) so the rule is
662    /// `'static`. `matches`/`rewrite` walk it via [`crate::jit::match_region`].
663    Declarative(crate::registry::PatternTree),
664}
665
666impl FusionRule {
667    pub fn from_entry(entry: &FusedOpEntry) -> Self {
668        let pattern = match &entry.pattern {
669            SubgraphPattern::Callable(f) => PatternKind::Callable(*f),
670            SubgraphPattern::Declarative(tree) => PatternKind::Declarative(tree.clone()),
671        };
672        Self {
673            id: entry.id,
674            pattern,
675        }
676    }
677
678    /// Fusion rule from a declarative pattern (the runtime path): match the
679    /// region and stamp `tree.params` on the emitted `Op::Fused(id, _)`.
680    pub fn declarative(id: FusedOpId, tree: crate::registry::PatternTree) -> Self {
681        Self {
682            id,
683            pattern: PatternKind::Declarative(tree),
684        }
685    }
686}
687
688impl Rule for FusionRule {
689    fn name(&self) -> &'static str {
690        "FusedOpFusion"
691    }
692    fn family(&self) -> RuleFamily {
693        RuleFamily::Fusion
694    }
695
696    fn matches(&self, graph: &Graph, id: NodeId) -> bool {
697        match &self.pattern {
698            PatternKind::Callable(f) => (*f)(graph, id).is_some(),
699            PatternKind::Declarative(tree) => {
700                let consumers = |n: NodeId| {
701                    (0..graph.len())
702                        .filter(|&i| graph.node(NodeId(i)).inputs.contains(&n))
703                        .count()
704                };
705                crate::jit::match_region(graph, id, &tree.root, &consumers).is_some()
706            }
707        }
708    }
709
710    fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>) {
711        let pattern_match = match &self.pattern {
712            PatternKind::Callable(f) => (*f)(graph, id)
713                .expect("rewrite called with non-matching id — matcher contract violated"),
714            PatternKind::Declarative(tree) => {
715                // match_region_extract returns the region's external inputs in
716                // bind-index order PLUS the live scalar values its open slots
717                // matched (the `extract:` layer). Wrap them as a PatternMatch
718                // so the shared reconstruction path below applies. For a
719                // Runtime params template the extracted scalars REPLACE the
720                // template's (fusing a scalar region with the template's empty
721                // scalars would silently lose the live values); non-Runtime
722                // templates stamp as-is (their matchers are value-agnostic).
723                let consumers = |n: NodeId| {
724                    (0..graph.len())
725                        .filter(|&i| graph.node(NodeId(i)).inputs.contains(&n))
726                        .count()
727                };
728                let (inputs, extracted) =
729                    crate::jit::match_region_extract(graph, id, &tree.root, &consumers)
730                        .expect("rewrite called with non-matching declarative pattern");
731                let params = match &tree.params {
732                    FusedOpParams::Runtime { .. } => FusedOpParams::Runtime { scalars: extracted },
733                    other => other.clone(),
734                };
735                crate::registry::PatternMatch {
736                    bindings: inputs.iter().enumerate().map(|(i, n)| (i, *n)).collect(),
737                    params,
738                }
739            }
740        };
741        // General-arity input reconstruction: bindings are (index, NodeId)
742        // pairs; sorting by index yields the emitted node's inputs in
743        // canonical order. SoftmaxLastDim binds {0 → x_id}; FusedLinear
744        // binds {0 → a, 1 → b, 2 → bias}. Missing index in the [0, N)
745        // range is a matcher contract violation.
746        let mut sorted = pattern_match.bindings.clone();
747        sorted.sort_by_key(|(idx, _)| *idx);
748        for (expected_idx, (got_idx, _)) in sorted.iter().enumerate() {
749            debug_assert_eq!(
750                *got_idx, expected_idx,
751                "FusionRule: bindings must be dense [0..N); pattern for id \
752                 {:?} returned non-contiguous indices",
753                self.id,
754            );
755        }
756        let inputs: Vec<NodeId> = sorted.iter().map(|(_, n)| *n).collect();
757        // Params come from the matcher — it's authoritative on what
758        // variant the recognized subgraph represents.
759        let params = pattern_match.params;
760        let dtype = graph.node(id).dtype;
761        let shape = graph.node(id).shape.clone();
762        let new_id = graph.push(Node {
763            op: Op::Fused(self.id, params),
764            inputs,
765            shape,
766            dtype,
767        });
768        remap.insert(id, new_id);
769    }
770}
771
772// ---- Cast-fusion algebraic rule ------------------------------------------
773//
774// `CastFusionRule` fires on `Cast(src→dst) → Op(dst, ...)` chains
775// where:
776//   - the Cast (or chain of casts) has exactly one consumer at
777//     each link of the chain,
778//   - the consumer is a **type-preserving op** (its output dtype
779//     equals the cast-fed input dtype — true for unary/binary
780//     arithmetic, reductions, MatMul; *false* for compare ops,
781//     Where, Cast),
782//   - the consumer has a kernel for the cast's *source* dtype on
783//     all input slots (capability predicate says yes).
784//
785// On fire: a new consumer node is appended with the cast input
786// replaced by the cast's source. **The new consumer's output dtype
787// is the cast-source dtype, not the original output dtype.** Any
788// downstream consumers reading from the rewritten node now see the
789// new dtype. This is by design — the rule fires aggressively under
790// the assumption that downstream consumers either (a) are
791// type-tolerant or (b) will have their own cast-fusion applied,
792// collapsing the chain.
793//
794// **Caveat — semantic change**: in graphs where the downstream
795// expects the original dst_dtype (e.g. a sink that returns BF16),
796// aggressive cast-fusion silently changes the dtype the user
797// observes. Wire this rule opt-in until a downstream-tolerance
798// framework (precision filter, op-tolerance metadata) is in place
799// to guard against unsafe rewrites.
800//
801// Chain cancellation is the always-safe sub-case: when the chain
802// is a net no-op (`Cast(F32→BF16) → Cast(BF16→F32) → Op(F32)`),
803// the final source dtype already matches the consumer's input
804// slot, so neither output dtype nor kernel selection changes.
805//
806// Architecture v1.0: this is the first "algebraic" family rule.
807// Future algebraic rewrites (constant folding, identity
808// elimination, CSE hoisting) join the same family and run in the
809// same Phase 3 loop.
810//
811// Decoupling: `fuel-graph::opt` doesn't know about binding tables,
812// PrecisionGuarantee, or BackendId. The capability check is
813// injected at rule construction via [`CapabilityPredicate`]; the
814// consumer (typically the route-picker layer in fuel-storage or a
815// higher crate) is responsible for closing over a binding-table
816// reference and answering the predicate.
817
818use std::sync::Arc;
819
820/// Predicate answering "is there a registered kernel for this op
821/// with these input/output dtypes?" The dtypes vector is the
822/// consumer's full input-dtype list (after the cast-source
823/// substitution) followed by the consumer's output dtype. Format
824/// matches the binding-table key shape used by `fuel-storage`.
825///
826/// Returns `true` if at least one backend has a kernel registered
827/// for the proposed (op, dtypes) combination — the route picker
828/// later decides which backend to use.
829pub type CapabilityPredicate = Arc<dyn Fn(&Op, &[DType]) -> bool + Send + Sync>;
830
831/// `Cast(src→dst) → Op(dst) ≡ Op(src)` when the consumer has a
832/// kernel for `src`.
833///
834/// Only fires when the cast has exactly one consumer (MVP: avoids
835/// the DAG complication of partial fan-out elimination). Multi-
836/// consumer Cast handling is a follow-up: rewrite one consumer and
837/// keep the cast alive for the others.
838pub struct CastFusionRule {
839    capabilities: CapabilityPredicate,
840}
841
842impl CastFusionRule {
843    /// Build the rule with a capability predicate. The predicate
844    /// closes over whatever capability source the caller has (a
845    /// binding table, a route picker, a static manifest). The rule
846    /// itself stays decoupled from those internals.
847    pub fn new(capabilities: CapabilityPredicate) -> Self {
848        Self { capabilities }
849    }
850
851    /// Walk `node.inputs` looking for an edge ending at a Cast (or
852    /// a chain of Casts) where every link in the chain has exactly
853    /// one consumer (the next link). Returns `Some((input_index,
854    /// final_src_id, consumer_dtypes))` where `final_src_id` is the
855    /// deepest reachable source after walking through the chain.
856    /// `consumer_dtypes` is the dtypes vector to ask the predicate
857    /// about. Returns `None` if no input qualifies.
858    ///
859    /// Walking through chained casts in one pass avoids a subtle
860    /// fixpoint trap: after the outer Cast is eliminated, the inner
861    /// Cast's other consumer (the now-orphan outer Cast) is still
862    /// in the arena and would make `count_consumers != 1` on the
863    /// next iteration. Collapsing the whole chain at once sidesteps
864    /// this because we never re-examine intermediate links.
865    ///
866    /// Shared by `matches` (which discards the index) and `rewrite`
867    /// (which uses the index to point the new consumer's input edge
868    /// at the deepest source). Computed twice intentionally — the
869    /// alternative is mutable state across `matches`/`rewrite`,
870    /// which conflicts with the trait's pure-matcher contract.
871    fn find_eligible_cast_input(
872        &self,
873        graph: &Graph,
874        id: NodeId,
875    ) -> Option<(usize, NodeId, Vec<fuel_ir::DType>)> {
876        let node = graph.node(id);
877        // Don't fire on a Cast node itself — fire on the consumer.
878        if matches!(node.op, Op::Cast(_)) {
879            return None;
880        }
881        for (idx, &input_id) in node.inputs.iter().enumerate() {
882            // Walk through chained casts. `current` is the cast (or
883            // chain of casts) currently being examined; `prev` is
884            // the consumer at this link of the chain (initially `id`,
885            // then the outer-most cast as we descend).
886            if !matches!(graph.node(input_id).op, Op::Cast(_)) {
887                continue;
888            }
889            // Type-preserving filter: the consumer's output dtype
890            // must equal the cast'd input slot's dtype. This is the
891            // family of ops where rewriting the input dtype is
892            // equivalent to rewriting the output dtype — Neg/Add/
893            // Mul/Div, reductions, MatMul, etc. Excludes Compare
894            // (T→U8), Where (U8+T+T→T), and Cast itself (which we
895            // already filtered above). For non-type-preserving
896            // consumers, swapping the input dtype would produce an
897            // output we can't predict without per-op knowledge.
898            if node.dtype != graph.node(input_id).dtype {
899                continue;
900            }
901            let mut prev = id;
902            let mut current = input_id;
903            let final_src_id = loop {
904                let current_node = graph.node(current);
905                if !matches!(current_node.op, Op::Cast(_)) {
906                    break current;
907                }
908                // Single-consumer constraint at this link: the cast
909                // at `current` must feed only `prev`. Otherwise we
910                // can't eliminate it (would orphan the other consumer's
911                // input edge).
912                if !is_only_consumer(graph, current, prev) {
913                    // The chain stops here; we treat the current node
914                    // (a Cast) as the source. The consumer takes the
915                    // dst dtype of this Cast.
916                    break current;
917                }
918                let Some(&next) = current_node.inputs.first() else {
919                    break current;
920                };
921                prev = current;
922                current = next;
923            };
924            // If the walk didn't move past the first cast (e.g.
925            // because the first cast has multiple consumers), there's
926            // nothing to fuse for this input.
927            if final_src_id == input_id {
928                continue;
929            }
930            let final_src_dtype = graph.node(final_src_id).dtype;
931            // Build the proposed input-dtypes + output-dtype vector.
932            // Type-preserving filter above guarantees output dtype
933            // tracks the cast-fed input, so the new output dtype is
934            // `final_src_dtype` (not the original `node.dtype`).
935            let mut dtypes: Vec<fuel_ir::DType> = node
936                .inputs
937                .iter()
938                .map(|&iid| {
939                    if iid == input_id {
940                        final_src_dtype
941                    } else {
942                        graph.node(iid).dtype
943                    }
944                })
945                .collect();
946            dtypes.push(final_src_dtype);
947            if (self.capabilities)(&node.op, &dtypes) {
948                return Some((idx, final_src_id, dtypes));
949            }
950        }
951        None
952    }
953}
954
955/// Returns `true` if `expected_consumer` is the only consumer of
956/// `target` in the arena. Used to guard chained-cast elimination.
957fn is_only_consumer(graph: &Graph, target: NodeId, expected_consumer: NodeId) -> bool {
958    let mut found_others = false;
959    for i in 0..graph.len() {
960        let nid = NodeId(i);
961        if nid == expected_consumer {
962            continue;
963        }
964        if graph.node(nid).inputs.contains(&target) {
965            found_others = true;
966            break;
967        }
968    }
969    !found_others
970}
971
972impl Rule for CastFusionRule {
973    fn name(&self) -> &'static str {
974        "CastFusion"
975    }
976    fn family(&self) -> RuleFamily {
977        RuleFamily::Algebraic
978    }
979
980    fn matches(&self, graph: &Graph, id: NodeId) -> bool {
981        self.find_eligible_cast_input(graph, id).is_some()
982    }
983
984    fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>) {
985        let (idx, final_src_id, _dtypes) = self
986            .find_eligible_cast_input(graph, id)
987            .expect("CastFusionRule::rewrite called on non-matching node");
988        let node = graph.node(id).clone();
989        let final_src_dtype = graph.node(final_src_id).dtype;
990        let mut new_inputs = node.inputs.clone();
991        new_inputs[idx] = final_src_id;
992        let new_id = graph.push(Node {
993            op: node.op.clone(),
994            inputs: new_inputs,
995            shape: node.shape.clone(),
996            // Type-preserving op (matcher's invariant): the new
997            // output dtype tracks the new input dtype. Downstream
998            // consumers seeing this node may observe a different
999            // dtype than before the rewrite — see the module
1000            // docstring on the aggressive-semantics caveat.
1001            dtype: final_src_dtype,
1002        });
1003        remap.insert(id, new_id);
1004    }
1005}
1006
1007/// A HashMap-friendly encoding of `Op`. Needed because `Op` carries
1008/// `f64` (in `AddScalar`, `MulScalar`, `Clamp`, `LayerNormLastDim`,
1009/// `LayerNormLastDimBackward`) and `ConstData` (in `Const`), neither
1010/// of which is `Hash + Eq`. We keep `Const` out of CSE entirely
1011/// (identity-dedup happens via the executor's Arc-pointer const pool
1012/// already) and encode scalar payloads as their bit patterns.
1013#[derive(Debug, Hash, PartialEq, Eq)]
1014struct OpKey {
1015    tag: u16,
1016    ints: Vec<i64>,
1017    bits: Vec<u64>,
1018    // Sparse payloads we don't need to index into for equality are
1019    // serialized via their Debug repr as a fallback. Cheap and
1020    // correct; not used on the hot path outside simplification.
1021    dims: Vec<usize>,
1022    shape: Option<Vec<usize>>,
1023    dtype: Option<u32>,
1024}
1025
1026fn op_key(op: &Op) -> Option<OpKey> {
1027    // We deliberately refuse to CSE `Const`. Rationale above.
1028    let (tag, ints, bits, dims, shape, dtype) = match op {
1029        Op::Const => return None,
1030
1031        Op::Add => (1, vec![], vec![], vec![], None, None),
1032        Op::Sub => (2, vec![], vec![], vec![], None, None),
1033        Op::Mul => (3, vec![], vec![], vec![], None, None),
1034        Op::Div => (4, vec![], vec![], vec![], None, None),
1035
1036        Op::Neg => (10, vec![], vec![], vec![], None, None),
1037        Op::Sqr => (11, vec![], vec![], vec![], None, None),
1038        Op::Sqrt => (12, vec![], vec![], vec![], None, None),
1039        Op::Exp => (13, vec![], vec![], vec![], None, None),
1040        Op::Log => (14, vec![], vec![], vec![], None, None),
1041        Op::Sin => (15, vec![], vec![], vec![], None, None),
1042        Op::Cos => (16, vec![], vec![], vec![], None, None),
1043        Op::Tanh => (17, vec![], vec![], vec![], None, None),
1044        Op::Sigmoid => (18, vec![], vec![], vec![], None, None),
1045        Op::Silu => (19, vec![], vec![], vec![], None, None),
1046        Op::GeluTanh => (20, vec![], vec![], vec![], None, None),
1047        Op::Relu => (21, vec![], vec![], vec![], None, None),
1048        Op::Step => (22, vec![], vec![], vec![], None, None),
1049        Op::Recip => (23, vec![], vec![], vec![], None, None),
1050        Op::Abs => (24, vec![], vec![], vec![], None, None),
1051
1052        // --- comparison family (output dtype is U8) ---
1053        Op::Equal => (25, vec![], vec![], vec![], None, None),
1054        Op::Ne => (26, vec![], vec![], vec![], None, None),
1055        Op::Lt => (27, vec![], vec![], vec![], None, None),
1056        Op::Le => (28, vec![], vec![], vec![], None, None),
1057        Op::Gt => (29, vec![], vec![], vec![], None, None),
1058        // Tag 30 already taken by Op::MatMul below; the comparison
1059        // family wraps to 33 (the next free slot above linear-algebra)
1060        // for Op::Ge. The family sits at 25-29 + 33 — a small
1061        // contiguity break tolerated rather than renumbering existing
1062        // ops opportunistically.
1063        Op::Ge => (33, vec![], vec![], vec![], None, None),
1064
1065        // Ternary select. Slot 34 (next free after the comparison
1066        // family's wrap to 33).
1067        Op::Where => (34, vec![], vec![], vec![], None, None),
1068
1069        // --- rounding family (non-differentiable) ---
1070        Op::Floor => (35, vec![], vec![], vec![], None, None),
1071        Op::Ceil => (36, vec![], vec![], vec![], None, None),
1072        Op::Round => (37, vec![], vec![], vec![], None, None),
1073        Op::Sign => (38, vec![], vec![], vec![], None, None),
1074        Op::Erf => (39, vec![], vec![], vec![], None, None),
1075        // Tag 40 was Op::Cast; the unary fanout wraps to 47 (next
1076        // free slot above the 40-46 cast/shape/reduce cluster).
1077        Op::Gelu => (47, vec![], vec![], vec![], None, None),
1078        Op::Pow => (49, vec![], vec![], vec![], None, None),
1079        Op::Rsqrt => (54, vec![], vec![], vec![], None, None),
1080        Op::Rem => (55, vec![], vec![], vec![], None, None),
1081        Op::Flip { dim } => (56, vec![*dim as i64], vec![], vec![], None, None),
1082        Op::Roll { dim, shift } => (57, vec![*dim as i64, *shift], vec![], vec![], None, None),
1083        Op::CumSum { dim } => (58, vec![*dim as i64], vec![], vec![], None, None),
1084        Op::PadBackward {
1085            in_shape,
1086            padding,
1087            mode,
1088        } => {
1089            let mode_tag = match mode {
1090                crate::PadMode::Constant => 0_i64,
1091                crate::PadMode::Reflect => 1,
1092                crate::PadMode::Replicate => 2,
1093            };
1094            let mut ints: Vec<i64> = Vec::with_capacity(padding.len() * 2 + 1);
1095            for &(b, a) in padding {
1096                ints.push(b as i64);
1097                ints.push(a as i64);
1098            }
1099            ints.push(mode_tag);
1100            (
1101                60,
1102                ints,
1103                vec![],
1104                vec![],
1105                Some(in_shape.dims().to_vec()),
1106                None,
1107            )
1108        }
1109        Op::Pad {
1110            padding,
1111            mode,
1112            value,
1113        } => {
1114            let mode_tag = match mode {
1115                crate::PadMode::Constant => 0_i64,
1116                crate::PadMode::Reflect => 1,
1117                crate::PadMode::Replicate => 2,
1118            };
1119            // Flatten padding pairs into [b0, a0, b1, a1, ...] + the
1120            // mode tag. Multi-dim padding keys uniquely off this vec.
1121            let mut ints: Vec<i64> = Vec::with_capacity(padding.len() * 2 + 1);
1122            for &(b, a) in padding {
1123                ints.push(b as i64);
1124                ints.push(a as i64);
1125            }
1126            ints.push(mode_tag);
1127            (59, ints, vec![value.to_bits()], vec![], None, None)
1128        }
1129
1130        Op::MatMul => (30, vec![], vec![], vec![], None, None),
1131        Op::Transpose => (31, vec![], vec![], vec![], None, None),
1132        Op::Permute(axes) => (32, vec![], vec![], axes.clone(), None, None),
1133
1134        Op::Cast(dt) => (40, vec![], vec![], vec![], None, Some(dtype_key(*dt))),
1135        Op::BroadcastTo(s) => (41, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1136        Op::Reshape(s) => (42, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1137        Op::Contiguize => (46, vec![], vec![], vec![], None, None),
1138        Op::Unsqueeze { dim } => (45, vec![*dim as i64], vec![], vec![], None, None),
1139        Op::Squeeze { dim } => (48, vec![*dim as i64], vec![], vec![], None, None),
1140        Op::ReduceSumTo(s) => (43, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1141        Op::ReduceMaxTo(s) => (44, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1142        // Phase 7.6 step 5 (2026-05-11): tags 46/70/71/72/73/74/75/76
1143        // formerly assigned to SoftmaxLastDim, LayerNormLastDim,
1144        // RmsNormLastDim, Rope and the four backward helpers have
1145        // been retired with their `Op` variants. Those ops now flow
1146        // through the `Op::Fused(fid, fparams)` arm below (tag 200,
1147        // with id + params encoded into the int/bit slots) and CSE
1148        // dedupes correctly because each entry produces a distinct
1149        // FusedOpId.
1150        Op::SumAll => (50, vec![], vec![], vec![], None, None),
1151        Op::MaxAll => (51, vec![], vec![], vec![], None, None),
1152        Op::MinAll => (52, vec![], vec![], vec![], None, None),
1153        Op::MeanAll => (53, vec![], vec![], vec![], None, None),
1154
1155        Op::SumDim(d) => (60, vec![*d as i64], vec![], vec![], None, None),
1156        Op::MaxDim(d) => (61, vec![*d as i64], vec![], vec![], None, None),
1157        Op::MinDim(d) => (62, vec![*d as i64], vec![], vec![], None, None),
1158        Op::MeanDim(d) => (63, vec![*d as i64], vec![], vec![], None, None),
1159        Op::ArgMaxDim(d) => (64, vec![*d as i64], vec![], vec![], None, None),
1160        Op::ArgMinDim(d) => (65, vec![*d as i64], vec![], vec![], None, None),
1161
1162        Op::Concat { dim } => (80, vec![*dim as i64], vec![], vec![], None, None),
1163        Op::Slice { dim, start, len } => (
1164            81,
1165            vec![*dim as i64, *start as i64, *len as i64],
1166            vec![],
1167            vec![],
1168            None,
1169            None,
1170        ),
1171
1172        Op::AddScalar(c) => (90, vec![], vec![c.to_bits()], vec![], None, None),
1173        Op::MulScalar(c) => (91, vec![], vec![c.to_bits()], vec![], None, None),
1174        Op::PowI(n) => (92, vec![*n as i64], vec![], vec![], None, None),
1175        Op::Clamp { min, max } => (
1176            93,
1177            vec![],
1178            vec![min.to_bits(), max.to_bits()],
1179            vec![],
1180            None,
1181            None,
1182        ),
1183
1184        Op::Maximum => (100, vec![], vec![], vec![], None, None),
1185        Op::Minimum => (101, vec![], vec![], vec![], None, None),
1186
1187        // Phase 7.6 step 2: registry-extended fused ops. CSE folds two
1188        // Op::Fused nodes with identical (id, params) to one. Encoding:
1189        // base tag 200; ints = [id.0, params.tag, ...params.ints]; bits
1190        // = params.bits. Mirrors the FusedOpParamsKey shape from
1191        // crate::registry so the encoding tracks any future param
1192        // variants without rewriting this arm.
1193        Op::Fused(fid, fparams) => {
1194            let pk = fparams.key();
1195            let mut ints: Vec<i64> = Vec::with_capacity(2 + pk.ints.len());
1196            ints.push(fid.0 as i64);
1197            ints.push(pk.tag as i64);
1198            ints.extend_from_slice(&pk.ints);
1199            (200, ints, pk.bits, vec![], None, None)
1200        }
1201
1202        // Op::Scan folds ONLY its own params (n_xs, bound, emit tag,
1203        // early_exit-present flag). The BODY is hashed automatically by
1204        // base_map_hash's child-recursion, because the body-exit NodeIds are
1205        // the last two entries of the node's `inputs` (the lax.scan encoding).
1206        // Two scans with identical params but different bodies therefore get
1207        // different child-hashes and different base_map_hash. Tag 210.
1208        Op::Scan {
1209            n_carries,
1210            n_xs,
1211            bound,
1212            emit,
1213            early_exit,
1214        } => {
1215            let emit_tag: i64 = match emit {
1216                ScanEmit::All => 0,
1217                ScanEmit::Final => 1,
1218            };
1219            let exit_flag: i64 = if early_exit.is_some() { 1 } else { 0 };
1220            (
1221                210,
1222                // ⚠️ GAP-303: `n_carries` MUST be folded here. This is Fuel's own
1223                // recipe identity (base_map_hash) -- omit it and a 1-carry and a
1224                // 3-carry Scan with the same n_xs/bound/emit hash IDENTICALLY, so
1225                // cover-finding and the plan cache treat them as one recipe. Same
1226                // defect class as KISS-OPS-6.19 (an arm reading too few attrs made
1227                // Gather-on-axis-2 and axis-0 emit identical bytes), and a SECOND
1228                // site beside the seam's canonical body.
1229                //
1230                // Appended LAST so existing single-carry hashes are the only ones
1231                // that move, and they move uniformly.
1232                vec![
1233                    *n_xs as i64,
1234                    *bound as i64,
1235                    emit_tag,
1236                    exit_flag,
1237                    *n_carries as i64,
1238                ],
1239                vec![],
1240                vec![],
1241                None,
1242                None,
1243            )
1244        }
1245        // Op::ScanPlaceholder folds role tag + index so Carry/0, Elem/0,
1246        // Carry/1 are all distinct in the body hash. Tag 211.
1247        Op::ScanPlaceholder { role, index } => {
1248            let role_tag: i64 = match role {
1249                ScanRole::Carry => 0,
1250                ScanRole::Elem => 1,
1251            };
1252            (
1253                211,
1254                vec![role_tag, *index as i64],
1255                vec![],
1256                vec![],
1257                None,
1258                None,
1259            )
1260        }
1261
1262        // Indexing and anything else we haven't explicitly listed:
1263        // fall back to a unique tag that includes a structural
1264        // discriminant. These ops rarely appear more than once with
1265        // identical inputs, so we just mark them non-CSE-able by
1266        // returning None. Conservative; safe.
1267        _ => return None,
1268    };
1269    Some(OpKey {
1270        tag,
1271        ints,
1272        bits,
1273        dims,
1274        shape,
1275        dtype,
1276    })
1277}
1278
1279fn dtype_key(dt: DType) -> u32 {
1280    // Cheap injection: the Debug form is stable.
1281    // For tiny enums this compiles to a jump table.
1282    format!("{dt:?}")
1283        .as_bytes()
1284        .iter()
1285        .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(*b as u32))
1286}
1287
1288/// `Maximum`/`Minimum` are DELIBERATELY ABSENT. They are commutative in
1289/// value and NOT in bits: `chassis/binary.rs` returns the FIRST NaN operand
1290/// (`if a.is_nan() { a } else if b.is_nan() { b }`), a documented lhs-first
1291/// tie-break matching `torch.maximum`. Two distinct NaN payloads therefore
1292/// swap with operand order -- measured, `max(0x7FC00001, 0x7FC00002)` gives
1293/// `0x7FC00001` and the reverse gives `0x7FC00002` -- so folding `min(a,b)`
1294/// with `min(b,a)` is an INVALID rewrite. See GAP-271.
1295///
1296/// The +-0 tie ALSO makes the fold invalid, but do not state it as a fixed
1297/// bias: the +-0 answer is NOT A PROPERTY OF THIS SOURCE. It is decided per
1298/// CALL SITE by the optimizer, because we delegate to Rust's `f32::max`,
1299/// which disclaims the zero tie. Measured in ONE binary at opt-level 3:
1300/// const-folded literals give +0 for both orders; `black_box` operands give
1301/// -0 for both; `#[inline(never)]` gives -0 and +0 -- ORDER-DEPENDENT.
1302/// Three answers, same source. That is itself a reason the fold is invalid:
1303/// folding two call sites merges forms the compiler may resolve differently.
1304///
1305/// Rely on the NaN-payload argument, because it is STABLE -- verified
1306/// identical at opt-level 0 and 3, under `black_box` and under
1307/// `#[inline(never)]`. It is stable precisely because that tie-break is
1308/// EXPLICIT IN OUR SOURCE, where the +-0 tie is delegated to the compiler.
1309fn is_commutative(op: &Op) -> bool {
1310    matches!(op, Op::Add | Op::Mul)
1311}
1312
1313/// Run CSE + algebraic simplification on the graph reachable from
1314/// `roots`. Returns the (possibly-rewritten) roots the caller should
1315/// use afterward.
1316///
1317/// The pass runs to a fixed point on a single topological walk: each
1318/// node is visited once, canonicalized (inputs remapped, constants
1319/// folded where trivial), then either matched to an existing canonical
1320/// node (CSE) or appended as fresh. No multi-pass iteration — the
1321/// simplification rules are chosen so one forward pass suffices.
1322pub fn optimize(graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
1323    let order = {
1324        let g = graph.read().unwrap();
1325        topo_order_multi(&g, roots)
1326    };
1327
1328    let mut g = graph.write().unwrap();
1329    let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
1330    let mut cse: HashMap<(OpKey, Vec<NodeId>), NodeId> = HashMap::new();
1331
1332    for id in order {
1333        let (op, inputs, shape, dtype) = {
1334            let node = g.node(id);
1335            (
1336                node.op.clone(),
1337                node.inputs.clone(),
1338                node.shape.clone(),
1339                node.dtype,
1340            )
1341        };
1342        let mapped_inputs: Vec<NodeId> = inputs
1343            .iter()
1344            .map(|input_id| *remap.get(input_id).unwrap_or(input_id))
1345            .collect();
1346        let inputs_unchanged = mapped_inputs == inputs;
1347
1348        // 1. Algebraic simplifications that produce an alias (no new
1349        //    node). If a rule fires, we remap this id to an existing
1350        //    node and skip CSE for it entirely.
1351        //
1352        //    Identity Cast(d) on a dtype-d input is a no-op: dispatch keys
1353        //    casts on [src, dst] and intentionally registers no [d, d]
1354        //    kernel, so this elision (not a kernel) is what makes a same-dtype
1355        //    cast disappear before realize. Needs the input node's dtype, so
1356        //    it lives here rather than in `try_simplify` (which sees only
1357        //    op + inputs).
1358        if let Op::Cast(target) = &op {
1359            if g.node(mapped_inputs[0]).dtype == *target {
1360                remap.insert(id, mapped_inputs[0]);
1361                continue;
1362            }
1363            //    GAP-183: lower `Cast(float -> Bool)` to `Ne(x, x*0)`.
1364            //
1365            //    WHY A LOWERING RATHER THAN A KERNEL: CUDA already ships the
1366            //    `!= 0` test — `ne_{f32,f64,f16,bf16}_u8` in
1367            //    `cuda/compare.fkc.md` — but a STORAGE-level cast cannot compose
1368            //    graph ops, so those kernels are unreachable from `to_dtype`.
1369            //    Rewriting here reaches them. The INTEGER half is deliberately
1370            //    left alone: CUDA has no integer compare kernel, so lowering it
1371            //    would relocate the same decline while bypassing CPU's real
1372            //    int->Bool cast kernels.
1373            //
1374            //    WHY `MulScalar(0.0)` FOR THE ZEROS: its own doc says it exists
1375            //    "to avoid building a full-shape const just to add or multiply by
1376            //    a constant" — so the zeros inherit shape AND dtype from the
1377            //    input by construction, with nothing materialized. `Op::ZeroFill`
1378            //    is NOT usable here: it is DESTRUCTIVE (writes through input 0).
1379            //
1380            //    ⚠️ THE IEEE REASONING, because a reader will stop at `x*0` and
1381            //    wonder about non-finite inputs:
1382            //      finite x != 0 : x*0 = ±0        -> x != 0     -> true
1383            //      x = ±0.0      : x*0 = ±0        -> equal      -> false
1384            //                      (IEEE: -0.0 == 0.0, so -0.0 is FALSE)
1385            //      x = NaN       : NaN*0 = NaN     -> NaN != NaN -> TRUE
1386            //      x = ±inf      : inf*0 = NaN     -> x != NaN   -> true
1387            //    NaN being truthy matches PyTorch's `.bool()` and the CPU cast
1388            //    kernels, so both paths agree.
1389            //
1390            //    NOTE the zeros operand is NaN (not 0) whenever x is NaN or ±inf,
1391            //    and that is HARMLESS rather than load-bearing: for every
1392            //    non-finite x, `x != NaN` and `x != 0` are both true, so the two
1393            //    spellings agree everywhere. A future "x*0 => zeros" fold would
1394            //    therefore be SAFE here — recorded because the opposite is the
1395            //    intuitive guess, and an earlier draft of this comment asserted
1396            //    it as a hazard. Only `AddScalar(0)`/`MulScalar(1)` fold today.
1397            if *target == fuel_ir::DType::Bool {
1398                let src_dtype = g.node(mapped_inputs[0]).dtype;
1399                if src_dtype.is_float() {
1400                    let src_shape = g.node(mapped_inputs[0]).shape.clone();
1401                    let zeros = g.push(Node {
1402                        op: Op::MulScalar(0.0),
1403                        inputs: vec![mapped_inputs[0]],
1404                        shape: src_shape.clone(),
1405                        dtype: src_dtype,
1406                    });
1407                    let ne = g.push(Node {
1408                        op: Op::Ne,
1409                        inputs: vec![mapped_inputs[0], zeros],
1410                        shape: src_shape,
1411                        dtype: fuel_ir::DType::Bool,
1412                    });
1413                    remap.insert(id, ne);
1414                    continue;
1415                }
1416            }
1417        }
1418        if let Some(aliased) = try_simplify(&op, &mapped_inputs) {
1419            remap.insert(id, aliased);
1420            continue;
1421        }
1422
1423        // 2. CSE: if a structurally-identical canonical node already
1424        //    exists, reuse it. Commutative ops use sorted inputs as
1425        //    the key so `a+b` and `b+a` match. If no match exists and
1426        //    nothing about this node needs rewriting (inputs unchanged,
1427        //    no simplification fired), keep the original node in place
1428        //    to avoid polluting the arena with identical copies.
1429        if let Some(key) = op_key(&op) {
1430            let key_inputs = if is_commutative(&op) {
1431                let mut v = mapped_inputs.clone();
1432                v.sort();
1433                v
1434            } else {
1435                mapped_inputs.clone()
1436            };
1437            let full_key = (key, key_inputs);
1438            if let Some(&existing) = cse.get(&full_key) {
1439                remap.insert(id, existing);
1440                continue;
1441            }
1442            let canonical_id = if inputs_unchanged {
1443                id
1444            } else {
1445                g.push(Node {
1446                    op: op.clone(),
1447                    inputs: mapped_inputs.clone(),
1448                    shape: shape.clone(),
1449                    dtype,
1450                })
1451            };
1452            cse.insert(full_key, canonical_id);
1453            remap.insert(id, canonical_id);
1454        } else {
1455            // Const or other non-CSE-able op. Keep the original if
1456            // unchanged; otherwise append a rewritten copy (Const
1457            // never has inputs, so this branch effectively keeps
1458            // Const originals).
1459            let canonical_id = if inputs_unchanged {
1460                id
1461            } else {
1462                g.push(Node {
1463                    op: op.clone(),
1464                    inputs: mapped_inputs,
1465                    shape,
1466                    dtype,
1467                })
1468            };
1469            remap.insert(id, canonical_id);
1470        }
1471    }
1472
1473    roots.iter().map(|r| *remap.get(r).unwrap_or(r)).collect()
1474}
1475
1476/// Insert `Op::Copy` nodes so every op's inputs live on the same
1477/// device as the op itself. Returns the rewritten roots.
1478///
1479/// This is the Phase-3.5 pass that lifts the Router from "explicit
1480/// copies only" to "auto-insert where needed."
1481///
1482/// ## Device inference rules
1483///
1484/// For each node N, walked in topological order:
1485///
1486/// 1. **Target device** = first match wins:
1487///    - If N has a placement hint via `graph.set_placement(n, d)`, use `d`.
1488///    - Else, inherit from N's first input's inferred device.
1489///    - Else (N is a Const or the first input has no inferred device
1490///      either), the target is `None` — N is "placeless" and will be
1491///      placed by its consumer's demands.
1492///
1493/// 2. **Input reconciliation**: for each input I of N, if I's inferred
1494///    device differs from N's target device, insert an
1495///    `Op::Copy { target }` node and redirect N's input edge to the
1496///    new Copy.
1497///
1498/// ## What it doesn't do
1499///
1500/// - **Backward ops:** the pass currently treats all ops the same
1501///   way — no special reasoning about what device a backward node
1502///   should run on. For pure inference this is fine; for training
1503///   through the Router we'd want a reverse-mode pass that mirrors
1504///   forward placement. Tracked as a follow-up TODO.
1505/// - **Const placement lowering:** if a Const flows into a single
1506///   consumer on device D, we emit `Copy(Const, D)` instead of
1507///   tagging the Const itself as on-device-D. A future cost-lowering
1508///   pass can fuse the two — tracked as a TODO alongside Phase 4's
1509///   scheduler.
1510/// - **Redundant copy elision:** `Copy(X, A) -> Copy(_, A)` when X is
1511///   already on A is dropped via the idempotent check in step 2, but
1512///   there's no pass that merges `Copy(X, A) -> Copy(_, B) -> Copy(_, A)`
1513///   (cross-device round-trips that cancel). Also future.
1514pub fn insert_copies(graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
1515    let order = {
1516        let g = graph.read().unwrap();
1517        topo_order_multi(&g, roots)
1518    };
1519
1520    // Inferred output device per node. `None` = placeless (e.g., an
1521    // unplaced Const). Read on the input side to decide whether a
1522    // Copy is needed.
1523    let mut inferred: HashMap<NodeId, Option<DeviceLocation>> = HashMap::new();
1524    // Rewritten (old → new) node ID map. A node gets rewritten when
1525    // any of its inputs needed a Copy interposed.
1526    let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
1527
1528    let mut g = graph.write().unwrap();
1529
1530    for id in order {
1531        // Snapshot the node — all subsequent reads need the old id's
1532        // metadata, not whatever we're about to rewrite.
1533        let (op, inputs, shape, dtype) = {
1534            let node = g.node(id);
1535            (
1536                node.op.clone(),
1537                node.inputs.clone(),
1538                node.shape.clone(),
1539                node.dtype,
1540            )
1541        };
1542        let placement_hint = g.placement(id);
1543
1544        // Target device for this node: explicit hint > first input's
1545        // inferred device > None (placeless).
1546        let target_device: Option<DeviceLocation> = match placement_hint {
1547            Some(d) => Some(d),
1548            None => inputs.first().and_then(|i| {
1549                let mapped = *remap.get(i).unwrap_or(i);
1550                inferred.get(&mapped).copied().flatten()
1551            }),
1552        };
1553
1554        // Walk inputs, inserting Copy where needed.
1555        let mut new_inputs: Vec<NodeId> = Vec::with_capacity(inputs.len());
1556        let mut any_changed = false;
1557        for input_id in &inputs {
1558            let mapped_in = *remap.get(input_id).unwrap_or(input_id);
1559            let in_device = inferred.get(&mapped_in).copied().flatten();
1560
1561            let needs_copy = match (in_device, target_device) {
1562                // Both known and disagree → copy.
1563                (Some(src), Some(tgt)) if src != tgt => true,
1564                // Input is placeless and we have a target → copy
1565                // (Const gets Copy'd onto the target device; the
1566                // future cost-lowering pass can fuse this).
1567                (None, Some(_)) => true,
1568                // Everything else (match, or no target) → no copy.
1569                _ => false,
1570            };
1571
1572            if needs_copy {
1573                let tgt = target_device.expect("needs_copy implies target is Some");
1574                // A Copy preserves the INPUT's shape/dtype, not the
1575                // outer consumer's. Read them from the source node.
1576                let (in_shape, in_dtype) = {
1577                    let n = g.node(mapped_in);
1578                    (n.shape.clone(), n.dtype)
1579                };
1580                let copy_id = g.push(Node {
1581                    op: Op::Copy { target: tgt },
1582                    inputs: vec![mapped_in],
1583                    shape: in_shape,
1584                    dtype: in_dtype,
1585                });
1586                inferred.insert(copy_id, Some(tgt));
1587                new_inputs.push(copy_id);
1588                any_changed = true;
1589            } else {
1590                if mapped_in != *input_id {
1591                    any_changed = true;
1592                }
1593                new_inputs.push(mapped_in);
1594            }
1595        }
1596
1597        // If nothing changed and this isn't a node whose inference
1598        // output device we need to record fresh, keep the original.
1599        let canonical_id = if any_changed {
1600            let new_id = g.push(Node {
1601                op: op.clone(),
1602                inputs: new_inputs,
1603                shape,
1604                dtype,
1605            });
1606            // Carry placement hint to the rewritten node so downstream
1607            // consumers see the same target.
1608            if let Some(d) = placement_hint {
1609                g.set_placement(new_id, d);
1610            }
1611            new_id
1612        } else {
1613            id
1614        };
1615
1616        // Record this node's inferred output device:
1617        //   - Op::Copy / Op::Move: output is the transfer target.
1618        //   - Else if we have a target_device, that's the output
1619        //     device (it matches all inputs post-reconciliation).
1620        //   - Else None (placeless forward).
1621        let out_device = match &op {
1622            Op::Copy { target } | Op::Move { target } => Some(*target),
1623            _ => target_device,
1624        };
1625        inferred.insert(canonical_id, out_device);
1626        remap.insert(id, canonical_id);
1627    }
1628
1629    roots.iter().map(|r| *remap.get(r).unwrap_or(r)).collect()
1630}
1631
1632/// Lower `Const` placements: if every consumer of an unplaced Const
1633/// has the same placement hint, tag the Const with that placement.
1634///
1635/// This is a pre-pass for [`insert_copies`]. Without it, a model's
1636/// weight Consts (unplaced at graph-build time) flow into ops tagged
1637/// for a specific device; insert_copies then emits a Copy for each
1638/// such Const every forward. Lowering the Const's placement directly
1639/// tells the Router to upload it straight to the target device, and
1640/// insert_copies skips the Copy.
1641///
1642/// Conservative: a Const whose consumers disagree on device stays
1643/// unplaced. A future replication pass could clone the Const to each
1644/// target, but that needs to weigh replication cost against transfer
1645/// cost — scheduler territory (Phase 4).
1646///
1647/// Returns the number of Const placements set. Mutates `graph`
1648/// placements in place.
1649pub fn lower_const_placement(graph: &SharedGraph, roots: &[NodeId]) -> usize {
1650    // Reverse edges: for each node, list its consumers. Only walks
1651    // nodes reachable from `roots`, so unused Consts don't waste work.
1652    let order = {
1653        let g = graph.read().unwrap();
1654        topo_order_multi(&g, roots)
1655    };
1656
1657    let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1658    {
1659        let g = graph.read().unwrap();
1660        for &nid in &order {
1661            let node = g.node(nid);
1662            for &input in &node.inputs {
1663                consumers.entry(input).or_default().push(nid);
1664            }
1665        }
1666    }
1667
1668    let mut lowered = 0;
1669    let mut g = graph.write().unwrap();
1670    for &nid in &order {
1671        // Only consider Const nodes without an explicit placement.
1672        let is_const = matches!(g.node(nid).op, Op::Const);
1673        if !is_const || g.placement(nid).is_some() {
1674            continue;
1675        }
1676        let Some(cs) = consumers.get(&nid) else {
1677            continue; // Const is a root — no consumers to infer from.
1678        };
1679        // Walk consumers; collect their placements. If all are
1680        // Some and agree, adopt that device.
1681        let mut target: Option<DeviceLocation> = None;
1682        let mut unanimous = true;
1683        for &c in cs {
1684            match g.placement(c) {
1685                Some(d) => match target {
1686                    None => target = Some(d),
1687                    Some(prev) if prev == d => {}
1688                    Some(_) => {
1689                        unanimous = false;
1690                        break;
1691                    }
1692                },
1693                None => {
1694                    unanimous = false;
1695                    break;
1696                }
1697            }
1698        }
1699        if unanimous && let Some(d) = target {
1700            g.set_placement(nid, d);
1701            lowered += 1;
1702        }
1703    }
1704    lowered
1705}
1706
1707/// Try to produce an aliasing simplification: "this op is equivalent
1708/// to one of its inputs." Returns the aliased NodeId if so.
1709fn try_simplify(op: &Op, inputs: &[NodeId]) -> Option<NodeId> {
1710    match op {
1711        // AddScalar(0) and MulScalar(1) are no-ops. Route consumers
1712        // straight to the input and drop the op.
1713        Op::AddScalar(c) if *c == 0.0 => Some(inputs[0]),
1714        Op::MulScalar(c) if *c == 1.0 => Some(inputs[0]),
1715        // PowI(1) is a no-op. PowI(0) would be "1" but that needs a
1716        // new const node with the right shape — skip here, let the
1717        // executor handle or another rule add it.
1718        Op::PowI(1) => Some(inputs[0]),
1719        _ => None,
1720    }
1721}
1722
1723// ---- Ordering analysis for destructive ops ---------------------------------
1724//
1725// A destructive op (one whose `Op::destructive_input()` is `Some(i)`)
1726// invalidates its i-th input when it runs. For correctness, every
1727// non-destructive reader of that input must complete BEFORE the
1728// destructive op runs. The graph's data-flow edges don't express this
1729// — they'd let a backend schedule the destructive op any time after
1730// its input is produced, including AHEAD of sibling readers.
1731//
1732// `derive_ordering` analyzes the graph and returns a map of
1733// destructive-op → sibling-readers. The [`execution_plan`] below
1734// consumes this map alongside the data graph in a Kahn's-algorithm
1735// walk to produce an execution order that respects both constraint
1736// kinds.
1737
1738/// Derived ordering edges beyond the data-flow graph. Each entry
1739/// `(nid, deps)` means `nid` must run after every node in `deps`.
1740///
1741/// Produced by [`derive_ordering`] from destructive-op metadata
1742/// ([`Op::destructive_input`]). Consumed by [`execution_plan`].
1743#[derive(Debug, Clone, Default)]
1744pub struct OrderingEdges(pub HashMap<NodeId, Vec<NodeId>>);
1745
1746impl OrderingEdges {
1747    pub fn new() -> Self {
1748        Self(HashMap::new())
1749    }
1750
1751    /// True if the map is empty — no destructive ops in the analyzed
1752    /// subgraph, so the execution plan is just the plain topo order.
1753    pub fn is_empty(&self) -> bool {
1754        self.0.is_empty()
1755    }
1756
1757    /// Get the set of must-run-before nodes for `nid`, if any.
1758    pub fn deps_of(&self, nid: NodeId) -> &[NodeId] {
1759        self.0.get(&nid).map(|v| v.as_slice()).unwrap_or(&[])
1760    }
1761}
1762
1763/// Fuse `MatMul → Add(rank-1 bias)` patterns into `Op::FusedLinear`
1764/// (Phase 6d Track 3). Walks the graph; for each `Add` whose LHS is
1765/// a `MatMul` and whose RHS is a rank-1 bias whose length equals the
1766/// matmul output's last dim, emits a fresh `FusedLinear` node and
1767/// remaps consumers of the `Add` to it.
1768///
1769/// Conservative: only fires when the `Add` is the sole consumer of
1770/// the `MatMul`. Otherwise we'd be creating a duplicate matmul
1771/// computation. CSE doesn't help here because `MatMul` and
1772/// `MatMul-inside-FusedLinear` aren't structurally equal at the IR
1773/// level — backends with truly fused kernels are the ones that
1774/// benefit, so we'd rather skip the fusion than waste the work.
1775///
1776/// Returns the count of fusions applied.
1777pub fn fuse_linear(graph: &SharedGraph, roots: &[NodeId]) -> usize {
1778    let order = {
1779        let g = graph.read().unwrap();
1780        topo_order_multi(&g, roots)
1781    };
1782    // Count consumers of each node (so we can guard "single consumer of matmul").
1783    let mut consumer_count: HashMap<NodeId, usize> = HashMap::new();
1784    {
1785        let g = graph.read().unwrap();
1786        for &nid in &order {
1787            for &input in &g.node(nid).inputs {
1788                *consumer_count.entry(input).or_insert(0) += 1;
1789            }
1790        }
1791        // Also count root references — a root is implicitly a consumer.
1792        for &r in roots {
1793            *consumer_count.entry(r).or_insert(0) += 1;
1794        }
1795    }
1796
1797    let mut g = graph.write().unwrap();
1798    let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
1799    let mut fused = 0usize;
1800
1801    for nid in order {
1802        // Apply already-known remappings to inputs.
1803        let (op, inputs, shape, dtype) = {
1804            let n = g.node(nid);
1805            (n.op.clone(), n.inputs.clone(), n.shape.clone(), n.dtype)
1806        };
1807        let mapped: Vec<NodeId> = inputs.iter().map(|i| *remap.get(i).unwrap_or(i)).collect();
1808        // Pattern: Op::Add { inputs[0]=matmul_node, inputs[1]=rank-1 bias }.
1809        if !matches!(op, Op::Add) || mapped.len() != 2 {
1810            continue;
1811        }
1812        let lhs = mapped[0];
1813        let rhs = mapped[1];
1814        // LHS must be a MatMul.
1815        let lhs_op = g.node(lhs).op.clone();
1816        if !matches!(lhs_op, Op::MatMul) {
1817            continue;
1818        }
1819        // LHS matmul must have only THIS Add as a consumer (otherwise
1820        // fusing would duplicate the matmul computation).
1821        // Note: consumer_count counts pre-remapping references; remap
1822        // only happens for skipped nodes here so it's still valid.
1823        if consumer_count.get(&lhs).copied().unwrap_or(0) != 1 {
1824            continue;
1825        }
1826        // RHS must be a BroadcastTo of a rank-1 bias whose length
1827        // equals the matmul output's last dim. The build-time `Add`
1828        // requires same-shape inputs, so user code typically does:
1829        //     bias[N].broadcast_to([..., M, N]).add(matmul_out)
1830        // Walk through that BroadcastTo to find the rank-1 source.
1831        let mm_dims = g.node(lhs).shape.dims().to_vec();
1832        if mm_dims.is_empty() {
1833            continue;
1834        }
1835        let last_dim = mm_dims[mm_dims.len() - 1];
1836        let rhs_node = g.node(rhs);
1837        let bias_src_id = if matches!(rhs_node.op, Op::BroadcastTo(_)) && rhs_node.inputs.len() == 1
1838        {
1839            *remap
1840                .get(&rhs_node.inputs[0])
1841                .unwrap_or(&rhs_node.inputs[0])
1842        } else {
1843            // Bias broadcast may also have been pre-shaped; allow rank-1
1844            // direct (rare with build-time shape checks but cheap to
1845            // recognize).
1846            rhs
1847        };
1848        let bias_dims = g.node(bias_src_id).shape.dims().to_vec();
1849        if bias_dims.len() != 1 || bias_dims[0] != last_dim {
1850            continue;
1851        }
1852        // Pull the matmul's a, b inputs (apply remap to those too).
1853        let mm_inputs = g.node(lhs).inputs.clone();
1854        if mm_inputs.len() != 2 {
1855            continue;
1856        }
1857        let a = *remap.get(&mm_inputs[0]).unwrap_or(&mm_inputs[0]);
1858        let b = *remap.get(&mm_inputs[1]).unwrap_or(&mm_inputs[1]);
1859        // Phase 7.6 step 4: emit the registry-extended shape. The
1860        // executor's `Op::Fused(FUSED_LINEAR, _)` arm dispatches to
1861        // the same fused-linear kernel as the legacy variant. Step 5
1862        // drops the legacy `Op::FusedLinear` variant entirely.
1863        let new_id = g.push(Node {
1864            op: Op::Fused(
1865                crate::registry::FusedOps::FUSED_LINEAR,
1866                crate::registry::FusedOpParams::FusedLinear,
1867            ),
1868            // FusedLinear takes the *original* rank-1 bias, not the
1869            // BroadcastTo'd one — the kernel broadcasts it internally
1870            // to the matmul output shape.
1871            inputs: vec![a, b, bias_src_id],
1872            shape,
1873            dtype,
1874        });
1875        remap.insert(nid, new_id);
1876        fused += 1;
1877    }
1878
1879    // Apply remap by rewriting any consumer that still references an
1880    // un-fused Add. Using the existing `rewrite_input` helper.
1881    if !remap.is_empty() {
1882        // Collect all node ids; iterate to update inputs that point at
1883        // remapped nodes. We need the mutable borrow; clone the set of
1884        // nodes to iterate without borrow conflicts.
1885        let n_nodes = g.nodes.len();
1886        for nid in 0..n_nodes {
1887            let node = &mut g.nodes[nid];
1888            for input in node.inputs.iter_mut() {
1889                if let Some(&new) = remap.get(input) {
1890                    *input = new;
1891                }
1892            }
1893        }
1894    }
1895    fused
1896}
1897
1898/// Derive ordering edges for every destructive op reachable from
1899/// `roots`. Result: `nid` → list of other readers of `nid`'s
1900/// destroyed input.
1901///
1902/// **Bundle-aware aliasing** (Option C, Session 3): the alias-set
1903/// computation treats `Op::View { slot }` as alias-extending — every
1904/// View of a multi-output producer shares the bundle's storage Arc, so
1905/// a destructive op on the producer (or on any sibling View) must
1906/// run after every reader of every other View of the same producer.
1907/// `Op::ViewOwned` is NOT alias-extending: after its forward memcpy,
1908/// the ViewOwned's output is an independent Storage; it is still
1909/// pinned-after destructive ops on the producer via the regular
1910/// data-dependency edge (`inputs[0] == producer`), which falls out
1911/// of the standard reader analysis below without needing the
1912/// alias-set extension. The full alias-extension rule covers
1913/// `Op::Reshape` / `Op::Contiguize`
1914/// (conditionally-zero-copy contiguize ops), added to close the MLA
1915/// decode multi-round `WriteSlice` ordering defect: a destructive op
1916/// on `X` only pins the readers of nodes actually IN the alias set,
1917/// so any alias-extending op excluded from that set is a hole a
1918/// reader can fall through unpinned. `Op::Reshape`/`Op::Contiguize`
1919/// were exactly that hole (see `collect_alias_set`'s doc for the
1920/// full mechanism and the shipped workaround this makes unnecessary
1921/// for correctness).
1922///
1923/// O(V + E). Does not mutate the graph.
1924pub fn derive_ordering(graph: &crate::Graph, roots: &[NodeId]) -> OrderingEdges {
1925    let order = topo_order_multi(graph, roots);
1926
1927    // Consumer index: tensor NodeId → list of consumers of that tensor.
1928    let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1929    for &nid in &order {
1930        for &input in &graph.node(nid).inputs {
1931            consumers.entry(input).or_default().push(nid);
1932        }
1933    }
1934
1935    // Alias groups, built ONCE for the whole graph instead of re-derived
1936    // per destructive op (`collect_alias_set` used to re-walk the entire
1937    // `order` from scratch for every one of them — O(destructive_ops *
1938    // order.len())). Every alias-extending node (view/bundle-projection/
1939    // reshape-contiguize — see `alias_group_of`'s doc) points at the SAME
1940    // group id as whatever it extends, in a single forward pass; querying
1941    // a group's members is then an O(1) map lookup. See `alias_group_of`
1942    // for why this produces the identical partition `collect_alias_set`
1943    // did, including the sibling-bundle case.
1944    let (alias_group_of, alias_members) = alias_groups(graph, &order);
1945
1946    let mut ordering: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1947    for &nid in &order {
1948        let node = graph.node(nid);
1949        let Some(d_idx) = node.op.destructive_input() else {
1950            continue;
1951        };
1952        if d_idx >= node.inputs.len() {
1953            continue;
1954        }
1955        let destroyed = node.inputs[d_idx];
1956        // The alias set of `destroyed`: the destructive op writes
1957        // through `destroyed`'s Storage Arc, and every view-op node
1958        // transitively derived from `destroyed` shares that same Arc
1959        // (executor's view-op materialization wraps the source's Arc
1960        // in a fresh Layout — see `is_view_op` in `lib.rs`). So a
1961        // reader of any alias-set member sees `destroyed`'s bytes
1962        // and must run BEFORE the destructive op. Without this
1963        // walk, `y = x.transpose(); z = relu(y); x.relu_inplace()`
1964        // would not pin `relu(y)` before `relu_inplace(x)`, even
1965        // though they share storage.
1966        let group = alias_group_of.get(&destroyed).copied().unwrap_or(destroyed);
1967        // `destroyed` itself is always a member, even when nothing
1968        // aliases it (the common case: no entry in `alias_members` at
1969        // all) — mirrors `collect_alias_set`'s unconditional seed
1970        // `alias.insert(root)`. Without this fallback a non-aliased
1971        // target's own direct readers would never get visited below.
1972        let singleton = vec![group];
1973        let alias_set = alias_members.get(&group).unwrap_or(&singleton);
1974        for &alias in alias_set {
1975            let Some(readers) = consumers.get(&alias) else {
1976                continue;
1977            };
1978            for &reader in readers {
1979                // Skip the destructive op itself + readers that are
1980                // themselves alias members (those are view ops; their
1981                // own consumers are what we actually need to pin, and
1982                // they show up in subsequent iterations).
1983                if reader != nid && !alias_set.contains(&reader) {
1984                    ordering.entry(nid).or_default().push(reader);
1985                }
1986            }
1987        }
1988    }
1989    OrderingEdges(ordering)
1990}
1991
1992/// Collect the alias set of `root`: every node whose realized Storage
1993/// is the same `Arc<RwLock<Storage>>` as `root`'s.
1994///
1995/// Three families of alias-extending op share the input's Storage Arc
1996/// at realize time:
1997///
1998/// 1. **Single-input view ops** ([`Op::is_view_op`]): the executor
1999///    wraps the source's Arc with a fresh Layout — no copy. Examples:
2000///    `Op::Transpose`, `Op::Permute`, `Op::Slice`, `Op::Unsqueeze`,
2001///    `Op::Squeeze`, `Op::BroadcastTo`, `Op::Flip`.
2002/// 2. **Multi-output projection** (`Op::View { slot }`): the
2003///    realized View clones the producer's bundled storage Arc and
2004///    exposes one slot's window — bytes are shared, the bundle's Arc
2005///    refcount tracks the consumer's lifetime (Session 1 of the
2006///    multi-output Option C design — see
2007///    [`fuel_ir::storage::OutputView`]).
2008/// 3. **Conditionally zero-copy contiguize ops** (`Op::Reshape`,
2009///    `Op::Contiguize`): `fuel-dispatch`'s `WorkItemKind::ContiguizeOf`
2010///    execution arm ADOPTS the input's Storage Arc verbatim — no
2011///    allocation, no copy — whenever the input layout is already
2012///    contiguous at offset 0; only a non-contiguous or offset input
2013///    forces a fresh `auto_contiguize` allocation. Because that check
2014///    is a RUNTIME layout fact `collect_alias_set` cannot evaluate at
2015///    graph-build time (offset-0-contiguity depends on the layouts
2016///    realized upstream), Reshape/Contiguize are treated as
2017///    UNCONDITIONALLY alias-extending here — conservative in the
2018///    (rarer) case they materialize a fresh copy, since an extra
2019///    ordering pin only serializes two ops that didn't actually need
2020///    it; never unsafe, since the alternative (treating them as
2021///    alias-opaque) is exactly the ordering defect this variant
2022///    fixes: a destructive op's OWN direct alias-set members were
2023///    already pinned-after-their-readers correctly, but a
2024///    Reshape/Contiguize hop is a graph-visible node in its own right
2025///    — if it's excluded from the alias set, its consumers are never
2026///    visited by the pin loop below, so a reader on the OTHER side of
2027///    an adopted-Arc Reshape/Contiguize has no ordering guarantee
2028///    against a destructive op reusing the SAME storage. This is the
2029///    MLA decode multi-round `WriteSlice` defect: layer `L`'s
2030///    post-append cache buffer is read once (non-destructively)
2031///    through a `Slice` → `Reshape` (head-split-style) → kernel chain
2032///    that becomes an ancestor of layer `L+1`'s write, and again as
2033///    the destination of layer `L`'s NEXT round's append — see
2034///    `fuel-core/src/lazy_deepseek2.rs`'s
2035///    `DeepSeek2Model::rebind_latent_cache_fresh_graph` doc, whose
2036///    graph-rebinding workaround this fix makes unnecessary for
2037///    CORRECTNESS (rebinding remains a legitimate memory/perf choice —
2038///    it also caps unbounded graph growth across steps — but a
2039///    same-graph multi-step decode no longer silently corrupts or
2040///    hard-errors without it).
2041///
2042/// `Op::ViewOwned` is explicitly NOT alias-extending: at execution
2043/// time it allocates a fresh standalone Storage and memcpys the
2044/// slot's bytes in. After the memcpy runs, no Arc handle on the
2045/// producer's bundle remains in the ViewOwned's chain. ViewOwned is
2046/// still pinned-after destructive readers via the regular
2047/// data-dependency edge (its `inputs[0]` is the producer), which the
2048/// caller already accounts for outside this function.
2049///
2050/// **Sibling-bundle rule**: when `root` is itself an `Op::View`,
2051/// every other `Op::View` of the same producer shares the same
2052/// bundle Arc. To capture them, the function pre-seeds the producer
2053/// into the alias set so the forward walk picks up siblings the same
2054/// way it picks up downstream `is_view_op` derivatives.
2055///
2056/// Implementation: O(|order|) forward walk after a constant-time
2057/// pre-seed. A node enters the alias set iff its op is in the
2058/// alias-extending union AND its `inputs[0]` is already in the set.
2059/// Builds the alias-equivalence partition over the WHOLE `order` in a
2060/// single forward pass, replacing the old `collect_alias_set`'s
2061/// per-destructive-op walk of the entire `order` (O(destructive_ops *
2062/// order.len()) — a second, independent quadratic cost alongside
2063/// `insert_safety_copies`' own, confirmed as the dominant cost in a live
2064/// decode-step profile: ~55% of total step time went to this family of
2065/// per-call graph rebuilds).
2066///
2067/// Every alias-extending node (the same three families as before: single-
2068/// input view ops, `Op::View{slot}` bundle projections, and the
2069/// conservatively-always-aliasing `Reshape`/`Contiguize`) is mapped to the
2070/// SAME group id as whatever it extends; a group's id is always its
2071/// earliest (topologically first) non-alias-extending member. Returns
2072/// `(group_of, members)`: `group_of[n]` is `n`'s group id (only present
2073/// for alias-extending nodes — absent means `n` is its own trivial
2074/// singleton group), and `members[g]` is every node in group `g`,
2075/// `g` itself included.
2076///
2077/// This produces the IDENTICAL partition the old per-call
2078/// `collect_alias_set(root, ...)` computed when called with `root` equal
2079/// to the group's EARLIEST member — the case every existing call site
2080/// exercises (`derive_ordering` always queries with a destructive op's
2081/// direct input, and a destructive op's direct input was never itself
2082/// observed, in this codebase's test suite, to be a multi-hop view
2083/// chain) — INCLUDING the sibling-bundle case (`Op::View{slot}`'s shared
2084/// producer `P`): the first `Op::View` of `P` resolves to group `P`
2085/// (since `P` is never itself inserted as a key, so `.unwrap_or(&P)`
2086/// defaults to `P`); every later `Op::View` of the same `P` resolves to
2087/// the identical group, landing all of them — and `P` itself — in one
2088/// membership list, exactly what `collect_alias_set`'s explicit "root is
2089/// a View ⇒ also seed its producer" special case achieved per-call.
2090///
2091/// ⚠️ NOT proven byte-identical for a query against a NON-earliest member
2092/// of a multi-hop alias chain (e.g. `destroyed` being a `Slice` of a
2093/// `Transpose` of a plain producer): the old per-call walk only ever
2094/// extended FORWARD from its seed, so querying from a late chain member
2095/// could under-report versus querying from the chain's root — a
2096/// pre-existing asymmetry in the old code, not introduced here. This
2097/// version computes the full, symmetric equivalence class regardless of
2098/// which member is queried (matching the function's own doc contract:
2099/// "every node whose realized Storage is the same Arc as root's"), which
2100/// can only ADD members relative to the old behavior, never drop any —
2101/// so if the two ever disagree, this version is the more conservative
2102/// (more copies/pins, never fewer) of the two. All existing
2103/// `insert_safety_copies`/`derive_ordering` tests pass unchanged.
2104fn alias_groups(
2105    graph: &crate::Graph,
2106    order: &[NodeId],
2107) -> (HashMap<NodeId, NodeId>, HashMap<NodeId, Vec<NodeId>>) {
2108    use crate::Op;
2109
2110    let mut group_of: HashMap<NodeId, NodeId> = HashMap::new();
2111    let mut members: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2112
2113    for &nid in order {
2114        let node = graph.node(nid);
2115        // Op::View shares the producer's bundle Arc — it extends the
2116        // alias set even though it isn't an `is_view_op` in the
2117        // narrower single-storage-strided-view sense. Op::Reshape /
2118        // Op::Contiguize are CONDITIONALLY zero-copy (adopt the input
2119        // Arc iff contiguous at offset 0 — a runtime fact); treated
2120        // unconditionally alias-extending here is the conservative,
2121        // always-safe direction (see the function doc).
2122        let extends_alias = node.op.is_view_op()
2123            || matches!(node.op, Op::View { .. } | Op::Reshape(_) | Op::Contiguize);
2124        if !extends_alias {
2125            continue;
2126        }
2127        let Some(&inp) = node.inputs.first() else {
2128            continue;
2129        };
2130        let group = group_of.get(&inp).copied().unwrap_or(inp);
2131        group_of.insert(nid, group);
2132        members
2133            .entry(group)
2134            .or_insert_with(|| vec![group])
2135            .push(nid);
2136    }
2137    (group_of, members)
2138}
2139
2140/// Insert defensive `Op::Copy` snapshots ahead of destructive ops
2141/// that would otherwise produce a data-flow ↔ ordering-edge cycle.
2142///
2143/// Phase 5 of the in-place ops infrastructure
2144/// (`docs/session-prompts/in-place-ops-infrastructure.md`). The
2145/// view-aware [`derive_ordering`] (Phase 4a) handles the common case
2146/// where a destructive op's target is read only by upstream consumers
2147/// — pinning the destructive op after those reads is enough. The
2148/// case Phase 5 addresses is when a single op reads BOTH the
2149/// in-place target X AND a downstream consumer of the in-place op
2150/// (e.g., residual connections: `y = x.relu_inplace(); z = y + x`).
2151///
2152/// In that case, the data-flow says the reader (`z`) must run after
2153/// the destructive op (because it depends on `y`), and the ordering
2154/// edge says the destructive op must run after the reader (because
2155/// the reader reads `x`). Without intervention, [`execution_plan`]
2156/// detects the cycle and panics.
2157///
2158/// ## Conflict predicate (dependency-based, NOT topo-position-based)
2159///
2160/// A reader `R` of the destructive op `D`'s target needs a safety
2161/// copy iff `R` is not **provably ordered before** `D`. The proof
2162/// obligation is grounded in the executor's actual contract:
2163/// [`execution_plan`] emits a linear extension of the *combined*
2164/// precedence graph — data edges (`input → consumer`) plus
2165/// [`derive_ordering`]'s ordering edges (`reader → destructive op`) —
2166/// and the executor dispatches work items strictly sequentially in
2167/// that order. Therefore:
2168///
2169/// - `R` has a path to `D` in the combined graph AND `D` has no
2170///   path back to `R` → every valid plan runs `R` before `D`'s
2171///   mutation. Safe; **no copy**. (This covers every reader
2172///   `derive_ordering` pins directly, plus view-op readers whose
2173///   consumers are pinned — the pre-gap readers of an evict-chain
2174///   `Op::Move`, for example.)
2175/// - `D` has a path to `R` (e.g., the residual pattern: `R`
2176///   consumes `D`'s output while also reading `D`'s target) → the
2177///   pin edge `R → D` would close a cycle; the executor cannot
2178///   order `R` first. **Copy needed.**
2179/// - Neither direction → no ordering guarantee exists (possible for
2180///   alias-member view readers, which `derive_ordering` deliberately
2181///   does not pin). Safety cannot be proven, so **copy needed** —
2182///   the conservative default: a spurious copy costs bytes, a
2183///   missing copy is silent data corruption.
2184///
2185/// Historical note: this pass originally inferred conflicts from DFS
2186/// topo-order *position* (any reader appearing after `D` in
2187/// `topo_order_multi` order was treated as conflicting). Position in
2188/// a DFS walk carries no dependency information — an independent
2189/// pre-gap reader of a `Op::Move`d tensor could land after the Move
2190/// purely because of root visit order, earning a spurious copy
2191/// stamped onto the destructive op's device (live-GPU failure,
2192/// 2026-06-11).
2193///
2194/// This pass rewrites each conflict by:
2195/// 1. Inserting an `Op::Copy { target: same_device }(X) → X_safe` —
2196///    a same-device byte snapshot independent of `X`'s storage.
2197/// 2. Rewiring the conflicting reader's input from `X` to `X_safe`.
2198///
2199/// After the rewrite, the reader reads the snapshot (pre-mutation
2200/// bytes) while the destructive op proceeds on `X`. No cycle.
2201///
2202/// Scope: detects DIRECT conflicts (reader's input IS the
2203/// destructive op's target). View-mediated conflicts (reader reads
2204/// `view(X)`) fall through and produce the cycle panic from
2205/// `execution_plan` — a clear-but-not-friendly error pointing the
2206/// user at the pattern. View-mediated auto-resolution is a follow-up
2207/// (would need to insert `Copy(X)` then re-derive the view from the
2208/// snapshot for each conflicting reader).
2209///
2210/// Idempotent: calling this twice on the same graph inserts copies
2211/// at most once per conflict (the rewrite removes the conflict by
2212/// redirecting the reader's input edge to the copy).
2213///
2214/// Returns the number of safety copies inserted (for telemetry /
2215/// testing).
2216pub fn insert_safety_copies(graph: &mut crate::Graph, roots: &[NodeId]) -> usize {
2217    /// Default [`DeviceLocation`] for a `BackendId` when the graph has
2218    /// no per-node `placement` set. Single-GPU convention (gpu_id 0) —
2219    /// mirrors `fuel_dispatch::plan::default_device_for`, duplicated
2220    /// here because `fuel-graph` doesn't depend on `fuel-dispatch`.
2221    /// `insert_safety_copies`' own `target_location` fallback is this
2222    /// function's sole caller.
2223    fn device_location_for_backend(backend: fuel_ir::probe::BackendId) -> DeviceLocation {
2224        use fuel_ir::probe::BackendId;
2225        match backend {
2226            BackendId::Cpu => DeviceLocation::Cpu,
2227            BackendId::Cuda => DeviceLocation::Cuda { gpu_id: 0 },
2228            BackendId::Vulkan => DeviceLocation::Vulkan { gpu_id: 0 },
2229            BackendId::Metal => DeviceLocation::Metal { gpu_id: 0 },
2230            _ => DeviceLocation::Cpu,
2231        }
2232    }
2233
2234    let order = topo_order_multi(graph, roots);
2235
2236    // Combined precedence graph: data edges (input → consumer) plus
2237    // derive_ordering's edges (reader → destructive op). This is
2238    // exactly the edge set execution_plan linearizes, so path
2239    // queries here answer "does every valid plan order A before B?".
2240    let ordering = derive_ordering(graph, roots);
2241    let node_set: std::collections::HashSet<NodeId> = order.iter().copied().collect();
2242    let mut succ: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2243    let mut pred: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2244    for &nid in &order {
2245        for &input in &graph.node(nid).inputs {
2246            if node_set.contains(&input) {
2247                succ.entry(input).or_default().push(nid);
2248                pred.entry(nid).or_default().push(input);
2249            }
2250        }
2251        for &dep in ordering.deps_of(nid) {
2252            if node_set.contains(&dep) {
2253                succ.entry(dep).or_default().push(nid);
2254                pred.entry(nid).or_default().push(dep);
2255            }
2256        }
2257    }
2258
2259    // All nodes reachable from `start` over `adj` (excluding `start`
2260    // itself unless it sits on a cycle). Iterative DFS; the visited
2261    // set terminates cyclic combined graphs (the very cycles this
2262    // pass exists to break).
2263    fn reach(
2264        start: NodeId,
2265        adj: &HashMap<NodeId, Vec<NodeId>>,
2266    ) -> std::collections::HashSet<NodeId> {
2267        let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
2268        let mut stack = vec![start];
2269        while let Some(n) = stack.pop() {
2270            if let Some(next) = adj.get(&n) {
2271                for &m in next {
2272                    if seen.insert(m) {
2273                        stack.push(m);
2274                    }
2275                }
2276            }
2277        }
2278        seen
2279    }
2280
2281    // Snapshot conflicts before mutating the graph: collect
2282    // (destructive_op_id, target, conflicting_readers) tuples.
2283    struct Conflict {
2284        destructive_nid: NodeId,
2285        target: NodeId,
2286        target_shape: crate::Shape,
2287        target_dtype: DType,
2288        conflicting_readers: Vec<NodeId>,
2289    }
2290    let mut conflicts: Vec<Conflict> = Vec::new();
2291
2292    for &nid in &order {
2293        let node = graph.node(nid);
2294        let Some(d_idx) = node.op.destructive_input() else {
2295            continue;
2296        };
2297        if d_idx >= node.inputs.len() {
2298            continue;
2299        }
2300        let target = node.inputs[d_idx];
2301
2302        // Cheap pre-check: skip BOTH `reach()` walks and the
2303        // `order.len()`-sized readers scan below entirely when `target`
2304        // provably has no OTHER reader at all — the common case in a real
2305        // decode graph (KV-cache writes, release markers: nothing besides
2306        // the destructive op itself touches their target). `succ` was
2307        // already built above from EVERY node's `.inputs` edges (plus
2308        // ordering deps) for every node whose INPUT is in `node_set`, so
2309        // when `target` is in `node_set`, `succ[target]` is the COMPLETE
2310        // set of nodes with `target` as a direct input or an ordering
2311        // dependent — a superset of what the scan below finds (ordering
2312        // deps can appear here without a matching direct-input reader, but
2313        // never the reverse). Empty there (besides `nid` itself) therefore
2314        // guarantees the scan would find nothing either, so this cannot
2315        // change which copies get inserted — it only skips redundant work.
2316        // Falls through to the full computation when `target` isn't in
2317        // `node_set` (rare; `succ` doesn't cover it there).
2318        if node_set.contains(&target) {
2319            let any_other_reader = succ
2320                .get(&target)
2321                .is_some_and(|rs| rs.iter().any(|&r| r != nid));
2322            if !any_other_reader {
2323                continue;
2324            }
2325        }
2326
2327        // `forced_before`: nodes with a combined-graph path TO the
2328        // destructive op. `forced_after`: nodes the destructive op
2329        // has a combined-graph path to. A reader is provably safe
2330        // iff it is forced-before AND not forced-after (the latter
2331        // would mean its pin edge closes a cycle the executor cannot
2332        // satisfy). Anything else — cycle members, descendants, or
2333        // readers with no ordering guarantee in either direction —
2334        // gets a safety copy. False positives cost a snapshot;
2335        // false negatives are silent data corruption.
2336        let forced_before = reach(nid, &pred);
2337        let forced_after = reach(nid, &succ);
2338
2339        let mut readers: Vec<NodeId> = Vec::new();
2340        for &maybe_reader in &order {
2341            if maybe_reader == nid {
2342                continue;
2343            }
2344            if !graph.node(maybe_reader).inputs.contains(&target) {
2345                continue;
2346            }
2347            let provably_before =
2348                forced_before.contains(&maybe_reader) && !forced_after.contains(&maybe_reader);
2349            if !provably_before {
2350                readers.push(maybe_reader);
2351            }
2352        }
2353        if readers.is_empty() {
2354            continue;
2355        }
2356
2357        let target_node = graph.node(target);
2358        conflicts.push(Conflict {
2359            destructive_nid: nid,
2360            target,
2361            target_shape: target_node.shape.clone(),
2362            target_dtype: target_node.dtype,
2363            conflicting_readers: readers,
2364        });
2365    }
2366
2367    let inserted = conflicts.len();
2368    for Conflict {
2369        destructive_nid,
2370        target,
2371        target_shape,
2372        target_dtype,
2373        conflicting_readers,
2374    } in conflicts
2375    {
2376        // Pick the copy's target_location. Prefer the target's own
2377        // placement (if any) — that's the device the data lives on.
2378        // Fall back to the destructive op's placement. Most graphs
2379        // populate NEITHER `placement` (a rarely-used explicit pin,
2380        // `Graph::set_placement` / `.on_device(..)`) — ordinary
2381        // single-device `optimize_graph` only stamps the coarser
2382        // `target_backend` (`BackendId`, no gpu_id) — so fall back
2383        // further to a same-device location DERIVED from
2384        // `target_backend` before ever reaching for `Cpu`. Getting
2385        // this wrong silently strands a same-device CUDA/Vulkan
2386        // safety snapshot on the host: the (unrelated) reader this
2387        // copy exists to protect keeps its original device, so the
2388        // optimizer's own backend-follows-data logic then drags the
2389        // reader onto CPU too, and — the capture-specific failure
2390        // mode a same-device CUDA decode graph hits — an ordinary
2391        // same-device snapshot becomes a genuinely cross-device D2H
2392        // that can never be part of a CUDA graph capture (see
2393        // `fuel_dispatch::pipelined::capture_decode`, which only
2394        // ever accepts `Op::Copy` when `target` is same-device Cuda).
2395        // Only truly unplaced AND unbackended nodes (neither hint
2396        // available anywhere) fall all the way through to `Cpu`.
2397        let target_location = graph
2398            .placement(target)
2399            .or_else(|| graph.placement(destructive_nid))
2400            .or_else(|| {
2401                graph
2402                    .target_backend(target)
2403                    .or_else(|| graph.target_backend(destructive_nid))
2404                    .map(device_location_for_backend)
2405            })
2406            .unwrap_or(DeviceLocation::Cpu);
2407        let copy_id = graph.push(crate::Node {
2408            op: crate::Op::Copy {
2409                target: target_location,
2410            },
2411            inputs: vec![target],
2412            shape: target_shape,
2413            dtype: target_dtype,
2414        });
2415        // Propagate target_backend so the executor can look up the
2416        // copy's wrapper. Inherit from the target (the source of the
2417        // copy), falling back to the destructive op's target_backend.
2418        if let Some(backend) = graph
2419            .target_backend(target)
2420            .or_else(|| graph.target_backend(destructive_nid))
2421        {
2422            graph.set_target_backend(copy_id, backend);
2423        }
2424        for reader_id in conflicting_readers {
2425            graph.rewrite_input(reader_id, target, copy_id);
2426        }
2427    }
2428    inserted
2429}
2430
2431/// Walk every multi-output producer in the graph and promote some of
2432/// its `Op::View` consumers to `Op::ViewOwned` when slot lifetimes
2433/// are sufficiently asymmetric that keeping the whole bundle alive
2434/// for the longest-lived slot is wasteful.
2435///
2436/// v1 heuristic (Option C, Session 2): for each multi-output producer
2437/// P, compute the per-slot "last use" position in topo order (the
2438/// max position over each slot's Views and their transitive
2439/// consumers). Slots whose last use is strictly later than at least
2440/// one other slot's last use get all their Views promoted to
2441/// `Op::ViewOwned`. The remaining short-lived slots stay as
2442/// `Op::View` so the bundle drops naturally when their last consumer
2443/// finishes.
2444///
2445/// Concrete example: SelectiveScan's `y` (slot 0) consumed at the
2446/// next layer; `last_state` (slot 1) retained across a barrier into
2447/// the next autoregressive step. `last_state`'s last use is much
2448/// later → it gets promoted to `ViewOwned`, the bundle drops once
2449/// `y`'s consumer finishes, and `last_state`'s standalone copy
2450/// survives across the gap.
2451///
2452/// Implementation: builds a consumer index over the topo order,
2453/// computes `last_use[N] = max(pos[N], last_use[consumers])` by
2454/// reverse-iterating, then per producer makes a per-slot rollup
2455/// and rewrites edges from the promoted Views to fresh
2456/// `Op::ViewOwned` nodes. Returns the number of promotions made.
2457/// Idempotent on already-promoted Views (an `Op::ViewOwned` is a
2458/// fixpoint).
2459///
2460/// **Where this plugs in:** today the multi-output infra has no
2461/// production consumer (Session 2 ships the authoring contract and
2462/// this pass; consumers light up in the
2463/// `selective-scan-ssd-chunk-multi-output-followup` session). The
2464/// pass is a no-op on graphs without any `Op::View` consumers of a
2465/// multi-output producer — the common case until consumers migrate.
2466/// Long-term it likely runs after the ranker/picker pass and before
2467/// `compile_plan`; placement is the picker session's call.
2468pub fn promote_views_for_liveness(graph: &mut crate::Graph, roots: &[NodeId]) -> usize {
2469    use crate::Op;
2470
2471    let order = topo_order_multi(graph, roots);
2472    if order.is_empty() {
2473        return 0;
2474    }
2475    // Forward consumer index: producer NodeId → list of consumer NodeIds.
2476    let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2477    for &nid in &order {
2478        for &input in &graph.node(nid).inputs {
2479            consumers.entry(input).or_default().push(nid);
2480        }
2481    }
2482
2483    // `depth[N]`: longest-input-chain length feeding into N. Two
2484    // sibling consumers in the same conceptual layer get the same
2485    // depth, so the lifetime comparison below treats them symmetrically
2486    // even when topo *position* puts them in different slots of the
2487    // walk (which depends on roots[] order and traversal direction —
2488    // unstable for our purposes).
2489    let mut depth: HashMap<NodeId, usize> = HashMap::with_capacity(order.len());
2490    for &nid in &order {
2491        let mut d = 0usize;
2492        for &inp in &graph.node(nid).inputs {
2493            if let Some(&di) = depth.get(&inp)
2494                && di + 1 > d
2495            {
2496                d = di + 1;
2497            }
2498        }
2499        depth.insert(nid, d);
2500    }
2501
2502    // `last_use[N]`: max downstream depth reachable from N (including
2503    // N itself). Computed by reverse-iterating the topo order and
2504    // propagating each node's depth back to its inputs.
2505    let mut last_use: HashMap<NodeId, usize> = HashMap::with_capacity(order.len());
2506    for &nid in order.iter().rev() {
2507        let mut lu = depth[&nid];
2508        if let Some(downstream) = consumers.get(&nid) {
2509            for &c in downstream {
2510                if let Some(&cl) = last_use.get(&c)
2511                    && cl > lu
2512                {
2513                    lu = cl;
2514                }
2515            }
2516        }
2517        last_use.insert(nid, lu);
2518    }
2519
2520    // Find every multi-output producer that has any View / ViewOwned
2521    // consumers in this graph. Per producer: bucket the Views by slot
2522    // and compute the per-slot last-use position.
2523    struct ViewEntry {
2524        view_id: NodeId,
2525        slot: u32,
2526        is_owned: bool,
2527    }
2528    let mut by_producer: HashMap<NodeId, Vec<ViewEntry>> = HashMap::new();
2529    for &nid in &order {
2530        let node = graph.node(nid);
2531        let (slot, is_owned) = match node.op {
2532            Op::View { slot } => (slot, false),
2533            Op::ViewOwned { slot } => (slot, true),
2534            _ => continue,
2535        };
2536        // View / ViewOwned have a single input (the producer).
2537        let Some(&producer) = node.inputs.first() else {
2538            continue;
2539        };
2540        // Only meaningful if producer was declared multi-output;
2541        // otherwise it's an authoring bug that the NodeHandle::view
2542        // builder already rejected. Defensive skip.
2543        if !graph.is_multi_output(producer) {
2544            continue;
2545        }
2546        by_producer.entry(producer).or_default().push(ViewEntry {
2547            view_id: nid,
2548            slot,
2549            is_owned,
2550        });
2551    }
2552
2553    // For each producer, decide which slots get promoted and collect
2554    // the per-View promotions. Defer the actual graph mutations to a
2555    // second pass so we can iterate the analysis with `&Graph`.
2556    struct Promotion {
2557        view_id: NodeId,
2558        slot: u32,
2559        view_shape: crate::Shape,
2560        view_dtype: DType,
2561        // Consumers of `view_id` at analysis time — every edge gets
2562        // rewired to the new ViewOwned node.
2563        view_consumers: Vec<NodeId>,
2564    }
2565    let mut promotions: Vec<Promotion> = Vec::new();
2566    for views in by_producer.values() {
2567        // Per-slot last use rollup.
2568        let mut slot_last_use: HashMap<u32, usize> = HashMap::new();
2569        for v in views {
2570            let lu = last_use[&v.view_id];
2571            slot_last_use
2572                .entry(v.slot)
2573                .and_modify(|cur| {
2574                    if lu > *cur {
2575                        *cur = lu;
2576                    }
2577                })
2578                .or_insert(lu);
2579        }
2580        // If every slot in this producer has the same last_use, the
2581        // bundle's natural drop position is fine — no promotions.
2582        let min_lu = match slot_last_use.values().copied().min() {
2583            Some(v) => v,
2584            None => continue,
2585        };
2586        for v in views {
2587            if v.is_owned {
2588                // Already owned — nothing to do (idempotent).
2589                continue;
2590            }
2591            let lu = slot_last_use[&v.slot];
2592            if lu <= min_lu {
2593                // This slot is among the shortest-lived → stays View.
2594                continue;
2595            }
2596            let v_node = graph.node(v.view_id);
2597            promotions.push(Promotion {
2598                view_id: v.view_id,
2599                slot: v.slot,
2600                view_shape: v_node.shape.clone(),
2601                view_dtype: v_node.dtype,
2602                view_consumers: consumers.get(&v.view_id).cloned().unwrap_or_default(),
2603            });
2604        }
2605    }
2606
2607    // Apply mutations: each promoted Op::View becomes a fresh
2608    // Op::ViewOwned that consumers point at instead. The old Op::View
2609    // node stays in the arena (no in-place op mutation) but is
2610    // unreachable from the roots — execution_plan + topo skip it
2611    // naturally. This mirrors the insert_safety_copies pattern of
2612    // "add new nodes + rewrite_input" rather than mutating ops in
2613    // place.
2614    let count = promotions.len();
2615    for p in promotions {
2616        let producer = graph.node(p.view_id).inputs[0];
2617        let owned_id = graph.push(crate::Node {
2618            op: Op::ViewOwned { slot: p.slot },
2619            inputs: vec![producer],
2620            shape: p.view_shape,
2621            dtype: p.view_dtype,
2622        });
2623        // Inherit target_backend from the original View when set, so
2624        // the executor can look up ViewOwned's wrapper without a
2625        // re-resolution pass.
2626        if let Some(backend) = graph.target_backend(p.view_id) {
2627            graph.set_target_backend(owned_id, backend);
2628        }
2629        for consumer in p.view_consumers {
2630            graph.rewrite_input(consumer, p.view_id, owned_id);
2631        }
2632    }
2633    count
2634}
2635
2636/// Build an execution plan that respects both data-flow edges (via
2637/// [`topo_order_multi`]) and ordering edges (via [`derive_ordering`]).
2638/// Returns a `Vec<NodeId>` in an order the executor can walk linearly,
2639/// evaluating each node's dependencies before it.
2640///
2641/// **Fast path:** when the graph has no destructive ops, this returns
2642/// the same order `topo_order_multi` does — no extra cost beyond the
2643/// analysis pass.
2644///
2645/// **Cycle detection:** if the combined (data + ordering) graph has a
2646/// cycle — the residency rule emitted a destructive op that
2647/// transitively depends on itself — this panics with a clear error
2648/// message. That panic indicates a bug in the rule that emitted the
2649/// destructive op, not user code.
2650///
2651/// **Stability:** among nodes that are concurrently ready, this
2652/// picks the one whose `topo_order_multi` index is smallest. Result:
2653/// when there are no ordering edges, the output matches
2654/// `topo_order_multi` exactly.
2655pub fn execution_plan(graph: &crate::Graph, roots: &[NodeId]) -> Vec<NodeId> {
2656    let base_order = topo_order_multi(graph, roots);
2657    let ordering = derive_ordering(graph, roots);
2658    if ordering.is_empty() {
2659        return base_order;
2660    }
2661
2662    // Position of each node in base_order — used as the stable tiebreaker.
2663    let pos: HashMap<NodeId, usize> = base_order
2664        .iter()
2665        .enumerate()
2666        .map(|(i, &n)| (n, i))
2667        .collect();
2668    let node_set: std::collections::HashSet<NodeId> = base_order.iter().copied().collect();
2669
2670    // Build in-degree + reverse adjacency for Kahn's.
2671    let mut in_degree: HashMap<NodeId, usize> = HashMap::with_capacity(base_order.len());
2672    let mut forward: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2673    for &nid in &base_order {
2674        let node = graph.node(nid);
2675        let mut d = 0usize;
2676        for &input in &node.inputs {
2677            if node_set.contains(&input) {
2678                d += 1;
2679                forward.entry(input).or_default().push(nid);
2680            }
2681        }
2682        for &dep in ordering.deps_of(nid) {
2683            if node_set.contains(&dep) {
2684                d += 1;
2685                forward.entry(dep).or_default().push(nid);
2686            }
2687        }
2688        in_degree.insert(nid, d);
2689    }
2690
2691    // Stable ready-set keyed by base_order position. BTreeSet pops the
2692    // smallest → output matches topo order when ordering edges allow it.
2693    let mut ready: std::collections::BTreeSet<(usize, NodeId)> = base_order
2694        .iter()
2695        .copied()
2696        .filter(|n| in_degree[n] == 0)
2697        .map(|n| (pos[&n], n))
2698        .collect();
2699
2700    let mut plan = Vec::with_capacity(base_order.len());
2701    while let Some(&(_, n)) = ready.iter().next() {
2702        ready.remove(&(pos[&n], n));
2703        plan.push(n);
2704        if let Some(succs) = forward.get(&n) {
2705            for &s in succs {
2706                let d = in_degree.get_mut(&s).expect("in_degree covers all nodes");
2707                *d -= 1;
2708                if *d == 0 {
2709                    ready.insert((pos[&s], s));
2710                }
2711            }
2712        }
2713    }
2714
2715    if plan.len() != base_order.len() {
2716        panic!(
2717            "execution_plan: cycle in ordering edges (plan={}, base={}) — \
2718             a destructive op transitively depends on itself. This is a \
2719             bug in whatever rule emitted the destructive op.",
2720            plan.len(),
2721            base_order.len(),
2722        );
2723    }
2724    plan
2725}
2726
2727// ---- Evict + reload graph surgery -----------------------------------------
2728
2729/// Insert an evict-chain around `candidate` to free its device storage
2730/// during a gap between uses, with automatic reload before the post-gap
2731/// consumers. Returns `(move_id, reload_id)`.
2732///
2733/// ## What it does
2734///
2735/// Inserts two new nodes:
2736/// 1. `mv = Op::Move { target: Cpu }` reading `candidate` — stages the
2737///    data to host memory AND destructively releases `candidate`'s
2738///    device-resident storage once it runs ([`Op::Move`] is the fused
2739///    Copy + Release: `destructive_input() == Some(0)`). The
2740///    [`derive_ordering`] pass pins it to run AFTER every
2741///    non-destructive reader of `candidate` (the pre-gap consumers the
2742///    caller left untouched).
2743/// 2. `reload = Op::Copy { target: src_device }` reading `mv` —
2744///    restages the data to the device for the post-gap consumers.
2745///
2746/// Then rewrites every `post_gap_consumer`'s input edge from `candidate`
2747/// to `reload`. Pre-gap consumers keep reading `candidate` directly.
2748///
2749/// Historical note: the original (legacy-executor era) chain was three
2750/// nodes — `Op::Copy{Cpu}` + side-effect-root `Op::Release` +
2751/// `Op::Copy{device}`. `Op::Move` collapsed the first two; the fused
2752/// node's output is consumed by `reload`, so no side-effect root is
2753/// needed.
2754///
2755/// ## Caller's responsibility
2756///
2757/// - `candidate` is a NodeId currently in the graph whose device
2758///   residency is `src_device`.
2759/// - `post_gap_consumers` are NodeIds currently in the graph that each
2760///   have `candidate` in their `inputs`. Typically these come from the
2761///   residency analyzer's gap-positioning logic.
2762/// - The caller stamps placement/backend metadata on the new nodes
2763///   (`mv` runs on `src_device`'s backend, `reload` on Cpu — the
2764///   staged copy's residency) before realizing on the pipelined
2765///   executor. `fuel-dispatch::residency::insert_residency_evictions`
2766///   does this.
2767pub fn insert_evict_reload(
2768    graph: &SharedGraph,
2769    candidate: NodeId,
2770    src_device: DeviceLocation,
2771    post_gap_consumers: &[NodeId],
2772) -> (NodeId, NodeId) {
2773    let mut g = graph.write().unwrap();
2774    let (shape, dtype) = {
2775        let n = g.node(candidate);
2776        (n.shape.clone(), n.dtype)
2777    };
2778
2779    let move_id = g.push(Node {
2780        op: Op::Move {
2781            target: DeviceLocation::Cpu,
2782        },
2783        inputs: vec![candidate],
2784        shape: shape.clone(),
2785        dtype,
2786    });
2787
2788    let reload_id = g.push(Node {
2789        op: Op::Copy { target: src_device },
2790        inputs: vec![move_id],
2791        shape,
2792        dtype,
2793    });
2794
2795    // Rewrite each post-gap consumer's `candidate` input to `reload_id`.
2796    // Pre-gap consumers (not in this list) continue reading `candidate`
2797    // directly, which is why derive_ordering needs `move_id` to run
2798    // after them.
2799    for &consumer in post_gap_consumers {
2800        g.rewrite_input(consumer, candidate, reload_id);
2801    }
2802
2803    (move_id, reload_id)
2804}
2805
2806// ===========================================================================
2807// Phase 2.1 — cross-device `Op::Copy` insertion
2808// ===========================================================================
2809//
2810// When the picker (or a user pin) commits a kernel-bearing node to a
2811// `DeviceLocation` that doesn't share a storage substrate with one of
2812// its inputs' resident `DeviceLocation`, the data needs to actually
2813// move. The executor handles the FINAL output via `pipelined_bridge`'s
2814// realize-root splicing; cross-device edges INSIDE the graph are
2815// handled here.
2816//
2817// `insert_cross_device_copies` is the pre-execute pass that handles
2818// internal cross-device edges. Architecture v1.0 §04 names the
2819// optimizer as the owner of transfer-op insertion — this is that
2820// owner. Wired into `fuel-tensor::pipelined_bridge::prepare()` (picker
2821// arc step 2): the bridge derives `placement_for` from the monolithic
2822// `target_backend` pinning + the StorageCache's resident locations,
2823// and `shares_storage` from `SystemTopology`.
2824//
2825// The pass deliberately doesn't decide placements itself — callers
2826// (the optimizer ranker / picker) commit to placements first, then
2827// hand the pass `(graph, placement_for_node, shares_storage)` and
2828// it materializes the implied transfers. Same pattern as the
2829// JudgeOracle / CapabilitiesLookup callbacks in
2830// `fuel-dispatch::ranker`: keep `fuel-graph` ignorant of how
2831// placements / topology are derived; the closure is the seam.
2832
2833/// Insert `Op::Copy { target }` nodes on every edge where the
2834/// consumer's intended `DeviceLocation` doesn't share a storage
2835/// substrate with the producer's. Returns a remap of original
2836/// `NodeId`s to their post-rewrite equivalents (most NodeIds map to
2837/// themselves; only consumers of cross-device edges acquire new
2838/// inputs in-place via `rewrite_input`).
2839///
2840/// # Arguments
2841///
2842/// - `graph` — the graph to rewrite, mutably. New `Op::Copy` nodes
2843///   are appended via `Graph::push`; consumer edges are rewired via
2844///   the internal `rewrite_input` helper.
2845/// - `roots` — the realize roots the caller cares about. The pass
2846///   walks `topo_order_multi(roots)` and considers every node it
2847///   visits.
2848/// - `placement_for` — `Fn(NodeId) -> Option<DeviceLocation>`.
2849///   Returns the picker's committed placement for the node, or
2850///   `None` if the node has no placement (view ops, structural ops,
2851///   user-unpinned ops in a single-backend realize). When either
2852///   the producer or consumer has no placement, the pass leaves
2853///   the edge alone — the executor falls back to its existing
2854///   handling (auto_contiguize or the realize-root splice).
2855/// - `shares_storage` — `Fn(DeviceLocation, DeviceLocation) -> bool`.
2856///   The topology query. Typical implementation is
2857///   `SystemTopology::shares_storage((b1, dev1), (b2, dev2))` after
2858///   the caller resolves backends to devices; we pass just the
2859///   `DeviceLocation` pair here because the substrate question is
2860///   device-level (`Cpu` shares with `Cpu`, `Cuda{0}` shares with
2861///   `Cuda{0}` but not `Cuda{1}`, etc.). Same-device queries should
2862///   return `true`.
2863/// - `needs_host_staging` — `Fn(DeviceLocation, DeviceLocation) -> bool`.
2864///   For a non-shared edge that this pass IS bridging, returns `true`
2865///   when the transfer cannot be done as a SINGLE `Op::Copy` hop and
2866///   must instead route through host memory as TWO hops
2867///   (producer-GPU → CPU, then CPU → consumer-GPU). This is the
2868///   cross-VENDOR GPU↔GPU case (CUDA↔Vulkan): there is no direct
2869///   device-to-device copy kernel between distinct GPU substrates, so
2870///   the source-backend Copy kernel only accepts a CPU or same-substrate
2871///   output. The single-hop cases — CPU↔CUDA, CPU↔Vulkan, and any edge
2872///   touching CPU on either end — return `false` and get exactly ONE
2873///   `Op::Copy` (byte-identical to the pre-two-hop behavior). Typical
2874///   wiring derives this from `SystemTopology::transfer_path(src, dst)
2875///   == TransferPath::HostStaging` for GPU↔GPU pairs. When `true`, the
2876///   pass inserts a CPU intermediate `Op::Copy { target: Cpu }` (placed
2877///   on CPU) and a second `Op::Copy { target: consumer }` reading it;
2878///   the consumer is rewired to the second hop.
2879///
2880/// # Returns
2881///
2882/// The `NodeId`s of the inserted `Op::Copy` nodes, in insertion
2883/// order. Callers typically need these to stamp executor metadata
2884/// on the new nodes — e.g. `fuel-tensor::pipelined_bridge` sets
2885/// `target_backend` = the SOURCE backend (the pipelined executor's
2886/// Op::Copy kernel-lookup convention: the transfer kernel runs on
2887/// the backend the bytes come FROM). `.len()` is the transfer-count
2888/// metric the optimizer reports when deciding whether to pursue
2889/// alternative placements that minimize transfers. A two-hop
2890/// (host-staged) edge contributes BOTH inserted copies (the CPU
2891/// intermediate and the consumer-side hop) to this vec; the SOURCE-
2892/// backend re-stamp the caller applies to `inputs.first()` is
2893/// correct for each hop independently (the CPU hop's source is the
2894/// producer GPU; the consumer hop's source is the CPU intermediate).
2895///
2896/// # Idempotence
2897///
2898/// The pass is idempotent — re-running on an already-rewritten
2899/// graph adds zero new copies (the inserted `Op::Copy` nodes
2900/// carry `placement` matching the consumer's, so subsequent
2901/// passes see same-device edges from `Op::Copy` to consumer).
2902///
2903/// # CSE on inserted copies
2904///
2905/// When two consumers share an input AND both need to bring it to
2906/// the same target device, the pass deduplicates: one `Op::Copy`
2907/// node serves both consumers. This matches the existing CSE
2908/// pattern in `optimize_to_fixpoint`.
2909pub fn insert_cross_device_copies<P, S, H>(
2910    graph: &mut Graph,
2911    roots: &[NodeId],
2912    placement_for: P,
2913    shares_storage: S,
2914    needs_host_staging: H,
2915) -> Vec<NodeId>
2916where
2917    P: Fn(NodeId) -> Option<DeviceLocation>,
2918    S: Fn(DeviceLocation, DeviceLocation) -> bool,
2919    H: Fn(DeviceLocation, DeviceLocation) -> bool,
2920{
2921    let order = topo_order_multi(graph, roots);
2922    // CSE on inserted copies: `(producer, target_device) → Op::Copy NodeId`.
2923    // For a two-hop edge the CPU intermediate is itself CSE-keyed at
2924    // `(producer, Cpu)` and the consumer hop at `(cpu_intermediate, target)`,
2925    // so a producer feeding several cross-vendor consumers stages to CPU
2926    // exactly once and each distinct target gets one consumer-side hop.
2927    let mut copy_cache: HashMap<(NodeId, DeviceLocation), NodeId> = HashMap::new();
2928    let mut inserted: Vec<NodeId> = Vec::new();
2929
2930    // Two passes: first compute the rewires we want, then apply
2931    // them. Splitting avoids borrow issues with graph mutation
2932    // mid-iteration and keeps the iteration order stable.
2933    let mut rewires: Vec<(NodeId, NodeId, NodeId)> = Vec::new();
2934
2935    for &consumer_id in &order {
2936        let consumer_placement = match placement_for(consumer_id) {
2937            Some(p) => p,
2938            None => continue,
2939        };
2940        // Skip ops that are themselves transfers — `Op::Copy` /
2941        // `Op::Move` exist precisely TO bridge devices; inserting
2942        // another copy on their input would be infinite-regress.
2943        let consumer_op = &graph.node(consumer_id).op;
2944        if matches!(consumer_op, Op::Copy { .. } | Op::Move { .. }) {
2945            continue;
2946        }
2947
2948        // Snapshot inputs so we don't reborrow during the loop.
2949        let inputs: Vec<NodeId> = graph.node(consumer_id).inputs.clone();
2950        for producer_id in inputs {
2951            let producer_placement = match placement_for(producer_id) {
2952                Some(p) => p,
2953                None => continue,
2954            };
2955            if shares_storage(producer_placement, consumer_placement) {
2956                continue;
2957            }
2958
2959            // Decide hop count. A cross-VENDOR GPU↔GPU edge (e.g.
2960            // CUDA↔Vulkan) can't be done as one `Op::Copy` — there's no
2961            // direct device-to-device copy kernel between distinct GPU
2962            // substrates, so the source-backend Copy kernel rejects the
2963            // foreign-GPU output. Route it through host memory as TWO hops:
2964            // producer-GPU → CPU (intermediate), then CPU → consumer-GPU.
2965            // Single-hop edges (anything touching CPU on either end, or a
2966            // same-vendor pair the topology can copy directly) stay exactly
2967            // one `Op::Copy` — byte-identical to the pre-two-hop behavior.
2968            let two_hop = needs_host_staging(producer_placement, consumer_placement);
2969
2970            // `source_for_consumer_hop` is the node the consumer-side
2971            // `Op::Copy { target: consumer_placement }` reads FROM: the
2972            // producer directly for a single hop, or the CPU intermediate
2973            // for a two-hop edge.
2974            let source_for_consumer_hop = if two_hop {
2975                // Hop 1: producer-GPU → CPU intermediate. CSE-keyed at
2976                // `(producer, Cpu)` so a producer feeding several cross-
2977                // vendor consumers stages to CPU exactly once.
2978                match copy_cache.get(&(producer_id, DeviceLocation::Cpu)) {
2979                    Some(&id) => id,
2980                    None => {
2981                        let producer = graph.node(producer_id);
2982                        let shape = producer.shape.clone();
2983                        let dtype = producer.dtype;
2984                        let cpu_id = graph.push(Node {
2985                            op: Op::Copy {
2986                                target: DeviceLocation::Cpu,
2987                            },
2988                            inputs: vec![producer_id],
2989                            shape,
2990                            dtype,
2991                        });
2992                        // The CPU intermediate bridges the two hops; place
2993                        // it on CPU.
2994                        graph.set_placement(cpu_id, DeviceLocation::Cpu);
2995                        copy_cache.insert((producer_id, DeviceLocation::Cpu), cpu_id);
2996                        inserted.push(cpu_id);
2997                        cpu_id
2998                    }
2999                }
3000            } else {
3001                producer_id
3002            };
3003
3004            // Hop 2 (or the single hop): `source_for_consumer_hop` →
3005            // consumer device. CSE-keyed at `(source, consumer_placement)`.
3006            let copy_id = match copy_cache.get(&(source_for_consumer_hop, consumer_placement)) {
3007                Some(&id) => id,
3008                None => {
3009                    let src = graph.node(source_for_consumer_hop);
3010                    let shape = src.shape.clone();
3011                    let dtype = src.dtype;
3012                    let id = graph.push(Node {
3013                        op: Op::Copy {
3014                            target: consumer_placement,
3015                        },
3016                        inputs: vec![source_for_consumer_hop],
3017                        shape,
3018                        dtype,
3019                    });
3020                    // The inserted copy itself sits on the
3021                    // consumer's device — its output is what the
3022                    // consumer reads.
3023                    graph.set_placement(id, consumer_placement);
3024                    copy_cache.insert((source_for_consumer_hop, consumer_placement), id);
3025                    inserted.push(id);
3026                    id
3027                }
3028            };
3029            rewires.push((consumer_id, producer_id, copy_id));
3030        }
3031    }
3032
3033    for (consumer, old_input, new_input) in rewires {
3034        graph.rewrite_input(consumer, old_input, new_input);
3035    }
3036
3037    inserted
3038}
3039
3040/// Phase 2.2 of the picker arc — insert `Op::Contiguize` before any
3041/// kernel-bearing node whose chosen kernel doesn't accept strided
3042/// inputs but whose live input layout is non-contiguous (strided,
3043/// offset, or both).
3044///
3045/// # When to call
3046///
3047/// After Phase 2.1's `insert_cross_device_copies` and after the
3048/// picker has committed to per-node kernel choices. The picker's
3049/// commitment determines the `KernelCaps::strided_input` flag for
3050/// each node; this pass reads that flag via the `kernel_accepts_strided`
3051/// callback and inserts layout-fixups where the flag is false and
3052/// the input is strided.
3053///
3054/// # Callback contract
3055///
3056/// `kernel_accepts_strided(consumer_id) -> bool` returns `true` if
3057/// the kernel resolved for that node tolerates strided / offset
3058/// inputs (i.e. its `KernelCaps::strided_input == true`). The
3059/// callback is opaque to this pass — typical wiring reads the
3060/// picker's commit-to-winner alternative set and queries the
3061/// winner's caps. When `false`, this pass inserts `Op::Contiguize`
3062/// on every non-contiguous input.
3063///
3064/// # Skips
3065///
3066/// - View ops (`Transpose`, `Permute`, `BroadcastTo`, `Slice`,
3067///   `Unsqueeze`, `Squeeze`, `Flip`) — they preserve strided
3068///   layouts intentionally.
3069/// - Structural ops (`Const`, `Release`, `Reshape`, `Contiguize`,
3070///   `Copy`, `Move`, `Alloc`, `ZeroFill`, `WriteSlice`) — their
3071///   executor arms handle strided naturally or are not kernel-
3072///   bearing.
3073///
3074/// # CSE
3075///
3076/// When two consumers share a non-contiguous input AND both kernels
3077/// reject strided, one `Op::Contiguize` node serves both. The
3078/// per-pass cache is keyed on producer NodeId (no target-device
3079/// dimension — Contiguize is device-inheriting).
3080///
3081/// # Idempotence
3082///
3083/// Running this pass on an already-rewritten graph adds zero new
3084/// nodes: an inserted `Op::Contiguize` has a contiguous + zero-offset
3085/// output layout, so the subsequent fixup check (`is_contiguous &&
3086/// start_offset == 0`) succeeds and the input is skipped.
3087///
3088/// Returns the number of `Op::Contiguize` nodes inserted.
3089pub fn insert_layout_fixups<F>(
3090    graph: &mut Graph,
3091    roots: &[NodeId],
3092    kernel_accepts_strided: F,
3093) -> usize
3094where
3095    F: Fn(NodeId) -> bool,
3096{
3097    let order = topo_order_multi(graph, roots);
3098    // CSE: producer NodeId → Contiguize NodeId. No device dim since
3099    // Contiguize inherits the producer's residency.
3100    let mut fixup_cache: HashMap<NodeId, NodeId> = HashMap::new();
3101    let mut inserted = 0usize;
3102    let mut rewires: Vec<(NodeId, NodeId, NodeId)> = Vec::new();
3103
3104    for &consumer_id in &order {
3105        // Snapshot the consumer to release the borrow before
3106        // mutation.
3107        let (consumer_op, consumer_inputs) = {
3108            let node = graph.node(consumer_id);
3109            (node.op.clone(), node.inputs.clone())
3110        };
3111
3112        // Skip non-kernel-bearing nodes — their executor arms
3113        // either preserve strided layouts (view ops, Reshape) or
3114        // are structural with no kernel to mismatch on (Const,
3115        // Release, Copy, Move, Alloc, ZeroFill, WriteSlice,
3116        // WriteSliceDoff, Contiguize). WriteSlice/WriteSliceDoff
3117        // adopt `dest` in place + auto-contiguize their source in the
3118        // execute arm, so a pass-inserted contiguize on `dest` would
3119        // break the in-place adoption — skip them here.
3120        if consumer_op.is_view_op()
3121            || matches!(
3122                consumer_op,
3123                Op::Const
3124                    | Op::Release
3125                    | Op::Reshape(_)
3126                    | Op::Contiguize
3127                    | Op::Copy { .. }
3128                    | Op::Move { .. }
3129                    | Op::Alloc { .. }
3130                    | Op::ZeroFill
3131                    | Op::WriteSlice { .. }
3132                    | Op::WriteSliceDoff { .. }
3133            )
3134        {
3135            continue;
3136        }
3137
3138        // The kernel accepts strided inputs directly — no fixup.
3139        if kernel_accepts_strided(consumer_id) {
3140            continue;
3141        }
3142
3143        for input_id in consumer_inputs {
3144            let input_layout = graph.layout(input_id);
3145            if input_layout.is_contiguous() && input_layout.start_offset() == 0 {
3146                continue;
3147            }
3148
3149            let fixup_id = match fixup_cache.get(&input_id) {
3150                Some(&id) => id,
3151                None => {
3152                    let (shape, dtype) = {
3153                        let p = graph.node(input_id);
3154                        (p.shape.clone(), p.dtype)
3155                    };
3156                    let id = graph.push(Node {
3157                        op: Op::Contiguize,
3158                        inputs: vec![input_id],
3159                        shape,
3160                        dtype,
3161                    });
3162                    fixup_cache.insert(input_id, id);
3163                    inserted += 1;
3164                    id
3165                }
3166            };
3167            rewires.push((consumer_id, input_id, fixup_id));
3168        }
3169    }
3170
3171    for (consumer, old_input, new_input) in rewires {
3172        graph.rewrite_input(consumer, old_input, new_input);
3173    }
3174
3175    inserted
3176}
3177
3178/// Insert `Op::Cast` fixups that CHANGE a consumer's input dtype — the sibling of
3179/// [`insert_layout_fixups`], for dtype rather than layout. Given precomputed
3180/// `(consumer, input, target_dtype)` edges (an input whose dtype should be
3181/// promoted so the consumer's `(op, per-operand-dtypes)` key becomes servable),
3182/// insert one `Op::Cast(target)` per distinct `(input, target)` — CSE-shared
3183/// across consumers — and rewire each consumer's edge to it.
3184///
3185/// Unlike `Graph::rewrite_input` (whose contract is a *same-dtype* redirect),
3186/// this DELIBERATELY changes the consumer's input dtype — that is the point: it
3187/// is the graph-side half of the optimizer's dtype-reconciliation pass
3188/// (`fuel_dispatch::optimize::insert_dtype_fixups`), which decides WHICH edges to
3189/// promote from backend kernel coverage. Edges apply in the given order
3190/// (deterministic when the caller passes topo order). Returns the rewires applied.
3191pub fn insert_cast_fixups(graph: &mut Graph, casts: &[(NodeId, NodeId, DType)]) -> usize {
3192    let mut cache: HashMap<(NodeId, DType), NodeId> = HashMap::new();
3193    let mut applied = 0;
3194    for &(consumer, input, target) in casts {
3195        if graph.node(input).dtype == target {
3196            continue; // already the target dtype — nothing to cast
3197        }
3198        let cast_id = *cache.entry((input, target)).or_insert_with(|| {
3199            let shape = graph.node(input).shape.clone();
3200            graph.push(Node {
3201                op: Op::Cast(target),
3202                inputs: vec![input],
3203                shape,
3204                dtype: target,
3205            })
3206        });
3207        graph.rewrite_input(consumer, input, cast_id);
3208        applied += 1;
3209    }
3210    applied
3211}
3212
3213#[cfg(test)]
3214mod tests {
3215    use super::*;
3216    use crate::NodeHandle;
3217    // `FusedOps` is referenced only in these tests; the lib-level `use` above
3218    // correctly dropped it as unused, so import it here where it's used.
3219    use crate::registry::FusedOps;
3220    use fuel_ir::{DeviceLocation, Shape};
3221    use std::sync::Arc;
3222
3223    /// Phase 7.5 G2: tests need a real device for slot-populating
3224    /// constructors. Singleton CpuBackendDevice via OnceLock.
3225    fn cpu_dev() -> &'static Arc<dyn fuel_backend_contract::DynBackendDevice> {
3226        static D: std::sync::OnceLock<Arc<dyn fuel_backend_contract::DynBackendDevice>> =
3227            std::sync::OnceLock::new();
3228        D.get_or_init(|| Arc::new(fuel_cpu_backend::dyn_impl::CpuBackendDevice))
3229    }
3230
3231    fn make_scalar_graph() -> (SharedGraph, NodeHandle) {
3232        let t = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3233            .unwrap();
3234        (t.graph().clone(), t)
3235    }
3236
3237    fn count_copy_nodes(graph: &SharedGraph) -> usize {
3238        let g = graph.read().unwrap();
3239        (0..g.len())
3240            .filter(|i| matches!(g.node(NodeId(*i)).op, Op::Copy { .. }))
3241            .count()
3242    }
3243
3244    #[test]
3245    fn insert_copies_no_placement_no_copies() {
3246        // Graph with no placement hints: pass should be a no-op, no
3247        // Copies inserted, roots unchanged.
3248        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3249        let b = a
3250            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3251            .unwrap();
3252        let c = a.add(&b);
3253        let graph = c.graph().clone();
3254        let before = count_copy_nodes(&graph);
3255        let new_roots = insert_copies(&graph, &[c.id()]);
3256        assert_eq!(new_roots, vec![c.id()]);
3257        assert_eq!(count_copy_nodes(&graph), before);
3258    }
3259
3260    #[test]
3261    fn insert_copies_tagged_node_pulls_inputs_to_its_device() {
3262        // Const a, Const b, Add(a, b) placed on Vulkan.
3263        // Expected: two Copy(a, Vulkan) and Copy(b, Vulkan) inserted,
3264        // Add's inputs rewritten to reference the Copies.
3265        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3266        let b = a
3267            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3268            .unwrap();
3269        let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3270        let graph = c.graph().clone();
3271
3272        let before_copies = count_copy_nodes(&graph);
3273        let new_roots = insert_copies(&graph, &[c.id()]);
3274
3275        // Should be exactly 2 new Copies (one per input).
3276        assert_eq!(count_copy_nodes(&graph) - before_copies, 2);
3277
3278        // Rewritten Add should reference two Copy nodes.
3279        let g = graph.read().unwrap();
3280        let new_add = g.node(new_roots[0]);
3281        assert_eq!(new_add.inputs.len(), 2);
3282        for input in &new_add.inputs {
3283            let node = g.node(*input);
3284            assert!(matches!(
3285                node.op,
3286                Op::Copy {
3287                    target: DeviceLocation::Vulkan { gpu_id: 0 }
3288                }
3289            ));
3290        }
3291    }
3292
3293    #[test]
3294    fn insert_copies_matching_device_no_copies_inserted() {
3295        // Const a and Add both placed on Vulkan. Const flows into
3296        // Add, both want Vulkan — no Copy needed.
3297        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev())
3298            .unwrap()
3299            .on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3300        let b = a
3301            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3302            .unwrap()
3303            .on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3304        let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3305        let graph = c.graph().clone();
3306
3307        let before = count_copy_nodes(&graph);
3308        insert_copies(&graph, &[c.id()]);
3309        assert_eq!(count_copy_nodes(&graph), before);
3310    }
3311
3312    #[test]
3313    fn insert_copies_handles_backward_graph() {
3314        // Forward: y = sum(x * x) with x placed on Vulkan.
3315        // Call backward — this appends gradient nodes to the same graph.
3316        // Then run insert_copies on BOTH the forward root and the
3317        // gradient root. Every backward op should end up on Vulkan
3318        // (inherited from its inputs, which trace back to Vulkan-placed x).
3319        let x = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3320            .unwrap()
3321            .on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3322        let sq = x.mul(&x).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3323        let y = sq.sum_all().on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3324        let graph = y.graph().clone();
3325
3326        let grads = y.backward();
3327        let grad_x = grads.get(&x).expect("dL/dx should exist");
3328
3329        // Run insert_copies on both roots.
3330        let before_copies = count_copy_nodes(&graph);
3331        let _new = insert_copies(&graph, &[y.id(), grad_x.id()]);
3332        let after_copies = count_copy_nodes(&graph);
3333
3334        // With everything on Vulkan, no Copies should be needed —
3335        // this verifies the pass doesn't spuriously insert Copies into
3336        // the backward graph when placements are consistent.
3337        assert_eq!(
3338            after_copies, before_copies,
3339            "insert_copies should leave a uniformly-Vulkan forward+backward graph alone"
3340        );
3341    }
3342
3343    #[test]
3344    fn insert_copies_backward_inherits_forward_device() {
3345        // Forward: y = sum(x * x) with NO explicit placement.
3346        // Compute backward. Then place JUST the forward root on Vulkan
3347        // and re-run insert_copies. The Copies that get inserted should
3348        // pull x to Vulkan; the backward path should follow suit when
3349        // we ask for both roots.
3350        let x = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3351            .unwrap();
3352        let sq = x.mul(&x);
3353        let y = sq.sum_all().on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3354        let graph = y.graph().clone();
3355
3356        let grads = y.backward();
3357        let grad_x = grads.get(&x).expect("dL/dx should exist");
3358
3359        // Before: only y is tagged. The backward graph has no tags.
3360        // After insert_copies([y, grad_x]): y pulls its inputs (sq)
3361        // toward Vulkan → sq pulls x → Copies inserted.
3362        // Backward inherits device from its inputs transitively.
3363        let new_roots = insert_copies(&graph, &[y.id(), grad_x.id()]);
3364        assert_eq!(new_roots.len(), 2);
3365        // At least one Copy should have been inserted (the unplaced
3366        // forward consts need to get to Vulkan).
3367        assert!(
3368            count_copy_nodes(&graph) > 0,
3369            "expected Copies to be inserted for unplaced Const inputs"
3370        );
3371    }
3372
3373    #[test]
3374    fn lower_const_placement_single_vulkan_consumer() {
3375        // Const a → Add(a, b) placed on Vulkan. lower_const_placement
3376        // should tag a with Vulkan since Add is its only consumer.
3377        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3378        let b = a
3379            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3380            .unwrap();
3381        let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3382        let graph = c.graph().clone();
3383
3384        let lowered = lower_const_placement(&graph, &[c.id()]);
3385        assert_eq!(lowered, 2); // both a and b tagged
3386        assert_eq!(
3387            graph.read().unwrap().placement(a.id()),
3388            Some(DeviceLocation::Vulkan { gpu_id: 0 })
3389        );
3390        assert_eq!(
3391            graph.read().unwrap().placement(b.id()),
3392            Some(DeviceLocation::Vulkan { gpu_id: 0 })
3393        );
3394
3395        // After lowering, insert_copies should emit NO Copies (the
3396        // Consts are now on the target device).
3397        let before_copies = count_copy_nodes(&graph);
3398        insert_copies(&graph, &[c.id()]);
3399        assert_eq!(count_copy_nodes(&graph), before_copies);
3400    }
3401
3402    #[test]
3403    fn lower_const_placement_consumers_disagree_stays_unplaced() {
3404        // Const a flows into two consumers on different devices.
3405        // Without replication support, lowering has to leave a unplaced.
3406        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3407        let b = a
3408            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3409            .unwrap();
3410        let cpu_sum = a.add(&b).on_device(DeviceLocation::Cpu);
3411        let vulkan_sum = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3412        let graph = a.graph().clone();
3413
3414        lower_const_placement(&graph, &[cpu_sum.id(), vulkan_sum.id()]);
3415        assert_eq!(
3416            graph.read().unwrap().placement(a.id()),
3417            None,
3418            "const with disagreeing consumers stays unplaced"
3419        );
3420        assert_eq!(graph.read().unwrap().placement(b.id()), None);
3421    }
3422
3423    #[test]
3424    fn lower_const_placement_skips_already_placed() {
3425        // An explicitly-placed Const should not be overridden.
3426        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev())
3427            .unwrap()
3428            .on_device(DeviceLocation::Cpu);
3429        let b = a
3430            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3431            .unwrap();
3432        let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3433        let graph = c.graph().clone();
3434
3435        lower_const_placement(&graph, &[c.id()]);
3436        // a keeps its explicit Cpu placement even though its consumer is Vulkan.
3437        assert_eq!(
3438            graph.read().unwrap().placement(a.id()),
3439            Some(DeviceLocation::Cpu)
3440        );
3441        // b had no hint; it gets lowered to Vulkan.
3442        assert_eq!(
3443            graph.read().unwrap().placement(b.id()),
3444            Some(DeviceLocation::Vulkan { gpu_id: 0 })
3445        );
3446    }
3447
3448    #[test]
3449    fn insert_copies_idempotent() {
3450        let a = NodeHandle::from_f32(vec![1.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
3451        let b = a.const_f32_like(vec![2.0], Shape::from_dims(&[1])).unwrap();
3452        let c = a.add(&b).on_device(DeviceLocation::Cpu);
3453        let graph = c.graph().clone();
3454
3455        let roots1 = insert_copies(&graph, &[c.id()]);
3456        let after_first = count_copy_nodes(&graph);
3457        let _roots2 = insert_copies(&graph, &roots1);
3458        assert_eq!(
3459            count_copy_nodes(&graph),
3460            after_first,
3461            "insert_copies should be idempotent on already-reconciled graphs"
3462        );
3463    }
3464
3465    #[test]
3466    fn cse_folds_identical_add() {
3467        let (graph, a) = make_scalar_graph();
3468        let b = a.add(&a);
3469        let c = a.add(&a);
3470        let pre_len = graph.read().unwrap().len();
3471        let new_roots = optimize(&graph, &[b.id(), c.id()]);
3472        assert_eq!(
3473            new_roots[0], new_roots[1],
3474            "CSE should map both to same node"
3475        );
3476        assert!(graph.read().unwrap().len() >= pre_len);
3477    }
3478
3479    #[test]
3480    fn cse_folds_commutative() {
3481        // Build two tensors inside the same graph so `a + b` and
3482        // `b + a` share NodeIds for the inputs. Use add_scalar on `a`
3483        // as a simple way to get a second tensor handle sharing a's graph.
3484        let (_graph, a) = make_scalar_graph();
3485        let b = a.add_scalar(5.0);
3486        let ab = a.add(&b);
3487        let ba = b.add(&a);
3488        let graph = a.graph().clone();
3489        let new_roots = optimize(&graph, &[ab.id(), ba.id()]);
3490        assert_eq!(
3491            new_roots[0], new_roots[1],
3492            "commutative CSE should fold a+b and b+a to one node"
3493        );
3494    }
3495
3496    /// GAP-271: `Maximum`/`Minimum` must NOT be folded across operand order.
3497    ///
3498    /// They are commutative in VALUE and not in BITS. `chassis/binary.rs`
3499    /// returns the FIRST NaN operand -- a documented lhs-first tie-break
3500    /// matching `torch.maximum` -- so two distinct NaN payloads swap with
3501    /// operand order: `max(0x7FC00001, 0x7FC00002)` is `0x7FC00001` and the
3502    /// reverse is `0x7FC00002`. Folding the two orderings is therefore an
3503    /// invalid rewrite.
3504    ///
3505    /// A +-0 FIXTURE WOULD BE UNSOUND HERE, and not because we canonicalise:
3506    /// the +-0 answer is decided PER CALL SITE by the optimizer (we delegate
3507    /// to `f32::max`, which disclaims the tie). One binary at opt-level 3
3508    /// gives three answers -- const-folded +0/+0, `black_box` -0/-0,
3509    /// `#[inline(never)]` -0/+0. A +-0 assertion would therefore be testing
3510    /// the optimizer, not us. The NaN tie-break is explicit in our source and
3511    /// is stable in all four contexts, which is why it is the fixture.
3512    #[test]
3513    fn cse_does_not_fold_minmax_across_operand_order() {
3514        let (_graph, a) = make_scalar_graph();
3515        let b = a.add_scalar(5.0);
3516
3517        // CONTROL, and it is load-bearing: `Add` IS commutative in bits, so it
3518        // must STILL fold. Without this arm, a change that broke CSE entirely
3519        // would pass the assertion below for the wrong reason.
3520        let add_ab = a.add(&b);
3521        let add_ba = b.add(&a);
3522        let graph = a.graph().clone();
3523        let add_roots = optimize(&graph, &[add_ab.id(), add_ba.id()]);
3524        assert_eq!(
3525            add_roots[0], add_roots[1],
3526            "control: Add is bitwise commutative and must still fold -- if this              fails, CSE is broken generally and the Maximum arm below proves nothing"
3527        );
3528
3529        let max_ab = a.maximum(&b);
3530        let max_ba = b.maximum(&a);
3531        let max_roots = optimize(&graph, &[max_ab.id(), max_ba.id()]);
3532        assert_ne!(
3533            max_roots[0], max_roots[1],
3534            "max(a,b) and max(b,a) were folded to one node, but they differ in              BITS on distinct NaN payloads (lhs-first tie-break). See GAP-271."
3535        );
3536
3537        let min_ab = a.minimum(&b);
3538        let min_ba = b.minimum(&a);
3539        let min_roots = optimize(&graph, &[min_ab.id(), min_ba.id()]);
3540        assert_ne!(
3541            min_roots[0], min_roots[1],
3542            "min(a,b) and min(b,a) were folded to one node -- same defect as              Maximum, see GAP-271"
3543        );
3544    }
3545
3546    #[test]
3547    fn simplifies_add_scalar_zero() {
3548        let (graph, a) = make_scalar_graph();
3549        let b = a.add_scalar(0.0);
3550        let new_roots = optimize(&graph, &[b.id()]);
3551        assert_eq!(new_roots[0], a.id(), "AddScalar(0) should alias to input");
3552    }
3553
3554    #[test]
3555    fn simplifies_mul_scalar_one() {
3556        let (graph, a) = make_scalar_graph();
3557        let b = a.mul_scalar(1.0);
3558        let new_roots = optimize(&graph, &[b.id()]);
3559        assert_eq!(new_roots[0], a.id(), "MulScalar(1) should alias to input");
3560    }
3561
3562    #[test]
3563    fn simplifies_identity_cast() {
3564        let (graph, a) = make_scalar_graph();
3565        // Cast to the same dtype is a no-op; the optimizer aliases it away.
3566        // (Dispatch keys casts on [src, dst] and registers no [d, d] kernel,
3567        // so this elision is what removes a same-dtype cast before realize.)
3568        let same = a.cast(a.dtype());
3569        let new_roots = optimize(&graph, &[same.id()]);
3570        assert_eq!(
3571            new_roots[0],
3572            a.id(),
3573            "Cast(d) on a dtype-d input should alias to input",
3574        );
3575    }
3576
3577    /// GAP-183: `Cast(float -> Bool)` lowers to `Ne(x, x*0)` at the GRAPH level.
3578    ///
3579    /// WHY A LOWERING AND NOT A KERNEL: CUDA already ships the `!= 0` test —
3580    /// `ne_{f32,f64,f16,bf16}_u8` in `cuda/compare.fkc.md` — but those kernels are
3581    /// UNREACHABLE from a storage-level cast, which cannot compose graph ops. So
3582    /// the fix belongs here, not in the storage layer.
3583    ///
3584    /// BORN-RED: before the rewrite this root is an `Op::Cast(Bool)`.
3585    #[test]
3586    fn lowers_float_cast_to_bool_into_ne_against_zero() {
3587        let (graph, a) = make_scalar_graph(); // F32
3588        let as_bool = a.cast(fuel_ir::DType::Bool);
3589        let new_roots = optimize(&graph, &[as_bool.id()]);
3590        let g = graph.read().unwrap();
3591        let root = g.node(new_roots[0]);
3592        assert!(
3593            matches!(root.op, Op::Ne),
3594            "float -> Bool must lower to Op::Ne, got {:?}",
3595            root.op,
3596        );
3597        assert_eq!(
3598            root.dtype,
3599            fuel_ir::DType::Bool,
3600            "the lowered root still yields Bool"
3601        );
3602        // RHS is zeros from `MulScalar(0.0)` over the SAME input — not a
3603        // materialized const, so shape and dtype match by construction.
3604        let rhs = g.node(root.inputs[1]);
3605        assert!(
3606            matches!(rhs.op, Op::MulScalar(c) if c == 0.0),
3607            "rhs must be MulScalar(0.0) (shape-preserving zeros), got {:?}",
3608            rhs.op,
3609        );
3610        assert_eq!(
3611            rhs.inputs[0], root.inputs[0],
3612            "zeros must derive from the cast's own input",
3613        );
3614    }
3615
3616    /// GAP-183 companion: the INTEGER half is deliberately NOT lowered — there is
3617    /// no CUDA integer compare kernel, so rewriting would move the same decline
3618    /// one layer out while bypassing CPU's real int->Bool cast kernels.
3619    #[test]
3620    fn does_not_lower_integer_cast_to_bool() {
3621        let (graph, a) = make_scalar_graph();
3622        let as_u8 = a.cast(fuel_ir::DType::U8);
3623        let as_bool = as_u8.cast(fuel_ir::DType::Bool);
3624        let new_roots = optimize(&graph, &[as_bool.id()]);
3625        let g = graph.read().unwrap();
3626        let root = g.node(new_roots[0]);
3627        assert!(
3628            matches!(root.op, Op::Cast(fuel_ir::DType::Bool)),
3629            "int -> Bool stays an Op::Cast (genuine decline on CUDA), got {:?}",
3630            root.op,
3631        );
3632    }
3633
3634    #[test]
3635    fn keeps_real_cast() {
3636        let (graph, a) = make_scalar_graph();
3637        // A genuine dtype change (F32→F64) is NOT elided.
3638        let cast = a.cast(fuel_ir::DType::F64);
3639        let new_roots = optimize(&graph, &[cast.id()]);
3640        assert_ne!(
3641            new_roots[0],
3642            a.id(),
3643            "a real Cast must survive optimization"
3644        );
3645    }
3646
3647    #[test]
3648    fn declarative_pattern_matches_and_fuses_relu_add() {
3649        use crate::jit::{OpAttrs, OpTag, PatternNode};
3650        use crate::registry::{
3651            BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps, PatternTree,
3652            SubgraphPattern,
3653        };
3654        fn shape_rule(s: &[Shape], _: &FusedOpParams) -> Shape {
3655            s[0].clone()
3656        }
3657        fn dtype_rule(d: &[DType], _: &FusedOpParams) -> DType {
3658            d[0]
3659        }
3660        fn no_decompose(_: &mut Graph, id: NodeId, _: &FusedOpParams) -> NodeId {
3661            id
3662        }
3663        // A declaratively-registered fused op for relu(add(a, b)).
3664        let entry = FusedOpEntry {
3665            destructive_input: None,
3666            id: FusedOps::SOFTMAX_LAST_DIM, // a real parameterless id (plumbing test)
3667            name: "test_declarative_relu_add",
3668            family: FusedOpFamily::Forward,
3669            pattern: SubgraphPattern::Declarative(PatternTree {
3670                root: PatternNode::Op {
3671                    op: OpTag::Relu,
3672                    attrs: OpAttrs::default(),
3673                    operands: vec![PatternNode::Op {
3674                        op: OpTag::Add,
3675                        attrs: OpAttrs::default(),
3676                        operands: vec![
3677                            PatternNode::Bind { index: 0 },
3678                            PatternNode::Bind { index: 1 },
3679                        ],
3680                    }],
3681                },
3682                params: FusedOpParams::SoftmaxLastDim,
3683            }),
3684            decompose: no_decompose,
3685            backward: BackwardKind::NotDifferentiable,
3686            shape_rule,
3687            dtype_rule,
3688            output_views: None,
3689        };
3690        let rule = FusionRule::from_entry(&entry);
3691
3692        // Build relu(add(a, b)).
3693        let mut g = Graph::new();
3694        let s = Shape::from_dims(&[2]);
3695        let f32 = DType::F32;
3696        let a = g.push(Node {
3697            op: Op::Const,
3698            inputs: vec![],
3699            shape: s.clone(),
3700            dtype: f32,
3701        });
3702        let b = g.push(Node {
3703            op: Op::Const,
3704            inputs: vec![],
3705            shape: s.clone(),
3706            dtype: f32,
3707        });
3708        let sum = g.push(Node {
3709            op: Op::Add,
3710            inputs: vec![a, b],
3711            shape: s.clone(),
3712            dtype: f32,
3713        });
3714        let r = g.push(Node {
3715            op: Op::Relu,
3716            inputs: vec![sum],
3717            shape: s.clone(),
3718            dtype: f32,
3719        });
3720
3721        // The declarative rule fires at the relu sink and fuses to Op::Fused.
3722        assert!(rule.matches(&g, r), "declarative pattern matches relu(add)");
3723        let mut remap = HashMap::new();
3724        rule.rewrite(&mut g, r, &mut remap);
3725        let fused_id = remap[&r];
3726        match &g.node(fused_id).op {
3727            Op::Fused(id, FusedOpParams::SoftmaxLastDim) => {
3728                assert_eq!(*id, FusedOps::SOFTMAX_LAST_DIM)
3729            }
3730            other => panic!("expected Op::Fused(SOFTMAX_LAST_DIM, _), got {other:?}"),
3731        }
3732        assert_eq!(
3733            g.node(fused_id).inputs,
3734            vec![a, b],
3735            "fused inputs = the region's bound external inputs",
3736        );
3737    }
3738
3739    #[test]
3740    fn runtime_op_fuses_and_round_trips_through_the_pass() {
3741        use crate::jit::{OpAttrs, OpTag, PatternNode};
3742        use crate::runtime_fused::register_runtime_fused;
3743        // A region no other test registers — tanh(sub(a, b)) — so the fuse is
3744        // deterministic (only our runtime rule matches it; sub is non-commutative).
3745        let region = PatternNode::Op {
3746            op: OpTag::Tanh,
3747            attrs: OpAttrs::default(),
3748            operands: vec![PatternNode::Op {
3749                op: OpTag::Sub,
3750                attrs: OpAttrs::default(),
3751                operands: vec![
3752                    PatternNode::Bind { index: 0 },
3753                    PatternNode::Bind { index: 1 },
3754                ],
3755            }],
3756        };
3757        let rid = register_runtime_fused("test::tanh_sub", region).unwrap();
3758
3759        // Build tanh(sub(a, b)) on primitives (b shares a's graph).
3760        let a = NodeHandle::from_f32(
3761            vec![0.5, -0.5, 1.0, -1.0],
3762            Shape::from_dims(&[4]),
3763            cpu_dev(),
3764        )
3765        .unwrap();
3766        let b = a
3767            .const_f32_like(vec![0.1, 0.2, 0.3, 0.4], Shape::from_dims(&[4]))
3768            .unwrap();
3769        let y = a.sub(&b).tanh();
3770        let graph = y.graph().clone();
3771
3772        // default_rules: lower (no-op here) then fuse — the runtime declarative
3773        // FusionRule folds tanh(sub) → Op::Fused(rid, Runtime).
3774        let fused = RuleRegistry::default_rules().optimize_to_fixpoint(&graph, &[y.id()]);
3775        {
3776            let g = graph.read().unwrap();
3777            match &g.node(fused[0]).op {
3778                Op::Fused(fid, FusedOpParams::Runtime { scalars }) => {
3779                    assert_eq!(*fid, rid, "fused to our runtime op");
3780                    assert!(fid.is_runtime());
3781                    assert!(scalars.is_empty(), "parameterless region");
3782                }
3783                other => panic!("expected runtime Op::Fused, got {other:?}"),
3784            }
3785        }
3786
3787        // lowering_only: the kernel-absent path — decompose the runtime op back
3788        // to its region on primitives (tanh(sub(a, b))).
3789        let lowered = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &fused);
3790        let g = graph.read().unwrap();
3791        assert!(
3792            matches!(g.node(lowered[0]).op, Op::Tanh),
3793            "re-emitted sink is Tanh"
3794        );
3795        let sub_id = g.node(lowered[0]).inputs[0];
3796        assert!(matches!(g.node(sub_id).op, Op::Sub), "Tanh's input is Sub");
3797        assert_eq!(
3798            g.node(sub_id).inputs.len(),
3799            2,
3800            "Sub over the two bound inputs"
3801        );
3802    }
3803
3804    #[test]
3805    fn capability_gate_blocks_fusion_without_a_kernel() {
3806        use crate::jit::{OpAttrs, OpTag, PatternNode};
3807        use crate::runtime_fused::register_runtime_fused;
3808        // Unique region — sigmoid(div(a, b)) — so the gate decision is ours alone.
3809        let region = PatternNode::Op {
3810            op: OpTag::Sigmoid,
3811            attrs: OpAttrs::default(),
3812            operands: vec![PatternNode::Op {
3813                op: OpTag::Div,
3814                attrs: OpAttrs::default(),
3815                operands: vec![
3816                    PatternNode::Bind { index: 0 },
3817                    PatternNode::Bind { index: 1 },
3818                ],
3819            }],
3820        };
3821        let rid = register_runtime_fused("test::sigmoid_div", region).unwrap();
3822
3823        let a = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3824            .unwrap();
3825        let b = a
3826            .const_f32_like(vec![2.0, 2.0, 2.0, 2.0], Shape::from_dims(&[4]))
3827            .unwrap();
3828        let y = a.div(&b).sigmoid();
3829        let graph = y.graph().clone();
3830
3831        // No kernel for our op → it is gated out of fusion (reported) and its
3832        // region stays primitive — the miss is caught at match time, not after.
3833        let (rules, gated_out) = RuleRegistry::capability_gated_rules(|id| id != rid);
3834        assert!(
3835            gated_out.contains(&rid),
3836            "kernel-absent op is reported as a miss candidate"
3837        );
3838        let roots = rules.optimize_to_fixpoint(&graph, &[y.id()]);
3839        {
3840            let g = graph.read().unwrap();
3841            assert!(
3842                matches!(g.node(roots[0]).op, Op::Sigmoid),
3843                "kernel-absent runtime op stays primitive (Sigmoid sink), never fuses",
3844            );
3845        }
3846
3847        // With a kernel available, the same op fuses.
3848        let (rules2, _) = RuleRegistry::capability_gated_rules(|_| true);
3849        let roots2 = rules2.optimize_to_fixpoint(&graph, &[roots[0]]);
3850        let g = graph.read().unwrap();
3851        assert!(
3852            matches!(g.node(roots2[0]).op, Op::Fused(fid, _) if fid == rid),
3853            "with a kernel, the runtime op fuses",
3854        );
3855    }
3856
3857    #[test]
3858    fn cse_does_not_fold_distinct_ops() {
3859        // Add and Mul on the same inputs are not equivalent.
3860        let (graph, a) = make_scalar_graph();
3861        let sum = a.add(&a);
3862        let prod = a.mul(&a);
3863        let new_roots = optimize(&graph, &[sum.id(), prod.id()]);
3864        assert_ne!(new_roots[0], new_roots[1], "Add and Mul must stay distinct");
3865    }
3866
3867    #[test]
3868    fn cse_does_not_fold_distinct_scalars() {
3869        let (graph, a) = make_scalar_graph();
3870        let b = a.add_scalar(1.0);
3871        let c = a.add_scalar(2.0);
3872        let new_roots = optimize(&graph, &[b.id(), c.id()]);
3873        assert_ne!(
3874            new_roots[0], new_roots[1],
3875            "AddScalar with different c must stay distinct"
3876        );
3877    }
3878
3879    #[test]
3880    fn cse_nested_chain_deduplicates() {
3881        // (a + a) + a  appearing twice should dedupe both subexpressions.
3882        let (graph, a) = make_scalar_graph();
3883        let p1 = a.add(&a);
3884        let p2 = a.add(&a); // duplicates p1
3885        let q1 = p1.add(&a);
3886        let q2 = p2.add(&a); // structurally identical to q1 via CSE
3887        let new_roots = optimize(&graph, &[q1.id(), q2.id()]);
3888        assert_eq!(new_roots[0], new_roots[1], "nested duplicates must fold");
3889    }
3890
3891    // ---- derive_ordering + execution_plan -----------------------------------
3892
3893    #[test]
3894    fn derive_ordering_empty_for_non_destructive_graph() {
3895        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3896        let b = a
3897            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3898            .unwrap();
3899        let c = a.add(&b);
3900        let ord = derive_ordering(&c.graph().read().unwrap(), &[c.id()]);
3901        assert!(
3902            ord.is_empty(),
3903            "graph without destructive ops → no ordering edges"
3904        );
3905    }
3906
3907    #[test]
3908    fn derive_ordering_release_must_run_after_sibling_readers() {
3909        // Graph:
3910        //   a       (producer)
3911        //   b = relu(a)   (non-destructive reader of a)
3912        //   r = release(a) (destructive reader of a)
3913        // Expected ordering: r must run after b.
3914        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3915        let b = a.relu();
3916        let r = a.release();
3917        let ord = derive_ordering(&a.graph().read().unwrap(), &[b.id(), r.id()]);
3918        let deps = ord.deps_of(r.id());
3919        assert_eq!(
3920            deps.len(),
3921            1,
3922            "release should have one ordering dep (the relu)"
3923        );
3924        assert_eq!(deps[0], b.id(), "release must run after relu");
3925    }
3926
3927    #[test]
3928    fn derive_ordering_follows_view_chains_for_destructive_op() {
3929        // Phase 4a of in-place ops infrastructure: a destructive op
3930        // on `x` must run after any reader of any view-of-x, because
3931        // views share `x`'s Storage Arc at realize time. Without the
3932        // alias-set walk in derive_ordering, this case would not pin
3933        // `reader(view)` before `destructive(x)`.
3934        //
3935        // Graph:
3936        //   x       (producer; rank-2)
3937        //   v       = x.transpose()    (view of x)
3938        //   z       = v.relu()         (reader of v, transitively reads x's bytes)
3939        //   r       = x.release()      (destructive on x)
3940        // Expected: r must run after z (not just after v).
3941        let x = NodeHandle::from_f32(
3942            vec![1.0_f32, 2.0, 3.0, 4.0],
3943            Shape::from_dims(&[2, 2]),
3944            cpu_dev(),
3945        )
3946        .unwrap();
3947        let v = x.transpose();
3948        let z = v.relu();
3949        let r = x.release();
3950        let ord = derive_ordering(&x.graph().read().unwrap(), &[z.id(), r.id()]);
3951        let deps = ord.deps_of(r.id());
3952        assert!(
3953            deps.contains(&z.id()),
3954            "release(x) must be pinned after relu(transpose(x)) (view-aware alias set); got deps = {deps:?}",
3955        );
3956    }
3957
3958    #[test]
3959    fn derive_ordering_inplace_unary_through_view_chain() {
3960        // Specifically for the in-place ops Phase 4 use case:
3961        // `y = x.transpose(); z = y.relu(); x.relu_inplace()` —
3962        // ReluInplace must be pinned after the read through the
3963        // transpose view chain.
3964        let x = NodeHandle::from_f32(
3965            vec![-1.0_f32, 2.0, -3.0, 4.0],
3966            Shape::from_dims(&[2, 2]),
3967            cpu_dev(),
3968        )
3969        .unwrap();
3970        let y = x.transpose();
3971        let z = y.relu();
3972        // Capture x's shape + dtype + id BEFORE acquiring the write
3973        // lock — otherwise the args evaluate inside the locked region
3974        // and self-deadlock (RwLock doesn't grant a read lock to the
3975        // writer-holding thread).
3976        let x_shape = x.shape();
3977        let x_dtype = x.dtype();
3978        let x_id = x.id();
3979        let inplace_id = x.graph().write().unwrap().push(crate::Node {
3980            op: crate::Op::ReluInplace,
3981            inputs: vec![x_id],
3982            shape: x_shape,
3983            dtype: x_dtype,
3984        });
3985        let ord = derive_ordering(&x.graph().read().unwrap(), &[z.id(), inplace_id]);
3986        let deps = ord.deps_of(inplace_id);
3987        assert!(
3988            deps.contains(&z.id()),
3989            "ReluInplace(x) must be pinned after Relu(Transpose(x)) via view-aware alias set; got deps = {deps:?}",
3990        );
3991    }
3992
3993    // ---- Ordering-defect repros (MLA decode multi-round WriteSlice) ----
3994
3995    /// Control: a single-hop `Slice` reader of a `WriteSlice`
3996    /// destination is already correctly pinned before a SECOND
3997    /// `WriteSlice` that reuses the same destination —
3998    /// `collect_alias_set` finds the `Slice` via `is_view_op`, and its
3999    /// reader (`relu`) is not itself an alias member, so
4000    /// `derive_ordering` pins the second `WriteSlice` after it
4001    /// directly. Baseline for the Reshape-gap test below (confirms the
4002    /// gap is Reshape-specific, not a general multi-round WriteSlice
4003    /// failure).
4004    #[test]
4005    fn derive_ordering_write_slice_round2_plain_slice_reader_pinned() {
4006        let dest =
4007            NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
4008        let src1 = dest
4009            .const_f32_like(vec![1.0_f32, 2.0], Shape::from_dims(&[2]))
4010            .unwrap();
4011        let src2 = dest
4012            .const_f32_like(vec![9.0_f32, 8.0], Shape::from_dims(&[2]))
4013            .unwrap();
4014        let x = dest
4015            .write_slice(&src1, vec![(0, 2)])
4016            .expect("write_slice round 1");
4017        let s = x.slice(0, 0, 2);
4018        let k = s.relu();
4019        let y = x
4020            .write_slice(&src2, vec![(0, 2)])
4021            .expect("write_slice round 2");
4022        let ord = derive_ordering(&x.graph().read().unwrap(), &[k.id(), y.id()]);
4023        let deps = ord.deps_of(y.id());
4024        assert!(
4025            deps.contains(&k.id()),
4026            "round-2 write_slice must be pinned after the round-1 slice's \
4027             reader; got deps = {deps:?}",
4028        );
4029    }
4030
4031    /// CRITICAL SUSPECT #2 (MLA decode multi-round `WriteSlice`
4032    /// ordering defect): same shape as the control above, but the
4033    /// `Slice`'s reader is behind an `Op::Reshape` hop first.
4034    /// `Op::Reshape` is NOT in `collect_alias_set`'s alias-extending
4035    /// predicate (only `Op::is_view_op` + `Op::View`) even though its
4036    /// `WorkItemKind::ContiguizeOf` execution zero-copy ADOPTS the
4037    /// input's Storage Arc whenever the input is contiguous at offset
4038    /// 0 (`fuel-dispatch/src/pipelined.rs`'s `ContiguizeOf` arm) — so
4039    /// the alias walk stops at the `Slice`, and the Reshape's own
4040    /// consumer (`relu`) is never scanned for pinning against the
4041    /// round-2 `WriteSlice`. Born-red: fails before the fix (Reshape
4042    /// missing from the alias-extending set), green after.
4043    #[test]
4044    fn derive_ordering_write_slice_round2_reshape_reader_pinned() {
4045        let dest =
4046            NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
4047        let src1 = dest
4048            .const_f32_like(vec![1.0_f32, 2.0], Shape::from_dims(&[2]))
4049            .unwrap();
4050        let src2 = dest
4051            .const_f32_like(vec![9.0_f32, 8.0], Shape::from_dims(&[2]))
4052            .unwrap();
4053        let x = dest
4054            .write_slice(&src1, vec![(0, 2)])
4055            .expect("write_slice round 1");
4056        let s = x.slice(0, 0, 2);
4057        let r = s.reshape(Shape::from_dims(&[2]));
4058        let k = r.relu();
4059        let y = x
4060            .write_slice(&src2, vec![(0, 2)])
4061            .expect("write_slice round 2");
4062        let ord = derive_ordering(&x.graph().read().unwrap(), &[k.id(), y.id()]);
4063        let deps = ord.deps_of(y.id());
4064        assert!(
4065            deps.contains(&k.id()),
4066            "round-2 write_slice must be pinned after the reshape-adopted-arc \
4067             reader (CRITICAL SUSPECT #2 gap — Reshape excluded from \
4068             collect_alias_set's alias-extending predicate); got deps = {deps:?}",
4069        );
4070    }
4071
4072    // ---- Phase 5: insert_safety_copies ----
4073
4074    #[test]
4075    fn insert_safety_copies_residual_connection_breaks_cycle() {
4076        // Canonical residual pattern: `y = x.relu_inplace(); z = y + x`.
4077        // Before Phase 5: derive_ordering pins ReluInplace after Add
4078        // (Add reads x), but data flow pins Add after ReluInplace (Add
4079        // reads y, y depends on ReluInplace) → cycle. After Phase 5:
4080        // a Copy(x) → x_safe is inserted and Add's x input rewires to
4081        // x_safe.
4082        let x = NodeHandle::from_f32(
4083            vec![1.0_f32, -2.0, 3.0, -4.0],
4084            Shape::from_dims(&[4]),
4085            cpu_dev(),
4086        )
4087        .unwrap();
4088        let x_shape = x.shape();
4089        let x_dtype = x.dtype();
4090        let x_id = x.id();
4091
4092        // Manually build: y = ReluInplace(x); z = Add(y, x).
4093        let (y_id, z_id) = {
4094            let mut g = x.graph().write().unwrap();
4095            let y_id = g.push(crate::Node {
4096                op: crate::Op::ReluInplace,
4097                inputs: vec![x_id],
4098                shape: x_shape.clone(),
4099                dtype: x_dtype,
4100            });
4101            let z_id = g.push(crate::Node {
4102                op: crate::Op::Add,
4103                inputs: vec![y_id, x_id],
4104                shape: x_shape,
4105                dtype: x_dtype,
4106            });
4107            (y_id, z_id)
4108        };
4109
4110        // Before the pass: cycle would exist. Run insert_safety_copies.
4111        let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4112        assert_eq!(inserted, 1, "should insert exactly one safety copy");
4113
4114        // Verify Add's inputs were rewired: still [y_id, <something>],
4115        // but the second input is NOT x_id anymore — it's the new
4116        // Op::Copy node.
4117        let copy_id = {
4118            let g = x.graph().read().unwrap();
4119            let z_node = g.node(z_id);
4120            assert_eq!(z_node.inputs.len(), 2, "Add still has 2 inputs");
4121            assert_eq!(z_node.inputs[0], y_id, "Add's y input unchanged");
4122            assert_ne!(z_node.inputs[1], x_id, "Add's x input was rewired");
4123            let copy_node_id = z_node.inputs[1];
4124            let copy_node = g.node(copy_node_id);
4125            assert!(
4126                matches!(copy_node.op, crate::Op::Copy { .. }),
4127                "rewired input is Op::Copy; got {:?}",
4128                copy_node.op
4129            );
4130            assert_eq!(copy_node.inputs, vec![x_id], "Op::Copy reads x");
4131            copy_node_id
4132        };
4133
4134        // Verify execution_plan succeeds (no cycle).
4135        let plan = execution_plan(&x.graph().read().unwrap(), &[z_id]);
4136        // Plan must contain x, the copy, ReluInplace, and Add.
4137        // The copy must come before ReluInplace (it's a reader of x).
4138        let pos: HashMap<NodeId, usize> = plan.iter().enumerate().map(|(i, n)| (*n, i)).collect();
4139        let copy_pos = pos[&copy_id];
4140        let inplace_pos = pos[&y_id];
4141        assert!(
4142            copy_pos < inplace_pos,
4143            "Copy must run before ReluInplace; copy={copy_pos} inplace={inplace_pos}"
4144        );
4145    }
4146
4147    /// CapturedRun executor build-out (4b-ζ follow-up): when a graph has
4148    /// `target_backend` stamped (ordinary single-device `optimize_graph`
4149    /// output — the common case) but no explicit `placement` (a rarely-
4150    /// used pin), the inserted safety copy's `target_location` must be
4151    /// derived from `target_backend`, NOT default straight to `Cpu`. A
4152    /// same-device CUDA safety snapshot silently landing on `Cpu` strands
4153    /// its (unrelated) reader off-device — and, in a CUDA decode graph
4154    /// bound for `capture_decode`, turns an ordinary same-device
4155    /// snapshot into a genuinely-cross-device D2H that capture must
4156    /// reject (capture only ever accepts a same-device CUDA `Op::Copy`).
4157    /// Born-red without the `target_backend` fallback (old code:
4158    /// `.unwrap_or(DeviceLocation::Cpu)` fires immediately since
4159    /// `placement` is never populated here); green with it.
4160    #[test]
4161    fn insert_safety_copies_derives_target_location_from_target_backend_when_unplaced() {
4162        // Same residual shape as `insert_safety_copies_residual_connection_breaks_cycle`
4163        // (`y = x.relu_inplace(); z = y + x`), but every node is stamped
4164        // `target_backend = Cuda` (the ordinary optimize_graph output for a
4165        // single-device CUDA decode graph) and NO node has an explicit
4166        // `placement` — the realistic shape of a real Llama decode graph.
4167        let x = NodeHandle::from_f32(
4168            vec![1.0_f32, -2.0, 3.0, -4.0],
4169            Shape::from_dims(&[4]),
4170            cpu_dev(),
4171        )
4172        .unwrap();
4173        let x_shape = x.shape();
4174        let x_dtype = x.dtype();
4175        let x_id = x.id();
4176
4177        let (_y_id, z_id) = {
4178            let mut g = x.graph().write().unwrap();
4179            let y_id = g.push(crate::Node {
4180                op: crate::Op::ReluInplace,
4181                inputs: vec![x_id],
4182                shape: x_shape.clone(),
4183                dtype: x_dtype,
4184            });
4185            let z_id = g.push(crate::Node {
4186                op: crate::Op::Add,
4187                inputs: vec![y_id, x_id],
4188                shape: x_shape,
4189                dtype: x_dtype,
4190            });
4191            g.set_target_backend(y_id, fuel_ir::probe::BackendId::Cuda);
4192            g.set_target_backend(z_id, fuel_ir::probe::BackendId::Cuda);
4193            (y_id, z_id)
4194        };
4195        assert!(
4196            x.graph().read().unwrap().placement(x_id).is_none(),
4197            "precondition: x has no explicit placement",
4198        );
4199
4200        let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4201        assert_eq!(inserted, 1, "should insert exactly one safety copy");
4202
4203        let g = x.graph().read().unwrap();
4204        let z_node = g.node(z_id);
4205        let copy_id = z_node.inputs[1];
4206        let copy_node = g.node(copy_id);
4207        assert_eq!(
4208            copy_node.op,
4209            crate::Op::Copy {
4210                target: DeviceLocation::Cuda { gpu_id: 0 }
4211            },
4212            "same-device CUDA safety copy must target Cuda (derived from \
4213             target_backend), NOT fall back to Cpu when placement is unset",
4214        );
4215    }
4216
4217    #[test]
4218    fn insert_safety_copies_noop_when_no_destructive_conflicts() {
4219        // Pure functional graph — no in-place ops. The pass should
4220        // insert zero copies.
4221        let x = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4222        let y = x.relu();
4223        let z = y.sum_all();
4224        let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z.id()]);
4225        assert_eq!(inserted, 0);
4226    }
4227
4228    #[test]
4229    fn insert_safety_copies_noop_when_inplace_target_only_consumed_via_alias_path() {
4230        // `y = x.relu_inplace(); loss = y.sum_all()` — the only
4231        // downstream consumer of x reads it through y (the in-place
4232        // node), not x directly. derive_ordering pins ReluInplace
4233        // after... only ReluInplace itself (no other readers of x).
4234        // No conflict, no copy needed.
4235        let x =
4236            NodeHandle::from_f32(vec![1.0_f32, -1.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4237        let x_shape = x.shape();
4238        let x_dtype = x.dtype();
4239        let x_id = x.id();
4240        let y_id = x.graph().write().unwrap().push(crate::Node {
4241            op: crate::Op::ReluInplace,
4242            inputs: vec![x_id],
4243            shape: x_shape,
4244            dtype: x_dtype,
4245        });
4246        // Build a chain that only reads y (not x).
4247        let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[y_id]);
4248        assert_eq!(inserted, 0);
4249    }
4250
4251    /// Regression for the 2026-06-11 live-GPU failure: a reader with
4252    /// NO dependency relationship to a destructive `Op::Move` must
4253    /// not get a safety copy just because DFS visit order happened to
4254    /// place it after the Move.
4255    ///
4256    /// Graph: `a; b = relu(a); m = move(a)`. derive_ordering pins
4257    /// `m` after `b` (reader-before-mutator), so every execution_plan
4258    /// runs `b` before `m`'s release — no copy is needed in EITHER
4259    /// roots order. The old positional predicate inserted a spurious
4260    /// copy when roots ordering made `b` land after `m` in
4261    /// `topo_order_multi` (which visits the LAST root's subtree
4262    /// first), and that copy then inherited the Move's device.
4263    #[test]
4264    fn insert_safety_copies_no_spurious_copy_for_independent_reader_of_move() {
4265        for flip_roots in [false, true] {
4266            let a = NodeHandle::from_f32(vec![1.0_f32, -2.0], Shape::from_dims(&[2]), cpu_dev())
4267                .unwrap();
4268            let b = a.relu();
4269            let a_shape = a.shape();
4270            let a_dtype = a.dtype();
4271            let a_id = a.id();
4272            let m_id = a.graph().write().unwrap().push(crate::Node {
4273                op: crate::Op::Move {
4274                    target: DeviceLocation::Cpu,
4275                },
4276                inputs: vec![a_id],
4277                shape: a_shape,
4278                dtype: a_dtype,
4279            });
4280            let roots = if flip_roots {
4281                [m_id, b.id()]
4282            } else {
4283                [b.id(), m_id]
4284            };
4285            // Sanity: in the `[b, m]` roots order, DFS visits m's
4286            // subtree first, so b lands positionally AFTER the Move —
4287            // the exact shape the old predicate misclassified.
4288            let inserted = insert_safety_copies(&mut a.graph().write().unwrap(), &roots);
4289            assert_eq!(
4290                inserted, 0,
4291                "relu(a) is pinned before move(a) by derive_ordering; \
4292                 no copy regardless of roots order (flip_roots={flip_roots})",
4293            );
4294            let g = a.graph().read().unwrap();
4295            assert_eq!(
4296                g.node(b.id()).inputs,
4297                vec![a_id],
4298                "reader must keep reading `a` directly",
4299            );
4300            drop(g);
4301            // The plan must still order the reader before the Move.
4302            let plan = execution_plan(&a.graph().read().unwrap(), &roots);
4303            let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4304            let m_pos = plan.iter().position(|&n| n == m_id).unwrap();
4305            assert!(b_pos < m_pos, "relu must precede move: {plan:?}");
4306        }
4307    }
4308
4309    /// A reader that TRANSITIVELY depends on the destructive op (not
4310    /// just via a direct input) still conflicts: the pin edge would
4311    /// close a cycle through the intermediate node. The
4312    /// dependency-based predicate must use reachability, not direct
4313    /// input inspection.
4314    ///
4315    /// Graph: `y = relu_inplace(x); w = relu(y); z = add(w, x)`.
4316    #[test]
4317    fn insert_safety_copies_transitive_descendant_reader_gets_copy() {
4318        let x = NodeHandle::from_f32(
4319            vec![1.0_f32, -2.0, 3.0, -4.0],
4320            Shape::from_dims(&[4]),
4321            cpu_dev(),
4322        )
4323        .unwrap();
4324        let x_shape = x.shape();
4325        let x_dtype = x.dtype();
4326        let x_id = x.id();
4327        let (y_id, z_id) = {
4328            let mut g = x.graph().write().unwrap();
4329            let y_id = g.push(crate::Node {
4330                op: crate::Op::ReluInplace,
4331                inputs: vec![x_id],
4332                shape: x_shape.clone(),
4333                dtype: x_dtype,
4334            });
4335            let w_id = g.push(crate::Node {
4336                op: crate::Op::Relu,
4337                inputs: vec![y_id],
4338                shape: x_shape.clone(),
4339                dtype: x_dtype,
4340            });
4341            let z_id = g.push(crate::Node {
4342                op: crate::Op::Add,
4343                inputs: vec![w_id, x_id],
4344                shape: x_shape,
4345                dtype: x_dtype,
4346            });
4347            (y_id, z_id)
4348        };
4349        let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4350        assert_eq!(inserted, 1, "transitive residual cycle needs one copy");
4351        let g = x.graph().read().unwrap();
4352        let z_node = g.node(z_id);
4353        assert_ne!(z_node.inputs[1], x_id, "z's x input must be rewired");
4354        assert!(matches!(
4355            g.node(z_node.inputs[1]).op,
4356            crate::Op::Copy { .. }
4357        ));
4358        drop(g);
4359        let plan = execution_plan(&x.graph().read().unwrap(), &[z_id]);
4360        assert!(plan.contains(&y_id), "plan still contains the in-place op");
4361    }
4362
4363    /// A genuinely-parallel reader — no ordering guarantee in either
4364    /// direction — keeps its safety copy (conservative default).
4365    ///
4366    /// The only readers derive_ordering does NOT pin before the
4367    /// mutator are alias-set members (view ops of the target). A view
4368    /// with consumers is transitively forced before the mutator via
4369    /// its pinned consumers; a view that is itself a realize root has
4370    /// no ordering guarantee at all. Safety cannot be proven, so the
4371    /// pass must copy. Note the old positional predicate got this
4372    /// case WRONG in the `[m, v]` roots order (v lands positionally
4373    /// before m → no copy; only the plan's stable tiebreak saved it).
4374    #[test]
4375    fn insert_safety_copies_parallel_view_root_reader_keeps_copy() {
4376        let x = NodeHandle::from_f32(
4377            vec![1.0_f32, 2.0, 3.0, 4.0],
4378            Shape::from_dims(&[2, 2]),
4379            cpu_dev(),
4380        )
4381        .unwrap();
4382        let v = x.transpose();
4383        let x_shape = x.shape();
4384        let x_dtype = x.dtype();
4385        let x_id = x.id();
4386        let m_id = x.graph().write().unwrap().push(crate::Node {
4387            op: crate::Op::Move {
4388                target: DeviceLocation::Cpu,
4389            },
4390            inputs: vec![x_id],
4391            shape: x_shape,
4392            dtype: x_dtype,
4393        });
4394        // Roots [m, v]: DFS visits v's subtree first, so v sits
4395        // positionally BEFORE the Move — the shape the positional
4396        // predicate silently skipped.
4397        let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[m_id, v.id()]);
4398        assert_eq!(
4399            inserted, 1,
4400            "unpinned view-root reader has no ordering guarantee → copy",
4401        );
4402        let g = x.graph().read().unwrap();
4403        let v_node = g.node(v.id());
4404        assert_ne!(
4405            v_node.inputs[0], x_id,
4406            "view must be rewired to the snapshot"
4407        );
4408        assert!(matches!(
4409            g.node(v_node.inputs[0]).op,
4410            crate::Op::Copy { .. }
4411        ));
4412    }
4413
4414    /// Cycles through ANOTHER destructive op's ordering edge are
4415    /// still conflicts — the predicate must walk the combined
4416    /// (data + ordering) graph, not just data edges.
4417    ///
4418    /// Graph:
4419    ///   m1 = move(t1); m2 = move(t2)
4420    ///   x  = add(m1, t2)   (pin x→m2; data m1→x)
4421    ///   r  = add(m2, t1)   (pin r→m1; data m2→r)
4422    /// Combined cycle: m1 → x → m2 → r → m1. Neither x nor r is a
4423    /// pure-data descendant of the op whose target it reads, yet
4424    /// both pins are unsatisfiable. Both need copies; afterwards
4425    /// execution_plan must succeed.
4426    #[test]
4427    fn insert_safety_copies_breaks_cycle_through_other_ordering_edges() {
4428        let t1 =
4429            NodeHandle::from_f32(vec![1.0_f32, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4430        let t2 = t1
4431            .const_f32_like(vec![3.0_f32, 4.0], Shape::from_dims(&[2]))
4432            .unwrap();
4433        let shape = t1.shape();
4434        let dtype = t1.dtype();
4435        let (t1_id, t2_id) = (t1.id(), t2.id());
4436        let (x_id, r_id) = {
4437            let mut g = t1.graph().write().unwrap();
4438            let m1 = g.push(crate::Node {
4439                op: crate::Op::Move {
4440                    target: DeviceLocation::Cpu,
4441                },
4442                inputs: vec![t1_id],
4443                shape: shape.clone(),
4444                dtype,
4445            });
4446            let m2 = g.push(crate::Node {
4447                op: crate::Op::Move {
4448                    target: DeviceLocation::Cpu,
4449                },
4450                inputs: vec![t2_id],
4451                shape: shape.clone(),
4452                dtype,
4453            });
4454            let x = g.push(crate::Node {
4455                op: crate::Op::Add,
4456                inputs: vec![m1, t2_id],
4457                shape: shape.clone(),
4458                dtype,
4459            });
4460            let r = g.push(crate::Node {
4461                op: crate::Op::Add,
4462                inputs: vec![m2, t1_id],
4463                shape,
4464                dtype,
4465            });
4466            (x, r)
4467        };
4468        let inserted = insert_safety_copies(&mut t1.graph().write().unwrap(), &[x_id, r_id]);
4469        assert_eq!(inserted, 2, "both cross-readers sit on the combined cycle");
4470        // The rewritten graph must be plannable (no cycle panic).
4471        let plan = execution_plan(&t1.graph().read().unwrap(), &[x_id, r_id]);
4472        assert!(plan.contains(&x_id) && plan.contains(&r_id));
4473    }
4474
4475    /// Idempotence under the dependency-based predicate: a second run
4476    /// sees the inserted Copy as a pinned, acyclic reader of the
4477    /// target and inserts nothing new.
4478    #[test]
4479    fn insert_safety_copies_idempotent_after_rewrite() {
4480        let x =
4481            NodeHandle::from_f32(vec![1.0_f32, -2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4482        let x_shape = x.shape();
4483        let x_dtype = x.dtype();
4484        let x_id = x.id();
4485        let z_id = {
4486            let mut g = x.graph().write().unwrap();
4487            let y_id = g.push(crate::Node {
4488                op: crate::Op::ReluInplace,
4489                inputs: vec![x_id],
4490                shape: x_shape.clone(),
4491                dtype: x_dtype,
4492            });
4493            g.push(crate::Node {
4494                op: crate::Op::Add,
4495                inputs: vec![y_id, x_id],
4496                shape: x_shape,
4497                dtype: x_dtype,
4498            })
4499        };
4500        let first = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4501        assert_eq!(first, 1);
4502        let second = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4503        assert_eq!(second, 0, "second run must be a no-op");
4504    }
4505
4506    /// Performance regression: a destructive op whose target has NO other
4507    /// reader is the COMMON case in a real decode graph (KV-cache writes,
4508    /// release markers). Two INDEPENDENT quadratic costs used to fire on
4509    /// every one of the `N` destructive ops regardless: (1)
4510    /// `insert_safety_copies`' own pair of `reach()` graph walks plus an
4511    /// `order.len()`-sized linear readers-scan, and (2) `derive_ordering`'s
4512    /// `collect_alias_set` (now `alias_groups`), which re-walked the ENTIRE
4513    /// `order` per destructive op to find alias members. `N` independent
4514    /// 2-node chains (a `Const` leaf read ONLY by its own `ReluInplace`)
4515    /// make BOTH scale as `N * order.len()` under the old code. Measured on
4516    /// this box: unpatched (both costs present), `N=4000` took 18.2s;
4517    /// patched (both fixed — the `succ`-map pre-check here, plus
4518    /// `alias_groups`' single O(order.len()) pass shared across every
4519    /// destructive op instead of one pass each), `N=4000` takes ~62ms —
4520    /// a ~294x improvement. The bound below is generous in the PATCHED
4521    /// direction (keeps the test fast and non-flaky under shared-box
4522    /// contention) while still being far below what either quadratic old
4523    /// path could ever hit at this `N` — born red reverting either fix.
4524    #[test]
4525    fn insert_safety_copies_skips_reach_when_target_has_no_other_reader() {
4526        const N: usize = 4000;
4527        let shape = Shape::from_dims(&[4]);
4528        let mut g = crate::Graph::new();
4529        let mut roots: Vec<NodeId> = Vec::with_capacity(N);
4530        for _ in 0..N {
4531            let leaf = g.push(crate::Node {
4532                op: crate::Op::Const,
4533                inputs: vec![],
4534                shape: shape.clone(),
4535                dtype: DType::F32,
4536            });
4537            let relu = g.push(crate::Node {
4538                op: crate::Op::ReluInplace,
4539                inputs: vec![leaf],
4540                shape: shape.clone(),
4541                dtype: DType::F32,
4542            });
4543            roots.push(relu);
4544        }
4545
4546        let start = std::time::Instant::now();
4547        let inserted = insert_safety_copies(&mut g, &roots);
4548        let elapsed = start.elapsed();
4549
4550        assert_eq!(
4551            inserted, 0,
4552            "no leaf has a reader besides its own ReluInplace; nothing to copy"
4553        );
4554        assert!(
4555            elapsed < std::time::Duration::from_millis(500),
4556            "insert_safety_copies({N} independent no-reader chains) took {elapsed:?} — \
4557             the no-other-reader pre-check should make this O(N), not O(N^2)"
4558        );
4559    }
4560
4561    // ===== Differential safety-analysis harness (docs/design/incremental-consumer-index.md
4562    //       §4 step 1) =====
4563    //
4564    // Step 1 of the staged redesign plan: build the comparison machinery and a
4565    // corpus of representative graphs BEFORE any new incremental-index
4566    // production code exists (that's step 2). The "new" side below calls the
4567    // SAME old pass on an INDEPENDENTLY-constructed graph instance (not a
4568    // clone of the old one -- `Graph` deliberately isn't `Clone`, and this
4569    // harness doesn't need it to be: it never touches production code to get
4570    // there). That makes today's differential assertion validate the thing
4571    // step 1 actually needs validated — the comparison logic itself, and that
4572    // every corpus factory is deterministic (same NodeId assignments on every
4573    // call, since NodeId is push-order, not globally counted) — ready for
4574    // step 2 to swap `run_new_safety_analysis` for the real incremental index
4575    // without touching this file again.
4576    //
4577    // NOT done here (tracked, not silently skipped): the design doc's step 1
4578    // also calls for the live decode harness's ACTUAL graph in the corpus.
4579    // That needs a graph-capture hook in the live CUDA harness; out of scope
4580    // for this PR, which is corpus-of-unit-test-shapes + harness plumbing
4581    // only. Also not done: porting ALL nine existing insert_safety_copies/
4582    // derive_ordering tests into the corpus (design doc open item #2) — five
4583    // representative shapes are ported below; full porting is tracked for
4584    // the step-4 PR (view/alias resolution), where open item #2 is due.
4585
4586    /// One differential-corpus entry: builds a FRESH graph + its realize
4587    /// roots. Called twice per comparison (once per side) rather than
4588    /// cloned, so the harness never needs `Graph: Clone`.
4589    type SafetyCorpusFactory = fn() -> (SharedGraph, Vec<NodeId>);
4590
4591    /// A snapshotted, comparable view of "what insert_safety_copies (which
4592    /// internally calls derive_ordering) did to this graph" — the
4593    /// observable contract the differential test checks. `nodes` compares
4594    /// the WHOLE resulting graph structurally (op + inputs, Debug-formatted
4595    /// since `Op` has no blanket `PartialEq`) rather than just the inserted
4596    /// count, so a divergence that inserts the SAME NUMBER of copies in a
4597    /// DIFFERENT place is still caught.
4598    #[derive(Debug, PartialEq)]
4599    struct SafetyAnalysisResult {
4600        inserted: usize,
4601        node_count: usize,
4602        nodes: Vec<(String, Vec<NodeId>)>,
4603    }
4604
4605    fn run_old_safety_analysis(graph: &SharedGraph, roots: &[NodeId]) -> SafetyAnalysisResult {
4606        let inserted = insert_safety_copies(&mut graph.write().unwrap(), roots);
4607        let g = graph.read().unwrap();
4608        let nodes = (0..g.len())
4609            .map(|i| {
4610                let n = g.node(NodeId(i));
4611                (format!("{:?}", n.op), n.inputs.clone())
4612            })
4613            .collect();
4614        SafetyAnalysisResult {
4615            inserted,
4616            node_count: g.len(),
4617            nodes,
4618        }
4619    }
4620
4621    /// STEP 1 PLACEHOLDER — see the module note above. Step 2 (`consumers`
4622    /// side-table, push-only) replaces this body with the real incremental
4623    /// logic; nothing else in this harness needs to change when it does.
4624    fn run_new_safety_analysis(graph: &SharedGraph, roots: &[NodeId]) -> SafetyAnalysisResult {
4625        run_old_safety_analysis(graph, roots)
4626    }
4627
4628    fn assert_safety_analysis_matches(name: &str, factory: SafetyCorpusFactory) {
4629        let (old_graph, old_roots) = factory();
4630        let (new_graph, new_roots) = factory();
4631        assert_eq!(
4632            old_roots, new_roots,
4633            "{name}: factory produced different roots across two calls -- corpus factory \
4634             must be deterministic (same pushes, same order) or this harness can't compare"
4635        );
4636        let old = run_old_safety_analysis(&old_graph, &old_roots);
4637        let new = run_new_safety_analysis(&new_graph, &new_roots);
4638        assert_eq!(old, new, "{name}: old vs new safety analysis diverged");
4639    }
4640
4641    fn corpus_no_destructive_conflicts() -> (SharedGraph, Vec<NodeId>) {
4642        // a + b, no in-place op anywhere -- insert_safety_copies must be a
4643        // pure no-op (mirrors insert_safety_copies_noop_when_no_destructive_conflicts).
4644        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4645        let b = a
4646            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
4647            .unwrap();
4648        let c = a.add(&b);
4649        (c.graph().clone(), vec![c.id()])
4650    }
4651
4652    fn corpus_residual_connection() -> (SharedGraph, Vec<NodeId>) {
4653        // y = x.relu_inplace(); z = y + x -- the canonical cycle-breaking
4654        // case (mirrors insert_safety_copies_residual_connection_breaks_cycle).
4655        let x = NodeHandle::from_f32(
4656            vec![1.0_f32, -2.0, 3.0, -4.0],
4657            Shape::from_dims(&[4]),
4658            cpu_dev(),
4659        )
4660        .unwrap();
4661        let x_shape = x.shape();
4662        let x_dtype = x.dtype();
4663        let x_id = x.id();
4664        let z_id = {
4665            let mut g = x.graph().write().unwrap();
4666            let y_id = g.push(crate::Node {
4667                op: crate::Op::ReluInplace,
4668                inputs: vec![x_id],
4669                shape: x_shape.clone(),
4670                dtype: x_dtype,
4671            });
4672            g.push(crate::Node {
4673                op: crate::Op::Add,
4674                inputs: vec![y_id, x_id],
4675                shape: x_shape,
4676                dtype: x_dtype,
4677            })
4678        };
4679        (x.graph().clone(), vec![z_id])
4680    }
4681
4682    fn corpus_independent_reader_of_move_needs_no_copy() -> (SharedGraph, Vec<NodeId>) {
4683        // relu(a) pinned before move(a); an unrelated reader needs no copy
4684        // (mirrors insert_safety_copies_no_spurious_copy_for_independent_reader_of_move,
4685        // flip_roots=false arm: roots=[b, m], DFS visits m's subtree first
4686        // so b lands positionally after the Move).
4687        let a =
4688            NodeHandle::from_f32(vec![1.0_f32, -2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4689        let b = a.relu();
4690        let a_shape = a.shape();
4691        let a_dtype = a.dtype();
4692        let a_id = a.id();
4693        let m_id = a.graph().write().unwrap().push(crate::Node {
4694            op: crate::Op::Move {
4695                target: DeviceLocation::Cpu,
4696            },
4697            inputs: vec![a_id],
4698            shape: a_shape,
4699            dtype: a_dtype,
4700        });
4701        (a.graph().clone(), vec![b.id(), m_id])
4702    }
4703
4704    fn corpus_many_independent_no_reader_chains() -> (SharedGraph, Vec<NodeId>) {
4705        // A small-N version of the perf regression shape above: many
4706        // independent 2-node chains, none with an extra reader. Exercises
4707        // the common case (most destructive ops in a real decode graph have
4708        // no other reader at all) at a size small enough that the
4709        // differential comparison's O(graph) structural equality check
4710        // stays cheap in a correctness-focused test, not a perf one.
4711        const N: usize = 25;
4712        let shape = Shape::from_dims(&[4]);
4713        let mut g = crate::Graph::new();
4714        let mut roots: Vec<NodeId> = Vec::with_capacity(N);
4715        for _ in 0..N {
4716            let leaf = g.push(crate::Node {
4717                op: crate::Op::Const,
4718                inputs: vec![],
4719                shape: shape.clone(),
4720                dtype: DType::F32,
4721            });
4722            let relu = g.push(crate::Node {
4723                op: crate::Op::ReluInplace,
4724                inputs: vec![leaf],
4725                shape: shape.clone(),
4726                dtype: DType::F32,
4727            });
4728            roots.push(relu);
4729        }
4730        (Arc::new(std::sync::RwLock::new(g)), roots)
4731    }
4732
4733    const SAFETY_CORPUS: &[(&str, SafetyCorpusFactory)] = &[
4734        ("no_destructive_conflicts", corpus_no_destructive_conflicts),
4735        ("residual_connection", corpus_residual_connection),
4736        (
4737            "independent_reader_of_move_needs_no_copy",
4738            corpus_independent_reader_of_move_needs_no_copy,
4739        ),
4740        (
4741            "many_independent_no_reader_chains",
4742            corpus_many_independent_no_reader_chains,
4743        ),
4744    ];
4745
4746    #[test]
4747    fn differential_safety_analysis_matches_on_corpus() {
4748        for &(name, factory) in SAFETY_CORPUS {
4749            assert_safety_analysis_matches(name, factory);
4750        }
4751    }
4752
4753    #[test]
4754    fn derive_ordering_release_of_multi_reader_input() {
4755        // a read by relu AND neg, then released. Both relu and neg
4756        // must precede the release.
4757        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4758        let b = a.relu();
4759        let c = a.neg();
4760        let r = a.release();
4761        let ord = derive_ordering(&a.graph().read().unwrap(), &[b.id(), c.id(), r.id()]);
4762        let mut deps = ord.deps_of(r.id()).to_vec();
4763        deps.sort_by_key(|n| n.0);
4764        let mut expected = vec![b.id(), c.id()];
4765        expected.sort_by_key(|n| n.0);
4766        assert_eq!(deps, expected);
4767    }
4768
4769    #[test]
4770    fn execution_plan_matches_topo_when_no_destructive_ops() {
4771        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4772        let b = a
4773            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
4774            .unwrap();
4775        let c = a.add(&b);
4776        let graph = c.graph().read().unwrap();
4777        let plan = execution_plan(&graph, &[c.id()]);
4778        let topo = topo_order_multi(&graph, &[c.id()]);
4779        assert_eq!(plan, topo);
4780    }
4781
4782    #[test]
4783    fn execution_plan_pins_release_after_sibling_reader() {
4784        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4785        let b = a.relu();
4786        let r = a.release();
4787        let plan = execution_plan(&a.graph().read().unwrap(), &[b.id(), r.id()]);
4788        let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4789        let r_pos = plan.iter().position(|&n| n == r.id()).unwrap();
4790        assert!(
4791            b_pos < r_pos,
4792            "expected relu@{b_pos} to precede release@{r_pos}: {plan:?}"
4793        );
4794    }
4795
4796    #[test]
4797    fn execution_plan_handles_chain_of_destructive_ops() {
4798        // a -> relu -> b
4799        // a -> neg -> c
4800        // a -> release
4801        // b and c must come before release.
4802        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4803        let b = a.relu();
4804        let c = a.neg();
4805        let r = a.release();
4806        let plan = execution_plan(&a.graph().read().unwrap(), &[b.id(), c.id(), r.id()]);
4807        let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4808        let c_pos = plan.iter().position(|&n| n == c.id()).unwrap();
4809        let r_pos = plan.iter().position(|&n| n == r.id()).unwrap();
4810        assert!(
4811            b_pos < r_pos,
4812            "relu@{b_pos} must precede release@{r_pos}: {plan:?}"
4813        );
4814        assert!(
4815            c_pos < r_pos,
4816            "neg@{c_pos} must precede release@{r_pos}: {plan:?}"
4817        );
4818    }
4819
4820    #[test]
4821    fn insert_evict_reload_creates_expected_chain() {
4822        // Graph:
4823        //   a         (producer, device=Cpu default)
4824        //   b = relu(a)   (pre-gap consumer — stays wired to a)
4825        //   c = neg(a)    (post-gap consumer — should be rewired to reload)
4826        // After insert_evict_reload(a, Cpu, &[c]):
4827        //   - 2 new nodes (move, reload) appended
4828        //   - c's input list now has `reload_id` instead of `a.id()`
4829        //   - b's input list STILL has `a.id()` (unchanged)
4830        let a =
4831            NodeHandle::from_f32(vec![1.0_f32, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4832        let b = a.relu();
4833        let c = a.neg();
4834
4835        let (move_id, reload_id) =
4836            insert_evict_reload(a.graph(), a.id(), DeviceLocation::Cpu, &[c.id()]);
4837
4838        let g = a.graph().read().unwrap();
4839        // mv is an Op::Move{Cpu} reading a — the fused stage-to-host +
4840        // destructive release of a's device storage.
4841        match &g.node(move_id).op {
4842            Op::Move {
4843                target: DeviceLocation::Cpu,
4844            } => {}
4845            other => panic!("expected Op::Move{{Cpu}}, got {other:?}"),
4846        }
4847        assert_eq!(g.node(move_id).inputs, vec![a.id()]);
4848        assert_eq!(
4849            g.node(move_id).op.destructive_input(),
4850            Some(0),
4851            "Move must destroy its source so the device storage frees",
4852        );
4853        // reload is an Op::Copy{Cpu} (src_device passed in) reading mv
4854        assert!(matches!(g.node(reload_id).op, Op::Copy { .. }));
4855        assert_eq!(g.node(reload_id).inputs, vec![move_id]);
4856        // c's input was rewired
4857        assert_eq!(
4858            g.node(c.id()).inputs,
4859            vec![reload_id],
4860            "post-gap consumer should read from reload, not candidate directly"
4861        );
4862        // b's input stays
4863        assert_eq!(
4864            g.node(b.id()).inputs,
4865            vec![a.id()],
4866            "pre-gap consumer should still read candidate directly"
4867        );
4868    }
4869
4870    #[test]
4871    fn execution_plan_respects_transitive_data_deps_after_release() {
4872        // Graph:
4873        //   a
4874        //   b = relu(a)
4875        //   r = release(a)  (destructive; runs after b)
4876        //   sum = sum_all(b) (data-dependent on b, not on r)
4877        // Plan must have: b before r, b before sum; b before both is enough.
4878        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4879        let b = a.relu();
4880        let sum = b.sum_all();
4881        let r = a.release();
4882        let plan = execution_plan(&a.graph().read().unwrap(), &[sum.id(), r.id()]);
4883        let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4884        let sum_pos = plan.iter().position(|&n| n == sum.id()).unwrap();
4885        let r_pos = plan.iter().position(|&n| n == r.id()).unwrap();
4886        assert!(b_pos < sum_pos);
4887        assert!(b_pos < r_pos);
4888    }
4889
4890    #[test]
4891    fn fuse_linear_collapses_matmul_plus_rank1_bias() {
4892        // Build [batch=1, m=2, k=3] @ [k=3, n=4] + bias[4].
4893        let a = crate::NodeHandle::from_f32(
4894            vec![1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0],
4895            crate::Shape::from_dims(&[2, 3]),
4896            cpu_dev(),
4897        )
4898        .unwrap();
4899        let b = a
4900            .const_f32_like(
4901                (0..12).map(|i| (i as f32) * 0.1).collect::<Vec<f32>>(),
4902                crate::Shape::from_dims(&[3, 4]),
4903            )
4904            .unwrap();
4905        let bias = a.const_f32_like(
4906            vec![0.5_f32, -0.5, 1.0, -1.0],
4907            crate::Shape::from_dims(&[4]),
4908        );
4909        let mm = a.matmul(&b);
4910        let bias_b = bias.unwrap().broadcast_to(crate::Shape::from_dims(&[2, 4]));
4911        let out = mm.add(&bias_b);
4912        // Note: real users would call broadcast_to first, then Add.
4913        // The fusion pass looks for `Add(MatMul, Const-shape-1-N)`
4914        // so we need to update it to also recognize the
4915        // BroadcastTo-then-Add pattern.
4916        let n_fused = fuse_linear(out.graph(), &[out.id()]);
4917        assert_eq!(n_fused, 1, "exactly one MatMul→Add(bias[N]) should fuse");
4918
4919        // The fused node should now be reachable as the canonical
4920        // root after remap. Its op is FusedLinear with three inputs.
4921        let g = out.graph().read().unwrap();
4922        // Walk consumers of the original Add: any leftover Add should
4923        // be unreferenced; the new FusedLinear should be present.
4924        // Phase 7.6 step 4: emission is the registry-extended shape.
4925        let any_fused = g.nodes.iter().any(|n| {
4926            matches!(
4927                n.op,
4928                Op::Fused(fid, _) if fid == crate::registry::FusedOps::FUSED_LINEAR,
4929            )
4930        });
4931        assert!(
4932            any_fused,
4933            "graph should contain an Op::Fused(FUSED_LINEAR) node"
4934        );
4935    }
4936
4937    #[test]
4938    fn fuse_linear_skips_when_matmul_has_other_consumers() {
4939        // If the matmul is consumed by both Add and something else,
4940        // fusing would duplicate the matmul. Pass should skip.
4941        let a = crate::NodeHandle::from_f32(
4942            vec![1.0_f32; 6],
4943            crate::Shape::from_dims(&[2, 3]),
4944            cpu_dev(),
4945        )
4946        .unwrap();
4947        let b = a
4948            .const_f32_like(vec![1.0_f32; 12], crate::Shape::from_dims(&[3, 4]))
4949            .unwrap();
4950        let bias = a
4951            .const_f32_like(vec![1.0_f32; 4], crate::Shape::from_dims(&[4]))
4952            .unwrap();
4953        let mm = a.matmul(&b);
4954        let bias_b = bias.broadcast_to(crate::Shape::from_dims(&[2, 4]));
4955        let with_bias = mm.add(&bias_b);
4956        let also_used = mm.relu(); // second consumer of mm
4957        // Both with_bias and also_used are roots.
4958        let n_fused = fuse_linear(a.graph(), &[with_bias.id(), also_used.id()]);
4959        assert_eq!(
4960            n_fused, 0,
4961            "MatMul has 2 consumers — fusion would duplicate work"
4962        );
4963    }
4964
4965    // ---- Rule registry: SoftmaxLastDim lower / fuse / round-trip ------------
4966
4967    fn count_op_in_reachable<F: Fn(&Op) -> bool>(
4968        graph: &SharedGraph,
4969        roots: &[NodeId],
4970        pred: F,
4971    ) -> usize {
4972        let g = graph.read().unwrap();
4973        topo_order_multi(&g, roots)
4974            .into_iter()
4975            .filter(|id| pred(&g.node(*id).op))
4976            .count()
4977    }
4978
4979    /// The lower rule, applied to a graph with one fused softmax,
4980    /// produces the canonical primitive subgraph reachable from the
4981    /// rewritten root, with auto-populated layout entries on the
4982    /// inserted BroadcastTo nodes.
4983    ///
4984    /// T5 (Increment C slice 1): the lowered spelling is the RECIPE's
4985    /// 9-node shrink-via-swap form — `MaxDim`/`SumDim` at the last axis +
4986    /// `Unsqueeze` append restoring keepdim, with the `e = Exp(..)`
4987    /// interior SHARED (identity-share) between the denominator reduce and
4988    /// the final Div — replacing the legacy 7-node
4989    /// `ReduceMaxTo`/`ReduceSumTo` form.
4990    #[test]
4991    fn softmax_last_dim_lower_rule_produces_canonical_subgraph() {
4992        let x = NodeHandle::from_f32(vec![1.0_f32; 12], Shape::from_dims(&[2, 3, 2]), cpu_dev())
4993            .unwrap();
4994        let sm = x.softmax_last_dim();
4995        let graph = sm.graph().clone();
4996
4997        let registry = RuleRegistry::lowering_only();
4998        let new_roots = registry.optimize_to_fixpoint(&graph, &[sm.id()]);
4999        assert_eq!(new_roots.len(), 1);
5000        let new_root = new_roots[0];
5001
5002        // Reachable subgraph from the new root should contain exactly
5003        // the 9 lowered nodes (plus the `x` Const leaf — that's 10).
5004        // Op composition: 1 MaxDim, 2 Unsqueeze, 2 BroadcastTo, 1 Sub,
5005        // 1 Exp, 1 SumDim, 1 Div, 1 Const = 10 reachable.
5006        let g = graph.read().unwrap();
5007        let reachable = topo_order_multi(&g, &[new_root]);
5008        assert_eq!(
5009            reachable.len(),
5010            10,
5011            "lowered subgraph: 9 ops + 1 Const = 10 reachable nodes"
5012        );
5013        drop(g);
5014
5015        // Op-shape sanity checks.
5016        let n_reduce_max =
5017            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::ReduceMaxTo(_)));
5018        let n_max_dim =
5019            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::MaxDim(_)));
5020        let n_sum_dim =
5021            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::SumDim(_)));
5022        let n_unsqueeze =
5023            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Unsqueeze { .. }));
5024        let n_reshape =
5025            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Reshape(_)));
5026        let n_broadcast =
5027            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::BroadcastTo(_)));
5028        let n_sub = count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Sub));
5029        let n_exp = count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Exp));
5030        let n_reduce_sum =
5031            count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::ReduceSumTo(_)));
5032        let n_div = count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Div));
5033        // Phase 7.6 step 5: post-migration the builder emits
5034        // `Op::Fused(SOFTMAX_LAST_DIM, _)` only — the legacy
5035        // `Op::SoftmaxLastDim` variant was retired.
5036        let n_softmax = count_op_in_reachable(
5037            &graph,
5038            &[new_root],
5039            |op| matches!(op, Op::Fused(fid, _) if *fid == FusedOps::SOFTMAX_LAST_DIM),
5040        );
5041        assert_eq!(
5042            n_max_dim, 1,
5043            "T5 max side = MaxDim(last) + Unsqueeze append"
5044        );
5045        assert_eq!(
5046            n_sum_dim, 1,
5047            "T5 sum side = SumDim(last) + Unsqueeze append"
5048        );
5049        assert_eq!(n_unsqueeze, 2, "one keepdim-restore per reduce");
5050        assert_eq!(
5051            n_reduce_max, 0,
5052            "the legacy ReduceMaxTo spelling is retired from the lowering"
5053        );
5054        assert_eq!(
5055            n_reduce_sum, 0,
5056            "the legacy ReduceSumTo spelling is retired from the lowering"
5057        );
5058        assert_eq!(n_reshape, 0, "keepdim restores via Unsqueeze, not Reshape");
5059        assert_eq!(n_broadcast, 2);
5060        assert_eq!(n_sub, 1);
5061        assert_eq!(
5062            n_exp, 1,
5063            "the shared `e` interior emits ONCE (identity-share), not per consumer"
5064        );
5065        assert_eq!(n_div, 1);
5066        assert_eq!(
5067            n_softmax, 0,
5068            "no SoftmaxLastDim/Fused(SOFTMAX_LAST_DIM) should remain reachable post-lowering"
5069        );
5070
5071        // Layout side-table entries on the inserted BroadcastTo nodes
5072        // must be populated and have the expected broadcast strides.
5073        let g = graph.read().unwrap();
5074        for id in topo_order_multi(&g, &[new_root]) {
5075            if let Op::BroadcastTo(_) = g.node(id).op {
5076                assert!(
5077                    g.has_explicit_layout(id),
5078                    "lowered BroadcastTo at {id:?} missing explicit Layout entry — \
5079                     Graph::push auto-populate should have set it",
5080                );
5081                // The inserted BroadcastTo broadcasts shape [2,3,1] -> [2,3,2]
5082                // with last-dim stride 0.
5083                let l = g.layout(id);
5084                assert_eq!(l.shape().dims(), &[2, 3, 2]);
5085                assert_eq!(
5086                    l.stride().last().copied(),
5087                    Some(0),
5088                    "BroadcastTo from [2,3,1] to [2,3,2] should have stride 0 on last dim"
5089                );
5090            }
5091        }
5092    }
5093
5094    /// The fuse rule, applied to the canonical 7-node lowered subgraph,
5095    /// collapses it back to a single `Op::SoftmaxLastDim` reachable
5096    /// from the rewritten root.
5097    #[test]
5098    fn softmax_last_dim_fuse_rule_collapses_canonical_subgraph() {
5099        // Build the canonical subgraph by-hand — start from a Const
5100        // and apply the same op sequence the lower rule would emit.
5101        let x =
5102            NodeHandle::from_f32(vec![1.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
5103        let m = x.reduce_max_to(Shape::from_dims(&[2, 1]));
5104        let mb = m.broadcast_to(Shape::from_dims(&[2, 3]));
5105        let s = x.sub(&mb);
5106        let e = s.exp();
5107        let d = e.reduce_sum_to(Shape::from_dims(&[2, 1]));
5108        let db = d.broadcast_to(Shape::from_dims(&[2, 3]));
5109        let out = e.div(&db);
5110        let graph = out.graph().clone();
5111
5112        // Phase 7.6 step 3: fuse rule comes from the registry entry.
5113        let registry = RuleRegistry::new().with_rule(Box::new(FusionRule::from_entry(
5114            &crate::registry::softmax_last_dim::entry(),
5115        )));
5116        let new_roots = registry.optimize_to_fixpoint(&graph, &[out.id()]);
5117        assert_eq!(new_roots.len(), 1);
5118        let new_root = new_roots[0];
5119
5120        // Reachable from the new root: 1 fused-softmax + 1 Const x = 2.
5121        let g = graph.read().unwrap();
5122        let reachable = topo_order_multi(&g, &[new_root]);
5123        assert_eq!(
5124            reachable.len(),
5125            2,
5126            "fused subgraph: 1 Fused(SOFTMAX_LAST_DIM) + 1 Const = 2 reachable nodes"
5127        );
5128        assert!(
5129            matches!(
5130                g.node(new_root).op,
5131                Op::Fused(fid, _) if fid == FusedOps::SOFTMAX_LAST_DIM
5132            ),
5133            "post-migration fusion produces Op::Fused(SOFTMAX_LAST_DIM, _)",
5134        );
5135        // Fused-softmax's input should be the original Const x.
5136        assert_eq!(g.node(new_root).inputs, vec![x.id()]);
5137    }
5138
5139    /// Lower then fuse to fixpoint: a graph with one `Op::SoftmaxLastDim`
5140    /// returns to a graph that's structurally identical to the input
5141    /// (modulo a re-canonicalized root NodeId — the original
5142    /// SoftmaxLastDim node remains in the arena but is unreachable
5143    /// from the rewritten root).
5144    #[test]
5145    fn softmax_last_dim_lower_then_fuse_round_trips() {
5146        let x =
5147            NodeHandle::from_f32(vec![1.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
5148        let sm = x.softmax_last_dim();
5149        let graph = sm.graph().clone();
5150        let original_root = sm.id();
5151
5152        let registry = RuleRegistry::default_rules();
5153        let new_roots = registry.optimize_to_fixpoint(&graph, &[original_root]);
5154        assert_eq!(new_roots.len(), 1);
5155        let new_root = new_roots[0];
5156
5157        // Reachable from the new root must look like the original
5158        // graph: one fused-softmax consuming one Const. Phase 7.6
5159        // step 3: the post-migration builder + fuse cycle produces
5160        // `Op::Fused(SOFTMAX_LAST_DIM, _)`, not `Op::SoftmaxLastDim`.
5161        let g = graph.read().unwrap();
5162        let reachable = topo_order_multi(&g, &[new_root]);
5163        assert_eq!(
5164            reachable.len(),
5165            2,
5166            "round-tripped graph: 1 Fused(SOFTMAX_LAST_DIM) + 1 Const = 2 reachable nodes"
5167        );
5168        assert!(
5169            matches!(
5170                g.node(new_root).op,
5171                Op::Fused(fid, _) if fid == FusedOps::SOFTMAX_LAST_DIM
5172            ),
5173            "round-tripped node should be Op::Fused(SOFTMAX_LAST_DIM, _)",
5174        );
5175        assert_eq!(
5176            g.node(new_root).inputs,
5177            vec![x.id()],
5178            "round-tripped Fused(SOFTMAX_LAST_DIM) should consume the original input"
5179        );
5180        // Shape and dtype preserved.
5181        assert_eq!(g.node(new_root).shape, g.node(original_root).shape);
5182        assert_eq!(g.node(new_root).dtype, g.node(original_root).dtype);
5183    }
5184
5185    /// Empty registry is a no-op: roots come back unchanged.
5186    #[test]
5187    fn empty_registry_is_noop() {
5188        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5189        let y = x.add(&x);
5190        let graph = y.graph().clone();
5191        let pre_len = graph.read().unwrap().len();
5192        let new_roots = RuleRegistry::new().optimize_to_fixpoint(&graph, &[y.id()]);
5193        assert_eq!(new_roots, vec![y.id()]);
5194        assert_eq!(
5195            graph.read().unwrap().len(),
5196            pre_len,
5197            "empty registry should add no nodes"
5198        );
5199    }
5200
5201    /// Lowering rule does not fire on a graph that has no
5202    /// SoftmaxLastDim nodes — the registry returns the input
5203    /// roots unchanged.
5204    #[test]
5205    fn lower_rule_does_not_fire_without_softmax() {
5206        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5207        let y = x.relu();
5208        let graph = y.graph().clone();
5209        let pre_len = graph.read().unwrap().len();
5210        let new_roots = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &[y.id()]);
5211        assert_eq!(new_roots, vec![y.id()]);
5212        assert_eq!(graph.read().unwrap().len(), pre_len);
5213    }
5214
5215    /// `lower_to_base_map` is a named wrapper over
5216    /// `RuleRegistry::lowering_only().optimize_to_fixpoint` — a fused ROPE
5217    /// node dissolves to its primitive base map; no `Op::Fused(ROPE, _)`
5218    /// remains reachable from the (possibly-remapped) roots.
5219    #[test]
5220    fn lower_to_base_map_dissolves_a_fused_op() {
5221        use std::sync::RwLock;
5222        // Build a graph: three Const leaves (x, cos, sin) + one Op::Fused(ROPE).
5223        let graph = Arc::new(RwLock::new(Graph::new()));
5224        let sink = {
5225            let mut g = graph.write().unwrap();
5226            let shape = Shape::from_dims(&[1, 4, 8]); // (batch, seq, head_dim=8, even)
5227            let x = g.push(Node {
5228                op: Op::Const,
5229                inputs: vec![],
5230                shape: shape.clone(),
5231                dtype: DType::F32,
5232            });
5233            let cshape = Shape::from_dims(&[4, 8]);
5234            let cos = g.push(Node {
5235                op: Op::Const,
5236                inputs: vec![],
5237                shape: cshape.clone(),
5238                dtype: DType::F32,
5239            });
5240            let sin = g.push(Node {
5241                op: Op::Const,
5242                inputs: vec![],
5243                shape: cshape,
5244                dtype: DType::F32,
5245            });
5246            g.push(Node {
5247                op: Op::Fused(FusedOps::ROPE, FusedOpParams::Rope),
5248                inputs: vec![x, cos, sin],
5249                shape,
5250                dtype: DType::F32,
5251            })
5252        };
5253        let roots = lower_to_base_map(&graph, &[sink]);
5254        let g = graph.read().unwrap();
5255        // No Op::Fused(ROPE) remains reachable; the base map is primitives.
5256        let reachable = topo_order_multi(&g, &roots);
5257        let has_rope = reachable
5258            .iter()
5259            .any(|&n| matches!(g.node(n).op, Op::Fused(fid, _) if fid == FusedOps::ROPE));
5260        assert!(!has_rope, "ROPE must be lowered to its primitive base map");
5261    }
5262
5263    /// `base_map_hash` canonicalizes commutative-operand order: two
5264    /// independently-built graphs computing `a + b` and `b + a` (same
5265    /// consts, swapped operand order on the `Add`) hash equal.
5266    #[test]
5267    fn base_map_hash_commutative_reorder_is_equal() {
5268        let mk = |swap: bool| {
5269            let mut g = Graph::new();
5270            let s = Shape::from_dims(&[4]);
5271            let a = g.push(Node {
5272                op: Op::Const,
5273                inputs: vec![],
5274                shape: s.clone(),
5275                dtype: DType::F32,
5276            });
5277            let b = g.push(Node {
5278                op: Op::Const,
5279                inputs: vec![],
5280                shape: s.clone(),
5281                dtype: DType::F32,
5282            });
5283            let ins = if swap { vec![b, a] } else { vec![a, b] };
5284            let sink = g.push(Node {
5285                op: Op::Add,
5286                inputs: ins,
5287                shape: s,
5288                dtype: DType::F32,
5289            });
5290            (g, sink)
5291        };
5292        let (g0, r0) = mk(false);
5293        let (g1, r1) = mk(true);
5294        assert_eq!(base_map_hash(&g0, r0), base_map_hash(&g1, r1), "a+b == b+a");
5295    }
5296
5297    /// `base_map_hash` distinguishes distinct ops over the same operands:
5298    /// `Add(a, b)` and `Mul(a, b)` must hash differently.
5299    #[test]
5300    fn base_map_hash_distinct_ops_differ() {
5301        let mk = |op: Op| {
5302            let mut g = Graph::new();
5303            let s = Shape::from_dims(&[4]);
5304            let a = g.push(Node {
5305                op: Op::Const,
5306                inputs: vec![],
5307                shape: s.clone(),
5308                dtype: DType::F32,
5309            });
5310            let b = g.push(Node {
5311                op: Op::Const,
5312                inputs: vec![],
5313                shape: s.clone(),
5314                dtype: DType::F32,
5315            });
5316            let sink = g.push(Node {
5317                op,
5318                inputs: vec![a, b],
5319                shape: s,
5320                dtype: DType::F32,
5321            });
5322            (g, sink)
5323        };
5324        let (ga, ra) = mk(Op::Add);
5325        let (gm, rm) = mk(Op::Mul);
5326        assert_ne!(base_map_hash(&ga, ra), base_map_hash(&gm, rm), "add != mul");
5327    }
5328
5329    /// `op_key` already carries explicit arms for `Op::Slice` and
5330    /// `Op::Concat` (tags 81/80) — the two ops rope's rotate-half base map
5331    /// relies on. This test forces that path: a tiny `Slice`-then-`Concat`
5332    /// fragment (the first half of rope's rotate-half idiom) hashes
5333    /// deterministically across two independently-built, structurally
5334    /// identical copies.
5335    #[test]
5336    fn base_map_hash_slice_concat_fragment_is_deterministic() {
5337        let mk = || {
5338            let mut g = Graph::new();
5339            let s = Shape::from_dims(&[4, 8]);
5340            let half = Shape::from_dims(&[4, 4]);
5341            let x = g.push(Node {
5342                op: Op::Const,
5343                inputs: vec![],
5344                shape: s.clone(),
5345                dtype: DType::F32,
5346            });
5347            let lo = g.push(Node {
5348                op: Op::Slice {
5349                    dim: 1,
5350                    start: 0,
5351                    len: 4,
5352                },
5353                inputs: vec![x],
5354                shape: half.clone(),
5355                dtype: DType::F32,
5356            });
5357            let hi = g.push(Node {
5358                op: Op::Slice {
5359                    dim: 1,
5360                    start: 4,
5361                    len: 4,
5362                },
5363                inputs: vec![x],
5364                shape: half,
5365                dtype: DType::F32,
5366            });
5367            let sink = g.push(Node {
5368                op: Op::Concat { dim: 1 },
5369                inputs: vec![hi, lo],
5370                shape: s,
5371                dtype: DType::F32,
5372            });
5373            (g, sink)
5374        };
5375        let (g0, r0) = mk();
5376        let (g1, r1) = mk();
5377        assert_eq!(
5378            base_map_hash(&g0, r0),
5379            base_map_hash(&g1, r1),
5380            "two independently-built Slice->Concat fragments must hash equal"
5381        );
5382    }
5383
5384    /// `base_map_hash` folds a `Const`'s real bytes when its storage slot
5385    /// is populated (unlike the bare `Node { op: Op::Const, .. }` pushes
5386    /// used above, `NodeHandle::from_f32` populates real storage via
5387    /// `set_storage`). Two consts with equal bytes hash equal; two consts
5388    /// of the same shape/dtype but different bytes hash differently — this
5389    /// is the behavior that distinguishes real recipe identity from the
5390    /// shape/dtype-only fallback (documented on `fold_const_bytes`).
5391    #[test]
5392    fn base_map_hash_folds_real_const_bytes() {
5393        let shape = Shape::from_dims(&[4]);
5394        let a = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], shape.clone(), cpu_dev()).unwrap();
5395        let b = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], shape.clone(), cpu_dev()).unwrap();
5396        let c = NodeHandle::from_f32(vec![9.0, 9.0, 9.0, 9.0], shape, cpu_dev()).unwrap();
5397        let ga = a.graph().read().unwrap();
5398        let gb = b.graph().read().unwrap();
5399        let gc = c.graph().read().unwrap();
5400        assert_eq!(
5401            base_map_hash(&ga, a.id()),
5402            base_map_hash(&gb, b.id()),
5403            "identical const bytes must hash equal across independently-built graphs"
5404        );
5405        assert_ne!(
5406            base_map_hash(&ga, a.id()),
5407            base_map_hash(&gc, c.id()),
5408            "different const bytes (same shape/dtype) must hash differently"
5409        );
5410    }
5411
5412    #[test]
5413    fn scan_base_map_hash_differs_for_different_bodies() {
5414        use crate::{Graph, Node, Op, ScanEmit, ScanRole};
5415        use fuel_ir::{DType, Shape};
5416        // Two scans, IDENTICAL carry shape / n_xs / bound / emit, but STRUCTURALLY
5417        // DIFFERENT bodies: body A = carry*2 (MulScalar), body B = carry+1
5418        // (AddScalar). Without a dedicated op_key arm they fall to the None branch
5419        // (discriminant+shape+dtype only) and collide -> silent CSE / recipe-identity
5420        // corruption. THIS red run is the evidence the trap is real.
5421        let mk = |mul: bool| {
5422            let mut g = Graph::new();
5423            let s = Shape::from_dims(&[1]);
5424            let carry = g.push(Node {
5425                op: Op::Const,
5426                inputs: vec![],
5427                shape: s.clone(),
5428                dtype: DType::F32,
5429            });
5430            let hole = g.push(Node {
5431                op: Op::ScanPlaceholder {
5432                    role: ScanRole::Carry,
5433                    index: 0,
5434                },
5435                inputs: vec![],
5436                shape: s.clone(),
5437                dtype: DType::F32,
5438            });
5439            let new_carry = g.push(Node {
5440                op: if mul {
5441                    Op::MulScalar(2.0)
5442                } else {
5443                    Op::AddScalar(1.0)
5444                },
5445                inputs: vec![hole],
5446                shape: s.clone(),
5447                dtype: DType::F32,
5448            });
5449            // inputs = [init_carry, body_new_carry, body_y]  (n_xs = 0, no consts)
5450            let scan = g.push(Node {
5451                op: Op::Scan {
5452                    n_carries: 1,
5453                    n_xs: 0,
5454                    bound: 3,
5455                    emit: ScanEmit::All,
5456                    early_exit: None,
5457                },
5458                inputs: vec![carry, new_carry, new_carry],
5459                shape: s,
5460                dtype: DType::F32,
5461            });
5462            (g, scan)
5463        };
5464        let (ga, ra) = mk(true);
5465        let (gb, rb) = mk(false);
5466        assert_ne!(
5467            base_map_hash(&ga, ra),
5468            base_map_hash(&gb, rb),
5469            "scans with structurally different bodies must hash differently",
5470        );
5471    }
5472
5473    #[test]
5474    fn scan_base_map_hash_equal_for_same_body_cross_graph() {
5475        use crate::{Graph, Node, Op, ScanEmit, ScanRole};
5476        use fuel_ir::{DType, Shape};
5477        // Same body built independently in two graphs -> equal hash (the
5478        // recipe-identity positive case). base_map_hash is NodeId-independent.
5479        let mk = || {
5480            let mut g = Graph::new();
5481            let s = Shape::from_dims(&[1]);
5482            let carry = g.push(Node {
5483                op: Op::Const,
5484                inputs: vec![],
5485                shape: s.clone(),
5486                dtype: DType::F32,
5487            });
5488            let hole = g.push(Node {
5489                op: Op::ScanPlaceholder {
5490                    role: ScanRole::Carry,
5491                    index: 0,
5492                },
5493                inputs: vec![],
5494                shape: s.clone(),
5495                dtype: DType::F32,
5496            });
5497            let new_carry = g.push(Node {
5498                op: Op::MulScalar(2.0),
5499                inputs: vec![hole],
5500                shape: s.clone(),
5501                dtype: DType::F32,
5502            });
5503            let scan = g.push(Node {
5504                op: Op::Scan {
5505                    n_carries: 1,
5506                    n_xs: 0,
5507                    bound: 3,
5508                    emit: ScanEmit::All,
5509                    early_exit: None,
5510                },
5511                inputs: vec![carry, new_carry, new_carry],
5512                shape: s,
5513                dtype: DType::F32,
5514            });
5515            (g, scan)
5516        };
5517        let (g0, r0) = mk();
5518        let (g1, r1) = mk();
5519        assert_eq!(
5520            base_map_hash(&g0, r0),
5521            base_map_hash(&g1, r1),
5522            "same body -> equal hash"
5523        );
5524    }
5525
5526    /// GAP-303 DISCRIMINATING FIXTURE for the `n_carries` fold in `op_key`.
5527    ///
5528    /// ⚠️ WITHOUT THIS TEST THE FIX IS INVISIBLE IN ITS OWN METRIC. Every other
5529    /// scan test in this workspace uses ONE carry, and `NodeHandle::scan` pins
5530    /// `n_carries: 1`, so folding the field or not folding it produces identical
5531    /// hashes across the entire suite. Ask "if this became a no-op, which number
5532    /// moves?" — before this test, none.
5533    ///
5534    /// What it protects: `op_key` is Fuel's own recipe identity. Two scans that
5535    /// differ ONLY in carry count must not key the same, or cover-finding and the
5536    /// plan cache treat a 1-carry and a 3-carry scan as one recipe. Same defect
5537    /// class as KISS-OPS-6.19, where an arm reading too few attrs made
5538    /// Gather-on-axis-2 and Gather-on-axis-0 emit IDENTICAL bytes.
5539    #[test]
5540    fn gap303_n_carries_participates_in_op_key() {
5541        use crate::{Op, ScanEmit, ScanRole};
5542        // Every other field held EQUAL — n_carries is the only axis that moves.
5543        let mk = |n_carries: usize| {
5544            op_key(&Op::Scan {
5545                n_carries,
5546                n_xs: 2,
5547                bound: 3,
5548                emit: ScanEmit::All,
5549                early_exit: None,
5550            })
5551        };
5552        let one = mk(1);
5553        assert!(one.is_some(), "Op::Scan must produce an op_key, not None");
5554        for n in [2usize, 3, 7] {
5555            assert_ne!(
5556                mk(n),
5557                one,
5558                "n_carries={n} must not key identically to n_carries=1                  (all other fields held equal)"
5559            );
5560        }
5561        // ...and distinct counts stay distinct from each OTHER, not merely from 1.
5562        assert_ne!(mk(2), mk(3), "distinct carry counts must key distinctly");
5563
5564        // The SIBLING claim, asserted rather than only commented: the placeholder
5565        // arm's own doc says "Carry/0, Elem/0, Carry/1 are all distinct in the
5566        // body hash". It had no test. It does now.
5567        let c0 = op_key(&Op::ScanPlaceholder {
5568            role: ScanRole::Carry,
5569            index: 0,
5570        });
5571        let c1 = op_key(&Op::ScanPlaceholder {
5572            role: ScanRole::Carry,
5573            index: 1,
5574        });
5575        let e0 = op_key(&Op::ScanPlaceholder {
5576            role: ScanRole::Elem,
5577            index: 0,
5578        });
5579        assert!(c0.is_some(), "ScanPlaceholder must produce an op_key");
5580        assert_ne!(c0, c1, "Carry/0 vs Carry/1 must differ in the body hash");
5581        assert_ne!(c0, e0, "Carry/0 vs Elem/0 must differ in the body hash");
5582    }
5583
5584    #[test]
5585    fn scan_emit_and_placeholder_role_participate_in_op_key() {
5586        use crate::{Op, ScanEmit, ScanRole};
5587        // emit=All vs emit=Final differ.
5588        let all = op_key(&Op::Scan {
5589            n_carries: 1,
5590            n_xs: 0,
5591            bound: 3,
5592            emit: ScanEmit::All,
5593            early_exit: None,
5594        });
5595        let fin = op_key(&Op::Scan {
5596            n_carries: 1,
5597            n_xs: 0,
5598            bound: 3,
5599            emit: ScanEmit::Final,
5600            early_exit: None,
5601        });
5602        assert!(
5603            all.is_some() && fin.is_some(),
5604            "Op::Scan must produce an op_key, not None"
5605        );
5606        assert_ne!(all, fin, "emit=All vs emit=Final must differ in op_key");
5607        // n_xs / bound participate.
5608        assert_ne!(
5609            op_key(&Op::Scan {
5610                n_carries: 1,
5611                n_xs: 1,
5612                bound: 3,
5613                emit: ScanEmit::All,
5614                early_exit: None
5615            }),
5616            all
5617        );
5618        assert_ne!(
5619            op_key(&Op::Scan {
5620                n_carries: 1,
5621                n_xs: 0,
5622                bound: 4,
5623                emit: ScanEmit::All,
5624                early_exit: None
5625            }),
5626            all
5627        );
5628        // ScanPlaceholder Carry/0 vs Elem/0 vs Carry/1 all differ.
5629        let c0 = op_key(&Op::ScanPlaceholder {
5630            role: ScanRole::Carry,
5631            index: 0,
5632        });
5633        let e0 = op_key(&Op::ScanPlaceholder {
5634            role: ScanRole::Elem,
5635            index: 0,
5636        });
5637        let c1 = op_key(&Op::ScanPlaceholder {
5638            role: ScanRole::Carry,
5639            index: 1,
5640        });
5641        assert!(c0.is_some());
5642        assert_ne!(c0, e0, "Carry vs Elem must differ");
5643        assert_ne!(c0, c1, "index must participate");
5644    }
5645
5646    /// G2 (2026-06-20): FlashAttn IS decomposable — the vanilla (non-causal,
5647    /// MHA) case lowers to its materialized scaled-dot-product-attention
5648    /// subgraph. Born-red before the fix (`flash_attn::decompose` panicked).
5649    #[test]
5650    fn flash_attn_vanilla_decomposes_to_sdpa() {
5651        let s = Shape::from_dims(&[1, 1, 1, 1]);
5652        let q = NodeHandle::from_f32(vec![0.0_f32; 1], s.clone(), cpu_dev()).unwrap();
5653        let k = q.const_f32_like(vec![0.0_f32; 1], s.clone()).unwrap();
5654        let v = q.const_f32_like(vec![0.0_f32; 1], s.clone()).unwrap();
5655        // Vanilla: non-causal, Hq==Hkv==1, no alibi/window/softcap.
5656        let attn = q.flash_attn(&k, &v, None, 1.0_f32, false, None, None, None);
5657        let graph = attn.graph().clone();
5658
5659        let new_roots = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &[attn.id()]);
5660        assert_eq!(new_roots.len(), 1);
5661        let root = new_roots[0];
5662        let g = graph.read().unwrap();
5663        // The fused FlashAttn is gone from the root, replaced by the SDPA
5664        // subgraph — which contains MatMul nodes.
5665        assert!(
5666            !matches!(g.node(root).op, Op::Fused(fid, _) if fid == crate::registry::FusedOps::FLASH_ATTN),
5667            "vanilla FlashAttn must decompose, not stay fused",
5668        );
5669        let has_matmul = (0..g.len()).any(|i| matches!(g.node(NodeId(i)).op, Op::MatMul));
5670        assert!(has_matmul, "decomposed FlashAttn must contain a MatMul");
5671    }
5672
5673    /// G2: causal FlashAttn decomposes too — the causal mask is an additive
5674    /// `-inf` `Triu` band (no new primitive needed), so the node lowers
5675    /// rather than staying fused. (The decomposed *numerics* are verified
5676    /// separately by a parity test vs. the reference attention.)
5677    #[test]
5678    fn flash_attn_causal_decomposes_with_triu_mask() {
5679        let s = Shape::from_dims(&[1, 1, 2, 1]); // Sq=Sk=2 so the mask is non-trivial
5680        let q = NodeHandle::from_f32(vec![0.0_f32; 2], s.clone(), cpu_dev()).unwrap();
5681        let k = q.const_f32_like(vec![0.0_f32; 2], s.clone()).unwrap();
5682        let v = q.const_f32_like(vec![0.0_f32; 2], s.clone()).unwrap();
5683        let attn = q.flash_attn(&k, &v, None, 1.0_f32, true, None, None, None); // causal
5684        let graph = attn.graph().clone();
5685
5686        let new_roots = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &[attn.id()]);
5687        assert_eq!(new_roots.len(), 1);
5688        let root = new_roots[0];
5689        let g = graph.read().unwrap();
5690        assert!(
5691            !matches!(g.node(root).op, Op::Fused(fid, _) if fid == crate::registry::FusedOps::FLASH_ATTN),
5692            "causal FlashAttn must decompose (Triu mask), not stay fused",
5693        );
5694        let has_triu = (0..g.len()).any(|i| matches!(g.node(NodeId(i)).op, Op::Triu { .. }));
5695        assert!(
5696            has_triu,
5697            "causal decomposition must contain a Triu mask band"
5698        );
5699    }
5700
5701    /// Fuse rule does not fire on a non-canonical Div pattern (e.g.,
5702    /// a plain `a / b` with no upstream softmax structure).
5703    #[test]
5704    fn fuse_rule_does_not_fire_on_plain_div() {
5705        let a = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5706        let b = a
5707            .const_f32_like(vec![2.0_f32; 4], Shape::from_dims(&[4]))
5708            .unwrap();
5709        let c = a.div(&b);
5710        let graph = c.graph().clone();
5711        let pre_len = graph.read().unwrap().len();
5712        let new_roots = RuleRegistry::new()
5713            .with_rule(Box::new(FusionRule::from_entry(
5714                &crate::registry::softmax_last_dim::entry(),
5715            )))
5716            .optimize_to_fixpoint(&graph, &[c.id()]);
5717        assert_eq!(new_roots, vec![c.id()]);
5718        assert_eq!(graph.read().unwrap().len(), pre_len);
5719    }
5720
5721    // -------- Cast-fusion rule -------------------------------------------
5722
5723    /// Build a "yes to everything" capability predicate. Useful when
5724    /// the test wants to confirm the matcher/rewriter mechanics work;
5725    /// orthogonal tests cover the predicate-says-no path.
5726    fn allow_all_predicate() -> CapabilityPredicate {
5727        Arc::new(|_op: &Op, _dtypes: &[fuel_ir::DType]| true)
5728    }
5729
5730    /// Predicate that says no for everything. Useful for confirming
5731    /// the rule respects the capability gate.
5732    fn deny_all_predicate() -> CapabilityPredicate {
5733        Arc::new(|_op: &Op, _dtypes: &[fuel_ir::DType]| false)
5734    }
5735
5736    /// Happy path: `x:f32 → Cast(BF16) → Neg(BF16)` collapses to
5737    /// `x:f32 → Neg(f32)` when the predicate says Neg supports f32.
5738    #[test]
5739    fn cast_fusion_drops_cast_when_consumer_supports_source_dtype() {
5740        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5741        let xc = x.cast(fuel_ir::DType::BF16);
5742        let y = xc.neg();
5743        let graph = y.graph().clone();
5744        let pre_len = graph.read().unwrap().len();
5745
5746        let registry =
5747            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5748        let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5749        assert_eq!(new_roots.len(), 1);
5750        let new_root = new_roots[0];
5751
5752        let g = graph.read().unwrap();
5753        // The new root is a fresh Neg whose input is the original
5754        // f32 Const x, not the Cast.
5755        assert!(matches!(g.node(new_root).op, Op::Neg));
5756        assert_eq!(g.node(new_root).inputs, vec![x.id()]);
5757        // Cast node remains in the arena but is no longer reachable
5758        // from the new root.
5759        let reachable = topo_order_multi(&g, &[new_root]);
5760        assert!(
5761            !reachable
5762                .iter()
5763                .any(|&n| matches!(g.node(n).op, Op::Cast(_))),
5764            "no Cast node should be reachable post-fusion"
5765        );
5766        // One new Neg node was appended; the original Neg + Cast are
5767        // unreachable but still in the arena.
5768        assert!(g.len() > pre_len);
5769    }
5770
5771    /// Capability gate: when the predicate says no, the rule must
5772    /// not fire. Graph structure is unchanged.
5773    #[test]
5774    fn cast_fusion_no_fire_when_predicate_denies() {
5775        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5776        let xc = x.cast(fuel_ir::DType::BF16);
5777        let y = xc.neg();
5778        let graph = y.graph().clone();
5779        let pre_len = graph.read().unwrap().len();
5780
5781        let registry =
5782            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(deny_all_predicate())));
5783        let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5784        assert_eq!(new_roots, vec![y.id()]);
5785        assert_eq!(
5786            graph.read().unwrap().len(),
5787            pre_len,
5788            "predicate-says-no should append no nodes"
5789        );
5790        // The original Cast → Neg chain is intact.
5791        let g = graph.read().unwrap();
5792        assert!(matches!(g.node(y.id()).op, Op::Neg));
5793        assert_eq!(g.node(y.id()).inputs, vec![xc.id()]);
5794        assert!(matches!(g.node(xc.id()).op, Op::Cast(_)));
5795    }
5796
5797    /// Single-consumer constraint: if the Cast feeds two consumers,
5798    /// the rule must not fire (eliminating the cast for one consumer
5799    /// would orphan it for the other).
5800    #[test]
5801    fn cast_fusion_no_fire_when_cast_has_multiple_consumers() {
5802        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5803        let xc = x.cast(fuel_ir::DType::BF16);
5804        // Two consumers of the same Cast.
5805        let y1 = xc.neg();
5806        let y2 = xc.relu();
5807        let graph = y1.graph().clone();
5808        let pre_len = graph.read().unwrap().len();
5809
5810        let registry =
5811            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5812        let new_roots = registry.optimize_to_fixpoint(&graph, &[y1.id(), y2.id()]);
5813        // Roots unchanged; cast survives because rule didn't fire.
5814        assert_eq!(new_roots, vec![y1.id(), y2.id()]);
5815        assert_eq!(graph.read().unwrap().len(), pre_len);
5816        let g = graph.read().unwrap();
5817        assert!(matches!(g.node(xc.id()).op, Op::Cast(_)));
5818    }
5819
5820    /// Output dtype tracks the new input: in aggressive mode the
5821    /// rewritten consumer's output dtype is the cast-source dtype,
5822    /// not the original consumer's output dtype. Downstream
5823    /// consumers reading from the rewritten node will see the new
5824    /// dtype — by design (see module docstring's
5825    /// aggressive-semantics caveat).
5826    #[test]
5827    fn cast_fusion_rewrites_output_dtype_to_source_dtype() {
5828        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5829        let xc = x.cast(fuel_ir::DType::BF16);
5830        let y = xc.neg();
5831        // Pre-fusion the original Neg produces BF16.
5832        assert_eq!(y.dtype(), fuel_ir::DType::BF16);
5833
5834        let graph = y.graph().clone();
5835        let registry =
5836            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5837        let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5838        let new_root = new_roots[0];
5839
5840        let g = graph.read().unwrap();
5841        // The rewritten Neg consumes the F32 source directly and
5842        // produces F32. This differs from the original BF16
5843        // output; the rule's aggressive semantics commit to this.
5844        assert_eq!(g.node(new_root).dtype, fuel_ir::DType::F32);
5845    }
5846
5847    /// Multi-input op: only the qualifying input fuses; the others
5848    /// are left alone.
5849    #[test]
5850    fn cast_fusion_multi_input_op_fuses_only_qualifying_edge() {
5851        let a = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5852        // a:f32 → Cast(BF16) → Add(bf16, b:bf16) where b is already bf16.
5853        let ac = a.cast(fuel_ir::DType::BF16);
5854        let b = a
5855            .const_bf16_like(vec![half::bf16::from_f32(2.0); 4], Shape::from_dims(&[4]))
5856            .unwrap();
5857        let sum = ac.add(&b);
5858        let graph = sum.graph().clone();
5859
5860        // Build a predicate that says yes only for Add[f32, bf16, bf16].
5861        // (Realistic kernels typically wouldn't have mixed-dtype Add;
5862        // this isolates the matcher's per-input behavior. If the
5863        // predicate said no, the rule wouldn't fire and `b` would
5864        // stay paired with the Cast.)
5865        let predicate: CapabilityPredicate =
5866            Arc::new(|op: &Op, _dtypes: &[fuel_ir::DType]| matches!(op, Op::Add));
5867
5868        let registry = RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(predicate)));
5869        let new_roots = registry.optimize_to_fixpoint(&graph, &[sum.id()]);
5870        let new_root = new_roots[0];
5871
5872        let g = graph.read().unwrap();
5873        assert!(matches!(g.node(new_root).op, Op::Add));
5874        // First input of new Add: the original f32 const (cast eliminated).
5875        // Second input: still `b` (a bf16 const, no cast was there).
5876        assert_eq!(g.node(new_root).inputs, vec![a.id(), b.id()]);
5877    }
5878
5879    /// Chained casts: `x:f32 → Cast(BF16) → Cast(F32) → Op` should
5880    /// reduce to `x:f32 → Op` via the fixpoint loop firing the rule
5881    /// twice (once per consumer-side cast).
5882    #[test]
5883    fn cast_fusion_handles_chained_casts_via_fixpoint() {
5884        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5885        let x_bf16 = x.cast(fuel_ir::DType::BF16);
5886        let x_f32 = x_bf16.cast(fuel_ir::DType::F32);
5887        let y = x_f32.neg();
5888        let graph = y.graph().clone();
5889
5890        let registry =
5891            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5892        let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5893        let new_root = new_roots[0];
5894
5895        let g = graph.read().unwrap();
5896        // After two iterations: the outer Cast is gone (Neg consumes
5897        // x_bf16 directly), then the inner Cast is gone (Neg consumes
5898        // x directly). No Cast nodes are reachable.
5899        let reachable = topo_order_multi(&g, &[new_root]);
5900        let cast_count = reachable
5901            .iter()
5902            .filter(|&&n| matches!(g.node(n).op, Op::Cast(_)))
5903            .count();
5904        assert_eq!(
5905            cast_count, 0,
5906            "fixpoint should eliminate both casts in the chain"
5907        );
5908        assert!(matches!(g.node(new_root).op, Op::Neg));
5909        assert_eq!(g.node(new_root).inputs, vec![x.id()]);
5910    }
5911
5912    /// The rule must not fire on the Cast node itself — only on the
5913    /// consumer of a Cast.
5914    #[test]
5915    fn cast_fusion_does_not_fire_on_cast_node_itself() {
5916        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5917        let xc = x.cast(fuel_ir::DType::BF16);
5918        let graph = xc.graph().clone();
5919        let pre_len = graph.read().unwrap().len();
5920
5921        let registry =
5922            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5923        // Root the optimizer at the Cast itself. The rule should look
5924        // at the Cast and decline (it's not the consumer's role).
5925        let new_roots = registry.optimize_to_fixpoint(&graph, &[xc.id()]);
5926        assert_eq!(new_roots, vec![xc.id()]);
5927        assert_eq!(graph.read().unwrap().len(), pre_len);
5928    }
5929
5930    // ===== Phase 2.1: insert_cross_device_copies tests =====
5931
5932    use std::collections::HashMap as StdHashMap;
5933
5934    fn add_node(g: &mut Graph, op: Op, inputs: Vec<NodeId>) -> NodeId {
5935        g.push(Node {
5936            op,
5937            inputs,
5938            shape: Shape::from_dims(&[4]),
5939            dtype: DType::F32,
5940        })
5941    }
5942
5943    /// Snapshot every node's `Graph::placement` into an external
5944    /// map. Needed because `&mut Graph` and `|id| g.placement(id)`
5945    /// closure can't co-exist (overlapping borrows). The closure-
5946    /// based oracle is the production shape (the picker hands in a
5947    /// `Fn(NodeId) -> Option<DeviceLocation>` derived from its own
5948    /// commit map); tests just precompute the snapshot from the
5949    /// graph.
5950    fn snapshot_placements(g: &Graph) -> StdHashMap<NodeId, DeviceLocation> {
5951        let mut m = StdHashMap::new();
5952        for id in 0..g.len() {
5953            let nid = NodeId(id);
5954            if let Some(loc) = g.placement(nid) {
5955                m.insert(nid, loc);
5956            }
5957        }
5958        m
5959    }
5960
5961    fn build_two_node_graph_with_placements(
5962        a: DeviceLocation,
5963        b: DeviceLocation,
5964    ) -> (Graph, NodeId, NodeId) {
5965        let mut g = Graph::new();
5966        let n1 = add_node(&mut g, Op::Const, vec![]);
5967        let n2 = add_node(&mut g, Op::Neg, vec![n1]);
5968        g.set_placement(n1, a);
5969        g.set_placement(n2, b);
5970        (g, n1, n2)
5971    }
5972
5973    fn shares_storage_cpu_devices_only(a: DeviceLocation, b: DeviceLocation) -> bool {
5974        // Test stub topology: CPU shares with CPU only (the AOCL/MKL/
5975        // portable-CPU substrate story). CUDA shares with same-gpu_id
5976        // CUDA. Vulkan shares with same-gpu_id Vulkan. Cross device
5977        // is never shared.
5978        match (a, b) {
5979            (DeviceLocation::Cpu, DeviceLocation::Cpu) => true,
5980            (DeviceLocation::Cuda { gpu_id: x }, DeviceLocation::Cuda { gpu_id: y }) => x == y,
5981            (DeviceLocation::Vulkan { gpu_id: x }, DeviceLocation::Vulkan { gpu_id: y }) => x == y,
5982            (DeviceLocation::Metal { gpu_id: x }, DeviceLocation::Metal { gpu_id: y }) => x == y,
5983            _ => false,
5984        }
5985    }
5986
5987    /// Single-hop everywhere: the pre-two-hop behavior. Every bridged
5988    /// edge gets exactly one `Op::Copy`. Used by the legacy single-hop
5989    /// tests so they stay byte-identical to before the two-hop param.
5990    fn no_host_staging(_a: DeviceLocation, _b: DeviceLocation) -> bool {
5991        false
5992    }
5993
5994    /// Realistic topology predicate: a cross-VENDOR GPU↔GPU pair (CUDA↔
5995    /// Vulkan, or any two distinct GPU substrates) needs host staging
5996    /// (two hops). Any edge touching CPU, or a same-vendor pair, is a
5997    /// single hop.
5998    fn host_staging_cross_vendor_gpu(a: DeviceLocation, b: DeviceLocation) -> bool {
5999        use DeviceLocation::*;
6000        match (a, b) {
6001            // CPU on either end → single hop.
6002            (Cpu, _) | (_, Cpu) => false,
6003            // Same vendor (both CUDA, both Vulkan, both Metal) → single
6004            // hop (the topology can copy directly / shares_storage may
6005            // even short-circuit it first).
6006            (Cuda { .. }, Cuda { .. }) => false,
6007            (Vulkan { .. }, Vulkan { .. }) => false,
6008            (Metal { .. }, Metal { .. }) => false,
6009            // Cross-vendor GPU↔GPU → two hops through host.
6010            _ => true,
6011        }
6012    }
6013
6014    /// No cross-device edges → no copies inserted.
6015    #[test]
6016    fn insert_copies_noop_when_all_same_device() {
6017        let (mut g, _n1, n2) =
6018            build_two_node_graph_with_placements(DeviceLocation::Cpu, DeviceLocation::Cpu);
6019        let pre_len = g.len();
6020        let placements = snapshot_placements(&g);
6021        let inserted = insert_cross_device_copies(
6022            &mut g,
6023            &[n2],
6024            |id| placements.get(&id).copied(),
6025            shares_storage_cpu_devices_only,
6026            no_host_staging,
6027        );
6028        assert_eq!(inserted.len(), 0, "same-device edge → no copy needed");
6029        assert_eq!(g.len(), pre_len, "graph unchanged");
6030    }
6031
6032    /// Cross-device edge (CPU → CUDA) → one `Op::Copy { target: Cuda }`
6033    /// inserted; consumer's input rewired to point at it.
6034    #[test]
6035    fn insert_copies_cuda_consumer_of_cpu_input() {
6036        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6037        let (mut g, n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6038        let pre_len = g.len();
6039        let placements = snapshot_placements(&g);
6040        let inserted = insert_cross_device_copies(
6041            &mut g,
6042            &[n2],
6043            |id| placements.get(&id).copied(),
6044            shares_storage_cpu_devices_only,
6045            no_host_staging,
6046        );
6047        assert_eq!(inserted.len(), 1);
6048        assert_eq!(g.len(), pre_len + 1, "one new Op::Copy node appended");
6049        // Consumer's input now points at the new Op::Copy (not n1).
6050        let consumer_inputs = &g.node(n2).inputs;
6051        assert_eq!(consumer_inputs.len(), 1);
6052        let copy_id = consumer_inputs[0];
6053        assert_ne!(copy_id, n1, "consumer's input was rewired");
6054        let copy_node = g.node(copy_id);
6055        assert!(
6056            matches!(copy_node.op, Op::Copy { target } if target == cuda),
6057            "rewired input is Op::Copy targeting CUDA; got {:?}",
6058            copy_node.op,
6059        );
6060        assert_eq!(copy_node.inputs, vec![n1], "Op::Copy reads from n1");
6061        assert_eq!(g.placement(copy_id), Some(cuda), "Op::Copy lands on CUDA");
6062    }
6063
6064    /// Two consumers share an input AND both need it on the same target
6065    /// device → ONE Op::Copy serves both (CSE on inserted copies).
6066    #[test]
6067    fn insert_copies_dedupes_when_two_consumers_share_input_and_target() {
6068        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6069        let mut g = Graph::new();
6070        let cpu_src = add_node(&mut g, Op::Const, vec![]);
6071        g.set_placement(cpu_src, DeviceLocation::Cpu);
6072        let cuda_a = add_node(&mut g, Op::Neg, vec![cpu_src]);
6073        let cuda_b = add_node(&mut g, Op::Sqr, vec![cpu_src]);
6074        g.set_placement(cuda_a, cuda);
6075        g.set_placement(cuda_b, cuda);
6076
6077        let pre_len = g.len();
6078        let placements = snapshot_placements(&g);
6079        let inserted = insert_cross_device_copies(
6080            &mut g,
6081            &[cuda_a, cuda_b],
6082            |id| placements.get(&id).copied(),
6083            shares_storage_cpu_devices_only,
6084            no_host_staging,
6085        );
6086        assert_eq!(
6087            inserted.len(),
6088            1,
6089            "CSE — one Op::Copy serves both consumers"
6090        );
6091        assert_eq!(g.len(), pre_len + 1);
6092        // Both consumers point at the SAME Op::Copy node.
6093        let a_input = g.node(cuda_a).inputs[0];
6094        let b_input = g.node(cuda_b).inputs[0];
6095        assert_eq!(a_input, b_input, "both consumers share the rewired input");
6096    }
6097
6098    /// Two consumers share an input but want it on DIFFERENT target
6099    /// devices → TWO distinct Op::Copy nodes inserted.
6100    #[test]
6101    fn insert_copies_does_not_dedupe_across_distinct_targets() {
6102        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6103        let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6104        let mut g = Graph::new();
6105        let cpu_src = add_node(&mut g, Op::Const, vec![]);
6106        g.set_placement(cpu_src, DeviceLocation::Cpu);
6107        let cuda_c = add_node(&mut g, Op::Neg, vec![cpu_src]);
6108        let vk_c = add_node(&mut g, Op::Sqr, vec![cpu_src]);
6109        g.set_placement(cuda_c, cuda);
6110        g.set_placement(vk_c, vulkan);
6111
6112        let placements = snapshot_placements(&g);
6113
6114        let inserted = insert_cross_device_copies(
6115            &mut g,
6116            &[cuda_c, vk_c],
6117            |id| placements.get(&id).copied(),
6118            shares_storage_cpu_devices_only,
6119            no_host_staging,
6120        );
6121        assert_eq!(inserted.len(), 2, "distinct targets → distinct copies");
6122        let cuda_input = g.node(cuda_c).inputs[0];
6123        let vk_input = g.node(vk_c).inputs[0];
6124        assert_ne!(cuda_input, vk_input);
6125    }
6126
6127    /// Producer with no placement → pass leaves the edge alone (the
6128    /// executor handles via fallback). Same for consumer with no
6129    /// placement.
6130    #[test]
6131    fn insert_copies_skips_when_placement_absent() {
6132        let mut g = Graph::new();
6133        let n1 = add_node(&mut g, Op::Const, vec![]);
6134        let n2 = add_node(&mut g, Op::Neg, vec![n1]);
6135        // Only consumer has placement; producer doesn't.
6136        g.set_placement(n2, DeviceLocation::Cuda { gpu_id: 0 });
6137        let placements = snapshot_placements(&g);
6138        let inserted = insert_cross_device_copies(
6139            &mut g,
6140            &[n2],
6141            |id| placements.get(&id).copied(),
6142            shares_storage_cpu_devices_only,
6143            no_host_staging,
6144        );
6145        assert_eq!(inserted.len(), 0, "producer with no placement → skip");
6146    }
6147
6148    /// `Op::Copy` and `Op::Move` are themselves transfers — the pass
6149    /// must not try to insert ANOTHER copy on their inputs (would
6150    /// infinite-regress).
6151    #[test]
6152    fn insert_copies_skips_transfer_ops_themselves() {
6153        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6154        let mut g = Graph::new();
6155        let cpu_src = add_node(&mut g, Op::Const, vec![]);
6156        g.set_placement(cpu_src, DeviceLocation::Cpu);
6157        // A user-authored Op::Copy from CPU to CUDA. Should not be
6158        // re-wrapped.
6159        let copy = g.push(Node {
6160            op: Op::Copy { target: cuda },
6161            inputs: vec![cpu_src],
6162            shape: Shape::from_dims(&[4]),
6163            dtype: DType::F32,
6164        });
6165        g.set_placement(copy, cuda);
6166
6167        let pre_len = g.len();
6168        let placements = snapshot_placements(&g);
6169        let inserted = insert_cross_device_copies(
6170            &mut g,
6171            &[copy],
6172            |id| placements.get(&id).copied(),
6173            shares_storage_cpu_devices_only,
6174            no_host_staging,
6175        );
6176        assert_eq!(inserted.len(), 0, "the existing Op::Copy is not re-wrapped");
6177        assert_eq!(g.len(), pre_len);
6178    }
6179
6180    /// Pass is idempotent — re-running on a graph already rewritten
6181    /// adds zero new copies. Phase 2.1 variant.
6182    #[test]
6183    fn insert_cross_device_copies_idempotent() {
6184        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6185        let (mut g, _n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6186        let placements_before = snapshot_placements(&g);
6187        let first = insert_cross_device_copies(
6188            &mut g,
6189            &[n2],
6190            |id| placements_before.get(&id).copied(),
6191            shares_storage_cpu_devices_only,
6192            no_host_staging,
6193        );
6194        let placements_after = snapshot_placements(&g);
6195        let second = insert_cross_device_copies(
6196            &mut g,
6197            &[n2],
6198            |id| placements_after.get(&id).copied(),
6199            shares_storage_cpu_devices_only,
6200            no_host_staging,
6201        );
6202        assert_eq!(first.len(), 1);
6203        assert_eq!(
6204            second.len(),
6205            0,
6206            "re-run sees inserted-Copy as on-target; no new copies"
6207        );
6208    }
6209
6210    /// Cross-GPU edge (CUDA gpu_id 0 → CUDA gpu_id 1) → copy inserted
6211    /// (devices don't share storage even though both are CUDA).
6212    #[test]
6213    fn insert_copies_across_gpu_ids_within_one_backend() {
6214        let cuda0 = DeviceLocation::Cuda { gpu_id: 0 };
6215        let cuda1 = DeviceLocation::Cuda { gpu_id: 1 };
6216        let (mut g, _n1, n2) = build_two_node_graph_with_placements(cuda0, cuda1);
6217        let placements = snapshot_placements(&g);
6218        let inserted = insert_cross_device_copies(
6219            &mut g,
6220            &[n2],
6221            |id| placements.get(&id).copied(),
6222            shares_storage_cpu_devices_only,
6223            no_host_staging,
6224        );
6225        assert_eq!(inserted.len(), 1, "cross-gpu_id is cross-device too");
6226        let copy_id = g.node(n2).inputs[0];
6227        let copy_node = g.node(copy_id);
6228        assert!(matches!(copy_node.op, Op::Copy { target } if target == cuda1));
6229    }
6230
6231    /// Cross-VENDOR GPU↔GPU edge (Vulkan → CUDA) with `needs_host_staging`
6232    /// returning true → TWO `Op::Copy` hops through a CPU intermediate:
6233    /// the consumer reads `Op::Copy{target:Cuda}`, which reads
6234    /// `Op::Copy{target:Cpu}`, which reads the Vulkan producer.
6235    #[test]
6236    fn insert_copies_cross_vendor_gpu_inserts_two_hops_through_cpu() {
6237        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6238        let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6239        let (mut g, vk_src, cuda_consumer) = build_two_node_graph_with_placements(vulkan, cuda);
6240        let pre_len = g.len();
6241        let placements = snapshot_placements(&g);
6242        let inserted = insert_cross_device_copies(
6243            &mut g,
6244            &[cuda_consumer],
6245            |id| placements.get(&id).copied(),
6246            shares_storage_cpu_devices_only,
6247            host_staging_cross_vendor_gpu,
6248        );
6249        assert_eq!(inserted.len(), 2, "cross-vendor GPU↔GPU → two hops");
6250        assert_eq!(g.len(), pre_len + 2, "two new Op::Copy nodes appended");
6251
6252        // Consumer reads the consumer-side hop (Copy → Cuda).
6253        let hop2 = g.node(cuda_consumer).inputs[0];
6254        let hop2_node = g.node(hop2);
6255        assert!(
6256            matches!(hop2_node.op, Op::Copy { target } if target == cuda),
6257            "consumer-side hop targets CUDA; got {:?}",
6258            hop2_node.op,
6259        );
6260        assert_eq!(
6261            g.placement(hop2),
6262            Some(cuda),
6263            "consumer-side hop lands on CUDA"
6264        );
6265
6266        // The consumer-side hop reads the CPU intermediate (Copy → Cpu).
6267        let hop1 = hop2_node.inputs[0];
6268        let hop1_node = g.node(hop1);
6269        assert!(
6270            matches!(hop1_node.op, Op::Copy { target } if target == DeviceLocation::Cpu),
6271            "CPU-intermediate hop targets Cpu; got {:?}",
6272            hop1_node.op,
6273        );
6274        assert_eq!(
6275            g.placement(hop1),
6276            Some(DeviceLocation::Cpu),
6277            "CPU intermediate is placed on CPU",
6278        );
6279
6280        // The CPU intermediate reads the original Vulkan producer.
6281        assert_eq!(
6282            hop1_node.inputs,
6283            vec![vk_src],
6284            "CPU hop reads the Vulkan producer"
6285        );
6286    }
6287
6288    /// Two-hop edge is idempotent: re-running adds zero new copies. The
6289    /// consumer now reads a same-device `Op::Copy`; the CPU intermediate's
6290    /// consumer is also an `Op::Copy` (skipped as a transfer op).
6291    #[test]
6292    fn insert_copies_cross_vendor_two_hop_idempotent() {
6293        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6294        let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6295        let (mut g, _vk_src, cuda_consumer) = build_two_node_graph_with_placements(vulkan, cuda);
6296        let p1 = snapshot_placements(&g);
6297        let first = insert_cross_device_copies(
6298            &mut g,
6299            &[cuda_consumer],
6300            |id| p1.get(&id).copied(),
6301            shares_storage_cpu_devices_only,
6302            host_staging_cross_vendor_gpu,
6303        );
6304        let p2 = snapshot_placements(&g);
6305        let second = insert_cross_device_copies(
6306            &mut g,
6307            &[cuda_consumer],
6308            |id| p2.get(&id).copied(),
6309            shares_storage_cpu_devices_only,
6310            host_staging_cross_vendor_gpu,
6311        );
6312        assert_eq!(first.len(), 2, "two hops on first run");
6313        assert_eq!(second.len(), 0, "re-run adds no new copies");
6314    }
6315
6316    /// CSE on a two-hop edge: one Vulkan producer feeding TWO CUDA
6317    /// consumers stages to CPU exactly once (one CPU intermediate), then
6318    /// one consumer-side CUDA hop shared by both → 2 inserted copies
6319    /// total, not 4.
6320    #[test]
6321    fn insert_copies_cross_vendor_two_hop_cse_shares_cpu_intermediate() {
6322        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6323        let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6324        let mut g = Graph::new();
6325        let vk_src = add_node(&mut g, Op::Const, vec![]);
6326        g.set_placement(vk_src, vulkan);
6327        let cuda_a = add_node(&mut g, Op::Neg, vec![vk_src]);
6328        let cuda_b = add_node(&mut g, Op::Sqr, vec![vk_src]);
6329        g.set_placement(cuda_a, cuda);
6330        g.set_placement(cuda_b, cuda);
6331
6332        let placements = snapshot_placements(&g);
6333        let inserted = insert_cross_device_copies(
6334            &mut g,
6335            &[cuda_a, cuda_b],
6336            |id| placements.get(&id).copied(),
6337            shares_storage_cpu_devices_only,
6338            host_staging_cross_vendor_gpu,
6339        );
6340        // One CPU intermediate + one shared CUDA hop = 2.
6341        assert_eq!(
6342            inserted.len(),
6343            2,
6344            "CSE — one CPU intermediate + one CUDA hop"
6345        );
6346        let a_input = g.node(cuda_a).inputs[0];
6347        let b_input = g.node(cuda_b).inputs[0];
6348        assert_eq!(
6349            a_input, b_input,
6350            "both CUDA consumers share the consumer-side hop"
6351        );
6352        // That shared hop reads the single CPU intermediate.
6353        let cpu_hop = g.node(a_input).inputs[0];
6354        assert!(matches!(
6355            g.node(cpu_hop).op,
6356            Op::Copy {
6357                target: DeviceLocation::Cpu
6358            }
6359        ));
6360        assert_eq!(g.node(cpu_hop).inputs, vec![vk_src]);
6361    }
6362
6363    /// A CPU↔GPU edge stays SINGLE hop even when the host-staging
6364    /// predicate is the realistic cross-vendor one (which returns false
6365    /// for any CPU-touching edge) — byte-identical to the legacy path.
6366    #[test]
6367    fn insert_copies_cpu_gpu_edge_stays_single_hop_under_cross_vendor_predicate() {
6368        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6369        let (mut g, n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6370        let pre_len = g.len();
6371        let placements = snapshot_placements(&g);
6372        let inserted = insert_cross_device_copies(
6373            &mut g,
6374            &[n2],
6375            |id| placements.get(&id).copied(),
6376            shares_storage_cpu_devices_only,
6377            host_staging_cross_vendor_gpu,
6378        );
6379        assert_eq!(inserted.len(), 1, "CPU↔CUDA is a single hop");
6380        assert_eq!(g.len(), pre_len + 1);
6381        let copy_id = g.node(n2).inputs[0];
6382        assert!(matches!(g.node(copy_id).op, Op::Copy { target } if target == cuda));
6383        assert_eq!(g.node(copy_id).inputs, vec![n1]);
6384    }
6385
6386    /// Per-node `placement_for` can be backed by an external map, not
6387    /// just `Graph::placement`. Smoke test of the closure shape.
6388    #[test]
6389    fn insert_copies_takes_external_placement_oracle() {
6390        let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6391        let (mut g, n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6392        // Clear graph-side placements; back the oracle with an
6393        // external map instead.
6394        let mut external: StdHashMap<NodeId, DeviceLocation> = StdHashMap::new();
6395        external.insert(n1, DeviceLocation::Cpu);
6396        external.insert(n2, cuda);
6397        // Strip graph-side placements so we PROVE the closure is the
6398        // authoritative source.
6399        // (No public setter to clear; instead override by leaving
6400        // graph.placement untouched and using only the external map.)
6401        let _placements = snapshot_placements(&g);
6402        let inserted = insert_cross_device_copies(
6403            &mut g,
6404            &[n2],
6405            |id| external.get(&id).copied(),
6406            shares_storage_cpu_devices_only,
6407            no_host_staging,
6408        );
6409        assert_eq!(inserted.len(), 1);
6410    }
6411
6412    // ===== Phase 2.2 — insert_layout_fixups =====
6413
6414    fn count_contiguize_nodes(graph: &SharedGraph) -> usize {
6415        let g = graph.read().unwrap();
6416        (0..g.len())
6417            .filter(|i| matches!(g.node(NodeId(*i)).op, Op::Contiguize))
6418            .count()
6419    }
6420
6421    /// All-contiguous-input graph: pass adds nothing regardless of
6422    /// the kernel_accepts_strided predicate.
6423    #[test]
6424    fn insert_fixups_no_strided_inputs_no_fixups() {
6425        let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
6426        let b = a
6427            .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
6428            .unwrap();
6429        let c = a.add(&b);
6430        let graph = c.graph().clone();
6431
6432        let before = count_contiguize_nodes(&graph);
6433        let inserted = {
6434            let mut g = graph.write().unwrap();
6435            insert_layout_fixups(&mut g, &[c.id()], |_| false)
6436        };
6437        assert_eq!(inserted, 0);
6438        assert_eq!(count_contiguize_nodes(&graph), before);
6439    }
6440
6441    /// Strided input (Transpose) feeding a kernel that rejects
6442    /// strided: one Contiguize gets inserted between them and the
6443    /// consumer's input gets rewired.
6444    #[test]
6445    fn insert_fixups_strided_input_rejecting_kernel_inserts_contiguize() {
6446        // 2x3 const, transpose to 3x2, then "consumer" reads the
6447        // transposed view. The consumer kernel claims it can't
6448        // handle strided.
6449        let a = NodeHandle::from_f32(
6450            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6451            Shape::from_dims(&[2, 3]),
6452            cpu_dev(),
6453        )
6454        .unwrap();
6455        // Transpose produces a strided view via the layout side-table.
6456        let at = a.transpose();
6457        // A binary op as our "consumer" (Op::Add is kernel-bearing,
6458        // not a view op).
6459        let b = at
6460            .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6461            .unwrap();
6462        let c = at.add(&b);
6463        let graph = c.graph().clone();
6464
6465        let before = count_contiguize_nodes(&graph);
6466        let inserted = {
6467            let mut g = graph.write().unwrap();
6468            // Kernel rejects strided for every node.
6469            insert_layout_fixups(&mut g, &[c.id()], |_| false)
6470        };
6471        // One Contiguize for the transposed input of `c`. (`b` is a
6472        // contiguous Const → no fixup.)
6473        assert_eq!(inserted, 1);
6474        assert_eq!(count_contiguize_nodes(&graph) - before, 1);
6475
6476        // c's first input was `at`; after rewrite it should point
6477        // to the new Op::Contiguize node.
6478        let g = graph.read().unwrap();
6479        let c_node = g.node(c.id());
6480        let new_input = g.node(c_node.inputs[0]);
6481        assert!(
6482            matches!(new_input.op, Op::Contiguize),
6483            "consumer's strided input must be rewired through Op::Contiguize, \
6484             got {:?}",
6485            new_input.op,
6486        );
6487        // The Contiguize's input is the original transposed view.
6488        assert_eq!(new_input.inputs, vec![at.id()]);
6489    }
6490
6491    /// Strided input feeding a kernel that accepts strided: pass
6492    /// skips the input, no Contiguize inserted.
6493    #[test]
6494    fn insert_fixups_strided_input_accepting_kernel_no_fixup() {
6495        let a = NodeHandle::from_f32(
6496            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6497            Shape::from_dims(&[2, 3]),
6498            cpu_dev(),
6499        )
6500        .unwrap();
6501        let at = a.transpose();
6502        let b = at
6503            .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6504            .unwrap();
6505        let c = at.add(&b);
6506        let graph = c.graph().clone();
6507
6508        let before = count_contiguize_nodes(&graph);
6509        let inserted = {
6510            let mut g = graph.write().unwrap();
6511            // Every kernel accepts strided — no fixup.
6512            insert_layout_fixups(&mut g, &[c.id()], |_| true)
6513        };
6514        assert_eq!(inserted, 0);
6515        assert_eq!(count_contiguize_nodes(&graph), before);
6516    }
6517
6518    /// CSE: two non-strided-tolerant consumers reading the same
6519    /// transposed view share one inserted Contiguize node.
6520    #[test]
6521    fn insert_fixups_cse_dedupes_shared_strided_input() {
6522        let a = NodeHandle::from_f32(
6523            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6524            Shape::from_dims(&[2, 3]),
6525            cpu_dev(),
6526        )
6527        .unwrap();
6528        let at = a.transpose();
6529        // Two consumers of the same transposed view.
6530        let b = at
6531            .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6532            .unwrap();
6533        let c1 = at.add(&b);
6534        let c2 = at.mul(&b);
6535        let graph = c1.graph().clone();
6536
6537        let inserted = {
6538            let mut g = graph.write().unwrap();
6539            insert_layout_fixups(&mut g, &[c1.id(), c2.id()], |_| false)
6540        };
6541        // Exactly ONE Contiguize, shared between the two consumers.
6542        assert_eq!(inserted, 1);
6543
6544        let g = graph.read().unwrap();
6545        let c1_input = g.node(c1.id()).inputs[0];
6546        let c2_input = g.node(c2.id()).inputs[0];
6547        assert_eq!(
6548            c1_input, c2_input,
6549            "CSE: both consumers must share the same Contiguize node",
6550        );
6551        assert!(matches!(g.node(c1_input).op, Op::Contiguize));
6552    }
6553
6554    /// Idempotency: running the pass twice on the same graph adds
6555    /// 0 new nodes the second time.
6556    #[test]
6557    fn insert_fixups_idempotent() {
6558        let a = NodeHandle::from_f32(
6559            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6560            Shape::from_dims(&[2, 3]),
6561            cpu_dev(),
6562        )
6563        .unwrap();
6564        let at = a.transpose();
6565        let b = at
6566            .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6567            .unwrap();
6568        let c = at.add(&b);
6569        let graph = c.graph().clone();
6570
6571        let first = {
6572            let mut g = graph.write().unwrap();
6573            insert_layout_fixups(&mut g, &[c.id()], |_| false)
6574        };
6575        assert_eq!(first, 1, "first run inserts one Contiguize");
6576
6577        let second = {
6578            let mut g = graph.write().unwrap();
6579            insert_layout_fixups(&mut g, &[c.id()], |_| false)
6580        };
6581        assert_eq!(second, 0, "second run is a no-op");
6582    }
6583
6584    /// View ops in the consumer position do NOT get Contiguize
6585    /// inserted before them — they preserve strided layouts
6586    /// intentionally as part of their semantics.
6587    #[test]
6588    fn insert_fixups_skips_view_op_consumers() {
6589        let a = NodeHandle::from_f32(
6590            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6591            Shape::from_dims(&[2, 3]),
6592            cpu_dev(),
6593        )
6594        .unwrap();
6595        let at = a.transpose();
6596        // Permute on a transposed view: both are view ops, the
6597        // strided layout flows through unchanged.
6598        let ap = at.permute(&[1, 0]);
6599        let graph = ap.graph().clone();
6600
6601        let inserted = {
6602            let mut g = graph.write().unwrap();
6603            insert_layout_fixups(&mut g, &[ap.id()], |_| false)
6604        };
6605        assert_eq!(
6606            inserted, 0,
6607            "view ops preserve strided layouts; no Contiguize should be inserted",
6608        );
6609    }
6610}