Trace the many-phase gap to local-redraw deferral, not the placement pin

The experiment's `many` loss against e44dea3 came from `redraw` refusing
any widget whose given frame is not its offer and marking its parent
instead, which under the frame/extent protocol chains to the root because
a span gives its children its own placement across itself as their frame.
Record the trace, the measured bound with the pin off, the local re-ask
branch `wip/local-reask` and its two remaining depth-6 counterexamples,
and point readers at this section before the earlier explanations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-17 20:52:15 -04:00
1 parent 12b7364998
commit cb4557f1bc
1 file changed
+78 -30
+78 -30
View File
@@ -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=<seed> SHRINK_DEPTH=6 SHRINK_CASE=<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