diff --git a/src/graph/mod.rs b/src/graph/mod.rs index e87af6d..348c643 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -1,10 +1,13 @@ mod format; +mod pathfinding; mod transform_edge; pub use format::Format; +pub use pathfinding::TransformPath; pub use transform_edge::TransformEdge; use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::EdgeRef; use std::collections::HashMap; /// A directed graph of document format transformations. @@ -100,6 +103,92 @@ impl TransformGraph { }; self.graph.contains_edge(from_idx, to_idx) } + + /// Reconstruct the cheapest directed edge between each consecutive pair of + /// nodes in `node_path` and return them as an ordered `Vec`. + /// + /// When multiple parallel edges connect the same pair of nodes the one with + /// the lowest cost is chosen, which is consistent with the cost function + /// used by the pathfinding algorithms. + fn edges_from_node_path(&self, node_path: &[NodeIndex]) -> Vec { + node_path + .windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + self.graph + .edges(a) + .filter(|e| e.target() == b) + .min_by(|x, y| { + x.weight() + .cost + .partial_cmp(&y.weight().cost) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .expect("node path contains a pair with no connecting edge") + .weight() + .clone() + }) + .collect() + } + + /// Find the lowest-cost path from `from` to `to` using Dijkstra's + /// algorithm. + /// + /// Cost is treated as additive (sum of edge costs) and the path is + /// selected to minimise total cost. Quality is computed multiplicatively + /// along the chosen path and stored in the returned [`TransformPath`]. + /// + /// Returns `None` when no path exists between the two formats. + pub fn find_path(&self, from: Format, to: Format) -> Option { + use petgraph::algo::astar; + + let (&from_idx, &to_idx) = + match (self.nodes.get(&from), self.nodes.get(&to)) { + (Some(f), Some(t)) => (f, t), + _ => return None, + }; + + let (_cost, node_path) = astar( + &self.graph, + from_idx, + |n| n == to_idx, + |e| e.weight().cost, + |_| 0.0_f32, + )?; + + Some(TransformPath::from_steps(self.edges_from_node_path(&node_path))) + } + + /// Return all simple paths (no repeated nodes) from `from` to `to`. + /// + /// The returned [`Vec`] is sorted by `total_cost` ascending so callers can + /// easily compare candidate pipelines. An empty `Vec` is returned when no + /// path exists. + pub fn find_all_paths(&self, from: Format, to: Format) -> Vec { + use petgraph::algo::all_simple_paths; + + let (&from_idx, &to_idx) = + match (self.nodes.get(&from), self.nodes.get(&to)) { + (Some(f), Some(t)) => (f, t), + _ => return Vec::new(), + }; + + let mut paths: Vec = + all_simple_paths::, _, std::collections::hash_map::RandomState>( + &self.graph, from_idx, to_idx, 0, None, + ) + .map(|node_path: Vec| { + TransformPath::from_steps(self.edges_from_node_path(&node_path)) + }) + .collect(); + + paths.sort_by(|a, b| { + a.total_cost + .partial_cmp(&b.total_cost) + .unwrap_or(std::cmp::Ordering::Equal) + }); + paths + } } impl Default for TransformGraph { @@ -317,4 +406,163 @@ mod tests { ); } } + + // ── find_path ───────────────────────────────────────────────────────────── + + #[test] + fn test_find_path_direct_single_hop() { + let mut graph = TransformGraph::new(); + graph.add_transform(markdown_to_pdf()); + + let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); + assert_eq!(path.steps.len(), 1); + assert_eq!(path.steps[0].from, Format::Markdown); + assert_eq!(path.steps[0].to, Format::Pdf); + assert!((path.total_cost - 1.0).abs() < 1e-5); + assert!((path.total_quality - 0.9).abs() < 1e-5); + } + + #[test] + fn test_find_path_multi_hop() { + let mut graph = TransformGraph::new(); + graph.add_transform(markdown_to_html()); // cost 0.5, quality 1.0 + graph.add_transform(html_to_pdf()); // cost 0.8, quality 0.85 + + let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); + assert_eq!(path.steps.len(), 2); + assert_eq!(path.steps[0].to, Format::Html); + assert_eq!(path.steps[1].to, Format::Pdf); + assert!((path.total_cost - 1.3).abs() < 1e-5); + assert!((path.total_quality - 0.85).abs() < 1e-5); + } + + #[test] + fn test_find_path_prefers_lower_cost() { + let mut graph = TransformGraph::new(); + // Direct path — more expensive. + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.9)); + // Indirect path via HTML — cheaper overall (0.5 + 0.8 = 1.3). + graph.add_transform(markdown_to_html()); + graph.add_transform(html_to_pdf()); + + let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); + // The indirect path (total_cost 1.3) should be chosen over the direct + // path (total_cost 5.0). + assert_eq!(path.steps.len(), 2); + assert!((path.total_cost - 1.3).abs() < 1e-5); + } + + #[test] + fn test_find_path_returns_none_when_no_path() { + let mut graph = TransformGraph::new(); + graph.add_transform(markdown_to_html()); + // No edge from Html → Pdf, so Markdown → Pdf has no path. + assert!(graph.find_path(Format::Markdown, Format::Pdf).is_none()); + } + + #[test] + fn test_find_path_returns_none_for_unknown_format() { + let graph = TransformGraph::new(); + assert!(graph.find_path(Format::Markdown, Format::Pdf).is_none()); + } + + #[test] + fn test_find_path_cost_additive() { + let mut graph = TransformGraph::new(); + // Three hops: Markdown → Html (1.0) → Pdf (2.0) — total 3.0 + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 1.0, 1.0)); + graph.add_transform(TransformEdge::new(Format::Html, Format::Pdf, 2.0, 1.0)); + + let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); + assert!((path.total_cost - 3.0).abs() < 1e-5); + } + + #[test] + fn test_find_path_quality_multiplicative() { + let mut graph = TransformGraph::new(); + // quality: 0.9 * 0.8 = 0.72 + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 1.0, 0.9)); + graph.add_transform(TransformEdge::new(Format::Html, Format::Pdf, 1.0, 0.8)); + + let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); + assert!((path.total_quality - 0.72).abs() < 1e-5); + } + + #[test] + fn test_find_path_chooses_cheapest_parallel_edge() { + let mut graph = TransformGraph::new(); + // Two parallel edges between the same nodes with different costs. + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 3.0, 0.9)); + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 1.0, 0.7)); + + let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); + assert!((path.total_cost - 1.0).abs() < 1e-5); + } + + // ── find_all_paths ──────────────────────────────────────────────────────── + + #[test] + fn test_find_all_paths_single_path() { + let mut graph = TransformGraph::new(); + graph.add_transform(markdown_to_pdf()); + + let paths = graph.find_all_paths(Format::Markdown, Format::Pdf); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].steps.len(), 1); + } + + #[test] + fn test_find_all_paths_returns_both_direct_and_indirect() { + let mut graph = TransformGraph::new(); + graph.add_transform(markdown_to_pdf()); // direct + graph.add_transform(markdown_to_html()); + graph.add_transform(html_to_pdf()); // indirect via Html + + let paths = graph.find_all_paths(Format::Markdown, Format::Pdf); + assert_eq!(paths.len(), 2); + } + + #[test] + fn test_find_all_paths_sorted_by_cost_ascending() { + let mut graph = TransformGraph::new(); + // Direct (cost 5.0) and indirect (cost 1.3). + graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.9)); + graph.add_transform(markdown_to_html()); + graph.add_transform(html_to_pdf()); + + let paths = graph.find_all_paths(Format::Markdown, Format::Pdf); + assert_eq!(paths.len(), 2); + // Cheaper path comes first. + assert!(paths[0].total_cost <= paths[1].total_cost); + assert!((paths[0].total_cost - 1.3).abs() < 1e-5); + assert!((paths[1].total_cost - 5.0).abs() < 1e-5); + } + + #[test] + fn test_find_all_paths_empty_when_no_path() { + let mut graph = TransformGraph::new(); + graph.add_transform(markdown_to_html()); + + let paths = graph.find_all_paths(Format::Markdown, Format::Pdf); + assert!(paths.is_empty()); + } + + #[test] + fn test_find_all_paths_empty_for_unknown_format() { + let graph = TransformGraph::new(); + assert!(graph.find_all_paths(Format::Markdown, Format::Pdf).is_empty()); + } + + #[test] + fn test_find_all_paths_metrics_correct() { + let mut graph = TransformGraph::new(); + // cost 0.5, quality 1.0 then cost 0.8, quality 0.85 + graph.add_transform(markdown_to_html()); + graph.add_transform(html_to_pdf()); + + let paths = graph.find_all_paths(Format::Markdown, Format::Pdf); + assert_eq!(paths.len(), 1); + assert!((paths[0].total_cost - 1.3).abs() < 1e-5); + assert!((paths[0].total_quality - 0.85).abs() < 1e-5); + } } diff --git a/src/graph/pathfinding.rs b/src/graph/pathfinding.rs new file mode 100644 index 0000000..3f9439e --- /dev/null +++ b/src/graph/pathfinding.rs @@ -0,0 +1,46 @@ +use super::TransformEdge; + +/// The result of a successful pathfinding query through the transformation +/// graph. +/// +/// `steps` contains the ordered sequence of [`TransformEdge`]s that must be +/// applied in order to convert a document from the source format to the target +/// format. `total_cost` is the sum of every edge cost along the path +/// (additive), and `total_quality` is the product of every edge quality value +/// along the path (multiplicative). +/// +/// # Example +/// +/// ```rust +/// use renderflow::graph::{Format, TransformEdge, TransformGraph, TransformPath}; +/// +/// let mut graph = TransformGraph::new(); +/// graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 0.5, 1.0)); +/// graph.add_transform(TransformEdge::new(Format::Html, Format::Pdf, 0.8, 0.85)); +/// +/// let path = graph.find_path(Format::Markdown, Format::Pdf).unwrap(); +/// assert_eq!(path.steps.len(), 2); +/// assert!((path.total_cost - 1.3).abs() < 1e-5); +/// assert!((path.total_quality - 0.85).abs() < 1e-5); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct TransformPath { + /// Ordered list of transformations to apply. + pub steps: Vec, + /// Sum of the cost of every step in the path (additive). + pub total_cost: f32, + /// Product of the quality of every step in the path (multiplicative). + pub total_quality: f32, +} + +impl TransformPath { + /// Build a `TransformPath` from an ordered list of [`TransformEdge`]s. + /// + /// `total_cost` is computed as the sum of all edge costs; `total_quality` + /// is computed as the product of all edge quality values. + pub(super) fn from_steps(steps: Vec) -> Self { + let total_cost = steps.iter().map(|e| e.cost).sum(); + let total_quality = steps.iter().map(|e| e.quality).product(); + Self { steps, total_cost, total_quality } + } +}