Files
ai-app/docs/LAYOUT_LOG.md
T
iris-ai 5798877829 Mark the rename step done and say what it renamed
The record's boxes are part, asked and placed, LayoutHolds carries window
ranges beside its two pins, and what is left of the layout plan is the
retained cost and the review of the branch as one diff.
2026-09-19 00:40:53 -04:00

34 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.

What making the frame a length found (worker, 2026-09-19)

Step 2 of the handoff, implemented over 0ef87eb in /home/bob/repos/iris-layout-experiment. Six of these are corrections to the plan, not to the code that implemented it, so they are worth keeping even after the step lands.

  • A narrowed frame is a window length, not a fraction of the parent's frame. The plan said both at once: Pad narrowing by rel 1 - 32px (a fraction) and Span narrowing by slot.len() (a window length). The first implementation resolved every narrow against the parent frame, which took padding off twice for anything inside a padded row -- a leftover(1) slot of a 900 px row inside pad(16) gave its child a frame of 418 where the slot is 434. A slot cannot be written as a fraction of the row's frame: that is a division of two lengths, the same argument that forced region node entries to be translations. So narrow is a window length, Pad reads its own frame with the new Painter::frame_len and takes the pixels off, and nothing resolves a narrow a second time. Pinned by layout::a_share_inside_padding_fills_the_slot_it_was_given.
  • The root resolved its own rule twice, because root_layout passed the declaration as both the narrow and the declaration: a root of rel(0.5) in a 900 px window came out 225 wide. Pinned by layout::a_root_with_a_fraction_rule_is_that_fraction_of_the_window.
  • Which box arm in_parent takes is decided by the box, not the frame. The first implementation treated any decided frame -- a narrow, a share, a declaration -- as a box this widget chose, so a Pad (which narrows) lost its child's box pin, and a rule changed over two zero pads relocated the column under them instead of dividing it again (seed 59, depth 5, resize-size). Only a declaration places the box inside the part it was given; a narrowed frame leaves the box exactly the part. Pinned by unsettled::changing_a_rule_over_two_pads_divides_the_column_again.
  • Frame validity is a pin, not a range. A range of window pixels cannot say "this answer is a fraction of that frame": two frames are different lengths at the same window size. LayoutHolds therefore carries frame_len beside extent_len, set where a widget reads its frame (frame_len, which Pad does) and where a rule that is a fraction of the frame is answered with it, and composed up where a length of this frame is what reached the child. It replaced a holds_for_frame helper that compared expressions and asked Holds::ANY whether anything depended on them, which missed the rule-answered case entirely. No test in the repo distinguishes the rule-answered half: 400 seeds over sixteen cases at depth 5 agree with it and without it, and hand-built trees for the shape the seed-30 records showed (a stale 1 rel answer beside a stack's sizing child) do not reproduce it either, because a frame that changes almost always changes a box as well and the box pins catch it. Kept anyway, because "these two invalidations always coincide" is the kind of unstated assumption the three earlier plans died of; it is one line at each of two sites if it is ever shown to be dead.
  • Scroll resolved its content length against its own box. With one unit its child's answer is a window length, so it becomes pixels against the window; the two differ wherever a scroll's box is not its frame. Fixing it then broke resize, because resolving a length against the window is a read of the window and nothing recorded it: a viewport 40 px tall inside a branch's box does not change when the window does, so an end-snapped scroll kept the offset it had (seed 942, depth 6). Painter::to_px takes the read where the resolution happens and pins the window only where the length has a fraction in it; window_px_len, which returned the number and recorded nothing, is gone. Pinned as unsettled::resizing_under_a_short_scroll_snaps_its_window_tall_content_again.
  • A local redraw resolves its own frame from its record's narrow and declaration (ask_again, shared with place_in) rather than reusing the retained frame. That deleted asked_px and the assert that the two agree: they legitimately disagree when an ancestor's frame changed and the ancestor was reused because nothing under it pinned the frame, and the freshly resolved one is the right one.
  • The clipping debug assert compared the reported size with the frame rather than with the box it is a claim about.

Three rig changes came out of it, all kept:

  • The shrinker prints the shrunk tree's divergence rather than the grown tree's, and the divergence now lists, for the failing widget and every ancestor, warm and cold frame, ask, box and size. Reading that table is what found the box-arm defect; reconstructing a 20-widget plan by hand would not have.
  • The fuzzer's Branch states the range it branched on rather than pinning the window it read the measurement against (adbedaf). It is the only widget in the fixture that reads a length in pixels and chooses a subtree from it, so with a pin every one of them redrew on every resize: seed 1's resize was 131 widget draws and seed 13's 828, against 44 and nothing once it says the range. That is a fixture that cannot tell a change that reuses well from one that does not, and it looked exactly like a regression in the change being measured.
  • A size-resize case: a size change and then a resize. Every other case changes something and compares at once or resizes first, so an answer kept as a fraction of the wrong length agreed at the window it was made at and parted from cold at every other one.

Checks at the end of the step (a30971e, and 23523ee before the rig and the renames): cargo fmt --all --check, clippy with and without layout-diagnostics, 122 suite tests, 20 core, 11 generated, the 400-seed depth-5 shrinker over all sixteen cases, the 1000-seed depth-6 oracle, and the 2000-seed depth-4 scan over all sixteen cases.

What the renders showed, at 1920x1200 against e44dea3 (#18), with the examples identical in both checkouts and Vulkan confirmed (the example asks for Backends::PRIMARY and would have aborted on the GL fallback):

  • minimal and tabs are byte-identical, and so is tabs after the reference replay -- which changes 94% of the screen, so it is a real comparison and not two pictures of nothing.
  • random differs in two pixels, each by one grey level: glyph antialiasing, nothing deleted.
  • text differs only in its third block, the rel(0.5) padded paragraph in a row: the block is the same width (945 px) and wraps the same lines, and sits one pixel further right. Its box is 948 px, so the drawing is centred with a 1.5 px remainder and the two expressions round that half-pixel the other way. The first two blocks, which have no declared width, are identical -- padding coming off the frame does not move a text that wraps at the box it was given.
  • A live resize of random to 900x1200 is byte-identical to a cold render at 900x1200, which is the warm/cold rule checked in pixels rather than in regions.

One thing is unexplained and worth knowing about: seed 30 at depth 5 (shuffle-all-but-first) failed once, with a span keeping an answer of 1 rel for an axis whose frame had become 78 px, and then stopped failing before the pin was added -- reverting each part of the change since that run did not bring it back. Either an edit fixed it that I could not identify, or the rig is not as deterministic as it looks. If a seed ever fails once and not again, suspect the second.

What the one-ask protocol found (planner, 2026-09-18, third session)

Branch wip/one-ask at 3091fb8 over 4328eac. The change is described in docs/HANDOFF.md; this is what building and fuzzing it turned up, in order.

  • The step 3 assertion was the wrong check. At the worker's stopping point every generated case stopped on assert_eq!(extent, info.part) in place, on a Text asked at 45 px in Within(All) whose longest word is 89.5 px: its drawing holds for [45, 45] and its answer box is 89.5 px wide. No leaf can promise its drawing holds for a box chosen from its answer; the plan's sentence "every drawing must hold for the answer box it supplies" was the defect, not Text. Removing the check and the redraw it guarded -- placement is re-expression, unconditionally -- made the suite green including both decided-box pins, with nothing else changed.
  • Six retained tests encoded the paired draw (settled + 2, "drawn once" expecting two): they now expect one draw. Two more failed because Stack had been changed to ask non-sizing children with Fill(All); they need Within(All) so a smaller answer sits inside the stack, with the frame narrowed to the sizing answer. That is the whole of the suite churn.
  • Seed 2 at depth 4 (repaint-some, region-node) shrinks to a Stack sized by one text. A local redraw of the text asked it again in the box the stack was asked in and left it filling that; cold puts it in the box the stack's answer chose. A local redraw must put the fresh drawing back at the retained place of the parent's answer box, always, not only when the asked and placed places differ.
  • Six seeds at depth 5 (60 repaint, 308 every-size, 20 region-node, 248, 384, 162 reorder) shrink to one shape: a zero Pad round a widget reading extent_len (a span with a share rect, or a Branch), as a share child of a span beside a fixed sibling. Seed 248 reproduced: cold was wrong. In the measuring pass the inner span read far = rel 1 - 17.6px and wrote slots in it; in the placing pass the pad got a narrowed frame of the same pixel length, was reused (its contract had no pin, because in_parent's Part::Of arm drops a child's extent_len), and its child was re-expressed into a frame where rel 1 means 17.6 px less. The inner span's own record had the pin; only the composition lost it. Composing the pin through Of -- exactly where the part is the whole box less pixels, pinned - part.px, and pinning the parent's own length otherwise -- fixed all six; the planner's earlier version of the same experiment broke seeds under the old protocol because the second draw was still there.
  • Cost after the fix (widget draws / distinct widgets, depth 8): seed 1 cold 331/288, many 78/78, size 3/3, scroll 1, resize 40/15; seed 13 cold 1179/982, many 335/333, resize nothing drawn. The table with the baselines is in the handoff. many at seed 13 touches 333 distinct widgets because a share child is asked twice per span draw, and resize at seed 1 draws 40 because positive-direction spans without shares pin far; both are step 4 there.
  • At 3091fb8 the 2000-seed depth-4 scan agreed over all fifteen cases (82,203 widgets, 269 s) and the 1000-seed depth-6 oracle found seed 946, shuffle-all-but-first, which shrinks to nine widgets: Span{LEFT}[Rect, Span{RIGHT}[Rect, Span{DOWN}[Wrapped, Rect, Wrapped].height(159), Rect]], the column emptied to its first text. Reproduced with the records printed: the column reports a share along the row while its rect has room (both texts wrap at the whole row, 35 px each, under 159) and a fixed width once asked in its third of the row (each text 88 px, no room, rect undrawn, cross-axis answer a pixel width). So the row asked it twice and built its slots on the first answer and its cross size on the second. Emptying the column changes only the first; the local redraw re-asked the second, found it unchanged, and told nobody. Cold has the column 874 px wide. Fixed in 0ef87eb by deferring any widget its parent asked twice in one draw to that parent, pinned as unsettled::emptying_a_column_the_row_asked_twice_asks_the_row_again (checked red without the deferral, green with it). Cost: many at depth 8 went from 78/78 to 110/92 draws/distinct at seed 1 and from 335/333 to 424/364 at seed 13; still under #18's 157/95 and 524/159 draws. At 0ef87eb the 1000-seed depth-6 oracle (148 s) and the 2000-seed depth-4 scan (262 s) both agree over all fifteen cases.

What the step 3 plan got wrong (planner, 2026-09-18, third session)

Three things, each a different kind of mistake:

  1. It kept the question the machinery could not answer. The offer bit existed because a widget was drawn twice, once where asked and once where its answer placed it, and something had to say which draw was the measurement. Both plans of 2026-09-18 tried to define that bit better (the worker's four rules, the planner's two experiments). The repair is to have one draw, after which the bit has nothing to name. A plan that proposes bookkeeping for a distinction should first ask whether the distinction has to exist.
  2. It stated the contract at the wrong box. "Every drawing must hold for the answer box it supplies" reads as a tightening and is actually unsatisfiable for any widget that reads its box. The right statement is the opposite: the answer box is never a question, so nothing is demanded of the drawing there; Holds is about the ask box only.
  3. It narrowed frames by region. The worker's share frames were extent_part(axis, From(span)), a position in the span's frame; a frame given as a position does not move when its part moves, and re_ask had no way to put it back. A narrowed frame must be a length of the parent's frame, placed into the part by alignment on every placement, exactly as a declared rule already was.

The requirements did change under the plans (transparent frames, then shares narrowing frames, then Scroll's content length), which explains some churn but not the three above; those were reasoning errors that a five-widget reproduction with the retained records printed would have caught in minutes. Print the records before proposing the next rule.

The two shrinker seeds that were open on wip/transparent-frames (planner, 2026-09-18) -- closed by wip/one-ask

Both were the rule in draw_inner that decided 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 to Stack{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 reports leftover across; 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 because in_parent's Part::Of arm drops the span's extent_len pin. 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 to Span{LEFT} > [Span{LEFT} > [Span{DOWN} > [Branch{probe Rect, wide Rect, narrow Wrapped, 483}, Wrapped], Rect], Rect]. A draw trace shows the DOWN span 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_inner discards that for the retained 438.9 px answer from the 450 px evaluation because the branch's below place embeds extent_len(Y), which moved when the sibling text's height changed, so place != 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 is unsettled::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.

Original step 3 plan, preserved verbatim (planner, 2026-09-18)

This is the original idea from ai-app-2 280fad7, kept here even as the handoff's stop notice changes with new evidence:

3. Evaluate children in parent-decided boxes

Change placement so an answer-derived box never runs a container body. Placing becomes reuse-or-translate. Remove the offer/measurement gate and its retained bookkeeping only as each caller stops needing it; do not leave a parallel old path.

Three current widgets must stop depending on measuring boxes they will never own:

  • Span: do not read extent_len unconditionally. Slots depend on far only when shares exist (the decided slot fills its part) or for negative direction; compute negative-direction slots from total. Pin the extent length only in those cases.
  • Stack: draw non-sizing children in From(0..size) on an axis where the sizing child's answer is px/rel, and All where it is leftover, instead of drawing them in All of the measuring room.
  • Branch in the random rig: express "the rest of my box" as Of(40px..FULL) rather than reading extent_len(Y).

Every drawing must hold for the answer box it supplies. The two focused tests from step 1 and the existing region-node and decided-box tests must pass here.

The surrounding seven-step implementation plan remains in docs/HANDOFF.md; the quoted text above is the part whose interpretation is now blocked, so it must remain available even if the live handoff is rewritten.

Step 3 experiment after verifying the tests (worker, 2026-09-18)

The initial 17 suite failures mixed obsolete expectations with real defects. ReadsBox, ReadsWidth, and Measured used pixel reads to compute only their answer; failing Counted cases installed a box dependency without drawing anything. Those fixtures encoded the old second draw inside the answer box. Giving the painter answer-only pixel reads preserved their invalidation and geometry claims while removing the obsolete paired-draw count. Separate fixtures still exercise actual drawing dependencies with ordinary pixel reads.

The experiment then reached a green 114-test suite. Its substantive findings:

  • answer validity and final-drawing validity must remain independent all the way through draw_inner; returning their old combined contract falsely pinned wrappers such as Pad to provisional boxes;
  • a repeated ask of one child replaces that child's provisional final-drawing contract rather than intersecting both drawings forever;
  • provisional Span asks fill their measuring room; Within immediately tries to put the child in its own answer before the span decides anything;
  • only an actually allocated share pins final span slots to far; an overfull row with a requested but unallocated share uses fixed slots;
  • a narrowed share frame is Part::From(slot) of the span's extent, not a zero-based span in the forwarded frame;
  • a local redraw re-asks the question that produced the retained answer and marks the parent to restore any distinct final placement.

The release fast oracle then failed all eleven ordinary cases during cold layout. Most failures were Text: its provisional drawing was pinned to a pixel width that did not include the smaller box its answer selected. A generated Stack showed the same class through a symbolic FULL pin. This is stronger evidence than the original suite failures: the generated tests are not asserting draw counts or the old two-draw protocol, and no warm state exists yet. The open decision is whether the guarantee quoted above applies to every leaf and how Text can establish it, or whether answer-derived leaf evaluation remains legal while container evaluation does not.

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 3091fb8 0ef87eb
cold 369/261/10.6 463/274/13.3 516/288/12.0 331/288/12.3 331/288/12.0
repaint 1 1 1 1 1
many 157/95/0.33 263/108/0.59 187/119/0.52 78/78/0.13 110/92/0.23
size 16/12/0.018 3/3 3/3/0.010 3/3/0.008 3/3/0.008
scroll 2/0.002 1 1/0.004 1/0.001 1/0.001
resize 13/13/0.019 22/15/0.032 24/76/0.090 40/15/0.096 40/15/0.096
seed 13 e44dea3 49cec82 3091fb8 0ef87eb
cold 1330/707/20.3 2940/982/28.3 1179/982/24.6 1179/982/24.6
many 524/159/1.09 1091/423/2.39 335/333/0.62 424/364/0.87
resize nothing drawn 2215/510/6.56 nothing drawn nothing drawn

The last two columns are single runs from the third session; cold and resize at seed 1 are after the Part::Of pin composition, which added 18 and 19 draws respectively over the version without it, and many at 0ef87eb includes the deferral of twice-asked share children.

Three findings, each applied on the branch:

  • Lazy Within placement 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: Pad drew sixty-four times in one resize frame at seed 13. Part::Of says it as a part of the box and composes without a length.
  • Pin one axis at a time. extent_len pinning 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 its fixed <= 0 branches 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 by a0693ac). dirty_size_under was 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; update marks 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): report ceil of the shaped size; the two tolerances that were holding it together (BREAK_EPSILON_PX, a nearest-step Holds start) 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 one div left layout.
  • wip/stack-fraction-twice (a stack sized by a child reporting rel(0.5) applied it twice) is closed by transparent frames: placed_extent takes 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-inset is superseded: padding goes outside what it pads, and its rel(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_under and under); a wider contract does not invalidate an existing guarantee; no measurement is different from a measured zero (ActiveData::answer is optional); the settling walk takes the deepest mark from a BTreeSet keyed by depth and what ends it is the mark set.

Failed hypotheses worth not repeating

  • The placement pin was blamed for the many gap 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-reask re-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 FULL under a region node and was resolved against that node's placed box, so everything under a Scroll was re-asked at the content's width. Pinned by unsettled::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; wip/one-ask removes the second draw, so there is nothing left for it to name.
  • Asserting that a placed drawing holds for its answer box (worker's step 3): unsatisfiable for Text and for any widget that reads its box and reports something else. The answer box is not a question.
  • 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 leftover would put into SizeRule::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.
  • Scroll returning 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 bisect once 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 4bd8607 and 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::new is seed | 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.
  • The cases are what a change is then compared at, and until 2026-09-19 every one of them compared at the window the change was made in. A size-resize case was added because a length kept as a fraction of the wrong box agrees with cold at that window and parts from it at every other one. There is still no case that changes a tree twice with a resize between, and no case that resizes twice.

Smaller open items (carried from the old handoff)

  • An undrawn leftover child still contributes its gap, so a vanished child leaves a double gap.
  • Nested spans pass leftover weight 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::div by zero answers MIN/MAX while ratio answers ZERO; both are caller bugs under debug_assert, but the fallbacks differ.
  • The comment on the local == UiRegion::FULL shortcut in widget_at says composing through FULL "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 name Painter::place, SetSize, desired_width, apply_rest, Len::dp, Aligned and MaxSize, none of which exist. Do not restore OnResize::Translate or OrthoSize.
  • tabs changed twice across d3b0ebf with nobody looking; take the oracle as the reference and the five renders as a spot check.