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