diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 6b4cbb5be..80cc838d1 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 GraphPartitioning --graph Factoring --target, --m, --n BinPacking --sizes, --capacity diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 2b4cb04b3..bb6da7544 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -89,6 +89,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", "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", _ => "", @@ -502,6 +503,24 @@ 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" + ) + })?; + 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(), + ) + } + // MinimumFeedbackVertexSet "MinimumFeedbackVertexSet" => { let arcs_str = args.arcs.as_ref().ok_or_else(|| { diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index e162efc24..0c8dc38c8 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -246,6 +246,7 @@ pub fn load_problem( _ => deser_opt::>(data), }, "Knapsack" => deser_opt::(data), + "PartitionIntoTriangles" => deser_sat::>(data), "LongestCommonSubsequence" => deser_opt::(data), "MinimumFeedbackVertexSet" => deser_opt::>(data), "SubsetSum" => deser_sat::(data), @@ -310,6 +311,7 @@ pub fn serialize_any_problem( _ => try_ser::>(any), }, "Knapsack" => try_ser::(any), + "PartitionIntoTriangles" => try_ser::>(any), "LongestCommonSubsequence" => try_ser::(any), "MinimumFeedbackVertexSet" => try_ser::>(any), "SubsetSum" => try_ser::(any), diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index a595f61b6..7d9c1584d 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -55,6 +55,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(), "lcs" | "longestcommonsubsequence" => "LongestCommonSubsequence".to_string(), "fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(), "subsetsum" => "SubsetSum".to_string(), diff --git a/src/lib.rs b/src/lib.rs index bdcbf5f32..aa5fb4087 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, + PartitionIntoTriangles, 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 42f46a155..9d34aab1c 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -10,6 +10,7 @@ //! - [`MaxCut`]: Maximum cut on weighted graphs //! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) //! - [`KColoring`]: K-vertex coloring +//! - [`PartitionIntoTriangles`]: Partition vertices into triangles //! - [`MaximumMatching`]: Maximum weight matching //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian @@ -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_triangles; 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_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..b5649d013 --- /dev/null +++ b/src/models/graph/partition_into_triangles.rs @@ -0,0 +1,160 @@ +//! 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; + } + + // 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 verts in &group_verts { + 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 6c8ac38a7..afb331c11 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, PartitionIntoTriangles, 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_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); +}