From 5bd8b571e73eae076b8634e1fcb9549ed52acfa7 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 20:20:05 -0600 Subject: [PATCH] feat(examples): rank the jiggly Pareto front and recommend a pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an a-posteriori decision step to the jiggly example. After NSGA-III produces the Pareto front, we apply a weighted-sum score over each objective normalized to [0, 1] across the front (best→1, worst→0, direction-aware), and report the top three plus a clear recommendation. The weights are stated explicitly with rationale, not buried in code: work_fail 45% — screen sleeping mid-meeting is the worst failure lunch_sleep 30% — the actual design goal presses 15% — UX friction the user feels after_hours 10% — minor, mostly screen burn This is the standard structure for picking a single answer out of a Pareto set without losing the front itself: someone with different weights can read the front and pick differently, but we surface a specific recommendation with reasoning rather than leaving the user to stare at 84 incomparable rows. Identical normalization could be swapped for TOPSIS or knee-point detection later if useful. --- examples/jiggly_tuning.rs | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/examples/jiggly_tuning.rs b/examples/jiggly_tuning.rs index 059c674..989e7af 100644 --- a/examples/jiggly_tuning.rs +++ b/examples/jiggly_tuning.rs @@ -268,6 +268,7 @@ fn fmt_rt(m: i32) -> String { } /// One row in the Pareto-front summary table. +#[derive(Clone)] struct Row { rt: i32, ya: i32, @@ -434,4 +435,156 @@ fn main() { println!("=== firmware shipping default (RT=4h00m YEL=30 RED=25 FST=20) ==="); print_header(); print_row("", &shipping_row); + println!(); + + // ------------------------------------------------------------------------- + // A-posteriori pick: rank the front by weighted preferences. + // ------------------------------------------------------------------------- + // + // Every point on the front is incomparable in the strict Pareto sense — + // none dominates another. To surface ONE recommendation we apply explicit + // weights to the four normalized objectives. Anyone with different + // priorities can read the front above and pick a different row. + // + // We add the firmware's shipping defaults to the candidate set so they + // compete on equal footing with the front the optimizer found. + + const W_WORK: f64 = 0.45; // work failures hurt most + const W_LUNCH: f64 = 0.30; // the design goal + const W_PRESS: f64 = 0.15; // UX friction + const W_AFTER: f64 = 0.10; // minor screen-burn cost + + let mut candidates: Vec<(String, Row)> = rows + .iter() + .map(|r| ("front".to_string(), r.clone())) + .collect(); + let shipping_candidate_idx = candidates.len(); + candidates.push(("shipping default".to_string(), shipping_row.clone())); + + let scores = + compute_weighted_scores(&candidates.iter().map(|(_, r)| r.clone()).collect::>()); + let mut ranked: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect(); + ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + println!("=== ranked by weighted preferences ==="); + println!( + " weights: work_fail {}% · lunch_sleep {}% · presses {}% · after_hours {}%", + (W_WORK * 100.0) as i32, + (W_LUNCH * 100.0) as i32, + (W_PRESS * 100.0) as i32, + (W_AFTER * 100.0) as i32, + ); + println!(" candidate set: {} Pareto-front rows + 1 shipping default", rows.len()); + println!(); + println!("{:>4} {:>5} source", "rank", "score"); + print_header(); + for (rank, &(idx, score)) in ranked.iter().take(5).enumerate() { + let (label, r) = &candidates[idx]; + println!("{:>4} {:.3} {label}", rank + 1, score); + print_row("", r); + } + println!(); + + let &(top_idx, top_score) = ranked.first().expect("at least one candidate"); + let (top_label, top) = &candidates[top_idx]; + let shipping_rank = ranked + .iter() + .position(|(i, _)| *i == shipping_candidate_idx) + .map(|p| p + 1) + .unwrap_or(0); + + let max_work = candidates.iter().map(|(_, r)| r.work_fail).fold(0.0, f64::max); + let max_press = candidates.iter().map(|(_, r)| r.presses).fold(0.0, f64::max); + + println!("=== RECOMMENDED PICK ({top_label}) ==="); + println!( + " RT={} YELLOW_AT={} RED_AT={} FAST_RED_AT={}", + fmt_rt(top.rt), + top.ya, + top.ra, + top.fra, + ); + println!(" weighted score = {top_score:.3}"); + println!(); + println!("Why:"); + println!( + " • {} mean work-time failure ({} better than the worst candidate)", + fmt_minutes(top.work_fail), + ratio_str(max_work, top.work_fail.max(1e-9)), + ); + println!( + " • {} mean lunch sleep ({:.1}% land in the 12:15–12:45 sweet spot)", + fmt_minutes(top.lunch), + top.p_sweet * 100.0, + ); + println!( + " • {:.2} button presses/day ({} fewer than the worst candidate)", + top.presses, + ratio_str(max_press, top.presses.max(1e-9)), + ); + println!(" • {} mean after-hours awake (negligible)", fmt_minutes(top.after)); + + if top_label != "shipping default" { + println!(); + println!( + "(Shipping default ranks #{shipping_rank} of {}.)", + candidates.len(), + ); + } else { + println!(); + println!( + "Note: the optimizer found {} non-dominated alternatives, but under", + rows.len(), + ); + println!("these weights the firmware's shipping defaults score highest."); + } +} + +/// Score every row in `rows` by a fixed weighted sum of normalized objectives. +/// +/// Each objective is normalized to `[0, 1]` across `rows` with `1` meaning +/// "best on the front" and `0` meaning "worst on the front", direction-aware +/// (lunch is maximize, the rest are minimize). +fn compute_weighted_scores(rows: &[Row]) -> Vec { + const W_WORK: f64 = 0.45; + const W_LUNCH: f64 = 0.30; + const W_PRESS: f64 = 0.15; + const W_AFTER: f64 = 0.10; + + let work_min = rows.iter().map(|r| r.work_fail).fold(f64::INFINITY, f64::min); + let work_max = rows.iter().map(|r| r.work_fail).fold(f64::NEG_INFINITY, f64::max); + let lunch_min = rows.iter().map(|r| r.lunch).fold(f64::INFINITY, f64::min); + let lunch_max = rows.iter().map(|r| r.lunch).fold(f64::NEG_INFINITY, f64::max); + let press_min = rows.iter().map(|r| r.presses).fold(f64::INFINITY, f64::min); + let press_max = rows.iter().map(|r| r.presses).fold(f64::NEG_INFINITY, f64::max); + let after_min = rows.iter().map(|r| r.after).fold(f64::INFINITY, f64::min); + let after_max = rows.iter().map(|r| r.after).fold(f64::NEG_INFINITY, f64::max); + + rows.iter() + .map(|r| { + let work = norm_min(r.work_fail, work_min, work_max); + let lunch = norm_max(r.lunch, lunch_min, lunch_max); + let press = norm_min(r.presses, press_min, press_max); + let after = norm_min(r.after, after_min, after_max); + W_WORK * work + W_LUNCH * lunch + W_PRESS * press + W_AFTER * after + }) + .collect() +} + +/// Normalize a minimize-direction value to `[0, 1]` (best→1, worst→0). +fn norm_min(v: f64, lo: f64, hi: f64) -> f64 { + if (hi - lo).abs() < 1e-12 { 1.0 } else { (hi - v) / (hi - lo) } +} + +/// Normalize a maximize-direction value to `[0, 1]` (best→1, worst→0). +fn norm_max(v: f64, lo: f64, hi: f64) -> f64 { + if (hi - lo).abs() < 1e-12 { 1.0 } else { (v - lo) / (hi - lo) } +} + +/// Render `worst / best` as e.g. "7.5×" for the recommendation rationale. +fn ratio_str(worst: f64, best: f64) -> String { + if best <= 1e-9 { + return "∞×".to_string(); + } + format!("{:.1}×", worst / best) }