Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
_ => "",
Expand Down Expand Up @@ -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(),
)
Comment on lines +518 to +521
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI create path calls PartitionIntoTriangles::new(graph), which asserts (panics) when the vertex count is not divisible by 3. That will crash the CLI with a panic backtrace instead of returning a user-facing error. Consider validating graph.num_vertices() % 3 == 0 here and bail! with a clear message/usage hint (or otherwise converting the precondition failure into a Result).

Copilot uses AI. Check for mistakes.
}

// MinimumFeedbackVertexSet
"MinimumFeedbackVertexSet" => {
let arcs_str = args.arcs.as_ref().ok_or_else(|| {
Expand Down
2 changes: 2 additions & 0 deletions problemreductions-cli/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ pub fn load_problem(
_ => deser_opt::<ClosestVectorProblem<i32>>(data),
},
"Knapsack" => deser_opt::<Knapsack>(data),
"PartitionIntoTriangles" => deser_sat::<PartitionIntoTriangles<SimpleGraph>>(data),
"LongestCommonSubsequence" => deser_opt::<LongestCommonSubsequence>(data),
"MinimumFeedbackVertexSet" => deser_opt::<MinimumFeedbackVertexSet<i32>>(data),
"SubsetSum" => deser_sat::<SubsetSum>(data),
Expand Down Expand Up @@ -310,6 +311,7 @@ pub fn serialize_any_problem(
_ => try_ser::<ClosestVectorProblem<i32>>(any),
},
"Knapsack" => try_ser::<Knapsack>(any),
"PartitionIntoTriangles" => try_ser::<PartitionIntoTriangles<SimpleGraph>>(any),
"LongestCommonSubsequence" => try_ser::<LongestCommonSubsequence>(any),
"MinimumFeedbackVertexSet" => try_ser::<MinimumFeedbackVertexSet<i32>>(any),
"SubsetSum" => try_ser::<SubsetSum>(any),
Expand Down
1 change: 1 addition & 0 deletions problemreductions-cli/src/problem_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/models/graph/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -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;
160 changes: 160 additions & 0 deletions src/models/graph/partition_into_triangles.rs
Original file line number Diff line number Diff line change
@@ -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<G> {
/// The underlying graph.
graph: G,
}

impl<G: Graph> PartitionIntoTriangles<G> {
/// 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<G> Problem for PartitionIntoTriangles<G>
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<usize> {
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<G: Graph + VariantParam> SatisfactionProblem for PartitionIntoTriangles<G> {}

crate::declare_variants! {
PartitionIntoTriangles<SimpleGraph> => "2^num_vertices",
}

#[cfg(test)]
#[path = "../../unit_tests/models/graph/partition_into_triangles.rs"]
mod tests;
2 changes: 1 addition & 1 deletion src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
127 changes: 127 additions & 0 deletions src/unit_tests/models/graph/partition_into_triangles.rs
Original file line number Diff line number Diff line change
@@ -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<SimpleGraph> = 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);
}
Loading