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}