diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 5726fe72c..35922ed4e 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -108,6 +108,7 @@ "ShortestCommonSupersequence": [Shortest Common Supersequence], "MinimumSumMulticenter": [Minimum Sum Multicenter], "SteinerTree": [Steiner Tree], + "StrongConnectivityAugmentation": [Strong Connectivity Augmentation], "SubgraphIsomorphism": [Subgraph Isomorphism], "PartitionIntoTriangles": [Partition Into Triangles], "FlowShopScheduling": [Flow Shop Scheduling], @@ -1070,6 +1071,49 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("StrongConnectivityAugmentation") + let nv = x.instance.graph.inner.nodes.len() + let ne = x.instance.graph.inner.edges.len() + let arcs = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + let candidates = x.instance.candidate_arcs + let bound = x.instance.bound + let sol = x.optimal.at(0) + let chosen = candidates.enumerate().filter(((i, _)) => sol.config.at(i) == 1).map(((i, arc)) => arc) + let arc = chosen.at(0) + let blue = graph-colors.at(0) + [ + #problem-def("StrongConnectivityAugmentation")[ + Given a directed graph $G = (V, A)$, a set $C subset.eq (V times V backslash A) times ZZ_(> 0)$ of weighted candidate arcs, and a bound $B in ZZ_(>= 0)$, determine whether there exists a subset $C' subset.eq C$ such that $sum_((u, v, w) in C') w <= B$ and the augmented digraph $(V, A union {(u, v) : (u, v, w) in C'})$ is strongly connected. + ][ + Strong Connectivity Augmentation models network design problems where a partially connected directed communication graph may be repaired by buying additional arcs. Eswaran and Tarjan showed that the unweighted augmentation problem is solvable in linear time, while the weighted variant is substantially harder @eswarantarjan1976. The decision version recorded as ND19 in Garey and Johnson is NP-complete @garey1979. The implementation here uses one binary variable per candidate arc, so brute-force over the candidate set yields a worst-case bound of $O^*(2^m)$ where $m = "num_potential_arcs"$. #footnote[No exact algorithm improving on brute-force is claimed here for the weighted candidate-arc formulation implemented in the codebase.] + + *Example.* The canonical instance has $n = #nv$ vertices, $|A| = #ne$ existing arcs, #candidates.len() weighted candidate arcs, and bound $B = #bound$. The base graph already contains the directed 3-cycle $v_0 -> v_1 -> v_2 -> v_0$ and the strongly connected component on ${v_3, v_4, v_5}$, with only the forward bridge $v_2 -> v_3$ between them. The unique satisfying augmentation under this bound selects the single candidate arc $(v_#arc.at(0), v_#arc.at(1)))$ of weight #arc.at(2), closing the cycle $v_2 -> v_3 -> v_4 -> v_5 -> v_2$ and making every vertex reachable from every other. The all-zero configuration is infeasible because no path returns from ${v_3, v_4, v_5}$ to ${v_0, v_1, v_2}$. + + #figure({ + let verts = ((0, 1), (1.2, 1.6), (1.2, 0.4), (3.4, 1.0), (4.6, 1.5), (4.6, 0.5)) + canvas(length: 1cm, { + for (u, v) in arcs { + draw.line(verts.at(u), verts.at(v), + stroke: 1pt + black, + mark: (end: "straight", scale: 0.4)) + } + draw.line(verts.at(arc.at(0)), verts.at(arc.at(1)), + stroke: 1.6pt + blue, + mark: (end: "straight", scale: 0.45)) + for (k, pos) in verts.enumerate() { + let highlighted = k == arc.at(0) or k == arc.at(1) + g-node(pos, name: "v" + str(k), + fill: if highlighted { blue.transparentize(65%) } else { white }, + label: [$v_#k$]) + } + }) + }, + caption: [Strong Connectivity Augmentation on a #{nv}-vertex digraph. Black arcs are present in $A$; the added candidate arc $(v_#arc.at(0), v_#arc.at(1)))$ is shown in blue. With bound $B = #bound$, this single augmentation makes the digraph strongly connected.], + ) + ] + ] +} #{ let x = load-model-example("MinimumMultiwayCut") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index a2ec9b00e..34fc92a17 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -64,6 +64,17 @@ @book{garey1979 year = {1979} } +@article{eswarantarjan1976, + author = {K. P. Eswaran and Robert E. Tarjan}, + title = {Augmentation Problems}, + journal = {SIAM Journal on Computing}, + volume = {5}, + number = {4}, + pages = {653--665}, + year = {1976}, + doi = {10.1137/0205044} +} + @article{gareyJohnsonStockmeyer1976, author = {Michael R. Garey and David S. Johnson and Larry Stockmeyer}, title = {Some Simplified {NP}-Complete Graph Problems}, diff --git a/docs/src/cli.md b/docs/src/cli.md index 78f34e46c..404c03ef1 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -352,6 +352,7 @@ pred create Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json pred create Factoring --target 21 --bits-m 3 --bits-n 3 -o factoring2.json pred create X3C --universe 9 --sets "0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8" -o x3c.json pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3 --precedence-pairs "0>3,1>3,1>4,2>4" -o mts.json +pred create StrongConnectivityAugmentation --arcs "0>1,1>2,2>0,3>4,4>3,2>3,4>5,5>3" --candidate-arcs "3>0:5,3>1:3,3>2:4,4>0:6,4>1:2,4>2:7,5>0:4,5>1:3,5>2:1,0>3:8,0>4:3,0>5:2,1>3:6,1>4:4,1>5:5,2>4:3,2>5:7,1>0:2" --bound 1 -o sca.json ``` For `LengthBoundedDisjointPaths`, the CLI flag `--bound` maps to the JSON field diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f9fdf0c62..1d49ad69d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -249,6 +249,7 @@ Flags by problem type: LCS --strings FAS --arcs [--weights] [--num-vertices] FVS --arcs [--weights] [--num-vertices] + StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] FlowShopScheduling --task-lengths, --deadline [--num-processors] MinimumTardinessSequencing --n, --deadlines [--precedence-pairs] SCS --strings, --bound [--alphabet-size] @@ -447,6 +448,9 @@ pub struct CreateArgs { /// Total budget for selected potential edges #[arg(long)] pub budget: Option, + /// Candidate weighted arcs for StrongConnectivityAugmentation (e.g., 2>0:1,2>1:3) + #[arg(long)] + pub candidate_arcs: Option, /// Deadlines for MinimumTardinessSequencing (comma-separated, e.g., "5,5,5,3,3") #[arg(long)] pub deadlines: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 477db6bba..8fb2bd823 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -10,7 +10,7 @@ use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExamp use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; use problemreductions::models::graph::{ GraphPartitioning, HamiltonianPath, LengthBoundedDisjointPaths, MinimumMultiwayCut, - MultipleChoiceBranching, SteinerTree, + MultipleChoiceBranching, SteinerTree, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, @@ -78,6 +78,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.pattern.is_none() && args.strings.is_none() && args.arcs.is_none() + && args.candidate_arcs.is_none() && args.potential_edges.is_none() && args.budget.is_none() && args.precedence_pairs.is_none() @@ -288,6 +289,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--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" } "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", + "StrongConnectivityAugmentation" => { + "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" + } "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" } @@ -1422,6 +1426,32 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // StrongConnectivityAugmentation + "StrongConnectivityAugmentation" => { + let usage = "Usage: pred create StrongConnectivityAugmentation --arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1 [--num-vertices N]"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "StrongConnectivityAugmentation requires --arcs\n\n\ + {usage}" + ) + })?; + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + let candidate_arcs = parse_candidate_arcs(args, graph.num_vertices())?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "StrongConnectivityAugmentation requires --bound\n\n\ + {usage}" + ) + })? as i32; + ( + ser( + StrongConnectivityAugmentation::try_new(graph, candidate_arcs, bound) + .map_err(|e| anyhow::anyhow!(e))?, + )?, + resolved_variant.clone(), + ) + } + // MinimumSumMulticenter (p-median) "MinimumSumMulticenter" => { let (graph, n) = parse_graph(args).map_err(|e| { @@ -2280,6 +2310,51 @@ fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { } } +/// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. +fn parse_candidate_arcs( + args: &CreateArgs, + num_vertices: usize, +) -> Result> { + let arcs_str = args.candidate_arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "StrongConnectivityAugmentation requires --candidate-arcs (e.g., \"2>0:1,2>1:3\")" + ) + })?; + + arcs_str + .split(',') + .map(|entry| { + let entry = entry.trim(); + let (arc_part, weight_part) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid candidate arc '{}': expected format u>v:w (e.g., 2>0:1)", + entry + ) + })?; + let parts: Vec<&str> = arc_part.split('>').collect(); + if parts.len() != 2 { + bail!( + "Invalid candidate arc '{}': expected format u>v:w (e.g., 2>0:1)", + entry + ); + } + + let u: usize = parts[0].parse()?; + let v: usize = parts[1].parse()?; + anyhow::ensure!( + u < num_vertices && v < num_vertices, + "candidate arc ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + + let w: i32 = weight_part.parse()?; + Ok((u, v, w)) + }) + .collect() +} + /// Handle `pred create --random ...` fn create_random( args: &CreateArgs, @@ -2613,6 +2688,7 @@ mod tests { arcs: None, potential_edges: None, budget: None, + candidate_arcs: None, deadlines: None, precedence_pairs: None, task_lengths: None, diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 938d8643c..ee607536a 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -184,6 +184,7 @@ mod tests { use problemreductions::models::graph::MaximumIndependentSet; use problemreductions::models::misc::BinPacking; use problemreductions::topology::SimpleGraph; + use serde_json::json; #[test] fn test_load_problem_alias_uses_registry_dispatch() { @@ -208,6 +209,29 @@ mod tests { assert!(loaded.is_err()); } + #[test] + fn test_load_problem_rejects_invalid_strong_connectivity_augmentation_instance() { + let variant = BTreeMap::from([("weight".to_string(), "i32".to_string())]); + let data = json!({ + "graph": { + "inner": { + "edge_property": "directed", + "nodes": [null, null, null], + "node_holes": [], + "edges": [[0, 1, null], [1, 2, null]] + } + }, + "candidate_arcs": [[0, 3, 1]], + "bound": 1 + }); + + let loaded = load_problem("StrongConnectivityAugmentation", &variant, data); + assert!(loaded.is_err()); + let err = loaded.err().unwrap().to_string(); + assert!(err.contains("candidate arc"), "err: {err}"); + assert!(err.contains("num_vertices"), "err: {err}"); + } + #[test] fn test_serialize_any_problem_round_trips_bin_packing() { let problem = BinPacking::new(vec![3i32, 3, 2, 2], 5i32); diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index 4266df37b..c13b45993 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -39,6 +39,7 @@ {"problem":"ShortestCommonSupersequence","variant":{},"instance":{"alphabet_size":3,"bound":4,"strings":[[0,1,2],[1,0,2]]},"samples":[{"config":[1,0,1,2],"metric":true}],"optimal":[{"config":[0,1,0,2],"metric":true},{"config":[1,0,1,2],"metric":true}]}, {"problem":"SpinGlass","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"couplings":[1,1,1,1,1,1,1],"fields":[0,0,0,0,0],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[3,4,null],[0,3,null],[1,3,null],[1,4,null],[2,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0],"metric":{"Valid":-3}}],"optimal":[{"config":[0,0,1,1,0],"metric":{"Valid":-3}},{"config":[0,1,0,0,1],"metric":{"Valid":-3}},{"config":[0,1,0,1,0],"metric":{"Valid":-3}},{"config":[0,1,1,1,0],"metric":{"Valid":-3}},{"config":[1,0,0,0,1],"metric":{"Valid":-3}},{"config":[1,0,1,0,1],"metric":{"Valid":-3}},{"config":[1,0,1,1,0],"metric":{"Valid":-3}},{"config":[1,1,0,0,1],"metric":{"Valid":-3}}]}, {"problem":"SteinerTree","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[2,5,2,1,5,6,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"terminals":[0,2,4]},"samples":[{"config":[1,0,1,1,0,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,0,1],"metric":{"Valid":6}}]}, + {"problem":"StrongConnectivityAugmentation","variant":{"weight":"i32"},"instance":{"bound":1,"candidate_arcs":[[3,0,5],[3,1,3],[3,2,4],[4,0,6],[4,1,2],[4,2,7],[5,0,4],[5,1,3],[5,2,1],[0,3,8],[0,4,3],[0,5,2],[1,3,6],[1,4,4],[1,5,5],[2,4,3],[2,5,7],[1,0,2]],"graph":{"inner":{"edge_property":"directed","edges":[[0,1,null],[1,2,null],[2,0,null],[3,4,null],[4,3,null],[2,3,null],[4,5,null],[5,3,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}}},"samples":[{"config":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"metric":true},{"config":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"metric":false}],"optimal":[{"config":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"metric":true}]}, {"problem":"TravelingSalesman","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,3,2,2,3,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}]}, {"problem":"UndirectedTwoCommodityIntegralFlow","variant":{},"instance":{"capacities":[1,1,2],"graph":{"inner":{"edge_property":"undirected","edges":[[0,2,null],[1,2,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}},"requirement_1":1,"requirement_2":1,"sink_1":3,"sink_2":3,"source_1":0,"source_2":1},"samples":[{"config":[1,0,0,0,0,0,1,0,1,0,1,0],"metric":true}],"optimal":[{"config":[0,0,1,0,1,0,0,0,1,0,1,0],"metric":true},{"config":[1,0,0,0,0,0,1,0,1,0,1,0],"metric":true}]} ], diff --git a/src/lib.rs b/src/lib.rs index 982c0a67a..563f63880 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,7 +48,7 @@ pub mod prelude { BicliqueCover, BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, - SubgraphIsomorphism, + StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index c621d113d..8816f0dc0 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -30,6 +30,7 @@ //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) //! - [`DirectedTwoCommodityIntegralFlow`]: Directed two-commodity integral flow (satisfaction) //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs +//! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; @@ -57,6 +58,7 @@ pub(crate) mod partition_into_triangles; pub(crate) mod rural_postman; pub(crate) mod spin_glass; pub(crate) mod steiner_tree; +pub(crate) mod strong_connectivity_augmentation; pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; pub(crate) mod undirected_two_commodity_integral_flow; @@ -87,6 +89,7 @@ pub use partition_into_triangles::PartitionIntoTriangles; pub use rural_postman::RuralPostman; pub use spin_glass::SpinGlass; pub use steiner_tree::SteinerTree; +pub use strong_connectivity_augmentation::StrongConnectivityAugmentation; pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFlow; @@ -118,5 +121,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Candidate augmenting arcs (u, v, w(u,v)) not already present in G" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on the total added weight" }, + ], + } +} + +/// Strong Connectivity Augmentation. +/// +/// Given a directed graph `G = (V, A)`, weighted candidate arcs not already in +/// `A`, and a bound `B`, determine whether some subset of the candidate arcs +/// has total weight at most `B` and makes the augmented digraph strongly +/// connected. +#[derive(Debug, Clone, Serialize)] +pub struct StrongConnectivityAugmentation { + graph: DirectedGraph, + candidate_arcs: Vec<(usize, usize, W)>, + bound: W::Sum, +} + +impl StrongConnectivityAugmentation { + /// Fallible constructor used by CLI validation and deserialization. + pub fn try_new( + graph: DirectedGraph, + candidate_arcs: Vec<(usize, usize, W)>, + bound: W::Sum, + ) -> Result { + if !matches!( + bound.partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err("bound must be nonnegative".to_string()); + } + + let num_vertices = graph.num_vertices(); + let mut seen_pairs = BTreeSet::new(); + + for (u, v, weight) in &candidate_arcs { + if *u >= num_vertices || *v >= num_vertices { + return Err(format!( + "candidate arc ({}, {}) references vertex >= num_vertices ({})", + u, v, num_vertices + )); + } + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Greater) + ) { + return Err(format!( + "candidate arc ({}, {}) weight must be positive", + u, v + )); + } + if graph.has_arc(*u, *v) { + return Err(format!( + "candidate arc ({}, {}) already exists in the base graph", + u, v + )); + } + if !seen_pairs.insert((*u, *v)) { + return Err(format!("duplicate candidate arc ({}, {})", u, v)); + } + } + + Ok(Self { + graph, + candidate_arcs, + bound, + }) + } + + /// Create a new strong connectivity augmentation instance. + /// + /// # Panics + /// + /// Panics if a candidate arc endpoint is out of range, if a candidate arc + /// already exists in the base graph, or if candidate arcs contain + /// duplicates. + pub fn new( + graph: DirectedGraph, + candidate_arcs: Vec<(usize, usize, W)>, + bound: W::Sum, + ) -> Self { + Self::try_new(graph, candidate_arcs, bound).unwrap_or_else(|msg| panic!("{msg}")) + } + + /// Get the base directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the candidate augmenting arcs. + pub fn candidate_arcs(&self) -> &[(usize, usize, W)] { + &self.candidate_arcs + } + + /// Get the upper bound on the total added weight. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Get the number of vertices in the base graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs in the base graph. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Get the number of potential augmenting arcs. + pub fn num_potential_arcs(&self) -> usize { + self.candidate_arcs.len() + } + + /// Check whether the problem uses non-unit weights. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Check whether a configuration is a satisfying augmentation. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate_config(config) + } + + fn evaluate_config(&self, config: &[usize]) -> bool { + if config.len() != self.candidate_arcs.len() { + return false; + } + + let mut total = W::Sum::zero(); + let mut augmented_arcs = self.graph.arcs(); + + for ((u, v, weight), &selected) in self.candidate_arcs.iter().zip(config.iter()) { + if selected > 1 { + return false; + } + if selected == 1 { + total += weight.to_sum(); + if total > self.bound { + return false; + } + augmented_arcs.push((*u, *v)); + } + } + + DirectedGraph::new(self.graph.num_vertices(), augmented_arcs).is_strongly_connected() + } +} + +impl Problem for StrongConnectivityAugmentation +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "StrongConnectivityAugmentation"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.candidate_arcs.len()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.evaluate_config(config) + } +} + +impl SatisfactionProblem for StrongConnectivityAugmentation where + W: WeightElement + crate::variant::VariantParam +{ +} + +crate::declare_variants! { + default sat StrongConnectivityAugmentation => "2^num_potential_arcs", +} + +#[derive(Deserialize)] +struct StrongConnectivityAugmentationData { + graph: DirectedGraph, + candidate_arcs: Vec<(usize, usize, W)>, + bound: W::Sum, +} + +impl<'de, W> Deserialize<'de> for StrongConnectivityAugmentation +where + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let data = StrongConnectivityAugmentationData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.candidate_arcs, data.bound).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "strong_connectivity_augmentation_i32", + build: || { + let problem = StrongConnectivityAugmentation::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (3, 4), + (4, 3), + (2, 3), + (4, 5), + (5, 3), + ], + ), + vec![ + (3, 0, 5), + (3, 1, 3), + (3, 2, 4), + (4, 0, 6), + (4, 1, 2), + (4, 2, 7), + (5, 0, 4), + (5, 1, 3), + (5, 2, 1), + (0, 3, 8), + (0, 4, 3), + (0, 5, 2), + (1, 3, 6), + (1, 4, 4), + (1, 5, 5), + (2, 4, 3), + (2, 5, 7), + (1, 0, 2), + ], + 1, + ); + + crate::example_db::specs::satisfaction_example( + problem, + vec![ + vec![0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0], + vec![0; 18], + ], + ) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/strong_connectivity_augmentation.rs"] +mod tests; diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs index 3fe0a011b..3e136146f 100644 --- a/src/topology/directed_graph.rs +++ b/src/topology/directed_graph.rs @@ -13,7 +13,7 @@ //! [`MinimumFeedbackVertexSet`]: crate::models::graph::MinimumFeedbackVertexSet //! [`MinimumFeedbackArcSet`]: crate::models::graph::MinimumFeedbackArcSet -use petgraph::algo::toposort; +use petgraph::algo::{kosaraju_scc, toposort}; use petgraph::graph::{DiGraph, NodeIndex}; use petgraph::visit::EdgeRef; use serde::{Deserialize, Serialize}; @@ -143,6 +143,11 @@ impl DirectedGraph { toposort(&self.inner, None).is_ok() } + /// Returns `true` if every vertex can reach every other vertex. + pub fn is_strongly_connected(&self) -> bool { + kosaraju_scc(&self.inner).len() <= 1 + } + /// Check if the subgraph induced by keeping only the given arcs is acyclic (a DAG). /// /// `kept_arcs` is a boolean slice of length `num_arcs()`, where `true` means the arc is kept. diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 66f62c5de..eaf308f1c 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -67,6 +67,23 @@ fn test_find_model_example_exact_cover_by_3_sets() { ); } +#[test] +fn test_find_model_example_strong_connectivity_augmentation() { + let problem = ProblemRef { + name: "StrongConnectivityAugmentation".to_string(), + variant: BTreeMap::from([("weight".to_string(), "i32".to_string())]), + }; + + let example = find_model_example(&problem).expect("SCA example should exist"); + assert_eq!(example.problem, "StrongConnectivityAugmentation"); + assert_eq!(example.variant, problem.variant); + assert!(example.instance.is_object()); + assert!( + !example.optimal.is_empty(), + "canonical example should include satisfying assignments" + ); +} + #[test] fn test_find_rule_example_mvc_to_mis_contains_full_problem_json() { let source = ProblemRef { diff --git a/src/unit_tests/models/graph/strong_connectivity_augmentation.rs b/src/unit_tests/models/graph/strong_connectivity_augmentation.rs new file mode 100644 index 000000000..4231fffac --- /dev/null +++ b/src/unit_tests/models/graph/strong_connectivity_augmentation.rs @@ -0,0 +1,161 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; + +fn issue_graph() -> DirectedGraph { + DirectedGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (3, 4), + (4, 3), + (2, 3), + (4, 5), + (5, 3), + ], + ) +} + +fn issue_candidate_arcs() -> Vec<(usize, usize, i32)> { + vec![ + (3, 0, 5), + (3, 1, 3), + (3, 2, 4), + (4, 0, 6), + (4, 1, 2), + (4, 2, 7), + (5, 0, 4), + (5, 1, 3), + (5, 2, 1), + (0, 3, 8), + (0, 4, 3), + (0, 5, 2), + (1, 3, 6), + (1, 4, 4), + (1, 5, 5), + (2, 4, 3), + (2, 5, 7), + (1, 0, 2), + ] +} + +fn issue_example_yes() -> StrongConnectivityAugmentation { + StrongConnectivityAugmentation::new(issue_graph(), issue_candidate_arcs(), 1) +} + +fn yes_config() -> Vec { + vec![0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] +} + +fn issue_example_already_strongly_connected() -> StrongConnectivityAugmentation { + StrongConnectivityAugmentation::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![(0, 2, 5)], + 0, + ) +} + +#[test] +fn test_strong_connectivity_augmentation_creation() { + let problem = issue_example_yes(); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 8); + assert_eq!(problem.num_potential_arcs(), 18); + assert_eq!(problem.candidate_arcs().len(), 18); + assert_eq!(problem.bound(), &1); + assert_eq!(problem.dims(), vec![2; 18]); + assert!(problem.is_weighted()); +} + +#[test] +fn test_strong_connectivity_augmentation_issue_example_yes() { + let problem = issue_example_yes(); + let config = yes_config(); + + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_strong_connectivity_augmentation_issue_example_no() { + let problem = issue_example_yes(); + assert!(!problem.evaluate(&[0; 18])); +} + +#[test] +fn test_strong_connectivity_augmentation_wrong_length() { + let problem = issue_example_yes(); + assert!(!problem.evaluate(&[0, 1])); + assert!(!problem.is_valid_solution(&[0, 1])); +} + +#[test] +fn test_strong_connectivity_augmentation_already_strongly_connected() { + let problem = issue_example_already_strongly_connected(); + assert_eq!(problem.dims(), vec![2]); + assert!(problem.evaluate(&[0])); + assert!(!problem.evaluate(&[1])); +} + +#[test] +fn test_strong_connectivity_augmentation_serialization() { + let problem = issue_example_yes(); + let json = serde_json::to_string(&problem).unwrap(); + let restored: StrongConnectivityAugmentation = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.graph(), problem.graph()); + assert_eq!(restored.candidate_arcs(), problem.candidate_arcs()); + assert_eq!(restored.bound(), problem.bound()); +} + +#[test] +fn test_strong_connectivity_augmentation_solver() { + let problem = issue_example_yes(); + let solver = BruteForce::new(); + + let satisfying = solver.find_satisfying(&problem).unwrap(); + assert!(problem.evaluate(&satisfying)); + + let all_satisfying = solver.find_all_satisfying(&problem); + assert_eq!(all_satisfying, vec![yes_config()]); +} + +#[test] +fn test_strong_connectivity_augmentation_variant() { + let variant = as Problem>::variant(); + assert_eq!(variant, vec![("weight", "i32")]); +} + +#[test] +#[should_panic(expected = "candidate arc (0, 1) already exists in the base graph")] +fn test_strong_connectivity_augmentation_existing_arc_candidate_panics() { + StrongConnectivityAugmentation::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![(0, 1, 1)], + 1, + ); +} + +#[test] +#[should_panic(expected = "duplicate candidate arc (0, 2)")] +fn test_strong_connectivity_augmentation_duplicate_candidate_arc_panics() { + StrongConnectivityAugmentation::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![(0, 2, 1), (0, 2, 3)], + 3, + ); +} + +#[test] +#[should_panic(expected = "candidate arc (0, 3) references vertex >= num_vertices")] +fn test_strong_connectivity_augmentation_out_of_range_candidate_panics() { + StrongConnectivityAugmentation::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![(0, 3, 1)], + 1, + ); +} diff --git a/src/unit_tests/topology/directed_graph.rs b/src/unit_tests/topology/directed_graph.rs index 859cf6e4e..2a668b39f 100644 --- a/src/unit_tests/topology/directed_graph.rs +++ b/src/unit_tests/topology/directed_graph.rs @@ -97,6 +97,30 @@ fn test_directed_graph_is_dag_self_loop() { assert!(!g.is_dag()); } +#[test] +fn test_is_strongly_connected_cycle() { + let g = DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]); + assert!(g.is_strongly_connected()); +} + +#[test] +fn test_is_strongly_connected_path() { + let g = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); + assert!(!g.is_strongly_connected()); +} + +#[test] +fn test_is_strongly_connected_single_vertex() { + let g = DirectedGraph::new(1, vec![]); + assert!(g.is_strongly_connected()); +} + +#[test] +fn test_is_strongly_connected_empty() { + let g = DirectedGraph::empty(0); + assert!(g.is_strongly_connected()); +} + #[test] fn test_directed_graph_is_acyclic_subgraph() { // Cycle: 0->1->2->0 diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index af7aff505..fb66a61d6 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -3,7 +3,7 @@ use crate::models::formula::*; use crate::models::graph::*; use crate::models::misc::*; use crate::models::set::*; -use crate::topology::{BipartiteGraph, SimpleGraph}; +use crate::topology::{BipartiteGraph, DirectedGraph, SimpleGraph}; use crate::traits::Problem; use crate::variant::K3; @@ -87,6 +87,14 @@ fn test_all_problems_implement_trait_correctly() { BooleanExpr::constant(true), )]); check_problem_trait(&CircuitSAT::new(circuit), "CircuitSAT"); + check_problem_trait( + &StrongConnectivityAugmentation::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![(0, 2, 1)], + 1, + ), + "StrongConnectivityAugmentation", + ); } #[test]