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