From c807cbc6c8fd22e10d2dc9ca0caea6399a31eb6c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 07:29:02 +0800 Subject: [PATCH 01/10] Add plan for #251: [Model] BoundedComponentSpanningForest --- ...03-16-bounded-component-spanning-forest.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/plans/2026-03-16-bounded-component-spanning-forest.md diff --git a/docs/plans/2026-03-16-bounded-component-spanning-forest.md b/docs/plans/2026-03-16-bounded-component-spanning-forest.md new file mode 100644 index 00000000..3b78eea5 --- /dev/null +++ b/docs/plans/2026-03-16-bounded-component-spanning-forest.md @@ -0,0 +1,287 @@ +# BoundedComponentSpanningForest Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `BoundedComponentSpanningForest` graph satisfaction model, wire it into the registry/CLI/example flows, document it in the paper, and verify the issue's YES/NO instances end-to-end. + +**Architecture:** Implement `BoundedComponentSpanningForest` as a graph-based satisfaction problem where each vertex is assigned one of at most `K` component ids. Evaluation groups vertices by assigned component, rejects any component whose total vertex weight exceeds `B`, and rejects any non-empty component whose induced subgraph is disconnected. Start with the default concrete registry variant `BoundedComponentSpanningForest` and keep the paper example aligned with the issue's YES instance. + +**Tech Stack:** Rust workspace, `serde`, graph `Problem`/`SatisfactionProblem` traits, registry metadata via `inventory` + `declare_variants!`, `pred create` CLI, Typst paper, mdBook schema/graph exports. + +--- + +**Execution notes** + +- This issue is currently an orphan model: `gh issue list --label rule --state open` returns no open rule issues whose title references `BoundedComponentSpanningForest`. Keep a visible orphan-model warning in the PR description unless a companion rule issue is filed separately. +- The issue-to-PR helper currently overmatches existing PRs for this issue number. Execute this plan in the dedicated `issue-251-bounded-component-spanning-forest` worktree and do not resume the unrelated PR `#631`. +- Use the issue's YES instance as the canonical implementation-facing example: + - Graph edges: `(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`, `B = 6` + - Satisfying assignment: `[0, 0, 1, 1, 1, 2, 2, 0]`, representing components `{0,1,7}`, `{2,3,4}`, `{5,6}` +- Also preserve a NO instance in tests: + - Graph edges: `(0,1) (1,2) (3,4) (4,5)` + - Weights: `[1, 1, 1, 1, 1, 1]` + - `K = 2`, `B = 2` + - Expected result: no satisfying assignment exists. + +## Batch 1: Model + Registry + CLI + Tests + +### Task 1: Write the failing model tests first + +**Files:** +- Create: `src/unit_tests/models/graph/bounded_component_spanning_forest.rs` + +**Step 1: Add failing tests for the issue behavior** + +Write tests that cover: +- construction + getters (`graph`, `weights`, `max_components`, `max_weight`, `dims`) +- YES-instance evaluation using config `[0, 0, 1, 1, 1, 2, 2, 0]` +- rejection when a component exceeds the weight bound +- rejection when a component is disconnected +- rejection when a config uses an out-of-range component id or wrong length +- serde round-trip +- solver behavior: `find_satisfying()` succeeds on the YES instance and returns `None` on the NO instance +- paper/example alignment test using the canonical YES instance + +**Step 2: Run the new test file to verify RED** + +Run: +```bash +cargo test bounded_component_spanning_forest --lib +``` + +Expected: FAIL because `BoundedComponentSpanningForest` does not exist yet. + +### Task 2: Implement the model and expose it from the crate + +**Files:** +- Create: `src/models/graph/bounded_component_spanning_forest.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +**Step 1: Implement `BoundedComponentSpanningForest`** + +Use the same broad shape as `RuralPostman` and `KColoring`: +- `graph: G` +- `weights: Vec` +- `max_components: usize` +- `max_weight: W::Sum` + +Include: +- `inventory::submit!` registration with `name = "BoundedComponentSpanningForest"` +- dimensions: + - `graph` default/allowed: `SimpleGraph` + - `weight` default/allowed: `i32` +- field schema entries for `graph`, `weights`, `max_components`, `max_weight` +- constructor validation: + - `weights.len() == graph.num_vertices()` + - `max_components >= 1` +- getters: + - `graph()` + - `weights()` + - `max_components()` + - `max_weight()` + - `num_vertices()` + - `num_edges()` + - `is_weighted()` +- helper methods: + - `is_valid_solution(&self, config: &[usize]) -> bool` + - an internal connectivity helper that BFS/DFSes only within one assigned component + +**Step 2: Implement the trait layer** + +Implement: +- `Problem` +- `SatisfactionProblem` +- `variant()` via `crate::variant_params![G, W]` +- `dims()` as `vec![self.max_components; self.graph.num_vertices()]` +- `evaluate()` as a pure feasibility check returning `true`/`false` + +For feasibility, reject when: +- config length differs from `num_vertices` +- any component id is `>= max_components` +- any non-empty component induces a disconnected subgraph +- any component's total weight exceeds `max_weight` + +**Step 3: Register the concrete variant + example-db hook** + +At the bottom of the model file: +- add `crate::declare_variants! { default sat BoundedComponentSpanningForest => "max_components^num_vertices" }` +- add `canonical_model_example_specs()` returning a satisfaction example built from the issue YES instance +- link the test file with `#[cfg(test)] #[path = "../../unit_tests/models/graph/bounded_component_spanning_forest.rs"]` + +**Step 4: Re-export the new type** + +Update: +- `src/models/graph/mod.rs` +- `src/models/mod.rs` +- `src/lib.rs` prelude exports + +**Step 5: Run the targeted tests to verify GREEN** + +Run: +```bash +cargo test bounded_component_spanning_forest --lib +``` + +Expected: PASS for the new model test file, plus any matching integration hooks. + +### Task 3: Add trait-consistency coverage and exported schema support + +**Files:** +- Modify: `src/unit_tests/trait_consistency.rs` + +**Step 1: Add the new problem to trait consistency** + +Add a small instance such as a 3-vertex path with unit weights expressed as `i32`, `K = 2`, `B = 2` to `test_all_problems_implement_trait_correctly`. + +Because this is a satisfaction problem, do not add it to `test_direction()`. + +**Step 2: Verify the new trait-consistency entry** + +Run: +```bash +cargo test trait_consistency --lib +``` + +Expected: PASS with the new problem included. + +### Task 4: Wire `pred create` for the new model + +**Files:** +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/src/cli.rs` + +**Step 1: Add a concrete `pred create BoundedComponentSpanningForest` arm** + +Use existing flags only: +- `--graph` +- `--weights` +- `--k` +- `--bound` + +The create arm should: +- parse `graph` as `SimpleGraph` +- require `weights` +- require `k` +- require `bound` +- validate `weights.len() == graph.num_vertices()` +- serialize `BoundedComponentSpanningForest::::new(graph, weights, k, bound as i32)` + +Do not add a new alias unless there is a literature-standard abbreviation. + +**Step 2: Update CLI help/examples** + +Add: +- an example string in `example_for(...)` +- a help-table row in `problemreductions-cli/src/cli.rs` for `BoundedComponentSpanningForest` + +If the existing `--bound` doc string only mentions other problems, extend it to include this model. + +**Step 3: Verify the CLI path** + +Run: +```bash +cargo run -p problemreductions-cli -- 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 +``` + +Expected: PASS and print JSON for a `BoundedComponentSpanningForest` instance. + +### Task 5: Regenerate exports and run focused verification + +**Files:** +- Modify/generated: `docs/src/reductions/problem_schemas.json` +- Modify/generated: `docs/src/reductions/reduction_graph.json` + +**Step 1: Refresh the generated schema/graph artifacts** + +Run: +```bash +cargo run --example export_graph +cargo run --example export_schemas +``` + +Expected: +- `problem_schemas.json` gains `BoundedComponentSpanningForest` +- `reduction_graph.json` updates consistently for the new catalog state + +**Step 2: Run the focused verification set** + +Run: +```bash +cargo test bounded_component_spanning_forest --lib +cargo test trait_consistency --lib +cargo test example_db --lib --features example-db +``` + +Expected: PASS. + +## Batch 2: Paper Entry + +### Task 6: Add the paper entry after the code and exports are stable + +**Files:** +- Modify: `docs/paper/reductions.typ` + +**Step 1: Register the display name** + +Add: +- `"BoundedComponentSpanningForest": [Bounded Component Spanning Forest],` + +to the `display-name` dictionary. + +**Step 2: Add `problem-def("BoundedComponentSpanningForest")`** + +Model it after the existing graph problem entries. Include: +- formal definition with graph `G = (V, E)`, vertex weights `w`, component limit `K`, and weight bound `B` +- short background on Garey & Johnson ND10 and its partitioning/redistricting interpretation +- a sentence on the exponential exact search bound used in the catalog (`O^*(K^n)`) +- the issue YES instance as the tutorial example, with one explicitly shown satisfying partition and a short verification argument + +Avoid claiming a stronger exact algorithm unless the citation is precise and directly supports this exact weighted connected-partition variant. + +**Step 3: Build the paper** + +Run: +```bash +make paper +``` + +Expected: PASS with the new model rendered in the PDF. + +## Batch 3: Final Verification + Review + +### Task 7: Run the full repo checks required before review + +**Files:** +- No new files; verification only + +**Step 1: Run formatting and lint/test verification** + +Run: +```bash +make fmt +make clippy +make test +``` + +Expected: PASS. + +**Step 2: Run structural implementation review** + +After all code is green, run the repo-local review workflow: + +```bash +/review-implementation +``` + +Auto-fix any issues it finds, then rerun the relevant verification commands until green. + +**Step 3: Prepare the PR summary** + +Before pushing the implementation commits: +- summarize the new model, tests, CLI support, generated exports, and paper entry +- note the deliberate deviation from the automated helper: the pipeline helper falsely matched PR `#631`, so this issue was executed in a fresh dedicated worktree instead of resuming that unrelated branch +- include the orphan-model warning unless a companion rule issue now exists From 9e79225c3fa1b825c9113e3dd801f84a59fea2c3 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 07:59:09 +0800 Subject: [PATCH 02/10] Implement #251: [Model] BoundedComponentSpanningForest --- docs/paper/reductions.typ | 13 + docs/src/reductions/problem_schemas.json | 26 ++ docs/src/reductions/reduction_graph.json | 244 +++++++++--------- problemreductions-cli/src/cli.rs | 5 +- problemreductions-cli/src/commands/create.rs | 74 +++++- problemreductions-cli/tests/cli_tests.rs | 146 +++++++++++ src/example_db/fixtures/examples.json | 1 + src/lib.rs | 4 +- .../bounded_component_spanning_forest.rs | 244 ++++++++++++++++++ src/models/graph/mod.rs | 4 + src/models/mod.rs | 10 +- src/unit_tests/example_db.rs | 19 +- src/unit_tests/export.rs | 3 +- .../bounded_component_spanning_forest.rs | 137 ++++++++++ src/unit_tests/trait_consistency.rs | 9 + 15 files changed, 797 insertions(+), 142 deletions(-) create mode 100644 src/models/graph/bounded_component_spanning_forest.rs create mode 100644 src/unit_tests/models/graph/bounded_component_spanning_forest.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b3a286b6..523cff14 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -89,6 +89,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], @@ -529,6 +530,18 @@ 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 exact algorithm enumerates all assignments of the $n = |V|$ vertices to at most $K$ component labels and checks connectivity plus the weight bound for each non-empty part, yielding an $O^*(K^n)$ exhaustive-search bound. + +*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. +] #{ let x = load-model-example("HamiltonianPath") let nv = graph-num-vertices(x.instance) diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 3a6e9df8..c07887e1 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -57,6 +57,32 @@ } ] }, + { + "name": "BoundedComponentSpanningForest", + "description": "Partition vertices into at most K connected components, each of total weight at most B", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "weights", + "type_name": "Vec", + "description": "Vertex weights w(v) for each vertex v in V" + }, + { + "name": "max_components", + "type_name": "usize", + "description": "Upper bound K on the number of connected components" + }, + { + "name": "max_weight", + "type_name": "W::Sum", + "description": "Upper bound B on the total weight of each component" + } + ] + }, { "name": "CircuitSAT", "description": "Find satisfying input to a boolean circuit", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index c1334cfb..e0ebf9e4 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -32,6 +32,16 @@ "doc_path": "models/misc/struct.BinPacking.html", "complexity": "2^num_items" }, + { + "name": "BoundedComponentSpanningForest", + "variant": { + "graph": "SimpleGraph", + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.BoundedComponentSpanningForest.html", + "complexity": "max_components^num_vertices" + }, { "name": "CircuitSAT", "variant": {}, @@ -519,7 +529,7 @@ "edges": [ { "source": 3, - "target": 12, + "target": 13, "overhead": [ { "field": "num_vars", @@ -533,8 +543,8 @@ "doc_path": "rules/binpacking_ilp/index.html" }, { - "source": 4, - "target": 12, + "source": 5, + "target": 13, "overhead": [ { "field": "num_vars", @@ -548,8 +558,8 @@ "doc_path": "rules/circuit_ilp/index.html" }, { - "source": 4, - "target": 54, + "source": 5, + "target": 55, "overhead": [ { "field": "num_spins", @@ -563,8 +573,8 @@ "doc_path": "rules/circuit_spinglass/index.html" }, { - "source": 8, - "target": 4, + "source": 9, + "target": 5, "overhead": [ { "field": "num_variables", @@ -578,8 +588,8 @@ "doc_path": "rules/factoring_circuit/index.html" }, { - "source": 8, - "target": 13, + "source": 9, + "target": 14, "overhead": [ { "field": "num_vars", @@ -593,8 +603,8 @@ "doc_path": "rules/factoring_ilp/index.html" }, { - "source": 12, - "target": 13, + "source": 13, + "target": 14, "overhead": [ { "field": "num_vars", @@ -608,8 +618,8 @@ "doc_path": "rules/ilp_bool_ilp_i32/index.html" }, { - "source": 12, - "target": 49, + "source": 13, + "target": 50, "overhead": [ { "field": "num_vars", @@ -619,8 +629,8 @@ "doc_path": "rules/ilp_qubo/index.html" }, { - "source": 16, - "target": 19, + "source": 17, + "target": 20, "overhead": [ { "field": "num_vertices", @@ -634,8 +644,8 @@ "doc_path": "rules/kcoloring_casts/index.html" }, { - "source": 19, - "target": 12, + "source": 20, + "target": 13, "overhead": [ { "field": "num_vars", @@ -649,8 +659,8 @@ "doc_path": "rules/coloring_ilp/index.html" }, { - "source": 19, - "target": 49, + "source": 20, + "target": 50, "overhead": [ { "field": "num_vars", @@ -660,8 +670,8 @@ "doc_path": "rules/coloring_qubo/index.html" }, { - "source": 20, - "target": 22, + "source": 21, + "target": 23, "overhead": [ { "field": "num_vars", @@ -675,8 +685,8 @@ "doc_path": "rules/ksatisfiability_casts/index.html" }, { - "source": 20, - "target": 49, + "source": 21, + "target": 50, "overhead": [ { "field": "num_vars", @@ -686,8 +696,8 @@ "doc_path": "rules/ksatisfiability_qubo/index.html" }, { - "source": 21, - "target": 22, + "source": 22, + "target": 23, "overhead": [ { "field": "num_vars", @@ -701,8 +711,8 @@ "doc_path": "rules/ksatisfiability_casts/index.html" }, { - "source": 21, - "target": 49, + "source": 22, + "target": 50, "overhead": [ { "field": "num_vars", @@ -712,8 +722,8 @@ "doc_path": "rules/ksatisfiability_qubo/index.html" }, { - "source": 21, - "target": 56, + "source": 22, + "target": 57, "overhead": [ { "field": "num_elements", @@ -723,8 +733,8 @@ "doc_path": "rules/ksatisfiability_subsetsum/index.html" }, { - "source": 22, - "target": 51, + "source": 23, + "target": 52, "overhead": [ { "field": "num_clauses", @@ -742,8 +752,8 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 23, - "target": 49, + "source": 24, + "target": 50, "overhead": [ { "field": "num_vars", @@ -753,8 +763,8 @@ "doc_path": "rules/knapsack_qubo/index.html" }, { - "source": 24, - "target": 12, + "source": 25, + "target": 13, "overhead": [ { "field": "num_vars", @@ -768,8 +778,8 @@ "doc_path": "rules/longestcommonsubsequence_ilp/index.html" }, { - "source": 25, - "target": 54, + "source": 26, + "target": 55, "overhead": [ { "field": "num_spins", @@ -783,8 +793,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 27, - "target": 12, + "source": 28, + "target": 13, "overhead": [ { "field": "num_vars", @@ -798,8 +808,8 @@ "doc_path": "rules/maximumclique_ilp/index.html" }, { - "source": 27, - "target": 31, + "source": 28, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -813,8 +823,8 @@ "doc_path": "rules/maximumclique_maximumindependentset/index.html" }, { - "source": 28, - "target": 29, + "source": 29, + "target": 30, "overhead": [ { "field": "num_vertices", @@ -828,8 +838,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 28, - "target": 33, + "source": 29, + "target": 34, "overhead": [ { "field": "num_vertices", @@ -843,8 +853,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 29, - "target": 34, + "source": 30, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -858,8 +868,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 28, + "source": 31, + "target": 29, "overhead": [ { "field": "num_vertices", @@ -873,8 +883,8 @@ "doc_path": "rules/maximumindependentset_gridgraph/index.html" }, { - "source": 30, - "target": 31, + "source": 31, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -888,8 +898,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 32, + "source": 31, + "target": 33, "overhead": [ { "field": "num_vertices", @@ -903,8 +913,8 @@ "doc_path": "rules/maximumindependentset_triangular/index.html" }, { - "source": 30, - "target": 36, + "source": 31, + "target": 37, "overhead": [ { "field": "num_sets", @@ -918,8 +928,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 27, + "source": 32, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -933,8 +943,8 @@ "doc_path": "rules/maximumindependentset_maximumclique/index.html" }, { - "source": 31, - "target": 38, + "source": 32, + "target": 39, "overhead": [ { "field": "num_sets", @@ -948,8 +958,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 45, + "source": 32, + "target": 46, "overhead": [ { "field": "num_vertices", @@ -963,8 +973,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 32, - "target": 34, + "source": 33, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -978,8 +988,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 33, - "target": 30, + "source": 34, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -993,8 +1003,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 33, - "target": 34, + "source": 34, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -1008,8 +1018,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 34, - "target": 31, + "source": 35, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1023,8 +1033,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 35, - "target": 12, + "source": 36, + "target": 13, "overhead": [ { "field": "num_vars", @@ -1038,8 +1048,8 @@ "doc_path": "rules/maximummatching_ilp/index.html" }, { - "source": 35, - "target": 38, + "source": 36, + "target": 39, "overhead": [ { "field": "num_sets", @@ -1053,8 +1063,8 @@ "doc_path": "rules/maximummatching_maximumsetpacking/index.html" }, { - "source": 36, - "target": 30, + "source": 37, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -1068,8 +1078,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 36, - "target": 38, + "source": 37, + "target": 39, "overhead": [ { "field": "num_sets", @@ -1083,8 +1093,8 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 37, - "target": 49, + "source": 38, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1094,8 +1104,8 @@ "doc_path": "rules/maximumsetpacking_qubo/index.html" }, { - "source": 38, - "target": 12, + "source": 39, + "target": 13, "overhead": [ { "field": "num_vars", @@ -1109,8 +1119,8 @@ "doc_path": "rules/maximumsetpacking_ilp/index.html" }, { - "source": 38, - "target": 31, + "source": 39, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1124,8 +1134,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 38, - "target": 37, + "source": 39, + "target": 38, "overhead": [ { "field": "num_sets", @@ -1139,8 +1149,8 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 39, - "target": 12, + "source": 40, + "target": 13, "overhead": [ { "field": "num_vars", @@ -1154,8 +1164,8 @@ "doc_path": "rules/minimumdominatingset_ilp/index.html" }, { - "source": 42, - "target": 12, + "source": 43, + "target": 13, "overhead": [ { "field": "num_vars", @@ -1169,8 +1179,8 @@ "doc_path": "rules/minimumsetcovering_ilp/index.html" }, { - "source": 45, - "target": 31, + "source": 46, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1184,8 +1194,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 45, - "target": 42, + "source": 46, + "target": 43, "overhead": [ { "field": "num_sets", @@ -1199,8 +1209,8 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 49, - "target": 12, + "source": 50, + "target": 13, "overhead": [ { "field": "num_vars", @@ -1214,8 +1224,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 49, - "target": 53, + "source": 50, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1225,8 +1235,8 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 51, - "target": 4, + "source": 52, + "target": 5, "overhead": [ { "field": "num_variables", @@ -1240,8 +1250,8 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 51, - "target": 16, + "source": 52, + "target": 17, "overhead": [ { "field": "num_vertices", @@ -1255,8 +1265,8 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 51, - "target": 21, + "source": 52, + "target": 22, "overhead": [ { "field": "num_clauses", @@ -1270,8 +1280,8 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 51, - "target": 30, + "source": 52, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -1285,8 +1295,8 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 51, - "target": 39, + "source": 52, + "target": 40, "overhead": [ { "field": "num_vertices", @@ -1300,8 +1310,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, - "target": 49, + "source": 54, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1311,8 +1321,8 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, - "target": 25, + "source": 55, + "target": 26, "overhead": [ { "field": "num_vertices", @@ -1326,8 +1336,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1341,8 +1351,8 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 57, - "target": 12, + "source": 58, + "target": 13, "overhead": [ { "field": "num_vars", @@ -1356,8 +1366,8 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 57, - "target": 49, + "source": 58, + "target": 50, "overhead": [ { "field": "num_vars", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index c42f69c2..e9b444ea 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 IsomorphicSpanningTree --graph, --tree Factoring --target, --m, --n BinPacking --sizes, --capacity @@ -373,8 +374,8 @@ pub struct CreateArgs { /// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4") #[arg(long)] pub required_edges: Option, - /// Upper bound (for RuralPostman or SCS) - #[arg(long)] + /// Upper bound (for RuralPostman, BoundedComponentSpanningForest, 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) #[arg(long)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a1444224..2936ad27 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::{ + 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}; @@ -224,6 +226,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", "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { @@ -320,9 +325,22 @@ 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) => { + let catalog_resolved = resolve_catalog_problem_ref(problem)?; + 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); + } + } + }; + let canonical = resolved.name.as_str(); + let resolved_variant = resolved.variant.clone(); let graph_type = resolved_graph_type(&resolved_variant); if args.random { @@ -351,7 +369,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" @@ -373,6 +391,50 @@ 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}"); + } + if max_components > n { + bail!( + "BoundedComponentSpanningForest requires --k <= number of vertices ({n})\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| { @@ -429,7 +491,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))?, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index f011bad1..aa65eca4 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1134,6 +1134,152 @@ 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_rejects_k_larger_than_num_vertices() { + let output = pred() + .args([ + "create", + "BoundedComponentSpanningForest", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + "--k", + "5", + "--bound", + "2", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--k <= number of vertices"), + "stderr: {stderr}" + ); +} + +#[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_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 e48eef82..a65969ee 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":"ExactCoverBy3Sets","variant":{},"instance":{"subsets":[[0,1,2],[0,2,4],[3,4,5],[3,5,7],[6,7,8],[1,4,6],[2,5,8]],"universe_size":9},"samples":[{"config":[1,0,1,0,1,0,0],"metric":true}],"optimal":[{"config":[1,0,1,0,1,0,0],"metric":true}]}, diff --git a/src/lib.rs b/src/lib.rs index bceccfe2..ffd6789e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,8 +45,8 @@ 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, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, SpinGlass, - SubgraphIsomorphism, + BicliqueCover, BoundedComponentSpanningForest, GraphPartitioning, HamiltonianPath, + IsomorphicSpanningTree, SpinGlass, 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..389ad4f8 --- /dev/null +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -0,0 +1,244 @@ +//! 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_components <= graph.num_vertices(), + "max_components must not exceed graph num_vertices" + ); + 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 { + if config.len() != self.graph.num_vertices() { + return false; + } + + let mut component_vertices = vec![Vec::new(); self.max_components]; + for (vertex, &component) in config.iter().enumerate() { + if component >= self.max_components { + return false; + } + component_vertices[component].push(vertex); + } + + for vertices in component_vertices { + if vertices.is_empty() { + continue; + } + + let mut total_weight = W::Sum::zero(); + for &vertex in &vertices { + total_weight += self.weights[vertex].to_sum(); + } + if total_weight > self.max_weight { + return false; + } + + if !is_connected_component(&self.graph, &vertices) { + 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, +{ +} + +fn is_connected_component(graph: &G, vertices: &[usize]) -> bool { + if vertices.len() <= 1 { + return true; + } + + let mut in_component = vec![false; graph.num_vertices()]; + for &vertex in vertices { + in_component[vertex] = true; + } + + let mut visited = vec![false; graph.num_vertices()]; + let mut queue = VecDeque::from([vertices[0]]); + visited[vertices[0]] = true; + let mut visited_count = 0usize; + + while let Some(vertex) = queue.pop_front() { + visited_count += 1; + for neighbor in graph.neighbors(vertex) { + if in_component[neighbor] && !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited_count == vertices.len() +} + +#[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 => "max_components^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 7d76de65..49be60ea 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) @@ -24,6 +25,7 @@ //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) pub(crate) mod biclique_cover; +pub(crate) mod bounded_component_spanning_forest; pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_path; pub(crate) mod isomorphic_spanning_tree; @@ -46,6 +48,7 @@ pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; pub use biclique_cover::BicliqueCover; +pub use bounded_component_spanning_forest::BoundedComponentSpanningForest; pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_path::HamiltonianPath; pub use isomorphic_spanning_tree::IsomorphicSpanningTree; @@ -85,6 +88,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec {} — regenerate fixtures", + loaded_rule.source.problem, + loaded_rule.target.problem + ); + let label = format!( + "{} -> {}", loaded_rule.source.problem, loaded_rule.target.problem ); - let label = - format!("{} -> {}", loaded_rule.source.problem, loaded_rule.target.problem); - for (loaded_pair, computed_pair) in - loaded_rule.solutions.iter().zip(computed_rule.solutions.iter()) + for (loaded_pair, computed_pair) in loaded_rule + .solutions + .iter() + .zip(computed_rule.solutions.iter()) { let loaded_target_problem = load_dyn( &loaded_rule.target.problem, @@ -718,10 +723,8 @@ fn verify_rule_fixtures_match_computed() { loaded_rule.target.instance.clone(), ) .unwrap_or_else(|e| panic!("{label}: load target: {e}")); - let loaded_energy = - loaded_target_problem.evaluate_dyn(&loaded_pair.target_config); - let computed_energy = - loaded_target_problem.evaluate_dyn(&computed_pair.target_config); + let loaded_energy = loaded_target_problem.evaluate_dyn(&loaded_pair.target_config); + let computed_energy = loaded_target_problem.evaluate_dyn(&computed_pair.target_config); assert_eq!( loaded_energy, computed_energy, "{label}: target energy mismatch — regenerate fixtures" diff --git a/src/unit_tests/export.rs b/src/unit_tests/export.rs index d6c4dc39..8b02dc62 100644 --- a/src/unit_tests/export.rs +++ b/src/unit_tests/export.rs @@ -210,8 +210,7 @@ fn test_write_example_db_uses_one_line_per_example_entry() { "model entry should be serialized as one compact JSON object line" ); assert!( - rule_line.trim().starts_with('{') - && rule_line.trim().trim_end_matches(',').ends_with('}'), + rule_line.trim().starts_with('{') && rule_line.trim().trim_end_matches(',').ends_with('}'), "rule entry should be serialized as one compact JSON object line" ); 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 00000000..7d40a6e6 --- /dev/null +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -0,0 +1,137 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +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_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] +#[should_panic(expected = "max_components must not exceed graph num_vertices")] +fn test_bounded_component_spanning_forest_rejects_too_many_components_in_constructor() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let _ = BoundedComponentSpanningForest::new(graph, vec![1, 1], 3, 1); +} + +#[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); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index eee5dc64..ef0f2703 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -41,6 +41,15 @@ fn test_all_problems_implement_trait_correctly() { &KColoring::::new(SimpleGraph::new(3, vec![(0, 1)])), "KColoring", ); + check_problem_trait( + &BoundedComponentSpanningForest::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1i32; 3], + 2, + 2, + ), + "BoundedComponentSpanningForest", + ); check_problem_trait( &MinimumDominatingSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]), "MinimumDominatingSet", From 10e9e90b478ea707aba4f8f5a22bc0dd87f54d92 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 08:02:11 +0800 Subject: [PATCH 03/10] chore: remove plan file after implementation --- ...03-16-bounded-component-spanning-forest.md | 287 ------------------ 1 file changed, 287 deletions(-) delete mode 100644 docs/plans/2026-03-16-bounded-component-spanning-forest.md diff --git a/docs/plans/2026-03-16-bounded-component-spanning-forest.md b/docs/plans/2026-03-16-bounded-component-spanning-forest.md deleted file mode 100644 index 3b78eea5..00000000 --- a/docs/plans/2026-03-16-bounded-component-spanning-forest.md +++ /dev/null @@ -1,287 +0,0 @@ -# BoundedComponentSpanningForest Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `BoundedComponentSpanningForest` graph satisfaction model, wire it into the registry/CLI/example flows, document it in the paper, and verify the issue's YES/NO instances end-to-end. - -**Architecture:** Implement `BoundedComponentSpanningForest` as a graph-based satisfaction problem where each vertex is assigned one of at most `K` component ids. Evaluation groups vertices by assigned component, rejects any component whose total vertex weight exceeds `B`, and rejects any non-empty component whose induced subgraph is disconnected. Start with the default concrete registry variant `BoundedComponentSpanningForest` and keep the paper example aligned with the issue's YES instance. - -**Tech Stack:** Rust workspace, `serde`, graph `Problem`/`SatisfactionProblem` traits, registry metadata via `inventory` + `declare_variants!`, `pred create` CLI, Typst paper, mdBook schema/graph exports. - ---- - -**Execution notes** - -- This issue is currently an orphan model: `gh issue list --label rule --state open` returns no open rule issues whose title references `BoundedComponentSpanningForest`. Keep a visible orphan-model warning in the PR description unless a companion rule issue is filed separately. -- The issue-to-PR helper currently overmatches existing PRs for this issue number. Execute this plan in the dedicated `issue-251-bounded-component-spanning-forest` worktree and do not resume the unrelated PR `#631`. -- Use the issue's YES instance as the canonical implementation-facing example: - - Graph edges: `(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`, `B = 6` - - Satisfying assignment: `[0, 0, 1, 1, 1, 2, 2, 0]`, representing components `{0,1,7}`, `{2,3,4}`, `{5,6}` -- Also preserve a NO instance in tests: - - Graph edges: `(0,1) (1,2) (3,4) (4,5)` - - Weights: `[1, 1, 1, 1, 1, 1]` - - `K = 2`, `B = 2` - - Expected result: no satisfying assignment exists. - -## Batch 1: Model + Registry + CLI + Tests - -### Task 1: Write the failing model tests first - -**Files:** -- Create: `src/unit_tests/models/graph/bounded_component_spanning_forest.rs` - -**Step 1: Add failing tests for the issue behavior** - -Write tests that cover: -- construction + getters (`graph`, `weights`, `max_components`, `max_weight`, `dims`) -- YES-instance evaluation using config `[0, 0, 1, 1, 1, 2, 2, 0]` -- rejection when a component exceeds the weight bound -- rejection when a component is disconnected -- rejection when a config uses an out-of-range component id or wrong length -- serde round-trip -- solver behavior: `find_satisfying()` succeeds on the YES instance and returns `None` on the NO instance -- paper/example alignment test using the canonical YES instance - -**Step 2: Run the new test file to verify RED** - -Run: -```bash -cargo test bounded_component_spanning_forest --lib -``` - -Expected: FAIL because `BoundedComponentSpanningForest` does not exist yet. - -### Task 2: Implement the model and expose it from the crate - -**Files:** -- Create: `src/models/graph/bounded_component_spanning_forest.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -**Step 1: Implement `BoundedComponentSpanningForest`** - -Use the same broad shape as `RuralPostman` and `KColoring`: -- `graph: G` -- `weights: Vec` -- `max_components: usize` -- `max_weight: W::Sum` - -Include: -- `inventory::submit!` registration with `name = "BoundedComponentSpanningForest"` -- dimensions: - - `graph` default/allowed: `SimpleGraph` - - `weight` default/allowed: `i32` -- field schema entries for `graph`, `weights`, `max_components`, `max_weight` -- constructor validation: - - `weights.len() == graph.num_vertices()` - - `max_components >= 1` -- getters: - - `graph()` - - `weights()` - - `max_components()` - - `max_weight()` - - `num_vertices()` - - `num_edges()` - - `is_weighted()` -- helper methods: - - `is_valid_solution(&self, config: &[usize]) -> bool` - - an internal connectivity helper that BFS/DFSes only within one assigned component - -**Step 2: Implement the trait layer** - -Implement: -- `Problem` -- `SatisfactionProblem` -- `variant()` via `crate::variant_params![G, W]` -- `dims()` as `vec![self.max_components; self.graph.num_vertices()]` -- `evaluate()` as a pure feasibility check returning `true`/`false` - -For feasibility, reject when: -- config length differs from `num_vertices` -- any component id is `>= max_components` -- any non-empty component induces a disconnected subgraph -- any component's total weight exceeds `max_weight` - -**Step 3: Register the concrete variant + example-db hook** - -At the bottom of the model file: -- add `crate::declare_variants! { default sat BoundedComponentSpanningForest => "max_components^num_vertices" }` -- add `canonical_model_example_specs()` returning a satisfaction example built from the issue YES instance -- link the test file with `#[cfg(test)] #[path = "../../unit_tests/models/graph/bounded_component_spanning_forest.rs"]` - -**Step 4: Re-export the new type** - -Update: -- `src/models/graph/mod.rs` -- `src/models/mod.rs` -- `src/lib.rs` prelude exports - -**Step 5: Run the targeted tests to verify GREEN** - -Run: -```bash -cargo test bounded_component_spanning_forest --lib -``` - -Expected: PASS for the new model test file, plus any matching integration hooks. - -### Task 3: Add trait-consistency coverage and exported schema support - -**Files:** -- Modify: `src/unit_tests/trait_consistency.rs` - -**Step 1: Add the new problem to trait consistency** - -Add a small instance such as a 3-vertex path with unit weights expressed as `i32`, `K = 2`, `B = 2` to `test_all_problems_implement_trait_correctly`. - -Because this is a satisfaction problem, do not add it to `test_direction()`. - -**Step 2: Verify the new trait-consistency entry** - -Run: -```bash -cargo test trait_consistency --lib -``` - -Expected: PASS with the new problem included. - -### Task 4: Wire `pred create` for the new model - -**Files:** -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/src/cli.rs` - -**Step 1: Add a concrete `pred create BoundedComponentSpanningForest` arm** - -Use existing flags only: -- `--graph` -- `--weights` -- `--k` -- `--bound` - -The create arm should: -- parse `graph` as `SimpleGraph` -- require `weights` -- require `k` -- require `bound` -- validate `weights.len() == graph.num_vertices()` -- serialize `BoundedComponentSpanningForest::::new(graph, weights, k, bound as i32)` - -Do not add a new alias unless there is a literature-standard abbreviation. - -**Step 2: Update CLI help/examples** - -Add: -- an example string in `example_for(...)` -- a help-table row in `problemreductions-cli/src/cli.rs` for `BoundedComponentSpanningForest` - -If the existing `--bound` doc string only mentions other problems, extend it to include this model. - -**Step 3: Verify the CLI path** - -Run: -```bash -cargo run -p problemreductions-cli -- 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 -``` - -Expected: PASS and print JSON for a `BoundedComponentSpanningForest` instance. - -### Task 5: Regenerate exports and run focused verification - -**Files:** -- Modify/generated: `docs/src/reductions/problem_schemas.json` -- Modify/generated: `docs/src/reductions/reduction_graph.json` - -**Step 1: Refresh the generated schema/graph artifacts** - -Run: -```bash -cargo run --example export_graph -cargo run --example export_schemas -``` - -Expected: -- `problem_schemas.json` gains `BoundedComponentSpanningForest` -- `reduction_graph.json` updates consistently for the new catalog state - -**Step 2: Run the focused verification set** - -Run: -```bash -cargo test bounded_component_spanning_forest --lib -cargo test trait_consistency --lib -cargo test example_db --lib --features example-db -``` - -Expected: PASS. - -## Batch 2: Paper Entry - -### Task 6: Add the paper entry after the code and exports are stable - -**Files:** -- Modify: `docs/paper/reductions.typ` - -**Step 1: Register the display name** - -Add: -- `"BoundedComponentSpanningForest": [Bounded Component Spanning Forest],` - -to the `display-name` dictionary. - -**Step 2: Add `problem-def("BoundedComponentSpanningForest")`** - -Model it after the existing graph problem entries. Include: -- formal definition with graph `G = (V, E)`, vertex weights `w`, component limit `K`, and weight bound `B` -- short background on Garey & Johnson ND10 and its partitioning/redistricting interpretation -- a sentence on the exponential exact search bound used in the catalog (`O^*(K^n)`) -- the issue YES instance as the tutorial example, with one explicitly shown satisfying partition and a short verification argument - -Avoid claiming a stronger exact algorithm unless the citation is precise and directly supports this exact weighted connected-partition variant. - -**Step 3: Build the paper** - -Run: -```bash -make paper -``` - -Expected: PASS with the new model rendered in the PDF. - -## Batch 3: Final Verification + Review - -### Task 7: Run the full repo checks required before review - -**Files:** -- No new files; verification only - -**Step 1: Run formatting and lint/test verification** - -Run: -```bash -make fmt -make clippy -make test -``` - -Expected: PASS. - -**Step 2: Run structural implementation review** - -After all code is green, run the repo-local review workflow: - -```bash -/review-implementation -``` - -Auto-fix any issues it finds, then rerun the relevant verification commands until green. - -**Step 3: Prepare the PR summary** - -Before pushing the implementation commits: -- summarize the new model, tests, CLI support, generated exports, and paper entry -- note the deliberate deviation from the automated helper: the pipeline helper falsely matched PR `#631`, so this issue was executed in a fresh dedicated worktree instead of resuming that unrelated branch -- include the orphan-model warning unless a companion rule issue now exists From 685cacf97cf997d11341bd6168300ecd2151c7c3 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 13:52:43 +0800 Subject: [PATCH 04/10] fix: address review feedback for bcsf --- docs/paper/reductions.typ | 2 +- docs/src/cli.md | 24 ++++- docs/src/reductions/reduction_graph.json | 2 +- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 53 ++++++++--- problemreductions-cli/tests/cli_tests.rs | 64 ++++++++++++++ .../bounded_component_spanning_forest.rs | 88 ++++++++++--------- .../bounded_component_spanning_forest.rs | 61 +++++++++++++ 8 files changed, 238 insertions(+), 58 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 523cff14..a7387e5c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -534,7 +534,7 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co #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 exact algorithm enumerates all assignments of the $n = |V|$ vertices to at most $K$ component labels and checks connectivity plus the weight bound for each non-empty part, yielding an $O^*(K^n)$ exhaustive-search bound. +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}$, diff --git a/docs/src/cli.md b/docs/src/cli.md index 6507c61f..03e86cf2 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -309,6 +309,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: @@ -414,7 +435,8 @@ Source evaluation: Valid(2) ``` > **Note:** The ILP solver requires a reduction path from the target problem to ILP. -> Some problems (e.g., QUBO, SpinGlass, MaxCut, CircuitSAT) do not have this path yet. +> Some problems (e.g., QUBO, SpinGlass, MaxCut, CircuitSAT, +> BoundedComponentSpanningForest) do not have this path yet. > Use `--solver brute-force` for these, or reduce to a problem that supports ILP first. ## Shell Completions diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index e0ebf9e4..1bf51fbe 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -40,7 +40,7 @@ }, "category": "graph", "doc_path": "models/graph/struct.BoundedComponentSpanningForest.html", - "complexity": "max_components^num_vertices" + "complexity": "3^num_vertices" }, { "name": "CircuitSAT", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index e9b444ea..1cb04db5 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -374,7 +374,7 @@ pub struct CreateArgs { /// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4") #[arg(long)] pub required_edges: Option, - /// Upper bound (for RuralPostman, BoundedComponentSpanningForest, or SCS) + /// Upper bound (for RuralPostman, BoundedComponentSpanningForest, OptimalLinearArrangement, 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 2936ad27..9d25421b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -256,6 +256,31 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } } +fn help_flag_name(canonical: &str, field_name: &str) -> String { + match (canonical, field_name) { + ("BoundedComponentSpanningForest", "max_components") => "k".to_string(), + ("BoundedComponentSpanningForest", "max_weight") => "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, @@ -280,10 +305,10 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { let hint = type_format_hint(&field.type_name, graph_type); eprintln!(" --{:<16} {} ({})", "arcs", 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} {} ({})", - field.name.replace('_', "-"), + help_flag_name(canonical, &field.name), field.description, hint ); @@ -912,17 +937,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(), @@ -1086,9 +1109,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| { @@ -1780,9 +1805,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 aa65eca4..03bac85c 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1280,6 +1280,70 @@ fn test_create_bounded_component_spanning_forest_rejects_out_of_range_bound() { 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/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 389ad4f8..682c48bf 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -115,32 +115,66 @@ impl BoundedComponentSpanningForest { /// Check if a configuration is a valid bounded-component partition. pub fn is_valid_solution(&self, config: &[usize]) -> bool { - if config.len() != self.graph.num_vertices() { + let num_vertices = self.graph.num_vertices(); + if config.len() != num_vertices { return false; } - let mut component_vertices = vec![Vec::new(); self.max_components]; + 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; } - component_vertices[component].push(vertex); + + 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; + } } - for vertices in component_vertices { - if vertices.is_empty() { + 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 mut total_weight = W::Sum::zero(); - for &vertex in &vertices { - total_weight += self.weights[vertex].to_sum(); - } - if total_weight > self.max_weight { - return false; + 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 !is_connected_component(&self.graph, &vertices) { + if visited_count != component_size { return false; } } @@ -177,34 +211,6 @@ where { } -fn is_connected_component(graph: &G, vertices: &[usize]) -> bool { - if vertices.len() <= 1 { - return true; - } - - let mut in_component = vec![false; graph.num_vertices()]; - for &vertex in vertices { - in_component[vertex] = true; - } - - let mut visited = vec![false; graph.num_vertices()]; - let mut queue = VecDeque::from([vertices[0]]); - visited[vertices[0]] = true; - let mut visited_count = 0usize; - - while let Some(vertex) = queue.pop_front() { - visited_count += 1; - for neighbor in graph.neighbors(vertex) { - if in_component[neighbor] && !visited[neighbor] { - visited[neighbor] = true; - queue.push_back(neighbor); - } - } - } - - visited_count == vertices.len() -} - #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { @@ -236,7 +242,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "max_components^num_vertices", + default sat BoundedComponentSpanningForest => "3^num_vertices", } #[cfg(test)] diff --git a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs index 7d40a6e6..0ad5a8c0 100644 --- a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -2,6 +2,53 @@ 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 = 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( @@ -72,6 +119,20 @@ fn test_bounded_component_spanning_forest_rejects_wrong_length() { 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(); From fd7b71a9bab6aed962b85d74043304858ce312d9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 13:58:23 +0800 Subject: [PATCH 05/10] fix: preserve create variant errors for graph-backed problems --- problemreductions-cli/src/commands/create.rs | 26 +++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 9d25421b..71739da5 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -2,7 +2,7 @@ use crate::cli::{CreateArgs, ExampleSide}; use crate::dispatch::ProblemJsonOutput; use crate::output::OutputConfig; use crate::problem_name::{ - resolve_catalog_problem_ref, resolve_problem_ref, unknown_problem_error, + parse_problem_spec, resolve_catalog_problem_ref, resolve_problem_ref, unknown_problem_error, }; use crate::util; use anyhow::{bail, Context, Result}; @@ -352,17 +352,25 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let rgraph = problemreductions::rules::ReductionGraph::new(); let resolved = match resolve_problem_ref(problem, &rgraph) { Ok(resolved) => resolved, - Err(graph_err) => { - let catalog_resolved = resolve_catalog_problem_ref(problem)?; - if rgraph.variants_for(catalog_resolved.name()).is_empty() { - ProblemRef { - name: catalog_resolved.name().to_string(), - variant: catalog_resolved.variant().clone(), + 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); } - } else { return Err(graph_err); } - } + }, }; let canonical = resolved.name.as_str(); let resolved_variant = resolved.variant.clone(); From 0661855811f368ae669d3f7c6110869614e1b1fa Mon Sep 17 00:00:00 2001 From: zazabap Date: Mon, 16 Mar 2026 16:38:21 +0000 Subject: [PATCH 06/10] fix: address final review weaknesses - Add CeTZ figure to paper entry showing the 3-component partition with colored vertices, weight labels, and region backgrounds - Relax max_components assertion: K > |V| is mathematically harmless (just means fewer than K components will be used) - Update CLI validation and tests accordingly Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 63 +++++++++++++++++++ problemreductions-cli/src/commands/create.rs | 5 -- problemreductions-cli/tests/cli_tests.rs | 14 +++-- .../bounded_component_spanning_forest.rs | 4 -- .../bounded_component_spanning_forest.rs | 8 ++- 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a66d9f59..4017cc72 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -547,6 +547,69 @@ $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 assignments: V1={0,1,7}, V2={2,3,4}, V3={5,6} + let partition = (0, 0, 1, 1, 1, 2, 2, 0) // vertex -> component index + let comp-colors = (graph-colors.at(0), graph-colors.at(2), graph-colors.at(1)) // blue, green, red + // Intra-component edges: both endpoints in same partition + let intra(e) = partition.at(e.at(0)) == partition.at(e.at(1)) + // Draw edges + for (u, v) in edges { + if intra((u, 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 partition region backgrounds + on-layer(-1, { + // V1 = {v0, v1, v7} — blue region (top-right, right, top-left) + hobby(verts.at(0), verts.at(1), verts.at(7), close: true, + fill: comp-colors.at(0).transparentize(90%), + stroke: (dash: "dashed", paint: comp-colors.at(0), thickness: 0.8pt)) + // V2 = {v2, v3, v4} — green region (lower-right to bottom) + hobby(verts.at(2), verts.at(3), verts.at(4), close: true, + fill: comp-colors.at(1).transparentize(90%), + stroke: (dash: "dashed", paint: comp-colors.at(1), thickness: 0.8pt)) + // V3 = {v5, v6} — red region (lower-left to left) + let mid56 = ((verts.at(5).at(0) + verts.at(6).at(0)) / 2, + (verts.at(5).at(1) + verts.at(6).at(1)) / 2 - 0.5) + hobby(verts.at(5), mid56, verts.at(6), close: true, + fill: comp-colors.at(2).transparentize(90%), + stroke: (dash: "dashed", paint: comp-colors.at(2), thickness: 0.8pt)) + }) + // Draw nodes with weight subscripts + for (k, pos) in verts.enumerate() { + let c = comp-colors.at(partition.at(k)) + let w = weights.at(k) + g-node(pos, name: "v" + str(k), + fill: c, + label: text(fill: white)[$v_#k$]) + // Weight label outside the node + let angle = 90deg - k * 45deg + let lpos = (calc.cos(angle) * (r + 0.45), calc.sin(angle) * (r + 0.45)) + content(lpos, text(7pt)[$#w$]) + } + // Component labels + content((r + 0.9, 0.6), text(8pt, fill: comp-colors.at(0))[$V_1$]) + content((0, -(r + 0.7)), text(8pt, fill: comp-colors.at(1))[$V_2$]) + content((-(r + 0.9), -0.3), text(8pt, fill: comp-colors.at(2))[$V_3$]) + }), + caption: [Eight-vertex graph partitioned into $V_1 = {v_0, v_1, v_7}$ (blue, weight 6), $V_2 = {v_2, v_3, v_4}$ (green, weight 6), and $V_3 = {v_5, v_6}$ (red, weight 3). Each component induces a connected subgraph and satisfies the bound $B = 6$. Vertex weights are shown outside each node; bold colored edges are intra-component, gray edges cross components.], +) ] #{ let x = load-model-example("LengthBoundedDisjointPaths") diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 5457d915..7a9654cf 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -573,11 +573,6 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { if max_components == 0 { bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); } - if max_components > n { - bail!( - "BoundedComponentSpanningForest requires --k <= number of vertices ({n})\n\n{usage}" - ); - } let bound_raw = args.bound.ok_or_else(|| { anyhow::anyhow!("BoundedComponentSpanningForest requires --bound\n\n{usage}") })?; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 203eaf42..1eb83926 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1803,7 +1803,9 @@ fn test_create_bounded_component_spanning_forest_rejects_zero_k() { } #[test] -fn test_create_bounded_component_spanning_forest_rejects_k_larger_than_num_vertices() { +fn test_create_bounded_component_spanning_forest_accepts_k_larger_than_num_vertices() { + let dir = tempdir().unwrap(); + let out = dir.path().join("bcsf_large_k.json"); let output = pred() .args([ "create", @@ -1816,15 +1818,17 @@ fn test_create_bounded_component_spanning_forest_rejects_k_larger_than_num_verti "5", "--bound", "2", + "-o", ]) + .arg(&out) .output() .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--k <= number of vertices"), - "stderr: {stderr}" + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) ); + assert!(out.exists()); } #[test] diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 682c48bf..3af20610 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -65,10 +65,6 @@ impl BoundedComponentSpanningForest { "weights must be nonnegative" ); assert!(max_components >= 1, "max_components must be at least 1"); - assert!( - max_components <= graph.num_vertices(), - "max_components must not exceed graph num_vertices" - ); assert!(max_weight > W::Sum::zero(), "max_weight must be positive"); Self { graph, diff --git a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs index 0ad5a8c0..2541b2e8 100644 --- a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -177,10 +177,12 @@ fn test_bounded_component_spanning_forest_rejects_zero_max_components_in_constru } #[test] -#[should_panic(expected = "max_components must not exceed graph num_vertices")] -fn test_bounded_component_spanning_forest_rejects_too_many_components_in_constructor() { +fn test_bounded_component_spanning_forest_accepts_k_larger_than_num_vertices() { let graph = SimpleGraph::new(2, vec![(0, 1)]); - let _ = BoundedComponentSpanningForest::new(graph, vec![1, 1], 3, 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] From 5efb54dfe9e1d74fdd385047dbb2a2bc2c95ae2d Mon Sep 17 00:00:00 2001 From: zazabap Date: Mon, 16 Mar 2026 16:42:28 +0000 Subject: [PATCH 07/10] fix: remove re-introduced trait_consistency.rs Main branch intentionally removed centralized trait tests in PR #676 (commit 9eaa786d). The PR branch diverged before that removal, so our merge kept the PR's version. Aligning with main's intent by removing it. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/unit_tests/trait_consistency.rs | 231 ---------------------------- 1 file changed, 231 deletions(-) delete mode 100644 src/unit_tests/trait_consistency.rs diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs deleted file mode 100644 index ef0f2703..00000000 --- a/src/unit_tests/trait_consistency.rs +++ /dev/null @@ -1,231 +0,0 @@ -use crate::models::algebraic::*; -use crate::models::formula::*; -use crate::models::graph::*; -use crate::models::misc::*; -use crate::models::set::*; -use crate::topology::{BipartiteGraph, DirectedGraph, SimpleGraph}; -use crate::traits::Problem; -use crate::variant::K3; - -fn check_problem_trait(problem: &P, name: &str) { - let dims = problem.dims(); - assert!( - !dims.is_empty() || name.contains("empty"), - "{} should have dimensions", - name - ); - for d in &dims { - assert!( - *d >= 1, - "{} should have at least 1 choice per dimension", - name - ); - } -} - -#[test] -fn test_all_problems_implement_trait_correctly() { - check_problem_trait( - &MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]), - "MaximumIndependentSet", - ); - check_problem_trait( - &MinimumVertexCover::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]), - "MinimumVertexCover", - ); - check_problem_trait( - &MaxCut::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32]), - "MaxCut", - ); - check_problem_trait( - &KColoring::::new(SimpleGraph::new(3, vec![(0, 1)])), - "KColoring", - ); - check_problem_trait( - &BoundedComponentSpanningForest::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2)]), - vec![1i32; 3], - 2, - 2, - ), - "BoundedComponentSpanningForest", - ); - check_problem_trait( - &MinimumDominatingSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]), - "MinimumDominatingSet", - ); - check_problem_trait( - &MaximalIS::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]), - "MaximalIS", - ); - check_problem_trait( - &MaximumMatching::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32]), - "MaximumMatching", - ); - check_problem_trait( - &Satisfiability::new(3, vec![CNFClause::new(vec![1])]), - "SAT", - ); - check_problem_trait( - &SpinGlass::new(3, vec![((0, 1), 1.0)], vec![0.0; 3]), - "SpinGlass", - ); - check_problem_trait(&QUBO::from_matrix(vec![vec![1.0; 3]; 3]), "QUBO"); - check_problem_trait( - &MinimumSetCovering::::new(3, vec![vec![0, 1]]), - "MinimumSetCovering", - ); - check_problem_trait( - &MaximumSetPacking::::new(vec![vec![0, 1]]), - "MaximumSetPacking", - ); - check_problem_trait( - &ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5]]), - "ExactCoverBy3Sets", - ); - check_problem_trait(&PaintShop::new(vec!["a", "a"]), "PaintShop"); - check_problem_trait(&BMF::new(vec![vec![true]], 1), "BMF"); - check_problem_trait( - &BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1), - "BicliqueCover", - ); - check_problem_trait(&Factoring::new(6, 2, 2), "Factoring"); - - let circuit = Circuit::new(vec![Assignment::new( - vec!["x".to_string()], - BooleanExpr::constant(true), - )]); - check_problem_trait(&CircuitSAT::new(circuit), "CircuitSAT"); - check_problem_trait( - &MinimumFeedbackArcSet::new( - DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), - vec![1i32; 3], - ), - "MinimumFeedbackArcSet", - ); - check_problem_trait( - &MinimumSumMulticenter::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2)]), - vec![1i32; 3], - vec![1i32; 2], - 1, - ), - "MinimumSumMulticenter", - ); - check_problem_trait( - &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), - "HamiltonianPath", - ); - check_problem_trait( - &OptimalLinearArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3), - "OptimalLinearArrangement", - ); - check_problem_trait( - &IsomorphicSpanningTree::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), - SimpleGraph::new(3, vec![(0, 1), (1, 2)]), - ), - "IsomorphicSpanningTree", - ); - check_problem_trait( - &ShortestCommonSupersequence::new(2, vec![vec![0, 1], vec![1, 0]], 3), - "ShortestCommonSupersequence", - ); - check_problem_trait( - &FlowShopScheduling::new(2, vec![vec![1, 2], vec![3, 4]], 10), - "FlowShopScheduling", - ); - check_problem_trait( - &MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 2)]), - "MinimumTardinessSequencing", - ); -} - -#[test] -fn test_direction() { - use crate::traits::OptimizationProblem; - use crate::types::Direction; - - // Minimization problems - assert_eq!( - MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]).direction(), - Direction::Minimize - ); - assert_eq!( - MinimumDominatingSet::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]).direction(), - Direction::Minimize - ); - assert_eq!( - MinimumSetCovering::::new(2, vec![vec![0, 1]]).direction(), - Direction::Minimize - ); - assert_eq!( - PaintShop::new(vec!["a", "a"]).direction(), - Direction::Minimize - ); - assert_eq!( - QUBO::from_matrix(vec![vec![1.0]]).direction(), - Direction::Minimize - ); - assert_eq!( - SpinGlass::new(1, vec![], vec![0.0]).direction(), - Direction::Minimize - ); - assert_eq!( - BMF::new(vec![vec![true]], 1).direction(), - Direction::Minimize - ); - assert_eq!(Factoring::new(6, 2, 2).direction(), Direction::Minimize); - assert_eq!( - MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 2)]).direction(), - Direction::Minimize - ); - assert_eq!( - BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1).direction(), - Direction::Minimize - ); - assert_eq!( - MinimumFeedbackArcSet::new( - DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), - vec![1i32; 3] - ) - .direction(), - Direction::Minimize - ); - assert_eq!( - MinimumSumMulticenter::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2)]), - vec![1i32; 3], - vec![1i32; 2], - 1 - ) - .direction(), - Direction::Minimize - ); - - // Maximization problems - assert_eq!( - MaximumIndependentSet::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]).direction(), - Direction::Maximize - ); - assert_eq!( - MaximalIS::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]).direction(), - Direction::Maximize - ); - assert_eq!( - MaxCut::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32]).direction(), - Direction::Maximize - ); - assert_eq!( - MaximumMatching::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32]).direction(), - Direction::Maximize - ); - assert_eq!( - MaximumSetPacking::::new(vec![vec![0]]).direction(), - Direction::Maximize - ); - assert_eq!( - MaximumClique::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32; 2]).direction(), - Direction::Maximize - ); -} From 71ca7857a7dca9619214740931b2d4ff37fa3a8d Mon Sep 17 00:00:00 2001 From: zazabap Date: Mon, 16 Mar 2026 16:43:57 +0000 Subject: [PATCH 08/10] fix: simplify paper figure to avoid hobby/on-layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use only g-node, g-edge, and content — the same primitives used by other figures in the paper. Removes hobby curve regions that could fail on older CeTZ versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 43 ++++++++------------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 4017cc72..bccd8753 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -559,14 +559,12 @@ is feasible: each set induces a connected subgraph, the component weights are $2 }) 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 assignments: V1={0,1,7}, V2={2,3,4}, V3={5,6} - let partition = (0, 0, 1, 1, 1, 2, 2, 0) // vertex -> component index - let comp-colors = (graph-colors.at(0), graph-colors.at(2), graph-colors.at(1)) // blue, green, red - // Intra-component edges: both endpoints in same partition - let intra(e) = partition.at(e.at(0)) == partition.at(e.at(1)) - // Draw edges + // 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 intra((u, v)) { + if partition.at(u) == partition.at(v) { g-edge(verts.at(u), verts.at(v), stroke: 2pt + comp-colors.at(partition.at(u))) } else { @@ -574,41 +572,18 @@ is feasible: each set induces a connected subgraph, the component weights are $2 stroke: 1pt + luma(180)) } } - // Draw partition region backgrounds - on-layer(-1, { - // V1 = {v0, v1, v7} — blue region (top-right, right, top-left) - hobby(verts.at(0), verts.at(1), verts.at(7), close: true, - fill: comp-colors.at(0).transparentize(90%), - stroke: (dash: "dashed", paint: comp-colors.at(0), thickness: 0.8pt)) - // V2 = {v2, v3, v4} — green region (lower-right to bottom) - hobby(verts.at(2), verts.at(3), verts.at(4), close: true, - fill: comp-colors.at(1).transparentize(90%), - stroke: (dash: "dashed", paint: comp-colors.at(1), thickness: 0.8pt)) - // V3 = {v5, v6} — red region (lower-left to left) - let mid56 = ((verts.at(5).at(0) + verts.at(6).at(0)) / 2, - (verts.at(5).at(1) + verts.at(6).at(1)) / 2 - 0.5) - hobby(verts.at(5), mid56, verts.at(6), close: true, - fill: comp-colors.at(2).transparentize(90%), - stroke: (dash: "dashed", paint: comp-colors.at(2), thickness: 0.8pt)) - }) - // Draw nodes with weight subscripts + // Draw nodes colored by partition, with weight labels for (k, pos) in verts.enumerate() { let c = comp-colors.at(partition.at(k)) - let w = weights.at(k) g-node(pos, name: "v" + str(k), fill: c, label: text(fill: white)[$v_#k$]) - // Weight label outside the node let angle = 90deg - k * 45deg - let lpos = (calc.cos(angle) * (r + 0.45), calc.sin(angle) * (r + 0.45)) - content(lpos, text(7pt)[$#w$]) + let lpos = (calc.cos(angle) * (r + 0.5), calc.sin(angle) * (r + 0.5)) + content(lpos, text(7pt)[$w = #(weights.at(k))$]) } - // Component labels - content((r + 0.9, 0.6), text(8pt, fill: comp-colors.at(0))[$V_1$]) - content((0, -(r + 0.7)), text(8pt, fill: comp-colors.at(1))[$V_2$]) - content((-(r + 0.9), -0.3), text(8pt, fill: comp-colors.at(2))[$V_3$]) }), - caption: [Eight-vertex graph partitioned into $V_1 = {v_0, v_1, v_7}$ (blue, weight 6), $V_2 = {v_2, v_3, v_4}$ (green, weight 6), and $V_3 = {v_5, v_6}$ (red, weight 3). Each component induces a connected subgraph and satisfies the bound $B = 6$. Vertex weights are shown outside each node; bold colored edges are intra-component, gray edges cross components.], + 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.], ) ] #{ From be65f510a5895a89e4c0c85bd9642069e0b63576 Mon Sep 17 00:00:00 2001 From: zazabap Date: Mon, 16 Mar 2026 16:49:47 +0000 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20resolve=20CI=20failures=20?= =?UTF-8?q?=E2=80=94=20tempdir=20and=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace tempdir() with std::env::temp_dir() in CLI test (tempfile crate not available in test scope) - Remove unused cli_flag_name function (superseded by help_flag_name) Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 16 ---------------- problemreductions-cli/tests/cli_tests.rs | 4 ++-- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7a9654cf..6b016872 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -238,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" diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 1eb83926..a8e77ab8 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1804,8 +1804,7 @@ fn test_create_bounded_component_spanning_forest_rejects_zero_k() { #[test] fn test_create_bounded_component_spanning_forest_accepts_k_larger_than_num_vertices() { - let dir = tempdir().unwrap(); - let out = dir.path().join("bcsf_large_k.json"); + let out = std::env::temp_dir().join("pred_test_bcsf_large_k.json"); let output = pred() .args([ "create", @@ -1829,6 +1828,7 @@ fn test_create_bounded_component_spanning_forest_accepts_k_larger_than_num_verti String::from_utf8_lossy(&output.stderr) ); assert!(out.exists()); + let _ = std::fs::remove_file(&out); } #[test] From 8b56cc97a486e98382534297267c081b7e3756cd Mon Sep 17 00:00:00 2001 From: zazabap Date: Mon, 16 Mar 2026 16:53:37 +0000 Subject: [PATCH 10/10] fix: merge cli_flag_name mappings into help_flag_name The cli_flag_name function was superseded by help_flag_name but its general field-name mappings (universe_size->universe, collection->sets, etc.) were still needed. Merge them into help_flag_name and remove the redundant function. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 35 ++++++++++---------- problemreductions-cli/tests/cli_tests.rs | 4 +-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7a9654cf..3b5a0edd 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -238,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" @@ -311,9 +295,24 @@ 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") => "k".to_string(), - ("BoundedComponentSpanningForest", "max_weight") => "bound".to_string(), + ("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('_', "-"), } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 1eb83926..a8e77ab8 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1804,8 +1804,7 @@ fn test_create_bounded_component_spanning_forest_rejects_zero_k() { #[test] fn test_create_bounded_component_spanning_forest_accepts_k_larger_than_num_vertices() { - let dir = tempdir().unwrap(); - let out = dir.path().join("bcsf_large_k.json"); + let out = std::env::temp_dir().join("pred_test_bcsf_large_k.json"); let output = pred() .args([ "create", @@ -1829,6 +1828,7 @@ fn test_create_bounded_component_spanning_forest_accepts_k_larger_than_num_verti String::from_utf8_lossy(&output.stderr) ); assert!(out.exists()); + let _ = std::fs::remove_file(&out); } #[test]