diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index bd036ad5c..83c5c5f7f 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -118,6 +118,7 @@ "OptimalLinearArrangement": [Optimal Linear Arrangement], "RuralPostman": [Rural Postman], "MixedChinesePostman": [Mixed Chinese Postman], + "StackerCrane": [Stacker Crane], "LongestCommonSubsequence": [Longest Common Subsequence], "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], @@ -3662,6 +3663,66 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("StackerCrane") + let arcs = x.instance.arcs.map(a => (a.at(0), a.at(1))) + let edges = x.instance.edges.map(e => (e.at(0), e.at(1))) + let B = x.instance.bound + let config = x.optimal_config + let positions = ( + (-2.0, 0.9), + (-2.0, -0.9), + (0.0, -1.5), + (2.0, -0.9), + (0.0, 1.5), + (2.0, 0.9), + ) + [ + #problem-def("StackerCrane")[ + Given a mixed graph $G = (V, A, E)$ with directed arcs $A$, undirected edges $E$, nonnegative lengths $l: A union E -> ZZ_(gt.eq 0)$, and a bound $B in ZZ^+$, determine whether there exists a closed walk in $G$ that traverses every arc in $A$ in its prescribed direction and has total length at most $B$. + ][ + Stacker Crane is the mixed-graph arc-routing problem listed as ND26 in Garey and Johnson @garey1979. Frederickson, Hecht, and Kim prove the problem NP-complete via reduction from Hamiltonian Circuit and give the classical $9 slash 5$-approximation for the metric case @frederickson1978routing. The problem stays difficult even on trees @fredericksonguan1993. The standard Held-Karp-style dynamic program over (current vertex, covered-arc subset) runs in $O(|V|^2 dot 2^|A|)$ time#footnote[Included as a straightforward exact dynamic-programming baseline over subsets of required arcs; no sharper exact bound was independently verified while preparing this entry.]. + + A configuration is a permutation of the required arcs, interpreted as the order in which those arcs are forced into the tour. The verifier traverses each chosen arc, then inserts the shortest available connector path from that arc's head to the tail of the next arc, wrapping around at the end to close the walk. + + *Example.* The canonical instance has 6 vertices, 5 required arcs, 7 undirected edges, and bound $B = #B$. The witness configuration $[#config.map(str).join(", ")]$ orders the required arcs as $a_0, a_2, a_1, a_4, a_3$. Traversing those arcs contributes 17 units of required-arc length, and the shortest connector paths contribute $1 + 1 + 1 + 0 + 0 = 3$, so the resulting closed walk has total length $20 = B$. Reducing the bound to 19 makes the same instance unsatisfiable. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o stacker-crane.json", + "pred solve stacker-crane.json --solver brute-force", + "pred evaluate stacker-crane.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + let blue = graph-colors.at(0) + let gray = luma(200) + + for (u, v) in edges { + line(positions.at(u), positions.at(v), stroke: (paint: gray, thickness: 0.7pt)) + } + + for (i, (u, v)) in arcs.enumerate() { + line(positions.at(u), positions.at(v), stroke: (paint: blue, thickness: 1.7pt)) + let mid = ( + (positions.at(u).at(0) + positions.at(v).at(0)) / 2, + (positions.at(u).at(1) + positions.at(v).at(1)) / 2, + ) + content(mid, text(6pt, fill: blue)[$a_#i$], fill: white, frame: "rect", padding: 0.05, stroke: none) + } + + for (i, pos) in positions.enumerate() { + circle(pos, radius: 0.18, fill: white, stroke: 0.6pt + black) + content(pos, text(7pt)[$v_#i$]) + } + }), + caption: [Stacker Crane hourglass instance. Required directed arcs are shown in blue and labeled $a_0$ through $a_4$; undirected connector edges are gray. The satisfying order $a_0, a_2, a_1, a_4, a_3$ yields total length 20.], + ) + ] + ] +} + #{ let x = load-model-example("SubgraphIsomorphism") let nv-host = x.instance.host_graph.num_vertices diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 6681affce..40833d6ac 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1068,6 +1068,27 @@ @article{frederickson1979 doi = {10.1145/322139.322150} } +@article{frederickson1978routing, + author = {Greg N. Frederickson and Matthew S. Hecht and Chul E. Kim}, + title = {Approximation Algorithms for Some Routing Problems}, + journal = {SIAM Journal on Computing}, + volume = {7}, + number = {2}, + pages = {178--193}, + year = {1978}, + doi = {10.1137/0207017} +} + +@article{fredericksonguan1993, + author = {Greg N. Frederickson and Da-Wei Guan}, + title = {Nonpreemptive Ensemble Motion Planning on a Tree}, + journal = {Journal of Algorithms}, + volume = {15}, + number = {1}, + pages = {29--60}, + year = {1993} +} + @article{gottlob2002, author = {Georg Gottlob and Nicola Leone and Francesco Scarcello}, title = {Hypertree Decompositions and Tractable Queries}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 9643be372..d5856d619 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -267,6 +267,7 @@ Flags by problem type: MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-costs, --bound [--num-vertices] RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound + StackerCrane --arcs, --graph, --arc-costs, --edge-lengths, --bound [--num-vertices] MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices] AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] ConsistencyOfDatabaseFrequencyTables --num-objects, --attribute-domains, --frequency-tables [--known-values] @@ -850,4 +851,22 @@ mod tests { assert!(help.contains("--potential-edges")); assert!(help.contains("--budget")); } + + #[test] + fn test_create_help_mentions_stacker_crane_flags() { + let cmd = Cli::command(); + let create = cmd.find_subcommand("create").expect("create subcommand"); + let help = create + .get_after_help() + .expect("create after_help") + .to_string(); + + assert!(help.contains("StackerCrane")); + assert!(help.contains("--arcs")); + assert!(help.contains("--graph")); + assert!(help.contains("--arc-costs")); + assert!(help.contains("--edge-lengths")); + assert!(help.contains("--bound")); + assert!(help.contains("--num-vertices")); + } } diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 65bb31d50..7d526a368 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -590,6 +590,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" } + "StackerCrane" => { + "--arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-costs 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --bound 20 --num-vertices 6" + } "MultipleChoiceBranching" => { "--arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" } @@ -671,6 +674,9 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("PrimeAttributeName", "query_attribute") => return "query".to_string(), ("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), + ("StackerCrane", "edges") => return "graph".to_string(), + ("StackerCrane", "arc_lengths") => return "arc-costs".to_string(), + ("StackerCrane", "edge_lengths") => return "edge-lengths".to_string(), ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), _ => {} @@ -1518,6 +1524,51 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // StackerCrane + "StackerCrane" => { + let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-costs 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --bound 20 --num-vertices 6"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; + let (arcs_graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let (edges_graph, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + anyhow::ensure!( + edges_graph.num_vertices() == num_vertices, + "internal error: inconsistent graph vertex count" + ); + anyhow::ensure!( + num_vertices == arcs_graph.num_vertices(), + "StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}" + ); + let arc_lengths = parse_arc_costs(args, num_arcs)?; + let edge_lengths = parse_i32_edge_values( + args.edge_lengths.as_ref(), + edges_graph.num_edges(), + "edge length", + )?; + let bound_raw = args + .bound + .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --bound\n\n{usage}"))?; + let bound = parse_nonnegative_usize_bound(bound_raw, "StackerCrane", usage)?; + let bound = i32::try_from(bound).map_err(|_| { + anyhow::anyhow!("StackerCrane --bound must fit in i32 (got {bound_raw})\n\n{usage}") + })?; + ( + ser(StackerCrane::try_new( + num_vertices, + arcs_graph.arcs(), + edges_graph.edges(), + arc_lengths, + edge_lengths, + bound, + ) + .map_err(|e| anyhow::anyhow!(e))?)?, + resolved_variant.clone(), + ) + } + // MultipleChoiceBranching "MultipleChoiceBranching" => { let usage = "Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10"; @@ -6016,6 +6067,82 @@ mod tests { std::fs::remove_file(output_path).ok(); } + #[test] + fn test_create_stacker_crane_json() { + let mut args = empty_args(); + args.problem = Some("StackerCrane".to_string()); + args.num_vertices = Some(6); + args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); + args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); + args.arc_costs = Some("3,4,2,5,3".to_string()); + args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); + args.bound = Some(20); + + let output_path = std::env::temp_dir().join("pred_test_create_stacker_crane.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "StackerCrane"); + assert_eq!(json["data"]["num_vertices"], 6); + assert_eq!(json["data"]["bound"], 20); + assert_eq!(json["data"]["arcs"][0], serde_json::json!([0, 4])); + assert_eq!(json["data"]["edge_lengths"][6], 3); + + std::fs::remove_file(output_path).ok(); + } + + #[test] + fn test_create_stacker_crane_rejects_mismatched_arc_lengths() { + let mut args = empty_args(); + args.problem = Some("StackerCrane".to_string()); + args.num_vertices = Some(6); + args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); + args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); + args.arc_costs = Some("3,4,2,5".to_string()); + args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); + args.bound = Some(20); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("Expected 5 arc costs but got 4")); + } + + #[test] + fn test_create_stacker_crane_rejects_out_of_range_vertices() { + let mut args = empty_args(); + args.problem = Some("StackerCrane".to_string()); + args.num_vertices = Some(5); + args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); + args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); + args.arc_costs = Some("3,4,2,5,3".to_string()); + args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); + args.bound = Some(20); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("--num-vertices (5) is too small for the arcs")); + } + #[test] fn test_create_balanced_complete_bipartite_subgraph() { use crate::dispatch::ProblemJsonOutput; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index b904cf85e..6388acdd2 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -132,6 +132,24 @@ fn test_show_balanced_complete_bipartite_subgraph_complexity() { ); } +#[test] +fn test_create_stacker_crane_schema_help_uses_documented_flags() { + let output = pred().args(["create", "StackerCrane"]).output().unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("StackerCrane"), "stderr: {stderr}"); + assert!(stderr.contains("--arcs"), "stderr: {stderr}"); + assert!(stderr.contains("--graph"), "stderr: {stderr}"); + assert!(stderr.contains("--arc-costs"), "stderr: {stderr}"); + assert!(stderr.contains("--edge-lengths"), "stderr: {stderr}"); + assert!(stderr.contains("--bound"), "stderr: {stderr}"); + assert!(stderr.contains("--num-vertices"), "stderr: {stderr}"); + assert!(!stderr.contains("--biedges"), "stderr: {stderr}"); + assert!(!stderr.contains("--arc-lengths"), "stderr: {stderr}"); + assert!(!stderr.contains("--edge-weights"), "stderr: {stderr}"); +} + #[test] fn test_solve_balanced_complete_bipartite_subgraph_suggests_bruteforce() { let tmp = std::env::temp_dir().join("pred_test_bcbs_problem.json"); diff --git a/src/lib.rs b/src/lib.rs index c2bb7220d..38d1ccfb7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,8 +73,8 @@ pub mod prelude { SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, - ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, Term, TimetableDesign, + ShortestCommonSupersequence, StackerCrane, StaffScheduling, StringToStringCorrection, + SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 71267a59d..e3b86f58b 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -20,6 +20,7 @@ //! - [`RectilinearPictureCompression`]: Cover 1-entries with bounded rectangles //! - [`ResourceConstrainedScheduling`]: Schedule unit-length tasks on processors with resource constraints //! - [`SchedulingWithIndividualDeadlines`]: Meet per-task deadlines on parallel processors +//! - [`StackerCrane`]: Route a crane through required arcs within a length bound //! - [`SequencingToMinimizeMaximumCumulativeCost`]: Keep every cumulative schedule cost prefix under a bound //! - [`SequencingToMinimizeWeightedCompletionTime`]: Minimize total weighted completion time //! - [`SequencingToMinimizeWeightedTardiness`]: Decide whether a schedule meets a weighted tardiness bound @@ -57,6 +58,7 @@ mod sequencing_to_minimize_weighted_tardiness; mod sequencing_with_release_times_and_deadlines; mod sequencing_within_intervals; pub(crate) mod shortest_common_supersequence; +mod stacker_crane; mod staff_scheduling; pub(crate) mod string_to_string_correction; mod subset_sum; @@ -91,6 +93,7 @@ pub use sequencing_to_minimize_weighted_tardiness::SequencingToMinimizeWeightedT pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines; pub use sequencing_within_intervals::SequencingWithinIntervals; pub use shortest_common_supersequence::ShortestCommonSupersequence; +pub use stacker_crane::StackerCrane; pub use staff_scheduling::StaffScheduling; pub use string_to_string_correction::StringToStringCorrection; pub use subset_sum::SubsetSum; @@ -114,6 +117,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Required directed arcs that must be traversed" }, + FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Undirected edges available for connector paths" }, + FieldInfo { name: "arc_lengths", type_name: "Vec", description: "Nonnegative lengths of the required directed arcs" }, + FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Nonnegative lengths of the undirected connector edges" }, + FieldInfo { name: "bound", type_name: "i32", description: "Upper bound on the total closed-walk length" }, + ], + } +} + +/// The Stacker Crane problem. +/// +/// A configuration is a permutation of the required arc indices. The walk +/// traverses those arcs in the chosen order, connecting the head of each arc +/// to the tail of the next arc by a shortest path in the mixed graph induced +/// by the required directed arcs together with the undirected edges. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "StackerCraneDef")] +pub struct StackerCrane { + num_vertices: usize, + arcs: Vec<(usize, usize)>, + edges: Vec<(usize, usize)>, + arc_lengths: Vec, + edge_lengths: Vec, + bound: i32, +} + +impl StackerCrane { + /// Create a new Stacker Crane instance. + /// + /// # Panics + /// + /// Panics if the instance data are inconsistent or contain negative + /// lengths or a negative bound. + pub fn new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + edges: Vec<(usize, usize)>, + arc_lengths: Vec, + edge_lengths: Vec, + bound: i32, + ) -> Self { + Self::try_new(num_vertices, arcs, edges, arc_lengths, edge_lengths, bound) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a new Stacker Crane instance, returning validation errors. + pub fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + edges: Vec<(usize, usize)>, + arc_lengths: Vec, + edge_lengths: Vec, + bound: i32, + ) -> Result { + if arc_lengths.len() != arcs.len() { + return Err("arc_lengths length must match arcs length".to_string()); + } + if edge_lengths.len() != edges.len() { + return Err("edge_lengths length must match edges length".to_string()); + } + if bound < 0 { + return Err("bound must be nonnegative".to_string()); + } + for (arc_index, &(tail, head)) in arcs.iter().enumerate() { + if tail >= num_vertices || head >= num_vertices { + return Err(format!( + "arc {arc_index} endpoint out of range for {num_vertices} vertices" + )); + } + } + for (edge_index, &(u, v)) in edges.iter().enumerate() { + if u >= num_vertices || v >= num_vertices { + return Err(format!( + "edge {edge_index} endpoint out of range for {num_vertices} vertices" + )); + } + } + for (arc_index, &length) in arc_lengths.iter().enumerate() { + if length < 0 { + return Err(format!("arc length {arc_index} must be nonnegative")); + } + } + for (edge_index, &length) in edge_lengths.iter().enumerate() { + if length < 0 { + return Err(format!("edge length {edge_index} must be nonnegative")); + } + } + + Ok(Self { + num_vertices, + arcs, + edges, + arc_lengths, + edge_lengths, + bound, + }) + } + + /// Get the number of vertices in the mixed graph. + pub fn num_vertices(&self) -> usize { + self.num_vertices + } + + /// Get the required directed arcs. + pub fn arcs(&self) -> &[(usize, usize)] { + &self.arcs + } + + /// Get the available undirected edges. + pub fn edges(&self) -> &[(usize, usize)] { + &self.edges + } + + /// Get the required arc lengths. + pub fn arc_lengths(&self) -> &[i32] { + &self.arc_lengths + } + + /// Get the undirected edge lengths. + pub fn edge_lengths(&self) -> &[i32] { + &self.edge_lengths + } + + /// Get the upper bound on total walk length. + pub fn bound(&self) -> i32 { + self.bound + } + + /// Get the number of required arcs. + pub fn num_arcs(&self) -> usize { + self.arcs.len() + } + + /// Get the number of undirected edges. + pub fn num_edges(&self) -> usize { + self.edges.len() + } + + fn is_arc_permutation(&self, config: &[usize]) -> bool { + if config.len() != self.num_arcs() { + return false; + } + + let mut seen = vec![false; self.num_arcs()]; + for &arc_index in config { + if arc_index >= self.num_arcs() || seen[arc_index] { + return false; + } + seen[arc_index] = true; + } + + true + } + + fn mixed_graph_adjacency(&self) -> Vec> { + let mut adjacency = vec![Vec::new(); self.num_vertices]; + + for (&(tail, head), &length) in self.arcs.iter().zip(&self.arc_lengths) { + adjacency[tail].push((head, length)); + } + + for (&(u, v), &length) in self.edges.iter().zip(&self.edge_lengths) { + adjacency[u].push((v, length)); + adjacency[v].push((u, length)); + } + + adjacency + } + + fn shortest_path_length( + &self, + adjacency: &[Vec<(usize, i32)>], + source: usize, + target: usize, + ) -> Option { + if source == target { + return Some(0); + } + + let mut dist = vec![i64::MAX; self.num_vertices]; + let mut heap = BinaryHeap::new(); + dist[source] = 0; + heap.push((Reverse(0i64), source)); + + while let Some((Reverse(cost), node)) = heap.pop() { + if cost > dist[node] { + continue; + } + if node == target { + return Some(cost); + } + + for &(next, length) in &adjacency[node] { + let next_cost = cost.checked_add(i64::from(length))?; + if next_cost < dist[next] { + dist[next] = next_cost; + heap.push((Reverse(next_cost), next)); + } + } + } + + None + } + + /// Compute the total closed-walk length induced by a configuration. + /// + /// Returns `None` for invalid permutations, unreachable connector paths, + /// or arithmetic overflow. + pub fn closed_walk_length(&self, config: &[usize]) -> Option { + if !self.is_arc_permutation(config) { + return None; + } + if config.is_empty() { + return Some(0); + } + + let adjacency = self.mixed_graph_adjacency(); + let mut total = 0i64; + + for position in 0..config.len() { + let arc_index = config[position]; + let next_arc_index = config[(position + 1) % config.len()]; + let (_, arc_head) = self.arcs[arc_index]; + let (next_arc_tail, _) = self.arcs[next_arc_index]; + + total = total.checked_add(i64::from(self.arc_lengths[arc_index]))?; + total = total.checked_add(self.shortest_path_length( + &adjacency, + arc_head, + next_arc_tail, + )?)?; + } + + i32::try_from(total).ok() + } +} + +impl Problem for StackerCrane { + const NAME: &'static str = "StackerCrane"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![self.num_arcs(); self.num_arcs()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + matches!(self.closed_walk_length(config), Some(total) if total <= self.bound) + } +} + +impl SatisfactionProblem for StackerCrane {} + +crate::declare_variants! { + default sat StackerCrane => "num_vertices^2 * 2^num_arcs", +} + +#[derive(Debug, Clone, Deserialize)] +struct StackerCraneDef { + num_vertices: usize, + arcs: Vec<(usize, usize)>, + edges: Vec<(usize, usize)>, + arc_lengths: Vec, + edge_lengths: Vec, + bound: i32, +} + +impl TryFrom for StackerCrane { + type Error = String; + + fn try_from(value: StackerCraneDef) -> Result { + Self::try_new( + value.num_vertices, + value.arcs, + value.edges, + value.arc_lengths, + value.edge_lengths, + value.bound, + ) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "stacker_crane", + instance: Box::new(StackerCrane::new( + 6, + vec![(0, 4), (2, 5), (5, 1), (3, 0), (4, 3)], + vec![(0, 1), (1, 2), (2, 3), (3, 5), (4, 5), (0, 3), (1, 5)], + vec![3, 4, 2, 5, 3], + vec![2, 1, 3, 2, 1, 4, 3], + 20, + )), + optimal_config: vec![0, 2, 1, 4, 3], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/stacker_crane.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 2926ed63c..0e535cb7b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -39,7 +39,7 @@ pub use misc::{ ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, + SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, }; pub use set::{ diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index c42f22bac..5350da092 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -80,6 +80,21 @@ fn test_find_model_example_staff_scheduling() { ); } +#[test] +fn test_find_model_example_stacker_crane() { + let problem = ProblemRef { + name: "StackerCrane".to_string(), + variant: BTreeMap::new(), + }; + + let example = find_model_example(&problem).expect("StackerCrane example should exist"); + assert_eq!(example.problem, "StackerCrane"); + assert_eq!(example.variant, problem.variant); + assert_eq!(example.optimal_config, vec![0, 2, 1, 4, 3]); + assert_eq!(example.instance["num_vertices"], 6); + assert_eq!(example.instance["bound"], 20); +} + #[test] fn test_find_model_example_multiprocessor_scheduling() { let problem = ProblemRef { diff --git a/src/unit_tests/models/misc/stacker_crane.rs b/src/unit_tests/models/misc/stacker_crane.rs new file mode 100644 index 000000000..258d45fec --- /dev/null +++ b/src/unit_tests/models/misc/stacker_crane.rs @@ -0,0 +1,162 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +fn issue_problem(bound: i32) -> StackerCrane { + StackerCrane::new( + 6, + vec![(0, 4), (2, 5), (5, 1), (3, 0), (4, 3)], + vec![(0, 1), (1, 2), (2, 3), (3, 5), (4, 5), (0, 3), (1, 5)], + vec![3, 4, 2, 5, 3], + vec![2, 1, 3, 2, 1, 4, 3], + bound, + ) +} + +fn small_problem() -> StackerCrane { + StackerCrane::new( + 3, + vec![(0, 1), (1, 2)], + vec![(0, 2)], + vec![1, 1], + vec![1], + 3, + ) +} + +#[test] +fn test_stacker_crane_creation_and_metadata() { + let problem = issue_problem(20); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 5); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.bound(), 20); + assert_eq!(problem.dims(), vec![5; 5]); + assert_eq!(::NAME, "StackerCrane"); + assert!(::variant().is_empty()); +} + +#[test] +fn test_stacker_crane_rejects_non_permutations_and_wrong_lengths() { + let problem = issue_problem(20); + + assert!(!problem.evaluate(&[0, 2, 1, 4, 4])); + assert!(!problem.evaluate(&[0, 2, 1, 4, 5])); + assert!(!problem.evaluate(&[0, 2, 1, 4])); + assert!(!problem.evaluate(&[0, 2, 1, 4, 3, 0])); +} + +#[test] +fn test_stacker_crane_issue_witness_and_tighter_bound() { + assert!(issue_problem(20).evaluate(&[0, 2, 1, 4, 3])); + assert!(!issue_problem(19).evaluate(&[0, 2, 1, 4, 3])); +} + +#[test] +fn test_stacker_crane_issue_instance_is_unsatisfiable_at_bound_19() { + let problem = issue_problem(19); + let solver = BruteForce::new(); + + assert!(solver.find_all_satisfying(&problem).is_empty()); +} + +#[test] +fn test_stacker_crane_paper_example() { + let problem = issue_problem(20); + let witness = vec![0, 2, 1, 4, 3]; + + assert_eq!(problem.closed_walk_length(&witness), Some(20)); + assert!(problem.evaluate(&witness)); + + let solver = BruteForce::new(); + let satisfying = solver.find_all_satisfying(&problem); + assert!(!satisfying.is_empty()); + assert!(satisfying.contains(&witness)); + for config in &satisfying { + assert!(problem.evaluate(config)); + } +} + +#[test] +fn test_stacker_crane_small_solver_instance() { + let problem = small_problem(); + let solver = BruteForce::new(); + + let satisfying = solver + .find_satisfying(&problem) + .expect("small instance should be satisfiable"); + let mut sorted = satisfying.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, vec![0, 1]); + assert!(problem.evaluate(&satisfying)); +} + +#[test] +fn test_stacker_crane_serialization_round_trip() { + let problem = issue_problem(20); + let json = serde_json::to_string(&problem).unwrap(); + let round_trip: StackerCrane = serde_json::from_str(&json).unwrap(); + + assert_eq!(round_trip.num_vertices(), 6); + assert_eq!(round_trip.num_arcs(), 5); + assert_eq!(round_trip.num_edges(), 7); + assert_eq!(round_trip.bound(), 20); + assert!(round_trip.evaluate(&[0, 2, 1, 4, 3])); +} + +#[test] +fn test_stacker_crane_try_new_validation_errors() { + // Mismatched arc_lengths length + assert!(StackerCrane::try_new(3, vec![(0, 1)], vec![], vec![1, 2], vec![], 5).is_err()); + + // Mismatched edge_lengths length + assert!(StackerCrane::try_new(3, vec![], vec![(0, 1)], vec![], vec![], 5).is_err()); + + // Negative bound + assert!(StackerCrane::try_new(3, vec![], vec![], vec![], vec![], -1).is_err()); + + // Arc endpoint out of range + assert!(StackerCrane::try_new(2, vec![(0, 5)], vec![], vec![1], vec![], 5).is_err()); + + // Edge endpoint out of range + assert!(StackerCrane::try_new(2, vec![], vec![(0, 5)], vec![], vec![1], 5).is_err()); + + // Negative arc length + assert!(StackerCrane::try_new(3, vec![(0, 1)], vec![], vec![-1], vec![], 5).is_err()); + + // Negative edge length + assert!(StackerCrane::try_new(3, vec![], vec![(0, 1)], vec![], vec![-1], 5).is_err()); +} + +#[test] +fn test_stacker_crane_unreachable_connector() { + // Two disconnected components: arc 0→1 and arc 2→3 with no connecting edges. + let problem = StackerCrane::new(4, vec![(0, 1), (2, 3)], vec![], vec![1, 1], vec![], 100); + + // No permutation can find a connector path from vertex 1 to vertex 2 (or 3 to 0). + assert_eq!(problem.closed_walk_length(&[0, 1]), None); + assert_eq!(problem.closed_walk_length(&[1, 0]), None); + assert!(!problem.evaluate(&[0, 1])); + assert!(!problem.evaluate(&[1, 0])); +} + +#[test] +fn test_stacker_crane_deserialization_rejects_invalid() { + let bad_json = r#"{"num_vertices":2,"arcs":[[0,5]],"edges":[],"arc_lengths":[1],"edge_lengths":[],"bound":5}"#; + assert!(serde_json::from_str::(bad_json).is_err()); +} + +#[test] +fn test_stacker_crane_is_available_in_prelude() { + let problem = crate::prelude::StackerCrane::new( + 3, + vec![(0, 1), (1, 2)], + vec![(0, 2)], + vec![1, 1], + vec![1], + 3, + ); + + assert_eq!(problem.num_arcs(), 2); +}