From 6a28edf5e01d9fb9da28795153da8b9f810c6be6 Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 12:40:59 +0000 Subject: [PATCH 1/5] Add plan for #230: BiconnectivityAugmentation model --- ...03-13-biconnectivity-augmentation-model.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/plans/2026-03-13-biconnectivity-augmentation-model.md diff --git a/docs/plans/2026-03-13-biconnectivity-augmentation-model.md b/docs/plans/2026-03-13-biconnectivity-augmentation-model.md new file mode 100644 index 00000000..00b57581 --- /dev/null +++ b/docs/plans/2026-03-13-biconnectivity-augmentation-model.md @@ -0,0 +1,70 @@ +# Plan: Add BiconnectivityAugmentation Model + +**Issue:** #230 +**Type:** [Model] +**Skill:** add-model + +## Information Checklist + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `BiconnectivityAugmentation` | +| 2 | Mathematical definition | Given graph G=(V,E), weighted potential edges, budget B. Is there E' with sum(w(e)) <= B such that G'=(V, E union E') is biconnected? | +| 3 | Problem type | Satisfaction (Metric = bool) | +| 4 | Type parameters | `G: Graph`, `W: WeightElement` | +| 5 | Struct fields | `graph: G`, `potential_weights: Vec<(usize, usize, W)>`, `budget: W::Sum` | +| 6 | Configuration space | `vec![2; self.potential_weights.len()]` — binary per potential edge | +| 7 | Feasibility check | G' = (V, E union selected_edges) is biconnected AND total weight <= budget | +| 8 | Objective function | N/A (satisfaction) — returns true if feasible | +| 9 | Best known exact algorithm | O*(2^num_potential_edges) brute-force (Eswaran & Tarjan 1976) | +| 10 | Solving strategy | BruteForce enumeration over subsets | +| 11 | Category | `graph` | + +## Implementation Steps + +### Step 1: Implement the model (`src/models/graph/biconnectivity_augmentation.rs`) + +- `inventory::submit!` for `ProblemSchemaEntry` +- Struct `BiconnectivityAugmentation` with fields: `graph: G`, `potential_weights: Vec<(usize, usize, W)>`, `budget: W::Sum` +- Constructor `new(graph, potential_weights, budget)` — validate potential edge indices are in range +- Getters: `graph()`, `potential_weights()`, `budget()`, `num_vertices()`, `num_edges()`, `num_potential_edges()` +- `is_weighted()` inherent method +- `Problem` impl: NAME = "BiconnectivityAugmentation", Metric = bool, dims = vec![2; potential_weights.len()] +- `evaluate()`: build augmented graph, check biconnectivity (no articulation points) AND weight sum <= budget +- `SatisfactionProblem` impl (marker) +- `declare_variants!` with `BiconnectivityAugmentation => "2^num_potential_edges"` +- `#[cfg(test)] #[path = "..."] mod tests;` +- Biconnectivity check: implement as a helper function using Tarjan's algorithm to find articulation points + +### Step 2: Register the model + +- `src/models/graph/mod.rs` — add module and re-export +- `src/models/mod.rs` — add to re-export line + +### Step 3: Register in CLI + +- `problemreductions-cli/src/dispatch.rs` — add `deser_sat::>` match arm in both `load_problem` and `serialize_any_problem` +- `problemreductions-cli/src/problem_name.rs` — add `"biconnectivityaugmentation"` alias in `resolve_alias()` + +### Step 4: Add CLI creation support + +- `problemreductions-cli/src/commands/create.rs` — add match arm parsing `--graph`, `--potential-edges`, `--budget` +- `problemreductions-cli/src/cli.rs` — add `--potential-edges` and `--budget` flags to `CreateArgs`, update `all_data_flags_empty()` and help table + +### Step 5: Write unit tests (`src/unit_tests/models/graph/biconnectivity_augmentation.rs`) + +- `test_biconnectivity_augmentation_creation` — construct instance, verify dimensions +- `test_biconnectivity_augmentation_evaluation` — verify evaluate on valid/invalid configs +- `test_biconnectivity_augmentation_serialization` — round-trip serde +- `test_biconnectivity_augmentation_solver` — BruteForce finds correct satisfying configs +- `test_biconnectivity_augmentation_no_solution` — verify unsatisfiable instance +- `test_is_biconnected` — test the helper function directly + +### Step 6: Document in paper + +- Add `display-name` entry: `"BiconnectivityAugmentation": [Biconnectivity Augmentation]` +- Add `problem-def("BiconnectivityAugmentation")` with formal definition and background + +### Step 7: Verify + +- `make test clippy` must pass From d89e4f7bc357d87c410538683ede782c43db45aa Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 04:19:37 +0800 Subject: [PATCH 2/5] Implement #230: Add BiconnectivityAugmentation model --- docs/paper/reductions.typ | 6 + docs/src/reductions/problem_schemas.json | 32 +++ docs/src/reductions/reduction_graph.json | 267 ++++++++++-------- problemreductions-cli/src/cli.rs | 51 ++++ problemreductions-cli/src/commands/create.rs | 210 +++++++++++++- problemreductions-cli/src/dispatch.rs | 65 +++++ problemreductions-cli/src/problem_name.rs | 5 + src/lib.rs | 4 +- .../graph/biconnectivity_augmentation.rs | 245 ++++++++++++++++ src/models/graph/mod.rs | 3 + src/models/mod.rs | 6 +- .../graph/biconnectivity_augmentation.rs | 104 +++++++ src/unit_tests/trait_consistency.rs | 4 + tests/suites/integration.rs | 13 + 14 files changed, 892 insertions(+), 123 deletions(-) create mode 100644 src/models/graph/biconnectivity_augmentation.rs create mode 100644 src/unit_tests/models/graph/biconnectivity_augmentation.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 7ab3b569..c2bb6aaf 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -30,6 +30,7 @@ "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], "GraphPartitioning": [Graph Partitioning], + "BiconnectivityAugmentation": [Biconnectivity Augmentation], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], "MaximumMatching": [Maximum Matching], @@ -434,6 +435,11 @@ 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("BiconnectivityAugmentation")[ + Given an undirected graph $G = (V, E)$, a set $F$ of candidate edges on $V$ with $F inter E = emptyset$, weights $w: F -> RR$, and a budget $B in RR$, find $F' subset.eq F$ such that $sum_(e in F') w(e) <= B$ and the augmented graph $G' = (V, E union F')$ is biconnected, meaning $G'$ is connected and deleting any single vertex leaves it connected. +][ +Biconnectivity augmentation is a classical network-design problem: add backup links so the graph survives any single vertex failure. The weighted candidate-edge formulation modeled here captures communication, transportation, and infrastructure planning settings where only a prescribed set of new links is feasible and each carries a cost. In this library, the exact baseline is brute-force enumeration over the $m = |F|$ candidate edges, yielding $O^*(2^m)$ time and matching the exported complexity metadata for the model. +] #problem-def("KColoring")[ Given $G = (V, E)$ and $k$ colors, find $c: V -> {1, ..., k}$ minimizing $|{(u, v) in E : c(u) = c(v)}|$. ][ diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index ef694f9d..febac8f6 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -41,6 +41,27 @@ } ] }, + { + "name": "BiconnectivityAugmentation", + "description": "Add weighted potential edges to make a graph biconnected within budget", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "potential_weights", + "type_name": "Vec<(usize, usize, W)>", + "description": "Potential edges with augmentation weights" + }, + { + "name": "budget", + "type_name": "W::Sum", + "description": "Maximum total augmentation weight B" + } + ] + }, { "name": "BinPacking", "description": "Assign items to bins minimizing number of bins used, subject to capacity", @@ -195,6 +216,17 @@ } ] }, + { + "name": "LongestCommonSubsequence", + "description": "Find the longest string that is a subsequence of every input string", + "fields": [ + { + "name": "strings", + "type_name": "Vec>", + "description": "The input strings" + } + ] + }, { "name": "MaxCut", "description": "Find maximum weight cut in a graph", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index 59ea473f..c03e8354 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -14,6 +14,16 @@ "doc_path": "models/graph/struct.BicliqueCover.html", "complexity": "2^num_vertices" }, + { + "name": "BiconnectivityAugmentation", + "variant": { + "graph": "SimpleGraph", + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.BiconnectivityAugmentation.html", + "complexity": "2^num_potential_edges" + }, { "name": "BinPacking", "variant": { @@ -175,6 +185,13 @@ "doc_path": "models/misc/struct.Knapsack.html", "complexity": "2^(num_items / 2)" }, + { + "name": "LongestCommonSubsequence", + "variant": {}, + "category": "misc", + "doc_path": "models/misc/struct.LongestCommonSubsequence.html", + "complexity": "2^min_string_length" + }, { "name": "MaxCut", "variant": { @@ -413,8 +430,8 @@ ], "edges": [ { - "source": 3, - "target": 9, + "source": 4, + "target": 10, "overhead": [ { "field": "num_vars", @@ -428,8 +445,8 @@ "doc_path": "rules/binpacking_ilp/index.html" }, { - "source": 4, - "target": 9, + "source": 5, + "target": 10, "overhead": [ { "field": "num_vars", @@ -443,8 +460,8 @@ "doc_path": "rules/circuit_ilp/index.html" }, { - "source": 4, - "target": 42, + "source": 5, + "target": 44, "overhead": [ { "field": "num_spins", @@ -458,8 +475,8 @@ "doc_path": "rules/circuit_spinglass/index.html" }, { - "source": 7, - "target": 4, + "source": 8, + "target": 5, "overhead": [ { "field": "num_variables", @@ -473,8 +490,8 @@ "doc_path": "rules/factoring_circuit/index.html" }, { - "source": 7, - "target": 10, + "source": 8, + "target": 11, "overhead": [ { "field": "num_vars", @@ -488,8 +505,8 @@ "doc_path": "rules/factoring_ilp/index.html" }, { - "source": 9, - "target": 10, + "source": 10, + "target": 11, "overhead": [ { "field": "num_vars", @@ -503,8 +520,8 @@ "doc_path": "rules/ilp_bool_ilp_i32/index.html" }, { - "source": 9, - "target": 39, + "source": 10, + "target": 41, "overhead": [ { "field": "num_vars", @@ -514,8 +531,8 @@ "doc_path": "rules/ilp_qubo/index.html" }, { - "source": 12, - "target": 15, + "source": 13, + "target": 16, "overhead": [ { "field": "num_vertices", @@ -529,8 +546,8 @@ "doc_path": "rules/kcoloring_casts/index.html" }, { - "source": 15, - "target": 9, + "source": 16, + "target": 10, "overhead": [ { "field": "num_vars", @@ -544,8 +561,8 @@ "doc_path": "rules/coloring_ilp/index.html" }, { - "source": 15, - "target": 39, + "source": 16, + "target": 41, "overhead": [ { "field": "num_vars", @@ -555,8 +572,8 @@ "doc_path": "rules/coloring_qubo/index.html" }, { - "source": 16, - "target": 18, + "source": 17, + "target": 19, "overhead": [ { "field": "num_vars", @@ -570,8 +587,8 @@ "doc_path": "rules/ksatisfiability_casts/index.html" }, { - "source": 16, - "target": 39, + "source": 17, + "target": 41, "overhead": [ { "field": "num_vars", @@ -581,8 +598,8 @@ "doc_path": "rules/ksatisfiability_qubo/index.html" }, { - "source": 17, - "target": 18, + "source": 18, + "target": 19, "overhead": [ { "field": "num_vars", @@ -596,8 +613,8 @@ "doc_path": "rules/ksatisfiability_casts/index.html" }, { - "source": 17, - "target": 39, + "source": 18, + "target": 41, "overhead": [ { "field": "num_vars", @@ -607,8 +624,8 @@ "doc_path": "rules/ksatisfiability_qubo/index.html" }, { - "source": 17, - "target": 43, + "source": 18, + "target": 45, "overhead": [ { "field": "num_elements", @@ -618,8 +635,8 @@ "doc_path": "rules/ksatisfiability_subsetsum/index.html" }, { - "source": 18, - "target": 40, + "source": 19, + "target": 42, "overhead": [ { "field": "num_clauses", @@ -637,8 +654,23 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 20, - "target": 42, + "source": 21, + "target": 10, + "overhead": [ + { + "field": "num_vars", + "formula": "num_chars_first * num_chars_second" + }, + { + "field": "num_constraints", + "formula": "num_chars_first + num_chars_second + (num_chars_first * num_chars_second)^2" + } + ], + "doc_path": "rules/longestcommonsubsequence_ilp/index.html" + }, + { + "source": 22, + "target": 44, "overhead": [ { "field": "num_spins", @@ -652,8 +684,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 22, - "target": 9, + "source": 24, + "target": 10, "overhead": [ { "field": "num_vars", @@ -667,8 +699,8 @@ "doc_path": "rules/maximumclique_ilp/index.html" }, { - "source": 22, - "target": 26, + "source": 24, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -682,8 +714,8 @@ "doc_path": "rules/maximumclique_maximumindependentset/index.html" }, { - "source": 23, - "target": 24, + "source": 25, + "target": 26, "overhead": [ { "field": "num_vertices", @@ -697,8 +729,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 23, - "target": 28, + "source": 25, + "target": 30, "overhead": [ { "field": "num_vertices", @@ -712,8 +744,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 24, - "target": 29, + "source": 26, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -727,8 +759,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 25, - "target": 23, + "source": 27, + "target": 25, "overhead": [ { "field": "num_vertices", @@ -742,8 +774,8 @@ "doc_path": "rules/maximumindependentset_gridgraph/index.html" }, { - "source": 25, - "target": 26, + "source": 27, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -757,8 +789,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 25, - "target": 27, + "source": 27, + "target": 29, "overhead": [ { "field": "num_vertices", @@ -772,8 +804,8 @@ "doc_path": "rules/maximumindependentset_triangular/index.html" }, { - "source": 25, - "target": 31, + "source": 27, + "target": 33, "overhead": [ { "field": "num_sets", @@ -787,8 +819,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 26, - "target": 22, + "source": 28, + "target": 24, "overhead": [ { "field": "num_vertices", @@ -802,8 +834,8 @@ "doc_path": "rules/maximumindependentset_maximumclique/index.html" }, { - "source": 26, - "target": 33, + "source": 28, + "target": 35, "overhead": [ { "field": "num_sets", @@ -817,8 +849,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 26, - "target": 37, + "source": 28, + "target": 39, "overhead": [ { "field": "num_vertices", @@ -832,8 +864,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 27, - "target": 29, + "source": 29, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -847,8 +879,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 28, - "target": 25, + "source": 30, + "target": 27, "overhead": [ { "field": "num_vertices", @@ -862,8 +894,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 28, - "target": 29, + "source": 30, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -877,8 +909,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 29, - "target": 26, + "source": 31, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -892,8 +924,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 9, + "source": 32, + "target": 10, "overhead": [ { "field": "num_vars", @@ -907,8 +939,8 @@ "doc_path": "rules/maximummatching_ilp/index.html" }, { - "source": 30, - "target": 33, + "source": 32, + "target": 35, "overhead": [ { "field": "num_sets", @@ -922,8 +954,8 @@ "doc_path": "rules/maximummatching_maximumsetpacking/index.html" }, { - "source": 31, - "target": 25, + "source": 33, + "target": 27, "overhead": [ { "field": "num_vertices", @@ -937,8 +969,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 33, + "source": 33, + "target": 35, "overhead": [ { "field": "num_sets", @@ -952,8 +984,8 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 32, - "target": 39, + "source": 34, + "target": 41, "overhead": [ { "field": "num_vars", @@ -963,8 +995,8 @@ "doc_path": "rules/maximumsetpacking_qubo/index.html" }, { - "source": 33, - "target": 9, + "source": 35, + "target": 10, "overhead": [ { "field": "num_vars", @@ -978,8 +1010,8 @@ "doc_path": "rules/maximumsetpacking_ilp/index.html" }, { - "source": 33, - "target": 26, + "source": 35, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -993,8 +1025,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 33, - "target": 32, + "source": 35, + "target": 34, "overhead": [ { "field": "num_sets", @@ -1008,8 +1040,8 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 34, - "target": 9, + "source": 36, + "target": 10, "overhead": [ { "field": "num_vars", @@ -1023,8 +1055,8 @@ "doc_path": "rules/minimumdominatingset_ilp/index.html" }, { - "source": 36, - "target": 9, + "source": 38, + "target": 10, "overhead": [ { "field": "num_vars", @@ -1038,8 +1070,8 @@ "doc_path": "rules/minimumsetcovering_ilp/index.html" }, { - "source": 37, - "target": 26, + "source": 39, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -1053,8 +1085,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 37, - "target": 36, + "source": 39, + "target": 38, "overhead": [ { "field": "num_sets", @@ -1068,8 +1100,8 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 39, - "target": 9, + "source": 41, + "target": 10, "overhead": [ { "field": "num_vars", @@ -1083,8 +1115,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 39, - "target": 41, + "source": 41, + "target": 43, "overhead": [ { "field": "num_spins", @@ -1094,8 +1126,8 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 40, - "target": 4, + "source": 42, + "target": 5, "overhead": [ { "field": "num_variables", @@ -1109,8 +1141,8 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 40, - "target": 12, + "source": 42, + "target": 13, "overhead": [ { "field": "num_vertices", @@ -1124,8 +1156,8 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 40, - "target": 17, + "source": 42, + "target": 18, "overhead": [ { "field": "num_clauses", @@ -1139,8 +1171,8 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 40, - "target": 25, + "source": 42, + "target": 27, "overhead": [ { "field": "num_vertices", @@ -1154,8 +1186,8 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 40, - "target": 34, + "source": 42, + "target": 36, "overhead": [ { "field": "num_vertices", @@ -1169,8 +1201,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 41, - "target": 39, + "source": 43, + "target": 41, "overhead": [ { "field": "num_vars", @@ -1180,8 +1212,8 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 42, - "target": 20, + "source": 44, + "target": 22, "overhead": [ { "field": "num_vertices", @@ -1195,8 +1227,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 42, - "target": 41, + "source": 44, + "target": 43, "overhead": [ { "field": "num_spins", @@ -1210,8 +1242,8 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 44, - "target": 9, + "source": 46, + "target": 10, "overhead": [ { "field": "num_vars", @@ -1223,6 +1255,17 @@ } ], "doc_path": "rules/travelingsalesman_ilp/index.html" + }, + { + "source": 46, + "target": 41, + "overhead": [ + { + "field": "num_vars", + "formula": "num_vertices^2" + } + ], + "doc_path": "rules/travelingsalesman_qubo/index.html" } ] } \ No newline at end of file diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 6b4cbb5b..489adf43 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,6 +216,7 @@ Flags by problem type: MaximumSetPacking --sets [--weights] MinimumSetCovering --universe, --sets [--weights] BicliqueCover --left, --right, --biedges, --k + BiconnectivityAugmentation --graph, --potential-edges, --budget BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] LCS --strings @@ -236,6 +237,7 @@ Examples: pred create MIS/KingsSubgraph --positions \"0,0;1,0;1,1;0,1\" pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5 pred create MIS --random --num-vertices 10 --edge-prob 0.3 + pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5 pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1")] pub struct CreateArgs { /// Problem type (e.g., MIS, QUBO, SAT) @@ -337,6 +339,12 @@ pub struct CreateArgs { /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: Option, + /// Weighted potential augmentation edges (e.g., 0-2:3,1-3:5) + #[arg(long)] + pub potential_edges: Option, + /// Total budget for selected potential edges + #[arg(long)] + pub budget: Option, } #[derive(clap::Args)] @@ -454,3 +462,46 @@ pub fn print_subcommand_help_hint(error_msg: &str) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_parses_biconnectivity_augmentation_flags() { + let cli = Cli::parse_from([ + "pred", + "create", + "BiconnectivityAugmentation", + "--graph", + "0-1,1-2", + "--potential-edges", + "0-2:3,1-3:5", + "--budget", + "7", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert_eq!(args.problem, "BiconnectivityAugmentation"); + assert_eq!(args.graph.as_deref(), Some("0-1,1-2")); + assert_eq!(args.potential_edges.as_deref(), Some("0-2:3,1-3:5")); + assert_eq!(args.budget.as_deref(), Some("7")); + } + + #[test] + fn test_create_help_mentions_biconnectivity_augmentation_flags() { + let cmd = Cli::command(); + let create = cmd.find_subcommand("create").expect("create subcommand"); + let help = create + .get_after_help() + .expect("create after_help") + .to_string(); + + assert!(help.contains("BiconnectivityAugmentation")); + assert!(help.contains("--potential-edges")); + assert!(help.contains("--budget")); + } +} diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2b4cb04b..2d8ea5a0 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -7,6 +7,7 @@ use anyhow::{bail, Context, Result}; use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; use problemreductions::models::graph::GraphPartitioning; use problemreductions::models::misc::{BinPacking, LongestCommonSubsequence, PaintShop, SubsetSum}; +use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ @@ -49,6 +50,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.bounds.is_none() && args.strings.is_none() && args.arcs.is_none() + && args.potential_edges.is_none() + && args.budget.is_none() } fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { @@ -59,9 +62,12 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { _ => "edge list: 0-1,1-2,2-3", }, "Vec" => "comma-separated: 1,2,3", + "Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => { + "comma-separated weighted edges: 0-2:3,1-3:5" + } "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", - "usize" => "integer", + "usize" | "W::Sum" => "integer", "u64" => "integer", "i64" => "integer", "Vec" => "comma-separated integers: 3,7,1,8", @@ -84,6 +90,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } + "BiconnectivityAugmentation" => { + "--graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5" + } "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", @@ -116,12 +125,12 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { } } else { let hint = type_format_hint(&field.type_name, graph_type); - eprintln!( - " --{:<16} {} ({})", - field.name.replace('_', "-"), - field.description, - hint - ); + let flag_name = if field.name == "potential_weights" { + "potential-edges".to_string() + } else { + field.name.replace('_', "-") + }; + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } } } else { @@ -213,6 +222,26 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // Biconnectivity augmentation + "BiconnectivityAugmentation" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5" + ) + })?; + let potential_edges = parse_potential_edges(args)?; + validate_potential_edges(&graph, &potential_edges)?; + let budget = parse_budget(args)?; + ( + ser(BiconnectivityAugmentation::new( + graph, + potential_edges, + budget, + ))?, + resolved_variant.clone(), + ) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -933,6 +962,57 @@ fn parse_matrix(args: &CreateArgs) -> Result>> { .collect() } +fn parse_potential_edges(args: &CreateArgs) -> Result> { + let edges_str = args.potential_edges.as_deref().ok_or_else(|| { + anyhow::anyhow!("BiconnectivityAugmentation requires --potential-edges (e.g., 0-2:3,1-3:5)") + })?; + + edges_str + .split(',') + .map(|entry| { + let entry = entry.trim(); + let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") + })?; + let (u_str, v_str) = edge_part.split_once('-').ok_or_else(|| { + anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") + })?; + let u = u_str.trim().parse::()?; + let v = v_str.trim().parse::()?; + if u == v { + bail!("Self-loop detected in potential edge {u}-{v}"); + } + let weight = weight_part.trim().parse::()?; + Ok((u, v, weight)) + }) + .collect() +} + +fn validate_potential_edges( + graph: &SimpleGraph, + potential_edges: &[(usize, usize, i32)], +) -> Result<()> { + let num_vertices = graph.num_vertices(); + for &(u, v, _) in potential_edges { + if u >= num_vertices || v >= num_vertices { + bail!( + "Potential edge {u}-{v} references a vertex outside the graph (num_vertices = {num_vertices})" + ); + } + } + Ok(()) +} + +fn parse_budget(args: &CreateArgs) -> Result { + let budget = args + .budget + .as_deref() + .ok_or_else(|| anyhow::anyhow!("BiconnectivityAugmentation requires --budget (e.g., 5)"))?; + budget + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid budget '{budget}': {e}")) +} + /// Handle `pred create --random ...` fn create_random( args: &CreateArgs, @@ -1091,3 +1171,119 @@ fn create_random( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_args() -> CreateArgs { + CreateArgs { + problem: "BiconnectivityAugmentation".to_string(), + graph: None, + weights: None, + edge_weights: None, + couplings: None, + fields: None, + clauses: None, + num_vars: None, + matrix: None, + k: None, + random: false, + num_vertices: None, + edge_prob: None, + seed: None, + target: None, + m: None, + n: None, + positions: None, + radius: None, + sizes: None, + capacity: None, + sequence: None, + sets: None, + universe: None, + biedges: None, + left: None, + right: None, + rank: None, + basis: None, + target_vec: None, + bounds: None, + strings: None, + arcs: None, + potential_edges: None, + budget: None, + } + } + + #[test] + fn test_all_data_flags_empty_treats_potential_edges_as_input() { + let mut args = empty_args(); + args.potential_edges = Some("0-2:3,1-3:5".to_string()); + assert!(!all_data_flags_empty(&args)); + } + + #[test] + fn test_all_data_flags_empty_treats_budget_as_input() { + let mut args = empty_args(); + args.budget = Some("7".to_string()); + assert!(!all_data_flags_empty(&args)); + } + + #[test] + fn test_parse_potential_edges() { + let mut args = empty_args(); + args.potential_edges = Some("0-2:3,1-3:5".to_string()); + + let potential_edges = parse_potential_edges(&args).unwrap(); + + assert_eq!(potential_edges, vec![(0, 2, 3), (1, 3, 5)]); + } + + #[test] + fn test_parse_potential_edges_rejects_missing_weight() { + let mut args = empty_args(); + args.potential_edges = Some("0-2,1-3:5".to_string()); + + let err = parse_potential_edges(&args).unwrap_err().to_string(); + + assert!(err.contains("u-v:w")); + } + + #[test] + fn test_parse_budget() { + let mut args = empty_args(); + args.budget = Some("7".to_string()); + + assert_eq!(parse_budget(&args).unwrap(), 7); + } + + #[test] + fn test_create_biconnectivity_augmentation_json() { + let mut args = empty_args(); + args.graph = Some("0-1,1-2,2-3".to_string()); + args.potential_edges = Some("0-2:3,0-3:4,1-3:2".to_string()); + args.budget = Some("5".to_string()); + + let output_path = std::env::temp_dir().join("pred_test_create_biconnectivity.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "BiconnectivityAugmentation"); + assert_eq!(json["data"]["budget"], 5); + assert_eq!( + json["data"]["potential_weights"][0], + serde_json::json!([0, 2, 3]) + ); + + std::fs::remove_file(output_path).ok(); + } +} diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index e162efc2..367e7b1e 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Context, Result}; use problemreductions::models::algebraic::{ClosestVectorProblem, ILP}; use problemreductions::models::misc::{BinPacking, Knapsack, LongestCommonSubsequence, SubsetSum}; +use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; use problemreductions::rules::{MinimizeSteps, ReductionGraph}; use problemreductions::solvers::{BruteForce, ILPSolver, Solver}; @@ -211,6 +212,9 @@ pub fn load_problem( "MaximumMatching" => deser_opt::>(data), "MinimumDominatingSet" => deser_opt::>(data), "GraphPartitioning" => deser_opt::>(data), + "BiconnectivityAugmentation" => { + deser_sat::>(data) + } "MaxCut" => deser_opt::>(data), "MaximalIS" => deser_opt::>(data), "TravelingSalesman" => deser_opt::>(data), @@ -272,6 +276,9 @@ pub fn serialize_any_problem( "MaximumMatching" => try_ser::>(any), "MinimumDominatingSet" => try_ser::>(any), "GraphPartitioning" => try_ser::>(any), + "BiconnectivityAugmentation" => { + try_ser::>(any) + } "MaxCut" => try_ser::>(any), "MaximalIS" => try_ser::>(any), "TravelingSalesman" => try_ser::>(any), @@ -376,3 +383,61 @@ fn solve_ilp(any: &dyn Any) -> Result { let evaluation = format!("{:?}", problem.evaluate(&config)); Ok(SolveResult { config, evaluation }) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn biconnectivity_variant() -> BTreeMap { + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]) + } + + #[test] + fn test_load_problem_biconnectivity_augmentation() { + let data = json!({ + "graph": { + "inner": { + "nodes": [null, null, null, null], + "node_holes": [], + "edge_property": "undirected", + "edges": [[0, 1, null], [1, 2, null], [2, 3, null]] + } + }, + "potential_weights": [[0, 2, 3], [0, 3, 4], [1, 3, 2]], + "budget": 5 + }); + + let problem = load_problem( + "biconnectivityaugmentation", + &biconnectivity_variant(), + data, + ) + .expect("load problem"); + + assert_eq!(problem.problem_name(), "BiconnectivityAugmentation"); + assert_eq!(problem.dims_dyn(), vec![2, 2, 2]); + } + + #[test] + fn test_serialize_any_problem_biconnectivity_augmentation() { + let problem = BiconnectivityAugmentation::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + vec![(0, 2, 3), (0, 3, 4), (1, 3, 2)], + 5, + ); + + let data = serialize_any_problem( + "BiconnectivityAugmentation", + &biconnectivity_variant(), + &problem, + ) + .expect("serialize problem"); + + assert_eq!(data["budget"], 5); + assert_eq!(data["potential_weights"][1], json!([0, 3, 4])); + } +} diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index a595f61b..f990d741 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -52,6 +52,7 @@ pub fn resolve_alias(input: &str) -> String { "paintshop" => "PaintShop".to_string(), "bmf" => "BMF".to_string(), "bicliquecover" => "BicliqueCover".to_string(), + "biconnectivityaugmentation" => "BiconnectivityAugmentation".to_string(), "binpacking" => "BinPacking".to_string(), "cvp" | "closestvectorproblem" => "ClosestVectorProblem".to_string(), "knapsack" => "Knapsack".to_string(), @@ -260,6 +261,10 @@ mod tests { assert_eq!(resolve_alias("3SAT"), "KSatisfiability"); assert_eq!(resolve_alias("QUBO"), "QUBO"); assert_eq!(resolve_alias("MaxCut"), "MaxCut"); + assert_eq!( + resolve_alias("biconnectivityaugmentation"), + "BiconnectivityAugmentation" + ); // Pass-through for full names assert_eq!( resolve_alias("MaximumIndependentSet"), diff --git a/src/lib.rs b/src/lib.rs index bdcbf5f3..b7206510 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,7 +40,9 @@ pub mod prelude { // Problem types pub use crate::models::algebraic::{BMF, QUBO}; pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; - pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; + pub use crate::models::graph::{ + BicliqueCover, BiconnectivityAugmentation, GraphPartitioning, SpinGlass, + }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, TravelingSalesman, diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs new file mode 100644 index 00000000..2e5b56d8 --- /dev/null +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -0,0 +1,245 @@ +//! Biconnectivity augmentation problem implementation. +//! +//! Given a graph, weighted potential edges, and a budget, determine whether +//! adding some subset of the potential edges can make the graph biconnected +//! without exceeding the budget. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +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::BTreeSet; + +inventory::submit! { + ProblemSchemaEntry { + name: "BiconnectivityAugmentation", + module_path: module_path!(), + description: "Add weighted potential edges to make a graph biconnected within budget", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "potential_weights", type_name: "Vec<(usize, usize, W)>", description: "Potential edges with augmentation weights" }, + FieldInfo { name: "budget", type_name: "W::Sum", description: "Maximum total augmentation weight B" }, + ], + } +} + +/// The Biconnectivity Augmentation problem. +/// +/// Given a graph `G = (V, E)`, weighted potential edges, and a budget `B`, +/// determine whether there exists a subset of potential edges `E'` such that: +/// - `sum_{e in E'} w(e) <= B` +/// - `(V, E union E')` is biconnected +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound( + serialize = "G: serde::Serialize, W: serde::Serialize, W::Sum: serde::Serialize", + deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>, W::Sum: serde::Deserialize<'de>" +))] +pub struct BiconnectivityAugmentation +where + W: WeightElement, +{ + /// The underlying graph. + graph: G, + /// Potential augmentation edges with their weights. + potential_weights: Vec<(usize, usize, W)>, + /// Maximum total weight of selected potential edges. + budget: W::Sum, +} + +impl BiconnectivityAugmentation { + /// Create a new biconnectivity augmentation instance. + /// + /// # Panics + /// Panics if any potential edge references a vertex index outside the graph. + pub fn new(graph: G, potential_weights: Vec<(usize, usize, W)>, budget: W::Sum) -> Self { + let num_vertices = graph.num_vertices(); + for &(u, v, _) in &potential_weights { + assert!( + u < num_vertices && v < num_vertices, + "potential edge ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + } + + Self { + graph, + potential_weights, + budget, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the weighted potential edges. + pub fn potential_weights(&self) -> &[(usize, usize, W)] { + &self.potential_weights + } + + /// Get the budget. + pub fn budget(&self) -> &W::Sum { + &self.budget + } + + /// 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() + } + + /// Get the number of potential augmentation edges. + pub fn num_potential_edges(&self) -> usize { + self.potential_weights.len() + } + + /// Check if the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + fn augmented_graph(&self, config: &[usize]) -> Option { + if config.len() != self.num_potential_edges() || config.iter().any(|&value| value >= 2) { + return None; + } + + let mut total = W::Sum::zero(); + let mut edges = BTreeSet::new(); + + for (u, v) in self.graph.edges() { + edges.insert(normalize_edge(u, v)); + } + + for (selected, &(u, v, ref weight)) in config.iter().zip(&self.potential_weights) { + if *selected == 1 { + total += weight.to_sum(); + if total > self.budget.clone() { + return None; + } + edges.insert(normalize_edge(u, v)); + } + } + + Some(SimpleGraph::new( + self.num_vertices(), + edges.into_iter().collect(), + )) + } +} + +impl Problem for BiconnectivityAugmentation +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "BiconnectivityAugmentation"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![2; self.num_potential_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.augmented_graph(config) + .is_some_and(|graph| is_biconnected(&graph)) + } +} + +impl SatisfactionProblem for BiconnectivityAugmentation +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ +} + +fn normalize_edge(u: usize, v: usize) -> (usize, usize) { + if u <= v { + (u, v) + } else { + (v, u) + } +} + +struct DfsState { + visited: Vec, + discovery_time: Vec, + low: Vec, + parent: Vec>, + time: usize, + has_articulation_point: bool, +} + +fn dfs_articulation_points(graph: &G, vertex: usize, state: &mut DfsState) { + if state.has_articulation_point { + return; + } + + state.visited[vertex] = true; + state.time += 1; + state.discovery_time[vertex] = state.time; + state.low[vertex] = state.time; + + let mut child_count = 0; + for neighbor in graph.neighbors(vertex) { + if !state.visited[neighbor] { + child_count += 1; + state.parent[neighbor] = Some(vertex); + dfs_articulation_points(graph, neighbor, state); + state.low[vertex] = state.low[vertex].min(state.low[neighbor]); + + if state.parent[vertex].is_none() && child_count > 1 { + state.has_articulation_point = true; + return; + } + + if state.parent[vertex].is_some() && state.low[neighbor] >= state.discovery_time[vertex] + { + state.has_articulation_point = true; + return; + } + } else if state.parent[vertex] != Some(neighbor) { + state.low[vertex] = state.low[vertex].min(state.discovery_time[neighbor]); + } + } +} + +fn is_biconnected(graph: &G) -> bool { + let num_vertices = graph.num_vertices(); + if num_vertices <= 1 { + return true; + } + + let mut state = DfsState { + visited: vec![false; num_vertices], + discovery_time: vec![0; num_vertices], + low: vec![0; num_vertices], + parent: vec![None; num_vertices], + time: 0, + has_articulation_point: false, + }; + + dfs_articulation_points(graph, 0, &mut state); + + !state.has_articulation_point && state.visited.into_iter().all(|seen| seen) +} + +crate::declare_variants! { + BiconnectivityAugmentation => "2^num_potential_edges", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/biconnectivity_augmentation.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 42f46a15..7a76d597 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -14,8 +14,10 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`BicliqueCover`]: Biclique cover on bipartite graphs +//! - [`BiconnectivityAugmentation`]: Biconnectivity augmentation with weighted potential edges pub(crate) mod biclique_cover; +pub(crate) mod biconnectivity_augmentation; pub(crate) mod graph_partitioning; pub(crate) mod kcoloring; pub(crate) mod max_cut; @@ -30,6 +32,7 @@ pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; pub use biclique_cover::BicliqueCover; +pub use biconnectivity_augmentation::BiconnectivityAugmentation; pub use graph_partitioning::GraphPartitioning; pub use kcoloring::KColoring; pub use max_cut::MaxCut; diff --git a/src/models/mod.rs b/src/models/mod.rs index 6c8ac38a..7435654e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,9 +12,9 @@ pub mod set; pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ - BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, - MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, - MinimumVertexCover, SpinGlass, TravelingSalesman, + BicliqueCover, BiconnectivityAugmentation, GraphPartitioning, KColoring, MaxCut, MaximalIS, + MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, + MinimumFeedbackVertexSet, MinimumVertexCover, SpinGlass, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs new file mode 100644 index 00000000..240e3962 --- /dev/null +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -0,0 +1,104 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::One; + +#[test] +fn test_biconnectivity_augmentation_creation() { + let graph = SimpleGraph::path(4); + let problem = BiconnectivityAugmentation::new(graph.clone(), vec![(0, 3, 2), (1, 3, 1)], 2); + + assert_eq!(problem.graph(), &graph); + assert_eq!(problem.potential_weights(), &[(0, 3, 2), (1, 3, 1)]); + assert_eq!(problem.budget(), &2); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.num_potential_edges(), 2); + assert_eq!(problem.dims(), vec![2, 2]); + assert_eq!(problem.num_variables(), 2); + assert!(problem.is_weighted()); + assert_eq!( + as Problem>::NAME, + "BiconnectivityAugmentation" + ); + assert_eq!( + as Problem>::variant(), + vec![("graph", "SimpleGraph"), ("weight", "i32")] + ); + + let unit_problem = + BiconnectivityAugmentation::<_, One>::new(SimpleGraph::path(3), vec![(0, 2, One)], 1); + assert!(!unit_problem.is_weighted()); +} + +#[test] +#[should_panic(expected = "references vertex >= num_vertices")] +fn test_biconnectivity_augmentation_creation_rejects_invalid_potential_edge() { + BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(0, 4, 1)], 1); +} + +#[test] +fn test_biconnectivity_augmentation_evaluation() { + let problem = BiconnectivityAugmentation::new( + SimpleGraph::path(4), + vec![(0, 2, 5), (1, 3, 1), (0, 3, 2)], + 2, + ); + + assert!(!problem.evaluate(&[0, 0, 0])); + assert!(!problem.evaluate(&[0, 1, 0])); + assert!(problem.evaluate(&[0, 0, 1])); + assert!(!problem.evaluate(&[0, 1, 1])); + assert!(!problem.evaluate(&[2, 0, 0])); + assert!(!problem.evaluate(&[1, 0])); +} + +#[test] +fn test_biconnectivity_augmentation_serialization() { + let problem = + BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(0, 3, 2), (1, 3, 1)], 2); + + let json = serde_json::to_value(&problem).unwrap(); + let restored: BiconnectivityAugmentation = + serde_json::from_value(json).unwrap(); + + assert_eq!(restored.graph(), problem.graph()); + assert_eq!(restored.potential_weights(), problem.potential_weights()); + assert_eq!(restored.budget(), problem.budget()); +} + +#[test] +fn test_biconnectivity_augmentation_solver() { + let problem = BiconnectivityAugmentation::new( + SimpleGraph::path(4), + vec![(0, 2, 5), (1, 3, 1), (0, 3, 2)], + 2, + ); + let solver = BruteForce::new(); + + let solution = solver + .find_satisfying(&problem) + .expect("expected a satisfying augmentation"); + assert_eq!(solution, vec![0, 0, 1]); + + let all_solutions = solver.find_all_satisfying(&problem); + assert_eq!(all_solutions, vec![vec![0, 0, 1]]); +} + +#[test] +fn test_biconnectivity_augmentation_no_solution() { + let problem = BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(0, 2, 1)], 1); + let solver = BruteForce::new(); + + assert!(solver.find_satisfying(&problem).is_none()); + assert!(solver.find_all_satisfying(&problem).is_empty()); +} + +#[test] +fn test_is_biconnected() { + assert!(is_biconnected(&SimpleGraph::cycle(4))); + assert!(is_biconnected(&SimpleGraph::complete(3))); + assert!(!is_biconnected(&SimpleGraph::path(4))); + assert!(!is_biconnected(&SimpleGraph::new(4, vec![(0, 1), (2, 3)]))); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 7eef0660..af7aff50 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -53,6 +53,10 @@ fn test_all_problems_implement_trait_correctly() { &MaximumMatching::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32]), "MaximumMatching", ); + check_problem_trait( + &BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(0, 3, 2)], 2), + "BiconnectivityAugmentation", + ); check_problem_trait( &Satisfiability::new(3, vec![CNFClause::new(vec![1])]), "SAT", diff --git a/tests/suites/integration.rs b/tests/suites/integration.rs index 49e43f6f..d7a256a4 100644 --- a/tests/suites/integration.rs +++ b/tests/suites/integration.rs @@ -109,6 +109,19 @@ mod all_problems_solvable { } } + #[test] + fn test_biconnectivity_augmentation_solvable() { + let problem = BiconnectivityAugmentation::new( + SimpleGraph::path(4), + vec![(0, 2, 5), (1, 3, 1), (0, 3, 2)], + 2, + ); + let solver = BruteForce::new(); + let satisfying = solver.find_all_satisfying(&problem); + assert_eq!(satisfying, vec![vec![0, 0, 1]]); + assert!(satisfying.iter().all(|config| problem.evaluate(config))); + } + #[test] fn test_satisfiability_solvable() { let problem = Satisfiability::new( From 791a5058b60d7b9ad5a8e7a9dbb10bd8d053cadb Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 04:24:32 +0800 Subject: [PATCH 3/5] Refine #230: add canonical example coverage --- docs/paper/reductions.typ | 2 ++ .../graph/biconnectivity_augmentation.rs | 25 +++++++++++++++++++ .../graph/biconnectivity_augmentation.rs | 15 +++++++++++ 3 files changed, 42 insertions(+) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index c2bb6aaf..260c0384 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -439,6 +439,8 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co Given an undirected graph $G = (V, E)$, a set $F$ of candidate edges on $V$ with $F inter E = emptyset$, weights $w: F -> RR$, and a budget $B in RR$, find $F' subset.eq F$ such that $sum_(e in F') w(e) <= B$ and the augmented graph $G' = (V, E union F')$ is biconnected, meaning $G'$ is connected and deleting any single vertex leaves it connected. ][ Biconnectivity augmentation is a classical network-design problem: add backup links so the graph survives any single vertex failure. The weighted candidate-edge formulation modeled here captures communication, transportation, and infrastructure planning settings where only a prescribed set of new links is feasible and each carries a cost. In this library, the exact baseline is brute-force enumeration over the $m = |F|$ candidate edges, yielding $O^*(2^m)$ time and matching the exported complexity metadata for the model. + +*Example.* Consider the path graph $v_0 - v_1 - v_2 - v_3 - v_4 - v_5$ with candidate edges $(v_0, v_2)$, $(v_0, v_3)$, $(v_0, v_4)$, $(v_1, v_3)$, $(v_1, v_4)$, $(v_1, v_5)$, $(v_2, v_4)$, $(v_2, v_5)$, $(v_3, v_5)$ carrying weights $(1, 2, 3, 1, 2, 3, 1, 2, 1)$ and budget $B = 4$. Selecting $F' = {(v_0, v_2), (v_1, v_3), (v_2, v_4), (v_3, v_5)}$ uses total weight $1 + 1 + 1 + 1 = 4$ and eliminates every articulation point: after deleting any single vertex, the remaining graph is still connected. Reducing the budget to $B = 3$ makes the instance infeasible, because one of the path endpoints remains attached through a single articulation vertex. ] #problem-def("KColoring")[ Given $G = (V, E)$ and $k$ colors, find $c: V -> {1, ..., k}$ minimizing $|{(u, v) in E : c(u) = c(v)}|$. diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 2e5b56d8..7d45e55d 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -240,6 +240,31 @@ crate::declare_variants! { BiconnectivityAugmentation => "2^num_potential_edges", } +#[cfg(test)] +pub(crate) fn canonical_model_example_specs() -> ( + SimpleGraph, + Vec<(usize, usize, i32)>, + i32, + Vec, +) { + ( + SimpleGraph::path(6), + vec![ + (0, 2, 1), + (0, 3, 2), + (0, 4, 3), + (1, 3, 1), + (1, 4, 2), + (1, 5, 3), + (2, 4, 1), + (2, 5, 2), + (3, 5, 1), + ], + 4, + vec![1, 0, 0, 1, 0, 0, 1, 0, 1], + ) +} + #[cfg(test)] #[path = "../../unit_tests/models/graph/biconnectivity_augmentation.rs"] mod tests; diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs index 240e3962..9b5719bc 100644 --- a/src/unit_tests/models/graph/biconnectivity_augmentation.rs +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -95,6 +95,21 @@ fn test_biconnectivity_augmentation_no_solution() { assert!(solver.find_all_satisfying(&problem).is_empty()); } +#[test] +fn test_biconnectivity_augmentation_paper_example() { + let (graph, potential_edges, budget, satisfying) = canonical_model_example_specs(); + let problem = BiconnectivityAugmentation::new(graph.clone(), potential_edges.clone(), budget); + let solver = BruteForce::new(); + let satisfying_solutions = solver.find_all_satisfying(&problem); + + assert!(problem.evaluate(&satisfying)); + assert!(satisfying_solutions.contains(&satisfying)); + + let over_budget_problem = BiconnectivityAugmentation::new(graph, potential_edges, 3); + assert!(!over_budget_problem.evaluate(&satisfying)); + assert!(solver.find_satisfying(&over_budget_problem).is_none()); +} + #[test] fn test_is_biconnected() { assert!(is_biconnected(&SimpleGraph::cycle(4))); From 9517acbd8cd820aa680902218bf018f34f88d0d6 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 04:37:05 +0800 Subject: [PATCH 4/5] Fix review feedback for #230 --- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 98 +++++++++++++++++-- .../graph/biconnectivity_augmentation.rs | 46 ++++++--- .../graph/biconnectivity_augmentation.rs | 29 ++++-- 4 files changed, 148 insertions(+), 27 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 489adf43..0cabceeb 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,7 +216,7 @@ Flags by problem type: MaximumSetPacking --sets [--weights] MinimumSetCovering --universe, --sets [--weights] BicliqueCover --left, --right, --biedges, --k - BiconnectivityAugmentation --graph, --potential-edges, --budget + BiconnectivityAugmentation --graph, --potential-edges, --budget [--num-vertices] BMF --matrix (0/1), --rank CVP --basis, --target-vec [--bounds] LCS --strings diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2d8ea5a0..c19bbf40 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -15,7 +15,7 @@ use problemreductions::topology::{ UnitDiskGraph, }; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// Check if all data flags are None (no problem-specific input provided). fn all_data_flags_empty(args: &CreateArgs) -> bool { @@ -682,7 +682,8 @@ fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { util::variant_map(pairs) } -/// Parse `--graph` into a SimpleGraph, inferring num_vertices from max index. +/// Parse `--graph` into a SimpleGraph, optionally preserving isolated vertices +/// via `--num-vertices`. fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { let edges_str = args .graph @@ -690,10 +691,12 @@ fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { .ok_or_else(|| anyhow::anyhow!("This problem requires --graph (e.g., 0-1,1-2,2-3)"))?; if edges_str.trim().is_empty() { - bail!( - "Empty graph string. To create a graph with isolated vertices, use:\n \ - pred create --random --num-vertices N --edge-prob 0.0" - ); + let num_vertices = args.num_vertices.ok_or_else(|| { + anyhow::anyhow!( + "Empty graph string. To create a graph with isolated vertices, pass --num-vertices N as well." + ) + })?; + return Ok((SimpleGraph::empty(num_vertices), num_vertices)); } let edges: Vec<(usize, usize)> = edges_str @@ -716,12 +719,23 @@ fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { }) .collect::>>()?; - let num_vertices = edges + let inferred_num_vertices = edges .iter() .flat_map(|(u, v)| [*u, *v]) .max() .map(|m| m + 1) .unwrap_or(0); + let num_vertices = match args.num_vertices { + Some(explicit) if explicit < inferred_num_vertices => { + bail!( + "--num-vertices {} is too small for the provided graph; need at least {}", + explicit, + inferred_num_vertices + ); + } + Some(explicit) => explicit, + None => inferred_num_vertices, + }; Ok((SimpleGraph::new(num_vertices, edges), num_vertices)) } @@ -993,12 +1007,20 @@ fn validate_potential_edges( potential_edges: &[(usize, usize, i32)], ) -> Result<()> { let num_vertices = graph.num_vertices(); + let mut seen_potential_edges = BTreeSet::new(); for &(u, v, _) in potential_edges { if u >= num_vertices || v >= num_vertices { bail!( "Potential edge {u}-{v} references a vertex outside the graph (num_vertices = {num_vertices})" ); } + let edge = if u <= v { (u, v) } else { (v, u) }; + if graph.has_edge(edge.0, edge.1) { + bail!("Potential edge {}-{} already exists in the graph", edge.0, edge.1); + } + if !seen_potential_edges.insert(edge) { + bail!("Duplicate potential edge {}-{} is not allowed", edge.0, edge.1); + } } Ok(()) } @@ -1258,6 +1280,37 @@ mod tests { assert_eq!(parse_budget(&args).unwrap(), 7); } + #[test] + fn test_parse_graph_respects_explicit_num_vertices() { + let mut args = empty_args(); + args.graph = Some("0-1".to_string()); + args.num_vertices = Some(3); + + let (graph, num_vertices) = parse_graph(&args).unwrap(); + + assert_eq!(num_vertices, 3); + assert_eq!(graph.num_vertices(), 3); + assert_eq!(graph.edges(), vec![(0, 1)]); + } + + #[test] + fn test_validate_potential_edges_rejects_existing_graph_edge() { + let err = validate_potential_edges(&SimpleGraph::path(3), &[(0, 1, 5)]) + .unwrap_err() + .to_string(); + + assert!(err.contains("already exists in the graph")); + } + + #[test] + fn test_validate_potential_edges_rejects_duplicate_edges() { + let err = validate_potential_edges(&SimpleGraph::path(4), &[(0, 3, 1), (3, 0, 2)]) + .unwrap_err() + .to_string(); + + assert!(err.contains("Duplicate potential edge")); + } + #[test] fn test_create_biconnectivity_augmentation_json() { let mut args = empty_args(); @@ -1286,4 +1339,35 @@ mod tests { std::fs::remove_file(output_path).ok(); } + + #[test] + fn test_create_biconnectivity_augmentation_json_with_isolated_vertices() { + let mut args = empty_args(); + args.graph = Some("0-1".to_string()); + args.num_vertices = Some(3); + args.potential_edges = Some("1-2:1".to_string()); + args.budget = Some("1".to_string()); + + let output_path = + std::env::temp_dir().join("pred_test_create_biconnectivity_isolated.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + let problem: BiconnectivityAugmentation = + serde_json::from_value(json["data"].clone()).unwrap(); + + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.potential_weights(), &[(1, 2, 1)]); + assert_eq!(problem.budget(), &1); + + std::fs::remove_file(output_path).ok(); + } } diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 7d45e55d..a2f181c2 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -52,9 +52,12 @@ impl BiconnectivityAugmentation { /// Create a new biconnectivity augmentation instance. /// /// # Panics - /// Panics if any potential edge references a vertex index outside the graph. + /// Panics if any potential edge references a vertex index outside the graph, + /// is a self-loop, duplicates another candidate edge, or already exists in + /// the input graph. pub fn new(graph: G, potential_weights: Vec<(usize, usize, W)>, budget: W::Sum) -> Self { let num_vertices = graph.num_vertices(); + let mut seen_potential_edges = BTreeSet::new(); for &(u, v, _) in &potential_weights { assert!( u < num_vertices && v < num_vertices, @@ -63,6 +66,20 @@ impl BiconnectivityAugmentation { v, num_vertices ); + assert!(u != v, "potential edge ({}, {}) is a self-loop", u, v); + let edge = normalize_edge(u, v); + assert!( + !graph.has_edge(edge.0, edge.1), + "potential edge ({}, {}) already exists in the graph", + edge.0, + edge.1 + ); + assert!( + seen_potential_edges.insert(edge), + "potential edge ({}, {}) is duplicated", + edge.0, + edge.1 + ); } Self { @@ -241,15 +258,18 @@ crate::declare_variants! { } #[cfg(test)] -pub(crate) fn canonical_model_example_specs() -> ( - SimpleGraph, - Vec<(usize, usize, i32)>, - i32, - Vec, -) { - ( - SimpleGraph::path(6), - vec![ +pub(crate) struct BiconnectivityAugmentationExample { + pub(crate) graph: SimpleGraph, + pub(crate) potential_edges: Vec<(usize, usize, i32)>, + pub(crate) budget: i32, + pub(crate) satisfying_config: Vec, +} + +#[cfg(test)] +pub(crate) fn canonical_model_example_specs() -> BiconnectivityAugmentationExample { + BiconnectivityAugmentationExample { + graph: SimpleGraph::path(6), + potential_edges: vec![ (0, 2, 1), (0, 3, 2), (0, 4, 3), @@ -260,9 +280,9 @@ pub(crate) fn canonical_model_example_specs() -> ( (2, 5, 2), (3, 5, 1), ], - 4, - vec![1, 0, 0, 1, 0, 0, 1, 0, 1], - ) + budget: 4, + satisfying_config: vec![1, 0, 0, 1, 0, 0, 1, 0, 1], + } } #[cfg(test)] diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs index 9b5719bc..81cef937 100644 --- a/src/unit_tests/models/graph/biconnectivity_augmentation.rs +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -38,6 +38,18 @@ fn test_biconnectivity_augmentation_creation_rejects_invalid_potential_edge() { BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(0, 4, 1)], 1); } +#[test] +#[should_panic(expected = "already exists in the graph")] +fn test_biconnectivity_augmentation_creation_rejects_existing_edge_candidate() { + BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(1, 2, 1)], 1); +} + +#[test] +#[should_panic(expected = "is duplicated")] +fn test_biconnectivity_augmentation_creation_rejects_duplicate_candidate() { + BiconnectivityAugmentation::new(SimpleGraph::path(4), vec![(0, 3, 1), (3, 0, 2)], 2); +} + #[test] fn test_biconnectivity_augmentation_evaluation() { let problem = BiconnectivityAugmentation::new( @@ -97,16 +109,21 @@ fn test_biconnectivity_augmentation_no_solution() { #[test] fn test_biconnectivity_augmentation_paper_example() { - let (graph, potential_edges, budget, satisfying) = canonical_model_example_specs(); - let problem = BiconnectivityAugmentation::new(graph.clone(), potential_edges.clone(), budget); + let example = canonical_model_example_specs(); + let problem = BiconnectivityAugmentation::new( + example.graph.clone(), + example.potential_edges.clone(), + example.budget, + ); let solver = BruteForce::new(); let satisfying_solutions = solver.find_all_satisfying(&problem); - assert!(problem.evaluate(&satisfying)); - assert!(satisfying_solutions.contains(&satisfying)); + assert!(problem.evaluate(&example.satisfying_config)); + assert!(satisfying_solutions.contains(&example.satisfying_config)); - let over_budget_problem = BiconnectivityAugmentation::new(graph, potential_edges, 3); - assert!(!over_budget_problem.evaluate(&satisfying)); + let over_budget_problem = + BiconnectivityAugmentation::new(example.graph, example.potential_edges, 3); + assert!(!over_budget_problem.evaluate(&example.satisfying_config)); assert!(solver.find_satisfying(&over_budget_problem).is_none()); } From 6c00ec81b7fc31b574756772cbe5f6837bd3d9a6 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 04:37:18 +0800 Subject: [PATCH 5/5] chore: remove plan file after implementation --- ...03-13-biconnectivity-augmentation-model.md | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 docs/plans/2026-03-13-biconnectivity-augmentation-model.md diff --git a/docs/plans/2026-03-13-biconnectivity-augmentation-model.md b/docs/plans/2026-03-13-biconnectivity-augmentation-model.md deleted file mode 100644 index 00b57581..00000000 --- a/docs/plans/2026-03-13-biconnectivity-augmentation-model.md +++ /dev/null @@ -1,70 +0,0 @@ -# Plan: Add BiconnectivityAugmentation Model - -**Issue:** #230 -**Type:** [Model] -**Skill:** add-model - -## Information Checklist - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `BiconnectivityAugmentation` | -| 2 | Mathematical definition | Given graph G=(V,E), weighted potential edges, budget B. Is there E' with sum(w(e)) <= B such that G'=(V, E union E') is biconnected? | -| 3 | Problem type | Satisfaction (Metric = bool) | -| 4 | Type parameters | `G: Graph`, `W: WeightElement` | -| 5 | Struct fields | `graph: G`, `potential_weights: Vec<(usize, usize, W)>`, `budget: W::Sum` | -| 6 | Configuration space | `vec![2; self.potential_weights.len()]` — binary per potential edge | -| 7 | Feasibility check | G' = (V, E union selected_edges) is biconnected AND total weight <= budget | -| 8 | Objective function | N/A (satisfaction) — returns true if feasible | -| 9 | Best known exact algorithm | O*(2^num_potential_edges) brute-force (Eswaran & Tarjan 1976) | -| 10 | Solving strategy | BruteForce enumeration over subsets | -| 11 | Category | `graph` | - -## Implementation Steps - -### Step 1: Implement the model (`src/models/graph/biconnectivity_augmentation.rs`) - -- `inventory::submit!` for `ProblemSchemaEntry` -- Struct `BiconnectivityAugmentation` with fields: `graph: G`, `potential_weights: Vec<(usize, usize, W)>`, `budget: W::Sum` -- Constructor `new(graph, potential_weights, budget)` — validate potential edge indices are in range -- Getters: `graph()`, `potential_weights()`, `budget()`, `num_vertices()`, `num_edges()`, `num_potential_edges()` -- `is_weighted()` inherent method -- `Problem` impl: NAME = "BiconnectivityAugmentation", Metric = bool, dims = vec![2; potential_weights.len()] -- `evaluate()`: build augmented graph, check biconnectivity (no articulation points) AND weight sum <= budget -- `SatisfactionProblem` impl (marker) -- `declare_variants!` with `BiconnectivityAugmentation => "2^num_potential_edges"` -- `#[cfg(test)] #[path = "..."] mod tests;` -- Biconnectivity check: implement as a helper function using Tarjan's algorithm to find articulation points - -### Step 2: Register the model - -- `src/models/graph/mod.rs` — add module and re-export -- `src/models/mod.rs` — add to re-export line - -### Step 3: Register in CLI - -- `problemreductions-cli/src/dispatch.rs` — add `deser_sat::>` match arm in both `load_problem` and `serialize_any_problem` -- `problemreductions-cli/src/problem_name.rs` — add `"biconnectivityaugmentation"` alias in `resolve_alias()` - -### Step 4: Add CLI creation support - -- `problemreductions-cli/src/commands/create.rs` — add match arm parsing `--graph`, `--potential-edges`, `--budget` -- `problemreductions-cli/src/cli.rs` — add `--potential-edges` and `--budget` flags to `CreateArgs`, update `all_data_flags_empty()` and help table - -### Step 5: Write unit tests (`src/unit_tests/models/graph/biconnectivity_augmentation.rs`) - -- `test_biconnectivity_augmentation_creation` — construct instance, verify dimensions -- `test_biconnectivity_augmentation_evaluation` — verify evaluate on valid/invalid configs -- `test_biconnectivity_augmentation_serialization` — round-trip serde -- `test_biconnectivity_augmentation_solver` — BruteForce finds correct satisfying configs -- `test_biconnectivity_augmentation_no_solution` — verify unsatisfiable instance -- `test_is_biconnected` — test the helper function directly - -### Step 6: Document in paper - -- Add `display-name` entry: `"BiconnectivityAugmentation": [Biconnectivity Augmentation]` -- Add `problem-def("BiconnectivityAugmentation")` with formal definition and background - -### Step 7: Verify - -- `make test clippy` must pass