//! 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` 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; // ----------------------------------------------------------------------------- // A-posteriori decision weights (must sum to 1.0). // ----------------------------------------------------------------------------- const W_LUNCH: f64 = 0.30; // top — design goal const W_AFTER: f64 = 0.25; // top — minimize after-hours waste const W_WORK: f64 = 0.20; // medium — failures bad but recoverable const W_PRESS: f64 = 0.15; // matters with a hinge below const W_BALANCE: f64 = 0.10; // bonus for longer yellow + red phases // Press hinge: full reward at or below LOW, linearly drops to 0 at COMFORT_CAP, // and any candidate with mean_presses > COMFORT_CAP is rejected outright. // // Counts every daily press: morning boot, 13:00 lunch retap, warning-phase // reactions, and any death-restart presses during the workday. With ~2 // baseline presses already mandatory each day, the LOW threshold sits just // above baseline (2 + a half warning press) and the cap allows up to // 1.5 additional presses on top of baseline before rejecting. const PRESS_HINGE_LOW: f64 = 2.5; const PRESS_COMFORT_CAP: f64 = 3.5; // Balance bonus saturates: a min(yellow_width, red_width) of >= this many // minutes scores the full balance term. const BALANCE_SATURATION_MIN: f64 = 10.0; // ----------------------------------------------------------------------------- // 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; // Boot press at workday start: user presses to begin cycle 1. let mut o = DayOutcome { presses: 1, ..Default::default() }; // Allow the loop to extend past the larger of (workday end, last // possible cycle end given any in-loop expire bumps). Cap at one // extra cycle's worth so a long string of presses can't blow the // budget. let t_max = e.max(expire).max(s + 2 * rt) + 1; let mut prev_running = true; for t in s..t_max { // 13:00 re-login press: user comes back from lunch, presses to // start cycle 2. if t == LUNCH_END && t < e { expire = t + rt; o.presses += 1; } 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; // Death-restart press: when the device transitions from running // to dead during workday (not at lunch), user notices the screen // sleeping and presses to restart. Counts as a press for THIS // minute; subsequent at-desk minutes are now covered. if prev_running && device_dead && in_workday && !at_lunch { expire = t + rt; o.presses += 1; prev_running = true; continue; } prev_running = 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; 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) -> 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 = 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 four normalized outcome axes plus two structural terms: // // * `lunch_sleep` (max), `after_hours` (min), `work_fail` (min) — // normalized to [0, 1] across the candidate set. // * `presses` — hinge: full reward when <= PRESS_HINGE_LOW, ramps to // zero at PRESS_COMFORT_CAP, candidates above the cap are rejected. // * `balance` — bonus for longer warning phases: // `min(YA - RA, RA - FRA)` saturated at BALANCE_SATURATION_MIN. // // 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. 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: lunch_sleep {}% · after_hours {}% · work_fail {}% · presses {}% · balance {}%", (W_LUNCH * 100.0) as i32, (W_AFTER * 100.0) as i32, (W_WORK * 100.0) as i32, (W_PRESS * 100.0) as i32, (W_BALANCE * 100.0) as i32, ); println!( " press hinge: full reward ≤ {:.1}/d, ramps to 0 at {:.1}/d, REJECTED above", PRESS_HINGE_LOW, PRESS_COMFORT_CAP, ); println!( " balance bonus: min(yellow_width, red_width), saturates at {:.0} min", BALANCE_SATURATION_MIN, ); 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); 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!(); let yellow_w = top.ya - top.ra; let red_w = top.ra - top.fra; println!("Why:"); println!( " • {} mean lunch sleep ({:.1}% land in the 12:15–12:45 sweet spot)", fmt_minutes(top.lunch), top.p_sweet * 100.0, ); println!( " • {} mean after-hours awake (kept tight, your second priority)", fmt_minutes(top.after), ); 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)), ); let press_note = if top.presses <= PRESS_HINGE_LOW { format!("inside your no-penalty zone ≤{:.1}/d", PRESS_HINGE_LOW) } else if top.presses < PRESS_COMFORT_CAP { format!( "above the {:.1}/d hinge but below your {:.1}/d cap", PRESS_HINGE_LOW, PRESS_COMFORT_CAP, ) } else { format!("AT or ABOVE your {:.1}/d comfort cap", PRESS_COMFORT_CAP) }; println!( " • {:.2} button presses/day total — {}", top.presses, press_note, ); println!(" (counts: boot + 13:00 retap + warning-phase reactions + death-restarts)"); println!( " • warning phases: yellow {} min, red {} min, fast-red {} min (balance score {:.2})", yellow_w, red_w, top.fra, balance_score_for(top), ); 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 weighted sum that combines normalized /// outcome axes with a press hinge and a phase-balance bonus. /// /// `work_fail`, `lunch`, and `after` are normalized to `[0, 1]` across `rows` /// (best→1, worst→0; direction-aware). `presses` uses a hinge that rewards /// values at or below `PRESS_HINGE_LOW`, ramps linearly to zero at /// `PRESS_COMFORT_CAP`, and rejects candidates above the cap by returning /// `f64::NEG_INFINITY`. `balance` is a bonus for longer yellow + red /// phases, computed as `min(YA - RA, RA - FRA)` saturated at /// `BALANCE_SATURATION_MIN`. fn compute_weighted_scores(rows: &[Row]) -> Vec { 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 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| { // Hard comfort cap on presses. if r.presses > PRESS_COMFORT_CAP { return f64::NEG_INFINITY; } let work = norm_min(r.work_fail, work_min, work_max); let lunch = norm_max(r.lunch, lunch_min, lunch_max); let after = norm_min(r.after, after_min, after_max); // Hinge: 1.0 at or below LOW, linear ramp to 0.0 at the cap. let press_score = if r.presses <= PRESS_HINGE_LOW { 1.0 } else { ((PRESS_COMFORT_CAP - r.presses) / (PRESS_COMFORT_CAP - PRESS_HINGE_LOW)) .clamp(0.0, 1.0) }; // Balance bonus: longer yellow + red is better, saturated. let balance_score = balance_score_for(r); W_LUNCH * lunch + W_AFTER * after + W_WORK * work + W_PRESS * press_score + W_BALANCE * balance_score }) .collect() } /// Balance bonus for a row: `min(YA - RA, RA - FRA)` clamped to /// `[0, BALANCE_SATURATION_MIN]` and divided by saturation so the result is /// in `[0, 1]`. fn balance_score_for(r: &Row) -> f64 { let yellow_w = (r.ya - r.ra) as f64; let red_w = (r.ra - r.fra) as f64; let raw = yellow_w.min(red_w).max(0.0); (raw / BALANCE_SATURATION_MIN).clamp(0.0, 1.0) } /// 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) }