Skip to content

Commit

Permalink
Move dag longest path functions to rustworkx-core. (#1192)
Browse files Browse the repository at this point in the history
* Added `longest_path` to rustworkx (needs verification)

Started to port `longest_path` to rustworkx by first adding `longest_path.rs` to `rustworkx-core/src/lib/rs`.

Differences from the original in `rustworkx/src/dag_algo/longest_path.rs`:

- Removed Python-specific code: The function no longer uses pyo3 and PyObject. Instead, it uses generic Rust types.
- Type flexibility: The function parameters now accept any type N for nodes, E for edges, and a weight function F that maps edges (represented by their node indices and edge reference) to a weight of type T. This allows the function to work with different graph and edge types.
- Error handling: Instead of returning a Result, it returns an Option to signify failure only in the case of a cycle, which is preemptively checked at the beginning.
- Using Ord and Add: These trait bounds on T allow for ordering and arithmetic, necessary for determining the longest path.

* Added tests to `rustworkx-core/src/longest_path.rs`

Explanation of Test Cases:

- test_empty_graph: Checks that an empty graph returns a path of length zero with no nodes.
- test_single_node: Checks that a graph with a single node returns the node itself with zero path length (since there's no edge).
 - test_simple_path: A simple two-node graph with one edge. Tests if the function correctly identifies the longest path.
 - test_dag_with_multiple_paths: A Directed Acyclic Graph (DAG) with three nodes and a choice of paths, to test if the function picks the longest one.
- test_graph_with_cycle: Ensures that the function returns None for a graph with a cycle, as longest path calculations should only be done on DAGs.

These test can be run with `cargo test` or with `cargo test test_longest_path`

* Added more detailed function description

Added function descriptions and comments to `longest_path.rs`.

The function description is based off of the format in `rustworkx-core/src/line_graph`.

* Style changes

* Updated longest_path.rs

Slight optimizations for `longest_path.rs`

* Changed longest_path to accept any G

Adjusted the function to accept `G` instead of a `DiGraph` where `G` has the mentioned traits to represent any directed graph. This allows the function to be more general and apply to both `DiGraph` and `StableDiGraph`.

I also changed some other parts of the code to match the original for consistency.

However, there is a current issue with compiling the tests, as for some reason, the tests do not satisfy the trait `EdgeRef`.

* Fixed Test Cases and Adjusted Parameters

Adjusted parameters to that F now represents a function that will pass the `EdgeRef` for the edges in the graph.

Before we had an error when passing the `EdgeRef` as a trait of `G`, but it should actually be used for `F`.

Adjusted the tests to account for that change and added additional tests of StableDiGraph and negative weights.

* Converted return time to use usize instead of NodeIndex

Converted the return type from ``Some((Vec<NodeIndex>, T))`` to `Some((Vec<usize>, T))` to match the original function more. May need to change back to `NodeIndex` if it makes more sense to use it, as I can see a couple of cases where that is more ideal.

* Updated `longest_path` in src to use rustworkx_core

Changed `longest_path` to call `core_longest_path` (the implementation in rustworkx_core) by creating the `dag` and `edge_cost` parameters for that function.

However, there is an issue of unvalid weights, which I am unsure how to handle at the moment.

All tests seem to pass except for `digraph.test_depth.TestWeightedLongestPath.test_nan_not_valid_weight`.

* Changed the return type for the weight function

Changed the return type to handle the kind of errors that the weight function can return.

Made changes so that it is more similar to how we implemented `dijkstra`.

Added a small test case for this change.

* Updated `longest_path.rs` in src to reflect the new type change

Updated the edge_cost to return `Result<T, PyErr>` and updated error handling.

Now all tests pass.

Also updated style and added an example.

* Added release notes

* Documentation changes

* Polish up and slight optimizations

Summary of Changes:
- Change variable names for clarity: `incoming_path` instead of `us` and `max_path` instead of `maxu`
- Simplified while Loop Condition: Replaced complex match statement with map_or for clearer and more concise logic in the backtracking loop.
- Optimized HashMap Initialization: Pre-allocated HashMap with capacity equal to the number of nodes to improve performance and memory usage.
- Enhanced Maximum Distance Calculation: Used into_iter and unwrap_or to simplify logic and improve readability by directly handling maximum value computation and default cases.

* Documentation and style

Added a function description to `longest_path` in `src/dag_algo/longest_path.rs`

* Update releasenotes/notes/migrate-longest_path-7c11cf2c8ac9781f.yaml

Co-authored-by: Matthew Treinish <mtreinish@kortar.org>

* Rename the module to `dag_algo`

Rename `longest_path.rs` to `dag_algo` as the `longest_path` function works only for DAGs and now we can include other DAG function here.

* Changed return type for the output `Vec`

Changed the return type from `Some((Vec<usize>, T))` to `Some((Vec<NodeId<G>>, T))`, as it makes  it easier for users to use `G::NodeId` (which is the generic form of NodeIndex).

Also added aliases for readbility.

---------

Co-authored-by: Matthew Treinish <mtreinish@kortar.org>
  • Loading branch information
henryzou50 and mtreinish committed May 15, 2024
1 parent 325839b commit 12f8af5
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 52 deletions.
7 changes: 7 additions & 0 deletions releasenotes/notes/migrate-longest_path-7c11cf2c8ac9781f.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
features:
- |
Added a new module ``dag_algo`` to rustworkx-core which contains a new function ``longest_path``
function to rustworkx-core. Previously the ``longest_path`` functionality for DAGs was only exposed
via the Python interface. Now Rust users can take advantage of this functionality in rustworkx-core.
236 changes: 236 additions & 0 deletions rustworkx-core/src/dag_algo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
use hashbrown::HashMap;

use petgraph::algo;
use petgraph::graph::NodeIndex;
use petgraph::visit::{
EdgeRef, GraphBase, GraphProp, IntoEdgesDirected, IntoNeighborsDirected, IntoNodeIdentifiers,
Visitable,
};
use petgraph::Directed;

use num_traits::{Num, Zero};

// Type aliases for readability
type NodeId<G> = <G as GraphBase>::NodeId;
type LongestPathResult<G, T, E> = Result<Option<(Vec<NodeId<G>>, T)>, E>;

/// Calculates the longest path in a directed acyclic graph (DAG).
///
/// This function computes the longest path by weight in a given DAG. It will return the longest path
/// along with its total weight, or `None` if the graph contains cycles which make the longest path
/// computation undefined.
///
/// # Arguments
/// * `graph`: Reference to a directed graph.
/// * `weight_fn` - An input callable that will be passed the `EdgeRef` for each edge in the graph.
/// The callable should return the weight of the edge as `Result<T, E>`. The weight must be a type that implements
/// `Num`, `Zero`, `PartialOrd`, and `Copy`.
///
/// # Type Parameters
/// * `G`: Type of the graph. Must be a directed graph.
/// * `F`: Type of the weight function.
/// * `T`: The type of the edge weight. Must implement `Num`, `Zero`, `PartialOrd`, and `Copy`.
/// * `E`: The type of the error that the weight function can return.
///
/// # Returns
/// * `None` if the graph contains a cycle.
/// * `Some((Vec<NodeId<G>>, T))` representing the longest path as a sequence of nodes and its total weight.
/// * `Err(E)` if there is an error computing the weight of any edge.
///
/// # Example
/// ```
/// use petgraph::graph::DiGraph;
/// use petgraph::graph::NodeIndex;
/// use petgraph::Directed;
/// use rustworkx_core::dag_algo::longest_path;
///
/// let mut graph: DiGraph<(), i32> = DiGraph::new();
/// let n0 = graph.add_node(());
/// let n1 = graph.add_node(());
/// let n2 = graph.add_node(());
/// graph.add_edge(n0, n1, 1);
/// graph.add_edge(n0, n2, 3);
/// graph.add_edge(n1, n2, 1);
///
/// let weight_fn = |edge: petgraph::graph::EdgeReference<i32>| Ok::<i32, &str>(*edge.weight());
/// let result = longest_path(&graph, weight_fn).unwrap();
/// assert_eq!(result, Some((vec![n0, n2], 3)));
/// ```
pub fn longest_path<G, F, T, E>(graph: G, mut weight_fn: F) -> LongestPathResult<G, T, E>
where
G: GraphProp<EdgeType = Directed>
+ IntoNodeIdentifiers
+ IntoNeighborsDirected
+ IntoEdgesDirected
+ Visitable
+ GraphBase<NodeId = NodeIndex>,
F: FnMut(G::EdgeRef) -> Result<T, E>,
T: Num + Zero + PartialOrd + Copy,
{
let mut path: Vec<NodeId<G>> = Vec::new();
let nodes = match algo::toposort(graph, None) {
Ok(nodes) => nodes,
Err(_) => return Ok(None), // Return None if the graph contains a cycle
};

if nodes.is_empty() {
return Ok(Some((path, T::zero())));
}

let mut dist: HashMap<NodeIndex, (T, NodeIndex)> = HashMap::with_capacity(nodes.len()); // Stores the distance and the previous node

// Iterate over nodes in topological order
for node in nodes {
let parents = graph.edges_directed(node, petgraph::Direction::Incoming);
let mut incoming_path: Vec<(T, NodeIndex)> = Vec::new(); // Stores the distance and the previous node for each parent
for p_edge in parents {
let p_node = p_edge.source();
let weight: T = weight_fn(p_edge)?;
let length = dist[&p_node].0 + weight;
incoming_path.push((length, p_node));
}
// Determine the maximum distance and corresponding parent node
let max_path: (T, NodeIndex) = incoming_path
.into_iter()
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
.unwrap_or((T::zero(), node)); // If there are no incoming edges, the distance is zero

// Store the maximum distance and the corresponding parent node for the current node
dist.insert(node, max_path);
}
let (first, _) = dist
.iter()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap();
let mut v = *first;
let mut u: Option<NodeIndex> = None;
// Backtrack from this node to find the path
while u.map_or(true, |u| u != v) {
path.push(v);
u = Some(v);
v = dist[&v].1;
}
path.reverse(); // Reverse the path to get the correct order
let path_weight = dist[first].0; // The total weight of the longest path

Ok(Some((path, path_weight)))
}

#[cfg(test)]
mod test_longest_path {
use super::*;
use petgraph::graph::DiGraph;
use petgraph::stable_graph::StableDiGraph;

#[test]
fn test_empty_graph() {
let graph: DiGraph<(), ()> = DiGraph::new();
let weight_fn = |_: petgraph::graph::EdgeReference<()>| Ok::<i32, &str>(0);
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Ok(Some((vec![], 0))));
}

#[test]
fn test_single_node_graph() {
let mut graph: DiGraph<(), ()> = DiGraph::new();
let n0 = graph.add_node(());
let weight_fn = |_: petgraph::graph::EdgeReference<()>| Ok::<i32, &str>(0);
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Ok(Some((vec![n0], 0))));
}

#[test]
fn test_dag_with_multiple_paths() {
let mut graph: DiGraph<(), i32> = DiGraph::new();
let n0 = graph.add_node(());
let n1 = graph.add_node(());
let n2 = graph.add_node(());
let n3 = graph.add_node(());
let n4 = graph.add_node(());
let n5 = graph.add_node(());
graph.add_edge(n0, n1, 3);
graph.add_edge(n0, n2, 2);
graph.add_edge(n1, n2, 1);
graph.add_edge(n1, n3, 4);
graph.add_edge(n2, n3, 2);
graph.add_edge(n3, n4, 2);
graph.add_edge(n2, n5, 1);
graph.add_edge(n4, n5, 3);
let weight_fn = |edge: petgraph::graph::EdgeReference<i32>| Ok::<i32, &str>(*edge.weight());
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Ok(Some((vec![n0, n1, n3, n4, n5], 12))));
}

#[test]
fn test_graph_with_cycle() {
let mut graph: DiGraph<(), i32> = DiGraph::new();
let n0 = graph.add_node(());
let n1 = graph.add_node(());
graph.add_edge(n0, n1, 1);
graph.add_edge(n1, n0, 1); // Creates a cycle

let weight_fn = |edge: petgraph::graph::EdgeReference<i32>| Ok::<i32, &str>(*edge.weight());
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Ok(None));
}

#[test]
fn test_negative_weights() {
let mut graph: DiGraph<(), i32> = DiGraph::new();
let n0 = graph.add_node(());
let n1 = graph.add_node(());
let n2 = graph.add_node(());
graph.add_edge(n0, n1, -1);
graph.add_edge(n0, n2, 2);
graph.add_edge(n1, n2, -2);
let weight_fn = |edge: petgraph::graph::EdgeReference<i32>| Ok::<i32, &str>(*edge.weight());
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Ok(Some((vec![n0, n2], 2))));
}

#[test]
fn test_longest_path_in_stable_digraph() {
let mut graph: StableDiGraph<(), i32> = StableDiGraph::new();
let n0 = graph.add_node(());
let n1 = graph.add_node(());
let n2 = graph.add_node(());
graph.add_edge(n0, n1, 1);
graph.add_edge(n0, n2, 3);
graph.add_edge(n1, n2, 1);
let weight_fn =
|edge: petgraph::stable_graph::EdgeReference<'_, i32>| Ok::<i32, &str>(*edge.weight());
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Ok(Some((vec![n0, n2], 3))));
}

#[test]
fn test_error_handling() {
let mut graph: DiGraph<(), i32> = DiGraph::new();
let n0 = graph.add_node(());
let n1 = graph.add_node(());
let n2 = graph.add_node(());
graph.add_edge(n0, n1, 1);
graph.add_edge(n0, n2, 2);
graph.add_edge(n1, n2, 1);
let weight_fn = |edge: petgraph::graph::EdgeReference<i32>| {
if *edge.weight() == 2 {
Err("Error: edge weight is 2")
} else {
Ok::<i32, &str>(*edge.weight())
}
};
let result = longest_path(&graph, weight_fn);
assert_eq!(result, Err("Error: edge weight is 2"));
}
}
2 changes: 1 addition & 1 deletion rustworkx-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ pub mod centrality;
/// Module for coloring algorithms.
pub mod coloring;
pub mod connectivity;
pub mod dag_algo;
pub mod generators;
pub mod line_graph;

/// Module for maximum weight matching algorithms.
pub mod max_weight_matching;
pub mod planar;
Expand Down
87 changes: 36 additions & 51 deletions src/dag_algo/longest_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,69 +11,54 @@
// under the License.

use crate::{digraph, DAGHasCycle};
use rustworkx_core::dag_algo::longest_path as core_longest_path;

use hashbrown::HashMap;
use petgraph::stable_graph::{EdgeReference, NodeIndex};
use petgraph::visit::EdgeRef;

use pyo3::prelude::*;

use petgraph::algo;
use petgraph::prelude::*;
use petgraph::stable_graph::NodeIndex;

use num_traits::{Num, Zero};

/// Calculate the longest path in a directed acyclic graph (DAG).
///
/// This function interfaces with the Python `PyDiGraph` object to compute the longest path
/// using the provided weight function.
///
/// # Arguments
/// * `graph`: Reference to a `PyDiGraph` object.
/// * `weight_fn`: A callable that takes the source node index, target node index, and the weight
/// object and returns the weight of the edge as a `PyResult<T>`.
///
/// # Type Parameters
/// * `F`: Type of the weight function.
/// * `T`: The type of the edge weight. Must implement `Num`, `Zero`, `PartialOrd`, and `Copy`.
///
/// # Returns
/// * `PyResult<(Vec<G::NodeId>, T)>` representing the longest path as a sequence of node indices and its total weight.
pub fn longest_path<F, T>(graph: &digraph::PyDiGraph, mut weight_fn: F) -> PyResult<(Vec<usize>, T)>
where
F: FnMut(usize, usize, &PyObject) -> PyResult<T>,
T: Num + Zero + PartialOrd + Copy,
{
let dag = &graph.graph;
let mut path: Vec<usize> = Vec::new();
let nodes = match algo::toposort(&graph.graph, None) {
Ok(nodes) => nodes,
Err(_err) => return Err(DAGHasCycle::new_err("Sort encountered a cycle")),

// Create a new weight function that matches the required signature
let edge_cost = |edge_ref: EdgeReference<'_, PyObject>| -> Result<T, PyErr> {
let source = edge_ref.source().index();
let target = edge_ref.target().index();
let weight = edge_ref.weight();
weight_fn(source, target, weight)
};
if nodes.is_empty() {
return Ok((path, T::zero()));
}
let mut dist: HashMap<NodeIndex, (T, NodeIndex)> = HashMap::new();
for node in nodes {
let parents = dag.edges_directed(node, petgraph::Direction::Incoming);
let mut us: Vec<(T, NodeIndex)> = Vec::new();
for p_edge in parents {
let p_node = p_edge.source();
let weight: T = weight_fn(p_node.index(), p_edge.target().index(), p_edge.weight())?;
let length = dist[&p_node].0 + weight;
us.push((length, p_node));
}
let maxu: (T, NodeIndex) = if !us.is_empty() {
*us.iter()
.max_by(|a, b| {
let weight_a = a.0;
let weight_b = b.0;
weight_a.partial_cmp(&weight_b).unwrap()
})
.unwrap()
} else {
(T::zero(), node)
};
dist.insert(node, maxu);
}
let first = dist
.keys()
.max_by(|a, b| dist[*a].partial_cmp(&dist[*b]).unwrap())
.unwrap();
let mut v = *first;
let mut u: Option<NodeIndex> = None;
while match u {
Some(u) => u != v,
None => true,
} {
path.push(v.index());
u = Some(v);
v = dist[&v].1;
}
path.reverse();
let path_weight = dist[first].0;

let (path, path_weight) = match core_longest_path(dag, edge_cost) {
Ok(Some((path, path_weight))) => (
path.into_iter().map(NodeIndex::index).collect(),
path_weight,
),
Ok(None) => return Err(DAGHasCycle::new_err("The graph contains a cycle")),
Err(e) => return Err(e),
};

Ok((path, path_weight))
}

0 comments on commit 12f8af5

Please sign in to comment.