Skip to main content

polydat_core/compile/
fusion.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Graph-level node fusion optimization pass.
5//!
6//! Recognizes subgraph patterns in the Polydat DAG and replaces them with
7//! semantically equivalent fused nodes that are computationally cheaper.
8//! Runs during assembly after wiring resolution, before dead code
9//! elimination and topological sort.
10//!
11//! See `crates/polydat/docs/design/graph_compiler.md` §5 for the full
12//! design.
13
14use std::borrow::Cow;
15
16use crate::ast::{Commutativity, ConstValue, PolydatNode};
17use crate::kernel::WireSource;
18
19// ---------------------------------------------------------------------------
20// Pattern types
21// ---------------------------------------------------------------------------
22
23/// A structural pattern that matches a subgraph of the Polydat DAG.
24///
25/// Patterns are trees — each sub-pattern matches exactly one node.
26/// Diamond shapes (two pattern leaves matching the same upstream node)
27/// are handled by bind-name equality checks after matching.
28#[derive(Debug, Clone)]
29pub enum FusionPattern {
30    /// Match a node by its `meta().name` string.
31    ///
32    /// Sub-patterns match the node's inputs (respecting the node's
33    /// declared commutativity). The node's `jit_constants()` are
34    /// captured under `bind`.
35    Node {
36        /// The node's `meta().name` (e.g., "hash", "mod", "add").
37        op: &'static str,
38        /// Sub-patterns for the node's inputs.
39        inputs: Vec<FusionPattern>,
40        /// Binding name for this node's constants in the match result.
41        bind: Cow<'static, str>,
42    },
43
44    /// Match any wire source (coordinate, upstream node output, etc.).
45    /// This is the "hole" — it captures the wire reference for rewiring
46    /// to the fused replacement node.
47    Any {
48        /// Binding name for this wire in the match result.
49        bind: Cow<'static, str>,
50    },
51
52    /// Match a variadic node with N children, applying a sub-pattern
53    /// to each child. Children are bound as `{bind}_0`, `{bind}_1`, etc.
54    ///
55    /// Use for fusion rules that operate on variadic nodes like `sum`
56    /// where the number of inputs isn't known at rule-definition time.
57    VariadicNode {
58        /// The node's `meta().name`.
59        op: &'static str,
60        /// Pattern applied to each child input.
61        child_pattern: Box<FusionPattern>,
62        /// Binding prefix: children bound as `{bind}_0`, `{bind}_1`, ...
63        /// The node's own constants are bound under `{bind}`.
64        bind: Cow<'static, str>,
65        /// Minimum number of children to match.
66        min_children: usize,
67    },
68}
69
70impl FusionPattern {
71    /// Convenience: `node(op, inputs, bind)`.
72    pub fn node(
73        op: &'static str,
74        inputs: Vec<FusionPattern>,
75        bind: impl Into<Cow<'static, str>>,
76    ) -> Self {
77        FusionPattern::Node {
78            op,
79            inputs,
80            bind: bind.into(),
81        }
82    }
83
84    /// Convenience: `any(bind)`.
85    pub fn any(bind: impl Into<Cow<'static, str>>) -> Self {
86        FusionPattern::Any { bind: bind.into() }
87    }
88
89    /// Return the root operation name, if this is a `Node` pattern.
90    pub fn root_op(&self) -> Option<&'static str> {
91        match self {
92            FusionPattern::Node { op, .. } => Some(op),
93            FusionPattern::VariadicNode { op, .. } => Some(op),
94            FusionPattern::Any { .. } => None,
95        }
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Match result
101// ---------------------------------------------------------------------------
102
103/// The result of a successful pattern match against a subgraph.
104#[derive(Debug, Clone)]
105pub struct MatchResult {
106    /// Bound wire sources: `bind_name → WireSource` for each `Any` leaf.
107    pub wires: Vec<(String, WireSource)>,
108
109    /// Bound node constants (JIT u64 form): `bind_name → jit_constants()`.
110    pub constants: Vec<(String, Vec<u64>)>,
111
112    /// Bound typed constants from the slot model.
113    /// Empty for nodes not yet migrated to slots.
114    pub typed_constants: Vec<(String, Vec<ConstValue>)>,
115
116    /// The set of node indices consumed by this match. These nodes
117    /// will be removed from the DAG and replaced by the fused node.
118    pub consumed_nodes: Vec<usize>,
119}
120
121impl MatchResult {
122    fn new() -> Self {
123        Self {
124            wires: Vec::new(),
125            constants: Vec::new(),
126            typed_constants: Vec::new(),
127            consumed_nodes: Vec::new(),
128        }
129    }
130
131    /// Look up a captured wire by binding name.
132    pub fn wire(&self, name: &str) -> &WireSource {
133        self.wires
134            .iter()
135            .find(|(n, _)| n == name)
136            .map(|(_, w)| w)
137            .unwrap_or_else(|| panic!("no wire bound as '{name}'"))
138    }
139
140    /// Look up captured constants (u64 form) by binding name.
141    pub fn const_vec(&self, name: &str) -> &[u64] {
142        self.constants
143            .iter()
144            .find(|(n, _)| n == name)
145            .map(|(_, c)| c.as_slice())
146            .unwrap_or_else(|| panic!("no constants bound as '{name}'"))
147    }
148
149    /// Look up a single captured constant by binding name.
150    pub fn const_u64(&self, name: &str) -> u64 {
151        self.const_vec(name)[0]
152    }
153
154    /// Look up typed constants by binding name.
155    pub fn typed_consts(&self, name: &str) -> &[ConstValue] {
156        self.typed_constants
157            .iter()
158            .find(|(n, _)| n == name)
159            .map(|(_, c)| c.as_slice())
160            .unwrap_or(&[])
161    }
162
163    fn merge(&mut self, other: MatchResult) {
164        self.wires.extend(other.wires);
165        self.constants.extend(other.constants);
166        self.typed_constants.extend(other.typed_constants);
167        self.consumed_nodes.extend(other.consumed_nodes);
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Fusion rule
173// ---------------------------------------------------------------------------
174
175/// A graph rewrite rule: a subgraph pattern and its replacement.
176///
177/// Each rule is a declarative specification. The pattern describes
178/// what to match; the replacement factory produces the fused node;
179/// `input_bindings` maps the fused node's input ports to captured
180/// wire sources by name.
181pub struct FusionRule {
182    /// Human-readable name for diagnostics, logging, and test output.
183    pub name: &'static str,
184
185    /// The subgraph pattern to match.
186    pub pattern: FusionPattern,
187
188    /// Factory: given the match result, produce the replacement fused node.
189    pub replacement: fn(&MatchResult) -> Box<dyn PolydatNode>,
190
191    /// Binding names for the fused node's inputs, in order.
192    /// Each name must correspond to an `Any` leaf in the pattern.
193    pub input_bindings: &'static [&'static str],
194}
195
196// ---------------------------------------------------------------------------
197// Pattern matching engine
198// ---------------------------------------------------------------------------
199
200/// Intermediate node representation used during fusion — borrows from
201/// the pending node list to avoid cloning.
202struct NodeView<'a> {
203    nodes: &'a [Option<Box<dyn PolydatNode>>],
204    wiring: &'a [Vec<WireSource>],
205}
206
207/// Try to match a pattern against the subgraph rooted at `node_idx`.
208fn try_match(
209    pattern: &FusionPattern,
210    source: &WireSource,
211    view: &NodeView<'_>,
212) -> Option<MatchResult> {
213    match pattern {
214        FusionPattern::Any { bind } => {
215            let mut result = MatchResult::new();
216            result.wires.push((bind.to_string(), source.clone()));
217            Some(result)
218        }
219
220        FusionPattern::Node { op, inputs, bind } => {
221            // The source must be a node output (not a coordinate or port).
222            let node_idx = match source {
223                WireSource::NodeOutput(idx, 0) => *idx,
224                _ => return None,
225            };
226
227            let node = view.nodes[node_idx].as_ref()?;
228
229            // Check the node's operation name matches.
230            if node.meta().name != *op {
231                return None;
232            }
233
234            // Check arity matches.
235            let node_wiring = &view.wiring[node_idx];
236            if node_wiring.len() != inputs.len() {
237                return None;
238            }
239
240            // Try to match sub-patterns against the node's inputs,
241            // respecting the node's commutativity declaration.
242            let matched = match_inputs(inputs, node_wiring, &node.commutativity(), view)?;
243
244            let mut result = matched;
245            result
246                .constants
247                .push((bind.to_string(), node.jit_constants()));
248
249            // Also capture typed constants from the slot model.
250            let typed: Vec<ConstValue> = node
251                .meta()
252                .const_slots()
253                .iter()
254                .map(|c| c.1.clone())
255                .collect();
256            if !typed.is_empty() {
257                result.typed_constants.push((bind.to_string(), typed));
258            }
259
260            result.consumed_nodes.push(node_idx);
261            Some(result)
262        }
263
264        FusionPattern::VariadicNode {
265            op,
266            child_pattern,
267            bind,
268            min_children,
269        } => {
270            let node_idx = match source {
271                WireSource::NodeOutput(idx, 0) => *idx,
272                _ => return None,
273            };
274
275            let node = view.nodes[node_idx].as_ref()?;
276            if node.meta().name != *op {
277                return None;
278            }
279
280            let node_wiring = &view.wiring[node_idx];
281            if node_wiring.len() < *min_children {
282                return None;
283            }
284
285            // Match each child input against the child pattern.
286            let mut result = MatchResult::new();
287            for (i, wire) in node_wiring.iter().enumerate() {
288                let child_bind = format!("{bind}_{i}");
289                // For Any patterns, override the bind name with the indexed one.
290                let indexed_pattern = match child_pattern.as_ref() {
291                    // Owned bind — no leak (the former `Box::leak`
292                    // fabricated a `'static` per child match; the
293                    // `Cow` carries ownership instead).
294                    FusionPattern::Any { .. } => FusionPattern::Any {
295                        bind: Cow::Owned(child_bind.clone()),
296                    },
297                    _ => *child_pattern.clone(),
298                };
299                let m = try_match(&indexed_pattern, wire, view)?;
300                result.merge(m);
301            }
302
303            // Capture the variadic node's own constants.
304            result
305                .constants
306                .push((bind.to_string(), node.jit_constants()));
307            let typed: Vec<ConstValue> = node
308                .meta()
309                .const_slots()
310                .iter()
311                .map(|c| c.1.clone())
312                .collect();
313            if !typed.is_empty() {
314                result.typed_constants.push((bind.to_string(), typed));
315            }
316
317            result.consumed_nodes.push(node_idx);
318            Some(result)
319        }
320    }
321}
322
323/// Match a list of sub-patterns against a node's input wires,
324/// respecting the node's commutativity.
325fn match_inputs(
326    patterns: &[FusionPattern],
327    wires: &[WireSource],
328    commutativity: &Commutativity,
329    view: &NodeView<'_>,
330) -> Option<MatchResult> {
331    match commutativity {
332        Commutativity::Positional => match_positional(patterns, wires, view),
333
334        Commutativity::AllCommutative => {
335            // Try all permutations of wire indices.
336            let indices: Vec<usize> = (0..wires.len()).collect();
337            for perm in permutations(&indices) {
338                let reordered: Vec<&WireSource> = perm.iter().map(|&i| &wires[i]).collect();
339                if let Some(m) = match_ordered(patterns, &reordered, view) {
340                    return Some(m);
341                }
342            }
343            None
344        }
345
346        Commutativity::Groups(groups) => {
347            // Build the set of indices that belong to some group.
348            let mut in_group = vec![false; wires.len()];
349            for g in groups {
350                for &idx in g {
351                    if idx < in_group.len() {
352                        in_group[idx] = true;
353                    }
354                }
355            }
356
357            // Start with positional matching for non-group indices.
358            // Then try permutations within each group.
359            try_groups_match(patterns, wires, groups, &in_group, view)
360        }
361    }
362}
363
364/// Positional matching: patterns[i] matches wires[i] in order.
365fn match_positional(
366    patterns: &[FusionPattern],
367    wires: &[WireSource],
368    view: &NodeView<'_>,
369) -> Option<MatchResult> {
370    let refs: Vec<&WireSource> = wires.iter().collect();
371    match_ordered(patterns, &refs, view)
372}
373
374/// Match patterns against wires in the given order.
375fn match_ordered(
376    patterns: &[FusionPattern],
377    wires: &[&WireSource],
378    view: &NodeView<'_>,
379) -> Option<MatchResult> {
380    let mut result = MatchResult::new();
381    for (pat, wire) in patterns.iter().zip(wires.iter()) {
382        let m = try_match(pat, wire, view)?;
383        result.merge(m);
384    }
385    Some(result)
386}
387
388/// Match with grouped commutativity: try permutations within each
389/// group, positional for everything else.
390fn try_groups_match(
391    patterns: &[FusionPattern],
392    wires: &[WireSource],
393    groups: &[Vec<usize>],
394    _in_group: &[bool],
395    view: &NodeView<'_>,
396) -> Option<MatchResult> {
397    // Generate all combinations of per-group permutations.
398    // For typical groups (size 2-3), this is small.
399    let mut index_map: Vec<usize> = (0..wires.len()).collect();
400
401    fn recurse(
402        group_idx: usize,
403        groups: &[Vec<usize>],
404        index_map: &mut Vec<usize>,
405        patterns: &[FusionPattern],
406        wires: &[WireSource],
407        view: &NodeView<'_>,
408    ) -> Option<MatchResult> {
409        if group_idx >= groups.len() {
410            // All groups assigned — try matching with this mapping.
411            let reordered: Vec<&WireSource> = index_map.iter().map(|&i| &wires[i]).collect();
412            return match_ordered(patterns, &reordered, view);
413        }
414
415        let group = &groups[group_idx];
416        let original_values: Vec<usize> = group.iter().map(|&i| index_map[i]).collect();
417
418        for perm in permutations(&original_values) {
419            for (slot, &val) in group.iter().zip(perm.iter()) {
420                index_map[*slot] = val;
421            }
422            if let Some(m) = recurse(group_idx + 1, groups, index_map, patterns, wires, view) {
423                return Some(m);
424            }
425        }
426
427        // Restore original values.
428        for (slot, val) in group.iter().zip(original_values.iter()) {
429            index_map[*slot] = *val;
430        }
431        None
432    }
433
434    recurse(0, groups, &mut index_map, patterns, wires, view)
435}
436
437/// Generate all permutations of a small slice (expected size <= 4).
438fn permutations(items: &[usize]) -> Vec<Vec<usize>> {
439    if items.len() <= 1 {
440        return vec![items.to_vec()];
441    }
442    let mut result = Vec::new();
443    for (i, &item) in items.iter().enumerate() {
444        let rest: Vec<usize> = items
445            .iter()
446            .enumerate()
447            .filter(|(j, _)| *j != i)
448            .map(|(_, &v)| v)
449            .collect();
450        for mut perm in permutations(&rest) {
451            perm.insert(0, item);
452            result.push(perm);
453        }
454    }
455    result
456}
457
458// ---------------------------------------------------------------------------
459// Fusion pass
460// ---------------------------------------------------------------------------
461
462/// Apply all fusion rules to the node graph, returning the number of
463/// fusions applied.
464///
465/// Operates on mutable vectors of nodes and wiring. Nodes consumed by
466/// fusion are replaced with `None` (removed during later DCE/topo sort).
467///
468/// The pass runs to a fixed point: it repeats until no more rules match.
469/// Apply all fusion rules to the node graph, returning the number of
470/// fusions applied.
471///
472/// `output_nodes` lists node indices that are directly referenced by
473/// named outputs — these must not be consumed as interior nodes.
474pub fn apply_fusions(
475    nodes: &mut Vec<Option<Box<dyn PolydatNode>>>,
476    wiring: &mut Vec<Vec<WireSource>>,
477    name_to_idx: &mut std::collections::HashMap<String, usize>,
478    rules: &[FusionRule],
479    output_nodes: &[usize],
480) -> usize {
481    let mut total_fused = 0;
482
483    loop {
484        let mut fused_this_pass = false;
485
486        // Compute consumer counts for the external-consumer guard.
487        let consumer_counts = compute_consumer_counts(nodes, wiring);
488
489        let view = NodeView { nodes, wiring };
490
491        // Try each rule against each node.
492        let mut best_match: Option<(usize, MatchResult, &FusionRule)> = None;
493
494        'rule_loop: for rule in rules {
495            let root_op = match rule.pattern.root_op() {
496                Some(op) => op,
497                None => continue,
498            };
499
500            for node_idx in 0..view.nodes.len() {
501                let node = match &view.nodes[node_idx] {
502                    Some(n) => n,
503                    None => continue, // already consumed
504                };
505
506                if node.meta().name != root_op {
507                    continue;
508                }
509
510                // Try matching the pattern rooted at this node.
511                let source = WireSource::NodeOutput(node_idx, 0);
512                let result = match try_match(&rule.pattern, &source, &view) {
513                    Some(r) => r,
514                    None => continue,
515                };
516
517                // External consumer guard: intermediate nodes (all consumed
518                // nodes except the root) must have no consumers outside the
519                // matched subgraph, and must not be output-referenced.
520                if !check_consumer_guard(&result, node_idx, &consumer_counts, output_nodes) {
521                    continue;
522                }
523
524                best_match = Some((node_idx, result, rule));
525                break 'rule_loop;
526            }
527        }
528
529        // Apply the best match, if any.
530        if let Some((root_idx, result, rule)) = best_match {
531            apply_single_fusion(root_idx, &result, rule, nodes, wiring, name_to_idx);
532            total_fused += 1;
533            fused_this_pass = true;
534        }
535
536        if !fused_this_pass {
537            break;
538        }
539    }
540
541    total_fused
542}
543
544/// Check that intermediate consumed nodes have no external consumers.
545///
546/// The root node is allowed to have external consumers — they'll be
547/// rewired to the fused replacement. But interior nodes that get
548/// deleted must not have consumers outside the matched subgraph.
549fn check_consumer_guard(
550    result: &MatchResult,
551    root_idx: usize,
552    consumer_counts: &[usize],
553    output_nodes: &[usize],
554) -> bool {
555    for &consumed in &result.consumed_nodes {
556        if consumed == root_idx {
557            // Root node — consumers will be rewired.
558            continue;
559        }
560        // Interior node must not be directly referenced by an output.
561        if output_nodes.contains(&consumed) {
562            return false;
563        }
564        // Interior node should have exactly 1 consumer (its parent
565        // in the pattern). If it has more, someone else reads from it.
566        if consumer_counts[consumed] > 1 {
567            return false;
568        }
569    }
570    true
571}
572
573/// Compute how many downstream nodes consume each node's output.
574fn compute_consumer_counts(
575    nodes: &[Option<Box<dyn PolydatNode>>],
576    wiring: &[Vec<WireSource>],
577) -> Vec<usize> {
578    let mut counts = vec![0usize; nodes.len()];
579    for (node_idx, node_wiring) in wiring.iter().enumerate() {
580        if nodes[node_idx].is_none() {
581            continue;
582        }
583        for source in node_wiring {
584            if let WireSource::NodeOutput(upstream, _) = source {
585                counts[*upstream] += 1;
586            }
587        }
588    }
589    counts
590}
591
592/// Apply a single fusion: replace the matched subgraph with the fused node.
593fn apply_single_fusion(
594    root_idx: usize,
595    result: &MatchResult,
596    rule: &FusionRule,
597    nodes: &mut [Option<Box<dyn PolydatNode>>],
598    wiring: &mut [Vec<WireSource>],
599    name_to_idx: &mut std::collections::HashMap<String, usize>,
600) {
601    // Build the fused node.
602    let fused_node = (rule.replacement)(result);
603    let _fused_name = fused_node.meta().name.clone();
604
605    // Build wiring for the fused node from the captured wire bindings.
606    let fused_wiring: Vec<WireSource> = rule
607        .input_bindings
608        .iter()
609        .map(|bind_name| result.wire(bind_name).clone())
610        .collect();
611
612    // Remove consumed interior nodes (not the root — we reuse its slot).
613    for &consumed in &result.consumed_nodes {
614        if consumed != root_idx {
615            nodes[consumed] = None;
616            wiring[consumed] = Vec::new();
617        }
618    }
619
620    // Replace the root node with the fused node.
621    nodes[root_idx] = Some(fused_node);
622    wiring[root_idx] = fused_wiring;
623
624    // Update name map: remove names for consumed interior nodes.
625    // Keep the root node's name(s) so downstream references still resolve.
626    name_to_idx.retain(|_, &mut idx| !result.consumed_nodes.contains(&idx) || idx == root_idx);
627
628    // Rewire any downstream nodes that referenced consumed interior
629    // nodes. This shouldn't happen if the consumer guard passed, but
630    // handle it defensively.
631    // (The root node keeps its index, so downstream refs to it are fine.)
632}
633
634// ---------------------------------------------------------------------------
635// Built-in fusion rules
636// ---------------------------------------------------------------------------
637
638/// A fusion rule a node crate contributes: the node library registers
639/// its rules through `inventory`, so the compiler knows no node by name
640/// (`polydat_nodes::hash`, `polydat_nodes::lerp`). Rules apply in
641/// ascending `priority`, then by name, so the order is the same
642/// however the crates link.
643pub struct FusionRuleRegistration {
644    /// Where the rule sits in the order rules are tried.
645    pub priority: u32,
646    /// Builds the rule.
647    pub build: fn() -> FusionRule,
648}
649
650inventory::collect!(FusionRuleRegistration);
651
652/// The fusion rules applied during assembly: every registered rule,
653/// in priority order. Each rule's correctness is verified by the
654/// equivalence tests beside the nodes it names.
655pub fn default_rules() -> Vec<FusionRule> {
656    let mut regs: Vec<&FusionRuleRegistration> = inventory::iter::<FusionRuleRegistration>
657        .into_iter()
658        .collect();
659    regs.sort_by_key(|r| (r.priority, (r.build)().name));
660    regs.into_iter().map(|r| (r.build)()).collect()
661}
662
663// ---------------------------------------------------------------------------
664// Equivalence testing support
665// ---------------------------------------------------------------------------
666
667/// A mini-DAG used for equivalence testing.
668///
669/// Built by fused nodes to represent their unfused (decomposed) form.
670/// Not used at runtime — only in tests.
671pub struct DecomposedGraph {
672    /// External inputs the graph takes.
673    pub input_count: usize,
674    /// Each node with the wiring of its inputs, in order.
675    pub nodes: Vec<(Box<dyn PolydatNode>, Vec<DecomposedWire>)>,
676    /// Where each output comes from.
677    pub output_wires: Vec<DecomposedWire>,
678}
679
680/// Wire source within a `DecomposedGraph`.
681#[derive(Debug, Clone)]
682pub enum DecomposedWire {
683    /// One of the graph's external inputs, by index.
684    Input(usize),
685    /// Output of a node within this graph: (node_index, port).
686    Node(usize, usize),
687}
688
689impl DecomposedGraph {
690    /// An empty graph over `input_count` inputs.
691    pub fn new(input_count: usize) -> Self {
692        Self {
693            input_count,
694            nodes: Vec::new(),
695            output_wires: Vec::new(),
696        }
697    }
698
699    /// Add a node and return its index.
700    pub fn add_node(&mut self, node: Box<dyn PolydatNode>, wires: Vec<DecomposedWire>) -> usize {
701        let idx = self.nodes.len();
702        self.nodes.push((node, wires));
703        idx
704    }
705
706    /// Set the output wire(s) of this graph.
707    pub fn set_outputs(&mut self, wires: Vec<DecomposedWire>) {
708        self.output_wires = wires;
709    }
710
711    /// Evaluate this decomposed graph on the given inputs.
712    /// Returns the output values.
713    pub fn eval(&self, inputs: &[crate::ast::Value]) -> Vec<crate::ast::Value> {
714        use crate::ast::Value;
715
716        let mut node_outputs: Vec<Vec<Value>> = Vec::new();
717
718        for (node, wire_sources) in &self.nodes {
719            // Gather inputs for this node.
720            let node_inputs: Vec<Value> = wire_sources
721                .iter()
722                .map(|w| match w {
723                    DecomposedWire::Input(i) => inputs[*i].clone(),
724                    DecomposedWire::Node(n, p) => node_outputs[*n][*p].clone(),
725                })
726                .collect();
727
728            // Evaluate.
729            let output_count = node.meta().outs.len();
730            let mut outputs = vec![Value::None; output_count];
731            node.eval(&node_inputs, &mut outputs);
732            node_outputs.push(outputs);
733        }
734
735        // Gather final outputs.
736        self.output_wires
737            .iter()
738            .map(|w| match w {
739                DecomposedWire::Input(i) => inputs[*i].clone(),
740                DecomposedWire::Node(n, p) => node_outputs[*n][*p].clone(),
741            })
742            .collect()
743    }
744}
745
746/// Trait for fused nodes that carry an equivalence contract.
747///
748/// Any node produced by a fusion rule's `replacement` factory should
749/// implement this to enable automated equivalence testing.
750pub trait FusedNode: PolydatNode {
751    /// Build the decomposed (unfused) subgraph that this node is
752    /// semantically equivalent to.
753    fn decomposed(&self) -> DecomposedGraph;
754}
755
756// ---------------------------------------------------------------------------
757// Tests
758// ---------------------------------------------------------------------------
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    #[test]
765    fn permutations_small() {
766        let p = permutations(&[0, 1]);
767        assert_eq!(p.len(), 2);
768        assert!(p.contains(&vec![0, 1]));
769        assert!(p.contains(&vec![1, 0]));
770
771        let p3 = permutations(&[0, 1, 2]);
772        assert_eq!(p3.len(), 6);
773    }
774
775    #[test]
776    fn permutations_single() {
777        let p = permutations(&[42]);
778        assert_eq!(p, vec![vec![42]]);
779    }
780
781    #[test]
782    fn permutations_empty() {
783        let p: Vec<Vec<usize>> = permutations(&[]);
784        assert_eq!(p, vec![Vec::<usize>::new()]);
785    }
786
787    // -------------------------------------------------------------------
788    // Equivalence property tests
789    // -------------------------------------------------------------------
790
791    // --- VariadicNode pattern tests ---
792
793    #[test]
794    fn match_result_string_bindings() {
795        // Verify String bindings work correctly for lookup.
796        let mut m = MatchResult::new();
797        m.wires.push(("x".to_string(), WireSource::Input(0)));
798        m.constants.push(("mod_node".to_string(), vec![42]));
799        m.typed_constants.push((
800            "mod_node".to_string(),
801            vec![crate::ast::ConstValue::U64(42)],
802        ));
803
804        assert_eq!(m.const_u64("mod_node"), 42);
805        assert_eq!(m.typed_consts("mod_node").len(), 1);
806        assert!(matches!(m.wire("x"), WireSource::Input(0)));
807    }
808}