Skip to content

Commit 6401e7d

Browse files
zazabapclaudeGiggleLiu
authored
Fix #232: Add PartitionIntoTriangles model (#609)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Fix clippy needless_range_loop warning in evaluate() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: GiggleLiu <cacate0129@gmail.com>
1 parent 0e14c45 commit 6401e7d

9 files changed

Lines changed: 316 additions & 2 deletions

File tree

problemreductions-cli/src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ Flags by problem type:
208208
QUBO --matrix
209209
SpinGlass --graph, --couplings, --fields
210210
KColoring --graph, --k
211+
PartitionIntoTriangles --graph
211212
GraphPartitioning --graph
212213
Factoring --target, --m, --n
213214
BinPacking --sizes, --capacity

problemreductions-cli/src/commands/create.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
8989
"QUBO" => "--matrix \"1,0.5;0.5,2\"",
9090
"SpinGlass" => "--graph 0-1,1-2 --couplings 1,1",
9191
"KColoring" => "--graph 0-1,1-2,2-0 --k 3",
92+
"PartitionIntoTriangles" => "--graph 0-1,1-2,0-2",
9293
"Factoring" => "--target 15 --m 4 --n 4",
9394
"SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11",
9495
_ => "",
@@ -502,6 +503,24 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
502503
)
503504
}
504505

506+
// PartitionIntoTriangles
507+
"PartitionIntoTriangles" => {
508+
let (graph, _) = parse_graph(args).map_err(|e| {
509+
anyhow::anyhow!(
510+
"{e}\n\nUsage: pred create PartitionIntoTriangles --graph 0-1,1-2,0-2"
511+
)
512+
})?;
513+
anyhow::ensure!(
514+
graph.num_vertices() % 3 == 0,
515+
"PartitionIntoTriangles requires vertex count divisible by 3, got {}",
516+
graph.num_vertices()
517+
);
518+
(
519+
ser(PartitionIntoTriangles::new(graph))?,
520+
resolved_variant.clone(),
521+
)
522+
}
523+
505524
// MinimumFeedbackVertexSet
506525
"MinimumFeedbackVertexSet" => {
507526
let arcs_str = args.arcs.as_ref().ok_or_else(|| {

problemreductions-cli/src/dispatch.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ pub fn load_problem(
246246
_ => deser_opt::<ClosestVectorProblem<i32>>(data),
247247
},
248248
"Knapsack" => deser_opt::<Knapsack>(data),
249+
"PartitionIntoTriangles" => deser_sat::<PartitionIntoTriangles<SimpleGraph>>(data),
249250
"LongestCommonSubsequence" => deser_opt::<LongestCommonSubsequence>(data),
250251
"MinimumFeedbackVertexSet" => deser_opt::<MinimumFeedbackVertexSet<i32>>(data),
251252
"SubsetSum" => deser_sat::<SubsetSum>(data),
@@ -310,6 +311,7 @@ pub fn serialize_any_problem(
310311
_ => try_ser::<ClosestVectorProblem<i32>>(any),
311312
},
312313
"Knapsack" => try_ser::<Knapsack>(any),
314+
"PartitionIntoTriangles" => try_ser::<PartitionIntoTriangles<SimpleGraph>>(any),
313315
"LongestCommonSubsequence" => try_ser::<LongestCommonSubsequence>(any),
314316
"MinimumFeedbackVertexSet" => try_ser::<MinimumFeedbackVertexSet<i32>>(any),
315317
"SubsetSum" => try_ser::<SubsetSum>(any),

problemreductions-cli/src/problem_name.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub fn resolve_alias(input: &str) -> String {
5555
"binpacking" => "BinPacking".to_string(),
5656
"cvp" | "closestvectorproblem" => "ClosestVectorProblem".to_string(),
5757
"knapsack" => "Knapsack".to_string(),
58+
"partitionintotriangles" => "PartitionIntoTriangles".to_string(),
5859
"lcs" | "longestcommonsubsequence" => "LongestCommonSubsequence".to_string(),
5960
"fvs" | "minimumfeedbackvertexset" => "MinimumFeedbackVertexSet".to_string(),
6061
"subsetsum" => "SubsetSum".to_string(),

src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ pub mod prelude {
4343
pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass};
4444
pub use crate::models::graph::{
4545
KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching,
46-
MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover, TravelingSalesman,
46+
MinimumDominatingSet, MinimumFeedbackVertexSet, MinimumVertexCover,
47+
PartitionIntoTriangles, TravelingSalesman,
4748
};
4849
pub use crate::models::misc::{
4950
BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum,

src/models/graph/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//! - [`MaxCut`]: Maximum cut on weighted graphs
1111
//! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning)
1212
//! - [`KColoring`]: K-vertex coloring
13+
//! - [`PartitionIntoTriangles`]: Partition vertices into triangles
1314
//! - [`MaximumMatching`]: Maximum weight matching
1415
//! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle)
1516
//! - [`SpinGlass`]: Ising model Hamiltonian
@@ -26,6 +27,7 @@ pub(crate) mod maximum_matching;
2627
pub(crate) mod minimum_dominating_set;
2728
pub(crate) mod minimum_feedback_vertex_set;
2829
pub(crate) mod minimum_vertex_cover;
30+
pub(crate) mod partition_into_triangles;
2931
pub(crate) mod spin_glass;
3032
pub(crate) mod traveling_salesman;
3133

@@ -40,5 +42,6 @@ pub use maximum_matching::MaximumMatching;
4042
pub use minimum_dominating_set::MinimumDominatingSet;
4143
pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet;
4244
pub use minimum_vertex_cover::MinimumVertexCover;
45+
pub use partition_into_triangles::PartitionIntoTriangles;
4346
pub use spin_glass::SpinGlass;
4447
pub use traveling_salesman::TravelingSalesman;
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
//! Partition Into Triangles problem implementation.
2+
//!
3+
//! Given a graph G = (V, E) where |V| = 3q, determine whether V can be
4+
//! partitioned into q triples, each forming a triangle (K3) in G.
5+
6+
use crate::registry::{FieldInfo, ProblemSchemaEntry};
7+
use crate::topology::{Graph, SimpleGraph};
8+
use crate::traits::{Problem, SatisfactionProblem};
9+
use crate::variant::VariantParam;
10+
use serde::{Deserialize, Serialize};
11+
12+
inventory::submit! {
13+
ProblemSchemaEntry {
14+
name: "PartitionIntoTriangles",
15+
module_path: module_path!(),
16+
description: "Partition vertices into triangles (K3 subgraphs)",
17+
fields: &[
18+
FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E) with |V| divisible by 3" },
19+
],
20+
}
21+
}
22+
23+
/// The Partition Into Triangles problem.
24+
///
25+
/// Given a graph G = (V, E) where |V| = 3q, determine whether V can be
26+
/// partitioned into q triples, each forming a triangle (K3) in G.
27+
///
28+
/// # Type Parameters
29+
///
30+
/// * `G` - Graph type (e.g., SimpleGraph)
31+
///
32+
/// # Example
33+
///
34+
/// ```
35+
/// use problemreductions::models::graph::PartitionIntoTriangles;
36+
/// use problemreductions::topology::SimpleGraph;
37+
/// use problemreductions::{Problem, Solver, BruteForce};
38+
///
39+
/// // Triangle graph: 3 vertices forming a single triangle
40+
/// let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
41+
/// let problem = PartitionIntoTriangles::new(graph);
42+
///
43+
/// let solver = BruteForce::new();
44+
/// let solution = solver.find_satisfying(&problem);
45+
/// assert!(solution.is_some());
46+
/// ```
47+
#[derive(Debug, Clone, Serialize, Deserialize)]
48+
#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))]
49+
pub struct PartitionIntoTriangles<G> {
50+
/// The underlying graph.
51+
graph: G,
52+
}
53+
54+
impl<G: Graph> PartitionIntoTriangles<G> {
55+
/// Create a new Partition Into Triangles problem from a graph.
56+
///
57+
/// # Panics
58+
/// Panics if the number of vertices is not divisible by 3.
59+
pub fn new(graph: G) -> Self {
60+
assert!(
61+
graph.num_vertices().is_multiple_of(3),
62+
"Number of vertices ({}) must be divisible by 3",
63+
graph.num_vertices()
64+
);
65+
Self { graph }
66+
}
67+
68+
/// Get a reference to the underlying graph.
69+
pub fn graph(&self) -> &G {
70+
&self.graph
71+
}
72+
73+
/// Get the number of vertices in the underlying graph.
74+
pub fn num_vertices(&self) -> usize {
75+
self.graph.num_vertices()
76+
}
77+
78+
/// Get the number of edges in the underlying graph.
79+
pub fn num_edges(&self) -> usize {
80+
self.graph.num_edges()
81+
}
82+
}
83+
84+
impl<G> Problem for PartitionIntoTriangles<G>
85+
where
86+
G: Graph + VariantParam,
87+
{
88+
const NAME: &'static str = "PartitionIntoTriangles";
89+
type Metric = bool;
90+
91+
fn variant() -> Vec<(&'static str, &'static str)> {
92+
crate::variant_params![G]
93+
}
94+
95+
fn dims(&self) -> Vec<usize> {
96+
let q = self.graph.num_vertices() / 3;
97+
vec![q; self.graph.num_vertices()]
98+
}
99+
100+
fn evaluate(&self, config: &[usize]) -> bool {
101+
let n = self.graph.num_vertices();
102+
let q = n / 3;
103+
104+
// Check config length
105+
if config.len() != n {
106+
return false;
107+
}
108+
109+
// Check all values are in range [0, q)
110+
if config.iter().any(|&c| c >= q) {
111+
return false;
112+
}
113+
114+
// Count vertices per group
115+
let mut counts = vec![0usize; q];
116+
for &c in config {
117+
counts[c] += 1;
118+
}
119+
120+
// Each group must have exactly 3 vertices
121+
if counts.iter().any(|&c| c != 3) {
122+
return false;
123+
}
124+
125+
// Build per-group vertex lists in a single pass over config.
126+
let mut group_verts = vec![[0usize; 3]; q];
127+
let mut group_pos = vec![0usize; q];
128+
129+
for (v, &g) in config.iter().enumerate() {
130+
let pos = group_pos[g];
131+
group_verts[g][pos] = v;
132+
group_pos[g] = pos + 1;
133+
}
134+
135+
// Check each group forms a triangle
136+
for verts in &group_verts {
137+
if !self.graph.has_edge(verts[0], verts[1]) {
138+
return false;
139+
}
140+
if !self.graph.has_edge(verts[0], verts[2]) {
141+
return false;
142+
}
143+
if !self.graph.has_edge(verts[1], verts[2]) {
144+
return false;
145+
}
146+
}
147+
148+
true
149+
}
150+
}
151+
152+
impl<G: Graph + VariantParam> SatisfactionProblem for PartitionIntoTriangles<G> {}
153+
154+
crate::declare_variants! {
155+
PartitionIntoTriangles<SimpleGraph> => "2^num_vertices",
156+
}
157+
158+
#[cfg(test)]
159+
#[path = "../../unit_tests/models/graph/partition_into_triangles.rs"]
160+
mod tests;

src/models/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability};
1414
pub use graph::{
1515
BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique,
1616
MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackVertexSet,
17-
MinimumVertexCover, SpinGlass, TravelingSalesman,
17+
MinimumVertexCover, PartitionIntoTriangles, SpinGlass, TravelingSalesman,
1818
};
1919
pub use misc::{BinPacking, Factoring, Knapsack, LongestCommonSubsequence, PaintShop, SubsetSum};
2020
pub use set::{MaximumSetPacking, MinimumSetCovering};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use super::*;
2+
use crate::solvers::{BruteForce, Solver};
3+
use crate::topology::SimpleGraph;
4+
5+
#[test]
6+
fn test_partitionintotriangles_basic() {
7+
use crate::traits::Problem;
8+
9+
// 9-vertex YES instance: three disjoint triangles
10+
// Triangle 1: 0-1-2, Triangle 2: 3-4-5, Triangle 3: 6-7-8
11+
let graph = SimpleGraph::new(
12+
9,
13+
vec![
14+
(0, 1),
15+
(1, 2),
16+
(0, 2),
17+
(3, 4),
18+
(4, 5),
19+
(3, 5),
20+
(6, 7),
21+
(7, 8),
22+
(6, 8),
23+
],
24+
);
25+
let problem = PartitionIntoTriangles::new(graph);
26+
27+
assert_eq!(problem.num_vertices(), 9);
28+
assert_eq!(problem.num_edges(), 9);
29+
assert_eq!(problem.dims(), vec![3; 9]);
30+
31+
// Valid partition: vertices 0,1,2 in group 0; 3,4,5 in group 1; 6,7,8 in group 2
32+
assert!(problem.evaluate(&[0, 0, 0, 1, 1, 1, 2, 2, 2]));
33+
34+
// Invalid: wrong grouping (vertices 0,1,3 are not a triangle)
35+
assert!(!problem.evaluate(&[0, 0, 1, 0, 1, 1, 2, 2, 2]));
36+
37+
// Invalid: group sizes wrong (4 in group 0, 2 in group 1)
38+
assert!(!problem.evaluate(&[0, 0, 0, 0, 1, 1, 2, 2, 2]));
39+
}
40+
41+
#[test]
42+
fn test_partitionintotriangles_no_solution() {
43+
use crate::traits::Problem;
44+
45+
// 6-vertex NO instance: path graph has no triangles at all
46+
let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]);
47+
let problem = PartitionIntoTriangles::new(graph);
48+
49+
assert_eq!(problem.num_vertices(), 6);
50+
assert_eq!(problem.dims(), vec![2; 6]);
51+
52+
// No valid partition exists since there are no triangles
53+
let solver = BruteForce::new();
54+
let solution = solver.find_satisfying(&problem);
55+
assert!(solution.is_none());
56+
}
57+
58+
#[test]
59+
fn test_partitionintotriangles_solver() {
60+
use crate::traits::Problem;
61+
62+
// Single triangle
63+
let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
64+
let problem = PartitionIntoTriangles::new(graph);
65+
66+
let solver = BruteForce::new();
67+
let solution = solver.find_satisfying(&problem);
68+
assert!(solution.is_some());
69+
let sol = solution.unwrap();
70+
assert!(problem.evaluate(&sol));
71+
72+
// All solutions should be valid
73+
let all = solver.find_all_satisfying(&problem);
74+
assert!(!all.is_empty());
75+
for s in &all {
76+
assert!(problem.evaluate(s));
77+
}
78+
}
79+
80+
#[test]
81+
fn test_partitionintotriangles_serialization() {
82+
let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
83+
let problem = PartitionIntoTriangles::new(graph);
84+
85+
let json = serde_json::to_string(&problem).unwrap();
86+
let deserialized: PartitionIntoTriangles<SimpleGraph> = serde_json::from_str(&json).unwrap();
87+
88+
assert_eq!(deserialized.num_vertices(), 3);
89+
assert_eq!(deserialized.num_edges(), 3);
90+
}
91+
92+
#[test]
93+
#[should_panic(expected = "must be divisible by 3")]
94+
fn test_partitionintotriangles_invalid_vertex_count() {
95+
let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]);
96+
let _ = PartitionIntoTriangles::new(graph);
97+
}
98+
99+
#[test]
100+
fn test_partitionintotriangles_config_out_of_range() {
101+
use crate::traits::Problem;
102+
103+
let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
104+
let problem = PartitionIntoTriangles::new(graph);
105+
106+
// q = 1, so only group 0 is valid; group 1 is out of range
107+
assert!(!problem.evaluate(&[0, 0, 1]));
108+
}
109+
110+
#[test]
111+
fn test_partitionintotriangles_wrong_config_length() {
112+
use crate::traits::Problem;
113+
114+
let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
115+
let problem = PartitionIntoTriangles::new(graph);
116+
117+
assert!(!problem.evaluate(&[0, 0]));
118+
assert!(!problem.evaluate(&[0, 0, 0, 0]));
119+
}
120+
121+
#[test]
122+
fn test_partitionintotriangles_size_getters() {
123+
let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]);
124+
let problem = PartitionIntoTriangles::new(graph);
125+
assert_eq!(problem.num_vertices(), 6);
126+
assert_eq!(problem.num_edges(), 6);
127+
}

0 commit comments

Comments
 (0)