From c8cac284c3c7513ad93c568c4917e94da5c96539 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:12:05 +0800 Subject: [PATCH 01/39] feat: add ResolvedPath, ReductionStep, EdgeKind types Add data types for variant-aware reduction paths: - ReductionStep: a node with problem name and variant map - EdgeKind: either a registered Reduction (with overhead) or NaturalCast - ResolvedPath: sequence of steps connected by edges, with helper methods Also add Serialize derives to Monomial, Polynomial, and ReductionOverhead to support serialization of the new EdgeKind::Reduction variant. Co-Authored-By: Claude Opus 4.6 --- src/polynomial.rs | 4 +-- src/rules/graph.rs | 66 +++++++++++++++++++++++++++++++++++ src/rules/registry.rs | 2 +- src/unit_tests/rules/graph.rs | 30 ++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/polynomial.rs b/src/polynomial.rs index 60436b2d7..58fd7225f 100644 --- a/src/polynomial.rs +++ b/src/polynomial.rs @@ -5,7 +5,7 @@ use std::fmt; use std::ops::Add; /// A monomial: coefficient × Π(variable^exponent) -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct Monomial { pub coefficient: f64, pub variables: Vec<(&'static str, u8)>, @@ -52,7 +52,7 @@ impl Monomial { } /// A polynomial: Σ monomials -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct Polynomial { pub terms: Vec, } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 6b4649a3a..78708a703 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -116,6 +116,72 @@ impl ReductionPath { } } +/// A node in a variant-level reduction path. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize)] +pub struct ReductionStep { + /// Problem name (e.g., "MaximumIndependentSet"). + pub name: String, + /// Variant at this point (e.g., {"graph": "GridGraph", "weight": "i32"}). + pub variant: std::collections::BTreeMap, +} + +/// The kind of transition between adjacent steps in a resolved path. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize)] +pub enum EdgeKind { + /// A registered reduction (backed by a ReduceTo impl). + Reduction { + /// Overhead from the matching ReductionEntry. + overhead: ReductionOverhead, + }, + /// A natural cast via subtype relaxation. Identity overhead. + NaturalCast, +} + +/// A fully resolved reduction path with variant information at each node. +/// +/// Created by [`ReductionGraph::resolve_path`] from a name-level [`ReductionPath`]. +/// Each adjacent pair of steps is connected by an [`EdgeKind`]: either a registered +/// reduction or a natural cast (subtype relaxation with identity overhead). +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize)] +pub struct ResolvedPath { + /// Sequence of (name, variant) nodes. + pub steps: Vec, + /// Edge kinds between adjacent steps. Length = steps.len() - 1. + pub edges: Vec, +} + +#[allow(dead_code)] +impl ResolvedPath { + /// Number of edges (reductions + casts) in the path. + pub fn len(&self) -> usize { + self.edges.len() + } + + /// Whether the path is empty. + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } + + /// Number of registered reduction steps (excludes natural casts). + pub fn num_reductions(&self) -> usize { + self.edges + .iter() + .filter(|e| matches!(e, EdgeKind::Reduction { .. })) + .count() + } + + /// Number of natural cast steps. + pub fn num_casts(&self) -> usize { + self.edges + .iter() + .filter(|e| matches!(e, EdgeKind::NaturalCast)) + .count() + } +} + /// Edge data for a reduction. #[derive(Clone, Debug)] pub struct ReductionEdge { diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 532b6cbe8..b9ac05983 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -4,7 +4,7 @@ use crate::polynomial::Polynomial; use crate::types::ProblemSize; /// Overhead specification for a reduction. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, serde::Serialize)] pub struct ReductionOverhead { /// Output size as polynomials of input size variables. /// Each entry is (output_field_name, polynomial). diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 9011e05ba..c02fd2e80 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -4,6 +4,36 @@ use crate::models::set::MaximumSetPacking; use crate::rules::cost::MinimizeSteps; use crate::topology::SimpleGraph; +#[test] +fn test_resolved_path_basic_structure() { + use crate::rules::graph::{EdgeKind, ReductionStep, ResolvedPath}; + use std::collections::BTreeMap; + + let steps = vec![ + ReductionStep { + name: "A".to_string(), + variant: BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + }, + ReductionStep { + name: "B".to_string(), + variant: BTreeMap::from([("weight".to_string(), "f64".to_string())]), + }, + ]; + let edges = vec![EdgeKind::Reduction { + overhead: Default::default(), + }]; + let path = ResolvedPath { + steps: steps.clone(), + edges, + }; + + assert_eq!(path.len(), 1); + assert_eq!(path.num_reductions(), 1); + assert_eq!(path.num_casts(), 0); + assert_eq!(path.steps[0].name, "A"); + assert_eq!(path.steps[1].name, "B"); +} + #[test] fn test_find_direct_path() { let graph = ReductionGraph::new(); From ea6ab1f83c07a6e7f03e25415d5bd31bf5239033 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:18:42 +0800 Subject: [PATCH 02/39] feat: add find_best_entry for variant-aware ReductionEntry lookup Co-Authored-By: Claude Opus 4.6 --- src/rules/graph.rs | 56 +++++++++++++++++++++++++++++++ src/unit_tests/rules/graph.rs | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 78708a703..8f6ef7686 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -54,6 +54,13 @@ pub struct NodeJson { pub doc_path: String, } +/// A matched reduction entry: (source_variant, target_variant, overhead). +pub type MatchedEntry = ( + std::collections::BTreeMap, + std::collections::BTreeMap, + ReductionOverhead, +); + /// Internal reference to a problem variant, used during edge construction. #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct VariantRef { @@ -691,6 +698,55 @@ impl ReductionGraph { } } + /// Find the best matching `ReductionEntry` for a (source_name, target_name) pair + /// given the caller's current source variant. + /// + /// "Best" means: compatible (current variant is reducible to the entry's source variant) + /// and most specific (tightest fit among all compatible entries). + /// + /// Returns `(entry_source_variant, entry_target_variant, overhead)` or `None`. + pub fn find_best_entry( + &self, + source_name: &str, + target_name: &str, + current_variant: &std::collections::BTreeMap, + ) -> Option { + let mut best: Option = None; + + for entry in inventory::iter:: { + if entry.source_name != source_name || entry.target_name != target_name { + continue; + } + + let entry_source = Self::variant_to_map(&entry.source_variant()); + let entry_target = Self::variant_to_map(&entry.target_variant()); + + // Check: current_variant is reducible to entry's source variant + // (current is equal-or-more-specific on every axis) + if current_variant != &entry_source + && !self.is_variant_reducible(current_variant, &entry_source) + { + continue; + } + + // Pick the most specific: if we already have a best, prefer the one + // whose source_variant is more specific (tighter fit) + let dominated = if let Some((ref best_source, _, _)) = best { + // New entry is more specific than current best? + self.is_variant_reducible(&entry_source, best_source) + || entry_source == *current_variant + } else { + true + }; + + if dominated { + best = Some((entry_source, entry_target, entry.overhead())); + } + } + + best + } + /// Export the reduction graph as a JSON-serializable structure. /// /// This method generates nodes for each variant based on the registered reductions. diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index c02fd2e80..8697f94bc 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -946,3 +946,66 @@ fn test_natural_edge_has_identity_overhead() { ); } } + +#[test] +fn test_find_matching_entry_ksat_k3() { + let graph = ReductionGraph::new(); + let variant_k3: std::collections::BTreeMap = + [("k".to_string(), "3".to_string())].into(); + + let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant_k3); + assert!(entry.is_some()); + let (source_var, _target_var, overhead) = entry.unwrap(); + // K=3 overhead has num_clauses term; K=2 does not + assert!(overhead + .output_size + .iter() + .any(|(field, _)| *field == "num_vars")); + // K=3 overhead: poly!(num_vars) + poly!(num_clauses) → two terms total + let num_vars_poly = &overhead + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert!( + num_vars_poly.terms.len() >= 2, + "K=3 overhead should have num_vars + num_clauses" + ); + // Verify the source variant matches k=3 + assert_eq!(source_var.get("k"), Some(&"3".to_string())); +} + +#[test] +fn test_find_matching_entry_ksat_k2() { + let graph = ReductionGraph::new(); + let variant_k2: std::collections::BTreeMap = + [("k".to_string(), "2".to_string())].into(); + + let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant_k2); + assert!(entry.is_some()); + let (_source_var, _target_var, overhead) = entry.unwrap(); + // K=2 overhead: just poly!(num_vars) → one term + let num_vars_poly = &overhead + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert_eq!( + num_vars_poly.terms.len(), + 1, + "K=2 overhead should have only num_vars" + ); +} + +#[test] +fn test_find_matching_entry_no_match() { + let graph = ReductionGraph::new(); + let variant: std::collections::BTreeMap = + [("k".to_string(), "99".to_string())].into(); + + // k=99 is not a subtype of k=2 or k=3 + let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant); + assert!(entry.is_none()); +} From b5743f0f934b683dee0ef0aa45a97117ac33eb1c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:23:41 +0800 Subject: [PATCH 03/39] feat: add resolve_path for variant-level reduction paths Co-Authored-By: Claude Opus 4.6 --- src/rules/graph.rs | 65 +++++++++++++++ src/unit_tests/rules/graph.rs | 147 ++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 8f6ef7686..2b30ac369 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -747,6 +747,71 @@ impl ReductionGraph { best } + /// Resolve a name-level [`ReductionPath`] into a variant-level [`ResolvedPath`]. + /// + /// Walks the name-level path, threading variant state through each edge. + /// For each step, picks the most-specific compatible `ReductionEntry` and + /// inserts `NaturalCast` steps where the caller's variant is more specific + /// than the rule's expected source variant. + /// + /// Returns `None` if no compatible reduction entry exists for any step. + pub fn resolve_path( + &self, + path: &ReductionPath, + source_variant: &std::collections::BTreeMap, + target_variant: &std::collections::BTreeMap, + ) -> Option { + if path.type_names.len() < 2 { + return None; + } + + let mut current_variant = source_variant.clone(); + let mut steps = vec![ReductionStep { + name: path.type_names[0].to_string(), + variant: current_variant.clone(), + }]; + let mut edges = Vec::new(); + + for i in 0..path.type_names.len() - 1 { + let src_name = path.type_names[i]; + let dst_name = path.type_names[i + 1]; + + let (entry_source, entry_target, overhead) = + self.find_best_entry(src_name, dst_name, ¤t_variant)?; + + // Insert natural cast if current variant differs from entry's source + if current_variant != entry_source { + steps.push(ReductionStep { + name: src_name.to_string(), + variant: entry_source, + }); + edges.push(EdgeKind::NaturalCast); + } + + // Advance through the reduction + current_variant = entry_target; + steps.push(ReductionStep { + name: dst_name.to_string(), + variant: current_variant.clone(), + }); + edges.push(EdgeKind::Reduction { overhead }); + } + + // Trailing natural cast if final variant differs from requested target + if current_variant != *target_variant + && self.is_variant_reducible(¤t_variant, target_variant) + { + let last_name = path.type_names.last().unwrap(); + steps.push(ReductionStep { + name: last_name.to_string(), + variant: target_variant.clone(), + }); + edges.push(EdgeKind::NaturalCast); + } + + Some(ResolvedPath { steps, edges }) + } + /// Export the reduction graph as a JSON-serializable structure. /// /// This method generates nodes for each variant based on the registered reductions. diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 8697f94bc..360feb745 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1009,3 +1009,150 @@ fn test_find_matching_entry_no_match() { let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant); assert!(entry.is_none()); } + +#[test] +fn test_resolve_path_direct_same_variant() { + use std::collections::BTreeMap; + let graph = ReductionGraph::new(); + + // MIS(SimpleGraph, i32) → VC(SimpleGraph, i32) — no cast needed + let name_path = graph + .find_shortest_path::< + MaximumIndependentSet, + MinimumVertexCover, + >() + .unwrap(); + + let source_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + let target_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + let resolved = graph + .resolve_path(&name_path, &source_variant, &target_variant) + .unwrap(); + + assert_eq!(resolved.num_reductions(), 1); + assert_eq!(resolved.num_casts(), 0); + assert_eq!(resolved.steps.len(), 2); + assert_eq!(resolved.steps[0].name, "MaximumIndependentSet"); + assert_eq!(resolved.steps[1].name, "MinimumVertexCover"); +} + +#[test] +fn test_resolve_path_with_natural_cast() { + use std::collections::BTreeMap; + use crate::topology::GridGraph; + let graph = ReductionGraph::new(); + + // MIS(GridGraph) → VC(SimpleGraph) — needs a natural cast MIS(GridGraph)→MIS(SimpleGraph) + let name_path = graph + .find_shortest_path::< + MaximumIndependentSet, i32>, + MinimumVertexCover, + >() + .unwrap(); + + let source_variant = BTreeMap::from([ + ("graph".to_string(), "GridGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + let target_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + let resolved = graph + .resolve_path(&name_path, &source_variant, &target_variant) + .unwrap(); + + // Should be: MIS(GridGraph) --NaturalCast--> MIS(SimpleGraph) --Reduction--> VC(SimpleGraph) + assert_eq!(resolved.num_reductions(), 1); + assert_eq!(resolved.num_casts(), 1); + assert_eq!(resolved.steps.len(), 3); + assert_eq!(resolved.steps[0].name, "MaximumIndependentSet"); + assert_eq!( + resolved.steps[0].variant.get("graph").unwrap(), + "GridGraph" + ); + assert_eq!(resolved.steps[1].name, "MaximumIndependentSet"); + assert_eq!( + resolved.steps[1].variant.get("graph").unwrap(), + "SimpleGraph" + ); + assert_eq!(resolved.steps[2].name, "MinimumVertexCover"); + assert!(matches!(resolved.edges[0], EdgeKind::NaturalCast)); + assert!(matches!(resolved.edges[1], EdgeKind::Reduction { .. })); +} + +#[test] +fn test_resolve_path_ksat_disambiguates() { + use std::collections::BTreeMap; + use crate::rules::graph::EdgeKind; + let graph = ReductionGraph::new(); + + let name_path = graph + .find_shortest_path_by_name("KSatisfiability", "QUBO") + .unwrap(); + + // Resolve with k=3 + let source_k3 = BTreeMap::from([("k".to_string(), "3".to_string())]); + let target = BTreeMap::from([("weight".to_string(), "f64".to_string())]); + + let resolved_k3 = graph + .resolve_path(&name_path, &source_k3, &target) + .unwrap(); + assert_eq!(resolved_k3.num_reductions(), 1); + + // Extract overhead from the reduction edge + let overhead_k3 = match &resolved_k3.edges.last().unwrap() { + EdgeKind::Reduction { overhead } => overhead, + _ => panic!("last edge should be Reduction"), + }; + // K=3 overhead has 2 terms in num_vars polynomial + let num_vars_poly_k3 = &overhead_k3 + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert!(num_vars_poly_k3.terms.len() >= 2); + + // Resolve with k=2 + let source_k2 = BTreeMap::from([("k".to_string(), "2".to_string())]); + let resolved_k2 = graph + .resolve_path(&name_path, &source_k2, &target) + .unwrap(); + let overhead_k2 = match &resolved_k2.edges.last().unwrap() { + EdgeKind::Reduction { overhead } => overhead, + _ => panic!("last edge should be Reduction"), + }; + let num_vars_poly_k2 = &overhead_k2 + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert_eq!(num_vars_poly_k2.terms.len(), 1); +} + +#[test] +fn test_resolve_path_incompatible_returns_none() { + use std::collections::BTreeMap; + let graph = ReductionGraph::new(); + + let name_path = graph + .find_shortest_path_by_name("KSatisfiability", "QUBO") + .unwrap(); + + // k=99 matches neither k=2 nor k=3 + let source = BTreeMap::from([("k".to_string(), "99".to_string())]); + let target = BTreeMap::from([("weight".to_string(), "f64".to_string())]); + + let resolved = graph.resolve_path(&name_path, &source, &target); + assert!(resolved.is_none()); +} From 74dbc7cc092f82111543eb48a36e88ea0a7d6757 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:28:42 +0800 Subject: [PATCH 04/39] refactor: deprecate lookup_overhead, migrate KSat example to resolve_path Co-Authored-By: Claude Opus 4.6 --- examples/reduction_ksatisfiability_to_qubo.rs | 25 ++++++++++++++++--- src/export.rs | 9 +++++++ src/rules/mod.rs | 3 ++- src/unit_tests/export.rs | 4 +++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/examples/reduction_ksatisfiability_to_qubo.rs b/examples/reduction_ksatisfiability_to_qubo.rs index ed7d16088..da8ffb2c0 100644 --- a/examples/reduction_ksatisfiability_to_qubo.rs +++ b/examples/reduction_ksatisfiability_to_qubo.rs @@ -116,9 +116,28 @@ pub fn run() { println!("\nVerification passed: all solutions maximize satisfied clauses"); - // Export JSON - let overhead = lookup_overhead("KSatisfiability", "QUBO") - .expect("KSatisfiability -> QUBO overhead not found"); + // Resolve variant-aware overhead via resolve_path + let rg = problemreductions::rules::ReductionGraph::new(); + let name_path = rg + .find_shortest_path_by_name("KSatisfiability", "QUBO") + .expect("KSatisfiability -> QUBO path not found"); + let source_variant = variant_to_map(KSatisfiability::<3>::variant()) + .into_iter() + .collect::>(); + let target_variant = variant_to_map(QUBO::::variant()) + .into_iter() + .collect::>(); + let resolved = rg + .resolve_path(&name_path, &source_variant, &target_variant) + .expect("Failed to resolve KSatisfiability -> QUBO path"); + // Extract overhead from the reduction edge + let overhead = match resolved.edges.iter().find_map(|e| match e { + problemreductions::rules::EdgeKind::Reduction { overhead } => Some(overhead), + _ => None, + }) { + Some(o) => o.clone(), + None => panic!("Resolved path has no reduction edge"), + }; let data = ReductionData { source: ProblemSide { diff --git a/src/export.rs b/src/export.rs index fa0232197..0c31e6bf6 100644 --- a/src/export.rs +++ b/src/export.rs @@ -88,6 +88,10 @@ pub fn overhead_to_json(overhead: &ReductionOverhead) -> Vec { /// Searches all registered `ReductionEntry` items for a matching source/target pair. /// Returns `None` if no matching reduction is registered (e.g., ILP reductions /// that don't use the `#[reduction]` macro). +#[deprecated( + since = "0.2.0", + note = "Use ReductionGraph::resolve_path() for variant-aware overhead lookup" +)] pub fn lookup_overhead(source_name: &str, target_name: &str) -> Option { for entry in inventory::iter:: { if entry.source_name == source_name && entry.target_name == target_name { @@ -98,7 +102,12 @@ pub fn lookup_overhead(source_name: &str, target_name: &str) -> Option ReductionOverhead { + #[allow(deprecated)] lookup_overhead(source_name, target_name).unwrap_or_default() } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 9bf45d5da..670c33184 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -63,7 +63,8 @@ pub use circuit_spinglass::{ pub use coloring_qubo::ReductionKColoringToQUBO; pub use factoring_circuit::ReductionFactoringToCircuit; pub use graph::{ - EdgeJson, NodeJson, ReductionEdge, ReductionGraph, ReductionGraphJson, ReductionPath, + EdgeJson, EdgeKind, NodeJson, ReductionEdge, ReductionGraph, ReductionGraphJson, + ReductionPath, ReductionStep, ResolvedPath, }; pub use ksatisfiability_qubo::{Reduction3SATToQUBO, ReductionKSatToQUBO}; pub use maximumindependentset_gridgraph::{ReductionISSimpleToGrid, ReductionISUnitDiskToGrid}; diff --git a/src/unit_tests/export.rs b/src/unit_tests/export.rs index dbc19b3d6..86dd0447b 100644 --- a/src/unit_tests/export.rs +++ b/src/unit_tests/export.rs @@ -94,6 +94,7 @@ fn test_variant_to_map_multiple() { } #[test] +#[allow(deprecated)] fn test_lookup_overhead_known_reduction() { // IS -> VC is a known registered reduction let result = lookup_overhead("MaximumIndependentSet", "MinimumVertexCover"); @@ -101,18 +102,21 @@ fn test_lookup_overhead_known_reduction() { } #[test] +#[allow(deprecated)] fn test_lookup_overhead_unknown_reduction() { let result = lookup_overhead("NonExistent", "AlsoNonExistent"); assert!(result.is_none()); } #[test] +#[allow(deprecated)] fn test_lookup_overhead_or_empty_known() { let overhead = lookup_overhead_or_empty("MaximumIndependentSet", "MinimumVertexCover"); assert!(!overhead.output_size.is_empty()); } #[test] +#[allow(deprecated)] fn test_lookup_overhead_or_empty_unknown() { let overhead = lookup_overhead_or_empty("NonExistent", "AlsoNonExistent"); assert!(overhead.output_size.is_empty()); From 06e2fbda7871957254b6f3f28c89a5a62de093ef Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:29:45 +0800 Subject: [PATCH 05/39] rm plan files --- .../2026-02-14-variant-aware-paths-design.md | 204 +++++ .../2026-02-14-variant-aware-paths-impl.md | 747 ++++++++++++++++++ 2 files changed, 951 insertions(+) create mode 100644 docs/plans/2026-02-14-variant-aware-paths-design.md create mode 100644 docs/plans/2026-02-14-variant-aware-paths-impl.md diff --git a/docs/plans/2026-02-14-variant-aware-paths-design.md b/docs/plans/2026-02-14-variant-aware-paths-design.md new file mode 100644 index 000000000..452a1b81f --- /dev/null +++ b/docs/plans/2026-02-14-variant-aware-paths-design.md @@ -0,0 +1,204 @@ +# Variant-Aware Reduction Paths + +**Goal:** Make reduction paths variant-level so that (a) variant-specific reductions are disambiguated (issue 2) and (b) natural cast steps are computed automatically from subtype hierarchies (issue 5). + +## Background + +The runtime `ReductionGraph` uses name-only nodes. `ReductionPath` is `Vec<&'static str>` — it carries no variant information. This causes two problems: + +1. **Overhead lookup ambiguity (issue 2):** `lookup_overhead("KSatisfiability", "QUBO")` returns the first hit from inventory. KSatisfiability<2>→QUBO and KSatisfiability<3>→QUBO have different overheads, but the caller can't distinguish them. + +2. **Natural edge inconsistency (issue 5):** The JSON export infers 8 natural edges (e.g., MIS GridGraph→SimpleGraph) from subtype hierarchies, but only 1 has a backing `ReduceTo` impl. Users see edges in documentation that aren't executable. + +## Design + +### 1. `ResolvedPath` Data Model + +A variant-level path where each node carries `(name, variant)` and each edge is typed: + +```rust +/// A node in a variant-level reduction path. +#[derive(Debug, Clone, Serialize)] +pub struct ReductionStep { + /// Problem name (e.g., "MaximumIndependentSet"). + pub name: String, + /// Variant at this point (e.g., {"graph": "GridGraph", "weight": "i32"}). + pub variant: BTreeMap, +} + +/// The kind of transition between adjacent steps. +#[derive(Debug, Clone, Serialize)] +pub enum EdgeKind { + /// A registered reduction (backed by a ReduceTo impl). + Reduction { + /// Overhead from the matching ReductionEntry. + overhead: ReductionOverhead, + }, + /// A natural cast via subtype relaxation. Identity overhead. + NaturalCast, +} + +/// A fully resolved reduction path with variant information at each node. +#[derive(Debug, Clone, Serialize)] +pub struct ResolvedPath { + /// Sequence of (name, variant) nodes. + pub steps: Vec, + /// Edge kinds between adjacent steps. Length = steps.len() - 1. + pub edges: Vec, +} +``` + +Example — resolving `MIS(GridGraph, i32) → QUBO(f64)` through name-path `["MIS", "QUBO"]`: + +``` +steps: + [0] MIS {graph: "GridGraph", weight: "i32"} ← source + [1] MIS {graph: "SimpleGraph", weight: "i32"} ← natural cast + [2] QUBO {weight: "f64"} ← reduction target + +edges: + [0] NaturalCast ← GridGraph <: SimpleGraph + [1] Reduction { overhead: ... } ← MIS→QUBO rule +``` + +### 2. Resolution Algorithm + +```rust +impl ReductionGraph { + pub fn resolve_path( + &self, + path: &ReductionPath, + source_variant: &BTreeMap, + target_variant: &BTreeMap, + ) -> Option { ... } +} +``` + +Algorithm: + +``` +current_variant = source_variant +steps = [ Step(path[0], current_variant) ] +edges = [] + +for each edge (src_name → dst_name) in the name-level path: + + 1. FIND CANDIDATES + Collect all ReductionEntry where + entry.source_name == src_name AND entry.target_name == dst_name + + 2. FILTER COMPATIBLE + Keep entries where current_variant is reducible to entry.source_variant + (current is equal-or-more-specific on every variant axis) + + 3. PICK MOST SPECIFIC + Among compatible entries, pick the one whose source_variant is the + tightest supertype of current_variant. + If none compatible → return None. + + 4. INSERT NATURAL CAST (if needed) + If current_variant ≠ best_rule.source_variant: + steps.push( Step(src_name, best_rule.source_variant) ) + edges.push( NaturalCast ) + + 5. ADVANCE + current_variant = best_rule.target_variant + steps.push( Step(dst_name, current_variant) ) + edges.push( Reduction { overhead: best_rule.overhead() } ) + +// Trailing natural cast if final variant differs from target +if current_variant ≠ target_variant + AND is_variant_reducible(current_variant, target_variant): + steps.push( Step(last_name, target_variant) ) + edges.push( NaturalCast ) + +return ResolvedPath { steps, edges } +``` + +### 3. KSat Disambiguation Example + +Resolving `KSat(k=3) → QUBO` via name-path `["KSatisfiability", "QUBO"]`: + +``` +FIND CANDIDATES: + - KSat<2>→QUBO (source_variant: {k:"2"}, overhead: num_vars) + - KSat<3>→QUBO (source_variant: {k:"3"}, overhead: num_vars + num_clauses) + +FILTER COMPATIBLE with current k=3: + - KSat<2>: k=3 reducible to k=2? No (3 is not a subtype of 2) + - KSat<3>: k=3 == k=3? Yes ✓ + +PICK: KSat<3>→QUBO with correct overhead. +``` + +Overhead ambiguity is resolved by construction — the resolver picks the exact matching entry. + +### 4. Natural Edges Become Implicit + +With `resolve_path`, natural casts are **computed from subtype hierarchies**, not registered as `ReduceTo` impls. + +**Removed:** +- `impl_natural_reduction!` macro invocations (the one in `natural.rs` and any future ones) +- Natural edges no longer need `ReductionEntry` registration via inventory + +**Kept:** +- `GraphSubtypeEntry` / `WeightSubtypeEntry` — source of truth for subtype relationships +- Inference logic in `to_json()` — unchanged, still produces natural edges in JSON export +- `GraphCast` trait — still needed for actual execution by callers + +**Callers execute natural steps** using `GraphCast::cast_graph()` (or equivalent weight cast) directly, guided by the `EdgeKind::NaturalCast` marker in the resolved path. No `ReduceTo` dispatch needed. + +### 5. `lookup_overhead` Deprecated + +`lookup_overhead(source_name, target_name)` is replaced by per-step overhead in `ResolvedPath`: + +```rust +impl ResolvedPath { + /// Total overhead for the entire path (composed across all steps). + pub fn total_overhead(&self) -> ReductionOverhead { ... } + + /// Number of reduction steps (excludes natural casts). + pub fn num_reductions(&self) -> usize { ... } + + /// Number of natural cast steps. + pub fn num_casts(&self) -> usize { ... } +} +``` + +Examples migrate from `lookup_overhead("A", "B")` to using the resolved path's overhead. + +### 6. Backward Compatibility + +| API | Change | +|-----|--------| +| `ReductionPath` | Unchanged — still returned by `find_paths`, `find_cheapest_path` | +| `find_paths`, `find_paths_by_name` | Unchanged | +| `find_cheapest_path` | Unchanged (name-level planning) | +| `has_direct_reduction` | Unchanged | +| `resolve_path` | **New** — lifts name-level path to variant-level | +| `ResolvedPath` | **New** | +| `lookup_overhead` | **Deprecated** — kept for one release, then removed | +| `lookup_overhead_or_empty` | **Deprecated** | +| `impl_natural_reduction!` | **Removed** after migration | + +Existing code using `find_paths` + `lookup_overhead` continues working. New code should use `find_paths` + `resolve_path` for variant-correct results. + +### 7. Files Changed + +| File | Change | +|------|--------| +| `src/rules/graph.rs` | Add `ResolvedPath`, `ReductionStep`, `EdgeKind`, `resolve_path()` method | +| `src/export.rs` | Deprecate `lookup_overhead`, `lookup_overhead_or_empty` | +| `src/rules/natural.rs` | Remove `impl_natural_reduction!` invocation | +| `src/rules/mod.rs` | Keep `impl_natural_reduction!` macro (optional convenience), remove from prelude | +| `examples/reduction_ksatisfiability_to_qubo.rs` | Migrate from `lookup_overhead` to `resolve_path` | +| `examples/*.rs` | Migrate remaining examples (can be incremental) | +| `src/unit_tests/rules/graph.rs` | Add tests for `resolve_path` | +| `src/unit_tests/rules/natural.rs` | Update or remove natural reduction tests | + +### 8. Non-Goals + +- Runtime graph does not become variant-level (stays name-only for path discovery) +- No execution engine — `ResolvedPath` is a plan; callers dispatch `ReduceTo` and `GraphCast` themselves +- No changes to `to_json()` natural edge inference (it already works correctly) +- No changes to `#[reduction]` macro diff --git a/docs/plans/2026-02-14-variant-aware-paths-impl.md b/docs/plans/2026-02-14-variant-aware-paths-impl.md new file mode 100644 index 000000000..ce6beba50 --- /dev/null +++ b/docs/plans/2026-02-14-variant-aware-paths-impl.md @@ -0,0 +1,747 @@ +# Variant-Aware Reduction Paths — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `resolve_path()` to lift name-level reduction paths into variant-level paths with natural cast steps, fixing overhead disambiguation (issue 2) and natural edge inconsistency (issue 5). + +**Architecture:** `ResolvedPath` is a new type layered on top of the existing `ReductionPath`. The resolver walks a name-level path, threads variant state through each edge, picks the most-specific matching `ReductionEntry`, and inserts `NaturalCast` steps where the caller's variant is more specific than what the rule expects. No changes to the name-level graph or path-finding algorithms. + +**Tech Stack:** Rust, `inventory` crate (existing), `petgraph` (existing), `serde` (existing), `BTreeMap` for variant representation. + +--- + +### Task 1: Add `ResolvedPath` data types + +**Files:** +- Modify: `src/rules/graph.rs` (after `ReductionPath` impl block, ~line 132) + +**Step 1: Write the failing test** + +Add to `src/unit_tests/rules/graph.rs`: + +```rust +#[test] +fn test_resolved_path_basic_structure() { + use crate::rules::graph::{ResolvedPath, ReductionStep, EdgeKind}; + use std::collections::BTreeMap; + + let steps = vec![ + ReductionStep { + name: "A".to_string(), + variant: BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + }, + ReductionStep { + name: "B".to_string(), + variant: BTreeMap::from([("weight".to_string(), "f64".to_string())]), + }, + ]; + let edges = vec![EdgeKind::Reduction { + overhead: Default::default(), + }]; + let path = ResolvedPath { + steps: steps.clone(), + edges, + }; + + assert_eq!(path.len(), 1); + assert_eq!(path.num_reductions(), 1); + assert_eq!(path.num_casts(), 0); + assert_eq!(path.steps[0].name, "A"); + assert_eq!(path.steps[1].name, "B"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test test_resolved_path_basic_structure -- --no-run 2>&1` +Expected: Compilation error — `ResolvedPath`, `ReductionStep`, `EdgeKind` not defined. + +**Step 3: Write the types** + +Add to `src/rules/graph.rs` after the `ReductionPath` impl block (after line 132): + +```rust +/// A node in a variant-level reduction path. +#[derive(Debug, Clone, Serialize)] +pub struct ReductionStep { + /// Problem name (e.g., "MaximumIndependentSet"). + pub name: String, + /// Variant at this point (e.g., {"graph": "GridGraph", "weight": "i32"}). + pub variant: std::collections::BTreeMap, +} + +/// The kind of transition between adjacent steps in a resolved path. +#[derive(Debug, Clone, Serialize)] +pub enum EdgeKind { + /// A registered reduction (backed by a ReduceTo impl). + Reduction { + /// Overhead from the matching ReductionEntry. + overhead: ReductionOverhead, + }, + /// A natural cast via subtype relaxation. Identity overhead. + NaturalCast, +} + +/// A fully resolved reduction path with variant information at each node. +/// +/// Created by [`ReductionGraph::resolve_path`] from a name-level [`ReductionPath`]. +/// Each adjacent pair of steps is connected by an [`EdgeKind`]: either a registered +/// reduction or a natural cast (subtype relaxation with identity overhead). +#[derive(Debug, Clone, Serialize)] +pub struct ResolvedPath { + /// Sequence of (name, variant) nodes. + pub steps: Vec, + /// Edge kinds between adjacent steps. Length = steps.len() - 1. + pub edges: Vec, +} + +impl ResolvedPath { + /// Number of edges (reductions + casts) in the path. + pub fn len(&self) -> usize { + self.edges.len() + } + + /// Whether the path is empty. + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } + + /// Number of registered reduction steps (excludes natural casts). + pub fn num_reductions(&self) -> usize { + self.edges + .iter() + .filter(|e| matches!(e, EdgeKind::Reduction { .. })) + .count() + } + + /// Number of natural cast steps. + pub fn num_casts(&self) -> usize { + self.edges + .iter() + .filter(|e| matches!(e, EdgeKind::NaturalCast)) + .count() + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cargo test test_resolved_path_basic_structure` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/rules/graph.rs src/unit_tests/rules/graph.rs +git commit -m "feat: add ResolvedPath, ReductionStep, EdgeKind types" +``` + +--- + +### Task 2: Add helper to find matching ReductionEntry candidates + +The resolver needs to iterate `inventory::iter::` filtered by name pair, check variant compatibility, and pick the most specific match. Add this as a private helper on `ReductionGraph`. + +**Files:** +- Modify: `src/rules/graph.rs` (inside the second `impl ReductionGraph` block that contains `is_variant_reducible`, after line 618) + +**Step 1: Write the failing test** + +Add to `src/unit_tests/rules/graph.rs`: + +```rust +#[test] +fn test_find_matching_entry_ksat_k3() { + let graph = ReductionGraph::new(); + let variant_k3: std::collections::BTreeMap = + [("k".to_string(), "3".to_string())].into(); + + let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant_k3); + assert!(entry.is_some()); + let (source_var, _target_var, overhead) = entry.unwrap(); + // K=3 overhead has num_clauses term; K=2 does not + assert!(overhead + .output_size + .iter() + .any(|(field, _)| *field == "num_vars")); + // K=3 overhead: poly!(num_vars) + poly!(num_clauses) → two terms total + let num_vars_poly = &overhead + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert!( + num_vars_poly.terms.len() >= 2, + "K=3 overhead should have num_vars + num_clauses" + ); +} + +#[test] +fn test_find_matching_entry_ksat_k2() { + let graph = ReductionGraph::new(); + let variant_k2: std::collections::BTreeMap = + [("k".to_string(), "2".to_string())].into(); + + let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant_k2); + assert!(entry.is_some()); + let (_source_var, _target_var, overhead) = entry.unwrap(); + // K=2 overhead: just poly!(num_vars) → one term + let num_vars_poly = &overhead + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert_eq!( + num_vars_poly.terms.len(), + 1, + "K=2 overhead should have only num_vars" + ); +} + +#[test] +fn test_find_matching_entry_no_match() { + let graph = ReductionGraph::new(); + let variant: std::collections::BTreeMap = + [("k".to_string(), "99".to_string())].into(); + + // k=99 is not a subtype of k=2 or k=3 + let entry = graph.find_best_entry("KSatisfiability", "QUBO", &variant); + assert!(entry.is_none()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test test_find_matching_entry -- --no-run 2>&1` +Expected: Compilation error — `find_best_entry` method not found. + +**Step 3: Implement `find_best_entry`** + +Add to `src/rules/graph.rs` in the `impl ReductionGraph` block that contains `is_variant_reducible` (after `is_variant_reducible` at ~line 618): + +```rust + /// Find the best matching `ReductionEntry` for a (source_name, target_name) pair + /// given the caller's current source variant. + /// + /// "Best" means: compatible (current variant is reducible to the entry's source variant) + /// and most specific (tightest fit among all compatible entries). + /// + /// Returns `(entry_source_variant, entry_target_variant, overhead)` or `None`. + pub fn find_best_entry( + &self, + source_name: &str, + target_name: &str, + current_variant: &std::collections::BTreeMap, + ) -> Option<( + std::collections::BTreeMap, + std::collections::BTreeMap, + ReductionOverhead, + )> { + use crate::rules::registry::ReductionEntry; + + let mut best: Option<( + std::collections::BTreeMap, + std::collections::BTreeMap, + ReductionOverhead, + )> = None; + + for entry in inventory::iter:: { + if entry.source_name != source_name || entry.target_name != target_name { + continue; + } + + let entry_source = Self::variant_to_map(&entry.source_variant()); + let entry_target = Self::variant_to_map(&entry.target_variant()); + + // Check: current_variant is reducible to entry's source variant + // (current is equal-or-more-specific on every axis) + if current_variant != &entry_source + && !self.is_variant_reducible(current_variant, &entry_source) + { + continue; + } + + // Pick the most specific: if we already have a best, prefer the one + // whose source_variant is more specific (tighter fit) + let dominated = if let Some((ref best_source, _, _)) = best { + // New entry is more specific than current best? + self.is_variant_reducible(&entry_source, best_source) + || entry_source == *current_variant + } else { + true + }; + + if dominated { + best = Some((entry_source, entry_target, entry.overhead())); + } + } + + best + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test test_find_matching_entry` +Expected: All 3 tests PASS. + +**Step 5: Commit** + +```bash +git add src/rules/graph.rs src/unit_tests/rules/graph.rs +git commit -m "feat: add find_best_entry for variant-aware ReductionEntry lookup" +``` + +--- + +### Task 3: Implement `resolve_path` + +**Files:** +- Modify: `src/rules/graph.rs` (add method to `ReductionGraph`, near `find_best_entry`) + +**Step 1: Write the failing tests** + +Add to `src/unit_tests/rules/graph.rs`: + +```rust +#[test] +fn test_resolve_path_direct_same_variant() { + use std::collections::BTreeMap; + let graph = ReductionGraph::new(); + + // MIS(SimpleGraph, i32) → VC(SimpleGraph, i32) — no cast needed + let name_path = graph + .find_shortest_path::< + MaximumIndependentSet, + MinimumVertexCover, + >() + .unwrap(); + + let source_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + let target_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + let resolved = graph + .resolve_path(&name_path, &source_variant, &target_variant) + .unwrap(); + + assert_eq!(resolved.num_reductions(), 1); + assert_eq!(resolved.num_casts(), 0); + assert_eq!(resolved.steps.len(), 2); + assert_eq!(resolved.steps[0].name, "MaximumIndependentSet"); + assert_eq!(resolved.steps[1].name, "MinimumVertexCover"); +} + +#[test] +fn test_resolve_path_with_natural_cast() { + use std::collections::BTreeMap; + use crate::topology::GridGraph; + let graph = ReductionGraph::new(); + + // MIS(GridGraph) → VC(SimpleGraph) — needs a natural cast MIS(GridGraph)→MIS(SimpleGraph) + let name_path = graph + .find_shortest_path::< + MaximumIndependentSet, + MinimumVertexCover, + >() + .unwrap(); + + let source_variant = BTreeMap::from([ + ("graph".to_string(), "GridGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + let target_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + let resolved = graph + .resolve_path(&name_path, &source_variant, &target_variant) + .unwrap(); + + // Should be: MIS(GridGraph) --NaturalCast--> MIS(SimpleGraph) --Reduction--> VC(SimpleGraph) + assert_eq!(resolved.num_reductions(), 1); + assert_eq!(resolved.num_casts(), 1); + assert_eq!(resolved.steps.len(), 3); + assert_eq!(resolved.steps[0].name, "MaximumIndependentSet"); + assert_eq!( + resolved.steps[0].variant.get("graph").unwrap(), + "GridGraph" + ); + assert_eq!(resolved.steps[1].name, "MaximumIndependentSet"); + assert_eq!( + resolved.steps[1].variant.get("graph").unwrap(), + "SimpleGraph" + ); + assert_eq!(resolved.steps[2].name, "MinimumVertexCover"); + assert!(matches!(resolved.edges[0], EdgeKind::NaturalCast)); + assert!(matches!(resolved.edges[1], EdgeKind::Reduction { .. })); +} + +#[test] +fn test_resolve_path_ksat_disambiguates() { + use std::collections::BTreeMap; + use crate::rules::graph::EdgeKind; + let graph = ReductionGraph::new(); + + let name_path = graph + .find_shortest_path_by_name("KSatisfiability", "QUBO") + .unwrap(); + + // Resolve with k=3 + let source_k3 = BTreeMap::from([("k".to_string(), "3".to_string())]); + let target = BTreeMap::from([("weight".to_string(), "f64".to_string())]); + + let resolved_k3 = graph + .resolve_path(&name_path, &source_k3, &target) + .unwrap(); + assert_eq!(resolved_k3.num_reductions(), 1); + + // Extract overhead from the reduction edge + let overhead_k3 = match &resolved_k3.edges.last().unwrap() { + EdgeKind::Reduction { overhead } => overhead, + _ => panic!("last edge should be Reduction"), + }; + // K=3 overhead has 2 terms in num_vars polynomial + let num_vars_poly_k3 = &overhead_k3 + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert!(num_vars_poly_k3.terms.len() >= 2); + + // Resolve with k=2 + let source_k2 = BTreeMap::from([("k".to_string(), "2".to_string())]); + let resolved_k2 = graph + .resolve_path(&name_path, &source_k2, &target) + .unwrap(); + let overhead_k2 = match &resolved_k2.edges.last().unwrap() { + EdgeKind::Reduction { overhead } => overhead, + _ => panic!("last edge should be Reduction"), + }; + let num_vars_poly_k2 = &overhead_k2 + .output_size + .iter() + .find(|(f, _)| *f == "num_vars") + .unwrap() + .1; + assert_eq!(num_vars_poly_k2.terms.len(), 1); +} + +#[test] +fn test_resolve_path_incompatible_returns_none() { + use std::collections::BTreeMap; + let graph = ReductionGraph::new(); + + let name_path = graph + .find_shortest_path_by_name("KSatisfiability", "QUBO") + .unwrap(); + + // k=99 matches neither k=2 nor k=3 + let source = BTreeMap::from([("k".to_string(), "99".to_string())]); + let target = BTreeMap::from([("weight".to_string(), "f64".to_string())]); + + let resolved = graph.resolve_path(&name_path, &source, &target); + assert!(resolved.is_none()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test test_resolve_path -- --no-run 2>&1` +Expected: Compilation error — `resolve_path` method not found. + +**Step 3: Implement `resolve_path`** + +Add to `src/rules/graph.rs` in the same `impl ReductionGraph` block, after `find_best_entry`: + +```rust + /// Resolve a name-level [`ReductionPath`] into a variant-level [`ResolvedPath`]. + /// + /// Walks the name-level path, threading variant state through each edge. + /// For each step, picks the most-specific compatible `ReductionEntry` and + /// inserts `NaturalCast` steps where the caller's variant is more specific + /// than the rule's expected source variant. + /// + /// Returns `None` if no compatible reduction entry exists for any step. + pub fn resolve_path( + &self, + path: &ReductionPath, + source_variant: &std::collections::BTreeMap, + target_variant: &std::collections::BTreeMap, + ) -> Option { + if path.type_names.len() < 2 { + return None; + } + + let mut current_variant = source_variant.clone(); + let mut steps = vec![ReductionStep { + name: path.type_names[0].to_string(), + variant: current_variant.clone(), + }]; + let mut edges = Vec::new(); + + for i in 0..path.type_names.len() - 1 { + let src_name = path.type_names[i]; + let dst_name = path.type_names[i + 1]; + + let (entry_source, entry_target, overhead) = + self.find_best_entry(src_name, dst_name, ¤t_variant)?; + + // Insert natural cast if current variant differs from entry's source + if current_variant != entry_source { + steps.push(ReductionStep { + name: src_name.to_string(), + variant: entry_source, + }); + edges.push(EdgeKind::NaturalCast); + } + + // Advance through the reduction + current_variant = entry_target; + steps.push(ReductionStep { + name: dst_name.to_string(), + variant: current_variant.clone(), + }); + edges.push(EdgeKind::Reduction { overhead }); + } + + // Trailing natural cast if final variant differs from requested target + if current_variant != *target_variant + && self.is_variant_reducible(¤t_variant, target_variant) + { + let last_name = path.type_names.last().unwrap(); + steps.push(ReductionStep { + name: last_name.to_string(), + variant: target_variant.clone(), + }); + edges.push(EdgeKind::NaturalCast); + } + + Some(ResolvedPath { steps, edges }) + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test test_resolve_path` +Expected: All 4 tests PASS. + +**Step 5: Commit** + +```bash +git add src/rules/graph.rs src/unit_tests/rules/graph.rs +git commit -m "feat: add resolve_path for variant-level reduction paths" +``` + +--- + +### Task 4: Deprecate `lookup_overhead` and migrate KSat example + +**Files:** +- Modify: `src/export.rs:91-98` (add deprecation) +- Modify: `src/export.rs:100-103` (add deprecation) +- Modify: `examples/reduction_ksatisfiability_to_qubo.rs:120-121` (migrate to resolve_path) + +**Step 1: Add deprecation annotations** + +In `src/export.rs`, add `#[deprecated]` to both functions: + +```rust +#[deprecated( + since = "0.2.0", + note = "Use ReductionGraph::resolve_path() for variant-aware overhead lookup" +)] +pub fn lookup_overhead(source_name: &str, target_name: &str) -> Option { + // ... unchanged body ... +} + +#[deprecated( + since = "0.2.0", + note = "Use ReductionGraph::resolve_path() for variant-aware overhead lookup" +)] +pub fn lookup_overhead_or_empty(source_name: &str, target_name: &str) -> ReductionOverhead { + lookup_overhead(source_name, target_name).unwrap_or_default() +} +``` + +**Step 2: Migrate the KSat example** + +In `examples/reduction_ksatisfiability_to_qubo.rs`, replace the `lookup_overhead` call (line 120-121) with `resolve_path`: + +```rust + // Resolve variant-aware overhead via resolve_path + let rg = problemreductions::rules::graph::ReductionGraph::new(); + let name_path = rg + .find_shortest_path_by_name("KSatisfiability", "QUBO") + .expect("KSatisfiability -> QUBO path not found"); + let source_variant = variant_to_map(KSatisfiability::<3>::variant()) + .into_iter() + .map(|(k, v)| (k, v)) + .collect::>(); + let target_variant = variant_to_map(QUBO::::variant()) + .into_iter() + .map(|(k, v)| (k, v)) + .collect::>(); + let resolved = rg + .resolve_path(&name_path, &source_variant, &target_variant) + .expect("Failed to resolve KSatisfiability -> QUBO path"); + // Extract overhead from the reduction edge + let overhead = match resolved.edges.iter().find_map(|e| match e { + problemreductions::rules::graph::EdgeKind::Reduction { overhead } => Some(overhead), + _ => None, + }) { + Some(o) => o.clone(), + None => panic!("Resolved path has no reduction edge"), + }; +``` + +**Step 3: Verify the example still compiles and runs** + +Run: `cargo build --example reduction_ksatisfiability_to_qubo --features ilp` +Expected: Builds (deprecation warnings for other examples are OK). + +Run: `cargo run --example reduction_ksatisfiability_to_qubo --features ilp` +Expected: Runs successfully, produces JSON output. + +**Step 4: Commit** + +```bash +git add src/export.rs examples/reduction_ksatisfiability_to_qubo.rs +git commit -m "refactor: deprecate lookup_overhead, migrate KSat example to resolve_path" +``` + +--- + +### Task 5: Remove `impl_natural_reduction!` invocation from `natural.rs` + +Now that `resolve_path` inserts natural casts automatically, the explicit natural reduction registration is no longer needed for planning. Remove it and update the test. + +**Files:** +- Modify: `src/rules/natural.rs` (remove invocation) +- Modify: `src/unit_tests/rules/natural.rs` (update test to use resolve_path instead) + +**Step 1: Update the test to verify natural casts via resolve_path** + +Replace `src/unit_tests/rules/natural.rs` contents: + +```rust +use crate::models::graph::MaximumIndependentSet; +use crate::rules::graph::{EdgeKind, ReductionGraph}; +use crate::topology::{SimpleGraph, Triangular}; +use crate::traits::Problem; +use std::collections::BTreeMap; + +#[test] +fn test_natural_cast_triangular_to_simple_via_resolve() { + let graph = ReductionGraph::new(); + + // Find any path from MIS to itself (via VC round-trip) to test natural cast insertion + // Instead, directly test that resolve_path inserts a natural cast for MIS(Triangular)→VC(SimpleGraph) + let name_path = graph + .find_shortest_path::< + MaximumIndependentSet, + crate::models::graph::MinimumVertexCover, + >() + .unwrap(); + + let source_variant = BTreeMap::from([ + ("graph".to_string(), "Triangular".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + let target_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + let resolved = graph + .resolve_path(&name_path, &source_variant, &target_variant) + .unwrap(); + + // Path should be: MIS(Triangular) --NaturalCast--> MIS(SimpleGraph) --Reduction--> VC(SimpleGraph) + assert_eq!(resolved.num_casts(), 1); + assert_eq!(resolved.num_reductions(), 1); + assert!(matches!(resolved.edges[0], EdgeKind::NaturalCast)); + assert!(matches!(resolved.edges[1], EdgeKind::Reduction { .. })); + assert_eq!( + resolved.steps[0].variant.get("graph").unwrap(), + "Triangular" + ); + assert_eq!( + resolved.steps[1].variant.get("graph").unwrap(), + "SimpleGraph" + ); +} +``` + +**Step 2: Remove the `impl_natural_reduction!` invocation** + +Update `src/rules/natural.rs` to: + +```rust +//! Natural-edge reductions via graph subtype relaxation. +//! +//! Natural reductions (e.g., a problem on `Triangular` solved as `SimpleGraph`) +//! are handled automatically by [`ReductionGraph::resolve_path`], which inserts +//! `NaturalCast` steps based on the registered graph/weight subtype hierarchies. +//! +//! No explicit `ReduceTo` impls are needed for natural edges — the resolver +//! computes them from `GraphSubtypeEntry` and `WeightSubtypeEntry` registrations. + +#[cfg(test)] +#[path = "../unit_tests/rules/natural.rs"] +mod tests; +``` + +**Step 3: Run the test** + +Run: `cargo test test_natural_cast_triangular_to_simple_via_resolve` +Expected: PASS + +**Step 4: Run full test suite to check nothing broke** + +Run: `make test clippy` +Expected: PASS (some deprecation warnings for examples still using `lookup_overhead` are OK) + +**Step 5: Commit** + +```bash +git add src/rules/natural.rs src/unit_tests/rules/natural.rs +git commit -m "refactor: remove explicit natural reduction, rely on resolve_path" +``` + +--- + +### Task 6: Run full verification + +**Files:** None (verification only) + +**Step 1: Run full check** + +Run: `make check` +Expected: fmt, clippy, and all tests pass. + +**Step 2: Run doc build** + +Run: `cargo doc --no-deps -p problemreductions` +Expected: No warnings. + +**Step 3: Run examples that use lookup_overhead (should still work via deprecation)** + +Run: `cargo build --examples --features ilp` +Expected: Builds with deprecation warnings but no errors. + +**Step 4: Commit any fixups if needed, then final commit message** + +```bash +git add -A +git commit -m "chore: final cleanup for variant-aware reduction paths" +``` From 2edd97bbc2ed1ec88e3708e2da2114c463e34f94 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:35:31 +0800 Subject: [PATCH 06/39] refactor: remove explicit natural reduction, rely on resolve_path Co-Authored-By: Claude Opus 4.6 --- src/rules/natural.rs | 19 +++----- src/unit_tests/rules/natural.rs | 82 ++++++++++++++++----------------- 2 files changed, 45 insertions(+), 56 deletions(-) diff --git a/src/rules/natural.rs b/src/rules/natural.rs index f617fdf97..8d7103467 100644 --- a/src/rules/natural.rs +++ b/src/rules/natural.rs @@ -1,19 +1,12 @@ //! Natural-edge reductions via graph subtype relaxation. //! -//! These reductions are trivial: a problem on a specific graph type -//! (e.g., `Triangular`) can always be solved as the same problem on a -//! more general graph type (e.g., `SimpleGraph`), since the specific -//! graph *is* a general graph. The solution mapping is identity. +//! Natural reductions (e.g., a problem on `Triangular` solved as `SimpleGraph`) +//! are handled automatically by [`ReductionGraph::resolve_path`], which inserts +//! `NaturalCast` steps based on the registered graph/weight subtype hierarchies. //! -//! Each reduction is generated by [`impl_natural_reduction!`]. +//! No explicit `ReduceTo` impls are needed for natural edges — the resolver +//! computes them from `GraphSubtypeEntry` and `WeightSubtypeEntry` registrations. -use crate::impl_natural_reduction; -use crate::models::graph::MaximumIndependentSet; -use crate::reduction; -use crate::topology::{SimpleGraph, Triangular}; - -impl_natural_reduction!(MaximumIndependentSet, Triangular, SimpleGraph, i32); - -#[cfg(all(test, feature = "ilp"))] +#[cfg(test)] #[path = "../unit_tests/rules/natural.rs"] mod tests; diff --git a/src/unit_tests/rules/natural.rs b/src/unit_tests/rules/natural.rs index c5d1bf9d5..3569ae488 100644 --- a/src/unit_tests/rules/natural.rs +++ b/src/unit_tests/rules/natural.rs @@ -1,49 +1,45 @@ use crate::models::graph::MaximumIndependentSet; -use crate::rules::{ReduceTo, ReductionResult}; -use crate::solvers::ILPSolver; +use crate::rules::graph::{EdgeKind, ReductionGraph}; use crate::topology::{SimpleGraph, Triangular}; -use crate::traits::Problem; +use std::collections::BTreeMap; #[test] -fn test_mis_triangular_to_simple_closed_loop() { - // Petersen graph: 10 vertices, 15 edges, max IS = 4 - let source = MaximumIndependentSet::::new( - 10, - vec![ - (0, 1), (1, 2), (2, 3), (3, 4), (4, 0), // outer cycle - (5, 7), (7, 9), (9, 6), (6, 8), (8, 5), // inner pentagram - (0, 5), (1, 6), (2, 7), (3, 8), (4, 9), // spokes - ], +fn test_natural_cast_triangular_to_simple_via_resolve() { + let graph = ReductionGraph::new(); + + // Find any path from MIS to itself (via VC round-trip) to test natural cast insertion + // Instead, directly test that resolve_path inserts a natural cast for MIS(Triangular)->VC(SimpleGraph) + let name_path = graph + .find_shortest_path::< + MaximumIndependentSet, + crate::models::graph::MinimumVertexCover, + >() + .unwrap(); + + let source_variant = BTreeMap::from([ + ("graph".to_string(), "Triangular".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + let target_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + let resolved = graph + .resolve_path(&name_path, &source_variant, &target_variant) + .unwrap(); + + // Path should be: MIS(Triangular) --NaturalCast--> MIS(SimpleGraph) --Reduction--> VC(SimpleGraph) + assert_eq!(resolved.num_casts(), 1); + assert_eq!(resolved.num_reductions(), 1); + assert!(matches!(resolved.edges[0], EdgeKind::NaturalCast)); + assert!(matches!(resolved.edges[1], EdgeKind::Reduction { .. })); + assert_eq!( + resolved.steps[0].variant.get("graph").unwrap(), + "Triangular" + ); + assert_eq!( + resolved.steps[1].variant.get("graph").unwrap(), + "SimpleGraph" ); - - // SimpleGraph → Triangular (unit disk mapping) - let to_tri = ReduceTo::>::reduce_to(&source); - let tri_problem = to_tri.target_problem(); - - // Triangular → SimpleGraph (natural edge: graph subtype relaxation) - let to_simple = ReduceTo::>::reduce_to(tri_problem); - let simple_problem = to_simple.target_problem(); - - // Graph structure is preserved by identity cast - assert_eq!(simple_problem.num_vertices(), tri_problem.num_vertices()); - assert_eq!(simple_problem.num_edges(), tri_problem.num_edges()); - - // Solve with ILP on the relaxed SimpleGraph problem - let solver = ILPSolver::new(); - let solution = solver.solve_reduced(simple_problem).expect("ILP should find a solution"); - - // Identity mapping: solution is unchanged - let extracted = to_simple.extract_solution(&solution); - assert_eq!(extracted, solution); - - // Extracted solution is valid on the Triangular problem - let metric = tri_problem.evaluate(&extracted); - assert!(metric.is_valid()); - - // Map back through the full chain to the original Petersen graph - let original_solution = to_tri.extract_solution(&extracted); - let original_metric = source.evaluate(&original_solution); - assert!(original_metric.is_valid()); - // Petersen graph max IS = 4 - assert_eq!(original_solution.iter().sum::(), 4); } From 8078bd3cd5c5a34f9374f4c6619c07d883d90e5e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:44:29 +0800 Subject: [PATCH 07/39] fix: add #[allow(deprecated)] to examples and fix formatting Allow deprecated lookup_overhead calls in examples pending migration, revert unintended macro indentation change, and apply rustfmt to tests. Co-Authored-By: Claude Opus 4.6 --- examples/reduction_circuitsat_to_spinglass.rs | 1 + examples/reduction_factoring_to_circuitsat.rs | 1 + examples/reduction_factoring_to_ilp.rs | 1 + examples/reduction_ilp_to_qubo.rs | 1 + examples/reduction_kcoloring_to_ilp.rs | 1 + examples/reduction_kcoloring_to_qubo.rs | 1 + examples/reduction_maxcut_to_spinglass.rs | 1 + examples/reduction_maximumclique_to_ilp.rs | 1 + .../reduction_maximumindependentset_to_ilp.rs | 1 + ...aximumindependentset_to_maximumsetpacking.rs | 1 + ...ximumindependentset_to_minimumvertexcover.rs | 1 + .../reduction_maximumindependentset_to_qubo.rs | 1 + examples/reduction_maximummatching_to_ilp.rs | 1 + ...tion_maximummatching_to_maximumsetpacking.rs | 1 + examples/reduction_maximumsetpacking_to_ilp.rs | 1 + examples/reduction_maximumsetpacking_to_qubo.rs | 1 + .../reduction_minimumdominatingset_to_ilp.rs | 1 + examples/reduction_minimumsetcovering_to_ilp.rs | 1 + examples/reduction_minimumvertexcover_to_ilp.rs | 1 + ...nimumvertexcover_to_maximumindependentset.rs | 1 + ..._minimumvertexcover_to_minimumsetcovering.rs | 1 + .../reduction_minimumvertexcover_to_qubo.rs | 1 + examples/reduction_qubo_to_spinglass.rs | 1 + .../reduction_satisfiability_to_kcoloring.rs | 1 + ...duction_satisfiability_to_ksatisfiability.rs | 1 + ...n_satisfiability_to_maximumindependentset.rs | 1 + ...on_satisfiability_to_minimumdominatingset.rs | 1 + examples/reduction_spinglass_to_maxcut.rs | 1 + examples/reduction_spinglass_to_qubo.rs | 1 + examples/reduction_travelingsalesman_to_ilp.rs | 1 + src/rules/mod.rs | 4 ++-- src/unit_tests/rules/graph.rs | 17 +++++------------ 32 files changed, 37 insertions(+), 14 deletions(-) diff --git a/examples/reduction_circuitsat_to_spinglass.rs b/examples/reduction_circuitsat_to_spinglass.rs index f7c7f940d..eb93fa06a 100644 --- a/examples/reduction_circuitsat_to_spinglass.rs +++ b/examples/reduction_circuitsat_to_spinglass.rs @@ -21,6 +21,7 @@ use problemreductions::models::specialized::{Assignment, BooleanExpr, Circuit}; use problemreductions::prelude::*; use problemreductions::topology::{Graph, SimpleGraph}; +#[allow(deprecated)] pub fn run() { // 1. Create CircuitSAT instance: 1-bit full adder // sum = a XOR b XOR cin, cout = (a AND b) OR (cin AND (a XOR b)) diff --git a/examples/reduction_factoring_to_circuitsat.rs b/examples/reduction_factoring_to_circuitsat.rs index e281c55ad..7e6f6e1c9 100644 --- a/examples/reduction_factoring_to_circuitsat.rs +++ b/examples/reduction_factoring_to_circuitsat.rs @@ -39,6 +39,7 @@ fn simulate_circuit( values } +#[allow(deprecated)] pub fn run() { // 1. Create Factoring instance: factor 35 with 3-bit factors // Possible: 5*7=35 or 7*5=35 diff --git a/examples/reduction_factoring_to_ilp.rs b/examples/reduction_factoring_to_ilp.rs index 83723d088..5272cc377 100644 --- a/examples/reduction_factoring_to_ilp.rs +++ b/examples/reduction_factoring_to_ilp.rs @@ -21,6 +21,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; use problemreductions::solvers::ILPSolver; +#[allow(deprecated)] pub fn run() { // 1. Create Factoring instance: find p (3-bit) x q (3-bit) = 35 let problem = Factoring::new(3, 3, 35); diff --git a/examples/reduction_ilp_to_qubo.rs b/examples/reduction_ilp_to_qubo.rs index cf4c38dd4..f19a0ab4a 100644 --- a/examples/reduction_ilp_to_qubo.rs +++ b/examples/reduction_ilp_to_qubo.rs @@ -38,6 +38,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; +#[allow(deprecated)] pub fn run() { println!("=== ILP (Binary) -> QUBO Reduction ===\n"); diff --git a/examples/reduction_kcoloring_to_ilp.rs b/examples/reduction_kcoloring_to_ilp.rs index 01130477e..2a0eb2cf4 100644 --- a/examples/reduction_kcoloring_to_ilp.rs +++ b/examples/reduction_kcoloring_to_ilp.rs @@ -21,6 +21,7 @@ use problemreductions::solvers::ILPSolver; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create KColoring instance: Petersen graph (10 vertices, 15 edges) with 3 colors, χ=3 let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_kcoloring_to_qubo.rs b/examples/reduction_kcoloring_to_qubo.rs index b9a7b7cab..1696ae003 100644 --- a/examples/reduction_kcoloring_to_qubo.rs +++ b/examples/reduction_kcoloring_to_qubo.rs @@ -33,6 +33,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::house; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { println!("=== K-Coloring -> QUBO Reduction ===\n"); diff --git a/examples/reduction_maxcut_to_spinglass.rs b/examples/reduction_maxcut_to_spinglass.rs index da27eb0b2..df37001cf 100644 --- a/examples/reduction_maxcut_to_spinglass.rs +++ b/examples/reduction_maxcut_to_spinglass.rs @@ -20,6 +20,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { let (num_vertices, edges) = petersen(); let maxcut = MaxCut::::unweighted(num_vertices, edges.clone()); diff --git a/examples/reduction_maximumclique_to_ilp.rs b/examples/reduction_maximumclique_to_ilp.rs index 4c43971af..5afc5e4fd 100644 --- a/examples/reduction_maximumclique_to_ilp.rs +++ b/examples/reduction_maximumclique_to_ilp.rs @@ -19,6 +19,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::octahedral; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create MaximumClique instance: Octahedron (K_{2,2,2}), 6 vertices, 12 edges, clique number 3 let (num_vertices, edges) = octahedral(); diff --git a/examples/reduction_maximumindependentset_to_ilp.rs b/examples/reduction_maximumindependentset_to_ilp.rs index 5bf753feb..48d424869 100644 --- a/examples/reduction_maximumindependentset_to_ilp.rs +++ b/examples/reduction_maximumindependentset_to_ilp.rs @@ -18,6 +18,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create IS instance: Petersen graph let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_maximumindependentset_to_maximumsetpacking.rs b/examples/reduction_maximumindependentset_to_maximumsetpacking.rs index 048c5c8b4..ce608783d 100644 --- a/examples/reduction_maximumindependentset_to_maximumsetpacking.rs +++ b/examples/reduction_maximumindependentset_to_maximumsetpacking.rs @@ -20,6 +20,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { println!("\n=== Independent Set -> Set Packing Reduction ===\n"); diff --git a/examples/reduction_maximumindependentset_to_minimumvertexcover.rs b/examples/reduction_maximumindependentset_to_minimumvertexcover.rs index eeb6f1b3b..95ffb3bf0 100644 --- a/examples/reduction_maximumindependentset_to_minimumvertexcover.rs +++ b/examples/reduction_maximumindependentset_to_minimumvertexcover.rs @@ -19,6 +19,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create IS instance: Petersen graph let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_maximumindependentset_to_qubo.rs b/examples/reduction_maximumindependentset_to_qubo.rs index e645836a9..77a3e5945 100644 --- a/examples/reduction_maximumindependentset_to_qubo.rs +++ b/examples/reduction_maximumindependentset_to_qubo.rs @@ -29,6 +29,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { println!("=== Independent Set -> QUBO Reduction ===\n"); diff --git a/examples/reduction_maximummatching_to_ilp.rs b/examples/reduction_maximummatching_to_ilp.rs index 0fdff47bb..de8267e70 100644 --- a/examples/reduction_maximummatching_to_ilp.rs +++ b/examples/reduction_maximummatching_to_ilp.rs @@ -18,6 +18,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create MaximumMatching instance: Petersen graph with unit weights let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_maximummatching_to_maximumsetpacking.rs b/examples/reduction_maximummatching_to_maximumsetpacking.rs index e36217c74..ddeedd855 100644 --- a/examples/reduction_maximummatching_to_maximumsetpacking.rs +++ b/examples/reduction_maximummatching_to_maximumsetpacking.rs @@ -20,6 +20,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { println!("\n=== MaximumMatching -> Set Packing Reduction ===\n"); diff --git a/examples/reduction_maximumsetpacking_to_ilp.rs b/examples/reduction_maximumsetpacking_to_ilp.rs index 9c2147dcb..07645ba4a 100644 --- a/examples/reduction_maximumsetpacking_to_ilp.rs +++ b/examples/reduction_maximumsetpacking_to_ilp.rs @@ -17,6 +17,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; +#[allow(deprecated)] pub fn run() { // 1. Create MaximumSetPacking instance: 6 sets over universe {0,...,7} let sets = vec![ diff --git a/examples/reduction_maximumsetpacking_to_qubo.rs b/examples/reduction_maximumsetpacking_to_qubo.rs index 22564e93b..3d5c0bab7 100644 --- a/examples/reduction_maximumsetpacking_to_qubo.rs +++ b/examples/reduction_maximumsetpacking_to_qubo.rs @@ -32,6 +32,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; +#[allow(deprecated)] pub fn run() { println!("=== Set Packing -> QUBO Reduction ===\n"); diff --git a/examples/reduction_minimumdominatingset_to_ilp.rs b/examples/reduction_minimumdominatingset_to_ilp.rs index 270d3af86..b965e599d 100644 --- a/examples/reduction_minimumdominatingset_to_ilp.rs +++ b/examples/reduction_minimumdominatingset_to_ilp.rs @@ -18,6 +18,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create MinimumDominatingSet instance: Petersen graph let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_minimumsetcovering_to_ilp.rs b/examples/reduction_minimumsetcovering_to_ilp.rs index b9c8c3f57..a8d380e46 100644 --- a/examples/reduction_minimumsetcovering_to_ilp.rs +++ b/examples/reduction_minimumsetcovering_to_ilp.rs @@ -17,6 +17,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; +#[allow(deprecated)] pub fn run() { // 1. Create MinimumSetCovering instance: universe {0,...,7}, 6 sets let sets = vec![ diff --git a/examples/reduction_minimumvertexcover_to_ilp.rs b/examples/reduction_minimumvertexcover_to_ilp.rs index f2807bfc9..4688cddb9 100644 --- a/examples/reduction_minimumvertexcover_to_ilp.rs +++ b/examples/reduction_minimumvertexcover_to_ilp.rs @@ -18,6 +18,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create VC instance: Petersen graph (10 vertices, 15 edges), VC=6 let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_minimumvertexcover_to_maximumindependentset.rs b/examples/reduction_minimumvertexcover_to_maximumindependentset.rs index b0869be64..dde6ac155 100644 --- a/examples/reduction_minimumvertexcover_to_maximumindependentset.rs +++ b/examples/reduction_minimumvertexcover_to_maximumindependentset.rs @@ -20,6 +20,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // Petersen graph: 10 vertices, 15 edges, VC=6 let (num_vertices, edges) = petersen(); diff --git a/examples/reduction_minimumvertexcover_to_minimumsetcovering.rs b/examples/reduction_minimumvertexcover_to_minimumsetcovering.rs index b8dff8144..3b102561c 100644 --- a/examples/reduction_minimumvertexcover_to_minimumsetcovering.rs +++ b/examples/reduction_minimumvertexcover_to_minimumsetcovering.rs @@ -20,6 +20,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { println!("\n=== Vertex Cover -> Set Covering Reduction ===\n"); diff --git a/examples/reduction_minimumvertexcover_to_qubo.rs b/examples/reduction_minimumvertexcover_to_qubo.rs index 855193caf..4604c80ba 100644 --- a/examples/reduction_minimumvertexcover_to_qubo.rs +++ b/examples/reduction_minimumvertexcover_to_qubo.rs @@ -29,6 +29,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { println!("=== Vertex Covering -> QUBO Reduction ===\n"); diff --git a/examples/reduction_qubo_to_spinglass.rs b/examples/reduction_qubo_to_spinglass.rs index e122e3d0a..63c15b3b8 100644 --- a/examples/reduction_qubo_to_spinglass.rs +++ b/examples/reduction_qubo_to_spinglass.rs @@ -21,6 +21,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { let (n, edges) = petersen(); let mut matrix = vec![vec![0.0; n]; n]; diff --git a/examples/reduction_satisfiability_to_kcoloring.rs b/examples/reduction_satisfiability_to_kcoloring.rs index e19e92156..1d2bc60df 100644 --- a/examples/reduction_satisfiability_to_kcoloring.rs +++ b/examples/reduction_satisfiability_to_kcoloring.rs @@ -19,6 +19,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create SAT instance: 5-variable, 3-clause formula with unit clauses // The SAT→KColoring reduction creates OR-gadgets that add 5 vertices per literal diff --git a/examples/reduction_satisfiability_to_ksatisfiability.rs b/examples/reduction_satisfiability_to_ksatisfiability.rs index 6d0d219cf..f67661e8d 100644 --- a/examples/reduction_satisfiability_to_ksatisfiability.rs +++ b/examples/reduction_satisfiability_to_ksatisfiability.rs @@ -21,6 +21,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; +#[allow(deprecated)] pub fn run() { // 1. Create SAT instance with varied clause sizes to demonstrate padding and splitting: // - 1 literal: padded to 3 diff --git a/examples/reduction_satisfiability_to_maximumindependentset.rs b/examples/reduction_satisfiability_to_maximumindependentset.rs index 0cb3f130f..389efe99b 100644 --- a/examples/reduction_satisfiability_to_maximumindependentset.rs +++ b/examples/reduction_satisfiability_to_maximumindependentset.rs @@ -17,6 +17,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create SAT instance: 5-variable, 7-clause 3-SAT formula let sat = Satisfiability::new( diff --git a/examples/reduction_satisfiability_to_minimumdominatingset.rs b/examples/reduction_satisfiability_to_minimumdominatingset.rs index f02f8c341..cfabd0f5a 100644 --- a/examples/reduction_satisfiability_to_minimumdominatingset.rs +++ b/examples/reduction_satisfiability_to_minimumdominatingset.rs @@ -17,6 +17,7 @@ use problemreductions::export::*; use problemreductions::prelude::*; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create SAT instance: 5-variable, 7-clause 3-SAT formula let sat = Satisfiability::new( diff --git a/examples/reduction_spinglass_to_maxcut.rs b/examples/reduction_spinglass_to_maxcut.rs index 1b6364eb4..88612a1ff 100644 --- a/examples/reduction_spinglass_to_maxcut.rs +++ b/examples/reduction_spinglass_to_maxcut.rs @@ -20,6 +20,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { let (n, edges) = petersen(); let couplings: Vec<((usize, usize), i32)> = edges diff --git a/examples/reduction_spinglass_to_qubo.rs b/examples/reduction_spinglass_to_qubo.rs index 86af48521..ea4a95fd5 100644 --- a/examples/reduction_spinglass_to_qubo.rs +++ b/examples/reduction_spinglass_to_qubo.rs @@ -21,6 +21,7 @@ use problemreductions::prelude::*; use problemreductions::topology::small_graphs::petersen; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { let (n, edges) = petersen(); // Alternating +/-1 couplings create frustration on odd cycles diff --git a/examples/reduction_travelingsalesman_to_ilp.rs b/examples/reduction_travelingsalesman_to_ilp.rs index bbba74d38..f6a83fac5 100644 --- a/examples/reduction_travelingsalesman_to_ilp.rs +++ b/examples/reduction_travelingsalesman_to_ilp.rs @@ -19,6 +19,7 @@ use problemreductions::prelude::*; use problemreductions::solvers::ILPSolver; use problemreductions::topology::SimpleGraph; +#[allow(deprecated)] pub fn run() { // 1. Create TSP instance: K4 with weights let problem = TravelingSalesman::::new( diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 670c33184..629e2b206 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -63,8 +63,8 @@ pub use circuit_spinglass::{ pub use coloring_qubo::ReductionKColoringToQUBO; pub use factoring_circuit::ReductionFactoringToCircuit; pub use graph::{ - EdgeJson, EdgeKind, NodeJson, ReductionEdge, ReductionGraph, ReductionGraphJson, - ReductionPath, ReductionStep, ResolvedPath, + EdgeJson, EdgeKind, NodeJson, ReductionEdge, ReductionGraph, ReductionGraphJson, ReductionPath, + ReductionStep, ResolvedPath, }; pub use ksatisfiability_qubo::{Reduction3SATToQUBO, ReductionKSatToQUBO}; pub use maximumindependentset_gridgraph::{ReductionISSimpleToGrid, ReductionISUnitDiskToGrid}; diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 360feb745..d2dc045d3 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1045,8 +1045,8 @@ fn test_resolve_path_direct_same_variant() { #[test] fn test_resolve_path_with_natural_cast() { - use std::collections::BTreeMap; use crate::topology::GridGraph; + use std::collections::BTreeMap; let graph = ReductionGraph::new(); // MIS(GridGraph) → VC(SimpleGraph) — needs a natural cast MIS(GridGraph)→MIS(SimpleGraph) @@ -1075,10 +1075,7 @@ fn test_resolve_path_with_natural_cast() { assert_eq!(resolved.num_casts(), 1); assert_eq!(resolved.steps.len(), 3); assert_eq!(resolved.steps[0].name, "MaximumIndependentSet"); - assert_eq!( - resolved.steps[0].variant.get("graph").unwrap(), - "GridGraph" - ); + assert_eq!(resolved.steps[0].variant.get("graph").unwrap(), "GridGraph"); assert_eq!(resolved.steps[1].name, "MaximumIndependentSet"); assert_eq!( resolved.steps[1].variant.get("graph").unwrap(), @@ -1091,8 +1088,8 @@ fn test_resolve_path_with_natural_cast() { #[test] fn test_resolve_path_ksat_disambiguates() { - use std::collections::BTreeMap; use crate::rules::graph::EdgeKind; + use std::collections::BTreeMap; let graph = ReductionGraph::new(); let name_path = graph @@ -1103,9 +1100,7 @@ fn test_resolve_path_ksat_disambiguates() { let source_k3 = BTreeMap::from([("k".to_string(), "3".to_string())]); let target = BTreeMap::from([("weight".to_string(), "f64".to_string())]); - let resolved_k3 = graph - .resolve_path(&name_path, &source_k3, &target) - .unwrap(); + let resolved_k3 = graph.resolve_path(&name_path, &source_k3, &target).unwrap(); assert_eq!(resolved_k3.num_reductions(), 1); // Extract overhead from the reduction edge @@ -1124,9 +1119,7 @@ fn test_resolve_path_ksat_disambiguates() { // Resolve with k=2 let source_k2 = BTreeMap::from([("k".to_string(), "2".to_string())]); - let resolved_k2 = graph - .resolve_path(&name_path, &source_k2, &target) - .unwrap(); + let resolved_k2 = graph.resolve_path(&name_path, &source_k2, &target).unwrap(); let overhead_k2 = match &resolved_k2.edges.last().unwrap() { EdgeKind::Reduction { overhead } => overhead, _ => panic!("last edge should be Reduction"), From 4e913de4a04730e349e1119c2b0c0bba6eb3afb0 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:53:23 +0800 Subject: [PATCH 08/39] docs: add design.md describing variant-aware reduction paths Co-Authored-By: Claude Opus 4.6 --- design.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 design.md diff --git a/design.md b/design.md new file mode 100644 index 000000000..077b9168d --- /dev/null +++ b/design.md @@ -0,0 +1,125 @@ +# Variant-Aware Reduction Paths + +## Problem + +The `ReductionGraph` performs path-finding at the name level (e.g., `"KSatisfiability" -> "QUBO"`), but problems have **variants** -- parameterized by graph type, weight type, or constants like `k`. This causes two concrete issues: + +1. **Overhead ambiguity**: `lookup_overhead("KSatisfiability", "QUBO")` returns whichever `ReductionEntry` inventory iterates first. But `KSatisfiability<2> -> QUBO` and `KSatisfiability<3> -> QUBO` have different overheads (the k=3 path introduces auxiliary variables via Rosenberg quadratization). Callers get the wrong overhead silently. + +2. **Natural edge gap**: The JSON export infers natural edges (e.g., `MIS{GridGraph} -> MIS{SimpleGraph}`) from the graph/weight subtype hierarchy, but these edges had no runtime backing -- no `ReduceTo` impl existed for most of them. The documentation showed edges that couldn't be executed. + +## Solution: Two-Phase Path Resolution + +The design separates path-finding into two phases: + +### Phase 1: Name-Level Path Discovery (unchanged) + +Existing APIs (`find_paths`, `find_cheapest_path`, `find_shortest_path`) continue to operate on base problem names. The internal `petgraph` has one node per problem name and one edge per registered reduction. This is fast and sufficient for topology. + +### Phase 2: Variant-Level Resolution (new) + +A new `resolve_path` method lifts a name-level `ReductionPath` into a `ResolvedPath` that carries full variant information at every node: + +```rust +pub fn resolve_path( + &self, + path: &ReductionPath, // name-level plan + source_variant: &BTreeMap, // caller's concrete variant + target_variant: &BTreeMap, // desired target variant +) -> Option +``` + +The resolver walks the name-level path, threading variant state through each step: + +1. **Find candidates** -- all `ReductionEntry` items matching `(src_name, dst_name)`. +2. **Filter compatible** -- keep entries where the current variant is equal-or-more-specific than the entry's source variant on every axis (graph, weight, k). +3. **Pick most specific** -- among compatible entries, choose the tightest fit. +4. **Insert natural cast** -- if the current variant is more specific than the chosen entry's source, emit a `NaturalCast` edge to relax the variant. +5. **Advance** -- update current variant to the entry's target variant, emit a `Reduction` edge carrying the correct overhead. + +### Data Model + +``` +ResolvedPath + steps: Vec // (name, variant) at each node + edges: Vec // Reduction{overhead} | NaturalCast between steps +``` + +Example -- resolving `MIS(GridGraph, i32) -> MinimumVertexCover(SimpleGraph, i32)`: + +``` +steps: MIS{GridGraph,i32} --NaturalCast--> MIS{SimpleGraph,i32} --Reduction--> VC{SimpleGraph,i32} +edges: [NaturalCast, Reduction{overhead: ...}] +``` + +### Subtype Hierarchies + +Two hierarchies drive variant compatibility: + +- **Graph**: `GridGraph <: UnitDiskGraph <: PlanarGraph <: SimpleGraph <: HyperGraph` (and `Triangular <: SimpleGraph`, `BipartiteGraph <: SimpleGraph`) +- **Weight**: `One <: i32 <: f64` +- **Constants** (k): a specific value like `"3"` is a subtype of `"N"` (generic) + +Both are built from `GraphSubtypeEntry` / `WeightSubtypeEntry` inventory registrations with transitive closure computed at construction time. + +### `find_best_entry` -- Variant-Aware Rule Matching + +A new public method selects the best `ReductionEntry` for a `(source_name, target_name)` pair given a caller's current variant: + +```rust +pub fn find_best_entry( + &self, + source_name: &str, + target_name: &str, + current_variant: &BTreeMap, +) -> Option<(source_variant, target_variant, overhead)> +``` + +This resolves the KSat ambiguity: given `k=3`, it filters out the `k=2` entry and returns the `k=3`-specific overhead. + +## Changes + +### New Types (`src/rules/graph.rs`) + +| Type | Purpose | +|------|---------| +| `ReductionStep` | `(name, variant)` node in a resolved path | +| `EdgeKind` | `Reduction{overhead}` or `NaturalCast` | +| `ResolvedPath` | Fully resolved variant-level path with helper methods (`len`, `num_reductions`, `num_casts`) | + +### New Methods (`ReductionGraph`) + +| Method | Purpose | +|--------|---------| +| `resolve_path` | Lift name-level path to variant-level | +| `find_best_entry` | Find most-specific compatible ReductionEntry for a variant | +| `is_variant_reducible` | Check if variant A is strictly more restrictive than B | +| `is_weight_subtype` | Weight hierarchy check (analogous to existing `is_graph_subtype`) | +| `weight_hierarchy` | Expose weight hierarchy for inspection | + +### Deprecations (`src/export.rs`) + +| Function | Replacement | +|----------|-------------| +| `lookup_overhead` | `resolve_path` -> extract overhead from `EdgeKind::Reduction` | +| `lookup_overhead_or_empty` | Same | + +### Natural Reductions (`src/rules/natural.rs`) + +The explicit `impl_natural_reduction!` invocation was removed. Natural casts are now computed implicitly by `resolve_path` from the subtype hierarchies. The `impl_natural_reduction!` macro itself is retained for callers who need concrete `ReduceTo` impls for specific subtype pairs. + +### Example Migration + +`examples/reduction_ksatisfiability_to_qubo.rs` was migrated from `lookup_overhead` to the new `resolve_path` API, demonstrating the correct pattern for obtaining variant-specific overhead. + +All other examples received `#[allow(deprecated)]` annotations as a temporary measure; they still use `lookup_overhead` and will be migrated incrementally. + +## Design Decisions + +1. **Name-level graph is kept** -- variant-level Dijkstra would explode the node count (every `(name, graph, weight, k)` combination). Two-phase resolution keeps path discovery fast. + +2. **No execution engine** -- `ResolvedPath` is a *plan*. Callers dispatch `ReduceTo` for `Reduction` edges and `GraphCast::cast_graph()` for `NaturalCast` edges themselves. This avoids type-erasure complexity. + +3. **Natural edges stay in JSON export** -- `to_json()` continues to infer natural edges from subtype hierarchies for documentation. The resolved path makes them executable at runtime. + +4. **Backward compatible** -- all existing `find_paths` / `find_cheapest_path` / `has_direct_reduction` APIs are unchanged. `lookup_overhead` is deprecated, not removed. From 5dfbe7cd489b1c0023f566b279d95ee1e3f211c4 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 15:56:26 +0800 Subject: [PATCH 09/39] docs: add variant-aware path resolution section to docs/src/design.md Describes the two-phase resolution approach (name-level discovery + variant-level resolve_path), KSat disambiguation, natural cast insertion, and the execution model. Removes the mistakenly created root design.md. Co-Authored-By: Claude Opus 4.6 --- design.md | 125 --------------------------------------------- docs/src/design.md | 66 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 125 deletions(-) delete mode 100644 design.md diff --git a/design.md b/design.md deleted file mode 100644 index 077b9168d..000000000 --- a/design.md +++ /dev/null @@ -1,125 +0,0 @@ -# Variant-Aware Reduction Paths - -## Problem - -The `ReductionGraph` performs path-finding at the name level (e.g., `"KSatisfiability" -> "QUBO"`), but problems have **variants** -- parameterized by graph type, weight type, or constants like `k`. This causes two concrete issues: - -1. **Overhead ambiguity**: `lookup_overhead("KSatisfiability", "QUBO")` returns whichever `ReductionEntry` inventory iterates first. But `KSatisfiability<2> -> QUBO` and `KSatisfiability<3> -> QUBO` have different overheads (the k=3 path introduces auxiliary variables via Rosenberg quadratization). Callers get the wrong overhead silently. - -2. **Natural edge gap**: The JSON export infers natural edges (e.g., `MIS{GridGraph} -> MIS{SimpleGraph}`) from the graph/weight subtype hierarchy, but these edges had no runtime backing -- no `ReduceTo` impl existed for most of them. The documentation showed edges that couldn't be executed. - -## Solution: Two-Phase Path Resolution - -The design separates path-finding into two phases: - -### Phase 1: Name-Level Path Discovery (unchanged) - -Existing APIs (`find_paths`, `find_cheapest_path`, `find_shortest_path`) continue to operate on base problem names. The internal `petgraph` has one node per problem name and one edge per registered reduction. This is fast and sufficient for topology. - -### Phase 2: Variant-Level Resolution (new) - -A new `resolve_path` method lifts a name-level `ReductionPath` into a `ResolvedPath` that carries full variant information at every node: - -```rust -pub fn resolve_path( - &self, - path: &ReductionPath, // name-level plan - source_variant: &BTreeMap, // caller's concrete variant - target_variant: &BTreeMap, // desired target variant -) -> Option -``` - -The resolver walks the name-level path, threading variant state through each step: - -1. **Find candidates** -- all `ReductionEntry` items matching `(src_name, dst_name)`. -2. **Filter compatible** -- keep entries where the current variant is equal-or-more-specific than the entry's source variant on every axis (graph, weight, k). -3. **Pick most specific** -- among compatible entries, choose the tightest fit. -4. **Insert natural cast** -- if the current variant is more specific than the chosen entry's source, emit a `NaturalCast` edge to relax the variant. -5. **Advance** -- update current variant to the entry's target variant, emit a `Reduction` edge carrying the correct overhead. - -### Data Model - -``` -ResolvedPath - steps: Vec // (name, variant) at each node - edges: Vec // Reduction{overhead} | NaturalCast between steps -``` - -Example -- resolving `MIS(GridGraph, i32) -> MinimumVertexCover(SimpleGraph, i32)`: - -``` -steps: MIS{GridGraph,i32} --NaturalCast--> MIS{SimpleGraph,i32} --Reduction--> VC{SimpleGraph,i32} -edges: [NaturalCast, Reduction{overhead: ...}] -``` - -### Subtype Hierarchies - -Two hierarchies drive variant compatibility: - -- **Graph**: `GridGraph <: UnitDiskGraph <: PlanarGraph <: SimpleGraph <: HyperGraph` (and `Triangular <: SimpleGraph`, `BipartiteGraph <: SimpleGraph`) -- **Weight**: `One <: i32 <: f64` -- **Constants** (k): a specific value like `"3"` is a subtype of `"N"` (generic) - -Both are built from `GraphSubtypeEntry` / `WeightSubtypeEntry` inventory registrations with transitive closure computed at construction time. - -### `find_best_entry` -- Variant-Aware Rule Matching - -A new public method selects the best `ReductionEntry` for a `(source_name, target_name)` pair given a caller's current variant: - -```rust -pub fn find_best_entry( - &self, - source_name: &str, - target_name: &str, - current_variant: &BTreeMap, -) -> Option<(source_variant, target_variant, overhead)> -``` - -This resolves the KSat ambiguity: given `k=3`, it filters out the `k=2` entry and returns the `k=3`-specific overhead. - -## Changes - -### New Types (`src/rules/graph.rs`) - -| Type | Purpose | -|------|---------| -| `ReductionStep` | `(name, variant)` node in a resolved path | -| `EdgeKind` | `Reduction{overhead}` or `NaturalCast` | -| `ResolvedPath` | Fully resolved variant-level path with helper methods (`len`, `num_reductions`, `num_casts`) | - -### New Methods (`ReductionGraph`) - -| Method | Purpose | -|--------|---------| -| `resolve_path` | Lift name-level path to variant-level | -| `find_best_entry` | Find most-specific compatible ReductionEntry for a variant | -| `is_variant_reducible` | Check if variant A is strictly more restrictive than B | -| `is_weight_subtype` | Weight hierarchy check (analogous to existing `is_graph_subtype`) | -| `weight_hierarchy` | Expose weight hierarchy for inspection | - -### Deprecations (`src/export.rs`) - -| Function | Replacement | -|----------|-------------| -| `lookup_overhead` | `resolve_path` -> extract overhead from `EdgeKind::Reduction` | -| `lookup_overhead_or_empty` | Same | - -### Natural Reductions (`src/rules/natural.rs`) - -The explicit `impl_natural_reduction!` invocation was removed. Natural casts are now computed implicitly by `resolve_path` from the subtype hierarchies. The `impl_natural_reduction!` macro itself is retained for callers who need concrete `ReduceTo` impls for specific subtype pairs. - -### Example Migration - -`examples/reduction_ksatisfiability_to_qubo.rs` was migrated from `lookup_overhead` to the new `resolve_path` API, demonstrating the correct pattern for obtaining variant-specific overhead. - -All other examples received `#[allow(deprecated)]` annotations as a temporary measure; they still use `lookup_overhead` and will be migrated incrementally. - -## Design Decisions - -1. **Name-level graph is kept** -- variant-level Dijkstra would explode the node count (every `(name, graph, weight, k)` combination). Two-phase resolution keeps path discovery fast. - -2. **No execution engine** -- `ResolvedPath` is a *plan*. Callers dispatch `ReduceTo` for `Reduction` edges and `GraphCast::cast_graph()` for `NaturalCast` edges themselves. This avoids type-erasure complexity. - -3. **Natural edges stay in JSON export** -- `to_json()` continues to infer natural edges from subtype hierarchies for documentation. The resolved path makes them executable at runtime. - -4. **Backward compatible** -- all existing `find_paths` / `find_cheapest_path` / `has_direct_reduction` APIs are unchanged. `lookup_overhead` is deprecated, not removed. diff --git a/docs/src/design.md b/docs/src/design.md index 990c884ae..98a3ba81b 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -203,6 +203,72 @@ MIS (SimpleGraph, i32) Both steps are identity reductions with zero overhead — no new variables or constraints are introduced. The variant system generates these edges automatically from the declared hierarchies. +### Variant-Aware Path Resolution + +The `ReductionGraph` performs path-finding at the **name level** — nodes are `"MaximumIndependentSet"`, not `"MaximumIndependentSet"`. This keeps path discovery fast (one node per problem name), but it means a `ReductionPath` like `["KSatisfiability", "QUBO"]` carries no variant information. Two issues follow: + +1. **Overhead ambiguity.** `KSatisfiability<2> → QUBO` and `KSatisfiability<3> → QUBO` have different overheads (k=3 introduces auxiliary variables via Rosenberg quadratization), but a name-level path can't distinguish them. + +2. **Natural edge execution.** The path `MIS(GridGraph) → VC(SimpleGraph)` needs an implicit graph-relaxation step, but the name-level path only says `["MaximumIndependentSet", "MinimumVertexCover"]`. + +The solution is **two-phase resolution**: name-level discovery followed by variant-level resolution. + +#### `resolve_path` + +```rust +pub fn resolve_path( + &self, + path: &ReductionPath, // name-level plan + source_variant: &BTreeMap, // caller's concrete variant + target_variant: &BTreeMap, // desired target variant +) -> Option +``` + +The resolver walks the name-level path, threading variant state through each step: + +1. **Find candidates** — all `ReductionEntry` items matching `(src_name, dst_name)`. +2. **Filter compatible** — keep entries where the current variant is equal-or-more-specific than the entry's source variant on every axis. +3. **Pick most specific** — among compatible entries, choose the tightest fit. +4. **Insert natural cast** — if the current variant is more specific than the chosen entry's source, emit a `NaturalCast` edge. +5. **Advance** — update current variant to the entry's target variant, emit a `Reduction` edge with the correct overhead. + +The result is a `ResolvedPath`: + +```rust +pub struct ResolvedPath { + pub steps: Vec, // (name, variant) at each node + pub edges: Vec, // Reduction{overhead} | NaturalCast +} +``` + +#### Example: MIS on GridGraph to MinimumVertexCover + +Resolving `MIS(GridGraph, i32) → VC(SimpleGraph, i32)` through name-path `["MIS", "VC"]`: + +``` +steps: MIS{GridGraph,i32} → MIS{SimpleGraph,i32} → VC{SimpleGraph,i32} +edges: NaturalCast Reduction{overhead} +``` + +The resolver finds that the `MIS → VC` reduction expects `SimpleGraph`, so it inserts a `NaturalCast` to relax `GridGraph` to `SimpleGraph` first. + +#### Example: KSat Disambiguation + +Resolving `KSat(k=3) → QUBO` through name-path `["KSatisfiability", "QUBO"]`: + +- Candidates: `KSat<2> → QUBO` (overhead: `num_vars`) and `KSat<3> → QUBO` (overhead: `num_vars + num_clauses`). +- Filter with `k=3`: only `KSat<3>` is compatible (`3` is not a subtype of `2`). +- Result: the k=3-specific overhead is returned. + +#### Execution Model + +`ResolvedPath` is a **plan**, not an executor. Callers dispatch each step themselves: + +- `EdgeKind::Reduction` → call `ReduceTo::reduce_to()` +- `EdgeKind::NaturalCast` → call `GraphCast::cast_graph()` or equivalent weight cast + +This avoids type-erasure complexity while giving callers precise variant and overhead information at each step. + ## Rules A reduction requires two pieces: From 412edcce7ccecded018ae5fc88abc02619863cfd Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 16:42:00 +0800 Subject: [PATCH 10/39] docs: add interactive reduction diagram design Co-Authored-By: Claude Opus 4.6 --- ...14-interactive-reduction-diagram-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/plans/2026-02-14-interactive-reduction-diagram-design.md diff --git a/docs/plans/2026-02-14-interactive-reduction-diagram-design.md b/docs/plans/2026-02-14-interactive-reduction-diagram-design.md new file mode 100644 index 000000000..6c9dc8cad --- /dev/null +++ b/docs/plans/2026-02-14-interactive-reduction-diagram-design.md @@ -0,0 +1,161 @@ +# Interactive Reduction Diagram Design + +**Date:** 2026-02-14 +**Location:** mdBook documentation site (docs/src/) +**Stack:** Cytoscape.js + ELK.js (CDN-loaded) + +## Goal + +An interactive network diagram showing all reduction relationships between NP-hard problems. Users can explore the problem landscape, discover reduction paths, and navigate to documentation pages — all from a single visual interface. + +## Architecture + +### Data Source + +The existing `docs/src/reductions/reduction_graph.json` (auto-generated from `#[reduction]` macros via `cargo run --example export_graph`) provides nodes (39 problem variants) and edges (44 reductions + natural casts). + +### Files + +``` +docs/src/reduction-graph.md — mdBook page with embedded container div +docs/src/static/reduction-graph.js — main diagram logic (preprocessing, layout, interactions) +docs/src/static/reduction-graph.css — diagram styles (node colors, tooltips, controls) +book.toml — updated: additional-js/css entries +docs/src/SUMMARY.md — updated: new page under User Guide +``` + +### Libraries (CDN) + +- `cytoscape.js` (~112 KB gzipped) — graph rendering + interaction +- `elkjs` (~435 KB gzipped) — stress layout algorithm +- `cytoscape-elk` (~2 KB) — adapter + +## Node Design + +### Collapsed Node (default) + +Every unique problem name renders as one collapsed node. This is the initial view (~20 nodes). + +``` +┌──────────────────────┐ +│ Max Independent Set │ +│ ●●●●● │ +└──────────────────────┘ +``` + +- Rounded rectangle, colored by category: + - graph → blue + - set → green + - optimization → orange + - satisfiability → purple + - specialized → gray +- Small dots along the bottom preview variant count +- Hover tooltip lists variant names + +### Expanded Node (after click) + +``` +┌─ Maximum Independent Set ────┐ +│ │ +│ ● base (default) │ +│ ● SimpleGraph, i32 ●──│──→ edges anchor here +│ ● GridGraph, i32 │ +│ ● Triangular, i32 │ +│ ● UnitDiskGraph, i32 │ +│ │ +└──────────────────────────────┘ +``` + +- Parent container expands; child nodes (variant pills) appear inside +- Each variant is a small labeled pill with a port dot on the side where edges connect +- Natural cast edges (e.g., Triangular → SimpleGraph) shown as dashed arrows inside the container +- Click a variant dot → filter: only that variant's edges are shown; all other edges fade to 10% opacity +- Click parent header → collapse back + +### Single-Variant Nodes + +Problems with only one variant (e.g., Satisfiability, CircuitSAT, Factoring) render as simple nodes — no expand/collapse, no variant dots. + +## Edge Design + +| Type | Style | Example | +|------|-------|---------| +| Reduction | Solid arrow, dark stroke | SAT → MIS | +| Natural cast | Dashed arrow, light gray | Triangular → SimpleGraph (internal) | + +### Collapsed Mode + +Multiple variant-level edges between the same two problems collapse to a single edge with a count label (e.g., "×3"). + +### Expanded Mode + +When a node is expanded, its edges "split" — each edge anchors to the specific variant dot that is its actual source/target in the JSON. + +### Hover Tooltip + +Shows overhead formula: `num_vars = num_vertices, num_constraints = num_edges` + +## Layout + +**Algorithm:** ELK.js stress layout (`elk.algorithm: 'stress'`) + +- Stress minimization positions nodes so geometric distances approximate graph-theoretic distances +- Produces natural, balanced layouts without strict hierarchical layering +- Compound nodes (expanded parents) handled by ELK's hierarchical container support +- Re-layout with smooth animation on expand/collapse +- Users can drag nodes to adjust positions after layout settles + +## Interactions + +| Action | Result | +|--------|--------| +| **Click node** | Expand/collapse to show/hide variant dots | +| **Click variant dot** | Filter edges: only that variant's connections shown, others fade | +| **Click background** | Reset all filters, collapse all expanded nodes | +| **Double-click node** | Open problem doc page (via `doc_path` field in JSON) | +| **Double-click edge** | Open reduction doc page (via `doc_path` field in JSON) | +| **Hover node** | Tooltip: variant list + reduction count | +| **Hover edge** | Tooltip: overhead formula | +| **Path finder** | Two dropdowns: "From" → "To". Highlights shortest reduction path. Shows path cost. | +| **Search bar** | Type to filter — matching nodes highlighted, non-matching fade | +| **Zoom/pan** | Scroll to zoom, drag to pan | + +## Data Preprocessing (client-side) + +1. Load `reduction_graph.json` +2. Group nodes by `name` → create compound parent nodes +3. For groups with >1 variant → create child nodes inside parent +4. For single-variant groups → create simple (non-compound) nodes +5. Map edges to connect to child nodes (variant-level targets) +6. Compute collapsed-mode edge summary (merge parallel edges between same parents) +7. Build adjacency list for client-side path finding (BFS/Dijkstra) + +## Path Finder + +Two dropdown menus at the top of the page: +- "From: [problem name]" — lists all problem names +- "To: [problem name]" — lists all problem names + +Clicking "Find Path" runs client-side Dijkstra on the name-level graph. The result: +- Highlights path edges in a distinct color (e.g., gold) +- Fades non-path nodes/edges +- Shows path summary below: `SAT → 3-SAT → MIS → QUBO` with total overhead + +## Category Color Scheme + +| Category | Light Mode | Dark Mode | +|----------|-----------|-----------| +| graph | `#3b82f6` (blue) | `#60a5fa` | +| set | `#22c55e` (green) | `#4ade80` | +| optimization | `#f97316` (orange) | `#fb923c` | +| satisfiability | `#a855f7` (purple) | `#c084fc` | +| specialized | `#6b7280` (gray) | `#9ca3af` | + +Dark/light mode follows mdBook's theme toggle. + +## Non-Goals + +- Server-side rendering — everything runs client-side +- Editing the graph — read-only visualization +- Animated edge flow — static arrows are sufficient +- Mobile-optimized layout — desktop-first, basic mobile support via zoom/pan From fc8caf86a4b0bab930539732728497a2abe54e04 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 16:45:14 +0800 Subject: [PATCH 11/39] docs: add interactive reduction diagram implementation plan Co-Authored-By: Claude Opus 4.6 --- ...2-14-interactive-reduction-diagram-impl.md | 885 ++++++++++++++++++ 1 file changed, 885 insertions(+) create mode 100644 docs/plans/2026-02-14-interactive-reduction-diagram-impl.md diff --git a/docs/plans/2026-02-14-interactive-reduction-diagram-impl.md b/docs/plans/2026-02-14-interactive-reduction-diagram-impl.md new file mode 100644 index 000000000..b819ad271 --- /dev/null +++ b/docs/plans/2026-02-14-interactive-reduction-diagram-impl.md @@ -0,0 +1,885 @@ +# Interactive Reduction Diagram Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Upgrade the existing Cytoscape.js reduction diagram in `docs/src/introduction.md` to support collapsible compound nodes with variant dots, ELK stress layout, edge filtering, and a search bar — while preserving existing path finding, doc links, and tooltip features. + +**Architecture:** Refactor the ~340-line inline ` + +``` + +Note: `cytoscape-elk` auto-registers with Cytoscape when loaded via ` - +
For theoretical background and correctness proofs, see the [PDF manual](https://codingthrust.github.io/problem-reductions/reductions.pdf). diff --git a/docs/src/static/reduction-graph.css b/docs/src/static/reduction-graph.css new file mode 100644 index 000000000..07567af8e --- /dev/null +++ b/docs/src/static/reduction-graph.css @@ -0,0 +1,64 @@ +#cy { + width: 100%; + height: 600px; + border: 1px solid var(--sidebar-bg); + border-radius: 4px; + background: var(--bg); +} + +#cy-controls { + margin-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + font-family: sans-serif; + font-size: 13px; + color: var(--fg); +} + +#legend span.swatch { + display: inline-block; + width: 14px; + height: 14px; + border: 1px solid #999; + margin-right: 3px; + vertical-align: middle; + border-radius: 2px; +} + +#legend span.swatch + span.swatch { + margin-left: 10px; +} + +#cy-tooltip { + display: none; + position: absolute; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--sidebar-bg); + padding: 8px 12px; + border-radius: 4px; + font-family: sans-serif; + font-size: 13px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + pointer-events: none; + z-index: 1000; +} + +#cy-help { + margin-top: 8px; + font-family: sans-serif; + font-size: 12px; + color: var(--fg); + opacity: 0.6; +} + +#clear-btn { + display: none; + margin-left: 8px; + padding: 3px 10px; + cursor: pointer; + font-size: 12px; +} diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js new file mode 100644 index 000000000..5a05b13e6 --- /dev/null +++ b/docs/src/static/reduction-graph.js @@ -0,0 +1,349 @@ +document.addEventListener('DOMContentLoaded', function() { + // Check if the cy container exists on this page + var cyContainer = document.getElementById('cy'); + if (!cyContainer) return; + + var categoryColors = { + graph: '#c8f0c8', set: '#f0c8c8', optimization: '#f0f0a0', + satisfiability: '#c8c8f0', specialized: '#f0c8e0' + }; + var categoryBorders = { + graph: '#4a8c4a', set: '#8c4a4a', optimization: '#8c8c4a', + satisfiability: '#4a4a8c', specialized: '#8c4a6a' + }; + + function variantId(name, variant) { + var keys = Object.keys(variant).sort(); + return name + '/' + keys.map(function(k) { return k + '=' + variant[k]; }).join(','); + } + + // Default values per variant key — omitted in concise labels + var variantDefaults = { graph: 'SimpleGraph', weight: 'One' }; + + function variantLabel(variant) { + var keys = Object.keys(variant); + var parts = []; + keys.forEach(function(k) { + var v = variant[k]; + if (variantDefaults[k] && v === variantDefaults[k]) return; // skip defaults + parts.push(k === 'graph' || k === 'weight' ? v : k + '=' + v); + }); + return parts.length > 0 ? parts.join(', ') : 'base'; + } + + function fullVariantLabel(variant) { + var keys = Object.keys(variant); + if (keys.length === 0) return 'no parameters'; + var parts = []; + keys.forEach(function(k) { + parts.push(k === 'graph' || k === 'weight' ? variant[k] : k + '=' + variant[k]); + }); + return parts.join(', '); + } + + function isBaseVariant(variant) { + var keys = Object.keys(variant); + return keys.every(function(k) { + return variantDefaults[k] && variant[k] === variantDefaults[k]; + }); + } + + fetch('reductions/reduction_graph.json') + .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) + .then(function(data) { + // Group all nodes by problem name + var problems = {}; + data.nodes.forEach(function(n) { + if (!problems[n.name]) { + problems[n.name] = { category: n.category, doc_path: n.doc_path, children: [] }; + } + // Only track nodes with non-empty variants as children; + // empty-variant nodes are base placeholders + if (n.variant && Object.keys(n.variant).length > 0) { + problems[n.name].children.push(n); + } + }); + + // Build edges at variant level, detecting bidirectional pairs + var edgeMap = {}; + data.edges.forEach(function(e) { + var src = data.nodes[e.source]; + var dst = data.nodes[e.target]; + var srcId = variantId(src.name, src.variant); + var dstId = variantId(dst.name, dst.variant); + var fwd = srcId + '->' + dstId; + var rev = dstId + '->' + srcId; + if (edgeMap[rev]) { edgeMap[rev].bidirectional = true; } + else if (!edgeMap[fwd]) { + edgeMap[fwd] = { source: srcId, target: dstId, bidirectional: false, overhead: e.overhead || [], doc_path: e.doc_path || '' }; + } + }); + + // Precompute per-problem base/non-base split + var problemNames = Object.keys(problems); + var problemInfo = {}; + problemNames.forEach(function(name) { + var info = problems[name]; + var baseChild = null, nonBase = []; + info.children.forEach(function(child) { + if (isBaseVariant(child.variant)) baseChild = child; + else nonBase.push(child); + }); + problemInfo[name] = { baseChild: baseChild, nonBase: nonBase }; + }); + + // ── Step 1: Layout one node per problem using cose ── + var tempElements = []; + problemNames.forEach(function(name) { + var info = problems[name]; + tempElements.push({ + data: { id: name, label: name, category: info.category } + }); + }); + var tempEdgeSet = {}; + data.edges.forEach(function(e) { + var srcName = data.nodes[e.source].name; + var dstName = data.nodes[e.target].name; + var key = srcName + '->' + dstName; + var rev = dstName + '->' + srcName; + if (!tempEdgeSet[key] && !tempEdgeSet[rev]) { + tempEdgeSet[key] = true; + tempElements.push({ data: { id: 'te_' + key, source: srcName, target: dstName } }); + } + }); + + var tempCy = cytoscape({ + container: document.getElementById('cy'), + elements: tempElements, + style: [ + { selector: 'node', style: { + 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', + 'font-size': '11px', 'font-family': 'monospace', + 'width': function(ele) { return Math.max(ele.data('label').length * 7 + 14, 60); }, + 'height': 28, 'shape': 'round-rectangle' + }}, + { selector: 'edge', style: { 'width': 1, 'line-color': '#ddd', 'target-arrow-shape': 'none' } } + ], + layout: { + name: 'cose', animate: false, + nodeRepulsion: function() { return 16000; }, + idealEdgeLength: function() { return 200; }, + gravity: 0.15, numIter: 1000, padding: 40 + } + }); + + var positions = {}; + tempCy.nodes().forEach(function(n) { + positions[n.id()] = { x: n.position('x'), y: n.position('y') }; + }); + tempCy.destroy(); + + // ── Step 2: Place flat variant nodes near parent positions ── + var elements = []; + var variantOffsetY = 30; + + problemNames.forEach(function(name) { + var info = problems[name]; + var pi = problemInfo[name]; + var pos = positions[name]; + + if (info.children.length === 0) { + // No parameterized variants — single node with empty variant + var vid = variantId(name, {}); + elements.push({ + data: { id: vid, label: name, fullLabel: name + ' (no parameters)', category: info.category, doc_path: info.doc_path }, + position: { x: pos.x, y: pos.y } + }); + } else if (pi.baseChild) { + // Base variant at parent position, labeled with problem name + var baseId = variantId(name, pi.baseChild.variant); + elements.push({ + data: { id: baseId, label: name, fullLabel: name + ' (' + fullVariantLabel(pi.baseChild.variant) + ')', category: info.category, doc_path: info.doc_path }, + position: { x: pos.x, y: pos.y } + }); + // Non-base variants placed below + pi.nonBase.forEach(function(child, i) { + var vid = variantId(name, child.variant); + var vl = variantLabel(child.variant); + elements.push({ + data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path }, + position: { x: pos.x, y: pos.y + (i + 1) * variantOffsetY } + }); + }); + } else if (pi.nonBase.length === 1) { + // Single non-base variant — place at parent position with just problem name + var child = pi.nonBase[0]; + var vid = variantId(name, child.variant); + elements.push({ + data: { id: vid, label: name, fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path }, + position: { x: pos.x, y: pos.y } + }); + } else { + // Multiple non-base variants, no base — first at parent, rest below + pi.nonBase.forEach(function(child, i) { + var vid = variantId(name, child.variant); + var vl = variantLabel(child.variant); + elements.push({ + data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path }, + position: { x: pos.x, y: pos.y + i * variantOffsetY } + }); + }); + } + }); + + // ── Step 3: Connect edges ── + Object.keys(edgeMap).forEach(function(k) { + var e = edgeMap[k]; + elements.push({ + data: { + id: k, source: e.source, target: e.target, + bidirectional: e.bidirectional, overhead: e.overhead, doc_path: e.doc_path + } + }); + }); + + var cy = cytoscape({ + container: document.getElementById('cy'), + elements: elements, + style: [ + { selector: 'node', style: { + 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', + 'font-size': '10px', 'font-family': 'monospace', + 'width': function(ele) { return Math.max(ele.data('label').length * 6.5 + 10, 50); }, + 'height': 24, 'shape': 'round-rectangle', + 'background-color': function(ele) { return categoryColors[ele.data('category')] || '#f0f0f0'; }, + 'border-width': 1, + 'border-color': function(ele) { return categoryBorders[ele.data('category')] || '#999'; }, + 'text-wrap': 'none', 'cursor': 'pointer' + }}, + { selector: 'edge', style: { + 'width': 1.5, 'line-color': '#999', 'target-arrow-color': '#999', 'target-arrow-shape': 'triangle', + 'source-arrow-color': '#999', + 'source-arrow-shape': function(ele) { return ele.data('bidirectional') ? 'triangle' : 'none'; }, + 'curve-style': 'bezier', 'arrow-scale': 0.7, 'cursor': 'pointer' + }}, + { selector: '.highlighted', style: { + 'background-color': '#ff6b6b', 'border-color': '#cc0000', 'border-width': 2, 'z-index': 10 + }}, + { selector: 'edge.highlighted', style: { + 'line-color': '#ff4444', 'target-arrow-color': '#ff4444', 'source-arrow-color': '#ff4444', 'width': 3, 'z-index': 10 + }}, + { selector: '.selected-node', style: { + 'border-color': '#0066cc', 'border-width': 2, 'background-color': '#cce0ff' + }} + ], + layout: { name: 'preset' }, + userZoomingEnabled: true, userPanningEnabled: true, boxSelectionEnabled: false + }); + + // Tooltip for nodes + var tooltip = document.getElementById('cy-tooltip'); + cy.on('mouseover', 'node', function(evt) { + var d = evt.target.data(); + tooltip.innerHTML = '' + d.fullLabel + '
Double-click to view API docs'; + tooltip.style.display = 'block'; + }); + cy.on('mousemove', 'node', function(evt) { + var pos = evt.renderedPosition || evt.position; + var rect = document.getElementById('cy').getBoundingClientRect(); + tooltip.style.left = (rect.left + window.scrollX + pos.x + 15) + 'px'; + tooltip.style.top = (rect.top + window.scrollY + pos.y - 10) + 'px'; + }); + cy.on('mouseout', 'node', function() { tooltip.style.display = 'none'; }); + + // Edge tooltip + cy.on('mouseover', 'edge', function(evt) { + var d = evt.target.data(); + var arrow = d.bidirectional ? ' \u2194 ' : ' \u2192 '; + var html = '' + evt.target.source().data('label') + arrow + evt.target.target().data('label') + ''; + if (d.overhead && d.overhead.length > 0) { + html += '
' + d.overhead.map(function(o) { return '' + o.field + ' = ' + o.formula + ''; }).join('
'); + } + html += '
Click to highlight, double-click for source code'; + tooltip.innerHTML = html; + tooltip.style.display = 'block'; + }); + cy.on('mousemove', 'edge', function(evt) { + var pos = evt.renderedPosition || evt.position; + var rect = document.getElementById('cy').getBoundingClientRect(); + tooltip.style.left = (rect.left + window.scrollX + pos.x + 15) + 'px'; + tooltip.style.top = (rect.top + window.scrollY + pos.y - 10) + 'px'; + }); + cy.on('mouseout', 'edge', function() { tooltip.style.display = 'none'; }); + + // Double-click node → rustdoc API page + cy.on('dbltap', 'node', function(evt) { + var d = evt.target.data(); + if (d.doc_path) { + window.location.href = 'api/problemreductions/' + d.doc_path; + } + }); + // Double-click edge → GitHub source code + cy.on('dbltap', 'edge', function(evt) { + var d = evt.target.data(); + if (d.doc_path) { + var module = d.doc_path.replace('/index.html', ''); + window.open('https://github.com/CodingThrust/problem-reductions/blob/main/src/' + module + '.rs', '_blank'); + } + }); + + // Single-click path selection + var selectedNode = null; + var instructions = document.getElementById('instructions'); + var clearBtn = document.getElementById('clear-btn'); + + function clearPath() { + cy.elements().removeClass('highlighted selected-node'); + selectedNode = null; + instructions.textContent = 'Click a node to start path selection'; + clearBtn.style.display = 'none'; + } + + clearBtn.addEventListener('click', clearPath); + + cy.on('tap', 'node', function(evt) { + var node = evt.target; + if (!selectedNode) { + selectedNode = node; + node.addClass('selected-node'); + instructions.textContent = 'Now click a target node to find path from ' + node.data('label'); + } else if (node === selectedNode) { + clearPath(); + } else { + var dijkstra = cy.elements().dijkstra({ root: selectedNode, directed: true }); + var path = dijkstra.pathTo(node); + cy.elements().removeClass('highlighted selected-node'); + if (path && path.length > 0) { + path.addClass('highlighted'); + instructions.textContent = 'Path: ' + path.nodes().map(function(n) { return n.data('label'); }).join(' \u2192 '); + } else { + instructions.textContent = 'No path from ' + selectedNode.data('label') + ' to ' + node.data('label'); + } + clearBtn.style.display = 'inline'; + selectedNode = null; + } + }); + + cy.on('tap', 'edge', function(evt) { + var edge = evt.target; + var d = edge.data(); + cy.elements().removeClass('highlighted selected-node'); + edge.addClass('highlighted'); + edge.source().addClass('highlighted'); + edge.target().addClass('highlighted'); + var arrow = d.bidirectional ? ' \u2194 ' : ' \u2192 '; + var text = edge.source().data('label') + arrow + edge.target().data('label'); + if (d.overhead && d.overhead.length > 0) { + text += ' | ' + d.overhead.map(function(o) { return o.field + ' = ' + o.formula; }).join(', '); + } + instructions.textContent = text; + clearBtn.style.display = 'inline'; + selectedNode = null; + }); + + cy.on('tap', function(evt) { if (evt.target === cy) { clearPath(); } }); + }) + .catch(function(err) { + document.getElementById('cy').innerHTML = '

Failed to load reduction graph: ' + err.message + '

'; + }); +}); From b29e5db878cb8437e23e6e3f19ab70baf08d4ae6 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:00:03 +0800 Subject: [PATCH 15/39] feat: switch reduction diagram to ELK stress layout Co-Authored-By: Claude Opus 4.6 --- docs/src/introduction.md | 3 + docs/src/static/reduction-graph.js | 91 ++++++++---------------------- 2 files changed, 27 insertions(+), 67 deletions(-) diff --git a/docs/src/introduction.md b/docs/src/introduction.md index a47d27c5b..be7d0b4b8 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -4,6 +4,9 @@ ## Reduction Graph + + +
diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index 5a05b13e6..6b1dda644 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -92,106 +92,53 @@ document.addEventListener('DOMContentLoaded', function() { problemInfo[name] = { baseChild: baseChild, nonBase: nonBase }; }); - // ── Step 1: Layout one node per problem using cose ── - var tempElements = []; - problemNames.forEach(function(name) { - var info = problems[name]; - tempElements.push({ - data: { id: name, label: name, category: info.category } - }); - }); - var tempEdgeSet = {}; - data.edges.forEach(function(e) { - var srcName = data.nodes[e.source].name; - var dstName = data.nodes[e.target].name; - var key = srcName + '->' + dstName; - var rev = dstName + '->' + srcName; - if (!tempEdgeSet[key] && !tempEdgeSet[rev]) { - tempEdgeSet[key] = true; - tempElements.push({ data: { id: 'te_' + key, source: srcName, target: dstName } }); - } - }); - - var tempCy = cytoscape({ - container: document.getElementById('cy'), - elements: tempElements, - style: [ - { selector: 'node', style: { - 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', - 'font-size': '11px', 'font-family': 'monospace', - 'width': function(ele) { return Math.max(ele.data('label').length * 7 + 14, 60); }, - 'height': 28, 'shape': 'round-rectangle' - }}, - { selector: 'edge', style: { 'width': 1, 'line-color': '#ddd', 'target-arrow-shape': 'none' } } - ], - layout: { - name: 'cose', animate: false, - nodeRepulsion: function() { return 16000; }, - idealEdgeLength: function() { return 200; }, - gravity: 0.15, numIter: 1000, padding: 40 - } - }); - - var positions = {}; - tempCy.nodes().forEach(function(n) { - positions[n.id()] = { x: n.position('x'), y: n.position('y') }; - }); - tempCy.destroy(); - - // ── Step 2: Place flat variant nodes near parent positions ── + // ── Build flat variant nodes ── var elements = []; - var variantOffsetY = 30; problemNames.forEach(function(name) { var info = problems[name]; var pi = problemInfo[name]; - var pos = positions[name]; if (info.children.length === 0) { // No parameterized variants — single node with empty variant var vid = variantId(name, {}); elements.push({ - data: { id: vid, label: name, fullLabel: name + ' (no parameters)', category: info.category, doc_path: info.doc_path }, - position: { x: pos.x, y: pos.y } + data: { id: vid, label: name, fullLabel: name + ' (no parameters)', category: info.category, doc_path: info.doc_path } }); } else if (pi.baseChild) { - // Base variant at parent position, labeled with problem name + // Base variant labeled with problem name var baseId = variantId(name, pi.baseChild.variant); elements.push({ - data: { id: baseId, label: name, fullLabel: name + ' (' + fullVariantLabel(pi.baseChild.variant) + ')', category: info.category, doc_path: info.doc_path }, - position: { x: pos.x, y: pos.y } + data: { id: baseId, label: name, fullLabel: name + ' (' + fullVariantLabel(pi.baseChild.variant) + ')', category: info.category, doc_path: info.doc_path } }); - // Non-base variants placed below - pi.nonBase.forEach(function(child, i) { + // Non-base variants + pi.nonBase.forEach(function(child) { var vid = variantId(name, child.variant); var vl = variantLabel(child.variant); elements.push({ - data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path }, - position: { x: pos.x, y: pos.y + (i + 1) * variantOffsetY } + data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path } }); }); } else if (pi.nonBase.length === 1) { - // Single non-base variant — place at parent position with just problem name + // Single non-base variant — labeled with just problem name var child = pi.nonBase[0]; var vid = variantId(name, child.variant); elements.push({ - data: { id: vid, label: name, fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path }, - position: { x: pos.x, y: pos.y } + data: { id: vid, label: name, fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path } }); } else { - // Multiple non-base variants, no base — first at parent, rest below - pi.nonBase.forEach(function(child, i) { + // Multiple non-base variants, no base + pi.nonBase.forEach(function(child) { var vid = variantId(name, child.variant); var vl = variantLabel(child.variant); elements.push({ - data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path }, - position: { x: pos.x, y: pos.y + i * variantOffsetY } + data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path } }); }); } }); - // ── Step 3: Connect edges ── + // ── Connect edges ── Object.keys(edgeMap).forEach(function(k) { var e = edgeMap[k]; elements.push({ @@ -232,7 +179,17 @@ document.addEventListener('DOMContentLoaded', function() { 'border-color': '#0066cc', 'border-width': 2, 'background-color': '#cce0ff' }} ], - layout: { name: 'preset' }, + layout: { + name: 'elk', + elk: { + algorithm: 'stress', + 'stress.desiredEdgeLength': 200, + 'nodeNode.spacing': 40, + }, + animate: true, + animationDuration: 500, + padding: 40 + }, userZoomingEnabled: true, userPanningEnabled: true, boxSelectionEnabled: false }); From e2dc048718cea3332d03637410b8eb4196c8e6a1 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:08:36 +0800 Subject: [PATCH 16/39] feat: implement compound nodes for reduction diagram (collapsed view) Co-Authored-By: Claude Opus 4.6 --- docs/src/static/reduction-graph.js | 203 +++++++++++++++++++---------- 1 file changed, 137 insertions(+), 66 deletions(-) diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index 6b1dda644..c6ff18a69 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -41,27 +41,16 @@ document.addEventListener('DOMContentLoaded', function() { return parts.join(', '); } - function isBaseVariant(variant) { - var keys = Object.keys(variant); - return keys.every(function(k) { - return variantDefaults[k] && variant[k] === variantDefaults[k]; - }); - } - fetch('reductions/reduction_graph.json') .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function(data) { - // Group all nodes by problem name + // Group nodes by problem name var problems = {}; - data.nodes.forEach(function(n) { + data.nodes.forEach(function(n, idx) { if (!problems[n.name]) { - problems[n.name] = { category: n.category, doc_path: n.doc_path, children: [] }; - } - // Only track nodes with non-empty variants as children; - // empty-variant nodes are base placeholders - if (n.variant && Object.keys(n.variant).length > 0) { - problems[n.name].children.push(n); + problems[n.name] = { category: n.category, doc_path: n.doc_path, variants: [] }; } + problems[n.name].variants.push({ index: idx, variant: n.variant, category: n.category, doc_path: n.doc_path }); }); // Build edges at variant level, detecting bidirectional pairs @@ -79,72 +68,107 @@ document.addEventListener('DOMContentLoaded', function() { } }); - // Precompute per-problem base/non-base split - var problemNames = Object.keys(problems); - var problemInfo = {}; - problemNames.forEach(function(name) { - var info = problems[name]; - var baseChild = null, nonBase = []; - info.children.forEach(function(child) { - if (isBaseVariant(child.variant)) baseChild = child; - else nonBase.push(child); - }); - problemInfo[name] = { baseChild: baseChild, nonBase: nonBase }; - }); - - // ── Build flat variant nodes ── + // ── Build compound nodes ── var elements = []; + var parentIds = {}; // name → parent node id - problemNames.forEach(function(name) { + Object.keys(problems).forEach(function(name) { var info = problems[name]; - var pi = problemInfo[name]; + var hasMultipleVariants = info.variants.length > 1; - if (info.children.length === 0) { - // No parameterized variants — single node with empty variant - var vid = variantId(name, {}); + if (hasMultipleVariants) { + // Create compound parent node + var parentId = 'parent_' + name; + parentIds[name] = parentId; elements.push({ - data: { id: vid, label: name, fullLabel: name + ' (no parameters)', category: info.category, doc_path: info.doc_path } + data: { + id: parentId, + label: name, + category: info.category, + doc_path: info.doc_path, + isParent: true, + variantCount: info.variants.length + } }); - } else if (pi.baseChild) { - // Base variant labeled with problem name - var baseId = variantId(name, pi.baseChild.variant); - elements.push({ - data: { id: baseId, label: name, fullLabel: name + ' (' + fullVariantLabel(pi.baseChild.variant) + ')', category: info.category, doc_path: info.doc_path } - }); - // Non-base variants - pi.nonBase.forEach(function(child) { - var vid = variantId(name, child.variant); - var vl = variantLabel(child.variant); + + // Create child nodes (hidden initially — collapsed) + info.variants.forEach(function(v) { + var vid = variantId(name, v.variant); elements.push({ - data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path } + data: { + id: vid, + parent: parentId, + label: variantLabel(v.variant), + fullLabel: name + ' (' + fullVariantLabel(v.variant) + ')', + category: v.category, + doc_path: v.doc_path, + isVariant: true, + problemName: name + } }); }); - } else if (pi.nonBase.length === 1) { - // Single non-base variant — labeled with just problem name - var child = pi.nonBase[0]; - var vid = variantId(name, child.variant); - elements.push({ - data: { id: vid, label: name, fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path } - }); } else { - // Multiple non-base variants, no base - pi.nonBase.forEach(function(child) { - var vid = variantId(name, child.variant); - var vl = variantLabel(child.variant); - elements.push({ - data: { id: vid, label: name + ' (' + vl + ')', fullLabel: name + ' (' + fullVariantLabel(child.variant) + ')', category: child.category, doc_path: child.doc_path } - }); + // Single variant — simple node (no parent) + var v = info.variants[0]; + var vid = variantId(name, v.variant); + elements.push({ + data: { + id: vid, + label: name, + fullLabel: name + ' (' + fullVariantLabel(v.variant) + ')', + category: v.category, + doc_path: v.doc_path, + isVariant: false, + problemName: name + } }); } }); - // ── Connect edges ── + // ── Build collapsed-mode edges (name-level) ── + var nameLevelEdges = {}; + data.edges.forEach(function(e) { + var srcName = data.nodes[e.source].name; + var dstName = data.nodes[e.target].name; + if (srcName === dstName) return; // skip intra-problem natural casts + var key = srcName + '->' + dstName; + if (!nameLevelEdges[key]) { + nameLevelEdges[key] = { count: 0, overhead: e.overhead, doc_path: e.doc_path }; + } + nameLevelEdges[key].count++; + }); + + // Add collapsed edges to elements + Object.keys(nameLevelEdges).forEach(function(key) { + var parts = key.split('->'); + var srcId = parentIds[parts[0]] || variantId(parts[0], problems[parts[0]].variants[0].variant); + var dstId = parentIds[parts[1]] || variantId(parts[1], problems[parts[1]].variants[0].variant); + var info = nameLevelEdges[key]; + elements.push({ + data: { + id: 'collapsed_' + key, + source: srcId, + target: dstId, + label: info.count > 1 ? '\u00d7' + info.count : '', + edgeLevel: 'collapsed', + overhead: info.overhead, + doc_path: info.doc_path + } + }); + }); + + // ── Build variant-level edges (hidden, shown when expanded) ── Object.keys(edgeMap).forEach(function(k) { var e = edgeMap[k]; elements.push({ data: { - id: k, source: e.source, target: e.target, - bidirectional: e.bidirectional, overhead: e.overhead, doc_path: e.doc_path + id: 'variant_' + k, + source: e.source, + target: e.target, + bidirectional: e.bidirectional, + edgeLevel: 'variant', + overhead: e.overhead, + doc_path: e.doc_path } }); }); @@ -153,6 +177,7 @@ document.addEventListener('DOMContentLoaded', function() { container: document.getElementById('cy'), elements: elements, style: [ + // Base node style (simple nodes — single variant, no parent) { selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '10px', 'font-family': 'monospace', @@ -163,12 +188,47 @@ document.addEventListener('DOMContentLoaded', function() { 'border-color': function(ele) { return categoryBorders[ele.data('category')] || '#999'; }, 'text-wrap': 'none', 'cursor': 'pointer' }}, + // Parent (compound) node — collapsed appearance + { selector: 'node[?isParent]', style: { + 'label': 'data(label)', + 'text-valign': 'top', + 'text-halign': 'center', + 'font-size': '11px', + 'font-family': 'monospace', + 'padding': '10px', + 'background-color': function(ele) { return categoryColors[ele.data('category')] || '#f0f0f0'; }, + 'border-width': 1.5, + 'border-color': function(ele) { return categoryBorders[ele.data('category')] || '#999'; }, + 'shape': 'round-rectangle', + 'cursor': 'pointer' + }}, + // Child variant nodes + { selector: 'node[?isVariant]', style: { + 'label': 'data(label)', + 'text-valign': 'center', + 'text-halign': 'center', + 'font-size': '9px', + 'font-family': 'monospace', + 'width': function(ele) { return Math.max(ele.data('label').length * 5.5 + 8, 40); }, + 'height': 18, + 'shape': 'round-rectangle', + 'background-color': function(ele) { return categoryColors[ele.data('category')] || '#f0f0f0'; }, + 'border-width': 1, + 'border-color': function(ele) { return categoryBorders[ele.data('category')] || '#999'; }, + 'cursor': 'pointer' + }}, + // Edge styles { selector: 'edge', style: { 'width': 1.5, 'line-color': '#999', 'target-arrow-color': '#999', 'target-arrow-shape': 'triangle', 'source-arrow-color': '#999', 'source-arrow-shape': function(ele) { return ele.data('bidirectional') ? 'triangle' : 'none'; }, - 'curve-style': 'bezier', 'arrow-scale': 0.7, 'cursor': 'pointer' + 'curve-style': 'bezier', 'arrow-scale': 0.7, 'cursor': 'pointer', + 'label': 'data(label)', 'font-size': '9px', 'text-rotation': 'autorotate', + 'color': '#666', 'text-margin-y': -8 }}, + // Hidden variant-level edges + { selector: 'edge[edgeLevel="variant"]', style: { 'display': 'none' } }, + // Highlighted styles { selector: '.highlighted', style: { 'background-color': '#ff6b6b', 'border-color': '#cc0000', 'border-width': 2, 'z-index': 10 }}, @@ -193,11 +253,18 @@ document.addEventListener('DOMContentLoaded', function() { userZoomingEnabled: true, userPanningEnabled: true, boxSelectionEnabled: false }); + // Start collapsed — hide all child variant nodes + cy.nodes('[?isVariant]').style('display', 'none'); + // Tooltip for nodes var tooltip = document.getElementById('cy-tooltip'); cy.on('mouseover', 'node', function(evt) { var d = evt.target.data(); - tooltip.innerHTML = '' + d.fullLabel + '
Double-click to view API docs'; + var title = d.fullLabel || d.label; + if (d.isParent) { + title += ' (' + d.variantCount + ' variants)'; + } + tooltip.innerHTML = '' + title + '
Double-click to view API docs'; tooltip.style.display = 'block'; }); cy.on('mousemove', 'node', function(evt) { @@ -260,6 +327,7 @@ document.addEventListener('DOMContentLoaded', function() { cy.on('tap', 'node', function(evt) { var node = evt.target; + if (node.data('isParent')) return; // skip — will be handled in Task 4 if (!selectedNode) { selectedNode = node; node.addClass('selected-node'); @@ -267,7 +335,10 @@ document.addEventListener('DOMContentLoaded', function() { } else if (node === selectedNode) { clearPath(); } else { - var dijkstra = cy.elements().dijkstra({ root: selectedNode, directed: true }); + var visibleElements = cy.elements().filter(function(ele) { + return ele.style('display') !== 'none'; + }); + var dijkstra = visibleElements.dijkstra({ root: selectedNode, directed: true }); var path = dijkstra.pathTo(node); cy.elements().removeClass('highlighted selected-node'); if (path && path.length > 0) { From 6e62b0012f3de8586e3f8be1a8c30c294764667b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:13:48 +0800 Subject: [PATCH 17/39] feat: add expand/collapse for compound variant nodes Co-Authored-By: Claude Opus 4.6 --- docs/src/static/reduction-graph.js | 60 +++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index c6ff18a69..55bfff383 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -256,6 +256,61 @@ document.addEventListener('DOMContentLoaded', function() { // Start collapsed — hide all child variant nodes cy.nodes('[?isVariant]').style('display', 'none'); + var expandedParents = {}; // parentId → true/false + + function toggleExpand(parentNode) { + var parentId = parentNode.id(); + var isExpanded = expandedParents[parentId]; + var children = parentNode.children(); + var name = parentNode.data('label'); + + if (isExpanded) { + // Collapse: hide children, show collapsed edges, hide variant edges + children.style('display', 'none'); + cy.edges('[edgeLevel="collapsed"]').forEach(function(e) { + var srcName = e.source().data('label') || e.source().data('problemName'); + var dstName = e.target().data('label') || e.target().data('problemName'); + if (srcName === name || dstName === name) { + e.style('display', 'element'); + } + }); + cy.edges('[edgeLevel="variant"]').forEach(function(e) { + var srcParent = e.source().data('parent'); + var dstParent = e.target().data('parent'); + if (srcParent === parentId || dstParent === parentId) { + e.style('display', 'none'); + } + }); + expandedParents[parentId] = false; + } else { + // Expand: show children, hide collapsed edges, show variant edges + children.style('display', 'element'); + cy.edges('[edgeLevel="collapsed"]').forEach(function(e) { + // Hide collapsed edges connected to this parent + if (e.source().id() === parentId || e.target().id() === parentId) { + e.style('display', 'none'); + } + }); + cy.edges('[edgeLevel="variant"]').forEach(function(e) { + var srcParent = e.source().data('parent'); + var dstParent = e.target().data('parent'); + if (srcParent === parentId || dstParent === parentId) { + e.style('display', 'element'); + } + }); + expandedParents[parentId] = true; + } + + // Re-layout with animation + cy.layout({ + name: 'elk', + elk: { algorithm: 'stress', 'stress.desiredEdgeLength': 200 }, + animate: true, + animationDuration: 300, + padding: 40 + }).run(); + } + // Tooltip for nodes var tooltip = document.getElementById('cy-tooltip'); cy.on('mouseover', 'node', function(evt) { @@ -327,7 +382,10 @@ document.addEventListener('DOMContentLoaded', function() { cy.on('tap', 'node', function(evt) { var node = evt.target; - if (node.data('isParent')) return; // skip — will be handled in Task 4 + if (node.data('isParent')) { + toggleExpand(node); + return; + } if (!selectedNode) { selectedNode = node; node.addClass('selected-node'); From 427b85e9eb3d58f6ef4dc5fa3a1dbb7148170112 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:16:46 +0800 Subject: [PATCH 18/39] feat: add variant edge filtering on click Co-Authored-By: Claude Opus 4.6 --- docs/src/static/reduction-graph.js | 36 +++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index 55bfff383..8cf13552b 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -237,6 +237,12 @@ document.addEventListener('DOMContentLoaded', function() { }}, { selector: '.selected-node', style: { 'border-color': '#0066cc', 'border-width': 2, 'background-color': '#cce0ff' + }}, + { selector: '.faded', style: { 'opacity': 0.1 } }, + { selector: '.variant-selected', style: { + 'border-color': '#0066cc', + 'border-width': 2.5, + 'background-color': '#cce0ff' }} ], layout: { @@ -257,6 +263,7 @@ document.addEventListener('DOMContentLoaded', function() { cy.nodes('[?isVariant]').style('display', 'none'); var expandedParents = {}; // parentId → true/false + var activeVariantFilter = null; function toggleExpand(parentNode) { var parentId = parentNode.id(); @@ -386,6 +393,27 @@ document.addEventListener('DOMContentLoaded', function() { toggleExpand(node); return; } + if (node.data('isVariant')) { + if (activeVariantFilter === node.id()) { + // Toggle off — clear filter + cy.elements().removeClass('faded variant-selected'); + activeVariantFilter = null; + instructions.textContent = 'Click a node to start path selection'; + return; + } + // Apply filter + activeVariantFilter = node.id(); + cy.elements().addClass('faded'); + node.removeClass('faded').addClass('variant-selected'); + var connectedEdges = node.connectedEdges('[edgeLevel="variant"]'); + connectedEdges.removeClass('faded'); + connectedEdges.connectedNodes().removeClass('faded'); + if (node.data('parent')) { + cy.getElementById(node.data('parent')).removeClass('faded'); + } + instructions.textContent = 'Showing edges for ' + node.data('fullLabel') + ' — click again to clear'; + return; + } if (!selectedNode) { selectedNode = node; node.addClass('selected-node'); @@ -427,7 +455,13 @@ document.addEventListener('DOMContentLoaded', function() { selectedNode = null; }); - cy.on('tap', function(evt) { if (evt.target === cy) { clearPath(); } }); + cy.on('tap', function(evt) { + if (evt.target === cy) { + clearPath(); + cy.elements().removeClass('faded variant-selected'); + activeVariantFilter = null; + } + }); }) .catch(function(err) { document.getElementById('cy').innerHTML = '

Failed to load reduction graph: ' + err.message + '

'; From 1d2273d3f0e09c6b0b659e02f055b2f7720e3765 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:17:12 +0800 Subject: [PATCH 19/39] feat: add search bar to reduction diagram Co-Authored-By: Claude Opus 4.6 --- docs/src/introduction.md | 12 ++++++++++++ docs/src/static/reduction-graph.js | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/src/introduction.md b/docs/src/introduction.md index be7d0b4b8..f0d3e63aa 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -7,6 +7,18 @@ +
diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index 8cf13552b..b4bed9846 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -462,6 +462,33 @@ document.addEventListener('DOMContentLoaded', function() { activeVariantFilter = null; } }); + + // Search bar handler + var searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.addEventListener('input', function() { + var query = this.value.trim().toLowerCase(); + if (query === '') { + cy.elements().removeClass('faded'); + return; + } + cy.nodes().forEach(function(node) { + var label = (node.data('label') || '').toLowerCase(); + var fullLabel = (node.data('fullLabel') || '').toLowerCase(); + if (label.includes(query) || fullLabel.includes(query)) { + node.removeClass('faded'); + } else { + node.addClass('faded'); + } + }); + cy.edges().addClass('faded'); + cy.nodes().not('.faded').connectedEdges().forEach(function(edge) { + if (!edge.source().hasClass('faded') && !edge.target().hasClass('faded')) { + edge.removeClass('faded'); + } + }); + }); + } }) .catch(function(err) { document.getElementById('cy').innerHTML = '

Failed to load reduction graph: ' + err.message + '

'; From 907476aa1daa3dc66fc357cbf88501fbe9846017 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:17:48 +0800 Subject: [PATCH 20/39] feat: integrate path finding with compound node structure Restructure tap handler so path selection takes priority when a source node is already selected, allowing variant nodes to participate in path finding while still supporting single-click variant edge filtering. Co-Authored-By: Claude Opus 4.6 --- docs/src/static/reduction-graph.js | 50 +++++++++++++++++------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index b4bed9846..31c481743 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -389,10 +389,34 @@ document.addEventListener('DOMContentLoaded', function() { cy.on('tap', 'node', function(evt) { var node = evt.target; + // Parent → expand/collapse if (node.data('isParent')) { toggleExpand(node); return; } + // Path selection in progress → find path (regardless of variant/simple) + if (selectedNode) { + if (node === selectedNode) { + clearPath(); + return; + } + var visibleElements = cy.elements().filter(function(ele) { + return ele.style('display') !== 'none'; + }); + var dijkstra = visibleElements.dijkstra({ root: selectedNode, directed: true }); + var path = dijkstra.pathTo(node); + cy.elements().removeClass('highlighted selected-node'); + if (path && path.length > 0) { + path.addClass('highlighted'); + instructions.textContent = 'Path: ' + path.nodes().map(function(n) { return n.data('label'); }).join(' \u2192 '); + } else { + instructions.textContent = 'No path from ' + selectedNode.data('label') + ' to ' + node.data('label'); + } + clearBtn.style.display = 'inline'; + selectedNode = null; + return; + } + // Variant node (no path selection active) → variant filter if (node.data('isVariant')) { if (activeVariantFilter === node.id()) { // Toggle off — clear filter @@ -414,28 +438,10 @@ document.addEventListener('DOMContentLoaded', function() { instructions.textContent = 'Showing edges for ' + node.data('fullLabel') + ' — click again to clear'; return; } - if (!selectedNode) { - selectedNode = node; - node.addClass('selected-node'); - instructions.textContent = 'Now click a target node to find path from ' + node.data('label'); - } else if (node === selectedNode) { - clearPath(); - } else { - var visibleElements = cy.elements().filter(function(ele) { - return ele.style('display') !== 'none'; - }); - var dijkstra = visibleElements.dijkstra({ root: selectedNode, directed: true }); - var path = dijkstra.pathTo(node); - cy.elements().removeClass('highlighted selected-node'); - if (path && path.length > 0) { - path.addClass('highlighted'); - instructions.textContent = 'Path: ' + path.nodes().map(function(n) { return n.data('label'); }).join(' \u2192 '); - } else { - instructions.textContent = 'No path from ' + selectedNode.data('label') + ' to ' + node.data('label'); - } - clearBtn.style.display = 'inline'; - selectedNode = null; - } + // Simple node (no path selection active) → start path selection + selectedNode = node; + node.addClass('selected-node'); + instructions.textContent = 'Now click a target node to find path from ' + node.data('label'); }); cy.on('tap', 'edge', function(evt) { From 5f078fa05f53979c13e2ca285fc62a46bf669a63 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:18:14 +0800 Subject: [PATCH 21/39] feat: add dashed style for natural cast edges Co-Authored-By: Claude Opus 4.6 --- docs/src/static/reduction-graph.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index 31c481743..c764fc7e3 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -160,6 +160,9 @@ document.addEventListener('DOMContentLoaded', function() { // ── Build variant-level edges (hidden, shown when expanded) ── Object.keys(edgeMap).forEach(function(k) { var e = edgeMap[k]; + var srcName = e.source.split('/')[0]; + var dstName = e.target.split('/')[0]; + var isNaturalCast = srcName === dstName; elements.push({ data: { id: 'variant_' + k, @@ -168,7 +171,8 @@ document.addEventListener('DOMContentLoaded', function() { bidirectional: e.bidirectional, edgeLevel: 'variant', overhead: e.overhead, - doc_path: e.doc_path + doc_path: e.doc_path, + isNaturalCast: isNaturalCast } }); }); @@ -228,6 +232,13 @@ document.addEventListener('DOMContentLoaded', function() { }}, // Hidden variant-level edges { selector: 'edge[edgeLevel="variant"]', style: { 'display': 'none' } }, + // Natural cast edges (intra-problem) + { selector: 'edge[?isNaturalCast]', style: { + 'line-style': 'dashed', + 'line-color': '#bbb', + 'target-arrow-color': '#bbb', + 'width': 1 + }}, // Highlighted styles { selector: '.highlighted', style: { 'background-color': '#ff6b6b', 'border-color': '#cc0000', 'border-width': 2, 'z-index': 10 From 59947f1dd111d8f4170662c5fe614dcde385384a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:18:44 +0800 Subject: [PATCH 22/39] docs: update diagram legend and help text for compound nodes Co-Authored-By: Claude Opus 4.6 --- docs/src/introduction.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/src/introduction.md b/docs/src/introduction.md index f0d3e63aa..0667c8494 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -27,6 +27,7 @@ Optimization Satisfiability Specialized + Natural Cast
Click a node to start path selection @@ -34,7 +35,11 @@
- Click two variant nodes to find a reduction path. Double-click a node for API docs, double-click an edge for source code. Scroll to zoom, drag to pan. + Click a problem node to expand/collapse its variants. + Click a variant to filter its edges. + Click two nodes to find a reduction path. + Double-click for API docs (nodes) or source code (edges). + Scroll to zoom, drag to pan.
From 49dad35dcfb01767a3b27bdd76b8003ad96ed07f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 14 Feb 2026 23:31:33 +0800 Subject: [PATCH 23/39] fix: polish reduction diagram interactions and edge cases - Fix CDN URL for cytoscape-elk (correct path: /dist/cytoscape-elk.js) - Add explicit ELK layout registration with cose fallback when CDN unavailable - Fix expand/collapse to only show variant edges when both endpoints are visible - Move search input styles to external CSS file Co-Authored-By: Claude Opus 4.6 --- docs/src/introduction.md | 17 +++--------- docs/src/static/reduction-graph.css | 15 +++++++++++ docs/src/static/reduction-graph.js | 42 +++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 0667c8494..e247094f3 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -5,19 +5,10 @@ ## Reduction Graph - - -