Skip to content

Commit 914a47b

Browse files
authored
Merge pull request #266 from egohygiene/copilot/add-pareto-frontier-path-selection
feat: add Pareto frontier path selection mode
2 parents a502562 + affe870 commit 914a47b

3 files changed

Lines changed: 319 additions & 4 deletions

File tree

src/cli.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ pub enum Commands {
4444
renderflow build Build using renderflow.yaml\n \
4545
renderflow build --config custom.yaml Build with a custom config file\n \
4646
renderflow build --dry-run Preview what would be built\n \
47-
renderflow build --optimization speed Build using speed optimization mode"
47+
renderflow build --optimization speed Build using speed optimization mode\n \
48+
renderflow build --optimization pareto Build with Pareto-optimal path selection"
4849
)]
4950
Build {
5051
/// Path to the renderflow configuration file
@@ -57,7 +58,8 @@ pub enum Commands {
5758

5859
/// Optimization mode: controls how transformation paths are selected.
5960
/// Overrides the value set in the config file when provided.
60-
/// Choices: speed (minimise cost), quality (maximise quality), balanced (default).
61+
/// Choices: speed (minimise cost), quality (maximise quality), balanced (default),
62+
/// pareto (return Pareto-optimal frontier of non-dominated paths).
6163
#[arg(long, value_name = "MODE")]
6264
optimization: Option<OptimizationMode>,
6365
},

src/graph/mod.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,46 @@ impl TransformGraph {
295295
}
296296
Some(dag)
297297
}
298+
299+
/// Return the Pareto-optimal frontier of paths from `from` to `to`.
300+
///
301+
/// All simple paths between the two formats are enumerated first. Any
302+
/// path that is dominated (worse or equal cost **and** worse or equal
303+
/// quality compared to another candidate, with at least one strict
304+
/// difference) is discarded. The surviving non-dominated paths are sorted
305+
/// by `total_cost` ascending and capped at `cap` entries to prevent an
306+
/// explosion for densely connected graphs.
307+
///
308+
/// Returns an empty `Vec` when no path exists or when `from`/`to` are
309+
/// unknown formats.
310+
///
311+
/// # Example
312+
///
313+
/// ```rust
314+
/// use renderflow::graph::{Format, TransformEdge, TransformGraph};
315+
///
316+
/// let mut graph = TransformGraph::new();
317+
/// // Direct path: cost=5.0, quality=0.99
318+
/// graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.99));
319+
/// // Indirect path via Html: total cost=1.3, quality=0.85
320+
/// graph.add_transform(TransformEdge::new(Format::Markdown, Format::Html, 0.5, 1.0));
321+
/// graph.add_transform(TransformEdge::new(Format::Html, Format::Pdf, 0.8, 0.85));
322+
///
323+
/// // Neither path dominates the other (cheap+low-quality vs expensive+high-quality).
324+
/// let frontier = graph.find_pareto_paths(Format::Markdown, Format::Pdf, 10);
325+
/// assert_eq!(frontier.len(), 2);
326+
/// ```
327+
pub fn find_pareto_paths(
328+
&self,
329+
from: Format,
330+
to: Format,
331+
cap: usize,
332+
) -> Vec<TransformPath> {
333+
use crate::optimization::pareto_frontier;
334+
335+
let candidates = self.find_all_paths(from, to);
336+
pareto_frontier(&candidates, Some(cap))
337+
}
298338
}
299339

300340
impl Default for TransformGraph {
@@ -758,4 +798,78 @@ mod tests {
758798
assert_eq!(path_default.steps.len(), path_speed.steps.len());
759799
assert!((path_default.total_cost - path_speed.total_cost).abs() < 1e-5);
760800
}
801+
802+
// ── find_pareto_paths ─────────────────────────────────────────────────────
803+
804+
#[test]
805+
fn test_find_pareto_paths_both_non_dominated() {
806+
let mut graph = TransformGraph::new();
807+
// Direct path: cost=5.0, quality=0.99 — high quality, high cost
808+
graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.99));
809+
// Indirect path via Html: total cost=1.3, quality=0.85 — cheaper, lower quality
810+
graph.add_transform(markdown_to_html());
811+
graph.add_transform(html_to_pdf());
812+
813+
// Neither path dominates the other.
814+
let frontier = graph.find_pareto_paths(Format::Markdown, Format::Pdf, 10);
815+
assert_eq!(frontier.len(), 2, "both paths should be on the frontier");
816+
}
817+
818+
#[test]
819+
fn test_find_pareto_paths_dominated_path_excluded() {
820+
let mut graph = TransformGraph::new();
821+
// Path A: cost=1.3, quality=0.85 (indirect via Html)
822+
graph.add_transform(markdown_to_html());
823+
graph.add_transform(html_to_pdf());
824+
// Path B: cost=5.0, quality=0.3 — dominated by A (worse cost AND worse quality)
825+
graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.3));
826+
827+
let frontier = graph.find_pareto_paths(Format::Markdown, Format::Pdf, 10);
828+
assert_eq!(frontier.len(), 1, "dominated path should be filtered out");
829+
assert!((frontier[0].total_cost - 1.3).abs() < 1e-5);
830+
}
831+
832+
#[test]
833+
fn test_find_pareto_paths_cap_limits_results() {
834+
let mut graph = TransformGraph::new();
835+
// Direct path: cost=5.0, quality=0.99
836+
graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.99));
837+
// Indirect path via Html: cost=1.3, quality=0.85
838+
graph.add_transform(markdown_to_html());
839+
graph.add_transform(html_to_pdf());
840+
841+
// Cap at 1 — only cheapest non-dominated path returned.
842+
let frontier = graph.find_pareto_paths(Format::Markdown, Format::Pdf, 1);
843+
assert_eq!(frontier.len(), 1);
844+
assert!((frontier[0].total_cost - 1.3).abs() < 1e-5);
845+
}
846+
847+
#[test]
848+
fn test_find_pareto_paths_empty_when_no_path() {
849+
let mut graph = TransformGraph::new();
850+
graph.add_transform(markdown_to_html());
851+
// No Html → Pdf edge; no path exists.
852+
let frontier = graph.find_pareto_paths(Format::Markdown, Format::Pdf, 10);
853+
assert!(frontier.is_empty());
854+
}
855+
856+
#[test]
857+
fn test_find_pareto_paths_empty_for_unknown_format() {
858+
let graph = TransformGraph::new();
859+
assert!(graph
860+
.find_pareto_paths(Format::Markdown, Format::Pdf, 10)
861+
.is_empty());
862+
}
863+
864+
#[test]
865+
fn test_find_pareto_paths_sorted_by_cost_ascending() {
866+
let mut graph = TransformGraph::new();
867+
graph.add_transform(TransformEdge::new(Format::Markdown, Format::Pdf, 5.0, 0.99));
868+
graph.add_transform(markdown_to_html());
869+
graph.add_transform(html_to_pdf());
870+
871+
let frontier = graph.find_pareto_paths(Format::Markdown, Format::Pdf, 10);
872+
assert_eq!(frontier.len(), 2);
873+
assert!(frontier[0].total_cost <= frontier[1].total_cost);
874+
}
761875
}

src/optimization.rs

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::graph::TransformPath;
88
/// * `Speed` – minimizes total cost; fastest but potentially lower quality.
99
/// * `Quality` – maximizes total quality; best output but potentially slower.
1010
/// * `Balanced` – weighted combination of cost and quality (default).
11+
/// * `Pareto` – returns the full Pareto-optimal frontier of non-dominated paths.
1112
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ValueEnum)]
1213
#[serde(rename_all = "lowercase")]
1314
pub enum OptimizationMode {
@@ -18,6 +19,8 @@ pub enum OptimizationMode {
1819
/// Weighted combination of cost and quality (default).
1920
#[default]
2021
Balanced,
22+
/// Return the Pareto-optimal frontier of non-dominated paths (multi-objective).
23+
Pareto,
2124
}
2225

2326
impl OptimizationMode {
@@ -28,11 +31,12 @@ impl OptimizationMode {
2831
/// * `Speed` – score = −total_cost (lower cost ⇒ higher score)
2932
/// * `Quality` – score = total_quality (higher quality ⇒ higher score)
3033
/// * `Balanced` – score = −0.5 × total_cost + 0.5 × total_quality
34+
/// * `Pareto` – delegates to `Balanced` (single-path scoring not applicable)
3135
pub fn score(&self, path: &TransformPath) -> f32 {
3236
match self {
3337
Self::Speed => -path.total_cost,
3438
Self::Quality => path.total_quality,
35-
Self::Balanced => -0.5 * path.total_cost + 0.5 * path.total_quality,
39+
Self::Balanced | Self::Pareto => -0.5 * path.total_cost + 0.5 * path.total_quality,
3640
}
3741
}
3842

@@ -43,21 +47,104 @@ impl OptimizationMode {
4347
/// * `Speed` – weight = cost
4448
/// * `Quality` – weight = 1 − quality (low quality ⇒ high weight)
4549
/// * `Balanced` – weight = 0.5 × cost + 0.5 × (1 − quality)
50+
/// * `Pareto` – delegates to `Balanced` (single-path weighting not applicable)
4651
pub fn edge_weight(&self, cost: f32, quality: f32) -> f32 {
4752
match self {
4853
Self::Speed => cost,
4954
Self::Quality => 1.0 - quality,
50-
Self::Balanced => 0.5 * cost + 0.5 * (1.0 - quality),
55+
Self::Balanced | Self::Pareto => 0.5 * cost + 0.5 * (1.0 - quality),
5156
}
5257
}
5358
}
5459

60+
/// Filter a slice of paths down to the Pareto-optimal (non-dominated) subset.
61+
///
62+
/// A path A **dominates** path B when A has a lower-or-equal cost *and* a
63+
/// higher-or-equal quality than B, with at least one of those comparisons being
64+
/// strict. Any dominated path is excluded from the returned set.
65+
///
66+
/// The returned paths are sorted by `total_cost` ascending so the cheapest
67+
/// option appears first.
68+
///
69+
/// An optional `cap` limits the number of returned paths; when `None` all
70+
/// non-dominated paths are returned.
71+
///
72+
/// # Example
73+
///
74+
/// ```rust
75+
/// use renderflow::graph::{Format, TransformEdge, TransformPath};
76+
/// use renderflow::optimization::pareto_frontier;
77+
///
78+
/// fn make_path(cost: f32, quality: f32) -> TransformPath {
79+
/// TransformPath {
80+
/// steps: vec![],
81+
/// total_cost: cost,
82+
/// total_quality: quality,
83+
/// }
84+
/// }
85+
///
86+
/// // Path A (cost=1, quality=0.9) dominates path B (cost=2, quality=0.8).
87+
/// let paths = vec![make_path(1.0, 0.9), make_path(2.0, 0.8)];
88+
/// let frontier = pareto_frontier(&paths, None);
89+
/// assert_eq!(frontier.len(), 1);
90+
/// assert!((frontier[0].total_cost - 1.0).abs() < 1e-5);
91+
/// ```
92+
pub fn pareto_frontier(paths: &[TransformPath], cap: Option<usize>) -> Vec<TransformPath> {
93+
let mut frontier: Vec<TransformPath> = Vec::new();
94+
95+
'outer: for candidate in paths {
96+
// Drop candidate if it is dominated by any path already in the frontier.
97+
for existing in &frontier {
98+
if dominates(existing, candidate) {
99+
continue 'outer;
100+
}
101+
}
102+
// Remove any existing paths that are dominated by the new candidate.
103+
frontier.retain(|existing| !dominates(candidate, existing));
104+
frontier.push(candidate.clone());
105+
}
106+
107+
// Sort by total_cost ascending (cheapest first).
108+
// Paths with NaN metrics sort as equal to each other due to partial_cmp fallback.
109+
frontier.sort_by(|a, b| {
110+
a.total_cost
111+
.partial_cmp(&b.total_cost)
112+
.unwrap_or(std::cmp::Ordering::Equal)
113+
});
114+
115+
// Deduplicate paths with identical objectives: two paths that share both
116+
// total_cost and total_quality represent the same trade-off point and only
117+
// one needs to be kept in the frontier.
118+
let mut seen = std::collections::HashSet::new();
119+
frontier.retain(|p| seen.insert((p.total_cost.to_bits(), p.total_quality.to_bits())));
120+
121+
if let Some(limit) = cap {
122+
frontier.truncate(limit);
123+
}
124+
125+
frontier
126+
}
127+
128+
/// Return `true` when path `a` dominates path `b`.
129+
///
130+
/// `a` dominates `b` when it is at least as good in every objective and
131+
/// strictly better in at least one:
132+
/// - `a.total_cost <= b.total_cost`
133+
/// - `a.total_quality >= b.total_quality`
134+
/// - at least one of those comparisons is strict.
135+
fn dominates(a: &TransformPath, b: &TransformPath) -> bool {
136+
a.total_cost <= b.total_cost
137+
&& a.total_quality >= b.total_quality
138+
&& (a.total_cost < b.total_cost || a.total_quality > b.total_quality)
139+
}
140+
55141
impl std::fmt::Display for OptimizationMode {
56142
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57143
match self {
58144
Self::Speed => write!(f, "speed"),
59145
Self::Quality => write!(f, "quality"),
60146
Self::Balanced => write!(f, "balanced"),
147+
Self::Pareto => write!(f, "pareto"),
61148
}
62149
}
63150
}
@@ -160,4 +247,116 @@ mod tests {
160247
fn test_display_balanced() {
161248
assert_eq!(OptimizationMode::Balanced.to_string(), "balanced");
162249
}
250+
251+
#[test]
252+
fn test_display_pareto() {
253+
assert_eq!(OptimizationMode::Pareto.to_string(), "pareto");
254+
}
255+
256+
// ── Pareto score / edge_weight delegate to Balanced ───────────────────────
257+
258+
#[test]
259+
fn test_pareto_score_same_as_balanced() {
260+
let path = make_path(2.0, 0.8);
261+
assert!(
262+
(OptimizationMode::Pareto.score(&path)
263+
- OptimizationMode::Balanced.score(&path))
264+
.abs()
265+
< 1e-5
266+
);
267+
}
268+
269+
#[test]
270+
fn test_pareto_edge_weight_same_as_balanced() {
271+
assert!(
272+
(OptimizationMode::Pareto.edge_weight(1.0, 0.8)
273+
- OptimizationMode::Balanced.edge_weight(1.0, 0.8))
274+
.abs()
275+
< 1e-5
276+
);
277+
}
278+
279+
// ── pareto_frontier ───────────────────────────────────────────────────────
280+
281+
#[test]
282+
fn test_pareto_frontier_single_path_is_optimal() {
283+
let paths = vec![make_path(1.0, 0.9)];
284+
let frontier = pareto_frontier(&paths, None);
285+
assert_eq!(frontier.len(), 1);
286+
}
287+
288+
#[test]
289+
fn test_pareto_frontier_dominated_path_removed() {
290+
// A (cost=1, q=0.9) dominates B (cost=2, q=0.8).
291+
let paths = vec![make_path(1.0, 0.9), make_path(2.0, 0.8)];
292+
let frontier = pareto_frontier(&paths, None);
293+
assert_eq!(frontier.len(), 1);
294+
assert!((frontier[0].total_cost - 1.0).abs() < 1e-5);
295+
}
296+
297+
#[test]
298+
fn test_pareto_frontier_two_non_dominated_paths_kept() {
299+
// A (cost=1, q=0.5) and B (cost=3, q=0.9) — neither dominates the other.
300+
let paths = vec![make_path(1.0, 0.5), make_path(3.0, 0.9)];
301+
let frontier = pareto_frontier(&paths, None);
302+
assert_eq!(frontier.len(), 2);
303+
}
304+
305+
#[test]
306+
fn test_pareto_frontier_sorted_by_cost_ascending() {
307+
let paths = vec![make_path(3.0, 0.9), make_path(1.0, 0.5)];
308+
let frontier = pareto_frontier(&paths, None);
309+
assert_eq!(frontier.len(), 2);
310+
assert!(frontier[0].total_cost <= frontier[1].total_cost);
311+
}
312+
313+
#[test]
314+
fn test_pareto_frontier_cap_limits_results() {
315+
// Three non-dominated paths; cap at 2.
316+
let paths = vec![
317+
make_path(1.0, 0.3),
318+
make_path(2.0, 0.6),
319+
make_path(3.0, 0.9),
320+
];
321+
let frontier = pareto_frontier(&paths, Some(2));
322+
assert_eq!(frontier.len(), 2);
323+
// Cheapest two are returned (sorted by cost).
324+
assert!((frontier[0].total_cost - 1.0).abs() < 1e-5);
325+
assert!((frontier[1].total_cost - 2.0).abs() < 1e-5);
326+
}
327+
328+
#[test]
329+
fn test_pareto_frontier_empty_input_returns_empty() {
330+
let frontier = pareto_frontier(&[], None);
331+
assert!(frontier.is_empty());
332+
}
333+
334+
#[test]
335+
fn test_pareto_frontier_all_dominated_except_one() {
336+
// Each successive path is strictly better in both dimensions.
337+
let paths = vec![
338+
make_path(3.0, 0.5),
339+
make_path(2.0, 0.7),
340+
make_path(1.0, 0.9),
341+
];
342+
let frontier = pareto_frontier(&paths, None);
343+
assert_eq!(frontier.len(), 1);
344+
assert!((frontier[0].total_cost - 1.0).abs() < 1e-5);
345+
assert!((frontier[0].total_quality - 0.9).abs() < 1e-5);
346+
}
347+
348+
#[test]
349+
fn test_pareto_frontier_equal_paths_deduplicated() {
350+
// Two paths with identical objectives: only one should be kept.
351+
let paths = vec![make_path(1.0, 0.9), make_path(1.0, 0.9)];
352+
let frontier = pareto_frontier(&paths, None);
353+
assert_eq!(frontier.len(), 1);
354+
}
355+
356+
#[test]
357+
fn test_pareto_frontier_cap_zero_returns_empty() {
358+
let paths = vec![make_path(1.0, 0.9), make_path(2.0, 0.5)];
359+
let frontier = pareto_frontier(&paths, Some(0));
360+
assert!(frontier.is_empty());
361+
}
163362
}

0 commit comments

Comments
 (0)