diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 46c6a3a..05cab4c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,1205 +1,194 @@ # Handoff -Where the work in flight stands, for a session picking it up cold. Not a -decisions log; when a piece of work finishes, replace this with the next one -rather than accumulating both. The subject now is pulling Iris out of ai-app -into a standalone framework. +Where the work in flight stands for a session picking it up cold. Keep current +invariants, measurements, and failed hypotheses here; this is not a decisions +log. ## Where things stand -Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are -in. +Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 +`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local +head is **`29c7881`**, forty-five commits, pushed to the fork. No PR reviews +were present when checked on 2026-09-15; the verification summary is posted on +the PR. -**#18 `split/18-position-chain`** is open: worktree `/home/bob/repos/iris-pr18`, -head `691e3eb`, forty-four commits. 70 tests pass and 8 are ignored, fmt and -clippy are clean, the 100-seed sweep passes, and every reference render is -byte-identical to `upstream/main` -- `tabs`, `view`, `minimal` and `text` at -1920x1200, `tabs` cold at 900x1200, `tabs` resized from 1920x1200 down to it, -and `tabs` with the image replay. `/home/bob/repos/ai-app-2` is on `rustify`, -worktree clean. +The current head completes LAYOUT.md §2's position chain and the requested +`leftover` behavior. A child whose length is only `leftover` is not drawn when +nothing is left. A child that also asks for pixels or a relative fraction keeps +that part and overflows as before. -It is LAYOUT.md §2's position chain, generalised to boxes, plus what the -fuzzers found on the way: `SetSize` measuring in its declared length -(`c596bf1`), text re-breaking across the interval a break holds for -(`9913194`), a dirty widget measured where its parent asked rather than in a -box its own answer decided (`02ff8c7`), and `abs()` restored in the rect -shader (`f61e893`), which a rename had taken with it so that every window -failed shader validation while `cargo test` stayed green. The last five -commits are 2026-09-15: hairline tests (`4063635`), the shader composing a -position the way the CPU does (`169db7f`), a declared length resolved where -the widget is drawn (`de9ddc0`), the glyph write inlined (`9644971`), and -`rest` renamed to `leftover` (`691e3eb`). +The last commit replaces the parallel resize rules with one retained-layout +contract, `Holds`: the interval of box lengths for which a widget's drawing and +reported size stay valid. Reading `Painter::px_len` or `px_size` narrows the +interval to the length read; `Painter::holds` lets the widget widen it. Parent +validity is the intersection of the ranges its children induce. This contract +is trusted. A widget that declares an incorrect range is a defective widget; +Iris does not add defensive work to recover optimizations from a false +declaration. -**An earlier reading that `tabs` differed from `upstream/main` by 1,283 pixels -is withdrawn.** It does not reproduce at any commit reachable now; a target -directory shared between two checkouts is the trap the render section warns -about, and both sides render the same bytes twice. +The implementation also fixes three counterexamples found while finishing the +rewrite: -## Next: a `leftover` with nothing left over should not draw +- An asked-but-undrawn size dependency must name the widget that asked as its + parent. Using the asker's parent skipped a reader and made generated seed 10 + settle differently warm and cold. +- `Scroll` must return the answer from the first box it asked about, whether + that answer came from a retained length or a fresh measurement. Returning + the final placed answer only on the retained path advanced one fixed-point + iteration and broke seed 86. +- A widget retains the layer it was entered on, not the last child layer its + painter visited. The old value drifted on local redraw and put a redrawn tab + background above its retained text. -This is the piece to pick up, and it is not a small one. The owner asked for -it on 2026-09-15: +`core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle +pin those rules. Seeds 10 and 86 are now in the ordinary generated set. -> it shouldn't be a clamp, if it's 0 wide for rest then it shouldn't draw in -> the first place. rel and abs should continue to overflow like before +## Verification at `29c7881` -(both quotes predate the rename, so "rest" in them is `leftover`.) +- `cargo fmt --all --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace`: 74 passed, 8 ignored +- `cargo test --release --test generated -- --ignored + a_long_run_of_seeds_agrees`: 100 seeds passed in 65.63 s +- Reference renders against `/home/bob/repos/iris-main-cmp` at `ca2b4b2`: + `tabs`, `view`, `minimal`, and `text` at 1920x1200; `tabs` cold at + 900x1200; live resize from 1920x1200 to 900x1200; and the tab interaction + before and after replay. Every comparison had zero differing pixels. The + live-resize image is also byte-identical to the cold 900x1200 image. -and, on what "0 wide" means: +The final pre-submit review was run in four passes. It caught the retained +layer defect above, corrected validity-range inversion for negative relative +extents, and removed an impossible-state `unwrap` from `Scroll`. -> it looks like you're skipping a child if it has any rest and there is no -> rest space. That is not correct, it should only skip if the child *only* has -> rest space. +### Performance -So: a child whose length is nothing but `leftover`, in a span with nothing left -over, is not drawn at all. One that also asked for pixels or a fraction keeps -those and overflows forward, which is what a span does today when nothing in it -is elastic -- three 60 px children in a 100 px box land at 0..60, 60..120 and -120..180. What is wrong today is the other case: put a `leftover` child between -each pair and their lengths come out at -40 px, so the children walk *backwards* -to 0..60, 20..80 and 40..100, which is how a squeezed layout ends up with -things drawn over each other. +The retained rewrite was compared with #18's previous head `691e3eb` using the +same release `layout_diagnostics` fixture, seed 1, depth 8, 500 frames, 130 +dirty widgets: -**Half of it is written and works.** `Painter::undraw` takes back a child that -was drawn only to measure it, which a span needs because its first pass draws -any child whose length it cannot get from `size_hint`: +| phase | `691e3eb` | `29c7881` | +| --- | ---: | ---: | +| many | 25.17M instructions/frame | 6.14M | +| resize | 16.08M | 8.39M | +| scroll | 0.720M | 0.691M | +| repaint | 0.720M | 0.689M | +| size | 0.730M | 0.698M | -```rust -/// Takes back a child that was drawn only to find out how long it is: its -/// drawing is dropped and it is not one of this widget's children this -/// frame, as though it had never been asked for. -pub fn undraw(&mut self, id: &StrongWidget) { - self.children.retain(|child| *child != id.id()); - self.size_deps.retain(|child| *child != id.id()); - self.state.remove_rec(id.id(), self.rsc); -} -``` +The final `29c7881` measurements were 3,070,693,965 instructions for 500 +`many` frames and 4,192,804,214 for 500 `resize` frames. Re-run before quoting +tighter figures. -`remove_rec` has to become `pub(super)` for it. In `Span::draw`: +## Retained-layout invariants -```rust -let any_leftover = total.leftover > 0.0 - && (1.0 - total.rel) * painter.px_len(axis) - total.px > 0.0; -... - if len.leftover > 0.0 && !any_leftover && len.px == 0.0 && len.rel == 0.0 { - painter.undraw(child); - continue; - } - ... - if len.leftover > 0.0 && any_leftover { // the arithmetic already there -``` +- A slot holds a whole `UiRegion` in the coordinates of the slot it names. + `UiRegion::FULL` is the identity. Slots are opt-in: `Painter::place` gives a + child a slot; `widget` and `widget_within` share the nearest ancestor's. +- A widget's `ActiveData::region` is where it was placed in its parent's slot. + A placed widget draws in `FULL`; its box lives in its slot. This keeps chains + at positioning-container depth rather than full widget-tree depth. +- The first box a parent asks about is the offer. A later box chosen from the + child's answer is a placement, not another independent answer. Dirty widgets + are re-asked at the offer and only then placed again. +- A retained drawing can be reused only when its `Holds` interval contains the + new pixel box on both axes, its parent slot is unchanged, and the widget is + clean. A moved unslotted widget must draw again because it has no slot to + rewrite. +- `Painter` records size-dependency edges only when a parent reads a child's + size or hint. An undrawn measured child remains recorded so a later change + reaches the parent that decided whether to draw it. +- Dirty widgets settle deepest-first. `dirty_size_under` prevents a reader + from taking a retained answer while something below that answer is still + dirty; the walk is an optimization against laying out twice, not a second + validity mechanism. +- Declared non-`leftover` lengths are resolved by the widget's parent where the + widget is drawn. A declared-length change therefore redraws the parent. +- Pixel comparison uses a 0.05 physical-pixel tolerance, against the last + actual layout. Repeated subpixel changes accumulate and eventually redraw. +- Text shaping is retained separately from line breaking. A greedy line break + remains valid from its longest produced line through the width at which it + was made, and `TextView` reports that interval through `Painter::holds`. +- `Span`'s decision to distribute `leftover` is a pixel question. Its validity + interval is split at the length where fixed parts fill the box; pure + `leftover` children are undrawn on the no-space side. +- `Scroll` reports its content's first measured size. Its drawing can survive + container-length changes only over the interval in which clamping and its + current offset do not change. -**The half that does not work is knowing whether anything is left over.** It -needs pixels -- `rel(0.5)` beside 300 px is full at 600 and overfull at 400, -and the difference is not expressible in fractions -- so a span's drawing -becomes a function of its own pixel length, and a span's box can be decided -from what it reports. Four ways of asking were measured on 2026-09-15 and none -of them holds: +## Rigs and reproduction -| what the span asks | what happens | -| --- | --- | -| its own box (`px_len`), `Span` keeping `OnResize::Scale` | seeds 8, 10 and 13 of `tests/generated.rs` diverge: a scaled drawing keeps an answer taken in another box | -| its own box, `Span` saying `Redraw` on its own axis when it holds a `leftover` child | the ordinary tests pass, the 100-seed sweep still fails at seed 10, and the `many` load goes from 12.59B to 23.57B instructions a frame | -| the box it was offered, threaded into `draw_inner` so it is the parent's current offer rather than the one on last frame's `ActiveData` | four generated cases diverge, worse than asking its own box | -| the box it was offered, plus a general rule that a drawing which read its box in pixels cannot be scaled along that axis (`on_resize(axis) == Scale && !size_box_inputs[axis]`) | two generated cases diverge | - -The failures all look the same from outside: warm draws a widget cold does not, -or the other way round. Seed 13 of `adding_and_removing_span_children` is the -clearest -- the span holds an identical region in both trees while -`ActiveData::px` says it drew against 63 px warm and 900 px cold, so the same -question was answered in two boxes. It is a `Span{dir:X-}` inside a `Scroll`, -which draws its child once to measure it and once in the content length. - -**The design question, which is the owner's.** A pixel-dependent draw is not -itself the problem: `Branch` in `iris::random` chooses which child to draw from -a measurement and the fuzzer is happy with it. What is missing is a way for -such an answer to *settle* when the box that decided it moves. That is the same -thing already parked for `OrthoSize::{Fill, Children}` -- "reads the span's own -box to rank candidates, which is this cycle with a second face" -- so whatever -shape it takes would take both off the shelf. Agree the shape before building -it; the four rows above are what a variation costs to find out. - -Reproducing takes: +Ordinary framework verification: ```sh -cargo test --workspace # seeds 8 and 13 show here -cargo test --release --test generated -- --ignored a_long_run_of_seeds -IRIS_PHASE=many IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_DIRTY=130 \ - perf stat -e instructions:u --ignored -``` - -Check for a review before starting anything, and read the newest `submitted_at` -rather than the first result: - -```sh -TOKEN=$(cat ~/.config/gitea/token) -N=18 -curl -s -H "Authorization: token $TOKEN" \ - https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews -curl -s -H "Authorization: token $TOKEN" \ - https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews//comments -curl -s -H "Authorization: token $TOKEN" \ - https://git.arirex.me/api/v1/repos/iris/iris/issues/$N/comments -``` - -My replies are ordinary issue comments on the same PR and say what each change -was for. - -## How a position resolves now - -Invariants, not history. Everything in `core/src/ui` rests on them. - -- **A slot holds a box, in the coordinates of the slot it names.** A primitive - instance and a mask each name one, and `prelude.wgsl` composes the chain with - `within`. A translation is the special case where the box has its parent's - relative extent. The identity is `UiRegion::FULL`, **not zero** -- a zeroed - entry is a box of no extent and collapses its subtree to a point, which - `MoveOffset`'s comment says beside the `Zeroable` that `Pod` requires. -- **A slot has to carry a whole box** rather than a scale and an offset: a - pixel-space affine map scales everything under it, including a child that - must keep its pixel length, and the `rel`/`abs` pair is exactly what - distinguishes the two. -- **Slots are opt-in.** `Painter::place` draws a child whose box its parent - decides and may decide again, and that child gets a slot; `widget` and - `widget_within` do not, and share the nearest ancestor's. `Span`, `Aligned` - and `Scroll` place. This is what keeps the chain 2-4 deep rather than full - tree depth, which is the difference between free and +42.6%. -- **A widget's region is held in the coordinates of the slot it draws in**, so - a placed widget draws against `UiRegion::FULL` and its box lives in its slot. - `ActiveData::region` is the box it was *placed in*, in its parent's slot - coordinates, and `ActiveData::parent_move` is the slot that is in; - `window_region` composes the one through the other, which is the walk the - shader does. -- **Nothing inverts a lerp.** `UiRegion::stretch`, `stretchable` and - `UiScalar::stretch` are gone. A box that changed length is written to its - slot and the descendants recompose against it, which also covers the case the - old guard refused outright: a fixed length has no fraction to recover, so a - 40-tall row could not be stretched on its other axis at all. -- **Reuse is decided on the box a widget drew against, in pixels** - (`ActiveData::px`). A region is a fraction of a slot's box, so an unchanged - region is *not* an unchanged box -- a child drawn at `FULL` of a slot that has - since halved compares equal to itself. This is the check everything else - rests on; do not weaken it back to comparing regions. -- **A size the parent learnt by drawing the child is an answer for that box - only.** `redraws_under` redraws a child whose size the widget read unless it - declares an exact `size_hint` for the changed axis -- the one case the parent - did not have to draw it to find out. The cost is that a size-reading - container gives up its reuse when its box changes length, which is every - span, so `OnResize::Scale` earns its keep on moves and on subtrees whose - sizes nobody read rather than on every stretch. -- **`redraws_under` is a question asked before reusing, never a marking.** - Marking descendants for redraw instead does not terminate: the mark escalates - to that descendant's size reader, which re-places the child, which marks it - again. Asking first and giving up the whole reuse adds no marks and stops. -- **A declared length is resolved where the widget is drawn, not inside it.** - `Painter` takes a child's `size_hint` in its own box before drawing it there, - which is what a fraction of a length means and is the identity for a caller - that already reserved the space; `SetSize::draw` keeps none of it. `leftover` - is deliberately not resolved there -- a part of what is left over is only a - length to the widget dividing one, so it passes up in the size the way it - already passes up out of a span. A declared length is therefore part of the - box its parent decided, so a change to one redraws the parent: the lengths - resolved into a box are kept in `ActiveData::declared` and compared there. - Assuming instead that any dirty widget which declares a length needs its - parent costs 17% of a frame that dirties 130 of 260 widgets and buys nothing. - Before this, `.width(rel(0.5))` in a 400-wide span drew its child 100 wide: - the span sized the box from the hint and `SetSize` took the fraction of it - again. `px` hid it, since 200 px of a 200 px box is all of it, and so did - `leftover`, which `apply_leftover` turns into the whole box. -- **A fixed pixel length keeps its pixel width through any chain.** Both edges - of such a part share their box's `rel`, and `within` is a function of that - `rel` alone, so the shader's `floor(rel * dim)` is the same for both edges - while the `px` parts still differ by exactly one. A hairline or a one-pixel - gap can therefore move by a pixel when a position falls the other side of the - `floor`; it cannot be widened or deleted by one. Only a length expressed as a - fraction can round away, and that was as true before the chain. Measured - 2026-09-15 by `tests/layout.rs`, which composes sixteen hairlines under four - levels of span and padding on `leftover(3)/leftover(7)/leftover(5)` weights and applies - the shader's own snapping at four output sizes. **A span short of room takes - it from its shares and never from a fixed length**, though a share that has - run out does not stop at zero: three 60 px children in a 100 px box overflow - forward to 0..60, 60..120 and 120..180, which is what the owner intends, - but putting a `leftover` child between each pair gives them -40 px and - walks the children *backwards* to 0..60, 20..80 and 40..100. Clamping a - share at zero is the fix, and is not in yet: the shares reach zero and - then go past it, so the fixed parts start overlapping each other -- at 400 - wide three marks sit at 132, 265 and 399, at 3 wide at 0, 1 and 2, and at 1 - wide two of them land on the same pixel. Overlapping is what hides a - separator in an over-constrained tree; collapsing one never happens. The same line - written as `rel(1.0 / 1920.0)` fails the test, but not through rounding: a - fraction is a fraction of *that widget's box*, so at the root of a 1920-wide - output it is exactly one pixel and two spans down, where the box is half the - output, it is half a pixel and floors to nothing. That is what `rel` means - rather than a defect, and it is the reason a hairline is written in `px`. -- **The walk stops where a length did not change.** A part of a box with no - relative extent on an axis is a fixed length held as offsets from that box's - start, and composing anything into it leaves no relative extent either -- so a - widget whose own box did not change length has no descendant whose box did. - An 80-wide child of a widened row is never asked. -- **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` - say `Scale`**; each places in fractions and offsets of its own box and none - reads its pixel length. `Scroll` and `MaxSize` read pixels and stay `Redraw`, - which is the general rule: `Scale` on an axis unless the draw reads the pixel - length of its box on that axis. The default stays `Redraw`. -- **`OnResize::Scale` keeps its name.** The owner rejected `Stretch` on - 2026-09-14: stretch has an opposite and scale does not, and the answer is per - axis, so the axis is already established where it is read. - -## Measured, so the next attempt is compared rather than argued - -| rig | what it says | -| --- | --- | -| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances. Translate slots: free to depth 8 (+5%), then ~3 us per level, +42.6% at 16 and +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level. A box slot (36 bytes) against a translate slot on the same binary: +0.6% at depth 1, +0.5% at 2, +0.8% at 4, then +9.6% at 8 and +32.2% at 64 -- free where opt-in slots put it, and dear only where the chain already was. | -| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. Five primitives per row; the regime that decides whether the chain is worth it is a transcript row of a few hundred glyphs, **so re-run it with 200 characters of text per row before concluding anything from it**. | -| `tests/draw_cost.rs` | What recording a frame costs on the CPU by layer count. Dispatch per list is 6 instructions, 0.1% of a frame at 256 and at 1024 layers. | - -A chain is irrelevant at an example's couple of hundred primitives; a -transcript's glyphs are tens of thousands, which is the regime `chain_cost` -measures. - -### How much of that work is necessary - -Measured 2026-09-14 on a random tree at seed 1, depth 7 -- 1061 widgets, 839 -of them drawn, 10,872 primitive instances -- against the same rig at `43ce8c7` -(the last commit before #16 sized a widget by drawing it) and at `f942385` -(#16 itself). The rig is a `Harness` load counting widget draws, text shapes -and instance writes per frame, plus the GPU pass through timestamp queries; -it lives in the scratch worktrees `/home/bob/repos/iris-size-{old,new}` and is -not in the repository, because the counters it reads are patched into -`iris-core`. - -| per frame | before #16 | #16 | #18 head | -| --- | --- | --- | --- | -| cold layout | 19.3 ms, 839 draws | 30.9 ms, 3317 | 23.6 ms, 2755 | -| repaint one leaf | 0.000 ms, 1 draw | 8.5 ms, 1683 | 6.5 ms, 1313 | -| scroll one scroller | 0.001 ms, 1 draw | 8.9 ms, 1683 | 6.5 ms, 1313 | -| resize the output | 3.2 ms, 839 draws | 31.8 ms, 6940 | 25.2 ms, 5512 | -| GPU pass | 0.129 ms | 0.114 ms | 0.115 ms | - -**The GPU is not the subject.** The pass is a tenth of a millisecond at every -revision and every phase; all of this is the CPU laying out. - -`a640c6c` and `84f589e` remove most of the CPU work without weakening the -retained-layout rules. Against the same seed-1/depth-7 load, widget draws are -now 2,117 cold, 1 for a leaf repaint, 11 for a scroll, and 3,139 for a resize -(from 2,755, 1,313, 1,313, and 5,512 respectively). Workspace tests pass; the -five reference renders, resize render, and image-tab replay are byte-identical -to the prior #18 head. - -Those draw counts explain mechanism, not total layout cost. Before #16, -measurement was a separate operation: the cold and resize rows each made 839 -draws plus 489 size queries, 314 of which hit the size cache. Current sizing is -a draw, so its draw count includes the provisional work and cannot be compared -directly with the old draw count. - -### Against the old code, on a tree both revisions build - -**The random generator is not a fixed load, so it cannot carry an old-versus-new -comparison.** Padding, scrolling and alignment grew into it during this work, so -`seed 1, depth 7` is 144 widgets today and was the 1061-widget tree the rows -above were measured on. Earlier statements here that the retained layout was -"about 1.6x the old 3.14 ms resize" compared a 260-widget depth-8 tree against -that 1061-widget depth-7 one, and are withdrawn. - -`tests/revision_cost.rs` is the load that does carry it: one hand-written tree --- 40 rows of `Dir::RIGHT` span holding a 40px rect and a `Dir::DOWN` span of -one wrapping and one short non-wrapping text, each its own random words -- in -source that compiles unchanged on `43ce8c7` and on #18. Drop it into an old -worktree and run it there; take the number from `perf stat -e instructions:u` -on the test binary rather than the clock. It also prints where the layout put -the paragraphs, which is the other half of the comparison, and `text_memory` -in the same file reports what the tree holds. - -**The old code is faster because it skips the question, not because it answers -it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong: - -- `SizeCtx::len_inner` keys its memo on the widget id alone. It stores the - constraint box the answer was computed under and never compares it, so the - first asker's box decides the answer for every later one -- 314 of those 489 - hits per frame. The frame-local answer cache rejected on seed 98 at least - compared the box; this does not. -- `Span::len_sum` measures every child against the container's whole axis - rather than the part left for it, which its own comment records as a - deliberate choice ("tempting to subtract the abs & rel from the ctx outer, - but that would create inconsistent sizing"). In the fixture above that shapes - a paragraph at 900 rather than the 860 left beside the rect, and it draws - 29 px past the edge of the output. - -So the amplification #18 pays is the cost of asking in the right box. That does -not excuse the size of it, and most of it turns out not to be layout at all. - -`480f0bc` retains that instrumentation behind the `layout-diagnostics` Cargo -feature; none of it is compiled into a normal Iris build. The ignored -`tests/layout_diagnostics.rs` rig selects `cold`, `repaint`, `size`, `scroll`, -or `resize` with `IRIS_PHASE`, plus seed, depth and frame count. Run it with the -feature for explanatory counters and inclusive phase timers, and without the -feature under `perf` for unperturbed CPU totals: - -```sh -IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=100 \ - cargo test --release --features layout-diagnostics \ - --test layout_diagnostics -- --ignored --nocapture - -IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=1000 \ - perf stat -e cycles:u,instructions:u cargo test --release \ - --test layout_diagnostics -- --ignored --nocapture -``` - -`82fa6c1` adds targeted tracing to the same feature. Call -`layout_diagnostics::trace_widget(id)` before a frame; `take().traces()` then -returns the selected widgets' ordered draw requests and pixel boxes, reuse -outcomes, placements, hints, size reads and reported sizes, and text widths. -The selection is a set, survives `take()`, and is removed with -`untrace_widget` or `clear_traced_widgets`. This replaces temporary -text-specific logging without imposing anything on normal builds. - -The first depth-8 resize run made the amplification concrete. A 260-widget -tree has 215 active widgets, but a resize averaged 1,395 widget draws, 913 -placement calls, 1,313 reads of drawn sizes and 34,844 primitive writes. Only -12 distinct text widgets rendered, yet they rendered and reshaped 282 times -per frame with no shape-cache hits. Text rendering accounted for 11.6 of 13.2 -ms, including 9.9 ms shaping and 1.7 ms placing glyphs. The hottest two text -widgets each drew 96 times below nested `Span`, `Aligned`, `Scroll`, `Pad`, and -`SetSize` ancestors. - -Tracing one of them showed that 96 was two multipliers, not 96 necessary -layout iterations. One traversal of the nested positioning chain shaped it 32 -times at 12 widths; three resize-dependent descendants then caused that whole -traversal to run three times through the same highest size reader. Resize -marking had queued each pixel reader independently, and each leaf discovered -and redrew the same reader path in turn. - -`82fa6c1` coalesces that frontier by marking every resize-dependent leaf and -its size-reader chain first, then settling the shallowest shared reader once. -Ordinary content and size changes remain deepest-first. On the same depth-8 -load, an actual resize now averages about 694 widget draws, 435 placement -calls, 649 drawn-size reads, 15,343 primitive writes, and 116 text shapes; the -hottest texts draw 32 times. Instrumented layout is about 5.5 ms instead of -13.2 ms. An uninstrumented 1,000-frame resize run measured a 5.61 ms median, -60.79B instructions total (60.8M/frame), and 23.85B cycles. This is about 58% -fewer instructions than the roughly 138M/frame #18 path before coalescing. -The remaining multiplier is within one constraint traversal. Re-sending the -existing output size also no longer starts a resize. - -`b1b3eca` records pixel reads and retained size inputs per axis. `Text` says it -reads only its offered width, while `Scroll` says it reads only its scrolling -axis. `Span`, `Aligned`, and `Scroll` ask for an exact hint or a retained child -length valid under the box they are about to offer, and draw to measure only -when neither exists. On the same depth-8 load, resize now averages 449 widget -draws, 296 placement calls, 485 drawn-size reads, 12,164 primitive writes, and -107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.95–5.24 ms -medians, 55.4B instructions total (55.4M/frame), and 21.5–21.6B cycles: about -9% fewer instructions than `82fa6c1`. - -Pixel comparisons now use a 0.05 physical-pixel tolerance. The comparison is -against the last actual layout, not the preceding resize event or retained -move, so repeated subpixel changes accumulate and eventually redraw. The -generated cold-layout oracle uses the same visual tolerance per coordinate. - -The generated tree now includes `Aligned` with every meaningful per-axis -alignment. That exposed two retained-layout ordering bugs which `84f589e` -fixes. The regular cold-layout equivalence suite and the ignored 100-seed -sweep pass. The latter previously stopped at seed 60 on `140.0` versus -`139.99996948242188`: exactly two `f32` ULPs from an equivalent composition -order, not a visible layout difference. The oracle now keeps draw presence -exact and allows at most 0.05 physical pixels per coordinate. Seed 98 is also -part of the ordinary suite because it catches stale retained size beneath an -aligned size-changing chain. - -The branches are invariants rather than widget exceptions: - -- A dirty widget draws locally first only while its retained box has the same - pixel size **and that box is a constraint rather than its own answer**. If - its returned `Size` is unchanged, no size reader can observe the repaint and - no ancestor draws. If the size changed, the dependent path lays out. A - changed pixel box takes the conservative path first, which is the condition - the discarded `/tmp/escalate.patch` missed on resize. A box decided from the - widget's own size, on an axis that size reads, takes it too: measuring there - can only repeat the answer, so the reader that decided it draws instead -- - see "a box that is its own answer" below. -- Dirty widgets settle deepest-first. A changed size queues only its immediate - reader; propagation stops as soon as a reader's own answer stays unchanged. - Output resize is the distinct invariant: all pixel-dependent leaves and - their reader chains are marked together, then shared reader roots settle - shallowest-first under the new output. This both chooses the parent box - before drawing its descendant and coalesces overlapping resize paths. -- A span measures an unknown child in the part of its axis still available, - rather than giving every child the whole container and immediately taking - most of it away. This is the archive's faster shape, recreated on the current - types; it often makes the measurement box the final box without caching an - answer under different constraints. - -A proposed `Scroll` shortcut that reused its direct child's retained size was -discarded. “Direct child is clean” is not strong enough while a dirty -descendant's structural change is still propagating; seed 52 demonstrated the -stale-size failure. Re-measuring the scroll subtree costs 11 draws rather than -1, but remains two orders of magnitude below the old 1,313 and follows the -actual dependency invariant. - -A frame-local cache of several size answers under different constraint boxes -was also discarded. It reached about 3.5 ms in the instrumented resize rig, -but seed 98 showed that a valid answer is not enough: the child's one active -drawing may still realize another constraint, and a parent can then lay out -from an answer its final child placement does not realize. A correct version -needs an answer-plus-realization or verification protocol; adding dirtiness -conditions to the cache does not fix that invariant. - -Do not restore the archive's old `DrawMode::Measure`. It skipped primitive and -retained-state writes while still walking widgets and shaping text, and once -improved a streamed-frame benchmark from 1.39 to 1.22 ms p50. Retained -placement later replaced it because a provisional draw is usually already -usable in its final box; measure-only mode would discard that useful output -and force another real traversal. The owner confirmed on 2026-09-14 that -direct placement is the intended path. - -### What a resize frame is actually spent on - -`perf record` on #18's head, resize phase, seed 1 depth 8: about 63% of the -frame is text -- shaping in parley, harfrust and icu\_segmenter, then -`TextData::place` at 10.8% on its own -- and `draw_inner` is 0.9%. **The -retained-layout machinery is not what the frame costs.** A container that -measures by drawing makes a text draw 32 times at about a dozen distinct -widths; every one of those re-runs the shaper, because the caches in front of -it (`TextView::width`/`tex` and `TextBuffer::layout_key`) each hold exactly one -entry and a trial width alternating with a final width evicts the answer that -is about to be asked for again. - -`e5f8b6b` and `2525637` are the two halves of the answer. - -**A new width is a line break, not a shaping.** Only the breaking depends on -the width -- the font selection, the unicode analysis and harfrust under it are -a function of the text and the attrs, and parley re-breaks them in place, which -is what its own editor does on every resize. On the depth-8 tree that is 107 -breaks at 0.119 ms where the shapings they replace were 4.0 ms. This is the -half that holds however far the width moves. - -**A bounded store on `TextData` holds what re-breaking still cannot avoid:** -the glyphs, placed per width, keyed by the text, the attrs and the width, with -`TextBuffer` holding the pair it is drawn as -- which is where `TextView::tex` -was, so there is one place to invalidate rather than two. Bounding the store -rather than each buffer is the point: a per-buffer cache of eight cost +79% on -a tree of 4,000 texts, and lazy eviction cannot fix that, because the texts -holding the memory are exactly the ones a retained layout has stopped drawing. - -Instructions per frame over 500 resize frames of `tests/revision_cost.rs`, for -widths that alternate -- the friendly case for anything that remembers an -answer -- and widths that never repeat, which is a drag: - -| | alternating | never repeating | -| --- | --- | --- | -| before #16 (`43ce8c7`) | 56.1M | 56.2M | -| #18 head (`b1b3eca`) | 124.2M | 123.6M | -| the store alone | 17.7M | 45.9M | -| re-breaking alone | 32.9M | 32.8M | -| both | 20.6M | 24.2M | - -Neither alone is good at both, which is the reason for having both. The frame -times that go with the last row are 1.88 ms median and 2.54 ms worst -alternating, 2.46 and 3.24 never repeating, against 4.99 and 6.47 before #16 -- -so the worst frame more than halves, and the two gestures are within a -millisecond of each other rather than a factor of two apart. - -Memory on the same rig at 2,000 rows: 108.6 MB on #18's head, 111.6 MB with -both, and **132.0 MB before #16**, which grows 23 MB over the resizes where -neither of the others grows at all. - -**Re-measured at `f61e893`, which is the number to quote.** The same rig, the -same two worktrees each with its own target directory, 500 resize frames at 40 -rows, taken with `perf stat -e instructions:u` on the test binary: - -| | before #16 (`43ce8c7`) | #18 head (`f61e893`) | -| --- | --- | --- | -| instructions/frame, alternating | 56.1M | 15.3M | -| instructions/frame, never repeating | 56.1M | 15.5M | -| median frame | 5.32 ms | 1.29 ms | -| worst frame | 8.06 ms | 1.81 ms | -| RSS after 40 resizes, 2,000 rows | 132.3 MB | 111.1 MB | - -So it is 3.6x fewer instructions than the code it replaces, on top of being the -revision that asks in the right box: in the same 900-wide output the old code -lays the second paragraph out to x=929 and the third to x=910, both past the -edge, where this one keeps them at 877 and 889. - -Widget draws do not move at all -- 449 either way -- so none of this touches a -retained-layout rule. That matters for more than purity: **text is the load -that makes a redundant draw expensive, and so the thing that shows when the -layout is drawing more than it needs to.** The counters keep saying so -- a -text render is counted per ask rather than per shaping, and breaks and glyph -placements are counted separately -- so neither half can hide the amplification -it is paying for. - -This is not the frame-local answer cache seed 98 rejected. That one let a -parent lay out from a size the child's one drawing might not realize; this -holds a *drawing* keyed on what produced it, and the widget still draws. The -five reference renders and the resize render are byte-identical, and the -sweep passes. - -### Settling a dirty set, and why the order is not free to change - -**There are two ways to obtain a retained size and only one of them is -guarded.** `Painter::known_len` -> `RenderState::retained_size` is the path -that answers a child's length without drawing it, and it refuses on -`size_is_invalid(id) || dirty_size_under(id)` -- it asks the deep question. -`try_reuse` is the path that keeps a child's *drawing* for a new box, and it -asks only whether that widget is itself in `needs_redraw`; the size comes back -as a by-product of the reuse succeeding, and never got the guard the other path -has. So a reader is not reaching past anything: it asks a clean child, and the -child answers from a record that is stale only because something below it has -not settled. **The settle order is standing in for a missing check on one of -two implementations of the same concept**, which is why the order is -load-bearing for the answer and why that was easy to miss. - -That also says why bolting `dirty_size_under` onto `try_reuse` costs about what -the sort saves. On the `many` phase at 130 of 260 dirty the guarded path runs -47 times a frame and `try_reuse` runs 569 -- so the O(subtree) walk moves from -a cold path to one twelve times hotter. **The owner rejected building a maintained count for this on -2026-09-14**, and the reasoning stands: the count is only clarity, it costs a -pairing obligation at every site that marks or consumes a mark plus a cost in -every shipped frame, and bottom-up is needed for its own sake regardless. A -count that drifts low is a silent stale size, which is worse than what it -replaces. Its one remaining argument would have been freedom to settle in any -order, and the rows below measure that as costing more than it saves. - -What guards the invariant instead: `a_long_run_of_seeds_agrees` compares a warm -incremental frame against a cold rebuild of the same tree over a hundred seeds, -which is what caught the defect on seed 2 and is a stronger oracle than an -assertion. A `debug_assert!(!self.dirty_size_under(..))` on the two paths in -`try_reuse` that return a size would make the dependence on the order fail -loudly there rather than silently -- those are the exact and moved paths, 133 -calls a frame on the `many` load rather than all 569 attempts -- and it -compiles out of release, matching what `depth` already does against -`walked_depth`. - -**Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse` -asks whether the widget in front of it is dirty and, if not, hands its parent -the size it last reported. It does not ask whether a dirty widget sits under it -through the size dependencies -- which is the check `retained_size` makes, on -the path that does not draw, for exactly this reason. **The settle order is -what covers that gap**, and it was written down nowhere: taking the deepest -first means that by the time a reader draws, what it reads has drawn and -propagated. - -The trace that shows it, on seed 2 of `tests/generated.rs`: with the deepest -first and with an arbitrary pick, the two are identical event for event until a -`Span` reports its size -- 147 px one way and 317 px the other, from the same -child sizes. It had reused a subtree exactly, and inside that subtree sat a -`SetSize` whose declared width had changed and which had not been drawn yet. -24 widgets end up wrong. - -So the order is not about cost, and this is the thing to fix before changing -it. Adding the missing check to `try_reuse` does make any order correct -- all -six generated cases and the hundred-seed sweep pass with the dirty set taken in -whatever order it yields. But `dirty_size_under` walks the size-dependency -subtree on every reuse that hands a size back, and that costs about what the -sort saved: at 130 of 260 widgets dirty, 7.83M instructions per frame against -8.23M; at 32 dirty, 3.32M against 2.84M, so *worse* where the dirty set is -small. Two other shapes measured and rejected on the way: putting the check at -the top of `try_reuse` rather than on the two paths that return a size (7% dearer -again), and phrasing it as `size_is_invalid`, which also lets a resize mark -through and takes the resize phase from 7M to 106M instructions per frame. - -**What would actually pay is a maintained count** rather than a walk: each -widget holding how many dirty widgets sit under it through size-dependency -edges, incremented up the reader chain when a mark is added and decremented -when one is consumed. That is O(depth) at a mark and O(1) at the check, where -today it is O(dirty x depth) per widget settled. It wants agreeing first: every -place that inserts into or removes from `needs_redraw` has to pair with it, and -a count that is too low is a stale size rather than a slow frame. - -`3f7cd82` takes most of it without touching the order: a widget's depth is -known where it is drawn -- its parent's plus one -- so `Painter` carries it and -`ActiveData` keeps it, and the choice reads a field instead of walking an -ancestry. Being reused counts as being visited, so the two reuse paths keep it -current; only a subtree nothing looked at can hold an old one, and nothing -under an unvisited subtree is being ordered. `depth` asserts the kept value -against the ancestry in debug builds, and the hundred-seed sweep passes with -those assertions on, reshuffles included -- those being what moves a widget to -another parent. Same load at 130 of 260 dirty: 8.16M instructions per frame to -7.14M, median 0.813 ms to 0.639, and the choosing from 25.8% of the frame to -4.7%. - -What is left of it is iterating the dirty set, which a `HashSet` walks by -capacity rather than by length. Ordering it -- a `BTreeSet` keyed by the kept -depth, or a bucket per depth -- would take that too, but `needs_redraw` lives -on `Widgets` and is inserted from places with no view of the tree, so either -means a second structure inside the render state kept in step with it. For 4.7% -that is not obviously worth the coupling. - -**Going order-free costs more than the order now does.** Measured 2026-09-14 -with the depth carried, instructions per frame, on the same three loads: - -| | 32 dirty | 130 dirty | resize | -| --- | --- | --- | --- | -| ordered, no check (what is in) | 2.80M | 7.14M | 6.70M | -| ordered, with the check | 2.83M | 7.18M | 6.72M | -| arbitrary, with the check | 3.12M | 7.38M | 6.28M | - -So the check is nearly free where the order already makes it redundant, and -dropping the order costs 11% on a small dirty set. **Keeping the answer does -not help: it never hits.** The dirty set moves about once per check -- every -widget drawn takes its own mark out -- so an answer worked out against one -state of it is asked for against another, and a generation stamp invalidates -everything each time. Only a count maintained up the reader chain as marks come -and go would answer in O(1) without an invalidation to lose, and that is the -version still unbuilt. - -One thing not understood: the arbitrary-order rows above are consistently ~6% -cheaper with the kept answer in than without it, though the kept answer never -hits and only two lines differ between the builds. Nothing here rests on that -difference, but it means those two rows are worth re-measuring before anyone -builds on them. - -### An immediate-mode path, proposed by the owner - -Iris raised this on 2026-09-15, as something to have rather than something to -do now: a way to render with nothing cached at all, redrawing everything cold. -Per widget would be nicer, but a switch on the ui, or a second entry point -alongside `update`, is the useful start. Two uses -- the performance floor a -retained layout is measured against, and a second opinion on correctness that -does not share any of the retained machinery. - -Worth recording what it would and would not have caught. Not the `SetSize` -defect below: that one is a first frame laying out wrongly, with no retained -state involved, so an immediate path would have reproduced it faithfully. It -would catch anything where keeping a drawing is what goes wrong, which is what -`generated.rs` uses a cold `Harness` for today -- and a cold `Harness` is a -weaker instrument, because building a second tree is not the same code path as -refusing to reuse the first. - -### Why a warm frame and a cold one disagree - -Three defects, found on 2026-09-15 by shrinking a fuzzer's counterexamples. -Two are fixed. The third is the one the others were hiding. - -**A first frame was wrong, and nothing about retained state was involved.** -`SetSize` drew its child in whatever box it had been offered and then reported -its *declared* length, so the child answered about a box it was never going to -have -- and the answer on the other axis was taken under that. A wrapping text -under `SetSize(x: 76px)` was measured in the whole 640 available, said one -line, and the parent sized itself to one line; the text was drawn again at 76 -and said two, too late. It now measures in the length it declares. Six widgets -reproduce it in `tests/unsettled.rs`. - -That one matters beyond itself: **`generated.rs` compares a warm frame against -a cold one, and cold was not a fixed point either.** Some of what this document -previously called a retained-layout defect was the cold side being wrong. - -**Re-breaking a text at its own longest line is a knife edge.** A parent that -sizes to a child offers back the length the child just reported, composed back -through the box chain -- so it lands an ulp either side, and which side decides -whether the longest line still fits. Three lines at 167.41 or four at 163.49, -from the same text in the same box. A greedy break does not need recomputing -there: breaking at one width gives lines that each fit and none of which could -have taken another word, so at any width down to the longest of them the same -break holds. `TextBuffer::shape` answers across that interval, with a -sub-pixel tolerance for the edge. - -**A box that is its own answer.** A span measures its children in its own -box, and its own box is what its parent gave it, from the size it reported, -from those children. Four widgets: - - Aligned(mid, -, Span[ Text(wrap), OneLine ]) - -Swap the two children of a live tree and the span is still 663.376 wide, so the -text is offered 357.44, which is what it already holds; the size is valid, the -span reports 663.376 again, nothing moves. Grow the same tree in that order and -the span is offered the window, the text is asked for 334.06 and answers -318.45, and the span settles at 624.38. **Both are stable.** Which one you get -depends on what the tree was before. - -No rule about when to keep a drawing can fix that, and several were tried -(`Painter::settle` -- place a child into its own reported size without -measuring it there -- passed the hand-written cases and failed two generated -ones). The defect is in *where a dirty widget is measured*: a local redraw -draws it in the box it was placed in, and when a reader decided that box from -the widget's answer, the old answer is a fixed point of measuring there -whatever the content now says. So `ActiveData::offered_px` keeps the pixel -size of the box the parent *first* asked about the child in -- `known_len` or -the first `place` of a draw -- beside `px`, the box it drew against. A dirty -widget whose size reads an axis on which some reader up its chain gave what -it read a box other than the one it asked in is not drawn locally: the chain -is marked and the parent of the highest such placement draws, since above it -every box is a constraint. The walk has to go up the whole reader chain rather -than one edge, because a pass-through hands a derived box down unchanged: the -second shrunk case is a `Scroll` placing a `SetSize` in a box the content -decided, and the span under it placed once, in that box. - -That made `Scroll` show a second thing: it read its box's pixel length for -the clamp through `px_len`, which records the reported size as depending on -the box, and it does not -- a `Scroll`'s size is its content's. Every scroll -tick was then a size question asked in a derived box, at 34x the -instructions. `Painter::px_len_for_draw` is the read that records nothing, -for a draw whose reported size does not follow from it. Instructions per -frame on the depth-8 rig, 1000 frames, against the head before this: - -| | before | after | -| --- | --- | --- | -| `many`, 32 dirty | 0.66M | 0.74M | -| `many`, 130 dirty | 27.7M | 26.5M | -| `resize` | 15.8M | 15.0M | -| `scroll`, `repaint`, `size` | 0.36M | 0.36M | - -The small load pays 12% for the escalations that are now required; the -larger ones get cheaper because a `Scroll`'s retained size no longer goes -invalid when its box changes. The reorder fuzzer passes 200 trees at depth 7 -in every case, and the sweep passes. - -`OrthoSize::{Fill, Children}` -- a span choosing whether to fill across its -axis or report its longest child -- is written and parked for the same reason: -`Children` reads the span's own box to rank candidates, which is this cycle -with a second face. It is not the cause of anything, and the divergence it was -blamed for reproduces without it. - -### Shrinking a counterexample - -`tests/generated.rs` is a fuzzer, and a seed is not a lead anybody can read: -reconstructing one of its failures by hand failed three times. Two things fixed -that, and both are worth keeping. - -`describe` prints what each ancestor of a mismatch was *configured* with rather -than its type name, so a run says `Text < SetSize{x:34 px;} < Aligned{x:neg, -y:pos}` and the tree can be written out again. `Widget: Any`, so it needs no -plumbing. - -`tests/shrink.rs` grows trees from a description it can simplify -- drop a -child, unwrap a wrapper, shorten a text, drop a declared length, reorder a -span -- and takes the first simplification that still fails until none does. It -reduced 402 widgets to 6, 905 to 6, and 486 to 4. It lives in the tests and the -library knows nothing about it. Validate it after changing it: with the -box-length check in `try_reuse` deliberately disabled it should reduce a -96-widget tree to 2, and an early version of it silently found nothing because -it never framed before resizing, so "warm" had no retained state at all. - -Run the ordinary tests first, then the fuzzers, and turn what they find into a -fast test rather than leaving a seed as the record. - -**Depth 4 was hiding all of this.** `tests/generated.rs` ran at a constant -`DEPTH = 4`; `IRIS_GENERATED_DEPTH` and `IRIS_GENERATED_SEEDS` now select the -load, and `SHRINK_DEPTH`, `SHRINK_SEEDS` and `SHRINK_CASE` do the same for the -shrinking fuzzer. The generator widens two to four ways per level, so depth -buys overlap between dependency paths rather than ancestry. Nothing yet covers -a deep narrow chain, which is a gap: Iris asked for high layer counts on -2026-09-14 and a hundred-deep tree is still unreachable by turning this knob. - -The comment on `a_long_run_of_seeds_agrees` blames a wrapping text on a span's -own axis and says such a text is stable on any other axis. Both halves are -wrong -- a divergence was found on a span whose axis is Y -- and the "7 of -these 90 / 30 with it" numbers beside it do not match a sweep that passes -clean. Re-measure before trusting them. - -### Dirtying many widgets at once - -**A frame that dirties many widgets at once was not being checked, and it is -where the settle order shows.** `77bb75e` adds two generated cases -- every -declared size changing together, and a spread of widgets marked for redraw -together, where nothing changes and so no box may move -- and `IRIS_PHASE=many` -to the rig, with `IRIS_DIRTY` widgets marked per frame. At 130 of 260 widgets, -**choosing which dirty widget to settle next is 24.5% of the frame**: the dirty -set is scanned once per widget settled, and a `HashSet` is walked by capacity -rather than by length. Memoizing the depth walk within one scan does not pay -(it trades parent lookups for memo lookups, 4% more instructions); the fix is -to stop rescanning. - -**That fix does not have to change the order, and the cost is not the -choosing.** Measured 2026-09-14 on the `many` phase at 130 of 260 dirty, all -4.7% of it is one symbol: hashbrown's `RawIterRange::fold_impl`, the table walk -under `max_by_key`. Since `3f7cd82` the key is a field read, so what is left is -the cost of *finding* an element in a set walked by capacity, once per widget -settled -- not the cost of deciding between them. A depth-bucketed worklist in -`UiRenderState` keeps the order exactly and makes the pop O(1); the objection -that `needs_redraw` lives on `Widgets` and is inserted from places with no view -of the tree does not apply to it, because the buckets are a hint rather than a -second truth. A bucket entry whose mark has since been consumed is skipped on -pop for one hash lookup, so the obligation is only never to miss an insert, -never to stay in step. - -**Going order-free is not the thing to buy.** `iter().next()` in place of the -deepest-first pick, with no check added, takes widget draws from 502 to 670 per -frame and instructions from 6.84M to 9.01M. The extra draws are not a parent -drawn twice on its own: the same widgets appear at the top of the per-widget -draw counts in both orders, at the same trial widths (a `Text` under -`SetSize < Scroll < Span` is drawn at 14 distinct widths either way), with -every count about 1.5x. The measure-by-drawing loop is not exploring more, it -is being re-entered. What drives the re-entry is that boxes change more often: -`try_reuse` refusals for a changed box go from 79 to 116 own-resize and 126 to -187 descendant-resize, because a reader settled before its children hands out -boxes from sizes that are about to move, and when they move every child in the -subtree refuses reuse. The `dirty_size_under` check is what buys that back, -which is why the order-free rows above come out 3.4% dearer at 130 dirty rather -than 32%. So the order is paying for itself in draws, and the count maintained -up the reader chain would be a way to afford dropping something worth keeping. - -One counter does not fit that account and was not chased down: queue pops fall -from 87 to 80 and local redraws from 66 to 59, where re-marking a reader should -raise both. The likely reading is that a re-marked reader is consumed inside a -later pop's subtree draw rather than popped on its own, but it is inferred from -`redraw`'s escalation path rather than traced. `EagerReaderRedraws` is 4 and 3, -so the escalation itself is not where the draws come from. - -**What a frame is made of now**, `perf record` on the `many` phase at 130 of -260 dirty, which is the heaviest thing the rig has: `Layers::write` 16%, -parley's `break_all_lines` 15%, `Painter::glyphs` 14%, `InstanceList::push` 7%, -`LayerDraws::free` 6%, `draw_inner` 5.5%, choosing the next dirty widget 4.7%. -Four fifths of it is primitive bookkeeping, and none of that has had a pass. -That is where to look next rather than at the settle loop. - -The remaining costs after this are: - -- **Writing a glyph instance per glyph per draw.** With shaping and placement - kept, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are most of - what is left. A cold frame writes 48,050 instances for a tree that - holds 10,872. What reaches the GPU is 10,872, since a layer uploads whole and - `set_instance` cancels a dirty mark when the bytes are unchanged -- so this is - CPU cost only, but a one-leaf repaint still uploads 5,863 instances where the - pre-#16 code uploaded 106. -- **A container measures a child by drawing it in a box it will not keep.** - The remaining-region trial removes many mismatches, but an unknown child's - measured length can still make its final box differ, and nested containers - compound those redraws. The general form of the shaping store is a widget - answering from a box it has already drawn in, which -- unlike a size answer -- - is realized by definition; the cost is retaining more than one drawing's - primitives, which is why text, where only the shaped layout has to be kept, - is where it is worth doing first. - -## The random trees - -`iris::random` grows a seeded tree -- spans in every direction holding two to -four children, stacks, scrolls on either axis, alignment on either axis, -padding with each of its four sides its own number, -rects with varying opacity, text both wrapping and overflowing, a declared size -over half of it, stopping at a depth. `examples/random.rs` draws one -(`IRIS_SEED`, `IRIS_DEPTH`). `tests/generated.rs` grows each seed twice -- once -and then changed, once with the change built in -- and compares every widget's -box across eight scenarios: a size change, a resize, both, and five ways of -changing what a span holds. `a_long_run_of_seeds_agrees` is the ignored sweep, -100 seeds across all eight, 800 comparisons, about four minutes. - -The property is that laying a tree out again lands where growing it cold does, -which is the same thing as layout being a function of the state. It earned -itself immediately: it found the non-terminating marking, the pixel-box reuse -check and the measured-size rule above, none of which the hand-written tests -reached, and it says the result is better than what it started from -- 90 of 90 -against 83 on `db1751f`. - -Four things about it that are easy to get wrong: - -- **Both trees must make the same widgets in the same order.** Comparison is - index for index, so a tree that makes fewer widgets, or frees one whose id is - then handed to the next, stops lining up at the first difference and every - comparison after it is against the wrong widget. Hence three spare leaves - grown beside every span whether they end up in it or not, and detached - children held until the comparison is over. -- **Attaching a spare moves it.** A widget belongs to one parent; - `WeakWidget::upgrade` registers an add and panics with "cannot add a widget - twice", so it is for a handle that was never added, not a second share. -- **Each shuffle asserts the tree actually changed** before comparing, or a - case that quietly did nothing passes green. -- **A failure prints the widget's ancestry**, marking the ones that own a slot, - because where two trees disagree is rarely where the cause is. - -## Verifying a slice - -```sh -cd +cd /home/bob/repos/iris-pr18 cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` -70 tests pass and 8 are ignored on #18's head. `--workspace` matters: -`rig-input` is a crate of its own. Nothing enabled by default takes a minute -- -the whole suite is about eight seconds, of which `generated.rs` is six -- and -the owner asked on 2026-09-15 that it stay that way, so anything slower than a -minute is `#[ignore]`d. The 100-seed sweep at 68 seconds is the reason that -rule exists. - -Render checks are the last pass, not the iteration loop -- the owner asked for -that on 2026-09-14, since the layout tests cover the CPU part and the shots -cost real time: +Run the long generated oracle only after ordinary tests pass: ```sh -./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/out.png -./scripts/run-headless.sh tabs --replay /tmp/taps.touch --shot /tmp/out.png -./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --resize 900x1200@60Hz --shot /tmp/rs.png +cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees ``` -- The reference shots are `tabs`, `view`, `minimal` and `text` at 1920x1200, - `tabs` cold at 900x1200, `tabs` resized from 1920x1200 down to it, and `tabs` - with a replay that switches to the image tab and adds two images. Compare - them against the same shots taken from a worktree at `upstream/main`; the - bar every slice has met is byte-identical. -- A `.touch` line is ` down|move|up ` in the output's own pixels; the - tab strip is at y=24 and the five tabs at x = 192, 576, 960, 1344 and 1728, - with the image tab's add button near (1836, 1116). The image replay is six - lines, and writing it out again is quicker than looking for the file: +`tests/generated.rs` compares a warm incremental tree with a cold tree of the +same state. `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS`, and +`IRIS_GENERATED_DEPTH` select failures. `tests/shrink.rs` reduces a failing +tree; use it to turn a seed into a readable regression rather than leaving the +seed as the only record. - 0 down 1728 24 - 80 up 1728 24 - 400 down 1836 1116 - 480 up 1836 1116 - 800 down 1836 1116 - 880 up 1836 1116 -- **A resize is its own case**, and `--resize` is it: the output changes under - the running app, and what it lands on must match a cold start at that size - byte for byte. That caught both of #16's defects and nothing in `cargo test` - can see it. -- **Run one at a time.** The rig reuses a single compositor and a single - output, so two invocations at once resize each other's window and quietly - screenshot the wrong thing. Two sets of shots were thrown away learning that. -- **Give a comparison worktree its own target dir.** While one was shared - between two checkouts I got results I could not reproduce afterwards; the - mechanism was never pinned down, so re-run any cross-checkout comparison in - isolation before believing it. +The headless reference set must be run one process at a time because the rig +reuses one compositor. Comparison worktrees need separate target directories. +Useful commands: -Two drawing paths still have no shot of their own, and each needs a ui the -examples do not have, so both are throwaway examples written into the worktree -and deleted after: +```sh +./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png +./scripts/run-headless.sh tabs --mode 900x1200@60Hz --shot /tmp/cold.png +./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ + --resize 900x1200@60Hz --shot /tmp/resized.png +./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ + --replay /tmp/tabs.touch --shot /tmp/replay.png +``` -1. An image alone in a layer, which is the case that failed GPU validation when - every other test happened to have a rectangle in the same layer. -2. Six lines of 400px text, which forces the atlas to four pages and proves the - array grew and its group was rebuilt. +The replay used for the final check was: -`tabs` with the image replay covers rects, glyphs and images together, so that -one is an ordinary check now. +```text +0 down 1728 24 +80 up 1728 24 +400 down 1836 1116 +480 up 1836 1116 +800 down 1836 1116 +880 up 1836 1116 +``` -## How the work is sequenced +`tests/layout_diagnostics.rs` is the retained CPU rig. Select `cold`, `many`, +`repaint`, `size`, `scroll`, or `resize` with `IRIS_PHASE`; use the feature for +explanatory counters and an uninstrumented release binary under `perf` for +instruction totals. -**Most fundamental first**, from the owner on 2026-09-13: *"please do more -fundamental changes first, such as library updates and core framework changes, -so that code only has to be written once"*, and *"should probably start adding -tests early on rather than later, so you don't have to make separate test -scripts and stuff."* So slices are ordered by how much depends on them, not by -what is nearest ready, and a slice arrives with tests rather than with a script -in `/tmp`. +## Next -**Agree a design before sending another variation of it.** The owner stopped -the fourth round of #11 with *"we should probably agree on the design here -rather than you keep submitting variations that I review"*. When a review comes -back about the shape of something rather than a defect in it, put the options -and a recommendation in front of her and implement what she picks. +Let PR #18 review. The next small LAYOUT.md §2 item is `LazySpan`; +`set_child_offset` is no longer separate, because placing a child is +`Painter::place`. -**Nothing is submitted without a separate review pass** -- the installed -`pre-submit-review` skill: build clean, review the code, review the comments on -their own once the code has settled, then verify the claim by running it. The -fixes a review produces are themselves unreviewed code, so the passes repeat -until a round finds nothing. It has earned its place repeatedly: four defects -on #11 that format, clippy, tests and five headless renders had all passed, and -on #12 a regression introduced by the review's own first draft. `audit.sh` in -the skill directory prints every comment line a branch adds against a base ref; -the owner's standing complaint is verbose agent comments, and the default -verdict is delete. **Machine-specific notes do not belong in the repository** -- -they live in `~/.claude/MACHINE.md` or a `this-machine-*` skill. +After #18 lands, the next structural design is built-in alignment and size. +Today a declared size and the `Size` returned by `draw` are two sources of +truth, and alignment can disappear behind a wrapper. Agree their ownership +before implementing it. Do not restore `OnResize::Translate`; retained +translation is now expressed by the same `Holds` contract and box chain. -Other standing instructions from the owner: +Other queued work, in dependency order: -- Pull Iris out even if the Rust application switchover is not accepted. No - app, session, transcript, setup or server concepts in Iris; the dependency - runs one way from `app/` to Iris. -- **Small, coherent PRs.** The original extraction PR was too large to review. - A slice may be redone rather than transplanted, and need not remove every old - feature. Non-conflicting pull requests may be open at once -- disjoint path - sets, each branched from current `upstream/main` rather than stacked. She - reviews small ones as they arrive and only avoids two *large* ones in flight. -- **Order by dependency, largest reach first.** On 2026-09-13: *"do the large - reaching framework changes first so less has to be redone."* -- Do not recreate an `ai` branch in canonical Iris; the fork is the boundary. -- **Never rewrite a pushed branch.** Follow review with additive commits, and - merge `upstream/main` in rather than rebasing when a branch falls behind. -- Respond to each review finding with a fix or a concise explanation. Do not - add a ceremonial comment when the changed code already answers it. -- **A test has to guard something that could break again.** She deleted #14's - test as pointless: the rename it guarded cannot regress. When a fix is - structural, the structure is the test. -- **Say who decided a constraint.** LAYOUT.md §2's "move slots carry - translation only" was written by an agent on 2026-09-04, was never asked for, - and read as settled until she said *"I was not aware that an agent decided - position slots should be translate only."* Mark an agent's own choice as one. - -## What is left - -**Next, and small:** `LazySpan`, the last of LAYOUT.md §2. `set_child_offset` -is no longer part of it -- a child offset is just placing the child, which -`Painter::place` now does. - -Then, roughly in dependency order: - -- **Built-in alignment**, which the owner moved ahead of everything else on - 2026-09-14. Half of the size question is answered -- a declared length is - resolved once, by whoever draws the widget -- but alignment still is not. - Reproduced in the harness: `.width(rel(0.5))` inside a `Dir::DOWN` span - reports 200 of 400 and is handed the whole 400 on the *other* axis, and a - `Pad` in between does not change that. **Do not "fix" it by reading the - child's ortho `size_hint`** -- a `Pad` between the `SetSize` and the span has - no hint of its own, so the declared width silently goes back to filling. It - works only when nothing is in the way. Alignment has to belong to the widget - rather than be discovered through whatever happens to sit on top of it. - - Two things beyond the bug argue for it. Built-in size removes `SetSize`, and - with it a wrapper reporting one size while handing its child the whole box. - And built-in alignment is what would let `OnResize::Translate` apply to - centred content, which otherwise has to say `Redraw` because only its own - draw knows where the middle was. Size is the harder half: a declared size - beside the one `draw` returns is two sources of truth for one thing, so - settle what each means before building it. This is independent of #18's - retained-update fix now that `Aligned` is in its generated coverage; land - #18 first, then design built-in alignment and size together as the next - structural slice rather than mixing that representation change into this - performance correction. -- **`OnResize::Translate`, which still does nothing.** The chain removed half - its obstacle: a placed widget's slot holds the box it was offered while its - drawing is a set of fractions of that box, so the two are no longer one - field. What is still missing is a widget saying *where* in a bigger box its - unchanged drawing should sit, which is the alignment work above. -- **`UiRenderState` behind `Rc>`**, queued by the owner on - 2026-09-13 as fundamental, and especially so for text. -- **`Len`, `LayoutLen` and dp.** The archive splits the type so that `leftover` is - unrepresentable where it is meaningless (a padding), and folds a density in - at resolve time. 21 files mention `Len`, so it is wide but shallow. -- **The input restructure** -- `src/default/sense.rs` becomes `src/rsc/sense.rs` - (308 lines to 2313), plus `core/src/event/controller.rs`, `desktop/input.rs`, - `android/input.rs` and `sense_tests.rs`: pointer capture, drag slop and axis, - platform cancellation, mask-aware hit testing, event timestamps. The - archive's own `consumes` is what #12 landed, so that part transplants; - `tests/pointer_routing.rs` is the acceptance criterion. -- Retained span, scrolling and layout placement. -- Retained paints, selection, overlays and shared UI runtime state. -- Generic desktop/Android framework hosts and reusable example/APK tooling. -- Application-owned fonts and application-named font families. -- Shared resource-handle bookkeeping and replaceable glyph-atlas buckets. +- `UiRenderState` behind `Rc>`. +- Split `Len`/layout length and add density-independent pixels. +- Input restructuring: pointer capture, drag slop and axis, cancellation, + mask-aware hit testing, and timestamps. +- Retained paints, selection, overlays, and shared runtime state. +- Generic desktop/Android hosts and reusable example/APK tooling. +- Application-owned fonts and replaceable glyph-atlas buckets. - Positioned text overflow and cluster-safe ellipsis. -Dependencies are current apart from `winit`, which stays on 0.30.12 until 0.31 -leaves prerelease. `parley` 0.11.1 and `image` 0.25.10 are latest. - -The archive is a reference, not a patch to apply -- it writes `Widget::draw` -against `painter.set_size`, which #16 replaced with a returned `Size`, and -lengths against `LayoutLen` and `density`, which canonical does not have. -Recreate a change on today's types, leave app-specific behaviour out, and -verify it independently. - -```sh -cd /home/bob/repos/iris && git fetch upstream -git diff --stat upstream/main..origin/archive/full-extraction -``` - -## How the renderer works now - -Current invariants, not history. Worth reading before touching `core/render`. - -- **A primitive registers itself by being drawn.** The type carries its own - WGSL, and `PrimitiveRegistry` keys ids by `TypeId`, so the kind comes from - the type and there are no `RECT`/`GLYPH`/`TEXTURE` constants. Nothing is - seeded, so an id depends on what a ui drew first and a ui pays only for the - pipelines it uses. -- **Each primitive records its own draws.** `Primitive::render` makes a - `PrimitiveRender` that states the layout its shader reads, uploads whatever - it owns, and records its draws. `GlyphRender` owns the atlas and binds it - once per list; `ImageRender` owns the images and binds one per instance; the - default owns nothing and draws every instance in one call. The renderer sets - the pipeline, the shared group, the list's data and its vertex buffer, and - knows nothing else. -- **The shared bind group is the window, the masks and the move chain**, given - to every draw. A mask texture would go here too. What a primitive samples is - its own group, and a primitive that samples nothing has no such group in its - pipeline. -- **Every binding size is stated.** A `None` minimum puts the binding on - wgpu-core's late-sized list, which `is_ready` scans on every draw. -- **`shader/prelude.wgsl` plus one file per primitive**, because one module - cannot declare two types at the same binding. The prelude carries only what - every primitive uses -- window, masks, the chain walk, the vertex shader, - `masked()` -- and its header is where binding numbers are written down. -- **A texture handle is drawn like anything else.** `Painter::primitive` takes - `impl PrimitiveLike`: a primitive, or something that yields one and does - whatever else drawing it needs -- a `&TextureHandle` retains its share on the - way through, which a `Pod` primitive cannot. -- **Order within a layer means nothing**, and the widgets do not rely on it: - `Stack` gives each child its own layer and `TextEdit` draws its view in a - child layer above the selection rectangles. -- **Images are one texture and one bind group each**, so each drawn image is a - draw call. The owner chose that on 2026-09-13 over packing images into arrays - like atlas pages; a bindless `binding_array` was ruled out by Android - support. Revisit only with her. -- **Layers are never freed** (`TODO` in `primitive/layer.rs`), so every layer a - session creates is walked every frame thereafter. Measured at ~2ns per empty - layer per frame, which is why it is the TODO's problem and not a bug of its - own. - -## Repository topology - -### ai-app checkout - -- `/home/bob/repos/ai-app-2`, `origin = git@git.arirex.me:iris/ai-app.git`, - branch `rustify`. -- `iris/` is a submodule pinned at `32f6ad8`, the complete extracted snapshot, - and `.gitmodules` points at the **bot fork**, not canonical Iris. -- Do not change either casually: ai-app needs the complete snapshot while - canonical Iris is only partly caught up. Reconcile when canonical contains - what ai-app needs, or when the owner accepts a temporarily non-building pin. - -### standalone Iris checkout - -- `/home/bob/repos/iris`, `origin` = fork, `upstream` = canonical. -- Fork `main` and `origin/archive/full-extraction` both name `32f6ad8`, the - target snapshot. `history/full` names the source-history result `a615bcd`. -- **Do not reset, overwrite or force-push fork `main`**: it is both the target - reference and the commit ai-app pins. -- Start each new branch from current `upstream/main` in its own worktree: - -```sh -cd /home/bob/repos/iris && git fetch upstream -git worktree add -b split/19-name /home/bob/repos/iris-pr19 upstream/main -``` - -`/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree -holds a merged branch; they are readable references, not places to build. -`/home/bob/repos/iris-perf2` is detached at `b1b3eca`, built, and is the -"before" side of any measurement of the last two commits; `iris-size-old` is -`43ce8c7` with the old patched counters, and is how the pre-#16 column above -was measured. Copy `tests/revision_cost.rs` into either to compare. -`iris-old-cmp` (`43ce8c7`) and `iris-main-cmp` (`upstream/main`) are unpatched -worktrees with target directories of their own, for the instruction counts and -the reference renders above; `43ce8c7` calls it `Len::abs` where this branch -says `Len::px`, which is the only edit a copied fixture needs. Keep -`iris-main-cmp` -- every render comparison starts by taking the same shot -there. - -## Cautions - -- Read `/home/bob/repos/ai-app-2/AGENTS.md` and the machine-wide rules first. - Anything about this machine -- the GPU that comes and goes, measuring a small - performance difference, the emulator -- is in `~/.claude/MACHINE.md` and the - `this-machine-*` skills, and belongs there rather than here. -- Keep Iris generic: session drivers, transcripts, setup and server concepts, - app icons and product fonts stay in ai-app. Android and desktop code is Iris - work only when it is a generic host or platform integration. -- Preserve the dirty-worktree rule. All worktrees were clean at handoff; - anything found later may be the owner's or another agent's. -- Do not delete the archived snapshot or the fork `main` ai-app pins. -- A complete target branch is not permission to recreate the giant PR. -- Another agent was freeing disk on this VM and removed `target/` from the - `iris-pr*` worktrees once. Sources and git state were untouched. Tell peers - before changing shared machine tooling, and expect a cold rebuild sometimes. - -## Merged so far - -| PR | On canonical `main` | -| --- | --- | -| #2 | Build on the current nightly (`4275314`) | -| #3 | Request a frame after resize (`936fbdd`) | -| #4 | Decouple `iris-core` from winit (`465e430`) | -| #5 | Use vsync by default (`ec2b5d4`) | -| #6 | Notify winit before presenting (`db9b0f2`) | -| #7 | Keep unsafe reference helpers internal (`0191f20`) | -| #8 | Initialize the window uniform from the surface (`6e271e8`) | -| #9 | Preserve primitive-count recursion (`b90c855`) | -| #10 | Text layout and rendering on Parley (`0f6a28b`) | -| #11 | Atlas as an array texture, and the primitive rendering overhaul (`b234497`) | -| #13 | Build on wgpu 30 (`00d2230`) | -| #14 | Rename the `Sized` widget to `SetSize` (`32b1038`) | -| #15 | Run a ui without a window, and test one (`c8ac669`) | -| #12 | Route pointer input per kind (`43ce8c7`) | -| #16 | Size a widget while drawing it, not in a pass of its own (`f942385`) | -| #17 | Bring the headless rig into the repository (`ca2b4b2`) | - -URLs are `https://git.arirex.me/iris/iris/pulls/{number}`. +The archive is a reference, not a patch: it predates returned `Size`, the +current box chain, and the current length types. Recreate changes on current +types and keep app/session concepts out of Iris.