diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ce08d760..a31fb97b 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -99,6 +99,7 @@ "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], "ShortestCommonSupersequence": [Shortest Common Supersequence], + "MinMaxMulticenter": [Min-Max Multicenter], "MinimumSumMulticenter": [Minimum Sum Multicenter], "SteinerTree": [Steiner Tree], "SubgraphIsomorphism": [Subgraph Isomorphism], @@ -1003,6 +1004,50 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS ] } +#{ + let x = load-model-example("MinMaxMulticenter") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + let K = x.instance.k + let B = x.instance.bound + let desired-config = (0, 1, 0, 0, 1, 0) + let sol = x.optimal.find(o => o.config == desired-config) + 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. In fact, #x.optimal.len() distinct center placements satisfy the bound. + + #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.], + ) + ] + ] +} + == Set Problems #{ diff --git a/docs/src/cli.md b/docs/src/cli.md index 8166cfd6..71bf2613 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -275,6 +275,7 @@ pred create QUBO --matrix "1,0.5;0.5,2" -o qubo.json pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.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 SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json pred create Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json pred create Factoring --target 21 --bits-m 3 --bits-n 3 -o factoring2.json @@ -393,6 +394,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 ``` When the problem is not ILP, the solver automatically reduces it to ILP, solves, and maps the solution back. The auto-reduction is shown in the output: @@ -418,7 +420,7 @@ Source evaluation: Valid(2) ``` > **Note:** The ILP solver requires a reduction path from the target problem to ILP. -> Some problems (e.g., QUBO, SpinGlass, MaxCut, CircuitSAT) do not have this path yet. +> Some problems (e.g., QUBO, SpinGlass, MaxCut, CircuitSAT, MinMaxMulticenter) do not have this path yet. > Use `--solver brute-force` for these, or reduce to a problem that supports ILP first. ## Shell Completions diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 5949a528..8a6d1c2f 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -366,6 +366,37 @@ } ] }, + { + "name": "MinMaxMulticenter", + "description": "Determine if K centers can be placed so max weighted distance is at most B (vertex p-center)", + "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": "bound", + "type_name": "W::Sum", + "description": "Upper bound B on maximum weighted distance" + } + ] + }, { "name": "MinimumDominatingSet", "description": "Find minimum weight dominating set in a graph", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index cd80f3f0..928b2a77 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -349,6 +349,16 @@ "doc_path": "models/set/struct.MaximumSetPacking.html", "complexity": "2^num_sets" }, + { + "name": "MinMaxMulticenter", + "variant": { + "graph": "SimpleGraph", + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.MinMaxMulticenter.html", + "complexity": "1.4969^num_vertices" + }, { "name": "MinimumDominatingSet", "variant": { @@ -569,7 +579,7 @@ }, { "source": 4, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -629,7 +639,7 @@ }, { "source": 12, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -670,7 +680,7 @@ }, { "source": 19, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -696,7 +706,7 @@ }, { "source": 20, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -722,7 +732,7 @@ }, { "source": 21, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -733,7 +743,7 @@ }, { "source": 21, - "target": 58, + "target": 59, "overhead": [ { "field": "num_elements", @@ -744,7 +754,7 @@ }, { "source": 22, - "target": 51, + "target": 52, "overhead": [ { "field": "num_clauses", @@ -763,7 +773,7 @@ }, { "source": 23, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -789,7 +799,7 @@ }, { "source": 25, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -969,7 +979,7 @@ }, { "source": 31, - "target": 45, + "target": 46, "overhead": [ { "field": "num_vertices", @@ -1104,7 +1114,7 @@ }, { "source": 37, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1159,7 +1169,7 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 39, + "source": 40, "target": 12, "overhead": [ { @@ -1174,7 +1184,7 @@ "doc_path": "rules/minimumdominatingset_ilp/index.html" }, { - "source": 42, + "source": 43, "target": 12, "overhead": [ { @@ -1189,7 +1199,7 @@ "doc_path": "rules/minimumsetcovering_ilp/index.html" }, { - "source": 45, + "source": 46, "target": 31, "overhead": [ { @@ -1204,8 +1214,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 45, - "target": 42, + "source": 46, + "target": 43, "overhead": [ { "field": "num_sets", @@ -1219,7 +1229,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 49, + "source": 50, "target": 12, "overhead": [ { @@ -1234,8 +1244,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 49, - "target": 53, + "source": 50, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1245,7 +1255,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 51, + "source": 52, "target": 4, "overhead": [ { @@ -1260,7 +1270,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 51, + "source": 52, "target": 16, "overhead": [ { @@ -1275,7 +1285,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 51, + "source": 52, "target": 21, "overhead": [ { @@ -1290,7 +1300,7 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 51, + "source": 52, "target": 30, "overhead": [ { @@ -1305,8 +1315,8 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 51, - "target": 39, + "source": 52, + "target": 40, "overhead": [ { "field": "num_vertices", @@ -1320,8 +1330,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, - "target": 49, + "source": 54, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1331,7 +1341,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, + "source": 55, "target": 25, "overhead": [ { @@ -1346,8 +1356,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1361,7 +1371,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 59, + "source": 60, "target": 12, "overhead": [ { @@ -1376,8 +1386,8 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 59, - "target": 49, + "source": 60, + "target": 50, "overhead": [ { "field": "num_vars", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index e2dbd5b5..960372b8 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -236,6 +236,7 @@ Flags by problem type: SteinerTree --graph, --edge-weights, --terminals CVP --basis, --target-vec [--bounds] OptimalLinearArrangement --graph, --bound + MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound SubgraphIsomorphism --graph (host), --pattern (pattern) LCS --strings @@ -430,6 +431,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. +Some problems (e.g., QUBO, SpinGlass, MaxCut, CircuitSAT, MinMaxMulticenter) do not have an ILP path yet. +Use `--solver brute-force` for these, or inspect the instance first to see available solvers. 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 db0e6c58..443ede20 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -213,6 +213,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" @@ -235,6 +243,9 @@ 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", + "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" } @@ -278,12 +289,8 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); } else { let hint = type_format_hint(&field.type_name, graph_type); - eprintln!( - " --{:<16} {} ({})", - field.name.replace('_', "-"), - field.description, - hint - ); + let flag_name = cli_flag_name(&field.name); + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } } } else { @@ -945,6 +952,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(), + ) + } + // MinimumSumMulticenter (p-median) "MinimumSumMulticenter" => { let (graph, n) = parse_graph(args).map_err(|e| { diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index 3a5d37ca..3a482b67 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -40,8 +40,12 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { } text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn())); - // Solvers - text.push_str("Solvers: ilp (default), brute-force\n"); + let solvers = problem.available_solvers(); + if solvers.first() == Some(&"ilp") { + text.push_str("Solvers: ilp (default), brute-force\n"); + } else { + text.push_str("Solvers: brute-force\n"); + } // Reductions let outgoing = graph.outgoing_reductions(name); @@ -56,7 +60,7 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": ["ilp", "brute-force"], + "solvers": solvers, "reduces_to": targets, }); diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index bdbc1db6..f4e86d85 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")?; @@ -97,7 +106,7 @@ fn solve_problem( result } "ilp" => { - let result = problem.solve_with_ilp()?; + let result = problem.solve_with_ilp().map_err(add_bruteforce_hint)?; let solver_desc = if name == "ILP" { "ilp".to_string() } else { @@ -139,7 +148,7 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) // 2. Solve the target problem let target_result = match solver_name { "brute-force" => target.solve_brute_force()?, - "ilp" => target.solve_with_ilp()?, + "ilp" => target.solve_with_ilp().map_err(add_bruteforce_hint)?, _ => unreachable!(), }; diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 659bba48..100ff450 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -39,22 +39,8 @@ impl std::ops::Deref for LoadedProblem { } impl LoadedProblem { - pub fn solve_brute_force(&self) -> Result { - let (config, evaluation) = self - .inner - .solve_brute_force() - .ok_or_else(|| anyhow::anyhow!("No solution found"))?; - Ok(SolveResult { config, evaluation }) - } - - /// Solve using the ILP solver. If the problem is not ILP, auto-reduce to ILP first. - pub fn solve_with_ilp(&self) -> Result { + fn find_ilp_reduction_path(&self) -> Option { let name = self.problem_name(); - if name == "ILP" { - return solve_ilp(self.as_any()); - } - - // Auto-reduce to ILP, solve, and map solution back let source_variant = self.variant_map(); let graph = ReductionGraph::new(); let ilp_variants = graph.variants_for("ILP"); @@ -79,8 +65,41 @@ impl LoadedProblem { } } - let reduction_path = - best_path.ok_or_else(|| anyhow::anyhow!("No reduction path from {} to ILP", name))?; + best_path + } + + pub fn supports_ilp(&self) -> bool { + self.problem_name() == "ILP" || self.find_ilp_reduction_path().is_some() + } + + pub fn available_solvers(&self) -> Vec<&'static str> { + let mut solvers = Vec::new(); + if self.supports_ilp() { + solvers.push("ilp"); + } + solvers.push("brute-force"); + solvers + } + + pub fn solve_brute_force(&self) -> Result { + let (config, evaluation) = self + .inner + .solve_brute_force() + .ok_or_else(|| anyhow::anyhow!("No solution found"))?; + Ok(SolveResult { config, evaluation }) + } + + /// Solve using the ILP solver. If the problem is not ILP, auto-reduce to ILP first. + pub fn solve_with_ilp(&self) -> Result { + let name = self.problem_name(); + if name == "ILP" { + return solve_ilp(self.as_any()); + } + + let reduction_path = self + .find_ilp_reduction_path() + .ok_or_else(|| anyhow::anyhow!("No reduction path from {} to ILP", name))?; + let graph = ReductionGraph::new(); let chain = graph .reduce_along_path(&reduction_path, self.as_any()) diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index d2dd0fd0..06ae08c6 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 e35a076f..37fbb236 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 d29ba247..9da915d6 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1055,6 +1055,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] @@ -1494,6 +1507,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_evaluate_wrong_config_length() { let problem_file = std::env::temp_dir().join("pred_test_eval_wrong_len.json"); @@ -2469,6 +2653,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/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index 83015b13..869303f4 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -18,6 +18,7 @@ {"problem":"MaximumIndependentSet","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[2,3,null],[3,4,null],[4,0,null],[5,7,null],[7,9,null],[9,6,null],[6,8,null],[8,5,null],[0,5,null],[1,6,null],[2,7,null],[3,8,null],[4,9,null]],"node_holes":[],"nodes":[null,null,null,null,null,null,null,null,null,null]}},"weights":[5,1,1,1,1,3,1,1,1,3]},"samples":[{"config":[1,0,1,0,0,0,0,0,1,1],"metric":{"Valid":10}}],"optimal":[{"config":[1,0,1,0,0,0,0,0,1,1],"metric":{"Valid":10}}]}, {"problem":"MaximumMatching","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,1,1,1,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,0,0,1,0],"metric":{"Valid":2}}],"optimal":[{"config":[0,0,1,0,1,0],"metric":{"Valid":2}},{"config":[0,1,0,0,0,1],"metric":{"Valid":2}},{"config":[0,1,1,0,0,0],"metric":{"Valid":2}},{"config":[1,0,0,0,0,1],"metric":{"Valid":2}},{"config":[1,0,0,0,1,0],"metric":{"Valid":2}},{"config":[1,0,0,1,0,0],"metric":{"Valid":2}}]}, {"problem":"MaximumSetPacking","variant":{"weight":"i32"},"instance":{"sets":[[0,1],[1,2],[2,3],[3,4]],"weights":[1,1,1,1]},"samples":[{"config":[1,0,1,0],"metric":{"Valid":2}}],"optimal":[{"config":[0,1,0,1],"metric":{"Valid":2}},{"config":[1,0,0,1],"metric":{"Valid":2}},{"config":[1,0,1,0],"metric":{"Valid":2}}]}, + {"problem":"MinMaxMulticenter","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"bound":1,"edge_lengths":[1,1,1,1,1,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[2,3,null],[3,4,null],[4,5,null],[0,5,null],[1,4,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}},"k":2,"vertex_weights":[1,1,1,1,1,1]},"samples":[{"config":[0,1,0,0,1,0],"metric":true}],"optimal":[{"config":[0,0,1,0,0,1],"metric":true},{"config":[0,1,0,0,1,0],"metric":true},{"config":[1,0,0,1,0,0],"metric":true}]}, {"problem":"MinimumDominatingSet","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[0,0,1,1,0],"metric":{"Valid":2}}],"optimal":[{"config":[0,0,1,1,0],"metric":{"Valid":2}},{"config":[0,1,0,0,1],"metric":{"Valid":2}},{"config":[0,1,0,1,0],"metric":{"Valid":2}},{"config":[0,1,1,0,0],"metric":{"Valid":2}},{"config":[1,0,0,0,1],"metric":{"Valid":2}},{"config":[1,0,0,1,0],"metric":{"Valid":2}},{"config":[1,0,1,0,0],"metric":{"Valid":2}}]}, {"problem":"MinimumFeedbackVertexSet","variant":{"weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"directed","edges":[[0,1,null],[1,2,null],[2,0,null],[0,3,null],[3,4,null],[4,1,null],[4,2,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[1,0,0,0,0],"metric":{"Valid":1}}],"optimal":[{"config":[0,0,1,0,0],"metric":{"Valid":1}},{"config":[1,0,0,0,0],"metric":{"Valid":1}}]}, {"problem":"MinimumSetCovering","variant":{"weight":"i32"},"instance":{"sets":[[0,1,2],[1,3],[2,3,4]],"universe_size":5,"weights":[1,1,1]},"samples":[{"config":[1,0,1],"metric":{"Valid":2}}],"optimal":[{"config":[1,0,1],"metric":{"Valid":2}}]}, diff --git a/src/lib.rs b/src/lib.rs index 64c77b6f..96225d1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,7 +50,7 @@ pub mod prelude { }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, + MinMaxMulticenter, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, TravelingSalesman, }; diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs new file mode 100644 index 00000000..5e20a544 --- /dev/null +++ b/src/models/graph/min_max_multicenter.rs @@ -0,0 +1,318 @@ +//! 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", + build: || { + 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); + crate::example_db::specs::satisfaction_example(problem, vec![vec![0, 1, 0, 0, 1, 0]]) + }, + }] +} + +#[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 a8ff8a4a..0b8e1101 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -19,6 +19,7 @@ //! - [`BicliqueCover`]: Biclique cover on bipartite graphs //! - [`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) //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) @@ -33,6 +34,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_dominating_set; pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_feedback_vertex_set; @@ -56,6 +58,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_dominating_set::MinimumDominatingSet; pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; @@ -84,6 +87,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 122362ef..c5ecbb61 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -111,6 +111,16 @@ fn test_all_problems_implement_trait_correctly() { ), "MinimumSumMulticenter", ); + 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",