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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 21:04:07 -04:00
1 parent e6c884a0cd
commit 68b48cfd14
3 files changed
+285 -1

No files matched your search

+26 -1
View File
@@ -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
+43
View File
@@ -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
+216
View File
@@ -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,