diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 28d17c4..9b97ca7 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,805 +1,296 @@ # Handoff -Where the work in flight stands for a session picking it up cold. Keep current -invariants, measurements, and failed hypotheses here; this is not a decisions -log. Pruned on 2026-09-18: the frame/extent prototype's chronicle went, its -four surviving findings and two failed hypotheses stayed, and the -transparent-frames plan became a record of what it landed as. +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 transparent frames lands, after moving any fact that must survive. ## Where things stand -Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 -`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head -**`e44dea3`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, -the `Holds` retained-layout contract, region nodes, built-in alignment and -size rules, fixed-point layout, a box in pixels threaded down the draw, and -a report read as a fraction of the containing widget. No PR review was -present when checked on 2026-09-15. +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` (the +untracked `target-own/` there is its build output). It is the reviewed +baseline this work must preserve or improve. `/home/bob/repos/iris-pr18` has +since moved to a different WIP branch; do not use that checkout as #18. -The separate frame/extent experiment lives in -`/home/bob/repos/iris-layout-experiment`. **The transparent-frames protocol -is implemented** on `wip/transparent-frames`, head **`49cec82`**, three -commits over `34cafb6` (`wip/region-and-placement`, the experiment as it -was). The app's framework pin is unchanged, and **none of this is ready to -replace #18**: two fuzzer seeds still settle differently warm than cold, and -`many` and `resize` cost more than #18 on a deep tree. What it does, what it -cost and what it found are in **Transparent frames: what landed** below; -read that before anything else here about `Span`, `Pad`, `Stack`, the -placement pin or `measure_len`, all of which it supersedes. +The continuation is `/home/bob/repos/iris-layout-experiment`, branch +`wip/transparent-frames`, head **`49cec82`**, three commits over **`34cafb6`**. +It implements transparent frames and several useful simplifications, but it +is not ready to replace #18: -## Transparent frames: what landed (2026-09-18) +- the shrinker disagrees warm versus cold at seed 2 (`repaint`) and seed 108 + (`reorder`) at depth 5; +- `many` and `resize` still do more work than #18 on a deep tree; +- a resolved `leftover` share does not yet narrow its child's frame; +- `Scroll` incorrectly treats `leftover` as a whole extra viewport. -Decided with Bryan on 2026-09-18 and implemented the same day. His rules are -kept below because they are the design; the protocol section says what the -code now does, and **What it cost** and **What is not done, and why** say -where it stands. The step list the plan carried is gone: steps 1 to 5 are in -`1956be3`, the measurements that follow are in `0954770`, the review pass is -`49cec82`, and steps 6 to 9 are the open questions at the end. +The app's Iris pin is unchanged. Both experimental patches used to isolate +the open failures were reverted; the experiment checkout was clean at +`49cec82` when last inspected. -### Decided by Bryan +## The two rules to protect -- **Containers are transparent.** The frame a widget's fractions are of is - forwarded from its parent through a span, a stack and a scroll unchanged, - and through an inset narrowed by its margins. Any number of nested spans - lay out against one frame. The reason: `px` already passes through, `rel` - should behave the same way, and `leftover` is already the way to say - "fill the containing widget", so `rel(1.0)` meaning that too would be two - ways to say one thing. -- A frame is narrowed only by what is decided from above: a declared length - on the widget (`.width(rel(0.5))` on a span narrows its children's frame - too), an inset's margins, the root. A box that is an *answer* (a row's - height, a stack sized by a child) is never anything's frame. -- `rel` overflows on purpose when it sums past one or has pixels beside it. -- **A `leftover` share narrows the frame exactly as a declared `px` or `rel` - does** (Bryan, 2026-09-18: "Leftover was always intended to narrow the - frame just like the other two"). The code's `declared_lens` filtering - `leftover` out of what narrows a child's frame is a bug, and so is the - wording above and in the plan that a frame is narrowed only by a declared - length, an inset or the root. `rel(1.0)` inside a share is the share; - inside a padded share it is the share less the padding, so it fits. -- `Inset` and `Outset` take `px`, `rel` and `leftover` margins. Outset adds - its margins to the child's report and moves the child in; Inset draws the - child in a frame with the margins subtracted and reports the child's size - plus what it subtracted. An inset resolves what it subtracts, so `px` and - `rel` margins narrow the frame symbolically and a `leftover` margin is - resolved in pixels from the extent after the child answers, like a span's - shares. A general `Pad` would outset pixels and inset `rel` and - `leftover`. **Do not build these yet**; make them a few lines each to - write. -- Along its own axis a span places a child whose length is known in pixels - at its final slot while measuring, so it is drawn once, and moves a child - that is in the wrong place, `px` or `rel`, by translation rather than - drawing it again. Across itself it may still re-place by the answer. -- Layers may later be tied to another widget (a popup near an anchor). A - position is never defined by two widgets; positions compose up the tree. +These outrank the accumulated machinery: -### The protocol +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. -Two boxes per widget, both in the *parent's frame coordinates*: +Do not fix a failure with a tolerance, another measurement flag, a special +case in `Span`, or another layout method. The investigation tried those +shapes and found that the protocol was asking an unanswerable question. -- **frame** -- what a declared or reported fraction is a fraction of. - `UiRegion::FULL` for a transparent parent; narrowed by a declared length, - and that is the only narrowing in the code today. Its *length* is the same - on every ask of the widget, which is the property the whole retained model - rests on. -- **extent** -- where the drawing goes, as a part of the parent's own box. +## Decisions + +Decided with Bryan on 2026-09-17 and 2026-09-18. + +### One draw method, in a box decided from above + +`Widget::draw` remains the only layout method. A second measure method would +duplicate layout and drift from drawing; a shared helper would merely move +that obligation without removing it. + +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. Measuring asks may be +provisional while a parent is dividing room. Once the parent decides a slot, +the child is evaluated in that slot; placing an answer is reuse or translation, +not another `draw_at` in an answer-derived box. + +The current `offer` bit cannot express this. It is derived from +`place == offer_place`, but one parent draw can evaluate a child in the +parent's room, in a slot the parent decided, and in a box derived from the +child's own answer. The fresh answer is right in some of those boxes and a +retained answer in others. Remove the question rather than adding state that +tries to answer it. + +The target retained model has one answer per widget. Its `Holds` contract says +which parts it remains valid for. `redraw` re-asks it in the part of its last +parent ask. The offer machinery can then go: answer gating, `offer_place`, +`offer_part`, `at_offer`, and `measured()`. + +Keep `Part::Of`. It expresses a part of a widget's own box without making the +container read that box's length, and was a sound addition to the experiment. +Its dropped `extent_len` pin exposed seed 2, but composing every such pin +through `Of` is not the repair: that experiment broke seed 220 and the +region-node regression. + +### Frames are narrowed by every length decided from above + +Containers that only divide room are transparent: absent a length decision, +they forward the parent's frame. A declared `px` or `rel` length narrows the +child's frame. **A resolved `leftover` share narrows it in exactly the same +way.** The code's exclusion of `leftover` in `declared_lens` is a bug. + +A share is known only after the deciding span has measured fixed children and +divided its room. The measuring ask therefore cannot settle a `leftover` +child's frame. The placing ask supplies the resolved share as both its slot +and narrowed frame, and fully evaluates the child there. This matters across +the span too: a wrapping child's height may change once its width share is +known, so the span reads that child's cross-axis answer from the decided-box +evaluation, not from the provisional one. + +A box a widget merely reports does not narrow its descendants' frames. The +frame changes because a parent decided a declared length or a share, not +because the child's own answer happened to have that length. + +### `Pad` remains an outset + +Padding goes outside what it pads. There is no mixed "outset pixels, inset +fractions and shares" `Pad`. + +The clipped `examples/text.rs` render is evidence of the unresolved-share +bug, not intended behavior and not a reason to replace `rel(1.0)` with +`leftover`. If a 900 px row gives a padded child a 450 px share, that share +narrows the pad's frame; after 16 px padding on each side, `rel(1.0)` inside +the pad is 418 px and fits. Keep the example so it verifies that rule. + +### A share never adds room beyond the deciding box + +`Scroll` currently calls `apply_leftover`, which turns any `leftover` in the +content answer into a whole additional viewport. A row reporting `600 px + +leftover` in a 900 px viewport therefore gets a 1500 px content box, its text +re-wraps in room it was not measured in, and layout advances one fixed-point +iteration each time it is evaluated. + +That behavior is wrong. A share takes the room left in the viewport. Resolve +scroll content from only the fixed part of the answer and make it at least the +viewport: ```rust -/// What of a widget's own box a child is given, along one axis. -pub enum Part { - /// The whole of it. - All, - /// Frame lengths from where the box starts, which is what a container - /// dividing room among its children speaks: a child's report is a length - /// of the frame, so the cursor that sums those reports is one too. A - /// moved box re-places every child by re-adding its start, exactly. - From(UiSpan), - /// A part of the box in its own coordinates, which is what a container - /// that insets one speaks: taking eleven pixels off the end needs no - /// length, where saying the same thing in frame lengths would make the - /// container read its own box -- and a box chosen from its own answer - /// then feeds back into the answer. - Of(UiSpan), -} - -/// Where a child goes along one axis, as a part of this widget's box. -pub enum Place { - /// The child's answer, aligned inside the part by the child's alignment. - Within(Part), - /// Exactly the part; the answer is not placed inside it again. - Fill(Part), -} +let fixed = Len::from_parts(answer_len.rel, answer_len.px).to_px(container_len); +self.content_len = fixed.max(container_len); ``` -`Part::Of` is not in the plan; **it is what the measurements asked for**, and -the reasoning is in its doc comment above. `Part::From` is the plan's -extent-relative span. - -The widget's `draw` sees only its own box: `px_len`/`px_size` are that box's -pixels and narrow the extent range, `holds` widens it. Primitives and masks -are written in its coordinates; there is no `DrawRegion` and no -frame-coordinate primitive. A container reads `extent_len(axis)`, the box's -*symbolic* length along one axis in frame units, which pins its drawing to -that length and to nothing about the start -- one axis at a time, because a -span dividing one of them holds for any length of the other. A span that -divides room also reads `frame_px_len(axis)` (was `region_px_len`) and widens -through `frame_holds` (was `region_holds`). `placement()`, `region()`, -`box_of`, `measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement` -are gone. - -The parent side is one call, with a shorthand: - -```rust -pub fn widget_at<'s, W: ?Sized>( - &'s mut self, - id: &'s StrongWidget, - frame: UiRegion, // in this widget's frame coordinates; FULL forwards it - place: [Place; 2], -) -> DrawResult<'s, 'a, W>; - -/// The transparent default: the frame as given, the answer aligned in the box. -pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResult<'s, 'a, W> { - self.widget_at(id, UiRegion::FULL, [Place::Within(Part::All); 2]) -} -``` - -`Span` is the plan's, without the known-length shortcut (step 7, not done), -and reads nothing about where it sits: - -```rust -let far = painter.extent_len(axis); // symbolic; pins the length -let along = |from: Len, to: Len| match self.dir.sign { - Sign::Pos => UiSpan::new(from, to), - Sign::Neg => UiSpan::new(far - to, far - from), -}; -let across = Place::Within(Part::All); -for child in &self.children { // measure - let room = Place::Within(Part::From(along(cursor, far))); - let len = painter - .widget_at(child, UiRegion::FULL, axis.pair(room, across)) - .len(axis); - cursor.px += len.px + self.gap; - cursor.rel += len.rel; - lens.push(len); -} -for (child, len) in self.children.iter().zip(&lens) { // place - let slot = Place::Fill(Part::From(along(from, start))); - let placed = painter.widget_at(child, UiRegion::FULL, axis.pair(slot, across)); -} -``` - -`Pad` is transparent and says its inset in its own box's lengths, which is -what keeps it from reading how long that box is: - -```rust -let inset = |lead: Px, trail: Px| { - Place::Within(Part::Of(UiSpan::new( - Len::from_parts(Rel::ZERO, lead), - Len::from_parts(Rel::ONE, -trail), - ))) -}; -``` - -`Stack` gives its sizing child `Fill(All)` and the rest `Within(All)`; -`Scroll` measures at `Fill(All)` and places at `Fill(From(px content box))`; -`Masked` sets its mask over its own box. - -A report is still `LayoutLen { px, rel, leftover }` per axis, a fraction of -the reporting widget's frame, so it passes a transparent parent unchanged and -is composed by `within_len(frame.len())` through a narrowing one. **A stack -sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)` and the -fraction is applied once** -- the parked `wip/stack-fraction-twice` defect is -closed by the protocol rather than by a guard, and `placed_extent` is where: -it takes the answer's length *from* the part rather than composing it into -the part. - -### Retained state and reuse - -Per widget (`ActiveData`), replacing `region`/`placement`/`given_region`/ -`offer_len`/`offer_placement`: - -- `frame: UiRegion` in the parent's frame coordinates and `frame_abs` in - `parent_move`'s; `extent: UiRegion` in the frame's own coordinates, which - `window_region` and recomposition compose as `extent.within(&frame_abs)`. - There is no `offer_frame`: the frame's length is the same on every ask. -- `place: [Place; 2]` as last given, `offer_place` from the ask its answer - came from, and `offer_part: UiRegion` -- the box that ask gave it. The box - is kept and not worked out again from where the parent's own box is now: a - parent drawn again in the box its answer chose gives its children boxes it - never measured anything in. -- `answer: Option<(Size, LayoutHolds)>` from that ask; `holds: LayoutHolds` - for the drawing, where - -```rust -pub struct LayoutHolds { - pub frame: [Holds; 2], // frame pixel lengths - pub extent: [Holds; 2], // pixel lengths of the widget's own box - pub extent_len: [Option; 2], // its symbolic length, where read -} -``` - - The `placement: Option` pin is gone. Nothing may depend on where - a box starts. - -- primitives and the mask retained in the widget's own box's coordinates. - -**Reuse** of a drawing at an ask: same layer, parent move and region-node -choice; frame pixels inside `frame`; the box resolved from `place` (`Fill` -is the part; `Within` is the answer aligned in the part, or the part where -the answer fills) has pixels inside `extent` and, where pinned, the same -symbolic length. A frame that moved recomposes the subtree from retained -local coordinates (`recompose_subtree`). A box that moved **re-places every -child** through its retained `place` (`reposition`, generalised from the old -`extent_children` to all children because every child is now a part of it). - -**Dependencies composed into the parent** (`in_parent`): a child's `frame` -range goes through the child's frame length into the parent's frame range. A -child's own-box range goes into the parent's *frame* range through the -part's length where the part is `From` (a frame length), into the parent's -*own-box* range through the part where it is `Of`, and straight into it -where the part is `All`. A pin composes only for `All`. Answer dependencies -come from children whose answer was read, drawing dependencies from every -child drawn. - -**Local redraw** (`redraw`) still defers where the box it is given is not as -long as the box it was measured in -- see **What is not done, and why**. - -### What it cost - -Widget draws / distinct widgets / update, from -`tests/layout_diagnostics.rs`, at depth 8. Draw counts are deterministic, so -these are single runs rather than medians; `e44dea3` is #18's head and -`34cafb6` the commit this branch starts from. - -| seed 1 | e44dea3 | 34cafb6 | here | -| --- | --- | --- | --- | -| cold | 369/261/10.6 | 463/274/13.3 | 516/288/12.0 | -| repaint | 1 | 1 | 1 | -| many | 157/95/0.33 | 263/108/0.59 | 187/119/0.52 | -| size | 16/12/0.018 | 3/3 | 3/3/0.010 | -| scroll | 2/0.002 | 1 | 1/0.004 | -| resize | 13/13/0.019 | 22/15/0.032 | 24/76/0.090 | - -| seed 13 | e44dea3 | here | -| --- | --- | --- | -| cold | 1330/707/20.3 | 2940/982/28.3 | -| many | 524/159/1.09 | 1091/423/2.39 | -| resize | nothing drawn | 2215/510/6.56 | - -So `size` and `scroll` keep the experiment's wins, `many` is better than the -commit it starts from and still well short of #18, and **`resize` on a deep -tree is where it loses badly**: #18 draws nothing at all at seed 13, because -every fraction scales and every `Holds` admits the new window. - -Three measured findings, each already applied: - -- **Lazy `Within` placement costs more than it saves.** The plan's step 5 -- - leave 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, and - where it does not hold for both that is two drawings rather than one. - Seed 1's resize went from 391 widget draws to 29 with it removed. The test - that pinned three draws for a numeric leaf in a span went with it. -- **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, chasing its own width. `Part::Of` - says it as a part of the box instead and composes without a length. -- **Pin one axis at a time.** `extent_len` pinning both made a span - dividing one axis hold for one length of the other, and a resize broke - every span whose cross-axis answer moved. - -### What is not done, and why - -**Step 6, `redraw` without the deferral, does not hold.** Removing it -- -asking the measuring question locally and placing the answer afterwards -- -makes seeds 104 (`align`) and 210 (`reorder`) at depth 5 settle differently -warm than cold. The plan said to stop and report rather than restore it; it -is restored, in the form the protocol allows (`info.part.size() != -offered.part.size()`), and this is the report. - -**What is underneath it.** A `Place` is relative to the box the container is -being drawn in, so the same ask expression resolves to a different box -depending on which of its own boxes the container is drawing in -- and a -container is routinely drawn twice, once in the box its parent measured it -in and once in the box its own answer chose. So "the box this widget's -answer was measured in" cannot be recovered from the ask, which is why -`offer_part` is retained; and whether a given draw is a measurement cannot -be recovered either, which is where the two open seeds live: - -- **seed 2, `repaint`, depth 5**: `Stack > Pad{6,14,16,15} > Span{DOWN}` - with a wrapping text under the span. The span answers 880 px measured in - the pad's first box and 874.24 px measured in the box the pad's answer - then chose, and both are fixed points of "measure in the box, place at the - answer, measure again". Cold reuses the drawing and keeps the first; a - repaint redraws and lands on the second. -- **seed 1 at depth 4 and 108 at depth 5, `reorder`**: the same shape - through `Branch`, which chooses a subtree from a measured length, so the - two fixed points are two different trees. - -Four bookkeeping rules were tried for "which draw is a measurement": the -ask's first-ness alone, the place matching the retained offer place, the box -matching the box the answer was measured in, and a retained flag on the -drawing. Each fixes some seeds and breaks others, which is the signal that -the question is the protocol's rather than the bookkeeping's. What the -baseline does instead is never act on it: a local redraw whose box is not -the offer's defers to the parent, and the parent re-asks from a geometry -that a cold layout also reaches. - -**Also not done**: step 7 (`Span`'s known-length shortcut and the cross-axis -report in frame pixels), step 8 (`Inset`/`Outset` as test widgets) and the -`LazySpan`/`SizeRule` work that sits on top of this. - -**`Pad` is an outset and that is visible.** `examples/text.rs` has -`wtext(..).width(rel(1.0))` inside a `.pad(16)`: under the old reading that -was the padded box, and under transparent frames it is the window, so the -labels now sit at the window's edges and the left one is clipped. `view` and -`minimal` are byte-identical to `34cafb6`; `tabs` moves its red square 3 px; -`random` is a generated tree and moves where nested spans do. The plan's -"a general `Pad` would outset pixels and inset `rel` and `leftover`" is the -answer to this and is **not expressible in the protocol as it stands**: an -inset that narrows the frame *and* places the child in a part of its own box -needs the child's box expressed in the child's narrowed frame, and -`(0.5·B - 20)/(B - 20)` is not `rel + px`. Either the frame narrows and the -box goes with it (what the code does, so an inset inside a row draws its -child across the whole row), or the box is a part and the frame does not -narrow (`Part::Of`, what `Pad` does), or a third thing Bryan decides. - -**The two decisions, in the form they need answering.** - -1. *What is a widget's answer the answer to?* Options as they stand: keep - the deferral, so a mismatched box is always the parent's question (what - the code does, and what #18 does); or give a container one drawing rather - than two, so there is no second geometry to disagree about, which means - deciding a child's box before drawing it; or name the measuring geometry - explicitly in the ask rather than deriving it, which is the retained - `offer_part` taken further. -2. *What is `Pad`?* An outset (what the code does), an inset that takes the - child's box with it (so an inset in a row draws its child across the - row), or "outset pixels, inset `rel` and `leftover`", which needs a way - to say a box in a narrowed frame that the grid cannot express today. - -### What the two open seeds are (2026-09-18, second planning pass) - -Read by the planning session from the code and from traces at `49cec82`, -after Bryan asked whether the two questions above need answering or whether -there is a larger flaw. Both, and the flaw is one level below the frame: it -is the rule that decides **which answer places a widget's box**. - -```rust -// draw_inner -let measured = match info.offer() { - true => answer.0, - false => self.active[&id].measured().unwrap_or(answer.0), -}; -// DrawInfo -fn offer(&self) -> bool { self.place == self.offer_place } -// draw_at: the placing re-draw reuses the parent's `info`, so it runs with -// the same flag as the measuring one -let at_offer = info.offer(); -``` - -The `offer` bit is meant to say "this ask is a measurement, keep its -answer". It has no consistent value once a container's body is evaluated in -more than one box, and the code evaluates it in three kinds: the room its -parent measured it in, a box the parent decided (a span slot, a fill), and a -box derived from its own answer (`place()`, and a scroll's content box). - -- **Seed 2, `repaint`.** 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; measured in the - stack's *box* it has none, undraws the rect and reports the one-line - text's width. Cold kept the first because the stack's placing evaluation - reused the pad's retained answer: the span's `extent_len` pin is dropped - by `in_parent`'s `Part::Of` arm (only `All` composes a pin), so the pad's - answer looked valid for a box the span had never been measured in. Warm - re-asked the span in the box and got the second. Composing the pin through - `Of` where the part is the whole box less pixels (`Len::from_parts(len.rel, - len.px - part_len.px)`) makes seed 2 agree, and breaks seed 220 (`reorder`) - and `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`. -- **Seed 108, `reorder`.** Shrinks to `Span{LEFT} > [Span{LEFT} > [Span{DOWN} - > [Branch{probe Rect, wide Rect, narrow Wrapped, 483}, Wrapped], Rect], - Rect]`. The trace shows the `DOWN` span evaluated at 900, 450, 600 and - 300 px across in one cold layout, the wrapped text re-shaped at each. In - the last, the narrow text is drawn in its real 300 px box and answers 286 - px, and `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`. It then redraws the text 438.9 px wide inside a - 300 px box. Warm does the same with a different stale answer (286 px from - the first frame). Making `measured = answer.0` unconditionally and - recording every ask's answer fixes seed 108 and fails seeds 184, 246, 292, - 372 and two suite tests, among them - `unsettled::a_span_given_the_box_its_answer_decided_matches_a_cold_layout`; - the new failures are all under a `Scroll`. - -So the gate is load-bearing in one case and wrong in the other two. A -container re-drawn in a box its own answer derived (a scroll's content box -holding a row with a `leftover` child and a wrapping text) must place its -children at the answers the layout was computed from, or the text is offered -the room the leftover child gave back and the layout chases its own tail. A -container drawn in a box its parent decided (a 300 px slot after being -measured in 600) must re-measure its children there. A same-tree -re-evaluation whose place expression happens to differ must keep the fresh -answer. `place == offer_place` cannot tell these apart; nor could the four -rules tried before it; nor can the deferral, which is about local redraws -while these failures are inside full parent redraws. **The answers to -question 1 as posed do not resolve this**: (a) leaves it, (c) is the same bit -with more state. - -**Where the plan was wrong.** It claimed the retained model rests on the -frame's length being the same on every ask. Answers depend on the *part*, -not the frame, for every widget that reads its box, so transparent frames -fixed fraction resolution (a real win) and moved the second geometry from the -frame to the extent rather than removing it. Step 6's premise followed from -that claim and was false for the same reason. And the plan carried the -`offer_place` concept forward from the old protocol without noticing it is -undefinable here. The worker executed the plan as written, found exactly -this, and stopped; `Part::Of` was a sound addition apart from the dropped -pin. - -**Recommendation for Bryan to decide.** Make a container's body run only in -boxes its parent offered or decided, never in one derived from its own -answer: the placing step becomes reuse-or-translate and never `draw_at`, -and the offer machinery (`answer` gating, `offer_place`, `offer_part`, -`at_offer`, `measured()`) goes, leaving one answer per widget with the holds -that say which parts it is valid for, re-asked by `redraw` in the part of -the last ask. That needs every drawing to hold for its own answer box, which -today fails in three widgets and is fixable locally in each: - -- `Span` reads `extent_len` unconditionally. The slots depend on `far` only - with `leftover` children (when it fills, so the box is the part) or with - `Sign::Neg` (compute slots from `total` instead). Pin only in those cases. -- `Stack` draws non-sizing children in `All` of its measuring box, a box it - will never have when the sizing child reports `px`/`rel`. Draw them in - `From(0..size)` per axis where `size` is that child's answer, `All` where - it is `leftover`. -- `Branch` reads `extent_len(Y)` for "the rest of my box"; that is - `Of(40px..FULL)`. - -`Scroll` looked like the one legitimate own-answer box left, and on a -closer read (planning pass, later the same day) its circularity is -`Scroll`'s own doing rather than the protocol's. It resolves the content -length with `apply_leftover`, which turns any `leftover` in the answer into a -*whole extra viewport on top of the 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, and the layout drifts one iteration per evaluation. Under Bryan's -definition a share is the room left in the deciding box, so the content -length should be `max(viewport, px + rel·viewport)`: 900 for that row, the -rect takes the 300 px left, nothing scrolls, and the row's box in the -content box offers every child the room it was measured in. With that the -content box never re-offers room, `Scroll` needs no own-answer evaluation, -and the "return the answer from the first box" rule (seed 86) has nothing -left to guard. Proposed, not run. - -**Question 2, `Pad`.** Answered by the decision above that a share narrows -the frame: the overflow in the example comes from the pad sitting in a -`leftover` share whose length did not narrow the frame, and once it does, -`rel(1.0)` inside the pad is the share less the padding and fits. `Pad` -stays an outset. What follows for `Span` is that a `leftover` child's frame -is only known once the room is divided, so the child cannot be settled at -a measuring ask made in the room; it must be given its share as its frame -when placed, which is one more reason for the single decided-box evaluation -recommended above. The paragraph below is the earlier reading, kept for the -record. - -The contradiction is not specific to `Pad`. A -`rel(1.0)` child directly in a half-row `Fill` slot overflows its slot -because `rel` is of the row, and Bryan accepted that. A `rel(1.0)` inside a -pad in that slot overflows the pad's box for the same reason and by the same -amount less the padding. "Fill my padded box" is `leftover` inside the pad, -which already works. So: keep `Pad` as the outset, drop the "outset pixels, -inset `rel` and `leftover`" variant as inexpressible and unnecessary, and -change `examples/text.rs` from `.width(rel(1.0))` to `leftover` or no rule. - -### The plan as written, for reference - -What follows is the plan as it was handed over on 2026-09-18, kept verbatim -so that what was asked for can be read against what landed. Where the two -differ, the sections above are the code: `Part::Of` is not in it, lazy -`Within` placement is in it and was removed, `extent_len` takes an axis, and -steps 6 to 9 are not done. - -Its own preamble: - -> Decided with Bryan on 2026-09-18 after the trace below. The mechanism was -> checked against the code of `34cafb6` and against the two counterexamples -> on `wip/local-reask`; the pieces that were in doubt are called out. Do the -> steps in order and run each step's check before the next. **If a check fails -> and the fix that suggests itself is a tolerance, a pin, a deferral, a second -> layout method, or a special case in `Span`, stop and report instead**: those -> are exactly the patches that have been made around this design before, and -> each one made the next problem harder to see. - -#### The protocol - -Two boxes per widget, both in the *parent's frame coordinates*: - -- **frame** -- what a declared or reported fraction is a fraction of. - `UiRegion::FULL` for a transparent parent; narrowed by `ask_box` for a - declared length and by an inset. Its *length* is the same on every ask of - the widget, which is the property the whole retained model rests on. -- **extent** -- where the drawing goes. Given by the parent as a `Place` - per axis, **relative to the parent's extent start, in frame units**: - -```rust -/// Where a child goes along one axis, as a part of this widget's extent. -/// Spans are frame lengths from the extent's start, so a span's slot is -/// `from..start` and a moved extent re-places every child by re-adding -/// its start, exactly. `None` is the whole extent. -pub enum Place { - /// The child's answer, aligned inside the part by the child's alignment. - Within(Option), - /// Exactly the part; the answer is not placed inside it again. - Fill(Option), -} -``` - -The widget's `draw` sees only its extent: `px_len`/`px_size` are the -extent's pixels and narrow the extent range, `holds` widens it. Primitives -and masks are written in extent coordinates (`FULL` is the extent); there is -no `DrawRegion` and no frame-coordinate primitive. A container reads -`extent_len() -> UiVec2`, the extent's *symbolic* length in frame units, -which pins its drawing to that length and to nothing about the start. A -span that divides room also reads `frame_px_len(axis)` (today's -`region_px_len`) and widens through `frame_holds` (today's `region_holds`), -which narrow the frame range. `placement()`, `region()`, `box_of`, -`measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement` go. - -The parent side is one call, with a shorthand: - -```rust -pub fn widget_at<'s, W: ?Sized>( - &'s mut self, - id: &'s StrongWidget, - frame: UiRegion, // in this widget's frame coordinates; FULL forwards it - place: [Place; 2], // relative to this widget's extent start, in frame units -) -> DrawResult<'s, 'a, W>; - -/// The transparent default: the frame as given, the answer aligned in the extent. -pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResult<'s, 'a, W> { - self.widget_at(id, UiRegion::FULL, [Place::Within(None); 2]) -} -``` - -`Span`, measuring and placing, with the row being its own extent: - -```rust -fn draw(&mut self, painter: &mut Painter) -> Size { - let axis = self.dir.axis; - let far = painter.extent_len().axis(axis); // symbolic; pins the length - let along = |from: Len, to: Len| match self.dir.sign { - Sign::Pos => UiSpan::new(from, to), - Sign::Neg => UiSpan::new(far - to, far - from), - }; - let across = Place::Within(None); - // Measure. A child whose length is already known -- a rule, a hint -- - // with no share before it is placed at its slot here and never again. - let mut cursor = Len::ZERO; - let mut shares_before = false; - let mut lens = Vec::with_capacity(self.children.len()); - for child in &self.children { - let known = painter.size_hint(child, axis).filter(|_| !shares_before); - let place = match known { - Some(len) if len.leftover == Weight::ZERO => { - Place::Fill(Some(along(cursor, cursor + Len::from_parts(len.rel, len.px)))) - } - _ => Place::Within(Some(along(cursor, far))), - }; - let len = painter.widget_at(child, UiRegion::FULL, axis.pair(place, across)).len(axis); - shares_before |= len.leftover != Weight::ZERO; - cursor += Len::from_parts(len.rel, len.px + self.gap); - lens.push(len); - } - // total, room = far - fixed, the shares decision through frame_px_len and - // frame_holds(through(room)): unchanged from today. - // Place. A child already at its slot is an exact reuse; one whose slot - // moved is recomposed, since its length did not change. - for (child, len) in self.children.iter().zip(&lens) { - // undraw a share with nothing to share, accumulate fixed/taken, then: - painter.widget_at(child, UiRegion::FULL, axis.pair(Place::Fill(Some(along(from, start))), across)); - } - Size::from_axis(axis, total, ortho) -} -``` - -Across itself a span reports the longest child in *frame pixels* among the -`px` and `rel` parts of its children's reports, as that child's own `Len`. -Comparing in pixels is safe here because the frame is decided from above -and nothing feeds back; the read is a `frame_px_len` and narrows the frame -range, and at the crossover both candidates are the same number of pixels, -so the drawing is the same on either side of it. A child's `leftover` -across a span contributes nothing to that: across, children overlap rather -than divide anything, so a share can only mean "as tall as the span", and -`Place::Within(None)` already fills the extent for a leftover answer. Only -when no child has a `px` or `rel` part does the span report `leftover` -itself and take its parent's room (Bryan, 2026-09-18: "returning rest if -any have it is probably fine", refined to this because it costs nothing). -So `row![text, rect]` is as tall as the text with the rect filling it, and -`row![rect, rect]` fills its column's share. **Deliberately not done**: a -fully transparent `leftover` across a span, where the span's height would -be the larger of its tallest fixed child and the share its parent hands -back. That needs the parent's division to come back down after the report, -a second resolution pass; Bryan deferred it until the core is settled. Do -not attempt it as part of this plan. - -The shapes the rest of the containers take (write them, they are short): - -```rust -// Stack: the sizing child takes the whole extent, the rest are aligned in it. -painter.widget_at(child, UiRegion::FULL, [Place::Fill(None); 2]).size() // sizing -painter.widget_at(child, UiRegion::FULL, [Place::Within(None); 2]); // others -// Scroll: viewport is the extent; content is a pixel box offset by the scroll. -painter.widget_at(&self.inner, UiRegion::FULL, - self.axis.pair(Place::Fill(Some(UiSpan::px(anchor - amt, anchor - amt + content_len))), Place::Fill(None))); -// Inset with px/rel margins m: a narrowed frame and a narrowed extent. -let frame = m.narrow(UiRegion::FULL); // Len subtraction, exact -let len = painter.extent_len(); -painter.widget_at(child, frame, [Place::Within(Some(UiSpan::new(m.lead.x, len.x - m.trail.x))), /* y */]).size() - + m.total() // report child plus margins -// Outset: the same placement with the frame forwarded (UiRegion::FULL). -// Leftover margins: measure the child with Within(None), divide the room -// left in the extent by weight as a span does, then place with Fill. -``` - -A report is still `LayoutLen { px, rel, leftover }` per axis. A fraction in -it is of the reporting widget's frame, so through a transparent parent it -passes unchanged and through a narrowing parent (declared, inset) it is -composed by `within_len(frame.len())` as `in_parent_frame` does today. A -stack sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)`, -is placed at half the frame, and hands its child the whole extent while the -child's frame is still the full one: the fraction is applied once. - -#### Retained state and reuse - -Per widget (`ActiveData`), replacing `region`/`placement`/`given_region`/ -`offer_len`/`offer_placement`: - -- `frame: UiRegion` in the parent's frame coordinates, and `place: [Place; 2]` - as last given; `offer_place: [Place; 2]` from the first ask of the - parent's draw at the offer. There is no `offer_frame`: the frame's length - is the same on every ask, and `redraw` asserts it in debug. -- `frame_abs`, `extent_abs`: the two composed into `parent_move` - coordinates, for writing primitives and for `window_region`; rewritten by - recomposition. -- `answer: Option<(Size, LayoutHolds)>` from the offer ask; `holds: - LayoutHolds` for the drawing, where - -```rust -pub struct LayoutHolds { - pub frame: [Holds; 2], // frame pixel lengths - pub extent: [Holds; 2], // extent pixel lengths - pub extent_len: [Option; 2], // symbolic extent length, where read -} -``` - - The `placement: Option` pin is gone. Nothing may depend on where - an extent starts. - -- primitives and the mask retained in extent-local coordinates, as now. - -**Reuse** of a drawing at an ask: same layer, parent move and region-node -choice; frame pixels inside `frame`; the extent resolved from `place` -(`Fill` is the part; `Within` is the answer aligned in the part, or the part -where the answer fills) has pixels inside `extent` and, where pinned, the -same symbolic length. A frame that moved recomposes the subtree from -retained local coordinates (today's `recompose_subtree`). An extent that -moved **re-places every child** through its retained `place` -(`extent_abs.start + place`, then the child's own reuse test), stopping at -region nodes; this is today's `reposition` over `extent_children`, -generalised to all children because every child is now placed relative to -the extent. A child whose reuse fails there is drawn again at its retained -place. Along a span this is what moves `px` and `rel` children whose slots -shifted: the span's redraw re-issues `Fill(from..start)` with the same -lengths and different starts, and the child recomposes. - -**A `Within` ask does not place the answer immediately.** The child draws -in the whole part, and the aligned answer box is applied by the next ask of -that child in the same parent draw, or, for a child not asked again, by a -pass at the end of the parent's draw over `children`. That is today's -`measure_len` rule made the rule for every open axis; it is what keeps a -span child at one draw plus one recomposition rather than two -recompositions. - -**Dependencies composed into the parent** (today's `in_parent`): a child's -`frame` range goes through the child's frame length into the parent's frame -range. A child's `extent` range goes into the parent's *frame* range through -the part's length where the part is a span (a frame length), and into the -parent's *extent* range where the part is `None` (the whole extent). Answer -dependencies come only from children whose answer was read, drawing -dependencies from every child drawn, as today. Pins do not compose: a child -pinned on its symbolic length is checked when it is re-placed. - -**Local redraw** (`redraw`): no deferral on lengths. Draw at `offer_place` -with the drawing left there where the given place differs (measuring), -compare answer and holds with what was retained, keep a still-covering old -guarantee as today, mark the parent only on a change, then place at the -given `place` (a reuse where the holds admit it). Deferral remains only for -a changed declared length or alignment and for an undrawn widget. The -`given_px != offered_px` branch and `offer_len` are deleted, not disabled. - -#### Steps, each with its check - -Work on a branch from `34cafb6` in `/home/bob/repos/iris-layout-experiment` -(its `target` is its own; the baseline's is `target-own`). The checks are -`cargo test --workspace` in debug, `cargo test --release --test generated` -(fast oracle), and after steps 4, 6 and 7 the long ones: +For `600 px + leftover` this is 900 px: the share receives 300 px and nothing +scrolls. Pixel content still scrolls when its fixed length exceeds the +viewport. A lone share fills the viewport. The content box no longer invents +new room, so `Scroll` is not an own-answer-box exception to the decided-box +rule. + +### Existing fixed-point and box-chain design stays + +The fixed-point grid, `Holds::through`, the pixel box threaded down the draw, +region nodes, and the warm/cold equality rule are sound. Their durable +invariants are in `docs/LAYOUT.md`. Transparent frames fixed fraction +resolution; the remaining defect is evaluating container bodies in multiple +boxes and trying to infer which evaluation counted as measurement. + +## Why the open seeds fail + +The complete traces, shrunk trees and counter-experiments are in +`docs/LAYOUT_LOG.md`. The short version a worker needs is: + +- **Seed 2 (`repaint`)**: a stack measures a non-sizing subtree in room the + stack will never have, then reuses that answer in the stack's one-line box. + A dropped `Part::Of` pin makes the reuse look valid. Composing that pin fixes + this seed but breaks seed 220 and an existing region-node test, so it is not + the fix. +- **Seed 108 (`reorder`)**: a nested span correctly evaluates a branch in its + final 300 px box, but `draw_inner` discards the fresh 286 px answer for a + retained 438.9 px answer from an earlier 450 px evaluation because the + place expression changed. Always keeping the fresh answer fixes this seed + but breaks four seeds and two tests under `Scroll`. +- Those `Scroll` failures are the `apply_leftover` feedback loop above. Once + that loop is removed, no legitimate container needs to lay children out in + a box derived from its own answer. + +The worker faithfully implemented the earlier plan, tried four definitions +of "measurement", restored the safe deferral when each failed elsewhere, and +stopped. Do not resume that search. + +## Implementation plan + +Work in `/home/bob/repos/iris-layout-experiment` from `49cec82`. Make each +step a warning-clean commit 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. + +### 1. Pin the two failures as focused tests + +Turn the shrunk seed 2 and seed 108 trees from `docs/LAYOUT_LOG.md` into fast, +named regression tests. Each must demonstrate the present warm/cold mismatch +at `49cec82`, then pass because both paths select the same tree and boxes—not +because the assertion was weakened. + +Also retain these nearby regression tests while changing the protocol: + +- `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered` +- `unsettled::a_span_given_the_box_its_answer_decided_matches_a_cold_layout` +- the seed 86 `Scroll` fixed-point case +- the tests for a length in pixels staying that many pixels and for an exact + leftover split + +Check the ordinary suite and each new test individually. + +### 2. Correct `Scroll`'s content length + +Replace its `apply_leftover` content sizing with the fixed-part calculation +above. Add focused cases for: + +- `600 px + leftover` in 900 px resolves to 900 px; +- fixed content wider than the viewport still scrolls; +- a lone `leftover` child fills without scrolling; +- the wrapping-text-plus-share case is stable warm and cold. + +The seed 86 test stays until the full protocol has landed, even if its old +special rule becomes moot. + +Check the suite, fast oracle, and the known Scroll counterexamples from the +fresh-answer experiment (seeds 184, 246, 292 and 372). + +### 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. + +### 4. Make resolved shares narrow frames + +Give a `leftover` child its resolved slot as its narrowed frame at the placing +ask. A span becomes a decided two-pass layout: + +1. measure fixed children and collect share weights; +2. divide the deciding box's remaining room; +3. place/evaluate each child in its decided box, with a share child's frame + narrowed to that share; +4. derive the span's cross-axis answer from those decided evaluations where a + child's answer can depend on its share. + +Do not put `leftover` back into a declaration helper before it has a resolved +length; unlike `px` and `rel`, its frame cannot be known during the first pass. + +Add tests that a `rel(1.0)` child directly inside a half share is half the row, +and that the same child inside `.pad(16)` is the share less 32 px. The existing +`examples/text.rs` case should render inside its padding without changing its +width rule. + +### 5. Remove obsolete machinery and settle the retained path + +Once all callers use the decided-box path, delete answer gating, +`offer_place`, `offer_part`, `at_offer`, `measured()`, and the local-redraw +deferral whose only purpose was distinguishing measurement from placement. +Write `ActiveData::answer` in one place, and keep `DrawInfo` on `ActiveData` +rather than copying fields and reconstructing it in `redraw`. + +Run the ordinary suite, fast oracle and shrinker before doing performance +work. Both new focused tests must pass on cold, repaint and reorder paths. + +### 6. Restore the expected retained cost + +Implement the known-length `Span` shortcut only after correctness is stable: +a `px` child after a `px` child should draw once cold and never on repaint. +Report cross-axis sizes from the decided evaluation, including wrapping +share children. Compare all six diagnostic phases with `e44dea3`; investigate +work-counter differences before interpreting time. + +Expected direction, not a license to weaken correctness: `size` and `scroll` +keep their wins, `many` approaches #18's distinct-widget counts, and `resize` +returns to about #18's 13 draws at seed 1. Record final counters in the +temporary log and durable conclusions in `docs/LAYOUT.md`. + +### 7. Full verification and landing + +Run, in the experiment checkout: ```sh -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 +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -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 ``` -1. **`Place` and `widget_at(child, frame, [Place; 2])`**, with `widget` as - the shorthand, replacing `widget_at`/`widget_within`/`widget`/ - `measure_len`. Internally keep `draw_inner` but make `DrawInfo` carry - `frame` and `place`; delete `DrawRegion`, `ExtentPlacement`, - `reads_placement`, `region()`, `placement()`, `box_of`. Add - `extent_len()`, rename `region_px_len`/`region_holds` to `frame_*`. - Check: it compiles with the call sites moved in step 2; no test yet. -2. **Call sites**: `Span` as above (without the known-length shortcut yet), - `Stack`, `Scroll`, `Pad` (as an inset by pixels, unchanged behaviour), - `Masked`, `Branch` in `random.rs`, `Text` (`glyphs` in extent - coordinates), the examples. Check: suite and fast oracle. Expected to - pass with `Span` reading `extent_len` on both axes and pinning it. -3. **Re-place every child on an extent move**, generalising `reposition`; - delete `extent_children`. Check: suite, fast oracle, - `retained::a_span_ruled_across_itself_moves_its_child_without_redrawing_it` - and a new test: a row whose first child grows by a pixel rule moves the - two after it without drawing them (count draws with the `Counted` widget - in `tests/cases/retained.rs`), once with a `px` second child and once - with a `rel` one. -4. **The symbolic-length pin replaces the placement pin**; `LayoutHolds` - as above. Check: suite, fast oracle, shrinker at 400/5, and the rig's - `many` at seed 13, depth 8 (`IRIS_SEED=13 IRIS_DEPTH=8 IRIS_PHASE=many`): - "reuse outside: the placement it was pinned to" is gone as a counter and - distinct widgets a frame should already be well under `34cafb6`'s 508. -5. **Lazy `Within`** with the end-of-draw pass. Check: suite; - `retained::a_span_does_not_place_its_measurement_before_assigning_the_childs_slot` - still counts three draws. -6. **`redraw` without the deferral**, as above; delete `offer_len` and the - `given_px != offered_px` branch. Check: suite, fast oracle, shrinker, - oracle at 1000/6 -- this is the step `wip/local-reask` failed at seeds - 532 and 398, and both must pass now because the frame no longer changes - under the widget. If either still fails, shrink it and report; do not - restore the deferral. Then the rig: `many` at seeds 1 and 13, depth 8, - against `e44dea3` in `/home/bob/repos/iris-layout-baseline` - (`CARGO_TARGET_DIR=$PWD/target-own`). Expect distinct widgets near - `e44dea3`'s 95 and 159 and no deferral at all. -7. **Span's known-length shortcut and the cross-axis report** as written. - New tests: a `px` child after a `px` child is drawn once on a cold - layout and never on repaint; nested rows two deep with `rel(0.5)` give - half the *root* (or half a declared `.width(rel(0.5))` ancestor) wherever - the child sits; a row with a `rel(0.5)`-tall child reports `rel(0.5)` - across. Check: everything, including the long runs and the five - reference renders (`view`, `minimal`, `random`, `tabs`, `text`) against - `34cafb6` -- expect `random` and `tabs` to move where nested spans or - span heights change meaning, and look at them rather than diffing. -8. **Prove `Inset`/`Outset` are easy**: write them as test widgets in - `tests/cases/layout.rs` with `px`, `rel` and `leftover` margins, a dozen - lines each, and assert the child's box; do not add them to the crate. -9. Measure all six phases against `e44dea3` with the uninstrumented rig, - record the table here, and update the "Retained-layout invariants" list: - delete the bullets about `reports_of`, `decided`, `dirty_size_under`, - the placement pin and the offer-length deferral, and add the frame rule. - -Expected against `e44dea3` at seed 1 and 13, depth 8: `size` and `scroll` -keep their wins; `many` within a few tens of percent, from the container -body running at the offer placement and at the placed one; `resize` at or -below its 13 draws. A `many` result above 2x is a sign something above was -not done as written, not a reason for a new mechanism. - -**Widen one fuzzer axis at a time, and record which.** Seeds **1121** and -**1839** at **depth 4** failed on `ea6dbae` and on every commit before it, -and nothing in the routine verification reached them: the fast oracle takes -ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6, -so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by -`4bd8607`. The scan that found them, worth running again after any change -to layout: +Then repeat the 2000-seed depth-4 scan over all fifteen cases. It is the only +run that found seeds 1121 and 1839 before their fix; depth and breadth find +different defects. `Rng::new` uses `seed | 1`, so adjacent even/odd seed +pairs describe the same tree. The temporary scan target used this body and +was deleted after the run: ```rust -// tests/scan.rs, deleted once it had done its job over_seeds((1..=2000).collect(), |seed| { let grown = plan(seed, 4, &Edits::default()); for &case in ALL.iter() { @@ -810,757 +301,16 @@ over_seeds((1..=2000).collect(), |seed| { }); ``` -261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at -`4bd8607`. `Rng::new` is `seed | 1`, so an even seed and the odd one above -it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and -1839. - -**Two ideas outrank everything else in this document** (Bryan, 2026-09-17). -First, a changed tree lays out exactly as if it had been drawn that way from -the start; that is what the retained machinery is for and what the oracle -and shrinker check. Second, widgets are predictable: `px` is that many -pixels, `rel(0.5)` is half of the containing widget's area however many -siblings there are and wherever it sits among them, and `leftover` is a -share of the room left once every sibling's `px` and `rel` are resolved. The -invariants listed further down were accumulated by agents chasing single -failures; any of them may be simplified or deleted if those two ideas still -hold. - -## Review of 2026-09-17 - -A fresh read of `core/src/fixed.rs`, `orientation/`, `ui/holds.rs`, -`ui/painter.rs`, `ui/render_state.rs` and the position widgets, outside of -doing work on them, with each finding checked by a scratch test. - -**Verdict.** The concepts are sound and stay. Fixed point on a `1/1024` -grid is the right base for a layout that decides "same box or not" by -equality. Threading the pixel box down the draw, with `Holds::through` the -exact preimage of that one multiply, is the strongest idea in the code: -layout has one route to every length and the reuse test is its exact -inverse. Offer, given and placed is the ordinary measure-then-arrange model. -What needed work was the bookkeeping around the second ask, one boundary in -`Span` computed by an expression other than the drawing it guards, and a -`rel` that meant two things. The two-step residual is structural and no -grid width fixes it; where it becomes visible is the shader's snap. - -### Decided by Bryan - -- **`rel` is a fraction of the containing widget's whole area.** In a span, - `rel(0.5)` is half the span whatever else is in it and wherever it sits. - It is never a fraction of what was left after earlier children. This - supersedes the 2026-09-16 reading that a report is a fraction of the box - the widget was given, wherever that box is a remainder rather than the - child's whole area. -- **`draw` stays the only layout method on `Widget`** (Bryan, 2026-09-17, - reversing the same day's acceptance of a measure/draw split). Ease of - writing a widget is half the reason. The real one is that a second method - holding the same layout drifts from the first, which a span makes - extremely easy, and the shared logic then gets pulled into helpers both - call that still have to be applied carefully in each. Where the framework - needs a widget's layout twice it runs the same body again with the - painter in a different state, or hands it more through the painter. -- **A widget's frame does not change between the ask that measures and the - ask that places.** Fractions are of the frame; its own box reaches the - widget through the painter. This is what transparent frames implements. - -### A report is a fraction of the containing widget (landed, `ffd79f3`) - -`rel(0.5)` is half the span whatever else is in it and wherever the child -sits. 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 -- where the same half written as a rule on the child took -half the row. - -The ask carried the base separately (`reports_of`) for a day; transparent -frames replaced it with the frame, which is the same statement made once per -widget rather than once per ask. The offer is still the remainder, because a -text has to wrap at the width actually there. - -**A span can now overflow itself without bound**, which is the consequence -Bryan's rule asks for: two children reporting half each take the whole row -and a third starts past the end. Under the old reading `total.rel` could -not exceed one, so `Span`'s `fixed <= 0` branches were only reachable -through declared fractions; they are ordinary now, and with them boxes of -negative length passed down to children. - -### An answer is not an answer while anything under it is dirty (`0e0d4af`, superseded by `a0693ac`) - -**Superseded: `dirty_size_under` is deleted.** Once a resize goes through -the settling walk the ordering makes the whole category unreachable rather -than checked, which is Bryan's steer and the better answer. The defect and -its reasoning are kept below because they say what the ordering is buying. - - -`draw_inner` took an answer from `try_reuse`, which checks only whether the -widget itself is marked, where `retained_answer` beside it also refused one -while anything the widget read a size from was dirty. A widget whose drawing -happened to be reusable therefore handed back the answer it gave before that -descendant changed, and nothing puts it right: the comparison that tells a -reader its child's answer moved is in `redraw`, and a widget settled inside -its parent's own draw never goes through it. The placing ask redraws the -subtree, the descendant's mark is cleared there, and the parent keeps a -number the tree no longer agrees with. - -So `dirty_size_under` was not the optimization its comment claimed: it was -what made an answer an answer, until the walk made the state it guarded -against impossible to be in. - -Found at seed 564, depth 6, `shuffle-every-other`, reachable only once a -span could overflow itself. No hand-built tree ever reproduced it, and the -seeds at depth 4 above fail for some other reason. - -### A text is handed back a box its own line fits in (landed, `4bd8607`) - -A wrapping text reported the width it used through `Px::from_f32`, which -takes the nearest step and is under the line the shaper measured half the -time. A parent that sizes itself from that report -- a stack taking a span's -width, the span taking its widest child's -- then hands the text back a box -its own longest line does not fit in, and a greedy break there is one line -longer. Warm kept the break it had; cold made the narrower one. - -Two tolerances were holding that together and both are gone: - -- `TextBuffer::shape` answered any width within `BREAK_EPSILON_PX = 0.05` of - the longest line from the break in hand. Fifty steps of the grid, and a - structural decision taken on a hair's breadth -- the thing the `Span` - boundary invariant below already forbids. It is `want >= layout.width()` - now, exactly. -- The `Holds` range the text declares started at the nearest step to its - longest line, so it admitted boxes that line does not fit in. It starts at - `Px::ceil_from_f32` of it now. - -Neither was the fix. **The fix is the report**: `Size::from_px( -PxVec2::ceil_from_f32(tex.size))`, the step at or above what was measured, -so the box that comes back fits. With it in place either tolerance could -have stayed and the case passes; both are wrong on their own terms, so both -went. `Fixed::ceil_from_f32` is new and is the only rounding on the grid -that is not to the nearest step. - -The general shape, and the third time this branch has hit it: **a value that -comes back as a box has to be rounded away from the measurement, not to the -nearest step.** Rounding to nearest is right for a value being carried; -it is wrong for a bound. - -### A frame settles strictly bottom-up (landed, `a92c6ac`) - -The queue was already deepest-first, but a widget that could not settle -where it was called `redraw` on its parent from inside itself, which drew a -shallow widget while dirty widgets deeper in other subtrees were still -pending. A parent drawing over a subtree that has not settled reads answers -about to move, and the one that settles does so inside the parent's draw -- -where its mark comes off and nothing compares what it now answers. - -A widget that cannot settle defers instead: it marks its parent, stays -marked, and waits in `deferred` until the walk reaches the parent's depth, -which cannot happen before everything deeper has settled. - -```rust -loop { - let next = rsc.widgets().needs_redraw.iter().copied() - .filter(|id| !self.deferred.contains(id)) - .max_by_key(|&id| self.depth(id)); - let Some(id) = next else { break }; - if !self.redraw(id, rsc) { - self.deferred.insert(id); - } -} -``` - -Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check: -"then that entire category of issue can't even occur". - -**The walk is sound on its own, and the second entry point is closed** -(`a0693ac`). By induction on depth: when a widget at depth d draws fresh, -every dirty widget deeper has been popped, so each is settled or deferred, -and a deferred one has marked its parent. A clean child asked by that draw -therefore has a clean subtree, because anything dirty under it would have a -dirty parent, and so on up to the child itself. - -The entry point that was left was `update` drawing the root for a resize -before the walk ran, top-down over a tree with dirty widgets still in it. -It is closed by marking the root instead, so layout is one walk a frame and -`dirty_size_under` is gone at both call sites. **The root is marked only -where the new output falls outside what its answer holds for**: that range -is the intersection of everything under it, so admitting the new output -says the whole tree stands, and nothing above the root moved. Marking it -unconditionally cost the root its own `Holds` -- a leaf root that scales -with its box was drawn again on every resize, which two tests caught. - -The rig says the cost is scheduling only: on `resize`, one queue pop, one -local redraw and one depth read appear and one failed reuse attempt goes, -and every other counter on `cold`, `repaint`, `many`, `size`, `scroll` and -`resize` is identical. - -### A subtree that changes hands is recorded on both sides (landed, `e44dea3`) - -A subtree can be reused whole under a different parent -- same box, same -layer, same region node, clean -- and nothing in the drawing says it moved. -Two things read who its parent is, and both were wrong after one of these. - -- The **old parent still listed it**, and a parent's next draw undraws - whatever is missing from that list. Two spans under one root, with the - root swapping which of them it holds, drew the subtree under the new span - and then erased it when the old one drew. -- Its **depth** was the one it had under the old parent, which is what the - settling walk orders by, so a change made under it afterwards settled at - the wrong point in the frame. - -Both are written where `draw_inner` already records what the ask decided: -`active.parent` is replaced and the old parent's `children` repaired, and -`try_reuse` re-walks the subtree's depths -- only where the top of it -moved, which is what makes that free in the ordinary case. Pinned by -`retained::a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left` -and `..._settles_at_the_depth_it_moved_to`; each fails without one half. - -The fuzzer never re-parents (`reshuffle` only trades children between a -span and its own spares), which is why nothing generated reached either. - -### A span's leftover boundary is its own inverse (landed, `53b00c6`) - -The decision used a rounded division where the room the children get is a -floored multiply, so the boundary and the drawing it guarded were two -expressions for one length. `room` is that length as a `Len`, `room.to_px` -is the multiply, and `Holds::through` is its exact preimage: - -```rust -let room = Len::rel_max() - Len::from_parts(total.rel, total.px); -let mut shares = false; -if total.leftover > Weight::ZERO { - shares = room.to_px(painter.px_len(axis)) > Px::ZERO; - let holds = match shares { - true => Holds::from(Px::STEP..=Px::MAX), - false => Holds::from(Px::MIN..=Px::ZERO), - }; - painter.holds(axis, holds.through(room)); -} -``` - -The three branches were the sign of `1 - rel`, which `through` reads -already. Forty lines became twelve and one `div` left layout. The general -rule stands and is now demonstrated: **derive a boundary through the -inverse of the expression that draws, never by a second expression for the -same length.** - -### Two branches parked, one of them now closed - -**`wip/stack-fraction-twice` is closed by transparent frames.** A stack -sized by a child that reported a fraction applied that fraction twice -- -half a row became a quarter -- because the placing ask resolved the fraction -in the box the answer had already chosen. Under the protocol above a report -is a fraction of the frame and `placed_extent` takes it *from* the part -rather than composing it into the part, so it is resolved once. No oracle -could see it (warm and cold shrank alike), so the test that came with the -branch is what pins it. - -**`wip/padding-outset-and-inset` is superseded** by the `Pad` question at the -end of "What is not done, and why". What it got right is kept: padding goes -outside what it pads, and an `Inset` is a separate widget. What stopped it -- -a child declaring `rel(0.5)` under an `Inset` coming out 47.5 px of the 190 -inside -- is the same second application of a fraction, and the protocol -removes it. - -### `Span`'s leftover boundary is a third expression for the room - -The decision uses a rounded division, `total.px.div(fixed)`, while the room -the children get is a floored multiply, so the two disagree at the boundary. -Measured with 300 px, `rel(2/3)` and a `leftover` child: - -| row width | leftover child | its threaded length | -| --- | --- | --- | -| 900.000 | undrawn | | -| 900.001 | drawn | 0 steps | -| 900.002 | drawn | 0 steps | - -Harmless at two steps, but the three-branch block collapses into the inverse -that already exists. `room` is computed a few lines below the block as -`Len::rel_max() - Len::from_parts(total.rel, total.px)`, and its `to_px` is -exactly the threaded length the leftover children share: - -```rust -let room = Len::rel_max() - Len::from_parts(total.rel, total.px); -let mut shares = false; -if total.leftover > Weight::ZERO { - shares = room.to_px(painter.px_len(axis)) > Px::ZERO; - let holds = match shares { - true => Holds::from(Px::STEP..=Px::MAX), - false => Holds::from(Px::MIN..=Px::ZERO), - }; - painter.holds(axis, holds.through(room)); -} -``` - -`through` already handles a negative fraction and a zero one, so the -`fixed < 0` and `fixed == 0` branches go with it. The general lesson: `mul` -floors while `div`, `div_int` and `ratio` round to nearest, so a boundary -derived with a division guards a drawing made with a multiply. Derive -boundaries through `through`, or make the grid floor everywhere. - -### Where the residual comes from, and the snap - -The `e44dea3` baseline's two-step allowance came from inverse remapping and -alignment composed by different routes. The frame/extent continuation below -retains local widget frames as well as local primitive coordinates, recomposes -in the same order as a cold draw, and removes inverse remapping. Its oracle -requires exact pixel-region equality. That experiment is not yet the pinned -framework; the baseline's snap decision below still applies there. - -Where it does matter is `snap_floor` in `prelude.wgsl`, which adds half a -layout step before flooring: that absorbs float error and not a layout -step, and truncation makes "one step under an integer" the common residue. -A third of 900 px is 299.999 on the grid and lands at 299 on screen, which -is the pixel `08c9d5a` moved `tabs`'s arcs by. Rounding to the nearest -pixel absorbs both the truncation and the two-step residual everywhere -except within two steps of a half pixel, where layout never lands on -purpose, and keeps integer widths for equal fractional parts: - -```wgsl -fn snap_floor(v: vec2) -> vec2 { - return floor(v + 0.5); -} -``` - -Approved by Bryan on 2026-09-17, together with rounding on the CPU; see -item 5 under "Next" for why both, and what each does not fix. The check is -the reference render set plus the oracle; expect `tabs` to move its arcs -back. - -### Smaller items - -- 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; the shortcut is performance only now. -- 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, and the doc's old example of two and two - did not distinguish it from per-span division. 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. - -### Before transparent frames, and what survives from it - -The frame/extent prototype that `34cafb6` is the head of separated a -widget's fraction reference from where its drawing sits, and the -transparent-frames protocol above is that idea finished. Four of its -findings still hold and are why the code is shaped this way: - -- **An answer and a drawing each retain their dependencies.** A child's size - answer constrains its parent only when the parent reads that answer; the - child's drawing constrains the parent's retained drawing whether its size - was read or not. `Painter` collects the two separately (`answer_under` and - `under`), which is what stopped a stack sized by one child from - remeasuring because an unmeasured overlay wrapped at another width. -- **A wider contract does not invalidate an existing guarantee.** When a - local redraw comes back with the same size under a contract covering the - old one, the old one is kept; comparing whole contracts by equality - doubled `scroll` cycles. -- **No measurement is different from a measured zero**: `ActiveData::answer` - is optional, and a previously undrawn pure share must not answer with the - placeholder zero it never gave. -- **The settling walk takes the deepest mark from a `BTreeSet` keyed by - depth**, and what ends it is the mark set rather than the queue. - -Two failed hypotheses from it, kept because they are cheap to repeat: - -- **The placement pin was blamed for the `many` gap and is not the cause.** - Disabling it (unsound, a bound) still redrew 487 distinct widgets a frame - at seed 13 against `e44dea3`'s 159. What the per-widget trace showed - instead was local redraws deferring to their parents and the deferrals - chaining to the root -- 43 deferrals in one frame, in chains such as - 424 → 425 → 437 → 449 → 483 → 487 → 491, because a span handed its - children its own placement as their frame and that placement changed - between the measuring ask and the placed one. Transparent frames is the - answer to that and it works: the frame no longer changes under a widget. -- **`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. The same shape is what "What is not done, and why" is about; the - branch is superseded and can be deleted. - -## How layout is decided - -### Fixed point - -Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in -floats. - -- **`Fixed` is an `i32` counting `1 / 2^SHIFT`.** Adding and - subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16: - truncation is preferable); `div`, `div_int` and `ratio` round to nearest; - `to_scale` takes the nearest step. Two routes to one place that land on - one number are the same place, so everything downstream compares for - equality. -- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536` - of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the - first two; the shader's copy is prepended from them by - `render::module_source`. `Px` was `1/64` first, where one rounding's - residue was 0.016 px and enough to move a box. Range is +/-2.1M px and - conversion to `f32` is exact to 16,384 px. -- A weight is not a fraction: a list divides its room by the total of its - weights, and `Rel::ratio` turns two weights into a share on the finer - grid. -- **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will - not draw reasonably anyway, so wrap and break clearly). Saturating cost a - twelfth of layout's instructions. `MIN` and `MAX` stand in for an - unbounded end and are only ever compared against; `from_f32` is the one - operation that clamps, and `Holds` keeps a saturating `narrow`. -- A pointer, a wheel notch, a shaped glyph advance and a window size arrive - as floats and go on the grid where they arrive. `Vec2` is what the GPU - and the platform speak; `PxVec2` is what layout decides in. -- **Do not widen the grid to chase a residue.** Every failure this branch - saw was one value reached by two expressions, sitting on a boundary - defined by the same value coming back the other way. No precision shrinks - a residue that is the whole distance. - -### A box in pixels is one multiply from its parent's - -`ActiveData` keeps a widget's box as lengths of its parent's box -- -`given_len`, and `offer_len` for the box it was first asked about -- -`DrawInfo` carries the pixel lengths themselves (`px`, `offered_px`), and a -draw threads them down one `Len::to_px` at a time: the box its parent gave -it, then the part of that box its own answer placed its drawing in, which -`placed_lens` states once for both `placed_box` and the walk. -`Painter::px_size` and `px_len` read that value, and -`UiRenderState::asked_px` takes the same steps back up the parent chain -when a local redraw starts part-way down the tree. Neither chain has a -coordinate frame in it, so a region node cannot break either, and warm and -cold reach every length by the same expression. - -- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**: - `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and - `floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two - `div_toward`s once the sign of `rel` has said which bound is which. The - answer is an interval even for a single length, because a floor is not - invertible. The range has to contain the box a drawing was made in (the - `Holds` assertion in `draw_at`, debug only) and must not contain a box - the drawing does not hold for (the oracle); being the preimage makes - those one statement rather than a trade-off. -- **Symbolic regions are for the GPU, hit testing and remaps alone.** - `Moves::resolve` is the only walk left and it is the vertex shader's. - Nothing layout decides is composed back up the move chain. -- **`px` is not stored on `ActiveData`, deliberately.** A resize every - widget's `Holds` admits redraws nothing, so a stored pixel length would - be stale on every widget in the tree with nothing to say so. `asked_px` - walks up only where a widget is already being redrawn; the mean chain is - 2.8 levels. -- **The window is not a move entry** (`5b78002`). A chain bottoms out in - `MoveIdx::NONE`; the window is applied where a fraction becomes pixels, - `to_px(output_size)` on the CPU and the uniform in the shader. A resize - rewrites no retained entry and re-uploads nothing but the uniform; its - cost is whatever `Holds` redraws. -- Failed hypothesis, kept as the shape of the mistake: an offer composed - back up the 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 and confirmed its own answer. Pinned by - `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`. - The old chain with an allowance in `through` passed that case and the old - chain with the exact `through` failed it; both halves had to land at once. - -### What the fuzzers tolerate - -The frame/extent continuation removes `AGREE_STEPS`: warm and cold pixel -regions must compare exactly. This is distinct from the equal-share test: -when a row's grid-step count is not divisible by the number of children, -individual share widths can differ while every rerun of that layout must -still agree exactly. The PR #18 baseline still allows two position steps; -its earlier failure at one step (`resize-size`, seeds 384 and 162 at depth 5) -is a useful regression target for the experimental recomposition. - -## Retained-layout invariants - -- `Holds` is the interval of box lengths for which a widget's drawing and - reported size stay valid. Reading `Painter::px_len` or `px_size` narrows - it to the length read; `Painter::holds` widens it. Parent validity is the - intersection of what its children induce: measured children for an answer, - all painted children for a drawing. The contract is trusted: a - widget declaring a wrong range is a defective widget, and Iris adds no - defensive work to recover from one. -- A retained drawing can be reused only when its `Holds` contains the new - pixel box on both axes, its parent node is unchanged, its region-node - choice matches the retained structure, it is on the layer it is asked - for, and the widget is clean. A valid ordinary subtree moves without - redrawing by recursive remap; a region node moves by one entry. -- **A retained drawing belongs to the layer it was made on.** A container - that measures a child by drawing it measures on the layer that child will - draw on -- `Painter::child_layer_at` -- or it pays two draws a frame. -- The first box a parent asks about is the offer; a later box chosen from - the answer is the final box, not another answer. A dirty widget is - re-asked in the box its parent gave it, and only where that box is as - long as the offer; anything else is its parent's question, with the mark - left on. Lengths and not whole boxes: what a drawing depends on is its - lengths, so the same lengths elsewhere is the same question. -- An answer is reusable where its measurement contract holds. Its final - drawing is checked independently and may need redrawing even while the answer - stands. Drawing validity is translated back through the chosen placement for - the parent's drawing contract, not intersected into the retained answer. -- **A widget's frame passes through every container that only divides room, - and its length is the same on every ask.** What narrows it is decided - above the widget: a declared length, and an inset once there is one. A box - that is an answer -- a row's height, a stack sized by a child -- is never - anything's frame. `Painter::widget_at(child, frame, [Place; 2])` says both - things about an ask: what the child's fractions are of, and what of this - widget's own box the drawing takes. -- **A fraction is resolved once, against the frame.** A report comes back - raw and is composed into the parent's frame by `within_len` only where the - parent narrowed the frame; `placed_extent` takes the reported length - *from* the part rather than composing it into the part. -- **Nothing may depend on where a box starts.** A container reads - `extent_len(axis)` for the length it divides, which pins that length - symbolically, and places children as parts of its own box, so moving the - box re-places them without drawing anything again. -- A widget that clips to its box reports its box: `Scroll` and `Masked` - report `LEFTOVER` on both axes, and a `debug_assert` holds any widget - that set a mask this draw to it. Overflowing is otherwise ordinary, which - is why the assertion is narrowed to mask-setters. Where content shorter - than a `Scroll`'s viewport sits is the scroll's own alignment, and its - "fits at the start of any box" widening is gated on near alignment. -- **A widget's own mask is not the one it inherited.** `ActiveData` keeps - both; they differ exactly where the widget called `set_mask`, which says - whose mask a move rewrites, and a local redraw is handed the inherited - one. Pinned by - `retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`. -- A span is as long across itself as its longest fixed child, unless a rule - gives that length outright (`Painter::has_exact_size`), in which case it - does not read its children there at all. Any relative or `leftover` child - makes it report `leftover`. (The plan's step 7 changes this to the longest - `px`-or-`rel` child compared in frame pixels; not done.) **Do not choose between a fixed and a relative - child in pixels at the span's current width**: that admits multiple - self-sizing fixed points, and generated seed 13 settled differently warm - and cold under it. The same circularity is what a cap containing - `leftover` would put into `SizeRule::Max`. -- `Span`'s leftover/no-leftover split is a strict layout decision, not a - rounding tolerance: its `Holds` range must use the same exact boundary as - drawing. A tolerant endpoint retained zero-height children in seed 16; a - boundary moved off where boxes land was needed in floats and is not on - the grid. **A structural decision may not be taken on a hair's breadth** - that two routes can disagree about. Pinned by - `unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`. -- A pixel comparison is equality. A length given in pixels is that many - pixels wherever it ends up, structurally: `Len::within` adds a part's own - pixels rather than scaling them. Pinned by - `a_length_in_pixels_is_that_many_pixels_however_it_is_nested`. A length - given as a share is not: equal shares come out one or two steps apart - because positions, not lengths, are what gets rounded, so the row fills - and no two children leave a seam - (`equal_shares_differ_by_at_most_two_steps_and_fill_the_row`). -- **A move that keeps a box's length is a translation, and exact.** A box - that changed length re-expresses each part as a fraction of the new one, - which rounds. This inverts the float-era rule; `tests/cases/drift.rs` - pins that the grid does not drift either way. -- `Scroll` must return the answer from the first box it asked about, - whether retained or fresh; returning the final placed answer advanced one - fixed-point iteration (seed 86). Content that fills the viewport unscrolled - is handed back as it came, because the same box written as its own - length in pixels does not round alike. -- An asked-but-undrawn size dependency names the widget that asked as its - parent (seed 10). `Painter` records size-dependency edges only when a - parent reads a child's size or hint; an undrawn measured child stays - recorded so a later change reaches whoever decided not to draw it. -- Dirty widgets settle deepest-first, including during resize. A deferred - child leaves its parent marked, so no clean answer can hide an unsettled - size dependency. `dirty_size_under` has been deleted. -- Declared non-`leftover` lengths are resolved by the parent where the - widget is drawn, so a declared-length change redraws the parent. A rule - wins on the axis it names and the widget under it never learns of it. - **A cap may not contain `leftover`**: a cap must read the report, so rule - and report are one equation, and a share puts the row's division into it - -- the multiple-fixed-point failure again. A cap is pixels and a fraction, - which is what `Len` is. -- Text shaping is retained separately from line breaking; a greedy break - holds from its longest produced line through the width it was made at, - reported through `Painter::holds`. -- Region nodes: a node holds a whole `UiRegion` in its parent node's - coordinates, `FULL` is the identity, widgets opt in with `.region_node()` - or `Widgets::set_region_node`, and changing it redraws the subtree once. - `.scrollable()` sets it once; raw `Scroll::new` does not. A removed node's - move entry stays alive until every descendant has migrated. `Span` and - `Align` add no nodes. -- Alignment is one `f32` per axis (Bryan, 2026-09-15), default the middle - on both because the edges assume a direction. One widget keeps one length - per axis; a second length needs a second widget, `Wrapper` via - `.wrapper()` (Bryan, 2026-09-16, `d21a215`). - -## Verification at the current head - -At `49cec82` on `wip/transparent-frames`: `cargo fmt --all --check`, clippy -with `-D warnings`, 108 suite tests and 20 core tests in debug, the 11 -generated cases, and the six rig phases in the table above. The shrinker at -400 trees of depth 5 **fails**, at seeds 2 (`repaint`) and 108 (`reorder`); -the long oracle at 1000/6 and the 2000/4 scan have not been run since they -would only find more of the same. The five reference renders were taken and -compared with `34cafb6`: `view` and `minimal` byte-identical, `tabs` 2,332 -pixels, `text` and `random` as described above. Venus on the RX 7900 XT, -confirmed by `vulkaninfo --summary` in the same session. - -At `e44dea3`, which is what #18 would merge: - -- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- - -D warnings`, `cargo test --workspace`: green, 92 suite tests, 19 core - unit tests, 11 generated cases. Only the long runs and the profiling rigs - are ignored; no known defect is. -- The release oracle at 100 seeds in 14.2 s, and **120 seeds in debug** in - 59 s -- the debug run exercises the `Holds` assertion in `draw_at`. -- All fifteen shrinker cases at 400 seeds of depth 5 in 57 s, the oracle at - 1000 seeds of depth 6 in 143 s, and **2000 seeds at depth 4 over all - fifteen cases** in 260 s. The last is not routine and should be: it is - the only run that has ever found anything past seed 400. -- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at - 1920x1200 against `25e456e`, as is `tabs` under the recorded replay. - `random` live-resized from 1920x1200 to 1280x800 is byte-identical to a - cold 1280x800 render. Rendered through Venus on the host's RX 7900 XT, - confirmed against `vulkaninfo --summary` in the same session -- an - llvmpipe fallback makes the same PNG and nothing in it says so. -- All rig counters identical on `cold`, `repaint`, `many`, `size` and - `scroll` across `25e456e`. `resize` gains one queue pop, one local - redraw and one depth read and loses one failed reuse attempt, which is - the root going through the walk; drawn widgets, widget draws, draw - requests and primitive writes do not move. - -Everything above is verification of what was changed, not a claim that the -branch is correct. - -**A claim about a render holds for the commit it was checked at and no -further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take -the oracle as the reference and the five renders as a spot check. - -**Run the long two before believing a rounding change**, and run the -ordinary suite in debug: - -```sh -cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees -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 -``` - -Depth is what finds things, but so is breadth: every late defect before -2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by -running 2000 seeds at depth 4, which nothing routine does. Widen one axis -at a time and record which. - -## Performance - -**Threading a box in pixels down the draw is free on cold layout and 9-13% -off the retained paths** (2026-09-17). Instructions:u, medians of 21 runs of -binaries built in one worktree, seed 1 at depth 8, against `5b78002`: - -| phase | before | after | | -| --- | --- | --- | --- | -| `cold`, 200 frames | 313.1M | 312.9M | -0.04% | -| `resize` | 408.1M | 405.6M | -0.61% | -| `many` | 1,924M | 1,756M | -8.75% | -| `scroll` | 357.3M | 323.4M | -9.49% | -| `repaint` | 363.3M | 315.4M | -13.18% | - -`cold` and `resize` compare directly: all twenty-five work counters are -identical. The other three do less work: `repaint` goes from 23 draw -requests and 13 widget draws to 1 and 1, because `redraw` composes nothing -and a widget whose box moved without changing length settles itself instead -of escalating. - -### How to measure here - -- **Check the work counters before comparing two commits' times.** The rig - prints drawn widgets, widget draws and primitive writes; a comparison is - only worth reading when they match. `random.rs`'s `Branch` picks a - subtree by a measured pixel length, so the fixture's shape moves with the - thing measured; `Edits::fixed_branches` pins it for timing and the oracle - keeps measured branches on purpose. A 3x this section once reported was - that artifact. -- **`perf stat` in this VM returns garbage readings** for both - `instructions:u` and `cycles:u`, roughly a quarter of the time, off by a - factor of five to fifteen. Take medians of nine or more and report how - many readings a filter kept. Cycles spread 1-3% between sets of one - unchanged binary and 6.7% in the worst; instruction counts hold to 0.02% - within a binary and move 0.5% across a rebuild, so build the baseline - beside the thing measured and quote a delta. `ex_div_busy` held to 0.1%. -- **What moves cycles is whether `UiSpan::within` inlines.** It is the - hottest line in layout; `nm` shows it as a symbol when it does not. - Shrinking its body until the inliner takes it won; `#[inline]` on the - body it had lost 1.5% cycles. Shrink it, do not annotate it. -- `Holds::through` divides twice per call and accounts for essentially all - of a run's `i64` divisions: 21.3M cycles of a 500-frame `many`, 2.8%. - The float head divided twice there too. - -### Tried and rejected, with numbers - -- A float reciprocal for `AxisRemap::apply_scalar`'s division: +6% cycles. - `Holds::through`'s division has not been tried. -- Branchless `shift_round`: +6.7% cycles alone, and worse again with the - short-circuits removed. Size, not the branch, is what keeps `within` out - of line. -- Removing the per-child hash lookup in `remap_subtree`: 0.0%. -- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%. -- Short-circuits guarding a saturating multiply stopped paying once the - multiply wrapped. Re-price a short-circuit before keeping it. -- Rust does not contract `a + b * c`; the float head never had an FMA to - compare the grid's multiply against. - -Wrapping (`4febabf`) was -8.6% instructions and -6.6% cycles. Truncating -(`08c9d5a`, with `Fixed::scaled`'s zero test and `within`'s `is_full` tests -removed as one commit, since they are worth 61M instructions apart and 115M -together) costs a share a thousandth of a pixel of its row, makes a flipped -span sit a step from its mirror, and moved an antialiased edge in `tabs` by -one pixel. See "Where the residual comes from" for what that last one is. - -## Rigs and reproduction - -Ordinary framework verification: - -```sh -cd /home/bob/repos/iris-pr18 -cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -``` - -The ordinary tests are modules of one `tests/suite.rs` target; pick a module -with `cargo test --test suite layout::`. `profile.test` uses -`debug = "line-tables-only"`, which halved the test-target rebuild. - -`tests/generated.rs` compares a warm incremental tree with a cold tree of -the same state; `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS` and -`IRIS_GENERATED_DEPTH` select what it covers. `tests/shrink.rs` reduces a -failing tree over the same fifteen cases and the same trees -- -`iris::random::plan(seed, depth, &edits)` and `build(rsc, &plan)`, so a -failing seed reduces directly and the oracle prints the command: - -```sh -SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \ - cargo test --release --test shrink -- --ignored --nocapture -``` - -The cases live in `tests/scenario/mod.rs`, included by both targets by -`#[path]`; a case only one rig knows is how the two drifted apart once. Turn -what the shrinker finds into a test of its own rather than leaving a seed as -the record. Both fuzzers take a thread per core but one. A `git bisect` -once named a commit that could not be the cause; read the tree rather than -the bisect when that happens. - -`tests/layout_diagnostics.rs` is the retained CPU rig: `IRIS_PHASE` selects -`cold`, `many`, `repaint`, `size`, `scroll` or `resize`, the -`layout-diagnostics` feature gives the explanatory counters, and an -uninstrumented release binary under `perf` gives totals. Dump the counters -with - -```sh -IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \ - --ignored --nocapture \ - | grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort -``` - -and `diff` two runs; identical output is what says a change is free. - -The float head is checked out at `/home/bob/repos/iris-float-cmp`, at -`5ed9e87` with `Edits::fixed_branches` applied uncommitted. Its counters do -not match the grid's and will not, so a comparison against it is a bound -rather than a measurement. - -The headless reference set runs one process at a time because the rig -reuses one compositor; comparison worktrees need separate target -directories. +Run all six `layout_diagnostics` phases at seeds 1 and 13, depth 8, against +`e44dea3`. Compare work counters first; use medians only after the work is the +same. Check that `UiSpan::within` still inlines with `nm`. + +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 @@ -1570,7 +320,7 @@ directories. --replay /tmp/tabs.touch --shot /tmp/replay.png ``` -The replay used for the reference check: +The reference replay is: ```text 0 down 1728 24 @@ -1581,72 +331,42 @@ The replay used for the reference check: 880 up 1836 1116 ``` -## Next +Before submitting, run the pre-submit review. Once transparent frames lands, +move any surviving fact from `docs/LAYOUT_LOG.md` into `docs/LAYOUT.md`, delete +the log, update this handoff to the next actual task, update the app's Iris pin +only when the Iris change is ready, and push every coherent commit. -In order, from the review above and Bryan's steer (2026-09-17): +## Verification already performed -1. **Two questions for Bryan, both from "What is not done, and why"**, now - sharpened by **What the two open seeds are**, which says why answering - them as posed does not settle the seeds and what would. What - a widget's answer is the answer *to*, when a container is drawn in two of - its own boxes in one frame -- which is what the two open fuzzer seeds - turn on and what step 6 needs before the deferral can go. And what `Pad` - should be now that a frame passes through: an outset (what the code does, - and `examples/text.rs` shows what it looks like), an inset that takes the - child's box with it, or the plan's "outset pixels, inset `rel` and - `leftover`", which the protocol cannot express as it stands. -2. **The rest of transparent frames**: step 7 (`Span`'s known-length - shortcut and the cross-axis report in frame pixels), then step 8 - (`Inset`/`Outset` as test widgets, once the question above is answered). - `wip/local-reask` is superseded and can be deleted. -3. Write `ActiveData::answer` in one place -- `try_reuse` hands back what - the last drawing reported, which is a measurement only where that drawing - was one. -4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the - reconstruction in `redraw`. -5. **Round on the CPU and snap to the nearest pixel in the shader**, as - one change with one verification. Bryan approved the snap on 2026-09-17 - (rendering may change wherever it brings the screen closer to what the - user's code says: three equal sections of 1000 px need one of them - rounded up), and to CPU rounding the same day. The reason for that one - is different: a - `Rel` is off by at most `2^-25` of its box, so with round-to-nearest - every product whose true value is a whole number of steps is exact for - boxes under about 8,000 px, where truncation leaves half of them one - step short and layout then decides "does not fit" on a container the - user meant to fit exactly. Use the branchless round-half-up form, - `(a * b + (1 << (BY - 1))) >> BY`, not the sign-branching - `shift_round`; re-derive `Holds::through` for `round` (its two shifted - bounds move by half a `Rel` step); check with `nm` that `UiSpan::within` - still inlines; expect a couple of percent of instructions and re-run the - long fuzzers and the render set once for both. -6. The smaller items: the stale `f32` comment, the gap of an undrawn child, - confirm nested `leftover` weights, one zero-divisor fallback. Add to - them: a span that overflows itself hands a child a box of negative - length, which is ordinary now rather than a corner, and nothing states - what a widget may assume about one. -7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable - subtree case; do not restore a separate child-placement API. -8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` - builders `8220a78` deleted. The clamp boundary is a hard layout decision - with an exact `Holds` split at the crossover, both sides in `Px`. Still - awaiting Bryan: whether a `Max` narrows the box the child draws in, or - only what the parent reports for it. -9. `Scroll` taking a direction rather than one axis. +At `49cec82`: formatting, warning-clean clippy, the debug suite (108 suite +tests and 20 core tests), the 11 generated cases, and all six diagnostic +phases ran. The 400-seed depth-5 shrinker fails at seed 2 (`repaint`) and 108 +(`reorder`); the long 1000/6 oracle and 2000/4 scan were intentionally not +run after that. Reference renders against `34cafb6`: `view` and `minimal` +were byte-identical; `tabs` differed by 2,332 pixels; `text` exposed the +unresolved-share bug; `random` moved where nested spans do. -`docs/LAYOUT.md` §4, §5 and the density section are stale: they name -`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`, -`Aligned` and `MaxSize`, none of which exist. Do not restore -`OnResize::Translate` or `OrthoSize`. +At `e44dea3`, the #18 baseline: format, clippy and workspace tests passed; +the release oracle at 100 seeds, debug oracle at 120 seeds, all fifteen +shrinker cases at 400/5, the oracle at 1000/6, and the 2000/4 scan passed. +The five reference renders and the `tabs` replay were byte-identical to their +baseline, and a live-resized `random` matched a cold render. These statements +describe those commits only; rerun them after changing layout. -Other queued work, in dependency order: `UiRenderState` behind -`Rc>`; density-independent pixels; input restructuring (pointer -capture, drag slop and axis, cancellation, mask-aware hit testing, -timestamps); retained paints, selection, overlays and shared runtime state; -generic desktop/Android hosts and reusable example/APK tooling; -application-owned fonts and replaceable glyph-atlas buckets; positioned -text overflow and cluster-safe ellipsis. +## Follow-on work, not part of this repair -The archive is a reference, not a patch: it predates returned `Size`, the -current box chain and the current length types. Recreate changes on current -types and keep app/session concepts out of Iris. +- 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. +- Test-only `Inset` and `Outset` can demonstrate their semantics after the + protocol is stable. `Pad` itself remains an outset. +- 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. +- `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. +- `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. diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 8ca09fa..afa77d1 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -4,11 +4,12 @@ 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) and the notes below have been -brought to what shipped rather than what was proposed; §4 to §6 describe the -same design as it stands, and name types that have since been replaced where -they were written before it. `docs/HANDOFF.md` has the invariants -the code now rests on and what is still to do. +§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. ## Design @@ -143,14 +144,14 @@ Drawing that ancestor consumes the marks of every dirty descendant it reaches; the loop then takes whatever remains. Drawing never synchronously invalidates or invokes a parent, so there is no layout recursion. -Dirty widgets settle deepest-first, and `dirty_size_under` stops a reader -taking a retained answer while something below that answer is still dirty -- -an optimisation against laying out twice rather than a second validity -mechanism. An exact `size_hint` stops propagation when both axes still equal -the retained size; otherwise propagation is deliberately conservative, since -only a dependent ancestor can assign the final boxes. This is a generic -constraint rule, not a text exception. Wrapped text is merely the common -example: it reads width, so changing only height leaves its answer valid. +Dirty widgets settle deepest-first. `dirty_size_under` has been deleted; +settling consumes descendant marks bottom-up, so no clean retained answer can +hide an unsettled size dependency. An exact `size_hint` stops propagation when +both axes still equal the retained size; otherwise propagation is deliberately +conservative, since only a dependent ancestor can assign the final boxes. +This is a generic constraint rule, not a text exception. Wrapped text is +merely the common example: it reads width, so changing only height leaves its +answer valid. ### 4. Wrapped text, and "needs child height before choosing width" @@ -312,14 +313,208 @@ fragment stage cannot make. Rendering and hit-testing both traverse the full mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs` checks the WGSL implementation against the CPU SDF. -## Offered boxes +## Frames, decided boxes and padding -`Pad` must work in every container: it offers an inset region to its child and -reports the child's used size plus padding. In a generous parent it behaves as -an inset; in a tight parent it grows the result outward. +Containers that only divide room are transparent to fractions. A child frame +is narrowed by a length its parent decided: a declared `px` or `rel` length, +or the resolved slot of a `leftover` child. A box a widget reports for itself +does not narrow its descendants' frame. -When a widget does not fit its offered box, it is redrawn at the box implied by -its reported size in the same frame. Deferring would leave ordinary -`.background(rect(..))` surfaces one frame behind their content. The settling -draw occurs only when the widget's own size changes. Widgets whose size varies -with every offered box are therefore unsuitable as `LazySpan` rows. +`Pad` is an outset: it forwards its frame less the padding, draws the child +inside that area, and reports the child's used size plus padding. A +`rel(1.0)` child inside padding inside a share is a fraction of the resolved +share less that padding. The mixed "outset pixels, inset fractions and +shares" interpretation is rejected. + +The current experiment still redraws some widgets in boxes derived from their +own answers. That is the open protocol defect, not a design invariant. The +target in `docs/HANDOFF.md` evaluates container bodies only in boxes a parent +offered or decided; placing an answer reuses or translates its drawing rather +than running the body in an answer-derived box. + +## Layout decisions and invariants (2026-09-15 to 2026-09-17) + +Moved here from the handoff on 2026-09-18. These are settled unless a +subsection explicitly says it is pending. + +### Fixed point + +Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in +floats. + +- **`Fixed` is an `i32` counting `1 / 2^SHIFT`.** Adding and + subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16: + truncation is preferable); `div`, `div_int` and `ratio` round to nearest; + `to_scale` takes the nearest step. Two routes to one place that land on + one number are the same place, so everything downstream compares for + equality. +- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536` + of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the + first two; the shader's copy is prepended from them by + `render::module_source`. `Px` was `1/64` first, where one rounding's + residue was 0.016 px and enough to move a box. Range is +/-2.1M px and + conversion to `f32` is exact to 16,384 px. +- A weight is not a fraction: a list divides its room by the total of its + weights, and `Rel::ratio` turns two weights into a share on the finer + grid. +- **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will + not draw reasonably anyway, so wrap and break clearly). Saturating cost a + twelfth of layout's instructions. `MIN` and `MAX` stand in for an + unbounded end and are only ever compared against; `from_f32` is the one + operation that clamps, and `Holds` keeps a saturating `narrow`. +- A pointer, a wheel notch, a shaped glyph advance and a window size arrive + as floats and go on the grid where they arrive. `Vec2` is what the GPU + and the platform speak; `PxVec2` is what layout decides in. +- **Do not widen the grid to chase a residue.** Every failure seen was one + value reached by two expressions, sitting on a boundary defined by the + same value coming back the other way. No precision shrinks a residue that + is the whole distance. +- **A value that comes back as a box is rounded away from the measurement, + not to the nearest step.** `Fixed::ceil_from_f32` exists for that and is + the only rounding on the grid that is not to nearest. Rounding to nearest + is right for a value being carried and wrong for a bound; a text reporting + `ceil` of its longest line is what keeps the box it is handed back one its + line fits in (`4bd8607`). +- **A structural decision may not be taken on a hair's breadth.** A + boundary that decides which children exist (a span's leftover split) is + derived through the inverse of the expression that draws, never by a + second expression for the same length: `mul` floors while `div` rounds, + so a boundary derived with a division guards a drawing made with a + multiply (`53b00c6`). + +### A box in pixels is one multiply from its parent's + +A draw threads pixel lengths down: the box a parent gave a widget, then the +part of that box its own answer placed its drawing in. `Painter::px_size` +and `px_len` read that value, and a local redraw takes the same steps back +up the parent chain (`asked_px`). Neither chain has a coordinate frame in it, +so a region node cannot break either, and warm and cold reach every length +by the same expression. + +- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**: + `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and + `floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two + `div_toward`s once the sign of `rel` has said which bound is which. The + answer is an interval even for a single length, because a floor is not + invertible. The range has to contain the box a drawing was made in (the + `Holds` assertion in `draw_at`, debug only) and must not contain a box + the drawing does not hold for (the oracle); being the preimage makes + those one statement rather than a trade-off. +- **Symbolic regions are for the GPU, hit testing and remaps alone.** + `Moves::resolve` is the only walk left and it is the vertex shader's. + Nothing layout decides is composed back up the move chain. +- **`px` is not stored on `ActiveData`, deliberately.** A resize every + widget's `Holds` admits redraws nothing, so a stored pixel length would + be stale on every widget in the tree with nothing to say so. `asked_px` + walks up only where a widget is already being redrawn; the mean chain is + 2.8 levels. +- **The window is not a move entry** (`5b78002`). A chain bottoms out in + `MoveIdx::NONE`; the window is applied where a fraction becomes pixels, + `to_px(output_size)` on the CPU and the uniform in the shader. A resize + rewrites no retained entry and re-uploads nothing but the uniform; its + cost is whatever `Holds` redraws. +- **A move that keeps a box's length is a translation, and exact.** A box + that changed length re-expresses each part as a fraction of the new one, + which rounds. `tests/cases/drift.rs` pins that the grid does not drift + either way. A length given in pixels is that many pixels wherever it ends + up (`Len::within` adds a part's own pixels rather than scaling them); + equal shares come out one or two steps apart because positions, not + lengths, are what gets rounded, so the row fills and no two children + leave a seam. + +### Retained-layout invariants + +- `Holds` is the interval of box lengths for which a widget's drawing and + reported size remain valid. Reading `Painter::px_len` or `px_size` narrows + it; `Painter::holds` widens it. The contract is trusted rather than checked + defensively on every use. +- A retained drawing is reusable only when its `Holds` contains the new box + on both axes, its parent and region-node choice match, it is on the layer it + was drawn on, and the widget is clean. A valid ordinary subtree moves by + recursive remap; a region node moves by one entry. A container that draws a + child to learn its size uses `Painter::child_layer_at`, the layer the child + will actually occupy. +- An answer's validity and its final drawing's validity are independent. A + parent may reuse an answer while redrawing the placed output. Translate the + drawing contract back through its placement; do not intersect it into the + answer contract. +- A fraction resolves once against its frame. A report returns raw and is + composed only where a parent narrowed that frame. A part's own pixel length + is added rather than scaled, so a pixel length remains that many pixels at + every nesting depth. +- An asked-but-undrawn size dependency belongs to the widget that asked. Keep + it recorded so a later child change reaches the parent that decided not to + draw it. Dirty size dependencies settle deepest-first. +- A widget that creates a mask clips to and reports its box. Its own mask and + its inherited mask are distinct retained state: the former says which mask + a move rewrites, while a local redraw receives the latter. +- A span's leftover/no-leftover boundary is a strict structural decision, not + a tolerance. Derive the boundary through the inverse of the expression that + places children. A cap may not contain `leftover`, because feeding the + span's own room division back into a cap admits multiple fixed points. +- Text shaping is retained separately from line breaking. A greedy break + holds from its longest produced line through the width at which it was + made, expressed with `Painter::holds`. +- A region node stores one whole `UiRegion` in its parent node's coordinates; + `FULL` is the identity. Changing node ownership redraws the subtree once, + and a removed node's move entry remains alive until every descendant has + migrated. +- 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()`. + +### What the fuzzers tolerate + +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. + +### Rendering the grid (pending) + +`snap_floor` in `prelude.wgsl` adds half a layout step before flooring, +which absorbs float error and not a layout step, so a third of 900 px +(299.999 on the grid) lands at 299 on screen. Bryan approved on 2026-09-17 +rounding to the nearest pixel in the shader together with round-to-nearest +in `Fixed::mul` on the CPU, as one change with one verification; neither has +landed. The reason for the CPU half: a `Rel` is off by at most `2^-25` of +its box, so with round-to-nearest every product whose true value is a whole +number of steps is exact for boxes under about 8,000 px, where truncation +leaves half of them one step short and layout then decides "does not fit" +on a container the user meant to fit exactly. Use the branchless +round-half-up form, `(a * b + (1 << (BY - 1))) >> BY`; re-derive +`Holds::through` for it; check with `nm` that `UiSpan::within` still +inlines. + +## Measuring layout cost on this machine + +- **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. + `random.rs`'s `Branch` picks a subtree by a measured pixel length, so the + fixture's shape moves with the thing measured; `Edits::fixed_branches` + pins it for timing and the oracle keeps measured branches on purpose. A + 3x once reported was that artifact. +- **`perf stat` in this VM returns garbage readings** for both + `instructions:u` and `cycles:u`, roughly a quarter of the time, off by a + factor of five to fifteen. Take medians of nine or more and report how + many readings a filter kept. Instruction counts hold to 0.02% within a + binary and move 0.5% across a rebuild, so build the baseline beside the + thing measured and quote a delta. +- **What moves cycles is whether `UiSpan::within` inlines.** It is the + hottest line in layout; `nm` shows it as a symbol when it does not. + Shrinking its body until the inliner takes it won; `#[inline]` on the + body it had lost 1.5% cycles. Shrink it, do not annotate it. +- `Holds::through` divides twice per call and accounts for essentially all + of a run's `i64` divisions: 2.8% of a 500-frame `many`. +- Tried and rejected, with numbers: a float reciprocal for the remap + division, +6% cycles; branchless `shift_round`, +6.7%; removing the + per-child hash lookup in `remap_subtree`, 0.0%; short-circuiting + `apply_scalar` where the fraction is nought or one, +17%. Short-circuits + guarding a saturating multiply stopped paying once the multiply wrapped; + re-price a short-circuit before keeping it. Rust does not contract + `a + b * c`. Wrapping (`4febabf`) was -8.6% instructions; truncating + (`08c9d5a`) costs a share a thousandth of a pixel of its row. +- Threading the pixel box down the draw (2026-09-17) was free on cold + layout and 9-13% of instructions off the retained paths, measured against + `5b78002` at seed 1, depth 8, medians of 21. diff --git a/docs/LAYOUT_LOG.md b/docs/LAYOUT_LOG.md new file mode 100644 index 0000000..79d4e74 --- /dev/null +++ b/docs/LAYOUT_LOG.md @@ -0,0 +1,249 @@ +# 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. + +## The two open shrinker seeds on `wip/transparent-frames` (planner, 2026-09-18) + +Both are the rule in `draw_inner` that decides 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. + +## 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 | +| --- | --- | --- | --- | +| cold | 369/261/10.6 | 463/274/13.3 | 516/288/12.0 | +| repaint | 1 | 1 | 1 | +| many | 157/95/0.33 | 263/108/0.59 | 187/119/0.52 | +| size | 16/12/0.018 | 3/3 | 3/3/0.010 | +| scroll | 2/0.002 | 1 | 1/0.004 | +| resize | 13/13/0.019 | 22/15/0.032 | 24/76/0.090 | + +| seed 13 | e44dea3 | 49cec82 | +| --- | --- | --- | +| cold | 1330/707/20.3 | 2940/982/28.3 | +| many | 524/159/1.09 | 1091/423/2.39 | +| resize | nothing drawn | 2215/510/6.56 | + +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; stop trying to define it. +- **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. + +## 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.