Record the derived-box rule and what the fuzzers found in the handoff

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-15 02:17:29 -04:00
1 parent 98dcb31c23
commit 39409b6683
1 file changed
+61 -22
+61 -22
View File
@@ -9,15 +9,20 @@ Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are
in. in.
**#18 `split/18-position-chain`** is open and finished apart from one decision: **#18 `split/18-position-chain`** is open and finished apart from one decision:
worktree `/home/bob/repos/iris-pr18`, head `3f7cd82`, twenty-four commits, worktree `/home/bob/repos/iris-pr18`, head `f61e893`, thirty-four commits,
workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position
chain, generalised to boxes. The last six are a performance pass that changes chain, generalised to boxes. After the performance pass (`e5f8b6b` to
no layout: the two halves of the text fix (`e5f8b6b`, `2525637`), three `3f7cd82`) came the fuzzers and what they found: the shrinking fuzzer
retained-layout details that claimed something they did not do (`f1a47e9`), (`b7caab3`, `386a0d1`), `SetSize` measuring in its declared length
the many-widgets-dirty cases (`77bb75e`), what the settle order is holding up (`c596bf1`), text re-breaking across the interval a break holds for
(`bf94380`), and carrying a widget's depth rather than walking for it (`9913194`), and a dirty widget measured where its parent asked rather than
(`3f7cd82`). Every one of them keeps the five reference renders and the resize in a box its own answer decided (`02ff8c7`). `f61e893` restores `abs()` in
render byte-identical, and the hundred-seed sweep passes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. the rect shader, which `7c50a3e`'s rename had taken with it so that every
window failed shader validation while `cargo test` stayed green, and adds
the device-free shader validation test that would have caught it. The five
reference renders and the resize render are byte-identical across all of
it, and the hundred-seed sweep passes. `/home/bob/repos/ai-app-2` is on
`rustify`, worktree clean.
**The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer **The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer
byte-identical to `upstream/main`: 1,283 pixels of 2.3M (0.06%), two byte-identical to `upstream/main`: 1,283 pixels of 2.3M (0.06%), two
@@ -70,7 +75,7 @@ Invariants, not history. Everything in `core/src/ui` rests on them.
tree depth, which is the difference between free and +42.6%. 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 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. a placed widget draws against `UiRegion::FULL` and its box lives in its slot.
`ActiveData::region` is the box it was *offered*, in its parent's 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; coordinates, and `ActiveData::parent_move` is the slot that is in;
`window_region` composes the one through the other, which is the walk the `window_region` composes the one through the other, which is the walk the
shader does. shader does.
@@ -273,10 +278,14 @@ aligned size-changing chain.
The branches are invariants rather than widget exceptions: The branches are invariants rather than widget exceptions:
- A dirty widget draws locally first only while its retained box has the same - A dirty widget draws locally first only while its retained box has the same
pixel size. If its returned `Size` is unchanged, no size reader can observe pixel size **and that box is a constraint rather than its own answer**. If
the repaint and no ancestor draws. If the size changed, the dependent path its returned `Size` is unchanged, no size reader can observe the repaint and
lays out. A changed pixel box takes the conservative path first, which is the no ancestor draws. If the size changed, the dependent path lays out. A
condition the discarded `/tmp/escalate.patch` missed on resize. 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 - 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. reader; propagation stops as soon as a reader's own answer stays unchanged.
Output resize is the distinct invariant: all pixel-dependent leaves and Output resize is the distinct invariant: all pixel-dependent leaves and
@@ -537,9 +546,9 @@ 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 break holds. `TextBuffer::shape` answers across that interval, with a
sub-pixel tolerance for the edge. sub-pixel tolerance for the edge.
**What is left is not a stale drawing -- the layout has two answers.** A span **A box that is its own answer.** A span measures its children in its own
measures its children in its own box, and its own box is what its parent gave box, and its own box is what its parent gave it, from the size it reported,
it, from the size it reported, from those children. Four widgets: from those children. Four widgets:
Aligned(mid, -, Span[ Text(wrap), OneLine ]) Aligned(mid, -, Span[ Text(wrap), OneLine ])
@@ -550,12 +559,42 @@ 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 318.45, and the span settles at 624.38. **Both are stable.** Which one you get
depends on what the tree was before. depends on what the tree was before.
So no rule about when to keep a drawing can fix it, and several were tried. 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 (`Painter::settle` -- place a child into its own reported size without
it there -- passes all four cases in `tests/unsettled.rs` and fails two in measuring it there -- passed the hand-written cases and failed two generated
`generated.rs`, in `Aligned` alone, in `Span` alone, with the child force-drawn ones). The defect is in *where a dirty widget is measured*: a local redraw
first or not. The fix wants the constraint a container measures under to be draws it in the box it was placed in, and when a reader decided that box from
something it is *given* rather than something it *ends up with*. 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 `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: axis or report its longest child -- is written and parked for the same reason: