From 94b3f40eb3461c115e35c940b80b0cf012f3ff14 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 16:21:17 +0800 Subject: [PATCH 1/7] Add plan for #298: [Model] LengthBoundedDisjointPaths --- .../2026-03-16-lengthboundeddisjointpaths.md | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/plans/2026-03-16-lengthboundeddisjointpaths.md diff --git a/docs/plans/2026-03-16-lengthboundeddisjointpaths.md b/docs/plans/2026-03-16-lengthboundeddisjointpaths.md new file mode 100644 index 00000000..bb67cfe3 --- /dev/null +++ b/docs/plans/2026-03-16-lengthboundeddisjointpaths.md @@ -0,0 +1,219 @@ +# LengthBoundedDisjointPaths Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `LengthBoundedDisjointPaths` graph satisfaction model, CLI creation support, canonical example, tests, and paper entry for issue `#298`. + +**Architecture:** Implement the model as a generic graph satisfaction problem with `J * |V|` binary variables, where each path slot selects a vertex subset that must induce a simple `s-t` path of length at most `K`. Enforce internal-vertex disjointness across path slots inside `evaluate()`, declare `SimpleGraph` as the default registered variant, and keep the canonical public problem name as `LengthBoundedDisjointPaths`. + +**Tech Stack:** Rust, serde, inventory registry, problem registry macros, CLI create command, example-db, Typst paper. + +--- + +## Batch 1: Model, tests, registry, CLI, examples + +### Task 1: Add failing model tests for the chosen encoding + +**Files:** +- Create: `src/unit_tests/models/graph/length_bounded_disjoint_paths.rs` +- Reference: `src/unit_tests/models/graph/hamiltonian_path.rs` +- Reference: `src/unit_tests/models/graph/maximum_independent_set.rs` + +**Step 1: Write the failing tests** + +Add tests that cover: +- construction and size getters (`num_vertices`, `num_edges`, `num_paths_required`, `max_length`) +- a valid YES instance using the 7-vertex issue example with `s = 0`, `t = 6`, `J = 2`, `K = 3` +- invalid configs for missing source/sink, disconnected selected vertices, overlong paths, and shared internal vertices across slots +- brute-force solver behavior on a small YES instance and a small NO instance +- serde round-trip +- the paper/example instance count of satisfying solutions + +Use the binary slot encoding directly in the test configs: the first `|V|` bits are slot 0, the next `|V|` bits are slot 1, etc. + +**Step 2: Run the new test target and verify RED** + +Run: +```bash +cargo test length_bounded_disjoint_paths --lib +``` + +Expected: compile or linkage failure because the model does not exist yet. + +**Step 3: Commit after green later** + +```bash +git add src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +git commit -m "test: add LengthBoundedDisjointPaths model coverage" +``` + +### Task 2: Implement the model and make the tests pass + +**Files:** +- Create: `src/models/graph/length_bounded_disjoint_paths.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +**Step 1: Write the minimal model implementation** + +Implement: +- `ProblemSchemaEntry` with canonical name `LengthBoundedDisjointPaths` +- `LengthBoundedDisjointPaths` with fields `graph`, `source`, `sink`, `num_paths_required`, `max_length` +- inherent getters and `is_valid_solution()` +- `Problem` + `SatisfactionProblem` +- `dims() = vec![2; num_paths_required * num_vertices]` +- `evaluate()` that: + - partitions the config into `J` path slots + - validates each slot induces a connected simple `s-t` path + - rejects paths longer than `K` + - rejects reuse of any internal vertex across different slots +- `declare_variants!` with `default sat LengthBoundedDisjointPaths => "2^(num_paths_required * num_vertices)"` +- canonical example-db spec for the same small YES instance used in tests + +Keep helper functions private unless tests need them. + +**Step 2: Register the model exports** + +Wire the new file into: +- `src/models/graph/mod.rs` +- `src/models/mod.rs` +- `src/lib.rs` prelude / re-exports + +Also extend `canonical_model_example_specs()` in `src/models/graph/mod.rs`. + +**Step 3: Run the targeted tests and verify GREEN** + +Run: +```bash +cargo test length_bounded_disjoint_paths --lib +``` + +Expected: the new unit tests pass. + +**Step 4: Commit** + +```bash +git add src/models/graph/length_bounded_disjoint_paths.rs src/models/graph/mod.rs src/models/mod.rs src/lib.rs +git commit -m "feat: add LengthBoundedDisjointPaths model" +``` + +### Task 3: Add CLI creation support and trait consistency coverage + +**Files:** +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `src/unit_tests/trait_consistency.rs` + +**Step 1: Write the failing CLI/trait tests if coverage exists nearby, otherwise extend existing assertions first** + +Add the trait-consistency entry for a small instance immediately, then add CLI support for: +- direct creation from `--graph`, `--source`, `--sink`, `--num-paths-required`, `--bound` +- random creation with sane defaults (`source = 0`, `sink = n - 1`, `num_paths_required = 1`, `bound = n - 1` unless overridden) + +Update help text and `all_data_flags_empty()` for the new flags. + +**Step 2: Implement the CLI arm** + +Follow the `HamiltonianPath` / `OptimalLinearArrangement` patterns: +- extend `CreateArgs` +- add example/help text for `LengthBoundedDisjointPaths` +- parse required integers and build the model + +**Step 3: Run targeted verification** + +Run: +```bash +cargo test trait_consistency --lib +cargo test create --package problemreductions-cli +``` + +Expected: the trait consistency test stays green and CLI tests/build still pass for the touched areas. + +**Step 4: Commit** + +```bash +git add problemreductions-cli/src/cli.rs problemreductions-cli/src/commands/create.rs src/unit_tests/trait_consistency.rs +git commit -m "feat: wire LengthBoundedDisjointPaths into CLI" +``` + +### Task 4: Run focused example-db and fixture verification + +**Files:** +- Reference: `src/example_db/model_builders.rs` +- Reference: `src/unit_tests/example_db.rs` + +**Step 1: Verify the canonical example is exported correctly** + +Run: +```bash +cargo test example_db --lib --features example-db +``` + +Expected: the new model example is discoverable and structurally valid. + +**Step 2: If fixture/export regeneration is required, run the repo command that updates checked-in exports** + +Run the smallest repo-supported command needed after the model lands, then stage the generated changes if they are expected. + +**Step 3: Commit if generated exports changed** + +```bash +git add docs/src/reductions/problem_schemas.json docs/src/reductions/reduction_graph.json +git commit -m "chore: refresh generated model exports" +``` + +## Batch 2: Paper entry + +### Task 5: Add the paper definition and example after implementation is stable + +**Files:** +- Modify: `docs/paper/reductions.typ` + +**Step 1: Add the display name** + +Insert: +- `\"LengthBoundedDisjointPaths\": [Length-Bounded Disjoint Paths],` + +**Step 2: Add the `problem-def(...)` entry** + +Document: +- formal decision definition with `G`, `s`, `t`, `J`, `K` +- brief background and the Itai-Perl-Shiloach complexity threshold (`K >= 5` NP-complete, `K <= 4` polynomial) +- a small example that matches the canonical model example and explains the chosen satisfying configuration + +Use the same 7-vertex example as the model/example-db tests so the paper, tests, and exports stay aligned. + +**Step 3: Run paper and model verification** + +Run: +```bash +make paper +cargo test length_bounded_disjoint_paths --lib --features example-db +``` + +Expected: paper compiles and the paper/example-alignment test remains green. + +**Step 4: Commit** + +```bash +git add docs/paper/reductions.typ +git commit -m "docs: document LengthBoundedDisjointPaths" +``` + +## Final verification + +After both batches are done, run: + +```bash +make fmt +make check +``` + +If export files changed as part of verification, stage them before the final push. + +## Notes / constraints + +- Keep the public canonical problem name `LengthBoundedDisjointPaths`; do not reintroduce a `Maximum` prefix for the decision problem. +- The open inbound rule issue `#371` still uses stale wording (`MAXIMUM LENGTH-BOUNDED DISJOINT PATHS`). Treat that as a follow-up naming/tooling concern, not as justification to change the canonical model name in this PR. +- Keep the example small enough that brute force remains practical in unit tests. From d2c2df168904b9048ff7fcbf0f74596e3442cedf Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 16:39:24 +0800 Subject: [PATCH 2/7] Implement #298: [Model] LengthBoundedDisjointPaths --- docs/paper/reductions.typ | 62 ++++ docs/src/reductions/problem_schemas.json | 31 ++ docs/src/reductions/reduction_graph.json | 171 +++++----- problemreductions-cli/src/cli.rs | 10 + problemreductions-cli/src/commands/create.rs | 101 +++++- src/example_db/fixtures/examples.json | 1 + src/lib.rs | 4 +- .../graph/length_bounded_disjoint_paths.rs | 307 ++++++++++++++++++ src/models/graph/mod.rs | 4 + src/models/mod.rs | 10 +- src/unit_tests/example_db.rs | 19 +- src/unit_tests/export.rs | 3 +- .../graph/length_bounded_disjoint_paths.rs | 115 +++++++ src/unit_tests/trait_consistency.rs | 10 + 14 files changed, 749 insertions(+), 99 deletions(-) create mode 100644 src/models/graph/length_bounded_disjoint_paths.rs create mode 100644 src/unit_tests/models/graph/length_bounded_disjoint_paths.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b3a286b6..47f90c47 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -67,6 +67,7 @@ "MaxCut": [Max-Cut], "GraphPartitioning": [Graph Partitioning], "HamiltonianPath": [Hamiltonian Path], + "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], @@ -529,6 +530,67 @@ 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.], ) ] +#{ + let x = load-model-example("LengthBoundedDisjointPaths") + let nv = graph-num-vertices(x.instance) + let ne = graph-num-edges(x.instance) + let edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + let s = x.instance.source + let t = x.instance.sink + let J = x.instance.num_paths_required + let K = x.instance.max_length + let chosen-verts = (0, 1, 2, 3, 6) + let chosen-edges = ((0, 1), (1, 6), (0, 2), (2, 3), (3, 6)) + [ + #problem-def("LengthBoundedDisjointPaths")[ + Given an undirected graph $G = (V, E)$, distinct terminals $s, t in V$, and positive integers $J, K$, determine whether $G$ contains at least $J$ pairwise internally vertex-disjoint paths from $s$ to $t$, each using at most $K$ edges. + ][ + Length-Bounded Disjoint Paths is the bounded-routing version of the classical disjoint-path problem, with applications in network routing and VLSI where multiple connections must fit simultaneously under quality-of-service limits. Garey & Johnson list it as ND41 and summarize the sharp threshold proved by Itai, Perl, and Shiloach: the problem is NP-complete for every fixed $K >= 5$, polynomial-time solvable for $K <= 4$, and becomes polynomial again when the length bound is removed entirely @garey1979. The implementation here uses the natural $J dot |V|$ binary membership encoding, so brute-force search over configurations runs in $O^*(2^(J dot |V|))$. + + *Example.* Consider the graph $G$ with $n = #nv$ vertices, $|E| = #ne$ edges, terminals $s = v_#s$, $t = v_#t$, $J = #J$, and $K = #K$. The two paths $P_1 = v_0 arrow v_1 arrow v_6$ and $P_2 = v_0 arrow v_2 arrow v_3 arrow v_6$ are both of length at most 3, and their internal vertex sets ${v_1}$ and ${v_2, v_3}$ are disjoint. Hence this instance is satisfying. The third branch $v_0 arrow v_4 arrow v_5 arrow v_6$ is available but unused, so the instance has multiple satisfying path-slot assignments. + + #figure( + canvas(length: 1cm, { + let blue = graph-colors.at(0) + let gray = luma(180) + let verts = ( + (0, 1), // v0 = s + (1.3, 1.8), + (1.3, 1.0), + (2.6, 1.0), + (1.3, 0.2), + (2.6, 0.2), + (3.9, 1), // v6 = t + ) + for (u, v) in edges { + let selected = chosen-edges.any(e => + (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u) + ) + g-edge(verts.at(u), verts.at(v), + stroke: if selected { 2pt + blue } else { 1pt + gray }) + } + for (k, pos) in verts.enumerate() { + let active = chosen-verts.contains(k) + g-node(pos, name: "v" + str(k), + fill: if active { blue } else { white }, + label: if active { + text(fill: white)[ + #if k == s { $s$ } + else if k == t { $t$ } + else { $v_#k$ } + ] + } else [ + #if k == s { $s$ } + else if k == t { $t$ } + else { $v_#k$ } + ]) + } + }), + caption: [A satisfying Length-Bounded Disjoint Paths instance with $s = v_0$, $t = v_6$, $J = 2$, and $K = 3$. The highlighted paths are $v_0 arrow v_1 arrow v_6$ and $v_0 arrow v_2 arrow v_3 arrow v_6$; the lower branch through $v_4, v_5$ remains unused.], + ) + ] + ] +} #{ 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..3dba4e0a 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -259,6 +259,37 @@ } ] }, + { + "name": "LengthBoundedDisjointPaths", + "description": "Find J internally vertex-disjoint s-t paths of length at most K", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "source", + "type_name": "usize", + "description": "The shared source vertex s" + }, + { + "name": "sink", + "type_name": "usize", + "description": "The shared sink vertex t" + }, + { + "name": "num_paths_required", + "type_name": "usize", + "description": "Required number J of disjoint s-t paths" + }, + { + "name": "max_length", + "type_name": "usize", + "description": "Maximum path length K in edges" + } + ] + }, { "name": "LongestCommonSubsequence", "description": "Find the longest string that is a subsequence of every input string", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index c1334cfb..15b2e71f 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -205,6 +205,15 @@ "doc_path": "models/misc/struct.Knapsack.html", "complexity": "2^(num_items / 2)" }, + { + "name": "LengthBoundedDisjointPaths", + "variant": { + "graph": "SimpleGraph" + }, + "category": "graph", + "doc_path": "models/graph/struct.LengthBoundedDisjointPaths.html", + "complexity": "2^(num_paths_required * num_vertices)" + }, { "name": "LongestCommonSubsequence", "variant": {}, @@ -549,7 +558,7 @@ }, { "source": 4, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -609,7 +618,7 @@ }, { "source": 12, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -650,7 +659,7 @@ }, { "source": 19, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -676,7 +685,7 @@ }, { "source": 20, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -702,7 +711,7 @@ }, { "source": 21, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -713,7 +722,7 @@ }, { "source": 21, - "target": 56, + "target": 57, "overhead": [ { "field": "num_elements", @@ -724,7 +733,7 @@ }, { "source": 22, - "target": 51, + "target": 52, "overhead": [ { "field": "num_clauses", @@ -743,7 +752,7 @@ }, { "source": 23, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -753,7 +762,7 @@ "doc_path": "rules/knapsack_qubo/index.html" }, { - "source": 24, + "source": 25, "target": 12, "overhead": [ { @@ -768,8 +777,8 @@ "doc_path": "rules/longestcommonsubsequence_ilp/index.html" }, { - "source": 25, - "target": 54, + "source": 26, + "target": 55, "overhead": [ { "field": "num_spins", @@ -783,7 +792,7 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 27, + "source": 28, "target": 12, "overhead": [ { @@ -798,8 +807,8 @@ "doc_path": "rules/maximumclique_ilp/index.html" }, { - "source": 27, - "target": 31, + "source": 28, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -813,8 +822,8 @@ "doc_path": "rules/maximumclique_maximumindependentset/index.html" }, { - "source": 28, - "target": 29, + "source": 29, + "target": 30, "overhead": [ { "field": "num_vertices", @@ -828,8 +837,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 28, - "target": 33, + "source": 29, + "target": 34, "overhead": [ { "field": "num_vertices", @@ -843,8 +852,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 29, - "target": 34, + "source": 30, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -858,8 +867,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 28, + "source": 31, + "target": 29, "overhead": [ { "field": "num_vertices", @@ -873,8 +882,8 @@ "doc_path": "rules/maximumindependentset_gridgraph/index.html" }, { - "source": 30, - "target": 31, + "source": 31, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -888,8 +897,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 32, + "source": 31, + "target": 33, "overhead": [ { "field": "num_vertices", @@ -903,8 +912,8 @@ "doc_path": "rules/maximumindependentset_triangular/index.html" }, { - "source": 30, - "target": 36, + "source": 31, + "target": 37, "overhead": [ { "field": "num_sets", @@ -918,8 +927,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 27, + "source": 32, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -933,8 +942,8 @@ "doc_path": "rules/maximumindependentset_maximumclique/index.html" }, { - "source": 31, - "target": 38, + "source": 32, + "target": 39, "overhead": [ { "field": "num_sets", @@ -948,8 +957,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 45, + "source": 32, + "target": 46, "overhead": [ { "field": "num_vertices", @@ -963,8 +972,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 32, - "target": 34, + "source": 33, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -978,8 +987,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 33, - "target": 30, + "source": 34, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -993,8 +1002,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 33, - "target": 34, + "source": 34, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -1008,8 +1017,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 34, - "target": 31, + "source": 35, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1023,7 +1032,7 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 35, + "source": 36, "target": 12, "overhead": [ { @@ -1038,8 +1047,8 @@ "doc_path": "rules/maximummatching_ilp/index.html" }, { - "source": 35, - "target": 38, + "source": 36, + "target": 39, "overhead": [ { "field": "num_sets", @@ -1053,8 +1062,8 @@ "doc_path": "rules/maximummatching_maximumsetpacking/index.html" }, { - "source": 36, - "target": 30, + "source": 37, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -1068,8 +1077,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 36, - "target": 38, + "source": 37, + "target": 39, "overhead": [ { "field": "num_sets", @@ -1083,8 +1092,8 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 37, - "target": 49, + "source": 38, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1094,7 +1103,7 @@ "doc_path": "rules/maximumsetpacking_qubo/index.html" }, { - "source": 38, + "source": 39, "target": 12, "overhead": [ { @@ -1109,8 +1118,8 @@ "doc_path": "rules/maximumsetpacking_ilp/index.html" }, { - "source": 38, - "target": 31, + "source": 39, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1124,8 +1133,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 38, - "target": 37, + "source": 39, + "target": 38, "overhead": [ { "field": "num_sets", @@ -1139,7 +1148,7 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 39, + "source": 40, "target": 12, "overhead": [ { @@ -1154,7 +1163,7 @@ "doc_path": "rules/minimumdominatingset_ilp/index.html" }, { - "source": 42, + "source": 43, "target": 12, "overhead": [ { @@ -1169,8 +1178,8 @@ "doc_path": "rules/minimumsetcovering_ilp/index.html" }, { - "source": 45, - "target": 31, + "source": 46, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1184,8 +1193,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 45, - "target": 42, + "source": 46, + "target": 43, "overhead": [ { "field": "num_sets", @@ -1199,7 +1208,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 49, + "source": 50, "target": 12, "overhead": [ { @@ -1214,8 +1223,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 49, - "target": 53, + "source": 50, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1225,7 +1234,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 51, + "source": 52, "target": 4, "overhead": [ { @@ -1240,7 +1249,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 51, + "source": 52, "target": 16, "overhead": [ { @@ -1255,7 +1264,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 51, + "source": 52, "target": 21, "overhead": [ { @@ -1270,8 +1279,8 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 51, - "target": 30, + "source": 52, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -1285,8 +1294,8 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 51, - "target": 39, + "source": 52, + "target": 40, "overhead": [ { "field": "num_vertices", @@ -1300,8 +1309,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, - "target": 49, + "source": 54, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1311,8 +1320,8 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, - "target": 25, + "source": 55, + "target": 26, "overhead": [ { "field": "num_vertices", @@ -1326,8 +1335,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1341,7 +1350,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 57, + "source": 58, "target": 12, "overhead": [ { @@ -1356,8 +1365,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..b7cbbe1d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -224,6 +224,7 @@ Flags by problem type: PartitionIntoTriangles --graph GraphPartitioning --graph IsomorphicSpanningTree --graph, --tree + LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound Factoring --target, --m, --n BinPacking --sizes, --capacity SubsetSum --sizes, --target @@ -286,6 +287,15 @@ pub struct CreateArgs { /// Edge weights (e.g., 2,3,1) [default: all 1s] #[arg(long)] pub edge_weights: Option, + /// Source vertex for path-based graph problems + #[arg(long)] + pub source: Option, + /// Sink vertex for path-based graph problems + #[arg(long)] + pub sink: Option, + /// Required number of paths for LengthBoundedDisjointPaths + #[arg(long)] + pub num_paths_required: Option, /// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s] #[arg(long)] pub couplings: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a1444224..fa4ff8cb 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -6,7 +6,9 @@ use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; -use problemreductions::models::graph::{GraphPartitioning, HamiltonianPath}; +use problemreductions::models::graph::{ + GraphPartitioning, HamiltonianPath, LengthBoundedDisjointPaths, +}; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, PaintShop, ShortestCommonSupersequence, SubsetSum, @@ -25,6 +27,9 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { args.graph.is_none() && args.weights.is_none() && args.edge_weights.is_none() + && args.source.is_none() + && args.sink.is_none() + && args.num_paths_required.is_none() && args.couplings.is_none() && args.fields.is_none() && args.clauses.is_none() @@ -225,6 +230,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { }, "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", "HamiltonianPath" => "--graph 0-1,1-2,2-3", + "LengthBoundedDisjointPaths" => { + "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3" + } "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" @@ -381,6 +389,50 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) } + // LengthBoundedDisjointPaths (graph + source + sink + path count + bound) + "LengthBoundedDisjointPaths" => { + let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") + })?; + let num_paths_required = args.num_paths_required.ok_or_else(|| { + anyhow::anyhow!( + "LengthBoundedDisjointPaths requires --num-paths-required\n\n{usage}" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --bound\n\n{usage}") + })?; + let max_length = usize::try_from(bound).map_err(|_| { + anyhow::anyhow!("--bound must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}") + })?; + + if source >= graph.num_vertices() || sink >= graph.num_vertices() { + bail!("--source and --sink must be valid graph vertices\n\n{usage}"); + } + if num_paths_required == 0 { + bail!("--num-paths-required must be positive\n\n{usage}"); + } + if max_length == 0 { + bail!("--bound must be positive\n\n{usage}"); + } + + ( + ser(LengthBoundedDisjointPaths::new( + graph, + source, + sink, + num_paths_required, + max_length, + ))?, + resolved_variant.clone(), + ) + } + // IsomorphicSpanningTree (graph + tree) "IsomorphicSpanningTree" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -1661,6 +1713,53 @@ fn create_random( (ser(HamiltonianPath::new(graph))?, variant) } + // LengthBoundedDisjointPaths (graph only, with path defaults) + "LengthBoundedDisjointPaths" => { + let num_vertices = if num_vertices < 2 { + eprintln!( + "Warning: LengthBoundedDisjointPaths requires at least 2 vertices; rounding {} up to 2", + num_vertices + ); + 2 + } else { + num_vertices + }; + let edge_prob = args.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + bail!("--edge-prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let source = args.source.unwrap_or(0); + let sink = args.sink.unwrap_or(num_vertices - 1); + let num_paths_required = args.num_paths_required.unwrap_or(1); + let bound = args.bound.unwrap_or((num_vertices - 1) as i64); + let max_length = usize::try_from(bound) + .map_err(|_| anyhow::anyhow!("--bound must be nonnegative"))?; + if source >= num_vertices || sink >= num_vertices { + bail!("--source and --sink must be valid graph vertices"); + } + if source == sink { + bail!("--source and --sink must be distinct"); + } + if num_paths_required == 0 { + bail!("--num-paths-required must be positive"); + } + if max_length == 0 { + bail!("--bound must be positive"); + } + let variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(LengthBoundedDisjointPaths::new( + graph, + source, + sink, + num_paths_required, + max_length, + ))?, + variant, + ) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let edge_prob = args.edge_prob.unwrap_or(0.5); diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index e48eef82..009111c5 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -11,6 +11,7 @@ {"problem":"IsomorphicSpanningTree","variant":{},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}},"tree":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[0,1,2,3],"metric":true}],"optimal":[{"config":[0,1,2,3],"metric":true},{"config":[0,1,3,2],"metric":true},{"config":[0,2,1,3],"metric":true},{"config":[0,2,3,1],"metric":true},{"config":[0,3,1,2],"metric":true},{"config":[0,3,2,1],"metric":true},{"config":[1,0,2,3],"metric":true},{"config":[1,0,3,2],"metric":true},{"config":[1,2,0,3],"metric":true},{"config":[1,2,3,0],"metric":true},{"config":[1,3,0,2],"metric":true},{"config":[1,3,2,0],"metric":true},{"config":[2,0,1,3],"metric":true},{"config":[2,0,3,1],"metric":true},{"config":[2,1,0,3],"metric":true},{"config":[2,1,3,0],"metric":true},{"config":[2,3,0,1],"metric":true},{"config":[2,3,1,0],"metric":true},{"config":[3,0,1,2],"metric":true},{"config":[3,0,2,1],"metric":true},{"config":[3,1,0,2],"metric":true},{"config":[3,1,2,0],"metric":true},{"config":[3,2,0,1],"metric":true},{"config":[3,2,1,0],"metric":true}]}, {"problem":"KColoring","variant":{"graph":"SimpleGraph","k":"K3"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"num_colors":3},"samples":[{"config":[0,1,1,0,2],"metric":true}],"optimal":[{"config":[0,1,1,0,2],"metric":true},{"config":[0,1,1,2,0],"metric":true},{"config":[0,1,2,0,1],"metric":true},{"config":[0,2,1,0,2],"metric":true},{"config":[0,2,2,0,1],"metric":true},{"config":[0,2,2,1,0],"metric":true},{"config":[1,0,0,1,2],"metric":true},{"config":[1,0,0,2,1],"metric":true},{"config":[1,0,2,1,0],"metric":true},{"config":[1,2,0,1,2],"metric":true},{"config":[1,2,2,0,1],"metric":true},{"config":[1,2,2,1,0],"metric":true},{"config":[2,0,0,1,2],"metric":true},{"config":[2,0,0,2,1],"metric":true},{"config":[2,0,1,2,0],"metric":true},{"config":[2,1,0,2,1],"metric":true},{"config":[2,1,1,0,2],"metric":true},{"config":[2,1,1,2,0],"metric":true}]}, {"problem":"KSatisfiability","variant":{"k":"K3"},"instance":{"clauses":[{"literals":[1,2,3]},{"literals":[-1,-2,3]},{"literals":[1,-2,-3]}],"num_vars":3},"samples":[{"config":[1,0,1],"metric":true}],"optimal":[{"config":[0,0,1],"metric":true},{"config":[0,1,0],"metric":true},{"config":[1,0,0],"metric":true},{"config":[1,0,1],"metric":true},{"config":[1,1,1],"metric":true}]}, + {"problem":"LengthBoundedDisjointPaths","variant":{"graph":"SimpleGraph"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,6,null],[0,2,null],[2,3,null],[3,6,null],[0,4,null],[4,5,null],[5,6,null]],"node_holes":[],"nodes":[null,null,null,null,null,null,null]}},"max_length":3,"num_paths_required":2,"sink":6,"source":0},"samples":[{"config":[1,1,0,0,0,0,1,1,0,1,1,0,0,1],"metric":true}],"optimal":[{"config":[1,0,0,0,1,1,1,1,0,1,1,0,0,1],"metric":true},{"config":[1,0,0,0,1,1,1,1,1,0,0,0,0,1],"metric":true},{"config":[1,0,1,1,0,0,1,1,0,0,0,1,1,1],"metric":true},{"config":[1,0,1,1,0,0,1,1,1,0,0,0,0,1],"metric":true},{"config":[1,1,0,0,0,0,1,1,0,0,0,1,1,1],"metric":true},{"config":[1,1,0,0,0,0,1,1,0,1,1,0,0,1],"metric":true}]}, {"problem":"MaxCut","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,1,1,1,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,0,1,0],"metric":{"Valid":5}}],"optimal":[{"config":[0,1,1,0,0],"metric":{"Valid":5}},{"config":[0,1,1,0,1],"metric":{"Valid":5}},{"config":[1,0,0,1,0],"metric":{"Valid":5}},{"config":[1,0,0,1,1],"metric":{"Valid":5}}]}, {"problem":"MaximalIS","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[2,3,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[0,1,0,1,0],"metric":{"Valid":2}},{"config":[1,0,1,0,1],"metric":{"Valid":3}}],"optimal":[{"config":[1,0,1,0,1],"metric":{"Valid":3}}]}, {"problem":"MaximumClique","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[0,0,1,1,1],"metric":{"Valid":3}}],"optimal":[{"config":[0,0,1,1,1],"metric":{"Valid":3}}]}, diff --git a/src/lib.rs b/src/lib.rs index bceccfe2..859d13bb 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, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, + LengthBoundedDisjointPaths, SpinGlass, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs new file mode 100644 index 00000000..f728eadf --- /dev/null +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -0,0 +1,307 @@ +//! Length-Bounded Disjoint Paths problem implementation. +//! +//! The problem asks whether a graph contains at least `J` internally +//! vertex-disjoint `s-t` paths, each using at most `K` edges. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::variant::VariantParam; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "LengthBoundedDisjointPaths", + display_name: "Length-Bounded Disjoint Paths", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], + module_path: module_path!(), + description: "Find J internally vertex-disjoint s-t paths of length at most K", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "source", type_name: "usize", description: "The shared source vertex s" }, + FieldInfo { name: "sink", type_name: "usize", description: "The shared sink vertex t" }, + FieldInfo { name: "num_paths_required", type_name: "usize", description: "Required number J of disjoint s-t paths" }, + FieldInfo { name: "max_length", type_name: "usize", description: "Maximum path length K in edges" }, + ], + } +} + +/// Length-Bounded Disjoint Paths on an undirected graph. +/// +/// A configuration uses `J * |V|` binary choices. For each path slot `j` and +/// vertex `v`, `x_{j,v} = 1` means that `v` belongs to slot `j`'s path. Each +/// slot must induce a simple `s-t` path, and the internal vertices of +/// different slots must be disjoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +pub struct LengthBoundedDisjointPaths { + graph: G, + source: usize, + sink: usize, + num_paths_required: usize, + max_length: usize, +} + +impl LengthBoundedDisjointPaths { + /// Create a new Length-Bounded Disjoint Paths instance. + pub fn new( + graph: G, + source: usize, + sink: usize, + num_paths_required: usize, + max_length: usize, + ) -> Self { + assert!( + source < graph.num_vertices(), + "source must be a valid graph vertex" + ); + assert!( + sink < graph.num_vertices(), + "sink must be a valid graph vertex" + ); + assert_ne!(source, sink, "source and sink must be distinct"); + assert!( + num_paths_required > 0, + "num_paths_required must be positive" + ); + assert!(max_length > 0, "max_length must be positive"); + assert!( + max_length <= graph.num_vertices(), + "max_length must be at most num_vertices" + ); + Self { + graph, + source, + sink, + num_paths_required, + max_length, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the shared source vertex. + pub fn source(&self) -> usize { + self.source + } + + /// Get the shared sink vertex. + pub fn sink(&self) -> usize { + self.sink + } + + /// Get the required number of paths. + pub fn num_paths_required(&self) -> usize { + self.num_paths_required + } + + /// Get the maximum permitted path length in edges. + pub fn max_length(&self) -> usize { + self.max_length + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check whether a configuration is a valid solution. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_path_collection( + &self.graph, + self.source, + self.sink, + self.num_paths_required, + self.max_length, + config, + ) + } +} + +impl Problem for LengthBoundedDisjointPaths +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "LengthBoundedDisjointPaths"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![2; self.num_paths_required * self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for LengthBoundedDisjointPaths {} + +fn is_valid_path_collection( + graph: &G, + source: usize, + sink: usize, + num_paths_required: usize, + max_length: usize, + config: &[usize], +) -> bool { + let num_vertices = graph.num_vertices(); + if config.len() != num_paths_required * num_vertices { + return false; + } + + let mut used_internal = vec![false; num_vertices]; + for slot in config.chunks(num_vertices) { + if !is_valid_path_slot(graph, source, sink, max_length, slot, &mut used_internal) { + return false; + } + } + true +} + +fn is_valid_path_slot( + graph: &G, + source: usize, + sink: usize, + max_length: usize, + slot: &[usize], + used_internal: &mut [bool], +) -> bool { + if slot.len() != graph.num_vertices() + || slot.get(source) != Some(&1) + || slot.get(sink) != Some(&1) + { + return false; + } + + let selected = slot + .iter() + .enumerate() + .filter_map(|(vertex, &chosen)| (chosen == 1).then_some(vertex)) + .collect::>(); + if selected.len() < 2 { + return false; + } + + let mut in_path = vec![false; graph.num_vertices()]; + for &vertex in &selected { + in_path[vertex] = true; + if vertex != source && vertex != sink && used_internal[vertex] { + return false; + } + } + + let mut degree_sum = 0usize; + for &vertex in &selected { + let degree = graph + .neighbors(vertex) + .into_iter() + .filter(|&neighbor| in_path[neighbor]) + .count(); + degree_sum += degree; + + if vertex == source || vertex == sink { + if degree != 1 { + return false; + } + } else if degree != 2 { + return false; + } + } + + let edge_count = degree_sum / 2; + if edge_count + 1 != selected.len() || edge_count > max_length { + return false; + } + + let mut seen = vec![false; graph.num_vertices()]; + let mut stack = vec![source]; + seen[source] = true; + let mut seen_count = 0usize; + while let Some(vertex) = stack.pop() { + seen_count += 1; + for neighbor in graph.neighbors(vertex) { + if in_path[neighbor] && !seen[neighbor] { + seen[neighbor] = true; + stack.push(neighbor); + } + } + } + + if !seen[sink] || seen_count != selected.len() { + return false; + } + + for &vertex in &selected { + if vertex != source && vertex != sink { + used_internal[vertex] = true; + } + } + true +} + +#[cfg(feature = "example-db")] +fn encode_paths(num_vertices: usize, slots: &[&[usize]]) -> Vec { + let mut config = vec![0; num_vertices * slots.len()]; + for (slot_index, slot_vertices) in slots.iter().enumerate() { + let offset = slot_index * num_vertices; + for &vertex in *slot_vertices { + config[offset + vertex] = 1; + } + } + config +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "length_bounded_disjoint_paths_simplegraph", + build: || { + let problem = LengthBoundedDisjointPaths::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (1, 6), + (0, 2), + (2, 3), + (3, 6), + (0, 4), + (4, 5), + (5, 6), + ], + ), + 0, + 6, + 2, + 3, + ); + crate::example_db::specs::satisfaction_example( + problem, + vec![encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]])], + ) + }, + }] +} + +crate::declare_variants! { + default sat LengthBoundedDisjointPaths => "2^(num_paths_required * num_vertices)", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/length_bounded_disjoint_paths.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7d76de65..20585dff 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -20,6 +20,7 @@ //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) +//! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) @@ -28,6 +29,7 @@ pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_path; pub(crate) mod isomorphic_spanning_tree; pub(crate) mod kcoloring; +pub(crate) mod length_bounded_disjoint_paths; pub(crate) mod max_cut; pub(crate) mod maximal_is; pub(crate) mod maximum_clique; @@ -50,6 +52,7 @@ pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_path::HamiltonianPath; pub use isomorphic_spanning_tree::IsomorphicSpanningTree; pub use kcoloring::KColoring; +pub use length_bounded_disjoint_paths::LengthBoundedDisjointPaths; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; @@ -76,6 +79,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/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs new file mode 100644 index 00000000..aa689cb2 --- /dev/null +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -0,0 +1,115 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +fn sample_yes_graph() -> SimpleGraph { + SimpleGraph::new( + 7, + vec![ + (0, 1), + (1, 6), + (0, 2), + (2, 3), + (3, 6), + (0, 4), + (4, 5), + (5, 6), + ], + ) +} + +fn sample_yes_problem() -> LengthBoundedDisjointPaths { + LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 6, 2, 3) +} + +fn encode_paths(num_vertices: usize, slots: &[&[usize]]) -> Vec { + let mut config = vec![0; num_vertices * slots.len()]; + for (slot_index, slot_vertices) in slots.iter().enumerate() { + let offset = slot_index * num_vertices; + for &vertex in *slot_vertices { + config[offset + vertex] = 1; + } + } + config +} + +#[test] +fn test_length_bounded_disjoint_paths_creation() { + let problem = sample_yes_problem(); + assert_eq!(problem.num_vertices(), 7); + assert_eq!(problem.num_edges(), 8); + assert_eq!(problem.num_paths_required(), 2); + assert_eq!(problem.max_length(), 3); + assert_eq!(problem.dims(), vec![2; 14]); +} + +#[test] +fn test_length_bounded_disjoint_paths_evaluation() { + let problem = sample_yes_problem(); + let config = encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]]); + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_rejects_missing_terminal() { + let problem = sample_yes_problem(); + let config = encode_paths(7, &[&[0, 1], &[0, 2, 3, 6]]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_rejects_disconnected_slot() { + let problem = sample_yes_problem(); + let config = encode_paths(7, &[&[0, 1, 3, 6], &[0, 4, 5, 6]]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_rejects_overlong_slot() { + let problem = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 6, 2, 2); + let config = encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_rejects_shared_internal_vertices() { + let problem = sample_yes_problem(); + let config = encode_paths(7, &[&[0, 2, 3, 6], &[0, 2, 3, 6]]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_solver_yes_and_no() { + let yes_problem = sample_yes_problem(); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&yes_problem).is_some()); + + let no_problem = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 6, 2, 2); + assert!(solver.find_satisfying(&no_problem).is_none()); +} + +#[test] +fn test_length_bounded_disjoint_paths_serialization() { + let problem = sample_yes_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let round_trip: LengthBoundedDisjointPaths = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip.num_vertices(), 7); + assert_eq!(round_trip.source(), 0); + assert_eq!(round_trip.sink(), 6); + assert_eq!(round_trip.num_paths_required(), 2); + assert_eq!(round_trip.max_length(), 3); +} + +#[test] +fn test_length_bounded_disjoint_paths_paper_example() { + let problem = sample_yes_problem(); + let config = encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]]); + assert!(problem.evaluate(&config)); + + let satisfying = BruteForce::new().find_all_satisfying(&problem); + assert_eq!(satisfying.len(), 6); + assert!(satisfying + .iter() + .all(|candidate| problem.evaluate(candidate))); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index eee5dc64..a3a97714 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -107,6 +107,16 @@ fn test_all_problems_implement_trait_correctly() { &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), "HamiltonianPath", ); + check_problem_trait( + &LengthBoundedDisjointPaths::new( + SimpleGraph::new(4, vec![(0, 1), (1, 3), (0, 2), (2, 3)]), + 0, + 3, + 2, + 2, + ), + "LengthBoundedDisjointPaths", + ); check_problem_trait( &OptimalLinearArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3), "OptimalLinearArrangement", From a823f4e40e2fa6d57ed3ac8ed2bf1da5a0000e20 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 16:50:02 +0800 Subject: [PATCH 3/7] Fix review findings for #298 --- .../graph/length_bounded_disjoint_paths.rs | 25 +++++++++++++++---- .../graph/length_bounded_disjoint_paths.rs | 22 ++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index f728eadf..618a90ae 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -68,10 +68,6 @@ impl LengthBoundedDisjointPaths { "num_paths_required must be positive" ); assert!(max_length > 0, "max_length must be positive"); - assert!( - max_length <= graph.num_vertices(), - "max_length must be at most num_vertices" - ); Self { graph, source, @@ -163,10 +159,22 @@ fn is_valid_path_collection( if config.len() != num_paths_required * num_vertices { return false; } + if config.iter().any(|&value| value > 1) { + return false; + } let mut used_internal = vec![false; num_vertices]; + let mut used_direct_path = false; for slot in config.chunks(num_vertices) { - if !is_valid_path_slot(graph, source, sink, max_length, slot, &mut used_internal) { + if !is_valid_path_slot( + graph, + source, + sink, + max_length, + slot, + &mut used_internal, + &mut used_direct_path, + ) { return false; } } @@ -180,6 +188,7 @@ fn is_valid_path_slot( max_length: usize, slot: &[usize], used_internal: &mut [bool], + used_direct_path: &mut bool, ) -> bool { if slot.len() != graph.num_vertices() || slot.get(source) != Some(&1) @@ -227,6 +236,12 @@ fn is_valid_path_slot( if edge_count + 1 != selected.len() || edge_count > max_length { return false; } + if edge_count == 1 { + if *used_direct_path { + return false; + } + *used_direct_path = true; + } let mut seen = vec![false; graph.num_vertices()]; let mut stack = vec![source]; diff --git a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs index aa689cb2..48787b26 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -44,6 +44,13 @@ fn test_length_bounded_disjoint_paths_creation() { assert_eq!(problem.dims(), vec![2; 14]); } +#[test] +fn test_length_bounded_disjoint_paths_allows_large_bounds() { + let problem = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 6, 2, 10); + let config = encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]]); + assert!(problem.evaluate(&config)); +} + #[test] fn test_length_bounded_disjoint_paths_evaluation() { let problem = sample_yes_problem(); @@ -79,6 +86,21 @@ fn test_length_bounded_disjoint_paths_rejects_shared_internal_vertices() { assert!(!problem.evaluate(&config)); } +#[test] +fn test_length_bounded_disjoint_paths_rejects_reused_direct_edge() { + let problem = LengthBoundedDisjointPaths::new(SimpleGraph::new(2, vec![(0, 1)]), 0, 1, 2, 1); + let config = encode_paths(2, &[&[0, 1], &[0, 1]]); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_rejects_non_binary_entries() { + let problem = sample_yes_problem(); + let mut config = encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]]); + config[4] = 2; + assert!(!problem.evaluate(&config)); +} + #[test] fn test_length_bounded_disjoint_paths_solver_yes_and_no() { let yes_problem = sample_yes_problem(); From 253a21ab7ffe0610ad66fce5ef31032a93dd2233 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 16:50:06 +0800 Subject: [PATCH 4/7] chore: remove plan file after implementation --- .../2026-03-16-lengthboundeddisjointpaths.md | 219 ------------------ 1 file changed, 219 deletions(-) delete mode 100644 docs/plans/2026-03-16-lengthboundeddisjointpaths.md diff --git a/docs/plans/2026-03-16-lengthboundeddisjointpaths.md b/docs/plans/2026-03-16-lengthboundeddisjointpaths.md deleted file mode 100644 index bb67cfe3..00000000 --- a/docs/plans/2026-03-16-lengthboundeddisjointpaths.md +++ /dev/null @@ -1,219 +0,0 @@ -# LengthBoundedDisjointPaths Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `LengthBoundedDisjointPaths` graph satisfaction model, CLI creation support, canonical example, tests, and paper entry for issue `#298`. - -**Architecture:** Implement the model as a generic graph satisfaction problem with `J * |V|` binary variables, where each path slot selects a vertex subset that must induce a simple `s-t` path of length at most `K`. Enforce internal-vertex disjointness across path slots inside `evaluate()`, declare `SimpleGraph` as the default registered variant, and keep the canonical public problem name as `LengthBoundedDisjointPaths`. - -**Tech Stack:** Rust, serde, inventory registry, problem registry macros, CLI create command, example-db, Typst paper. - ---- - -## Batch 1: Model, tests, registry, CLI, examples - -### Task 1: Add failing model tests for the chosen encoding - -**Files:** -- Create: `src/unit_tests/models/graph/length_bounded_disjoint_paths.rs` -- Reference: `src/unit_tests/models/graph/hamiltonian_path.rs` -- Reference: `src/unit_tests/models/graph/maximum_independent_set.rs` - -**Step 1: Write the failing tests** - -Add tests that cover: -- construction and size getters (`num_vertices`, `num_edges`, `num_paths_required`, `max_length`) -- a valid YES instance using the 7-vertex issue example with `s = 0`, `t = 6`, `J = 2`, `K = 3` -- invalid configs for missing source/sink, disconnected selected vertices, overlong paths, and shared internal vertices across slots -- brute-force solver behavior on a small YES instance and a small NO instance -- serde round-trip -- the paper/example instance count of satisfying solutions - -Use the binary slot encoding directly in the test configs: the first `|V|` bits are slot 0, the next `|V|` bits are slot 1, etc. - -**Step 2: Run the new test target and verify RED** - -Run: -```bash -cargo test length_bounded_disjoint_paths --lib -``` - -Expected: compile or linkage failure because the model does not exist yet. - -**Step 3: Commit after green later** - -```bash -git add src/unit_tests/models/graph/length_bounded_disjoint_paths.rs -git commit -m "test: add LengthBoundedDisjointPaths model coverage" -``` - -### Task 2: Implement the model and make the tests pass - -**Files:** -- Create: `src/models/graph/length_bounded_disjoint_paths.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -**Step 1: Write the minimal model implementation** - -Implement: -- `ProblemSchemaEntry` with canonical name `LengthBoundedDisjointPaths` -- `LengthBoundedDisjointPaths` with fields `graph`, `source`, `sink`, `num_paths_required`, `max_length` -- inherent getters and `is_valid_solution()` -- `Problem` + `SatisfactionProblem` -- `dims() = vec![2; num_paths_required * num_vertices]` -- `evaluate()` that: - - partitions the config into `J` path slots - - validates each slot induces a connected simple `s-t` path - - rejects paths longer than `K` - - rejects reuse of any internal vertex across different slots -- `declare_variants!` with `default sat LengthBoundedDisjointPaths => "2^(num_paths_required * num_vertices)"` -- canonical example-db spec for the same small YES instance used in tests - -Keep helper functions private unless tests need them. - -**Step 2: Register the model exports** - -Wire the new file into: -- `src/models/graph/mod.rs` -- `src/models/mod.rs` -- `src/lib.rs` prelude / re-exports - -Also extend `canonical_model_example_specs()` in `src/models/graph/mod.rs`. - -**Step 3: Run the targeted tests and verify GREEN** - -Run: -```bash -cargo test length_bounded_disjoint_paths --lib -``` - -Expected: the new unit tests pass. - -**Step 4: Commit** - -```bash -git add src/models/graph/length_bounded_disjoint_paths.rs src/models/graph/mod.rs src/models/mod.rs src/lib.rs -git commit -m "feat: add LengthBoundedDisjointPaths model" -``` - -### Task 3: Add CLI creation support and trait consistency coverage - -**Files:** -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `src/unit_tests/trait_consistency.rs` - -**Step 1: Write the failing CLI/trait tests if coverage exists nearby, otherwise extend existing assertions first** - -Add the trait-consistency entry for a small instance immediately, then add CLI support for: -- direct creation from `--graph`, `--source`, `--sink`, `--num-paths-required`, `--bound` -- random creation with sane defaults (`source = 0`, `sink = n - 1`, `num_paths_required = 1`, `bound = n - 1` unless overridden) - -Update help text and `all_data_flags_empty()` for the new flags. - -**Step 2: Implement the CLI arm** - -Follow the `HamiltonianPath` / `OptimalLinearArrangement` patterns: -- extend `CreateArgs` -- add example/help text for `LengthBoundedDisjointPaths` -- parse required integers and build the model - -**Step 3: Run targeted verification** - -Run: -```bash -cargo test trait_consistency --lib -cargo test create --package problemreductions-cli -``` - -Expected: the trait consistency test stays green and CLI tests/build still pass for the touched areas. - -**Step 4: Commit** - -```bash -git add problemreductions-cli/src/cli.rs problemreductions-cli/src/commands/create.rs src/unit_tests/trait_consistency.rs -git commit -m "feat: wire LengthBoundedDisjointPaths into CLI" -``` - -### Task 4: Run focused example-db and fixture verification - -**Files:** -- Reference: `src/example_db/model_builders.rs` -- Reference: `src/unit_tests/example_db.rs` - -**Step 1: Verify the canonical example is exported correctly** - -Run: -```bash -cargo test example_db --lib --features example-db -``` - -Expected: the new model example is discoverable and structurally valid. - -**Step 2: If fixture/export regeneration is required, run the repo command that updates checked-in exports** - -Run the smallest repo-supported command needed after the model lands, then stage the generated changes if they are expected. - -**Step 3: Commit if generated exports changed** - -```bash -git add docs/src/reductions/problem_schemas.json docs/src/reductions/reduction_graph.json -git commit -m "chore: refresh generated model exports" -``` - -## Batch 2: Paper entry - -### Task 5: Add the paper definition and example after implementation is stable - -**Files:** -- Modify: `docs/paper/reductions.typ` - -**Step 1: Add the display name** - -Insert: -- `\"LengthBoundedDisjointPaths\": [Length-Bounded Disjoint Paths],` - -**Step 2: Add the `problem-def(...)` entry** - -Document: -- formal decision definition with `G`, `s`, `t`, `J`, `K` -- brief background and the Itai-Perl-Shiloach complexity threshold (`K >= 5` NP-complete, `K <= 4` polynomial) -- a small example that matches the canonical model example and explains the chosen satisfying configuration - -Use the same 7-vertex example as the model/example-db tests so the paper, tests, and exports stay aligned. - -**Step 3: Run paper and model verification** - -Run: -```bash -make paper -cargo test length_bounded_disjoint_paths --lib --features example-db -``` - -Expected: paper compiles and the paper/example-alignment test remains green. - -**Step 4: Commit** - -```bash -git add docs/paper/reductions.typ -git commit -m "docs: document LengthBoundedDisjointPaths" -``` - -## Final verification - -After both batches are done, run: - -```bash -make fmt -make check -``` - -If export files changed as part of verification, stage them before the final push. - -## Notes / constraints - -- Keep the public canonical problem name `LengthBoundedDisjointPaths`; do not reintroduce a `Maximum` prefix for the decision problem. -- The open inbound rule issue `#371` still uses stale wording (`MAXIMUM LENGTH-BOUNDED DISJOINT PATHS`). Treat that as a follow-up naming/tooling concern, not as justification to change the canonical model name in this PR. -- Keep the example small enough that brute force remains practical in unit tests. From 8b5a7914ce33551b0dd7d1fb4b0d4fc9546db7df Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 16:58:03 +0800 Subject: [PATCH 5/7] Fix CLI help for #298 --- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 57 +++++++++++++++++--- problemreductions-cli/src/problem_name.rs | 4 +- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index b7cbbe1d..146898ba 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -383,7 +383,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 or SCS) + /// Upper bound or length bound (for LengthBoundedDisjointPaths, OptimalLinearArrangement, RuralPostman, or SCS) #[arg(long)] 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 fa4ff8cb..f6354f66 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -271,25 +271,22 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { eprintln!("{}\n {}\n", canonical, s.description); eprintln!("Parameters:"); for field in &s.fields { + let flag_name = + problem_help_flag_name(canonical, &field.name, &field.type_name, is_geometry); // For geometry variants, show --positions instead of --graph if field.type_name == "G" && is_geometry { let hint = type_format_hint(&field.type_name, graph_type); - eprintln!(" --{:<16} {} ({hint})", "positions", field.description); + eprintln!(" --{:<16} {} ({hint})", flag_name, field.description); if graph_type == Some("UnitDiskGraph") { eprintln!(" --{:<16} Distance threshold [default: 1.0]", "radius"); } } else if field.type_name == "DirectedGraph" { // DirectedGraph fields use --arcs, not --graph let hint = type_format_hint(&field.type_name, graph_type); - eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } else { let hint = type_format_hint(&field.type_name, graph_type); - eprintln!( - " --{:<16} {} ({})", - field.name.replace('_', "-"), - field.description, - hint - ); + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } } } else { @@ -311,6 +308,24 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { Ok(()) } +fn problem_help_flag_name( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> String { + if field_type == "G" && is_geometry { + return "positions".to_string(); + } + if field_type == "DirectedGraph" { + return "arcs".to_string(); + } + if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { + return "bound".to_string(); + } + field_name.replace('_', "-") +} + /// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). fn resolved_graph_type(variant: &BTreeMap) -> &str { variant @@ -1841,3 +1856,29 @@ fn create_random( emit_problem_output(&output, out) } + +#[cfg(test)] +mod tests { + use super::problem_help_flag_name; + + #[test] + fn test_problem_help_uses_bound_for_length_bounded_disjoint_paths() { + assert_eq!( + problem_help_flag_name("LengthBoundedDisjointPaths", "max_length", "usize", false), + "bound" + ); + } + + #[test] + fn test_problem_help_preserves_generic_field_kebab_case() { + assert_eq!( + problem_help_flag_name( + "LengthBoundedDisjointPaths", + "num_paths_required", + "usize", + false, + ), + "num-paths-required" + ); + } +} diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 2f7ee789..8750855c 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -237,8 +237,8 @@ fn edit_distance(a: &str, b: &str) -> usize { for (i, row) in dp.iter_mut().enumerate().take(n + 1) { row[0] = i; } - for j in 0..=m { - dp[0][j] = j; + for (j, value) in dp[0].iter_mut().enumerate().take(m + 1) { + *value = j; } for i in 1..=n { From 232b7c349d353d370219489a624c389761660528 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 18:54:44 +0800 Subject: [PATCH 6/7] fix: address PR #659 review feedback - validate LengthBoundedDisjointPaths CLI inputs consistently - add regression coverage for constructor and CLI edge cases - document the LengthBoundedDisjointPaths CLI flow and local run path --- README.md | 3 +- docs/src/cli.md | 27 ++++- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 88 +++++++++----- problemreductions-cli/tests/cli_tests.rs | 114 ++++++++++++++++++ .../graph/length_bounded_disjoint_paths.rs | 5 + .../graph/length_bounded_disjoint_paths.rs | 30 +++++ 7 files changed, 237 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 94d4cb1f..85f7d640 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Or build from source: ```bash git clone https://github.com/CodingThrust/problem-reductions cd problem-reductions -make cli # builds target/release/pred +cargo build -p problemreductions-cli --release # builds target/release/pred +cargo install --path problemreductions-cli # optional: installs `pred` to ~/.cargo/bin ``` See the [Getting Started](https://codingthrust.github.io/problem-reductions/getting-started.html) guide for usage examples, the reduction workflow, and [CLI usage](https://codingthrust.github.io/problem-reductions/cli.html). diff --git a/docs/src/cli.md b/docs/src/cli.md index 8166cfd6..eba08d5b 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -15,7 +15,8 @@ Or build from source: ```bash git clone https://github.com/CodingThrust/problem-reductions cd problem-reductions -make cli # builds target/release/pred +cargo build -p problemreductions-cli --release # builds target/release/pred +cargo install --path problemreductions-cli # optional: installs `pred` to ~/.cargo/bin ``` Verify the installation: @@ -24,6 +25,12 @@ Verify the installation: pred --version ``` +For a workspace-local run without installing globally, use: + +```bash +cargo run -p problemreductions-cli --bin pred -- --version +``` + ### ILP Backend The default ILP backend is HiGHS. To use a different backend: @@ -48,6 +55,9 @@ pred create MIS --graph 0-1,1-2,2-3 --weights 3,1,2,1 -o weighted.json # Create a Steiner Tree instance pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json +# Create a Length-Bounded Disjoint Paths instance +pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3 -o lbdp.json + # Or start from a canonical model example pred create --example MIS/SimpleGraph/i32 -o example.json @@ -57,12 +67,18 @@ pred create --example MVC/SimpleGraph/i32 --to MIS/SimpleGraph/i32 -o example.js # Inspect what's inside a problem file pred inspect problem.json +# Inspect the new path problem +pred inspect lbdp.json + # Solve it (auto-reduces to ILP) pred solve problem.json # Or solve with brute-force pred solve problem.json --solver brute-force +# LengthBoundedDisjointPaths currently needs brute-force +pred solve lbdp.json --solver brute-force + # Evaluate a specific configuration (shows Valid(N) or Invalid) pred evaluate problem.json --config 1,0,1,0 @@ -276,12 +292,16 @@ pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json pred create SteinerTree --graph 0-1,0-3,1-2,1-3,2-3,2-4,3-4 --edge-weights 2,5,2,1,5,6,1 --terminals 0,2,4 -o steiner.json +pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --num-paths-required 2 --bound 3 -o lbdp.json pred create Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json pred create Factoring --target 21 --bits-m 3 --bits-n 3 -o factoring2.json pred create X3C --universe 9 --sets "0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8" -o x3c.json pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3 --precedence-pairs "0>3,1>3,1>4,2>4" -o mts.json ``` +For `LengthBoundedDisjointPaths`, the CLI flag `--bound` maps to the JSON field +`max_length`. + Canonical examples are useful when you want a known-good instance from the paper/example database. For model examples, `pred create --example ` emits the canonical instance for that graph node. @@ -418,8 +438,9 @@ 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. -> Use `--solver brute-force` for these, or reduce to a problem that supports ILP first. +> `LengthBoundedDisjointPaths` does not currently have one, so use +> `pred solve lbdp.json --solver brute-force`. +> For other problems, use `pred path ILP` to check whether an ILP reduction path exists. ## Shell Completions diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 2ca1345f..45c9eab8 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -388,7 +388,7 @@ pub struct CreateArgs { #[arg(long)] pub required_edges: Option, /// Upper bound or length bound (for LengthBoundedDisjointPaths, OptimalLinearArrangement, RuralPostman, or SCS) - #[arg(long)] + #[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 0c6ef4ec..3d6957ae 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -328,6 +328,51 @@ fn problem_help_flag_name( field_name.replace('_', "-") } +fn lbdp_validation_error(message: &str, usage: Option<&str>) -> anyhow::Error { + match usage { + Some(usage) => anyhow::anyhow!("{message}\n\n{usage}"), + None => anyhow::anyhow!("{message}"), + } +} + +fn validate_length_bounded_disjoint_paths_args( + num_vertices: usize, + source: usize, + sink: usize, + num_paths_required: usize, + bound: i64, + usage: Option<&str>, +) -> Result { + let max_length = usize::try_from(bound).map_err(|_| { + lbdp_validation_error( + "--bound must be a nonnegative integer for LengthBoundedDisjointPaths", + usage, + ) + })?; + if source >= num_vertices || sink >= num_vertices { + return Err(lbdp_validation_error( + "--source and --sink must be valid graph vertices", + usage, + )); + } + if source == sink { + return Err(lbdp_validation_error( + "--source and --sink must be distinct", + usage, + )); + } + if num_paths_required == 0 { + return Err(lbdp_validation_error( + "--num-paths-required must be positive", + usage, + )); + } + if max_length == 0 { + return Err(lbdp_validation_error("--bound must be positive", usage)); + } + Ok(max_length) +} + /// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). fn resolved_graph_type(variant: &BTreeMap) -> &str { variant @@ -437,19 +482,14 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let bound = args.bound.ok_or_else(|| { anyhow::anyhow!("LengthBoundedDisjointPaths requires --bound\n\n{usage}") })?; - let max_length = usize::try_from(bound).map_err(|_| { - anyhow::anyhow!("--bound must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}") - })?; - - if source >= graph.num_vertices() || sink >= graph.num_vertices() { - bail!("--source and --sink must be valid graph vertices\n\n{usage}"); - } - if num_paths_required == 0 { - bail!("--num-paths-required must be positive\n\n{usage}"); - } - if max_length == 0 { - bail!("--bound must be positive\n\n{usage}"); - } + let max_length = validate_length_bounded_disjoint_paths_args( + graph.num_vertices(), + source, + sink, + num_paths_required, + bound, + Some(usage), + )?; ( ser(LengthBoundedDisjointPaths::new( @@ -1789,20 +1829,14 @@ fn create_random( let sink = args.sink.unwrap_or(num_vertices - 1); let num_paths_required = args.num_paths_required.unwrap_or(1); let bound = args.bound.unwrap_or((num_vertices - 1) as i64); - let max_length = usize::try_from(bound) - .map_err(|_| anyhow::anyhow!("--bound must be nonnegative"))?; - if source >= num_vertices || sink >= num_vertices { - bail!("--source and --sink must be valid graph vertices"); - } - if source == sink { - bail!("--source and --sink must be distinct"); - } - if num_paths_required == 0 { - bail!("--num-paths-required must be positive"); - } - if max_length == 0 { - bail!("--bound must be positive"); - } + let max_length = validate_length_bounded_disjoint_paths_args( + num_vertices, + source, + sink, + num_paths_required, + bound, + None, + )?; let variant = variant_map(&[("graph", "SimpleGraph")]); ( ser(LengthBoundedDisjointPaths::new( diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index d29ba247..3f3facbf 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1494,6 +1494,120 @@ fn test_create_kcoloring_missing_k() { assert!(stderr.contains("--k")); } +#[test] +fn test_create_length_bounded_disjoint_paths_rejects_equal_terminals() { + let output = pred() + .args([ + "create", + "LengthBoundedDisjointPaths", + "--graph", + "0-1,1-2", + "--source", + "0", + "--sink", + "0", + "--num-paths-required", + "1", + "--bound", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--source and --sink must be distinct"), + "expected user-facing validation error, got: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "create command should reject equal terminals without panicking: {stderr}" + ); +} + +#[test] +fn test_create_length_bounded_disjoint_paths_succeeds() { + let output = pred() + .args([ + "create", + "LengthBoundedDisjointPaths", + "--graph", + "0-1,1-3,0-2,2-3", + "--source", + "0", + "--sink", + "3", + "--num-paths-required", + "2", + "--bound", + "2", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "LengthBoundedDisjointPaths"); + assert_eq!(json["data"]["source"], 0); + assert_eq!(json["data"]["sink"], 3); + assert_eq!(json["data"]["num_paths_required"], 2); + assert_eq!(json["data"]["max_length"], 2); +} + +#[test] +fn test_create_length_bounded_disjoint_paths_rejects_negative_bound_value() { + let output = pred() + .args([ + "create", + "LengthBoundedDisjointPaths", + "--graph", + "0-1,1-2", + "--source", + "0", + "--sink", + "1", + "--num-paths-required", + "1", + "--bound", + "-1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--bound must be a nonnegative integer for LengthBoundedDisjointPaths"), + "expected user-facing negative-bound error, got: {stderr}" + ); +} + +#[test] +fn test_create_random_length_bounded_disjoint_paths_rejects_negative_bound_value() { + let output = pred() + .args([ + "create", + "LengthBoundedDisjointPaths", + "--random", + "--num-vertices", + "3", + "--seed", + "7", + "--bound=-1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--bound must be a nonnegative integer for LengthBoundedDisjointPaths"), + "expected shared negative-bound validation, got: {stderr}" + ); +} + #[test] fn test_evaluate_wrong_config_length() { let problem_file = std::env::temp_dir().join("pred_test_eval_wrong_len.json"); diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 618a90ae..46263868 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -47,6 +47,11 @@ pub struct LengthBoundedDisjointPaths { impl LengthBoundedDisjointPaths { /// Create a new Length-Bounded Disjoint Paths instance. + /// + /// # Panics + /// + /// Panics if `source` or `sink` is not a valid graph vertex, if `source == + /// sink`, if `num_paths_required == 0`, or if `max_length == 0`. pub fn new( graph: G, source: usize, diff --git a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs index 48787b26..e734af10 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -51,6 +51,36 @@ fn test_length_bounded_disjoint_paths_allows_large_bounds() { assert!(problem.evaluate(&config)); } +#[test] +#[should_panic(expected = "source must be a valid graph vertex")] +fn test_length_bounded_disjoint_paths_creation_rejects_invalid_source() { + let _ = LengthBoundedDisjointPaths::new(sample_yes_graph(), 7, 6, 2, 3); +} + +#[test] +#[should_panic(expected = "sink must be a valid graph vertex")] +fn test_length_bounded_disjoint_paths_creation_rejects_invalid_sink() { + let _ = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 7, 2, 3); +} + +#[test] +#[should_panic(expected = "source and sink must be distinct")] +fn test_length_bounded_disjoint_paths_creation_rejects_equal_terminals() { + let _ = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 0, 2, 3); +} + +#[test] +#[should_panic(expected = "num_paths_required must be positive")] +fn test_length_bounded_disjoint_paths_creation_rejects_zero_paths() { + let _ = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 6, 0, 3); +} + +#[test] +#[should_panic(expected = "max_length must be positive")] +fn test_length_bounded_disjoint_paths_creation_rejects_zero_bound() { + let _ = LengthBoundedDisjointPaths::new(sample_yes_graph(), 0, 6, 2, 0); +} + #[test] fn test_length_bounded_disjoint_paths_evaluation() { let problem = sample_yes_problem(); From ca9a605a46656c0f8ac59a9f89d2ce754821a97b Mon Sep 17 00:00:00 2001 From: zazabap Date: Mon, 16 Mar 2026 13:01:29 +0000 Subject: [PATCH 7/7] fix: CLI source==sink validation, coverage tests, revert unrelated README changes Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 +-- .../graph/length_bounded_disjoint_paths.rs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85f7d640..94d4cb1f 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,7 @@ Or build from source: ```bash git clone https://github.com/CodingThrust/problem-reductions cd problem-reductions -cargo build -p problemreductions-cli --release # builds target/release/pred -cargo install --path problemreductions-cli # optional: installs `pred` to ~/.cargo/bin +make cli # builds target/release/pred ``` See the [Getting Started](https://codingthrust.github.io/problem-reductions/getting-started.html) guide for usage examples, the reduction workflow, and [CLI usage](https://codingthrust.github.io/problem-reductions/cli.html). diff --git a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs index e734af10..f6a891e4 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -153,6 +153,32 @@ fn test_length_bounded_disjoint_paths_serialization() { assert_eq!(round_trip.max_length(), 3); } +#[test] +fn test_length_bounded_disjoint_paths_graph_getter() { + let problem = sample_yes_problem(); + assert_eq!(problem.graph().num_vertices(), 7); + assert_eq!(problem.graph().num_edges(), 8); +} + +#[test] +fn test_length_bounded_disjoint_paths_num_variables() { + let problem = sample_yes_problem(); + assert_eq!(problem.num_variables(), 14); +} + +#[test] +fn test_length_bounded_disjoint_paths_is_valid_solution() { + let problem = sample_yes_problem(); + let config = encode_paths(7, &[&[0, 1, 6], &[0, 2, 3, 6]]); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_length_bounded_disjoint_paths_rejects_wrong_length_config() { + let problem = sample_yes_problem(); + assert!(!problem.evaluate(&[0, 1, 0])); +} + #[test] fn test_length_bounded_disjoint_paths_paper_example() { let problem = sample_yes_problem();