diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ
index 4910b9b4f..bccd87530 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.],
 ) <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.
+
+*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)
diff --git a/docs/src/cli.md b/docs/src/cli.md
index 99e709043..5c595e905 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 <file> --solver brute-force` for these.
 > For other problems, use `pred path <PROBLEM> 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 4f0ff53e3..0cc80eabb 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<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)
diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs
index 47e5c682f..3b5a0edd4 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> {
+    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 <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 {
@@ -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<Vec<usize>> = 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 70f6ab838..a8e77ab8d 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 ea07c1daa..c25fe3a1c 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 dd1642522..40af6396b 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 000000000..3af20610f
--- /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<W>", 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<G, W: WeightElement> {
+    /// The underlying graph.
+    graph: G,
+    /// Weights for each vertex.
+    weights: Vec<W>,
+    /// 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<G: Graph, W: WeightElement> BoundedComponentSpanningForest<G, W> {
+    /// Create a new bounded-component spanning forest instance.
+    pub fn new(graph: G, weights: Vec<W>, 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<G, W> Problem for BoundedComponentSpanningForest<G, W>
+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<usize> {
+        vec![self.max_components; self.graph.num_vertices()]
+    }
+
+    fn evaluate(&self, config: &[usize]) -> bool {
+        self.is_valid_solution(config)
+    }
+}
+
+impl<G, W> SatisfactionProblem for BoundedComponentSpanningForest<G, W>
+where
+    G: Graph + crate::variant::VariantParam,
+    W: WeightElement + crate::variant::VariantParam,
+{
+}
+
+#[cfg(feature = "example-db")]
+pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
+    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<SimpleGraph, i32> => "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 a2c1bedec..2152db586 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<crate::example_db::specs::M
     specs.extend(multiple_choice_branching::canonical_model_example_specs());
     specs.extend(spin_glass::canonical_model_example_specs());
     specs.extend(biclique_cover::canonical_model_example_specs());
+    specs.extend(bounded_component_spanning_forest::canonical_model_example_specs());
     specs.extend(partition_into_triangles::canonical_model_example_specs());
     specs.extend(steiner_tree::canonical_model_example_specs());
     specs.extend(directed_two_commodity_integral_flow::canonical_model_example_specs());
diff --git a/src/models/mod.rs b/src/models/mod.rs
index 5469c3a06..eca260c46 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -12,13 +12,13 @@ pub mod set;
 pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO};
 pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability};
 pub use graph::{
-    BicliqueCover, DirectedTwoCommodityIntegralFlow, GraphPartitioning, HamiltonianPath,
-    IsomorphicSpanningTree, KColoring, LengthBoundedDisjointPaths, MaxCut, MaximalIS,
-    MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet,
-    MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover,
-    MultipleChoiceBranching, OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman,
-    SpinGlass, SteinerTree, SubgraphIsomorphism, TravelingSalesman,
-    UndirectedTwoCommodityIntegralFlow,
+    BicliqueCover, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow,
+    GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, KColoring,
+    LengthBoundedDisjointPaths, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet,
+    MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet,
+    MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, OptimalLinearArrangement,
+    PartitionIntoTriangles, RuralPostman, SpinGlass, SteinerTree, SubgraphIsomorphism,
+    TravelingSalesman, UndirectedTwoCommodityIntegralFlow,
 };
 pub use misc::{
     BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence,
diff --git a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs
new file mode 100644
index 000000000..2541b2e89
--- /dev/null
+++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs
@@ -0,0 +1,200 @@
+use super::*;
+use crate::solvers::{BruteForce, Solver};
+use crate::topology::SimpleGraph;
+use crate::traits::Problem;
+use std::alloc::{GlobalAlloc, Layout, System};
+use std::cell::Cell;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+struct CountingAllocator;
+
+static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0);
+
+thread_local! {
+    static COUNT_ALLOCATIONS: Cell<bool> = 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<T>(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<SimpleGraph, i32> {
+    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<SimpleGraph, i32> {
+    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<usize> = (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<SimpleGraph, i32> =
+        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);
+}
