Skip to content

Commit fa3e24b

Browse files
GiggleLiuisPANNclaude
authored
Fix #288: [Model] LongestPath (#734)
* Add plan for #288: [Model] LongestPath * Implement #288: [Model] LongestPath * chore: remove plan file after implementation * Fix formatting after merge --------- Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e770c59 commit fa3e24b

11 files changed

Lines changed: 1040 additions & 6 deletions

File tree

docs/paper/reductions.typ

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"BiconnectivityAugmentation": [Biconnectivity Augmentation],
7474
"HamiltonianPath": [Hamiltonian Path],
7575
"LongestCircuit": [Longest Circuit],
76+
"LongestPath": [Longest Path],
7677
"ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path],
7778
"UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow],
7879
"LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths],
@@ -1072,6 +1073,65 @@ is feasible: each set induces a connected subgraph, the component weights are $2
10721073
]
10731074
]
10741075
}
1076+
#{
1077+
let x = load-model-example("LongestPath")
1078+
let nv = graph-num-vertices(x.instance)
1079+
let edges = x.instance.graph.edges
1080+
let lengths = x.instance.edge_lengths
1081+
let s = x.instance.source_vertex
1082+
let t = x.instance.target_vertex
1083+
let path-config = x.optimal_config
1084+
let path-order = (0, 1, 3, 2, 4, 5, 6)
1085+
let path-edges = edges.enumerate().filter(((idx, _)) => path-config.at(idx) == 1).map(((idx, e)) => e)
1086+
[
1087+
#problem-def("LongestPath")[
1088+
Given an undirected graph $G = (V, E)$ with positive edge lengths $l: E -> ZZ^+$ and designated vertices $s, t in V$, find a simple path $P$ from $s$ to $t$ maximizing $sum_(e in P) l(e)$.
1089+
][
1090+
Longest Path is problem ND29 in Garey & Johnson @garey1979. It bridges weighted routing and Hamiltonicity: when every edge has unit length, the optimum reaches $|V| - 1$ exactly when there is a Hamiltonian path from $s$ to $t$. The implementation catalog records the classical subset-DP exact bound $O(|V| dot 2^|V|)$, in the style of Held--Karp dynamic programming @heldkarp1962. For the parameterized $k$-path version, color-coding gives randomized $2^(O(k)) |V|^(O(1))$ algorithms @alon1995.
1091+
1092+
Variables: one binary value per edge. A configuration is valid exactly when the selected edges form a single simple $s$-$t$ path; otherwise the metric is `Invalid`. For valid selections, the metric is the total selected edge length.
1093+
1094+
*Example.* Consider the graph on #nv vertices with source $s = v_#s$ and target $t = v_#t$. The highlighted path $#path-order.map(v => $v_#v$).join($arrow$)$ uses edges ${#path-edges.map(((u, v)) => $(v_#u, v_#v)$).join(", ")}$, so its total length is $3 + 4 + 1 + 5 + 3 + 4 = 20$. Another valid path, $v_0 arrow v_2 arrow v_4 arrow v_5 arrow v_3 arrow v_1 arrow v_6$, has total length $17$, so the highlighted path is strictly better.
1095+
1096+
#pred-commands(
1097+
"pred create --example LongestPath -o longest-path.json",
1098+
"pred solve longest-path.json",
1099+
"pred evaluate longest-path.json --config " + x.optimal_config.map(str).join(","),
1100+
)
1101+
1102+
#figure({
1103+
let blue = graph-colors.at(0)
1104+
let gray = luma(200)
1105+
let verts = ((0, 1.2), (1.2, 2.0), (1.2, 0.4), (2.5, 2.0), (2.5, 0.4), (3.8, 1.2), (5.0, 1.2))
1106+
canvas(length: 1cm, {
1107+
import draw: *
1108+
for (idx, (u, v)) in edges.enumerate() {
1109+
let on-path = path-config.at(idx) == 1
1110+
g-edge(verts.at(u), verts.at(v), stroke: if on-path { 2pt + blue } else { 1pt + gray })
1111+
let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2
1112+
let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2
1113+
let dx = if idx == 0 or idx == 2 { 0 } else if idx == 1 or idx == 4 { -0.18 } else if idx == 5 or idx == 6 { 0.18 } else if idx == 8 { 0 } else { 0.16 }
1114+
let dy = if idx == 0 or idx == 2 or idx == 5 or idx == 8 { 0.18 } else if idx == 1 or idx == 4 or idx == 6 { -0.18 } else if idx == 3 { 0 } else { 0.16 }
1115+
draw.content(
1116+
(mx + dx, my + dy),
1117+
text(7pt, fill: luma(80))[#str(int(lengths.at(idx)))]
1118+
)
1119+
}
1120+
for (k, pos) in verts.enumerate() {
1121+
let on-path = path-order.any(v => v == k)
1122+
g-node(pos, name: "v" + str(k),
1123+
fill: if on-path { blue } else { white },
1124+
label: if on-path { text(fill: white)[$v_#k$] } else { [$v_#k$] })
1125+
}
1126+
content((0, 1.55), text(8pt)[$s$])
1127+
content((5.0, 1.55), text(8pt)[$t$])
1128+
})
1129+
},
1130+
caption: [Longest Path instance with edge lengths shown on the edges. The highlighted path from $s = v_0$ to $t = v_6$ has total length 20.],
1131+
) <fig:longest-path>
1132+
]
1133+
]
1134+
}
10751135
#{
10761136
let x = load-model-example("UndirectedTwoCommodityIntegralFlow")
10771137
let satisfying_count = 1
@@ -6944,6 +7004,40 @@ The following reductions to Integer Linear Programming are straightforward formu
69447004
#let tsp_qubo = load-example("TravelingSalesman", "QUBO")
69457005
#let tsp_qubo_sol = tsp_qubo.solutions.at(0)
69467006

7007+
#let lp_ilp = load-example("LongestPath", "ILP")
7008+
#let lp_ilp_sol = lp_ilp.solutions.at(0)
7009+
#reduction-rule("LongestPath", "ILP",
7010+
example: true,
7011+
example-caption: [The 3-vertex path $0 arrow 1 arrow 2$ encoded as a 7-variable ILP with optimum 5.],
7012+
extra: [
7013+
#pred-commands(
7014+
"pred create --example LongestPath -o longest-path.json",
7015+
"pred reduce longest-path.json --to " + target-spec(lp_ilp) + " -o bundle.json",
7016+
"pred solve bundle.json",
7017+
"pred evaluate longest-path.json --config " + lp_ilp_sol.source_config.map(str).join(","),
7018+
)
7019+
*Step 1 -- Orient each undirected edge.* The canonical witness has two source edges, so the reduction creates four directed-arc variables. The optimal witness sets $x_(0,1) = 1$ and $x_(1,2) = 1$, leaving the reverse directions at 0.\
7020+
7021+
*Step 2 -- Add order variables.* The target has #lp_ilp.target.instance.num_vars variables and #lp_ilp.target.instance.constraints.len() constraints in total. The order block $bold(o) = (#lp_ilp_sol.target_config.slice(4, 7).map(str).join(", "))$ certifies the increasing path positions $0 < 1 < 2$.\
7022+
7023+
*Step 3 -- Check the objective.* The target witness $bold(z) = (#lp_ilp_sol.target_config.map(str).join(", "))$ selects lengths $2$ and $3$, so the ILP objective is $5$, matching the source optimum. #sym.checkmark
7024+
],
7025+
)[
7026+
A simple $s$-$t$ path can be represented as one unit of directed flow from $s$ to $t$ on oriented copies of the undirected edges. Integer order variables then force the selected arcs to move strictly forward, which forbids detached directed cycles.
7027+
][
7028+
_Construction._ For graph $G = (V, E)$ with $n = |V|$ and $m = |E|$:
7029+
7030+
_Variables:_ For each undirected edge ${u, v} in E$, introduce two binary arc variables $x_(u,v), x_(v,u) in {0, 1}$. Interpretation: $x_(u,v) = 1$ iff the path traverses edge ${u, v}$ from $u$ to $v$. For each vertex $v in V$, add an integer order variable $o_v in {0, dots, n-1}$. Total: $2m + n$ variables.
7031+
7032+
_Constraints:_ (1) Flow balance: $sum_(w : {v,w} in E) x_(v,w) - sum_(u : {u,v} in E) x_(u,v) = 1$ at the source, equals $-1$ at the target, and equals $0$ at every other vertex. (2) Degree bounds: every vertex has at most one selected outgoing arc and at most one selected incoming arc. (3) Edge exclusivity: $x_(u,v) + x_(v,u) <= 1$ for each undirected edge. (4) Ordering: for every oriented edge $u -> v$, $o_v - o_u >= 1 - n(1 - x_(u,v))$. (5) Anchor the path at the source with $o_s = 0$.
7033+
7034+
_Objective._ Maximize $sum_({u,v} in E) l({u,v}) dot (x_(u,v) + x_(v,u))$.
7035+
7036+
_Correctness._ ($arrow.r.double$) Any simple $s$-$t$ path can be oriented from $s$ to $t$, giving exactly one outgoing arc at $s$, one incoming arc at $t$, balanced flow at every internal vertex, and strictly increasing order values along the path. ($arrow.l.double$) Any feasible ILP solution satisfies the flow equations and degree bounds, so the selected arcs form vertex-disjoint directed paths and cycles. The ordering inequalities make every selected arc increase the order value by at least 1, so directed cycles are impossible. The only remaining positive-flow component is therefore a single directed $s$-$t$ path, whose objective is exactly the total selected edge length.
7037+
7038+
_Solution extraction._ For each undirected edge ${u, v}$, select it in the source configuration iff either $x_(u,v)$ or $x_(v,u)$ is 1.
7039+
]
7040+
69477041
#reduction-rule("TravelingSalesman", "QUBO",
69487042
example: true,
69497043
example-caption: [TSP on $K_3$ with weights $w_(01) = 1$, $w_(02) = 2$, $w_(12) = 3$: the QUBO ground state encodes the optimal tour with cost $1 + 2 + 3 = 6$.],

problemreductions-cli/src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ TIP: Run `pred create <PROBLEM>` (no other flags) to see problem-specific help.
217217
Flags by problem type:
218218
MIS, MVC, MaxClique, MinDomSet --graph, --weights
219219
MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights
220+
LongestPath --graph, --edge-lengths, --source-vertex, --target-vertex
220221
ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound
221222
MaximalIS --graph, --weights
222223
SAT, NAESAT --num-vars, --clauses

problemreductions-cli/src/commands/create.rs

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ use problemreductions::models::algebraic::{
1313
use problemreductions::models::formula::Quantifier;
1414
use problemreductions::models::graph::{
1515
GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath,
16-
LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut,
17-
MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs,
18-
StrongConnectivityAugmentation,
16+
LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets,
17+
MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree,
18+
SteinerTreeInGraphs, StrongConnectivityAugmentation,
1919
};
2020
use problemreductions::models::misc::{
2121
AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery,
@@ -528,6 +528,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
528528
"--graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6"
529529
}
530530
"HamiltonianPath" => "--graph 0-1,1-2,2-3",
531+
"LongestPath" => {
532+
"--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"
533+
}
531534
"UndirectedTwoCommodityIntegralFlow" => {
532535
"--graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"
533536
},
@@ -1332,6 +1335,39 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
13321335
(ser(HamiltonianPath::new(graph))?, resolved_variant.clone())
13331336
}
13341337

1338+
// LongestPath
1339+
"LongestPath" => {
1340+
let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6";
1341+
let (graph, _) =
1342+
parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?;
1343+
if args.weights.is_some() {
1344+
bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}");
1345+
}
1346+
let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| {
1347+
anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}")
1348+
})?;
1349+
let edge_lengths =
1350+
parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?;
1351+
ensure_positive_i32_values(&edge_lengths, "edge lengths")?;
1352+
let source_vertex = args.source_vertex.ok_or_else(|| {
1353+
anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}")
1354+
})?;
1355+
let target_vertex = args.target_vertex.ok_or_else(|| {
1356+
anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}")
1357+
})?;
1358+
ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?;
1359+
ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?;
1360+
(
1361+
ser(LongestPath::new(
1362+
graph,
1363+
edge_lengths,
1364+
source_vertex,
1365+
target_vertex,
1366+
))?,
1367+
resolved_variant.clone(),
1368+
)
1369+
}
1370+
13351371
// ShortestWeightConstrainedPath
13361372
"ShortestWeightConstrainedPath" => {
13371373
let usage = "pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --length-bound 10 --weight-bound 8";
@@ -6069,6 +6105,118 @@ mod tests {
60696105
assert!(err.to_string().contains("GeneralizedHex requires --sink"));
60706106
}
60716107

6108+
#[test]
6109+
fn test_create_longest_path_serializes_problem_json() {
6110+
let output = temp_output_path("longest_path_create");
6111+
let cli = Cli::try_parse_from([
6112+
"pred",
6113+
"-o",
6114+
output.to_str().unwrap(),
6115+
"create",
6116+
"LongestPath",
6117+
"--graph",
6118+
"0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6",
6119+
"--edge-lengths",
6120+
"3,2,4,1,5,2,3,2,4,1",
6121+
"--source-vertex",
6122+
"0",
6123+
"--target-vertex",
6124+
"6",
6125+
])
6126+
.unwrap();
6127+
let out = OutputConfig {
6128+
output: cli.output.clone(),
6129+
quiet: true,
6130+
json: false,
6131+
auto_json: false,
6132+
};
6133+
let args = match cli.command {
6134+
Commands::Create(args) => args,
6135+
_ => unreachable!(),
6136+
};
6137+
6138+
create(&args, &out).unwrap();
6139+
6140+
let json: serde_json::Value =
6141+
serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap();
6142+
fs::remove_file(&output).unwrap();
6143+
assert_eq!(json["type"], "LongestPath");
6144+
assert_eq!(json["variant"]["graph"], "SimpleGraph");
6145+
assert_eq!(json["variant"]["weight"], "i32");
6146+
assert_eq!(json["data"]["source_vertex"], 0);
6147+
assert_eq!(json["data"]["target_vertex"], 6);
6148+
assert_eq!(
6149+
json["data"]["edge_lengths"],
6150+
serde_json::json!([3, 2, 4, 1, 5, 2, 3, 2, 4, 1])
6151+
);
6152+
}
6153+
6154+
#[test]
6155+
fn test_create_longest_path_requires_edge_lengths() {
6156+
let cli = Cli::try_parse_from([
6157+
"pred",
6158+
"create",
6159+
"LongestPath",
6160+
"--graph",
6161+
"0-1,1-2",
6162+
"--source-vertex",
6163+
"0",
6164+
"--target-vertex",
6165+
"2",
6166+
])
6167+
.unwrap();
6168+
let out = OutputConfig {
6169+
output: None,
6170+
quiet: true,
6171+
json: false,
6172+
auto_json: false,
6173+
};
6174+
let args = match cli.command {
6175+
Commands::Create(args) => args,
6176+
_ => unreachable!(),
6177+
};
6178+
6179+
let err = create(&args, &out).unwrap_err();
6180+
assert!(err
6181+
.to_string()
6182+
.contains("LongestPath requires --edge-lengths"));
6183+
}
6184+
6185+
#[test]
6186+
fn test_create_longest_path_rejects_weights_flag() {
6187+
let cli = Cli::try_parse_from([
6188+
"pred",
6189+
"create",
6190+
"LongestPath",
6191+
"--graph",
6192+
"0-1,1-2",
6193+
"--weights",
6194+
"1,1,1",
6195+
"--source-vertex",
6196+
"0",
6197+
"--target-vertex",
6198+
"2",
6199+
"--edge-lengths",
6200+
"5,7",
6201+
])
6202+
.unwrap();
6203+
let out = OutputConfig {
6204+
output: None,
6205+
quiet: true,
6206+
json: false,
6207+
auto_json: false,
6208+
};
6209+
let args = match cli.command {
6210+
Commands::Create(args) => args,
6211+
_ => unreachable!(),
6212+
};
6213+
6214+
let err = create(&args, &out).unwrap_err();
6215+
assert!(err
6216+
.to_string()
6217+
.contains("LongestPath uses --edge-lengths, not --weights"));
6218+
}
6219+
60726220
fn empty_args() -> CreateArgs {
60736221
CreateArgs {
60746222
problem: Some("BiconnectivityAugmentation".to_string()),

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ pub mod prelude {
5353
DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit,
5454
HamiltonianPath, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers,
5555
IsomorphicSpanningTree, KClique, KthBestSpanningTree, LengthBoundedDisjointPaths,
56-
MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation,
56+
LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation,
5757
SubgraphIsomorphism,
5858
};
5959
pub use crate::models::graph::{

0 commit comments

Comments
 (0)