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.
**#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
chain, generalised to boxes. The last six are a performance pass that changes
no layout: the two halves of the text fix (`e5f8b6b`, `2525637`), three
retained-layout details that claimed something they did not do (`f1a47e9`),
the many-widgets-dirty cases (`77bb75e`), what the settle order is holding up
(`bf94380`), and carrying a widget's depth rather than walking for it
(`3f7cd82`). Every one of them keeps the five reference renders and the resize
render byte-identical, and the hundred-seed sweep passes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean.
chain, generalised to boxes. After the performance pass (`e5f8b6b` to
`3f7cd82`) came the fuzzers and what they found: the shrinking fuzzer
(`b7caab3`, `386a0d1`), `SetSize` measuring in its declared length
(`c596bf1`), text re-breaking across the interval a break holds for
(`9913194`), and a dirty widget measured where its parent asked rather than
in a box its own answer decided (`02ff8c7`). `f61e893` restores `abs()` in
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
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%.
- **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.
`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;
`window_region` composes the one through the other, which is the walk the
shader does.
@@ -273,10 +278,14 @@ aligned size-changing chain.
The branches are invariants rather than widget exceptions:
- 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
the repaint and no ancestor draws. If the size changed, the dependent path
lays out. A changed pixel box takes the conservative path first, which is the
condition the discarded `/tmp/escalate.patch` missed on resize.
pixel size **and that box is a constraint rather than its own answer**. If
its returned `Size` is unchanged, no size reader can observe the repaint and
no ancestor draws. If the size changed, the dependent path lays out. A
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
reader; propagation stops as soon as a reader's own answer stays unchanged.
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
sub-pixel tolerance for the edge.
**What is left is not a stale drawing -- the layout has two answers.** A span
measures its children in its own box, and its own box is what its parent gave
it, from the size it reported, from those children. Four widgets:
**A box that is its own answer.** A span measures its children in its own
box, and its own box is what its parent gave it, from the size it reported,
from those children. Four widgets:
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
depends on what the tree was before.
So no rule about when to keep a drawing can fix it, and several were tried.
`Painter::settle` -- place a child into its own reported size without measuring
it there -- passes all four cases in `tests/unsettled.rs` and fails two in
`generated.rs`, in `Aligned` alone, in `Span` alone, with the child force-drawn
first or not. The fix wants the constraint a container measures under to be
something it is *given* rather than something it *ends up with*.
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 it there -- passed the hand-written cases and failed two generated
ones). The defect is in *where a dirty widget is measured*: a local redraw
draws it in the box it was placed in, and when a reader decided that box from
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
axis or report its longest child -- is written and parked for the same reason: