Bring the handoff up to date with what the fuzzer found

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-15 01:42:13 -04:00
1 parent 1ffe6ea067
commit 98dcb31c23
1 file changed
+86 -32
+86 -32
View File
@@ -509,42 +509,96 @@ would catch anything where keeping a drawing is what goes wrong, which is what
weaker instrument, because building a second tree is not the same code path as
refusing to reuse the first.
### A span's size across its own axis
### Why a warm frame and a cold one disagree
`Span` reported `max(children abs)` across its axis, except that one child with
any `rel` or `rest` flipped the whole span to `Len::REST` -- a discontinuity
its own `TODO` admitted. So any disagreement about a child's reported size
moved the span between tight and filled rather than by a little, and every
child is placed `FULL` across the axis, so they all inherit the move together.
That is what the depth-5 seed-10 divergence looks like: three texts, identical
heights, all jumping from 874 px wide to 306.
Three defects, found on 2026-09-15 by shrinking a fuzzer's counterexamples.
Two are fixed. The third is the one the others were hiding.
`OrthoSize::{Fill, Children}` replaces it, chosen per span rather than inferred
from what the children happen to report. `Children` propagates the largest
child whole -- a child at `rel` 0.5 makes the span `rel` 0.5 -- and compares
candidates in pixels only when one has a share the other does not, since that
is the only case the components cannot be compared directly. Reading pixels
costs `OnResize::Scale` across the axis, which is why the choice is explicit.
**A first frame was wrong, and nothing about retained state was involved.**
`SetSize` drew its child in whatever box it had been offered and then reported
its *declared* length, so the child answered about a box it was never going to
have -- and the answer on the other axis was taken under that. A wrapping text
under `SetSize(x: 76px)` was measured in the whole 640 available, said one
line, and the parent sized itself to one line; the text was drawn again at 76
and said two, too late. It now measures in the length it declares. Six widgets
reproduce it in `tests/unsettled.rs`.
**It is circular, and this is the open question.** `Children` reads the span's
own box to decide which child is longest, and what it reports decides that box:
offered 900 it may report 306, be given 306, and on the next draw compare
against 306 and pick a different child. `adding_and_removing_span_children`
seed 13 diverges in release with 112 widgets wrong, at `DEPTH = 4`. The
comparison wants a reference that does not depend on the answer -- the offered
box rather than the settled one -- which is stable until a `Children` span
nests inside another. This is the cross-axis twin of the along-axis
double-shaping in LAYOUT.md §4, not a separate defect.
That one matters beyond itself: **`generated.rs` compares a warm frame against
a cold one, and cold was not a fixed point either.** Some of what this document
previously called a retained-layout defect was the cold side being wrong.
**Depth 4 is not enough.** `tests/generated.rs` ran at `DEPTH = 4` and the
generator branches two to four ways per level, so depth is exponential in width
and a deep tree cannot be reached by raising it. `IRIS_GENERATED_DEPTH` and
`IRIS_GENERATED_SEEDS` now select the load. At depth 5 the first 300 seeds
already fail -- seed 10, `AddThree`, a wrapping text under a span whose axis is
**Y**, which the comment on `a_long_run_of_seeds_agrees` claims is the stable
case. That claim is wrong, and the "7 of these 90 / 30 with it" numbers beside
it do not match a sweep that passes clean at depth 4; re-measure before
trusting them.
**Re-breaking a text at its own longest line is a knife edge.** A parent that
sizes to a child offers back the length the child just reported, composed back
through the box chain -- so it lands an ulp either side, and which side decides
whether the longest line still fits. Three lines at 167.41 or four at 163.49,
from the same text in the same box. A greedy break does not need recomputing
there: breaking at one width gives lines that each fit and none of which could
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:
Aligned(mid, -, Span[ Text(wrap), OneLine ])
Swap the two children of a live tree and the span is still 663.376 wide, so the
text is offered 357.44, which is what it already holds; the size is valid, the
span reports 663.376 again, nothing moves. Grow the same tree in that order and
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*.
`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:
`Children` reads the span's own box to rank candidates, which is this cycle
with a second face. It is not the cause of anything, and the divergence it was
blamed for reproduces without it.
### Shrinking a counterexample
`tests/generated.rs` is a fuzzer, and a seed is not a lead anybody can read:
reconstructing one of its failures by hand failed three times. Two things fixed
that, and both are worth keeping.
`describe` prints what each ancestor of a mismatch was *configured* with rather
than its type name, so a run says `Text < SetSize{x:34 px;} < Aligned{x:neg,
y:pos}` and the tree can be written out again. `Widget: Any`, so it needs no
plumbing.
`tests/shrink.rs` grows trees from a description it can simplify -- drop a
child, unwrap a wrapper, shorten a text, drop a declared length, reorder a
span -- and takes the first simplification that still fails until none does. It
reduced 402 widgets to 6, 905 to 6, and 486 to 4. It lives in the tests and the
library knows nothing about it. Validate it after changing it: with the
box-length check in `try_reuse` deliberately disabled it should reduce a
96-widget tree to 2, and an early version of it silently found nothing because
it never framed before resizing, so "warm" had no retained state at all.
Run the ordinary tests first, then the fuzzers, and turn what they find into a
fast test rather than leaving a seed as the record.
**Depth 4 was hiding all of this.** `tests/generated.rs` ran at a constant
`DEPTH = 4`; `IRIS_GENERATED_DEPTH` and `IRIS_GENERATED_SEEDS` now select the
load, and `SHRINK_DEPTH`, `SHRINK_SEEDS` and `SHRINK_CASE` do the same for the
shrinking fuzzer. The generator widens two to four ways per level, so depth
buys overlap between dependency paths rather than ancestry. Nothing yet covers
a deep narrow chain, which is a gap: Iris asked for high layer counts on
2026-09-14 and a hundred-deep tree is still unreachable by turning this knob.
The comment on `a_long_run_of_seeds_agrees` blames a wrapping text on a span's
own axis and says such a text is stable on any other axis. Both halves are
wrong -- a divergence was found on a span whose axis is Y -- and the "7 of
these 90 / 30 with it" numbers beside it do not match a sweep that passes
clean. Re-measure before trusting them.
### Dirtying many widgets at once