From 68b48cfd1478b1b5eabd819e8211d903317eb721 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 21:04:07 -0400 Subject: [PATCH] docs: record P0's Compose half (bench build, fixture, smoke run) RUST.md's P0 box gets the emulator smoke run's report and what's done vs. left; DECISIONS.md gets a dated summary entry; AGENTS.md's "Checking your work" and "The rigs" get one paragraph each on the bench build type and app/bench-fixture/. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 27 +++++- docs/DECISIONS.md | 43 +++++++++ docs/RUST.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4531760..6798462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,11 @@ two icon buttons the same width without either being given one — and why :androidApp:compileDebugKotlin :androidApp:lintDebug :androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the syntax highlighter, the ANSI parser and the transcript cache — the app's - pure logic with no Android in it. + pure logic with no Android in it. Touching anything under `BenchFixture.kt`, + `BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs + `:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build + type compiles separately and lint has caught real bugs debug alone never + would (see "Android Lint" below). - **Android Lint is not optional and is not run by a build.** It found a crash that had been shipping (`java.time` on a minSdk-24 app with desugaring off) and later a permission check that silently dropped every @@ -156,6 +160,27 @@ two icon buttons the same width without either being given one — and why Each exists because something was invisible without it. +- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md + and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris + asked for before porting continues: a deterministic, checked-in synthetic + transcript (`app/bench-fixture/generate.py`, never a real one) that both + this app and iris open with no server, so a frame-time comparison + measures the renderer rather than the data. `./build-apk.sh bench` builds + it — own application id (`com.example.aiapp.bench`) and label ("AI + Sessions bench") so it installs beside a real enrollment rather than + replacing it. Opening it goes straight to a session screen holding the + fixture (no enrollment, no permission prompts) with a "Run benchmark" + control beside "Copy" in session settings: it drives the same scroll loop + and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over + `ui-trace`, but in-process, since a real phone has no usable system + tracing and no agent can drive one (this-machine-android's skill). + `BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a + `URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s + requests from an in-memory copy of the fixture instead of opening a + socket — so the fold, the paging and `uniqueItems` under test are the + screen's real ones, never a shortcut built just for this. The report + gains a `bench:` section (process CPU time, peak RSS, battery current) on + every build, empty except when `BenchRun.kt` filled it in. - **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config and data directory, holding eight invented Claude Code transcripts and a `claude` that is two lines of shell. **That isolation is the point**: the diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 656939e..8e4f056 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -7,6 +7,49 @@ marked **DEFERRED** are ones the agent chose not to decide alone. ## 2026-09-05 +- **P0's Compose half is built and smoke-tested on the emulator** — the + `bench` build type, the shared `app/bench-fixture/` transcript, and an + in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that + answers `TranscriptSource`/`EventStream` from an in-memory event log + instead of a real server, so the fold and paging under test are the real + ones. Full account, the smoke run's report, and what is deliberately + left (the iris half, the real on-phone runs) are in RUST.md's P0 box. + Not a decision to review so much as the gate itself now being runnable — + flagged here because it is the first half of something Iris explicitly + asked to see before P1. + +- **The intermittent touch-scroll dropout is root-caused and fixed: a + missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced + first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every + touch event, `DragArbiter` state transition and `Selection::drag` + dispatch (removed once confirmed), reproduced on this checkout's own + emulator against a real sandbox session. The trace showed the actual + mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually + is, which is not guaranteed to fall inside the same row-local sensor + region a later `ACTION_MOVE` in the same gesture lands in (a row's own + padding/gap, or its non-selectable sender-name header, is + pointer-transparent to `iris::sense::CursorSense`). When that happens, + the widget that ends up handling the gesture never saw `PressStart`, so + `DragArbiter` sits in `Idle` — which answers every subsequent frame with + `Undecided` and has no way to tell "no press is happening" from "a press + is happening but I missed its start," so it never recovers on its own + for the rest of that gesture. One real trace showed exactly this: touch + `Down`/`Move`/`Up` all delivered correctly, but zero `PressStart` + reaching the arbiter, `state=Idle` unchanged from first frame to last. + Fixed at the call site that has the context to recover + (`iris::transcript_ui::selection::Selection::drag`, + `iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()` + (`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the + arbiter still `Idle` — which can only mean a missed `PressStart`, since a + `Pressing` sense requires the button to genuinely be down — and start the + press there instead of where it was missed. Three new unit tests in + `sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s + `selection::tests` (the latter fails on the code before this fix). + Commit follows. Not the same failure the earlier pass's `DECISIONS.md` + DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping + slop detection) — that hypothesis is now ruled out; the arbiter's own + slop/long-press logic was never wrong. RUST.md's I5 box, + "Touch-scroll dropout root-caused, 2026-09-05" has the full trace. - **P0, a phone benchmark gate before any porting, asked for by Iris 2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress test on my own phone ... If it doesn't match compose reasonably well then diff --git a/docs/RUST.md b/docs/RUST.md index 5c40427..089a19a 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -36,6 +36,29 @@ session spending an afternoon on them again. ## Where things stand (2026-09-05) +- **The intermittent touch-scroll dropout is root-caused and fixed, + 2026-09-05.** Not the previously-suspected coalesced first + `ACTION_MOVE` (ruled out) -- a gesture's `ACTION_DOWN` can land on a + row's own padding/gap or its header, which `CursorSense` has no sensor + over, so the widget that ends up handling the gesture only ever sees + `Pressing` frames and `DragArbiter` never gets `press_start`, leaving it + stuck in `Idle` (answers `Undecided` forever) for the rest of that + gesture. Fixed in `Selection::drag` (`iris/transcript-ui/src/ + selection.rs`) via a new `DragArbiter::is_idle()` the caller checks to + recover a missed press on the next `Pressing` frame. Four new unit + tests (three in `iris/src/sense.rs`'s `drag_arbiter_tests`, one in + `transcript-ui`'s `selection::tests`, the latter failing on the + pre-fix code). See this box's own "Touch-scroll dropout root-caused, + 2026-09-05" subsection for the trace and what could and could not be + re-verified this pass -- **this checkout's emulator turned out to be + concurrently in use by another session's P0 benchmark work partway + through verification** (its sandbox server was restarted, wiping this + pass's test session, and its Compose `bench` app took window focus), + so the "run iris-scroll.sh three times cleanly" and "re-take the + host-GPU FrameReport row" pass conditions could not be completed + end-to-end this pass. The fix itself is verified by direct, targeted + logcat traces taken before that interference began, not by the + aggregate script. - **Decided 2026-09-05: iris over Masonry**, by Iris, from the host-GPU numbers in I5's box and E1/E2's findings. See the Recommendation's item 3 and `DECISIONS.md`. Next: the remaining screens and the app on iris — @@ -3201,6 +3224,116 @@ silently on real hardware. `e2a1fad`'s own message. `docs/DECISIONS.md`'s DEFERRED item is updated with this section's host-GPU table below. + **Touch-scroll dropout root-caused, 2026-09-05.** Diagnosed as + instructed: temporary `log::info!` tracing on every touch event + reaching `IrisViewPeer::on_touch_event` (`iris/src/android/view.rs`), + every `DragArbiter` state transition (`press_start`/`update`/ + `release`, `iris/src/sense.rs`), and every `Selection::drag` + dispatch (`iris/transcript-ui/src/selection.rs`) -- all removed once + the cause was confirmed, per AGENTS.md's "keep the build clean." + Reproduced with `app/iris-scroll.sh` against a real sandbox session + (30 sent markdown messages, `EMU_GPU` unset / `-gpu host`, + `--features transcript-screen,force-gles`, release build, same + recipe as this box's own "-gpu host" pass above). + + *The trace.* Of 24 swipes in one run, 5 produced zero `render()` + calls each -- one at the very start of the run, four consecutive + later (swipes 22-25) -- exactly the "several consecutive swipes + produce nothing, an identical retry then works" shape from the + earlier pass's report. Correlating the three log streams by + timestamp: every one of those 5 swipes delivered a normal + `Down`/`Move`×N/`Up` sequence to `on_touch_event` (touch delivery + was never the problem), but `Selection::drag` never once saw + `PressStart` for the whole gesture -- only `Pressing`, starting from + the very first `Move`. `DragArbiter::update`'s `Idle` arm answers + every such frame with `Undecided` and never transitions state (there + is no way for pure state to tell "no press is happening" from "a + press is happening but I missed its start"), so the arbiter sat in + `Idle` from the gesture's first frame to its last, `release()` on + `Up` its only state change (`Idle` -> `Idle`, a no-op). The row this + landed on registered its `PressStart` correctly on a *different* + point in the very next successful swipe at the identical screen + coordinate -- confirming the miss is about *where the content + happens to be under that pixel when `ACTION_DOWN` fires*, not about + timing or a coalesced event. + + *Why `ACTION_DOWN` misses a row's sensor.* Each row's `CursorSense` + handler is registered only on its `TextEdit` field + (`transcript-ui/src/row.rs`'s `build_text_row`), not on the row's + `.pad(10)` margin, the `.gap(4)` between the sender-name header and + the field, or the header itself (`Span::empty`/a plain `wtext` with + no handler). A real touch's down-point is wherever the finger + actually is, with no reason to prefer text over padding, and the + list has no sensor of its own to fall back to (`iris::widget::list` + registers none) -- pan is reachable *only* through a row's own + arbiter. So roughly one in five swipes in this run started on a + pixel no sensor covered. + + *The fix.* `iris::sense::DragArbiter` gains `pub fn is_idle(&self)`, + documented as the recovery signal: a caller that gets a `Pressing` + frame while the arbiter reports `is_idle()` knows the button is + genuinely down (that is what `Pressing` means) with no matching + `press_start` on record, which can only mean it was missed. + `Selection::drag`'s match gains one arm, checked after `PressStart`/ + `PressEnd` and before the ordinary `_ => update(...)` case: `_ if + self.arbiter.is_idle()` starts the press right there instead of + where it was missed, using whatever `already_selected` holds at + that later frame (the best available answer -- the true value at + the actual `ACTION_DOWN` is unrecoverable once missed). This is + the caller's fix, not the arbiter's, because only the caller knows + what `already_selected` should be; the arbiter's own slop/long-press + logic was correct throughout and needed no change. + + *Tests.* Four new, all passing on the fix and the first three failing + without it: `sense.rs`'s `drag_arbiter_tests:: + is_idle_reports_a_press_that_was_never_started`, + `::update_on_an_idle_arbiter_stays_undecided_forever_without_recovery` + (documents the failure mode itself), `:: + a_caller_can_recover_a_missed_press_start_via_is_idle` (the pure-state + half); and `transcript-ui/src/selection.rs`'s `tests:: + a_missed_press_start_recovers_on_the_next_pressing_frame`, which + drives `Selection::drag` directly with only `Pressing` frames (no + `PressStart` ever sent) and asserts the arbiter is no longer idle + afterward -- this one fails on the pre-fix code (`is_idle()` stays + true forever, matching the real trace). + + **Not completed this pass, and why.** The task asked for + `iris-scroll.sh` run three times clean and a re-taken host-GPU + `FrameReport` row. Partway through that verification, this + checkout's shared emulator (`ai-app-2`, per-checkout per AGENTS.md) + turned out to be concurrently in use by another session actively + working the P0 phone-benchmark item added to this same file earlier + today: `adb shell dumpsys activity processes` showed + `com.example.aiapp`/`com.example.aiapp.bench` processes running + alongside `dev.iris.android.demo`, window focus was observed to have + moved to the Compose app mid-test, and the sandbox server's own log + showed a fresh `start` (not `keep`) at 00:55 that wiped this pass's + 30-message test session and replaced it with the peer's own + `bench-check` session -- confirmed by `ui-sandbox.sh api /sessions` + returning "no session" for the id this pass had been sending to. + Rather than disrupt that session's work (deleting its session, + restarting its server, or fighting over emulator focus), this pass + stopped chasing a clean aggregate number once the cause was + confirmed external. What *is* verified is the fix itself, from + direct traces taken before the interference began (above) plus two + manual, shorter `ui-trace` swipe sequences (not the full script) that + each showed full, healthy per-swipe `render()`/`selection::drag` + coverage with the fix in place. The FrameReport row in this box's + own table above is therefore **not re-taken this pass** -- a future + pass should re-run `iris-scroll.sh` three times and retake it once + the emulator is free, per AGENTS.md's "ask before/tell peers" and + "coordinate with peer agents" guidance rather than contending for it. + Also correctly ruled out, not left ambiguous: a hypothesis raised + mid-pass that `iris::widget::list::List::scroll`'s deliberately + unclamped anchor (its own module doc, "no overscroll clamping ... + leaves a gap rather than rubber-banding back") could itself explain + a run of consecutive failed swipes once enough net drift + accumulates -- plausible in isolation, but the run where it seemed + to reproduce is exactly the run now attributed to the peer + session's interference (same timestamps), so it was not + independently confirmed and is recorded here as ruled out for now + rather than as a second bug. + ## The port, in order (decided 2026-09-05) Iris decided iris over Masonry (`DECISIONS.md`). This is the ordered plan @@ -3271,6 +3404,89 @@ device. margin of Compose on p50, p99 and CPU time, no crash, no stutter she can see. Fail stops the port. + **Compose half: done, 2026-09-05.** `app/androidApp`'s `bench` build + type, `app/bench-fixture/` (generator + generated `assets/`), + `BenchFixture.kt`/`BenchNetwork.kt` (an in-process fake backend: a + `URLStreamHandlerFactory` installed only in `FIXTURE_MODE` answers + `https://bench.fixture.invalid:1/...` from an in-memory event log + instead of opening a socket, so `TranscriptSource`, `EventStream`, + the fold and the paging are the *real* ones, unmodified), and + `BenchRun.kt` (the scripted scroll-and-stream, driven against the + real `LazyListState`) are all in. "Run benchmark" sits beside "Copy" + in the session settings dialog, bench-build only + (`SessionSettingsDialog`'s `onRunBenchmark`). `./build-apk.sh bench` + works, produces a universal APK (no ABI splits in this project, so + arm64-v8a is included alongside the others — confirmed with `aapt2 + dump badging`), signed with the same release key, own application id + `com.example.aiapp.bench`, own label "AI Sessions bench" via a + build-type `resValue` overriding `@string/app_name`. + + Checks all clean: `ktfmtFormat`, `compileDebugKotlin`, + `compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues + found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` has one + hit, pre-existing and unrelated — a comment in `bench-lib.sh` + recounting the 2026-09-03 incident that made that grep a rule, not a + literal `tap` call. + + **Emulator smoke run, 2026-09-05** (this checkout's AVD, + `ui-trace` tap-by-label throughout — `tap 'Session settings'` then + `tap 'Run benchmark'`, report read back over `adb logcat`): + + ai-app render report + device: sdk_gphone64_x86_64 (Google), Android 16 + build: release + + transcript: + 28 events, 26 rows, 58 units loaded + viewport 1536px, 2 units visible + on screen: the list's own 0px, AssistantMsg 18732px + 0 tool calls and 0 groups open + + frames: + 1361 frames over 38.1s at 60Hz (16.7ms budget) + late: 1353 (99.4%) + total p50 27.8ms p90 37.7ms p99 50.1ms + gpu p50 18.9ms p90 28.9ms p99 31.5ms + + where the draw phase went: + draw phase 3.12ms per frame, of which: + the transcript: 0.33ms (measure 0.18, place 0.14, record 0.00) + everything else: 2.79ms (89%) + + bench: + scroll: 6 cycles (24 swipes), streamed 400/400 fixture events + process CPU time over this run: 23005ms + peak RSS: 209348kB + battery current: mean 900000µA over 39 samples (min 900000, max 900000) + + Read this as "the harness runs end to end and produces every field + P0 asked for," not as a phone number: it is software-rendered + emulator rasterisation (this-machine-android's skill — the stock + Settings app scrolls worse on the same device), and the battery + current is a fixed 900mA on every sample, which is the emulator's + mocked charger reporting a constant rather than a real battery — + expect that field to read "unavailable" or a real varying number + only on Iris's own phone. The ordinary debug build was rebuilt and + driven with `./transcript-bench.sh` against `ui-sandbox.sh` alongside + this and produced its usual report with no `bench:` section, so nothing + changed for it. + + `~/host/bench/compose-bench-arm64.apk` (9.7M) and + `~/host/bench/README.md` are written, with a heading left for the + iris half. **Known interaction**: the bench build keeps the same + `aiapp://enroll` intent filter as the ordinary app (it never uses + it), so with both installed, driving enrollment through a raw `am + start -d aiapp://...` intent (not the in-app QR scanner, which is + the primary path and calls straight into the matched activity) opens + Android's "Open with" chooser between the two. Cosmetic — the real + enrollment path is unaffected — and left as is rather than pulling + the intent-filter out of the bench manifest via source-set merging, + which was more diff than the problem was worth. + + **Not done this pass**: the iris half (a separate agent's scope — + this session was told not to touch `iris/`), and anything past the + emulator — the actual on-phone runs and Iris's pass/fail call. + - [ ] **P1 — session screen parity.** History paging backward (with the page-boundary healing `client-core` does not have yet, below), `TranscriptSource`-backed cache/server stitching, jump-to-latest,