diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index f500fe7dc..c60bbcde7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -132,6 +132,7 @@ "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], "Partition": [Partition], + "PartialFeedbackEdgeSet": [Partial Feedback Edge Set], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], "ConjunctiveBooleanQuery": [Conjunctive Boolean Query], @@ -4804,6 +4805,65 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("PartialFeedbackEdgeSet") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.edges + let ne = edges.len() + let K = x.instance.budget + let L = x.instance.max_cycle_length + let config = x.optimal_config + let removed-indices = config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + let removed-edges = removed-indices.map(i => edges.at(i)) + let blue = graph-colors.at(0) + let gray = luma(180) + [ + #problem-def("PartialFeedbackEdgeSet")[ + Given an undirected graph $G = (V, E)$, a budget $K in ZZ_(>= 0)$, and a cycle-length bound $L in ZZ_(>= 0)$, determine whether there exists a subset $E' subset.eq E$ with $|E'| <= K$ such that every simple cycle in $G$ of length at most $L$ contains at least one edge of $E'$. + ][ + Partial Feedback Edge Set is the bounded-cycle edge-deletion problem GT9 in Garey and Johnson @garey1979. Bounding the cycle length is what makes the problem hard: hitting only the short cycles is NP-complete, whereas the unrestricted undirected feedback-edge-set problem is polynomial-time solvable by reducing to a spanning forest. The implementation here uses one binary variable per edge, so brute-force search explores $O^*(2^|E|)$ candidate edge subsets.#footnote[No sharper general exact worst-case bound is claimed here.] + + *Example.* Consider the graph $G$ with $n = #nv$ vertices, $|E| = #ne$ edges, budget $K = #K$, and length bound $L = #L$. Removing + $E' = {#removed-edges.map(e => [$\{v_#(e.at(0)), v_#(e.at(1))\}$]).join(", ")}$ + hits the triangles $(v_0, v_1, v_2)$, $(v_0, v_2, v_3)$, $(v_2, v_3, v_4)$, and $(v_3, v_4, v_5)$, together with the 4-cycles $(v_0, v_1, v_2, v_3)$, $(v_0, v_2, v_4, v_3)$, and $(v_2, v_3, v_5, v_4)$. Hence every cycle of length at most 4 is hit. Brute-force search on this instance finds exactly five satisfying 3-edge deletions and none of size 2, so the displayed configuration certifies a YES-instance. + + #pred-commands( + "pred create --example PartialFeedbackEdgeSet -o partial-feedback-edge-set.json", + "pred solve partial-feedback-edge-set.json", + "pred evaluate partial-feedback-edge-set.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + let verts = ( + (0, 1.4), + (1.2, 2.4), + (1.9, 1.0), + (3.3, 1.4), + (4.5, 2.4), + (4.5, 0.4), + ) + for edge in edges { + let (u, v) = edge + let selected = removed-edges.any(e => + (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u) + ) + g-edge( + verts.at(u), + verts.at(v), + stroke: if selected { 2pt + blue } else { 1pt + gray }, + ) + } + for (idx, pos) in verts.enumerate() { + g-node(pos, name: "v" + str(idx), label: [$v_#idx$]) + } + }), + caption: [Partial Feedback Edge Set example with $K = 3$ and $L = 4$. Blue edges $\{v_0, v_2\}$, $\{v_2, v_3\}$, and $\{v_3, v_4\}$ form a satisfying edge set that hits every cycle of length at most 4.], + ) + ] + ] +} + #{ let x = load-model-example("MultipleChoiceBranching") let nv = graph-num-vertices(x.instance) diff --git a/examples/export_module_graph.rs b/examples/export_module_graph.rs index c6d456df2..6245dc211 100644 --- a/examples/export_module_graph.rs +++ b/examples/export_module_graph.rs @@ -92,9 +92,7 @@ fn main() { for entry in inventory::iter:: { let display = module_display_path(entry.module_path); let category = module_category(entry.module_path).to_string(); - module_categories - .entry(display.clone()) - .or_insert(category); + module_categories.entry(display.clone()).or_insert(category); module_items.entry(display).or_default().push(ModuleItem { name: entry.display_name.to_string(), kind: "struct".to_string(), @@ -103,13 +101,21 @@ fn main() { } // Add well-known non-model modules with their key items - type ModuleSpec = (&'static str, &'static str, &'static [(&'static str, &'static str, &'static str)]); + type ModuleSpec = ( + &'static str, + &'static str, + &'static [(&'static str, &'static str, &'static str)], + ); let static_modules: &[ModuleSpec] = &[ ( "traits", "core", &[ - ("Problem", "trait", "Core trait for all computational problems"), + ( + "Problem", + "trait", + "Core trait for all computational problems", + ), ( "OptimizationProblem", "trait", @@ -140,11 +146,7 @@ fn main() { "variant", "core", &[ - ( - "VariantParam", - "trait", - "Trait for variant parameter types", - ), + ("VariantParam", "trait", "Trait for variant parameter types"), ( "CastToParent", "trait", @@ -188,16 +190,8 @@ fn main() { "struct", "Global graph of all registered reductions", ), - ( - "ReductionEntry", - "struct", - "A single registered reduction", - ), - ( - "VariantEntry", - "struct", - "A registered problem variant", - ), + ("ReductionEntry", "struct", "A single registered reduction"), + ("VariantEntry", "struct", "A registered problem variant"), ], ), ( @@ -205,11 +199,7 @@ fn main() { "solver", &[ ("BruteForce", "struct", "Exhaustive search solver"), - ( - "ILPSolver", - "struct", - "Integer linear programming solver", - ), + ("ILPSolver", "struct", "Integer linear programming solver"), ( "Solver", "trait", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f17067dfa..af87c7087 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -265,6 +265,7 @@ Flags by problem type: BicliqueCover --left, --right, --biedges, --k BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k BiconnectivityAugmentation --graph, --potential-edges, --budget [--num-vertices] + PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices] BMF --matrix (0/1), --rank ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k ConsecutiveOnesSubmatrix --matrix (0/1), --k @@ -612,6 +613,9 @@ pub struct CreateArgs { /// Total budget for selected potential edges #[arg(long)] pub budget: Option, + /// Maximum cycle length L for PartialFeedbackEdgeSet + #[arg(long)] + pub max_cycle_length: Option, /// Candidate weighted arcs for StrongConnectivityAugmentation (e.g., 2>0:1,2>1:3) #[arg(long)] pub candidate_arcs: Option, @@ -916,6 +920,44 @@ mod tests { assert!(help.contains("--budget")); } + #[test] + fn test_create_parses_partial_feedback_edge_set_flags() { + let cli = Cli::parse_from([ + "pred", + "create", + "PartialFeedbackEdgeSet", + "--graph", + "0-1,1-2,2-0", + "--budget", + "1", + "--max-cycle-length", + "3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert_eq!(args.problem.as_deref(), Some("PartialFeedbackEdgeSet")); + assert_eq!(args.graph.as_deref(), Some("0-1,1-2,2-0")); + assert_eq!(args.budget.as_deref(), Some("1")); + assert_eq!(args.max_cycle_length, Some(3)); + } + + #[test] + fn test_create_help_mentions_partial_feedback_edge_set_flags() { + let cmd = Cli::command(); + let create = cmd.find_subcommand("create").expect("create subcommand"); + let help = create + .get_after_help() + .expect("create after_help") + .to_string(); + + assert!(help.contains("PartialFeedbackEdgeSet")); + assert!(help.contains("--budget")); + assert!(help.contains("--max-cycle-length")); + } + #[test] fn test_create_help_mentions_stacker_crane_flags() { let cmd = Cli::command(); diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 4abe9ee30..3829c6015 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -142,6 +142,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.candidate_arcs.is_none() && args.potential_edges.is_none() && args.budget.is_none() + && args.max_cycle_length.is_none() && args.deadlines.is_none() && args.lengths.is_none() && args.precedence_pairs.is_none() @@ -587,6 +588,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "BiconnectivityAugmentation" => { "--graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5" } + "PartialFeedbackEdgeSet" => { + "--graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4" + } "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "NAESatisfiability" => "--num-vars 3 --clauses \"1,2,-3;-1,2,3\"", "QuantifiedBooleanFormulas" => { @@ -1351,6 +1355,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // Partial Feedback Edge Set + "PartialFeedbackEdgeSet" => { + let usage = "Usage: pred create PartialFeedbackEdgeSet --graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let budget = args + .budget + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}") + })? + .parse::() + .map_err(|e| { + anyhow::anyhow!( + "Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}" + ) + })?; + let max_cycle_length = args.max_cycle_length.ok_or_else(|| { + anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}") + })?; + ( + ser(PartialFeedbackEdgeSet::new(graph, budget, max_cycle_length))?, + resolved_variant.clone(), + ) + } + // Bounded Component Spanning Forest "BoundedComponentSpanningForest" => { let usage = "Usage: pred create BoundedComponentSpanningForest --graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6"; @@ -7200,6 +7229,7 @@ mod tests { distance_matrix: None, potential_edges: None, budget: None, + max_cycle_length: None, candidate_arcs: None, deadlines: None, precedence_pairs: None, @@ -7258,6 +7288,13 @@ mod tests { assert!(!all_data_flags_empty(&args)); } + #[test] + fn test_all_data_flags_empty_treats_max_cycle_length_as_input() { + let mut args = empty_args(); + args.max_cycle_length = Some(4); + assert!(!all_data_flags_empty(&args)); + } + #[test] fn test_all_data_flags_empty_treats_homologous_pairs_as_input() { let mut args = empty_args(); @@ -7458,6 +7495,61 @@ mod tests { std::fs::remove_file(output_path).ok(); } + #[test] + fn test_create_partial_feedback_edge_set_json() { + use problemreductions::models::graph::PartialFeedbackEdgeSet; + + let mut args = empty_args(); + args.problem = Some("PartialFeedbackEdgeSet".to_string()); + args.graph = Some("0-1,1-2,2-0".to_string()); + args.budget = Some("1".to_string()); + args.max_cycle_length = Some(3); + + let output_path = + std::env::temp_dir().join("pred_test_create_partial_feedback_edge_set.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "PartialFeedbackEdgeSet"); + assert_eq!(json["data"]["budget"], 1); + assert_eq!(json["data"]["max_cycle_length"], 3); + + let problem: PartialFeedbackEdgeSet = + serde_json::from_value(json["data"].clone()).unwrap(); + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.budget(), 1); + assert_eq!(problem.max_cycle_length(), 3); + + std::fs::remove_file(output_path).ok(); + } + + #[test] + fn test_create_partial_feedback_edge_set_requires_max_cycle_length() { + let mut args = empty_args(); + args.problem = Some("PartialFeedbackEdgeSet".to_string()); + args.graph = Some("0-1,1-2,2-0".to_string()); + args.budget = Some("1".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("PartialFeedbackEdgeSet requires --max-cycle-length")); + } + #[test] fn test_create_ensemble_computation_json() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index 217ff0a67..30c0d4729 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,10 +61,10 @@ pub mod prelude { MaximumMatching, MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, - MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, - PartitionIntoTriangles, PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, - ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, - UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, + MultipleCopyFileAllocation, OptimalLinearArrangement, PartialFeedbackEdgeSet, + PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow, + RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs, + TravelingSalesman, UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 69b4c01d4..71d258e05 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -34,6 +34,7 @@ //! - [`BottleneckTravelingSalesman`]: Hamiltonian cycle minimizing the maximum selected edge weight //! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) +//! - [`PartialFeedbackEdgeSet`]: Remove at most K edges to hit every short cycle //! - [`RootedTreeArrangement`]: Rooted-tree embedding with bounded total edge stretch //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinMaxMulticenter`]: Min-max multicenter (vertex p-center, satisfaction) @@ -94,6 +95,7 @@ pub(crate) mod mixed_chinese_postman; pub(crate) mod multiple_choice_branching; pub(crate) mod multiple_copy_file_allocation; pub(crate) mod optimal_linear_arrangement; +pub(crate) mod partial_feedback_edge_set; pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod partition_into_triangles; pub(crate) mod path_constrained_network_flow; @@ -149,6 +151,7 @@ pub use mixed_chinese_postman::MixedChinesePostman; pub use multiple_choice_branching::MultipleChoiceBranching; pub use multiple_copy_file_allocation::MultipleCopyFileAllocation; pub use optimal_linear_arrangement::OptimalLinearArrangement; +pub use partial_feedback_edge_set::PartialFeedbackEdgeSet; pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use partition_into_triangles::PartitionIntoTriangles; pub use path_constrained_network_flow::PathConstrainedNetworkFlow; @@ -219,6 +222,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec { + graph: G, + budget: usize, + max_cycle_length: usize, +} + +impl PartialFeedbackEdgeSet { + /// Create a new Partial Feedback Edge Set instance. + pub fn new(graph: G, budget: usize, max_cycle_length: usize) -> Self { + Self { + graph, + budget, + max_cycle_length, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the edge-removal budget `K`. + pub fn budget(&self) -> usize { + self.budget + } + + /// Get the cycle-length bound `L`. + pub fn max_cycle_length(&self) -> usize { + self.max_cycle_length + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check whether a configuration is a satisfying partial feedback edge set. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if config.len() != self.num_edges() || config.iter().any(|&value| value > 1) { + return false; + } + + let removed_edges = config.iter().filter(|&&value| value == 1).count(); + if removed_edges > self.budget { + return false; + } + + let kept_edges: Vec = config.iter().map(|&value| value == 0).collect(); + !has_cycle_with_length_at_most(&self.graph, &kept_edges, self.max_cycle_length) + } +} + +impl Problem for PartialFeedbackEdgeSet +where + G: Graph + crate::variant::VariantParam, +{ + const NAME: &'static str = "PartialFeedbackEdgeSet"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![2; self.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for PartialFeedbackEdgeSet where + G: Graph + crate::variant::VariantParam +{ +} + +fn has_cycle_with_length_at_most( + graph: &G, + kept_edges: &[bool], + max_cycle_length: usize, +) -> bool { + if kept_edges.len() != graph.num_edges() || max_cycle_length < 3 || graph.num_vertices() < 3 { + return false; + } + + let mut adjacency = vec![Vec::new(); graph.num_vertices()]; + for (keep, (u, v)) in kept_edges.iter().copied().zip(graph.edges()) { + if keep { + adjacency[u].push(v); + adjacency[v].push(u); + } + } + + let mut visited = vec![false; graph.num_vertices()]; + for start in 0..graph.num_vertices() { + visited[start] = true; + for &neighbor in &adjacency[start] { + if neighbor <= start { + continue; + } + visited[neighbor] = true; + if dfs_short_cycle( + &adjacency, + start, + neighbor, + 1, + max_cycle_length, + &mut visited, + ) { + return true; + } + visited[neighbor] = false; + } + visited[start] = false; + } + + false +} + +fn dfs_short_cycle( + adjacency: &[Vec], + start: usize, + current: usize, + path_length: usize, + max_cycle_length: usize, + visited: &mut [bool], +) -> bool { + for &neighbor in &adjacency[current] { + if neighbor == start { + let cycle_length = path_length + 1; + if cycle_length >= 3 && cycle_length <= max_cycle_length { + return true; + } + continue; + } + + if visited[neighbor] || neighbor <= start || path_length + 1 >= max_cycle_length { + continue; + } + + visited[neighbor] = true; + if dfs_short_cycle( + adjacency, + start, + neighbor, + path_length + 1, + max_cycle_length, + visited, + ) { + return true; + } + visited[neighbor] = false; + } + + false +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (2, 3), + (3, 4), + (4, 2), + (3, 5), + (5, 4), + (0, 3), + ], + ); + let chosen: BTreeSet<_> = [(0, 2), (2, 3), (3, 4)] + .into_iter() + .map(|(u, v)| normalize_edge(u, v)) + .collect(); + let optimal_config = graph + .edges() + .into_iter() + .map(|(u, v)| usize::from(chosen.contains(&normalize_edge(u, v)))) + .collect(); + + vec![crate::example_db::specs::ModelExampleSpec { + id: "partial_feedback_edge_set_simplegraph", + instance: Box::new(PartialFeedbackEdgeSet::new(graph, 3, 4)), + optimal_config, + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(any(feature = "example-db", test))] +fn normalize_edge(u: usize, v: usize) -> (usize, usize) { + if u <= v { + (u, v) + } else { + (v, u) + } +} + +crate::declare_variants! { + default sat PartialFeedbackEdgeSet => "2^num_edges", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/partial_feedback_edge_set.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index c5ac0fe93..0b463e8cc 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -28,10 +28,11 @@ pub use graph::{ MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MixedChinesePostman, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, - PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow, - RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, SpinGlass, SteinerTree, - SteinerTreeInGraphs, StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, - UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, + PartialFeedbackEdgeSet, PartitionIntoPathsOfLength2, PartitionIntoTriangles, + PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, + SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, + SubgraphIsomorphism, TravelingSalesman, UndirectedFlowLowerBounds, + UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/unit_tests/models/graph/partial_feedback_edge_set.rs b/src/unit_tests/models/graph/partial_feedback_edge_set.rs new file mode 100644 index 000000000..b721095f6 --- /dev/null +++ b/src/unit_tests/models/graph/partial_feedback_edge_set.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; + +fn issue_graph() -> SimpleGraph { + SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (2, 3), + (3, 4), + (4, 2), + (3, 5), + (5, 4), + (0, 3), + ], + ) +} + +fn yes_instance() -> PartialFeedbackEdgeSet { + PartialFeedbackEdgeSet::new(issue_graph(), 3, 4) +} + +fn no_instance() -> PartialFeedbackEdgeSet { + PartialFeedbackEdgeSet::new(issue_graph(), 2, 4) +} + +fn select_edges(graph: &G, selected_edges: &[(usize, usize)]) -> Vec { + let chosen: std::collections::BTreeSet<_> = selected_edges + .iter() + .copied() + .map(|(u, v)| super::normalize_edge(u, v)) + .collect(); + graph + .edges() + .into_iter() + .map(|(u, v)| usize::from(chosen.contains(&super::normalize_edge(u, v)))) + .collect() +} + +#[test] +fn test_partial_feedback_edge_set_creation() { + let problem = yes_instance(); + assert_eq!(problem.graph().num_vertices(), 6); + assert_eq!(problem.graph().num_edges(), 9); + assert_eq!(problem.budget(), 3); + assert_eq!(problem.max_cycle_length(), 4); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 9); + assert_eq!(problem.num_variables(), 9); + assert_eq!(problem.dims(), vec![2; 9]); +} + +#[test] +fn test_partial_feedback_edge_set_accepts_correct_issue_solution() { + let problem = yes_instance(); + let config = select_edges(problem.graph(), &[(0, 2), (2, 3), (3, 4)]); + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_partial_feedback_edge_set_rejects_under_budget_instance() { + let problem = no_instance(); + let config = select_edges(problem.graph(), &[(0, 2), (2, 3), (3, 4)]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partial_feedback_edge_set_rejects_missing_cycle_hit() { + let problem = yes_instance(); + let config = select_edges(problem.graph(), &[(0, 2), (3, 4), (3, 5)]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partial_feedback_edge_set_rejects_wrong_length_config() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 1, 0])); +} + +#[test] +fn test_partial_feedback_edge_set_rejects_non_binary_entries() { + let problem = yes_instance(); + let mut config = select_edges(problem.graph(), &[(0, 2), (2, 3), (3, 4)]); + config[0] = 2; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partial_feedback_edge_set_solver_yes_and_no_instances() { + let solver = BruteForce::new(); + + let yes_problem = yes_instance(); + let solution = solver.find_satisfying(&yes_problem).unwrap(); + assert!(yes_problem.evaluate(&solution)); + + let no_problem = no_instance(); + assert!(solver.find_satisfying(&no_problem).is_none()); +} + +#[test] +fn test_partial_feedback_edge_set_paper_example() { + let problem = yes_instance(); + let config = select_edges(problem.graph(), &[(0, 2), (2, 3), (3, 4)]); + assert!(problem.evaluate(&config)); + + let satisfying = BruteForce::new().find_all_satisfying(&problem); + assert_eq!(satisfying.len(), 5); + assert!(satisfying.iter().any(|candidate| candidate == &config)); +} + +#[test] +fn test_partial_feedback_edge_set_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let round_trip: PartialFeedbackEdgeSet = serde_json::from_str(&json).unwrap(); + assert_eq!(round_trip.num_vertices(), 6); + assert_eq!(round_trip.num_edges(), 9); + assert_eq!(round_trip.budget(), 3); + assert_eq!(round_trip.max_cycle_length(), 4); +}