diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 2132a81ae..a87476d43 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -71,6 +71,7 @@ "HamiltonianCircuit": [Hamiltonian Circuit], "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], + "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], @@ -1020,44 +1021,56 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] } #{ - let x = load-model-example("KthBestSpanningTree") + let x = load-model-example("ShortestWeightConstrainedPath") + let nv = graph-num-vertices(x.instance) let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) - let weights = x.instance.weights - let m = edges.len() - let sol = x.optimal_config - let tree1 = sol.enumerate().filter(((i, v)) => i < m and v == 1).map(((i, _)) => edges.at(i)) - let blue = graph-colors.at(0) - let gray = luma(190) + let lengths = x.instance.edge_lengths + let weights = x.instance.edge_weights + let s = x.instance.source_vertex + let t = x.instance.target_vertex + let K = x.instance.length_bound + let W = x.instance.weight_bound + let path-config = x.optimal_config + let path-edges = edges.enumerate().filter(((idx, _)) => path-config.at(idx) == 1).map(((idx, e)) => e) + let path-order = (0, 2, 3, 5) [ - #problem-def("KthBestSpanningTree")[ - Given an undirected graph $G = (V, E)$ with edge weights $w: E -> ZZ_(gt.eq 0)$, a positive integer $k$, and a bound $B in ZZ_(gt.eq 0)$, determine whether there exist $k$ distinct spanning trees $T_1, dots, T_k subset.eq E$ such that $sum_(e in T_i) w(e) lt.eq B$ for every $i$. + #problem-def("ShortestWeightConstrainedPath")[ + Given an undirected graph $G = (V, E)$ with positive edge lengths $l: E -> ZZ^+$, positive edge weights $w: E -> ZZ^+$, designated vertices $s, t in V$, and bounds $K, W in ZZ^+$, determine whether there exists a simple path $P$ from $s$ to $t$ such that $sum_(e in P) l(e) <= K$ and $sum_(e in P) w(e) <= W$. ][ - Kth Best Spanning Tree is catalogued as ND9 in Garey and Johnson @garey1979 and is marked there with an asterisk because the general problem is NP-hard but not known to lie in NP. For any fixed value of $k$, Lawler's $k$-best enumeration framework gives a polynomial-time algorithm when combined with minimum-spanning-tree subroutines @lawler1972. For output-sensitive enumeration, Eppstein gave an algorithm that lists the $k$ smallest spanning trees of a weighted graph in $O(m log beta(m, n) + k^2)$ time @eppstein1992. + Also called the _restricted shortest path_ or _resource-constrained shortest path_ problem. Garey and Johnson list it as ND30 and show NP-completeness via transformation from Partition @garey1979. The model captures bicriteria routing: one resource measures path length or delay, while the other captures a second consumable budget such as cost, risk, or bandwidth. Because pseudo-polynomial dynamic programming formulations are known @joksch1966, the hardness is weak rather than strong; approximation schemes were later developed by Hassin @hassin1992 and improved by Lorenz and Raz @lorenzraz2001. - Variables: $k |E|$ binary values grouped into $k$ consecutive edge-selection blocks. Entry $x_(i, e) = 1$ means edge $e$ belongs to the $i$-th candidate tree. A configuration is satisfying exactly when each block selects a spanning tree, every selected tree has total weight at most $B$, and the $k$ blocks encode pairwise distinct edge sets. + The implementation catalog reports the natural brute-force complexity of the edge-subset encoding used here: with $m = |E|$ binary variables, exhaustive search over all candidate subsets costs $O^*(2^m)$. A configuration is satisfying precisely when the selected edges form a single simple $s$-$t$ path and both resource sums stay within their bounds. - *Example.* Consider $K_4$ with edge weights $w = {(0,1): 1, (0,2): 1, (0,3): 2, (1,2): 2, (1,3): 2, (2,3): 3}$. With $k = 2$ and $B = 4$, exactly two of the $16$ spanning trees have total weight $lt.eq 4$: the star $T_1 = {(0,1), (0,2), (0,3)}$ with weight $4$ and $T_2 = {(0,1), (0,2), (1,3)}$ with weight $4$. Since two distinct bounded spanning trees exist, this is a YES-instance. + *Example.* Consider the graph on #nv vertices with source $s = v_#s$, target $t = v_#t$, length bound $K = #K$, and weight bound $W = #W$. Edge labels are written as $(l(e), w(e))$. The highlighted path $#path-order.map(v => $v_#v$).join($arrow$)$ uses edges ${#path-edges.map(((u, v)) => $(v_#u, v_#v)$).join(", ")}$, so its total length is $4 + 1 + 4 = 9 <= #K$ and its total weight is $1 + 3 + 3 = 7 <= #W$. This instance has 2 satisfying edge selections; another feasible path is $v_0 arrow v_1 arrow v_4 arrow v_5$. #figure({ + let blue = graph-colors.at(0) + let gray = luma(200) + let verts = ((0, 1), (1.5, 1.8), (1.5, 0.2), (3, 1.8), (3, 0.2), (4.5, 1)) canvas(length: 1cm, { import draw: * - let pos = ((0.0, 1.8), (2.4, 1.8), (2.4, 0.0), (0.0, 0.0)) for (idx, (u, v)) in edges.enumerate() { - let in-tree1 = tree1.any(e => (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u)) - g-edge(pos.at(u), pos.at(v), stroke: if in-tree1 { 2pt + blue } else { 1pt + gray }) - let mid-x = (pos.at(u).at(0) + pos.at(v).at(0)) / 2 - let mid-y = (pos.at(u).at(1) + pos.at(v).at(1)) / 2 - // Offset diagonal edge labels to avoid overlap at center - let (ox, oy) = if u == 0 and v == 2 { (0.3, 0) } else if u == 1 and v == 3 { (-0.3, 0) } else { (0, 0) } - content((mid-x + ox, mid-y + oy), text(7pt)[#weights.at(idx)], fill: white, frame: "rect", padding: .06, stroke: none) + let on-path = path-config.at(idx) == 1 + g-edge(verts.at(u), verts.at(v), stroke: if on-path { 2pt + blue } else { 1pt + gray }) + let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2 + let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2 + let dx = if idx == 7 { -0.25 } else if idx == 5 or idx == 6 { 0.15 } else { 0 } + let dy = if idx == 0 or idx == 2 or idx == 5 { 0.16 } else if idx == 1 or idx == 4 or idx == 6 { -0.16 } else if idx == 7 { 0.12 } else { 0 } + draw.content( + (mx + dx, my + dy), + text(7pt, fill: luma(80))[#("(" + str(int(lengths.at(idx))) + ", " + str(int(weights.at(idx))) + ")")] + ) } - for (idx, p) in pos.enumerate() { - g-node(p, name: "v" + str(idx), fill: white, label: $v_#idx$) + for (k, pos) in verts.enumerate() { + let on-path = path-order.any(v => v == k) + g-node(pos, name: "v" + str(k), + fill: if on-path { blue } else { white }, + label: if on-path { text(fill: white)[$v_#k$] } else { [$v_#k$] }) } }) }, - caption: [Kth Best Spanning Tree on $K_4$. Blue edges show $T_1 = {(0,1), (0,2), (0,3)}$, one of two spanning trees with weight $lt.eq 4$.], - ) + caption: [Shortest Weight-Constrained Path instance with edge labels $(l(e), w(e))$. The highlighted path $v_0 arrow v_2 arrow v_3 arrow v_5$ satisfies both bounds.], + ) ] ] } diff --git a/docs/paper/references.bib b/docs/paper/references.bib index dc2a63d5b..bce4d90d8 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -201,6 +201,39 @@ @article{eswarantarjan1976 doi = {10.1137/0205044} } +@article{hassin1992, + author = {Refael Hassin}, + title = {Approximation Schemes for the Restricted Shortest Path Problem}, + journal = {Mathematics of Operations Research}, + volume = {17}, + number = {1}, + pages = {36--42}, + year = {1992}, + doi = {10.1287/moor.17.1.36} +} + +@article{joksch1966, + author = {Hans C. Joksch}, + title = {The Shortest Route Problem with Constraints}, + journal = {Journal of Mathematical Analysis and Applications}, + volume = {14}, + number = {2}, + pages = {191--197}, + year = {1966}, + doi = {10.1016/0022-247X(66)90002-6} +} + +@article{lorenzraz2001, + author = {Daniel H. Lorenz and Danny Raz}, + title = {A Simple Efficient Approximation Scheme for the Restricted Shortest Path Problem}, + journal = {Operations Research Letters}, + volume = {28}, + number = {5}, + pages = {213--219}, + year = {2001}, + doi = {10.1016/S0167-6377(01)00079-6} +} + @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 622996ad0..452532cf9 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -354,6 +354,7 @@ pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 - pred create SpinGlass --graph 0-1,1-2 -o sg.json pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 1 -o pcenter.json +pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --length-bound 10 --weight-bound 8 -o swcp.json pred create RectilinearPictureCompression --matrix "1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1" --k 2 -o rpc.json pred solve rpc.json --solver brute-force pred create MinimumMultiwayCut --graph 0-1,1-2,2-3,3-0 --terminals 0,2 --edge-weights 3,1,2,4 -o mmc.json diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91a2b98fb..e2bca054e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -217,6 +217,7 @@ TIP: Run `pred create ` (no other flags) to see problem-specific help. Flags by problem type: MIS, MVC, MaxClique, MinDomSet --graph, --weights MaxCut, MaxMatching, TSP --graph, --edge-weights + ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound MaximalIS --graph, --weights SAT, KSAT --num-vars, --clauses [--k] QUBO --matrix @@ -338,6 +339,9 @@ pub struct CreateArgs { /// Edge weights (e.g., 2,3,1) [default: all 1s] #[arg(long)] pub edge_weights: Option, + /// Edge lengths (e.g., 2,3,1) [default: all 1s] + #[arg(long)] + pub edge_lengths: Option, /// Edge capacities for multicommodity flow problems (e.g., 1,1,2) #[arg(long)] pub capacities: Option, @@ -375,6 +379,12 @@ pub struct CreateArgs { /// Number of vertices for random graph generation #[arg(long)] pub num_vertices: Option, + /// Source vertex for path problems + #[arg(long)] + pub source_vertex: Option, + /// Target vertex for path problems + #[arg(long)] + pub target_vertex: Option, /// Edge probability for random graph generation (0.0 to 1.0) [default: 0.5] #[arg(long)] pub edge_prob: Option, @@ -483,6 +493,12 @@ pub struct CreateArgs { /// Upper bound or length bound (for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleCopyFileAllocation, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection) #[arg(long, allow_hyphen_values = true)] pub bound: Option, + /// Upper bound on total path length + #[arg(long)] + pub length_bound: Option, + /// Upper bound on total path weight + #[arg(long)] + pub weight_bound: Option, /// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0) #[arg(long)] pub pattern: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 9fda3b338..878ee9bac 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -46,6 +46,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { args.graph.is_none() && args.weights.is_none() && args.edge_weights.is_none() + && args.edge_lengths.is_none() && args.capacities.is_none() && args.source.is_none() && args.sink.is_none() @@ -60,6 +61,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.m.is_none() && args.n.is_none() && args.num_vertices.is_none() + && args.source_vertex.is_none() + && args.target_vertex.is_none() && args.edge_prob.is_none() && args.seed.is_none() && args.positions.is_none() @@ -94,6 +97,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.tree.is_none() && args.required_edges.is_none() && args.bound.is_none() + && args.length_bound.is_none() + && args.weight_bound.is_none() && args.pattern.is_none() && args.strings.is_none() && args.costs.is_none() @@ -341,6 +346,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "Vec<(Vec, Vec)>" => "semicolon-separated dependencies: \"0,1>2;0,2>3\"", "Vec" => "comma-separated integers: 4,5,3,2,6", "Vec" => "comma-separated: 1,2,3", + "W" | "N" | "W::Sum" | "N::Sum" => "numeric value: 10", "Vec" => "comma-separated indices: 0,2,4", "Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => { "comma-separated weighted edges: 0-2:3,1-3:5" @@ -349,7 +355,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", - "usize" | "W::Sum" => "integer", + "usize" => "integer", "u64" => "integer", "i64" => "integer", "BigUint" => "nonnegative decimal integer", @@ -399,6 +405,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } + "ShortestWeightConstrainedPath" => { + "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --length-bound 10 --weight-bound 8" + } "SteinerTreeInGraphs" => "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --terminals 0,3", "BiconnectivityAugmentation" => { "--graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5" @@ -1019,6 +1028,70 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) } + // ShortestWeightConstrainedPath + "ShortestWeightConstrainedPath" => { + let usage = "pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --length-bound 10 --weight-bound 8"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + if args.weights.is_some() { + bail!( + "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" + ); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" + ) + })?; + let edge_weights_raw = args.edge_weights.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --edge-weights\n\nUsage: {usage}" + ) + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + let edge_weights = + parse_i32_edge_values(Some(edge_weights_raw), graph.num_edges(), "edge weight")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + ensure_positive_i32_values(&edge_weights, "edge weights")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" + ) + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" + ) + })?; + let length_bound = args.length_bound.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --length-bound\n\nUsage: {usage}" + ) + })?; + let weight_bound = args.weight_bound.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --weight-bound\n\nUsage: {usage}" + ) + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + ensure_positive_i32(length_bound, "length_bound")?; + ensure_positive_i32(weight_bound, "weight_bound")?; + ( + ser(ShortestWeightConstrainedPath::new( + graph, + edge_lengths, + edge_weights, + source_vertex, + target_vertex, + length_bound, + weight_bound, + ))?, + resolved_variant.clone(), + ) + } + // MultipleCopyFileAllocation (graph + usage + storage + bound) "MultipleCopyFileAllocation" => { let (graph, num_vertices) = parse_graph(args) @@ -3457,6 +3530,31 @@ fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result, + num_edges: usize, + value_label: &str, +) -> Result> { + match values { + Some(raw) => { + let parsed: Vec = raw + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if parsed.len() != num_edges { + bail!( + "Expected {} {} values but got {}", + num_edges, + value_label, + parsed.len() + ); + } + Ok(parsed) + } + None => Ok(vec![1i32; num_edges]), + } +} + fn parse_vertex_i64_values( raw: Option<&str>, field_name: &str, @@ -3506,25 +3604,30 @@ fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result> Ok(terminals) } -/// Parse `--edge-weights` as edge weights (i32), defaulting to all 1s. -fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { - match &args.edge_weights { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_edges { - bail!( - "Expected {} edge weights but got {}", - num_edges, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_edges]), +fn ensure_positive_i32_values(values: &[i32], label: &str) -> Result<()> { + if values.iter().any(|&value| value <= 0) { + bail!("All {label} must be positive (> 0)"); + } + Ok(()) +} + +fn ensure_positive_i32(value: i32, label: &str) -> Result<()> { + if value <= 0 { + bail!("{label} must be positive (> 0)"); } + Ok(()) +} + +fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> Result<()> { + if vertex >= num_vertices { + bail!("{label} {vertex} out of bounds (graph has {num_vertices} vertices)"); + } + Ok(()) +} + +/// Parse `--edge-weights` as per-edge numeric values (i32), defaulting to all 1s. +fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { + parse_i32_edge_values(args.edge_weights.as_ref(), num_edges, "edge weight") } fn validate_vertex_index( @@ -4946,6 +5049,7 @@ mod tests { graph: None, weights: None, edge_weights: None, + edge_lengths: None, capacities: None, source: None, sink: None, @@ -4957,6 +5061,8 @@ mod tests { matrix: None, k: None, random: false, + source_vertex: None, + target_vertex: None, num_vertices: None, edge_prob: None, seed: None, @@ -4994,6 +5100,8 @@ mod tests { tree: None, required_edges: None, bound: None, + length_bound: None, + weight_bound: None, pattern: None, strings: None, arcs: None, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 2a1ee9425..a6aa1b924 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -6702,6 +6702,280 @@ fn test_create_bare_mis_default_variant() { assert_eq!(json["type"], "MaximumIndependentSet"); } +#[test] +fn test_create_shortest_weight_constrained_path() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-lengths", + "2,4,3,1,5,4,2,6", + "--edge-weights", + "5,1,2,3,2,3,1,1", + "--source-vertex", + "0", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .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"], "ShortestWeightConstrainedPath"); + assert_eq!(json["data"]["source_vertex"], 0); + assert_eq!(json["data"]["target_vertex"], 5); + assert_eq!(json["data"]["length_bound"], 10); + assert_eq!(json["data"]["weight_bound"], 8); +} + +#[test] +fn test_create_shortest_weight_constrained_path_missing_source_vertex() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-lengths", + "2,4,3,1,5,4,2,6", + "--edge-weights", + "5,1,2,3,2,3,1,1", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--source-vertex"), "stderr: {stderr}"); +} + +#[test] +fn test_create_shortest_weight_constrained_path_edge_length_count_mismatch() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-lengths", + "2,4,3,1,5,4,2", + "--edge-weights", + "5,1,2,3,2,3,1,1", + "--source-vertex", + "0", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Expected 8 edge length values but got 7"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_create_shortest_weight_constrained_path_no_flags_shows_vector_hints() { + let output = pred() + .args(["create", "ShortestWeightConstrainedPath"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "should exit non-zero when showing help" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--edge-lengths"), + "expected '--edge-lengths' in help output, got: {stderr}" + ); + assert!( + stderr.match_indices("comma-separated: 1,2,3").count() >= 2, + "expected vector hints for edge lengths and weights, got: {stderr}" + ); + assert!( + stderr.match_indices("numeric value: 10").count() >= 2, + "expected numeric hints for length and weight bounds, got: {stderr}" + ); +} + +#[test] +fn test_create_shortest_weight_constrained_path_rejects_out_of_bounds_source_vertex() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-lengths", + "2,4,3,1,5,4,2,6", + "--edge-weights", + "5,1,2,3,2,3,1,1", + "--source-vertex", + "9", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("source_vertex 9 out of bounds"), + "stderr: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "out-of-bounds input should produce a normal CLI error, got: {stderr}" + ); +} + +#[test] +fn test_create_shortest_weight_constrained_path_requires_edge_lengths() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-weights", + "5,1,2,3,2,3,1,1", + "--source-vertex", + "0", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ShortestWeightConstrainedPath requires --edge-lengths"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_create_shortest_weight_constrained_path_rejects_weights_flag_typo() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-lengths", + "2,4,3,1,5,4,2,6", + "--weights", + "5,1,2,3,2,3,1,1", + "--source-vertex", + "0", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("uses --edge-weights, not --weights"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_create_shortest_weight_constrained_path_rejects_non_positive_edge_lengths() { + let output = pred() + .args([ + "create", + "ShortestWeightConstrainedPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4", + "--edge-lengths=-2,4,3,1,5,4,2,6", + "--edge-weights", + "5,1,2,3,2,3,1,1", + "--source-vertex", + "0", + "--target-vertex", + "5", + "--length-bound", + "10", + "--weight-bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("All edge lengths must be positive (> 0)"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_show_shortest_weight_constrained_path_uses_weight_schema_type_names() { + let output = pred() + .args(["show", "ShortestWeightConstrainedPath"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("edge_lengths (Vec)"), + "expected Vec schema type for edge_lengths, got: {stdout}" + ); + assert!( + stdout.contains("edge_weights (Vec)"), + "expected Vec schema type for edge_weights, got: {stdout}" + ); + assert!( + stdout.contains("length_bound (W::Sum)"), + "expected W::Sum schema type for length_bound, got: {stdout}" + ); + assert!( + stdout.contains("weight_bound (W::Sum)"), + "expected W::Sum schema type for weight_bound, got: {stdout}" + ); +} + // ---- Show JSON includes default annotation ---- #[test] diff --git a/src/lib.rs b/src/lib.rs index 11e208b48..1c051bb98 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,8 +58,9 @@ pub mod prelude { MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, - PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, SteinerTreeInGraphs, - TravelingSalesman, UndirectedTwoCommodityIntegralFlow, + PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, + ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, + UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7bf48195e..a1973da55 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -20,6 +20,7 @@ //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`MinimumMultiwayCut`]: Minimum weight multiway cut //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) +//! - [`ShortestWeightConstrainedPath`]: Bicriteria simple s-t path with length and weight bounds //! - [`PartitionIntoPathsOfLength2`]: Partition vertices into triples with at least two edges each //! - [`BicliqueCover`]: Biclique cover on bipartite graphs //! - [`SteinerTreeInGraphs`]: Minimum weight Steiner tree connecting terminal vertices @@ -72,6 +73,7 @@ pub(crate) mod optimal_linear_arrangement; pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod partition_into_triangles; pub(crate) mod rural_postman; +pub(crate) mod shortest_weight_constrained_path; pub(crate) mod spin_glass; pub(crate) mod steiner_tree; pub(crate) mod steiner_tree_in_graphs; @@ -112,6 +114,7 @@ pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use partition_into_triangles::PartitionIntoTriangles; pub use rural_postman::RuralPostman; +pub use shortest_weight_constrained_path::ShortestWeightConstrainedPath; pub use spin_glass::SpinGlass; pub use steiner_tree::SteinerTree; pub use steiner_tree_in_graphs::SteinerTreeInGraphs; @@ -144,6 +147,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Edge lengths l: E -> ZZ_(> 0)" }, + FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, + FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, + FieldInfo { name: "length_bound", type_name: "W::Sum", description: "Upper bound K on total path length" }, + FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound W on total path weight" }, + ], + } +} + +/// The Shortest Weight-Constrained Path problem. +/// +/// Given a graph G = (V, E) with positive edge lengths l(e) and edge weights +/// w(e), designated vertices s and t, and bounds K and W, determine whether +/// there exists a simple path from s to t with total length at most K and +/// total weight at most W. +/// +/// # Representation +/// +/// Each edge is assigned a binary variable: +/// - 0: edge is not in the selected path +/// - 1: edge is in the selected path +/// +/// A valid configuration must: +/// - form a single simple path from `source_vertex` to `target_vertex` +/// - use only edges present in the graph +/// - satisfy both the length and weight bounds +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// * `N` - The edge length / weight type (e.g., `i32`, `f64`) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShortestWeightConstrainedPath { + /// The underlying graph. + graph: G, + /// Length for each edge in graph-edge order. + edge_lengths: Vec, + /// Weight for each edge in graph-edge order. + edge_weights: Vec, + /// Source vertex s. + source_vertex: usize, + /// Target vertex t. + target_vertex: usize, + /// Upper bound K on total path length. + length_bound: N::Sum, + /// Upper bound W on total path weight. + weight_bound: N::Sum, +} + +impl ShortestWeightConstrainedPath { + fn assert_positive_edge_values(values: &[N], label: &str) { + let zero = N::Sum::zero(); + assert!( + values.iter().all(|value| value.to_sum() > zero.clone()), + "All {label} must be positive (> 0)" + ); + } + + fn assert_positive_bound(bound: &N::Sum, label: &str) { + let zero = N::Sum::zero(); + assert!(bound > &zero, "{label} must be positive (> 0)"); + } + + /// Create a new ShortestWeightConstrainedPath instance. + /// + /// # Panics + /// + /// Panics if either edge vector length does not match the graph's edge + /// count, or if the source / target vertices are out of bounds. + #[allow(clippy::too_many_arguments)] + pub fn new( + graph: G, + edge_lengths: Vec, + edge_weights: Vec, + source_vertex: usize, + target_vertex: usize, + length_bound: N::Sum, + weight_bound: N::Sum, + ) -> Self { + assert_eq!( + edge_lengths.len(), + graph.num_edges(), + "edge_lengths length must match num_edges" + ); + assert_eq!( + edge_weights.len(), + graph.num_edges(), + "edge_weights length must match num_edges" + ); + Self::assert_positive_edge_values(&edge_lengths, "edge lengths"); + Self::assert_positive_edge_values(&edge_weights, "edge weights"); + assert!( + source_vertex < graph.num_vertices(), + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ); + assert!( + target_vertex < graph.num_vertices(), + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ); + Self::assert_positive_bound(&length_bound, "length_bound"); + Self::assert_positive_bound(&weight_bound, "weight_bound"); + Self { + graph, + edge_lengths, + edge_weights, + source_vertex, + target_vertex, + length_bound, + weight_bound, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the edge lengths. + pub fn edge_lengths(&self) -> &[N] { + &self.edge_lengths + } + + /// Get the edge weights. + pub fn edge_weights(&self) -> &[N] { + &self.edge_weights + } + + /// Set new edge lengths. + pub fn set_lengths(&mut self, edge_lengths: Vec) { + assert_eq!( + edge_lengths.len(), + self.graph.num_edges(), + "edge_lengths length must match num_edges" + ); + Self::assert_positive_edge_values(&edge_lengths, "edge lengths"); + self.edge_lengths = edge_lengths; + } + + /// Set new edge weights. + pub fn set_weights(&mut self, edge_weights: Vec) { + assert_eq!( + edge_weights.len(), + self.graph.num_edges(), + "edge_weights length must match num_edges" + ); + Self::assert_positive_edge_values(&edge_weights, "edge weights"); + self.edge_weights = edge_weights; + } + + /// Get the source vertex. + pub fn source_vertex(&self) -> usize { + self.source_vertex + } + + /// Get the target vertex. + pub fn target_vertex(&self) -> usize { + self.target_vertex + } + + /// Get the length bound. + pub fn length_bound(&self) -> &N::Sum { + &self.length_bound + } + + /// Get the weight bound. + pub fn weight_bound(&self) -> &N::Sum { + &self.weight_bound + } + + /// Check whether this problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !N::IS_UNIT + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check if a configuration is a valid constrained s-t path. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if config.len() != self.graph.num_edges() || config.iter().any(|&value| value > 1) { + return false; + } + + if self.source_vertex == self.target_vertex { + if config.contains(&1) { + return false; + } + let zero = N::Sum::zero(); + return zero <= self.length_bound.clone() && zero <= self.weight_bound.clone(); + } + + let edges = self.graph.edges(); + let mut degree = vec![0usize; self.graph.num_vertices()]; + let mut adjacency = vec![Vec::new(); self.graph.num_vertices()]; + let mut selected_edge_count = 0usize; + let mut total_length = N::Sum::zero(); + let mut total_weight = N::Sum::zero(); + + for (idx, &selected) in config.iter().enumerate() { + if selected == 0 { + continue; + } + let (u, v) = edges[idx]; + degree[u] += 1; + degree[v] += 1; + adjacency[u].push(v); + adjacency[v].push(u); + selected_edge_count += 1; + total_length += self.edge_lengths[idx].to_sum(); + total_weight += self.edge_weights[idx].to_sum(); + } + + if selected_edge_count == 0 { + return false; + } + + if total_length > self.length_bound.clone() || total_weight > self.weight_bound.clone() { + return false; + } + + if degree[self.source_vertex] != 1 || degree[self.target_vertex] != 1 { + return false; + } + + for (vertex, &vertex_degree) in degree.iter().enumerate() { + if vertex == self.source_vertex || vertex == self.target_vertex { + continue; + } + if vertex_degree != 0 && vertex_degree != 2 { + return false; + } + } + + let mut visited = vec![false; self.graph.num_vertices()]; + let mut queue = VecDeque::new(); + visited[self.source_vertex] = true; + queue.push_back(self.source_vertex); + + while let Some(vertex) = queue.pop_front() { + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + if !visited[self.target_vertex] { + return false; + } + + let used_vertex_count = degree + .iter() + .filter(|&&vertex_degree| vertex_degree > 0) + .count(); + for (vertex, &vertex_degree) in degree.iter().enumerate() { + if vertex_degree > 0 && !visited[vertex] { + return false; + } + } + + used_vertex_count == selected_edge_count + 1 + } +} + +impl Problem for ShortestWeightConstrainedPath +where + G: Graph + crate::variant::VariantParam, + N: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "ShortestWeightConstrainedPath"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, N] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for ShortestWeightConstrainedPath +where + G: Graph + crate::variant::VariantParam, + N: WeightElement + crate::variant::VariantParam, +{ +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "shortest_weight_constrained_path_simplegraph_i32", + instance: Box::new(ShortestWeightConstrainedPath::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (1, 4), + ], + ), + vec![2, 4, 3, 1, 5, 4, 2, 6], + vec![5, 1, 2, 3, 2, 3, 1, 1], + 0, + 5, + 10, + 8, + )), + optimal_config: vec![0, 1, 0, 1, 0, 1, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +crate::declare_variants! { + default sat ShortestWeightConstrainedPath => "2^num_edges", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/shortest_weight_constrained_path.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 6da7a4afd..61f21e9f8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -25,8 +25,9 @@ pub use graph::{ MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, - SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, - SubgraphIsomorphism, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, + ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, + StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, + UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs new file mode 100644 index 000000000..0e03b95b9 --- /dev/null +++ b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs @@ -0,0 +1,203 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +fn issue_problem() -> ShortestWeightConstrainedPath { + ShortestWeightConstrainedPath::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (1, 4), + ], + ), + vec![2, 4, 3, 1, 5, 4, 2, 6], + vec![5, 1, 2, 3, 2, 3, 1, 1], + 0, + 5, + 10, + 8, + ) +} + +#[test] +fn test_shortest_weight_constrained_path_creation() { + let problem = issue_problem(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 8); + assert_eq!(problem.source_vertex(), 0); + assert_eq!(problem.target_vertex(), 5); + assert_eq!(*problem.length_bound(), 10); + assert_eq!(*problem.weight_bound(), 8); + assert_eq!(problem.dims(), vec![2; 8]); + assert!(problem.is_weighted()); +} + +#[test] +fn test_shortest_weight_constrained_path_evaluation() { + let problem = issue_problem(); + + assert!(problem.evaluate(&[0, 1, 0, 1, 0, 1, 0, 0])); + assert!(problem.evaluate(&[1, 0, 0, 0, 0, 0, 1, 1])); + assert!(!problem.evaluate(&[0, 1, 0, 1, 1, 1, 0, 0])); + assert!(!problem.evaluate(&[1, 0, 0, 1, 0, 0, 1, 0])); + assert!(!problem.evaluate(&[1, 0, 1, 0, 0, 1, 0, 0])); + assert!(!problem.evaluate(&[0, 1, 0, 0, 1, 0, 1, 0])); +} + +#[test] +fn test_shortest_weight_constrained_path_accessors() { + let mut problem = issue_problem(); + problem.set_lengths(vec![1, 1, 1, 1, 1, 1, 1, 1]); + problem.set_weights(vec![2, 2, 2, 2, 2, 2, 2, 2]); + assert_eq!(problem.edge_lengths(), &[1, 1, 1, 1, 1, 1, 1, 1]); + assert_eq!(problem.edge_weights(), &[2, 2, 2, 2, 2, 2, 2, 2]); +} + +#[test] +fn test_shortest_weight_constrained_path_bruteforce() { + let problem = issue_problem(); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); + + let all = solver.find_all_satisfying(&problem); + assert_eq!(all.len(), 2); + for config in &all { + assert!(problem.evaluate(config)); + } +} + +#[test] +fn test_shortest_weight_constrained_path_no_solution() { + let problem = ShortestWeightConstrainedPath::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (1, 4), + ], + ), + vec![2, 4, 3, 1, 5, 4, 2, 6], + vec![5, 1, 2, 3, 2, 3, 1, 1], + 0, + 5, + 6, + 4, + ); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_shortest_weight_constrained_path_serialization() { + let problem = issue_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: ShortestWeightConstrainedPath = + serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_vertices(), 6); + assert_eq!(restored.num_edges(), 8); + assert_eq!(restored.source_vertex(), 0); + assert_eq!(restored.target_vertex(), 5); + assert_eq!(*restored.length_bound(), 10); + assert_eq!(*restored.weight_bound(), 8); +} + +#[test] +fn test_shortest_weight_constrained_path_problem_name() { + assert_eq!( + as Problem>::NAME, + "ShortestWeightConstrainedPath" + ); +} + +#[test] +fn test_shortestweightconstrainedpath_paper_example() { + let problem = issue_problem(); + assert!(problem.evaluate(&[0, 1, 0, 1, 0, 1, 0, 0])); + + let all = BruteForce::new().find_all_satisfying(&problem); + assert_eq!(all.len(), 2); +} + +#[test] +fn test_shortest_weight_constrained_path_rejects_invalid_configs() { + let problem = issue_problem(); + + assert!(!problem.is_valid_solution(&[0, 1])); + assert!(!problem.is_valid_solution(&[0, 1, 0, 1, 0, 1, 0, 2])); + assert!(!problem.is_valid_solution(&[0, 0, 0, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_shortest_weight_constrained_path_source_equals_target_allows_only_empty_path() { + let problem = ShortestWeightConstrainedPath::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![3, 4], + vec![2, 5], + 1, + 1, + 1, + 1, + ); + + assert!(problem.is_valid_solution(&[0, 0])); + assert!(!problem.is_valid_solution(&[1, 0])); +} + +#[test] +fn test_shortest_weight_constrained_path_rejects_disconnected_selected_edges() { + let problem = ShortestWeightConstrainedPath::new( + SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4), (4, 5), (5, 3)]), + vec![1, 1, 1, 1, 1], + vec![1, 1, 1, 1, 1], + 0, + 2, + 10, + 10, + ); + + assert!(!problem.is_valid_solution(&[1, 1, 1, 1, 1])); +} + +#[test] +#[should_panic(expected = "All edge lengths must be positive (> 0)")] +fn test_shortest_weight_constrained_path_rejects_non_positive_edge_lengths() { + ShortestWeightConstrainedPath::new( + SimpleGraph::new(2, vec![(0, 1)]), + vec![0], + vec![1], + 0, + 1, + 1, + 1, + ); +} + +#[test] +#[should_panic(expected = "length_bound must be positive (> 0)")] +fn test_shortest_weight_constrained_path_rejects_non_positive_bounds() { + ShortestWeightConstrainedPath::new( + SimpleGraph::new(2, vec![(0, 1)]), + vec![1], + vec![1], + 0, + 1, + 0, + 1, + ); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 09f7671cd..f45862e05 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -138,6 +138,18 @@ fn test_all_problems_implement_trait_correctly() { &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), "HamiltonianPath", ); + check_problem_trait( + &ShortestWeightConstrainedPath::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1i32; 2], + vec![1i32; 2], + 0, + 2, + 2, + 2, + ), + "ShortestWeightConstrainedPath", + ); check_problem_trait( &MultipleCopyFileAllocation::new( SimpleGraph::new(3, vec![(0, 1), (1, 2)]), diff --git a/tests/suites/integration.rs b/tests/suites/integration.rs index 14e3d477c..973333f38 100644 --- a/tests/suites/integration.rs +++ b/tests/suites/integration.rs @@ -109,6 +109,35 @@ mod all_problems_solvable { } } + #[test] + fn test_shortest_weight_constrained_path_solvable() { + let problem = ShortestWeightConstrainedPath::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (1, 4), + ], + ), + vec![2, 4, 3, 1, 5, 4, 2, 6], + vec![5, 1, 2, 3, 2, 3, 1, 1], + 0, + 5, + 10, + 8, + ); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); + } + #[test] fn test_biconnectivity_augmentation_solvable() { let problem = BiconnectivityAugmentation::new(