733 lines
25 KiB
Rust
733 lines
25 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;
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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<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 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::<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: 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<f64> {
|
||
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)
|
||
}
|