diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1873c6b..b65593e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -18,14 +18,14 @@ present when checked on 2026-09-15. The separate frame/extent experiment now addresses the dominant size/resize redraw cascade through dependency tracking and invalidation, not glyph emission. -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` -- 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. +Its current head is `34cafb6` on `wip/region-and-placement` in +`/home/bob/repos/iris-layout-experiment`. See **Frame and extent: retained +prototype** for the mechanism. The app's framework pin is unchanged. **It is +not ready to replace #18 yet**: against `e44dea3` it wins on `size` and +`scroll` and loses on `many` and `resize`. The cause of the `many` loss was +traced on 2026-09-17 and is not what the earlier sections say; read **Where +the `many` gap actually comes from** before anything else in this document +about performance, and take the earlier sections' explanations as history. **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, @@ -762,31 +762,79 @@ core tests, the oracle at 100 seeds, the shrinker at 400 trees of depth 5, 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 +### Where the `many` gap actually comes from (2026-09-17, traced) -**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 two sections above blame the placement pin and then the extent contract. +Neither is the cause. Disabling the pin (unsound, a bound) still redraws +**487** distinct widgets a frame at seed 13 against `e44dea3`'s 159, and a +per-widget trace of one `many` frame shows what does it: **local redraws +defer to the parent, and the deferrals chain to the root.** -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. +`redraw` refuses a dirty widget whose given box is not as long as its offer +and marks its parent instead. Under this protocol that is nearly every +widget under a self-sized container. `Span` hands its children its own +placement across itself as their *frame* (`UiRegion::from_axis(axis, +UiSpan::FULL, *own.axis(!axis))`), and that placement is `FULL` while the +span is being measured and its answer once it is placed, so a child's offer +frame across the span is the whole window and its given frame is the span's +height or width. The trace has 43 deferrals in one frame, in chains such as +424 → 425 → 437 → 449 → 483 → 487 → 491 with `given=(52, 40) +offered=(1920, 40)` at every step. Each container reached that way runs its +body at both placements, redrawing its subtree at two geometries. +`e44dea3` defers under the same rule but its given and offer differ only by +the widget's own alignment slack, so its chains stop after a level or two: +19 deferrals, 159 distinct widgets. -**`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. +**Re-asking locally at the offer** is the fix that follows, on branch +`wip/local-reask` (one commit over `34cafb6`, pushed). `ActiveData` keeps +`offer_region` beside `given_region`; `redraw` draws the widget once in the +offer's frame at the offer's lengths and placement, and again at the given +box where the two differ. Uninstrumented, 200 frames, seed and depth as +before, against `e44dea3`: + +| phase | `e44dea3` | `34cafb6` | `wip/local-reask` | +| --- | ---: | ---: | ---: | +| `many`, seed 1, depth 8 | 0.263 ms / 157 draws / 95 distinct | 0.549 / 263 / 108 | 0.499 / 239 / 93 | +| `many`, seed 13, depth 8 | 0.896 / 524 / 159 | 4.727 / 1904 / 508 | 1.282 / 647 / 294 | +| `size`, seed 1 | 0.016 / 16 draws | | 0.010 / 3 | +| `size`, seed 13 | 0.094 | | 0.003 | +| `resize`, seed 1 | 0.019 / 13 draws | 27 draws | 0.028 / 22 | +| `scroll`, `repaint` | 1 us | | 1 us | + +With the pin also disabled on that branch (bound), draws fall a further 15% +and distinct widgets do not move. **What is left is the offer-then-place +double pass**: a dirty container is drawn at its offer placement, where its +children get the offer geometry, and again at its placed one, and any child +whose `Holds` is a point (`Branch`, `Scroll`, wrapping text at a width it +was not measured at) is drawn on both passes. That pass exists because the +frame a span gives its children across itself is derived from the span's +own answer, so it cannot be the same on both asks. + +**The branch is not sound.** The suite, the debug oracle, 100 seeds and the +shrinker at 400 trees of depth 5 pass; the oracle at 1000 seeds of depth 6 +diverges on two. Both reduce to a self-sized container whose answer changes +under a local redraw: + +- seed 532, `reorder`: `Pad{0} > Stack[ Span{RIGHT}[ Wrapped, Wrapped, + Rect[x:178px, y:123px] ] ]`, rotating the span's children. Warm places + a text at 947..1115 x 536..659, cold at 986..1076 x 483..712. +- seed 398, `every-size`: `Span{LEFT}[ Rect, Rect, Stack[ Rect, + Branch{ probe: Wrapped, wide: Rect, narrow: Span{DOWN}[ Rect[x:129px, + y:leftover] ] } ] ]` with a size rule on the stack. Warm places a widget + at 11..183, cold at 6.5..187.5 on x. + +Reduce them with `SHRINK_SEED= SHRINK_DEPTH=6 SHRINK_CASE=` on +that branch before building on it. + +**The cross-axis frame is the protocol question, not the pin.** Bryan's +rule that a widget's frame does not change between the measuring and the +placing ask is broken across every span, because the only sensible +reference for a child's fraction across a span is the span's own box, and +that box is the span's answer. `e44dea3` has the same double pass and pays +for it by deferring to the parent; what it does not have is a frame that +changes under a widget. Whether to keep frame and placement apart at all +turns on this and on nested-span semantics; see the 2026-09-17 assessment +handed to Bryan. ## How layout is decided