From 6fe8c86708cc3bb9de930240406ca8717e6e9f2a Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 14 Jul 2026 10:07:06 +0200 Subject: [PATCH] docs(sync): revise wifi-auto-sync curve with v0.7 field numbers The ~8s-burst placeholder is replaced by the measured warm :gp (19.1s = 10.3s splice + 5.9s push leg): K roughly doubles to ~0.6 mAh/sync and its mix shifts from radio to SD/CPU, but the splice reruns per sync so the 1/T shape, the 5-10 min knee, and the 10-min default all hold. TLS session resumption (2.4s vs 4.0s connect) and the run-8 keep-alive death strengthen teardown-between-syncs; clean ticks are radio-free via the dirty journal; :gl noted as outbound-only. ASCII curve replaced with a mermaid xychart. --- docs/tradeoff-curves/wifi-auto-sync.md | 178 +++++++++++++++---------- 1 file changed, 111 insertions(+), 67 deletions(-) diff --git a/docs/tradeoff-curves/wifi-auto-sync.md b/docs/tradeoff-curves/wifi-auto-sync.md index e527359..c218a41 100644 --- a/docs/tradeoff-curves/wifi-auto-sync.md +++ b/docs/tradeoff-curves/wifi-auto-sync.md @@ -3,95 +3,132 @@ > **Decision:** `auto_sync` defaults to **10 min**, and is an *opportunistic, > rate-limited* push — not a wall-clock timer that wakes the device. See > [Policy](#policy). Backs the `.typoena.toml` `auto_sync` key in -> [`../macroplan.md`](../macroplan.md) (v0.5), whose runtime timer lands in v0.7 and -> must respect sleep (v0.8). +> [`../macroplan.md`](../macroplan.md) (v0.5). The runtime behaviour was +> pencilled for v0.7, but v0.7 closed 2026-07-14 as search + manual `:gl`/`:gp` +> without it — it is **re-homed to v0.8** +> ([`../v0.8-battery-and-sleep.md`](../v0.8-battery-and-sleep.md)), alongside +> the sleep transitions it must respect and the per-sync radio teardown it +> depends on. +> +> **Revised 2026-07-14** with the first real-device sync measurements (v0.7 +> runs on the real ~560 MB notes repo, see +> [`../v0.7-search-and-git.md`](../v0.7-search-and-git.md)): the per-sync +> constant roughly **doubles** and its mix shifts from radio to SD/CPU, but the +> shape, the knee, and the decision all survive — and two v0.7 results make the +> teardown-between-syncs argument *stronger*. > > Tradeoff-curves index: [`README.md`](README.md). Docs index: > [`../README.md`](../README.md). ## The model -For a **text** commit the git payload is a few KB — negligible. Almost all the -energy of one sync is a *fixed* radio burst that costs the same no matter how -little changed: +For a **text** commit the git payload is a few KB — negligible. The cost of one +sync is (almost) independent of how much you wrote — but it is no longer the +single fixed radio burst the first version of this doc assumed. Measured on +device (v0.7 run 4, 2026-07-14, warm clean `:gp` on the real notes repo): ``` -radio wake → AP association → TLS handshake → tiny push → teardown +splice commit (SD/CPU, radio not needed) 10.3 s O(depth) loose writes × ~0.4 s FAT dir scan +push leg (connect + pack + upload) 5.9 s TLS-resumed connect 2.4 s (4.0 s first-of-session) + ────── +warm `:gp` end-to-end 19.1 s root-level file ≈ 12–13 s — the splice is depth-bound ``` -So energy per unit time scales as **(fixed cost per sync) × (syncs per hour)**: +Both halves are paid once per *dirty* sync, so energy per unit time still +scales as **(fixed cost per sync) × (syncs per hour)**: ``` -E(T) = K / T T = interval in minutes, K = one burst's worth of energy +E(T) = K / T T = interval in minutes, K = one sync's worth of energy ``` A hyperbola. Doubling the frequency doubles the cost; the words you actually -wrote barely move it. +wrote barely move it. What the measurement changed is **K and its +composition**: the awake window is ~12–19 s (not the ~8 s placeholder), and +more than half of it is SD/CPU splice work that no radio policy can touch — +but the splice re-runs on every sync too, so it rides the same 1/T curve. The +splice scales with file *depth* and dirty-set size, not with words (the +FAT-dir-scan story in +[`sync-commit-staging.md`](sync-commit-staging.md), run 6). -Placeholder constants (pending the v0.8 bench measurement — "measure idle / -typing / push current draw"): an ~8 s radio burst at ~150 mA average ⇒ -**0.33 mAh per sync**, so `K ≈ 20 mAh·min/hr`. The vertical scale below moves -with the real measurement; the *shape* and the knee do not. +Placeholder *currents*, still pending the v0.8 bench measurement ("measure +idle / typing / push current draw"): ~10 s of CPU+SD at ~100 mA plus ~8 s of +radio-on at ~150 mA ⇒ **≈ 0.6 mAh per sync**, `K ≈ 36 mAh·min/hr` — about 2× +the original estimate. The vertical scale below moves with the real current +measurement; the *shape* and the knee do not. -**One assumption is baked into that burst: the radio is fully off between +Two refinements the field runs added: + +- **A clean tick is free.** Since the dirty-journal plumbing (2026-07-13), + `:gp` with an empty journal answers "up to date" without touching the radio + at all. An auto-sync tick only pays when there is actually something to + push — idle hours with no edits cost zero regardless of the interval. +- **The worst case is bounded at ~2×.** If the mirror moved underneath (a Mac + push), an auto-sync hits the rejected-push → reconcile → replay → push cycle: + 24.0 s and three connections measured (run 3, *with* session resumption). + Rare, and it doesn't move the knee. + +**One assumption is baked into the burst: the radio is fully off between syncs**, not parked in modem-sleep. Holding the association awake to skip the -per-sync handshake costs ~15–20 mAh/hr on the WROOM — more than a 1-min interval -and ~10× the 10-min default — and only pays back above ~150 syncs/hr (one sync -every ~24 s), which a writing appliance never reaches. So each sync legitimately -pays a full fresh `wake → associate → handshake` burst, and "off" everywhere -below means radio **de-init**, not beacon-listening. Tear the connection down -immediately after each push, too: with syncs ≥2 min apart a keep-alive window -saves nothing, and Typoena only ever *pushes* — there's no inbound traffic that -would justify staying reachable. +per-sync handshake costs ~15–20 mAh/hr on the WROOM — more than a 1-min +interval and ~10× the 10-min default. Two v0.7 results bury keep-alive +further: -> **Status (v0.1) — the shipped firmware does *not* cycle the radio yet.** It -> brings Wi-Fi up lazily on the first `:sync` and then keeps it up for the rest -> of the session: `run_git_service` in -> [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs) holds the -> `wifi` handle across its whole request loop and never stops, disconnects, or -> drops it (grep the module for `stop`/`disconnect`/`drop` — zero hits). So -> today's device runs the *stay-associated* strategy this section argues -> against, at ~15–20 mAh/hr after the first push. The off-between-syncs -> assumption above is the **target**, not current behaviour: the modem is -> `.take()`n exactly once, so per-sync teardown is a v0.8 refactor of that -> ownership, not a config flip — and a prerequisite before any sleep mode ships. +- **TLS session resumption** (the `esp_mbedtls_stream.c` vendor delta) cut a + reconnect to 2.4 s vs 4.0 s for the session's first: the handshake that + staying-associated was supposed to amortise is now the cheap part, and the + session ticket lives in RAM — it survives radio de-init between syncs. +- **Held connections die anyway.** In run 8 the idle keep-alive was closed + during a ~31 s quiet gap and libgit2 does not reconnect (`SSL Generic + error`, push lost). Keep-alive is not just expensive; it's fragile. + +So each sync legitimately pays a fresh `wake → associate → resumed-handshake` +burst, and "off" everywhere here means radio **de-init**, not +beacon-listening. Tear the connection down immediately after each push, too: +with syncs ≥ 2 min apart a keep-alive window saves nothing. (v0.7 added `:gl` +pull, so "Typoena only ever pushes" is no longer literally true — but pull is +user-initiated and outbound like everything else; nothing unsolicited ever +arrives, so there is still no reason to stay reachable.) + +> **Status (v0.7) — the shipped firmware still does *not* cycle the radio.** +> Wi-Fi comes up lazily on the first git op and then stays associated for the +> rest of the session: `ensure_online` in +> [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs) owns the +> `wifi` handle and the module never stops, disconnects, or drops it (the +> `remote.disconnect()` calls in there are git smart-protocol connections, not +> the radio). So today's device runs the *stay-associated* strategy this +> section argues against, at ~15–20 mAh/hr after the first git op. Per-sync +> teardown remains a v0.8 refactor of the modem ownership — and a prerequisite +> before any sleep mode ships. ## The curve -``` - Wi-Fi energy vs auto-sync interval E(T) ≈ K / T +`E(T) = K / T` with the revised `K ≈ 36 mAh·min/hr` (≈ 0.6 mAh per dirty +sync): - mAh/hr - 20 | * each sync ≈ one fixed radio burst, - | * independent of how much text changed - | * - 15 | * ← STEEP: every extra sync/min costs a full burst - | * for zero payload benefit - | * - | * - 10 | * - | * - | * - | * ← knee - 5 | *·.___ (5 min) - | `·-·__ ______ - | `·-·__·--·______ (15) diminishing returns: - 0 | `·--·----·----·----·----·--- the tail is ~flat - +----+----+----+----+----+----+----+----+----+----+----+----+ - 0 5 10 15 20 25 30 35 40 45 50 55 min - └── knee: 5–10 min. Left of here you pay a lot; - right of here you save almost nothing. +```mermaid +xychart-beta + title "Wi-Fi energy vs auto-sync interval (placeholder currents)" + x-axis "interval (min)" ["1", "2", "5", "10", "15", "30", "60"] + y-axis "mAh/hr" 0 --> 36 + bar [36, 18, 7.2, 3.6, 2.4, 1.2, 0.6] ``` +The knee sits at **5–10 min**: left of it, every extra sync per hour costs a +full splice + radio burst for zero payload benefit; right of it, the tail is +nearly flat and longer intervals save almost nothing. + | interval | syncs/hr | Wi-Fi mAh/hr | vs 5-min | per 8 h day | | ---: | ---: | ---: | ---: | ---: | -| 1 min | 60 | 20.0 | 5.0× | 160 mAh | -| 2 min | 30 | 10.0 | 2.5× | 80 mAh | -| 5 min | 12 | 4.0 | 1.0× | 32 mAh | -| **10 min** | 6 | **2.0** | **0.5×** | 16 mAh | -| 15 min | 4 | 1.33 | 0.33× | 10.7 mAh | -| 30 min | 2 | 0.67 | 0.17× | 5.3 mAh | -| 60 min | 1 | 0.33 | 0.08× | 2.7 mAh | +| 1 min | 60 | 36.0 | 5.0× | 288 mAh | +| 2 min | 30 | 18.0 | 2.5× | 144 mAh | +| 5 min | 12 | 7.2 | 1.0× | 58 mAh | +| **10 min** | 6 | **3.6** | **0.5×** | 29 mAh | +| 15 min | 4 | 2.4 | 0.33× | 19 mAh | +| 30 min | 2 | 1.2 | 0.17× | 9.6 mAh | +| 60 min | 1 | 0.6 | 0.08× | 4.8 mAh | + +(Every row assumes the device was dirty at each tick — a writing session in +progress. Clean ticks cost nothing, per the journal gate above.) ## Two things that move where "best" sits @@ -99,7 +136,8 @@ would justify staying reachable. freshness.** The durable local copy is the SD write on the idle pause. A longer sync interval never risks *losing work*; it only means the GitHub mirror is a few minutes staler. That's a weak cost, and it pushes the optimum toward -*longer* intervals. +*longer* intervals. The doubled K pushes the same direction: each avoided sync +now saves twice what the original model claimed. **The real battery risk is the sleep interaction, not the awake case.** While you're typing, the CPU/e-ink baseline dwarfs the sync cost — 5 vs 15 min is @@ -115,7 +153,9 @@ read as a *max-staleness cap* rather than a timer period: - **Push when already awake + dirty**, coalesced into the existing idle-pause, rate-limited to at most once per `auto_sync` — so a fast typist pausing every - 20 s doesn't sync 100×/hr. + 20 s doesn't sync 100×/hr. The dirty journal makes the check free: a tick + with nothing to push never spins the radio (this is `:gp`'s shipped + radio-free up-to-date path). - **Push once on the way into sleep** (idle → light sleep, and especially lid-close → deep sleep) if dirty. This is the highest-value sync: nearly free (the device is spinning up anyway) and it's the freshness guarantee. @@ -123,6 +163,10 @@ read as a *max-staleness cap* rather than a timer period: standby life. On the single number: **10 min** halves the sync energy versus a 5-min default -for essentially no real cost, because `save_on_idle` already owns data safety. -Clamp the minimum to **~2 min** so a palette command (`> auto sync: 10s`) can't -quietly drain the battery. +(3.6 vs 7.2 mAh/hr under the revised K) for essentially no real cost, because +`save_on_idle` already owns data safety. Clamp the minimum to **~2 min** so a +palette command (`> auto sync: 10s`) can't quietly drain the battery. One new +UX input since the first version: a dirty auto-sync now occupies the git +thread for ~12–19 s (UI stays live — proven by the run-4/5 concurrent-UI +work), so at the 2-min clamp a heavy writer's device would be syncing ~15% of +the time. The 10-min default keeps that under 3%.