diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 4910b9b4..bccd8753 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -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], @@ -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.], ) ] + +#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. + +*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.], +) +] #{ let x = load-model-example("LengthBoundedDisjointPaths") let nv = graph-num-vertices(x.instance) diff --git a/docs/src/cli.md b/docs/src/cli.md index 99e70904..5c595e90 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -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: @@ -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 --solver brute-force` for these. > For other problems, use `pred path ILP` to check whether an ILP reduction path exists. ## Shell Completions diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 4f0ff53e..0cc80eab 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -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 @@ -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, - /// 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, /// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 47e5c682..3b5a0edd 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -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}; @@ -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" @@ -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" @@ -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::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, @@ -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 ); @@ -439,9 +468,30 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [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 { @@ -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" @@ -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| { @@ -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))?, @@ -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(), @@ -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> = strings_str .split(';') .map(|s| { @@ -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) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 70f6ab83..a8e77ab8 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1747,6 +1747,220 @@ fn test_create_kcoloring() { std::fs::remove_file(&output_file).ok(); } +#[test] +fn test_create_bounded_component_spanning_forest() { + let output_file = std::env::temp_dir().join("pred_test_create_bcsf.json"); + let output = pred() + .args([ + "-o", + output_file.to_str().unwrap(), + "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", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let content = std::fs::read_to_string(&output_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "BoundedComponentSpanningForest"); + assert_eq!(json["data"]["max_components"], 3); + assert_eq!(json["data"]["max_weight"], 6); + std::fs::remove_file(&output_file).ok(); +} + +#[test] +fn test_create_bounded_component_spanning_forest_rejects_zero_k() { + let output = pred() + .args([ + "create", + "BoundedComponentSpanningForest", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--k", + "0", + "--bound", + "2", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--k >= 1"), "stderr: {stderr}"); +} + +#[test] +fn test_create_bounded_component_spanning_forest_accepts_k_larger_than_num_vertices() { + let out = std::env::temp_dir().join("pred_test_bcsf_large_k.json"); + let output = pred() + .args([ + "create", + "BoundedComponentSpanningForest", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--k", + "5", + "--bound", + "2", + "-o", + ]) + .arg(&out) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(out.exists()); + let _ = std::fs::remove_file(&out); +} + +#[test] +fn test_create_bounded_component_spanning_forest_rejects_negative_weights() { + let output = pred() + .args([ + "create", + "BoundedComponentSpanningForest", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,-1,1,1", + "--k", + "2", + "--bound", + "2", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("nonnegative --weights"), "stderr: {stderr}"); +} + +#[test] +fn test_create_bounded_component_spanning_forest_rejects_negative_bound() { + let output = pred() + .args([ + "create", + "BoundedComponentSpanningForest", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--k", + "2", + "--bound", + "-1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("positive --bound"), "stderr: {stderr}"); +} + +#[test] +fn test_create_bounded_component_spanning_forest_rejects_out_of_range_bound() { + let output = pred() + .args([ + "create", + "BoundedComponentSpanningForest", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--k", + "2", + "--bound", + "3000000000", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("within i32 range"), "stderr: {stderr}"); +} + +#[test] +fn test_create_bounded_component_spanning_forest_no_flags_shows_actual_cli_flags() { + let output = pred() + .args(["create", "BoundedComponentSpanningForest"]) + .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("--k"), + "expected '--k' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--max-components"), + "help should not advertise nonexistent '--max-components' flag: {stderr}" + ); + assert!( + !stderr.contains("--max-weight"), + "help should not advertise nonexistent '--max-weight' flag: {stderr}" + ); +} + +#[test] +fn test_create_ola_rejects_negative_bound() { + let output = pred() + .args([ + "create", + "OptimalLinearArrangement", + "--graph", + "0-1,1-2,2-3", + "--bound", + "-1", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "negative bound should be rejected" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("nonnegative --bound"), "stderr: {stderr}"); +} + +#[test] +fn test_create_scs_rejects_negative_bound() { + let output = pred() + .args(["create", "SCS", "--strings", "0,1,2;1,2,0", "--bound", "-1"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "negative bound should be rejected" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("nonnegative --bound"), "stderr: {stderr}"); +} + #[test] fn test_create_spinglass() { let output_file = std::env::temp_dir().join("pred_test_create_sg.json"); diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index ea07c1da..c25fe3a1 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -2,6 +2,7 @@ "models": [ {"problem":"BMF","variant":{},"instance":{"k":2,"m":3,"matrix":[[true,true,false],[true,true,true],[false,true,true]],"n":3},"samples":[{"config":[1,0,1,1,0,1,1,1,0,0,1,1],"metric":{"Valid":0}}],"optimal":[{"config":[0,1,1,1,1,0,0,1,1,1,1,0],"metric":{"Valid":0}},{"config":[1,0,1,1,0,1,1,1,0,0,1,1],"metric":{"Valid":0}}]}, {"problem":"BicliqueCover","variant":{},"instance":{"graph":{"edges":[[0,0],[0,1],[1,1],[1,2]],"left_size":2,"right_size":3},"k":2},"samples":[{"config":[1,0,0,1,1,0,1,1,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[0,1,0,1,0,1,0,1,0,1],"metric":{"Valid":5}},{"config":[1,0,1,0,1,0,1,0,1,0],"metric":{"Valid":5}}]}, + {"problem":"BoundedComponentSpanningForest","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,5,null],[5,6,null],[6,7,null],[0,7,null],[1,5,null],[2,6,null]],"node_holes":[],"nodes":[null,null,null,null,null,null,null,null]}},"max_components":3,"max_weight":6,"weights":[2,3,1,2,3,1,2,1]},"samples":[{"config":[0,0,1,1,1,2,2,0],"metric":true}],"optimal":[{"config":[0,0,0,1,1,1,2,2],"metric":true},{"config":[0,0,0,1,1,2,2,2],"metric":true},{"config":[0,0,0,2,2,1,1,1],"metric":true},{"config":[0,0,0,2,2,2,1,1],"metric":true},{"config":[0,0,1,1,1,0,2,2],"metric":true},{"config":[0,0,1,1,1,2,2,0],"metric":true},{"config":[0,0,1,1,1,2,2,2],"metric":true},{"config":[0,0,1,1,2,0,1,1],"metric":true},{"config":[0,0,1,1,2,1,1,0],"metric":true},{"config":[0,0,1,1,2,2,1,0],"metric":true},{"config":[0,0,1,1,2,2,1,1],"metric":true},{"config":[0,0,1,1,2,2,2,0],"metric":true},{"config":[0,0,1,2,2,0,1,1],"metric":true},{"config":[0,0,1,2,2,1,1,0],"metric":true},{"config":[0,0,1,2,2,1,1,1],"metric":true},{"config":[0,0,1,2,2,2,1,0],"metric":true},{"config":[0,0,1,2,2,2,1,1],"metric":true},{"config":[0,0,2,1,1,0,2,2],"metric":true},{"config":[0,0,2,1,1,1,2,0],"metric":true},{"config":[0,0,2,1,1,1,2,2],"metric":true},{"config":[0,0,2,1,1,2,2,0],"metric":true},{"config":[0,0,2,1,1,2,2,2],"metric":true},{"config":[0,0,2,2,1,0,2,2],"metric":true},{"config":[0,0,2,2,1,1,1,0],"metric":true},{"config":[0,0,2,2,1,1,2,0],"metric":true},{"config":[0,0,2,2,1,1,2,2],"metric":true},{"config":[0,0,2,2,1,2,2,0],"metric":true},{"config":[0,0,2,2,2,0,1,1],"metric":true},{"config":[0,0,2,2,2,1,1,0],"metric":true},{"config":[0,0,2,2,2,1,1,1],"metric":true},{"config":[0,1,0,2,2,1,0,0],"metric":true},{"config":[0,1,0,2,2,2,0,0],"metric":true},{"config":[0,1,1,1,2,0,0,0],"metric":true},{"config":[0,1,1,1,2,2,0,0],"metric":true},{"config":[0,1,1,1,2,2,2,0],"metric":true},{"config":[0,1,1,2,2,0,0,0],"metric":true},{"config":[0,1,1,2,2,1,0,0],"metric":true},{"config":[0,1,1,2,2,2,0,0],"metric":true},{"config":[0,1,1,2,2,2,1,0],"metric":true},{"config":[0,1,2,2,2,0,0,0],"metric":true},{"config":[0,1,2,2,2,1,0,0],"metric":true},{"config":[0,1,2,2,2,1,1,0],"metric":true},{"config":[0,2,0,1,1,1,0,0],"metric":true},{"config":[0,2,0,1,1,2,0,0],"metric":true},{"config":[0,2,1,1,1,0,0,0],"metric":true},{"config":[0,2,1,1,1,2,0,0],"metric":true},{"config":[0,2,1,1,1,2,2,0],"metric":true},{"config":[0,2,2,1,1,0,0,0],"metric":true},{"config":[0,2,2,1,1,1,0,0],"metric":true},{"config":[0,2,2,1,1,1,2,0],"metric":true},{"config":[0,2,2,1,1,2,0,0],"metric":true},{"config":[0,2,2,2,1,0,0,0],"metric":true},{"config":[0,2,2,2,1,1,0,0],"metric":true},{"config":[0,2,2,2,1,1,1,0],"metric":true},{"config":[1,0,0,0,2,1,1,1],"metric":true},{"config":[1,0,0,0,2,2,1,1],"metric":true},{"config":[1,0,0,0,2,2,2,1],"metric":true},{"config":[1,0,0,2,2,0,1,1],"metric":true},{"config":[1,0,0,2,2,1,1,1],"metric":true},{"config":[1,0,0,2,2,2,0,1],"metric":true},{"config":[1,0,0,2,2,2,1,1],"metric":true},{"config":[1,0,1,2,2,0,1,1],"metric":true},{"config":[1,0,1,2,2,2,1,1],"metric":true},{"config":[1,0,2,2,2,0,0,1],"metric":true},{"config":[1,0,2,2,2,0,1,1],"metric":true},{"config":[1,0,2,2,2,1,1,1],"metric":true},{"config":[1,1,0,0,0,1,2,2],"metric":true},{"config":[1,1,0,0,0,2,2,1],"metric":true},{"config":[1,1,0,0,0,2,2,2],"metric":true},{"config":[1,1,0,0,2,0,0,1],"metric":true},{"config":[1,1,0,0,2,1,0,0],"metric":true},{"config":[1,1,0,0,2,2,0,0],"metric":true},{"config":[1,1,0,0,2,2,0,1],"metric":true},{"config":[1,1,0,0,2,2,2,1],"metric":true},{"config":[1,1,0,2,2,0,0,0],"metric":true},{"config":[1,1,0,2,2,0,0,1],"metric":true},{"config":[1,1,0,2,2,1,0,0],"metric":true},{"config":[1,1,0,2,2,2,0,0],"metric":true},{"config":[1,1,0,2,2,2,0,1],"metric":true},{"config":[1,1,1,0,0,0,2,2],"metric":true},{"config":[1,1,1,0,0,2,2,2],"metric":true},{"config":[1,1,1,2,2,0,0,0],"metric":true},{"config":[1,1,1,2,2,2,0,0],"metric":true},{"config":[1,1,2,0,0,0,2,1],"metric":true},{"config":[1,1,2,0,0,0,2,2],"metric":true},{"config":[1,1,2,0,0,1,2,2],"metric":true},{"config":[1,1,2,0,0,2,2,1],"metric":true},{"config":[1,1,2,0,0,2,2,2],"metric":true},{"config":[1,1,2,2,0,0,0,1],"metric":true},{"config":[1,1,2,2,0,0,2,1],"metric":true},{"config":[1,1,2,2,0,0,2,2],"metric":true},{"config":[1,1,2,2,0,1,2,2],"metric":true},{"config":[1,1,2,2,0,2,2,1],"metric":true},{"config":[1,1,2,2,2,0,0,0],"metric":true},{"config":[1,1,2,2,2,0,0,1],"metric":true},{"config":[1,1,2,2,2,1,0,0],"metric":true},{"config":[1,2,0,0,0,1,1,1],"metric":true},{"config":[1,2,0,0,0,2,1,1],"metric":true},{"config":[1,2,0,0,0,2,2,1],"metric":true},{"config":[1,2,1,0,0,0,1,1],"metric":true},{"config":[1,2,1,0,0,2,1,1],"metric":true},{"config":[1,2,2,0,0,0,1,1],"metric":true},{"config":[1,2,2,0,0,0,2,1],"metric":true},{"config":[1,2,2,0,0,1,1,1],"metric":true},{"config":[1,2,2,0,0,2,1,1],"metric":true},{"config":[1,2,2,2,0,0,0,1],"metric":true},{"config":[1,2,2,2,0,0,1,1],"metric":true},{"config":[1,2,2,2,0,1,1,1],"metric":true},{"config":[2,0,0,0,1,1,1,2],"metric":true},{"config":[2,0,0,0,1,1,2,2],"metric":true},{"config":[2,0,0,0,1,2,2,2],"metric":true},{"config":[2,0,0,1,1,0,2,2],"metric":true},{"config":[2,0,0,1,1,1,0,2],"metric":true},{"config":[2,0,0,1,1,1,2,2],"metric":true},{"config":[2,0,0,1,1,2,2,2],"metric":true},{"config":[2,0,1,1,1,0,0,2],"metric":true},{"config":[2,0,1,1,1,0,2,2],"metric":true},{"config":[2,0,1,1,1,2,2,2],"metric":true},{"config":[2,0,2,1,1,0,2,2],"metric":true},{"config":[2,0,2,1,1,1,2,2],"metric":true},{"config":[2,1,0,0,0,1,1,2],"metric":true},{"config":[2,1,0,0,0,1,2,2],"metric":true},{"config":[2,1,0,0,0,2,2,2],"metric":true},{"config":[2,1,1,0,0,0,1,2],"metric":true},{"config":[2,1,1,0,0,0,2,2],"metric":true},{"config":[2,1,1,0,0,1,2,2],"metric":true},{"config":[2,1,1,0,0,2,2,2],"metric":true},{"config":[2,1,1,1,0,0,0,2],"metric":true},{"config":[2,1,1,1,0,0,2,2],"metric":true},{"config":[2,1,1,1,0,2,2,2],"metric":true},{"config":[2,1,2,0,0,0,2,2],"metric":true},{"config":[2,1,2,0,0,1,2,2],"metric":true},{"config":[2,2,0,0,0,1,1,1],"metric":true},{"config":[2,2,0,0,0,1,1,2],"metric":true},{"config":[2,2,0,0,0,2,1,1],"metric":true},{"config":[2,2,0,0,1,0,0,2],"metric":true},{"config":[2,2,0,0,1,1,0,0],"metric":true},{"config":[2,2,0,0,1,1,0,2],"metric":true},{"config":[2,2,0,0,1,1,1,2],"metric":true},{"config":[2,2,0,0,1,2,0,0],"metric":true},{"config":[2,2,0,1,1,0,0,0],"metric":true},{"config":[2,2,0,1,1,0,0,2],"metric":true},{"config":[2,2,0,1,1,1,0,0],"metric":true},{"config":[2,2,0,1,1,1,0,2],"metric":true},{"config":[2,2,0,1,1,2,0,0],"metric":true},{"config":[2,2,1,0,0,0,1,1],"metric":true},{"config":[2,2,1,0,0,0,1,2],"metric":true},{"config":[2,2,1,0,0,1,1,1],"metric":true},{"config":[2,2,1,0,0,1,1,2],"metric":true},{"config":[2,2,1,0,0,2,1,1],"metric":true},{"config":[2,2,1,1,0,0,0,2],"metric":true},{"config":[2,2,1,1,0,0,1,1],"metric":true},{"config":[2,2,1,1,0,0,1,2],"metric":true},{"config":[2,2,1,1,0,1,1,2],"metric":true},{"config":[2,2,1,1,0,2,1,1],"metric":true},{"config":[2,2,1,1,1,0,0,0],"metric":true},{"config":[2,2,1,1,1,0,0,2],"metric":true},{"config":[2,2,1,1,1,2,0,0],"metric":true},{"config":[2,2,2,0,0,0,1,1],"metric":true},{"config":[2,2,2,0,0,1,1,1],"metric":true},{"config":[2,2,2,1,1,0,0,0],"metric":true},{"config":[2,2,2,1,1,1,0,0],"metric":true}]}, {"problem":"CircuitSAT","variant":{},"instance":{"circuit":{"assignments":[{"expr":{"op":{"And":[{"op":{"Var":"x1"}},{"op":{"Var":"x2"}}]}},"outputs":["a"]},{"expr":{"op":{"Or":[{"op":{"Var":"x1"}},{"op":{"Var":"x2"}}]}},"outputs":["b"]},{"expr":{"op":{"Xor":[{"op":{"Var":"a"}},{"op":{"Var":"b"}}]}},"outputs":["c"]}]},"variables":["a","b","c","x1","x2"]},"samples":[{"config":[0,1,1,0,1],"metric":true},{"config":[0,1,1,1,0],"metric":true}],"optimal":[{"config":[0,0,0,0,0],"metric":true},{"config":[0,1,1,0,1],"metric":true},{"config":[0,1,1,1,0],"metric":true},{"config":[1,1,0,1,1],"metric":true}]}, {"problem":"ClosestVectorProblem","variant":{"weight":"i32"},"instance":{"basis":[[2,0],[1,2]],"bounds":[{"lower":-2,"upper":4},{"lower":-2,"upper":4}],"target":[2.8,1.5]},"samples":[{"config":[3,3],"metric":{"Valid":0.5385164807134505}}],"optimal":[{"config":[3,3],"metric":{"Valid":0.5385164807134505}}]}, {"problem":"DirectedTwoCommodityIntegralFlow","variant":{},"instance":{"capacities":[1,1,1,1,1,1,1,1],"graph":{"inner":{"edge_property":"directed","edges":[[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,4,null],[2,5,null],[3,4,null],[3,5,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}},"requirement_1":1,"requirement_2":1,"sink_1":4,"sink_2":5,"source_1":0,"source_2":1},"samples":[{"config":[1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1],"metric":true}],"optimal":[{"config":[0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0],"metric":true},{"config":[0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[0,0,0,1,0,0,1,0,0,1,1,0,0,1,0,1],"metric":true},{"config":[0,0,0,1,0,0,1,0,0,1,1,0,1,0,0,1],"metric":true},{"config":[0,0,0,1,0,0,1,0,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,0,0,1,0,0,1,0,1,0,1,0,1,1,0,0],"metric":true},{"config":[0,0,0,1,0,0,1,0,1,1,0,0,0,1,0,1],"metric":true},{"config":[0,0,0,1,0,0,1,0,1,1,0,0,1,0,0,1],"metric":true},{"config":[0,0,0,1,0,0,1,0,1,1,1,0,1,1,0,1],"metric":true},{"config":[0,0,1,0,1,0,0,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[0,0,1,0,1,0,0,0,0,1,0,1,0,0,1,1],"metric":true},{"config":[0,0,1,0,1,0,0,0,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,0,1,0,1,0,0,0,1,0,0,1,0,1,0,1],"metric":true},{"config":[0,0,1,0,1,0,0,0,1,0,0,1,0,1,1,0],"metric":true},{"config":[0,0,1,0,1,0,0,0,1,1,0,0,0,1,0,1],"metric":true},{"config":[0,0,1,0,1,0,0,0,1,1,0,0,0,1,1,0],"metric":true},{"config":[0,0,1,0,1,0,0,0,1,1,0,1,0,1,1,1],"metric":true},{"config":[0,0,1,1,0,1,1,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[0,0,1,1,0,1,1,0,1,1,0,0,1,0,0,1],"metric":true},{"config":[0,0,1,1,1,0,0,1,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,0,1,1,1,0,0,1,1,1,0,0,0,1,1,0],"metric":true},{"config":[0,0,1,1,1,0,1,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[0,0,1,1,1,0,1,0,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,0,1,1,1,0,1,0,1,1,0,0,0,1,0,1],"metric":true},{"config":[0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[0,1,0,0,0,0,1,0,0,0,1,0,0,1,0,0],"metric":true},{"config":[0,1,0,0,0,0,1,0,0,0,1,1,0,1,0,1],"metric":true},{"config":[0,1,0,0,0,0,1,0,0,0,1,1,1,0,0,1],"metric":true},{"config":[0,1,0,0,0,0,1,0,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,1,0,0,0,0,1,0,1,0,0,1,0,1,0,1],"metric":true},{"config":[0,1,0,0,0,0,1,0,1,0,0,1,1,0,0,1],"metric":true},{"config":[0,1,0,0,0,0,1,0,1,0,1,0,1,1,0,0],"metric":true},{"config":[0,1,0,0,0,0,1,0,1,0,1,1,1,1,0,1],"metric":true},{"config":[0,1,0,1,0,0,1,1,0,0,1,0,0,1,0,0],"metric":true},{"config":[0,1,0,1,0,0,1,1,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,1,0,1,0,0,1,1,1,0,1,0,1,1,0,0],"metric":true},{"config":[0,1,1,0,0,1,1,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[0,1,1,0,0,1,1,0,1,0,0,1,1,0,0,1],"metric":true},{"config":[0,1,1,0,1,0,0,1,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,1,1,0,1,0,0,1,1,0,0,1,0,1,1,0],"metric":true},{"config":[0,1,1,0,1,0,1,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[0,1,1,0,1,0,1,0,1,0,0,0,0,1,0,0],"metric":true},{"config":[0,1,1,0,1,0,1,0,1,0,0,1,0,1,0,1],"metric":true},{"config":[0,1,1,1,1,0,1,1,1,0,0,0,0,1,0,0],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,0,1,1,0,1,0,1],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,0,1,1,0,1,1,0],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,1,0,1,0,0,1,1],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,1,1,0,0,1,0,1],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,1,1,0,0,1,1,0],"metric":true},{"config":[1,0,0,0,1,0,0,0,0,1,1,1,0,1,1,1],"metric":true},{"config":[1,0,0,1,0,1,1,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[1,0,0,1,0,1,1,0,0,1,1,0,1,0,0,1],"metric":true},{"config":[1,0,0,1,1,0,0,1,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,0,0,1,1,0,0,1,0,1,1,0,0,1,1,0],"metric":true},{"config":[1,0,0,1,1,0,1,0,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,0,0,1,1,0,1,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[1,0,0,1,1,0,1,0,0,1,1,0,0,1,0,1],"metric":true},{"config":[1,0,1,0,1,1,0,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[1,0,1,0,1,1,0,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[1,0,1,0,1,1,0,0,0,1,0,1,0,0,1,1],"metric":true},{"config":[1,0,1,1,1,1,1,0,0,1,0,0,0,0,0,1],"metric":true},{"config":[1,1,0,0,0,1,1,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[1,1,0,0,0,1,1,0,0,0,1,1,1,0,0,1],"metric":true},{"config":[1,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,1,0,0,1,0,0,1,0,0,1,1,0,1,1,0],"metric":true},{"config":[1,1,0,0,1,0,1,0,0,0,0,1,0,0,0,1],"metric":true},{"config":[1,1,0,0,1,0,1,0,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,1,0,0,1,0,1,0,0,0,1,1,0,1,0,1],"metric":true},{"config":[1,1,0,1,1,0,1,1,0,0,1,0,0,1,0,0],"metric":true},{"config":[1,1,1,0,1,1,1,0,0,0,0,1,0,0,0,1],"metric":true}]}, diff --git a/src/lib.rs b/src/lib.rs index dd164252..40af6396 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,9 +45,9 @@ pub mod prelude { pub use crate::models::algebraic::{BMF, QUBO}; pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use crate::models::graph::{ - BicliqueCover, DirectedTwoCommodityIntegralFlow, GraphPartitioning, HamiltonianPath, - IsomorphicSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, - SubgraphIsomorphism, + BicliqueCover, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, + GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, LengthBoundedDisjointPaths, + SpinGlass, SteinerTree, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs new file mode 100644 index 00000000..3af20610 --- /dev/null +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -0,0 +1,246 @@ +//! Bounded Component Spanning Forest problem implementation. +//! +//! The Bounded Component Spanning Forest problem asks whether the vertices of a +//! weighted graph can be partitioned into at most `K` connected components, each +//! of total weight at most `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}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "BoundedComponentSpanningForest", + display_name: "Bounded Component Spanning Forest", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Partition vertices into at most K connected components, each of total weight at most B", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, + FieldInfo { name: "max_components", type_name: "usize", description: "Upper bound K on the number of connected components" }, + FieldInfo { name: "max_weight", type_name: "W::Sum", description: "Upper bound B on the total weight of each component" }, + ], + } +} + +/// The Bounded Component Spanning Forest problem. +/// +/// Given a graph `G = (V, E)`, a nonnegative weight `w(v)` for each vertex, an +/// integer `K`, and a bound `B`, determine whether the vertices can be +/// partitioned into at most `K` non-empty sets such that every set induces a +/// connected subgraph and the total weight of each set is at most `B`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoundedComponentSpanningForest { + /// The underlying graph. + graph: G, + /// Weights for each vertex. + weights: Vec, + /// Upper bound on the number of connected components. + max_components: usize, + /// Upper bound on the total weight of every component. + max_weight: W::Sum, +} + +impl BoundedComponentSpanningForest { + /// Create a new bounded-component spanning forest instance. + pub fn new(graph: G, weights: Vec, max_components: usize, max_weight: W::Sum) -> Self { + assert_eq!( + weights.len(), + graph.num_vertices(), + "weights length must match graph num_vertices" + ); + assert!( + weights + .iter() + .all(|weight| weight.to_sum() >= W::Sum::zero()), + "weights must be nonnegative" + ); + assert!(max_components >= 1, "max_components must be at least 1"); + assert!(max_weight > W::Sum::zero(), "max_weight must be positive"); + Self { + graph, + weights, + max_components, + max_weight, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the vertex weights. + pub fn weights(&self) -> &[W] { + &self.weights + } + + /// Get the maximum number of components. + pub fn max_components(&self) -> usize { + self.max_components + } + + /// Get the maximum allowed component weight. + pub fn max_weight(&self) -> &W::Sum { + &self.max_weight + } + + /// 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() + } + + /// Check if the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Check if a configuration is a valid bounded-component partition. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + let num_vertices = self.graph.num_vertices(); + if config.len() != num_vertices { + return false; + } + + let mut component_weights = vec![W::Sum::zero(); self.max_components]; + let mut component_sizes = vec![0usize; self.max_components]; + let mut component_starts = vec![usize::MAX; self.max_components]; + let mut used_components = Vec::with_capacity(self.max_components); + + for (vertex, &component) in config.iter().enumerate() { + if component >= self.max_components { + return false; + } + + if component_sizes[component] == 0 { + component_starts[component] = vertex; + used_components.push(component); + } + + component_sizes[component] += 1; + component_weights[component] += self.weights[vertex].to_sum(); + if component_weights[component] > self.max_weight { + return false; + } + } + + if used_components + .iter() + .all(|&component| component_sizes[component] <= 1) + { + return true; + } + + let mut visited_marks = vec![0usize; num_vertices]; + let mut queue = VecDeque::with_capacity(num_vertices); + + for (mark, component) in used_components.into_iter().enumerate() { + let component_size = component_sizes[component]; + if component_size <= 1 { + continue; + } + + let start = component_starts[component]; + queue.clear(); + queue.push_back(start); + visited_marks[start] = mark + 1; + let mut visited_count = 0usize; + + while let Some(vertex) = queue.pop_front() { + visited_count += 1; + for neighbor in self.graph.neighbors(vertex) { + if config[neighbor] == component && visited_marks[neighbor] != mark + 1 { + visited_marks[neighbor] = mark + 1; + queue.push_back(neighbor); + } + } + } + + if visited_count != component_size { + return false; + } + } + + true + } +} + +impl Problem for BoundedComponentSpanningForest +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "BoundedComponentSpanningForest"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![self.max_components; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for BoundedComponentSpanningForest +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "bounded_component_spanning_forest_simplegraph_i32", + build: || { + let graph = SimpleGraph::new( + 8, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (0, 7), + (1, 5), + (2, 6), + ], + ); + let problem = + BoundedComponentSpanningForest::new(graph, vec![2, 3, 1, 2, 3, 1, 2, 1], 3, 6); + crate::example_db::specs::satisfaction_example( + problem, + vec![vec![0, 0, 1, 1, 1, 2, 2, 0]], + ) + }, + }] +} + +crate::declare_variants! { + default sat BoundedComponentSpanningForest => "3^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/bounded_component_spanning_forest.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index a2c1bede..2152db58 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -17,6 +17,7 @@ //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) //! - [`BicliqueCover`]: Biclique cover on bipartite graphs +//! - [`BoundedComponentSpanningForest`]: Partition vertices into bounded-weight connected components //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) @@ -29,6 +30,7 @@ //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs pub(crate) mod biclique_cover; +pub(crate) mod bounded_component_spanning_forest; pub(crate) mod directed_two_commodity_integral_flow; pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_path; @@ -56,6 +58,7 @@ pub(crate) mod traveling_salesman; pub(crate) mod undirected_two_commodity_integral_flow; pub use biclique_cover::BicliqueCover; +pub use bounded_component_spanning_forest::BoundedComponentSpanningForest; pub use directed_two_commodity_integral_flow::DirectedTwoCommodityIntegralFlow; pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_path::HamiltonianPath; @@ -102,6 +105,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec = const { Cell::new(false) }; +} + +#[global_allocator] +static GLOBAL_ALLOCATOR: CountingAllocator = CountingAllocator; + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + COUNT_ALLOCATIONS.with(|enabled| { + if enabled.get() { + ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed); + } + }); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + COUNT_ALLOCATIONS.with(|enabled| { + if enabled.get() { + ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed); + } + }); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +fn count_allocations(f: impl FnOnce() -> T) -> (T, usize) { + ALLOCATION_COUNT.store(0, Ordering::Relaxed); + COUNT_ALLOCATIONS.with(|enabled| enabled.set(true)); + let result = f(); + COUNT_ALLOCATIONS.with(|enabled| enabled.set(false)); + let allocations = ALLOCATION_COUNT.swap(0, Ordering::Relaxed); + (result, allocations) +} + +fn yes_instance() -> BoundedComponentSpanningForest { + let graph = SimpleGraph::new( + 8, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (0, 7), + (1, 5), + (2, 6), + ], + ); + BoundedComponentSpanningForest::new(graph, vec![2, 3, 1, 2, 3, 1, 2, 1], 3, 6) +} + +fn no_instance() -> BoundedComponentSpanningForest { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + BoundedComponentSpanningForest::new(graph, vec![1, 1, 1, 1, 1, 1], 2, 2) +} + +#[test] +fn test_bounded_component_spanning_forest_creation() { + let problem = yes_instance(); + assert_eq!(problem.graph().num_vertices(), 8); + assert_eq!(problem.graph().num_edges(), 10); + assert_eq!(problem.weights(), &[2, 3, 1, 2, 3, 1, 2, 1]); + assert_eq!(problem.max_components(), 3); + assert_eq!(problem.max_weight(), &6); + assert_eq!(problem.num_vertices(), 8); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.dims(), vec![3; 8]); + assert!(problem.is_weighted()); +} + +#[test] +fn test_bounded_component_spanning_forest_yes_instance() { + let problem = yes_instance(); + assert!(problem.evaluate(&[0, 0, 1, 1, 1, 2, 2, 0])); + assert!(problem.is_valid_solution(&[0, 0, 1, 1, 1, 2, 2, 0])); +} + +#[test] +fn test_bounded_component_spanning_forest_rejects_weight_overflow() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 0, 1, 1, 1, 1, 2, 0])); +} + +#[test] +fn test_bounded_component_spanning_forest_rejects_disconnected_component() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 1, 0, 1, 1, 2, 2, 0])); +} + +#[test] +fn test_bounded_component_spanning_forest_rejects_out_of_range_component() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 0, 1, 1, 1, 2, 2, 3])); +} + +#[test] +fn test_bounded_component_spanning_forest_rejects_wrong_length() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 0, 1])); +} + +#[test] +fn test_bounded_component_spanning_forest_evaluate_uses_fixed_allocation_budget() { + let problem = BoundedComponentSpanningForest::new(SimpleGraph::empty(16), vec![1; 16], 16, 1); + let config: Vec = (0..16).collect(); + + let (is_valid, allocations) = count_allocations(|| problem.evaluate(&config)); + + assert!(is_valid); + assert!( + allocations <= 6, + "expected evaluate to use only a fixed number of allocations, got {allocations}" + ); +} + +#[test] +fn test_bounded_component_spanning_forest_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let round_trip: BoundedComponentSpanningForest = + serde_json::from_str(&json).unwrap(); + assert_eq!(round_trip.graph().num_vertices(), 8); + assert_eq!(round_trip.weights(), &[2, 3, 1, 2, 3, 1, 2, 1]); + assert_eq!(round_trip.max_components(), 3); + assert_eq!(round_trip.max_weight(), &6); +} + +#[test] +fn test_bounded_component_spanning_forest_solver_yes_and_no_instances() { + let solver = BruteForce::new(); + + let yes_problem = yes_instance(); + let solution = solver.find_satisfying(&yes_problem); + assert!(solution.is_some()); + assert!(yes_problem.evaluate(solution.as_ref().unwrap())); + + let no_problem = no_instance(); + assert!(solver.find_satisfying(&no_problem).is_none()); +} + +#[test] +fn test_bounded_component_spanning_forest_paper_example() { + let problem = yes_instance(); + let config = vec![0, 0, 1, 1, 1, 2, 2, 0]; + assert!(problem.evaluate(&config)); + + let solver = BruteForce::new(); + let all_solutions = solver.find_all_satisfying(&problem); + assert!(all_solutions.iter().any(|solution| solution == &config)); +} + +#[test] +#[should_panic(expected = "max_components must be at least 1")] +fn test_bounded_component_spanning_forest_rejects_zero_max_components_in_constructor() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let _ = BoundedComponentSpanningForest::new(graph, vec![1, 1], 0, 1); +} + +#[test] +fn test_bounded_component_spanning_forest_accepts_k_larger_than_num_vertices() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let problem = BoundedComponentSpanningForest::new(graph, vec![1, 1], 5, 2); + // K > |V| is mathematically harmless — just means fewer than K components possible + assert_eq!(problem.max_components(), 5); + assert!(problem.evaluate(&[0, 0])); +} + +#[test] +#[should_panic(expected = "weights must be nonnegative")] +fn test_bounded_component_spanning_forest_rejects_negative_weights_in_constructor() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let _ = BoundedComponentSpanningForest::new(graph, vec![1, -1], 1, 1); +} + +#[test] +#[should_panic(expected = "max_weight must be positive")] +fn test_bounded_component_spanning_forest_rejects_nonpositive_bound_in_constructor() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let _ = BoundedComponentSpanningForest::new(graph, vec![1, 1], 1, 0); +}