diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index 61f472f..2a4cc17 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -373,41 +373,46 @@ 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. -**Picking any dirty widget instead of the deepest does not work, and the -generated cases say so.** Tried on 2026-09-14: take whatever the dirty set -yields first, and let `redraw`'s existing climb to the highest size reader do -the ordering -- then also climbing to an ancestor that is itself dirty, since -drawing that redraws this one anyway. Both fail -`a_changed_size_lands_where_growing_it_that_way_would` at seed 1, 24 widgets -wrong, a subtree keeping a 317 px width where a cold tree has 147. +**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. -What the trace says, and where it stops: with the deepest first, seed 1 settles -in 50 draws and the same `SetSize` is drawn three times as its box is decided -and redecided above it. Picking any settles in 12, and two of the three edited -`SetSize` widgets are never popped at all -- they are drawn inside a `Span`'s -pass, which consumes their marks. Nothing in the climb misfires; no escalation -happens in this scenario at all, so the difference is only which widgets are -drawn as the root of a pass and which inside someone else's. **Why that changes -the answer is not yet pinned down**, and it is the thing to find out before -changing the order again. +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 load-bearing and the cost is the scan rather than the order. -It splits: of the 24.5%, about 15 points are the depth walk (removing it -entirely takes 16.5B instructions to 14.0B) and about 9 are iterating the -`HashSet` itself, which walks by capacity rather than by length. Three ways -out, none of them tried yet: +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. -- **Keep the depth on `ActiveData`.** `draw_inner` knows the parent, whose - depth is in its own entry, so this is O(1) to read and the order is - unchanged. The risk is a second source of truth: a widget whose ancestor is - re-placed under a different parent without the widget itself being redrawn - would hold a stale depth. -- **Bucket the dirty set by depth.** Exact, and the depth is only needed when - an id is inserted -- which inside the loop is always the parent of a widget - whose depth is already known, so no walk is needed there. -- **Process in rounds**: sort once, drain, re-sort what is left. Cheapest to - write and *not* the same order -- a widget marked during a round can be - deeper than what remains of it. +**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. + +Of the sort's own 24.5%, about 15 points are the depth walk and about 9 are +iterating the `HashSet`, which walks by capacity rather than by length. Keeping +the depth on `ActiveData` would remove the first without changing the order at +all, at the price of a second source of truth for it. **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