From 98dcb31c23c95a111bbfe5d0b38f5d36c3adfcb9 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 01:42:13 -0400 Subject: [PATCH] Bring the handoff up to date with what the fuzzer found Co-Authored-By: Claude Opus 5 --- docs/IRIS_EXTRACTION_HANDOFF.md | 118 +++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index aa6181f..34906bb 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -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