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:
2026-05-07 06:32:17 -06:00
co-authored by Claude Opus 4.7
parent 7effaa293b
commit c45fceaf7a
15 changed files with 1123 additions and 322 deletions
+1
View File
@@ -1,2 +1,3 @@
/.cargo /.cargo
/target /target
/tuning/target
+47 -1
View File
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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:1512: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 ## [0.2.0] - 2026-05-01
### Added ### 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 `bootstrap`. All recipes execute inside `mise exec -- sh -eu -c` so the
pinned toolchain is used regardless of shell activation state. 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.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 [0.1.0]: https://github.com/swaits/jiggly/releases/tag/v0.1.0
Generated
+1 -1
View File
@@ -825,7 +825,7 @@ dependencies = [
[[package]] [[package]]
name = "jiggly" name = "jiggly"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"cortex-m", "cortex-m",
"cortex-m-rt", "cortex-m-rt",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jiggly" name = "jiggly"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
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." 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."
+35 -26
View File
@@ -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 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 macOS; a key tap does. F13 is chosen because it's harmless if it ever
ends up stuck — no OS maps it by default. 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. so the host never falls asleep.
- Breathes the on-board NeoPixel green → yellow → red as time runs - Breathes the on-board NeoPixel green → yellow → red as time runs
down. Two on-screen "spiral" warnings fire 10 min and 5 min before 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. 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 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 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 | | `RED_AT` | minutes-remaining where breathing-red begins |
| `FAST_RED_AT` | minutes-remaining where the fast-pulse-red blink 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 The `tuning/` crate solves it as a four-objective Pareto search
search over those four constants across 50 000 simulated workdays. using [`heuropt`][heuropt] and NSGA-III:
The model:
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 - 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:0013:00. `Triangular(16:00, mode 17:30, 19:00)`. Lunch is fixed at 12:0013:00.
@@ -102,34 +108,36 @@ The model:
fires. fires.
- Free `RESET` at boot and at 13:00 (re-login after lunch). - Free `RESET` at boot and at 13:00 (re-login after lunch).
The composite score rewards lunch-hour expiration (especially NSGA-III returns a Pareto front of ≈28 non-dominated points across
12:1512:45) and penalizes the screen sleeping while the user is at those four objectives — every one of them a legitimate tradeoff. To
their desk. pick a single recommendation the tuner applies explicit decision
weights (lunch_sleep 30 %, after_hours 25 %, work_fail 20 %, presses
The winner — and what the firmware ships: 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 (LED thresholds are minutes-remaining.) Across 1 000 simulated
during lunch on **~74 %** of simulated days and in the 12:1512:45 workdays this combination averages **26 minutes** of lunch sleep
sweet spot on **~52 %**. and lands in the 12:1512: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` The interesting result is that **shorter warning phases are better**.
thresholds (long, gentle warning, urgent finish) ranked dead-average A long yellow phase gives you 30 minutes to glance up, notice the
out of 2 245 combos. Long visible warnings turn out to be LED, and tap `RESET` out of an abundance of caution — and a tap
counter-productive: a 30-minute yellow phase gives you 30 minutes to during yellow extends the cycle into the afternoon, the opposite of
glance up, notice the LED, and tap `RESET` — and a tap during yellow the goal. The Pareto-front winner runs an 11-minute yellow, a 7-
extends the cycle into the afternoon, the opposite of the goal. minute red, and a 4-minute fast-red: long enough to register the
Shrinking yellow and red to "long enough to notice, short enough not warning, short enough that the natural reaction is to wait it out.
to act on" pushes more days into a clean lunch death.
If your day looks different — different start/end distribution, If your day looks different — different start/end distribution,
different press habits, different lunch length — edit the constants different press habits, different lunch length — edit the model
and ranges at the top of `scripts/tune_runtime.py`, run it constants in `tuning/src/main.rs`, run `just tune` (or `cargo run
(`uv run scripts/tune_runtime.py`), and update the four values in --release` from inside `tuning/`), and update the four values in
`src/main.rs`. `src/config.rs`.
## USB identity ## USB identity
@@ -148,6 +156,7 @@ MIT — see [LICENSE](LICENSE).
[xiao]: https://wiki.seeedstudio.com/XIAO-RP2040/ [xiao]: https://wiki.seeedstudio.com/XIAO-RP2040/
[embassy]: https://embassy.dev/ [embassy]: https://embassy.dev/
[hsmc]: https://crates.io/crates/hsmc [hsmc]: https://crates.io/crates/hsmc
[heuropt]: https://crates.io/crates/heuropt
[mise]: https://mise.jdx.dev/ [mise]: https://mise.jdx.dev/
[just]: https://just.systems/ [just]: https://just.systems/
[pidcodes]: https://pid.codes/ [pidcodes]: https://pid.codes/
+7
View File
@@ -132,3 +132,10 @@ bootstrap:
# Show firmware file size summary. # Show firmware file size summary.
stats: uf2 stats: uf2
@ls -lh {{ out_release }} {{ out_bin }} {{ out_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
-271
View File
@@ -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:0013: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:1512: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()
+1 -2
View File
@@ -14,8 +14,7 @@ use crate::config::{
}; };
use crate::kbd::{KbdHid, send_kbd, wake_with_keyboard}; use crate::kbd::{KbdHid, send_kbd, wake_with_keyboard};
use crate::led::{ use crate::led::{
Neo, blink_fast_red, boot_sweep, breathe_color, fade_to_green, paint, pulse_blue, Neo, blink_fast_red, boot_sweep, breathe_color, fade_to_green, paint, pulse_blue, pulse_white,
pulse_white,
}; };
use crate::mouse::{ use crate::mouse::{
MouseHid, animate_final_spiral, animate_spinner, animate_warning_5, animate_warning_10, MouseHid, animate_final_spiral, animate_spinner, animate_warning_5, animate_warning_10,
+13 -15
View File
@@ -1,20 +1,19 @@
//! Compile-time tuning constants — timing, geometry, brightness. //! Compile-time tuning constants — timing, geometry, brightness.
//! //!
//! Every magic number lives here so the rest of the firmware reads as //! 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` //! pure behaviour. Several values are joint-tuned by the `tuning/` crate
//! — see the README's "Why four hours…" section for the rationale. //! — see the README's "Why these timings…" section for the rationale.
use embassy_time::Duration as EDuration; use embassy_time::Duration as EDuration;
use hsmc::Duration; use hsmc::Duration;
// ── Lifecycle timing (statechart Durations) ──────────────────────── // ── Lifecycle timing (statechart Durations) ────────────────────────
// 4h00m: optimum from the 4-D Monte Carlo (RUN_DURATION × YELLOW_AT × // 3h51m: a-posteriori pick from a 4-objective NSGA-III Pareto search
// RED_AT × FAST_RED_AT) over a typical office workday distribution // over (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT) — minimize work-
// with a per-minute press-on-warning user model. Lands the screen-sleep // time failures, maximize lunch sleep, minimize button presses, minimize
// in the 12:1512:45 sweet spot on ~52 % of days and somewhere in // after-hours waste. See the README's "Why these timings?" section and
// lunch on ~74 %. See the README's "Why four hours…" section and // `tuning/` for the search.
// `scripts/tune_runtime.py` for the simulation. pub(crate) const RUN_DURATION: Duration = Duration::from_mins(3 * 60 + 51);
pub(crate) const RUN_DURATION: Duration = Duration::from_hours(4);
pub(crate) const SHUTDOWN_LEAD: Duration = Duration::from_secs(30); 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 RUN_BEFORE_SHUTDOWN: Duration = RUN_DURATION.saturating_sub(SHUTDOWN_LEAD);
pub(crate) const SHUTDOWN_ANIM_BUDGET: Duration = Duration::from_secs(5); 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); pub(crate) const FLASH_DURATION: Duration = Duration::from_millis(100);
// Phase boundaries — compared against time *remaining* in Active. // Phase boundaries — compared against time *remaining* in Active.
// Joint optimum from `scripts/tune_runtime.py`. Yellow and red are // Joint pick from the NSGA-III Pareto search; see the README's
// kept deliberately short (5 min each); the long phase is fast-red. // "Why these timings…" section for the rationale.
// See the README's "Why four hours…" section for the rationale. pub(crate) const YELLOW_AT: EDuration = EDuration::from_secs(22 * 60);
pub(crate) const YELLOW_AT: EDuration = EDuration::from_secs(30 * 60); pub(crate) const RED_AT: EDuration = EDuration::from_secs(11 * 60);
pub(crate) const RED_AT: EDuration = EDuration::from_secs(25 * 60); pub(crate) const FAST_RED_AT: EDuration = EDuration::from_secs(4 * 60);
pub(crate) const FAST_RED_AT: EDuration = EDuration::from_secs(20 * 60);
// LED breathing math // LED breathing math
pub(crate) const LED_TICK: EDuration = EDuration::from_millis(20); pub(crate) const LED_TICK: EDuration = EDuration::from_millis(20);
+14 -4
View File
@@ -15,8 +15,8 @@ use smart_leds::RGB8;
use crate::chart::Ev; use crate::chart::Ev;
use crate::config::{ use crate::config::{
BOOT_SWEEP_STEP, BREATHE_FLOOR, BREATHE_PEAK, FAST_RED_AT, FAST_RED_PERIOD, LED_TICK, BOOT_SWEEP_STEP, BREATHE_FLOOR, BREATHE_PEAK, FAST_RED_AT, FAST_RED_PERIOD, LED_TICK, RED_AT,
RED_AT, RED_PERIOD, RUN_BEFORE_SHUTDOWN, SETTLING_PULSE_PERIOD, SLOW_GREEN_PERIOD, RED_PERIOD, RUN_BEFORE_SHUTDOWN, SETTLING_PULSE_PERIOD, SLOW_GREEN_PERIOD,
SPINNER_FADE_DURATION, WAKING_PULSE_PERIOD, YELLOW_AT, YELLOW_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 { pub(crate) async fn pulse_blue(neo: &mut Neo) -> Ev {
let start = Instant::now(); let start = Instant::now();
loop { 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; paint(neo, 0, 0, level).await;
Timer::after(LED_TICK).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 { pub(crate) async fn pulse_white(neo: &mut Neo) -> Ev {
let start = Instant::now(); let start = Instant::now();
loop { 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; paint(neo, level, level, level).await;
Timer::after(LED_TICK).await; Timer::after(LED_TICK).await;
} }
+1 -1
View File
@@ -87,7 +87,7 @@ async fn main(spawner: Spawner) {
#[cfg(feature = "defmt")] #[cfg(feature = "defmt")]
defmt::info!("usb serial: {}", serial); defmt::info!("usb serial: {}", serial);
config.serial_number = Some(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_power = 100;
config.max_packet_size_0 = 64; config.max_packet_size_0 = 64;
+6
View File
@@ -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"
+243
View File
@@ -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",
]
+25
View File
@@ -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
+728
View File
@@ -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:0013: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:1512:45 sweet spot.
p_sweet: f64,
mean_lunch: f64,
mean_work_sleep: f64,
mean_presses: f64,
mean_after: f64,
}
fn sample_triangular(low: f64, mode: f64, high: f64, rng: &mut StdRng) -> f64 {
let u: f64 = rng.random();
let c = (mode - low) / (high - low);
if u < c {
low + ((high - low) * (mode - low) * u).sqrt()
} else {
high - ((high - low) * (high - mode) * (1.0 - u)).sqrt()
}
}
/// Pre-sampled simulated workdays. Sampling once and reusing across all
/// `evaluate` calls is the standard SAA pattern: every parameter combination
/// is scored on the same days, so differences in objective values reflect the
/// parameters rather than Monte Carlo noise between evaluations.
struct JigglyTuning {
days: Vec<(i32, i32, u64)>, // start_min, end_min, per-day RNG seed
}
impl JigglyTuning {
fn new(n_days: usize, seed: u64) -> Self {
let mut rng = StdRng::seed_from_u64(seed);
let days = (0..n_days)
.map(|_| {
let s = (sample_triangular(8.0, 8.5, 9.5, &mut rng) * 60.0) as i32;
let e = (sample_triangular(16.0, 17.5, 19.0, &mut rng) * 60.0) as i32;
let day_seed: u64 = rng.random();
(s, e, day_seed)
})
.collect();
Self { days }
}
fn simulate_one(
s: i32,
e: i32,
day_seed: u64,
rt: i32,
ya: i32,
ra: i32,
fra: i32,
) -> DayOutcome {
let mut rng = StdRng::seed_from_u64(day_seed);
let mut expire = s + rt;
// 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:1512: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)
}