@@ -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" ) ]
1314pub 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
2326impl 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+
55141impl 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