evidence · 26.2 KiB

README.md

Committed at docs/evidence/theme-picker/board-session-2026-10-12/README.md. This is what grounds the requirements that cite it.

Open raw source

Board session 2026-10-12: picker overhead, matched pairs, motion trial, tap-apply

One SSH-driven session on the reserved board (single operator, flock /tmp/k230-board.lock held for the whole session). Evidence class: software-injected touch events through the verified virtual device named K230 injected touchscreen (the physical Goodix node was never written), journal and Rust trace timing, and native grim captures taken outside every timing pass. No finger, no camera, no glass observation. Tasks 14.4 and 18.4 (real finger) were not attempted.

Worktree /home/jadams/tmp/k230-theme-board-2026-10-12, branch theme/board-session-2026-10-12, base e7a47f2f. Session window 2026-10-12 05:44 to 07:20 UTC.

What is installed and what was compared

ItemIdentity
System (booted = current, never changed)/nix/store/zvxmd93gnn6n3vpk1mdxgajqnb8h1gkm-nixos-system-nixos-26.11.20260919.20b1ddd, kernel 7.3.0-rc5
Installed Rust shell (candidate; source faae7298: 18.2 motion, 13.2 counters, 13.3 admission gate, 17.2 row repaint)/nix/store/ybpnyqmcn672n7x45nx9ybzprljgdhfg-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0
Repaired baseline for 13.4/11.3/17.3 (8cik; 12.1 fix, no 13.3 gate, no 17.2 row repaint)/nix/store/8cikjd811d7b7hsdhn7nnb3lnipf3vwp-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0
Motion baseline for 18.3 (si9vd; pre-18.2, has 13.3 and 17.2)/nix/store/si9vd9hhdcb8kzppfsfq9id8kaqhnmwk-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0
Compositor / helper interpreter/nix/store/q96sk40q7j5v9f0ygxf2wx3yk7nzixqs-sway-unwrapped-riscv64-unknown-linux-gnu-1.12/bin/sway, /nix/store/v189xydz6qkcd4cbkixcmv91w8hbc560-python3-riscv64-unknown-linux-gnu-3.14.7/bin/python3.14
Catalog22 themes, 4 backgrounds (still images), 568x1232

8cik was garbage-collected on the board. It was shipped with nix-store --export | xz | ssh ... nix-store --import (its closure was otherwise present; only the path itself was missing) and pinned with gcroots /nix/var/nix/gcroots/k230-theme-baseline-8cik and .../k230-theme-motion-si9vd (left in place). Runtime references: nix-store -q --references of 8cik, si9vd and the installed ybpn ELF produce the same seven-path set (md5 adb9f78378c5b1747e6d1f7ce94699dc for all three): glibc, glib, cairo, pango, gcc-lib, librsvg, libxkbcommon. The ELF swap is therefore a like-for-like comparison of the binary.

The two builds do not draw the same picker. The 8cik captures show perspective-skewed cards with a blue outline; the installed build draws flat cards with page bars (see 13.4/vis/). Frame-cost differences below therefore include the visual-polish change as well as the row-repaint mechanism.

Starting state: no durable theme pointer (departs from the brief)

The board had been reflashed or reset: /home/shell/.local/state/omarchy/current held only .activation.lock; the shell ran the bundled default generation and k230-theme list reported active: {id: null, generation: null}. In that state the picker’s background row stays at “Loading backgrounds” indefinitely (reproduced 12 s and 22 s after opening; theme_ui.rs only loads the background detail when list.active.id is set). This is the same symptom docs/evidence/theme-picker/working-set/README.md recorded, and it makes the 13.1 gates (both rows resolved) unmeetable.

To get a comparable board I applied the bundled catppuccin theme once through the helper as the shell user (k230-theme preview then activate --expected-generation; durable pointer to generation 1af520f2..., selected background 1-totoro). That is the fixed generation for every run below. At the end I removed that state and restarted shell.service to put the board back to its original pointer-less default (see Final state). Applying as root fails with appearance peer has wrong UID / fanout prepare failed and rollback was not acknowledged; this was the expected UID check, not a regression.

Method (all runs)

tools/run.py (board, root, board Python) swaps only the final ELF through a runtime drop-in /run/systemd/system/shell-ui.service.d/92-theme-session.conf (wrapper copy with the last exec line replaced), arms an independent systemd-run --on-active restoration timer, restarts theme-helper and shell-ui (so every run starts from fresh processes and the same active theme index 0 and background index 0), opens Settings, and then runs tools/theme-picker-profile.py capture (the 13.1 workload: 2 s baseline, first open 10 s, 6 + 4 swipes of 240 px / 200 ms with 1.2 s rest, 15 s idle, back, warm open 5 s, 6 warm theme swipes, 15 s idle; no activation). The finally-block removes the drop-in, reloads and restarts the service; the timer is stopped. The profile harness gates every run: verified virtual device, exact executable identities, PID/start-tick identity, active generation unchanged for every 0.5 s sample, rows resolved and reviewed (I reviewed native captures of both rows before the first run), process-cold first opening (journal shows no earlier touch for that PID), no activation. All 31 runs passed every gate; each reduced report is in 13.4/reports/.

tools/ holds the executed drivers (run.py, gestures.py) and the host analysers; host/ holds the shell wrappers that invoked them. Raw private captures stay in ~/tmp/k230-theme-board-run-2026-10-12/ (46 MB, not committed); their SHA-256 values are in raw-sha256.json and per-run identities in runs-meta.json. Typical invocation (UTC start times in runs-meta.json):

BOARD_PYTHON=/nix/store/v189xydz6qkcd4cbkixcmv91w8hbc560-python3-riscv64-unknown-linux-gnu-3.14.7/bin/python3
systemd-run --pipe --wait --collect --unit=k230-theme-sess-NAME --property=RuntimeMaxSec=400s \
  --setenv=PATH=/run/current-system/sw/bin:/run/current-system/sw/sbin \
  $BOARD_PYTHON $B/run.py run --label NAME --kind baseline|candidate --exe ELF --mode off|counters|trace --out $B/runs/NAME
python3 tools/theme-picker-profile.py reduce RAW --rust-trace TRACE --output REPORT   # host
python3 tools/theme-picker-profile.py compare BASE_REPORT CAND_REPORT                # host, gates then deltas

One harness change was needed and is committed with a test: reduce called any journal line whose Rust elapsed clock was lower than the previous line “a process restart”. Lines from different threads reach the journal a few milliseconds out of order, and this fired on about half the runs (a 7 ms frame-done reordering). It now tolerates 250 ms (CLOCK_JITTER_MS); a real restart changes the PID and is still caught by the PID gate. python3 -m unittest discover -s tests -p test_theme_picker_profile.py: 51 tests pass.

Task 13.2 / 13.4: observer cost first

Same installed ELF, same workload, fresh restart before each; three OFF, three counters-only, one trace run (ov-*, 05:48 to 06:05 UTC). Pooled medians (13.4/overhead-pooled.json; harness pairwise output in 13.4/harness-overhead-*.json):

Pooled medianOFF (3)counters (3)trace (1)
warm theme commit-gap median, ms (each run)112.5 (111-141)129 (115.5-143)142
warm theme Rust CPU %69.1 (54-71)57.4 (56-66)59.6
background-swipe Rust CPU %11.1 (9.9-11.4)14.4 (10.6-16.1)13.8
background commit-gap median, ms393938
idle Rust CPU % (15 s windows)0.510.590.94
RSS at end, MiB86.085.187.9

Run-to-run spread with the recorder off is as large as any recorder effect on the theme row (the helper’s speculative preview work lands in different swipes each run: 6 to 14 preview requests). The measurable recorder cost is about +0.08 percentage points of Rust CPU when idle and about +3 points during the cheapest (background) swipes; trace mode adds about +0.4 points idle. No difference in background commit-gap median and no memory increase. Judgement: small enough to interpret counters, with that caveat; they are indicative, and absolute CPU figures from instrumented runs are slightly high. The pairs below were run twice per build: trace mode (event ring on both builds, so spans compare) and recorder off (clean CPU and cadence).

Task 13.4 / 11.3 / 17.3: three matched pairs, alternating order

Order of execution (UTC from runs-meta.json): trace mode p1 base, cand (06:07); p2 cand, base; p3 base, cand; then recorder off p1 cand, base (06:21); p2 base, cand; p3 cand, base. Fresh processes each run; the first opening of every run is journal-verified process-cold (disk and wallpaper caches were not cleared, so this is not disk-cold) and the second is warm. Gates passed for all six pairs (13.4/compare-*.json: comparable: true).

Recorder off (CPU, cadence, memory) — base / candidate per pair, then median

Metricpair 1pair 2pair 3median
Warm theme commit gap, median ms137.5 / 108135 / 117141 / 114137.5 / 114
Warm theme commit gap, p95 ms183 / 160173 / 177165 / 141173 / 160
Cold theme commit gap, median ms143 / 118143 / 123.5140.5 / 121143 / 121
Cold theme commit gap, p95 ms177 / 168175 / 184181 / 182177 / 182
Background commit gap, median ms80.5 / 3993 / 3987 / 38.587 / 39
Background commit gap, p95 ms142 / 95154 / 90155 / 89154 / 90
Background commits in 4 swipes30 / 5931 / 5831 / 6031 / 59
Rust CPU %, warm theme swipes77.8 / 63.079.4 / 68.980.5 / 75.279.4 / 68.9
Rust CPU %, cold theme swipes81.0 / 60.881.0 / 72.880.2 / 63.781.0 / 63.7
Rust CPU %, background swipes35.1 / 9.844.4 / 18.143.9 / 11.043.9 / 11.0
Rust CPU %, idle windows0.49 / 0.490.56 / 0.520.59 / 0.550.56 / 0.52
Helper cgroup CPU %, warm theme swipes0.06 / 1.010.06 / 1.670.06 / 0.710.06 / 1.01
Rust RSS at end of warm idle, MiB78.8 / 86.080.7 / 88.182.5 / 86.780.7 / 86.7
Rust RSS growth over the run, MiB27.6 / 34.929.8 / 36.831.6 / 35.329.8 / 35.3
Helper RSS, MiB23.9 / 24.024.1 / 24.024.0 / 24.024.0 / 24.0
Commits in the two 15 s idle windows0 / 00 / 03 / 00 / 0
Theme preview requests to the helper4 / 145 / 155 / 145 / 14
Overlay pre-renders completed (all while no touch held)4 / 64 / 94 / 74 / 7

All Rust CPU in the sampled windows is on the main k230-shell-rust thread (per-thread sampling with start-tick identity; short-lived worker threads fall between 0.5 s samples, so worker CPU is only visible through the trace spans below). Opening: first open 6 commits and about 4.4% Rust CPU for both builds; warm open 3 commits for both.

Trace mode (spans, presentation) — base / candidate per pair, then median

Metricpair 1pair 2pair 3median
Warm theme presented gap, median ms140.2 / 105.2127.0 / 105.3133.6 / 122.6133.6 / 105.3
Warm theme presented gap, p95 ms186 / 166160 / 178163 / 165163 / 166
Background presented gap, median ms84.0 / 38.365.6 / 38.365.8 / 38.365.8 / 38.3
render_total per frame, warm theme, median ms133.3 / 98.6124.8 / 96.9128.6 / 104.3128.6 / 98.6
render_total per frame, background, median ms76.9 / 3.261.8 / 3.160.4 / 3.261.8 / 3.2
Full scene rebuilds, warm theme swipes59 / 865 / 1665 / 1765 / 16
Full scene rebuilds, cold theme swipes61 / 1960 / 2061 / 1961 / 19
Full scene rebuilds, background swipes28 / 030 / 030 / 030 / 0
Scene-rebuild wall in warm theme swipes, ms7185 / 10407877 / 20027780 / 22157780 / 2002
Row repaint median per frame (candidate only), theme / background, ms- / 92.8 / 40.3- / 93.2 / 39.6- / 88.1 / 38.192.8 / 39.6
Thumbnail hash CPU in warm theme swipes (all off the main thread), ms360 / 10537 / 264112 / 219112 / 219
Rust RSS at end, MiB89.3 / 84.175.6 / 88.976.4 / 87.076.4 / 87.0

The first baseline run (tr-p1-base, 89.3 MiB) is an outlier; the other two baseline trace runs end at 75.6 and 76.4 MiB, so the trace pairs are noisier on memory than the recorder-off pairs, but the candidate is higher in five of six comparisons (+13 and +11 MiB in two trace pairs).

Speculative admission and in-flight work (trace pairs)

pair 1pair 2pair 3
Baseline admissions in total / inside a carousel drag3 / 12 / 02 / 0
Candidate admissions in total / by shell gesture phase held + moving13 / 010 / 013 / 0
Candidate admissions at rest, inside a swipe window (touch plus the 1.2 s after)11811
Candidate completions (in-flight work finishing) by shell phase held / moving1 / 01 / 10 / 0
Candidate completions that land inside a carousel touch window727

No new speculative admission starts during either row’s motion in the candidate (0 of 36 in held/moving). The gate defers them to the 1.2 s rest after each swipe, and the work admitted there (a helper preview of about 230 to 400 ms, a neighbour warm) is still in flight when the next swipe starts: most completions fall at the next touch-down. The candidate admits four to six times as many speculative requests as the baseline (13 / 10 / 13 vs 3 / 2 / 2 admissions in trace mode; helper preview requests 12 to 15 vs 4 to 5 in trace mode and 14 to 15 vs 4 to 5 in off mode) because of the neighbour queue, and the helper cgroup sits at 1 to 2% (24% in one cold-swipe window) where the baseline’s is 0.06%.

Candidate counters by shell gesture phase (trace runs; count, wall ms, thread CPU ms)

Kindmoving pair 1 / 2 / 3held pair 1 / 2 / 3
scene_rebuild23, 2999, 2203 / 33, 4836, 3013 / 33, 4475, 33254 / 4 / 3
picker_row_repaint53, 4846, 3909 / 32, 3707, 2254 / 40, 3472, 279536, 2957, 2483 / 30, 3065, 2056 / 34, 2815, 2360
render_total121 / 111 / 116 frames42 / 36 / 39 frames
thumbnail_request (hash and resolve each equally many)28 / 48 / 408 / 12 / 10

Full per-phase span tables are in 13.4/trace-phase-summary.json and 13.4/pairs-summary.json.

Native stills and sampled frames (not a timing pass)

13.4/vis/baseline-open-theme-background.png and .../candidate-...png: open, after one theme swipe, after one background swipe (injected 240 px / 200 ms), native grim -s 0.5. Both rows respond in both builds (theme row catppuccin to flexoki-light, background row Totoro to Omarchy). 13.4/browse-base.mp4 and browse-cand.mp4 encode the sampled frames (about 17 frames in 16 s, tools/sample-grim-frames.sh with half-scale grim, timestamps in the JSON). They are sampled screencopy, not smooth video: the baseline clip catches the background row in motion (Blue eye frame); the candidate clip shows theme-row motion but its background swipes fall between samples (that row’s response is shown by the still). I viewed the stills and sampled frames.

Outcome for 13.4, 11.3 and 17.3

Because warm-swipe p95 does not improve and memory does not hold, 11.3 stays open. The comparison tasks 13.4 and 17.3 were performed with the evidence above, so they are ticked in tasks.md; the push and site-deployment check in 17.3 and 14.4 / wider 13 and 15 gates are not covered.

Task 18.3: guarded motion trial (open)

18.3/: eight runs (4 per build, 06:34 to 07:00 UTC, trace mode, alternating order mo theme, ca theme, ca bg, mo bg, ca theme, mo theme, mo bg, ca bg), each with two repetitions of five injected programs (tools/gestures.py) on one row, a fresh Settings to Themes opening before every gesture, 2.5 s settle, then a native grim -s 0.4 capture (outside the gesture window). 80 gestures; the generation and process identities were checked after every one and never changed.

The shell records no per-frame carousel position or velocity, so no position or velocity curve is published. input_motion carries counters, commit and presented carry no carousel state, and the journal logs only finger positions. Deriving a curve by replaying the touches through the host model would be a model, not a board result. What exists is the injected finger path, the frame commits and the settled item:

GestureRowFrames after release (base / cand)Last commit after release, msFinal item (base / cand), ordinal
flicktheme14 / 9.51684 / 1120matte-black 11 / lupine 10 (4 of 4 each)
slowtheme7 / 7626 / 704ethereal 2 / everforest 3
hold-releasetheme4 / 5321 / 419catppuccin-latte 1 / 1
reversaltheme8 / 12.5298 / 547catppuccin 0 / 0
interrupttheme8 / 8805 / 781kanagawa 7 (3 of 4; gruvbox 5 once) / hackerman 6 (4 of 4)
flickbackground7.5 / 13237 / 506Omarchy 3 / Omarchy 3
slowbackground7.5 / 10.5245 / 383Omarchy 3 / Omarchy 3
hold-releasebackground4 / 7174 / 360Waves 1 / 1
reversalbackground7.5 / 13263 / 487Totoro 0 / 0
interruptbackground8.5 / 13.5440 / 745Omarchy 3 / Blue eye 2

Observations only: both builds are repeatable for identical injected input; commit-gap medians are 38 to 65 ms on the background row in both builds (reversal 41 versus 57 ms in the candidate’s favour) and 100 to 125 ms on the theme row in both (reversal 39 versus 47 ms), so the theme row is drawn at 8 to 10 frames per second in both builds; the candidate commits more frames after release on the background row (13 versus 7.5 after a flick); the candidate’s slow drag settles one item farther (3 versus 2) and its interrupt absorbs one item more; the settle tail after release is longer on the background row in the candidate. Whether that is better direct-tracking, flick distance or settling is exactly 18.4’s finger question, and no curve here supports a claim.

18.3 stays open. To publish position and velocity curves the shell needs a bounded per-frame carousel position/velocity record in the Rust trace (one value per row per commit). I did not add instrumentation in this session.

Task 10.7 and 6.6: warm tap-apply, dark and light (two trials)

10.7-6.6/ holds the sanitized journal excerpts (journal-trial-{1,2}.txt; board local time CDT = UTC-5, identifiers replaced by labels), the marker logs, picker-dark-light-dark.png (five native captures: open dark, light selected, light applied, dark selected, dark applied) and panel-bottom-full-resolution.png. Each trial: cold systemctl restart theme-helper shell-ui, Settings to Themes, 15 s for the neighbour warm, hold-release to the neighbouring light theme (catppuccin-latte), injected centre tap to apply, a fast swipe back to catppuccin, injected centre tap to apply. Trial 1 started 07:11:11 UTC, trial 2 07:12:45 UTC. The active generation path hash was identical before and after both trials (1d6e6815e6151009), i.e. the dark generation was restored.

Light apply (t1 / t2)Dark apply (t1 / t2)
optimistic-apply shown ms201.2 / 204.4156.1 / 163.2
prerendered, reasonfalse, content-changed (both)false, content-changed (both)
stage adopt / wallpaper / overlay / flush ms0.5 / 2.6 / 85.7 / 0.0; 0.4 / 2.8 / 86.2 / 0.90.4 / 7.5 / 55.1 / 0.8; 0.4 / 11.5 / 58.8 / 0.8
tap-up to shown frame (journal Rust clock), ms566 / 568511 / 510
activate_generation total, ms; prepare_phase; warm230.6 / 242.0; 3.1 / 0.1 ms; 2192.9 / 195.0; 0.2 / 0.1 ms; 2
theme-command activate round trip, ms300 / 314253 / 265

10.7. The shown ms figure did not move toward the ~30 ms target and is no better than the 161 ms baseline (dark 156 to 163, light 201 to 204). The reported reason is that the pre-render made while browsing (optimistic-apply prerendered generation=... appears before each tap) was not reused: prerendered=false reason=content-changed; the overlay stage (55 to 86 ms) is the dominant part. Measured from touch-up, the frame appears about 510 to 570 ms later because a preview request (255 to 288 ms, with a second appearance-prepare-accepted) runs after the tap, before the optimistic frame. Both themes read correctly in the native captures and the picker fits the 568x1232 panel: the theme card, background card, labels and the “Theme applied” status line are all inside the frame, the status line sits about 27 px above the bottom edge at full resolution (panel-bottom-full-resolution.png), tight but not clipped. This is compositor output (grim), not a camera view of the glass.

6.6 (not met). (a) Apply of an already-prepared generation is not under 150 ms: activate_generation is 193 to 242 ms and the activate round trip 253 to 314 ms. (b) activate_generation did skip the redundant prepare for both receivers (prepare_phase 0.1 to 3.1 ms, warm=2 targets=2), and the helper’s activate request showed parse 5 to 7 ms (trial 1) and prepare_entry 28 to 38 ms. (c) A second appearance-prepare-accepted does occur between the neighbour warm and the Apply, because the tap sends a preview first. (d) The cold-open neighbour check passes: after a fresh restart, the active theme previews in 380 to 383 ms and the neighbouring theme in 231 to 241 ms about 0.3 s later, not the earlier ~4 s cold figure. The disk caches were warm, so this is a process-cold, not disk-cold, result. 6.6 stays open. Next measured correction: skip the tap-time preview when the Rust side already holds the prepared generation (or make preview idempotent for a generation the receivers already hold), and reuse the browse-time pre-render instead of discarding it as content-changed.

Observer cost and limits

Final state of the board (verified 07:17 to 07:20 UTC)