77cee6a8fa167e49fde410dc3eb83fb95998a18d
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
77cee6a8fa |
The bench fixture streams a reply shaped like a real one, and keeps the run-on as stress
Iris, on the two findings from the incremental-text investigation: "let's switch to new lines for the test, and also let's keep the single line around for stress + could be something to try to optimize later." The streamed tail now takes a blank line every 4-12 deltas, so it is 53 markdown blocks with a longest of 502 characters instead of one block of 14,888 -- against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant messages. Layer 1's streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to p50 2.20 / p90 5.90 / worst 8.78. The run-on message is kept as the first two backlog events, 14,824 characters in one block, just under text_cap's 16 KiB so it draws in full. The *streaming* pathology stays in frame_profile.rs rather than the fixture: it needs a growing block, and iterating on it there costs a second instead of a two-minute phone run. Adding it is purely additive -- the random state is saved and restored around those two events, so every other backlog event is byte-identical. That is not tidiness: the first attempt shifted the backlog and broke `a_long_press_and_drag_selects_text`, which replays a real recording at (300, 1000) and needs the content it was recorded against to still be there. BACKLOG_COUNT is 3202 now, in generate.py, fixture.rs and BenchFixture.kt, which split the file by line index. And the answer to Iris's question, which the code already had: the newest message does *not* cap. `build_row`'s `cap` is false for the live tail because a row that grew while capped would appear to stop growing, and a reply growing past the cap is never caught either since it grows through apply_delta. So a streamed block's shaping cost has no ceiling -- ~29ms per delta at 50k characters, ~58ms at 100k. Recorded but not chased: the emulator's `stream: build p50` did not move (10.4 -> 10.5ms) while layer 1's frame nearly halved, so most of a streaming frame on a GPU path is the whole-arena primitive re-upload layer 1 never performs -- 11,568 primitives rewritten per delta, with the fling phase as the control at 0.4ms for the same primitives moved through move_offsets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
43a3a345e4 |
Incremental text: parley cannot, the app already does it, and the 9.5ms is the fixture
Iris asked to look into incremental text rendering, hoping parley supported it. It does not, by design: a `Layout` re-linebreaks and re-aligns freely but "if the text content or the styles applied to that content change then a new `Layout` must be created", its LRU cache holds harfrust's per-font shaper data rather than shaped runs, and its own `PlainEditor` rebuilds the whole layout from the whole buffer on every keystroke. The app already does what incremental layout would buy: `RowBlocks:: apply_delta` keeps one `TextEdit` per markdown block and re-shapes only the one a delta landed in. Re-splitting the markdown to find it is 18µs at 18,000 characters; comparing the blocks is 470ns. What is left is one `TextBuffer::shape` of that block, linear in its length at ~0.23ms per 1,000 characters here -- and the bench fixture's streamed message is 14,888 characters in a *single* block, a run-on paragraph with no blank line in it, so every delta reshapes all of it. That is 3.5ms of the measured 3.86ms frame. Real replies are not that: across 7,706 top-level blocks from 3,675 real assistant messages on this machine (lengths only, no content copied anywhere), p50 147 characters, p90 449, p99 836, largest 1,580, nothing above 4,000; code fences p50 126, largest 589. At those sizes a reshape is 48µs to 372µs here, roughly 0.12-0.93ms on the phone -- inside a 120Hz budget with no incremental anything. So the recommendation is not to build it, and to give the fixture's streamed message the paragraph structure a real reply has instead. Three runs added to `frame_profile.rs` so none of this is re-derived: what reshaping a growing message costs (including at the sizes real replies reach), where a delta's cost is, and what the fixture actually streams. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9bf714fa2e |
The frame report says what it measured: idle is not stutter, waiting is not late
Iris's phone came back "now THAT is smooth", and reading that run against the bench's own timings found three things the report was getting wrong -- two of them shipped yesterday in the fix for the last three. `missed vsyncs` counted idleness. Every gap between frames was treated as cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms rests between flings, 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms stream gaps -- each within a few percent of the arithmetic. A gap now measures anything only if the frame before it had asked for another one. `late` counted the swapchain wait as cost. A well-paced loop spends each frame blocked in the acquire, so its total sits at exactly one refresh period and every frame lands on the budget boundary -- 0.4ms of work and 5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`. And the refresh rate is the larger of what the platform claims and what the run sustained, because each can only be wrong one way. `Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in 33.1s, since a phone that varies its rate answers with whatever mode it is in when asked. The first attempt at measuring it instead took the fastest tenth of the gaps and reported 88Hz for this repo's 60Hz emulator, whose app manages 54 -- a budget no frame there could meet, invented out of the app's best moments, and caught only by running the corrected report on the emulator before shipping it. A sustained rate is a floor and cannot do that. Both are printed when they disagree. Also corrected in the docs: "103fps on a 120Hz screen" divided the fling phase by its whole duration, rests included. Both runs sustained ~120.3fps through the motion, so the callback ordering was never costing frames -- what changed is the clock, which moves no frame count at all, which is exactly why nothing in a report could show it. `fling_profile.rs` is `frame_profile.rs` and gained a stream run, which says where the frame time now is: folding an arriving event is 0.35ms and applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the phone. 401 events move the item count 652 -> 654, so nearly every one is a delta into the same row -- the cost is re-shaping one growing message, not `fold_event`'s per-event clone, which was the hypothesis and is what measuring it ruled out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
42d54eec95 |
Fling: the vsync clock, the frame ask, and a report that can say what it measured
Iris, from her phone: "some stuttering when flinging in particular. Harder to notice with my finger directly moving the scroll." Her fling phase was 103fps on a 120Hz screen at p50 6.3ms. Two of the four things found are corrections to the instrument, not the renderer. The swapchain acquire -- `get_current_texture`, which *blocks* until the compositor frees an image -- was inside the span the report called iris's CPU work, so a fling comfortably ahead of the display read as milliseconds of being slow. A frame is now three measured parts (`FrameParts`: build, acquire, submit), per phase as well as per run. And nothing could say a frame was never *produced*: `late` counts frames that cost too much, which a reader does not see, while a frame that never happens leaves the last one up for two refreshes, which is the stutter. `PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980 vsyncs. The other two are the frame loop. `Choreographer.postFrameCallback` schedules for the next vsync after the call, and iris asked at the *end* of the callback -- so any frame whose work ran past the boundary registered too late and got the vsync after, one frame over budget silently costing a second. It is asked for immediately after `tick_animations` now, on both backends. And the fling was advanced on `Instant::now()` rather than the vsync `do_frame` carries: frames are presented on an even cadence whatever clock computes them, so sampling the spline at "whenever the callback ran" moves the content unevenly with no frame late enough to appear in any report -- and a drag never had it, which is the asymmetry Iris described. `PointerClock` is `DeviceClock` and the view keeps one, anchored by whichever of a touch or a frame comes first, so a fling is advanced on the clock its velocity was measured on. `opt-level` for the Android release build goes from "s" to 3. The table in RUST.md picked "s" on bytes alone; over the same warm fling eight times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK. `app-rust/tests/fling_profile.rs` is the rig that established what a fling frame actually costs and is kept for next time (Iris: "please keep the profiling rig around for future use"): only one frame in six lays anything out, and the multi-millisecond spikes are all first-pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ccfda6b8e |
Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make decisions when planning with agents rather than after they do things, and they're both too long for me to wanna read, + don't cover all the decisions I'll wanna make about the code anyways. I'll just naturally run into things for now. Todo is important though." So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and AGENTS.md now says not to start another: raise a choice while planning it with her, otherwise decide it and put the reasoning at the code it governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them -- same artefact, same reasoning, and she did not name it, so its six decisions were folded into docs/SUBAGENTS.md rather than deleted. Deleting the logs left ~30 citations dangling in code comments and docs. Each states its reason inline and cited the file only for provenance, so they now read "decided 2026-09-07" or name the module doc that carries the reasoning. The root had six things that were not a program or a document. Moved, per "I only meant top level sh files": run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh -> scripts/ rigs/ -> scripts/rigs/ xtask/ -> scripts/xtask/ A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh, iris/*.sh and server/enroll-link.sh did not move. `target/` at the root is deleted and cannot come back: there was never a workspace there, and the 29 MB was only xtask's scratch space, now in scripts/xtask/target/. `cargo xtask apk` still runs from the repo root and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk` discovery pattern needs, and scripts/xtask/build would have been two. Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and the repo root is now eleven entries with no build output among them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
09778346a0 |
Prune the docs of work already done: 18,252 -> 7,567 lines
Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."
docs/RUST.md 8503 -> 905 the framework bake-off (options,
recommendation, twelve closed
experiment boxes) and two superseded
"where things stand" sections, out;
what the experiments settled kept as
one line each
docs/IRIS_TODO.md 1383 -> 229 fifty closed items and six
phone-report sections whose defects
are all fixed
docs/LAYOUT.md 1116 -> 829 the pre-implementation framing: the
old trait, the checklist, the
migration list, the pass conditions
docs/TEXTURES.md 496 -> 240 the prior-art survey, the proposal
and its review, all implemented
docs/REVIEW-*.md 673 -> 0 two completed review passes; the two
findings left open on purpose (mask
hit-testing, the phone's font set)
moved into RUST.md
What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.
Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.
Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6d5a231f5c |
iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |