diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 7ab3b569d..929c5a2ed 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -53,6 +53,7 @@ "BicliqueCover": [Biclique Cover], "BinPacking": [Bin Packing], "ClosestVectorProblem": [Closest Vector Problem], + "RuralPostman": [Rural Postman], "LongestCommonSubsequence": [Longest Common Subsequence], "SubsetSum": [Subset Sum], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], @@ -981,6 +982,14 @@ Biclique Cover is equivalent to factoring the biadjacency matrix $M$ of the bipa *Example.* Let $n = 4$ items with weights $(2, 3, 4, 5)$, values $(3, 4, 5, 7)$, and capacity $C = 7$. Selecting $S = {1, 2}$ (items with weights 3 and 4) gives total weight $3 + 4 = 7 lt.eq C$ and total value $4 + 5 = 9$. Selecting $S = {0, 3}$ (weights 2 and 5) gives weight $2 + 5 = 7 lt.eq C$ and value $3 + 7 = 10$, which is optimal. ] +#problem-def("RuralPostman")[ + Given an undirected graph $G = (V, E)$ with edge lengths $l: E -> ZZ_(gt.eq 0)$, a subset $E' subset.eq E$ of required edges, and a bound $B in ZZ^+$, determine whether there exists a circuit (closed walk) in $G$ that traverses every edge in $E'$ and has total length at most $B$. +][ + The Rural Postman Problem (RPP) is a fundamental NP-complete arc-routing problem @lenstra1976 that generalizes the Chinese Postman Problem. When $E' = E$, the problem reduces to finding an Eulerian circuit with minimum augmentation (polynomial-time solvable via $T$-join matching). For general $E' subset.eq E$, exact algorithms use dynamic programming over subsets of required edges in $O(n^2 dot 2^r)$ time, where $r = |E'|$ and $n = |V|$, analogous to the Held-Karp algorithm for TSP. The problem admits a $3 slash 2$-approximation for metric instances @frederickson1979. + + *Example.* Consider a hexagonal graph with 6 vertices and 8 edges, where all outer edges have length 1 and two diagonal edges have length 2. The required edges are $E' = {(v_0, v_1), (v_2, v_3), (v_4, v_5)}$ with bound $B = 6$. The outer cycle $v_0 -> v_1 -> v_2 -> v_3 -> v_4 -> v_5 -> v_0$ covers all three required edges with total length $6 times 1 = 6 = B$, so the answer is YES. +] + #problem-def("LongestCommonSubsequence")[ Given $k$ strings $s_1, dots, s_k$ over a finite alphabet $Sigma$, find a longest string $w$ that is a subsequence of every $s_i$. A string $w$ is a _subsequence_ of $s$ if $w$ can be obtained by deleting zero or more characters from $s$ without changing the order of the remaining characters. ][ diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index ef694f9de..e688df7c1 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -195,6 +195,17 @@ } ] }, + { + "name": "LongestCommonSubsequence", + "description": "Find the longest string that is a subsequence of every input string", + "fields": [ + { + "name": "strings", + "type_name": "Vec>", + "description": "The input strings" + } + ] + }, { "name": "MaxCut", "description": "Find maximum weight cut in a graph", @@ -387,6 +398,32 @@ } ] }, + { + "name": "RuralPostman", + "description": "Find a circuit covering required edges with total length at most B (Rural Postman Problem)", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "edge_weights", + "type_name": "Vec", + "description": "Edge lengths l(e) for each e in E" + }, + { + "name": "required_edges", + "type_name": "Vec", + "description": "Edge indices of the required subset E' ⊆ E" + }, + { + "name": "bound", + "type_name": "W::Sum", + "description": "Upper bound B on total circuit length" + } + ] + }, { "name": "Satisfiability", "description": "Find satisfying assignment for CNF formula", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 80cc838d1..21e79f032 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -219,6 +219,7 @@ Flags by problem type: BicliqueCover --left, --right, --biedges, --k BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] + RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound LCS --strings FVS --arcs [--weights] [--num-vertices] ILP, CircuitSAT (via reduction only) @@ -332,6 +333,12 @@ pub struct CreateArgs { /// Variable bounds for CVP as "lower,upper" (e.g., "-10,10") [default: -10,10] #[arg(long, allow_hyphen_values = true)] pub bounds: Option, + /// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4") + #[arg(long)] + pub required_edges: Option, + /// Upper bound B for RuralPostman + #[arg(long)] + pub bound: Option, /// Input strings for LCS (semicolon-separated, e.g., "ABAC;BACA") #[arg(long)] pub strings: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index bb6da7544..c7c71f550 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -47,6 +47,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.basis.is_none() && args.target_vec.is_none() && args.bounds.is_none() + && args.required_edges.is_none() + && args.bound.is_none() && args.strings.is_none() && args.arcs.is_none() } @@ -91,6 +93,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", "Factoring" => "--target 15 --m 4 --n 4", + "RuralPostman" => { + "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" + } "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", _ => "", } @@ -232,6 +237,38 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (data, resolved_variant.clone()) } + // RuralPostman + "RuralPostman" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2 --bound 6" + ) + })?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + let required_edges_str = args.required_edges.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "RuralPostman requires --required-edges\n\n\ + Usage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2 --bound 6" + ) + })?; + let required_edges: Vec = util::parse_comma_list(required_edges_str)?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "RuralPostman requires --bound\n\n\ + Usage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2 --bound 6" + ) + })?; + ( + ser(RuralPostman::new( + graph, + edge_weights, + required_edges, + bound, + ))?, + resolved_variant.clone(), + ) + } + // KColoring "KColoring" => { let (graph, _) = parse_graph(args).map_err(|e| { diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 0c8dc38c8..967e32499 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -214,6 +214,7 @@ pub fn load_problem( "MaxCut" => deser_opt::>(data), "MaximalIS" => deser_opt::>(data), "TravelingSalesman" => deser_opt::>(data), + "RuralPostman" => deser_sat::>(data), "KColoring" => match variant.get("k").map(|s| s.as_str()) { Some("K3") => deser_sat::>(data), _ => deser_sat::>(data), @@ -276,6 +277,7 @@ pub fn serialize_any_problem( "MaxCut" => try_ser::>(any), "MaximalIS" => try_ser::>(any), "TravelingSalesman" => try_ser::>(any), + "RuralPostman" => try_ser::>(any), "KColoring" => match variant.get("k").map(|s| s.as_str()) { Some("K3") => try_ser::>(any), _ => try_ser::>(any), diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 7d9c1584d..9e6e2b8bc 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -20,6 +20,7 @@ pub const ALIASES: &[(&str, &str)] = &[ ("KSAT", "KSatisfiability"), ("TSP", "TravelingSalesman"), ("CVP", "ClosestVectorProblem"), + ("RPP", "RuralPostman"), ("LCS", "LongestCommonSubsequence"), ("MaxMatching", "MaximumMatching"), ("FVS", "MinimumFeedbackVertexSet"), @@ -49,6 +50,7 @@ pub fn resolve_alias(input: &str) -> String { "kcoloring" => "KColoring".to_string(), "maximalis" => "MaximalIS".to_string(), "travelingsalesman" | "tsp" => "TravelingSalesman".to_string(), + "ruralpostman" | "rpp" => "RuralPostman".to_string(), "paintshop" => "PaintShop".to_string(), "bmf" => "BMF".to_string(), "bicliquecover" => "BicliqueCover".to_string(), diff --git a/src/lib.rs b/src/lib.rs index aa5fb4087..8f8e83fd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ pub mod prelude { pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, - PartitionIntoTriangles, TravelingSalesman, + PartitionIntoTriangles, RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 9d34aab1c..33c952869 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -15,6 +15,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`BicliqueCover`]: Biclique cover on bipartite graphs +//! - [`RuralPostman`]: Rural Postman (circuit covering required edges) pub(crate) mod biclique_cover; pub(crate) mod graph_partitioning; @@ -28,6 +29,7 @@ pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_vertex_cover; pub(crate) mod partition_into_triangles; +pub(crate) mod rural_postman; pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; @@ -43,5 +45,6 @@ pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_vertex_cover::MinimumVertexCover; pub use partition_into_triangles::PartitionIntoTriangles; +pub use rural_postman::RuralPostman; pub use spin_glass::SpinGlass; pub use traveling_salesman::TravelingSalesman; diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs new file mode 100644 index 000000000..5613258de --- /dev/null +++ b/src/models/graph/rural_postman.rs @@ -0,0 +1,277 @@ +//! Rural Postman problem implementation. +//! +//! The Rural Postman problem asks whether there exists a circuit in a graph +//! that includes each edge in a required subset E' and has total length +//! at most a given bound B. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "RuralPostman", + module_path: module_path!(), + description: "Find a circuit covering required edges with total length at most B (Rural Postman Problem)", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge lengths l(e) for each e in E" }, + FieldInfo { name: "required_edges", type_name: "Vec", description: "Edge indices of the required subset E' ⊆ E" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on total circuit length" }, + ], + } +} + +/// The Rural Postman problem. +/// +/// Given a weighted graph G = (V, E) with edge lengths l(e), +/// a subset E' ⊆ E of required edges, and a bound B, +/// determine if there exists a circuit (closed walk) in G that +/// includes each edge in E' and has total length at most B. +/// +/// # Representation +/// +/// Each edge is assigned a multiplicity variable: +/// - 0: edge is not traversed +/// - 1: edge is traversed once +/// - 2: edge is traversed twice +/// +/// A valid circuit requires: +/// - All required edges have multiplicity ≥ 1 +/// - All vertices have even degree (sum of multiplicities of incident edges) +/// - Edges with multiplicity > 0 form a connected subgraph +/// - Total length (sum of multiplicity × edge length) ≤ bound +/// +/// Note: In an optimal RPP solution on undirected graphs, each edge is +/// traversed at most twice, so multiplicity ∈ {0, 1, 2} is sufficient. +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// * `W` - The weight type for edge lengths (e.g., `i32`, `f64`) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuralPostman { + /// The underlying graph. + graph: G, + /// Lengths for each edge (in edge index order). + edge_lengths: Vec, + /// Indices of required edges (subset E' ⊆ E). + required_edges: Vec, + /// Upper bound B on total circuit length. + bound: W::Sum, +} + +impl RuralPostman { + /// Create a new RuralPostman problem. + /// + /// # Panics + /// Panics if edge_lengths length does not match graph edges, + /// or if any required edge index is out of bounds. + pub fn new(graph: G, edge_lengths: Vec, required_edges: Vec, bound: W::Sum) -> Self { + assert_eq!( + edge_lengths.len(), + graph.num_edges(), + "edge_lengths length must match num_edges" + ); + for &idx in &required_edges { + assert!( + idx < graph.num_edges(), + "required edge index {} out of bounds (graph has {} edges)", + idx, + graph.num_edges() + ); + } + Self { + graph, + edge_lengths, + required_edges, + bound, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the edge lengths. + pub fn edge_lengths(&self) -> &[W] { + &self.edge_lengths + } + + /// Get the required edge indices. + pub fn required_edges(&self) -> &[usize] { + &self.required_edges + } + + /// Get the bound B. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Get the number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Get the number of required edges. + pub fn num_required_edges(&self) -> usize { + self.required_edges.len() + } + + /// Set new edge lengths. + pub fn set_weights(&mut self, weights: Vec) { + assert_eq!(weights.len(), self.graph.num_edges()); + self.edge_lengths = weights; + } + + /// Get the edge lengths as a Vec. + pub fn weights(&self) -> Vec { + self.edge_lengths.clone() + } + + /// Check if the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Check if a configuration represents a valid circuit covering all required edges + /// with total length at most the bound. + /// + /// Each `config[i]` is the multiplicity (number of traversals) of edge `i`. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if config.len() != self.graph.num_edges() { + return false; + } + + let edges = self.graph.edges(); + let n = self.graph.num_vertices(); + + // Check all required edges are traversed at least once + for &req_idx in &self.required_edges { + if config[req_idx] == 0 { + return false; + } + } + + // Compute degree of each vertex (sum of multiplicities of incident edges) + let mut degree = vec![0usize; n]; + let mut has_edges = false; + for (idx, &mult) in config.iter().enumerate() { + if mult > 0 { + let (u, v) = edges[idx]; + degree[u] += mult; + degree[v] += mult; + has_edges = true; + } + } + + // No edges used: only valid if no required edges + if !has_edges { + return self.required_edges.is_empty(); + } + + // All vertices must have even degree (Eulerian condition) + for &d in °ree { + if d % 2 != 0 { + return false; + } + } + + // Edges with multiplicity > 0 must form a connected subgraph + // (considering only vertices with degree > 0) + let mut adj: Vec> = vec![vec![]; n]; + let mut first_vertex = None; + for (idx, &mult) in config.iter().enumerate() { + if mult > 0 { + let (u, v) = edges[idx]; + adj[u].push(v); + adj[v].push(u); + if first_vertex.is_none() { + first_vertex = Some(u); + } + } + } + + let first = match first_vertex { + Some(v) => v, + None => return self.required_edges.is_empty(), + }; + + let mut visited = vec![false; n]; + let mut queue = VecDeque::new(); + visited[first] = true; + queue.push_back(first); + + while let Some(node) = queue.pop_front() { + for &neighbor in &adj[node] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + // All vertices with degree > 0 must be visited + for v in 0..n { + if degree[v] > 0 && !visited[v] { + return false; + } + } + + // Check total length ≤ bound (sum of multiplicity × edge length) + let mut total = W::Sum::zero(); + for (idx, &mult) in config.iter().enumerate() { + for _ in 0..mult { + total += self.edge_lengths[idx].to_sum(); + } + } + + total <= self.bound + } +} + +impl Problem for RuralPostman +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "RuralPostman"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![3; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for RuralPostman +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ +} + +crate::declare_variants! { + RuralPostman => "num_vertices^2 * 2^num_required_edges", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/rural_postman.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index afb331c11..a30413581 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,7 +14,7 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, - MinimumVertexCover, PartitionIntoTriangles, SpinGlass, TravelingSalesman, + MinimumVertexCover, PartitionIntoTriangles, RuralPostman, SpinGlass, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/rural_postman.rs b/src/unit_tests/models/graph/rural_postman.rs new file mode 100644 index 000000000..d51428e86 --- /dev/null +++ b/src/unit_tests/models/graph/rural_postman.rs @@ -0,0 +1,210 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +/// Instance 1 from issue: hexagonal graph with 3 required edges, B=6 +fn hexagon_rpp() -> RuralPostman { + // 6 vertices, 8 edges + // Edges: {0,1}:1, {1,2}:1, {2,3}:1, {3,4}:1, {4,5}:1, {5,0}:1, {0,3}:2, {1,4}:2 + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + ], + ); + let edge_lengths = vec![1, 1, 1, 1, 1, 1, 2, 2]; + // Required edges: {0,1}=idx 0, {2,3}=idx 2, {4,5}=idx 4 + let required_edges = vec![0, 2, 4]; + let bound = 6; + RuralPostman::new(graph, edge_lengths, required_edges, bound) +} + +/// Instance 3 from issue: C4 cycle, all edges required (Chinese Postman), B=4 +fn chinese_postman_rpp() -> RuralPostman { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)]); + let edge_lengths = vec![1, 1, 1, 1]; + let required_edges = vec![0, 1, 2, 3]; + let bound = 4; + RuralPostman::new(graph, edge_lengths, required_edges, bound) +} + +#[test] +fn test_rural_postman_creation() { + let problem = hexagon_rpp(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 8); + assert_eq!(problem.num_required_edges(), 3); + assert_eq!(problem.dims().len(), 8); + assert!(problem.dims().iter().all(|&d| d == 3)); +} + +#[test] +fn test_rural_postman_accessors() { + let problem = hexagon_rpp(); + assert_eq!(problem.graph().num_vertices(), 6); + assert_eq!(problem.edge_lengths().len(), 8); + assert_eq!(problem.required_edges(), &[0, 2, 4]); + assert_eq!(*problem.bound(), 6); + assert!(problem.is_weighted()); +} + +#[test] +fn test_rural_postman_valid_circuit() { + let problem = hexagon_rpp(); + // Circuit: 0->1->2->3->4->5->0 uses edges 0,1,2,3,4,5 (the hexagon) + // Total length = 6 * 1 = 6 = B, covers all required edges + let config = vec![1, 1, 1, 1, 1, 1, 0, 0]; + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_missing_required_edge() { + let problem = hexagon_rpp(); + // Select edges but miss required edge 4 ({4,5}) + let config = vec![1, 1, 1, 1, 0, 1, 0, 0]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_odd_degree() { + let problem = hexagon_rpp(); + // Select edges 0,2,4 only (the 3 required edges) — disconnected, odd degree + let config = vec![1, 0, 1, 0, 1, 0, 0, 0]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_exceeds_bound() { + // Same graph but with tight bound + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + ], + ); + let edge_lengths = vec![1, 1, 1, 1, 1, 1, 2, 2]; + let required_edges = vec![0, 2, 4]; + let bound = 5; // Too tight — the hexagon cycle costs 6 + let problem = RuralPostman::new(graph, edge_lengths, required_edges, bound); + // Hexagon cycle costs 6 > 5 + let config = vec![1, 1, 1, 1, 1, 1, 0, 0]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_chinese_postman_case() { + let problem = chinese_postman_rpp(); + // Select all edges in the C4 cycle: valid Eulerian circuit, length 4 = B + let config = vec![1, 1, 1, 1]; + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_no_edges_no_required() { + // No required edges, bound 0 — selecting no edges is valid (empty circuit) + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let edge_lengths = vec![1, 1, 1]; + let required_edges = vec![]; + let bound = 0; + let problem = RuralPostman::new(graph, edge_lengths, required_edges, bound); + let config = vec![0, 0, 0]; + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_disconnected_selection() { + // Select two disconnected triangles — even degree but not connected + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)]); + let edge_lengths = vec![1, 1, 1, 1, 1, 1]; + let required_edges = vec![0, 3]; // edges in different components + let bound = 100; + let problem = RuralPostman::new(graph, edge_lengths, required_edges, bound); + // Select both triangles: even degree but disconnected + let config = vec![1, 1, 1, 1, 1, 1]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_rural_postman_brute_force_finds_solution() { + let problem = chinese_postman_rpp(); + let solver = BruteForce::new(); + let result = solver.find_satisfying(&problem); + assert!(result.is_some()); + let sol = result.unwrap(); + assert!(problem.evaluate(&sol)); +} + +#[test] +fn test_rural_postman_brute_force_hexagon() { + let problem = hexagon_rpp(); + let solver = BruteForce::new(); + let result = solver.find_satisfying(&problem); + assert!(result.is_some()); + let sol = result.unwrap(); + assert!(problem.evaluate(&sol)); +} + +#[test] +fn test_rural_postman_brute_force_no_solution() { + // Instance 2 from issue: no feasible circuit with B=4 + let graph = SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (2, 3), (3, 0), (3, 4), (4, 5), (5, 3)], + ); + let edge_lengths = vec![1, 1, 1, 1, 3, 1, 3]; + let required_edges = vec![0, 5]; // {0,1} and {4,5} + let bound = 4; + let problem = RuralPostman::new(graph, edge_lengths, required_edges, bound); + let solver = BruteForce::new(); + let result = solver.find_satisfying(&problem); + assert!(result.is_none()); +} + +#[test] +fn test_rural_postman_serialization() { + let problem = chinese_postman_rpp(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: RuralPostman = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_vertices(), problem.num_vertices()); + assert_eq!(restored.num_edges(), problem.num_edges()); + assert_eq!(restored.num_required_edges(), problem.num_required_edges()); + assert_eq!(restored.required_edges(), problem.required_edges()); +} + +#[test] +fn test_rural_postman_problem_name() { + assert_eq!( + as Problem>::NAME, + "RuralPostman" + ); +} + +#[test] +fn test_rural_postman_set_weights() { + let mut problem = chinese_postman_rpp(); + problem.set_weights(vec![2, 2, 2, 2]); + assert_eq!(problem.weights(), vec![2, 2, 2, 2]); +} + +#[test] +fn test_rural_postman_size_getters() { + let problem = hexagon_rpp(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 8); + assert_eq!(problem.num_required_edges(), 3); +}