Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"BMF": [Boolean Matrix Factorization],
"PaintShop": [Paint Shop],
"BicliqueCover": [Biclique Cover],
"BoundedComponentSpanningForest": [Bounded Component Spanning Forest],
"BinPacking": [Bin Packing],
"ClosestVectorProblem": [Closest Vector Problem],
"OptimalLinearArrangement": [Optimal Linear Arrangement],
Expand Down Expand Up @@ -535,6 +536,56 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co
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.],
) <fig:graph-partitioning>
]

#problem-def("BoundedComponentSpanningForest")[
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$.
][
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.

Comment on lines +540 to +544
*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
$V_1 = {v_0, v_1, v_7}$,
$V_2 = {v_2, v_3, v_4}$,
$V_3 = {v_5, v_6}$
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.

#figure(
canvas(length: 1cm, {
import draw: *
// 8 vertices in a circular layout (radius 1.6)
let r = 1.6
let verts = range(8).map(k => {
let angle = 90deg - k * 45deg
(calc.cos(angle) * r, calc.sin(angle) * r)
})
let weights = (2, 3, 1, 2, 3, 1, 2, 1)
let edges = ((0,1),(1,2),(2,3),(3,4),(4,5),(5,6),(6,7),(0,7),(1,5),(2,6))
// Partition: V1={0,1,7} blue, V2={2,3,4} green, V3={5,6} red
let partition = (0, 0, 1, 1, 1, 2, 2, 0)
let comp-colors = (graph-colors.at(0), graph-colors.at(2), graph-colors.at(1))
// Draw edges: bold colored for intra-component, gray for cross-component
for (u, v) in edges {
if partition.at(u) == partition.at(v) {
g-edge(verts.at(u), verts.at(v),
stroke: 2pt + comp-colors.at(partition.at(u)))
} else {
g-edge(verts.at(u), verts.at(v),
stroke: 1pt + luma(180))
}
}
// Draw nodes colored by partition, with weight labels
for (k, pos) in verts.enumerate() {
let c = comp-colors.at(partition.at(k))
g-node(pos, name: "v" + str(k),
fill: c,
label: text(fill: white)[$v_#k$])
let angle = 90deg - k * 45deg
let lpos = (calc.cos(angle) * (r + 0.5), calc.sin(angle) * (r + 0.5))
content(lpos, text(7pt)[$w = #(weights.at(k))$])
}
}),
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.],
) <fig:bcsf>
]
#{
let x = load-model-example("LengthBoundedDisjointPaths")
let nv = graph-num-vertices(x.instance)
Expand Down
25 changes: 23 additions & 2 deletions docs/src/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,27 @@ The output file uses a standard wrapper format:
}
```

#### Example: Bounded Component Spanning Forest

`BoundedComponentSpanningForest` uses one component label per vertex in the
evaluation config. If the graph has `n` vertices and limit `k`, then
`--config` expects `n` comma-separated integers in `0..k-1`.

```bash
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 \
-o bcsf.json

pred evaluate bcsf.json --config 0,0,1,1,1,2,2,0
pred solve bcsf.json --solver brute-force
```

The brute-force solver is required here because this model does not yet have an
ILP reduction path.

### `pred evaluate` — Evaluate a configuration

Evaluate a configuration against a problem instance:
Expand Down Expand Up @@ -439,8 +460,8 @@ Source evaluation: Valid(2)
```

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

## Shell Completions
Expand Down
3 changes: 2 additions & 1 deletion problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ Flags by problem type:
KColoring --graph, --k
PartitionIntoTriangles --graph
GraphPartitioning --graph
BoundedComponentSpanningForest --graph, --weights, --k, --bound
UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2
IsomorphicSpanningTree --graph, --tree
LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound
Expand Down Expand Up @@ -418,7 +419,7 @@ pub struct CreateArgs {
/// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4")
#[arg(long)]
pub required_edges: Option<String>,
/// Upper bound or length bound (for LengthBoundedDisjointPaths, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, or SCS)
/// Upper bound or length bound (for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, or SCS)
#[arg(long, allow_hyphen_values = true)]
pub bound: Option<i64>,
/// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0)
Expand Down
161 changes: 126 additions & 35 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use crate::cli::{CreateArgs, ExampleSide};
use crate::dispatch::ProblemJsonOutput;
use crate::output::OutputConfig;
use crate::problem_name::{resolve_problem_ref, unknown_problem_error};
use crate::problem_name::{
parse_problem_spec, resolve_catalog_problem_ref, resolve_problem_ref, unknown_problem_error,
};
use crate::util;
use anyhow::{bail, Context, Result};
use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample};
Expand Down Expand Up @@ -236,22 +238,6 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str {
}
}

fn cli_flag_name(field_name: &str) -> String {
match field_name {
"universe_size" => "universe".to_string(),
"collection" | "subsets" => "sets".to_string(),
"left_size" => "left".to_string(),
"right_size" => "right".to_string(),
"edges" => "biedges".to_string(),
"vertex_weights" => "weights".to_string(),
"edge_lengths" => "edge-weights".to_string(),
"num_tasks" => "n".to_string(),
"precedences" => "precedence-pairs".to_string(),
"threshold" => "bound".to_string(),
_ => field_name.replace('_', "-"),
}
}

fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
match canonical {
"MaximumIndependentSet"
Expand All @@ -264,6 +250,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
_ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1",
},
"GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3",
"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"
}
"HamiltonianPath" => "--graph 0-1,1-2,2-3",
"UndirectedTwoCommodityIntegralFlow" => {
"--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"
Expand Down Expand Up @@ -305,6 +294,46 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
}
}

fn help_flag_name(canonical: &str, field_name: &str) -> String {
// Problem-specific overrides first
match (canonical, field_name) {
("BoundedComponentSpanningForest", "max_components") => return "k".to_string(),
("BoundedComponentSpanningForest", "max_weight") => return "bound".to_string(),
_ => {}
}
// General field-name overrides (previously in cli_flag_name)
match field_name {
"universe_size" => "universe".to_string(),
"collection" | "subsets" => "sets".to_string(),
"left_size" => "left".to_string(),
"right_size" => "right".to_string(),
"edges" => "biedges".to_string(),
"vertex_weights" => "weights".to_string(),
"edge_lengths" => "edge-weights".to_string(),
"num_tasks" => "n".to_string(),
"precedences" => "precedence-pairs".to_string(),
"threshold" => "bound".to_string(),
_ => field_name.replace('_', "-"),
}
}

fn help_flag_hint(
canonical: &str,
field_name: &str,
type_name: &str,
graph_type: Option<&str>,
) -> &'static str {
match (canonical, field_name) {
("BoundedComponentSpanningForest", "max_weight") => "integer",
_ => type_format_hint(type_name, graph_type),
}
}

fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> Result<usize> {
usize::try_from(bound)
.map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}"))
}

fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> {
let is_geometry = matches!(
graph_type,
Expand All @@ -331,10 +360,10 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> {
let hint = type_format_hint(&field.type_name, graph_type);
eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint);
} else {
let hint = type_format_hint(&field.type_name, graph_type);
let hint = help_flag_hint(canonical, &field.name, &field.type_name, graph_type);
eprintln!(
" --{:<16} {} ({})",
cli_flag_name(&field.name),
help_flag_name(canonical, &field.name),
field.description,
hint
);
Expand Down Expand Up @@ -439,9 +468,30 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
anyhow::anyhow!("Missing problem type.\n\nUsage: pred create <PROBLEM> [FLAGS]")
})?;
let rgraph = problemreductions::rules::ReductionGraph::new();
let resolved = resolve_problem_ref(problem, &rgraph)?;
let canonical = &resolved.name;
let resolved_variant = resolved.variant;
let resolved = match resolve_problem_ref(problem, &rgraph) {
Ok(resolved) => resolved,
Err(graph_err) => match resolve_catalog_problem_ref(problem) {
Ok(catalog_resolved) => {
if rgraph.variants_for(catalog_resolved.name()).is_empty() {
ProblemRef {
name: catalog_resolved.name().to_string(),
variant: catalog_resolved.variant().clone(),
}
} else {
return Err(graph_err);
}
}
Err(catalog_err) => {
let spec = parse_problem_spec(problem)?;
if rgraph.variants_for(&spec.name).is_empty() {
return Err(catalog_err);
}
return Err(graph_err);
}
},
};
let canonical = resolved.name.as_str();
let resolved_variant = resolved.variant.clone();
let graph_type = resolved_graph_type(&resolved_variant);

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

let (data, variant) = match canonical.as_str() {
let (data, variant) = match canonical {
// Graph problems with vertex weights
"MaximumIndependentSet"
| "MinimumVertexCover"
Expand Down Expand Up @@ -505,6 +555,45 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
)
}

// Bounded Component Spanning Forest
"BoundedComponentSpanningForest" => {
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";
let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
args.weights.as_deref().ok_or_else(|| {
anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}")
})?;
let weights = parse_vertex_weights(args, n)?;
if weights.iter().any(|&weight| weight < 0) {
bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}");
}
let max_components = args.k.ok_or_else(|| {
anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}")
})?;
if max_components == 0 {
bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}");
}
let bound_raw = args.bound.ok_or_else(|| {
anyhow::anyhow!("BoundedComponentSpanningForest requires --bound\n\n{usage}")
})?;
if bound_raw <= 0 {
bail!("BoundedComponentSpanningForest requires positive --bound\n\n{usage}");
}
let max_weight = i32::try_from(bound_raw).map_err(|_| {
anyhow::anyhow!(
"BoundedComponentSpanningForest requires --bound within i32 range\n\n{usage}"
)
})?;
(
ser(BoundedComponentSpanningForest::new(
graph,
weights,
max_components,
max_weight,
))?,
resolved_variant.clone(),
)
}

// Hamiltonian path (graph only, no weights)
"HamiltonianPath" => {
let (graph, _) = parse_graph(args).map_err(|e| {
Expand Down Expand Up @@ -651,7 +740,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
)
})?;
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
let data = match canonical.as_str() {
let data = match canonical {
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
Expand Down Expand Up @@ -1125,17 +1214,15 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {

// OptimalLinearArrangement — graph + bound
"OptimalLinearArrangement" => {
let (graph, _) = parse_graph(args).map_err(|e| {
let usage = "Usage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3 --bound 5";
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
let bound_raw = args.bound.ok_or_else(|| {
anyhow::anyhow!(
"{e}\n\nUsage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3 --bound 5"
"OptimalLinearArrangement requires --bound (upper bound K on total edge length)\n\n{usage}"
)
})?;
let bound = args.bound.ok_or_else(|| {
anyhow::anyhow!(
"OptimalLinearArrangement requires --bound (upper bound K on total edge length)\n\n\
Usage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3 --bound 5"
)
})? as usize;
let bound =
parse_nonnegative_usize_bound(bound_raw, "OptimalLinearArrangement", usage)?;
(
ser(OptimalLinearArrangement::new(graph, bound))?,
resolved_variant.clone(),
Expand Down Expand Up @@ -1360,9 +1447,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
let strings_str = args.strings.as_deref().ok_or_else(|| {
anyhow::anyhow!("ShortestCommonSupersequence requires --strings\n\n{usage}")
})?;
let bound = args.bound.ok_or_else(|| {
let bound_raw = args.bound.ok_or_else(|| {
anyhow::anyhow!("ShortestCommonSupersequence requires --bound\n\n{usage}")
})? as usize;
})?;
let bound =
parse_nonnegative_usize_bound(bound_raw, "ShortestCommonSupersequence", usage)?;
let strings: Vec<Vec<usize>> = strings_str
.split(';')
.map(|s| {
Expand Down Expand Up @@ -2263,9 +2352,11 @@ fn create_random(
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
// Default bound: (n-1) * num_edges ensures satisfiability (max edge stretch is n-1)
let n = graph.num_vertices();
let usage = "Usage: pred create OptimalLinearArrangement --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] [--bound 10]";
let bound = args
.bound
.map(|b| b as usize)
.map(|b| parse_nonnegative_usize_bound(b, "OptimalLinearArrangement", usage))
.transpose()?
.unwrap_or((n.saturating_sub(1)) * graph.num_edges());
let variant = variant_map(&[("graph", "SimpleGraph")]);
(ser(OptimalLinearArrangement::new(graph, bound))?, variant)
Expand Down
Loading
Loading