Skip to content

Commit cb32085

Browse files
zazabapGiggleLiuclaude
authored
Fix #330: Add MinMaxMulticenter (p-Center) model (#630)
* Add plan for #330: MinMaxMulticenter model * Add MinMaxMulticenter (vertex p-center) satisfaction problem model Implements the vertex-restricted p-center problem as a satisfaction problem: given a graph with vertex weights, edge lengths, K centers, and distance bound B, determine if K vertices can be chosen such that max{w(v)*d(v)} <= B. Includes model, unit tests (15 tests), CLI creation support, canonical example, and regenerated fixtures/schemas. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add MinMaxMulticenter problem-def to paper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Review fixes: add trait_consistency entry and non-unit edge length test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove plan file after implementation * fix: address PR #630 review findings * fix: tighten PR #630 MinMaxMulticenter CLI review follow-ups * Fix stale API usage after merging main - Update canonical_model_example_specs to use new ModelExampleSpec struct fields (instance, optimal_config, optimal_value) instead of the old callback-based API - Fix paper graph access: graph.edges instead of graph.inner.edges - Fix paper optimal access: use optimal_config/optimal_value instead of old optimal array API - Remove count of satisfying solutions (not available in new export) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add MinMaxMulticenter to brute-force-only hint in solve help MinMaxMulticenter has no ILP reduction path, so it needs --solver brute-force. Add it to the solve help text alongside LengthBoundedDisjointPaths and StringToStringCorrection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: GiggleLiu <cacate0129@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1d7128b commit cb32085

14 files changed

Lines changed: 942 additions & 8 deletions

File tree

docs/paper/reductions.typ

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"ConsecutiveBlockMinimization": [Consecutive Block Minimization],
121121
"ConsecutiveOnesSubmatrix": [Consecutive Ones Submatrix],
122122
"DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow],
123+
"MinMaxMulticenter": [Min-Max Multicenter],
123124
"FlowShopScheduling": [Flow Shop Scheduling],
124125
"MinimumCutIntoBoundedSets": [Minimum Cut Into Bounded Sets],
125126
"MinimumSumMulticenter": [Minimum Sum Multicenter],
@@ -1616,6 +1617,49 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76],
16161617
]
16171618
}
16181619

1620+
#{
1621+
let x = load-model-example("MinMaxMulticenter")
1622+
let nv = graph-num-vertices(x.instance)
1623+
let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1)))
1624+
let K = x.instance.k
1625+
let B = x.instance.bound
1626+
let sol = (config: x.optimal_config, metric: x.optimal_value)
1627+
let centers = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
1628+
[
1629+
#problem-def("MinMaxMulticenter")[
1630+
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$?
1631+
][
1632+
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).
1633+
1634+
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).
1635+
1636+
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$.
1637+
1638+
*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.
1639+
1640+
#figure({
1641+
let blue = graph-colors.at(0)
1642+
let gray = luma(200)
1643+
canvas(length: 1cm, {
1644+
import draw: *
1645+
let verts = ((-1.5, 0.8), (0, 1.5), (1.5, 0.8), (1.5, -0.8), (0, -1.5), (-1.5, -0.8))
1646+
for (u, v) in edges {
1647+
g-edge(verts.at(u), verts.at(v), stroke: 1pt + gray)
1648+
}
1649+
for (k, pos) in verts.enumerate() {
1650+
let is-center = centers.any(c => c == k)
1651+
g-node(pos, name: "v" + str(k),
1652+
fill: if is-center { blue } else { white },
1653+
label: if is-center { text(fill: white)[$v_#k$] } else { [$v_#k$] })
1654+
}
1655+
})
1656+
},
1657+
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.],
1658+
) <fig:min-max-multicenter>
1659+
]
1660+
]
1661+
}
1662+
16191663
#{
16201664
let x = load-model-example("MultipleCopyFileAllocation")
16211665
let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1)))

docs/src/cli.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json
353353
pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3 -o kth.json
354354
pred create SpinGlass --graph 0-1,1-2 -o sg.json
355355
pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json
356+
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
356357
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
357358
pred solve rpc.json --solver brute-force
358359
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 `-`:
514515
```bash
515516
pred create MIS --graph 0-1,1-2,2-3 | pred solve -
516517
pred create MIS --graph 0-1,1-2,2-3 | pred solve - --solver brute-force
518+
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
517519
pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets "0,1,2;3,4,5;1,3;2,4;0,5" | pred solve - --solver brute-force
518520
```
519521
@@ -541,7 +543,7 @@ Source evaluation: Valid(2)
541543
542544
> **Note:** The ILP solver requires a reduction path from the target problem to ILP.
543545
> Some problems do not currently have one. Examples include BoundedComponentSpanningForest,
544-
> LengthBoundedDisjointPaths, MinimumCardinalityKey, QUBO, SpinGlass, MaxCut, CircuitSAT, and MultiprocessorScheduling.
546+
> LengthBoundedDisjointPaths, MinimumCardinalityKey, QUBO, SpinGlass, MaxCut, CircuitSAT, MinMaxMulticenter, and MultiprocessorScheduling.
545547
> Use `pred solve <file> --solver brute-force` for these, or reduce to a problem that supports ILP first.
546548
> For other problems, use `pred path <PROBLEM> ILP` to check whether an ILP reduction path exists.
547549

problemreductions-cli/src/cli.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ Flags by problem type:
258258
MultiprocessorScheduling --lengths, --num-processors, --deadline
259259
SequencingWithinIntervals --release-times, --deadlines, --lengths
260260
OptimalLinearArrangement --graph, --bound
261+
MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound
261262
RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound
262263
MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices]
263264
AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys]
@@ -622,8 +623,8 @@ Solve via explicit reduction:
622623
Input: a problem JSON from `pred create`, or a reduction bundle from `pred reduce`.
623624
When given a bundle, the target is solved and the solution is mapped back to the source.
624625
The ILP solver auto-reduces non-ILP problems before solving.
625-
Problems without an ILP reduction path, such as `LengthBoundedDisjointPaths` and
626-
`StringToStringCorrection`, currently need `--solver brute-force`.
626+
Problems without an ILP reduction path, such as `LengthBoundedDisjointPaths`,
627+
`MinMaxMulticenter`, and `StringToStringCorrection`, currently need `--solver brute-force`.
627628
628629
ILP backend (default: HiGHS). To use a different backend:
629630
cargo install problemreductions-cli --features coin-cbc

problemreductions-cli/src/commands/create.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,14 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str {
360360
}
361361
}
362362

363+
fn cli_flag_name(field_name: &str) -> String {
364+
match field_name {
365+
"vertex_weights" => "weights".to_string(),
366+
"edge_lengths" => "edge-weights".to_string(),
367+
_ => field_name.replace('_', "-"),
368+
}
369+
}
370+
363371
fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
364372
match canonical {
365373
"MaximumIndependentSet"
@@ -405,6 +413,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
405413
"SpinGlass" => "--graph 0-1,1-2 --couplings 1,1",
406414
"KColoring" => "--graph 0-1,1-2,2-0 --k 3",
407415
"HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0",
416+
"MinMaxMulticenter" => {
417+
"--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 2"
418+
}
408419
"MinimumSumMulticenter" => {
409420
"--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2"
410421
}
@@ -2608,6 +2619,50 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
26082619
)
26092620
}
26102621

2622+
// MinMaxMulticenter (vertex p-center)
2623+
"MinMaxMulticenter" => {
2624+
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";
2625+
let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
2626+
let vertex_weights = parse_vertex_weights(args, n)?;
2627+
let edge_lengths = parse_edge_weights(args, graph.num_edges())?;
2628+
let k = args.k.ok_or_else(|| {
2629+
anyhow::anyhow!(
2630+
"MinMaxMulticenter requires --k (number of centers)\n\n\
2631+
Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2 --bound 2"
2632+
)
2633+
})?;
2634+
let bound = args.bound.ok_or_else(|| {
2635+
anyhow::anyhow!(
2636+
"MinMaxMulticenter requires --bound (distance bound B)\n\n\
2637+
Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2 --bound 2"
2638+
)
2639+
})?;
2640+
let bound = i32::try_from(bound).map_err(|_| {
2641+
anyhow::anyhow!(
2642+
"MinMaxMulticenter --bound must fit in i32 (got {bound})\n\n{usage}"
2643+
)
2644+
})?;
2645+
if vertex_weights.iter().any(|&weight| weight < 0) {
2646+
bail!("MinMaxMulticenter --weights must be non-negative");
2647+
}
2648+
if edge_lengths.iter().any(|&length| length < 0) {
2649+
bail!("MinMaxMulticenter --edge-weights must be non-negative");
2650+
}
2651+
if bound < 0 {
2652+
bail!("MinMaxMulticenter --bound must be non-negative");
2653+
}
2654+
(
2655+
ser(MinMaxMulticenter::new(
2656+
graph,
2657+
vertex_weights,
2658+
edge_lengths,
2659+
k,
2660+
bound,
2661+
))?,
2662+
resolved_variant.clone(),
2663+
)
2664+
}
2665+
26112666
// StrongConnectivityAugmentation
26122667
"StrongConnectivityAugmentation" => {
26132668
let usage = "Usage: pred create StrongConnectivityAugmentation --arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1 [--num-vertices N]";

problemreductions-cli/src/commands/solve.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ enum SolveInput {
1313
Bundle(ReductionBundle),
1414
}
1515

16+
fn add_bruteforce_hint(err: anyhow::Error) -> anyhow::Error {
17+
let message = err.to_string();
18+
if message.contains("No reduction path from") {
19+
anyhow::anyhow!("{message}\n\nTry: pred solve <INPUT> --solver brute-force")
20+
} else {
21+
err
22+
}
23+
}
24+
1625
fn parse_input(path: &Path) -> Result<SolveInput> {
1726
let content = read_input(path)?;
1827
let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse JSON")?;

problemreductions-cli/src/mcp/tests.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,41 @@ mod tests {
353353
assert_eq!(json["source"], "MaximumIndependentSet");
354354
}
355355

356+
#[test]
357+
fn test_inspect_minmaxmulticenter_lists_bruteforce_only() {
358+
let server = McpServer::new();
359+
let problem_json = serde_json::json!({
360+
"type": "MinMaxMulticenter",
361+
"variant": {"graph": "SimpleGraph", "weight": "i32"},
362+
"data": {
363+
"graph": {
364+
"inner": {
365+
"nodes": [null, null, null, null],
366+
"node_holes": [],
367+
"edge_property": "undirected",
368+
"edges": [[0, 1, null], [1, 2, null], [2, 3, null]]
369+
}
370+
},
371+
"vertex_weights": [1, 1, 1, 1],
372+
"edge_lengths": [1, 1, 1],
373+
"k": 2,
374+
"bound": 1
375+
}
376+
})
377+
.to_string();
378+
379+
let result = server.inspect_problem_inner(&problem_json);
380+
assert!(result.is_ok());
381+
let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
382+
let solvers: Vec<&str> = json["solvers"]
383+
.as_array()
384+
.unwrap()
385+
.iter()
386+
.map(|v| v.as_str().unwrap())
387+
.collect();
388+
assert_eq!(solvers, vec!["brute-force"]);
389+
}
390+
356391
#[test]
357392
fn test_solve_sat_problem() {
358393
let server = McpServer::new();

problemreductions-cli/src/mcp/tools.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,14 +690,15 @@ impl McpServer {
690690
let mut targets: Vec<String> = outgoing.iter().map(|e| e.target_name.to_string()).collect();
691691
targets.sort();
692692
targets.dedup();
693+
let solvers = problem.available_solvers();
693694

694695
let result = serde_json::json!({
695696
"kind": "problem",
696697
"type": name,
697698
"variant": variant,
698699
"size_fields": size_fields,
699700
"num_variables": problem.num_variables_dyn(),
700-
"solvers": ["ilp", "brute-force"],
701+
"solvers": solvers,
701702
"reduces_to": targets,
702703
});
703704
Ok(serde_json::to_string_pretty(&result)?)

0 commit comments

Comments
 (0)