diff --git a/README.md b/README.md index 94d4cb1f..0667e7cd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,13 @@ cd problem-reductions make cli # builds target/release/pred ``` +If you prefer a workspace-local build without installing into your global Cargo bin directory: + +```bash +cargo build -p problemreductions-cli --release +./target/release/pred --version +``` + See the [Getting Started](https://codingthrust.github.io/problem-reductions/getting-started.html) guide for usage examples, the reduction workflow, and [CLI usage](https://codingthrust.github.io/problem-reductions/cli.html). ## MCP Server (AI Integration) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b3a286b6..2d6da26e 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -98,6 +98,7 @@ "SubsetSum": [Subset Sum], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "StrongConnectivityAugmentation": [Strong Connectivity Augmentation], "ShortestCommonSupersequence": [Shortest Common Supersequence], "MinimumSumMulticenter": [Minimum Sum Multicenter], "SubgraphIsomorphism": [Subgraph Isomorphism], @@ -892,6 +893,50 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS ] } +#{ + let x = load-model-example("StrongConnectivityAugmentation") + let nv = graph-num-vertices(x.instance) + let ne = graph-num-edges(x.instance) + 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("MinimumSumMulticenter") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 5fc78c6e..48923f57 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 6507c61f..d7ebd9f9 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -18,6 +18,13 @@ cd problem-reductions make cli # builds target/release/pred ``` +If you prefer a workspace-local build without installing into your global Cargo bin directory: + +```bash +cargo build -p problemreductions-cli --release +./target/release/pred --version +``` + Verify the installation: ```bash @@ -276,6 +283,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 ``` Canonical examples are useful when you want a known-good instance from the paper/example database. diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 3a6e9df8..22999756 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -636,6 +636,27 @@ } ] }, + { + "name": "StrongConnectivityAugmentation", + "description": "Add a bounded set of weighted candidate arcs to make a digraph strongly connected", + "fields": [ + { + "name": "graph", + "type_name": "DirectedGraph", + "description": "The initial directed graph G=(V,A)" + }, + { + "name": "candidate_arcs", + "type_name": "Vec<(usize, usize, W)>", + "description": "Candidate augmenting arcs (u, v, w(u,v)) not already present in G" + }, + { + "name": "bound", + "type_name": "W::Sum", + "description": "Upper bound B on the total added weight" + } + ] + }, { "name": "SubgraphIsomorphism", "description": "Determine if host graph G contains a subgraph isomorphic to pattern graph H", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index c1334cfb..967593c1 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -491,6 +491,15 @@ "doc_path": "models/graph/struct.SpinGlass.html", "complexity": "2^num_spins" }, + { + "name": "StrongConnectivityAugmentation", + "variant": { + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.StrongConnectivityAugmentation.html", + "complexity": "2^num_potential_arcs" + }, { "name": "SubgraphIsomorphism", "variant": {}, @@ -713,7 +722,7 @@ }, { "source": 21, - "target": 56, + "target": 57, "overhead": [ { "field": "num_elements", @@ -1341,7 +1350,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 57, + "source": 58, "target": 12, "overhead": [ { @@ -1356,7 +1365,7 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 57, + "source": 58, "target": 49, "overhead": [ { diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index c42f69c2..12beda2c 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -240,6 +240,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] @@ -385,6 +386,9 @@ pub struct CreateArgs { /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: 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 a1444224..55238bc2 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -1,7 +1,7 @@ use crate::cli::{CreateArgs, ExampleSide}; use crate::dispatch::ProblemJsonOutput; use crate::output::OutputConfig; -use crate::problem_name::{resolve_problem_ref, unknown_problem_error}; +use crate::problem_name::{resolve_create_problem_ref, resolve_problem_ref, unknown_problem_error}; use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; @@ -57,6 +57,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.deadlines.is_none() && args.precedence_pairs.is_none() && args.task_lengths.is_none() @@ -208,6 +209,8 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "Vec" => "comma-separated nonnegative decimal integers: 3,7,1,8", "Vec" => "comma-separated integers: 3,7,1,8", "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", + "Vec<(usize, usize, W)>" => "candidate arcs with weights: 0>2:5,2>1:3", + "W::Sum" => "integer", _ => "value", } } @@ -237,6 +240,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "MinimumSumMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" } + "StrongConnectivityAugmentation" => { + "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" + } "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", "Factoring" => "--target 15 --m 4 --n 4", "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3 --bound 5", @@ -320,13 +326,13 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") })?; let rgraph = problemreductions::rules::ReductionGraph::new(); - let resolved = resolve_problem_ref(problem, &rgraph)?; - let canonical = &resolved.name; + let resolved = resolve_create_problem_ref(problem, &rgraph)?; + let canonical = resolved.name; let resolved_variant = resolved.variant; let graph_type = resolved_graph_type(&resolved_variant); if args.random { - return create_random(args, canonical, &resolved_variant, out); + return create_random(args, &canonical, &resolved_variant, out); } // ILP and CircuitSAT have complex input structures not suited for CLI flags. @@ -347,7 +353,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { } else { None }; - print_problem_help(canonical, gt)?; + print_problem_help(&canonical, gt)?; std::process::exit(2); } @@ -357,7 +363,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { | "MinimumVertexCover" | "MaximumClique" | "MinimumDominatingSet" => { - create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)? + create_vertex_weight_problem(args, &canonical, graph_type, &resolved_variant)? } // Graph partitioning (graph only, no weights) @@ -563,7 +569,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // MaximalIS — same as MIS (graph + vertex weights) "MaximalIS" => { - create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)? + create_vertex_weight_problem(args, &canonical, graph_type, &resolved_variant)? } // BinPacking @@ -930,6 +936,27 @@ 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 = parse_nonnegative_i32_bound(args, "StrongConnectivityAugmentation", usage)?; + ( + 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| { @@ -1086,11 +1113,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } - _ => bail!("{}", crate::problem_name::unknown_problem_error(canonical)), + _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)), }; let output = ProblemJsonOutput { - problem_type: canonical.to_string(), + problem_type: canonical, variant, data, }; @@ -1565,6 +1592,61 @@ 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 weight: i32 = weight_part.parse()?; + Ok((u, v, weight)) + }) + .collect() +} + +fn parse_nonnegative_i32_bound(args: &CreateArgs, problem: &str, usage: &str) -> Result { + let raw_bound = args + .bound + .ok_or_else(|| anyhow::anyhow!("{problem} requires --bound\n\n{usage}"))?; + let bound = + i32::try_from(raw_bound).map_err(|_| anyhow::anyhow!("{problem} bound must fit in i32"))?; + anyhow::ensure!(bound >= 0, "{problem} bound must be nonnegative"); + Ok(bound) +} + /// Handle `pred create --random ...` fn create_random( args: &CreateArgs, diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 659bba48..cb4feda4 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -180,6 +180,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() { @@ -204,6 +205,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/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 2f7ee789..adbea497 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -1,3 +1,4 @@ +use problemreductions::export::ProblemRef; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; @@ -39,6 +40,28 @@ pub fn resolve_catalog_problem_ref( .map_err(|e| anyhow::anyhow!("{e}")) } +/// Resolve a problem ref for `pred create`. +/// +/// Prefer reduction-graph-backed variants for problems already present in the +/// graph so `pred create` only emits loadable variants. Fall back to catalog +/// resolution for catalog-only problems that have not yet been connected by +/// reductions. +pub fn resolve_create_problem_ref( + input: &str, + graph: &problemreductions::rules::ReductionGraph, +) -> anyhow::Result { + let spec = parse_problem_spec(input)?; + if graph.variants_for(&spec.name).is_empty() { + let resolved = resolve_catalog_problem_ref(input)?; + return Ok(ProblemRef { + name: resolved.name().to_string(), + variant: resolved.variant().clone(), + }); + } + + resolve_problem_ref(input, graph) +} + /// Parse a problem spec string like "MIS/UnitDiskGraph/i32" into name + variant values. pub fn parse_problem_spec(input: &str) -> anyhow::Result { let parts: Vec<&str> = input.split('/').collect(); @@ -167,8 +190,6 @@ pub fn resolve_problem_ref( }) } -use problemreductions::export::ProblemRef; - /// A value parser that accepts any string but provides problem names as /// completion candidates for shell completion scripts. #[derive(Clone)] @@ -481,4 +502,19 @@ mod tests { ); assert_eq!(r.variant().get("weight").map(|s| s.as_str()), Some("One")); } + + #[test] + fn resolve_create_problem_ref_uses_catalog_for_problem_missing_from_graph() { + let graph = problemreductions::rules::ReductionGraph::new(); + let r = resolve_create_problem_ref("StrongConnectivityAugmentation", &graph).unwrap(); + assert_eq!(r.name, "StrongConnectivityAugmentation"); + assert_eq!(r.variant.get("weight").map(|s| s.as_str()), Some("i32")); + } + + #[test] + fn resolve_create_problem_ref_rejects_catalog_only_variant_for_graph_problem() { + let graph = problemreductions::rules::ReductionGraph::new(); + let err = resolve_create_problem_ref("MIS/TriangularSubgraph/One", &graph).unwrap_err(); + assert!(err.to_string().contains("Resolved variant")); + } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index f011bad1..89aed175 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -280,6 +280,157 @@ fn test_evaluate_sat() { std::fs::remove_file(&tmp).ok(); } +#[test] +fn test_create_problem_strong_connectivity_augmentation() { + let output = pred() + .args([ + "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", + ]) + .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"], "StrongConnectivityAugmentation"); + assert!(json["data"]["graph"].is_object()); + assert!(json["data"]["candidate_arcs"].is_array()); + assert_eq!(json["data"]["bound"], 1); +} + +#[test] +fn test_create_problem_strong_connectivity_augmentation_rejects_existing_candidate_arc() { + let output = pred() + .args([ + "create", + "StrongConnectivityAugmentation", + "--arcs", + "0>1,1>2", + "--candidate-arcs", + "0>1:5", + "--bound", + "1", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("already exists in the base graph"), + "stderr: {stderr}" + ); + assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); +} + +#[test] +fn test_create_problem_strong_connectivity_augmentation_rejects_negative_weight() { + let output = pred() + .args([ + "create", + "StrongConnectivityAugmentation", + "--arcs", + "0>1,1>2", + "--candidate-arcs", + "2>0:-5", + "--bound", + "0", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("must be positive"), "stderr: {stderr}"); +} + +#[test] +fn test_create_problem_strong_connectivity_augmentation_rejects_negative_bound() { + let output = pred() + .args([ + "create", + "StrongConnectivityAugmentation", + "--arcs", + "0>1,1>2", + "--candidate-arcs", + "2>0:1", + "--bound=-1", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("bound must be nonnegative"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_create_problem_strong_connectivity_augmentation_rejects_oversized_bound() { + let output = pred() + .args([ + "create", + "StrongConnectivityAugmentation", + "--arcs", + "0>1,1>2", + "--candidate-arcs", + "2>0:1", + "--bound", + "3000000000", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("bound must fit in i32"), "stderr: {stderr}"); +} + +#[test] +fn test_create_rejects_catalog_only_variant_for_registered_problem() { + let output = pred() + .args([ + "create", + "MIS/TriangularSubgraph/One", + "--positions", + "0,0;1,0;0,1", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Resolved variant"), "stderr: {stderr}"); +} + #[test] fn test_reduce() { let problem_json = r#"{ diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index e48eef82..5db7f624 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -30,6 +30,7 @@ {"problem":"Satisfiability","variant":{},"instance":{"clauses":[{"literals":[1,2]},{"literals":[-1,3]},{"literals":[-2,-3]}],"num_vars":3},"samples":[{"config":[1,0,1],"metric":true}],"optimal":[{"config":[0,1,0],"metric":true},{"config":[1,0,1],"metric":true}]}, {"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":"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}}]} ], "rules": [ diff --git a/src/lib.rs b/src/lib.rs index bceccfe2..504c4302 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,7 +46,7 @@ pub mod prelude { pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use crate::models::graph::{ BicliqueCover, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, SpinGlass, - 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 7d76de65..d29428f7 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -21,6 +21,7 @@ //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) +//! - [`StrongConnectivityAugmentation`]: Add weighted arcs to make a digraph strongly connected //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) pub(crate) mod biclique_cover; @@ -42,6 +43,7 @@ pub(crate) mod optimal_linear_arrangement; pub(crate) mod partition_into_triangles; pub(crate) mod rural_postman; pub(crate) mod spin_glass; +pub(crate) mod strong_connectivity_augmentation; pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; @@ -64,6 +66,7 @@ pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partition_into_triangles::PartitionIntoTriangles; pub use rural_postman::RuralPostman; pub use spin_glass::SpinGlass; +pub use strong_connectivity_augmentation::StrongConnectivityAugmentation; pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; @@ -86,5 +89,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/models/mod.rs b/src/models/mod.rs index 8a9da4db..a1146178 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -15,8 +15,8 @@ pub use graph::{ BicliqueCover, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, - OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, SpinGlass, SubgraphIsomorphism, - TravelingSalesman, + OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, SpinGlass, + StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs index 3fe0a011..3e136146 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 1152ffbd..d558bffe 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 00000000..faa4763c --- /dev/null +++ b/src/unit_tests/models/graph/strong_connectivity_augmentation.rs @@ -0,0 +1,169 @@ +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] +fn test_strong_connectivity_augmentation_paper_example() { + let problem = issue_example_yes(); + let config = yes_config(); + + assert!(problem.evaluate(&config)); +} + +#[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 859cf6e4..2a668b39 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 eee5dc64..a095e934 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -94,6 +94,14 @@ fn test_all_problems_implement_trait_correctly() { ), "MinimumFeedbackArcSet", ); + check_problem_trait( + &StrongConnectivityAugmentation::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![(2, 0, 1i32)], + 1, + ), + "StrongConnectivityAugmentation", + ); check_problem_trait( &MinimumSumMulticenter::new( SimpleGraph::new(3, vec![(0, 1), (1, 2)]),