From d7904861797e3c014c5a403cc52c97d6afa2aa28 Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 14:18:04 +0000 Subject: [PATCH 1/4] Add plan for #248: [Model] RuralPostman Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-rural-postman.md | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/plans/2026-03-12-rural-postman.md diff --git a/docs/plans/2026-03-12-rural-postman.md b/docs/plans/2026-03-12-rural-postman.md new file mode 100644 index 000000000..04b000110 --- /dev/null +++ b/docs/plans/2026-03-12-rural-postman.md @@ -0,0 +1,71 @@ +# Plan: Add RuralPostman Model (#248) + +## Overview + +Add the Rural Postman Problem (RPP) as a satisfaction problem. Given a graph G=(V,E) with edge lengths, a subset E' of required edges, and a bound B, determine if there exists a circuit covering all required edges with total length at most B. + +This is a **satisfaction problem** (Metric = bool, implements SatisfactionProblem). + +## Information Checklist + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `RuralPostman` | +| 2 | Mathematical definition | Given G=(V,E), lengths l(e), required edges E'⊆E, bound B, find circuit covering E' with length ≤ B | +| 3 | Problem type | Satisfaction (decision) | +| 4 | Type parameters | `G: Graph`, `W: WeightElement` | +| 5 | Struct fields | `graph: G`, `edge_lengths: Vec`, `required_edges: Vec`, `bound: W::Sum` | +| 6 | Configuration space | `vec![2; num_edges]` — binary per edge (selected or not) | +| 7 | Feasibility check | Selected edges form a closed walk covering all required edges with total length ≤ bound | +| 8 | Objective function | N/A (satisfaction: returns bool) | +| 9 | Best known exact algorithm | O(num_vertices^2 * 2^num_required_edges) DP over required edge subsets | +| 10 | Solving strategy | BruteForce (enumerate edge subsets, check feasibility) | +| 11 | Category | `graph` | + +## Implementation Steps + +### Step 1: Create model file +- File: `src/models/graph/rural_postman.rs` +- Struct `RuralPostman` with fields: graph, edge_lengths, required_edges, bound +- Constructor, accessors, size getters (num_vertices, num_edges, num_required_edges) +- Problem trait impl with Metric = bool +- SatisfactionProblem impl +- declare_variants! with complexity `num_vertices^2 * 2^num_required_edges` +- The evaluate function checks: config selects edges forming a closed walk that covers all required edges with total length ≤ bound +- Note: Since brute force enumerates binary edge configs (include/exclude each edge), the circuit is the multiset of selected edges. We need to verify the selected edges form an Eulerian subgraph (all vertices have even degree) that is connected (considering only vertices with degree > 0) and covers all required edges. + +### Step 2: Register the model +- Add `pub(crate) mod rural_postman;` and `pub use rural_postman::RuralPostman;` to `src/models/graph/mod.rs` +- Add `RuralPostman` to re-export in `src/models/mod.rs` +- Add `RuralPostman` to prelude in `src/lib.rs` + +### Step 3: Register in CLI dispatch +- Add `"RuralPostman"` arm in `load_problem()` using `deser_sat::>` +- Add `"RuralPostman"` arm in `serialize_any_problem()` using `try_ser::>` + +### Step 4: Register CLI alias +- Add `"ruralpostman" | "rpp"` mapping in `resolve_alias()` in `problem_name.rs` +- Add `("RPP", "RuralPostman")` to ALIASES array (RPP is a well-established abbreviation) + +### Step 5: Add CLI create support +- Add `"RuralPostman"` match arm in `commands/create.rs` +- Parse `--graph`, `--edge-weights`, `--required-edges` (new flag), `--bound` (new flag) +- Add `required_edges` and `bound` fields to `CreateArgs` in `cli.rs` +- Update `all_data_flags_empty()` to include new flags +- Update help table + +### Step 6: Write unit tests +- File: `src/unit_tests/models/graph/rural_postman.rs` +- Test creation and dimensions +- Test evaluate on valid circuit (YES instance from issue) +- Test evaluate on infeasible instance (NO instance) +- Test Chinese Postman special case (E'=E) +- Test brute force solver finds satisfying solution +- Test serialization round-trip + +### Step 7: Write paper entry +- Add `"RuralPostman": [Rural Postman],` to display-name dict +- Add problem-def entry with formal definition + +### Step 8: Verify +- `make test clippy` From 3017c7908c4904790d42b1239039649aa8121abd Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 14:27:37 +0000 Subject: [PATCH 2/4] Implement #248: Add RuralPostman model Add the Rural Postman Problem as a satisfaction problem: given a graph with edge lengths, a required subset of edges, and a bound B, determine if a circuit exists covering all required edges within the bound. - Model: src/models/graph/rural_postman.rs (SatisfactionProblem, Metric=bool) - 16 unit tests covering valid/invalid circuits, brute force, serialization - CLI: dispatch, alias (RPP), create handler with --required-edges/--bound - Paper: display-name + problem-def entry Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 9 + docs/src/reductions/problem_schemas.json | 26 ++ problemreductions-cli/src/cli.rs | 7 + problemreductions-cli/src/commands/create.rs | 37 +++ problemreductions-cli/src/dispatch.rs | 2 + problemreductions-cli/src/problem_name.rs | 2 + src/lib.rs | 2 +- src/models/graph/mod.rs | 3 + src/models/graph/rural_postman.rs | 271 +++++++++++++++++++ src/models/mod.rs | 3 +- src/unit_tests/models/graph/rural_postman.rs | 210 ++++++++++++++ 11 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 src/models/graph/rural_postman.rs create mode 100644 src/unit_tests/models/graph/rural_postman.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 915878827..49ff06518 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -52,6 +52,7 @@ "BicliqueCover": [Biclique Cover], "BinPacking": [Bin Packing], "ClosestVectorProblem": [Closest Vector Problem], + "RuralPostman": [Rural Postman], ) // Definition label: "def:" — each definition block must have a matching label @@ -895,6 +896,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 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. +] + // Completeness check: warn about problem types in JSON but missing from paper #{ let json-models = { diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 50d327e9b..5e3a7854f 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -360,6 +360,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_lengths", + "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 91e9bd252..804c9e6bc 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,6 +216,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 ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): @@ -326,6 +327,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, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 3594a24a0..52cf748b4 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -45,6 +45,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() } fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { @@ -83,6 +85,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "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" + } _ => "", } } @@ -210,6 +215,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 7a8498421..776b8aeb4 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -213,6 +213,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), @@ -270,6 +271,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 acd9b4b59..2a9ca55fd 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"), ("MaxMatching", "MaximumMatching"), ]; @@ -46,6 +47,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 b0d99699a..16c35ed5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumVertexCover, RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 1198f7fbc..059e6c126 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -12,6 +12,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 kcoloring; @@ -22,6 +23,7 @@ pub(crate) mod maximum_independent_set; pub(crate) mod maximum_matching; pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_vertex_cover; +pub(crate) mod rural_postman; pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; @@ -34,5 +36,6 @@ pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_vertex_cover::MinimumVertexCover; +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..009368dd6 --- /dev/null +++ b/src/models/graph/rural_postman.rs @@ -0,0 +1,271 @@ +//! 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_lengths", 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 binary variable: +/// - 0: edge is not in the circuit +/// - 1: edge is in the circuit +/// +/// A valid circuit requires: +/// - All required edges are selected (config[i] == 1 for i in required_edges) +/// - All vertices with selected edges have even degree (Eulerian condition) +/// - The selected edges form a connected subgraph +/// - Total length ≤ bound +/// +/// # 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. + 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 selected + for &req_idx in &self.required_edges { + if config[req_idx] != 1 { + return false; + } + } + + // Count selected edges and compute degree of each vertex + let mut degree = vec![0usize; n]; + let mut selected_count = 0usize; + for (idx, &sel) in config.iter().enumerate() { + if sel == 1 { + let (u, v) = edges[idx]; + degree[u] += 1; + degree[v] += 1; + selected_count += 1; + } + } + + // No edges selected: only valid if no required edges + if selected_count == 0 { + return self.required_edges.is_empty(); + } + + // All vertices with selected edges must have even degree (Eulerian condition) + for &d in °ree { + if d % 2 != 0 { + return false; + } + } + + // Selected edges 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, &sel) in config.iter().enumerate() { + if sel == 1 { + 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 + let mut total = W::Sum::zero(); + for (idx, &sel) in config.iter().enumerate() { + if sel == 1 { + 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![2; 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 96b4b79d1..72ae62e32 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,7 +13,8 @@ pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, - MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, TravelingSalesman, + MaximumMatching, MinimumDominatingSet, MinimumVertexCover, RuralPostman, SpinGlass, + TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop}; 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..30147cbc7 --- /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 == 2)); +} + +#[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); +} From 6121a9af180bfc2af0cf7e88bdf7c64d6f291ad5 Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 14:27:41 +0000 Subject: [PATCH 3/4] chore: remove plan file after implementation --- docs/plans/2026-03-12-rural-postman.md | 71 -------------------------- 1 file changed, 71 deletions(-) delete mode 100644 docs/plans/2026-03-12-rural-postman.md diff --git a/docs/plans/2026-03-12-rural-postman.md b/docs/plans/2026-03-12-rural-postman.md deleted file mode 100644 index 04b000110..000000000 --- a/docs/plans/2026-03-12-rural-postman.md +++ /dev/null @@ -1,71 +0,0 @@ -# Plan: Add RuralPostman Model (#248) - -## Overview - -Add the Rural Postman Problem (RPP) as a satisfaction problem. Given a graph G=(V,E) with edge lengths, a subset E' of required edges, and a bound B, determine if there exists a circuit covering all required edges with total length at most B. - -This is a **satisfaction problem** (Metric = bool, implements SatisfactionProblem). - -## Information Checklist - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `RuralPostman` | -| 2 | Mathematical definition | Given G=(V,E), lengths l(e), required edges E'⊆E, bound B, find circuit covering E' with length ≤ B | -| 3 | Problem type | Satisfaction (decision) | -| 4 | Type parameters | `G: Graph`, `W: WeightElement` | -| 5 | Struct fields | `graph: G`, `edge_lengths: Vec`, `required_edges: Vec`, `bound: W::Sum` | -| 6 | Configuration space | `vec![2; num_edges]` — binary per edge (selected or not) | -| 7 | Feasibility check | Selected edges form a closed walk covering all required edges with total length ≤ bound | -| 8 | Objective function | N/A (satisfaction: returns bool) | -| 9 | Best known exact algorithm | O(num_vertices^2 * 2^num_required_edges) DP over required edge subsets | -| 10 | Solving strategy | BruteForce (enumerate edge subsets, check feasibility) | -| 11 | Category | `graph` | - -## Implementation Steps - -### Step 1: Create model file -- File: `src/models/graph/rural_postman.rs` -- Struct `RuralPostman` with fields: graph, edge_lengths, required_edges, bound -- Constructor, accessors, size getters (num_vertices, num_edges, num_required_edges) -- Problem trait impl with Metric = bool -- SatisfactionProblem impl -- declare_variants! with complexity `num_vertices^2 * 2^num_required_edges` -- The evaluate function checks: config selects edges forming a closed walk that covers all required edges with total length ≤ bound -- Note: Since brute force enumerates binary edge configs (include/exclude each edge), the circuit is the multiset of selected edges. We need to verify the selected edges form an Eulerian subgraph (all vertices have even degree) that is connected (considering only vertices with degree > 0) and covers all required edges. - -### Step 2: Register the model -- Add `pub(crate) mod rural_postman;` and `pub use rural_postman::RuralPostman;` to `src/models/graph/mod.rs` -- Add `RuralPostman` to re-export in `src/models/mod.rs` -- Add `RuralPostman` to prelude in `src/lib.rs` - -### Step 3: Register in CLI dispatch -- Add `"RuralPostman"` arm in `load_problem()` using `deser_sat::>` -- Add `"RuralPostman"` arm in `serialize_any_problem()` using `try_ser::>` - -### Step 4: Register CLI alias -- Add `"ruralpostman" | "rpp"` mapping in `resolve_alias()` in `problem_name.rs` -- Add `("RPP", "RuralPostman")` to ALIASES array (RPP is a well-established abbreviation) - -### Step 5: Add CLI create support -- Add `"RuralPostman"` match arm in `commands/create.rs` -- Parse `--graph`, `--edge-weights`, `--required-edges` (new flag), `--bound` (new flag) -- Add `required_edges` and `bound` fields to `CreateArgs` in `cli.rs` -- Update `all_data_flags_empty()` to include new flags -- Update help table - -### Step 6: Write unit tests -- File: `src/unit_tests/models/graph/rural_postman.rs` -- Test creation and dimensions -- Test evaluate on valid circuit (YES instance from issue) -- Test evaluate on infeasible instance (NO instance) -- Test Chinese Postman special case (E'=E) -- Test brute force solver finds satisfying solution -- Test serialization round-trip - -### Step 7: Write paper entry -- Add `"RuralPostman": [Rural Postman],` to display-name dict -- Add problem-def entry with formal definition - -### Step 8: Verify -- `make test clippy` From 54ac295681e4cf10325b26a91cc2f7c06cd1cccd Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 19:39:54 +0800 Subject: [PATCH 4/4] Fix Copilot review comments on RuralPostman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rename schema field edge_lengths → edge_weights for CLI consistency 2. Fix subset symbol inconsistency in paper (subset → subset.eq) 3. Allow edge multiplicity {0,1,2} instead of binary selection to correctly model circuits that traverse edges multiple times (RPP semantics) Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 2 +- docs/src/reductions/problem_schemas.json | 13 ++++- src/models/graph/rural_postman.rs | 60 +++++++++++--------- src/unit_tests/models/graph/rural_postman.rs | 2 +- 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 168e42328..929c5a2ed 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -985,7 +985,7 @@ Biclique Cover is equivalent to factoring the biadjacency matrix $M$ of the bipa #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 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. + 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. ] diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 5f074d44f..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", @@ -397,7 +408,7 @@ "description": "The underlying graph G=(V,E)" }, { - "name": "edge_lengths", + "name": "edge_weights", "type_name": "Vec", "description": "Edge lengths l(e) for each e in E" }, diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 009368dd6..5613258de 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -19,7 +19,7 @@ inventory::submit! { 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_lengths", type_name: "Vec", description: "Edge lengths l(e) for each e in 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" }, ], @@ -35,15 +35,19 @@ inventory::submit! { /// /// # Representation /// -/// Each edge is assigned a binary variable: -/// - 0: edge is not in the circuit -/// - 1: edge is in the circuit +/// 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 are selected (config[i] == 1 for i in required_edges) -/// - All vertices with selected edges have even degree (Eulerian condition) -/// - The selected edges form a connected subgraph -/// - Total length ≤ bound +/// - 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 /// @@ -142,6 +146,8 @@ impl RuralPostman { /// 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; @@ -150,43 +156,43 @@ impl RuralPostman { let edges = self.graph.edges(); let n = self.graph.num_vertices(); - // Check all required edges are selected + // Check all required edges are traversed at least once for &req_idx in &self.required_edges { - if config[req_idx] != 1 { + if config[req_idx] == 0 { return false; } } - // Count selected edges and compute degree of each vertex + // Compute degree of each vertex (sum of multiplicities of incident edges) let mut degree = vec![0usize; n]; - let mut selected_count = 0usize; - for (idx, &sel) in config.iter().enumerate() { - if sel == 1 { + let mut has_edges = false; + for (idx, &mult) in config.iter().enumerate() { + if mult > 0 { let (u, v) = edges[idx]; - degree[u] += 1; - degree[v] += 1; - selected_count += 1; + degree[u] += mult; + degree[v] += mult; + has_edges = true; } } - // No edges selected: only valid if no required edges - if selected_count == 0 { + // No edges used: only valid if no required edges + if !has_edges { return self.required_edges.is_empty(); } - // All vertices with selected edges must have even degree (Eulerian condition) + // All vertices must have even degree (Eulerian condition) for &d in °ree { if d % 2 != 0 { return false; } } - // Selected edges must form a connected subgraph + // 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, &sel) in config.iter().enumerate() { - if sel == 1 { + for (idx, &mult) in config.iter().enumerate() { + if mult > 0 { let (u, v) = edges[idx]; adj[u].push(v); adj[v].push(u); @@ -222,10 +228,10 @@ impl RuralPostman { } } - // Check total length ≤ bound + // Check total length ≤ bound (sum of multiplicity × edge length) let mut total = W::Sum::zero(); - for (idx, &sel) in config.iter().enumerate() { - if sel == 1 { + for (idx, &mult) in config.iter().enumerate() { + for _ in 0..mult { total += self.edge_lengths[idx].to_sum(); } } @@ -247,7 +253,7 @@ where } fn dims(&self) -> Vec { - vec![2; self.graph.num_edges()] + vec![3; self.graph.num_edges()] } fn evaluate(&self, config: &[usize]) -> bool { diff --git a/src/unit_tests/models/graph/rural_postman.rs b/src/unit_tests/models/graph/rural_postman.rs index 30147cbc7..d51428e86 100644 --- a/src/unit_tests/models/graph/rural_postman.rs +++ b/src/unit_tests/models/graph/rural_postman.rs @@ -43,7 +43,7 @@ fn test_rural_postman_creation() { 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 == 2)); + assert!(problem.dims().iter().all(|&d| d == 3)); } #[test]