Files
ai-app/docs/LAYOUT_LOG.md
T
iris-ai 2fa734cd63 Record what the step 1 review found
The pre-submit review of the six one-ask commits found a scroll placing
content that fits into a window-length box rather than its viewport, and
three comments still calling window lengths frame lengths.
2026-09-19 01:24:22 -04:00

600 lines
36 KiB
Markdown

# 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 step 1 review found (worker, 2026-09-19)
The pre-submit review of the six commits `4328eac..a30971e` as one diff.
Build, lint and every fuzzer were already clean, and the review still found
one wrong layout.
- **A scroll placed content that fits into a box the length of the
window.** `Scroll` asked for its content box as
`Place::Fill(Part::From(content))` with `content` defaulting to
`UiSpan::FULL`, and only replaced it when the content is scrolled or
longer than the viewport. A `Part::From` span is in window lengths, so
`UiSpan::FULL` -- `rel 0` to `rel 1` -- is the whole window rather than the
whole box. The 300 px viewport of a 400 px window put its content in a
400 px box anchored at the viewport's start: a 50 px child centred at
275..325 where #18 has it at 225..275. It is the common case, not a corner
one -- any content shorter than its viewport takes it.
Nothing in the repository caught it. The warm/cold oracle cannot: both
sides are wrong the same way, and it compares warm with cold rather than
with what is right. The render set cannot either: every scroll in
`minimal`, `tabs`, `random`, `text` and `view` is either window-length on
its axis or has content longer than its viewport, so all five are
byte-identical before and after the fix. Only a hand-written expectation
found it, which is the argument for keeping some.
Fixed at `e8a5792` by saying the whole of a box as `Part::All`, the one
expression that cannot mean anything else -- and, being the place the
child was already asked in, one that makes the placement a no-op. Pinned
by `scroll::content_that_fits_is_placed_in_the_viewport_and_not_in_the_window`.
- **`Part::From`'s own documentation still called its spans frame lengths**,
which is what the scroll above read them as, and two comments in
`in_parent` still described the window ranges as frame ranges converted
through a frame. Corrected at `a888717`. When a unit changes, the comments
naming the old one are not decoration: this one cost a layout defect.
Checks re-run at `a888717`: `cargo fmt --all --check`, clippy with and
without `layout-diagnostics`, 123 suite tests, 20 core, 11 generated, the
400-seed depth-5 shrinker over all sixteen cases (73 s, 34,488 widgets), the
1000-seed depth-6 oracle (187 s), the 2000-seed depth-4 scan (347 s, 82,203
widgets), all five renders at 1920x1200, the `tabs` replay, and a live
resize of `random` to 900x1200 against a cold render there. The renderer was
confirmed as Venus on the host's RX 7900 XT rather than llvmpipe. Every
render is byte-identical to the same render at `a30971e`.
## 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.