diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 113de9b06..eb6e854d8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -63,6 +63,7 @@ // Problem display names for theorem headers #let display-name = ( "AdditionalKey": [Additional Key], + "AcyclicPartition": [Acyclic Partition], "MaximumIndependentSet": [Maximum Independent Set], "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], @@ -3636,6 +3637,61 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("AcyclicPartition") + let nv = x.instance.graph.num_vertices + let arcs = x.instance.graph.arcs.map(a => (a.at(0), a.at(1))) + let weights = x.instance.vertex_weights + let config = x.optimal_config + let B = x.instance.weight_bound + let K = x.instance.cost_bound + let part0 = range(nv).filter(v => config.at(v) == 0) + let part1 = range(nv).filter(v => config.at(v) == 1) + let part2 = range(nv).filter(v => config.at(v) == 2) + let part0w = part0.map(v => weights.at(v)).sum(default: 0) + let part1w = part1.map(v => weights.at(v)).sum(default: 0) + let part2w = part2.map(v => weights.at(v)).sum(default: 0) + let cross-arcs = arcs.filter(a => config.at(a.at(0)) != config.at(a.at(1))) + [ + #problem-def("AcyclicPartition")[ + Given a directed graph $G = (V, A)$ with vertex weights $w: V -> ZZ^+$, arc costs $c: A -> ZZ^+$, and bounds $B, K in ZZ^+$, determine whether there exists a partition $V = V_1 ∪ dots ∪ V_m$ such that every part satisfies $sum_(v in V_i) w(v) <= B$, the total cost of arcs crossing between different parts is at most $K$, and the quotient digraph on the parts is acyclic. + ][ + Acyclic Partition is the directed partitioning problem ND15 in Garey & Johnson @garey1979. Unlike ordinary graph partitioning, the goal is not merely to minimize the cut: the partition must preserve a global topological order after every part is contracted to a super-node. This makes the model a natural abstraction for DAG-aware task clustering in compiler scheduling, parallel execution pipelines, and automatic differentiation systems where coarse-grained blocks must still communicate without creating cyclic dependencies. + + The implementation uses the natural witness encoding in which each of the $n = #nv$ vertices chooses one of at most $n$ part labels, so direct brute-force search explores $n^n$ assignments.#footnote[Many labelings represent the same unordered partition, but the full configuration space exposed to the solver is still $n^n$.] + + *Example.* Consider the six-vertex digraph in the figure with vertex weights $w = (#weights.map(w => str(w)).join(", "))$, part bound $B = #B$, and cut-cost bound $K = #K$. The witness $V_0 = {#part0.map(v => $v_#v$).join(", ")}$, $V_1 = {#part1.map(v => $v_#v$).join(", ")}$, $V_2 = {#part2.map(v => $v_#v$).join(", ")}$ has part weights $#part0w$, $#part1w$, and $#part2w$, so every part respects the weight cap. Exactly #cross-arcs.len() arcs cross between different parts, namely #cross-arcs.map(a => $(v_#(a.at(0)) arrow v_#(a.at(1)))$).join($,$), so the total crossing cost is $#cross-arcs.len() <= K$. These crossings induce quotient arcs $V_0 arrow V_1$, $V_0 arrow V_2$, and $V_1 arrow V_2$, which form a DAG; hence this instance is a YES-instance. + + #figure({ + let verts = ((0, 1.6), (1.4, 2.4), (1.4, 0.8), (3.2, 2.4), (3.2, 0.8), (4.8, 1.6)) + canvas(length: 1cm, { + for arc in arcs { + let (u, v) = arc + let crossing = config.at(u) != config.at(v) + draw.line( + verts.at(u), + verts.at(v), + stroke: if crossing { 1.3pt + black } else { 0.9pt + luma(170) }, + mark: (end: "straight", scale: if crossing { 0.5 } else { 0.4 }), + ) + } + for (v, pos) in verts.enumerate() { + let color = graph-colors.at(config.at(v)) + g-node( + pos, + name: "v" + str(v), + fill: color, + label: text(fill: white)[$v_#v$], + ) + } + }) + }, + caption: [A YES witness for Acyclic Partition. Node colors indicate the parts $V_0$, $V_1$, and $V_2$. Black arcs cross parts and define the quotient DAG $V_0 arrow V_1$, $V_0 arrow V_2$, $V_1 arrow V_2$; gray arcs stay inside a part and therefore do not contribute to the quotient graph.], + ) + ] + ] +} + #{ let x = load-model-example("FlowShopScheduling") let m = x.instance.num_processors diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index c2a5b60c3..5a26d50c5 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -259,6 +259,7 @@ Flags by problem type: ConsecutiveOnesSubmatrix --matrix (0/1), --k SteinerTree --graph, --edge-weights, --terminals MultipleCopyFileAllocation --graph, --usage, --storage, --bound + AcyclicPartition --arcs [--weights] [--arc-costs] --weight-bound --cost-bound [--num-vertices] CVP --basis, --target-vec [--bounds] MultiprocessorScheduling --lengths, --num-processors, --deadline SequencingWithinIntervals --release-times, --deadlines, --lengths @@ -506,6 +507,9 @@ pub struct CreateArgs { /// Upper bound on total path weight #[arg(long)] pub weight_bound: Option, + /// Upper bound on total inter-partition arc cost + #[arg(long)] + pub cost_bound: Option, /// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0) #[arg(long)] pub pattern: Option, @@ -515,6 +519,9 @@ pub struct CreateArgs { /// Task costs for SequencingToMinimizeMaximumCumulativeCost (comma-separated, e.g., "2,-1,3,-2,1,-3") #[arg(long, allow_hyphen_values = true)] pub costs: Option, + /// Arc costs for directed graph problems with per-arc costs (comma-separated, e.g., "1,1,2,3") + #[arg(long)] + pub arc_costs: Option, /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a272556c2..5947fb94b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -100,9 +100,11 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.bound.is_none() && args.length_bound.is_none() && args.weight_bound.is_none() + && args.cost_bound.is_none() && args.pattern.is_none() && args.strings.is_none() && args.costs.is_none() + && args.arc_costs.is_none() && args.arcs.is_none() && args.quantifiers.is_none() && args.usage.is_none() @@ -580,6 +582,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "MultipleCopyFileAllocation" => { MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS } + "AcyclicPartition" => { + "--arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5" + } "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3 --bound 5", "DirectedTwoCommodityIntegralFlow" => { "--arcs \"0>2,0>3,1>2,1>3,2>4,2>5,3>4,3>5\" --capacities 1,1,1,1,1,1,1,1 --source-1 0 --sink-1 4 --source-2 1 --sink-2 5 --requirement-1 1 --requirement-2 1" @@ -3004,6 +3009,45 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // AcyclicPartition + "AcyclicPartition" => { + let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let vertex_weights = parse_vertex_weights(args, graph.num_vertices())?; + let arc_costs = parse_arc_costs(args, num_arcs)?; + let weight_bound = args.weight_bound.ok_or_else(|| { + anyhow::anyhow!("AcyclicPartition requires --weight-bound\n\n{usage}") + })?; + let cost_bound = args.cost_bound.ok_or_else(|| { + anyhow::anyhow!("AcyclicPartition requires --cost-bound\n\n{usage}") + })?; + if vertex_weights.iter().any(|&weight| weight <= 0) { + bail!("AcyclicPartition --weights must be positive (Z+)"); + } + if arc_costs.iter().any(|&cost| cost <= 0) { + bail!("AcyclicPartition --arc-costs must be positive (Z+)"); + } + if weight_bound <= 0 { + bail!("AcyclicPartition --weight-bound must be positive (Z+)"); + } + if cost_bound <= 0 { + bail!("AcyclicPartition --cost-bound must be positive (Z+)"); + } + ( + ser(AcyclicPartition::new( + graph, + vertex_weights, + arc_costs, + weight_bound, + cost_bound, + ))?, + resolved_variant.clone(), + ) + } + // MinMaxMulticenter (vertex p-center) "MinMaxMulticenter" => { let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2 --bound 2"; @@ -4745,6 +4789,27 @@ fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { } } +/// Parse `--arc-costs` as per-arc costs (i32), defaulting to all 1s. +fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { + match &args.arc_costs { + Some(costs) => { + let parsed: Vec = costs + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if parsed.len() != num_arcs { + bail!( + "Expected {} arc costs but got {}", + num_arcs, + parsed.len() + ); + } + Ok(parsed) + } + None => Ok(vec![1i32; num_arcs]), + } +} + /// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. fn parse_candidate_arcs( args: &CreateArgs, @@ -5670,8 +5735,10 @@ mod tests { bound: None, length_bound: None, weight_bound: None, + cost_bound: None, pattern: None, strings: None, + arc_costs: None, arcs: None, values: None, precedences: None, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 86588c376..a5e4abe85 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1951,6 +1951,100 @@ fn test_create_model_example_multiple_choice_branching_round_trips_into_solve() std::fs::remove_file(&path).ok(); } +#[test] +fn test_create_acyclic_partition() { + let output = pred() + .args([ + "create", + "AcyclicPartition/i32", + "--arcs", + "0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5", + "--weights", + "2,3,2,1,3,1", + "--arc-costs", + "1,1,1,1,1,1,1,1", + "--weight-bound", + "5", + "--cost-bound", + "5", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "AcyclicPartition"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["vertex_weights"], serde_json::json!([2, 3, 2, 1, 3, 1])); + assert_eq!(json["data"]["arc_costs"], serde_json::json!([1, 1, 1, 1, 1, 1, 1, 1])); + assert_eq!(json["data"]["weight_bound"], 5); + assert_eq!(json["data"]["cost_bound"], 5); +} + +#[test] +fn test_create_model_example_acyclic_partition() { + let output = pred() + .args(["create", "--example", "AcyclicPartition/i32"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "AcyclicPartition"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["weight_bound"], 5); + assert_eq!(json["data"]["cost_bound"], 5); + assert_eq!(json["data"]["graph"]["num_vertices"], 6); +} + +#[test] +fn test_create_model_example_acyclic_partition_round_trips_into_solve() { + let path = std::env::temp_dir().join(format!( + "pred_test_model_example_acyclic_partition_{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let create = pred() + .args([ + "create", + "--example", + "AcyclicPartition/i32", + "-o", + path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + create.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create.stderr) + ); + + let solve = pred() + .args(["solve", path.to_str().unwrap(), "--solver", "brute-force"]) + .output() + .unwrap(); + assert!( + solve.status.success(), + "stderr: {}", + String::from_utf8_lossy(&solve.stderr) + ); + + std::fs::remove_file(&path).ok(); +} + #[test] fn test_create_multiple_choice_branching_rejects_negative_bound() { let output = pred() @@ -5355,6 +5449,65 @@ fn test_inspect_undirected_two_commodity_integral_flow_reports_size_fields() { std::fs::remove_file(&result_file).ok(); } +#[test] +fn test_inspect_acyclic_partition_reports_size_fields() { + let problem_file = std::env::temp_dir().join("pred_test_acyclic_partition_inspect_in.json"); + let result_file = std::env::temp_dir().join("pred_test_acyclic_partition_inspect_out.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "--example", + "AcyclicPartition/i32", + ]) + .output() + .unwrap(); + assert!( + create_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create_out.stderr) + ); + + let output = pred() + .args([ + "-o", + result_file.to_str().unwrap(), + "inspect", + problem_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(result_file.exists()); + + let content = std::fs::read_to_string(&result_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + let size_fields: Vec<&str> = json["size_fields"] + .as_array() + .expect("size_fields should be an array") + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!( + size_fields.contains(&"num_vertices"), + "AcyclicPartition size_fields should contain num_vertices, got: {:?}", + size_fields + ); + assert!( + size_fields.contains(&"num_arcs"), + "AcyclicPartition size_fields should contain num_arcs, got: {:?}", + size_fields + ); + + std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&result_file).ok(); +} + #[test] fn test_inspect_multiple_copy_file_allocation_reports_size_fields() { let problem_file = std::env::temp_dir().join("pred_test_mcfa_inspect_in.json"); diff --git a/src/lib.rs b/src/lib.rs index bc25bf36f..9c8bd8664 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,7 +48,7 @@ pub mod prelude { Satisfiability, }; pub use crate::models::graph::{ - BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs new file mode 100644 index 000000000..6660d76a7 --- /dev/null +++ b/src/models/graph/acyclic_partition.rs @@ -0,0 +1,273 @@ +//! Acyclic Partition problem implementation. +//! +//! Given a directed graph with vertex weights, arc costs, and bounds, determine +//! whether the vertices can be partitioned into groups whose quotient graph is a +//! DAG, each group's total vertex weight is bounded, and the total +//! inter-partition arc cost is bounded. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::topology::DirectedGraph; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +inventory::submit! { + ProblemSchemaEntry { + name: "AcyclicPartition", + display_name: "Acyclic Partition", + aliases: &[], + dimensions: &[ + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Partition a directed graph into bounded-weight groups with an acyclic quotient graph and bounded inter-partition cost", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, + FieldInfo { name: "arc_costs", type_name: "Vec", description: "Arc costs c(a) for each arc a in A, matching graph.arcs() order" }, + FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Maximum total vertex weight B for each partition" }, + FieldInfo { name: "cost_bound", type_name: "W::Sum", description: "Maximum total inter-partition arc cost K" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "AcyclicPartition", + fields: &["num_vertices", "num_arcs"], + } +} + +/// Acyclic Partition (Garey & Johnson ND15). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcyclicPartition { + graph: DirectedGraph, + vertex_weights: Vec, + arc_costs: Vec, + weight_bound: W::Sum, + cost_bound: W::Sum, +} + +impl AcyclicPartition { + /// Create a new Acyclic Partition instance. + pub fn new( + graph: DirectedGraph, + vertex_weights: Vec, + arc_costs: Vec, + weight_bound: W::Sum, + cost_bound: W::Sum, + ) -> Self { + assert_eq!( + vertex_weights.len(), + graph.num_vertices(), + "vertex_weights length must match graph num_vertices" + ); + assert_eq!( + arc_costs.len(), + graph.num_arcs(), + "arc_costs length must match graph num_arcs" + ); + Self { + graph, + vertex_weights, + arc_costs, + weight_bound, + cost_bound, + } + } + + /// Get the underlying graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the vertex weights. + pub fn vertex_weights(&self) -> &[W] { + &self.vertex_weights + } + + /// Get the arc costs. + pub fn arc_costs(&self) -> &[W] { + &self.arc_costs + } + + /// Replace the vertex weights. + pub fn set_vertex_weights(&mut self, vertex_weights: Vec) { + assert_eq!( + vertex_weights.len(), + self.graph.num_vertices(), + "vertex_weights length must match graph num_vertices" + ); + self.vertex_weights = vertex_weights; + } + + /// Replace the arc costs. + pub fn set_arc_costs(&mut self, arc_costs: Vec) { + assert_eq!( + arc_costs.len(), + self.graph.num_arcs(), + "arc_costs length must match graph num_arcs" + ); + self.arc_costs = arc_costs; + } + + /// Get the per-part weight bound. + pub fn weight_bound(&self) -> &W::Sum { + &self.weight_bound + } + + /// Get the inter-partition cost bound. + pub fn cost_bound(&self) -> &W::Sum { + &self.cost_bound + } + + /// Check whether this instance uses non-unit weights. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Check whether a configuration is a valid solution. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_acyclic_partition( + &self.graph, + &self.vertex_weights, + &self.arc_costs, + &self.weight_bound, + &self.cost_bound, + config, + ) + } +} + +impl Problem for AcyclicPartition +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "AcyclicPartition"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![self.graph.num_vertices(); self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + is_valid_acyclic_partition( + &self.graph, + &self.vertex_weights, + &self.arc_costs, + &self.weight_bound, + &self.cost_bound, + config, + ) + } +} + +impl SatisfactionProblem for AcyclicPartition where W: WeightElement + crate::variant::VariantParam {} + +fn is_valid_acyclic_partition( + graph: &DirectedGraph, + vertex_weights: &[W], + arc_costs: &[W], + weight_bound: &W::Sum, + cost_bound: &W::Sum, + config: &[usize], +) -> bool { + let num_vertices = graph.num_vertices(); + if config.len() != num_vertices { + return false; + } + if vertex_weights.len() != num_vertices || arc_costs.len() != graph.num_arcs() { + return false; + } + if config.iter().any(|&label| label >= num_vertices) { + return false; + } + + let mut partition_weights = vec![W::Sum::zero(); num_vertices]; + let mut used_labels = vec![false; num_vertices]; + for (vertex, &label) in config.iter().enumerate() { + used_labels[label] = true; + partition_weights[label] += vertex_weights[vertex].to_sum(); + if partition_weights[label] > *weight_bound { + return false; + } + } + + let mut dense_label = vec![usize::MAX; num_vertices]; + let mut next_dense = 0usize; + for (label, used) in used_labels.iter().enumerate() { + if *used { + dense_label[label] = next_dense; + next_dense += 1; + } + } + + let mut total_cost = W::Sum::zero(); + let mut quotient_arcs = BTreeSet::new(); + for ((source, target), cost) in graph.arcs().iter().zip(arc_costs.iter()) { + let source_label = config[*source]; + let target_label = config[*target]; + if source_label == target_label { + continue; + } + total_cost += cost.to_sum(); + if total_cost > *cost_bound { + return false; + } + quotient_arcs.insert((dense_label[source_label], dense_label[target_label])); + } + + DirectedGraph::new(next_dense, quotient_arcs.into_iter().collect()).is_dag() +} + +crate::declare_variants! { + default sat AcyclicPartition => "num_vertices^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "acyclic_partition_i32", + instance: Box::new(AcyclicPartition::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 5), + (4, 5), + ], + ), + vec![2, 3, 2, 1, 3, 1], + vec![1; 8], + 5, + 5, + )), + optimal_config: vec![0, 1, 0, 2, 2, 2], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/acyclic_partition.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index c88017482..c971b7746 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -1,6 +1,7 @@ //! Graph problems. //! //! Problems whose input is a graph (optionally weighted): +//! - [`AcyclicPartition`]: Partition a digraph into bounded-weight groups with an acyclic quotient graph //! - [`MaximumIndependentSet`]: Maximum weight independent set //! - [`MaximalIS`]: Maximal independent set //! - [`MinimumVertexCover`]: Minimum weight vertex cover @@ -43,6 +44,7 @@ //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs pub(crate) mod balanced_complete_bipartite_subgraph; +pub(crate) mod acyclic_partition; pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bounded_component_spanning_forest; @@ -84,6 +86,7 @@ pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; pub(crate) mod undirected_two_commodity_integral_flow; +pub use acyclic_partition::AcyclicPartition; pub use balanced_complete_bipartite_subgraph::BalancedCompleteBipartiteSubgraph; pub use biclique_cover::BicliqueCover; pub use biconnectivity_augmentation::BiconnectivityAugmentation; @@ -129,6 +132,7 @@ pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFl #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { let mut specs = Vec::new(); + specs.extend(acyclic_partition::canonical_model_example_specs()); specs.extend(maximum_independent_set::canonical_model_example_specs()); specs.extend(minimum_vertex_cover::canonical_model_example_specs()); specs.extend(max_cut::canonical_model_example_specs()); diff --git a/src/models/mod.rs b/src/models/mod.rs index 7ae612340..e364f8254 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -18,7 +18,7 @@ pub use formula::{ Quantifier, Satisfiability, }; pub use graph::{ - BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, KColoring, KthBestSpanningTree, LengthBoundedDisjointPaths, MaxCut, MaximalIS, MaximumClique, diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs new file mode 100644 index 000000000..0abb21887 --- /dev/null +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -0,0 +1,223 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::registry::declared_size_fields; +use crate::topology::DirectedGraph; +use crate::traits::Problem; +use serde_json; +use std::collections::{BTreeSet, HashSet}; + +fn yes_instance() -> AcyclicPartition { + AcyclicPartition::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 5), + (4, 5), + ], + ), + vec![2, 3, 2, 1, 3, 1], + vec![1; 8], + 5, + 5, + ) +} + +fn no_cost_instance() -> AcyclicPartition { + AcyclicPartition::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 5), + (4, 5), + ], + ), + vec![2, 3, 2, 1, 3, 1], + vec![1; 8], + 5, + 4, + ) +} + +fn quotient_cycle_instance() -> AcyclicPartition { + AcyclicPartition::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1, 1, 1], + vec![1, 1, 1], + 3, + 3, + ) +} + +fn canonicalize_labels(config: &[usize]) -> Vec { + let mut next_label = 0usize; + let mut mapping = std::collections::BTreeMap::new(); + let mut normalized = Vec::with_capacity(config.len()); + for &label in config { + let mapped = mapping.entry(label).or_insert_with(|| { + let current = next_label; + next_label += 1; + current + }); + normalized.push(*mapped); + } + normalized +} + +#[test] +fn test_acyclic_partition_creation_and_accessors() { + let mut problem = yes_instance(); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 8); + assert_eq!(problem.dims(), vec![6; 6]); + assert_eq!(problem.graph().arcs().len(), 8); + assert_eq!(problem.vertex_weights(), &[2, 3, 2, 1, 3, 1]); + assert_eq!(problem.arc_costs(), &[1, 1, 1, 1, 1, 1, 1, 1]); + assert_eq!(problem.weight_bound(), &5); + assert_eq!(problem.cost_bound(), &5); + assert!(problem.is_weighted()); + + problem.set_vertex_weights(vec![1; 6]); + problem.set_arc_costs(vec![2; 8]); + assert_eq!(problem.vertex_weights(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(problem.arc_costs(), &[2, 2, 2, 2, 2, 2, 2, 2]); +} + +#[test] +fn test_acyclic_partition_rejects_weight_length_mismatch() { + let result = std::panic::catch_unwind(|| { + AcyclicPartition::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1], + vec![1], + 2, + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_acyclic_partition_rejects_arc_cost_length_mismatch() { + let result = std::panic::catch_unwind(|| { + AcyclicPartition::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1, 1], + vec![], + 2, + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_acyclic_partition_evaluate_yes_instance() { + let problem = yes_instance(); + let config = vec![0, 1, 0, 2, 2, 2]; + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_acyclic_partition_rejects_too_small_cost_bound() { + let problem = no_cost_instance(); + assert!(!problem.evaluate(&[0, 1, 0, 2, 2, 2])); +} + +#[test] +fn test_acyclic_partition_rejects_quotient_cycle() { + let problem = quotient_cycle_instance(); + assert!(!problem.evaluate(&[0, 1, 2])); +} + +#[test] +fn test_acyclic_partition_rejects_weight_bound_violation() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 0, 0, 1, 1, 1])); +} + +#[test] +fn test_acyclic_partition_rejects_wrong_config_length() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 1, 0])); +} + +#[test] +fn test_acyclic_partition_rejects_out_of_range_label() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 1, 0, 2, 2, 6])); +} + +#[test] +fn test_acyclic_partition_solver_finds_issue_example() { + let problem = yes_instance(); + let solver = BruteForce::new(); + + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} + +#[test] +fn test_acyclic_partition_solver_has_four_canonical_solutions() { + let problem = yes_instance(); + let solutions = BruteForce::new().find_all_satisfying(&problem); + let normalized: BTreeSet> = solutions + .iter() + .map(|config| canonicalize_labels(config)) + .collect(); + + let expected = BTreeSet::from([ + vec![0, 0, 1, 2, 1, 2], + vec![0, 0, 1, 2, 2, 2], + vec![0, 1, 0, 1, 2, 2], + vec![0, 1, 0, 2, 2, 2], + ]); + + assert_eq!(normalized, expected); +} + +#[test] +fn test_acyclic_partition_no_solution_when_cost_bound_is_four() { + let problem = no_cost_instance(); + assert!(BruteForce::new().find_satisfying(&problem).is_none()); +} + +#[test] +fn test_acyclic_partition_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: AcyclicPartition = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.num_vertices(), 6); + assert_eq!(deserialized.num_arcs(), 8); + assert_eq!(deserialized.weight_bound(), &5); + assert_eq!(deserialized.cost_bound(), &5); +} + +#[test] +fn test_acyclic_partition_num_variables() { + let problem = yes_instance(); + assert_eq!(problem.num_variables(), 6); +} + +#[test] +fn test_acyclic_partition_declares_problem_size_fields() { + let fields: HashSet<&'static str> = declared_size_fields("AcyclicPartition") + .into_iter() + .collect(); + assert_eq!(fields, HashSet::from(["num_vertices", "num_arcs"])); +}