From 6c25bcaebddbc98bf6a5784234484d592e7e0c06 Mon Sep 17 00:00:00 2001 From: zazabap Date: Thu, 12 Mar 2026 14:18:41 +0000 Subject: [PATCH 1/3] feat: add PartitionIntoTriangles model (#232) Implement the Partition Into Triangles satisfaction problem (GJ GT11). Given a graph G with |V| = 3q, determine if vertices can be partitioned into q triples each forming a triangle. Includes CLI registration, unit tests, and brute-force solver support. Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 14 ++ problemreductions-cli/src/dispatch.rs | 2 + problemreductions-cli/src/problem_name.rs | 1 + src/lib.rs | 2 +- src/models/graph/mod.rs | 3 + src/models/graph/partition_into_triangles.rs | 158 ++++++++++++++++++ src/models/mod.rs | 3 +- .../models/graph/partition_into_triangles.rs | 127 ++++++++++++++ 9 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/models/graph/partition_into_triangles.rs create mode 100644 src/unit_tests/models/graph/partition_into_triangles.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91e9bd252..4eab98174 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -208,6 +208,7 @@ Flags by problem type: QUBO --matrix SpinGlass --graph, --couplings, --fields KColoring --graph, --k + PartitionIntoTriangles --graph Factoring --target, --m, --n BinPacking --sizes, --capacity PaintShop --sequence diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 3594a24a0..4b96fa419 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -82,6 +82,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "QUBO" => "--matrix \"1,0.5;0.5,2\"", "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", + "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", "Factoring" => "--target 15 --m 4 --n 4", _ => "", } @@ -442,6 +443,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // PartitionIntoTriangles + "PartitionIntoTriangles" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create PartitionIntoTriangles --graph 0-1,1-2,0-2" + ) + })?; + ( + ser(PartitionIntoTriangles::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 7a8498421..0b9ae1c06 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -245,6 +245,7 @@ pub fn load_problem( _ => deser_opt::>(data), }, "Knapsack" => deser_opt::(data), + "PartitionIntoTriangles" => deser_sat::>(data), _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), } } @@ -305,6 +306,7 @@ pub fn serialize_any_problem( _ => try_ser::>(any), }, "Knapsack" => try_ser::(any), + "PartitionIntoTriangles" => 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 acd9b4b59..dfb8f3c4f 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -52,6 +52,7 @@ pub fn resolve_alias(input: &str) -> String { "binpacking" => "BinPacking".to_string(), "cvp" | "closestvectorproblem" => "ClosestVectorProblem".to_string(), "knapsack" => "Knapsack".to_string(), + "partitionintotriangles" => "PartitionIntoTriangles".to_string(), _ => input.to_string(), // pass-through for exact names } } diff --git a/src/lib.rs b/src/lib.rs index b0d99699a..5113c7683 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod prelude { pub use crate::models::graph::{BicliqueCover, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, + MinimumDominatingSet, MinimumVertexCover, PartitionIntoTriangles, TravelingSalesman, }; pub use crate::models::misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 1198f7fbc..a8a98668c 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -8,6 +8,7 @@ //! - [`MaximumClique`]: Maximum weight clique //! - [`MaxCut`]: Maximum cut on weighted graphs //! - [`KColoring`]: K-vertex coloring +//! - [`PartitionIntoTriangles`]: Partition vertices into triangles //! - [`MaximumMatching`]: Maximum weight matching //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian @@ -22,6 +23,7 @@ pub(crate) mod maximum_independent_set; pub(crate) mod maximum_matching; pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_vertex_cover; +pub(crate) mod partition_into_triangles; pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; @@ -34,5 +36,6 @@ pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_matching::MaximumMatching; pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_vertex_cover::MinimumVertexCover; +pub use partition_into_triangles::PartitionIntoTriangles; pub use spin_glass::SpinGlass; pub use traveling_salesman::TravelingSalesman; diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs new file mode 100644 index 000000000..f2770a4d3 --- /dev/null +++ b/src/models/graph/partition_into_triangles.rs @@ -0,0 +1,158 @@ +//! Partition Into Triangles problem implementation. +//! +//! Given a graph G = (V, E) where |V| = 3q, determine whether V can be +//! partitioned into q triples, each forming a triangle (K3) in G. + +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: "PartitionIntoTriangles", + module_path: module_path!(), + description: "Partition vertices into triangles (K3 subgraphs)", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E) with |V| divisible by 3" }, + ], + } +} + +/// The Partition Into Triangles problem. +/// +/// Given a graph G = (V, E) where |V| = 3q, determine whether V can be +/// partitioned into q triples, each forming a triangle (K3) in G. +/// +/// # Type Parameters +/// +/// * `G` - Graph type (e.g., SimpleGraph) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::PartitionIntoTriangles; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Triangle graph: 3 vertices forming a single triangle +/// let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); +/// let problem = PartitionIntoTriangles::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 PartitionIntoTriangles { + /// The underlying graph. + graph: G, +} + +impl PartitionIntoTriangles { + /// Create a new Partition Into Triangles problem from a graph. + /// + /// # Panics + /// Panics if the number of vertices is not divisible by 3. + pub fn new(graph: G) -> Self { + assert!( + graph.num_vertices().is_multiple_of(3), + "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 underlying graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } +} + +impl Problem for PartitionIntoTriangles +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "PartitionIntoTriangles"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + let q = self.graph.num_vertices() / 3; + vec![q; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + let n = self.graph.num_vertices(); + let q = n / 3; + + // Check config length + if config.len() != n { + return false; + } + + // Check all values are in range [0, q) + if config.iter().any(|&c| c >= q) { + return false; + } + + // Count vertices per group + let mut counts = vec![0usize; q]; + for &c in config { + counts[c] += 1; + } + + // Each group must have exactly 3 vertices + if counts.iter().any(|&c| c != 3) { + return false; + } + + // Check each group forms a triangle + for g in 0..q { + let verts: Vec = config + .iter() + .enumerate() + .filter(|(_, &c)| c == g) + .map(|(i, _)| i) + .collect(); + + // Check all 3 edges present + if !self.graph.has_edge(verts[0], verts[1]) { + return false; + } + if !self.graph.has_edge(verts[0], verts[2]) { + return false; + } + if !self.graph.has_edge(verts[1], verts[2]) { + return false; + } + } + + true + } +} + +impl SatisfactionProblem for PartitionIntoTriangles {} + +crate::declare_variants! { + PartitionIntoTriangles => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/partition_into_triangles.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 96b4b79d1..608ff8c31 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,7 +13,8 @@ pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, - MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, TravelingSalesman, + MaximumMatching, MinimumDominatingSet, MinimumVertexCover, PartitionIntoTriangles, SpinGlass, + TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/partition_into_triangles.rs b/src/unit_tests/models/graph/partition_into_triangles.rs new file mode 100644 index 000000000..86fc7d71c --- /dev/null +++ b/src/unit_tests/models/graph/partition_into_triangles.rs @@ -0,0 +1,127 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; + +#[test] +fn test_partitionintotriangles_basic() { + use crate::traits::Problem; + + // 9-vertex YES instance: three disjoint triangles + // Triangle 1: 0-1-2, Triangle 2: 3-4-5, Triangle 3: 6-7-8 + let graph = SimpleGraph::new( + 9, + vec![ + (0, 1), + (1, 2), + (0, 2), + (3, 4), + (4, 5), + (3, 5), + (6, 7), + (7, 8), + (6, 8), + ], + ); + let problem = PartitionIntoTriangles::new(graph); + + assert_eq!(problem.num_vertices(), 9); + assert_eq!(problem.num_edges(), 9); + assert_eq!(problem.dims(), vec![3; 9]); + + // Valid partition: vertices 0,1,2 in group 0; 3,4,5 in group 1; 6,7,8 in group 2 + assert!(problem.evaluate(&[0, 0, 0, 1, 1, 1, 2, 2, 2])); + + // Invalid: wrong grouping (vertices 0,1,3 are not a triangle) + assert!(!problem.evaluate(&[0, 0, 1, 0, 1, 1, 2, 2, 2])); + + // Invalid: group sizes wrong (4 in group 0, 2 in group 1) + assert!(!problem.evaluate(&[0, 0, 0, 0, 1, 1, 2, 2, 2])); +} + +#[test] +fn test_partitionintotriangles_no_solution() { + use crate::traits::Problem; + + // 6-vertex NO instance: path graph has no triangles at all + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]); + let problem = PartitionIntoTriangles::new(graph); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.dims(), vec![2; 6]); + + // No valid partition exists since there are no triangles + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_none()); +} + +#[test] +fn test_partitionintotriangles_solver() { + use crate::traits::Problem; + + // Single triangle + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = PartitionIntoTriangles::new(graph); + + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + let sol = solution.unwrap(); + assert!(problem.evaluate(&sol)); + + // All solutions should be valid + let all = solver.find_all_satisfying(&problem); + assert!(!all.is_empty()); + for s in &all { + assert!(problem.evaluate(s)); + } +} + +#[test] +fn test_partitionintotriangles_serialization() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = PartitionIntoTriangles::new(graph); + + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: PartitionIntoTriangles = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.num_vertices(), 3); + assert_eq!(deserialized.num_edges(), 3); +} + +#[test] +#[should_panic(expected = "must be divisible by 3")] +fn test_partitionintotriangles_invalid_vertex_count() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let _ = PartitionIntoTriangles::new(graph); +} + +#[test] +fn test_partitionintotriangles_config_out_of_range() { + use crate::traits::Problem; + + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = PartitionIntoTriangles::new(graph); + + // q = 1, so only group 0 is valid; group 1 is out of range + assert!(!problem.evaluate(&[0, 0, 1])); +} + +#[test] +fn test_partitionintotriangles_wrong_config_length() { + use crate::traits::Problem; + + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = PartitionIntoTriangles::new(graph); + + assert!(!problem.evaluate(&[0, 0])); + assert!(!problem.evaluate(&[0, 0, 0, 0])); +} + +#[test] +fn test_partitionintotriangles_size_getters() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]); + let problem = PartitionIntoTriangles::new(graph); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 6); +} From 68b137efd5f41895b989e101c76a0d8161c84678 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 20:03:35 +0800 Subject: [PATCH 2/3] Address Copilot review: optimize evaluate() and validate CLI input - Optimize PartitionIntoTriangles::evaluate() to build per-group vertex lists in a single pass instead of O(n*q) repeated filtering - Add vertex count validation in CLI create before calling new() to return a user-friendly error instead of panicking Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/commands/create.rs | 5 +++++ src/models/graph/partition_into_triangles.rs | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 110db2ff2..bb6da7544 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -510,6 +510,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "{e}\n\nUsage: pred create PartitionIntoTriangles --graph 0-1,1-2,0-2" ) })?; + anyhow::ensure!( + graph.num_vertices() % 3 == 0, + "PartitionIntoTriangles requires vertex count divisible by 3, got {}", + graph.num_vertices() + ); ( ser(PartitionIntoTriangles::new(graph))?, resolved_variant.clone(), diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index f2770a4d3..0a973b949 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -122,16 +122,19 @@ where return false; } + // Build per-group vertex lists in a single pass over config. + let mut group_verts = vec![[0usize; 3]; q]; + let mut group_pos = vec![0usize; q]; + + for (v, &g) in config.iter().enumerate() { + let pos = group_pos[g]; + group_verts[g][pos] = v; + group_pos[g] = pos + 1; + } + // Check each group forms a triangle for g in 0..q { - let verts: Vec = config - .iter() - .enumerate() - .filter(|(_, &c)| c == g) - .map(|(i, _)| i) - .collect(); - - // Check all 3 edges present + let verts = group_verts[g]; if !self.graph.has_edge(verts[0], verts[1]) { return false; } From e901e0674f415206b74bd4d22f3e2f3bd6e5657a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 13 Mar 2026 20:14:01 +0800 Subject: [PATCH 3/3] Fix clippy needless_range_loop warning in evaluate() Co-Authored-By: Claude Opus 4.6 --- src/models/graph/partition_into_triangles.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index 0a973b949..b5649d013 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -133,8 +133,7 @@ where } // Check each group forms a triangle - for g in 0..q { - let verts = group_verts[g]; + for verts in &group_verts { if !self.graph.has_edge(verts[0], verts[1]) { return false; }