Skip to main content

problemreductions/rules/
graph.rs

1//! Runtime reduction graph for discovering and executing reduction paths.
2//!
3//! The graph uses variant-level nodes: each node is a unique `(problem_name, variant)` pair.
4//! Nodes come from `VariantEntry` inventory, and `ReductionEntry` inventory supplies edges.
5//!
6//! Edges combine registered constructions and their result mappings.
7//!
8//! This module implements:
9//! - Variant-level graph construction from `VariantEntry` and `ReductionEntry` inventory
10//! - Symbolic path composition and concrete path execution
11//! - JSON export for documentation and visualization
12
13use crate::rules::registry::{
14    AggregateReduceFn, EdgeCapabilities, ParameterContractError, ReduceFn,
15    ReductionParameterContract,
16};
17use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult};
18use crate::types::ProblemParameters;
19use petgraph::algo::all_simple_paths;
20use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex};
21use petgraph::visit::EdgeRef;
22use serde::Serialize;
23use std::any::Any;
24use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
25use std::rc::Rc;
26
27type NodePathOrderKey<'a> = (usize, Vec<(&'static str, &'a BTreeMap<String, String>)>);
28
29/// A source/target pair from the reduction graph, returned by
30/// [`ReductionGraph::outgoing_reductions`] and [`ReductionGraph::incoming_reductions`].
31#[derive(Debug, Clone)]
32pub struct ReductionEdgeInfo {
33    pub source_name: &'static str,
34    pub source_variant: BTreeMap<String, String>,
35    pub target_name: &'static str,
36    pub target_variant: BTreeMap<String, String>,
37    pub parameter_contract: Result<ReductionParameterContract, ParameterContractError>,
38    pub capabilities: EdgeCapabilities,
39}
40
41/// Internal edge data combining explicit parameter contracts and executable reduction functions.
42#[derive(Clone)]
43pub(crate) struct ReductionEdgeData {
44    pub parameter_contract: Result<ReductionParameterContract, ParameterContractError>,
45    pub reduce_fn: Option<ReduceFn>,
46    pub reduce_aggregate_fn: Option<AggregateReduceFn>,
47    pub aggregate_view_fn: Option<crate::rules::registry::AggregateViewFn>,
48    pub turing: bool,
49}
50
51impl ReductionEdgeData {
52    fn capabilities(&self) -> EdgeCapabilities {
53        EdgeCapabilities::from_executors(self.reduce_fn, self.reduce_aggregate_fn, self.turing)
54    }
55}
56
57/// JSON-serializable representation of the reduction graph.
58#[derive(Debug, Clone, Serialize)]
59pub(crate) struct ReductionGraphJson {
60    /// List of problem type nodes.
61    pub(crate) nodes: Vec<NodeJson>,
62    /// List of reduction edges.
63    pub(crate) edges: Vec<EdgeJson>,
64}
65
66impl ReductionGraphJson {
67    /// Get the source node of an edge.
68    #[cfg_attr(not(test), allow(dead_code))]
69    pub(crate) fn source_node(&self, edge: &EdgeJson) -> &NodeJson {
70        &self.nodes[edge.source]
71    }
72
73    /// Get the target node of an edge.
74    #[cfg_attr(not(test), allow(dead_code))]
75    pub(crate) fn target_node(&self, edge: &EdgeJson) -> &NodeJson {
76        &self.nodes[edge.target]
77    }
78}
79
80/// A node in the reduction graph JSON.
81#[derive(Debug, Clone, Serialize)]
82pub(crate) struct NodeJson {
83    /// Base problem name (e.g., "MaximumIndependentSet").
84    pub(crate) name: String,
85    /// Variant attributes as key-value pairs.
86    pub(crate) variant: BTreeMap<String, String>,
87    /// Structural category declared by the problem schema.
88    pub(crate) category: crate::registry::ProblemCategory,
89    /// Relative rustdoc path (e.g., "models/graph/maximum_independent_set").
90    pub(crate) doc_path: String,
91    /// Worst-case time complexity expression (empty if not declared).
92    pub(crate) complexity: String,
93}
94
95/// Internal reference to a problem variant, used as HashMap key.
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97struct VariantRef {
98    name: String,
99    variant: BTreeMap<String, String>,
100}
101
102/// One explicitly classified target parameter field in graph export.
103#[derive(Debug, Clone, Serialize)]
104pub(crate) struct ParameterFieldJson {
105    pub(crate) field: String,
106    pub(crate) contract: &'static str,
107    pub(crate) formula: Option<String>,
108    pub(crate) reason: Option<String>,
109}
110
111/// An edge in the reduction graph JSON.
112#[derive(Debug, Clone, Serialize)]
113pub(crate) struct EdgeJson {
114    /// Index into the `nodes` array for the source problem variant.
115    pub(crate) source: usize,
116    /// Index into the `nodes` array for the target problem variant.
117    pub(crate) target: usize,
118    /// Symbolic or unavailable target-parameter fields.
119    pub(crate) parameters: Vec<ParameterFieldJson>,
120    pub(crate) parameter_contract_error: Option<String>,
121    /// Relative rustdoc path for the reduction module.
122    pub(crate) doc_path: String,
123    /// Whether the edge supports witness/config workflows.
124    pub(crate) witness: bool,
125    /// Whether the edge supports aggregate/value workflows.
126    pub(crate) aggregate: bool,
127    /// Whether the edge is a Turing (multi-query) reduction.
128    pub(crate) turing: bool,
129}
130
131/// A path through the variant-level reduction graph.
132#[derive(Debug, Clone)]
133pub struct ReductionPath {
134    /// Variant-level steps in the path.
135    pub steps: Vec<ReductionStep>,
136}
137
138/// A selected concrete path batch could not be executed.
139#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
140pub enum ExecutePathsError {
141    #[error("concrete path {path_index} is empty")]
142    EmptyPath { path_index: usize },
143    #[error("concrete path {path_index} contains no reduction edge")]
144    NoEdges { path_index: usize },
145    #[error("concrete path {path_index} starts at a different source node")]
146    DifferentSource { path_index: usize },
147    #[error("concrete path {path_index} references unknown node {problem} {variant:?}")]
148    UnknownNode {
149        path_index: usize,
150        problem: String,
151        variant: BTreeMap<String, String>,
152    },
153    #[error("concrete path {path_index} has no registered edge from {source_problem} to {target_problem}")]
154    MissingEdge {
155        path_index: usize,
156        source_problem: String,
157        target_problem: String,
158    },
159    #[error("concrete path {path_index} edge {source_problem} -> {target_problem} is not witness-executable")]
160    NotWitnessExecutable {
161        path_index: usize,
162        source_problem: String,
163        target_problem: String,
164    },
165    #[error("concrete path {path_index} failed during reduction: {cause}")]
166    Reduction {
167        path_index: usize,
168        #[source]
169        cause: crate::rules::ReductionError,
170    },
171}
172
173/// Why symbolic parameter propagation could not be completed for a path.
174#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
175pub enum PathParameterError {
176    #[error("cannot compose an empty reduction path")]
177    EmptyPath,
178    #[error("reduction path references unknown node {problem} {variant:?}")]
179    UnknownNode {
180        problem: String,
181        variant: BTreeMap<String, String>,
182    },
183    #[error(
184        "reduction path contains no registered edge from {source_problem} to {target_problem}"
185    )]
186    MissingEdge {
187        source_problem: String,
188        target_problem: String,
189    },
190    #[error("reduction step {step} ({source_problem} -> {target_problem}) is a multi-query reduction without a query-cost model")]
191    TuringEdge {
192        step: usize,
193        source_problem: String,
194        target_problem: String,
195    },
196    #[error("reduction step {step} ({source_problem} -> {target_problem}) has an invalid parameter contract: {error}")]
197    InvalidContract {
198        step: usize,
199        source_problem: String,
200        target_problem: String,
201        #[source]
202        error: Box<ParameterContractError>,
203    },
204    #[error("reduction step {step} ({source_problem} -> {target_problem}) has no symbolic parameter transform")]
205    Unavailable {
206        step: usize,
207        source_problem: String,
208        target_problem: String,
209    },
210    #[error(
211        "cannot compose reduction step {step} ({source_problem} -> {target_problem}): {error}"
212    )]
213    Step {
214        step: usize,
215        source_problem: String,
216        target_problem: String,
217        #[source]
218        error: Box<crate::parameters::ParameterTransformError>,
219    },
220}
221
222impl ReductionPath {
223    /// Number of edges (reductions) in the path.
224    pub fn len(&self) -> usize {
225        if self.steps.is_empty() {
226            0
227        } else {
228            self.steps.len() - 1
229        }
230    }
231
232    /// Whether the path is empty.
233    pub fn is_empty(&self) -> bool {
234        self.steps.is_empty()
235    }
236
237    /// Source problem name.
238    pub fn source(&self) -> Option<&str> {
239        self.steps.first().map(|s| s.name.as_str())
240    }
241
242    /// Target problem name.
243    pub fn target(&self) -> Option<&str> {
244        self.steps.last().map(|s| s.name.as_str())
245    }
246
247    /// Name-level path (deduplicated consecutive same-name steps).
248    pub fn type_names(&self) -> Vec<&str> {
249        let mut names: Vec<&str> = Vec::new();
250        for step in &self.steps {
251            if names.last() != Some(&step.name.as_str()) {
252                names.push(&step.name);
253            }
254        }
255        names
256    }
257}
258
259impl std::fmt::Display for ReductionPath {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        let mut prev_name = "";
262        for step in &self.steps {
263            if step.name != prev_name {
264                if prev_name.is_empty() {
265                    write!(f, "{step}")?;
266                } else {
267                    write!(f, " → {step}")?;
268                }
269                prev_name = &step.name;
270            }
271        }
272        Ok(())
273    }
274}
275
276/// A node in a variant-level reduction path.
277#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize)]
278pub struct ReductionStep {
279    /// Problem name (e.g., "MaximumIndependentSet").
280    pub name: String,
281    /// Variant at this point (e.g., {"graph": "KingsSubgraph", "weight": "i64"}).
282    pub variant: BTreeMap<String, String>,
283}
284
285impl std::fmt::Display for ReductionStep {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        write!(f, "{}", self.name)?;
288        if !self.variant.is_empty() {
289            let vars: Vec<_> = self
290                .variant
291                .iter()
292                .map(|(k, v)| format!("{k}: {v:?}"))
293                .collect();
294            write!(f, " {{{}}}", vars.join(", "))?;
295        }
296        Ok(())
297    }
298}
299
300/// Internal node data for the variant-level graph.
301#[derive(Debug, Clone)]
302struct VariantNode {
303    name: &'static str,
304    variant: BTreeMap<String, String>,
305    complexity: &'static str,
306}
307
308/// Information about a neighbor in the reduction graph.
309#[derive(Debug, Clone)]
310pub struct NeighborInfo {
311    /// Problem name.
312    pub name: &'static str,
313    /// Variant attributes.
314    pub variant: BTreeMap<String, String>,
315    /// Hop distance from the source.
316    pub hops: usize,
317}
318
319/// Traversal mode for graph exploration.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum TraversalFlow {
322    /// Follow outgoing edges (what can this reduce to?).
323    Outgoing,
324    /// Follow incoming edges (what can reduce to this?).
325    Incoming,
326    /// Follow edges in both directions.
327    Both,
328}
329
330/// Required capability for reduction path search.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum ReductionMode {
333    Witness,
334    Aggregate,
335    /// Multi-query (Turing) reductions: solving the source requires multiple
336    /// adaptive queries to the target (e.g., binary search over a bound).
337    Turing,
338}
339
340/// A tree node for neighbor traversal results.
341#[derive(Debug, Clone)]
342pub struct NeighborTree {
343    /// Problem name.
344    pub name: String,
345    /// Variant attributes.
346    pub variant: BTreeMap<String, String>,
347    /// Child nodes (sorted by name).
348    pub children: Vec<NeighborTree>,
349}
350
351/// Runtime graph of all registered reductions.
352///
353/// Uses variant-level nodes: each node is a unique `(problem_name, variant)` pair.
354/// All edges come from the resolved reduction registry.
355///
356/// The graph supports:
357/// - Auto-discovery of registered reductions and result mappings
358/// - Path finding by problem type or by name
359pub struct ReductionGraph {
360    /// Graph with node indices as node data, edge weights as ReductionEdgeData.
361    graph: DiGraph<usize, ReductionEdgeData>,
362    /// All variant nodes, indexed by position.
363    nodes: Vec<VariantNode>,
364    /// Map from base type name to all NodeIndex values for that name.
365    name_to_nodes: HashMap<&'static str, Vec<NodeIndex>>,
366    /// Declared default variant for each problem name.
367    default_variants: HashMap<String, BTreeMap<String, String>>,
368}
369
370impl ReductionGraph {
371    /// Create a new reduction graph with all registered reductions from inventory.
372    pub fn new() -> Self {
373        crate::registry::validate_variant_parameter_schemas().unwrap_or_else(|errors| {
374            panic!("invalid problem parameters schemas:\n{}", errors.join("\n"))
375        });
376        crate::rules::registry::validate_reduction_parameter_schemas().unwrap_or_else(|errors| {
377            panic!(
378                "invalid reduction parameter schemas:\n{}",
379                errors.join("\n")
380            )
381        });
382        let mut graph = DiGraph::new();
383        let mut nodes: Vec<VariantNode> = Vec::new();
384        let mut node_index: HashMap<VariantRef, NodeIndex> = HashMap::new();
385        let mut name_to_nodes: HashMap<&'static str, Vec<NodeIndex>> = HashMap::new();
386
387        // Helper to ensure a variant node exists in the graph.
388        let ensure_node = |name: &'static str,
389                           variant: BTreeMap<String, String>,
390                           complexity: &'static str,
391                           nodes: &mut Vec<VariantNode>,
392                           graph: &mut DiGraph<usize, ReductionEdgeData>,
393                           node_index: &mut HashMap<VariantRef, NodeIndex>,
394                           name_to_nodes: &mut HashMap<&'static str, Vec<NodeIndex>>|
395         -> NodeIndex {
396            let vref = VariantRef {
397                name: name.to_string(),
398                variant: variant.clone(),
399            };
400            if let Some(&idx) = node_index.get(&vref) {
401                idx
402            } else {
403                let node_id = nodes.len();
404                nodes.push(VariantNode {
405                    name,
406                    variant,
407                    complexity,
408                });
409                let idx = graph.add_node(node_id);
410                node_index.insert(vref, idx);
411                name_to_nodes.entry(name).or_default().push(idx);
412                idx
413            }
414        };
415
416        // Collect declared default variants from VariantEntry inventory
417        let mut default_variants: HashMap<String, BTreeMap<String, String>> = HashMap::new();
418
419        // Phase 1: Build nodes from VariantEntry inventory
420        for entry in inventory::iter::<crate::registry::VariantEntry> {
421            let variant = Self::variant_to_map(&entry.variant());
422            ensure_node(
423                entry.name,
424                variant.clone(),
425                entry.complexity,
426                &mut nodes,
427                &mut graph,
428                &mut node_index,
429                &mut name_to_nodes,
430            );
431            if entry.is_default {
432                default_variants.insert(entry.name.to_string(), variant);
433            }
434        }
435
436        // Phase 2: Build edges from ReductionEntry inventory
437        for entry in crate::rules::registry::reduction_entries() {
438            let source_variant = Self::variant_to_map(&entry.source_variant());
439            let target_variant = Self::variant_to_map(&entry.target_variant());
440
441            let src_idx = node_index[&VariantRef {
442                name: entry.source_name.to_string(),
443                variant: source_variant,
444            }];
445            let dst_idx = node_index[&VariantRef {
446                name: entry.target_name.to_string(),
447                variant: target_variant,
448            }];
449
450            let parameter_contract = entry.parameter_contract();
451            if graph.find_edge(src_idx, dst_idx).is_none() {
452                graph.add_edge(
453                    src_idx,
454                    dst_idx,
455                    ReductionEdgeData {
456                        parameter_contract,
457                        reduce_fn: entry.reduce_fn,
458                        reduce_aggregate_fn: entry.reduce_aggregate_fn,
459                        aggregate_view_fn: entry.aggregate_view_fn,
460                        turing: entry.turing,
461                    },
462                );
463            }
464        }
465
466        Self {
467            graph,
468            nodes,
469            name_to_nodes,
470            default_variants,
471        }
472    }
473
474    /// Convert a variant slice to a BTreeMap.
475    /// Normalizes empty "graph" values to "SimpleGraph" for consistency.
476    pub fn variant_to_map(variant: &[(&str, &str)]) -> BTreeMap<String, String> {
477        variant
478            .iter()
479            .map(|(k, v)| {
480                let value = if *k == "graph" && v.is_empty() {
481                    "SimpleGraph".to_string()
482                } else {
483                    v.to_string()
484                };
485                (k.to_string(), value)
486            })
487            .collect()
488    }
489
490    /// Look up a variant node by name and variant map.
491    fn lookup_node(&self, name: &str, variant: &BTreeMap<String, String>) -> Option<NodeIndex> {
492        let nodes = self.name_to_nodes.get(name)?;
493        nodes
494            .iter()
495            .find(|&&idx| self.nodes[self.graph[idx]].variant == *variant)
496            .copied()
497    }
498
499    fn edge_supports_mode(edge: &ReductionEdgeData, mode: ReductionMode) -> bool {
500        match mode {
501            ReductionMode::Witness => edge.reduce_fn.is_some(),
502            ReductionMode::Aggregate => edge.reduce_aggregate_fn.is_some(),
503            ReductionMode::Turing => edge.turing,
504        }
505    }
506
507    fn ordered_outgoing_edges(
508        &self,
509        node: NodeIndex,
510        mode: ReductionMode,
511    ) -> Vec<(NodeIndex, EdgeIndex)> {
512        let mut edges: Vec<_> = self
513            .graph
514            .edges(node)
515            .filter(|edge| Self::edge_supports_mode(edge.weight(), mode))
516            .map(|edge| (edge.target(), edge.id()))
517            .collect();
518        edges.sort_by(|a, b| {
519            let a = &self.nodes[self.graph[a.0]];
520            let b = &self.nodes[self.graph[b.0]];
521            (a.name, &a.variant).cmp(&(b.name, &b.variant))
522        });
523        edges
524    }
525
526    fn node_path_supports_mode(&self, node_path: &[NodeIndex], mode: ReductionMode) -> bool {
527        node_path.windows(2).all(|pair| {
528            self.graph
529                .find_edge(pair[0], pair[1])
530                .is_some_and(|edge_idx| Self::edge_supports_mode(&self.graph[edge_idx], mode))
531        })
532    }
533
534    /// Convert a node index path to a `ReductionPath`.
535    fn node_path_to_reduction_path(&self, node_path: &[NodeIndex]) -> ReductionPath {
536        let steps = node_path
537            .iter()
538            .map(|&idx| {
539                let node = &self.nodes[self.graph[idx]];
540                ReductionStep {
541                    name: node.name.to_string(),
542                    variant: node.variant.clone(),
543                }
544            })
545            .collect();
546        ReductionPath { steps }
547    }
548
549    fn node_path_order_key(&self, node_path: &[NodeIndex]) -> NodePathOrderKey<'_> {
550        (
551            node_path.len().saturating_sub(1),
552            node_path
553                .iter()
554                .map(|&idx| {
555                    let node = &self.nodes[self.graph[idx]];
556                    (node.name, &node.variant)
557                })
558                .collect(),
559        )
560    }
561
562    #[allow(clippy::too_many_arguments)]
563    fn shortest_node_path(
564        &self,
565        source: NodeIndex,
566        target: NodeIndex,
567        adjacency: &[Vec<NodeIndex>],
568        excluded_nodes: &HashSet<NodeIndex>,
569        excluded_edges: &HashSet<(NodeIndex, NodeIndex)>,
570        max_nodes: usize,
571    ) -> Option<Vec<NodeIndex>> {
572        if excluded_nodes.contains(&source) || max_nodes == 0 {
573            return None;
574        }
575        if source == target {
576            return Some(vec![source]);
577        }
578
579        let mut queue = VecDeque::from([(source, 1usize)]);
580        let mut parents = HashMap::new();
581        let mut visited = HashSet::from([source]);
582
583        while let Some((current, path_nodes)) = queue.pop_front() {
584            if path_nodes == max_nodes {
585                continue;
586            }
587            for &next in &adjacency[current.index()] {
588                if excluded_nodes.contains(&next)
589                    || excluded_edges.contains(&(current, next))
590                    || !visited.insert(next)
591                {
592                    continue;
593                }
594                parents.insert(next, current);
595                if next == target {
596                    let mut path = vec![target];
597                    let mut node = target;
598                    while node != source {
599                        node = parents[&node];
600                        path.push(node);
601                    }
602                    path.reverse();
603                    return Some(path);
604                }
605                queue.push_back((next, path_nodes + 1));
606            }
607        }
608        None
609    }
610
611    fn find_k_shortest_node_paths(
612        &self,
613        source: NodeIndex,
614        target: NodeIndex,
615        mode: ReductionMode,
616        limit: usize,
617        max_nodes: usize,
618    ) -> Vec<Vec<NodeIndex>> {
619        if source == target || limit == 0 {
620            return Vec::new();
621        }
622
623        let mut adjacency = vec![Vec::new(); self.graph.node_count()];
624        for node in self.graph.node_indices() {
625            adjacency[node.index()] = self
626                .ordered_outgoing_edges(node, mode)
627                .into_iter()
628                .map(|(target, _)| target)
629                .collect();
630        }
631
632        let Some(first) = self.shortest_node_path(
633            source,
634            target,
635            &adjacency,
636            &HashSet::new(),
637            &HashSet::new(),
638            max_nodes,
639        ) else {
640            return Vec::new();
641        };
642
643        let mut accepted = vec![first];
644        let mut candidates = BTreeSet::new();
645
646        while accepted.len() < limit {
647            let previous = accepted.last().expect("accepted path exists");
648            for spur_index in 0..previous.len().saturating_sub(1) {
649                let root = &previous[..=spur_index];
650                let excluded_edges = accepted
651                    .iter()
652                    .filter(|path| path.len() > spur_index + 1 && path[..=spur_index] == *root)
653                    .map(|path| (path[spur_index], path[spur_index + 1]))
654                    .collect::<HashSet<_>>();
655                let excluded_nodes = root[..spur_index].iter().copied().collect();
656                let max_spur_nodes = max_nodes.saturating_sub(spur_index);
657                let Some(spur) = self.shortest_node_path(
658                    previous[spur_index],
659                    target,
660                    &adjacency,
661                    &excluded_nodes,
662                    &excluded_edges,
663                    max_spur_nodes,
664                ) else {
665                    continue;
666                };
667                let mut candidate = root[..spur_index].to_vec();
668                candidate.extend(spur);
669                if !accepted.contains(&candidate) {
670                    let key = self.node_path_order_key(&candidate);
671                    candidates.insert((key, candidate));
672                }
673            }
674
675            let Some((_, next)) = candidates.pop_first() else {
676                break;
677            };
678            accepted.push(next);
679        }
680
681        accepted
682    }
683
684    /// Find all simple paths between two specific problem variants.
685    ///
686    /// Uses `all_simple_paths` on the variant-level graph from the exact
687    /// source variant node to the exact target variant node.
688    pub fn find_all_paths(
689        &self,
690        source: &str,
691        source_variant: &BTreeMap<String, String>,
692        target: &str,
693        target_variant: &BTreeMap<String, String>,
694    ) -> Vec<ReductionPath> {
695        self.find_all_paths_mode(
696            source,
697            source_variant,
698            target,
699            target_variant,
700            ReductionMode::Witness,
701        )
702    }
703
704    /// Find all simple paths between two specific problem variants while
705    /// requiring a specific edge capability.
706    pub fn find_all_paths_mode(
707        &self,
708        source: &str,
709        source_variant: &BTreeMap<String, String>,
710        target: &str,
711        target_variant: &BTreeMap<String, String>,
712        mode: ReductionMode,
713    ) -> Vec<ReductionPath> {
714        let src = match self.lookup_node(source, source_variant) {
715            Some(idx) => idx,
716            None => return vec![],
717        };
718        let dst = match self.lookup_node(target, target_variant) {
719            Some(idx) => idx,
720            None => return vec![],
721        };
722
723        let paths: Vec<Vec<NodeIndex>> = all_simple_paths::<
724            Vec<NodeIndex>,
725            _,
726            std::hash::RandomState,
727        >(&self.graph, src, dst, 0, None)
728        .collect();
729
730        paths
731            .iter()
732            .filter(|p| self.node_path_supports_mode(p, mode))
733            .map(|p| self.node_path_to_reduction_path(p))
734            .collect()
735    }
736
737    /// Find up to `limit` simple paths between two specific problem variants.
738    ///
739    /// Returns witness-capable paths in deterministic order: fewest edges first,
740    /// then canonical problem name and variant order. Enumeration stops after
741    /// collecting `limit` paths.
742    pub fn find_paths_up_to(
743        &self,
744        source: &str,
745        source_variant: &BTreeMap<String, String>,
746        target: &str,
747        target_variant: &BTreeMap<String, String>,
748        limit: usize,
749    ) -> Vec<ReductionPath> {
750        self.find_paths_up_to_mode_bounded(
751            source,
752            source_variant,
753            target,
754            target_variant,
755            ReductionMode::Witness,
756            limit,
757            None,
758        )
759    }
760
761    /// Returns paths whose edges support `mode`, ordered by fewest edges first
762    /// and canonical problem name and variant order, stopping after `limit` paths.
763    pub fn find_paths_up_to_mode(
764        &self,
765        source: &str,
766        source_variant: &BTreeMap<String, String>,
767        target: &str,
768        target_variant: &BTreeMap<String, String>,
769        mode: ReductionMode,
770        limit: usize,
771    ) -> Vec<ReductionPath> {
772        self.find_paths_up_to_mode_bounded(
773            source,
774            source_variant,
775            target,
776            target_variant,
777            mode,
778            limit,
779            None,
780        )
781    }
782
783    /// Like [`find_paths_up_to_mode`](Self::find_paths_up_to_mode), with at most
784    /// `max_intermediate_nodes` nodes strictly between the source and target.
785    #[allow(clippy::too_many_arguments)]
786    pub fn find_paths_up_to_mode_bounded(
787        &self,
788        source: &str,
789        source_variant: &BTreeMap<String, String>,
790        target: &str,
791        target_variant: &BTreeMap<String, String>,
792        mode: ReductionMode,
793        limit: usize,
794        max_intermediate_nodes: Option<usize>,
795    ) -> Vec<ReductionPath> {
796        let src = match self.lookup_node(source, source_variant) {
797            Some(idx) => idx,
798            None => return vec![],
799        };
800        let dst = match self.lookup_node(target, target_variant) {
801            Some(idx) => idx,
802            None => return vec![],
803        };
804
805        if limit == 0 {
806            return Vec::new();
807        }
808
809        let max_intermediate =
810            max_intermediate_nodes.unwrap_or_else(|| self.graph.node_count().saturating_sub(2));
811        let max_nodes = max_intermediate.saturating_add(2);
812        self.find_k_shortest_node_paths(src, dst, mode, limit, max_nodes)
813            .iter()
814            .map(|path| self.node_path_to_reduction_path(path))
815            .collect()
816    }
817
818    /// Check if a direct reduction exists from S to T.
819    pub fn has_direct_reduction<S: crate::traits::Problem, T: crate::traits::Problem>(
820        &self,
821    ) -> bool {
822        self.has_direct_reduction_by_name(S::NAME, T::NAME)
823    }
824
825    /// Check if a direct reduction exists by name.
826    pub fn has_direct_reduction_by_name(&self, src: &str, dst: &str) -> bool {
827        let src_nodes = match self.name_to_nodes.get(src) {
828            Some(nodes) => nodes,
829            None => return false,
830        };
831        let dst_nodes = match self.name_to_nodes.get(dst) {
832            Some(nodes) => nodes,
833            None => return false,
834        };
835
836        let dst_set: HashSet<NodeIndex> = dst_nodes.iter().copied().collect();
837
838        for &src_idx in src_nodes {
839            for edge_ref in self.graph.edges(src_idx) {
840                if dst_set.contains(&edge_ref.target()) {
841                    return true;
842                }
843            }
844        }
845
846        false
847    }
848
849    /// Check if a direct reduction exists by name in a specific mode.
850    pub fn has_direct_reduction_by_name_mode(
851        &self,
852        src: &str,
853        dst: &str,
854        mode: ReductionMode,
855    ) -> bool {
856        let src_nodes = match self.name_to_nodes.get(src) {
857            Some(nodes) => nodes,
858            None => return false,
859        };
860        let dst_nodes = match self.name_to_nodes.get(dst) {
861            Some(nodes) => nodes,
862            None => return false,
863        };
864
865        let dst_set: HashSet<NodeIndex> = dst_nodes.iter().copied().collect();
866
867        for &src_idx in src_nodes {
868            for edge_ref in self.graph.edges(src_idx) {
869                if dst_set.contains(&edge_ref.target())
870                    && Self::edge_supports_mode(edge_ref.weight(), mode)
871                {
872                    return true;
873                }
874            }
875        }
876
877        false
878    }
879
880    /// Check if a direct reduction exists from S to T in a specific mode.
881    pub fn has_direct_reduction_mode<S: crate::traits::Problem, T: crate::traits::Problem>(
882        &self,
883        mode: ReductionMode,
884    ) -> bool {
885        self.has_direct_reduction_by_name_mode(S::NAME, T::NAME, mode)
886    }
887
888    /// Get all registered problem type names (base names).
889    pub fn problem_types(&self) -> Vec<&'static str> {
890        self.name_to_nodes.keys().copied().collect()
891    }
892
893    /// Get the number of registered problem types (unique base names).
894    pub fn num_types(&self) -> usize {
895        self.name_to_nodes.len()
896    }
897
898    /// Get the number of registered reductions (edges).
899    pub fn num_reductions(&self) -> usize {
900        self.graph.edge_count()
901    }
902
903    /// Get the number of variant-level nodes.
904    pub fn num_variant_nodes(&self) -> usize {
905        self.nodes.len()
906    }
907
908    /// Return the symbolic parameter transform for every edge of a path.
909    pub fn path_parameter_transforms(
910        &self,
911        path: &ReductionPath,
912    ) -> Result<Vec<crate::parameters::ParameterTransform>, PathParameterError> {
913        if path.steps.len() <= 1 {
914            return Ok(vec![]);
915        }
916
917        let node_indices: Vec<NodeIndex> = path
918            .steps
919            .iter()
920            .map(|step| {
921                self.lookup_node(&step.name, &step.variant).ok_or_else(|| {
922                    PathParameterError::UnknownNode {
923                        problem: step.name.clone(),
924                        variant: step.variant.clone(),
925                    }
926                })
927            })
928            .collect::<Result<_, _>>()?;
929
930        node_indices
931            .windows(2)
932            .enumerate()
933            .map(|(index, pair)| {
934                let edge_idx = self.graph.find_edge(pair[0], pair[1]).ok_or_else(|| {
935                    PathParameterError::MissingEdge {
936                        source_problem: path.steps[index].name.clone(),
937                        target_problem: path.steps[index + 1].name.clone(),
938                    }
939                })?;
940                if self.graph[edge_idx].turing {
941                    return Err(PathParameterError::TuringEdge {
942                        step: index + 1,
943                        source_problem: path.steps[index].name.clone(),
944                        target_problem: path.steps[index + 1].name.clone(),
945                    });
946                }
947                let contract =
948                    self.graph[edge_idx]
949                        .parameter_contract
950                        .as_ref()
951                        .map_err(|error| PathParameterError::InvalidContract {
952                            step: index + 1,
953                            source_problem: path.steps[index].name.clone(),
954                            target_problem: path.steps[index + 1].name.clone(),
955                            error: Box::new(error.clone()),
956                        })?;
957                let mut transform = contract.transform().cloned().unwrap_or_else(|| {
958                    crate::parameters::ParameterTransform::new(
959                        format!(
960                            "{} -> {}",
961                            path.steps[index].name,
962                            path.steps[index + 1].name
963                        ),
964                        crate::parameters::ParameterRelation::Exact,
965                        Vec::<(&str, crate::expr::Expr)>::new(),
966                    )
967                    .expect("empty transform is valid")
968                });
969                for field in contract.unavailable() {
970                    transform.declare_unavailable(field.field, field.reason);
971                }
972                Ok(transform)
973            })
974            .collect()
975    }
976
977    /// Compose symbolic parameter transforms along a path.
978    pub fn compose_path_parameter_transform(
979        &self,
980        path: &ReductionPath,
981    ) -> Result<Option<crate::parameters::ParameterTransform>, PathParameterError> {
982        if path.steps.is_empty() {
983            return Err(PathParameterError::EmptyPath);
984        }
985        if path.steps.len() == 1 {
986            return Ok(None);
987        }
988
989        let mut transforms = self.path_parameter_transforms(path)?.into_iter();
990        let Some(mut composed) = transforms.next() else {
991            return Ok(None);
992        };
993        for (offset, transform) in transforms.enumerate() {
994            let edge_index = offset + 1;
995            composed = composed
996                .compose(
997                    &transform,
998                    format!(
999                        "{} -> {}",
1000                        path.steps[0].name,
1001                        path.steps[edge_index + 1].name
1002                    ),
1003                )
1004                .map_err(|error| PathParameterError::Step {
1005                    step: edge_index + 1,
1006                    source_problem: path.steps[edge_index].name.clone(),
1007                    target_problem: path.steps[edge_index + 1].name.clone(),
1008                    error: Box::new(error),
1009                })?;
1010        }
1011        Ok(Some(composed))
1012    }
1013
1014    /// Get all variant maps registered for a problem name.
1015    ///
1016    /// Returns the declared default first, followed by the remaining variants
1017    /// in lexicographic order.
1018    pub fn variants_for(&self, name: &str) -> Vec<BTreeMap<String, String>> {
1019        let mut variants: Vec<BTreeMap<String, String>> = self
1020            .name_to_nodes
1021            .get(name)
1022            .map(|indices| {
1023                indices
1024                    .iter()
1025                    .map(|&idx| self.nodes[self.graph[idx]].variant.clone())
1026                    .collect()
1027            })
1028            .unwrap_or_default();
1029        variants.sort();
1030        if let Some(default) = self.default_variants.get(name) {
1031            if let Some(index) = variants.iter().position(|variant| variant == default) {
1032                let default = variants.remove(index);
1033                variants.insert(0, default);
1034            }
1035        }
1036        variants
1037    }
1038
1039    /// Get the declared default variant for a problem type.
1040    ///
1041    /// Returns the variant that was marked `default` in `declare_variants!`.
1042    /// If no entry was explicitly marked `default`, the first registered variant
1043    /// for the problem is used as the implicit default.
1044    /// Returns `None` if the problem type is not registered.
1045    pub fn default_variant_for(&self, name: &str) -> Option<BTreeMap<String, String>> {
1046        self.default_variants.get(name).cloned()
1047    }
1048
1049    /// Get the complexity expression for a specific variant.
1050    pub fn variant_complexity(
1051        &self,
1052        name: &str,
1053        variant: &BTreeMap<String, String>,
1054    ) -> Option<&'static str> {
1055        let idx = self.lookup_node(name, variant)?;
1056        let node = &self.nodes[self.graph[idx]];
1057        if node.complexity.is_empty() {
1058            None
1059        } else {
1060            Some(node.complexity)
1061        }
1062    }
1063
1064    /// Get all outgoing reductions from a problem (across all its variants).
1065    pub fn outgoing_reductions(&self, name: &str) -> Vec<ReductionEdgeInfo> {
1066        let Some(indices) = self.name_to_nodes.get(name) else {
1067            return vec![];
1068        };
1069        let index_set: HashSet<NodeIndex> = indices.iter().copied().collect();
1070        self.graph
1071            .edge_references()
1072            .filter(|e| index_set.contains(&e.source()))
1073            .map(|e| {
1074                let src = &self.nodes[self.graph[e.source()]];
1075                let dst = &self.nodes[self.graph[e.target()]];
1076                ReductionEdgeInfo {
1077                    source_name: src.name,
1078                    source_variant: src.variant.clone(),
1079                    target_name: dst.name,
1080                    target_variant: dst.variant.clone(),
1081                    parameter_contract: self.graph[e.id()].parameter_contract.clone(),
1082                    capabilities: self.graph[e.id()].capabilities(),
1083                }
1084            })
1085            .collect()
1086    }
1087
1088    /// Get executable outgoing reductions from one exact problem variant.
1089    ///
1090    /// # Panics
1091    ///
1092    /// Panics if `name` and `variant` do not identify an exactly registered problem variant.
1093    pub fn outgoing_reductions_from(
1094        &self,
1095        name: &str,
1096        variant: &BTreeMap<String, String>,
1097        mode: ReductionMode,
1098    ) -> Vec<ReductionEdgeInfo> {
1099        let source = self
1100            .lookup_node(name, variant)
1101            .unwrap_or_else(|| panic!("registered problem variant not found: {name} {variant:?}"));
1102
1103        self.ordered_outgoing_edges(source, mode)
1104            .into_iter()
1105            .map(|(target, edge)| {
1106                let src = &self.nodes[self.graph[source]];
1107                let dst = &self.nodes[self.graph[target]];
1108                ReductionEdgeInfo {
1109                    source_name: src.name,
1110                    source_variant: src.variant.clone(),
1111                    target_name: dst.name,
1112                    target_variant: dst.variant.clone(),
1113                    parameter_contract: self.graph[edge].parameter_contract.clone(),
1114                    capabilities: self.graph[edge].capabilities(),
1115                }
1116            })
1117            .collect()
1118    }
1119
1120    /// Get a problem type's canonical parameter names in declaration order.
1121    pub fn parameter_names(&self, name: &str) -> Vec<String> {
1122        inventory::iter::<crate::registry::VariantEntry>
1123            .into_iter()
1124            .find(|entry| entry.name == name)
1125            .map(|entry| {
1126                entry
1127                    .parameter_names()
1128                    .iter()
1129                    .map(|name| (*name).to_string())
1130                    .collect()
1131            })
1132            .unwrap_or_default()
1133    }
1134
1135    /// Measure the complete problem-owned parameters at this exact variant.
1136    pub fn compute_problem_parameters(
1137        name: &str,
1138        variant: &BTreeMap<String, String>,
1139        instance: &dyn Any,
1140    ) -> ProblemParameters {
1141        let entry = crate::registry::find_variant_entry(name, variant)
1142            .unwrap_or_else(|| panic!("unregistered exact problem variant `{name}` {variant:?}"));
1143        (entry.parameter_measure_fn)(instance)
1144    }
1145
1146    /// Get all incoming reductions to a problem (across all its variants).
1147    pub fn incoming_reductions(&self, name: &str) -> Vec<ReductionEdgeInfo> {
1148        let Some(indices) = self.name_to_nodes.get(name) else {
1149            return vec![];
1150        };
1151        let index_set: HashSet<NodeIndex> = indices.iter().copied().collect();
1152        self.graph
1153            .edge_references()
1154            .filter(|e| index_set.contains(&e.target()))
1155            .map(|e| {
1156                let src = &self.nodes[self.graph[e.source()]];
1157                let dst = &self.nodes[self.graph[e.target()]];
1158                ReductionEdgeInfo {
1159                    source_name: src.name,
1160                    source_variant: src.variant.clone(),
1161                    target_name: dst.name,
1162                    target_variant: dst.variant.clone(),
1163                    parameter_contract: self.graph[e.id()].parameter_contract.clone(),
1164                    capabilities: self.graph[e.id()].capabilities(),
1165                }
1166            })
1167            .collect()
1168    }
1169
1170    /// Find all problems reachable within `max_hops` edges from a starting node.
1171    ///
1172    /// Returns neighbors sorted by (hops, name). The starting node itself is excluded.
1173    /// If a node is reachable at multiple distances, it appears at the shortest distance only.
1174    pub fn k_neighbors(
1175        &self,
1176        name: &str,
1177        variant: &BTreeMap<String, String>,
1178        max_hops: usize,
1179        direction: TraversalFlow,
1180    ) -> Vec<NeighborInfo> {
1181        use std::collections::VecDeque;
1182
1183        let Some(start_idx) = self.lookup_node(name, variant) else {
1184            return vec![];
1185        };
1186
1187        let mut visited: HashSet<NodeIndex> = HashSet::new();
1188        visited.insert(start_idx);
1189        let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
1190        queue.push_back((start_idx, 0));
1191        let mut results: Vec<NeighborInfo> = Vec::new();
1192
1193        while let Some((node_idx, hops)) = queue.pop_front() {
1194            if hops >= max_hops {
1195                continue;
1196            }
1197
1198            let directions = match direction {
1199                TraversalFlow::Outgoing => vec![petgraph::Outgoing],
1200                TraversalFlow::Incoming => vec![petgraph::Incoming],
1201                TraversalFlow::Both => {
1202                    vec![petgraph::Outgoing, petgraph::Incoming]
1203                }
1204            };
1205
1206            for dir in directions {
1207                for neighbor_idx in self.graph.neighbors_directed(node_idx, dir) {
1208                    if visited.insert(neighbor_idx) {
1209                        let neighbor_node = &self.nodes[self.graph[neighbor_idx]];
1210                        results.push(NeighborInfo {
1211                            name: neighbor_node.name,
1212                            variant: neighbor_node.variant.clone(),
1213                            hops: hops + 1,
1214                        });
1215                        queue.push_back((neighbor_idx, hops + 1));
1216                    }
1217                }
1218            }
1219        }
1220
1221        results.sort_by(|a, b| a.hops.cmp(&b.hops).then_with(|| a.name.cmp(b.name)));
1222        results
1223    }
1224
1225    /// Build a tree of neighbors via BFS with parent tracking.
1226    ///
1227    /// Returns the children of the starting node as a forest of `NeighborTree` nodes.
1228    /// Each node appears at most once (shortest-path tree). Children are sorted by name.
1229    pub fn k_neighbor_tree(
1230        &self,
1231        name: &str,
1232        variant: &BTreeMap<String, String>,
1233        max_hops: usize,
1234        direction: TraversalFlow,
1235    ) -> Vec<NeighborTree> {
1236        use std::collections::VecDeque;
1237
1238        let Some(start_idx) = self.lookup_node(name, variant) else {
1239            return vec![];
1240        };
1241
1242        let mut visited: HashSet<NodeIndex> = HashSet::new();
1243        visited.insert(start_idx);
1244
1245        let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
1246        queue.push_back((start_idx, 0));
1247
1248        // Map from node_idx -> children node indices
1249        let mut node_children: HashMap<NodeIndex, Vec<NodeIndex>> = HashMap::new();
1250
1251        while let Some((node_idx, depth)) = queue.pop_front() {
1252            if depth >= max_hops {
1253                continue;
1254            }
1255
1256            let directions = match direction {
1257                TraversalFlow::Outgoing => vec![petgraph::Outgoing],
1258                TraversalFlow::Incoming => vec![petgraph::Incoming],
1259                TraversalFlow::Both => {
1260                    vec![petgraph::Outgoing, petgraph::Incoming]
1261                }
1262            };
1263
1264            let mut children = Vec::new();
1265            for dir in directions {
1266                for neighbor_idx in self.graph.neighbors_directed(node_idx, dir) {
1267                    if visited.insert(neighbor_idx) {
1268                        children.push(neighbor_idx);
1269                        queue.push_back((neighbor_idx, depth + 1));
1270                    }
1271                }
1272            }
1273            children.sort_by(|a, b| {
1274                self.nodes[self.graph[*a]]
1275                    .name
1276                    .cmp(self.nodes[self.graph[*b]].name)
1277            });
1278            node_children.insert(node_idx, children);
1279        }
1280
1281        // Recursively build NeighborTree from BFS parent map.
1282        fn build(
1283            idx: NodeIndex,
1284            node_children: &HashMap<NodeIndex, Vec<NodeIndex>>,
1285            nodes: &[VariantNode],
1286            graph: &DiGraph<usize, ReductionEdgeData>,
1287        ) -> NeighborTree {
1288            let children = node_children
1289                .get(&idx)
1290                .map(|cs| {
1291                    cs.iter()
1292                        .map(|&c| build(c, node_children, nodes, graph))
1293                        .collect()
1294                })
1295                .unwrap_or_default();
1296            let node = &nodes[graph[idx]];
1297            NeighborTree {
1298                name: node.name.to_string(),
1299                variant: node.variant.clone(),
1300                children,
1301            }
1302        }
1303
1304        node_children
1305            .get(&start_idx)
1306            .map(|cs| {
1307                cs.iter()
1308                    .map(|&c| build(c, &node_children, &self.nodes, &self.graph))
1309                    .collect()
1310            })
1311            .unwrap_or_default()
1312    }
1313}
1314
1315impl Default for ReductionGraph {
1316    fn default() -> Self {
1317        Self::new()
1318    }
1319}
1320
1321impl ReductionGraph {
1322    /// Export the reduction graph as a JSON-serializable structure.
1323    ///
1324    /// Nodes and edges come directly from the variant-level graph.
1325    pub(crate) fn to_json(&self) -> ReductionGraphJson {
1326        use crate::registry::ProblemSchemaEntry;
1327
1328        // Build the model-owned metadata lookup from ProblemSchemaEntry inventory.
1329        let schema_metadata: HashMap<&str, (&str, crate::registry::ProblemCategory)> =
1330            inventory::iter::<ProblemSchemaEntry>
1331                .into_iter()
1332                .map(|entry| (entry.name, (entry.module_path, entry.category)))
1333                .collect();
1334
1335        // Build sorted node list from the internal nodes
1336        let mut json_nodes: Vec<(usize, NodeJson)> = self
1337            .nodes
1338            .iter()
1339            .enumerate()
1340            .map(|(i, node)| {
1341                let &(module_path, category) =
1342                    schema_metadata.get(node.name).unwrap_or_else(|| {
1343                        panic!(
1344                            "missing problem schema for registered variant `{}`",
1345                            node.name
1346                        )
1347                    });
1348                (
1349                    i,
1350                    NodeJson {
1351                        name: node.name.to_string(),
1352                        variant: node.variant.clone(),
1353                        category,
1354                        doc_path: Self::doc_path_from_module_path(module_path, node.name),
1355                        complexity: node.complexity.to_string(),
1356                    },
1357                )
1358            })
1359            .collect();
1360        json_nodes.sort_by(|a, b| (&a.1.name, &a.1.variant).cmp(&(&b.1.name, &b.1.variant)));
1361
1362        // Build old-index -> new-index mapping
1363        let mut old_to_new: HashMap<usize, usize> = HashMap::new();
1364        for (new_idx, (old_idx, _)) in json_nodes.iter().enumerate() {
1365            old_to_new.insert(*old_idx, new_idx);
1366        }
1367
1368        let nodes: Vec<NodeJson> = json_nodes.into_iter().map(|(_, n)| n).collect();
1369
1370        // Build edges from the graph
1371        let mut edges: Vec<EdgeJson> = Vec::new();
1372        for edge_ref in self.graph.edge_references() {
1373            let src_node_id = self.graph[edge_ref.source()];
1374            let dst_node_id = self.graph[edge_ref.target()];
1375            let contract = &edge_ref.weight().parameter_contract;
1376            let capabilities = edge_ref.weight().capabilities();
1377
1378            let mut parameters = Vec::new();
1379            if let Ok(contract) = contract {
1380                if let Some(transform) = contract.transform() {
1381                    parameters.extend(transform.expressions().map(|(field, expression)| {
1382                        ParameterFieldJson {
1383                            field: field.to_string(),
1384                            contract: match transform.relation(field).expect("declared formula") {
1385                                crate::parameters::ParameterRelation::Exact => "exact",
1386                                crate::parameters::ParameterRelation::UpperBound => "upper_bound",
1387                            },
1388                            formula: Some(expression.to_string()),
1389                            reason: None,
1390                        }
1391                    }));
1392                }
1393                parameters.extend(contract.unavailable().iter().map(|unavailable| {
1394                    ParameterFieldJson {
1395                        field: unavailable.field.to_string(),
1396                        contract: "unavailable",
1397                        formula: None,
1398                        reason: Some(unavailable.reason.to_string()),
1399                    }
1400                }));
1401            }
1402            let parameter_contract_error = contract.as_ref().err().map(ToString::to_string);
1403
1404            // Find the doc_path from the matching ReductionEntry
1405            let src_name = self.nodes[src_node_id].name;
1406            let dst_name = self.nodes[dst_node_id].name;
1407            let src_variant = &self.nodes[src_node_id].variant;
1408            let dst_variant = &self.nodes[dst_node_id].variant;
1409
1410            let doc_path = self.find_entry_doc_path(src_name, dst_name, src_variant, dst_variant);
1411
1412            edges.push(EdgeJson {
1413                source: old_to_new[&src_node_id],
1414                target: old_to_new[&dst_node_id],
1415                parameters,
1416                parameter_contract_error,
1417                doc_path,
1418                witness: capabilities.witness,
1419                aggregate: capabilities.aggregate,
1420                turing: capabilities.turing,
1421            });
1422        }
1423
1424        // Sort edges for deterministic output
1425        edges.sort_by(|a, b| {
1426            (
1427                &nodes[a.source].name,
1428                &nodes[a.source].variant,
1429                &nodes[a.target].name,
1430                &nodes[a.target].variant,
1431            )
1432                .cmp(&(
1433                    &nodes[b.source].name,
1434                    &nodes[b.source].variant,
1435                    &nodes[b.target].name,
1436                    &nodes[b.target].variant,
1437                ))
1438        });
1439
1440        ReductionGraphJson { nodes, edges }
1441    }
1442
1443    /// Find the doc_path for a reduction entry matching the given source/target.
1444    fn find_entry_doc_path(
1445        &self,
1446        src_name: &str,
1447        dst_name: &str,
1448        src_variant: &BTreeMap<String, String>,
1449        dst_variant: &BTreeMap<String, String>,
1450    ) -> String {
1451        for entry in crate::rules::registry::reduction_entries() {
1452            if entry.source_name == src_name && entry.target_name == dst_name {
1453                let entry_src = Self::variant_to_map(&entry.source_variant());
1454                let entry_dst = Self::variant_to_map(&entry.target_variant());
1455                if &entry_src == src_variant && &entry_dst == dst_variant {
1456                    return Self::module_path_to_doc_path(entry.module_path);
1457                }
1458            }
1459        }
1460        String::new()
1461    }
1462
1463    /// Export the reduction graph as a JSON string.
1464    pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
1465        let json = self.to_json();
1466        serde_json::to_string_pretty(&json)
1467    }
1468
1469    /// Export the reduction graph as a JSON value.
1470    pub fn to_json_value(&self) -> Result<serde_json::Value, serde_json::Error> {
1471        serde_json::to_value(self.to_json())
1472    }
1473
1474    /// Export the reduction graph to a JSON file.
1475    pub fn to_json_file(&self, path: &std::path::Path) -> std::io::Result<()> {
1476        let json_string = self
1477            .to_json_string()
1478            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1479        std::fs::write(path, json_string)
1480    }
1481
1482    /// Convert a module path to a rustdoc relative path.
1483    ///
1484    /// E.g., `"problemreductions::rules::spinglass_qubo"` -> `"rules/spinglass_qubo/index.html"`.
1485    fn module_path_to_doc_path(module_path: &str) -> String {
1486        let stripped = module_path
1487            .strip_prefix("problemreductions::")
1488            .unwrap_or(module_path);
1489        format!("{}/index.html", stripped.replace("::", "/"))
1490    }
1491
1492    /// Build the rustdoc path from a module path and problem name.
1493    ///
1494    /// E.g., `"problemreductions::models::graph::maximum_independent_set"`, `"MaximumIndependentSet"`
1495    /// -> `"models/graph/struct.MaximumIndependentSet.html"`.
1496    fn doc_path_from_module_path(module_path: &str, name: &str) -> String {
1497        let stripped = module_path
1498            .strip_prefix("problemreductions::")
1499            .unwrap_or(module_path);
1500        if let Some(parent) = stripped.rsplit_once("::").map(|(p, _)| p) {
1501            format!("{}/struct.{}.html", parent.replace("::", "/"), name)
1502        } else {
1503            format!("struct.{}.html", name)
1504        }
1505    }
1506
1507    /// Find the graph edge for exact source and target variants.
1508    ///
1509    /// No fallback is attempted — callers that need fuzzy matching should resolve
1510    /// variants before calling this method.
1511    pub fn find_entry(
1512        &self,
1513        source_name: &str,
1514        source_variant: &BTreeMap<String, String>,
1515        target_name: &str,
1516        target_variant: &BTreeMap<String, String>,
1517    ) -> Option<MatchedEntry> {
1518        let source = self.lookup_node(source_name, source_variant)?;
1519        let target = self.lookup_node(target_name, target_variant)?;
1520        let edge = self.graph.find_edge(source, target)?;
1521
1522        Some(MatchedEntry {
1523            source_variant: source_variant.clone(),
1524            target_variant: target_variant.clone(),
1525            parameter_contract: self.graph[edge].parameter_contract.clone(),
1526        })
1527    }
1528}
1529
1530/// A matched reduction entry returned by [`ReductionGraph::find_entry`].
1531pub struct MatchedEntry {
1532    /// The entry's source variant.
1533    pub source_variant: BTreeMap<String, String>,
1534    /// The entry's target variant.
1535    pub target_variant: BTreeMap<String, String>,
1536    /// The reduction's explicit parameter contract.
1537    pub parameter_contract: Result<ReductionParameterContract, ParameterContractError>,
1538}
1539
1540/// One executed edge and its source instance for completed-result recovery.
1541pub(crate) struct RecoveryStep<'a> {
1542    pub result: &'a dyn DynReductionResult,
1543    pub aggregate_view: Option<crate::rules::registry::AggregateViewFn>,
1544    pub source: &'a dyn crate::registry::DynProblem,
1545}
1546
1547/// Recover a completed result under the caller's solver contract.
1548/// `None` denotes established infeasibility, never an extraction failure.
1549pub(crate) fn recover_completed_result(
1550    steps: &[RecoveryStep<'_>],
1551    target: &dyn crate::registry::DynProblem,
1552    outcome: &crate::solvers::SolveOutcome,
1553) -> crate::rules::ExtractionResult<Option<(Box<dyn Any>, String)>> {
1554    use crate::rules::ExtractionError;
1555    use crate::solvers::SolveOutcome;
1556    let SolveOutcome::Optimal {
1557        solution,
1558        evaluation,
1559    } = outcome
1560    else {
1561        return Ok(None);
1562    };
1563    let mut value = target.evaluate_json(solution)?;
1564    let mut actual = target
1565        .aggregate_witness_evaluation(&value)?
1566        .ok_or_else(|| ExtractionError::invalid("target witness is infeasible"))?;
1567    if evaluation != &actual {
1568        return Err(ExtractionError::invalid(
1569            "target evaluation does not match the witness",
1570        ));
1571    }
1572    let last = steps
1573        .last()
1574        .ok_or_else(|| ExtractionError::invalid("recovery requires a reduction edge"))?;
1575    let mut witness = last.result.target_solution_from_json(solution.clone())?;
1576    for step in steps.iter().rev() {
1577        let mapped = step
1578            .aggregate_view
1579            .map(|view| view(step.result)?.extract_value_dyn(value.clone()))
1580            .transpose()?;
1581        if let Some(mapped_value) = &mapped {
1582            if step
1583                .source
1584                .aggregate_witness_evaluation(mapped_value)?
1585                .is_none()
1586            {
1587                return Ok(None);
1588            }
1589        }
1590        witness = step.result.extract_solution_dyn(witness.as_ref())?;
1591        value = step
1592            .source
1593            .evaluate_json(&step.result.source_solution_json(witness.as_ref())?)?;
1594        actual = step
1595            .source
1596            .aggregate_witness_evaluation(&value)?
1597            .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?;
1598        if mapped.is_some_and(|mapped| mapped != value) {
1599            return Err(ExtractionError::invalid(
1600                "extracted witness does not realize the mapped aggregate",
1601            ));
1602        }
1603    }
1604    Ok(Some((witness, actual)))
1605}
1606
1607/// A composed reduction chain produced by [`ReductionGraph::reduce_along_path`].
1608///
1609/// Holds the intermediate reduction results from executing a multi-step
1610/// reduction path. Provides access to the final target problem and
1611/// solution and aggregate-value mappings back to the source problem space.
1612/// Callers establish solver status before recovering a completed result.
1613pub struct ReductionChain {
1614    steps: Vec<Box<dyn DynReductionResult>>,
1615    aggregate_views: Vec<Option<crate::rules::registry::AggregateViewFn>>,
1616    path: ReductionPath,
1617}
1618
1619impl ReductionChain {
1620    /// Recover a completed target result, checking mapped values against extracted witnesses.
1621    /// The caller establishes optimality or infeasibility under its solver's contract.
1622    pub fn extract_result(
1623        &self,
1624        source: &dyn crate::registry::DynProblem,
1625        outcome: &crate::solvers::SolveOutcome,
1626    ) -> crate::rules::ExtractionResult<crate::solvers::SolveOutcome> {
1627        use crate::rules::ExtractionError;
1628        use crate::solvers::SolveOutcome;
1629        let borrow = |index: usize, problem| {
1630            let node = &self.path.steps[index];
1631            crate::registry::find_variant_entry(&node.name, &node.variant)
1632                .and_then(|entry| (entry.borrow_fn)(problem))
1633                .ok_or_else(|| ExtractionError::invalid("intermediate problem type mismatch"))
1634        };
1635        let steps = self
1636            .steps
1637            .iter()
1638            .enumerate()
1639            .map(|(index, step)| {
1640                Ok(RecoveryStep {
1641                    result: step.as_ref(),
1642                    aggregate_view: self.aggregate_views[index],
1643                    source: if index == 0 {
1644                        source
1645                    } else {
1646                        borrow(index, self.steps[index - 1].target_problem_any())?
1647                    },
1648                })
1649            })
1650            .collect::<crate::rules::ExtractionResult<Vec<_>>>()?;
1651        let target = borrow(self.steps.len(), self.target_problem_any())?;
1652        match recover_completed_result(&steps, target, outcome)? {
1653            None => Ok(SolveOutcome::Infeasible),
1654            Some((witness, evaluation)) => Ok(SolveOutcome::Optimal {
1655                solution: steps[0].result.source_solution_json(witness.as_ref())?,
1656                evaluation,
1657            }),
1658        }
1659    }
1660
1661    /// Whether every step can map an aggregate value using its existing construction.
1662    pub fn has_value_mapping(&self) -> bool {
1663        self.aggregate_views.iter().all(Option::is_some)
1664    }
1665
1666    /// Map a target aggregate back to the source using the executed reductions.
1667    /// Every step must provide a value mapping. This does not establish that
1668    /// the supplied value is the target optimum or aggregate.
1669    pub fn extract_value(
1670        &self,
1671        target_value: serde_json::Value,
1672    ) -> crate::rules::ExtractionResult<serde_json::Value> {
1673        self.steps.iter().zip(&self.aggregate_views).rev().try_fold(
1674            target_value,
1675            |value, (step, view)| {
1676                let view = view.ok_or_else(|| {
1677                    crate::rules::ExtractionError::invalid("reduction has no value mapping")
1678                })?;
1679                view(step.as_ref())?.extract_value_dyn(value)
1680            },
1681        )
1682    }
1683
1684    /// Get the final target problem as a type-erased reference.
1685    pub fn target_problem_any(&self) -> &dyn Any {
1686        self.steps
1687            .last()
1688            .expect("ReductionChain has no steps")
1689            .target_problem_any()
1690    }
1691
1692    /// Get a typed reference to the final target problem.
1693    ///
1694    /// Panics if the actual target type does not match `T`.
1695    pub fn target_problem<T: 'static>(&self) -> &T {
1696        self.target_problem_any()
1697            .downcast_ref::<T>()
1698            .expect("ReductionChain target type mismatch")
1699    }
1700
1701    /// Extract a solution from target space back to source space.
1702    pub fn extract_solution<S: 'static, T: 'static>(
1703        &self,
1704        target_solution: &T,
1705    ) -> crate::rules::ExtractionResult<S> {
1706        let mut steps = self.steps.iter().rev();
1707        let first = steps.next().expect("ReductionChain has no steps");
1708        let mut solution = first.extract_solution_dyn(target_solution)?;
1709        for step in steps {
1710            solution = step.extract_solution_dyn(solution.as_ref())?;
1711        }
1712        solution
1713            .downcast::<S>()
1714            .map(|solution| *solution)
1715            .map_err(|_| crate::rules::ExtractionError::invalid("source solution type mismatch"))
1716    }
1717
1718    /// Extract a JSON target witness into a JSON source witness.
1719    pub fn extract_solution_json(
1720        &self,
1721        target_solution: serde_json::Value,
1722    ) -> crate::rules::ExtractionResult<serde_json::Value> {
1723        let last = self.steps.last().expect("ReductionChain has no steps");
1724        let mut solution = last.target_solution_from_json(target_solution)?;
1725        for step in self.steps.iter().rev() {
1726            solution = step.extract_solution_dyn(solution.as_ref())?;
1727        }
1728        self.steps[0].source_solution_json(solution.as_ref())
1729    }
1730}
1731
1732/// A composed aggregate reduction chain produced by
1733/// [`ReductionGraph::reduce_aggregate_along_path`].
1734pub struct AggregateReductionChain {
1735    steps: Vec<Box<dyn DynAggregateReductionResult>>,
1736}
1737
1738impl AggregateReductionChain {
1739    /// Get the final target problem as a type-erased reference.
1740    pub fn target_problem_any(&self) -> &dyn Any {
1741        self.steps
1742            .last()
1743            .expect("AggregateReductionChain has no steps")
1744            .target_problem_any()
1745    }
1746
1747    /// Get a typed reference to the final target problem.
1748    ///
1749    /// Panics if the actual target type does not match `T`.
1750    pub fn target_problem<T: 'static>(&self) -> &T {
1751        self.target_problem_any()
1752            .downcast_ref::<T>()
1753            .expect("AggregateReductionChain target type mismatch")
1754    }
1755
1756    /// Extract an aggregate value from target space back to source space.
1757    pub fn extract_value(
1758        &self,
1759        target_value: serde_json::Value,
1760    ) -> crate::rules::ExtractionResult<serde_json::Value> {
1761        self.steps
1762            .iter()
1763            .rev()
1764            .try_fold(target_value, |value, step| step.extract_value_dyn(value))
1765    }
1766}
1767
1768impl ReductionGraph {
1769    fn execute_aggregate_edge(
1770        &self,
1771        edge_idx: EdgeIndex,
1772        input: &dyn Any,
1773    ) -> Result<Option<Box<dyn DynAggregateReductionResult>>, crate::rules::ReductionError> {
1774        let edge = &self.graph[edge_idx];
1775        let Some(reduce) = edge.reduce_aggregate_fn else {
1776            return Ok(None);
1777        };
1778        reduce(input).map(Some)
1779    }
1780
1781    /// Execute a reduction path on a source problem instance.
1782    ///
1783    /// Looks up each edge's `reduce_fn`, chains them, and returns the
1784    /// resulting [`ReductionChain`]. The source must be passed as `&dyn Any`
1785    /// (use `&problem as &dyn Any` or pass a concrete reference directly).
1786    ///
1787    /// # Example
1788    ///
1789    /// ```text
1790    /// let Some(chain) = graph.reduce_along_path(&path, &source_problem)? else {
1791    ///     return Err("path is not witness-executable".into());
1792    /// };
1793    /// let target: &QUBO<f64> = chain.target_problem();
1794    /// let source_solution = chain.extract_solution(&target_solution);
1795    /// ```
1796    pub fn reduce_along_path(
1797        &self,
1798        path: &ReductionPath,
1799        source: &dyn Any,
1800    ) -> Result<Option<ReductionChain>, crate::rules::ReductionError> {
1801        if path.steps.len() < 2 {
1802            return Ok(None);
1803        }
1804        // Collect edge reduce_fns
1805        let mut edge_fns = Vec::new();
1806        let mut aggregate_views = Vec::new();
1807        for window in path.steps.windows(2) {
1808            let Some(src) = self.lookup_node(&window[0].name, &window[0].variant) else {
1809                return Ok(None);
1810            };
1811            let Some(dst) = self.lookup_node(&window[1].name, &window[1].variant) else {
1812                return Ok(None);
1813            };
1814            let Some(edge_idx) = self.graph.find_edge(src, dst) else {
1815                return Ok(None);
1816            };
1817            if !Self::edge_supports_mode(&self.graph[edge_idx], ReductionMode::Witness) {
1818                return Ok(None);
1819            }
1820            let Some(reduce) = self.graph[edge_idx].reduce_fn else {
1821                return Ok(None);
1822            };
1823            edge_fns.push(reduce);
1824            aggregate_views.push(self.graph[edge_idx].aggregate_view_fn);
1825        }
1826        // Execute the chain
1827        let mut steps: Vec<Box<dyn DynReductionResult>> = Vec::new();
1828        let step = (edge_fns[0])(source)?;
1829        steps.push(step);
1830        for edge_fn in &edge_fns[1..] {
1831            let step = {
1832                let prev_target = steps.last().unwrap().target_problem_any();
1833                edge_fn(prev_target)?
1834            };
1835            steps.push(step);
1836        }
1837        Ok(Some(ReductionChain {
1838            steps,
1839            aggregate_views,
1840            path: path.clone(),
1841        }))
1842    }
1843
1844    /// Execute an aggregate-value reduction path on a source problem instance.
1845    pub fn reduce_aggregate_along_path(
1846        &self,
1847        path: &ReductionPath,
1848        source: &dyn Any,
1849    ) -> Result<Option<AggregateReductionChain>, crate::rules::ReductionError> {
1850        if path.steps.len() < 2 {
1851            return Ok(None);
1852        }
1853
1854        let mut edge_indices = Vec::new();
1855        for window in path.steps.windows(2) {
1856            let Some(src) = self.lookup_node(&window[0].name, &window[0].variant) else {
1857                return Ok(None);
1858            };
1859            let Some(dst) = self.lookup_node(&window[1].name, &window[1].variant) else {
1860                return Ok(None);
1861            };
1862            let Some(edge_idx) = self.graph.find_edge(src, dst) else {
1863                return Ok(None);
1864            };
1865            edge_indices.push(edge_idx);
1866        }
1867
1868        let mut steps: Vec<Box<dyn DynAggregateReductionResult>> = Vec::new();
1869        let Some(step) = self.execute_aggregate_edge(edge_indices[0], source)? else {
1870            return Ok(None);
1871        };
1872        steps.push(step);
1873        for &edge_idx in &edge_indices[1..] {
1874            let step = {
1875                let prev_target = steps.last().unwrap().target_problem_any();
1876                let Some(step) = self.execute_aggregate_edge(edge_idx, prev_target)? else {
1877                    return Ok(None);
1878                };
1879                step
1880            };
1881            steps.push(step);
1882        }
1883        Ok(Some(AggregateReductionChain { steps }))
1884    }
1885}
1886
1887/// A concrete reduction path whose reductions have already been executed.
1888///
1889/// The constructed chain is retained so callers can inspect target parameterss and
1890/// extract solutions without re-executing any reduction.
1891pub struct ExecutedPath {
1892    /// The variant-level path.
1893    pub path: ReductionPath,
1894    /// The executed reduction steps (one per hop), shared via `Rc`.
1895    steps: Vec<Rc<dyn DynReductionResult>>,
1896}
1897
1898impl ExecutedPath {
1899    /// Get the final target problem as a type-erased reference.
1900    pub fn target_problem_any(&self) -> &dyn Any {
1901        self.steps
1902            .last()
1903            .expect("ExecutedPath has no steps")
1904            .target_problem_any()
1905    }
1906
1907    /// Return the parameters of every concrete intermediate target in this path.
1908    pub fn target_parameters(&self) -> Vec<ProblemParameters> {
1909        self.steps
1910            .iter()
1911            .zip(self.path.steps.iter().skip(1))
1912            .map(|(result, target)| {
1913                ReductionGraph::compute_problem_parameters(
1914                    &target.name,
1915                    &target.variant,
1916                    result.target_problem_any(),
1917                )
1918            })
1919            .collect()
1920    }
1921
1922    /// Extract a solution from target space back to source space.
1923    pub fn extract_solution<S: 'static, T: 'static>(
1924        &self,
1925        target_solution: &T,
1926    ) -> crate::rules::ExtractionResult<S> {
1927        let mut steps = self.steps.iter().rev();
1928        let first = steps.next().expect("ExecutedPath has no steps");
1929        let mut solution = first.extract_solution_dyn(target_solution)?;
1930        for step in steps {
1931            solution = step.extract_solution_dyn(solution.as_ref())?;
1932        }
1933        solution
1934            .downcast::<S>()
1935            .map(|solution| *solution)
1936            .map_err(|_| crate::rules::ExtractionError::invalid("source solution type mismatch"))
1937    }
1938}
1939
1940impl ReductionGraph {
1941    /// Execute a selected batch of witness paths while sharing every common prefix.
1942    pub fn execute_paths(
1943        &self,
1944        paths: &[ReductionPath],
1945        source_instance: &dyn Any,
1946    ) -> Result<Vec<ExecutedPath>, ExecutePathsError> {
1947        let mut prefixes: HashMap<Vec<ReductionStep>, Vec<Rc<dyn DynReductionResult>>> =
1948            HashMap::new();
1949        let mut executed = Vec::with_capacity(paths.len());
1950        let mut batch_source: Option<&ReductionStep> = None;
1951        for (path_index, path) in paths.iter().enumerate() {
1952            let source = path
1953                .steps
1954                .first()
1955                .ok_or(ExecutePathsError::EmptyPath { path_index })?;
1956            if path.steps.len() < 2 {
1957                return Err(ExecutePathsError::NoEdges { path_index });
1958            }
1959            if let Some(expected) = batch_source {
1960                if source != expected {
1961                    return Err(ExecutePathsError::DifferentSource { path_index });
1962                }
1963            } else {
1964                batch_source = Some(source);
1965            }
1966            let source_prefix = vec![source.clone()];
1967            let mut chain = prefixes.get(&source_prefix).cloned().unwrap_or_default();
1968            prefixes.entry(source_prefix.clone()).or_default();
1969            let mut prefix = source_prefix;
1970            for pair in path.steps.windows(2) {
1971                prefix.push(pair[1].clone());
1972                if let Some(cached) = prefixes.get(&prefix) {
1973                    chain = cached.clone();
1974                    continue;
1975                }
1976                let source_node = self
1977                    .lookup_node(&pair[0].name, &pair[0].variant)
1978                    .ok_or_else(|| ExecutePathsError::UnknownNode {
1979                        path_index,
1980                        problem: pair[0].name.clone(),
1981                        variant: pair[0].variant.clone(),
1982                    })?;
1983                let target_node_index = self
1984                    .lookup_node(&pair[1].name, &pair[1].variant)
1985                    .ok_or_else(|| ExecutePathsError::UnknownNode {
1986                        path_index,
1987                        problem: pair[1].name.clone(),
1988                        variant: pair[1].variant.clone(),
1989                    })?;
1990                let edge_index = self
1991                    .graph
1992                    .find_edge(source_node, target_node_index)
1993                    .ok_or_else(|| ExecutePathsError::MissingEdge {
1994                        path_index,
1995                        source_problem: pair[0].name.clone(),
1996                        target_problem: pair[1].name.clone(),
1997                    })?;
1998                let edge_data = &self.graph[edge_index];
1999                let Some(reduce_fn) = edge_data.reduce_fn else {
2000                    return Err(ExecutePathsError::NotWitnessExecutable {
2001                        path_index,
2002                        source_problem: pair[0].name.clone(),
2003                        target_problem: pair[1].name.clone(),
2004                    });
2005                };
2006                let current = chain
2007                    .last()
2008                    .map(|step| step.target_problem_any())
2009                    .unwrap_or(source_instance);
2010                let result = reduce_fn(current)
2011                    .map_err(|cause| ExecutePathsError::Reduction { path_index, cause })?;
2012                chain.push(Rc::from(result));
2013                prefixes.insert(prefix.clone(), chain.clone());
2014            }
2015            executed.push(ExecutedPath {
2016                path: path.clone(),
2017                steps: chain,
2018            });
2019        }
2020        Ok(executed)
2021    }
2022}
2023
2024#[cfg(test)]
2025impl ReductionGraph {
2026    /// Build a bare reduction graph from an explicit node/edge list (test-only).
2027    ///
2028    /// Nodes carry the empty variant and empty complexity; each edge carries a
2029    /// [`ReductionEdgeData`] without depending on registered inventory.
2030    pub(crate) fn from_test_edges(
2031        node_names: &[&'static str],
2032        edges: &[(&'static str, &'static str, ReductionEdgeData)],
2033    ) -> Self {
2034        Self::from_test_variant_edges(
2035            &node_names
2036                .iter()
2037                .map(|&name| (name, BTreeMap::new()))
2038                .collect::<Vec<_>>(),
2039            edges,
2040        )
2041    }
2042
2043    pub(crate) fn from_test_variant_edges(
2044        test_nodes: &[(&'static str, BTreeMap<String, String>)],
2045        edges: &[(&'static str, &'static str, ReductionEdgeData)],
2046    ) -> Self {
2047        let mut graph: DiGraph<usize, ReductionEdgeData> = DiGraph::new();
2048        let mut nodes: Vec<VariantNode> = Vec::new();
2049        let mut name_to_nodes: HashMap<&'static str, Vec<NodeIndex>> = HashMap::new();
2050        let mut index_of: HashMap<&'static str, NodeIndex> = HashMap::new();
2051
2052        for (name, variant) in test_nodes {
2053            let node_id = nodes.len();
2054            nodes.push(VariantNode {
2055                name,
2056                variant: variant.clone(),
2057                complexity: "",
2058            });
2059            let idx = graph.add_node(node_id);
2060            index_of.insert(name, idx);
2061            name_to_nodes.entry(name).or_default().push(idx);
2062        }
2063
2064        for (src, dst, data) in edges {
2065            let s = index_of[src];
2066            let d = index_of[dst];
2067            graph.add_edge(s, d, data.clone());
2068        }
2069
2070        Self {
2071            graph,
2072            nodes,
2073            name_to_nodes,
2074            default_variants: HashMap::new(),
2075        }
2076    }
2077}
2078
2079#[cfg(test)]
2080#[path = "../unit_tests/rules/graph.rs"]
2081mod tests;
2082
2083#[cfg(test)]
2084#[path = "../unit_tests/rules/reduction_path_parity.rs"]
2085mod reduction_path_parity_tests;
2086
2087#[cfg(test)]
2088#[path = "../unit_tests/rules/maximumindependentset_ilp.rs"]
2089mod maximumindependentset_ilp_path_tests;
2090
2091#[cfg(test)]
2092#[path = "../unit_tests/rules/minimumvertexcover_ilp.rs"]
2093mod minimumvertexcover_ilp_path_tests;
2094
2095#[cfg(test)]
2096#[path = "../unit_tests/rules/maximumindependentset_qubo.rs"]
2097mod maximumindependentset_qubo_path_tests;
2098
2099#[cfg(test)]
2100#[path = "../unit_tests/rules/minimumvertexcover_qubo.rs"]
2101mod minimumvertexcover_qubo_path_tests;