Files
heuropt/examples/jiggly_tuning.rs
T
swaits f70a12010d feat(examples): add jiggly USB-jiggler runtime tuning problem
Port of `scripts/tune_runtime.py` from ~/Code/jiggly: optimize the four
lifecycle constants of a USB mouse-jiggler firmware so the screen sleeps
during the user's lunch hour rather than failing during work.

The Python script grid-searches against a single composite score that
linearly combines several genuinely conflicting goals — a workaround
for the fact that grid search needs one number to rank by. heuropt has
the actual right tool, so this example is structured as a 4-objective
NSGA-III run that surfaces the Pareto front of legitimate tradeoffs:

1. minimize work-time failures (mean_work_sleep)
2. maximize lunch sleep (mean_lunch)
3. minimize human button presses (mean_presses)
4. minimize after-hours waste (mean_after)

Decision: 4-element `Vec<f64>` for (RT, YA, RA, FRA), continuous-relaxed
and rounded to integer minutes inside `evaluate`. The firmware ordering
constraint YA > RA > FRA > 0 is encoded as `constraint_violation` so
heuropt's feasible-beats-infeasible logic handles it for free.

Solver: NSGA-III with M=4, H=6 → 84 reference points, matching the
population size. Each evaluate runs a 1,000-workday Monte Carlo, so
the example is also a deliberately meaty evaluator that benefits from
`--features parallel`.

Output is in jiggly's native units — RT as Xh00m, thresholds in plain
minutes, sleep durations as Xh00m / Mm, probabilities as percentages —
and contrasts the Pareto front against:
- the four extreme single-axis winners (most lunch / fewest work fails /
  fewest presses / least after-hours)
- the firmware's currently-shipping defaults (which sit inside the
  front as a balanced compromise)
2026-05-04 20:16:59 -06:00

438 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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:0013: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:1512: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.
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);
}