docs: record the streaming-rebuild fix, its numbers, and the new scripts

RUST.md's P0 box gets the fix, the before/after streaming-phase numbers
(with their caveats), the build-apk.sh/run-bench.sh scripts, and what the
dropout-fix pass's three remaining verifications are blocked on (the
sandbox ai-server currently fails to build, unrelated to this change).
IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply
API entries. AGENTS.md's rigs section gets one sentence on each script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 22:14:35 -04:00
1 parent 5655fa8093
commit 46d3a6fd41
3 files changed
+199 -16

No files matched your search

+7
View File
@@ -261,6 +261,13 @@ Each exists because something was invisible without it.
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
checkout's own emulator, taps "Run benchmark" by label, and prints the
report -- written so the P0 build/install/tap/read-report cycle stops
being retyped by hand each time (docs/RUST.md's P0 box).
### Driving the UI
+47
View File
@@ -437,3 +437,50 @@ with a number instead of a guess (RUST.md's I5 box).
blocked handing the frame to the driver," not a confirmed GPU-completion
time. Enough to separate "iris is slow building the frame" from "iris is
slow handing it off," not enough to claim an exact GPU budget.
## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply`
Fixes the "every client refolds and rebuilds the whole widget tree per
streamed event" cost RUST.md's P0 box measured (20 events/second against a
~3,200-row transcript). Two small additions to `iris::widget::List`
(`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s
`TranscriptScreen`.
- **`List::replace_back(row: ListRow) -> Option<ListRow>`**: swaps the
*last* row's widget for a new one without moving it — same slot index,
so an anchor already pinned there (in particular a list flush with its
own end) stays pinned, and a `List` scrolled elsewhere is untouched.
`None` if the list is empty. `RowKey` may differ between the old and new
row; only `heights`/`extents` care, and both are invalidated for the
evicted key the same way `pop_back` already does.
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
state (`more_before`/`more_after` untouched — a caller that wants those
cleared too calls `set_more_before(None)`/`set_more_after(None)` itself).
The fallback path for a change that touches more than the tail.
- **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**:
the incremental alternative to rebuilding the whole screen from
`transcript_ui::build_tree` on every folded event. Diffs the two
`group_tool_runs` outputs and picks the cheapest update: nothing changed
(no-op), a pure append (`push_row`, unchanged cost), or — the common
streaming case, a delta into a still-open assistant message — a rebuild
of just the one changed row via `List::replace_back`, with any further
new rows appended after it. A row changing *before* the tail (only
`group_tool_runs` retroactively grouping tool calls into a run does
this) falls back to `List::clear` plus a full rebuild, counted in
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs`
and `desktop-app/app.rs` all call this now instead of rebuilding on every
event; only the opening page (and `apply`'s own fallback) still calls
`build_tree`.
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
`Vec<SpanStyle>` in one call, needed because a streamed row's markdown
re-renders to both a new string and a new span list on every delta and
the two have to land together — a stale span list drawn against new
text can point past its end. `set()` itself is unchanged (still clears
spans to none, as before).
Measured on this checkout's emulator (`iris/android-app/run-bench.sh`,
release, x86_64, `force-gles`): worst-frame and p99 during the streaming
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
~101130ms/~76103ms across three runs (this fix) — see RUST.md's P0 box
for the full numbers and the comparison's caveats (different AVD
instances, not a controlled A/B on identical hardware state).
+145 -16
View File
@@ -36,6 +36,20 @@ session spending an afternoon on them again.
## Where things stand (2026-09-05)
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
longer costs a full rebuild" subsection): `iris::widget::List::
replace_back`/`clear` plus `transcript_ui::TranscriptScreen::apply`
replace the "refold + rebuild the whole ~3,200-row tree per event" path
in all three clients. Worst/p99 frame time in the streaming phase
dropped roughly 3x on this checkout's emulator (see the box for the
exact numbers and their caveats). Blocked and not done this same pass:
the three `iris-scroll.sh` runs, the host-GPU `FrameReport` retake, and
the `EMU_GPU=software` cold boot the dropout-fix pass left open --
`app/ui-sandbox.sh`'s `ai-server` currently fails to build
(`event_model::Event::LimitReached` missing, unrelated to this pass's
diff). Two new scripts, `iris/android-app/build-apk.sh` and
`iris/android-app/run-bench.sh`, now do the build/install/tap/read-report
cycle that used to be typed out by hand each time.
- **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
@@ -1515,20 +1529,23 @@ accepted.
the bare REST fetch (no live stream yet) looked perfect on its own,
and only *resuming* a stream after it exposed the seam.
**Deliberately left simple, and why** (`app.rs`'s module doc has the
full account): every incoming SSE event refolds the session's whole
item list and rebuilds the entire right-hand widget tree from
scratch, rather than reaching for `TranscriptScreen::push_row`'s
incremental append -- `push_row` can only add a new row, and a
streaming reply is exactly a row whose text keeps changing after it
first appears. Fine at the size a desktop session's conversation
is; wrong for a long, fast-streaming one, and the real fix needs
`transcript-ui` to expose updating a row already on screen, which it
does not yet. The composer's in-progress text is saved and restored
across a rebuild so a reply streaming in while the reader is typing
a followup doesn't erase it. No history paging (I3's job, reused
as-is if this becomes permanent) and no scroll-position preservation
across a rebuild -- both named rather than silently missing.
**Deliberately left simple at the time, later fixed** (`app.rs`'s
module doc has the full account): every incoming SSE event used to
refold the session's whole item list and rebuild the entire
right-hand widget tree from scratch on every event, rather than
reaching for `TranscriptScreen::push_row`'s incremental append --
`push_row` could only add a new row, and a streaming reply is
exactly a row whose text keeps changing after it first appears.
Fine at the size a desktop session's conversation is; wrong for a
long, fast-streaming one -- fixed below (this same box's "Streaming
no longer costs a full rebuild" entry) by giving `transcript-ui` a
`TranscriptScreen::apply` that updates a row already on screen
instead of rebuilding every row around it. `rebuild_transcript`
still runs the whole tree once, for a freshly loaded/selected
session and for `apply`'s own rare full-rebuild fallback. No history
paging (I3's job, reused as-is if this becomes permanent) and no
scroll-position preservation across a rebuild -- both still named
rather than silently missing, and neither depends on the fix below.
Background network I/O runs on plain `std::thread`s reporting back
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
@@ -3581,8 +3598,12 @@ device.
400 fixture events replay at 20/s through `fold_event` -- the same
fold path a live SSE frame takes in `transcript_client.rs`'s own
`apply_event` -- each one triggering `rebuild_transcript`'s full
`transcript_ui::build_tree` rebuild, same tradeoff as
`TranscriptClient`/`desktop-app`. A battery sampler runs
`transcript_ui::build_tree` rebuild at the time this box was
written, same tradeoff as `TranscriptClient`/`desktop-app`. **Fixed
2026-09-05, later the same day**: all three now call
`TranscriptScreen::apply` instead -- see this same section's
"Streaming no longer costs a full rebuild" entry below for the
before/after numbers. A battery sampler runs
concurrently on its own `tokio::spawn`d task (not through
`ctx.update`, since a JNI battery read needs no widget-tree access),
attaching whichever thread it runs on via a stored `JavaVM` --
@@ -3715,6 +3736,114 @@ device.
branch was using (`iris/core/src/render/mod.rs`,
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
**Streaming no longer costs a full rebuild, 2026-09-05.** The gap
named above and in E4/I5 (`push_row` can only append; a streaming
reply is a row that keeps *changing* after it appears) is closed:
`iris::widget::List` gained `replace_back` (swap the last row's
widget in place, same slot, so a pinned-to-newest list stays pinned
and an off-screen replace moves nothing on screen -- two new unit
tests, `replacing_the_last_row_stays_pinned_to_the_bottom` and
`replacing_the_last_row_out_of_view_does_not_move_visible_rows` in
`iris/src/widget/list.rs`) and `clear` (drop every row, the fallback
path). `transcript_ui::TranscriptScreen::apply(rsc, old_items,
new_items)` diffs `group_tool_runs(old)`/`group_tool_runs(new)`
(pure bookkeeping, no widget built doing it) and picks the cheapest
update: unchanged (no-op), pure append (`push_row`, same as before),
the common streaming case -- only the last row's content changed --
rebuilds just that one row and swaps it in with `replace_back`, or
(rare: `group_tool_runs` regrouping a row before the tail) a full
`List::clear` rebuild, counted by `TranscriptScreen::take_rebuilds()`.
Seven new unit tests in `transcript-ui/src/lib.rs`'s `diff_tests`
cover all three cases directly against synthetic `Vec<FoldedRow>`s
(no widget/Rsc needed for the decision itself). `bench_client.rs`,
`transcript_client.rs` and `desktop-app/app.rs` all call `apply` now
instead of rebuilding per event; `IRIS.md`'s 2026-09-05 entry has
the full API account. `TextEditCtx` also gained `set_with_spans`
(`iris/src/widget/text/edit.rs`) -- `set()` plus a fresh span list
in one call, since a streamed row's re-rendered markdown needs both
to land together.
**Two new scripts, `iris/android-app/build-apk.sh` and
`iris/android-app/run-bench.sh`**, written this pass after repeating
the ANDROID_HOME/NDK-export/`cargo ndk`/Gradle-release/keystore/
apksigner incantation by hand one too many times. `build-apk.sh
[debug|release] [--abi arm64-v8a|x86_64] [--features "..."]` builds
the cdylib and the APK and verifies it (badging, and signing for a
release build); `run-bench.sh [--apk PATH]` installs on this
checkout's own emulator (`emu serial`), taps "Run benchmark" by
label (no coordinates), polls logcat for the report line, and prints
it. Used for everything below and for the redelivery at the end of
this box.
**Numbers, this checkout's AVD (`ai-app-2`), release, x86_64,
`force-gles`, via `run-bench.sh` -- three separate runs, same warm
AVD (not a fresh cold boot each time):**
frames=690 janky%=78.26 p50=26.9ms p90=60.3ms p99=103.4ms worst=130.1ms cpu_p50=7.4ms gpu_wait_p50=15.7ms
frames=691 janky%=84.95 p50=28.3ms p90=60.9ms p99=95.2ms worst=120.7ms cpu_p50=5.4ms gpu_wait_p50=18.2ms
frames=691 janky%=58.32 p50=18.9ms p90=40.3ms p99=75.6ms worst=101.4ms cpu_p50=3.5ms gpu_wait_p50=12.5ms
Against this same box's earlier iris-half reading (full rebuild per
event, a *different*, freshly-booted x86_64 AVD, host GPU):
`frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms
worst=369.3ms cpu_p50=0.4ms gpu_wait_p50=13.9ms`. The tail is what
moved: `worst` dropped from 369.3ms to 101130ms and `p99` from
284.5ms to 76103ms across all three post-fix runs, consistent with
removing the periodic full-tree-rebuild stall during the
20-events/second streaming phase. `p50`/`cpu_p50` are *not* a clean
comparison -- these three runs share one already-warm AVD instance
rather than each getting its own fresh cold boot the way the earlier
reading did, and `cpu_p50` in particular is noisy run to run (3.5 to
7.4ms here) in a way a controlled A/B would need to separate from
the code change itself. **What a future pass should do for a clean
number**: two fresh cold boots of the same AVD, one per build,
`run-bench.sh` on each, nothing else running.
**The three remaining I5 verifications the dropout-root-cause pass
left open (this box's "Touch-scroll dropout root-caused" subsection)
-- attempted this pass, blocked, not silently dropped.**
`app/iris-scroll.sh` needs `dev.iris.android.demo`'s plain
`transcript-screen` (non-`bench`) debug build, which needs a live
sandbox server (`app/ui-sandbox.sh`) to bake in at build time
(`build.rs`'s `AI_APP_TRANSCRIPT_HOST`/etc, skipped only under
`bench`). `./ui-sandbox.sh start` fails to build **the Rust
`ai-server` itself**, unrelated to anything in this box's diff:
`error[E0599]: no variant named 'LimitReached' found for enum
'event_model::Event'` in `server/src/session/mod.rs:2633`,
`server/src/session/claude/translate.rs:307`, and
`server/src/session/echo.rs:389` -- `event_model` and `server` have
drifted out of sync on this branch, most likely from concurrent
work elsewhere on `rustify` (this pass touched nothing under
`server/` or `event-model/`, confirmed by `git status`). Since
fixing that is a separate, unrelated repair (and risks colliding
with whatever pass is mid-edit there), this pass did not attempt
it. Consequently, not done this pass: the three `iris-scroll.sh`
runs, the host-GPU `FrameReport` table row retake, and the
`EMU_GPU=software` + `force-gles` cold-boot `FrameReport` (the
backend-isolation question) -- none of the three need the broken
server directly, but the first two need the same debug build the
server outage blocks, and by the time that was found there was not
enough of this pass left to justify a fresh `EMU_GPU=software` cold
boot (several minutes) for the third alone without also covering the
other two on the same session. A future pass: fix or wait out the
`server`/`event_model` drift, rebuild `dev.iris.android.demo` with
plain `transcript-screen` via `./build-apk.sh debug --abi x86_64
--features "transcript-screen force-gles"`, then run
`app/iris-scroll.sh` three times and retake the host-GPU row, and
separately cold-boot with `EMU_GPU=software` for the third.
**Redelivered, 2026-09-05.** `./build-apk.sh release --abi
arm64-v8a` (arm64-only jniLibs; an earlier step in this same pass
had left an x86_64 slice in there from the emulator testing above,
removed before this build so the delivered APK matches P0's
original arm64-only shape) -- `aapt2 dump badging` confirms
`native-code: 'arm64-v8a'` and the same
`dev.iris.android.demo.bench` id, `apksigner verify` the same
`CN=ai-app` cert as before. Copied over
`~/host/bench/iris-bench-arm64.apk`; `~/host/bench/README.md` gained
a one-line build-date/commit note so Iris can tell which build she
has.
- [ ] **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,