diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 8a77a14..1873c6b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -22,9 +22,10 @@ Its current head is `0e107f0` in `/home/bob/repos/iris-layout-experiment`. See **Frame and extent: retained prototype** for the mechanism, measured costs, and the remaining cold/many-update work. The app's framework pin is unchanged. **It is not ready to replace #18 yet**: measured against `e44dea3` it wins on -`size` and `scroll` and loses badly on `many` -- 6x on seed 13 at depth 8 -- -and the cause is one conservative dependency rather than the semantics. See -**Measured against #18's head, and the placement pin behind the gap**. +`size` and `scroll` and loses badly on `many` -- 4.5x on seed 13 at depth 8 +after the 2026-09-17 work -- and what is left of that is the measure/place +double draw rather than a conservative read. See **Measured against #18's +head, and the placement pin behind the gap** and the two sections after it. **Widen one fuzzer axis at a time, and record which.** Seeds **1121** and **1839** at **depth 4** failed on `ea6dbae` and on every commit before it, @@ -707,31 +708,85 @@ the dependency: | seed 13, depth 8 | 1.088 / 524 | 6.351 / 2380 | 0.147 / 84 | `cold` at seed 1 falls from 484 draws to 280 and 12.5 ms to 11.1 ms with it -gone, and `resize` from 48 us to 12 us. So the pin is not a cost the frame and -extent semantics require: it is `Pad` and `Stack` having no way to say "inside -my extent" other than reading the raw box. +gone, and `resize` from 48 us to 12 us. Read it as an upper bound on what +`Pad` and `Stack` having no way to say "inside my extent" was costing, not as +what removing the read properly would buy -- see below for the difference. **That is what `c44bd19` was, and the 1.8% that got it reverted was measured -before the dependency split.** Extending `widget_within` to accept -`DrawRegion::Extent` was priced against a head where one accumulated range -still forced the redraws, so the saving had nowhere to show. Re-price it -against `0e107f0` before the experiment replaces #18; the enlarged child -record it costs is 16 bytes against a phase that is presently 6x #18's on -seed 13. +before the dependency split.** It is now landed as `e6ba570`, below, and the +bound above turned out to overstate it: deleting the read also deletes +invalidation that the extent semantics genuinely require, so it prices "no +dependency at all" rather than "the dependency expressed properly". -**A smaller one, unrelated and free.** `redraw_updates` picks the deepest -dirty widget by scanning the whole `needs_redraw` set and calling `depth` on -every member, once per pop, and `depth` is a hash lookup. Depth reads per -`many` frame at seed 1, depth 8: 131 at `IRIS_DIRTY=8`, 1,314 at 33, and -14,611 at 145, for 21, 62 and 182 pops. It is about 3% of a 33-dirty frame -(`depth` plus the hashbrown fold in `perf report`) and grows as the square of -the update set. Ordering the pops rather than re-scanning needs no retained -state. +### Landed on 2026-09-17: extent children, and an ordered walk -Verified green at `0e107f0` on 2026-09-17 before any of the above: -`cargo fmt --all --check`, `cargo clippy --workspace --all-targets -D -warnings`, and `cargo test --workspace` (106 suite, 20 core, 11 generated, -4 harness) all clean. +Three commits on `wip/region-and-placement`, pushed, on top of `0e107f0`. + +**`e6ba570`, a child gets a part of the container's extent.** +`widget_within` takes a `DrawRegion`, and `DrawRegion::Extent(part)` gives +the child a part of the extent without reading it. What is retained is the +part rather than the box it resolved to, so moving the extent re-places the +child through the same rule: `inherited_children` became `extent_children`, +carrying `Inherit` for the wrapper case `Painter::widget` already had and +`Within(part)` for the new one. `Pad` and `Stack` use it and no longer read +`placement()`. The dependency that goes up is a range on the container's +*extent*, since only the part's length reaches the child. A declared length +is unchanged -- it is a length of the frame wherever its box came from. +What still pins the placement is a report with a fraction in it, and that +pin is on the answer rather than the drawing; the test from the first +attempt fails without it. + +**`3bf2293` and `34cafb6`, the walk takes the deepest mark from a +`BTreeSet` keyed by depth** rather than `max_by_key` over the whole set. +Every mark made while the walk runs goes through `mark`, which queues +itself; the set is still what says the walk is done, so a mark that arrived +another way cannot be left for the next frame. Depth reads per `many` frame +at seed 1 depth 8: 131/1,314/14,611 at 9/34/145 marks become 57/160/436. +What is drawn does not change at any load measured. Ties between equal +depths now break by widget id, which makes the walk deterministic. + +Cycles, medians of seven alternating runs, `perf stat -e cycles:u`, against +both the branch #18 would merge and the experiment as it stood: + +| phase | `e44dea3` | `0e107f0` | head | vs `0e107f0` | +| --- | ---: | ---: | ---: | ---: | +| `many`, seed 1 | 1.273 B | 2.543 B | 2.502 B | -1.6% | +| `many`, seed 13 | 1.323 B | 7.308 B | 5.931 B | -18.8% | +| `resize` | 0.269 B | 0.485 B | 0.389 B | -19.9% | +| `size` | 0.261 B | 0.205 B | 0.204 B | -0.2% | +| `scroll` | 1.730 B | 0.912 B | 0.939 B | +2.9% | + +Verified at each commit: fmt, clippy with `-D warnings`, 109 suite and 20 +core tests, the oracle at 100 seeds, the shrinker at 400 trees of depth 5, +1000 seeds at depth 6 and 2000 at depth 4 over all fifteen cases, and the +five reference renders plus `tabs` resized to 900x1200 and `random` to +1280x800, all byte-identical on Venus. + +### What is left of the gap, and it is not the placement pin + +**The extent contract is what refuses most reuses.** The diagnostics rig +now splits a failed reuse three ways. At seed 13, depth 8, `many`, of 1,203 +refusals a frame: **819 fail an extent range**, 379 a frame range and 310 +the placement pin (they overlap). Seed 1: 55, 20 and 37 of 98. + +The point ranges are the tell -- `Holds { lo: 20, hi: 20 }`, `{ lo: 1916, +hi: 1916 }`. `Painter::px_len` narrows the extent to exactly the length +read, and the measuring ask and the placing ask are two different extents: +a widget that reads its pixel width while being measured cannot have that +drawing reused where it is finally placed. Wrapping text and `Scroll` both +do. That is the measure/place double draw, and it is why the experiment +draws 508 distinct widgets of 583 active where `e44dea3` draws 159. + +**`Span` is the one raw placement read left that costs anything.** Replacing +`painter.placement()` with a non-recording read -- unsound, a bound only -- +takes seed 13's `many` from 4.73 ms to 3.03 ms; doing the same to `Scroll` +moves nothing. A span cannot use an extent ask as it stands: it hands each +child the *frame* along the axis (so `rel(0.5)` is half the row) and a slot +cut from its own *extent*, and the slot is accumulated from lengths that are +fractions of the frame. Expressing those slots in extent coordinates needs +the frame-to-extent ratio, which is the inverse division this branch +removed. **Ask Bryan before designing that**; it is the shape of the +protocol, not a cleanup. ## How layout is decided