Skip to content

Commit f2801c5

Browse files
GiggleLiuclaude
andcommitted
feat: auto-generate natural variant reduction edges in JSON export
Natural edges connect same-problem variant nodes when all variant fields are transitively reducible (e.g., GridGraph => SimpleGraph, Unweighted => i32). Overhead is identity: p(x) = x. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 14ee1df commit f2801c5

2 files changed

Lines changed: 258 additions & 13 deletions

File tree

src/rules/graph.rs

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,48 @@ impl Default for ReductionGraph {
627627
}
628628

629629
impl ReductionGraph {
630+
/// Check if variant A is strictly more restrictive than variant B (same problem name).
631+
/// Returns true if every field of A is a subtype of (or equal to) the corresponding field in B,
632+
/// and at least one field is strictly more restrictive.
633+
fn is_variant_reducible(
634+
&self,
635+
a: &std::collections::BTreeMap<String, String>,
636+
b: &std::collections::BTreeMap<String, String>,
637+
) -> bool {
638+
if a == b {
639+
return false; // No self-reduction
640+
}
641+
642+
let mut all_compatible = true;
643+
644+
// Check all fields present in either variant
645+
let all_keys: std::collections::BTreeSet<_> = a.keys().chain(b.keys()).collect();
646+
647+
for key in all_keys {
648+
let a_val = a.get(key.as_str()).map(|s| s.as_str()).unwrap_or("");
649+
let b_val = b.get(key.as_str()).map(|s| s.as_str()).unwrap_or("");
650+
651+
if a_val == b_val {
652+
continue; // Equal on this field
653+
}
654+
655+
// Check subtype relationship based on field type
656+
let is_sub = match key.as_str() {
657+
"graph" => self.is_graph_subtype(a_val, b_val),
658+
"weight" => self.is_weight_subtype(a_val, b_val),
659+
_ => false, // Unknown fields must be equal
660+
};
661+
662+
if !is_sub {
663+
all_compatible = false;
664+
break;
665+
}
666+
}
667+
668+
// all_compatible is true and a != b means at least one field is strictly more restrictive
669+
all_compatible
670+
}
671+
630672
/// Helper to convert a variant slice to a BTreeMap.
631673
/// Normalizes empty "graph" values to "SimpleGraph" for consistency.
632674
fn variant_to_map(
@@ -682,10 +724,7 @@ impl ReductionGraph {
682724

683725
// Also collect nodes from ConcreteVariantEntry registrations
684726
for entry in inventory::iter::<ConcreteVariantEntry> {
685-
node_set.insert((
686-
entry.name.to_string(),
687-
Self::variant_to_map(entry.variant),
688-
));
727+
node_set.insert((entry.name.to_string(), Self::variant_to_map(entry.variant)));
689728
}
690729

691730
// Build nodes with categories and doc paths
@@ -751,6 +790,93 @@ impl ReductionGraph {
751790
))
752791
});
753792

793+
// Auto-generate natural edges between same-name variant nodes.
794+
// A natural edge exists from A to B when all variant fields of A are
795+
// at least as restrictive as B's (and at least one is strictly more restrictive).
796+
// The overhead is identity: p(x) = x for each field.
797+
{
798+
// Group non-empty-variant nodes by problem name
799+
let mut nodes_by_name: HashMap<&str, Vec<&std::collections::BTreeMap<String, String>>> =
800+
HashMap::new();
801+
for (name, variant) in &node_set {
802+
if !variant.is_empty() {
803+
nodes_by_name
804+
.entry(name.as_str())
805+
.or_default()
806+
.push(variant);
807+
}
808+
}
809+
810+
// Collect overhead field names per problem from existing edges.
811+
// Use edges where the problem is the TARGET, since the overhead fields
812+
// describe the target problem's size dimensions.
813+
let mut fields_by_problem: HashMap<String, Vec<String>> = HashMap::new();
814+
for edge in &edges {
815+
if !edge.overhead.is_empty() {
816+
fields_by_problem
817+
.entry(edge.target.name.clone())
818+
.or_insert_with(|| edge.overhead.iter().map(|o| o.field.clone()).collect());
819+
}
820+
}
821+
822+
// For each pair of same-name nodes, check transitive reducibility
823+
for (name, variants) in &nodes_by_name {
824+
for a in variants {
825+
for b in variants {
826+
if self.is_variant_reducible(a, b) {
827+
let src_ref = VariantRef {
828+
name: name.to_string(),
829+
variant: (*a).clone(),
830+
};
831+
let dst_ref = VariantRef {
832+
name: name.to_string(),
833+
variant: (*b).clone(),
834+
};
835+
let key = (src_ref.clone(), dst_ref.clone());
836+
if edge_set.insert(key) {
837+
// Identity overhead: each field maps to itself, p(x) = x
838+
let overhead = fields_by_problem
839+
.get(*name)
840+
.map(|fields| {
841+
fields
842+
.iter()
843+
.map(|f| OverheadFieldJson {
844+
field: f.clone(),
845+
formula: f.clone(),
846+
})
847+
.collect()
848+
})
849+
.unwrap_or_default();
850+
851+
edges.push(EdgeJson {
852+
source: src_ref,
853+
target: dst_ref,
854+
overhead,
855+
doc_path: String::new(),
856+
});
857+
}
858+
}
859+
}
860+
}
861+
}
862+
863+
// Re-sort after adding natural edges
864+
edges.sort_by(|a, b| {
865+
(
866+
&a.source.name,
867+
&a.source.variant,
868+
&a.target.name,
869+
&a.target.variant,
870+
)
871+
.cmp(&(
872+
&b.source.name,
873+
&b.source.variant,
874+
&b.target.name,
875+
&b.target.variant,
876+
))
877+
});
878+
}
879+
754880
ReductionGraphJson { nodes, edges }
755881
}
756882

src/unit_tests/rules/graph.rs

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -241,16 +241,13 @@ fn test_sat_based_reductions() {
241241
let graph = ReductionGraph::new();
242242

243243
// SAT -> IS
244-
assert!(graph
245-
.has_direct_reduction::<Satisfiability, MaximumIndependentSet<SimpleGraph, i32>>());
244+
assert!(graph.has_direct_reduction::<Satisfiability, MaximumIndependentSet<SimpleGraph, i32>>());
246245

247246
// SAT -> KColoring
248247
assert!(graph.has_direct_reduction::<Satisfiability, KColoring<3, SimpleGraph, i32>>());
249248

250249
// SAT -> MinimumDominatingSet
251-
assert!(
252-
graph.has_direct_reduction::<Satisfiability, MinimumDominatingSet<SimpleGraph, i32>>()
253-
);
250+
assert!(graph.has_direct_reduction::<Satisfiability, MinimumDominatingSet<SimpleGraph, i32>>());
254251
}
255252

256253
#[test]
@@ -832,9 +829,131 @@ fn test_concrete_variant_nodes_in_json() {
832829
});
833830
assert!(mis_unitdisk, "MIS/UnitDiskGraph node should exist");
834831

835-
let maxcut_gridgraph = json.nodes.iter().any(|n| {
836-
n.name == "MaxCut"
837-
&& n.variant.get("graph") == Some(&"GridGraph".to_string())
838-
});
832+
let maxcut_gridgraph = json
833+
.nodes
834+
.iter()
835+
.any(|n| n.name == "MaxCut" && n.variant.get("graph") == Some(&"GridGraph".to_string()));
839836
assert!(maxcut_gridgraph, "MaxCut/GridGraph node should exist");
840837
}
838+
839+
#[test]
840+
fn test_natural_edge_graph_relaxation() {
841+
let graph = ReductionGraph::new();
842+
let json = graph.to_json();
843+
844+
// MIS/GridGraph -> MIS/SimpleGraph should exist (graph type relaxation)
845+
let has_edge = json.edges.iter().any(|e| {
846+
e.source.name == "MaximumIndependentSet"
847+
&& e.target.name == "MaximumIndependentSet"
848+
&& e.source.variant.get("graph") == Some(&"GridGraph".to_string())
849+
&& e.target.variant.get("graph") == Some(&"SimpleGraph".to_string())
850+
});
851+
assert!(
852+
has_edge,
853+
"Natural edge MIS/GridGraph -> MIS/SimpleGraph should exist"
854+
);
855+
}
856+
857+
#[test]
858+
fn test_natural_edge_gridgraph_to_unitdisk() {
859+
let graph = ReductionGraph::new();
860+
let json = graph.to_json();
861+
862+
// MIS/GridGraph -> MIS/UnitDiskGraph should exist
863+
let has_edge = json.edges.iter().any(|e| {
864+
e.source.name == "MaximumIndependentSet"
865+
&& e.target.name == "MaximumIndependentSet"
866+
&& e.source.variant.get("graph") == Some(&"GridGraph".to_string())
867+
&& e.target.variant.get("graph") == Some(&"UnitDiskGraph".to_string())
868+
});
869+
assert!(
870+
has_edge,
871+
"Natural edge MIS/GridGraph -> MIS/UnitDiskGraph should exist"
872+
);
873+
}
874+
875+
#[test]
876+
fn test_natural_edge_weight_promotion() {
877+
let graph = ReductionGraph::new();
878+
let json = graph.to_json();
879+
880+
// MIS{SimpleGraph, Unweighted} -> MIS{SimpleGraph, i32} should exist
881+
let has_edge = json.edges.iter().any(|e| {
882+
e.source.name == "MaximumIndependentSet"
883+
&& e.target.name == "MaximumIndependentSet"
884+
&& e.source.variant.get("graph") == Some(&"SimpleGraph".to_string())
885+
&& e.target.variant.get("graph") == Some(&"SimpleGraph".to_string())
886+
&& e.source.variant.get("weight") == Some(&"Unweighted".to_string())
887+
&& e.target.variant.get("weight") == Some(&"i32".to_string())
888+
});
889+
assert!(
890+
has_edge,
891+
"Natural edge MIS/Unweighted -> MIS/i32 should exist"
892+
);
893+
}
894+
895+
#[test]
896+
fn test_no_natural_edge_wrong_direction() {
897+
let graph = ReductionGraph::new();
898+
let json = graph.to_json();
899+
900+
// MIS/SimpleGraph -> MIS/GridGraph should NOT exist (wrong direction)
901+
let has_edge = json.edges.iter().any(|e| {
902+
e.source.name == "MaximumIndependentSet"
903+
&& e.target.name == "MaximumIndependentSet"
904+
&& e.source.variant.get("graph") == Some(&"SimpleGraph".to_string())
905+
&& e.target.variant.get("graph") == Some(&"GridGraph".to_string())
906+
});
907+
assert!(
908+
!has_edge,
909+
"Should NOT have MIS/SimpleGraph -> MIS/GridGraph"
910+
);
911+
}
912+
913+
#[test]
914+
fn test_no_natural_self_edge() {
915+
let graph = ReductionGraph::new();
916+
let json = graph.to_json();
917+
918+
// No self-edges (same variant to same variant)
919+
for edge in &json.edges {
920+
if edge.source.name == edge.target.name {
921+
assert!(
922+
edge.source.variant != edge.target.variant,
923+
"Should not have self-edge: {} {:?}",
924+
edge.source.name,
925+
edge.source.variant
926+
);
927+
}
928+
}
929+
}
930+
931+
#[test]
932+
fn test_natural_edge_has_identity_overhead() {
933+
let graph = ReductionGraph::new();
934+
let json = graph.to_json();
935+
936+
// Find a natural edge and verify its overhead is identity (field == formula)
937+
let natural_edge = json.edges.iter().find(|e| {
938+
e.source.name == "MaximumIndependentSet"
939+
&& e.target.name == "MaximumIndependentSet"
940+
&& e.source.variant.get("graph") == Some(&"GridGraph".to_string())
941+
&& e.target.variant.get("graph") == Some(&"SimpleGraph".to_string())
942+
&& e.source.variant.get("weight") == Some(&"Unweighted".to_string())
943+
&& e.target.variant.get("weight") == Some(&"Unweighted".to_string())
944+
});
945+
assert!(natural_edge.is_some(), "Natural edge should exist");
946+
let edge = natural_edge.unwrap();
947+
// Overhead should be identity: each field maps to itself
948+
assert!(
949+
!edge.overhead.is_empty(),
950+
"Natural edge should have identity overhead"
951+
);
952+
for o in &edge.overhead {
953+
assert_eq!(
954+
o.field, o.formula,
955+
"Natural edge overhead should be identity: {} != {}",
956+
o.field, o.formula
957+
);
958+
}
959+
}

0 commit comments

Comments
 (0)