diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 2006b57b1..c34812b5b 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -58,6 +58,7 @@ "SubsetSum": [Subset Sum], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "MinimumSumMulticenter": [Minimum Sum Multicenter], "SubgraphIsomorphism": [Subgraph Isomorphism], "SubsetSum": [Subset Sum], ) @@ -575,6 +576,16 @@ caption: [A directed graph with FVS $S = {v_0}$ (blue, $w(S) = 1$). Removing $v_ ) ] +#problem-def("MinimumSumMulticenter")[ + Given a graph $G = (V, E)$ with vertex weights $w: V -> ZZ_(>= 0)$, edge lengths $l: E -> ZZ_(>= 0)$, and a positive integer $K <= |V|$, find a set $P subset.eq V$ of $K$ vertices (centers) that minimizes the total weighted distance $sum_(v in V) w(v) dot d(v, P)$, where $d(v, P) = min_(p in P) d(v, p)$ is the shortest-path distance from $v$ to the nearest center in $P$. +][ +Also known as the _p-median problem_. This is a classical NP-complete facility location problem from Garey & Johnson (A2 ND51). The goal is to optimally place $K$ service centers (e.g., warehouses, hospitals) to minimize total service cost. NP-completeness was established by Kariv and Hakimi (1979) via transformation from Dominating Set. The problem remains NP-complete even with unit weights and unit edge lengths, but is solvable in polynomial time for fixed $K$ or when $G$ is a tree. + +The best known exact algorithm runs in $O^*(2^n)$ time by brute-force enumeration of all $binom(n, K)$ vertex subsets. Constant-factor approximation algorithms exist: Charikar et al. (1999) gave the first constant-factor result, and the best known ratio is $(2 + epsilon)$ by Cohen-Addad et al. (STOC 2022). + +Variables: $n = |V|$ binary variables, one per vertex. $x_v = 1$ if vertex $v$ is selected as a center. A configuration is valid when exactly $K$ centers are selected and all vertices are reachable from at least one center. +] + == Set Problems #problem-def("MaximumSetPacking")[ diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 793fcc1f1..6358e5906 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -355,6 +355,32 @@ } ] }, + { + "name": "MinimumSumMulticenter", + "description": "Find K centers minimizing total weighted distance (p-median problem)", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "vertex_weights", + "type_name": "Vec", + "description": "Vertex weights w: V -> R" + }, + { + "name": "edge_lengths", + "type_name": "Vec", + "description": "Edge lengths l: E -> R" + }, + { + "name": "k", + "type_name": "usize", + "description": "Number of centers to place" + } + ] + }, { "name": "MinimumVertexCover", "description": "Find minimum weight vertex cover in a graph", @@ -382,6 +408,17 @@ } ] }, + { + "name": "PartitionIntoTriangles", + "description": "Partition vertices into triangles (K3 subgraphs)", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E) with |V| divisible by 3" + } + ] + }, { "name": "QUBO", "description": "Minimize quadratic unconstrained binary objective", diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index d405d7339..9c66facc9 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -93,6 +93,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "QUBO" => "--matrix \"1,0.5;0.5,2\"", "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", + "MinimumSumMulticenter" => "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2", "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", "Factoring" => "--target 15 --m 4 --n 4", "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", @@ -564,6 +565,32 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumSumMulticenter (p-median) + "MinimumSumMulticenter" => { + let (graph, n) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create MinimumSumMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2" + ) + })?; + let vertex_weights = parse_vertex_weights(args, n)?; + let edge_lengths = parse_edge_weights(args, graph.num_edges())?; + let k = args.k.ok_or_else(|| { + anyhow::anyhow!( + "MinimumSumMulticenter requires --k (number of centers)\n\n\ + Usage: pred create MinimumSumMulticenter --graph 0-1,1-2,2-3 --k 2" + ) + })?; + ( + ser(MinimumSumMulticenter::new( + graph, + vertex_weights, + edge_lengths, + k, + ))?, + resolved_variant.clone(), + ) + } + // SubgraphIsomorphism "SubgraphIsomorphism" => { let (host_graph, _) = parse_graph(args).map_err(|e| { diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 6291ce928..5d7f71cda 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -210,6 +210,7 @@ pub fn load_problem( "MaximumClique" => deser_opt::>(data), "MaximumMatching" => deser_opt::>(data), "MinimumDominatingSet" => deser_opt::>(data), + "MinimumSumMulticenter" => deser_opt::>(data), "GraphPartitioning" => deser_opt::>(data), "MaxCut" => deser_opt::>(data), "MaximalIS" => deser_opt::>(data), @@ -275,6 +276,7 @@ pub fn serialize_any_problem( "MaximumClique" => try_ser::>(any), "MaximumMatching" => try_ser::>(any), "MinimumDominatingSet" => try_ser::>(any), + "MinimumSumMulticenter" => try_ser::>(any), "GraphPartitioning" => try_ser::>(any), "MaxCut" => try_ser::>(any), "MaximalIS" => try_ser::>(any), diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 9f478a150..2933149fb 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -2,8 +2,8 @@ use crate::util; use problemreductions::models::algebraic::QUBO; use problemreductions::models::formula::{CNFClause, Satisfiability}; use problemreductions::models::graph::{ - MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, - MinimumVertexCover, SpinGlass, TravelingSalesman, + MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumSumMulticenter, + MinimumDominatingSet, MinimumVertexCover, SpinGlass, TravelingSalesman, }; use problemreductions::models::misc::Factoring; use problemreductions::registry::collect_schemas; @@ -514,6 +514,25 @@ impl McpServer { (ser(Factoring::new(bits_m, bits_n, target))?, variant) } + // MinimumSumMulticenter (p-median) + "MinimumSumMulticenter" => { + let (graph, n) = parse_graph_from_params(params)?; + let vertex_weights = parse_vertex_weights_from_params(params, n)?; + let edge_lengths = parse_edge_lengths_from_params(params, graph.num_edges())?; + let k = params + .get("k") + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .ok_or_else(|| { + anyhow::anyhow!("MinimumSumMulticenter requires 'k' (number of centers)") + })?; + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(MinimumSumMulticenter::new(graph, vertex_weights, edge_lengths, k))?, + variant, + ) + } + _ => anyhow::bail!("{}", unknown_problem_error(&canonical)), }; @@ -634,10 +653,34 @@ impl McpServer { util::validate_k_param(resolved_variant, k_flag, Some(3), "KColoring")?; util::ser_kcoloring(graph, k)? } + "MinimumSumMulticenter" => { + let edge_prob = params + .get("edge_prob") + .and_then(|v| v.as_f64()) + .unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + anyhow::bail!("edge_prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, seed); + let num_edges = graph.num_edges(); + let vertex_weights = vec![1i32; num_vertices]; + let edge_lengths = vec![1i32; num_edges]; + let k = params + .get("k") + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(1.max(num_vertices / 3)); + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(MinimumSumMulticenter::new(graph, vertex_weights, edge_lengths, k))?, + variant, + ) + } _ => anyhow::bail!( "Random generation is not supported for {}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ - MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman)", + MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, \ + TravelingSalesman, MinimumSumMulticenter)", canonical ), }; @@ -1294,6 +1337,30 @@ fn parse_edge_weights_from_params( } } +/// Parse `edge_lengths` field from JSON params as edge lengths (i32), defaulting to all 1s. +fn parse_edge_lengths_from_params( + params: &serde_json::Value, + num_edges: usize, +) -> anyhow::Result> { + match params.get("edge_lengths").and_then(|v| v.as_str()) { + Some(w) => { + let lengths: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if lengths.len() != num_edges { + anyhow::bail!( + "Expected {} edge lengths but got {}", + num_edges, + lengths.len() + ); + } + Ok(lengths) + } + None => Ok(vec![1i32; num_edges]), + } +} + /// Parse `clauses` field from JSON params as semicolon-separated clauses. fn parse_clauses_from_params(params: &serde_json::Value) -> anyhow::Result> { let clauses_str = params diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index f0ff726e2..582430c52 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -25,6 +25,7 @@ pub const ALIASES: &[(&str, &str)] = &[ ("MaxMatching", "MaximumMatching"), ("FVS", "MinimumFeedbackVertexSet"), ("FAS", "MinimumFeedbackArcSet"), + ("pmedian", "MinimumSumMulticenter"), ]; /// Resolve a short alias to the canonical problem name. @@ -63,6 +64,7 @@ pub fn resolve_alias(input: &str) -> String { "lcs" | "longestcommonsubsequence" => "LongestCommonSubsequence".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), "fas" | "minimumfeedbackarcset" => "MinimumFeedbackArcSet".to_string(), + "minimumsummulticenter" | "pmedian" => "MinimumSumMulticenter".to_string(), "subsetsum" => "SubsetSum".to_string(), _ => input.to_string(), // pass-through for exact names } diff --git a/src/lib.rs b/src/lib.rs index d8b79fa74..95caa1aaf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,7 +45,8 @@ pub mod prelude { }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumVertexCover, + MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, + MinimumSumMulticenter, MinimumVertexCover, PartitionIntoTriangles, RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{ diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs new file mode 100644 index 000000000..bacbed941 --- /dev/null +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -0,0 +1,262 @@ +//! Min-Sum Multicenter (p-median) problem implementation. +//! +//! The p-median problem asks for K facility locations (centers) on a graph +//! that minimize the total weighted distance from all vertices to their nearest center. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumSumMulticenter", + module_path: module_path!(), + description: "Find K centers minimizing total weighted distance (p-median problem)", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, + FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, + ], + } +} + +/// The Min-Sum Multicenter (p-median) problem. +/// +/// Given a graph G = (V, E) with vertex weights w(v) and edge lengths l(e), +/// find a subset P ⊆ V of K vertices (centers) that minimizes the total +/// weighted distance Σ_{v ∈ V} w(v) · d(v, P), where d(v, P) is the +/// shortest-path distance from v to the nearest center in P. +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// * `W` - The weight/length type (e.g., `i32`, `One`) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::MinimumSumMulticenter; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Path graph: 0-1-2, unit weights and lengths, K=1 +/// let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); +/// let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1); +/// +/// let solver = BruteForce::new(); +/// let solution = solver.find_best(&problem).unwrap(); +/// // Center at vertex 1 gives total distance 0+1+1 = 2 (optimal) +/// assert_eq!(solution, vec![0, 1, 0]); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumSumMulticenter { + /// The underlying graph. + graph: G, + /// Non-negative weight for each vertex. + vertex_weights: Vec, + /// Non-negative length for each edge (in edge index order). + edge_lengths: Vec, + /// Number of centers to place. + k: usize, +} + +impl MinimumSumMulticenter { + /// Create a MinimumSumMulticenter problem. + /// + /// # Panics + /// - If `vertex_weights.len() != graph.num_vertices()` + /// - If `edge_lengths.len() != graph.num_edges()` + /// - If `k == 0` or `k > graph.num_vertices()` + pub fn new(graph: G, vertex_weights: Vec, edge_lengths: Vec, k: usize) -> Self { + assert_eq!( + vertex_weights.len(), + graph.num_vertices(), + "vertex_weights length must match num_vertices" + ); + assert_eq!( + edge_lengths.len(), + graph.num_edges(), + "edge_lengths length must match num_edges" + ); + assert!(k > 0, "k must be positive"); + assert!(k <= graph.num_vertices(), "k must not exceed num_vertices"); + Self { + graph, + vertex_weights, + edge_lengths, + k, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get a reference to the vertex weights. + pub fn vertex_weights(&self) -> &[W] { + &self.vertex_weights + } + + /// Get a reference to the edge lengths. + pub fn edge_lengths(&self) -> &[W] { + &self.edge_lengths + } + + /// Get the number of centers K. + pub fn k(&self) -> usize { + self.k + } +} + +impl MinimumSumMulticenter { + /// 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 centers K. + pub fn num_centers(&self) -> usize { + self.k + } + + /// Compute shortest distances from each vertex to the nearest center. + /// + /// Uses multi-source Dijkstra with linear scan: initializes all centers + /// at distance 0 and greedily relaxes edges by increasing distance. + /// Correct because all edge lengths are non-negative. + /// + /// Returns `None` if any vertex is unreachable from all centers. + fn shortest_distances(&self, config: &[usize]) -> Option> { + let n = self.graph.num_vertices(); + let edges = self.graph.edges(); + + let mut adj: Vec> = vec![Vec::new(); n]; + for (idx, &(u, v)) in edges.iter().enumerate() { + let len = self.edge_lengths[idx].to_sum(); + adj[u].push((v, len.clone())); + adj[v].push((u, len)); + } + + // Multi-source Dijkstra with linear scan (works with PartialOrd) + let mut dist: Vec> = vec![None; n]; + let mut visited = vec![false; n]; + + // Initialize centers + for (v, &selected) in config.iter().enumerate() { + if selected == 1 { + dist[v] = Some(W::Sum::zero()); + } + } + + for _ in 0..n { + // Find unvisited vertex with smallest distance + let mut u = None; + for v in 0..n { + if visited[v] { + continue; + } + if let Some(ref dv) = dist[v] { + match u { + None => u = Some(v), + Some(prev) => { + if *dv < dist[prev].clone().unwrap() { + u = Some(v); + } + } + } + } + } + let u = match u { + Some(v) => v, + None => break, // remaining vertices are unreachable + }; + visited[u] = true; + + let du = dist[u].clone().unwrap(); + for &(next, ref len) in &adj[u] { + if visited[next] { + continue; + } + let new_dist = du.clone() + len.clone(); + let update = match &dist[next] { + None => true, + Some(d) => new_dist < *d, + }; + if update { + dist[next] = Some(new_dist); + } + } + } + + dist.into_iter().collect() + } +} + +impl Problem for MinimumSumMulticenter +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MinimumSumMulticenter"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + // Check exactly K centers are selected + let num_selected: usize = config.iter().sum(); + if num_selected != self.k { + return SolutionSize::Invalid; + } + + // Compute shortest distances to nearest center + let distances = match self.shortest_distances(config) { + Some(d) => d, + None => return SolutionSize::Invalid, + }; + + // Compute total weighted distance: Σ w(v) * d(v) + let mut total = W::Sum::zero(); + for (v, dist) in distances.iter().enumerate() { + total += self.vertex_weights[v].to_sum() * dist.clone(); + } + + SolutionSize::Valid(total) + } +} + +impl OptimizationProblem for MinimumSumMulticenter +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +crate::declare_variants! { + MinimumSumMulticenter => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/minimum_sum_multicenter.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 2cb4f9831..fd8032429 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -16,6 +16,7 @@ //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`BicliqueCover`]: Biclique cover on bipartite graphs //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs +//! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) @@ -27,6 +28,7 @@ pub(crate) mod maximal_is; pub(crate) mod maximum_clique; pub(crate) mod maximum_independent_set; pub(crate) mod maximum_matching; +pub(crate) mod minimum_sum_multicenter; pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_feedback_vertex_set; @@ -45,6 +47,7 @@ pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; +pub use minimum_sum_multicenter::MinimumSumMulticenter; pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; diff --git a/src/models/mod.rs b/src/models/mod.rs index af02d8686..33e79f9dc 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,6 +14,7 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, + MinimumSumMulticenter, MinimumFeedbackVertexSet, MinimumVertexCover, PartitionIntoTriangles, RuralPostman, SpinGlass, SubgraphIsomorphism, TravelingSalesman, }; diff --git a/src/unit_tests/models/graph/minimum_sum_multicenter.rs b/src/unit_tests/models/graph/minimum_sum_multicenter.rs new file mode 100644 index 000000000..c92b5348f --- /dev/null +++ b/src/unit_tests/models/graph/minimum_sum_multicenter.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +#[test] +fn test_min_sum_multicenter_creation() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 4], vec![1i32; 3], 2); + assert_eq!(problem.graph().num_vertices(), 4); + assert_eq!(problem.graph().num_edges(), 3); + assert_eq!(problem.k(), 2); + assert_eq!(problem.vertex_weights(), &[1, 1, 1, 1]); + assert_eq!(problem.edge_lengths(), &[1, 1, 1]); +} + +#[test] +fn test_min_sum_multicenter_size_getters() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 5], vec![1i32; 4], 2); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_edges(), 4); + assert_eq!(problem.num_centers(), 2); +} + +#[test] +fn test_min_sum_multicenter_direction() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_min_sum_multicenter_evaluate_path() { + // Path: 0-1-2, unit weights and lengths, K=1 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1); + + // Center at vertex 1: distances = [1, 0, 1], total = 2 + let result = problem.evaluate(&[0, 1, 0]); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 2); + + // Center at vertex 0: distances = [0, 1, 2], total = 3 + let result = problem.evaluate(&[1, 0, 0]); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 3); +} + +#[test] +fn test_min_sum_multicenter_wrong_k() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 2); + + // Only 1 center selected when K=2 + let result = problem.evaluate(&[0, 1, 0]); + assert!(!result.is_valid()); + + // 3 centers selected when K=2 + let result = problem.evaluate(&[1, 1, 1]); + assert!(!result.is_valid()); + + // No centers selected + let result = problem.evaluate(&[0, 0, 0]); + assert!(!result.is_valid()); +} + +#[test] +fn test_min_sum_multicenter_weighted() { + // Path: 0-1-2, vertex weights = [3, 1, 2], edge lengths = [1, 1], K=1 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![3i32, 1, 2], vec![1i32; 2], 1); + + // Center at 0: distances = [0, 1, 2], total = 3*0 + 1*1 + 2*2 = 5 + assert_eq!(problem.evaluate(&[1, 0, 0]).unwrap(), 5); + + // Center at 1: distances = [1, 0, 1], total = 3*1 + 1*0 + 2*1 = 5 + assert_eq!(problem.evaluate(&[0, 1, 0]).unwrap(), 5); + + // Center at 2: distances = [2, 1, 0], total = 3*2 + 1*1 + 2*0 = 7 + assert_eq!(problem.evaluate(&[0, 0, 1]).unwrap(), 7); +} + +#[test] +fn test_min_sum_multicenter_weighted_edges() { + // Triangle: 0-1 (len 1), 1-2 (len 3), 0-2 (len 2), K=1 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1, 3, 2], 1); + + // Center at 0: d(0)=0, d(1)=1, d(2)=2, total=3 + assert_eq!(problem.evaluate(&[1, 0, 0]).unwrap(), 3); + + // Center at 1: d(1)=0, d(0)=1, d(2)=min(3, 1+2)=3, total=4 + assert_eq!(problem.evaluate(&[0, 1, 0]).unwrap(), 4); +} + +#[test] +fn test_min_sum_multicenter_two_centers() { + // Path: 0-1-2-3-4, unit weights and lengths, K=2 + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 5], vec![1i32; 4], 2); + + // Centers at {1, 3}: d = [1, 0, 1, 0, 1], total = 3 + assert_eq!(problem.evaluate(&[0, 1, 0, 1, 0]).unwrap(), 3); + + // Centers at {0, 4}: d = [0, 1, 2, 1, 0], total = 4 + assert_eq!(problem.evaluate(&[1, 0, 0, 0, 1]).unwrap(), 4); +} + +#[test] +fn test_min_sum_multicenter_solver() { + // Issue example: 7 vertices, 8 edges, unit weights, K=2 + let graph = SimpleGraph::new( + 7, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (0, 6), + (2, 5), + ], + ); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 7], vec![1i32; 8], 2); + + let solver = BruteForce::new(); + let best = solver.find_best(&problem).unwrap(); + let best_cost = problem.evaluate(&best).unwrap(); + + // Optimal cost should be 6 (centers at {2, 5}) + assert_eq!(best_cost, 6); +} + +#[test] +fn test_min_sum_multicenter_disconnected() { + // Two disconnected components: 0-1 and 2-3, K=1 + let graph = SimpleGraph::new(4, vec![(0, 1), (2, 3)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 4], vec![1i32; 2], 1); + + // Center at 0: vertex 2 and 3 are unreachable + let result = problem.evaluate(&[1, 0, 0, 0]); + assert!(!result.is_valid()); + + // With K=2, centers at {0, 2}: all reachable + let graph2 = SimpleGraph::new(4, vec![(0, 1), (2, 3)]); + let problem2 = MinimumSumMulticenter::new(graph2, vec![1i32; 4], vec![1i32; 2], 2); + let result2 = problem2.evaluate(&[1, 0, 1, 0]); + assert!(result2.is_valid()); + assert_eq!(result2.unwrap(), 2); // d = [0, 1, 0, 1] +} + +#[test] +fn test_min_sum_multicenter_single_vertex() { + let graph = SimpleGraph::new(1, vec![]); + let problem = MinimumSumMulticenter::new(graph, vec![5i32], vec![], 1); + let result = problem.evaluate(&[1]); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 0); // Only vertex is the center, distance = 0 +} + +#[test] +fn test_min_sum_multicenter_all_centers() { + // K = num_vertices: all vertices are centers, total distance = 0 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 3); + let result = problem.evaluate(&[1, 1, 1]); + assert!(result.is_valid()); + assert_eq!(result.unwrap(), 0); +} + +#[test] +#[should_panic(expected = "vertex_weights length must match num_vertices")] +fn test_min_sum_multicenter_wrong_vertex_weights_len() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinimumSumMulticenter::new(graph, vec![1i32; 2], vec![1i32; 1], 1); +} + +#[test] +#[should_panic(expected = "edge_lengths length must match num_edges")] +fn test_min_sum_multicenter_wrong_edge_lengths_len() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1); +} + +#[test] +#[should_panic(expected = "k must be positive")] +fn test_min_sum_multicenter_k_zero() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 1], 0); +} + +#[test] +#[should_panic(expected = "k must not exceed num_vertices")] +fn test_min_sum_multicenter_k_too_large() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 1], 4); +} + +#[test] +fn test_min_sum_multicenter_dims() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 5], vec![1i32; 4], 2); + assert_eq!(problem.dims(), vec![2; 5]); +} + +#[test] +fn test_min_sum_multicenter_find_all_best() { + // Path: 0-1-2, unit weights, K=1. Center at 1 is optimal (cost 2) + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1); + + let solver = BruteForce::new(); + let solutions = solver.find_all_best(&problem); + assert_eq!(solutions.len(), 1); + assert_eq!(solutions[0], vec![0, 1, 0]); +} + +#[test] +fn test_min_sum_multicenter_serialization() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumSumMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1); + + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: MinimumSumMulticenter = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.graph().num_vertices(), 3); + assert_eq!(deserialized.graph().num_edges(), 2); + assert_eq!(deserialized.vertex_weights(), &[1, 1, 1]); + assert_eq!(deserialized.edge_lengths(), &[1, 1]); + assert_eq!(deserialized.k(), 1); + + // Verify evaluation produces same results + let config = vec![0, 1, 0]; + assert_eq!( + problem.evaluate(&config).unwrap(), + deserialized.evaluate(&config).unwrap() + ); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index a90872ca8..cdbdba7a1 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -90,6 +90,10 @@ fn test_all_problems_implement_trait_correctly() { ), "MinimumFeedbackArcSet", ); + check_problem_trait( + &MinimumSumMulticenter::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1i32; 3], vec![1i32; 2], 1), + "MinimumSumMulticenter", + ); } #[test] @@ -139,6 +143,10 @@ fn test_direction() { .direction(), Direction::Minimize ); + assert_eq!( + MinimumSumMulticenter::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1i32; 3], vec![1i32; 2], 1).direction(), + Direction::Minimize + ); // Maximization problems assert_eq!(