chore: cut 0.3.0 — new tuning crate, retuned lifecycle constants
- Adds an in-repo `tuning/` crate that solves the four-knob LED-threshold tuning problem as a 4-objective Pareto search using published `heuropt` 0.8 (NSGA-III + a-posteriori weighted ranking), replacing `scripts/tune_runtime.py`'s single-composite-score grid. `just tune` runs it; the crate is its own workspace root with a local `.cargo/config.toml` overriding the firmware's inherited `thumbv6m-none-eabi` build target so it can use `std`. - Retunes the shipping defaults from the new Pareto front: `RUN_DURATION` 4h00m → 3h51m, `YELLOW_AT` 30 → 22, `RED_AT` 25 → 11, `FAST_RED_AT` 20 → 4 (LED thresholds in minutes-remaining). Across 1,000 simulated workdays the new combination averages 26 minutes of lunch sleep and lands in the 12:15–12:45 sweet spot on ~57 % of days, with zero mean work-time failure and ~2 min/day of after-hours waste. - Bumps `config.device_release` 0x0200 → 0x0300 to match firmware version 0.3.0. - README "Why four hours…" → "Why these timings…", rewritten for the new methodology with the actual run statistics. `src/config.rs` module-level + lifecycle/phase comments updated accordingly. - Picks up a small `cargo fmt` drift in `src/chart.rs` and `src/led.rs` that had crept in under the 0.2.0 module split. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Override the firmware crate's thumbv6m-none-eabi default. This crate is a
|
||||
# host-side simulation tool; it needs std and the host toolchain. Closer
|
||||
# .cargo/config.toml files win key-by-key, so this `target` overrides the
|
||||
# parent's `target = "thumbv6m-none-eabi"`.
|
||||
[build]
|
||||
target = "x86_64-unknown-linux-gnu"
|
||||
Generated
+243
@@ -0,0 +1,243 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heuropt"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5496b6d41f95f70a1c9bd0dd65a7d856b15ecd4109ccc105296c267138e36a98"
|
||||
dependencies = [
|
||||
"rand",
|
||||
"rand_distr",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiggly-tuning"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"heuropt",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "jiggly-tuning"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
description = "Multi-objective tuner for jiggly's four lifecycle constants — internal tool, not published."
|
||||
license = "MIT"
|
||||
publish = false # internal tuning tool; not for crates.io
|
||||
|
||||
# Standalone workspace so this crate doesn't get pulled into any parent
|
||||
# workspace and so cargo doesn't walk up looking for one.
|
||||
[workspace]
|
||||
|
||||
[features]
|
||||
default = ["parallel"]
|
||||
parallel = ["heuropt/parallel"]
|
||||
|
||||
[dependencies]
|
||||
heuropt = { version = "0.8", default-features = false }
|
||||
rand = "0.9"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Tune the four lifecycle constants of the `jiggly` USB-mouse-jiggler firmware
|
||||
//! as a **multi-objective** optimization problem.
|
||||
//!
|
||||
//! `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 is a deliberately meaty evaluator. The `parallel`
|
||||
//! feature (rayon, on by default) gives ~8× wall-clock on a typical laptop.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release # from inside tuning/
|
||||
//! just tune # from the firmware repo root
|
||||
//! ```
|
||||
//!
|
||||
//! 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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[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 (jiggly's native units — `Xh00m` / `Mm`, percentages)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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!();
|
||||
|
||||
// Match the four constants in `../src/config.rs` (RUN_DURATION, YELLOW_AT,
|
||||
// RED_AT, FAST_RED_AT). Update this when the firmware ships new defaults
|
||||
// so the comparison block reflects what's actually flashed.
|
||||
let shipping = problem.aggregate(231, 22, 11, 4);
|
||||
let shipping_row = row_for(&[231.0, 22.0, 11.0, 4.0], &shipping);
|
||||
println!("=== firmware shipping default (RT=3h51m YEL=22 RED=11 FST=4) ===");
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user