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}