diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b3a286b6..d03713d0 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -68,6 +68,7 @@ "GraphPartitioning": [Graph Partitioning], "HamiltonianPath": [Hamiltonian Path], "IsomorphicSpanningTree": [Isomorphic Spanning Tree], + "KthBestSpanningTree": [Kth Best Spanning Tree], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], "MaximumMatching": [Maximum Matching], @@ -631,6 +632,41 @@ Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel co ] ] } +#{ + let x = load-model-example("KthBestSpanningTree") + let edges = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + let m = edges.len() + let sol = x.optimal.at(0).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) + [ + #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$. + ][ + 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. + + 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. + + *Example.* Consider the 5-vertex graph with weighted edges ${(0,1): 2, (0,2): 3, (1,2): 1, (1,3): 4, (2,3): 2, (2,4): 5, (3,4): 3, (0,4): 6}$. With $k = 3$ and $B = 12$, the spanning trees $T_1 = {(0,1), (1,2), (2,3), (3,4)}$, $T_2 = {(0,1), (1,2), (1,3), (3,4)}$, and $T_3 = {(0,2), (1,2), (2,3), (3,4)}$ have weights $8$, $10$, and $9$, respectively. They are all distinct and all satisfy the bound, so this instance is a YES-instance. + + #figure({ + canvas(length: 1cm, { + let pos = ((0.0, 1.2), (1.1, 2.0), (2.3, 1.2), (1.8, 0.0), (0.3, 0.0)) + for (u, v) in edges { + 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 }) + } + for (idx, p) in pos.enumerate() { + g-node(p, name: "v" + str(idx), fill: white, label: $v_#idx$) + } + }) + }, + caption: [Kth Best Spanning Tree example graph. Blue edges show $T_1 = {(0,1), (1,2), (2,3), (3,4)}$, one of the three bounded spanning trees used to certify the YES-instance for $k = 3$ and $B = 12$.], + ) + ] + ] +} #{ 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..9dcacb4a 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -650,3 +650,25 @@ @article{papadimitriou1982 year = {1982}, doi = {10.1145/322307.322309} } + +@article{lawler1972, + author = {Eugene L. Lawler}, + title = {A Procedure for Computing the $K$ Best Solutions to Discrete Optimization Problems and Its Application to the Shortest Path Problem}, + journal = {Management Science}, + volume = {18}, + number = {7}, + pages = {401--405}, + year = {1972}, + doi = {10.1287/mnsc.18.7.401} +} + +@article{eppstein1992, + author = {David Eppstein}, + title = {Finding the $k$ Smallest Spanning Trees}, + journal = {BIT}, + volume = {32}, + number = {2}, + pages = {237--248}, + year = {1992}, + doi = {10.1007/BF01994880} +} diff --git a/docs/src/cli.md b/docs/src/cli.md index 6507c61f..93f2abf8 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -270,6 +270,7 @@ pred create MIS --graph 0-1,1-2,2-3 --weights 2,1,3,1 -o problem.json pred create SAT --num-vars 3 --clauses "1,2;-1,3" -o sat.json 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 KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3 -o kth.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 Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 3a6e9df8..a6defe3a 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -259,6 +259,32 @@ } ] }, + { + "name": "KthBestSpanningTree", + "description": "Do there exist k distinct spanning trees with total weight at most B?", + "fields": [ + { + "name": "graph", + "type_name": "SimpleGraph", + "description": "The underlying graph G=(V,E)" + }, + { + "name": "weights", + "type_name": "Vec", + "description": "Edge weights w(e) for each edge in E" + }, + { + "name": "k", + "type_name": "usize", + "description": "Number of distinct spanning trees required" + }, + { + "name": "bound", + "type_name": "W::Sum", + "description": "Upper bound B on each spanning tree weight" + } + ] + }, { "name": "LongestCommonSubsequence", "description": "Find the longest string that is a subsequence of every input string", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index c1334cfb..6f24ff27 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -205,6 +205,15 @@ "doc_path": "models/misc/struct.Knapsack.html", "complexity": "2^(num_items / 2)" }, + { + "name": "KthBestSpanningTree", + "variant": { + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.KthBestSpanningTree.html", + "complexity": "2^(num_edges * k)" + }, { "name": "LongestCommonSubsequence", "variant": {}, @@ -549,7 +558,7 @@ }, { "source": 4, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -609,7 +618,7 @@ }, { "source": 12, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -650,7 +659,7 @@ }, { "source": 19, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -676,7 +685,7 @@ }, { "source": 20, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -702,7 +711,7 @@ }, { "source": 21, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -713,7 +722,7 @@ }, { "source": 21, - "target": 56, + "target": 57, "overhead": [ { "field": "num_elements", @@ -724,7 +733,7 @@ }, { "source": 22, - "target": 51, + "target": 52, "overhead": [ { "field": "num_clauses", @@ -743,7 +752,7 @@ }, { "source": 23, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -753,7 +762,7 @@ "doc_path": "rules/knapsack_qubo/index.html" }, { - "source": 24, + "source": 25, "target": 12, "overhead": [ { @@ -768,8 +777,8 @@ "doc_path": "rules/longestcommonsubsequence_ilp/index.html" }, { - "source": 25, - "target": 54, + "source": 26, + "target": 55, "overhead": [ { "field": "num_spins", @@ -783,7 +792,7 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 27, + "source": 28, "target": 12, "overhead": [ { @@ -798,8 +807,8 @@ "doc_path": "rules/maximumclique_ilp/index.html" }, { - "source": 27, - "target": 31, + "source": 28, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -813,8 +822,8 @@ "doc_path": "rules/maximumclique_maximumindependentset/index.html" }, { - "source": 28, - "target": 29, + "source": 29, + "target": 30, "overhead": [ { "field": "num_vertices", @@ -828,8 +837,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 28, - "target": 33, + "source": 29, + "target": 34, "overhead": [ { "field": "num_vertices", @@ -843,8 +852,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 29, - "target": 34, + "source": 30, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -858,8 +867,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 28, + "source": 31, + "target": 29, "overhead": [ { "field": "num_vertices", @@ -873,8 +882,8 @@ "doc_path": "rules/maximumindependentset_gridgraph/index.html" }, { - "source": 30, - "target": 31, + "source": 31, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -888,8 +897,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 30, - "target": 32, + "source": 31, + "target": 33, "overhead": [ { "field": "num_vertices", @@ -903,8 +912,8 @@ "doc_path": "rules/maximumindependentset_triangular/index.html" }, { - "source": 30, - "target": 36, + "source": 31, + "target": 37, "overhead": [ { "field": "num_sets", @@ -918,8 +927,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 27, + "source": 32, + "target": 28, "overhead": [ { "field": "num_vertices", @@ -933,8 +942,8 @@ "doc_path": "rules/maximumindependentset_maximumclique/index.html" }, { - "source": 31, - "target": 38, + "source": 32, + "target": 39, "overhead": [ { "field": "num_sets", @@ -948,8 +957,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 31, - "target": 45, + "source": 32, + "target": 46, "overhead": [ { "field": "num_vertices", @@ -963,8 +972,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 32, - "target": 34, + "source": 33, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -978,8 +987,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 33, - "target": 30, + "source": 34, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -993,8 +1002,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 33, - "target": 34, + "source": 34, + "target": 35, "overhead": [ { "field": "num_vertices", @@ -1008,8 +1017,8 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 34, - "target": 31, + "source": 35, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1023,7 +1032,7 @@ "doc_path": "rules/maximumindependentset_casts/index.html" }, { - "source": 35, + "source": 36, "target": 12, "overhead": [ { @@ -1038,8 +1047,8 @@ "doc_path": "rules/maximummatching_ilp/index.html" }, { - "source": 35, - "target": 38, + "source": 36, + "target": 39, "overhead": [ { "field": "num_sets", @@ -1053,8 +1062,8 @@ "doc_path": "rules/maximummatching_maximumsetpacking/index.html" }, { - "source": 36, - "target": 30, + "source": 37, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -1068,8 +1077,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 36, - "target": 38, + "source": 37, + "target": 39, "overhead": [ { "field": "num_sets", @@ -1083,8 +1092,8 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 37, - "target": 49, + "source": 38, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1094,7 +1103,7 @@ "doc_path": "rules/maximumsetpacking_qubo/index.html" }, { - "source": 38, + "source": 39, "target": 12, "overhead": [ { @@ -1109,8 +1118,8 @@ "doc_path": "rules/maximumsetpacking_ilp/index.html" }, { - "source": 38, - "target": 31, + "source": 39, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1124,8 +1133,8 @@ "doc_path": "rules/maximumindependentset_maximumsetpacking/index.html" }, { - "source": 38, - "target": 37, + "source": 39, + "target": 38, "overhead": [ { "field": "num_sets", @@ -1139,7 +1148,7 @@ "doc_path": "rules/maximumsetpacking_casts/index.html" }, { - "source": 39, + "source": 40, "target": 12, "overhead": [ { @@ -1154,7 +1163,7 @@ "doc_path": "rules/minimumdominatingset_ilp/index.html" }, { - "source": 42, + "source": 43, "target": 12, "overhead": [ { @@ -1169,8 +1178,8 @@ "doc_path": "rules/minimumsetcovering_ilp/index.html" }, { - "source": 45, - "target": 31, + "source": 46, + "target": 32, "overhead": [ { "field": "num_vertices", @@ -1184,8 +1193,8 @@ "doc_path": "rules/minimumvertexcover_maximumindependentset/index.html" }, { - "source": 45, - "target": 42, + "source": 46, + "target": 43, "overhead": [ { "field": "num_sets", @@ -1199,7 +1208,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 49, + "source": 50, "target": 12, "overhead": [ { @@ -1214,8 +1223,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 49, - "target": 53, + "source": 50, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1225,7 +1234,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 51, + "source": 52, "target": 4, "overhead": [ { @@ -1240,7 +1249,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 51, + "source": 52, "target": 16, "overhead": [ { @@ -1255,7 +1264,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 51, + "source": 52, "target": 21, "overhead": [ { @@ -1270,8 +1279,8 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 51, - "target": 30, + "source": 52, + "target": 31, "overhead": [ { "field": "num_vertices", @@ -1285,8 +1294,8 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 51, - "target": 39, + "source": 52, + "target": 40, "overhead": [ { "field": "num_vertices", @@ -1300,8 +1309,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, - "target": 49, + "source": 54, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1311,8 +1320,8 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, - "target": 25, + "source": 55, + "target": 26, "overhead": [ { "field": "num_vertices", @@ -1326,8 +1335,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1341,7 +1350,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 57, + "source": 58, "target": 12, "overhead": [ { @@ -1356,8 +1365,8 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 57, - "target": 49, + "source": 58, + "target": 50, "overhead": [ { "field": "num_vars", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index c42f69c2..d8ed844f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -224,6 +224,7 @@ Flags by problem type: PartitionIntoTriangles --graph GraphPartitioning --graph IsomorphicSpanningTree --graph, --tree + KthBestSpanningTree --graph, --edge-weights, --k, --bound Factoring --target, --m, --n BinPacking --sizes, --capacity SubsetSum --sizes, --target diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a1444224..213ee8fb 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -226,6 +226,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", "HamiltonianPath" => "--graph 0-1,1-2,2-3", "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", + "KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3", "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } @@ -251,6 +252,44 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } } +fn uses_edge_weights_flag(canonical: &str) -> bool { + matches!( + canonical, + "KthBestSpanningTree" + | "MaxCut" + | "MaximumMatching" + | "TravelingSalesman" + | "RuralPostman" + ) +} + +fn help_flag_name(canonical: &str, field_name: &str) -> String { + if field_name == "weights" && uses_edge_weights_flag(canonical) { + "edge-weights".to_string() + } else { + field_name.replace('_', "-") + } +} + +fn reject_vertex_weights_for_edge_weight_problem( + args: &CreateArgs, + canonical: &str, + graph_type: Option<&str>, +) -> Result<()> { + if args.weights.is_some() && uses_edge_weights_flag(canonical) { + bail!( + "{canonical} uses --edge-weights, not --weights.\n\n\ + Usage: pred create {} {}", + match graph_type { + Some(g) => format!("{canonical}/{g}"), + None => canonical.to_string(), + }, + example_for(canonical, graph_type) + ); + } + Ok(()) +} + fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { let is_geometry = matches!( graph_type, @@ -278,7 +317,7 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { let hint = type_format_hint(&field.type_name, graph_type); eprintln!( " --{:<16} {} ({})", - field.name.replace('_', "-"), + help_flag_name(canonical, &field.name), field.description, hint ); @@ -420,8 +459,37 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // KthBestSpanningTree (weighted graph + k + bound) + "KthBestSpanningTree" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3" + ) + })?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + let (k, _variant) = + util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree")?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "KthBestSpanningTree requires --bound\n\n\ + Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3" + ) + })? as i32; + ( + ser(problemreductions::models::graph::KthBestSpanningTree::new( + graph, + edge_weights, + k, + bound, + ))?, + resolved_variant.clone(), + ) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--edge-weights 1,1,1]", @@ -440,6 +508,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // RuralPostman "RuralPostman" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( "{e}\n\nUsage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2 --bound 6" diff --git a/problemreductions-cli/src/util.rs b/problemreductions-cli/src/util.rs index bc63585c..70ac24b5 100644 --- a/problemreductions-cli/src/util.rs +++ b/problemreductions-cli/src/util.rs @@ -65,6 +65,10 @@ pub fn validate_k_param( }, }; + if effective_k == 0 { + bail!("{problem_name}: --k must be positive"); + } + // Build the variant map with the effective k let mut variant = resolved_variant.clone(); variant.insert("k".to_string(), k_variant_str(effective_k).to_string()); @@ -268,3 +272,19 @@ pub fn parse_edge_pairs(s: &str) -> Result> { }) .collect() } + +#[cfg(test)] +mod tests { + use super::validate_k_param; + use std::collections::BTreeMap; + + #[test] + fn test_validate_k_param_rejects_zero() { + let err = validate_k_param(&BTreeMap::new(), Some(0), None, "KthBestSpanningTree") + .expect_err("k=0 should be rejected before problem construction"); + assert!( + err.to_string().contains("positive"), + "unexpected error message: {err}" + ); + } +} diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index f011bad1..0e15e307 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1424,6 +1424,74 @@ fn test_create_kcoloring_missing_k() { assert!(stderr.contains("--k")); } +#[test] +fn test_create_kth_best_spanning_tree_rejects_zero_k() { + let output = pred() + .args([ + "create", + "KthBestSpanningTree", + "--graph", + "0-1,1-2,0-2", + "--edge-weights", + "2,3,1", + "--k", + "0", + "--bound", + "3", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("must be positive"), + "expected positive-k validation error, got: {stderr}" + ); +} + +#[test] +fn test_create_kth_best_spanning_tree_help_uses_edge_weights() { + let output = pred() + .args(["create", "KthBestSpanningTree"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--edge-weights"), + "expected edge-weight help, got: {stderr}" + ); + assert!( + !stderr.contains("\n --weights"), + "vertex-weight flag should not be suggested, got: {stderr}" + ); +} + +#[test] +fn test_create_kth_best_spanning_tree_rejects_vertex_weights_flag() { + let output = pred() + .args([ + "create", + "KthBestSpanningTree", + "--graph", + "0-1,0-2,1-2", + "--weights", + "9,9,9", + "--k", + "1", + "--bound", + "3", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--edge-weights"), + "expected guidance toward edge weights, got: {stderr}" + ); +} + #[test] fn test_evaluate_wrong_config_length() { let problem_file = std::env::temp_dir().join("pred_test_eval_wrong_len.json"); diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index e48eef82..75f5e8ab 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -11,6 +11,7 @@ {"problem":"IsomorphicSpanningTree","variant":{},"instance":{"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]}},"tree":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[0,1,2,3],"metric":true}],"optimal":[{"config":[0,1,2,3],"metric":true},{"config":[0,1,3,2],"metric":true},{"config":[0,2,1,3],"metric":true},{"config":[0,2,3,1],"metric":true},{"config":[0,3,1,2],"metric":true},{"config":[0,3,2,1],"metric":true},{"config":[1,0,2,3],"metric":true},{"config":[1,0,3,2],"metric":true},{"config":[1,2,0,3],"metric":true},{"config":[1,2,3,0],"metric":true},{"config":[1,3,0,2],"metric":true},{"config":[1,3,2,0],"metric":true},{"config":[2,0,1,3],"metric":true},{"config":[2,0,3,1],"metric":true},{"config":[2,1,0,3],"metric":true},{"config":[2,1,3,0],"metric":true},{"config":[2,3,0,1],"metric":true},{"config":[2,3,1,0],"metric":true},{"config":[3,0,1,2],"metric":true},{"config":[3,0,2,1],"metric":true},{"config":[3,1,0,2],"metric":true},{"config":[3,1,2,0],"metric":true},{"config":[3,2,0,1],"metric":true},{"config":[3,2,1,0],"metric":true}]}, {"problem":"KColoring","variant":{"graph":"SimpleGraph","k":"K3"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"num_colors":3},"samples":[{"config":[0,1,1,0,2],"metric":true}],"optimal":[{"config":[0,1,1,0,2],"metric":true},{"config":[0,1,1,2,0],"metric":true},{"config":[0,1,2,0,1],"metric":true},{"config":[0,2,1,0,2],"metric":true},{"config":[0,2,2,0,1],"metric":true},{"config":[0,2,2,1,0],"metric":true},{"config":[1,0,0,1,2],"metric":true},{"config":[1,0,0,2,1],"metric":true},{"config":[1,0,2,1,0],"metric":true},{"config":[1,2,0,1,2],"metric":true},{"config":[1,2,2,0,1],"metric":true},{"config":[1,2,2,1,0],"metric":true},{"config":[2,0,0,1,2],"metric":true},{"config":[2,0,0,2,1],"metric":true},{"config":[2,0,1,2,0],"metric":true},{"config":[2,1,0,2,1],"metric":true},{"config":[2,1,1,0,2],"metric":true},{"config":[2,1,1,2,0],"metric":true}]}, {"problem":"KSatisfiability","variant":{"k":"K3"},"instance":{"clauses":[{"literals":[1,2,3]},{"literals":[-1,-2,3]},{"literals":[1,-2,-3]}],"num_vars":3},"samples":[{"config":[1,0,1],"metric":true}],"optimal":[{"config":[0,0,1],"metric":true},{"config":[0,1,0],"metric":true},{"config":[1,0,0],"metric":true},{"config":[1,0,1],"metric":true},{"config":[1,1,1],"metric":true}]}, + {"problem":"KthBestSpanningTree","variant":{"weight":"i32"},"instance":{"bound":12,"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null],[0,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"k":3,"weights":[2,3,1,4,2,5,3,6]},"samples":[{"config":[1,0,1,0,1,0,1,0,1,0,1,1,0,0,1,0,0,1,1,0,1,0,1,0],"metric":true}],"optimal":[{"config":[1,0,1,0,1,0,1,0,1,0,1,1,0,0,1,0,0,1,1,0,1,0,1,0],"metric":true}]}, {"problem":"MaxCut","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,1,1,1,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,0,1,0],"metric":{"Valid":5}}],"optimal":[{"config":[0,1,1,0,0],"metric":{"Valid":5}},{"config":[0,1,1,0,1],"metric":{"Valid":5}},{"config":[1,0,0,1,0],"metric":{"Valid":5}},{"config":[1,0,0,1,1],"metric":{"Valid":5}}]}, {"problem":"MaximalIS","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[2,3,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[0,1,0,1,0],"metric":{"Valid":2}},{"config":[1,0,1,0,1],"metric":{"Valid":3}}],"optimal":[{"config":[1,0,1,0,1],"metric":{"Valid":3}}]}, {"problem":"MaximumClique","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[0,0,1,1,1],"metric":{"Valid":3}}],"optimal":[{"config":[0,0,1,1,1],"metric":{"Valid":3}}]}, diff --git a/src/lib.rs b/src/lib.rs index bceccfe2..9fed7dc5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,8 +45,8 @@ pub mod prelude { pub use crate::models::algebraic::{BMF, QUBO}; pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use crate::models::graph::{ - BicliqueCover, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, SpinGlass, - SubgraphIsomorphism, + BicliqueCover, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, + KthBestSpanningTree, SpinGlass, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs new file mode 100644 index 00000000..80e001bf --- /dev/null +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -0,0 +1,277 @@ +//! Kth Best Spanning Tree problem implementation. +//! +//! Given a weighted graph, determine whether it contains `k` distinct spanning +//! trees whose total weights are all at most a prescribed bound. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "KthBestSpanningTree", + display_name: "Kth Best Spanning Tree", + aliases: &[], + dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], + module_path: module_path!(), + description: "Do there exist k distinct spanning trees with total weight at most B?", + fields: &[ + FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w(e) for each edge in E" }, + FieldInfo { name: "k", type_name: "usize", description: "Number of distinct spanning trees required" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on each spanning tree weight" }, + ], + } +} + +/// Kth Best Spanning Tree. +/// +/// Given an undirected graph `G = (V, E)`, non-negative edge weights `w(e)`, +/// a positive integer `k`, and a bound `B`, determine whether there are `k` +/// distinct spanning trees of `G` whose total weights are all at most `B`. +/// +/// # Representation +/// +/// A configuration is `k` consecutive binary blocks of length `|E|`. +/// Each block selects the edges of one candidate spanning tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KthBestSpanningTree { + graph: SimpleGraph, + weights: Vec, + k: usize, + bound: W::Sum, +} + +impl KthBestSpanningTree { + /// Create a new KthBestSpanningTree instance. + /// + /// # Panics + /// + /// Panics if the number of weights does not match the number of edges, or + /// if `k` is zero. + pub fn new(graph: SimpleGraph, weights: Vec, k: usize, bound: W::Sum) -> Self { + assert_eq!( + weights.len(), + graph.num_edges(), + "weights length must match graph num_edges" + ); + assert!(k > 0, "k must be positive"); + + Self { + graph, + weights, + k, + bound, + } + } + + /// Get the underlying graph. + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + /// Get the edge weights. + pub fn weights(&self) -> &[W] { + &self.weights + } + + /// Get the requested number of trees. + pub fn k(&self) -> usize { + self.k + } + + /// Get the weight bound. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check whether the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Check whether a configuration satisfies the problem. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate_config(config) + } + + fn block_is_valid_tree(&self, block: &[usize], edges: &[(usize, usize)]) -> bool { + if block.len() != edges.len() || block.iter().any(|&value| value > 1) { + return false; + } + + let num_vertices = self.graph.num_vertices(); + let selected_count = block.iter().filter(|&&value| value == 1).count(); + if selected_count != num_vertices.saturating_sub(1) { + return false; + } + + let mut total_weight = W::Sum::zero(); + let mut adjacency = vec![Vec::new(); num_vertices]; + let mut start = None; + + for (idx, &selected) in block.iter().enumerate() { + if selected == 0 { + continue; + } + total_weight += self.weights[idx].to_sum(); + let (u, v) = edges[idx]; + adjacency[u].push(v); + adjacency[v].push(u); + if start.is_none() { + start = Some(u); + } + } + + if total_weight > self.bound { + return false; + } + + if num_vertices <= 1 { + return true; + } + + let start = match start { + Some(vertex) => vertex, + None => return false, + }; + + let mut visited = vec![false; num_vertices]; + let mut queue = VecDeque::new(); + visited[start] = true; + queue.push_back(start); + + while let Some(vertex) = queue.pop_front() { + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited.into_iter().all(|seen| seen) + } + + fn blocks_are_pairwise_distinct(&self, config: &[usize], block_size: usize) -> bool { + if block_size == 0 { + return self.k == 1; + } + + let blocks: Vec<&[usize]> = config.chunks_exact(block_size).collect(); + for left in 0..blocks.len() { + for right in (left + 1)..blocks.len() { + if blocks[left] == blocks[right] { + return false; + } + } + } + true + } + + fn evaluate_config(&self, config: &[usize]) -> bool { + let block_size = self.graph.num_edges(); + let expected_len = self.k * block_size; + if config.len() != expected_len { + return false; + } + + if block_size == 0 { + return self.k == 1 && self.block_is_valid_tree(config, &[]); + } + + let edges = self.graph.edges(); + let blocks = config.chunks_exact(block_size); + if !blocks.remainder().is_empty() { + return false; + } + + if !self.blocks_are_pairwise_distinct(config, block_size) { + return false; + } + + config + .chunks_exact(block_size) + .all(|block| self.block_is_valid_tree(block, &edges)) + } +} + +impl Problem for KthBestSpanningTree +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "KthBestSpanningTree"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.k * self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.evaluate_config(config) + } +} + +impl SatisfactionProblem for KthBestSpanningTree where + W: WeightElement + crate::variant::VariantParam +{ +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "kth_best_spanning_tree_i32", + build: || { + let graph = SimpleGraph::new( + 5, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (0, 4), + ], + ); + let problem = KthBestSpanningTree::new(graph, vec![2, 3, 1, 4, 2, 5, 3, 6], 3, 12); + let witness = vec![ + 1, 0, 1, 0, 1, 0, 1, 0, // + 1, 0, 1, 1, 0, 0, 1, 0, // + 0, 1, 1, 0, 1, 0, 1, 0, + ]; + crate::example_db::specs::explicit_example( + problem, + vec![witness.clone()], + vec![witness], + ) + }, + }] +} + +crate::declare_variants! { + default sat KthBestSpanningTree => "2^(num_edges * k)", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/kth_best_spanning_tree.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7d76de65..76684432 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -10,6 +10,7 @@ //! - [`MaxCut`]: Maximum cut on weighted graphs //! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) //! - [`IsomorphicSpanningTree`]: Isomorphic spanning tree (satisfaction) +//! - [`KthBestSpanningTree`]: K distinct bounded spanning trees (satisfaction) //! - [`KColoring`]: K-vertex coloring //! - [`PartitionIntoTriangles`]: Partition vertices into triangles //! - [`MaximumMatching`]: Maximum weight matching @@ -28,6 +29,7 @@ pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_path; pub(crate) mod isomorphic_spanning_tree; pub(crate) mod kcoloring; +pub(crate) mod kth_best_spanning_tree; pub(crate) mod max_cut; pub(crate) mod maximal_is; pub(crate) mod maximum_clique; @@ -50,6 +52,7 @@ pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_path::HamiltonianPath; pub use isomorphic_spanning_tree::IsomorphicSpanningTree; pub use kcoloring::KColoring; +pub use kth_best_spanning_tree::KthBestSpanningTree; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; @@ -76,6 +79,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec {} — 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/kth_best_spanning_tree.rs b/src/unit_tests/models/graph/kth_best_spanning_tree.rs new file mode 100644 index 00000000..a146397c --- /dev/null +++ b/src/unit_tests/models/graph/kth_best_spanning_tree.rs @@ -0,0 +1,185 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +fn yes_instance() -> KthBestSpanningTree { + let graph = SimpleGraph::new( + 5, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (0, 4), + ], + ); + let weights = vec![2, 3, 1, 4, 2, 5, 3, 6]; + KthBestSpanningTree::new(graph, weights, 3, 12) +} + +fn no_instance() -> KthBestSpanningTree { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let weights = vec![1, 1, 1]; + KthBestSpanningTree::new(graph, weights, 2, 3) +} + +fn small_yes_instance() -> KthBestSpanningTree { + let graph = SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + let weights = vec![1, 1, 1]; + KthBestSpanningTree::new(graph, weights, 2, 2) +} + +fn yes_witness_config() -> Vec { + vec![ + 1, 0, 1, 0, 1, 0, 1, 0, // {0,1}, {1,2}, {2,3}, {3,4} + 1, 0, 1, 1, 0, 0, 1, 0, // {0,1}, {1,2}, {1,3}, {3,4} + 0, 1, 1, 0, 1, 0, 1, 0, // {0,2}, {1,2}, {2,3}, {3,4} + ] +} + +fn duplicate_tree_config() -> Vec { + vec![ + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, + ] +} + +fn overweight_tree_config() -> Vec { + vec![ + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, + 0, // {0,1}, {0,2}, {1,3}, {2,4} => 14 + ] +} + +#[test] +fn test_kthbestspanningtree_creation() { + let problem = yes_instance(); + + assert_eq!(problem.dims(), vec![2; 24]); + assert_eq!(problem.graph().num_vertices(), 5); + assert_eq!(problem.graph().num_edges(), 8); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_edges(), 8); + assert_eq!(problem.k(), 3); + assert_eq!(problem.weights(), &[2, 3, 1, 4, 2, 5, 3, 6]); + assert_eq!(*problem.bound(), 12); + assert!(problem.is_weighted()); + assert_eq!(KthBestSpanningTree::::NAME, "KthBestSpanningTree"); +} + +#[test] +fn test_kthbestspanningtree_evaluation_yes_instance() { + let problem = yes_instance(); + assert!(problem.evaluate(&yes_witness_config())); + assert!(problem.is_valid_solution(&yes_witness_config())); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_duplicate_trees() { + let problem = yes_instance(); + assert!(!problem.evaluate(&duplicate_tree_config())); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_overweight_tree() { + let problem = yes_instance(); + assert!(!problem.evaluate(&overweight_tree_config())); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_wrong_length_config() { + let problem = yes_instance(); + assert!(!problem.evaluate(&yes_witness_config()[..23])); +} + +#[test] +fn test_kthbestspanningtree_evaluation_rejects_nonbinary_value() { + let problem = yes_instance(); + let mut config = yes_witness_config(); + config[0] = 2; + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_kthbestspanningtree_solver_yes_instance() { + let problem = yes_instance(); + let solver = BruteForce::new(); + + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} + +#[test] +fn test_kthbestspanningtree_solver_no_instance() { + let problem = no_instance(); + let solver = BruteForce::new(); + + assert!(solver.find_satisfying(&problem).is_none()); + assert!(solver.find_all_satisfying(&problem).is_empty()); +} + +#[test] +fn test_kthbestspanningtree_small_exhaustive_search() { + let problem = small_yes_instance(); + let solver = BruteForce::new(); + + let all = solver.find_all_satisfying(&problem); + assert_eq!(all.len(), 6); + assert!(all.iter().all(|config| problem.evaluate(config))); +} + +#[test] +fn test_kthbestspanningtree_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let restored: KthBestSpanningTree = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.num_vertices(), problem.num_vertices()); + assert_eq!(restored.num_edges(), problem.num_edges()); + assert_eq!(restored.k(), problem.k()); + assert_eq!(restored.weights(), problem.weights()); + assert_eq!(restored.bound(), problem.bound()); + assert!(restored.evaluate(&yes_witness_config())); +} + +#[test] +fn test_kthbestspanningtree_paper_example() { + let problem = yes_instance(); + let witness = yes_witness_config(); + + assert!(problem.evaluate(&witness)); + + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem).unwrap(); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_kthbestspanningtree_single_vertex_accepts_single_empty_tree() { + let problem = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 1, 0); + assert!(problem.evaluate(&[])); + assert!(problem.is_valid_solution(&[])); +} + +#[test] +fn test_kthbestspanningtree_single_vertex_rejects_multiple_empty_trees() { + let problem = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 2, 0); + assert!(!problem.evaluate(&[])); +} + +#[test] +#[should_panic(expected = "weights length must match graph num_edges")] +fn test_kthbestspanningtree_creation_rejects_weight_length_mismatch() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let _ = KthBestSpanningTree::new(graph, vec![1], 1, 2); +} + +#[test] +#[should_panic(expected = "k must be positive")] +fn test_kthbestspanningtree_creation_rejects_zero_k() { + let _ = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 0, 0); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index eee5dc64..23dc118b 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -118,6 +118,15 @@ fn test_all_problems_implement_trait_correctly() { ), "IsomorphicSpanningTree", ); + check_problem_trait( + &KthBestSpanningTree::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1, 1, 1], + 1, + 2, + ), + "KthBestSpanningTree", + ); check_problem_trait( &ShortestCommonSupersequence::new(2, vec![vec![0, 1], vec![1, 0]], 3), "ShortestCommonSupersequence",