diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 947435146..114d25dbd 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -73,6 +73,7 @@ "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], "LongestCircuit": [Longest Circuit], + "LongestPath": [Longest Path], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], @@ -1072,6 +1073,65 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("LongestPath") + let nv = graph-num-vertices(x.instance) + let edges = x.instance.graph.edges + let lengths = x.instance.edge_lengths + let s = x.instance.source_vertex + let t = x.instance.target_vertex + let path-config = x.optimal_config + let path-order = (0, 1, 3, 2, 4, 5, 6) + let path-edges = edges.enumerate().filter(((idx, _)) => path-config.at(idx) == 1).map(((idx, e)) => e) + [ + #problem-def("LongestPath")[ + 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)$. + ][ + 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. + + 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. + + *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. + + #pred-commands( + "pred create --example LongestPath -o longest-path.json", + "pred solve longest-path.json", + "pred evaluate longest-path.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + let blue = graph-colors.at(0) + let gray = luma(200) + 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)) + canvas(length: 1cm, { + import draw: * + for (idx, (u, v)) in edges.enumerate() { + let on-path = path-config.at(idx) == 1 + g-edge(verts.at(u), verts.at(v), stroke: if on-path { 2pt + blue } else { 1pt + gray }) + let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2 + let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2 + let dx = if idx == 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 } + 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 } + draw.content( + (mx + dx, my + dy), + text(7pt, fill: luma(80))[#str(int(lengths.at(idx)))] + ) + } + for (k, pos) in verts.enumerate() { + let on-path = path-order.any(v => v == k) + g-node(pos, name: "v" + str(k), + fill: if on-path { blue } else { white }, + label: if on-path { text(fill: white)[$v_#k$] } else { [$v_#k$] }) + } + content((0, 1.55), text(8pt)[$s$]) + content((5.0, 1.55), text(8pt)[$t$]) + }) + }, + 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.], + ) + ] + ] +} #{ let x = load-model-example("UndirectedTwoCommodityIntegralFlow") let satisfying_count = 1 @@ -6944,6 +7004,40 @@ The following reductions to Integer Linear Programming are straightforward formu #let tsp_qubo = load-example("TravelingSalesman", "QUBO") #let tsp_qubo_sol = tsp_qubo.solutions.at(0) +#let lp_ilp = load-example("LongestPath", "ILP") +#let lp_ilp_sol = lp_ilp.solutions.at(0) +#reduction-rule("LongestPath", "ILP", + example: true, + example-caption: [The 3-vertex path $0 arrow 1 arrow 2$ encoded as a 7-variable ILP with optimum 5.], + extra: [ + #pred-commands( + "pred create --example LongestPath -o longest-path.json", + "pred reduce longest-path.json --to " + target-spec(lp_ilp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate longest-path.json --config " + lp_ilp_sol.source_config.map(str).join(","), + ) + *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.\ + + *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$.\ + + *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 + ], +)[ + 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. +][ + _Construction._ For graph $G = (V, E)$ with $n = |V|$ and $m = |E|$: + + _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. + + _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$. + + _Objective._ Maximize $sum_({u,v} in E) l({u,v}) dot (x_(u,v) + x_(v,u))$. + + _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. + + _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. +] + #reduction-rule("TravelingSalesman", "QUBO", example: true, 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$.], diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 4925bd3c9..b25ffc2c0 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -217,6 +217,7 @@ TIP: Run `pred create ` (no other flags) to see problem-specific help. Flags by problem type: MIS, MVC, MaxClique, MinDomSet --graph, --weights MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights + LongestPath --graph, --edge-lengths, --source-vertex, --target-vertex ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound MaximalIS --graph, --weights SAT, NAESAT --num-vars, --clauses diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 6277af665..e54c829ad 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,9 +13,9 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut, - MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, - StrongConnectivityAugmentation, + LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, + MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree, + SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -528,6 +528,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--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" } "HamiltonianPath" => "--graph 0-1,1-2,2-3", + "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" + } "UndirectedTwoCommodityIntegralFlow" => { "--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" }, @@ -1332,6 +1335,39 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) } + // LongestPath + "LongestPath" => { + 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"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + if args.weights.is_some() { + bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + ( + ser(LongestPath::new( + graph, + edge_lengths, + source_vertex, + target_vertex, + ))?, + resolved_variant.clone(), + ) + } + // ShortestWeightConstrainedPath "ShortestWeightConstrainedPath" => { let usage = "pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --length-bound 10 --weight-bound 8"; @@ -6069,6 +6105,118 @@ mod tests { assert!(err.to_string().contains("GeneralizedHex requires --sink")); } + #[test] + fn test_create_longest_path_serializes_problem_json() { + let output = temp_output_path("longest_path_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "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", + ]) + .unwrap(); + let out = OutputConfig { + output: cli.output.clone(), + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + fs::remove_file(&output).unwrap(); + assert_eq!(json["type"], "LongestPath"); + assert_eq!(json["variant"]["graph"], "SimpleGraph"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["source_vertex"], 0); + assert_eq!(json["data"]["target_vertex"], 6); + assert_eq!( + json["data"]["edge_lengths"], + serde_json::json!([3, 2, 4, 1, 5, 2, 3, 2, 4, 1]) + ); + } + + #[test] + fn test_create_longest_path_requires_edge_lengths() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "LongestPath", + "--graph", + "0-1,1-2", + "--source-vertex", + "0", + "--target-vertex", + "2", + ]) + .unwrap(); + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let err = create(&args, &out).unwrap_err(); + assert!(err + .to_string() + .contains("LongestPath requires --edge-lengths")); + } + + #[test] + fn test_create_longest_path_rejects_weights_flag() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "LongestPath", + "--graph", + "0-1,1-2", + "--weights", + "1,1,1", + "--source-vertex", + "0", + "--target-vertex", + "2", + "--edge-lengths", + "5,7", + ]) + .unwrap(); + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let err = create(&args, &out).unwrap_err(); + assert!(err + .to_string() + .contains("LongestPath uses --edge-lengths, not --weights")); + } + fn empty_args() -> CreateArgs { CreateArgs { problem: Some("BiconnectivityAugmentation".to_string()), diff --git a/src/lib.rs b/src/lib.rs index 1b26c9052..d6939b41f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,7 +53,7 @@ pub mod prelude { DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, IsomorphicSpanningTree, KClique, KthBestSpanningTree, LengthBoundedDisjointPaths, - MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, + LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs new file mode 100644 index 000000000..682de7ed8 --- /dev/null +++ b/src/models/graph/longest_path.rs @@ -0,0 +1,303 @@ +//! Longest Path problem implementation. +//! +//! The Longest Path problem asks for a simple path between two distinguished +//! vertices that maximizes the total edge length. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, One, SolutionSize, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "LongestPath", + display_name: "Longest Path", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32", "One"]), + ], + module_path: module_path!(), + description: "Find a simple s-t path of maximum total edge length", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> ZZ_(> 0)" }, + FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, + FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, + ], + } +} + +/// The Longest Path problem. +/// +/// Given a graph `G = (V, E)` with positive edge lengths `l(e)` and +/// distinguished vertices `s` and `t`, find a simple path from `s` to `t` +/// maximizing the total length of its selected edges. +/// +/// # Representation +/// +/// Each edge is assigned a binary variable: +/// - `0`: the edge is not selected +/// - `1`: the edge is selected +/// +/// A valid configuration must select exactly the edges of one simple +/// undirected path from `source_vertex` to `target_vertex`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LongestPath { + graph: G, + edge_lengths: Vec, + source_vertex: usize, + target_vertex: usize, +} + +impl LongestPath { + fn assert_positive_edge_lengths(edge_lengths: &[W]) { + let zero = W::Sum::zero(); + assert!( + edge_lengths + .iter() + .all(|length| length.to_sum() > zero.clone()), + "All edge lengths must be positive (> 0)" + ); + } + + /// Create a new LongestPath instance. + pub fn new(graph: G, edge_lengths: Vec, source_vertex: usize, target_vertex: usize) -> Self { + assert_eq!( + edge_lengths.len(), + graph.num_edges(), + "edge_lengths length must match num_edges" + ); + Self::assert_positive_edge_lengths(&edge_lengths); + assert!( + source_vertex < graph.num_vertices(), + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ); + assert!( + target_vertex < graph.num_vertices(), + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ); + Self { + graph, + edge_lengths, + source_vertex, + target_vertex, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the edge lengths. + pub fn edge_lengths(&self) -> &[W] { + &self.edge_lengths + } + + /// Replace the edge lengths with a new vector. + pub fn set_lengths(&mut self, edge_lengths: Vec) { + assert_eq!( + edge_lengths.len(), + self.graph.num_edges(), + "edge_lengths length must match num_edges" + ); + Self::assert_positive_edge_lengths(&edge_lengths); + self.edge_lengths = edge_lengths; + } + + /// Get the source vertex. + pub fn source_vertex(&self) -> usize { + self.source_vertex + } + + /// Get the target vertex. + pub fn target_vertex(&self) -> usize { + self.target_vertex + } + + /// Check whether this problem uses non-unit edge lengths. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check if a configuration encodes a valid simple source-target path. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_simple_st_path(&self.graph, self.source_vertex, self.target_vertex, config) + } +} + +impl Problem for LongestPath +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "LongestPath"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + if !self.is_valid_solution(config) { + return SolutionSize::Invalid; + } + + let mut total = W::Sum::zero(); + for (idx, &selected) in config.iter().enumerate() { + if selected == 1 { + total += self.edge_lengths[idx].to_sum(); + } + } + SolutionSize::Valid(total) + } +} + +impl OptimizationProblem for LongestPath +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + type Value = W::Sum; + + fn direction(&self) -> Direction { + Direction::Maximize + } +} + +fn is_simple_st_path( + graph: &G, + source_vertex: usize, + target_vertex: usize, + config: &[usize], +) -> bool { + if config.len() != graph.num_edges() || config.iter().any(|&value| value > 1) { + return false; + } + + if source_vertex == target_vertex { + return config.iter().all(|&value| value == 0); + } + + let edges = graph.edges(); + let mut degree = vec![0usize; graph.num_vertices()]; + let mut adjacency = vec![Vec::new(); graph.num_vertices()]; + let mut selected_edge_count = 0usize; + + for (idx, &selected) in config.iter().enumerate() { + if selected == 0 { + continue; + } + let (u, v) = edges[idx]; + degree[u] += 1; + degree[v] += 1; + if degree[u] > 2 || degree[v] > 2 { + return false; + } + adjacency[u].push(v); + adjacency[v].push(u); + selected_edge_count += 1; + } + + if selected_edge_count == 0 { + return false; + } + if degree[source_vertex] != 1 || degree[target_vertex] != 1 { + return false; + } + + let mut selected_vertex_count = 0usize; + for (vertex, &vertex_degree) in degree.iter().enumerate() { + if vertex_degree == 0 { + continue; + } + selected_vertex_count += 1; + if vertex != source_vertex && vertex != target_vertex && vertex_degree != 2 { + return false; + } + } + + if selected_edge_count != selected_vertex_count.saturating_sub(1) { + return false; + } + + let mut visited = vec![false; graph.num_vertices()]; + let mut queue = VecDeque::new(); + visited[source_vertex] = true; + queue.push_back(source_vertex); + + while let Some(vertex) = queue.pop_front() { + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited[target_vertex] + && degree + .iter() + .enumerate() + .all(|(vertex, &vertex_degree)| vertex_degree == 0 || visited[vertex]) +} + +crate::declare_variants! { + default opt LongestPath => "num_vertices * 2^num_vertices", + opt LongestPath => "num_vertices * 2^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "longest_path_simplegraph_i32", + instance: Box::new(LongestPath::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 6), + (1, 6), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 4, 1], + 0, + 6, + )), + optimal_config: vec![1, 0, 1, 1, 1, 0, 1, 0, 1, 0], + optimal_value: serde_json::json!({"Valid": 20}), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/longest_path.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 5a263558a..ecd77c9f2 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -22,6 +22,7 @@ //! - [`SpinGlass`]: Ising model Hamiltonian //! - [`MinimumMultiwayCut`]: Minimum weight multiway cut //! - [`HamiltonianPath`]: Hamiltonian path (simple path visiting every vertex) +//! - [`LongestPath`]: Maximum-length simple s-t path //! - [`ShortestWeightConstrainedPath`]: Bicriteria simple s-t path with length and weight bounds //! - [`PartitionIntoPathsOfLength2`]: Partition vertices into triples with at least two edges each //! - [`BicliqueCover`]: Biclique cover on bipartite graphs @@ -66,6 +67,7 @@ pub(crate) mod kcoloring; pub(crate) mod kth_best_spanning_tree; pub(crate) mod length_bounded_disjoint_paths; pub(crate) mod longest_circuit; +pub(crate) mod longest_path; pub(crate) mod max_cut; pub(crate) mod maximal_is; pub(crate) mod maximum_clique; @@ -114,6 +116,7 @@ pub use kcoloring::KColoring; pub use kth_best_spanning_tree::KthBestSpanningTree; pub use length_bounded_disjoint_paths::LengthBoundedDisjointPaths; pub use longest_circuit::LongestCircuit; +pub use longest_path::LongestPath; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; @@ -160,6 +163,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + num_edges: usize, +} + +impl ReductionLongestPathToILP { + fn arc_var(edge_idx: usize, dir: usize) -> usize { + 2 * edge_idx + dir + } +} + +impl ReductionResult for ReductionLongestPathToILP { + type Source = LongestPath; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.num_edges) + .map(|edge_idx| { + usize::from( + target_solution + .get(Self::arc_var(edge_idx, 0)) + .copied() + .unwrap_or(0) + > 0 + || target_solution + .get(Self::arc_var(edge_idx, 1)) + .copied() + .unwrap_or(0) + > 0, + ) + }) + .collect() + } +} + +#[reduction(overhead = { + num_vars = "2 * num_edges + num_vertices", + num_constraints = "5 * num_edges + 4 * num_vertices + 1", +})] +impl ReduceTo> for LongestPath { + type Result = ReductionLongestPathToILP; + + fn reduce_to(&self) -> Self::Result { + let edges = self.graph().edges(); + let num_vertices = self.num_vertices(); + let num_edges = self.num_edges(); + let num_vars = 2 * num_edges + num_vertices; + let source = self.source_vertex(); + let target = self.target_vertex(); + let big_m = num_vertices as f64; + + let order_var = |vertex: usize| 2 * num_edges + vertex; + + let mut outgoing = vec![Vec::new(); num_vertices]; + let mut incoming = vec![Vec::new(); num_vertices]; + + for (edge_idx, &(u, v)) in edges.iter().enumerate() { + let forward = ReductionLongestPathToILP::arc_var(edge_idx, 0); + let reverse = ReductionLongestPathToILP::arc_var(edge_idx, 1); + outgoing[u].push((forward, 1.0)); + incoming[v].push((forward, 1.0)); + outgoing[v].push((reverse, 1.0)); + incoming[u].push((reverse, 1.0)); + } + + let mut constraints = Vec::new(); + + // Directed arc variables are binary within ILP. + for edge_idx in 0..num_edges { + constraints.push(LinearConstraint::le( + vec![(ReductionLongestPathToILP::arc_var(edge_idx, 0), 1.0)], + 1.0, + )); + constraints.push(LinearConstraint::le( + vec![(ReductionLongestPathToILP::arc_var(edge_idx, 1), 1.0)], + 1.0, + )); + } + + // Order variables stay within [0, |V|-1]. + for vertex in 0..num_vertices { + constraints.push(LinearConstraint::le( + vec![(order_var(vertex), 1.0)], + num_vertices.saturating_sub(1) as f64, + )); + } + + // Flow balance and degree bounds force one simple directed path. + for vertex in 0..num_vertices { + let mut balance_terms = outgoing[vertex].clone(); + for &(var, coef) in &incoming[vertex] { + balance_terms.push((var, -coef)); + } + + let rhs = if source != target { + if vertex == source { + 1.0 + } else if vertex == target { + -1.0 + } else { + 0.0 + } + } else { + 0.0 + }; + constraints.push(LinearConstraint::eq(balance_terms, rhs)); + constraints.push(LinearConstraint::le(outgoing[vertex].clone(), 1.0)); + constraints.push(LinearConstraint::le(incoming[vertex].clone(), 1.0)); + } + + // An undirected edge can be used in at most one direction. + for edge_idx in 0..num_edges { + constraints.push(LinearConstraint::le( + vec![ + (ReductionLongestPathToILP::arc_var(edge_idx, 0), 1.0), + (ReductionLongestPathToILP::arc_var(edge_idx, 1), 1.0), + ], + 1.0, + )); + } + + // If arc u->v is selected then order(v) >= order(u) + 1. + for (edge_idx, &(u, v)) in edges.iter().enumerate() { + constraints.push(LinearConstraint::ge( + vec![ + (order_var(v), 1.0), + (order_var(u), -1.0), + (ReductionLongestPathToILP::arc_var(edge_idx, 0), -big_m), + ], + 1.0 - big_m, + )); + constraints.push(LinearConstraint::ge( + vec![ + (order_var(u), 1.0), + (order_var(v), -1.0), + (ReductionLongestPathToILP::arc_var(edge_idx, 1), -big_m), + ], + 1.0 - big_m, + )); + } + + constraints.push(LinearConstraint::eq(vec![(order_var(source), 1.0)], 0.0)); + + let mut objective = Vec::with_capacity(2 * num_edges); + for (edge_idx, length) in self.edge_lengths().iter().enumerate() { + let coeff = f64::from(*length); + objective.push((ReductionLongestPathToILP::arc_var(edge_idx, 0), coeff)); + objective.push((ReductionLongestPathToILP::arc_var(edge_idx, 1), coeff)); + } + + ReductionLongestPathToILP { + target: ILP::new(num_vars, constraints, objective, ObjectiveSense::Maximize), + num_edges, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "longestpath_to_ilp", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, ILP>( + LongestPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![2, 3], 0, 2), + SolutionPair { + source_config: vec![1, 1], + target_config: vec![1, 0, 1, 0, 0, 1, 2], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/longestpath_ilp.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 25baa1342..e9fbdb280 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -70,6 +70,8 @@ pub(crate) mod knapsack_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod longestcommonsubsequence_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod longestpath_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod maximumclique_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod maximummatching_ilp; @@ -140,6 +142,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec LongestPath { + LongestPath::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 6), + (1, 6), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 4, 1], + 0, + 6, + ) +} + +fn optimal_config() -> Vec { + vec![1, 0, 1, 1, 1, 0, 1, 0, 1, 0] +} + +fn suboptimal_config() -> Vec { + vec![0, 1, 1, 0, 1, 1, 1, 0, 0, 1] +} + +#[test] +fn test_longest_path_creation() { + let mut problem = issue_problem(); + + assert_eq!(problem.graph().num_vertices(), 7); + assert_eq!(problem.graph().num_edges(), 10); + assert_eq!(problem.num_vertices(), 7); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.source_vertex(), 0); + assert_eq!(problem.target_vertex(), 6); + assert_eq!(problem.dims(), vec![2; 10]); + assert_eq!(problem.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 4, 1]); + assert!(problem.is_weighted()); + assert_eq!(problem.direction(), Direction::Maximize); + + problem.set_lengths(vec![1; 10]); + assert_eq!(problem.edge_lengths(), &[1; 10]); + + let unweighted = LongestPath::new(SimpleGraph::path(4), vec![One; 3], 0, 3); + assert!(!unweighted.is_weighted()); +} + +#[test] +fn test_longest_path_evaluate_valid_and_invalid_configs() { + let problem = issue_problem(); + + assert_eq!(problem.evaluate(&optimal_config()), SolutionSize::Valid(20)); + assert_eq!( + problem.evaluate(&suboptimal_config()), + SolutionSize::Valid(17) + ); + assert!(problem.is_valid_solution(&optimal_config())); + assert!(problem.is_valid_solution(&suboptimal_config())); + + assert_eq!( + problem.evaluate(&[1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + SolutionSize::Invalid + ); + assert_eq!( + problem.evaluate(&[1, 0, 1, 0, 1, 0, 0, 0, 0, 1]), + SolutionSize::Invalid + ); + assert_eq!( + problem.evaluate(&[1, 0, 1, 1, 1, 1, 1, 1, 1, 1]), + SolutionSize::Invalid + ); + assert_eq!(problem.evaluate(&[0; 10]), SolutionSize::Invalid); + assert!(!problem.is_valid_solution(&[1, 0, 1])); + assert!(!problem.is_valid_solution(&[1, 0, 1, 0, 1, 0, 1, 0, 1, 2])); +} + +#[test] +fn test_longest_path_bruteforce_finds_issue_optimum() { + let problem = issue_problem(); + let solver = BruteForce::new(); + + let best = solver.find_best(&problem).unwrap(); + assert_eq!(best, optimal_config()); + assert_eq!(problem.evaluate(&best), SolutionSize::Valid(20)); + + let all_best = solver.find_all_best(&problem); + assert_eq!(all_best, vec![optimal_config()]); +} + +#[test] +fn test_longest_path_serialization() { + let problem = issue_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: LongestPath = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.num_vertices(), 7); + assert_eq!(restored.num_edges(), 10); + assert_eq!(restored.source_vertex(), 0); + assert_eq!(restored.target_vertex(), 6); + assert_eq!(restored.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 4, 1]); + assert_eq!( + restored.evaluate(&optimal_config()), + SolutionSize::Valid(20) + ); +} + +#[test] +fn test_longest_path_source_equals_target_only_allows_empty_path() { + let problem = LongestPath::new(SimpleGraph::path(3), vec![5, 7], 1, 1); + + assert!(problem.is_valid_solution(&[0, 0])); + assert_eq!(problem.evaluate(&[0, 0]), SolutionSize::Valid(0)); + assert!(!problem.is_valid_solution(&[1, 0])); + assert_eq!(problem.evaluate(&[1, 0]), SolutionSize::Invalid); + + let best = BruteForce::new().find_best(&problem).unwrap(); + assert_eq!(best, vec![0, 0]); +} + +#[test] +fn test_longestpath_paper_example() { + let problem = issue_problem(); + + assert_eq!(problem.evaluate(&optimal_config()), SolutionSize::Valid(20)); + assert_eq!( + problem.evaluate(&suboptimal_config()), + SolutionSize::Valid(17) + ); + assert_eq!( + problem.evaluate(&[1, 1, 1, 0, 0, 0, 0, 0, 0, 0]), + SolutionSize::Invalid + ); +} + +#[test] +fn test_longest_path_problem_name() { + assert_eq!( + as Problem>::NAME, + "LongestPath" + ); +} + +#[test] +#[should_panic(expected = "edge_lengths length must match num_edges")] +fn test_longest_path_rejects_wrong_edge_lengths_len() { + LongestPath::new(SimpleGraph::path(3), vec![1], 0, 2); +} + +#[test] +#[should_panic(expected = "All edge lengths must be positive (> 0)")] +fn test_longest_path_rejects_non_positive_edge_lengths() { + LongestPath::new(SimpleGraph::path(2), vec![0], 0, 1); +} + +#[test] +#[should_panic(expected = "source_vertex 3 out of bounds (graph has 3 vertices)")] +fn test_longest_path_rejects_out_of_bounds_source() { + LongestPath::new(SimpleGraph::path(3), vec![1, 1], 3, 2); +} + +#[test] +#[should_panic(expected = "target_vertex 3 out of bounds (graph has 3 vertices)")] +fn test_longest_path_rejects_out_of_bounds_target() { + LongestPath::new(SimpleGraph::path(3), vec![1, 1], 0, 3); +} diff --git a/src/unit_tests/rules/longestpath_ilp.rs b/src/unit_tests/rules/longestpath_ilp.rs new file mode 100644 index 000000000..6f8411e05 --- /dev/null +++ b/src/unit_tests/rules/longestpath_ilp.rs @@ -0,0 +1,108 @@ +use super::*; +use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::{BruteForce, ILPSolver, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::SolutionSize; + +fn issue_problem() -> LongestPath { + LongestPath::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 5), + (4, 5), + (4, 6), + (5, 6), + (1, 6), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 4, 1], + 0, + 6, + ) +} + +fn simple_path_problem() -> LongestPath { + LongestPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![2, 3], 0, 2) +} + +#[test] +fn test_reduction_creates_expected_ilp_shape() { + let problem = simple_path_problem(); + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + assert_eq!(ilp.num_vars, 7); + assert_eq!(ilp.constraints.len(), 23); + assert_eq!(ilp.sense, ObjectiveSense::Maximize); + + let mut objective = vec![0.0; ilp.num_vars]; + for &(var, coeff) in &ilp.objective { + objective[var] = coeff; + } + + assert_eq!(objective[0], 2.0); + assert_eq!(objective[1], 2.0); + assert_eq!(objective[2], 3.0); + assert_eq!(objective[3], 3.0); + assert_eq!(objective[4], 0.0); +} + +#[test] +fn test_longestpath_to_ilp_closed_loop_on_issue_example() { + let problem = issue_problem(); + let brute_force = BruteForce::new(); + let best = brute_force + .find_best(&problem) + .expect("brute-force optimum"); + let best_value = problem.evaluate(&best); + assert_eq!(best_value, SolutionSize::Valid(20)); + + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + let ilp_solver = ILPSolver::new(); + let ilp_solution = ilp_solver + .solve(reduction.target_problem()) + .expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert!(problem.is_valid_solution(&extracted)); + assert_eq!(problem.evaluate(&extracted), best_value); +} + +#[test] +fn test_solution_extraction_from_handcrafted_ilp_assignment() { + let problem = simple_path_problem(); + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + + // x_{0->1}, x_{1->0}, x_{1->2}, x_{2->1}, o_0, o_1, o_2 + let target_solution = vec![1, 0, 1, 0, 0, 1, 2]; + let extracted = reduction.extract_solution(&target_solution); + + assert_eq!(extracted, vec![1, 1]); + assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(5)); +} + +#[test] +fn test_source_equals_target_uses_empty_path() { + let problem = LongestPath::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![4, 5, 6], + 1, + 1, + ); + let reduction: ReductionLongestPathToILP = ReduceTo::>::reduce_to(&problem); + let ilp_solver = ILPSolver::new(); + let ilp_solution = ilp_solver + .solve(reduction.target_problem()) + .expect("ILP should solve the trivial empty-path case"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert_eq!(extracted, vec![0, 0, 0]); + assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(0)); +}