diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b3a286b6..1a3de150 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -67,6 +67,7 @@ "MaxCut": [Max-Cut], "GraphPartitioning": [Graph Partitioning], "HamiltonianPath": [Hamiltonian Path], + "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], @@ -631,6 +632,62 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co ] ] } +#{ + let x = load-model-example("ShortestWeightConstrainedPath") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + 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 sample = x.samples.at(0) + let path-config = sample.config + let path-edges = edges.enumerate().filter(((idx, _)) => path-config.at(idx) == 1).map(((idx, e)) => e) + let path-order = (0, 2, 3, 5) + let satisfying-count = x.optimal.len() + [ + #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$. + ][ + 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. + + 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 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 #satisfying-count 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: * + for (idx, (u, v)) in edges.enumerate() { + 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 (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: [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.], + ) + ] + ] +} #{ let x = load-model-example("KColoring") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 5fc78c6e..2423c5cd 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -64,6 +64,39 @@ @book{garey1979 year = {1979} } +@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{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{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 6507c61f..ec74e2e1 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -272,6 +272,7 @@ pred create QUBO --matrix "1,0.5;0.5,2" -o qubo.json pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json 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 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 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 diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 3a6e9df8..ba9d6ead 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -615,6 +615,47 @@ } ] }, + { + "name": "ShortestWeightConstrainedPath", + "description": "Find a simple s-t path whose total length and weight stay within given bounds", + "fields": [ + { + "name": "graph", + "type_name": "G", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "edge_lengths", + "type_name": "Vec", + "description": "Edge lengths l: E -> ZZ_(> 0)" + }, + { + "name": "edge_weights", + "type_name": "Vec", + "description": "Edge weights w: E -> ZZ_(> 0)" + }, + { + "name": "source_vertex", + "type_name": "usize", + "description": "Source vertex s" + }, + { + "name": "target_vertex", + "type_name": "usize", + "description": "Target vertex t" + }, + { + "name": "length_bound", + "type_name": "W::Sum", + "description": "Upper bound K on total path length" + }, + { + "name": "weight_bound", + "type_name": "W::Sum", + "description": "Upper bound W on total path weight" + } + ] + }, { "name": "SpinGlass", "description": "Minimize Ising Hamiltonian on a graph", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index c1334cfb..f3287dff 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -471,6 +471,16 @@ "doc_path": "models/misc/struct.ShortestCommonSupersequence.html", "complexity": "alphabet_size ^ bound" }, + { + "name": "ShortestWeightConstrainedPath", + "variant": { + "graph": "SimpleGraph", + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.ShortestWeightConstrainedPath.html", + "complexity": "2^num_edges" + }, { "name": "SpinGlass", "variant": { @@ -549,7 +559,7 @@ }, { "source": 4, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -713,7 +723,7 @@ }, { "source": 21, - "target": 56, + "target": 57, "overhead": [ { "field": "num_elements", @@ -769,7 +779,7 @@ }, { "source": 25, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -1215,7 +1225,7 @@ }, { "source": 49, - "target": 53, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1300,7 +1310,7 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, + "source": 54, "target": 49, "overhead": [ { @@ -1311,7 +1321,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, + "source": 55, "target": 25, "overhead": [ { @@ -1326,8 +1336,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1341,7 +1351,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 57, + "source": 58, "target": 12, "overhead": [ { @@ -1356,7 +1366,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..60595e14 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -216,6 +216,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 @@ -286,6 +287,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, /// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s] #[arg(long)] pub couplings: Option, @@ -310,6 +314,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, @@ -376,6 +386,12 @@ pub struct CreateArgs { /// Upper bound (for RuralPostman or SCS) #[arg(long)] 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 a1444224..ae4d768f 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -25,6 +25,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.couplings.is_none() && args.fields.is_none() && args.clauses.is_none() @@ -35,6 +36,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() @@ -54,6 +57,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.arcs.is_none() @@ -199,6 +204,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { _ => "edge list: 0-1,1-2,2-3", }, "Vec" => "comma-separated: 1,2,3", + "W" | "N" | "W::Sum" | "N::Sum" => "numeric value: 10", "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", "usize" => "integer", @@ -229,6 +235,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" + } "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", @@ -381,6 +390,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(), + ) + } + // IsomorphicSpanningTree (graph + tree) "IsomorphicSpanningTree" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -1298,27 +1371,57 @@ fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result Result> { - match &args.edge_weights { - Some(w) => { - let weights: Vec = w +fn parse_i32_edge_values( + values: Option<&String>, + num_edges: usize, + value_label: &str, +) -> Result> { + match values { + Some(raw) => { + let parsed: Vec = raw .split(',') .map(|s| s.trim().parse::()) .collect::, _>>()?; - if weights.len() != num_edges { + if parsed.len() != num_edges { bail!( - "Expected {} edge weights but got {}", + "Expected {} {} values but got {}", num_edges, - weights.len() + value_label, + parsed.len() ); } - Ok(weights) + Ok(parsed) } 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") +} + /// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. fn parse_couplings(args: &CreateArgs, num_edges: usize) -> Result> { match &args.couplings { diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index f011bad1..932cee47 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3452,6 +3452,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/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index e48eef82..0501ba49 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -29,6 +29,7 @@ {"problem":"QUBO","variant":{"weight":"f64"},"instance":{"matrix":[[-1.0,2.0,0.0],[0.0,-1.0,2.0],[0.0,0.0,-1.0]],"num_vars":3},"samples":[{"config":[1,0,1],"metric":{"Valid":-2.0}}],"optimal":[{"config":[1,0,1],"metric":{"Valid":-2.0}}]}, {"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":"ShortestWeightConstrainedPath","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_lengths":[2,4,3,1,5,4,2,6],"edge_weights":[5,1,2,3,2,3,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,5,null],[4,5,null],[1,4,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}},"length_bound":10,"source_vertex":0,"target_vertex":5,"weight_bound":8},"samples":[{"config":[0,1,0,1,0,1,0,0],"metric":true}],"optimal":[{"config":[0,1,0,1,0,1,0,0],"metric":true},{"config":[1,0,0,0,0,0,1,1],"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":"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}}]} ], diff --git a/src/lib.rs b/src/lib.rs index bceccfe2..37327dbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,7 @@ pub mod prelude { KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, OptimalLinearArrangement, - PartitionIntoTriangles, RuralPostman, TravelingSalesman, + PartitionIntoTriangles, RuralPostman, ShortestWeightConstrainedPath, TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7d76de65..53bd60c2 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -16,6 +16,7 @@ //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) +//! - [`ShortestWeightConstrainedPath`]: Bicriteria simple s-t path with length and weight bounds //! - [`BicliqueCover`]: Biclique cover on bipartite graphs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs @@ -41,6 +42,7 @@ pub(crate) mod minimum_vertex_cover; pub(crate) mod optimal_linear_arrangement; 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 subgraph_isomorphism; pub(crate) mod traveling_salesman; @@ -63,6 +65,7 @@ pub use minimum_vertex_cover::MinimumVertexCover; pub use optimal_linear_arrangement::OptimalLinearArrangement; 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 subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; @@ -83,6 +86,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", + build: || { + 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, + ); + crate::example_db::specs::satisfaction_example( + problem, + vec![vec![0, 1, 0, 1, 0, 1, 0, 0]], + ) + }, + }] +} + +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 8a9da4db..f922d5a2 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, ShortestWeightConstrainedPath, + SpinGlass, SubgraphIsomorphism, TravelingSalesman, }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 1152ffbd..66f62c5d 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -705,12 +705,17 @@ fn verify_rule_fixtures_match_computed() { loaded_rule.solutions.len(), computed_rule.solutions.len(), "solution count mismatch for {} -> {} — regenerate fixtures", + loaded_rule.source.problem, + loaded_rule.target.problem + ); + let label = format!( + "{} -> {}", loaded_rule.source.problem, loaded_rule.target.problem ); - let label = - format!("{} -> {}", loaded_rule.source.problem, loaded_rule.target.problem); - for (loaded_pair, computed_pair) in - loaded_rule.solutions.iter().zip(computed_rule.solutions.iter()) + for (loaded_pair, computed_pair) in loaded_rule + .solutions + .iter() + .zip(computed_rule.solutions.iter()) { let loaded_target_problem = load_dyn( &loaded_rule.target.problem, @@ -718,10 +723,8 @@ fn verify_rule_fixtures_match_computed() { loaded_rule.target.instance.clone(), ) .unwrap_or_else(|e| panic!("{label}: load target: {e}")); - let loaded_energy = - loaded_target_problem.evaluate_dyn(&loaded_pair.target_config); - let computed_energy = - loaded_target_problem.evaluate_dyn(&computed_pair.target_config); + let loaded_energy = loaded_target_problem.evaluate_dyn(&loaded_pair.target_config); + let computed_energy = loaded_target_problem.evaluate_dyn(&computed_pair.target_config); assert_eq!( loaded_energy, computed_energy, "{label}: target energy mismatch — regenerate fixtures" diff --git a/src/unit_tests/export.rs b/src/unit_tests/export.rs index d6c4dc39..8b02dc62 100644 --- a/src/unit_tests/export.rs +++ b/src/unit_tests/export.rs @@ -210,8 +210,7 @@ fn test_write_example_db_uses_one_line_per_example_entry() { "model entry should be serialized as one compact JSON object line" ); assert!( - rule_line.trim().starts_with('{') - && rule_line.trim().trim_end_matches(',').ends_with('}'), + rule_line.trim().starts_with('{') && rule_line.trim().trim_end_matches(',').ends_with('}'), "rule entry should be serialized as one compact JSON object line" ); 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 00000000..0e03b95b --- /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 eee5dc64..86e32025 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -107,6 +107,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( &OptimalLinearArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3), "OptimalLinearArrangement", diff --git a/tests/suites/integration.rs b/tests/suites/integration.rs index 49e43f6f..311a1830 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_satisfiability_solvable() { let problem = Satisfiability::new(