Skip to content

Commit 65de0d3

Browse files
GiggleLiuzazabapclaude
authored
Fix #251: [Model] BoundedComponentSpanningForest (#655)
* Add plan for #251: [Model] BoundedComponentSpanningForest * Implement #251: [Model] BoundedComponentSpanningForest * chore: remove plan file after implementation * fix: address review feedback for bcsf * fix: preserve create variant errors for graph-backed problems * fix: address final review weaknesses - Add CeTZ figure to paper entry showing the 3-component partition with colored vertices, weight labels, and region backgrounds - Relax max_components assertion: K > |V| is mathematically harmless (just means fewer than K components will be used) - Update CLI validation and tests accordingly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove re-introduced trait_consistency.rs Main branch intentionally removed centralized trait tests in PR #676 (commit 9eaa786). The PR branch diverged before that removal, so our merge kept the PR's version. Aligning with main's intent by removing it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: simplify paper figure to avoid hobby/on-layer Use only g-node, g-edge, and content — the same primitives used by other figures in the paper. Removes hobby curve regions that could fail on older CeTZ versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve CI failures — tempdir and dead code - Replace tempdir() with std::env::temp_dir() in CLI test (tempfile crate not available in test scope) - Remove unused cli_flag_name function (superseded by help_flag_name) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: merge cli_flag_name mappings into help_flag_name The cli_flag_name function was superseded by help_flag_name but its general field-name mappings (universe_size->universe, collection->sets, etc.) were still needed. Merge them into help_flag_name and remove the redundant function. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: zazabap <sweynan@icloud.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9eaa786 commit 65de0d3

11 files changed

Lines changed: 877 additions & 48 deletions

File tree

docs/paper/reductions.typ

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"BMF": [Boolean Matrix Factorization],
9393
"PaintShop": [Paint Shop],
9494
"BicliqueCover": [Biclique Cover],
95+
"BoundedComponentSpanningForest": [Bounded Component Spanning Forest],
9596
"BinPacking": [Bin Packing],
9697
"ClosestVectorProblem": [Closest Vector Problem],
9798
"OptimalLinearArrangement": [Optimal Linear Arrangement],
@@ -535,6 +536,56 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co
535536
caption: [Graph with $n = 6$ vertices partitioned into $A = {v_0, v_1, v_2}$ (blue) and $B = {v_3, v_4, v_5}$ (red). The 3 crossing edges $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$ are shown in bold red; internal edges are gray.],
536537
) <fig:graph-partitioning>
537538
]
539+
540+
#problem-def("BoundedComponentSpanningForest")[
541+
Given an undirected graph $G = (V, E)$ with vertex weights $w: V -> ZZ_(gt.eq 0)$, a positive integer $K <= |V|$, and a positive bound $B$, determine whether there exists a partition of $V$ into $t$ non-empty sets $V_1, dots, V_t$ with $1 <= t <= K$ such that each induced subgraph $G[V_i]$ is connected and each part satisfies $sum_(v in V_i) w(v) <= B$.
542+
][
543+
Bounded Component Spanning Forest appears as ND10 in Garey and Johnson @garey1979. It asks for a decomposition into a bounded number of connected pieces, each with bounded total weight, so it naturally captures contiguous districting and redistricting-style constraints where each district must remain connected while respecting a population cap. A direct exhaustive search over component labels gives an $O^*(K^n)$ baseline, but subset-DP techniques via inclusion-exclusion improve the exact running time to $O^*(3^n)$ @bjorklund2009.
544+
545+
*Example.* Consider the graph on vertices ${v_0, v_1, dots, v_7}$ with edges $(v_0, v_1)$, $(v_1, v_2)$, $(v_2, v_3)$, $(v_3, v_4)$, $(v_4, v_5)$, $(v_5, v_6)$, $(v_6, v_7)$, $(v_0, v_7)$, $(v_1, v_5)$, $(v_2, v_6)$; vertex weights $(2, 3, 1, 2, 3, 1, 2, 1)$; component limit $K = 3$; and bound $B = 6$. The partition
546+
$V_1 = {v_0, v_1, v_7}$,
547+
$V_2 = {v_2, v_3, v_4}$,
548+
$V_3 = {v_5, v_6}$
549+
is feasible: each set induces a connected subgraph, the component weights are $2 + 3 + 1 = 6$, $1 + 2 + 3 = 6$, and $1 + 2 = 3$, and exactly three non-empty components are used. Therefore this instance is a YES instance.
550+
551+
#figure(
552+
canvas(length: 1cm, {
553+
import draw: *
554+
// 8 vertices in a circular layout (radius 1.6)
555+
let r = 1.6
556+
let verts = range(8).map(k => {
557+
let angle = 90deg - k * 45deg
558+
(calc.cos(angle) * r, calc.sin(angle) * r)
559+
})
560+
let weights = (2, 3, 1, 2, 3, 1, 2, 1)
561+
let edges = ((0,1),(1,2),(2,3),(3,4),(4,5),(5,6),(6,7),(0,7),(1,5),(2,6))
562+
// Partition: V1={0,1,7} blue, V2={2,3,4} green, V3={5,6} red
563+
let partition = (0, 0, 1, 1, 1, 2, 2, 0)
564+
let comp-colors = (graph-colors.at(0), graph-colors.at(2), graph-colors.at(1))
565+
// Draw edges: bold colored for intra-component, gray for cross-component
566+
for (u, v) in edges {
567+
if partition.at(u) == partition.at(v) {
568+
g-edge(verts.at(u), verts.at(v),
569+
stroke: 2pt + comp-colors.at(partition.at(u)))
570+
} else {
571+
g-edge(verts.at(u), verts.at(v),
572+
stroke: 1pt + luma(180))
573+
}
574+
}
575+
// Draw nodes colored by partition, with weight labels
576+
for (k, pos) in verts.enumerate() {
577+
let c = comp-colors.at(partition.at(k))
578+
g-node(pos, name: "v" + str(k),
579+
fill: c,
580+
label: text(fill: white)[$v_#k$])
581+
let angle = 90deg - k * 45deg
582+
let lpos = (calc.cos(angle) * (r + 0.5), calc.sin(angle) * (r + 0.5))
583+
content(lpos, text(7pt)[$w = #(weights.at(k))$])
584+
}
585+
}),
586+
caption: [Bounded Component Spanning Forest on 8 vertices with $K = 3$ and $B = 6$. The partition $V_1 = {v_0, v_1, v_7}$ (blue, weight 6), $V_2 = {v_2, v_3, v_4}$ (green, weight 6), $V_3 = {v_5, v_6}$ (red, weight 3) is feasible. Bold colored edges are intra-component; gray edges cross components.],
587+
) <fig:bcsf>
588+
]
538589
#{
539590
let x = load-model-example("LengthBoundedDisjointPaths")
540591
let nv = graph-num-vertices(x.instance)

docs/src/cli.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,27 @@ The output file uses a standard wrapper format:
334334
}
335335
```
336336

337+
#### Example: Bounded Component Spanning Forest
338+
339+
`BoundedComponentSpanningForest` uses one component label per vertex in the
340+
evaluation config. If the graph has `n` vertices and limit `k`, then
341+
`--config` expects `n` comma-separated integers in `0..k-1`.
342+
343+
```bash
344+
pred create BoundedComponentSpanningForest \
345+
--graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 \
346+
--weights 2,3,1,2,3,1,2,1 \
347+
--k 3 \
348+
--bound 6 \
349+
-o bcsf.json
350+
351+
pred evaluate bcsf.json --config 0,0,1,1,1,2,2,0
352+
pred solve bcsf.json --solver brute-force
353+
```
354+
355+
The brute-force solver is required here because this model does not yet have an
356+
ILP reduction path.
357+
337358
### `pred evaluate` — Evaluate a configuration
338359

339360
Evaluate a configuration against a problem instance:
@@ -439,8 +460,8 @@ Source evaluation: Valid(2)
439460
```
440461

441462
> **Note:** The ILP solver requires a reduction path from the target problem to ILP.
442-
> `LengthBoundedDisjointPaths` does not currently have one, so use
443-
> `pred solve lbdp.json --solver brute-force`.
463+
> Some problems (e.g., BoundedComponentSpanningForest, LengthBoundedDisjointPaths) do not currently have one, so use
464+
> `pred solve <file> --solver brute-force` for these.
444465
> For other problems, use `pred path <PROBLEM> ILP` to check whether an ILP reduction path exists.
445466
446467
## Shell Completions

problemreductions-cli/src/cli.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ Flags by problem type:
223223
KColoring --graph, --k
224224
PartitionIntoTriangles --graph
225225
GraphPartitioning --graph
226+
BoundedComponentSpanningForest --graph, --weights, --k, --bound
226227
UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2
227228
IsomorphicSpanningTree --graph, --tree
228229
LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound
@@ -418,7 +419,7 @@ pub struct CreateArgs {
418419
/// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4")
419420
#[arg(long)]
420421
pub required_edges: Option<String>,
421-
/// Upper bound or length bound (for LengthBoundedDisjointPaths, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, or SCS)
422+
/// Upper bound or length bound (for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, or SCS)
422423
#[arg(long, allow_hyphen_values = true)]
423424
pub bound: Option<i64>,
424425
/// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0)

problemreductions-cli/src/commands/create.rs

Lines changed: 126 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::cli::{CreateArgs, ExampleSide};
22
use crate::dispatch::ProblemJsonOutput;
33
use crate::output::OutputConfig;
4-
use crate::problem_name::{resolve_problem_ref, unknown_problem_error};
4+
use crate::problem_name::{
5+
parse_problem_spec, resolve_catalog_problem_ref, resolve_problem_ref, unknown_problem_error,
6+
};
57
use crate::util;
68
use anyhow::{bail, Context, Result};
79
use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample};
@@ -236,22 +238,6 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str {
236238
}
237239
}
238240

239-
fn cli_flag_name(field_name: &str) -> String {
240-
match field_name {
241-
"universe_size" => "universe".to_string(),
242-
"collection" | "subsets" => "sets".to_string(),
243-
"left_size" => "left".to_string(),
244-
"right_size" => "right".to_string(),
245-
"edges" => "biedges".to_string(),
246-
"vertex_weights" => "weights".to_string(),
247-
"edge_lengths" => "edge-weights".to_string(),
248-
"num_tasks" => "n".to_string(),
249-
"precedences" => "precedence-pairs".to_string(),
250-
"threshold" => "bound".to_string(),
251-
_ => field_name.replace('_', "-"),
252-
}
253-
}
254-
255241
fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
256242
match canonical {
257243
"MaximumIndependentSet"
@@ -264,6 +250,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
264250
_ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1",
265251
},
266252
"GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3",
253+
"BoundedComponentSpanningForest" => {
254+
"--graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6"
255+
}
267256
"HamiltonianPath" => "--graph 0-1,1-2,2-3",
268257
"UndirectedTwoCommodityIntegralFlow" => {
269258
"--graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"
@@ -305,6 +294,46 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
305294
}
306295
}
307296

297+
fn help_flag_name(canonical: &str, field_name: &str) -> String {
298+
// Problem-specific overrides first
299+
match (canonical, field_name) {
300+
("BoundedComponentSpanningForest", "max_components") => return "k".to_string(),
301+
("BoundedComponentSpanningForest", "max_weight") => return "bound".to_string(),
302+
_ => {}
303+
}
304+
// General field-name overrides (previously in cli_flag_name)
305+
match field_name {
306+
"universe_size" => "universe".to_string(),
307+
"collection" | "subsets" => "sets".to_string(),
308+
"left_size" => "left".to_string(),
309+
"right_size" => "right".to_string(),
310+
"edges" => "biedges".to_string(),
311+
"vertex_weights" => "weights".to_string(),
312+
"edge_lengths" => "edge-weights".to_string(),
313+
"num_tasks" => "n".to_string(),
314+
"precedences" => "precedence-pairs".to_string(),
315+
"threshold" => "bound".to_string(),
316+
_ => field_name.replace('_', "-"),
317+
}
318+
}
319+
320+
fn help_flag_hint(
321+
canonical: &str,
322+
field_name: &str,
323+
type_name: &str,
324+
graph_type: Option<&str>,
325+
) -> &'static str {
326+
match (canonical, field_name) {
327+
("BoundedComponentSpanningForest", "max_weight") => "integer",
328+
_ => type_format_hint(type_name, graph_type),
329+
}
330+
}
331+
332+
fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> Result<usize> {
333+
usize::try_from(bound)
334+
.map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}"))
335+
}
336+
308337
fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> {
309338
let is_geometry = matches!(
310339
graph_type,
@@ -331,10 +360,10 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> {
331360
let hint = type_format_hint(&field.type_name, graph_type);
332361
eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint);
333362
} else {
334-
let hint = type_format_hint(&field.type_name, graph_type);
363+
let hint = help_flag_hint(canonical, &field.name, &field.type_name, graph_type);
335364
eprintln!(
336365
" --{:<16} {} ({})",
337-
cli_flag_name(&field.name),
366+
help_flag_name(canonical, &field.name),
338367
field.description,
339368
hint
340369
);
@@ -439,9 +468,30 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
439468
anyhow::anyhow!("Missing problem type.\n\nUsage: pred create <PROBLEM> [FLAGS]")
440469
})?;
441470
let rgraph = problemreductions::rules::ReductionGraph::new();
442-
let resolved = resolve_problem_ref(problem, &rgraph)?;
443-
let canonical = &resolved.name;
444-
let resolved_variant = resolved.variant;
471+
let resolved = match resolve_problem_ref(problem, &rgraph) {
472+
Ok(resolved) => resolved,
473+
Err(graph_err) => match resolve_catalog_problem_ref(problem) {
474+
Ok(catalog_resolved) => {
475+
if rgraph.variants_for(catalog_resolved.name()).is_empty() {
476+
ProblemRef {
477+
name: catalog_resolved.name().to_string(),
478+
variant: catalog_resolved.variant().clone(),
479+
}
480+
} else {
481+
return Err(graph_err);
482+
}
483+
}
484+
Err(catalog_err) => {
485+
let spec = parse_problem_spec(problem)?;
486+
if rgraph.variants_for(&spec.name).is_empty() {
487+
return Err(catalog_err);
488+
}
489+
return Err(graph_err);
490+
}
491+
},
492+
};
493+
let canonical = resolved.name.as_str();
494+
let resolved_variant = resolved.variant.clone();
445495
let graph_type = resolved_graph_type(&resolved_variant);
446496

447497
if args.random {
@@ -470,7 +520,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
470520
std::process::exit(2);
471521
}
472522

473-
let (data, variant) = match canonical.as_str() {
523+
let (data, variant) = match canonical {
474524
// Graph problems with vertex weights
475525
"MaximumIndependentSet"
476526
| "MinimumVertexCover"
@@ -505,6 +555,45 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
505555
)
506556
}
507557

558+
// Bounded Component Spanning Forest
559+
"BoundedComponentSpanningForest" => {
560+
let usage = "Usage: pred create BoundedComponentSpanningForest --graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6";
561+
let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
562+
args.weights.as_deref().ok_or_else(|| {
563+
anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}")
564+
})?;
565+
let weights = parse_vertex_weights(args, n)?;
566+
if weights.iter().any(|&weight| weight < 0) {
567+
bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}");
568+
}
569+
let max_components = args.k.ok_or_else(|| {
570+
anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}")
571+
})?;
572+
if max_components == 0 {
573+
bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}");
574+
}
575+
let bound_raw = args.bound.ok_or_else(|| {
576+
anyhow::anyhow!("BoundedComponentSpanningForest requires --bound\n\n{usage}")
577+
})?;
578+
if bound_raw <= 0 {
579+
bail!("BoundedComponentSpanningForest requires positive --bound\n\n{usage}");
580+
}
581+
let max_weight = i32::try_from(bound_raw).map_err(|_| {
582+
anyhow::anyhow!(
583+
"BoundedComponentSpanningForest requires --bound within i32 range\n\n{usage}"
584+
)
585+
})?;
586+
(
587+
ser(BoundedComponentSpanningForest::new(
588+
graph,
589+
weights,
590+
max_components,
591+
max_weight,
592+
))?,
593+
resolved_variant.clone(),
594+
)
595+
}
596+
508597
// Hamiltonian path (graph only, no weights)
509598
"HamiltonianPath" => {
510599
let (graph, _) = parse_graph(args).map_err(|e| {
@@ -651,7 +740,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
651740
)
652741
})?;
653742
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
654-
let data = match canonical.as_str() {
743+
let data = match canonical {
655744
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
656745
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
657746
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
@@ -1125,17 +1214,15 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
11251214

11261215
// OptimalLinearArrangement — graph + bound
11271216
"OptimalLinearArrangement" => {
1128-
let (graph, _) = parse_graph(args).map_err(|e| {
1217+
let usage = "Usage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3 --bound 5";
1218+
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
1219+
let bound_raw = args.bound.ok_or_else(|| {
11291220
anyhow::anyhow!(
1130-
"{e}\n\nUsage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3 --bound 5"
1221+
"OptimalLinearArrangement requires --bound (upper bound K on total edge length)\n\n{usage}"
11311222
)
11321223
})?;
1133-
let bound = args.bound.ok_or_else(|| {
1134-
anyhow::anyhow!(
1135-
"OptimalLinearArrangement requires --bound (upper bound K on total edge length)\n\n\
1136-
Usage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3 --bound 5"
1137-
)
1138-
})? as usize;
1224+
let bound =
1225+
parse_nonnegative_usize_bound(bound_raw, "OptimalLinearArrangement", usage)?;
11391226
(
11401227
ser(OptimalLinearArrangement::new(graph, bound))?,
11411228
resolved_variant.clone(),
@@ -1360,9 +1447,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
13601447
let strings_str = args.strings.as_deref().ok_or_else(|| {
13611448
anyhow::anyhow!("ShortestCommonSupersequence requires --strings\n\n{usage}")
13621449
})?;
1363-
let bound = args.bound.ok_or_else(|| {
1450+
let bound_raw = args.bound.ok_or_else(|| {
13641451
anyhow::anyhow!("ShortestCommonSupersequence requires --bound\n\n{usage}")
1365-
})? as usize;
1452+
})?;
1453+
let bound =
1454+
parse_nonnegative_usize_bound(bound_raw, "ShortestCommonSupersequence", usage)?;
13661455
let strings: Vec<Vec<usize>> = strings_str
13671456
.split(';')
13681457
.map(|s| {
@@ -2263,9 +2352,11 @@ fn create_random(
22632352
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
22642353
// Default bound: (n-1) * num_edges ensures satisfiability (max edge stretch is n-1)
22652354
let n = graph.num_vertices();
2355+
let usage = "Usage: pred create OptimalLinearArrangement --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] [--bound 10]";
22662356
let bound = args
22672357
.bound
2268-
.map(|b| b as usize)
2358+
.map(|b| parse_nonnegative_usize_bound(b, "OptimalLinearArrangement", usage))
2359+
.transpose()?
22692360
.unwrap_or((n.saturating_sub(1)) * graph.num_edges());
22702361
let variant = variant_map(&[("graph", "SimpleGraph")]);
22712362
(ser(OptimalLinearArrangement::new(graph, bound))?, variant)

0 commit comments

Comments
 (0)