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.