Measured at a888717 and at the new f6242aa on wip/hint-first, with the inset experiment recorded as tested and rejected. The plan is now review, render and land.
672 lines
41 KiB
Markdown
672 lines
41 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 the step 5 measurements found (planner, 2026-09-19)
|
|
|
|
Re-measured `a888717` first: every number in the handoff's table reproduced
|
|
exactly (342/288, 118/95, 3/3, 1, 44/13 at seed 1; 1278/982, 429/366 at
|
|
seed 13), so the table was trusted. What the counters said beyond it:
|
|
|
|
- **Most redraws never reached the reuse check.** `many` at seed 1 made 118
|
|
draws and only 78 reuse attempts, 4 of them rejected; the other draws
|
|
came from `retained_answer` failing before `try_reuse` ran. Mechanism: a
|
|
share child is asked in the room and then in its slot, one record holds
|
|
one answer, and each ask overwrites it -- so the room ask finds the slot
|
|
answer (pinned to the slot's length) and the slot ask finds the room
|
|
answer, and each draws. Every ancestor redraw pays it twice per share
|
|
child, multiplied down nested shares.
|
|
- **#18 avoided the room ask where a hint or rule gave the length.** Its
|
|
`Span` called `painter.known_len` first; `3091fb8` dropped that with the
|
|
offer machinery. Restored at `f6242aa` as `Painter::size_hint`, which now
|
|
resolves a fraction against the asking widget's frame (it returned the raw
|
|
rule before, which no caller had noticed because its one caller read
|
|
`px`) and pins that frame where it did. `Scroll`, `Masked`, a `Stack`
|
|
without a sizing child and the fixture's `Branch` gained `LEFTOVER`
|
|
hints. A hinted child is asked once, in its slot; a hinted share with no
|
|
room is never asked and is undrawn. Cost (draws / distinct, depth 8):
|
|
seed 1 cold 264/232, many 41/41, size 3/3, scroll 1, resize 36/13; seed
|
|
13 cold 758/627, many 16/16, resize nothing. `many` is at its floor:
|
|
every draw is a marked widget or the parent one deferred to. Cold layout
|
|
of 400 trees at depth 5 is byte-identical to `a888717` (the new
|
|
`tests/layout_dump.rs`), and the three long fuzzers agree.
|
|
- **What is left is reported shares.** With hints in, the hottest widget
|
|
in seed 1's resize frame is a `Span` drawn 8 times, two more drawn 4 and a
|
|
`Branch` drawn 4: a span whose children report `leftover` has no hint, is
|
|
asked in the room and in its slot, and both draw because the room drawing
|
|
divided the room (`far` read, pinned to that length). Under the root's
|
|
resize redraw that is 2 per level of nesting. It is also the whole of
|
|
cold's draws over its distinct count.
|
|
|
|
The design that removes it, not implemented: keep two answers on the
|
|
record, the room answer from a measuring ask and the slot answer from the
|
|
placing ask, with the drawing belonging to the placing ask. A measuring
|
|
ask (`Painter::measure`, a new call the parent makes explicitly, so
|
|
"which ask is the measurement" is stated rather than inferred) reuses the
|
|
room answer when its holds contain the room and touches no drawing;
|
|
otherwise it draws in the room and records the answer as the room's. The
|
|
placing ask is `widget_at` as now. A local redraw of a twice-asked child
|
|
re-asks both questions, marks the parent if either answer moved, and puts
|
|
the drawing back otherwise -- which also retires `re_asked`'s deferral.
|
|
The cost is a second `Option<(Size, LayoutHolds)>` on `ActiveData` and a
|
|
measuring path through `draw_inner` that skips `try_reuse` and `relocate`.
|
|
This is the one-record-two-questions bookkeeping the earlier plans died
|
|
of, made explicit at the call site instead of recovered from the ask; it
|
|
is worth doing only once an app screen shows reported shares nested
|
|
under a resize, and that screen should be measured first.
|
|
- **Dropping the span's `far` pin does nothing** (`wip/inset` at `5b181bc`,
|
|
pushed, one commit over `f6242aa`): `Part::Of` replaced by
|
|
`Part::Inset { lead, trail }` in window lengths, an exact `in_parent` arm
|
|
for a pixel inset (the range moved by the pixels, `Holds::longer_by`) and
|
|
a pinned own length plus a window range for one with a fraction, `Pad`
|
|
speaking it, and `Span` reading `extent_len` only with shares or in the
|
|
negative direction. Suite, oracle and clippy green; counters identical to
|
|
`f6242aa` at every phase of both seeds. Why: a symbolic pin survives a
|
|
resize because every ask box is symbolically stable across one -- the
|
|
viewport a scroll asks its content in is `rel 1`, a slot is a sum of
|
|
pixel answers, a stack's sized part is an answer -- so the pin only fails
|
|
where an answer changed, which is a real relayout. The worker's "44 to
|
|
20" at `a888717` was a deliberately unsound read, and what it saved was
|
|
share spans reusing the drawing that divided the room in the slot that
|
|
is narrower: wrong layout, not spared work. Not to be repeated; the
|
|
branch is evidence.
|
|
- **The frame pin on a rule that is a fraction costs nothing** to speak of:
|
|
`reuse outside: a frame length` is 3 of 264 cold draws at seed 1 and 1 of
|
|
758 at seed 13. Dropped from the plan.
|
|
|
|
## 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.
|