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.
591 lines
20 KiB
Rust
591 lines
20 KiB
Rust
//! Tune the four lifecycle constants of the `jiggly` USB-mouse-jiggler firmware
|
||
//! as a **multi-objective** optimization problem.
|
||
//!
|
||
//! The Python `tune_runtime.py` from ~/Code/jiggly grid-searches against a
|
||
//! single composite score that linearly combines several genuinely conflicting
|
||
//! goals. That's a workable workaround for grid search — you have to rank by
|
||
//! one number — but it bakes the user's weights into the search and hides the
|
||
//! tradeoffs.
|
||
//!
|
||
//! `heuropt` lets us optimize the goals as separate objectives and surface the
|
||
//! Pareto front of legitimate tradeoffs:
|
||
//!
|
||
//! 1. **minimize work-time failures** — the screen sleeping while the user is
|
||
//! working is the worst outcome. (`mean_work_sleep`, minutes/day)
|
||
//! 2. **maximize lunch sleep** — the entire design goal. (`mean_lunch`,
|
||
//! minutes/day, encoded as a Maximize objective)
|
||
//! 3. **minimize human interactions** — every button press is UX cost.
|
||
//! (`mean_presses`, per day)
|
||
//! 4. **minimize after-hours waste** — keeping the screen alive past the end
|
||
//! of the workday is screen burn for nothing. (`mean_after`, minutes/day)
|
||
//!
|
||
//! Decision: a 4-element `Vec<f64>` for `(RT, YELLOW_AT, RED_AT, FAST_RED_AT)`,
|
||
//! continuous-relaxed and rounded to integer minutes inside `evaluate`. The
|
||
//! firmware ordering constraint `YA > RA > FRA > 0` is encoded as
|
||
//! `constraint_violation` so the algorithm's feasible-beats-infeasible logic
|
||
//! handles it automatically.
|
||
//!
|
||
//! Solver: NSGA-III with 4 objectives and Das-Dennis H=6 → 84 reference
|
||
//! points, matching the population size. Each `evaluate` runs a 1,000-workday
|
||
//! Monte Carlo, so this example is also a deliberately meaty evaluator that
|
||
//! benefits from `--features parallel` (≈8× wall-clock with rayon enabled on
|
||
//! a typical laptop).
|
||
//!
|
||
//! ```bash
|
||
//! cargo run --release --example jiggly_tuning
|
||
//! cargo run --release --example jiggly_tuning --features parallel
|
||
//! ```
|
||
//!
|
||
//! Output is in jiggly's native units — `RT` as `Xh00m`, thresholds as plain
|
||
//! minutes, durations as `Xh00m` / `Mm`, probabilities as percentages.
|
||
|
||
use std::time::Instant;
|
||
|
||
use rand::Rng as _;
|
||
use rand::SeedableRng;
|
||
use rand::rngs::StdRng;
|
||
|
||
use heuropt::prelude::*;
|
||
|
||
const LUNCH_START: i32 = 12 * 60;
|
||
const LUNCH_END: i32 = 13 * 60;
|
||
|
||
const P_PRESS_YELLOW: f64 = 0.015;
|
||
const P_PRESS_RED: f64 = 0.040;
|
||
const P_PRESS_FAST_RED: f64 = 0.060;
|
||
const P_WARN10_BUMP: f64 = 0.04;
|
||
const P_WARN5_BUMP: f64 = 0.03;
|
||
|
||
// Sweet-spot lunch-sleep window (minutes spent dead during 12:00–13:00).
|
||
const SWEET_LO: u32 = 15;
|
||
const SWEET_HI: u32 = 45;
|
||
|
||
const N_DAYS: usize = 1000;
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Day model + Monte Carlo (same model as scripts/tune_runtime.py)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
#[derive(Default, Clone, Copy)]
|
||
struct DayOutcome {
|
||
presses: u32,
|
||
slept_work: u32,
|
||
slept_lunch: u32,
|
||
after_hours: u32,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct Stats {
|
||
/// Probability of landing in the 12:15–12:45 sweet spot.
|
||
p_sweet: f64,
|
||
mean_lunch: f64,
|
||
mean_work_sleep: f64,
|
||
mean_presses: f64,
|
||
mean_after: f64,
|
||
}
|
||
|
||
fn sample_triangular(low: f64, mode: f64, high: f64, rng: &mut StdRng) -> f64 {
|
||
let u: f64 = rng.random();
|
||
let c = (mode - low) / (high - low);
|
||
if u < c {
|
||
low + ((high - low) * (mode - low) * u).sqrt()
|
||
} else {
|
||
high - ((high - low) * (high - mode) * (1.0 - u)).sqrt()
|
||
}
|
||
}
|
||
|
||
/// Pre-sampled simulated workdays. Sampling once and reusing across all
|
||
/// `evaluate` calls is the standard SAA pattern: every parameter combination
|
||
/// is scored on the same days, so differences in objective values reflect the
|
||
/// parameters rather than Monte Carlo noise between evaluations.
|
||
struct JigglyTuning {
|
||
days: Vec<(i32, i32, u64)>, // start_min, end_min, per-day RNG seed
|
||
}
|
||
|
||
impl JigglyTuning {
|
||
fn new(n_days: usize, seed: u64) -> Self {
|
||
let mut rng = StdRng::seed_from_u64(seed);
|
||
let days = (0..n_days)
|
||
.map(|_| {
|
||
let s = (sample_triangular(8.0, 8.5, 9.5, &mut rng) * 60.0) as i32;
|
||
let e = (sample_triangular(16.0, 17.5, 19.0, &mut rng) * 60.0) as i32;
|
||
let day_seed: u64 = rng.random();
|
||
(s, e, day_seed)
|
||
})
|
||
.collect();
|
||
Self { days }
|
||
}
|
||
|
||
fn simulate_one(
|
||
s: i32,
|
||
e: i32,
|
||
day_seed: u64,
|
||
rt: i32,
|
||
ya: i32,
|
||
ra: i32,
|
||
fra: i32,
|
||
) -> DayOutcome {
|
||
let mut rng = StdRng::seed_from_u64(day_seed);
|
||
let mut expire = s + rt;
|
||
let mut o = DayOutcome::default();
|
||
let t_max = e.max(expire) + 1;
|
||
for t in s..t_max {
|
||
// Free re-tap when the user re-logs in at 13:00.
|
||
if t == LUNCH_END && t < e {
|
||
expire = t + rt;
|
||
}
|
||
let in_workday = t >= s && t < e;
|
||
let at_lunch = (LUNCH_START..LUNCH_END).contains(&t);
|
||
let device_running = t < expire;
|
||
let device_dead = !device_running;
|
||
|
||
if device_dead && in_workday {
|
||
if at_lunch {
|
||
o.slept_lunch += 1;
|
||
} else {
|
||
o.slept_work += 1;
|
||
}
|
||
}
|
||
if t >= e && device_running {
|
||
o.after_hours += 1;
|
||
}
|
||
|
||
if !at_lunch && in_workday && device_running {
|
||
let remaining = expire - t;
|
||
let mut p = 0.0;
|
||
if remaining > ra && remaining <= ya {
|
||
p = P_PRESS_YELLOW;
|
||
} else if remaining > fra && remaining <= ra {
|
||
p = P_PRESS_RED;
|
||
} else if remaining > 0 && remaining <= fra {
|
||
p = P_PRESS_FAST_RED;
|
||
}
|
||
if remaining == 10 {
|
||
p += P_WARN10_BUMP;
|
||
}
|
||
if remaining == 5 {
|
||
p += P_WARN5_BUMP;
|
||
}
|
||
let roll: f64 = rng.random();
|
||
if roll < p {
|
||
expire = t + rt;
|
||
o.presses += 1;
|
||
}
|
||
}
|
||
}
|
||
o
|
||
}
|
||
|
||
fn aggregate(&self, rt: i32, ya: i32, ra: i32, fra: i32) -> Stats {
|
||
let n = self.days.len() as f64;
|
||
let mut sweet = 0u32;
|
||
let mut sum_lunch = 0.0_f64;
|
||
let mut sum_work = 0.0_f64;
|
||
let mut sum_presses = 0.0_f64;
|
||
let mut sum_after = 0.0_f64;
|
||
for &(s, e, ds) in &self.days {
|
||
let o = Self::simulate_one(s, e, ds, rt, ya, ra, fra);
|
||
if (SWEET_LO..=SWEET_HI).contains(&o.slept_lunch) {
|
||
sweet += 1;
|
||
}
|
||
sum_lunch += o.slept_lunch as f64;
|
||
sum_work += o.slept_work as f64;
|
||
sum_presses += o.presses as f64;
|
||
sum_after += o.after_hours as f64;
|
||
}
|
||
Stats {
|
||
p_sweet: sweet as f64 / n,
|
||
mean_lunch: sum_lunch / n,
|
||
mean_work_sleep: sum_work / n,
|
||
mean_presses: sum_presses / n,
|
||
mean_after: sum_after / n,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Problem for JigglyTuning {
|
||
type Decision = Vec<f64>;
|
||
|
||
fn objectives(&self) -> ObjectiveSpace {
|
||
ObjectiveSpace::new(vec![
|
||
Objective::minimize("work_failure_min"),
|
||
Objective::maximize("lunch_sleep_min"),
|
||
Objective::minimize("presses_per_day"),
|
||
Objective::minimize("after_hours_min"),
|
||
])
|
||
}
|
||
|
||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||
let rt = x[0].round() as i32;
|
||
let ya = x[1].round() as i32;
|
||
let ra = x[2].round() as i32;
|
||
let fra = x[3].round() as i32;
|
||
|
||
// Soft constraint: YA > RA > FRA > 0 (any violation is positive).
|
||
let mut violation = 0.0_f64;
|
||
if ra >= ya {
|
||
violation += (ra - ya + 1) as f64;
|
||
}
|
||
if fra >= ra {
|
||
violation += (fra - ra + 1) as f64;
|
||
}
|
||
if fra <= 0 {
|
||
violation += (1 - fra) as f64;
|
||
}
|
||
|
||
let stats = self.aggregate(rt, ya, ra, fra);
|
||
Evaluation::constrained(
|
||
vec![
|
||
stats.mean_work_sleep,
|
||
stats.mean_lunch, // Objective is Maximize → as_minimization will negate
|
||
stats.mean_presses,
|
||
stats.mean_after,
|
||
],
|
||
violation.max(0.0),
|
||
)
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Output formatting (matches the units used in tune_runtime.py)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
fn fmt_minutes(m: f64) -> String {
|
||
let total = m.round() as i32;
|
||
let h = total / 60;
|
||
let mm = total % 60;
|
||
if h > 0 {
|
||
format!("{h}h{mm:02}m")
|
||
} else {
|
||
format!("{mm}m")
|
||
}
|
||
}
|
||
|
||
fn fmt_rt(m: i32) -> String {
|
||
let h = m / 60;
|
||
let mm = m % 60;
|
||
format!("{h}h{mm:02}m")
|
||
}
|
||
|
||
/// One row in the Pareto-front summary table.
|
||
#[derive(Clone)]
|
||
struct Row {
|
||
rt: i32,
|
||
ya: i32,
|
||
ra: i32,
|
||
fra: i32,
|
||
work_fail: f64,
|
||
lunch: f64,
|
||
presses: f64,
|
||
after: f64,
|
||
p_sweet: f64,
|
||
}
|
||
|
||
fn row_for(decision: &[f64], stats: &Stats) -> Row {
|
||
Row {
|
||
rt: decision[0].round() as i32,
|
||
ya: decision[1].round() as i32,
|
||
ra: decision[2].round() as i32,
|
||
fra: decision[3].round() as i32,
|
||
work_fail: stats.mean_work_sleep,
|
||
lunch: stats.mean_lunch,
|
||
presses: stats.mean_presses,
|
||
after: stats.mean_after,
|
||
p_sweet: stats.p_sweet,
|
||
}
|
||
}
|
||
|
||
fn print_header() {
|
||
println!(
|
||
"{:<6} {:>3} {:>3} {:>3} {:>9} {:>9} {:>8} {:>8} {:>7}",
|
||
"RT", "YA", "RA", "FRA", "work fail↓", "lunch↑", "presses↓", "after↓", "p_sweet",
|
||
);
|
||
println!("{}", "-".repeat(78));
|
||
}
|
||
|
||
fn print_row(label: &str, r: &Row) {
|
||
let prefix = if label.is_empty() { String::new() } else { format!("{label} ") };
|
||
println!(
|
||
"{}{:<6} {:>3} {:>3} {:>3} {:>9} {:>9} {:>7.2}/d {:>8} {:>6.1}%",
|
||
prefix,
|
||
fmt_rt(r.rt),
|
||
r.ya,
|
||
r.ra,
|
||
r.fra,
|
||
fmt_minutes(r.work_fail),
|
||
fmt_minutes(r.lunch),
|
||
r.presses,
|
||
fmt_minutes(r.after),
|
||
r.p_sweet * 100.0,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Main
|
||
// -----------------------------------------------------------------------------
|
||
|
||
fn main() {
|
||
let problem = JigglyTuning::new(N_DAYS, 2026);
|
||
|
||
let bounds = vec![
|
||
(230.0, 250.0), // RT
|
||
(20.0, 70.0), // YELLOW_AT
|
||
(10.0, 40.0), // RED_AT
|
||
(4.0, 20.0), // FAST_RED_AT
|
||
];
|
||
let initializer = RealBounds::new(bounds.clone());
|
||
// Canonical NSGA-II/-III operator pair (SBX + PolyMut) with bounds.
|
||
let variation = CompositeVariation {
|
||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 30.0, 1.0),
|
||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 4.0),
|
||
};
|
||
// M=4, H=6 → C(9,3) = 84 reference points. Match the population size.
|
||
let pop = 84;
|
||
let gens = 25;
|
||
let config = Nsga3Config {
|
||
population_size: pop,
|
||
generations: gens,
|
||
reference_divisions: 6,
|
||
seed: 42,
|
||
};
|
||
|
||
println!("Optimizing jiggly's 4 lifecycle constants — 4-objective Pareto search");
|
||
println!(" algorithm: NSGA-III (84 ref points, M=4, H=6)");
|
||
println!(" N_DAYS: {N_DAYS} simulated workdays per evaluation");
|
||
println!(" search: RT∈[230,250], YA∈[20,70], RA∈[10,40], FRA∈[4,20]");
|
||
println!(
|
||
" budget: {pop} pop × {gens} gens = {} evaluations",
|
||
pop * (gens + 1)
|
||
);
|
||
println!();
|
||
|
||
let mut opt = Nsga3::new(config, initializer, variation);
|
||
let t0 = Instant::now();
|
||
let result = opt.run(&problem);
|
||
let elapsed = t0.elapsed();
|
||
|
||
println!(
|
||
"NSGA-III finished in {:.2}s ({} evaluations, |front|={})",
|
||
elapsed.as_secs_f64(),
|
||
result.evaluations,
|
||
result.pareto_front.len(),
|
||
);
|
||
println!();
|
||
|
||
// Materialize each Pareto member's full Stats so we can print rich rows.
|
||
// Multiple f64 decisions can round to the same integer combo — dedupe.
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut rows: Vec<Row> = result
|
||
.pareto_front
|
||
.iter()
|
||
.filter_map(|c| {
|
||
let rt = c.decision[0].round() as i32;
|
||
let ya = c.decision[1].round() as i32;
|
||
let ra = c.decision[2].round() as i32;
|
||
let fra = c.decision[3].round() as i32;
|
||
if !seen.insert((rt, ya, ra, fra)) {
|
||
return None;
|
||
}
|
||
let stats = problem.aggregate(rt, ya, ra, fra);
|
||
Some(row_for(&c.decision, &stats))
|
||
})
|
||
.collect();
|
||
// Drop any infeasible front entries (shouldn't happen for a converged
|
||
// run, but guard anyway).
|
||
rows.retain(|r| r.ya > r.ra && r.ra > r.fra && r.fra > 0);
|
||
|
||
println!("=== Pareto front (sorted by lunch sleep, descending) ===");
|
||
print_header();
|
||
rows.sort_by(|a, b| b.lunch.partial_cmp(&a.lunch).unwrap_or(std::cmp::Ordering::Equal));
|
||
for r in rows.iter().take(15) {
|
||
print_row("", r);
|
||
}
|
||
if rows.len() > 15 {
|
||
println!(" ... ({} more on the front)", rows.len() - 15);
|
||
}
|
||
println!();
|
||
|
||
// Re-rank by each individual objective to surface extreme tradeoffs.
|
||
let best_by = |key: fn(&Row) -> f64, want_high: bool| -> Option<&Row> {
|
||
rows.iter().min_by(|a, b| {
|
||
let ka = key(a);
|
||
let kb = key(b);
|
||
let cmp = ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal);
|
||
if want_high { cmp.reverse() } else { cmp }
|
||
})
|
||
};
|
||
println!("=== extreme tradeoffs ===");
|
||
print_header();
|
||
if let Some(r) = best_by(|r| r.work_fail, false) {
|
||
print_row("FEWEST WORK FAILS ", r);
|
||
}
|
||
if let Some(r) = best_by(|r| r.lunch, true) {
|
||
print_row("MOST LUNCH SLEEP ", r);
|
||
}
|
||
if let Some(r) = best_by(|r| r.presses, false) {
|
||
print_row("FEWEST PRESSES ", r);
|
||
}
|
||
if let Some(r) = best_by(|r| r.after, false) {
|
||
print_row("LEAST AFTER-HOURS ", r);
|
||
}
|
||
println!();
|
||
|
||
let shipping = problem.aggregate(240, 30, 25, 20);
|
||
let shipping_row = row_for(&[240.0, 30.0, 25.0, 20.0], &shipping);
|
||
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::<Vec<_>>());
|
||
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<f64> {
|
||
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)
|
||
}
|