From 3ecd676bcfabe3c3f1bcb3004152397400a483bf Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sat, 19 Sep 2026 02:57:17 -0400 Subject: [PATCH] Land the layout repair's findings and retire the working log `docs/LAYOUT_LOG.md` existed until the one-ask protocol landed. It has, as Iris PR #19 replacing #18, so what has to outlive the log moves into `docs/LAYOUT.md`: the hint rule and the one ask a span makes, the reported shares that are left and the two-answers design that would remove them, what the fuzzers reach and what they cannot, the hypotheses not to repeat, and the counter and dump commands with the numbers they print now. The handoff becomes what it is for -- where the work stands, which is waiting on a review. --- docs/HANDOFF.md | 571 +++++--------------------------------- docs/LAYOUT.md | 132 ++++++++- docs/LAYOUT_LOG.md | 671 --------------------------------------------- 3 files changed, 191 insertions(+), 1183 deletions(-) delete mode 100644 docs/LAYOUT_LOG.md diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 89ef929..81be509 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,516 +1,79 @@ # Handoff -Where the Iris retained-layout work stands for a worker picking it up cold. -This file contains current decisions, the implementation plan and its checks. -The durable layout design and measurement method are in `docs/LAYOUT.md`. -The temporary investigation record is in `docs/LAYOUT_LOG.md`; delete that -log when the one-ask protocol lands, after moving any fact that must survive. +Where the work in flight stands. The durable layout design, the measurement +method and the findings that outlived the working log are in +`docs/LAYOUT.md`. -## Where things stand +## The Iris layout repair is submitted -Canonical upstream Iris `main` is **`ca2b4b2`** (#17, the headless rig). PR -#18's pushed branch is `split/18-position-chain` at **`e44dea3`**. Its -detached comparison checkout is `/home/bob/repos/iris-layout-baseline`. It is -the reviewed baseline this work must preserve or improve. +**Iris PR #19** (`layout/one-ask` at `cadfba0`, 123 commits over upstream +`main` at `ca2b4b2`) is open and waiting on Bryan. It **replaces #18**, which +is closed and whose branch `split/18-position-chain` stays as the reviewed +baseline the newer work was measured against. Bryan decided on 2026-09-19 +that the experiment replaces it, because it is more performant where it +matters. -The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch -**`wip/hint-first`** at **`f6242aa`**, one commit over `wip/one-ask` at -`a888717` (nine commits over `4328eac`, the head of `wip/transparent-frames`, -which is unchanged). Both branches are pushed to `origin`. `wip/one-ask` -replaced the old step 3 plan with the one-ask protocol below, `1512d84` and -`23523ee` made the frame a length of the window, and `e8a5792` is what the -step 1 review found. `f6242aa` is the step 5 result: a span takes a child's -length from its hint or rule and asks it once, in its slot. It passes every -check: +The checkout is `/home/bob/repos/iris-layout-experiment`, on branch +`wip/hint-first`, which is the same commit as `layout/one-ask`. Both are +pushed to `origin` (`iris-ai/iris`). What the branch does and what it leaves +undone is in the PR description; the settled protocol is in `docs/LAYOUT.md` +under "Frames, decided boxes and padding". -| check at `f6242aa` | result | +Verification at `cadfba0`, all run in this checkout: + +| check | result | | --- | --- | | `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean | -| `cargo test --workspace` (debug) | 123 suite, 20 core, 11 generated, all green | +| `cargo test --workspace` | 123 suite, 20 core, 11 generated, green | | `cargo test --release --test generated` | 11/11 | -| shrinker, 400 seeds, depth 5, all sixteen cases | agree, 63 s (34,488 widgets) | -| 1000 seeds at depth 6 | agree, 277 s | -| 2000-seed depth-4 scan, all sixteen cases | agree, 283 s (82,203 widgets) | -| cold layout of 400 trees at depth 5, dumped and diffed against `a888717` | byte-identical (34,488 widgets) | -| renders, the `tabs` replay and the `random` resize | **not re-run since `a888717`**; step 3 below | - -The cold-layout dump is `tests/layout_dump.rs`, new at `f6242aa`: it prints -every widget's box for many grown trees so two commits can be diffed on cold -layout, which the warm/cold oracle cannot see a change to. - -What implementing it corrected in the plan is in `docs/LAYOUT_LOG.md`; the -four that would have shipped as wrong layout are a share inside padding -losing the padding twice, a root resolving its own rule twice, a rule changed -over two pads relocating the column under them instead of dividing it again, -and a resize leaving a short scroll's window-tall content where it was. Each -is pinned as a named test. - -The renders and the replay are done and recorded in `docs/LAYOUT_LOG.md`: -against #18, `minimal` and `tabs` are byte-identical (before and after the -reference gesture), `random` differs in two pixels of glyph antialiasing, -`text` moves one padded block one pixel, and a live resize of `random` -matches a cold render at that size byte for byte. Re-run at `a888717`, all -five are byte-identical to the same renders at `a30971e`, so the scroll fix -below changed none of them. - -Not done: the pre-submit review of `f6242aa`, the render set at it, the doc -pruning and the landing (steps 1, 3 and 6 below). Step 5's measurements and -the two ideas it tested are in `docs/LAYOUT_LOG.md` under "What the step 5 -measurements found". - -The worker's older step 3/4 experiment is preserved as branch -`wip/step3-experiment` (one commit over `4328eac`) and as -`~/repos/iris-step3-experiment.patch`. It is evidence, not the protocol. -The app's Iris pin is unchanged. - -## The two rules to protect - -These outrank the accumulated machinery: - -1. A changed tree lays out exactly as if it had been drawn that way from the - start. The warm/cold oracle and shrinker test this. -2. Lengths are predictable. `px` is that many pixels; `rel(0.5)` is half of - the frame decided for the widget, wherever it sits; `leftover` is a share - of the room left after every sibling's `px` and `rel` lengths are resolved. - -Do not fix a failure with a tolerance, another measurement flag, a special -case in `Span`, or another layout method. - -## What the previous plan got wrong - -The full account is in `docs/LAYOUT_LOG.md`. The short version, because it -is the third plan for this repair and the next one should not repeat it: - -- **Every plan kept the second draw.** The old protocol drew a widget in the - box it was asked in, then drew it *again* in the box its own answer placed - it in whenever the first drawing's `Holds` did not cover that box. All the - offer machinery -- `offer_place`, `offer_part`, `at_offer`, `measured()`, - the local-redraw deferral -- existed to remember which of the two draws was - the question. The plans tried to define that bit better; the defect was - that there were two draws at all. -- **The step 3 plan then over-corrected.** It said "every drawing must hold - for the answer box it supplies", and the worker implemented exactly that as - an assertion in `place`. A wrapped `Text` asked at 45 px whose longest word - is 89.5 px cannot satisfy it, and neither can any widget that reads its box - and reports something other than it. The answer box is not a question, so - no contract about it can be demanded of the widget. -- **It also let a caller narrow a frame by position.** A frame narrowed to a - region (the worker's share frames) does not move when the part it sits in - moves; only a frame narrowed to a *length*, put back into the part on - every placement, does. - -## The protocol now in the experiment - -**A widget draws once, in the box it is asked in. Its answer is placed inside -that box by re-expressing the drawing. Nothing is drawn again in a box an -answer chose.** `Holds` is a contract about the ask box alone, consulted only -to decide whether a re-ask can be skipped. This is `draw_inner` at `1512d84`: - -```rust -let reused = (!stale) - .then(|| self.retained_answer(id, part, info)) - .flatten() - .and_then(|answer| { - let extent = placed_extent(part, answer.0, declared, info.fill(), align); - self.try_reuse(id, part, extent, info, rsc).map(|()| answer) - }); -let answer = reused.unwrap_or_else(|| { - if old.is_none() { - old = self.remove(id, false, rsc); - } - let answer = self.draw_at(id, part, info, old.take(), rsc); - let extent = placed_extent(part, answer.0, declared, info.fill(), align); - if extent != part { - self.relocate(id, extent, info, rsc); - } - answer -}); -``` - -`try_reuse` checks the drawing against `part` and relocates it to `extent`; -the old `place` (redraw in the answer box) is gone, and with it every offer -field's purpose. `ActiveData` keeps `part` as the ask box, `asked` as where -it was asked and `placed` as where it was put (renamed in step 4). - -A container that puts an answer somewhere other than where it asked says so -with a new call that never runs the body: - -```rust -/// Puts a child asked about in this draw somewhere else in this -/// widget's box: its answer, placed in this part instead. The drawing -/// is re-expressed there rather than made again -- what a row does once -/// it knows every slot, having measured each child from its cursor. -pub fn place_at(&mut self, id: &StrongWidget, place: [Place; 2]) -``` - -A frame is narrowed by a *length of the window*, never a region and never a -fraction of the parent's frame -- a row's slot cannot be written as a -fraction of the row. The box stays whatever `place` names; only a declaration -places the box inside it, by the child's alignment, on every placement: - -```rust -pub fn widget_at<'s, W: ?Sized>( - &'s mut self, - id: &'s StrongWidget, - narrow: [Option; 2], - place: [Place; 2], -) -> DrawResult<'s, 'a, W> -``` - -`Span` takes each child's length along itself from its hint where one says --- a rule, or a widget that always reports the whole of its box -- and -otherwise asks the child once from its cursor (`Within(From(cursor..far))`). -Then it moves a drawn fixed child to its slot, and asks every other child -once in its decided slot, a share with the frame narrowed to it. This is -`Span::draw` at `f6242aa`: - -```rust -let size = match painter.size_hint(child, axis) { - Some(len) => { - measured.push(None); - len - } - None => { - let room = Place::Within(Part::From(along(cursor, far))); - let size = painter - .widget_at(child, [None; 2], axis.pair(room, across)) - .size(); - measured.push(Some(size)); - size.axis(axis) - } -}; -... -let slot = along(from, start); -let place = axis.pair(Place::Fill(Part::From(slot)), across); -let mut narrow = [None; 2]; -if len.leftover > Weight::ZERO && shares { - narrow[axis as usize] = Some(slot.len()); -} -let used = match (measured, narrow[axis as usize]) { - (Some(size), None) => { - painter.place_at(child, place); - size.axis(!axis) - } - _ => painter.widget_at(child, narrow, place).len(!axis), -}; -``` - -A hint is the length the child's draw would report: `Painter::size_hint` -resolves a fraction in it against the asking widget's frame, which is the -frame a child asked with nothing narrowed gets, and pins that frame where it -did. `Scroll`, `Masked`, a `Stack` without a sizing child and the fixture's -`Branch` hint `LEFTOVER`, since each always reports it; `Rect` and `()` -already did. A widget that reports a share its children gave it -- a span -with a `Rect` in it -- has no hint and is still asked twice, which is the -whole of what step 5 left. - -`Stack` asks non-sizing children with `Part::Sized(len)` of what its sizing -child decided, on every axis that is not a share -- a box of that length -where their own alignment puts it, and that length as their frame -- and -`Scroll` asks its content once in the viewport and `place_at`s it to the -scrolled offset. - -A local redraw asks the retained question again -- the same place of the box -the parent was *asked* in -- and, if the answer stands, puts the fresh drawing -back at the retained place of the box the parent's answer *chose*. Both halves -are needed: seed 2 at depth 4 (a stack sized by its text) fails without the -second. - -A widget its parent asked more than once in one draw -- a share child, asked -in the room and then in its slot -- has two questions and one record, so it -cannot settle locally: `redraw` defers it to the parent the way it defers a -widget whose declared length changed (`ActiveData::re_asked`, set by -`widget_at` when the child is already in `children`). Seed 946 at depth 6 -found the case: a fixed-height column that is a share while its rect fits -and a fixed width once it does not, so emptying it changes the room answer -and not the slot answer. - -A symbolic length a child pinned now composes through `Part::Of` where the -part is the whole box less pixels, and pins the parent's own length otherwise -(`in_parent`). Dropping it let a zero `Pad` reuse a drawing across a narrowed -frame of the same pixel length; the shrinker found six such seeds at depth 5. - -## Decisions - -Decided with Bryan on 2026-09-17 and 2026-09-18, kept where still true. - -### One draw method, in a box decided from above - -`Widget::draw` remains the only layout method. A container's body runs only -in a box its parent offered or decided, never in a box derived from the -container's own answer. **The experiment extends this to every widget:** a -leaf is not drawn in its answer box either. Its drawing is re-expressed -there, which for a text means the block it shaped at the asked width is -positioned inside the box its reported size chose, and its lines do not -change. `examples/text.rs` and `random` have not been rendered since; do that -before landing and inspect any change. - -### Frames are narrowed by every length decided from above - -A declared `px` or `rel`, a resolved share, and the box a stack's sizing -child decided (Bryan, 2026-09-18: the sizing child, if any, determines how -the rest are laid out) all narrow the frame. -`declared_lens` still excludes `leftover`, which is right: a share has no -length until the span divides its room, and it narrows the frame at the -placing ask instead. - -### Padding is an inset, and the frame is a length while the box is a region - -Bryan, 2026-09-18: padding is an inset. It subtracts from both the child's -frame and its box and adds itself to the reported size, so `rel(1.0)` inside -padding fills the parent without overflowing. A span's frame never subtracts -siblings; only the padding subtracts from it. No outset kind and no mixed -kind for now; the name stays `Pad`. Worked example, 900 px row: - -```rust -let row = (rect(Color::RED).width(24), wtext(PARAGRAPH).wrap(true).pad(16)).span(Dir::RIGHT); -``` - -The text is asked in 900 − 24 − 32 = **844** px and wraps there; a -`rel(1.0)` inside the same padding is 900 − 32 = **868** px and overflows the -row by exactly the icon's width. With the pad in a share instead, both are -the share less 32. An icon *after* the padded text overflows; a user who -wanted otherwise meant `leftover`. - -Implemented at `1512d84`. `Pad` reads its own frame (`Painter::frame_len`, -which pins it), takes the padding off, and hands that down as the child's -frame, while the box it gives is the inset part of its own box. The two are -different lengths whenever the box is narrower than the frame -- which is -exactly the wrapping case above. - -### A share never adds room beyond the deciding box - -`Scroll` resolves its content length from the fixed part of the answer and -makes it at least the viewport (`4328eac`). Unchanged. - -### Existing fixed-point and box-chain design stays - -Unchanged; see `docs/LAYOUT.md`. - -## Implementation plan - -Work in `/home/bob/repos/iris-layout-experiment` on `wip/hint-first` from -`f6242aa`. Make each step a warning-clean commit, push it, and run its named -checks before the next. If a step exposes a different mechanism, stop and -update this handoff rather than papering over it. - -The order is 1, 3, 6: review the step 5 commit, render at it, then land. -Steps 2, 4 and 5 are done and kept below for what they decided. - -Anything a fuzzer finds is shrunk first (`SHRINK_SEED= -SHRINK_DEPTH= SHRINK_CASE=`, which now prints each level's -frame, ask, box and size warm against cold), pinned as a named test in -`tests/cases/unsettled.rs`, then fixed under the two rules above. Do not add -a second draw back. - -### 1. Review the step 5 commit - -The six commits `4328eac..a888717` were reviewed as one diff (done at -`a888717`; it found one wrong layout, fixed at `e8a5792` and pinned by -`scroll::content_that_fits_is_placed_in_the_viewport_and_not_in_the_window`, -and three stale comments). **Still to do: the pre-submit review of -`a888717..f6242aa`**, which the planner wrote and measured but did not -review as a separate pass. It touches `Span::draw`, `Painter::size_hint`, -four `size_hint` implementations and the dump rig. Things to look at: - -- `Painter::size_hint`'s pin: it sets `frame_own_len` where the hint has a - fraction. A child asked in its slot afterwards pins the same frame through - `in_parent` anyway, so the pin only matters for a caller that reads a hint - and never asks the child. Confirm that reading is right, or delete the pin - and say why. -- A hinted share child with no room is never asked and `Span` calls - `painter.undraw` on it, which is what takes its last frame's drawing down. - A drawn-in-the-room child got the same call before; check nothing else - relied on the room draw having happened first (`size_deps` records the - hint read, and `draw_at`'s `asked` handles a dep that is not a child). -- The two comments in `Span::draw` above the passes, and the `Stack` hint's - comment. - -Check: the ordinary suite, then `cargo test --release --test generated`. -Anything the review changes in `Span` or `Painter` re-runs the three long -fuzzers and the cold dump (commands under step 6). - -### 2. Make the frame a length and the box a region - -**Done at `1512d84` and `23523ee`.** The settled rule is in `docs/LAYOUT.md` -under "Frames, decided boxes and padding"; what implementing it corrected in -the plan -- four of them wrong layout that would have shipped -- is in -`docs/LAYOUT_LOG.md`. - -### 3. Render and replay - -Done at `adbedaf` and re-run at `a888717`, against `e44dea3` (#18); what -each render showed is in `docs/LAYOUT_LOG.md`. **Not yet run at `f6242aa`.** -Render the five examples, the `tabs` replay and the `random` resize at the -reviewed head and compare with the same renders at `a888717` (a -`git worktree add ../iris-a888717 a888717` with its own target directory, -one process at a time). Expected: byte-identical, every one -- the cold -dump already says so for 400 random trees, but the fuzzer grows no `rel` -rules and the examples do, so `text` is the render that can still differ. -Any difference is a finding: read the records before touching anything. -`view` has no counterpart in #18 and is compared with `a888717` only. The -commands are under step 6. - -### 4. Rename and delete - -**Done at `a30971e`.** `ActiveData` and `DrawInfo` now say `part` for the box -a widget was asked in, `asked` for the place it was asked at and `placed` for -where its drawing was put; `LayoutHolds::frame` is `window`, since those -ranges are window pixels and the frame's own entry is the `frame_len` pin -beside them, and `Painter::frame_own` is `window_own`. `answers_at` had one -caller and is inlined there. `ActiveData::measured` is kept: `place_in` reads -it, and what it says -- the answer rather than the last drawing's report -- -is worth a name. - -### 5. Restore the expected retained cost - -**Done at `f6242aa`**, by one change: a span takes a child's length from -its hint or rule and asks it once, in its slot, instead of drawing it in the -measuring room first. Work counters, seeds 1 and 13, depth 8, widget draws / -distinct widgets, beside `e44dea3` (#18) and `a888717` (before it): - -| seed 1 | e44dea3 | a888717 | f6242aa | -| --- | --- | --- | --- | -| cold | 369/261 | 342/288 | 264/232 | -| many | 157/95 | 118/95 | 41/41 | -| size | 16/12 | 3/3 | 3/3 | -| scroll | 2 | 1 | 1 | -| resize | 13/13 | 44/13 | 36/13 | - -| seed 13 | e44dea3 | a888717 | f6242aa | -| --- | --- | --- | --- | -| cold | 1330/707 | 1278/982 | 758/627 | -| many | 524/159 | 429/366 | 16/16 | -| resize | nothing | nothing | nothing | - -The command is - -```sh -IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \ - --test layout_diagnostics -- --ignored --nocapture layout_cost -``` - -reading `widget draws` and `distinct widgets` from each phase's block, and -`hottest widget draws` with `IRIS_PHASE=resize IRIS_FRAMES=2` for who is -drawn how often. - -`many`, `size` and `scroll` are at their floor: every draw is a marked -widget, or the parent a marked widget deferred to. `cold` is under #18 at -both seeds. What is left is `resize` at seed 1, 36 draws of 13 widgets where -#18 drew 13, and the `cold` draws over the distinct count (32 at seed 1, -131 at seed 13). Both are one mechanism, understood and not yet worth a -change: a **reported share** -- a span whose children report `leftover`, a -stack sized by a child, a wrapper round either -- has no hint, so its parent -still asks it in the room and again in its slot, and each of those asks -draws, since the room drawing divided the room and does not hold for the -slot. Under the root's resize redraw that multiplies down the tree: the -hottest widget at seed 1 is a `Span` drawn 8 times in one frame, two -levels of reported shares under it drawn 4. The design that would remove it -is in `docs/LAYOUT_LOG.md` ("two answers per record"); do it only once an -app screen shows the cost, and measure that screen first. - -Two ideas from the previous version of this step were tested and are **not -to be done**; the measurements are in `docs/LAYOUT_LOG.md`: - -- Not reading the span's own length where no slot depends on it - (`wip/inset` at `5b181bc`, pushed): identical counters at every phase. - The symbolic pin survives a resize because every ask box is symbolically - stable across one; the earlier "44 to 20" came from share spans reusing - their room drawing in their slot, which is wrong layout rather than saved - work. -- Resolving a rule that is a fraction of the frame from the record instead - of redrawing: `reuse outside: a frame length` is 3 draws of 264 cold at - seed 1 and 1 of 758 at seed 13. Nothing to recover. - -Report every phase at both seeds, work counters first, medians only when the -work agrees. - -### 6. Full verification and landing - -Run, in the experiment checkout: - -```sh -cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo clippy --workspace --all-targets --features layout-diagnostics -- -D warnings -cargo test --workspace -cargo test --release --test generated -SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \ - cargo test --release --test shrink -- --ignored --nocapture -IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \ - cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees -SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \ - cargo test --release --test shrink -- --ignored --nocapture -``` - -The last line is the 2000-seed depth-4 scan over all sixteen cases; the -shrinker runs the same cases as the scan and reduces anything it finds, so -no temporary test body is needed any more. `Rng::new` uses `seed | 1`, so -adjacent even/odd seed pairs describe the same tree. All of these passed at -`f6242aa`; re-run whatever a later commit could affect. - -A change that could move cold layout also gets the dump diff, which the -oracle cannot replace: - -```sh -IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release --test layout_dump \ - -- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ ' > /tmp/after.txt -``` - -once at the commit before and once after (check the earlier commit out in -the same checkout so the build is incremental; the rig file is untracked -there, copy it in with `git show wip/hint-first:tests/layout_dump.rs`), then -`diff` the two. Zero differing lines is the expectation for a cost change. - -Render `view`, `minimal`, `random`, `tabs` and `text` at 1920x1200 and inspect -every intentional change. Also replay `tabs` and compare a live resize of -`random` with a cold render at the same size. Read the installed graphics -skill before rendering and confirm the renderer; an llvmpipe fallback can -produce a plausible PNG. The headless rig reuses one compositor, so run one -process at a time and give comparison worktrees separate target directories: - -```sh -./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png -./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ - --resize 900x1200@60Hz --shot /tmp/resized.png -./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ - --replay /tmp/tabs.touch --shot /tmp/replay.png -``` - -The reference replay is: - -```text -0 down 1728 24 -80 up 1728 24 -400 down 1836 1116 -480 up 1836 1116 -800 down 1836 1116 -880 up 1836 1116 -``` - -Before submitting, run the pre-submit review. Then land: move any surviving -fact from `docs/LAYOUT_LOG.md` into `docs/LAYOUT.md` (the settled protocol, -the measurement method including the dump rig, and the "not to be done" -results under step 5), delete the log, update this handoff to the next -actual task, and push every coherent commit. How the branch reaches -upstream is Bryan's call and has not been asked: `wip/hint-first` is some -sixty commits over #18's `split/18-position-chain`, which is still open, so -either #18 lands first and this follows as one PR, or this replaces #18. -Ask before opening anything. Update the app's Iris pin only when the Iris -change is ready. - -## Follow-on work, not part of this repair - -- CPU round-to-nearest and shader nearest-pixel snapping are approved as one - separately verified change. Neither has landed. Re-derive `Holds::through` - for the new rounding and run both long fuzzers plus the render set. -- Smaller layout items remain in `docs/LAYOUT_LOG.md`: an undrawn share's - gap, nested share weights, inconsistent zero-divisor fallbacks, and the - stale `f32` identity comment. +| shrinker, 400 seeds, depth 5, sixteen cases | agree, 60 s | +| 1000 seeds at depth 6 | agree, 177 s | +| 2000-seed depth-4 scan, sixteen cases | agree, 295 s (82,203 widgets) | +| cold dump of 400 trees at depth 5, diffed against `f6242aa` | identical, 34,488 widget boxes | +| counters, seeds 1 and 13 at depth 8 | unchanged from `f6242aa` (the table is in `docs/LAYOUT.md`) | +| five renders, the `tabs` replay, a live resize against a cold render | all byte-identical to `a888717` | + +The renderer was Vulkan on the host's RX 7900 XT: `src/default/render.rs` +asks for `Backends::PRIMARY` and `.expect()`s the adapter, so a PNG at all +means it was not the software fallback. + +## What is next, in order + +1. **Bryan's review of #19.** Fixes to it are unreviewed code: repeat the + `pre-submit-review` passes over whatever each round changes, and re-run + the three fuzzers and the dump diff for anything that touches `Span`, + `Painter` or `render_state`. +2. **The app's Iris pin**, once #19 lands. It is still on the old submodule + commit, and the branch changes API the app uses: `SizeRule` beside a + widget rather than a wrapper, alignment as a widget property, `rest` + renamed to `leftover`, `OrthoSize` gone. +3. **Round-to-nearest**, CPU and shader together as one verified change. + Bryan approved it on 2026-09-17 and neither half has landed; the + derivation, the form to use and what to re-check are in `docs/LAYOUT.md` + under "Rendering the grid (pending)". + +## Smaller layout items, none urgent + +- 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. +- A span can overflow itself without bound, so boxes of negative length reach + children and nothing states what a widget may assume about one. +- `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`. - `LazySpan`, then `SizeRule::{Min, Max, Clamp}`. A cap may not contain - `leftover`; whether `Max` narrows the child's drawing box is still a real - product decision. + `leftover`; whether `Max` narrows the child's drawing box is a product + decision. - `Scroll` taking a direction rather than one axis. -Other product work remains in `docs/PLAN.md` and the focused documents it -links. Do not mix it into the Iris layout branch. +Other product work is in `docs/PLAN.md` and the focused documents it links. +Do not mix it into the Iris layout branch. diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index cd7fde8..56def26 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -4,12 +4,13 @@ A widget draws once and records its size on the `Painter`. Reading a child `DrawResult::size()` records a retained size dependency; drawing the child without reading that result does not make the parent's size depend on it. -§1, §2 and §3 have landed in Iris (#16 and #18). §4 to §6 and the density -section retain the rationale of the design but still name types that have -since been replaced; they are not an API reference. `docs/HANDOFF.md` is the -current transparent-frames work and its checks; `docs/LAYOUT_LOG.md` is what -the sessions doing that work found, kept until it lands. The sections at the -end of this file are durable design moved out of the handoff on 2026-09-18. +§1 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19 +carries those commits whole plus the retained-layout repair built on them. §4 to §6 and the density section +retain the rationale of the design but still name types that have since been +replaced; they are not an API reference. `docs/HANDOFF.md` is where the work +in flight stands. The sections from "Frames, decided boxes and padding" +onwards are the settled design, the findings that outlived the working log, +and the measurement method. ## Design @@ -333,7 +334,38 @@ that box by re-expressing the drawing, and nothing is drawn again in a box an answer chose. `Holds` is a contract about the ask box alone, read only to decide whether a re-ask can be skipped. A container that puts an answer somewhere other than where it asked says so with `Painter::place_at`, which -never runs the body. This is `wip/one-ask` in the experiment checkout. +never runs the body. + +The answer box is never a question, so no contract can be demanded of a +widget there: a `Text` asked at 45 px whose longest word is 89.5 px cannot +promise its drawing holds for the box its own answer chose. An earlier plan +asserted exactly that and was unsatisfiable. + +A container that can learn a child's length without drawing it does. +`Painter::size_hint` answers from the child's rule, or from +`Widget::size_hint` where it implements one, resolved against the asking +widget's frame; `Scroll`, `Masked` and a `Stack` without a sizing child hint +`LEFTOVER`, since each always reports the whole of its box. `Span` takes each +child's length from its hint where there is one and asks that child exactly +once, in the slot it decided; a child with no hint is drawn in the room left +from the cursor, because a text has to wrap at the width actually there, and +its drawing is moved to its slot with `place_at`. Reading a hint records a +size dependency and pins the frame where the hint declared a fraction, the +same pin a rule that is a fraction takes. + +**What that leaves, and the design that would remove it.** A widget that +reports a share its children gave it -- a span whose children report +`leftover`, a stack sized by such a child, a wrapper round either -- can have +no hint, so its parent asks it in the room and again in its slot, and both +asks draw, since the room drawing divided the room. Under a resize that +multiplies down the tree: at seed 1, depth 8, the hottest widget in the +resize frame is a `Span` drawn 8 times. Removing it means two answers on one +record -- a room answer from an explicit measuring ask (`Painter::measure`, +so which ask is the measurement is stated rather than inferred) and a slot +answer from the placing ask, with the drawing belonging to the placing one -- +plus a local redraw that re-asks both questions and marks the parent if +either answer moved, which also retires `re_asked`'s deferral. Do it only +once an app screen shows the cost, and measure that screen first. **There is one coordinate unit, the window** (`1512d84`, `23523ee`). Every box in the tree is a region in window units; a widget's frame is a *length* in the same @@ -489,14 +521,68 @@ of its parent's frame and a local redraw walked back up the parent chain in - Alignment is one value per axis and defaults to the middle because neither edge is neutral without a direction. One widget has one length per axis; a second length requires a second widget through `.wrapper()`. +- No measurement is a different state from a measured zero, so + `ActiveData::answer` is an `Option` rather than a zero size. -### What the fuzzers tolerate +### What the fuzzers tolerate, and what they reach Warm and cold pixel regions must compare exactly; there is no step allowance. When a row's grid-step count is not divisible by the number of children, individual share widths differ, but every rerun of that layout must still agree exactly. +- The routine runs are ten seeds (fast oracle), 400 at depth 5 through the + shrinker, and 1000 at depth 6. The 2000-seed depth-4 scan over every case + is what found seeds that had failed on every commit for a week, so run it + 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. Check a single seed against the + unpatched code before calling it new. +- Depth finds things and so does breadth; widen one axis at a time and + record which. +- If a seed fails once and never again, suspect the rig's determinism before + believing an edit fixed it: seed 30 at depth 5 did exactly that on + 2026-09-19 and reverting each part of the change did not bring it back. +- The cases are what a change is *then* compared at. Until the `size-resize` + case was added, every one of them compared at the window the change was + made in, and a length kept as a fraction of the wrong box agrees there and + parts from cold at every other window. There is still no case that changes + a tree twice with a resize between, and none that resizes twice. +- The fuzzer grows no `rel` rules and never re-parents a widget, so neither + a fraction resolved against a frame nor a subtree changing hands is + covered by any generated tree. Both have cost a defect that only a + hand-written expectation or a render caught. + +### Failed hypotheses worth not repeating + +- **"Which draw is the measurement" cannot be defined.** Four bookkeeping + rules and two experiments each fixed some seeds and broke others. The bit + existed only because a widget was drawn twice; one draw leaves it nothing + to name. A plan that proposes bookkeeping for a distinction should first + ask whether the distinction has to exist. +- **A frame narrowed by a region does not move when its part moves.** A + narrowed frame must be a *length*, put back into the part on every + placement, exactly as a declared rule already is. +- **Choosing between a fixed and a relative child in pixels at the span's + current width** admits multiple self-sizing fixed points; seed 13 settles + 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** retains zero-height + children (seed 16). The boundary is derived through the inverse of the + expression that places children, and is pinned by + `unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`. +- **Not reading a span's own length where no slot depends on it** saves + nothing (`wip/inset`, measured 2026-09-19): counters identical at every + phase. A symbolic pin survives a resize because every ask box is + symbolically stable across one, so the pin only fails where an answer + changed, which is a real relayout. +- **Resolving a rule that is a fraction of the frame from the record instead + of redrawing** recovers nothing: `reuse outside: a frame length` is 3 of + 264 cold draws at seed 1 and 1 of 758 at seed 13. +- **A `git bisect`** once named a commit that could not be the cause; read + the tree rather than the bisect when that happens. + ### Rendering the grid (pending) `snap_floor` in `prelude.wgsl` adds half a layout step before flooring, @@ -515,6 +601,36 @@ inlines. ## Measuring layout cost on this machine +- **Counters, at two seeds, before anything else.** + + IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \ + --test layout_diagnostics -- --ignored --nocapture layout_cost + + reports each phase's `widget draws` and `distinct widgets`, and + `IRIS_PHASE=resize IRIS_FRAMES=2` names who is drawn how often. Where the + one-ask protocol stands, draws / distinct at depth 8, beside #18: + + | seed 1 | #18 | now | | seed 13 | #18 | now | + | --- | --- | --- | --- | --- | --- | --- | + | cold | 369/261 | 264/232 | | cold | 1330/707 | 758/627 | + | many | 157/95 | 41/41 | | many | 524/159 | 16/16 | + | size | 16/12 | 3/3 | | resize | nothing | nothing | + | scroll | 2 | 1 | | | | | + | resize | 13/13 | 36/13 | | | | | + + `many`, `size` and `scroll` are at their floor: every draw is a marked + widget, or the parent a marked widget deferred to. `resize` at seed 1 and + cold's draws over its distinct count are the reported-share cost above. + +- **A change that could move cold layout gets the dump diff**, which the + warm/cold oracle cannot replace -- both of its sides move together: + + IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release \ + --test layout_dump -- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ ' + + once at each commit, then `diff`. Zero differing lines of 34,488 is the + expectation for a cost change. + - **Check the work counters before comparing two commits' times.** `tests/layout_diagnostics.rs` prints drawn widgets, widget draws and primitive writes; a comparison is only worth reading when they match. diff --git a/docs/LAYOUT_LOG.md b/docs/LAYOUT_LOG.md deleted file mode 100644 index ae8e326..0000000 --- a/docs/LAYOUT_LOG.md +++ /dev/null @@ -1,671 +0,0 @@ -# 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.