From 2cf7d653115e61ce61c3b510b1f8ee218a4b11a8 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 09:28:04 +0800 Subject: [PATCH 1/4] Add plan for #297: [Model] DisjointConnectingPaths --- ...6-03-22-disjoint-connecting-paths-model.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/plans/2026-03-22-disjoint-connecting-paths-model.md diff --git a/docs/plans/2026-03-22-disjoint-connecting-paths-model.md b/docs/plans/2026-03-22-disjoint-connecting-paths-model.md new file mode 100644 index 000000000..deecccf9d --- /dev/null +++ b/docs/plans/2026-03-22-disjoint-connecting-paths-model.md @@ -0,0 +1,167 @@ +# Plan: Add DisjointConnectingPaths Model + +**Issue:** #297 — [Model] DisjointConnectingPaths +**Skill:** add-model +**Execution:** superpowers:subagent-driven-development + +## Information Checklist + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `DisjointConnectingPaths` | +| 2 | Mathematical definition | Given an undirected graph `G = (V, E)` and pairwise disjoint terminal pairs `(s_1, t_1), ..., (s_k, t_k)`, determine whether `G` contains `k` mutually vertex-disjoint paths, one connecting each pair | +| 3 | Problem type | Satisfaction (`Metric = bool`) | +| 4 | Type parameters | `G: Graph` | +| 5 | Struct fields | `graph: G`, `terminal_pairs: Vec<(usize, usize)>` | +| 6 | Configuration space | `vec![2; num_edges]` — one binary variable per edge in a canonical sorted edge list | +| 7 | Feasibility check | Selected edges must induce exactly `k` pairwise vertex-disjoint simple paths whose endpoint pairs match the requested terminal pairs and whose internal vertices are non-terminals | +| 8 | Objective function | `bool` — `true` iff the selected edge subset realizes all terminal pairs simultaneously | +| 9 | Best known exact algorithm | Brute-force over all edge subsets; complexity string: `"2^num_edges"` | +| 10 | Solving strategy | Existing `BruteForce` solver is sufficient for the model; no ILP rule is required in this issue because brute-force already gives a valid solver path | +| 11 | Category | `graph` | +| 12 | Expected outcome from the issue | YES example: edges `{0,1}, {1,3}, {2,4}, {4,5}` connect `(0,3)` and `(2,5)` with vertex-disjoint paths; NO example: pairs `(0,4)` and `(1,5)` cannot be routed disjointly through the cut vertices | + +## Associated Rule Check + +- Planned inbound rule already exists in the issue body: `3SAT -> DisjointConnectingPaths`. +- No orphan-model warning is needed. + +## Design Decisions + +### Canonical edge order + +The issue explicitly fixes an edge-based encoding. To make that encoding deterministic across serialization, example-db fixtures, CLI evaluation, and tests, the model will define a helper that normalizes each undirected edge to `(min(u,v), max(u,v))` and sorts the list lexicographically. Config index `i` refers to the `i`-th edge in that canonical order, not to `petgraph`'s internal storage order. + +### Validity semantics + +`evaluate(config)` should accept a config iff: + +1. `config.len() == num_edges` and every entry is binary. +2. The selected-edge subgraph decomposes into exactly `num_pairs()` connected components. +3. Every selected component is a simple path: + - endpoints have degree 1, + - internal vertices have degree 2, + - the component is connected and acyclic. +4. The endpoints of each component match one requested terminal pair (in either orientation). +5. No terminal vertex is used as an internal path vertex, and every requested pair is realized exactly once. + +### CLI shape + +Add a dedicated `--terminal-pairs` flag using `u-v,u-v,...` syntax, with validation that: + +- every vertex index exists in the graph, +- every pair uses distinct endpoints, +- no vertex appears in more than one pair. + +This keeps the CLI aligned with the schema field name instead of overloading the existing `--terminals` flag. + +### Canonical example + +Use the issue's repaired YES instance as the canonical example: + +- graph edges: `(0,1), (1,3), (0,2), (1,4), (2,4), (3,5), (4,5)` +- terminal pairs: `(0,3), (2,5)` +- satisfying config over sorted edges: `[1, 0, 1, 0, 1, 0, 1]` + +Also keep the issue's NO instance in unit tests to pin down the cut-vertex failure mode. + +## Batch 1: Implementation and Registration + +### Step 1: Implement the model + +**Files:** +- Create: `src/models/graph/disjoint_connecting_paths.rs` +- Create: `src/unit_tests/models/graph/disjoint_connecting_paths.rs` +- Reference: `src/models/graph/length_bounded_disjoint_paths.rs` +- Reference: `src/models/graph/hamiltonian_path.rs` + +Work items: + +1. Add `ProblemSchemaEntry` metadata with graph-only variants and constructor-facing fields `graph` and `terminal_pairs`. +2. Implement `DisjointConnectingPaths` with constructor validation, accessors, size getters, `is_valid_solution`, and a canonical-edge helper. +3. Implement `Problem` and `SatisfactionProblem`, with `dims()` based on `num_edges()` and `evaluate()` using the path-component validation logic above. +4. Add `declare_variants!` for `DisjointConnectingPaths` with complexity `"2^num_edges"`. +5. Add `canonical_model_example_specs()` using the repaired YES instance from the issue. +6. Write tests first for: + - constructor validation, + - YES instance, + - NO/cut-vertex instance, + - malformed configs, + - brute-force solver, + - serialization, + - canonical paper/example instance. + +### Step 2: Register the model + +**Files:** +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +Work items: + +1. Add the new module and public re-export in the graph model tree. +2. Add top-level exports so the type is reachable from `problemreductions::models::*` and the prelude. +3. Extend the graph example-db aggregation chain. + +### Step 3: Add CLI discovery and creation support + +**Files:** +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/src/problem_name.rs` + +Work items: + +1. Add `DisjointConnectingPaths` to the imported graph model list. +2. Add `--terminal-pairs` to `CreateArgs`, `all_data_flags_empty()`, the create help table, and any field-to-flag mapping helpers. +3. Add a parser/validator for terminal-pair lists. +4. Add the `create()` match arm for `pred create DisjointConnectingPaths --graph ... --terminal-pairs ...`. +5. Add a canonical example string and any parser-focused CLI tests needed for the new flag. +6. Add alias resolution for the full lowercase name only; do not invent a short alias. + +### Step 4: Verify Batch 1 + +Run targeted commands while implementing, then finish with: + +```bash +cargo test disjoint_connecting_paths --lib +cargo test create_disjoint_connecting_paths --package problemreductions-cli +make test +make clippy +``` + +## Batch 2: Paper and Fixture Integration + +### Step 5: Document the model in the paper + +**Files:** +- Modify: `docs/paper/reductions.typ` +- Modify: `docs/paper/references.bib` (only if the Robertson--Seymour / Kawarabayashi--Kobayashi--Reed citations are not already present) + +Work items: + +1. Add `"DisjointConnectingPaths": [Disjoint Connecting Paths]` to the display-name map. +2. Add a `problem-def("DisjointConnectingPaths")` entry with: + - the formal definition from the issue, + - background linking the problem to routing / VLSI, + - NP-completeness context from Garey & Johnson / Karp, + - fixed-`k` polynomial-time results with citations to Robertson--Seymour and Kawarabayashi--Kobayashi--Reed, + - the repaired YES example rendered with a graph figure and the selected edge subset, + - `pred-commands()` driven by the canonical example-db entry. +3. Rebuild the paper/example exports and verify the example matches the issue's expected outcome. + +### Step 6: Verify Batch 2 + +```bash +make paper +make test +make clippy +``` + +## Exit Criteria + +- `DisjointConnectingPaths` is registered, exported, serializable, and solvable by brute force. +- `pred create DisjointConnectingPaths --graph ... --terminal-pairs ...` works. +- The canonical example-db entry and paper example use the issue's repaired YES instance. +- Tests cover both the YES and NO issue instances. From 4e555d6e48b03c940e23d054b1ba063a7f9e11e9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 09:42:43 +0800 Subject: [PATCH 2/4] Implement #297: [Model] DisjointConnectingPaths --- docs/paper/reductions.typ | 61 ++++ docs/paper/references.bib | 22 ++ problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 118 +++++++- src/lib.rs | 8 +- src/models/graph/disjoint_connecting_paths.rs | 265 ++++++++++++++++++ src/models/graph/mod.rs | 4 + src/models/mod.rs | 18 +- .../models/graph/disjoint_connecting_paths.rs | 109 +++++++ 9 files changed, 592 insertions(+), 17 deletions(-) create mode 100644 src/models/graph/disjoint_connecting_paths.rs create mode 100644 src/unit_tests/models/graph/disjoint_connecting_paths.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 9167a0c15..716fb8d9e 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -116,6 +116,7 @@ "ConsistencyOfDatabaseFrequencyTables": [Consistency of Database Frequency Tables], "ClosestVectorProblem": [Closest Vector Problem], "ConsecutiveSets": [Consecutive Sets], + "DisjointConnectingPaths": [Disjoint Connecting Paths], "MinimumMultiwayCut": [Minimum Multiway Cut], "OptimalLinearArrangement": [Optimal Linear Arrangement], "RuralPostman": [Rural Postman], @@ -956,6 +957,66 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("DisjointConnectingPaths") + let nv = graph-num-vertices(x.instance) + let ne = graph-num-edges(x.instance) + let chosen-edges = ((0, 1), (1, 3), (2, 4), (4, 5)) + [ + #problem-def("DisjointConnectingPaths")[ + Given an undirected graph $G = (V, E)$ and pairwise disjoint terminal pairs $(s_1, t_1), dots, (s_k, t_k)$, determine whether $G$ contains $k$ mutually vertex-disjoint paths such that path $P_i$ joins $s_i$ to $t_i$ for every $i$. + ][ + Disjoint Connecting Paths is the classical routing form of the vertex-disjoint paths problem, catalogued as ND40 in Garey & Johnson @garey1979. When the number of terminal pairs $k$ is part of the input, the problem is NP-complete @karp1972. In contrast, for every fixed $k$, Robertson and Seymour give an $O(n^3)$ algorithm @robertsonSeymour1995, and Kawarabayashi, Kobayashi, and Reed later improve the dependence on $n$ to $O(n^2)$ @kawarabayashiKobayashiReed2012. The implementation in this crate uses one binary variable per undirected edge, so brute-force search explores an $O^*(2^|E|)$ configuration space.#footnote[This is the exact-search bound induced by the edge-subset encoding implemented in the codebase; no sharper general exact worst-case bound is claimed here.] + + *Example.* Consider the repaired YES instance with $n = #nv$ vertices, $|E| = #ne$ edges, and terminal pairs $(v_0, v_3)$ and $(v_2, v_5)$. Selecting the edges $v_0v_1$, $v_1v_3$, $v_2v_4$, and $v_4v_5$ yields the two vertex-disjoint paths $v_0 arrow v_1 arrow v_3$ and $v_2 arrow v_4 arrow v_5$, so the instance is satisfying. + + #pred-commands( + "pred create --example DisjointConnectingPaths -o disjoint-connecting-paths.json", + "pred solve disjoint-connecting-paths.json", + "pred evaluate disjoint-connecting-paths.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + let blue = graph-colors.at(0) + let gray = luma(180) + let verts = ( + (0, 1.2), + (1.4, 1.2), + (0, 0), + (2.8, 1.2), + (1.4, 0), + (2.8, 0), + ) + let edges = ((0, 1), (1, 3), (0, 2), (1, 4), (2, 4), (3, 5), (4, 5)) + 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 terminal = k == 0 or k == 2 or k == 3 or k == 5 + g-node(pos, name: "v" + str(k), + fill: if terminal { blue } else { white }, + label: if terminal { + text(fill: white)[ + #if k == 0 { $s_1$ } + else if k == 3 { $t_1$ } + else if k == 2 { $s_2$ } + else { $t_2$ } + ] + } else [ + $v_#k$ + ]) + } + }), + caption: [A satisfying Disjoint Connecting Paths instance with terminal pairs $(v_0, v_3)$ and $(v_2, v_5)$. The highlighted edges form the vertex-disjoint paths $v_0 arrow v_1 arrow v_3$ and $v_2 arrow v_4 arrow v_5$.], + ) + ] + ] +} #{ let x = load-model-example("GeneralizedHex") let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index f24f1a092..71e9545e8 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -180,6 +180,28 @@ @book{garey1979 year = {1979} } +@article{robertsonSeymour1995, + author = {Neil Robertson and P. D. Seymour}, + title = {Graph Minors. XIII. The Disjoint Paths Problem}, + journal = {Journal of Combinatorial Theory, Series B}, + volume = {63}, + number = {1}, + pages = {65--110}, + year = {1995}, + doi = {10.1006/jctb.1995.1006} +} + +@article{kawarabayashiKobayashiReed2012, + author = {Ken-ichi Kawarabayashi and Yusuke Kobayashi and Bruce Reed}, + title = {The disjoint paths problem in quadratic time}, + journal = {Journal of Combinatorial Theory, Series B}, + volume = {102}, + number = {2}, + pages = {424--435}, + year = {2012}, + doi = {10.1016/j.jctb.2011.07.004} +} + @article{bruckerGareyJohnson1977, author = {Peter Brucker and Michael R. Garey and David S. Johnson}, title = {Scheduling equal-length tasks under tree-like precedence constraints to minimize maximum lateness}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 02df552d7..7e7c94ecf 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -234,6 +234,7 @@ Flags by problem type: LongestCircuit --graph, --edge-weights, --bound BoundedComponentSpanningForest --graph, --weights, --k, --bound UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 + DisjointConnectingPaths --graph, --terminal-pairs IsomorphicSpanningTree --graph, --tree KthBestSpanningTree --graph, --edge-weights, --k, --bound LengthBoundedDisjointPaths --graph, --source, --sink, --num-paths-required, --bound @@ -495,6 +496,9 @@ pub struct CreateArgs { /// Terminal vertices for SteinerTree or MinimumMultiwayCut (comma-separated indices, e.g., "0,2,4") #[arg(long)] pub terminals: Option, + /// Terminal pairs for DisjointConnectingPaths (comma-separated pairs, e.g., "0-3,2-5") + #[arg(long = "terminal-pairs")] + pub terminal_pairs: Option, /// Tree edge list for IsomorphicSpanningTree (e.g., 0-1,1-2,2-3) #[arg(long)] pub tree: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index d03de0341..3f7d5253f 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -12,10 +12,10 @@ use problemreductions::models::algebraic::{ }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ - GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut, - MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, - StrongConnectivityAugmentation, + DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, + HamiltonianPath, LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, + MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree, + SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -96,6 +96,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.deadlines.is_none() && args.lengths.is_none() && args.terminals.is_none() + && args.terminal_pairs.is_none() && args.tree.is_none() && args.required_edges.is_none() && args.bound.is_none() @@ -523,6 +524,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "UndirectedTwoCommodityIntegralFlow" => { "--graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1" }, + "DisjointConnectingPaths" => { + "--graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5" + } "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" } @@ -737,6 +741,7 @@ fn help_flag_hint( match (canonical, field_name) { ("BoundedComponentSpanningForest", "max_weight") => "integer", ("SequencingWithinIntervals", "release_times") => "comma-separated integers: 0,0,5", + ("DisjointConnectingPaths", "terminal_pairs") => "comma-separated pairs: 0-3,2-5", ("PrimeAttributeName", "dependencies") => { "semicolon-separated dependencies: \"0,1>2,3;2,3>0,1\"" } @@ -1116,6 +1121,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // DisjointConnectingPaths (graph + terminal pairs) + "DisjointConnectingPaths" => { + let usage = + "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let terminal_pairs = parse_terminal_pairs(args, graph.num_vertices()) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + ( + ser(DisjointConnectingPaths::new(graph, terminal_pairs))?, + resolved_variant.clone(), + ) + } + // Minimum cut into bounded sets (graph + edge weights + s/t/B/K) "MinimumCutIntoBoundedSets" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -4126,6 +4144,41 @@ fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result> Ok(terminals) } +/// Parse `--terminal-pairs` as comma-separated `u-v` vertex pairs. +fn parse_terminal_pairs(args: &CreateArgs, num_vertices: usize) -> Result> { + let raw = args + .terminal_pairs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--terminal-pairs required (e.g., \"0-3,2-5\")"))?; + let terminal_pairs = util::parse_edge_pairs(raw)?; + anyhow::ensure!( + !terminal_pairs.is_empty(), + "at least 1 terminal pair required" + ); + + let mut used = BTreeSet::new(); + for &(source, sink) in &terminal_pairs { + anyhow::ensure!( + source < num_vertices, + "terminal pair source {source} >= num_vertices ({num_vertices})" + ); + anyhow::ensure!( + sink < num_vertices, + "terminal pair sink {sink} >= num_vertices ({num_vertices})" + ); + anyhow::ensure!( + source != sink, + "terminal pair endpoints must be distinct" + ); + anyhow::ensure!( + used.insert(source) && used.insert(sink), + "terminal vertices must be pairwise disjoint across terminal pairs" + ); + } + + Ok(terminal_pairs) +} + fn ensure_positive_i32_values(values: &[i32], label: &str) -> Result<()> { if values.iter().any(|&value| value <= 0) { bail!("All {label} must be positive (> 0)"); @@ -5917,6 +5970,7 @@ mod tests { release_times: None, lengths: None, terminals: None, + terminal_pairs: None, tree: None, required_edges: None, bound: None, @@ -6016,6 +6070,62 @@ mod tests { assert_eq!(parse_budget(&args).unwrap(), 7); } + #[test] + fn test_create_disjoint_connecting_paths_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::DisjointConnectingPaths; + + let mut args = empty_args(); + args.problem = Some("DisjointConnectingPaths".to_string()); + args.graph = Some("0-1,1-3,0-2,1-4,2-4,3-5,4-5".to_string()); + args.terminal_pairs = Some("0-3,2-5".to_string()); + + let output_path = + std::env::temp_dir().join(format!("dcp-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "DisjointConnectingPaths"); + assert_eq!( + created.variant, + BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]) + ); + + let problem: DisjointConnectingPaths = + serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.terminal_pairs(), &[(0, 3), (2, 5)]); + + let _ = std::fs::remove_file(output_path); + } + + #[test] + fn test_create_disjoint_connecting_paths_rejects_overlapping_terminal_pairs() { + let mut args = empty_args(); + args.problem = Some("DisjointConnectingPaths".to_string()); + args.graph = Some("0-1,1-2,2-3,3-4".to_string()); + args.terminal_pairs = Some("0-2,2-4".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("pairwise disjoint")); + } + #[test] fn test_parse_graph_respects_explicit_num_vertices() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index f9e84dca0..2cf33d22d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,10 +50,10 @@ pub mod prelude { pub use crate::models::graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest, - DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, - HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, - LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree, - StrongConnectivityAugmentation, SubgraphIsomorphism, + DirectedTwoCommodityIntegralFlow, DisjointConnectingPaths, GeneralizedHex, + GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, + KthBestSpanningTree, LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, + SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, LongestCircuit, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs new file mode 100644 index 000000000..dafcba9f5 --- /dev/null +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -0,0 +1,265 @@ +//! Disjoint Connecting Paths problem implementation. +//! +//! The problem asks whether an undirected graph contains pairwise +//! vertex-disjoint paths connecting a prescribed collection of terminal pairs. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::variant::VariantParam; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +inventory::submit! { + ProblemSchemaEntry { + name: "DisjointConnectingPaths", + display_name: "Disjoint Connecting Paths", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], + module_path: module_path!(), + description: "Find pairwise vertex-disjoint paths connecting given terminal pairs", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "terminal_pairs", type_name: "Vec<(usize, usize)>", description: "Disjoint terminal pairs (s_i, t_i)" }, + ], + } +} + +/// Disjoint Connecting Paths on an undirected graph. +/// +/// A configuration uses one binary variable per edge in the graph's canonical +/// sorted edge list. A valid solution selects exactly the edges of one simple +/// path for each terminal pair, with all such paths pairwise vertex-disjoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +pub struct DisjointConnectingPaths { + graph: G, + terminal_pairs: Vec<(usize, usize)>, +} + +impl DisjointConnectingPaths { + /// Create a new Disjoint Connecting Paths instance. + /// + /// # Panics + /// + /// Panics if no terminal pairs are provided, if a pair uses invalid or + /// repeated endpoints, or if any terminal appears in more than one pair. + pub fn new(graph: G, terminal_pairs: Vec<(usize, usize)>) -> Self { + assert!( + !terminal_pairs.is_empty(), + "terminal_pairs must contain at least one pair" + ); + + let num_vertices = graph.num_vertices(); + let mut used = vec![false; num_vertices]; + for &(source, sink) in &terminal_pairs { + assert!(source < num_vertices, "terminal pair source out of bounds"); + assert!(sink < num_vertices, "terminal pair sink out of bounds"); + assert_ne!(source, sink, "terminal pair endpoints must be distinct"); + assert!( + !used[source], + "terminal vertices must be pairwise disjoint across pairs" + ); + assert!( + !used[sink], + "terminal vertices must be pairwise disjoint across pairs" + ); + used[source] = true; + used[sink] = true; + } + + Self { + graph, + terminal_pairs, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the terminal pairs. + pub fn terminal_pairs(&self) -> &[(usize, usize)] { + &self.terminal_pairs + } + + /// 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() + } + + /// Get the number of terminal pairs. + pub fn num_pairs(&self) -> usize { + self.terminal_pairs.len() + } + + /// Return the canonical lexicographically sorted undirected edge list. + pub fn ordered_edges(&self) -> Vec<(usize, usize)> { + canonical_edges(&self.graph) + } + + /// Check whether a configuration is a valid solution. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_disjoint_connecting_paths(&self.graph, &self.terminal_pairs, config) + } +} + +impl Problem for DisjointConnectingPaths +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "DisjointConnectingPaths"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![2; self.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for DisjointConnectingPaths {} + +fn canonical_edges(graph: &G) -> Vec<(usize, usize)> { + let mut edges = graph + .edges() + .into_iter() + .map(|(u, v)| if u <= v { (u, v) } else { (v, u) }) + .collect::>(); + edges.sort_unstable(); + edges +} + +fn normalize_edge(u: usize, v: usize) -> (usize, usize) { + if u <= v { (u, v) } else { (v, u) } +} + +fn is_valid_disjoint_connecting_paths( + graph: &G, + terminal_pairs: &[(usize, usize)], + config: &[usize], +) -> bool { + let edges = canonical_edges(graph); + if config.len() != edges.len() { + return false; + } + if config.iter().any(|&value| value > 1) { + return false; + } + + let num_vertices = graph.num_vertices(); + let mut adjacency = vec![Vec::new(); num_vertices]; + let mut degrees = vec![0usize; num_vertices]; + for (index, &chosen) in config.iter().enumerate() { + if chosen == 1 { + let (u, v) = edges[index]; + adjacency[u].push(v); + adjacency[v].push(u); + degrees[u] += 1; + degrees[v] += 1; + } + } + + let mut terminal_vertices = vec![false; num_vertices]; + let required_pairs = terminal_pairs + .iter() + .map(|&(u, v)| { + terminal_vertices[u] = true; + terminal_vertices[v] = true; + normalize_edge(u, v) + }) + .collect::>(); + let mut matched_pairs = BTreeSet::new(); + let mut visited = vec![false; num_vertices]; + let mut component_count = 0usize; + + for start in 0..num_vertices { + if degrees[start] == 0 || visited[start] { + continue; + } + + component_count += 1; + let mut stack = vec![start]; + let mut vertices = Vec::new(); + let mut degree_sum = 0usize; + visited[start] = true; + + while let Some(vertex) = stack.pop() { + vertices.push(vertex); + degree_sum += degrees[vertex]; + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + stack.push(neighbor); + } + } + } + + let edge_count = degree_sum / 2; + if edge_count + 1 != vertices.len() { + return false; + } + + let mut endpoints = Vec::new(); + for &vertex in &vertices { + match degrees[vertex] { + 1 => endpoints.push(vertex), + 2 => { + if terminal_vertices[vertex] { + return false; + } + } + _ => return false, + } + } + + if endpoints.len() != 2 { + return false; + } + + let realized_pair = normalize_edge(endpoints[0], endpoints[1]); + if !required_pairs.contains(&realized_pair) || !matched_pairs.insert(realized_pair) { + return false; + } + } + + component_count == terminal_pairs.len() && matched_pairs.len() == terminal_pairs.len() +} + +crate::declare_variants! { + default sat DisjointConnectingPaths => "2^num_edges", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "disjoint_connecting_paths_simplegraph", + instance: Box::new(DisjointConnectingPaths::new( + SimpleGraph::new( + 6, + vec![(0, 1), (1, 3), (0, 2), (1, 4), (2, 4), (3, 5), (4, 5)], + ), + vec![(0, 3), (2, 5)], + )), + optimal_config: vec![1, 0, 1, 0, 1, 0, 1], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/disjoint_connecting_paths.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 06627fb70..b47a217d7 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -44,6 +44,7 @@ //! - [`DirectedTwoCommodityIntegralFlow`]: Directed two-commodity integral flow (satisfaction) //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs +//! - [`DisjointConnectingPaths`]: Vertex-disjoint paths connecting prescribed terminal pairs pub(crate) mod acyclic_partition; pub(crate) mod balanced_complete_bipartite_subgraph; @@ -52,6 +53,7 @@ pub(crate) mod biconnectivity_augmentation; pub(crate) mod bottleneck_traveling_salesman; pub(crate) mod bounded_component_spanning_forest; pub(crate) mod directed_two_commodity_integral_flow; +pub(crate) mod disjoint_connecting_paths; pub(crate) mod generalized_hex; pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_circuit; @@ -98,6 +100,7 @@ pub use biconnectivity_augmentation::BiconnectivityAugmentation; pub use bottleneck_traveling_salesman::BottleneckTravelingSalesman; pub use bounded_component_spanning_forest::BoundedComponentSpanningForest; pub use directed_two_commodity_integral_flow::DirectedTwoCommodityIntegralFlow; +pub use disjoint_connecting_paths::DisjointConnectingPaths; pub use generalized_hex::GeneralizedHex; pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_circuit::HamiltonianCircuit; @@ -177,6 +180,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec DisjointConnectingPaths { + DisjointConnectingPaths::new( + SimpleGraph::new( + 6, + vec![(0, 1), (1, 3), (0, 2), (1, 4), (2, 4), (3, 5), (4, 5)], + ), + vec![(0, 3), (2, 5)], + ) +} + +fn issue_yes_config() -> Vec { + vec![1, 0, 1, 0, 1, 0, 1] +} + +fn issue_no_problem() -> DisjointConnectingPaths { + DisjointConnectingPaths::new( + SimpleGraph::new(6, vec![(0, 2), (1, 2), (2, 3), (3, 4), (3, 5)]), + vec![(0, 4), (1, 5)], + ) +} + +#[test] +fn test_disjoint_connecting_paths_creation() { + let problem = issue_yes_problem(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.num_pairs(), 2); + assert_eq!(problem.terminal_pairs(), &[(0, 3), (2, 5)]); + assert_eq!(problem.dims(), vec![2; 7]); + assert_eq!( + problem.ordered_edges(), + vec![(0, 1), (0, 2), (1, 3), (1, 4), (2, 4), (3, 5), (4, 5)] + ); +} + +#[test] +#[should_panic(expected = "terminal_pairs must contain at least one pair")] +fn test_disjoint_connecting_paths_rejects_empty_pairs() { + let _ = DisjointConnectingPaths::new(SimpleGraph::new(2, vec![(0, 1)]), vec![]); +} + +#[test] +#[should_panic(expected = "terminal vertices must be pairwise disjoint across pairs")] +fn test_disjoint_connecting_paths_rejects_overlapping_terminals() { + let _ = DisjointConnectingPaths::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + vec![(0, 2), (2, 3)], + ); +} + +#[test] +fn test_disjoint_connecting_paths_yes_instance() { + let problem = issue_yes_problem(); + assert!(problem.evaluate(&issue_yes_config())); +} + +#[test] +fn test_disjoint_connecting_paths_no_instance() { + let problem = issue_no_problem(); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_disjoint_connecting_paths_rejects_wrong_length_config() { + let problem = issue_yes_problem(); + assert!(!problem.evaluate(&[1, 0, 1])); +} + +#[test] +fn test_disjoint_connecting_paths_rejects_non_binary_entries() { + let problem = issue_yes_problem(); + let mut config = issue_yes_config(); + config[3] = 2; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_disjoint_connecting_paths_rejects_branching_subgraph() { + let problem = issue_yes_problem(); + assert!(!problem.evaluate(&[1, 0, 1, 1, 1, 0, 1])); +} + +#[test] +fn test_disjoint_connecting_paths_serialization() { + let problem = issue_yes_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let round_trip: DisjointConnectingPaths = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip.num_vertices(), 6); + assert_eq!(round_trip.num_edges(), 7); + assert_eq!(round_trip.terminal_pairs(), &[(0, 3), (2, 5)]); +} + +#[test] +fn test_disjoint_connecting_paths_paper_example() { + let problem = issue_yes_problem(); + let config = issue_yes_config(); + assert!(problem.evaluate(&config)); + + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} From c8ced6ed41da006e7adea22f48a205c7d00a0c68 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 22 Mar 2026 09:42:51 +0800 Subject: [PATCH 3/4] chore: remove plan file after implementation --- ...6-03-22-disjoint-connecting-paths-model.md | 167 ------------------ 1 file changed, 167 deletions(-) delete mode 100644 docs/plans/2026-03-22-disjoint-connecting-paths-model.md diff --git a/docs/plans/2026-03-22-disjoint-connecting-paths-model.md b/docs/plans/2026-03-22-disjoint-connecting-paths-model.md deleted file mode 100644 index deecccf9d..000000000 --- a/docs/plans/2026-03-22-disjoint-connecting-paths-model.md +++ /dev/null @@ -1,167 +0,0 @@ -# Plan: Add DisjointConnectingPaths Model - -**Issue:** #297 — [Model] DisjointConnectingPaths -**Skill:** add-model -**Execution:** superpowers:subagent-driven-development - -## Information Checklist - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `DisjointConnectingPaths` | -| 2 | Mathematical definition | Given an undirected graph `G = (V, E)` and pairwise disjoint terminal pairs `(s_1, t_1), ..., (s_k, t_k)`, determine whether `G` contains `k` mutually vertex-disjoint paths, one connecting each pair | -| 3 | Problem type | Satisfaction (`Metric = bool`) | -| 4 | Type parameters | `G: Graph` | -| 5 | Struct fields | `graph: G`, `terminal_pairs: Vec<(usize, usize)>` | -| 6 | Configuration space | `vec![2; num_edges]` — one binary variable per edge in a canonical sorted edge list | -| 7 | Feasibility check | Selected edges must induce exactly `k` pairwise vertex-disjoint simple paths whose endpoint pairs match the requested terminal pairs and whose internal vertices are non-terminals | -| 8 | Objective function | `bool` — `true` iff the selected edge subset realizes all terminal pairs simultaneously | -| 9 | Best known exact algorithm | Brute-force over all edge subsets; complexity string: `"2^num_edges"` | -| 10 | Solving strategy | Existing `BruteForce` solver is sufficient for the model; no ILP rule is required in this issue because brute-force already gives a valid solver path | -| 11 | Category | `graph` | -| 12 | Expected outcome from the issue | YES example: edges `{0,1}, {1,3}, {2,4}, {4,5}` connect `(0,3)` and `(2,5)` with vertex-disjoint paths; NO example: pairs `(0,4)` and `(1,5)` cannot be routed disjointly through the cut vertices | - -## Associated Rule Check - -- Planned inbound rule already exists in the issue body: `3SAT -> DisjointConnectingPaths`. -- No orphan-model warning is needed. - -## Design Decisions - -### Canonical edge order - -The issue explicitly fixes an edge-based encoding. To make that encoding deterministic across serialization, example-db fixtures, CLI evaluation, and tests, the model will define a helper that normalizes each undirected edge to `(min(u,v), max(u,v))` and sorts the list lexicographically. Config index `i` refers to the `i`-th edge in that canonical order, not to `petgraph`'s internal storage order. - -### Validity semantics - -`evaluate(config)` should accept a config iff: - -1. `config.len() == num_edges` and every entry is binary. -2. The selected-edge subgraph decomposes into exactly `num_pairs()` connected components. -3. Every selected component is a simple path: - - endpoints have degree 1, - - internal vertices have degree 2, - - the component is connected and acyclic. -4. The endpoints of each component match one requested terminal pair (in either orientation). -5. No terminal vertex is used as an internal path vertex, and every requested pair is realized exactly once. - -### CLI shape - -Add a dedicated `--terminal-pairs` flag using `u-v,u-v,...` syntax, with validation that: - -- every vertex index exists in the graph, -- every pair uses distinct endpoints, -- no vertex appears in more than one pair. - -This keeps the CLI aligned with the schema field name instead of overloading the existing `--terminals` flag. - -### Canonical example - -Use the issue's repaired YES instance as the canonical example: - -- graph edges: `(0,1), (1,3), (0,2), (1,4), (2,4), (3,5), (4,5)` -- terminal pairs: `(0,3), (2,5)` -- satisfying config over sorted edges: `[1, 0, 1, 0, 1, 0, 1]` - -Also keep the issue's NO instance in unit tests to pin down the cut-vertex failure mode. - -## Batch 1: Implementation and Registration - -### Step 1: Implement the model - -**Files:** -- Create: `src/models/graph/disjoint_connecting_paths.rs` -- Create: `src/unit_tests/models/graph/disjoint_connecting_paths.rs` -- Reference: `src/models/graph/length_bounded_disjoint_paths.rs` -- Reference: `src/models/graph/hamiltonian_path.rs` - -Work items: - -1. Add `ProblemSchemaEntry` metadata with graph-only variants and constructor-facing fields `graph` and `terminal_pairs`. -2. Implement `DisjointConnectingPaths` with constructor validation, accessors, size getters, `is_valid_solution`, and a canonical-edge helper. -3. Implement `Problem` and `SatisfactionProblem`, with `dims()` based on `num_edges()` and `evaluate()` using the path-component validation logic above. -4. Add `declare_variants!` for `DisjointConnectingPaths` with complexity `"2^num_edges"`. -5. Add `canonical_model_example_specs()` using the repaired YES instance from the issue. -6. Write tests first for: - - constructor validation, - - YES instance, - - NO/cut-vertex instance, - - malformed configs, - - brute-force solver, - - serialization, - - canonical paper/example instance. - -### Step 2: Register the model - -**Files:** -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -Work items: - -1. Add the new module and public re-export in the graph model tree. -2. Add top-level exports so the type is reachable from `problemreductions::models::*` and the prelude. -3. Extend the graph example-db aggregation chain. - -### Step 3: Add CLI discovery and creation support - -**Files:** -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/src/problem_name.rs` - -Work items: - -1. Add `DisjointConnectingPaths` to the imported graph model list. -2. Add `--terminal-pairs` to `CreateArgs`, `all_data_flags_empty()`, the create help table, and any field-to-flag mapping helpers. -3. Add a parser/validator for terminal-pair lists. -4. Add the `create()` match arm for `pred create DisjointConnectingPaths --graph ... --terminal-pairs ...`. -5. Add a canonical example string and any parser-focused CLI tests needed for the new flag. -6. Add alias resolution for the full lowercase name only; do not invent a short alias. - -### Step 4: Verify Batch 1 - -Run targeted commands while implementing, then finish with: - -```bash -cargo test disjoint_connecting_paths --lib -cargo test create_disjoint_connecting_paths --package problemreductions-cli -make test -make clippy -``` - -## Batch 2: Paper and Fixture Integration - -### Step 5: Document the model in the paper - -**Files:** -- Modify: `docs/paper/reductions.typ` -- Modify: `docs/paper/references.bib` (only if the Robertson--Seymour / Kawarabayashi--Kobayashi--Reed citations are not already present) - -Work items: - -1. Add `"DisjointConnectingPaths": [Disjoint Connecting Paths]` to the display-name map. -2. Add a `problem-def("DisjointConnectingPaths")` entry with: - - the formal definition from the issue, - - background linking the problem to routing / VLSI, - - NP-completeness context from Garey & Johnson / Karp, - - fixed-`k` polynomial-time results with citations to Robertson--Seymour and Kawarabayashi--Kobayashi--Reed, - - the repaired YES example rendered with a graph figure and the selected edge subset, - - `pred-commands()` driven by the canonical example-db entry. -3. Rebuild the paper/example exports and verify the example matches the issue's expected outcome. - -### Step 6: Verify Batch 2 - -```bash -make paper -make test -make clippy -``` - -## Exit Criteria - -- `DisjointConnectingPaths` is registered, exported, serializable, and solvable by brute force. -- `pred create DisjointConnectingPaths --graph ... --terminal-pairs ...` works. -- The canonical example-db entry and paper example use the issue's repaired YES instance. -- Tests cover both the YES and NO issue instances. From 4870b4153a10a1fce934aa9df58e7879c1351ba1 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 22 Mar 2026 16:06:27 +0800 Subject: [PATCH 4/4] cargo fmt after merge --- problemreductions-cli/src/commands/create.rs | 5 +---- src/models/graph/disjoint_connecting_paths.rs | 6 +++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 661b2c3f6..005fbc713 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -4527,10 +4527,7 @@ fn parse_terminal_pairs(args: &CreateArgs, num_vertices: usize) -> Result= num_vertices ({num_vertices})" ); - anyhow::ensure!( - source != sink, - "terminal pair endpoints must be distinct" - ); + anyhow::ensure!(source != sink, "terminal pair endpoints must be distinct"); anyhow::ensure!( used.insert(source) && used.insert(sink), "terminal vertices must be pairwise disjoint across terminal pairs" diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index dafcba9f5..2f6bcebd6 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -145,7 +145,11 @@ fn canonical_edges(graph: &G) -> Vec<(usize, usize)> { } fn normalize_edge(u: usize, v: usize) -> (usize, usize) { - if u <= v { (u, v) } else { (v, u) } + if u <= v { + (u, v) + } else { + (v, u) + } } fn is_valid_disjoint_connecting_paths(