Skip to content

Commit 51e49f5

Browse files
GiggleLiuclaude
andauthored
Fix #245: [Model] StackerCrane (#730)
* Add plan for #245: [Model] StackerCrane * Fix #245: add StackerCrane model * chore: remove plan file after implementation * Fix StackerCrane complexity string and add coverage tests - Change complexity from num_vertices * 2^num_arcs to num_vertices^2 * 2^num_arcs (total DP time, not just state space) - Update paper text to say "total time" instead of "state space" - Add tests for try_new() validation errors, unreachable connectors, and invalid deserialization to bring coverage above 95% Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a01930d commit 51e49f5

11 files changed

Lines changed: 757 additions & 3 deletions

File tree

docs/paper/reductions.typ

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"OptimalLinearArrangement": [Optimal Linear Arrangement],
119119
"RuralPostman": [Rural Postman],
120120
"MixedChinesePostman": [Mixed Chinese Postman],
121+
"StackerCrane": [Stacker Crane],
121122
"LongestCommonSubsequence": [Longest Common Subsequence],
122123
"ExactCoverBy3Sets": [Exact Cover by 3-Sets],
123124
"SubsetSum": [Subset Sum],
@@ -3662,6 +3663,66 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76],
36623663
]
36633664
}
36643665

3666+
#{
3667+
let x = load-model-example("StackerCrane")
3668+
let arcs = x.instance.arcs.map(a => (a.at(0), a.at(1)))
3669+
let edges = x.instance.edges.map(e => (e.at(0), e.at(1)))
3670+
let B = x.instance.bound
3671+
let config = x.optimal_config
3672+
let positions = (
3673+
(-2.0, 0.9),
3674+
(-2.0, -0.9),
3675+
(0.0, -1.5),
3676+
(2.0, -0.9),
3677+
(0.0, 1.5),
3678+
(2.0, 0.9),
3679+
)
3680+
[
3681+
#problem-def("StackerCrane")[
3682+
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$.
3683+
][
3684+
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.].
3685+
3686+
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.
3687+
3688+
*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.
3689+
3690+
#pred-commands(
3691+
"pred create --example " + problem-spec(x) + " -o stacker-crane.json",
3692+
"pred solve stacker-crane.json --solver brute-force",
3693+
"pred evaluate stacker-crane.json --config " + x.optimal_config.map(str).join(","),
3694+
)
3695+
3696+
#figure(
3697+
canvas(length: 1cm, {
3698+
import draw: *
3699+
let blue = graph-colors.at(0)
3700+
let gray = luma(200)
3701+
3702+
for (u, v) in edges {
3703+
line(positions.at(u), positions.at(v), stroke: (paint: gray, thickness: 0.7pt))
3704+
}
3705+
3706+
for (i, (u, v)) in arcs.enumerate() {
3707+
line(positions.at(u), positions.at(v), stroke: (paint: blue, thickness: 1.7pt))
3708+
let mid = (
3709+
(positions.at(u).at(0) + positions.at(v).at(0)) / 2,
3710+
(positions.at(u).at(1) + positions.at(v).at(1)) / 2,
3711+
)
3712+
content(mid, text(6pt, fill: blue)[$a_#i$], fill: white, frame: "rect", padding: 0.05, stroke: none)
3713+
}
3714+
3715+
for (i, pos) in positions.enumerate() {
3716+
circle(pos, radius: 0.18, fill: white, stroke: 0.6pt + black)
3717+
content(pos, text(7pt)[$v_#i$])
3718+
}
3719+
}),
3720+
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.],
3721+
) <fig:stacker-crane>
3722+
]
3723+
]
3724+
}
3725+
36653726
#{
36663727
let x = load-model-example("SubgraphIsomorphism")
36673728
let nv-host = x.instance.host_graph.num_vertices

docs/paper/references.bib

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,27 @@ @article{frederickson1979
10681068
doi = {10.1145/322139.322150}
10691069
}
10701070

1071+
@article{frederickson1978routing,
1072+
author = {Greg N. Frederickson and Matthew S. Hecht and Chul E. Kim},
1073+
title = {Approximation Algorithms for Some Routing Problems},
1074+
journal = {SIAM Journal on Computing},
1075+
volume = {7},
1076+
number = {2},
1077+
pages = {178--193},
1078+
year = {1978},
1079+
doi = {10.1137/0207017}
1080+
}
1081+
1082+
@article{fredericksonguan1993,
1083+
author = {Greg N. Frederickson and Da-Wei Guan},
1084+
title = {Nonpreemptive Ensemble Motion Planning on a Tree},
1085+
journal = {Journal of Algorithms},
1086+
volume = {15},
1087+
number = {1},
1088+
pages = {29--60},
1089+
year = {1993}
1090+
}
1091+
10711092
@article{gottlob2002,
10721093
author = {Georg Gottlob and Nicola Leone and Francesco Scarcello},
10731094
title = {Hypertree Decompositions and Tractable Queries},

problemreductions-cli/src/cli.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ Flags by problem type:
267267
MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound
268268
MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-costs, --bound [--num-vertices]
269269
RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound
270+
StackerCrane --arcs, --graph, --arc-costs, --edge-lengths, --bound [--num-vertices]
270271
MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices]
271272
AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys]
272273
ConsistencyOfDatabaseFrequencyTables --num-objects, --attribute-domains, --frequency-tables [--known-values]
@@ -850,4 +851,22 @@ mod tests {
850851
assert!(help.contains("--potential-edges"));
851852
assert!(help.contains("--budget"));
852853
}
854+
855+
#[test]
856+
fn test_create_help_mentions_stacker_crane_flags() {
857+
let cmd = Cli::command();
858+
let create = cmd.find_subcommand("create").expect("create subcommand");
859+
let help = create
860+
.get_after_help()
861+
.expect("create after_help")
862+
.to_string();
863+
864+
assert!(help.contains("StackerCrane"));
865+
assert!(help.contains("--arcs"));
866+
assert!(help.contains("--graph"));
867+
assert!(help.contains("--arc-costs"));
868+
assert!(help.contains("--edge-lengths"));
869+
assert!(help.contains("--bound"));
870+
assert!(help.contains("--num-vertices"));
871+
}
853872
}

problemreductions-cli/src/commands/create.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
590590
"RuralPostman" => {
591591
"--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4"
592592
}
593+
"StackerCrane" => {
594+
"--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"
595+
}
593596
"MultipleChoiceBranching" => {
594597
"--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"
595598
}
@@ -671,6 +674,9 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String {
671674
("PrimeAttributeName", "query_attribute") => return "query".to_string(),
672675
("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(),
673676
("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(),
677+
("StackerCrane", "edges") => return "graph".to_string(),
678+
("StackerCrane", "arc_lengths") => return "arc-costs".to_string(),
679+
("StackerCrane", "edge_lengths") => return "edge-lengths".to_string(),
674680
("StaffScheduling", "shifts_per_schedule") => return "k".to_string(),
675681
("TimetableDesign", "num_tasks") => return "num-tasks".to_string(),
676682
_ => {}
@@ -1518,6 +1524,51 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
15181524
)
15191525
}
15201526

1527+
// StackerCrane
1528+
"StackerCrane" => {
1529+
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";
1530+
let arcs_str = args
1531+
.arcs
1532+
.as_deref()
1533+
.ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?;
1534+
let (arcs_graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?;
1535+
let (edges_graph, num_vertices) =
1536+
parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
1537+
anyhow::ensure!(
1538+
edges_graph.num_vertices() == num_vertices,
1539+
"internal error: inconsistent graph vertex count"
1540+
);
1541+
anyhow::ensure!(
1542+
num_vertices == arcs_graph.num_vertices(),
1543+
"StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}"
1544+
);
1545+
let arc_lengths = parse_arc_costs(args, num_arcs)?;
1546+
let edge_lengths = parse_i32_edge_values(
1547+
args.edge_lengths.as_ref(),
1548+
edges_graph.num_edges(),
1549+
"edge length",
1550+
)?;
1551+
let bound_raw = args
1552+
.bound
1553+
.ok_or_else(|| anyhow::anyhow!("StackerCrane requires --bound\n\n{usage}"))?;
1554+
let bound = parse_nonnegative_usize_bound(bound_raw, "StackerCrane", usage)?;
1555+
let bound = i32::try_from(bound).map_err(|_| {
1556+
anyhow::anyhow!("StackerCrane --bound must fit in i32 (got {bound_raw})\n\n{usage}")
1557+
})?;
1558+
(
1559+
ser(StackerCrane::try_new(
1560+
num_vertices,
1561+
arcs_graph.arcs(),
1562+
edges_graph.edges(),
1563+
arc_lengths,
1564+
edge_lengths,
1565+
bound,
1566+
)
1567+
.map_err(|e| anyhow::anyhow!(e))?)?,
1568+
resolved_variant.clone(),
1569+
)
1570+
}
1571+
15211572
// MultipleChoiceBranching
15221573
"MultipleChoiceBranching" => {
15231574
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 {
60166067
std::fs::remove_file(output_path).ok();
60176068
}
60186069

6070+
#[test]
6071+
fn test_create_stacker_crane_json() {
6072+
let mut args = empty_args();
6073+
args.problem = Some("StackerCrane".to_string());
6074+
args.num_vertices = Some(6);
6075+
args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string());
6076+
args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string());
6077+
args.arc_costs = Some("3,4,2,5,3".to_string());
6078+
args.edge_lengths = Some("2,1,3,2,1,4,3".to_string());
6079+
args.bound = Some(20);
6080+
6081+
let output_path = std::env::temp_dir().join("pred_test_create_stacker_crane.json");
6082+
let out = OutputConfig {
6083+
output: Some(output_path.clone()),
6084+
quiet: true,
6085+
json: false,
6086+
auto_json: false,
6087+
};
6088+
6089+
create(&args, &out).unwrap();
6090+
6091+
let content = std::fs::read_to_string(&output_path).unwrap();
6092+
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
6093+
assert_eq!(json["type"], "StackerCrane");
6094+
assert_eq!(json["data"]["num_vertices"], 6);
6095+
assert_eq!(json["data"]["bound"], 20);
6096+
assert_eq!(json["data"]["arcs"][0], serde_json::json!([0, 4]));
6097+
assert_eq!(json["data"]["edge_lengths"][6], 3);
6098+
6099+
std::fs::remove_file(output_path).ok();
6100+
}
6101+
6102+
#[test]
6103+
fn test_create_stacker_crane_rejects_mismatched_arc_lengths() {
6104+
let mut args = empty_args();
6105+
args.problem = Some("StackerCrane".to_string());
6106+
args.num_vertices = Some(6);
6107+
args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string());
6108+
args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string());
6109+
args.arc_costs = Some("3,4,2,5".to_string());
6110+
args.edge_lengths = Some("2,1,3,2,1,4,3".to_string());
6111+
args.bound = Some(20);
6112+
6113+
let out = OutputConfig {
6114+
output: None,
6115+
quiet: true,
6116+
json: false,
6117+
auto_json: false,
6118+
};
6119+
6120+
let err = create(&args, &out).unwrap_err().to_string();
6121+
assert!(err.contains("Expected 5 arc costs but got 4"));
6122+
}
6123+
6124+
#[test]
6125+
fn test_create_stacker_crane_rejects_out_of_range_vertices() {
6126+
let mut args = empty_args();
6127+
args.problem = Some("StackerCrane".to_string());
6128+
args.num_vertices = Some(5);
6129+
args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string());
6130+
args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string());
6131+
args.arc_costs = Some("3,4,2,5,3".to_string());
6132+
args.edge_lengths = Some("2,1,3,2,1,4,3".to_string());
6133+
args.bound = Some(20);
6134+
6135+
let out = OutputConfig {
6136+
output: None,
6137+
quiet: true,
6138+
json: false,
6139+
auto_json: false,
6140+
};
6141+
6142+
let err = create(&args, &out).unwrap_err().to_string();
6143+
assert!(err.contains("--num-vertices (5) is too small for the arcs"));
6144+
}
6145+
60196146
#[test]
60206147
fn test_create_balanced_complete_bipartite_subgraph() {
60216148
use crate::dispatch::ProblemJsonOutput;

problemreductions-cli/tests/cli_tests.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ fn test_show_balanced_complete_bipartite_subgraph_complexity() {
132132
);
133133
}
134134

135+
#[test]
136+
fn test_create_stacker_crane_schema_help_uses_documented_flags() {
137+
let output = pred().args(["create", "StackerCrane"]).output().unwrap();
138+
assert!(!output.status.success());
139+
140+
let stderr = String::from_utf8(output.stderr).unwrap();
141+
assert!(stderr.contains("StackerCrane"), "stderr: {stderr}");
142+
assert!(stderr.contains("--arcs"), "stderr: {stderr}");
143+
assert!(stderr.contains("--graph"), "stderr: {stderr}");
144+
assert!(stderr.contains("--arc-costs"), "stderr: {stderr}");
145+
assert!(stderr.contains("--edge-lengths"), "stderr: {stderr}");
146+
assert!(stderr.contains("--bound"), "stderr: {stderr}");
147+
assert!(stderr.contains("--num-vertices"), "stderr: {stderr}");
148+
assert!(!stderr.contains("--biedges"), "stderr: {stderr}");
149+
assert!(!stderr.contains("--arc-lengths"), "stderr: {stderr}");
150+
assert!(!stderr.contains("--edge-weights"), "stderr: {stderr}");
151+
}
152+
135153
#[test]
136154
fn test_solve_balanced_complete_bipartite_subgraph_suggests_bruteforce() {
137155
let tmp = std::env::temp_dir().join("pred_test_bcbs_problem.json");

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ pub mod prelude {
7373
SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost,
7474
SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness,
7575
SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals,
76-
ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum,
77-
SumOfSquaresPartition, Term, TimetableDesign,
76+
ShortestCommonSupersequence, StackerCrane, StaffScheduling, StringToStringCorrection,
77+
SubsetSum, SumOfSquaresPartition, Term, TimetableDesign,
7878
};
7979
pub use crate::models::set::{
8080
ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking,

src/models/misc/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
//! - [`RectilinearPictureCompression`]: Cover 1-entries with bounded rectangles
2121
//! - [`ResourceConstrainedScheduling`]: Schedule unit-length tasks on processors with resource constraints
2222
//! - [`SchedulingWithIndividualDeadlines`]: Meet per-task deadlines on parallel processors
23+
//! - [`StackerCrane`]: Route a crane through required arcs within a length bound
2324
//! - [`SequencingToMinimizeMaximumCumulativeCost`]: Keep every cumulative schedule cost prefix under a bound
2425
//! - [`SequencingToMinimizeWeightedCompletionTime`]: Minimize total weighted completion time
2526
//! - [`SequencingToMinimizeWeightedTardiness`]: Decide whether a schedule meets a weighted tardiness bound
@@ -57,6 +58,7 @@ mod sequencing_to_minimize_weighted_tardiness;
5758
mod sequencing_with_release_times_and_deadlines;
5859
mod sequencing_within_intervals;
5960
pub(crate) mod shortest_common_supersequence;
61+
mod stacker_crane;
6062
mod staff_scheduling;
6163
pub(crate) mod string_to_string_correction;
6264
mod subset_sum;
@@ -91,6 +93,7 @@ pub use sequencing_to_minimize_weighted_tardiness::SequencingToMinimizeWeightedT
9193
pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines;
9294
pub use sequencing_within_intervals::SequencingWithinIntervals;
9395
pub use shortest_common_supersequence::ShortestCommonSupersequence;
96+
pub use stacker_crane::StackerCrane;
9497
pub use staff_scheduling::StaffScheduling;
9598
pub use string_to_string_correction::StringToStringCorrection;
9699
pub use subset_sum::SubsetSum;
@@ -114,6 +117,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::M
114117
specs.extend(scheduling_with_individual_deadlines::canonical_model_example_specs());
115118
specs.extend(sequencing_within_intervals::canonical_model_example_specs());
116119
specs.extend(staff_scheduling::canonical_model_example_specs());
120+
specs.extend(stacker_crane::canonical_model_example_specs());
117121
specs.extend(timetable_design::canonical_model_example_specs());
118122
specs.extend(shortest_common_supersequence::canonical_model_example_specs());
119123
specs.extend(resource_constrained_scheduling::canonical_model_example_specs());

0 commit comments

Comments
 (0)