15 KiB
Layout findings log
What the sessions working on Iris's retained layout found, planner and
worker alike, kept so that nothing here is rediscovered. Each entry says who
found it and when. Delete this file when transparent frames lands; what
must outlive it (settled design, the measurement method) is already in
docs/LAYOUT.md, and the current plan is in docs/HANDOFF.md. Commit ids
are in /home/bob/repos/iris-layout-experiment unless said otherwise.
The two open shrinker seeds on wip/transparent-frames (planner, 2026-09-18)
Both are the rule in draw_inner that decides which answer places a widget's
box: measured = if info.offer() { fresh } else { retained } with
offer() := place == offer_place. That bit is meant to say "this ask is a
measurement" and has no consistent value once a container's body runs in
more than one box.
- Seed 2,
repaint, depth 5 shrinks toStack{sized by child 0}[x: leftover] > [OneLine, Pad{0} > Span{DOWN}[Rect, OneLine], Branch]. The stack's box is one text line tall. The span measured in the stack's room has leftover room, draws the rect and reportsleftoveracross; in the stack's box it has none, undraws the rect and reports the text's width. Cold kept the first because the stack's placing evaluation reused the pad's retained answer, which looked valid only becausein_parent'sPart::Ofarm drops the span'sextent_lenpin. Warm re-asked the span in the box and got the second. The correct layout is the second: the stack's non-sizing children belong in the stack's box, and the measuring pass drew them in a box the stack never has. - Seed 108,
reorder, depth 5 shrinks toSpan{LEFT} > [Span{LEFT} > [Span{DOWN} > [Branch{probe Rect, wide Rect, narrow Wrapped, 483}, Wrapped], Rect], Rect]. A draw trace shows theDOWNspan evaluated at 900, 450, 600 and 300 px across in one cold layout (room of the outer span, slot inside the inner span's measuring pass, slot of the inner span, slot inside that), the wrapping text re-shaped at each. In the last one the branch's narrow text is drawn in its real 300 px box and answers 286 px;draw_innerdiscards that for the retained 438.9 px answer from the 450 px evaluation because the branch'sbelowplace embedsextent_len(Y), which moved when the sibling text's height changed, soplace != offer_place; the text is redrawn 438.9 px wide in a 300 px box. Warm does the same with a different stale answer (286 px from the first frame). The same shape isunsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered.
Two one-line experiments, both reverted, run at 49cec82 with the fast
oracle, the debug suite and the shrinker at 400 seeds of depth 5:
| change | seed 2 | seed 108 | else |
|---|---|---|---|
compose the pin through Part::Of (len.px - part_len.px where part_len.rel == ONE) |
fixed | fails | seed 220 reorder and one suite test fail |
measured = answer.0 always, and every ask records answer/offer_part |
fails | fixed | seeds 184, 246, 292, 372 and two suite tests fail, all under Scroll |
The second experiment's failures are the case the gate was secretly holding
up: a container re-drawn in a box its own answer derived. The specific one
is Scroll, whose apply_leftover content length adds a whole viewport on
top of the content's pixels (a row reporting 600 px + leftover in a 900 px
viewport gets a 1500 px content box), so the wrapping text inside is offered
900 px more room than it was measured in and re-wraps; the layout drifts one
iteration per evaluation, and warm and cold differ by how many they ran.
Bryan: that content length was always wrong; a share is the room left in the
viewport, so content = max(viewport, px + rel*viewport). With it the
content box never re-offers room and Scroll needs no own-answer
evaluation.
The many/resize cost is the same mechanism in miniature: four
evaluations of a ten-widget subtree in one cold layout, from Within(cursor.. far) measuring rooms followed by Fill(slot) placing, with the span's
extent_len pin forcing a redraw at every box length.
What the plan of 2026-09-18 got wrong (planner, same day)
The plan claimed the retained model rests on the frame's length being the
same on every ask. Answers depend on the part (the box offered), not the
frame, for every widget that reads its box, so transparent frames fixed
fraction resolution and moved the second geometry from the frame to the
extent rather than removing it. Step 6 ("redraw without the deferral; both
seeds must pass now because the frame no longer changes under the widget")
followed from that claim and was false for the same reason. The plan also
carried offer_place ("from the first ask of the parent's draw at the
offer") forward from the old protocol without noticing it is undefinable
once a container is evaluated in several boxes, and its Inset sketch
assumed a narrowed frame and a placed extent could coexist, which
frame_and_extent collapses ("a narrowed frame is its own extent") and which
the extent-in-frame-coordinates representation cannot express in general
((0.5*B - 20)/(B - 20) is not rel + px). The plan also wrote "a frame is
narrowed only by a declared length, an inset or the root" and the code's
declared_lens filters leftover out; Bryan: a share was always meant to
narrow the frame like the other two.
The worker executed the plan as written, found exactly this ("whether a
given draw is a measurement cannot be recovered from the ask"), tried four
bookkeeping rules (first-ness alone, place matching the retained offer place,
box matching the measured box, a retained flag on the drawing), each fixing
some seeds and breaking others, restored the deferral and stopped. That was
the right call. The plan as handed over is at ai-app-2 152bed7 if the
wording is ever needed.
What the worker measured on wip/transparent-frames (worker, 2026-09-18)
Widget draws / distinct widgets / update ms from tests/layout_diagnostics.rs
at depth 8; draw counts are deterministic so these are single runs.
e44dea3 is #18's head, 34cafb6 the commit the branch starts from,
49cec82 its head.
| seed 1 | e44dea3 | 34cafb6 | 49cec82 |
|---|---|---|---|
| cold | 369/261/10.6 | 463/274/13.3 | 516/288/12.0 |
| repaint | 1 | 1 | 1 |
| many | 157/95/0.33 | 263/108/0.59 | 187/119/0.52 |
| size | 16/12/0.018 | 3/3 | 3/3/0.010 |
| scroll | 2/0.002 | 1 | 1/0.004 |
| resize | 13/13/0.019 | 22/15/0.032 | 24/76/0.090 |
| seed 13 | e44dea3 | 49cec82 |
|---|---|---|
| cold | 1330/707/20.3 | 2940/982/28.3 |
| many | 524/159/1.09 | 1091/423/2.39 |
| resize | nothing drawn | 2215/510/6.56 |
Three findings, each applied on the branch:
- Lazy
Withinplacement costs more than it saves. Leaving a child's answer to be placed at the end of the parent's draw puts the drawing in the part first and in the answer's box after; where it does not hold for both that is two drawings. Seed 1's resize went from 391 widget draws to 29 with it removed. - An inset said in frame lengths makes a container read its own box.
"Less eleven pixels at the end" needs the length, and a container whose
box is its own answer then depends on its own answer:
Paddrew sixty-four times in one resize frame at seed 13.Part::Ofsays it as a part of the box and composes without a length. - Pin one axis at a time.
extent_lenpinning both axes made a span dividing one hold for one length of the other.
Part::Of's in_parent arm drops a child's extent_len pin (only All
composes one), which is what let seed 2's pad reuse an answer for a box its
span had never been measured in.
Reference renders at 49cec82 against 34cafb6: view and minimal
byte-identical; tabs 2,332 pixels; text shows wtext(..).width(rel(1.0))
inside .pad(16) at the window's edges. Under the decided share rule that
example is correct as written: once the share narrows the frame, rel(1.0)
is the padded share and fits. The clipped render records the current bug.
random moves where nested spans do.
Defects landed before transparent frames, and their lessons (2026-09-17 review)
- A report is a fraction of the containing widget (
ffd79f3). A report used to come back composed through the box it was offered, and a span offers each child the room from its cursor, so a nested span taking half of what it was given took a quarter of a row whose first half was spoken for. Consequence: a span can overflow itself without bound, so itsfixed <= 0branches are ordinary and boxes of negative length reach children; nothing yet states what a widget may assume about one. - An answer is not an answer while anything under it is dirty
(
0e0d4af, superseded bya0693ac).dirty_size_underwas what made an answer an answer until the settling walk made the state it guarded against unreachable. Found at seed 564, depth 6,shuffle-every-other. - A frame settles strictly bottom-up (
a92c6ac,a0693ac). A widget that cannot settle defers to its parent rather than drawing the parent from inside itself;updatemarks the root for a resize instead of drawing it top-down, and only where the new output falls outside what the root's answer holds for. Sound by induction on depth. Bryan: "then that entire category of issue can't even occur." - A text is handed back a box its own line fits in (
4bd8607): reportceilof the shaped size; the two tolerances that were holding it together (BREAK_EPSILON_PX, a nearest-stepHoldsstart) both went. - A subtree that changes hands is recorded on both sides (
e44dea3): the old parent's child list is repaired and the subtree's depths re-walked where its top moved. The fuzzer never re-parents, which is why nothing generated reached it. - A span's leftover boundary is its own inverse (
53b00c6): forty lines became twelve and onedivleft layout. wip/stack-fraction-twice(a stack sized by a child reportingrel(0.5)applied it twice) is closed by transparent frames:placed_extenttakes the reported length from the part rather than composing it into the part. No oracle could see it; the branch's test pins it.wip/padding-outset-and-insetis superseded: padding goes outside what it pads, and itsrel(0.5)-under-inset failure was the same second application.- Four findings from the frame/extent prototype still shape the code: an
answer and a drawing each retain their dependencies (
answer_underandunder); a wider contract does not invalidate an existing guarantee; no measurement is different from a measured zero (ActiveData::answeris optional); the settling walk takes the deepest mark from aBTreeSetkeyed by depth and what ends it is the mark set.
Failed hypotheses worth not repeating
- The placement pin was blamed for the
manygap and is not the cause (2026-09-17). Disabling it still redrew 487 distinct widgets a frame at seed 13 against 159; the per-widget trace showed local redraws deferring to their parents in chains to the root because a span handed its children its own placement as their frame. Transparent frames fixed that part. wip/local-reaskre-asked a dirty widget at its offer instead of deferring, under the old protocol, and diverged at seeds 532 and 398 of depth 6. Superseded; the branch can be deleted.- An offer composed back up the move chain fell back to
FULLunder a region node and was resolved against that node's placed box, so everything under aScrollwas re-asked at the content's width. Pinned byunsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered. - Four bookkeeping rules for "which draw is a measurement" (worker) and the two experiments above (planner): each fixes some seeds and breaks others. The bit is undefinable; stop trying to define it.
- Choosing between a fixed and a relative child in pixels at the span's
current width admits multiple self-sizing fixed points; seed 13 settled
differently warm and cold under it. The same circularity is what a cap
containing
leftoverwould put intoSizeRule::Max. - A tolerant endpoint on the span's leftover split retained zero-height
children at seed 16. Pinned by
unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over. Scrollreturning the final placed answer rather than the first box's advanced one fixed-point iteration (seed 86). Moot once the content box never re-offers room, but keep the test until that is measured.- A
git bisectonce named a commit that could not be the cause; read the tree rather than the bisect when that happens.
Fuzzer coverage
- Seeds 1121 and 1839 at depth 4 failed on every commit before
4bd8607and nothing routine reached them: the fast oracle takes ten seeds, the shrinker 400 at depth 5, the long oracle 1000 at depth 6. The scan that found them (2000 seeds at depth 4 over all fifteen cases, 261 s) should be run after any layout change.Rng::newisseed | 1, so an even seed and the odd one above it are one tree. - The shrinker panics per thread at the first failing seed, so a run reports at most one seed per chunk of about 58; a seed listed as new after a change may have been hidden behind another in the same chunk. Check a single seed against the unpatched code before calling it new (seed 220 was checked this way and is new under the pin experiment).
- Depth finds things and so does breadth; widen one axis at a time and record which.
Smaller open items (carried from the old handoff)
- An undrawn
leftoverchild still contributes its gap, so a vanished child leaves a double gap. - Nested spans pass
leftoverweight up, so three leftover children in one inner span beside one in another get three quarters to one quarter. No other layout system does that; confirm it is wanted. Fixed::divby zero answersMIN/MAXwhileratioanswersZERO; both are caller bugs underdebug_assert, but the fallbacks differ.- The comment on the
local == UiRegion::FULLshortcut inwidget_atsays composing throughFULL"is not quite the identity in f32"; on the grid it is exact and the shortcut is performance only. docs/LAYOUT.md§4, §5 and the density section namePainter::place,SetSize,desired_width,apply_rest,Len::dp,AlignedandMaxSize, none of which exist. Do not restoreOnResize::TranslateorOrthoSize.tabschanged twice acrossd3b0ebfwith nobody looking; take the oracle as the reference and the five renders as a spot check.