From e6064ca8865c5fa814bbb2c7973b6b758619c424 Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 12:30:43 +0000 Subject: [PATCH 1/5] Add plan for #227: PartitionIntoPathsOfLength2 Co-Authored-By: Claude Opus 4.6 --- ...-03-13-partition-into-paths-of-length-2.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/plans/2026-03-13-partition-into-paths-of-length-2.md diff --git a/docs/plans/2026-03-13-partition-into-paths-of-length-2.md b/docs/plans/2026-03-13-partition-into-paths-of-length-2.md new file mode 100644 index 00000000..07100aef --- /dev/null +++ b/docs/plans/2026-03-13-partition-into-paths-of-length-2.md @@ -0,0 +1,79 @@ +# Plan: Add PartitionIntoPathsOfLength2 Model + +**Issue:** #227 +**Type:** Model +**Skill:** add-model + +## Summary + +Add the PartitionIntoPathsOfLength2 satisfaction problem: given a graph G = (V, E) with |V| = 3q, determine whether V can be partitioned into q disjoint triples such that each triple induces at least 2 edges (i.e., a path of length 2 or a triangle). + +## Collected Information + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `PartitionIntoPathsOfLength2` | +| 2 | Mathematical definition | Given G=(V,E) with \|V\|=3q, partition V into q triples each inducing >= 2 edges | +| 3 | Problem type | Satisfaction (Metric = bool) | +| 4 | Type parameters | `G: Graph` (graph type parameter) | +| 5 | Struct fields | `graph: G` | +| 6 | Configuration space | `vec![q; n]` where q = n/3, n = num_vertices | +| 7 | Feasibility check | Each group has exactly 3 vertices, and each group induces >= 2 edges | +| 8 | Objective function | N/A (satisfaction: returns bool) | +| 9 | Best known exact algorithm | O(3^n) naive set-partition DP; no better exact algorithm known | +| 10 | Solving strategy | BruteForce works | +| 11 | Category | `graph` | + +## Steps + +### Step 1: Create model file +**File:** `src/models/graph/partition_into_paths_of_length_2.rs` + +- `inventory::submit!` for ProblemSchemaEntry with field `graph` +- Struct `PartitionIntoPathsOfLength2` with `graph: G` field +- Constructor `new(graph: G)` — panics if `num_vertices % 3 != 0` +- Getter methods: `graph()`, `num_vertices()`, `num_edges()` +- `evaluate()`: decode config as group assignments (each vertex gets value 0..q-1), check: + 1. Each group has exactly 3 members + 2. Each group induces at least 2 edges +- `Problem` impl: NAME = "PartitionIntoPathsOfLength2", Metric = bool, dims = `vec![q; n]`, variant = `variant_params![G]` +- `SatisfactionProblem` impl (marker trait) +- `declare_variants!`: `PartitionIntoPathsOfLength2 => "3^num_vertices"` + +### Step 2: Register the model +- `src/models/graph/mod.rs`: add `pub(crate) mod partition_into_paths_of_length_2;` and `pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2;` +- `src/models/mod.rs`: add `PartitionIntoPathsOfLength2` to the graph re-export line + +### Step 3: Register in CLI +- `problemreductions-cli/src/dispatch.rs`: + - `load_problem`: add `"PartitionIntoPathsOfLength2" => deser_sat::>(data)` + - `serialize_any_problem`: add `"PartitionIntoPathsOfLength2" => try_ser::>(any)` +- `problemreductions-cli/src/problem_name.rs`: + - Add `"partitionintopathsoflength2" => "PartitionIntoPathsOfLength2".to_string()` to `resolve_alias()` + +### Step 4: CLI creation support +- `problemreductions-cli/src/commands/create.rs`: + - Add match arm for `"PartitionIntoPathsOfLength2"` that parses `--graph` and constructs the problem + - No new CLI flags needed (just `--graph`) +- `problemreductions-cli/src/cli.rs`: + - Add entry to "Flags by problem type" table in `after_help` + - No new struct fields needed in `CreateArgs` + - No changes to `all_data_flags_empty()` + +### Step 5: Write unit tests +**File:** `src/unit_tests/models/graph/partition_into_paths_of_length_2.rs` + +- `test_partition_into_paths_basic`: create 9-vertex instance from issue example, verify dims, evaluate valid/invalid configs +- `test_partition_into_paths_no_solution`: create 6-vertex NO instance from issue, verify brute force finds no solution +- `test_partition_into_paths_solver`: verify brute force solver finds valid solutions on YES instance +- `test_partition_into_paths_serialization`: round-trip serde test +- `test_partition_into_paths_invalid_group_size`: verify configs where groups don't have exactly 3 members return false + +Link test file via `#[cfg(test)] #[path = "..."] mod tests;` at bottom of model file. + +### Step 6: Document in paper +- Add `"PartitionIntoPathsOfLength2": [Partition into Paths of Length 2]` to `display-name` dict in `docs/paper/reductions.typ` +- Add `#problem-def("PartitionIntoPathsOfLength2")` entry with formal definition and background + +### Step 7: Verify +- `make test clippy` must pass From 74ca83a69f9eacabdf9180c1f2a59cdc13130ad7 Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 12:37:39 +0000 Subject: [PATCH 2/5] Implement #227: Add PartitionIntoPathsOfLength2 model Co-Authored-By: Claude Opus 4.6 --- ...aximumindependentset_to_maximumclique.json | 8 +- docs/paper/reductions.typ | 9 + problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 13 ++ problemreductions-cli/src/dispatch.rs | 4 + problemreductions-cli/src/problem_name.rs | 1 + src/lib.rs | 3 +- src/models/graph/mod.rs | 3 + .../graph/partition_into_paths_of_length_2.rs | 172 ++++++++++++++++++ src/models/mod.rs | 2 +- .../graph/partition_into_paths_of_length_2.rs | 160 ++++++++++++++++ 11 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 src/models/graph/partition_into_paths_of_length_2.rs create mode 100644 src/unit_tests/models/graph/partition_into_paths_of_length_2.rs diff --git a/docs/paper/examples/maximumindependentset_to_maximumclique.json b/docs/paper/examples/maximumindependentset_to_maximumclique.json index 352ddeb6..e8be9c62 100644 --- a/docs/paper/examples/maximumindependentset_to_maximumclique.json +++ b/docs/paper/examples/maximumindependentset_to_maximumclique.json @@ -2,8 +2,8 @@ "source": { "problem": "MaximumIndependentSet", "variant": { - "graph": "SimpleGraph", - "weight": "i32" + "weight": "i32", + "graph": "SimpleGraph" }, "instance": { "edges": [ @@ -31,8 +31,8 @@ "target": { "problem": "MaximumClique", "variant": { - "graph": "SimpleGraph", - "weight": "i32" + "weight": "i32", + "graph": "SimpleGraph" }, "instance": { "edges": [ diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 7ab3b569..4d4f7627 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -56,6 +56,7 @@ "LongestCommonSubsequence": [Longest Common Subsequence], "SubsetSum": [Subset Sum], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "PartitionIntoPathsOfLength2": [Partition into Paths of Length 2], ) // Definition label: "def:" — each definition block must have a matching label @@ -571,6 +572,14 @@ caption: [A directed graph with FVS $S = {v_0}$ (blue, $w(S) = 1$). Removing $v_ ) ] +#problem-def("PartitionIntoPathsOfLength2")[ + Given $G = (V, E)$ with $|V| = 3q$, determine if $V$ can be partitioned into $q$ disjoint sets $V_1, ..., V_q$ of three vertices each, such that each $V_t$ induces at least two edges in $G$. +][ +A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], proved hard by reduction from 3-Dimensional Matching. Each triple in the partition must form a path of length 2 (exactly two edges, i.e., a $P_3$ subgraph) or a triangle (all three edges). The problem models constrained grouping scenarios where cluster connectivity is required. The best known exact approach uses subset DP in $O^*(3^n)$ time. + +*Example.* Consider the graph $G$ with $n = 9$ vertices and edges ${0,1}, {1,2}, {3,4}, {4,5}, {6,7}, {7,8}$ (plus cross-edges ${0,3}, {2,5}, {3,6}, {5,8}$). Setting $q = 3$, the partition $V_1 = {0,1,2}$, $V_2 = {3,4,5}$, $V_3 = {6,7,8}$ is valid: $V_1$ contains edges ${0,1}, {1,2}$ (path $0 dash.em 1 dash.em 2$), $V_2$ contains ${3,4}, {4,5}$, and $V_3$ contains ${6,7}, {7,8}$. +] + == Set Problems #problem-def("MaximumSetPacking")[ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 6b4cbb5b..fe7b403d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -220,6 +220,7 @@ Flags by problem type: CVP --basis, --target-vec [--bounds] LCS --strings FVS --arcs [--weights] [--num-vertices] + PartitionIntoPathsOfLength2 --graph ILP, CircuitSAT (via reduction only) Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2b4cb04b..5db5c492 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -552,6 +552,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // PartitionIntoPathsOfLength2 + "PartitionIntoPathsOfLength2" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create PartitionIntoPathsOfLength2 --graph 0-1,1-2,3-4,4-5" + ) + })?; + ( + ser(problemreductions::models::graph::PartitionIntoPathsOfLength2::new(graph))?, + resolved_variant.clone(), + ) + } + _ => bail!("{}", crate::problem_name::unknown_problem_error(canonical)), }; diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index e162efc2..097c3768 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -248,6 +248,9 @@ pub fn load_problem( "Knapsack" => deser_opt::(data), "LongestCommonSubsequence" => deser_opt::(data), "MinimumFeedbackVertexSet" => deser_opt::>(data), + "PartitionIntoPathsOfLength2" => { + deser_sat::>(data) + } "SubsetSum" => deser_sat::(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } @@ -312,6 +315,7 @@ pub fn serialize_any_problem( "Knapsack" => try_ser::(any), "LongestCommonSubsequence" => try_ser::(any), "MinimumFeedbackVertexSet" => try_ser::>(any), + "PartitionIntoPathsOfLength2" => try_ser::>(any), "SubsetSum" => try_ser::(any), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index a595f61b..5719aa32 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -57,6 +57,7 @@ pub fn resolve_alias(input: &str) -> String { "knapsack" => "Knapsack".to_string(), "lcs" | "longestcommonsubsequence" => "LongestCommonSubsequence".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), + "partitionintopathsoflength2" => "PartitionIntoPathsOfLength2".to_string(), "subsetsum" => "SubsetSum".to_string(), _ => input.to_string(), // pass-through for exact names } diff --git a/src/lib.rs b/src/lib.rs index bdcbf5f3..1b05271c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,8 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, + PartitionIntoPathsOfLength2, TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 42f46a15..4f35d51f 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -13,6 +13,7 @@ //! - [`MaximumMatching`]: Maximum weight matching //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian +//! - [`PartitionIntoPathsOfLength2`]: Partition vertices into P3 paths //! - [`BicliqueCover`]: Biclique cover on bipartite graphs pub(crate) mod biclique_cover; @@ -26,6 +27,7 @@ pub(crate) mod maximum_matching; pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_vertex_cover; +pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; @@ -40,5 +42,6 @@ pub use maximum_matching::MaximumMatching; pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_vertex_cover::MinimumVertexCover; +pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use spin_glass::SpinGlass; pub use traveling_salesman::TravelingSalesman; diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs new file mode 100644 index 00000000..ba9b4f41 --- /dev/null +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -0,0 +1,172 @@ +//! Partition into Paths of Length 2 problem implementation. +//! +//! Given a graph G = (V, E) with |V| = 3q, determine whether V can be partitioned +//! into q disjoint sets of three vertices each, such that each set induces at least +//! two edges (i.e., a path of length 2 or a triangle). +//! +//! This is a classical NP-complete problem from Garey & Johnson, Chapter 3, Section 3.3, p.76. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::variant::VariantParam; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "PartitionIntoPathsOfLength2", + module_path: module_path!(), + description: "Partition vertices into triples each inducing a path of length 2", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E) with |V| divisible by 3" }, + ], + } +} + +/// Partition into Paths of Length 2 problem. +/// +/// Given a graph G = (V, E) with |V| = 3q for a positive integer q, +/// determine whether V can be partitioned into q disjoint sets +/// V_1, V_2, ..., V_q of three vertices each, such that each V_t +/// induces at least two edges in G. +/// +/// Each triple must form either a path of length 2 (exactly 2 edges) +/// or a triangle (all 3 edges). +/// +/// # Type Parameters +/// +/// * `G` - Graph type (e.g., SimpleGraph) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::PartitionIntoPathsOfLength2; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // 6-vertex graph with two P3 paths: 0-1-2 and 3-4-5 +/// let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); +/// let problem = PartitionIntoPathsOfLength2::new(graph); +/// +/// let solver = BruteForce::new(); +/// let solution = solver.find_satisfying(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +pub struct PartitionIntoPathsOfLength2 { + /// The underlying graph. + graph: G, +} + +impl PartitionIntoPathsOfLength2 { + /// Create a new PartitionIntoPathsOfLength2 problem from a graph. + /// + /// # Panics + /// Panics if `graph.num_vertices()` is not divisible by 3. + pub fn new(graph: G) -> Self { + assert_eq!( + graph.num_vertices() % 3, + 0, + "Number of vertices ({}) must be divisible by 3", + graph.num_vertices() + ); + Self { graph } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// 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 q = |V| / 3, the number of groups in the partition. + pub fn num_groups(&self) -> usize { + self.graph.num_vertices() / 3 + } + + /// Check if a configuration represents a valid partition. + /// + /// A valid configuration assigns each vertex to a group (0..q-1) such that: + /// 1. Each group contains exactly 3 vertices. + /// 2. Each group induces at least 2 edges. + pub fn is_valid_partition(&self, config: &[usize]) -> bool { + let n = self.graph.num_vertices(); + let q = self.num_groups(); + + if config.len() != n { + return false; + } + + // Check all assignments are in range + if config.iter().any(|&g| g >= q) { + return false; + } + + // Count vertices per group + let mut group_sizes = vec![0usize; q]; + for &g in config { + group_sizes[g] += 1; + } + + // Each group must have exactly 3 vertices + if group_sizes.iter().any(|&s| s != 3) { + return false; + } + + // Check each group induces at least 2 edges + for group_id in 0..q { + let mut edge_count = 0; + for (u, v) in self.graph.edges() { + if config[u] == group_id && config[v] == group_id { + edge_count += 1; + } + } + if edge_count < 2 { + return false; + } + } + + true + } +} + +impl Problem for PartitionIntoPathsOfLength2 +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "PartitionIntoPathsOfLength2"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + let q = self.num_groups(); + vec![q; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_partition(config) + } +} + +impl SatisfactionProblem for PartitionIntoPathsOfLength2 {} + +crate::declare_variants! { + PartitionIntoPathsOfLength2 => "3^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/partition_into_paths_of_length_2.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 6c8ac38a..1971217c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,7 +14,7 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet, - MinimumVertexCover, SpinGlass, TravelingSalesman, + MinimumVertexCover, PartitionIntoPathsOfLength2, SpinGlass, TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs new file mode 100644 index 00000000..81f64bc1 --- /dev/null +++ b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs @@ -0,0 +1,160 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +#[test] +fn test_partition_into_paths_basic() { + // 9-vertex graph: three P3 paths: 0-1-2, 3-4-5, 6-7-8 + let graph = SimpleGraph::new( + 9, + vec![ + (0, 1), + (1, 2), + (3, 4), + (4, 5), + (6, 7), + (7, 8), + (0, 3), + (2, 5), + (3, 6), + (5, 8), + ], + ); + let problem = PartitionIntoPathsOfLength2::new(graph); + + assert_eq!(problem.num_vertices(), 9); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.num_groups(), 3); + assert_eq!(problem.dims(), vec![3; 9]); + + // Valid partition: {0,1,2}, {3,4,5}, {6,7,8} + // Config: vertex i -> group i/3 + let valid_config = vec![0, 0, 0, 1, 1, 1, 2, 2, 2]; + assert!(problem.evaluate(&valid_config)); + + // Invalid partition: {0,1,3}, {2,4,5}, {6,7,8} + // Group {0,1,3}: edges (0,1) and (0,3) and (1, nothing with 3) — 2 edges present, valid + // Group {2,4,5}: edges (4,5) and (2,5) — 2 edges present, valid + // This is actually valid too + let another_config = vec![0, 0, 1, 0, 1, 1, 2, 2, 2]; + assert!(problem.evaluate(&another_config)); +} + +#[test] +fn test_partition_into_paths_no_solution() { + // 6-vertex graph where no valid partition exists + // Edges: {0,1}, {2,3}, {0,4}, {1,5} + let graph = SimpleGraph::new(6, vec![(0, 1), (2, 3), (0, 4), (1, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_groups(), 2); + + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_none(), "Expected no solution for this graph"); +} + +#[test] +fn test_partition_into_paths_solver() { + // Simple 6-vertex graph with obvious partition: 0-1-2 and 3-4-5 + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + let solver = BruteForce::new(); + let solutions = solver.find_all_satisfying(&problem); + assert!(!solutions.is_empty(), "Expected at least one solution"); + + for sol in &solutions { + assert!(problem.evaluate(sol)); + } +} + +#[test] +fn test_partition_into_paths_invalid_group_size() { + // 6-vertex path: 0-1-2-3-4-5 + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Config where group 0 has 4 vertices and group 1 has 2 vertices + let bad_config = vec![0, 0, 0, 0, 1, 1]; + assert!(!problem.evaluate(&bad_config)); +} + +#[test] +fn test_partition_into_paths_insufficient_edges() { + // 6 vertices, only 2 edges — not enough for any group to have 2 edges + let graph = SimpleGraph::new(6, vec![(0, 1), (3, 4)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Even a well-sized partition fails because groups lack edges + let config = vec![0, 0, 0, 1, 1, 1]; + // Group {0,1,2}: only edge (0,1) — 1 edge < 2, invalid + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partition_into_paths_triangle() { + // Triangle group: 3 vertices, 3 edges — also valid (>= 2 edges) + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Single group with all 3 vertices forming a triangle + let config = vec![0, 0, 0]; + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_partition_into_paths_serialization() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: PartitionIntoPathsOfLength2 = + serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.num_vertices(), 6); + assert_eq!(deserialized.num_edges(), 4); + assert_eq!(deserialized.num_groups(), 2); + + // Verify evaluation is consistent + let config = vec![0, 0, 0, 1, 1, 1]; + assert_eq!(problem.evaluate(&config), deserialized.evaluate(&config)); +} + +#[test] +#[should_panic(expected = "must be divisible by 3")] +fn test_partition_into_paths_invalid_vertex_count() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); + let _problem = PartitionIntoPathsOfLength2::new(graph); +} + +#[test] +fn test_partition_into_paths_size_getters() { + let graph = SimpleGraph::new(9, vec![(0, 1), (1, 2), (3, 4), (4, 5), (6, 7), (7, 8)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + assert_eq!(problem.num_vertices(), 9); + assert_eq!(problem.num_edges(), 6); + assert_eq!(problem.num_groups(), 3); +} + +#[test] +fn test_partition_into_paths_out_of_range_group() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + // Group index out of range (q=2, so valid groups are 0 and 1) + let config = vec![0, 0, 0, 2, 2, 2]; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_partition_into_paths_is_valid_partition() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)]); + let problem = PartitionIntoPathsOfLength2::new(graph); + + assert!(problem.is_valid_partition(&[0, 0, 0, 1, 1, 1])); + assert!(!problem.is_valid_partition(&[0, 0, 1, 1, 1, 1])); // Wrong group sizes + assert!(!problem.is_valid_partition(&[0, 0, 0])); // Wrong length +} From dafbd15390c80d049abff6d61a6d0a9c1e293829 Mon Sep 17 00:00:00 2001 From: zazabap Date: Fri, 13 Mar 2026 12:37:45 +0000 Subject: [PATCH 3/5] chore: remove plan file after implementation Co-Authored-By: Claude Opus 4.6 --- ...-03-13-partition-into-paths-of-length-2.md | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 docs/plans/2026-03-13-partition-into-paths-of-length-2.md diff --git a/docs/plans/2026-03-13-partition-into-paths-of-length-2.md b/docs/plans/2026-03-13-partition-into-paths-of-length-2.md deleted file mode 100644 index 07100aef..00000000 --- a/docs/plans/2026-03-13-partition-into-paths-of-length-2.md +++ /dev/null @@ -1,79 +0,0 @@ -# Plan: Add PartitionIntoPathsOfLength2 Model - -**Issue:** #227 -**Type:** Model -**Skill:** add-model - -## Summary - -Add the PartitionIntoPathsOfLength2 satisfaction problem: given a graph G = (V, E) with |V| = 3q, determine whether V can be partitioned into q disjoint triples such that each triple induces at least 2 edges (i.e., a path of length 2 or a triangle). - -## Collected Information - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `PartitionIntoPathsOfLength2` | -| 2 | Mathematical definition | Given G=(V,E) with \|V\|=3q, partition V into q triples each inducing >= 2 edges | -| 3 | Problem type | Satisfaction (Metric = bool) | -| 4 | Type parameters | `G: Graph` (graph type parameter) | -| 5 | Struct fields | `graph: G` | -| 6 | Configuration space | `vec![q; n]` where q = n/3, n = num_vertices | -| 7 | Feasibility check | Each group has exactly 3 vertices, and each group induces >= 2 edges | -| 8 | Objective function | N/A (satisfaction: returns bool) | -| 9 | Best known exact algorithm | O(3^n) naive set-partition DP; no better exact algorithm known | -| 10 | Solving strategy | BruteForce works | -| 11 | Category | `graph` | - -## Steps - -### Step 1: Create model file -**File:** `src/models/graph/partition_into_paths_of_length_2.rs` - -- `inventory::submit!` for ProblemSchemaEntry with field `graph` -- Struct `PartitionIntoPathsOfLength2` with `graph: G` field -- Constructor `new(graph: G)` — panics if `num_vertices % 3 != 0` -- Getter methods: `graph()`, `num_vertices()`, `num_edges()` -- `evaluate()`: decode config as group assignments (each vertex gets value 0..q-1), check: - 1. Each group has exactly 3 members - 2. Each group induces at least 2 edges -- `Problem` impl: NAME = "PartitionIntoPathsOfLength2", Metric = bool, dims = `vec![q; n]`, variant = `variant_params![G]` -- `SatisfactionProblem` impl (marker trait) -- `declare_variants!`: `PartitionIntoPathsOfLength2 => "3^num_vertices"` - -### Step 2: Register the model -- `src/models/graph/mod.rs`: add `pub(crate) mod partition_into_paths_of_length_2;` and `pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2;` -- `src/models/mod.rs`: add `PartitionIntoPathsOfLength2` to the graph re-export line - -### Step 3: Register in CLI -- `problemreductions-cli/src/dispatch.rs`: - - `load_problem`: add `"PartitionIntoPathsOfLength2" => deser_sat::>(data)` - - `serialize_any_problem`: add `"PartitionIntoPathsOfLength2" => try_ser::>(any)` -- `problemreductions-cli/src/problem_name.rs`: - - Add `"partitionintopathsoflength2" => "PartitionIntoPathsOfLength2".to_string()` to `resolve_alias()` - -### Step 4: CLI creation support -- `problemreductions-cli/src/commands/create.rs`: - - Add match arm for `"PartitionIntoPathsOfLength2"` that parses `--graph` and constructs the problem - - No new CLI flags needed (just `--graph`) -- `problemreductions-cli/src/cli.rs`: - - Add entry to "Flags by problem type" table in `after_help` - - No new struct fields needed in `CreateArgs` - - No changes to `all_data_flags_empty()` - -### Step 5: Write unit tests -**File:** `src/unit_tests/models/graph/partition_into_paths_of_length_2.rs` - -- `test_partition_into_paths_basic`: create 9-vertex instance from issue example, verify dims, evaluate valid/invalid configs -- `test_partition_into_paths_no_solution`: create 6-vertex NO instance from issue, verify brute force finds no solution -- `test_partition_into_paths_solver`: verify brute force solver finds valid solutions on YES instance -- `test_partition_into_paths_serialization`: round-trip serde test -- `test_partition_into_paths_invalid_group_size`: verify configs where groups don't have exactly 3 members return false - -Link test file via `#[cfg(test)] #[path = "..."] mod tests;` at bottom of model file. - -### Step 6: Document in paper -- Add `"PartitionIntoPathsOfLength2": [Partition into Paths of Length 2]` to `display-name` dict in `docs/paper/reductions.typ` -- Add `#problem-def("PartitionIntoPathsOfLength2")` entry with formal definition and background - -### Step 7: Verify -- `make test clippy` must pass From 618277bd26e4c3ab9d04027b25e922ab91379594 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sun, 15 Mar 2026 08:22:15 +0000 Subject: [PATCH 4/5] fix: address review comments for PartitionIntoPathsOfLength2 - Fix ProblemSchemaEntry to include new required fields (display_name, aliases, dimensions) - Add `sat` keyword to declare_variants! macro call - Optimize is_valid_partition to single-pass edge counting (was O(q*m)) - Fix description to say "at least two edges (P3 or triangle)" instead of just "path of length 2" - Fix misleading test comment about "invalid partition" - Add CLI validation for vertex count divisible by 3 (prevents panic) Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/commands/create.rs | 6 ++++ src/models/graph/mod.rs | 2 +- .../graph/partition_into_paths_of_length_2.rs | 29 ++++++++++--------- .../graph/partition_into_paths_of_length_2.rs | 7 ++--- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index e09b0f17..e8c31544 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -989,6 +989,12 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "{e}\n\nUsage: pred create PartitionIntoPathsOfLength2 --graph 0-1,1-2,3-4,4-5" ) })?; + if graph.num_vertices() % 3 != 0 { + bail!( + "PartitionIntoPathsOfLength2 requires vertex count divisible by 3, got {}", + graph.num_vertices() + ); + } ( ser(problemreductions::models::graph::PartitionIntoPathsOfLength2::new(graph))?, resolved_variant.clone(), diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 83e61894..960eb758 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -16,7 +16,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) -//! - [`PartitionIntoPathsOfLength2`]: Partition vertices into P3 paths +//! - [`PartitionIntoPathsOfLength2`]: Partition vertices into triples with at least two edges each //! - [`BicliqueCover`]: Biclique cover on bipartite graphs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs index ba9b4f41..aba9d4d3 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -6,7 +6,7 @@ //! //! This is a classical NP-complete problem from Garey & Johnson, Chapter 3, Section 3.3, p.76. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::{Problem, SatisfactionProblem}; use crate::variant::VariantParam; @@ -15,8 +15,13 @@ use serde::{Deserialize, Serialize}; inventory::submit! { ProblemSchemaEntry { name: "PartitionIntoPathsOfLength2", + display_name: "Partition Into Paths Of Length 2", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], module_path: module_path!(), - description: "Partition vertices into triples each inducing a path of length 2", + description: "Partition vertices into triples each inducing at least two edges (P3 or triangle)", fields: &[ FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E) with |V| divisible by 3" }, ], @@ -123,18 +128,16 @@ impl PartitionIntoPathsOfLength2 { return false; } - // Check each group induces at least 2 edges - for group_id in 0..q { - let mut edge_count = 0; - for (u, v) in self.graph.edges() { - if config[u] == group_id && config[v] == group_id { - edge_count += 1; - } - } - if edge_count < 2 { - return false; + // Check each group induces at least 2 edges (single pass over edges) + let mut group_edge_counts = vec![0usize; q]; + for (u, v) in self.graph.edges() { + if config[u] == config[v] { + group_edge_counts[config[u]] += 1; } } + if group_edge_counts.iter().any(|&c| c < 2) { + return false; + } true } @@ -164,7 +167,7 @@ where impl SatisfactionProblem for PartitionIntoPathsOfLength2 {} crate::declare_variants! { - PartitionIntoPathsOfLength2 => "3^num_vertices", + default sat PartitionIntoPathsOfLength2 => "3^num_vertices", } #[cfg(test)] diff --git a/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs index 81f64bc1..c576cdef 100644 --- a/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs +++ b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs @@ -33,10 +33,9 @@ fn test_partition_into_paths_basic() { let valid_config = vec![0, 0, 0, 1, 1, 1, 2, 2, 2]; assert!(problem.evaluate(&valid_config)); - // Invalid partition: {0,1,3}, {2,4,5}, {6,7,8} - // Group {0,1,3}: edges (0,1) and (0,3) and (1, nothing with 3) — 2 edges present, valid - // Group {2,4,5}: edges (4,5) and (2,5) — 2 edges present, valid - // This is actually valid too + // Alternative valid partition: {0,1,3}, {2,4,5}, {6,7,8} + // Group {0,1,3}: edges (0,1) and (0,3) — 2 edges, valid + // Group {2,4,5}: edges (4,5) and (2,5) — 2 edges, valid let another_config = vec![0, 0, 1, 0, 1, 1, 2, 2, 2]; assert!(problem.evaluate(&another_config)); } From ba43493a2cd5363f738b7d2ac382afd71f602eb9 Mon Sep 17 00:00:00 2001 From: zazabap Date: Sun, 15 Mar 2026 08:28:32 +0000 Subject: [PATCH 5/5] fix: address structural review gaps for PartitionIntoPathsOfLength2 - Fix missing closing bracket in paper problem-def block - Fix display_name casing to match paper convention (lowercase prepositions) - Add trait_consistency test entry - Add canonical model example specs (example-db) - Register example specs in graph/mod.rs Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 1 + src/models/graph/mod.rs | 1 + .../graph/partition_into_paths_of_length_2.rs | 16 +++++++++++++++- src/unit_tests/trait_consistency.rs | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a303ff79..35a014d7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -695,6 +695,7 @@ caption: [A directed graph with FVS $S = {v_0}$ (blue, $w(S) = 1$). Removing $v_ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], proved hard by reduction from 3-Dimensional Matching. Each triple in the partition must form a path of length 2 (exactly two edges, i.e., a $P_3$ subgraph) or a triangle (all three edges). The problem models constrained grouping scenarios where cluster connectivity is required. The best known exact approach uses subset DP in $O^*(3^n)$ time. *Example.* Consider the graph $G$ with $n = 9$ vertices and edges ${0,1}, {1,2}, {3,4}, {4,5}, {6,7}, {7,8}$ (plus cross-edges ${0,3}, {2,5}, {3,6}, {5,8}$). Setting $q = 3$, the partition $V_1 = {0,1,2}$, $V_2 = {3,4,5}$, $V_3 = {6,7,8}$ is valid: $V_1$ contains edges ${0,1}, {1,2}$ (path $0 dash.em 1 dash.em 2$), $V_2$ contains ${3,4}, {4,5}$, and $V_3$ contains ${6,7}, {7,8}$. +] #problem-def("MinimumSumMulticenter")[ Given a graph $G = (V, E)$ with vertex weights $w: V -> ZZ_(>= 0)$, edge lengths $l: E -> ZZ_(>= 0)$, and a positive integer $K <= |V|$, find a set $P subset.eq V$ of $K$ vertices (centers) that minimizes the total weighted distance $sum_(v in V) w(v) dot d(v, P)$, where $d(v, P) = min_(p in P) d(v, p)$ is the shortest-path distance from $v$ to the nearest center in $P$. diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 960eb758..997cc8b1 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -89,5 +89,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "3^num_vertices", } +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "partition_into_paths_of_length_2_simplegraph", + build: || { + let problem = PartitionIntoPathsOfLength2::new(SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (3, 4), (4, 5)], + )); + crate::example_db::specs::satisfaction_example(problem, vec![vec![0, 0, 0, 1, 1, 1]]) + }, + }] +} + #[cfg(test)] #[path = "../../unit_tests/models/graph/partition_into_paths_of_length_2.rs"] mod tests; diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index ebbc68a0..547d050d 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -122,6 +122,10 @@ fn test_all_problems_implement_trait_correctly() { &FlowShopScheduling::new(2, vec![vec![1, 2], vec![3, 4]], 10), "FlowShopScheduling", ); + check_problem_trait( + &PartitionIntoPathsOfLength2::new(SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5)])), + "PartitionIntoPathsOfLength2", + ); } #[test]