diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index e7360b027..2132a81ae 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -120,6 +120,7 @@ "ConsecutiveBlockMinimization": [Consecutive Block Minimization], "ConsecutiveOnesSubmatrix": [Consecutive Ones Submatrix], "DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow], + "MinMaxMulticenter": [Min-Max Multicenter], "FlowShopScheduling": [Flow Shop Scheduling], "MinimumCutIntoBoundedSets": [Minimum Cut Into Bounded Sets], "MinimumSumMulticenter": [Minimum Sum Multicenter], @@ -1616,6 +1617,49 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("MinMaxMulticenter") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) + let K = x.instance.k + let B = x.instance.bound + let sol = (config: x.optimal_config, metric: x.optimal_value) + let centers = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + [ + #problem-def("MinMaxMulticenter")[ + Given a graph $G = (V, E)$ with vertex weights $w: V -> ZZ_(>= 0)$, edge lengths $l: E -> ZZ_(>= 0)$, a positive integer $K <= |V|$, and a rational bound $B > 0$, does there exist $S subset.eq V$ with $|S| = K$ such that $max_(v in V) w(v) dot d(v, S) <= B$, where $d(v, S) = min_(s in S) d(v, s)$ is the shortest weighted-path distance from $v$ to the nearest vertex in $S$? + ][ + Also known as the _vertex p-center problem_ (Garey & Johnson A2 ND50). The goal is to place $K$ facilities so that the worst-case weighted distance from any demand point to its nearest facility is at most $B$. NP-complete even with unit weights and unit edge lengths (Kariv and Hakimi, 1979). + + Closely related to Dominating Set: on unweighted unit-length graphs, a $K$-center with radius $B = 1$ is exactly a dominating set of size $K$. The best known exact algorithm runs in $O^*(1.4969^n)$ via binary search over distance thresholds combined with dominating set computation @vanrooij2011. An optimal 2-approximation exists (Hochbaum and Shmoys, 1985); no $(2 - epsilon)$-approximation is possible unless $P = "NP"$ (Hsu and Nemhauser, 1979). + + Variables: $n = |V|$ binary variables, one per vertex. $x_v = 1$ if vertex $v$ is selected as a center. A configuration is satisfying when exactly $K$ centers are selected and $max_(v in V) w(v) dot d(v, S) <= B$. + + *Example.* Consider the graph $G$ on #nv vertices with unit weights $w(v) = 1$, unit edge lengths, edges ${#edges.map(((u, v)) => $(#u, #v)$).join(", ")}$, $K = #K$, and $B = #B$. Placing centers at $S = {#centers.map(i => $v_#i$).join(", ")}$ gives maximum distance $max_v d(v, S) = 1 <= B$, so this is a feasible solution. + + #figure({ + let blue = graph-colors.at(0) + let gray = luma(200) + canvas(length: 1cm, { + import draw: * + let verts = ((-1.5, 0.8), (0, 1.5), (1.5, 0.8), (1.5, -0.8), (0, -1.5), (-1.5, -0.8)) + for (u, v) in edges { + g-edge(verts.at(u), verts.at(v), stroke: 1pt + gray) + } + for (k, pos) in verts.enumerate() { + let is-center = centers.any(c => c == k) + g-node(pos, name: "v" + str(k), + fill: if is-center { blue } else { white }, + label: if is-center { text(fill: white)[$v_#k$] } else { [$v_#k$] }) + } + }) + }, + caption: [Min-Max Multicenter with $K = #K$, $B = #B$ on a #{nv}-vertex graph. Centers #centers.map(i => $v_#i$).join(" and ") (blue) ensure every vertex is within distance $B$ of some center.], + ) + ] + ] +} + #{ let x = load-model-example("MultipleCopyFileAllocation") let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) diff --git a/docs/src/cli.md b/docs/src/cli.md index 80aa6ff68..622996ad0 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -353,6 +353,7 @@ pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3 -o kth.json pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json +pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 1 -o pcenter.json pred create RectilinearPictureCompression --matrix "1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1" --k 2 -o rpc.json pred solve rpc.json --solver brute-force pred create MinimumMultiwayCut --graph 0-1,1-2,2-3,3-0 --terminals 0,2 --edge-weights 3,1,2,4 -o mmc.json @@ -514,6 +515,7 @@ Stdin is supported with `-`: ```bash pred create MIS --graph 0-1,1-2,2-3 | pred solve - pred create MIS --graph 0-1,1-2,2-3 | pred solve - --solver brute-force +pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 1 | pred solve - --solver brute-force pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets "0,1,2;3,4,5;1,3;2,4;0,5" | pred solve - --solver brute-force ``` @@ -541,7 +543,7 @@ Source evaluation: Valid(2) > **Note:** The ILP solver requires a reduction path from the target problem to ILP. > Some problems do not currently have one. Examples include BoundedComponentSpanningForest, -> LengthBoundedDisjointPaths, MinimumCardinalityKey, QUBO, SpinGlass, MaxCut, CircuitSAT, and MultiprocessorScheduling. +> LengthBoundedDisjointPaths, MinimumCardinalityKey, QUBO, SpinGlass, MaxCut, CircuitSAT, MinMaxMulticenter, and MultiprocessorScheduling. > Use `pred solve --solver brute-force` for these, or reduce to a problem that supports ILP first. > For other problems, use `pred path ILP` to check whether an ILP reduction path exists. diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 5c3bae2d1..91a2b98fb 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -258,6 +258,7 @@ Flags by problem type: MultiprocessorScheduling --lengths, --num-processors, --deadline SequencingWithinIntervals --release-times, --deadlines, --lengths OptimalLinearArrangement --graph, --bound + MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices] AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] @@ -622,8 +623,8 @@ Solve via explicit reduction: Input: a problem JSON from `pred create`, or a reduction bundle from `pred reduce`. When given a bundle, the target is solved and the solution is mapped back to the source. The ILP solver auto-reduces non-ILP problems before solving. -Problems without an ILP reduction path, such as `LengthBoundedDisjointPaths` and -`StringToStringCorrection`, currently need `--solver brute-force`. +Problems without an ILP reduction path, such as `LengthBoundedDisjointPaths`, +`MinMaxMulticenter`, and `StringToStringCorrection`, currently need `--solver brute-force`. ILP backend (default: HiGHS). To use a different backend: cargo install problemreductions-cli --features coin-cbc diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 85fb53849..9fda3b338 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -360,6 +360,14 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { } } +fn cli_flag_name(field_name: &str) -> String { + match field_name { + "vertex_weights" => "weights".to_string(), + "edge_lengths" => "edge-weights".to_string(), + _ => field_name.replace('_', "-"), + } +} + fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { match canonical { "MaximumIndependentSet" @@ -405,6 +413,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", "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", + "MinMaxMulticenter" => { + "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 2" + } "MinimumSumMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" } @@ -2608,6 +2619,50 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinMaxMulticenter (vertex p-center) + "MinMaxMulticenter" => { + let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2 --bound 2"; + let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + 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!( + "MinMaxMulticenter requires --k (number of centers)\n\n\ + Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2 --bound 2" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "MinMaxMulticenter requires --bound (distance bound B)\n\n\ + Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2 --bound 2" + ) + })?; + let bound = i32::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "MinMaxMulticenter --bound must fit in i32 (got {bound})\n\n{usage}" + ) + })?; + if vertex_weights.iter().any(|&weight| weight < 0) { + bail!("MinMaxMulticenter --weights must be non-negative"); + } + if edge_lengths.iter().any(|&length| length < 0) { + bail!("MinMaxMulticenter --edge-weights must be non-negative"); + } + if bound < 0 { + bail!("MinMaxMulticenter --bound must be non-negative"); + } + ( + ser(MinMaxMulticenter::new( + graph, + vertex_weights, + edge_lengths, + k, + bound, + ))?, + resolved_variant.clone(), + ) + } + // StrongConnectivityAugmentation "StrongConnectivityAugmentation" => { let usage = "Usage: pred create StrongConnectivityAugmentation --arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1 [--num-vertices N]"; diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 663ee33b3..a08b41de5 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -13,6 +13,15 @@ enum SolveInput { Bundle(ReductionBundle), } +fn add_bruteforce_hint(err: anyhow::Error) -> anyhow::Error { + let message = err.to_string(); + if message.contains("No reduction path from") { + anyhow::anyhow!("{message}\n\nTry: pred solve --solver brute-force") + } else { + err + } +} + fn parse_input(path: &Path) -> Result { let content = read_input(path)?; let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse JSON")?; diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index d2dd0fd0c..06ae08c6c 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -353,6 +353,41 @@ mod tests { assert_eq!(json["source"], "MaximumIndependentSet"); } + #[test] + fn test_inspect_minmaxmulticenter_lists_bruteforce_only() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "MinMaxMulticenter", + "variant": {"graph": "SimpleGraph", "weight": "i32"}, + "data": { + "graph": { + "inner": { + "nodes": [null, null, null, null], + "node_holes": [], + "edge_property": "undirected", + "edges": [[0, 1, null], [1, 2, null], [2, 3, null]] + } + }, + "vertex_weights": [1, 1, 1, 1], + "edge_lengths": [1, 1, 1], + "k": 2, + "bound": 1 + } + }) + .to_string(); + + let result = server.inspect_problem_inner(&problem_json); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + let solvers: Vec<&str> = json["solvers"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(solvers, vec!["brute-force"]); + } + #[test] fn test_solve_sat_problem() { let server = McpServer::new(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index e35a076f9..37fbb236f 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -690,6 +690,7 @@ impl McpServer { let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); targets.sort(); targets.dedup(); + let solvers = problem.available_solvers(); let result = serde_json::json!({ "kind": "problem", @@ -697,7 +698,7 @@ impl McpServer { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": ["ilp", "brute-force"], + "solvers": solvers, "reduces_to": targets, }); Ok(serde_json::to_string_pretty(&result)?) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index a2f6a22fe..2a1ee9425 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -2404,6 +2404,19 @@ fn test_solve_unknown_solver() { std::fs::remove_file(&problem_file).ok(); } +#[test] +fn test_solve_help_mentions_bruteforce_only_models() { + let output = pred().args(["solve", "--help"]).output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("MinMaxMulticenter"), "stdout: {stdout}"); + assert!(stdout.contains("--solver brute-force"), "stdout: {stdout}"); +} + // ---- Create command: more problem types ---- #[test] @@ -3465,6 +3478,177 @@ fn test_create_kcoloring_missing_k() { assert!(stderr.contains("--k")); } +#[test] +fn test_create_minmaxmulticenter_bound_out_of_range() { + let output = pred() + .args([ + "create", + "MinMaxMulticenter", + "--graph", + "0-1", + "--k", + "1", + "--bound", + "2147483648", + ]) + .output() + .unwrap(); + assert!(!output.status.success(), "expected bound overflow to fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("must fit in i32"), "stderr: {stderr}"); +} + +#[test] +fn test_create_minmaxmulticenter_success() { + let output = pred() + .args([ + "create", + "MinMaxMulticenter", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,2,3,4", + "--edge-weights", + "5,6,7", + "--k", + "2", + "--bound", + "8", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "MinMaxMulticenter"); + assert_eq!(json["variant"]["graph"], "SimpleGraph"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["k"], 2); + assert_eq!(json["data"]["bound"], 8); + assert_eq!( + json["data"]["vertex_weights"], + serde_json::json!([1, 2, 3, 4]) + ); + assert_eq!(json["data"]["edge_lengths"], serde_json::json!([5, 6, 7])); +} + +#[test] +fn test_create_minmaxmulticenter_help_uses_cli_flag_names() { + let output = pred() + .args(["create", "MinMaxMulticenter"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "should exit non-zero when showing help without data flags" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--weights"), "stderr: {stderr}"); + assert!(stderr.contains("--edge-weights"), "stderr: {stderr}"); + assert!(!stderr.contains("--vertex-weights"), "stderr: {stderr}"); + assert!(!stderr.contains("--edge-lengths"), "stderr: {stderr}"); +} + +#[test] +fn test_create_minmaxmulticenter_negative_inputs_rejected() { + let vertex_weights = pred() + .args([ + "create", + "MinMaxMulticenter", + "--graph", + "0-1", + "--weights", + "1,-1", + "--edge-weights", + "1", + "--k", + "1", + "--bound", + "1", + ]) + .output() + .unwrap(); + assert!(!vertex_weights.status.success()); + assert!(String::from_utf8_lossy(&vertex_weights.stderr).contains("must be non-negative")); + + let edge_weights = pred() + .args([ + "create", + "MinMaxMulticenter", + "--graph", + "0-1", + "--weights", + "1,1", + "--edge-weights=-1", + "--k", + "1", + "--bound", + "1", + ]) + .output() + .unwrap(); + assert!(!edge_weights.status.success()); + assert!(String::from_utf8_lossy(&edge_weights.stderr).contains("must be non-negative")); + + let bound = pred() + .args([ + "create", + "MinMaxMulticenter", + "--graph", + "0-1", + "--weights", + "1,1", + "--edge-weights", + "1", + "--k", + "1", + "--bound=-1", + ]) + .output() + .unwrap(); + assert!(!bound.status.success()); + assert!(String::from_utf8_lossy(&bound.stderr).contains("must be non-negative")); +} + +#[test] +fn test_solve_minmaxmulticenter_ilp_error_suggests_bruteforce() { + let problem_file = std::env::temp_dir().join("pred_test_minmaxmulticenter_solve.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "MinMaxMulticenter", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--edge-weights", + "1,1,1", + "--k", + "2", + "--bound", + "1", + ]) + .output() + .unwrap(); + assert!(create_out.status.success()); + + let solve_out = pred() + .args(["solve", problem_file.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!solve_out.status.success()); + let stderr = String::from_utf8_lossy(&solve_out.stderr); + assert!(stderr.contains("--solver brute-force"), "stderr: {stderr}"); + + std::fs::remove_file(&problem_file).ok(); +} + #[test] fn test_create_consecutive_ones_submatrix_succeeds() { let output = pred() @@ -4728,6 +4912,52 @@ fn test_inspect_problem() { std::fs::remove_file(&problem_file).ok(); } +#[test] +fn test_inspect_minmaxmulticenter_lists_bruteforce_only() { + let problem_file = std::env::temp_dir().join("pred_test_inspect_minmaxmulticenter.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "MinMaxMulticenter", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--edge-weights", + "1,1,1", + "--k", + "2", + "--bound", + "1", + ]) + .output() + .unwrap(); + assert!(create_out.status.success()); + + let output = pred() + .args(["inspect", problem_file.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let solvers: Vec<&str> = json["solvers"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(solvers, vec!["brute-force"]); + + std::fs::remove_file(&problem_file).ok(); +} + #[test] fn test_inspect_bundle() { let problem_file = std::env::temp_dir().join("pred_test_inspect_bundle_p.json"); diff --git a/src/lib.rs b/src/lib.rs index 34ca7f66a..11e208b48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,7 @@ pub mod prelude { }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, + MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, SteinerTreeInGraphs, diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs new file mode 100644 index 000000000..05f3c26d8 --- /dev/null +++ b/src/models/graph/min_max_multicenter.rs @@ -0,0 +1,322 @@ +//! Min-Max Multicenter (vertex p-center) problem implementation. +//! +//! The vertex p-center problem asks whether K centers can be placed on vertices +//! of a graph such that the maximum weighted distance from any vertex to its +//! nearest center is at most a given bound B. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinMaxMulticenter", + display_name: "Min-Max Multicenter", + aliases: &["pCenter"], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Determine if K centers can be placed so max weighted distance is at most B (vertex p-center)", + 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" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on maximum weighted distance" }, + ], + } +} + +/// The Min-Max Multicenter (vertex p-center) problem. +/// +/// Given a graph G = (V, E) with vertex weights w(v) and edge lengths l(e), +/// a number K of centers to place, and a bound B, determine whether there +/// exists a subset P of K vertices (centers) such that +/// max_{v in V} w(v) * d(v, P) <= B, +/// where d(v, P) is the shortest-path distance from v to the nearest center. +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// * `W` - The weight/length type (e.g., `i32`) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::MinMaxMulticenter; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Hexagonal-like graph: 6 vertices, 7 edges, unit weights/lengths, K=2, B=1 +/// let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4)]); +/// let problem = MinMaxMulticenter::new(graph, vec![1i32; 6], vec![1i32; 7], 2, 1); +/// +/// let solver = BruteForce::new(); +/// let solution = solver.find_satisfying(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinMaxMulticenter { + /// 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, + /// Upper bound B on the maximum weighted distance. + bound: W::Sum, +} + +impl MinMaxMulticenter { + /// Create a MinMaxMulticenter problem. + /// + /// # Panics + /// - If `vertex_weights.len() != graph.num_vertices()` + /// - If `edge_lengths.len() != graph.num_edges()` + /// - If any vertex weight, edge length, or `bound` is negative + /// - If `k == 0` or `k > graph.num_vertices()` + pub fn new( + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, + bound: W::Sum, + ) -> 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" + ); + let zero = W::Sum::zero(); + assert!( + vertex_weights + .iter() + .all(|weight| weight.to_sum() >= zero.clone()), + "vertex_weights must be non-negative" + ); + assert!( + edge_lengths + .iter() + .all(|length| length.to_sum() >= zero.clone()), + "edge_lengths must be non-negative" + ); + assert!(bound >= zero, "bound must be non-negative"); + 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, + bound, + } + } + + /// 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 + } + + /// 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 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(); + if config.len() != n || config.iter().any(|&selected| selected > 1) { + return None; + } + 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 MinMaxMulticenter +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MinMaxMulticenter"; + type Metric = bool; + + 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]) -> bool { + if config.len() != self.graph.num_vertices() || config.iter().any(|&selected| selected > 1) + { + return false; + } + + // Check exactly K centers are selected + let num_selected = config.iter().filter(|&&selected| selected == 1).count(); + if num_selected != self.k { + return false; + } + + // Compute shortest distances to nearest center + let distances = match self.shortest_distances(config) { + Some(d) => d, + None => return false, + }; + + // Compute max weighted distance: max_{v} w(v) * d(v) + let mut max_wd = W::Sum::zero(); + for (v, dist) in distances.iter().enumerate() { + let wd = self.vertex_weights[v].to_sum() * dist.clone(); + if wd > max_wd { + max_wd = wd; + } + } + + max_wd <= self.bound + } +} + +impl SatisfactionProblem for MinMaxMulticenter +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ +} + +crate::declare_variants! { + default sat MinMaxMulticenter => "1.4969^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "min_max_multicenter_simplegraph_i32", + instance: Box::new(MinMaxMulticenter::new( + SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4)], + ), + vec![1i32; 6], + vec![1i32; 7], + 2, + 1, + )), + optimal_config: vec![0, 1, 0, 0, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/min_max_multicenter.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 274d9b76c..7bf48195e 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -29,6 +29,7 @@ //! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs +//! - [`MinMaxMulticenter`]: Min-max multicenter (vertex p-center, satisfaction) //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) //! - [`MultipleChoiceBranching`]: Directed branching with partition constraints //! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths @@ -57,6 +58,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 min_max_multicenter; pub(crate) mod minimum_cut_into_bounded_sets; pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_feedback_arc_set; @@ -96,6 +98,7 @@ pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; +pub use min_max_multicenter::MinMaxMulticenter; pub use minimum_cut_into_bounded_sets::MinimumCutIntoBoundedSets; pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; @@ -138,6 +141,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec MinMaxMulticenter { + let graph = SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4)], + ); + MinMaxMulticenter::new(graph, vec![1i32; 6], vec![1i32; 7], 2, 1) +} + +#[test] +fn test_minmaxmulticenter_basic() { + let problem = example_instance(); + assert_eq!(problem.graph().num_vertices(), 6); + assert_eq!(problem.graph().num_edges(), 7); + assert_eq!(problem.k(), 2); + assert_eq!(*problem.bound(), 1); + assert_eq!(problem.vertex_weights(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(problem.edge_lengths(), &[1, 1, 1, 1, 1, 1, 1]); + assert_eq!(problem.dims(), vec![2; 6]); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.num_centers(), 2); +} + +#[test] +fn test_minmaxmulticenter_evaluate_valid() { + let problem = example_instance(); + // Centers at vertices 1 and 4: + // Distances: d(0)=1, d(1)=0, d(2)=1, d(3)=1, d(4)=0, d(5)=1 + // Max weighted distance = 1*1 = 1 <= B=1 + assert!(problem.evaluate(&[0, 1, 0, 0, 1, 0])); +} + +#[test] +fn test_minmaxmulticenter_evaluate_invalid_count() { + let problem = example_instance(); + // 3 centers selected when K=2 + assert!(!problem.evaluate(&[1, 1, 1, 0, 0, 0])); +} + +#[test] +fn test_minmaxmulticenter_evaluate_invalid_distance() { + let problem = example_instance(); + // Centers at 0 and 5 (adjacent via edge {0,5}): + // Distances: d(0)=0, d(1)=1, d(2)=2, d(3)=2, d(4)=1, d(5)=0 + // Max weighted distance = 1*2 = 2 > B=1 + assert!(!problem.evaluate(&[1, 0, 0, 0, 0, 1])); +} + +#[test] +fn test_minmaxmulticenter_evaluate_no_centers() { + let problem = example_instance(); + assert!(!problem.evaluate(&[0, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_minmaxmulticenter_evaluate_wrong_config_length() { + let problem = example_instance(); + assert!(!problem.evaluate(&[0, 1, 0, 0, 0, 0, 1])); +} + +#[test] +fn test_minmaxmulticenter_serialization() { + let problem = example_instance(); + + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: MinMaxMulticenter = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.graph().num_vertices(), 6); + assert_eq!(deserialized.graph().num_edges(), 7); + assert_eq!(deserialized.vertex_weights(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(deserialized.edge_lengths(), &[1, 1, 1, 1, 1, 1, 1]); + assert_eq!(deserialized.k(), 2); + assert_eq!(*deserialized.bound(), 1); + + // Verify evaluation produces same results + let config = vec![0, 1, 0, 0, 1, 0]; + assert_eq!(problem.evaluate(&config), deserialized.evaluate(&config)); +} + +#[test] +fn test_minmaxmulticenter_solver() { + let problem = example_instance(); + + let solver = BruteForce::new(); + let solutions = solver.find_all_satisfying(&problem); + + // All solutions should evaluate to true + assert!(!solutions.is_empty()); + for sol in &solutions { + assert!(problem.evaluate(sol)); + } + + // Centers at {1, 4} should be among the solutions + assert!(solutions.contains(&vec![0, 1, 0, 0, 1, 0])); +} + +#[test] +fn test_minmaxmulticenter_disconnected() { + // Two disconnected components: 0-1 and 2-3, K=1, B=1 + let graph = SimpleGraph::new(4, vec![(0, 1), (2, 3)]); + let problem = MinMaxMulticenter::new(graph, vec![1i32; 4], vec![1i32; 2], 1, 1); + + // Center at 0: vertices 2 and 3 are unreachable -> false + assert!(!problem.evaluate(&[1, 0, 0, 0])); + + // With K=2, centers at {0, 2}: all reachable, max distance = 1 <= B=1 + let graph2 = SimpleGraph::new(4, vec![(0, 1), (2, 3)]); + let problem2 = MinMaxMulticenter::new(graph2, vec![1i32; 4], vec![1i32; 2], 2, 1); + assert!(problem2.evaluate(&[1, 0, 1, 0])); +} + +#[test] +fn test_minmaxmulticenter_weighted() { + // Path: 0-1-2, vertex weights = [3, 1, 2], edge lengths = [1, 1], K=1, B=3 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinMaxMulticenter::new(graph, vec![3i32, 1, 2], vec![1i32; 2], 1, 3); + + // Center at 1: d(0)=1, d(1)=0, d(2)=1 + // w(0)*d(0) = 3*1 = 3, w(1)*d(1) = 0, w(2)*d(2) = 2*1 = 2 + // max = 3 <= B=3 -> true + assert!(problem.evaluate(&[0, 1, 0])); + + // Center at 0: d(0)=0, d(1)=1, d(2)=2 + // w(0)*d(0) = 0, w(1)*d(1) = 1, w(2)*d(2) = 4 + // max = 4 > B=3 -> false + assert!(!problem.evaluate(&[1, 0, 0])); +} + +#[test] +fn test_minmaxmulticenter_single_vertex() { + let graph = SimpleGraph::new(1, vec![]); + let problem = MinMaxMulticenter::new(graph, vec![5i32], vec![], 1, 0); + // Only vertex is the center, max weighted distance = 0 <= B=0 + assert!(problem.evaluate(&[1])); +} + +#[test] +fn test_minmaxmulticenter_all_centers() { + // K = num_vertices: all vertices are centers, max distance = 0 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 3, 0); + assert!(problem.evaluate(&[1, 1, 1])); +} + +#[test] +fn test_minmaxmulticenter_nonunit_edge_lengths() { + // Path: 0-1-2, unit vertex weights, edge lengths [1, 3], K=1, B=2 + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32, 3], 1, 2); + + // Center at 0: d(0)=0, d(1)=1, d(2)=1+3=4; max=4 > B=2 -> false + assert!(!problem.evaluate(&[1, 0, 0])); + + // Center at 1: d(0)=1, d(1)=0, d(2)=3; max=3 > B=2 -> false + assert!(!problem.evaluate(&[0, 1, 0])); + + // Center at 2: d(0)=4, d(1)=3, d(2)=0; max=4 > B=2 -> false + assert!(!problem.evaluate(&[0, 0, 1])); + + // With B=3: center at 1 gives max=3 <= B=3 -> true + let graph2 = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem2 = MinMaxMulticenter::new(graph2, vec![1i32; 3], vec![1i32, 3], 1, 3); + assert!(problem2.evaluate(&[0, 1, 0])); +} + +#[test] +#[should_panic(expected = "vertex_weights length must match num_vertices")] +fn test_minmaxmulticenter_wrong_vertex_weights_len() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinMaxMulticenter::new(graph, vec![1i32; 2], vec![1i32; 1], 1, 0); +} + +#[test] +#[should_panic(expected = "edge_lengths length must match num_edges")] +fn test_minmaxmulticenter_wrong_edge_lengths_len() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1, 0); +} + +#[test] +#[should_panic(expected = "k must be positive")] +fn test_minmaxmulticenter_k_zero() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32; 1], 0, 0); +} + +#[test] +#[should_panic(expected = "k must not exceed num_vertices")] +fn test_minmaxmulticenter_k_too_large() { + let graph = SimpleGraph::new(3, vec![(0, 1)]); + MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32; 1], 4, 0); +} + +#[test] +#[should_panic(expected = "vertex_weights must be non-negative")] +fn test_minmaxmulticenter_negative_vertex_weight() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + MinMaxMulticenter::new(graph, vec![1i32, -1, 1], vec![1i32; 2], 1, 1); +} + +#[test] +#[should_panic(expected = "edge_lengths must be non-negative")] +fn test_minmaxmulticenter_negative_edge_length() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32, -1], 1, 1); +} + +#[test] +#[should_panic(expected = "bound must be non-negative")] +fn test_minmaxmulticenter_negative_bound() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32; 2], 1, -1); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 60032c92c..09f7671cd 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -124,6 +124,16 @@ fn test_all_problems_implement_trait_correctly() { &HamiltonianCircuit::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])), "HamiltonianCircuit", ); + check_problem_trait( + &MinMaxMulticenter::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1i32; 3], + vec![1i32; 2], + 1, + 1, + ), + "MinMaxMulticenter", + ); check_problem_trait( &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), "HamiltonianPath",