diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index 4d190c6c..c2d98ca1 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -3,7 +3,8 @@ use crate::dispatch::{ }; use crate::output::OutputConfig; use anyhow::Result; -use problemreductions::rules::ReductionGraph; +use problemreductions::rules::{ReductionGraph, ReductionMode}; +use std::collections::BTreeMap; use std::path::Path; pub fn inspect(input: &Path, out: &OutputConfig) -> Result<()> { @@ -59,8 +60,7 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { } // Reductions - let outgoing = graph.outgoing_reductions(name); - let targets = targets_deduped(&outgoing); + let targets = executable_reduction_targets(&graph, name, &variant); if !targets.is_empty() { text.push_str(&format!("Reduces to: {}\n", targets.join(", "))); } @@ -100,8 +100,29 @@ fn inspect_bundle(bundle: &ReductionBundle, out: &OutputConfig) -> Result<()> { out.emit_with_default_name("", &text, &json_val) } -fn targets_deduped(outgoing: &[problemreductions::rules::ReductionEdgeInfo]) -> Vec { - let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); +pub(crate) fn executable_reduction_targets( + graph: &ReductionGraph, + name: &str, + variant: &BTreeMap, +) -> Vec { + let mut targets: Vec = graph + .outgoing_reductions_from(name, variant, ReductionMode::Witness) + .into_iter() + .map(|edge| { + let default_variant = graph + .default_variant_for(edge.target_name) + .unwrap_or_else(|| panic!("default variant not found for {}", edge.target_name)); + if default_variant == edge.target_variant { + edge.target_name.to_string() + } else { + format!( + "{}{}", + edge.target_name, + crate::commands::graph::variant_to_full_slash(&edge.target_variant) + ) + } + }) + .collect(); targets.sort(); targets.dedup(); targets diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 49148dad..d81d4be8 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -846,10 +846,8 @@ impl McpServer { let size_fields = graph.size_field_names(name); - let outgoing = graph.outgoing_reductions(name); - let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); - targets.sort(); - targets.dedup(); + let targets = + crate::commands::inspect::executable_reduction_targets(&graph, name, &variant); let solver_view = solver_capabilities_view(&problem)?; let result = serde_json::json!({ diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 9c504229..8ed0a238 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -6092,6 +6092,132 @@ fn test_inspect_problem() { std::fs::remove_file(&problem_file).ok(); } +#[test] +fn test_inspect_reports_only_executable_reductions_for_exact_variant() { + let unit_file = std::env::temp_dir().join("pred_test_inspect_exact_variant_unit.json"); + let weighted_file = std::env::temp_dir().join("pred_test_inspect_exact_variant_weighted.json"); + + let unit_create = pred() + .args([ + "create", + "MIS", + "--graph", + "0-1,1-2,2-3", + "-o", + unit_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + unit_create.status.success(), + "stderr: {}", + String::from_utf8_lossy(&unit_create.stderr) + ); + + let weighted_create = pred() + .args([ + "create", + "MIS/SimpleGraph/i32", + "--graph", + "0-1,1-2,2-3", + "--weights", + "3,1,2,1", + "-o", + weighted_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + weighted_create.status.success(), + "stderr: {}", + String::from_utf8_lossy(&weighted_create.stderr) + ); + + for (source, expected, excluded) in [ + (&unit_file, "MaximumSetPacking", "IntegralFlowBundles"), + ( + &weighted_file, + "IntegralFlowBundles", + "MaximumIndependentSet/KingsSubgraph/One", + ), + ] { + let inspect = pred() + .args(["inspect", source.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + inspect.status.success(), + "stderr: {}", + String::from_utf8_lossy(&inspect.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&inspect.stdout).unwrap(); + let targets = json["reduces_to"].as_array().unwrap(); + assert!(targets.iter().any(|target| target == expected)); + assert!(!targets.iter().any(|target| target == excluded)); + + for (index, target) in targets.iter().enumerate() { + let target = target.as_str().unwrap(); + let bundle = std::env::temp_dir().join(format!( + "pred_test_inspect_exact_variant_bundle_{index}.json" + )); + let reduce = pred() + .args([ + "reduce", + source.to_str().unwrap(), + "--to", + target, + "-o", + bundle.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + reduce.status.success(), + "inspect advertised non-executable target {target}: {}", + String::from_utf8_lossy(&reduce.stderr) + ); + std::fs::remove_file(bundle).unwrap(); + } + } + + std::fs::remove_file(unit_file).unwrap(); + std::fs::remove_file(weighted_file).unwrap(); +} + +#[test] +fn test_inspect_excludes_non_witness_reductions() { + let problem_file = std::env::temp_dir().join("pred_test_inspect_witness_reductions_only.json"); + let create = pred() + .args([ + "create", + "--example", + "MinimumDominatingSet", + "-o", + problem_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + create.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create.stderr) + ); + + let inspect = pred() + .args(["inspect", problem_file.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + inspect.status.success(), + "stderr: {}", + String::from_utf8_lossy(&inspect.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&inspect.stdout).unwrap(); + assert_eq!(json["reduces_to"], serde_json::json!(["ILP"])); + + std::fs::remove_file(problem_file).unwrap(); +} + #[test] fn test_inspect_minmaxmulticenter_lists_ilp_and_bruteforce() { let problem_file = std::env::temp_dir().join("pred_test_inspect_minmaxmulticenter.json"); diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 9447dc46..9e1c5574 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1401,6 +1401,38 @@ impl ReductionGraph { .collect() } + /// Get executable outgoing reductions from one exact problem variant. + /// + /// # Panics + /// + /// Panics if `name` and `variant` do not identify an exactly registered problem variant. + pub fn outgoing_reductions_from( + &self, + name: &str, + variant: &BTreeMap, + mode: ReductionMode, + ) -> Vec { + let source = self + .lookup_node(name, variant) + .unwrap_or_else(|| panic!("registered problem variant not found: {name} {variant:?}")); + + self.ordered_outgoing_edges(source, mode) + .into_iter() + .map(|(target, edge)| { + let src = &self.nodes[self.graph[source]]; + let dst = &self.nodes[self.graph[target]]; + ReductionEdgeInfo { + source_name: src.name, + source_variant: src.variant.clone(), + target_name: dst.name, + target_variant: dst.variant.clone(), + overhead: self.graph[edge].overhead.clone(), + capabilities: self.graph[edge].capabilities(), + } + }) + .collect() + } + /// Get the problem size field names for a problem type. /// /// Derives size fields from the overhead expressions of reduction entries diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index a71a7fc2..9ab5bcec 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1716,6 +1716,55 @@ fn test_compute_source_size_uses_exact_variant_executor() { assert_eq!(size.get("num_edges"), Some(3)); } +#[test] +fn test_outgoing_reductions_from_uses_exact_variant_and_mode() { + let graph = ReductionGraph::new(); + let unit = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let weighted = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + + let unit_targets = + graph.outgoing_reductions_from("MaximumIndependentSet", &unit, ReductionMode::Witness); + assert!(unit_targets + .iter() + .all(|edge| edge.source_variant == unit && edge.capabilities.witness)); + assert!(unit_targets.iter().any(|edge| { + edge.target_name == "MaximumSetPacking" + && edge.target_variant.get("weight").map(String::as_str) == Some("One") + })); + assert!(!unit_targets + .iter() + .any(|edge| edge.target_name == "IntegralFlowBundles")); + + let weighted_targets = + graph.outgoing_reductions_from("MaximumIndependentSet", &weighted, ReductionMode::Witness); + assert!(weighted_targets + .iter() + .all(|edge| edge.source_variant == weighted && edge.capabilities.witness)); + assert!(weighted_targets + .iter() + .any(|edge| edge.target_name == "IntegralFlowBundles")); + assert!(!weighted_targets.iter().any(|edge| { + edge.target_name == "MaximumIndependentSet" + && edge.target_variant.get("graph").map(String::as_str) == Some("KingsSubgraph") + })); +} + +#[test] +#[should_panic(expected = "registered problem variant not found")] +fn test_outgoing_reductions_from_rejects_unknown_exact_variant() { + let graph = ReductionGraph::new(); + graph.outgoing_reductions_from( + "MaximumIndependentSet", + &BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i64".to_string()), + ]), + ReductionMode::Witness, + ); +} + #[test] fn test_compute_source_size_unknown_problem() { let problem = 42u32;