From c45fceaf7ac1cc87e88e45b423e44f550b2f8840 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 21:08:34 -0600 Subject: [PATCH] =?UTF-8?q?chore:=20cut=200.3.0=20=E2=80=94=20new=20tuning?= =?UTF-8?q?=20crate,=20retuned=20lifecycle=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 1 + CHANGELOG.md | 48 ++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 61 ++-- justfile | 7 + scripts/tune_runtime.py | 271 -------------- src/chart.rs | 3 +- src/config.rs | 28 +- src/led.rs | 18 +- src/main.rs | 2 +- tuning/.cargo/config.toml | 6 + tuning/Cargo.lock | 243 +++++++++++++ tuning/Cargo.toml | 25 ++ tuning/src/main.rs | 728 ++++++++++++++++++++++++++++++++++++++ 15 files changed, 1123 insertions(+), 322 deletions(-) delete mode 100644 scripts/tune_runtime.py create mode 100644 tuning/.cargo/config.toml create mode 100644 tuning/Cargo.lock create mode 100644 tuning/Cargo.toml create mode 100644 tuning/src/main.rs diff --git a/.gitignore b/.gitignore index 1f4c2c7..a958d64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.cargo /target +/tuning/target diff --git a/CHANGELOG.md b/CHANGELOG.md index 00bf9a9..2379e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-05-07 + +### Added + +- **`tuning/` crate** — a host-side multi-objective NSGA-III tuner for + jiggly's four lifecycle constants. Runs against published [`heuropt`][heuropt] + 0.8 (with the `parallel` rayon feature) and prints the Pareto front, + extreme tradeoffs per objective, the firmware's current shipping + defaults, and a single weighted-rank recommendation. 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`. Invoked via `just tune` or `cargo run --release` from inside + `tuning/`. + +### Changed + +- **Lifecycle timings retuned** via the new tuner's 4-objective + (work-time failure, lunch sleep, presses, after-hours waste) Pareto + search, then collapsed by explicit decision weights. New shipping + values: **`RUN_DURATION` 4h00m → 3h51m**, **`YELLOW_AT` 30 → 22**, + **`RED_AT` 25 → 11**, **`FAST_RED_AT` 20 → 4** (LED thresholds in + minutes-remaining). The 0.2.0 single-composite-score grid had baked + the user's weight choices into the search itself; the new approach + surfaces the legitimate tradeoffs first and applies preferences + afterward. Across 1,000 simulated workdays the new combination + averages 26 minutes of lunch sleep, 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. The 0.2.0 shipping defaults survive on the new + Pareto front but rank well below the new pick under the same weights. +- **`config.device_release` 0x0200 → 0x0300** — matches firmware + version 0.3.0. +- **README section heading "Why four hours…" → "Why these timings…"**, + rewritten to describe the new methodology, the four objectives, the + explicit decision weights, and the actual run statistics. + +### Removed + +- **`scripts/tune_runtime.py`** — the Python single-composite-score + grid search is superseded by the in-repo `tuning/` crate's NSGA-III + multi-objective search. The new tuner ships with the firmware, builds + reproducibly through `cargo`/`mise`, and is just a normal Rust + dependency on `heuropt` (no separate `uv` invocation). + +[heuropt]: https://crates.io/crates/heuropt + ## [0.2.0] - 2026-05-01 ### Added @@ -145,6 +190,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `bootstrap`. All recipes execute inside `mise exec -- sh -eu -c` so the pinned toolchain is used regardless of shell activation state. -[Unreleased]: https://github.com/swaits/jiggly/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/swaits/jiggly/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/swaits/jiggly/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/swaits/jiggly/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 286e5ae..8917272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,7 +825,7 @@ dependencies = [ [[package]] name = "jiggly" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/Cargo.toml b/Cargo.toml index 782092f..d649f3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jiggly" -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["Stephen Waits "] description = "USB mouse jiggler firmware for the Seeed Studio Xiao RP2040 — keeps your screen awake during the workday, then politely shuts up so you can go home." diff --git a/README.md b/README.md index d5a65db..8c8b9c4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Plugs into USB, presents as a composite mouse + keyboard HID device wake any sleeping host. Mouse motion alone doesn't reliably wake macOS; a key tap does. F13 is chosen because it's harmless if it ever ends up stuck — no OS maps it by default. -- For the next four hours, nudges the cursor one pixel every 4½ minutes +- For the next 3 h 51 m, nudges the cursor one pixel every 4½ minutes so the host never falls asleep. - Breathes the on-board NeoPixel green → yellow → red as time runs down. Two on-screen "spiral" warnings fire 10 min and 5 min before @@ -72,7 +72,7 @@ Active green→yellow→red breathing A separate embassy task feeds the hardware watchdog every 5 s. -## Why four hours, and why those LED thresholds? +## Why these timings, and why those LED thresholds? The point isn't to keep the screen awake forever. It's to keep it awake while you're at your desk and let it sleep when you're not. The @@ -90,9 +90,15 @@ That's a four-knob problem: | `RED_AT` | minutes-remaining where breathing-red begins | | `FAST_RED_AT` | minutes-remaining where the fast-pulse-red blink begins | -`scripts/tune_runtime.py` is a Monte Carlo that does a 4-D grid -search over those four constants across 50 000 simulated workdays. -The model: +The `tuning/` crate solves it as a four-objective Pareto search +using [`heuropt`][heuropt] and NSGA-III: + +1. **minimize work-time failures** (screen sleeps while the user is at their desk) +2. **maximize lunch sleep** +3. **minimize button presses** +4. **minimize after-hours waste** (screen still awake past clock-out) + +The user model: - Workday start is `Triangular(8:00, mode 8:30, 9:30)`, end is `Triangular(16:00, mode 17:30, 19:00)`. Lunch is fixed at 12:00–13:00. @@ -102,34 +108,36 @@ The model: fires. - Free `RESET` at boot and at 13:00 (re-login after lunch). -The composite score rewards lunch-hour expiration (especially -12:15–12:45) and penalizes the screen sleeping while the user is at -their desk. - -The winner — and what the firmware ships: +NSGA-III returns a Pareto front of ≈28 non-dominated points across +those four objectives — every one of them a legitimate tradeoff. To +pick a single recommendation the tuner applies explicit decision +weights (lunch_sleep 30 %, after_hours 25 %, work_fail 20 %, presses +15 %, balance 10 %) plus a press-count comfort cap. The pick — and +what the firmware ships: ``` -RUN_DURATION = 4h00m YELLOW_AT = 30 RED_AT = 25 FAST_RED_AT = 20 +RUN_DURATION = 3h51m YELLOW_AT = 22 RED_AT = 11 FAST_RED_AT = 4 ``` -(LED thresholds are minutes-remaining.) The screen sleeps somewhere -during lunch on **~74 %** of simulated days and in the 12:15–12:45 -sweet spot on **~52 %**. +(LED thresholds are minutes-remaining.) Across 1 000 simulated +workdays this 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 minutes/day of after-hours +waste at a cost of ~2.9 button presses/day. -The interesting result is that the obvious-looking `60 / 30 / 10` -thresholds (long, gentle warning, urgent finish) ranked dead-average -out of 2 245 combos. Long visible warnings turn out to be -counter-productive: a 30-minute yellow phase gives you 30 minutes to -glance up, notice the LED, and tap `RESET` — and a tap during yellow -extends the cycle into the afternoon, the opposite of the goal. -Shrinking yellow and red to "long enough to notice, short enough not -to act on" pushes more days into a clean lunch death. +The interesting result is that **shorter warning phases are better**. +A long yellow phase gives you 30 minutes to glance up, notice the +LED, and tap `RESET` out of an abundance of caution — and a tap +during yellow extends the cycle into the afternoon, the opposite of +the goal. The Pareto-front winner runs an 11-minute yellow, a 7- +minute red, and a 4-minute fast-red: long enough to register the +warning, short enough that the natural reaction is to wait it out. If your day looks different — different start/end distribution, -different press habits, different lunch length — edit the constants -and ranges at the top of `scripts/tune_runtime.py`, run it -(`uv run scripts/tune_runtime.py`), and update the four values in -`src/main.rs`. +different press habits, different lunch length — edit the model +constants in `tuning/src/main.rs`, run `just tune` (or `cargo run +--release` from inside `tuning/`), and update the four values in +`src/config.rs`. ## USB identity @@ -148,6 +156,7 @@ MIT — see [LICENSE](LICENSE). [xiao]: https://wiki.seeedstudio.com/XIAO-RP2040/ [embassy]: https://embassy.dev/ [hsmc]: https://crates.io/crates/hsmc +[heuropt]: https://crates.io/crates/heuropt [mise]: https://mise.jdx.dev/ [just]: https://just.systems/ [pidcodes]: https://pid.codes/ diff --git a/justfile b/justfile index 74b1240..b7e88ce 100644 --- a/justfile +++ b/justfile @@ -132,3 +132,10 @@ bootstrap: # Show firmware file size summary. stats: uf2 @ls -lh {{ out_release }} {{ out_bin }} {{ out_uf2 }} + +# Run the multi-objective NSGA-III tuner against the published heuropt crate +# and print the recommended (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT) +# pick. See README "Why these timings…" for the methodology and +# `tuning/src/main.rs` to edit the day model or weights. +tune: + cd tuning && cargo run --release diff --git a/scripts/tune_runtime.py b/scripts/tune_runtime.py deleted file mode 100644 index 2f6cac0..0000000 --- a/scripts/tune_runtime.py +++ /dev/null @@ -1,271 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = ["numpy"] -# /// -""" -Monte Carlo tuner for the four lifecycle constants in src/main.rs: - - RUN_DURATION full cycle length, in minutes - YELLOW_AT remaining-minute threshold where breathing-yellow begins - RED_AT remaining-minute threshold where breathing-red begins - FAST_RED_AT remaining-minute threshold where the fast-pulse blink begins - -Sweeps a 4-D grid (with the constraint YELLOW_AT > RED_AT > FAST_RED_AT > 0) -across 50 000 simulated workdays and picks the combination that lands the -screen-sleep in the lunch hour as often as possible. - -The model - - - Day starts at Triangular(8:00, mode 8:30, 9:30) and ends at - Triangular(16:00, mode 17:30, 19:00). Lunch is 12:00–13:00 (fixed). - - Free RESET at start (boot) and at 13:00 (re-login after lunch). - - User-at-desk minute-by-minute, sees the LED, and may tap RESET to - extend the cycle: - yellow 1.5 % / min - red 4.0 % / min - fast-red 6.0 % / min - warning10/5 one-shot bumps the minute the spiral animation fires - - A composite score rewards lunch-hour expiration (especially the - 12:15–12:45 sweet spot) and penalizes daytime failures. - -Usage - - uv run scripts/tune_runtime.py # default 50 000 days - uv run scripts/tune_runtime.py --n 100000 # finer Monte Carlo - -Adjust the per-phase press probabilities and grid ranges at the top of -main() to match your own behavior or your own workday distribution. -""" - -from __future__ import annotations - -import argparse -import itertools -import time - -import numpy as np - -LUNCH_START = 12 * 60 -LUNCH_END = 13 * 60 - -# Per-minute press probabilities per LED phase. -P_PRESS_YELLOW = 0.015 -P_PRESS_RED = 0.040 -P_PRESS_FAST_RED = 0.060 -# One-shot bumps when the on-screen spiral animations fire (10 / 5 min before -# death). Independent of LED-phase boundaries — the firmware fires those at -# fixed offsets from death. -P_WARN10_BUMP = 0.04 -P_WARN5_BUMP = 0.03 - - -def sample_days(n: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: - s = (rng.triangular(8.0, 8.5, 9.5, n) * 60).astype(np.int32) - e = (rng.triangular(16.0, 17.5, 19.0, n) * 60).astype(np.int32) - return s, e - - -def simulate( - rt: int, - yellow_at: int, - red_at: int, - fast_red_at: int, - s: np.ndarray, - e: np.ndarray, - rng: np.random.Generator, -) -> dict: - n = len(s) - expire = s + rt - - presses = np.zeros(n, dtype=np.int32) - slept_work = np.zeros(n, dtype=np.int32) - slept_lunch = np.zeros(n, dtype=np.int32) - after_hours = np.zeros(n, dtype=np.int32) - - t_min = int(s.min()) - t_max = int(max(e.max(), expire.max())) + 1 - - for t in range(t_min, t_max): - # Free re-tap when the user re-logs in at 13:00. - if t == LUNCH_END: - in_workday = (t >= s) & (t < e) - expire = np.where(in_workday, t + rt, expire) - - in_workday = (t >= s) & (t < e) - at_lunch = LUNCH_START <= t < LUNCH_END - device_running = t < expire - device_dead = ~device_running - - if at_lunch: - slept_lunch += (in_workday & device_dead).astype(np.int32) - else: - slept_work += (in_workday & device_dead).astype(np.int32) - - past_end = (t >= e) & device_running - after_hours += past_end.astype(np.int32) - - if not at_lunch: - eligible = in_workday & device_running - if eligible.any(): - remaining = expire - t - p = np.zeros(n, dtype=np.float32) - yellow = (remaining > red_at) & (remaining <= yellow_at) - red = (remaining > fast_red_at) & (remaining <= red_at) - fast_red = (remaining > 0) & (remaining <= fast_red_at) - p[yellow] = P_PRESS_YELLOW - p[red] = P_PRESS_RED - p[fast_red] = P_PRESS_FAST_RED - p[remaining == 10] += P_WARN10_BUMP - p[remaining == 5] += P_WARN5_BUMP - - roll = rng.random(n).astype(np.float32) - press = eligible & (roll < p) - np.putmask(expire, press, t + rt) - presses += press.astype(np.int32) - - return { - "rt": rt, - "yellow_at": yellow_at, - "red_at": red_at, - "fast_red_at": fast_red_at, - "presses": presses, - "slept_work": slept_work, - "slept_lunch": slept_lunch, - "after_hours": after_hours, - } - - -def summarize(r: dict) -> dict: - sw = r["slept_work"] - sl = r["slept_lunch"] - ah = r["after_hours"] - pr = r["presses"] - sweet = (sl >= 15) & (sl <= 45) - return { - "rt": r["rt"], - "yellow_at": r["yellow_at"], - "red_at": r["red_at"], - "fast_red_at": r["fast_red_at"], - "p_sweet": sweet.mean(), - "p_lunch_any": (sl > 0).mean(), - "p_no_work_sleep": (sw == 0).mean(), - "mean_lunch": sl.mean(), - "mean_work_sleep": sw.mean(), - "mean_presses": pr.mean(), - "mean_after": ah.mean(), - } - - -def score(r: dict) -> float: - return ( - r["p_sweet"] - + 0.5 * r["p_lunch_any"] - - 1.5 * (1 - r["p_no_work_sleep"]) - - 0.05 * r["mean_after"] / 60 - ) - - -def fmt_h(m: float) -> str: - m = int(round(m)) - h, mm = divmod(m, 60) - return f"{h}h{mm:02d}m" if h else f"{mm}m" - - -def fmt_rt(m: int) -> str: - h, mm = divmod(int(m), 60) - return f"{h}h{mm:02d}m" - - -def main() -> None: - ap = argparse.ArgumentParser() - ap.add_argument("--n", type=int, default=50_000, help="days per combo") - ap.add_argument("--seed", type=int, default=2026) - args = ap.parse_args() - - # 4-D search grid. Wider/finer is more honest; narrower is faster. - rt_range = list(range(230, 251, 5)) # 230..250 step 5 (5) - yellow_at_range = list(range(20, 71, 5)) # 20..70 step 5 (11) - red_at_range = list(range(10, 41, 5)) # 10..40 step 5 (7) - fast_red_at_range = list(range(4, 21, 2)) # 4..20 step 2 (9) - - print(f"tune_runtime — N={args.n} days/combo") - print(f" RT {rt_range[0]}..{rt_range[-1]} step 5 ({len(rt_range)})") - print(f" YELLOW_AT {yellow_at_range[0]}..{yellow_at_range[-1]} step 5 ({len(yellow_at_range)})") - print(f" RED_AT {red_at_range[0]}..{red_at_range[-1]} step 5 ({len(red_at_range)})") - print(f" FAST_RED_AT {fast_red_at_range[0]}..{fast_red_at_range[-1]} step 2 ({len(fast_red_at_range)})") - print(f" press: yellow {P_PRESS_YELLOW}/min, red {P_PRESS_RED}/min, " - f"fast {P_PRESS_FAST_RED}/min") - print() - - rng = np.random.default_rng(args.seed) - s, e = sample_days(args.n, rng) - - combos = [ - (rt, ya, ra, fra) - for rt, ya, ra, fra in itertools.product( - rt_range, yellow_at_range, red_at_range, fast_red_at_range - ) - if ya > ra > fra > 0 - ] - print(f" {len(combos)} valid combos to evaluate...") - t0 = time.time() - - results = [] - for i, (rt, ya, ra, fra) in enumerate(combos): - sim_rng = np.random.default_rng(args.seed + 1 + i) - r = simulate(rt, ya, ra, fra, s, e, sim_rng) - results.append(summarize(r)) - if (i + 1) % 200 == 0: - elapsed = time.time() - t0 - rate = (i + 1) / elapsed - eta = (len(combos) - i - 1) / rate - print(f" ... {i+1}/{len(combos)} ({rate:.1f}/sec, ETA {eta:.0f}s)") - - print(f" done in {time.time() - t0:.0f}s") - print() - - by_score = sorted(results, key=lambda r: -score(r))[:25] - print("=== top 25 by composite score ===") - print(f"{'RT':>6} {'YEL':>4} {'RED':>4} {'FST':>4} | " - f"{'p_sweet':>7} {'p_any':>6} {'p_no_fail':>9} | " - f"{'lunch':>5} {'work':>4} {'press':>5} {'score':>6}") - print("-" * 86) - for r in by_score: - print(f"{fmt_rt(r['rt']):>6} {r['yellow_at']:>4} {r['red_at']:>4} {r['fast_red_at']:>4} | " - f"{r['p_sweet']*100:>6.1f}% {r['p_lunch_any']*100:>5.1f}% " - f"{r['p_no_work_sleep']*100:>8.1f}% | " - f"{fmt_h(r['mean_lunch']):>5} {fmt_h(r['mean_work_sleep']):>4} " - f"{r['mean_presses']:>5.2f} {score(r):>6.3f}") - - # Where does the firmware's currently-shipping combo land? - shipping = next( - (r for r in results - if r["rt"] == 240 and r["yellow_at"] == 30 - and r["red_at"] == 25 and r["fast_red_at"] == 20), - None, - ) - if shipping is not None: - rank = 1 + sum(1 for r in results if score(r) > score(shipping)) - print() - print("=== current firmware (RT=4h00 YEL=30 RED=25 FST=20) ===") - print(f" p_sweet={shipping['p_sweet']*100:.1f}% " - f"p_any={shipping['p_lunch_any']*100:.1f}% " - f"p_no_fail={shipping['p_no_work_sleep']*100:.1f}% " - f"score={score(shipping):.3f}") - print(f" rank = {rank} / {len(results)}") - - best = by_score[0] - print() - print(f"PICK: RT={fmt_rt(best['rt'])} YELLOW_AT={best['yellow_at']} " - f"RED_AT={best['red_at']} FAST_RED_AT={best['fast_red_at']}") - print(f" P(sweet 12:15-12:45) = {best['p_sweet']*100:.1f}%") - print(f" P(any lunch sleep) = {best['p_lunch_any']*100:.1f}%") - print(f" P(no work fail) = {best['p_no_work_sleep']*100:.1f}%") - print(f" mean lunch dead = {fmt_h(best['mean_lunch'])}") - print(f" mean work sleep = {fmt_h(best['mean_work_sleep'])}") - print(f" mean presses = {best['mean_presses']:.2f}/day") - print(f" mean after-hrs = {fmt_h(best['mean_after'])}") - - -if __name__ == "__main__": - main() diff --git a/src/chart.rs b/src/chart.rs index 255f5e2..ab3cf47 100644 --- a/src/chart.rs +++ b/src/chart.rs @@ -14,8 +14,7 @@ use crate::config::{ }; use crate::kbd::{KbdHid, send_kbd, wake_with_keyboard}; use crate::led::{ - Neo, blink_fast_red, boot_sweep, breathe_color, fade_to_green, paint, pulse_blue, - pulse_white, + Neo, blink_fast_red, boot_sweep, breathe_color, fade_to_green, paint, pulse_blue, pulse_white, }; use crate::mouse::{ MouseHid, animate_final_spiral, animate_spinner, animate_warning_5, animate_warning_10, diff --git a/src/config.rs b/src/config.rs index b476af9..2ae5254 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,20 +1,19 @@ //! Compile-time tuning constants — timing, geometry, brightness. //! //! Every magic number lives here so the rest of the firmware reads as -//! pure behaviour. Several values are joint-tuned by `scripts/tune_runtime.py` -//! — see the README's "Why four hours…" section for the rationale. +//! pure behaviour. Several values are joint-tuned by the `tuning/` crate +//! — see the README's "Why these timings…" section for the rationale. use embassy_time::Duration as EDuration; use hsmc::Duration; // ── Lifecycle timing (statechart Durations) ──────────────────────── -// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT × -// RED_AT × FAST_RED_AT) over a typical office workday distribution -// with a per-minute press-on-warning user model. Lands the screen-sleep -// in the 12:15–12:45 sweet spot on ~52 % of days and somewhere in -// lunch on ~74 %. See the README's "Why four hours…" section and -// `scripts/tune_runtime.py` for the simulation. -pub(crate) const RUN_DURATION: Duration = Duration::from_hours(4); +// 3h51m: a-posteriori pick from a 4-objective NSGA-III Pareto search +// over (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT) — minimize work- +// time failures, maximize lunch sleep, minimize button presses, minimize +// after-hours waste. See the README's "Why these timings?" section and +// `tuning/` for the search. +pub(crate) const RUN_DURATION: Duration = Duration::from_mins(3 * 60 + 51); pub(crate) const SHUTDOWN_LEAD: Duration = Duration::from_secs(30); pub(crate) const RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD); pub(crate) const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5); @@ -23,12 +22,11 @@ pub(crate) const JIGGLE_PERIOD: Duration = Duration::from_secs(270); pub(crate) const FLASH_DURATION: Duration = Duration::from_millis(100); // Phase boundaries — compared against time *remaining* in Active. -// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are -// kept deliberately short (5 min each); the long phase is fast-red. -// See the README's "Why four hours…" section for the rationale. -pub(crate) const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60); -pub(crate) const RED_AT: EDuration = EDuration::from_secs(25 * 60); -pub(crate) const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60); +// Joint pick from the NSGA-III Pareto search; see the README's +// "Why these timings…" section for the rationale. +pub(crate) const YELLOW_AT: EDuration = EDuration::from_secs(22 * 60); +pub(crate) const RED_AT: EDuration = EDuration::from_secs(11 * 60); +pub(crate) const FAST_RED_AT: EDuration = EDuration::from_secs(4 * 60); // LED breathing math pub(crate) const LED_TICK: EDuration = EDuration::from_millis(20); diff --git a/src/led.rs b/src/led.rs index a57a4f1..aab9092 100644 --- a/src/led.rs +++ b/src/led.rs @@ -15,8 +15,8 @@ use smart_leds::RGB8; use crate::chart::Ev; use crate::config::{ - BOOT_SWEEP_STEP, BREATHE_FLOOR, BREATHE_PEAK, FAST_RED_AT, FAST_RED_PERIOD, LED_TICK, - RED_AT, RED_PERIOD, RUN_BEFORE_SHUTDOWN, SETTLING_PULSE_PERIOD, SLOW_GREEN_PERIOD, + BOOT_SWEEP_STEP, BREATHE_FLOOR, BREATHE_PEAK, FAST_RED_AT, FAST_RED_PERIOD, LED_TICK, RED_AT, + RED_PERIOD, RUN_BEFORE_SHUTDOWN, SETTLING_PULSE_PERIOD, SLOW_GREEN_PERIOD, SPINNER_FADE_DURATION, WAKING_PULSE_PERIOD, YELLOW_AT, YELLOW_PERIOD, }; @@ -68,7 +68,12 @@ pub(crate) async fn blink_fast_red(neo: &mut Neo) -> Ev { pub(crate) async fn pulse_blue(neo: &mut Neo) -> Ev { let start = Instant::now(); loop { - let level = sin_breath(WAKING_PULSE_PERIOD, start.elapsed(), BREATHE_FLOOR, BREATHE_PEAK); + let level = sin_breath( + WAKING_PULSE_PERIOD, + start.elapsed(), + BREATHE_FLOOR, + BREATHE_PEAK, + ); paint(neo, 0, 0, level).await; Timer::after(LED_TICK).await; } @@ -77,7 +82,12 @@ pub(crate) async fn pulse_blue(neo: &mut Neo) -> Ev { pub(crate) async fn pulse_white(neo: &mut Neo) -> Ev { let start = Instant::now(); loop { - let level = sin_breath(SETTLING_PULSE_PERIOD, start.elapsed(), BREATHE_FLOOR, BREATHE_PEAK); + let level = sin_breath( + SETTLING_PULSE_PERIOD, + start.elapsed(), + BREATHE_FLOOR, + BREATHE_PEAK, + ); paint(neo, level, level, level).await; Timer::after(LED_TICK).await; } diff --git a/src/main.rs b/src/main.rs index 8a1bd48..8ca94e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,7 +87,7 @@ async fn main(spawner: Spawner) { #[cfg(feature = "defmt")] defmt::info!("usb serial: {}", serial); config.serial_number = Some(serial); - config.device_release = 0x0200; // matches firmware version 0.2.0 + config.device_release = 0x0300; // matches firmware version 0.3.0 config.max_power = 100; config.max_packet_size_0 = 64; diff --git a/tuning/.cargo/config.toml b/tuning/.cargo/config.toml new file mode 100644 index 0000000..5960082 --- /dev/null +++ b/tuning/.cargo/config.toml @@ -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" diff --git a/tuning/Cargo.lock b/tuning/Cargo.lock new file mode 100644 index 0000000..c180ced --- /dev/null +++ b/tuning/Cargo.lock @@ -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", +] diff --git a/tuning/Cargo.toml b/tuning/Cargo.toml new file mode 100644 index 0000000..897770d --- /dev/null +++ b/tuning/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "jiggly-tuning" +version = "0.1.0" +edition = "2024" +authors = ["Stephen Waits "] +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 diff --git a/tuning/src/main.rs b/tuning/src/main.rs new file mode 100644 index 0000000..139c5a5 --- /dev/null +++ b/tuning/src/main.rs @@ -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` 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; + + 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 (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 = 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::>(), + ); + 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) +}