From 398e4efaeb8d4dc489667b1ece639dfa607cef0d Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Fri, 18 Sep 2026 01:13:40 -0400 Subject: [PATCH] Record what transparent frames landed as, and what it found The plan in this document is now a protocol in the code, on `wip/transparent-frames` in the layout experiment: what it does, what the rig measures it at, and the two things it ran into -- what a widget's answer is the answer to when a container is drawn in two of its own boxes in one frame, and what `Pad` should be now that a frame passes through. Both are Bryan's to decide and both are at the top of Next. The frame/extent prototype's chronicle goes with it: its four findings that still hold and its two failed hypotheses stay, the rest was a record of how the code got to a shape it no longer has. --- docs/HANDOFF.md | 1078 +++++++++++++---------------------------------- 1 file changed, 299 insertions(+), 779 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 72f1479..66c6d07 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -2,9 +2,9 @@ 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-17: the chronicle of closed defects, superseded -verification lists and cross-fixture tables went, the rules and the lessons -stayed. +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 things stand @@ -16,32 +16,25 @@ 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. -The separate frame/extent experiment now addresses the dominant size/resize -redraw cascade through dependency tracking and invalidation, not glyph emission. -Its current head is `34cafb6` on `wip/region-and-placement` in -`/home/bob/repos/iris-layout-experiment`. See **Frame and extent: retained -prototype** for the mechanism. The app's framework pin is unchanged. **It is -not ready to replace #18 yet**: against `e44dea3` it wins on `size` and -`scroll` and loses on `many` and `resize`. The cause of the `many` loss was -traced on 2026-09-17 and is not what the earlier sections say; read **Where -the `many` gap actually comes from** before anything else in this document -about performance, and take the earlier sections' explanations as history. +The separate frame/extent experiment lives in +`/home/bob/repos/iris-layout-experiment`. **The transparent-frames protocol +is implemented** on `wip/transparent-frames`, head **`0954770`**, two +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 current work is the plan in the next section**, decided with Bryan on -2026-09-18. It is what the next agent does, on top of `34cafb6`, and it -supersedes the "Frame and extent" sections' description of `Span`, `Pad`, -`Stack`, the placement pin and `measure_len` wherever they differ. +## Transparent frames: what landed (2026-09-18) -## Plan: transparent frames (2026-09-18) - -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. +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`, and steps 6 to 9 +are the open questions at the end. ### Decided by Bryan @@ -78,35 +71,54 @@ each one made the next problem harder to see. 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**: + `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. ```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. +/// 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(Option), + Within(Part), /// Exactly the part; the answer is not placed inside it again. - Fill(Option), + Fill(Part), } ``` -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. +`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: @@ -115,277 +127,183 @@ 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 + place: [Place; 2], ) -> DrawResult<'s, 'a, W>; -/// The transparent default: the frame as given, the answer aligned in the extent. +/// 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(None); 2]) + self.widget_at(id, UiRegion::FULL, [Place::Within(Part::All); 2]) } ``` -`Span`, measuring and placing, with the row being its own extent: +`Span` is the plan's, without the known-length shortcut (step 7, not done): +it reads `far = painter.extent_len(axis)`, measures each child at +`Place::Within(Part::From(along(cursor, far)))`, and places it at +`Place::Fill(Part::From(along(from, start)))`, with `Place::Within(Part::All)` +across itself. `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))`; `Pad` is transparent and insets by +`Within(Of(..))`; `Masked` sets its mask over its own box. -```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. +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 `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 +- `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], // extent pixel lengths - pub extent_len: [Option; 2], // symbolic extent length, where read + 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 - an extent starts. + a box starts. -- primitives and the mask retained in extent-local coordinates, as now. +- 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 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. +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). -**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** (`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. -**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`) 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**. -**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. +### What it cost -### Steps, each with its check +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. -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: +| seed 1 | e44dea3 | 34cafb6 | `0954770` | +| --- | --- | --- | --- | +| 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 | -```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 -``` +| seed 13 | e44dea3 | `0954770` | +| --- | --- | --- | +| 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 | -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. +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. -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. +Three measured findings, each already applied: -**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: +- **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. -```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() { - if let Some(how) = diverges(&grown, case, seed) { - println!("HIT seed {seed} case {} {how}", case.name()); - } - } -}); -``` +### What is not done, and why -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. +**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. -**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. +**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. ## Review of 2026-09-17 @@ -421,19 +339,11 @@ grid width fixes it; where it becomes visible is the shader's snap. 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; the extent reaches the - widget through the painter. See "Frame and extent" below. + 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`) -**Superseded in part.** Bryan refined the rule twice on 2026-09-17: a -fraction means whatever the *parent* says it means, and it need not even -mean the same thing for two siblings -- "Main point is truly that rel is -decided by parent". The span conclusion below is unchanged and the reason -for it stands (a span's offer differs per child and the row does not), but -`reports_of` is the wrong shape for saying it; see the parked padding -branch. - `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 @@ -441,24 +351,10 @@ 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 offer is still the remainder, because a text has to wrap at the width -actually there. What separated from it is the base a report's fractions are -of, which the ask now carries as `reports_of`: - -```rust -pub fn widget_at<'s, W: ?Sized>( - &'s mut self, - id: &'s StrongWidget, - region: UiRegion, - reports_of: UiVec2, - decided: [bool; 2], -) -> DrawResult<'s, 'a, W> -``` - -It is `region.size()` wherever the box offered is the child's whole area -- -`Pad`'s inset, a `Stack` child, `Scroll`'s content -- and `Span` passes -`UiVec2::FULL_SIZE` along its row. `widget_decided` is gone; `widget_at` -says both things about an ask rather than one of them. +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 @@ -625,53 +521,23 @@ 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, both real, neither ready +### Two branches parked, one of them now closed -**Superseded on 2026-09-17: the two branches are one defect, and it is in -the protocol rather than in either widget.** See "Frame and extent" below. -Do not chase seed 1091's three steps or the Inset's 47.5 px; both are the -placing ask resolving a fraction in the answer box. What each branch got -right is kept: the `Inset`/`Outset` pair and its tests, and the stack test -asserting that half stays half. Their painter changes go. +**`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/stack-fraction-twice`.** A stack sized by a child that reports a -*fraction* applies that fraction twice: its parent places the stack at the -reported length, and `box_of(size)` then takes the same fraction of that -box. Half a row becomes a quarter. Pixels are idempotent under a second -application, so only a share ever shrank -- and warm and cold shrink alike, -so **no oracle can see it**. Bryan: "I believe this exact thing has come up -multiple times for some reason." - -The fix is that every child gets the whole box, plus `widget_at` not -resolving a rule into a box already chosen from it. `Painter::box_of` is -deleted rather than guarded: a first attempt made it answer the whole box on -a `decided` axis, which fails because `place` can reuse the stack's -*measuring* drawing by remapping it, so `Stack::draw` never re-runs. **A -drawing has to be a function of its box alone** -- if `decided` is in it, a -reused drawing is wrong. - -What stops it landing: seed 1091 at depth 4, `shuffle-swap-for-three`, -disagrees by three steps where the oracle tolerates two (warm 1053.9971 -against cold 1054). `box_of` was also making placement *exact*, by handing a -child a box of exactly the length it asked for, and the whole box puts a -rounding back at each nesting level. Find that composition; do not widen -`AGREE_STEPS`. - -**`wip/padding-outset-and-inset`.** Padding goes outside what it pads and -never insets the child (Bryan, 2026-09-17): otherwise a child's `rel` and -`leftover` would mean the inner box while its `px` meant the outer one. -`Padding::region` moves the child's box in rather than shrinking it, a new -`Inset` widget with `.inset()` is the old behaviour, and `Pad` is to be -renamed **`Outset`** with `.outset()` to match. `in_parent_frame`'s -composition and the `reports_of` argument go with it -- a report comes up -raw and the parent says what it is a fraction of, which `Inset` does for -itself. - -What stops it landing: a child declaring `rel(0.5)` under an `Inset` comes -out 47.5 px wide of the 190 inside rather than 95, and the second halving is -unaccounted for. The `Pad` half is green on its own; three tests moved to -`.inset()` because they were using padding as scaffolding rather than -testing 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 @@ -752,413 +618,44 @@ back. - `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`; both are caller bugs under `debug_assert`, but the fallbacks differ. -### Frame and extent: retained prototype +### Before transparent frames, and what survives from it -The original proposal was implemented by Claude as `5fcace1` on -`wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction -reference stable but failed five layout cases and three draw-count cases. -The first correction is `efb416b`, exact recomposition is `2ed5503`, and the -current performance continuation is **`0e107f0`**, pushed to origin's -`wip/region-and-placement`, in the isolated checkout -`/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original -checkout is unchanged. Keep the experiment's build directory separate: Cargo -can accept a different worktree's artifacts as fresh when a target directory -is shared. The comparison checkout's source timestamps can precede its build. +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: -**The distinction stays.** `Painter::region` is the frame, the parent's chosen -reference for fractions. `placement` is the extent in that frame. A sizing -report must not replace the reference against which that report was obtained. -`px_len` reads the extent's pixel length; `region_px_len` reads the frame's. -Text wraps at the available extent, not the whole fraction reference. +- **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. -**The offer includes availability.** A span can keep the same reference frame -while offering a text less room after an earlier sibling. `measure_len` therefore -accepts the placement as well as the frame. The first measurement's -`offer_placement` survives later placing evaluations, and local redraw asks -that original question before restoring the assigned slot. Frame-length -equality alone no longer identifies the measurement question. +Two failed hypotheses from it, kept because they are cheap to repeat: -**An answer and a drawing each retain their dependencies.** `LayoutHolds` -keeps frame ranges, extent ranges, and an optional raw placement snapshot. -`px_len` narrows one extent axis without making position or the other axis a -dependency; `holds` widens that extent range without erasing a frame read. -`Holds::through` still inverts one fixed mapping exactly. A measured answer -and the final drawing can only substitute for one another when those -independent inputs satisfy the retained contract. The old fallback checked -only frame ranges, so text placed at a fixed pixel width answered a narrower -window's measurement with its previous line breaks. - -**Geometry retains its reference.** `DrawRegion::{Frame, Extent}` records local -primitive and mask coordinates before composition. Ordinary `primitive()` -follows the extent; explicit `UiRegion` arguments remain frame-relative. -Text glyphs use extent-relative origins. `Painter::widget()` records a child -that inherits placement, so a pass-through wrapper and its valid descendants -can be repositioned without rerunning their draw bodies. `placement()` is -still a conservative raw read for arbitrary widget computations. - -**No measurement is different from a measured zero.** The first wider scan -found seeds 560 (`shuffle-swap-for-three`) and 1690 (`shuffle-add-three`) at -depth 4. A previously undrawn pure share had only supplied a hint; its -placeholder zero answer became reusable once it was drawn during placement. -`ActiveData::answer` is now optional. Both seeds pass after that correction, -and `adding_text_to_a_reverse_row_keeps_its_shared_height` reproduces the -zero-height failure with a small tree. - -**Exact recomposition and reuse.** The continuation replaces `given_len` with -`given_region`: each widget retains its original frame in its parent's -coordinates. Moving a retained subtree replays those same local compositions, -and stops at region nodes. `RegionRemap`, `AxisRemap`, and inverse division -are gone. Fixed-pixel frames can now change size without forcing otherwise -valid descendants to draw again. This adds 16 bytes per active widget; local -primitive coordinates were already retained by `efb416b`. - -The shared oracle now compares pixel regions with ordinary equality; there -is no `AGREE_STEPS` allowance. Text publishes the range of its retained line -breaks rather than narrowing that range to every later -requested width. A layout with only explicit line breaks or no breaks holds -at arbitrarily wider widths. New tests compare actual primitive and mask -geometry and assert reuse for fixed-frame resizing and widening unwrapped -text (including explicit newlines and empty content). - -**Measure the offer without placing an intermediate answer.** `Painter::measure_len` -replaces the hint/cache query and its duplicated drawing fallback. It still runs -`Widget::draw` when needed, but leaves that drawing in the offer until the parent -assigns the child's slot. Placing the child's own answer first was wasted work: -the parent immediately replaced that placement. The regression counts three -rather than four evaluations for a numeric-size leaf in a span, checks its actual -primitive bounds, and repeats after resize. There is no second layout body on -`Widget`. `Holds::ANY.through(...)` now returns `ANY` directly: an unrestricted -range needs no inverse division, including for a negative fraction. - -**A chosen slot does not cancel a child's alignment.** Bryan caught this in the -`tabs` image: a fixed 100 px square under a flexible wrapper sat at the slot's -near edge. `ask_box` now aligns a declared frame inside the chosen slot, just as -it does inside an unassigned offer. A regression asserts the actual coordinates -before and after resize. Warm/cold equality could not find this because both -were wrong. The corrected 900 px image still has 88 px between blue and red and -78 px between red and orange: the enclosing `.pad(10)` adds 10 px before the -red slot. Red is centered in that slot, 5 px right of the visible black gap's -center. Do not describe those two centers as the same thing or compensate with -an arbitrary offset. The parked padding API change is still separate. - -**The major update gap was over-invalidation, not a required correctness cost.** -Two different dependency graphs had been accumulated into one range. 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. A stack sized by one child must not remeasure because an -unmeasured overlay wrapped at another width during provisional layout. - -`Painter` now collects these contracts separately. `DrawResult::size()` and -`measure_len` contribute answer dependencies; every child draw contributes -drawing dependencies. Direct numeric and raw-placement reads conservatively -constrain both. The original offer retains the answer contract, and final -placement retains the drawing contract. Previous attempts separated storage -without separating which child dependencies entered each contract, so they -continued to invalidate measurements for unrelated drawing work. - -**A valid answer does not certify a valid drawing.** Root resize checks both. -Local updates propagate an invalidated drawing contract as well as an invalidated -answer; otherwise an ancestor can retain a broad resize range after a descendant -begins reading its pixel width. Focused regressions cover both paths and fail when -those checks are removed. No geometry check or equality requirement was relaxed. - -**A wider validity range does not invalidate an existing guarantee.** When a -local redraw returns the same size under a contract covering the old one, the -old answer contract remains sufficient. Keep it, and likewise keep a still-valid -old drawing contract. Adopting the wider range without telling the parent would -lose which guarantee the parent relies on; keeping it prevents both widening -and narrowing back from causing layout. Scroll alternates between an exact -width and a wider interval as it enters/leaves end anchoring. Comparing whole -contracts by equality caused four draws and doubled scroll cycles after the -initial dependency split (`f860f71`). Retaining the valid guarantee reduces -scroll to one draw, below the previous baseline's two. It introduces no retained -state or separate propagation subsystem. A focused test widens/narrows several -times without redrawing the parent, then resizes and requires the leaf to redraw. - -**Declared-size changes use ordinary bottom-up propagation.** The changed child -and its direct parent remain marked until the parent resolves the new rule. -Further propagation depends on the parent's new answer and drawing contract. -The blanket ancestor walk, `answer_invalid` set and frame-global -`replace_answers` flag are gone. They predated strict bottom-up settlement and -original-offer replay; a parent's independent answer no longer invalidates the -whole tree merely because a descendant declared a new width. A regression -checks the changed geometry and that propagation stops at an independent parent. - -**The `c44bd19` extent-child extension was removed.** It changed -`widget_within` to accept `DrawRegion::Extent` and enlarged retained child records, -but saved only 1.8% resize cycles and no primitive writes. The dependency fixes -address the major cost with the original `widget_within(UiRegion)` API and -widget-id-only inherited-child list. Pad and Stack again read raw placement; -those conservative reads are not the cause of the pathological update counts. - -Instrumented work, seed 1/depth 8, 50 frames per phase (cold once): - -| phase | draws at c44bd19 | current draws | primitive writes at c44bd19 | current writes | -| --- | ---: | ---: | ---: | ---: | -| cold | 463 | 484 | 9179 | 9179 | -| repaint | 1 | 1 | 1 | 1 | -| many | 263.1 | 274 | 5873 | 5873 | -| size | 240 | 3 | 5200 | 105 | -| scroll | 2 | 1 | 0 | 0 | -| resize | 278 | 27 | 5730 | 0 | - -Resize performs no text renders. Size updates render one text. Removing the -extent-child extension increases cold/many container evaluations again. Do not -hide those costs behind the large size/resize gains. The original `e44dea3` -measured 16 draws/106 writes for size and 13/0 for resize, but does not implement -the corrected frame/extent semantics, so it is context rather than a correctness -baseline. - -Nine alternating pairs of direct, uninstrumented release executables compare -`c44bd19` with `0e107f0` under `perf stat -e cycles:u,instructions:u`. All samples -were retained, with no scans or builds running during measurement. Counts include -fixture setup; these are VM CPU measurements, not phone frame times. - -| phase (frames) | median cycles before → after (billions) | median instructions before → after (billions) | -| --- | ---: | ---: | -| size (2,000) | 4.0774 → 0.2021 (-95.0%) | 10.4017 → 0.4993 (-95.2%) | -| resize (2,000) | 4.7070 → 0.4930 (-89.5%) | 11.9383 → 1.3009 (-89.1%) | -| many (2,000) | 4.7718 → 5.0276 (+5.4%) | 12.0567 → 12.4463 (+3.2%) | -| repaint (1,000,000) | 2.6803 → 2.9086 (+8.5%) | 6.0224 → 6.4382 (+6.9%) | -| scroll (300,000) | 2.6252 → 1.3443 (-48.8%) | 6.5802 → 3.0649 (-53.4%) | - -All five before/after cycle ranges are disjoint. The main gains coexist with -5.4% more cycles for `many` and 8.5% more for repaint. No claim is made that the -prototype is now uniformly faster. - -Additional depth-6 fixtures, nine alternating pairs and 4000 frames: seed 3 -size updates use 20.2% fewer cycles and 23.0% fewer instructions, while its resize -case costs 5.4% more cycles and 3.5% more instructions. Seed 13 resize uses 6.6% -fewer cycles and 7.7% fewer instructions. Those cycle ranges are disjoint too. -Seed 13's size case performs no widget draws and its cycle ranges overlap. - -Verification: workspace formatting, clippy with `layout-diagnostics`, and tests -(106 suite tests, 21 core tests, fast generated cases) pass. Exact release scans -pass 2000 seeds/depth 4 and 1000/depth 6; the shrinker passes 400 trees/depth 5, -and the debug scan passes 120 seeds/depth 4 with assertions enabled. Every scan -runs all fifteen scenarios without a position tolerance. Each of the five new -regressions fails when its corresponding behavior is deliberately broken. -All five reference renders (`view`, `minimal`, `random`, `tabs`, `text`) match -`a7307d9` pixel for pixel. Resizing `tabs` from 1920x1200 to 900x1200 matches a -cold render there, also with zero differing pixels. The GPU probe reports Venus -on the RX 7900 XT. - -**Remaining work is narrower, but not proved unavoidable.** Cold layout and -widespread content updates still run descendants while evaluating provisional -sizes, including children whose answers will never be read. A disposable trial -made child requests lazy during measurement and skipped primitive emission, but -reused the same active records for measurement and painting. It failed nine -oracle scenarios and made dirty updates much worse. Measurement cannot erase -or replace the final drawing's dependency/lifetime state. The trial was removed; -there is no second widget layout body or measurement phase in this change. - -The hot wrapping text really does return different heights at 68 and 89.53613 px, -so some width-dependent evaluation is necessary. That does not justify repeating -it after an unchanged size answer. The resize trace and reduced stack/overlay -case distinguished those two situations and led to this fix. Future work on -cold/many should preserve that distinction rather than weakening raw-placement -checks or memoizing arbitrary width results without a validity model. - -The earlier glyph-origin composition optimization (`a7307d9`) remains; it changes -no layout decisions or retained state. Two removed arithmetic trials remain -unpromising: combining frame recomposition with extent movement saved under 0.8% -instructions and no cycles; special-casing equal endpoint fractions in -`UiSpan::within` cost 11% cycles and 7% instructions. Retained local coordinates -still cost memory per primitive. The prototype remains separate from PR #18; -the `Inset`/`Outset` changes remain parked. - -### Measured against #18's head, and the placement pin behind the gap - -Checked on 2026-09-17 in `/home/bob/repos/iris-layout-baseline` (`e44dea3`, -its own `target-own` because its `target` is a symlink into -`/home/bob/repos/iris-pr18`) against `/home/bob/repos/iris-layout-experiment` -(`0e107f0`). **The five-phase cycles table above compares `c44bd19` with -`0e107f0` -- two commits inside the experiment -- so it says what the last two -fixes bought, not what the experiment costs against the branch it would -replace.** Against `e44dea3` the picture is different, and it decides whether -this can replace #18. - -Nine alternating pairs under `perf stat`, seed 1 at depth 8, uninstrumented -release executables: - -| phase | cycles `e44dea3` -> `0e107f0` | instructions | -| --- | ---: | ---: | -| size (2,000) | 0.2618 -> 0.2026 B (-22.6%) | -21.3% | -| scroll (300,000) | 2.5169 -> 1.2796 B (-49.2%) | -49.5% | -| repaint (300,000) | 0.8546 -> 0.9533 B (+11.5%) | +15.0% | -| resize (2,000) | 0.2697 -> 0.4813 B (+78.5%) | +99.0% | -| many (2,000) | 2.3885 -> 4.9504 B (+107.3%) | +98.4% | - -Each phase's before and after cycle ranges are disjoint. The work counters agree with the times: `size` -falls from 16 widget draws to 3 and `scroll` from 2 to 1, while `many` rises -from 157 to 274 and `resize` from 13 to 27. `many` is also the phase that -costs anything at all -- median frames at seed 1, depth 8 are 1 us for -repaint and scroll, 9 us for size, 48 us for resize and 667 us for `many`, -so a percentage on `many` is worth two orders of magnitude more than the same -percentage on repaint. - -**The `many` regression varies enormously with the tree**, so one fixture -cannot settle it. Median frame, 200 frames, `IRIS_DIRTY` at its default: - -| fixture | `e44dea3` | `0e107f0` | draws before -> after | -| --- | ---: | ---: | --- | -| seed 1, depth 8 | 0.318 ms | 0.667 ms | 157 -> 274 | -| seed 2, depth 8 | 0.218 ms | 0.700 ms | 111 -> 349 | -| seed 5, depth 8 | 0.759 ms | 0.702 ms | 337 -> 230 | -| seed 13, depth 8 | 1.088 ms | 6.351 ms | 524 -> 2380 | -| seed 3, depth 6 | 0.173 ms | 0.156 ms | 83 -> 51 | -| seed 13, depth 6 | 0.583 ms | 0.570 ms | 325 -> 268 | - -**The cause is the raw placement read, and it is nearly all of it.** -`LayoutHolds::placement` is `Some(..)` for any widget that called -`Painter::placement`, and that pins the exact box the drawing sits in: the -widget is redrawn whenever it moves at all, however wide its frame and extent -ranges are. `Pad` and `Stack` both read it -- `self.padding.region_of( -painter.placement())` and `let placement = painter.placement()` -- so every -`Pad` and `Stack` in a row redraws its whole subtree when an earlier sibling -changes length. A counter for reuse failures where the frame and extent -ranges still hold and only the placement moved says it is 45 of the 109 -failures a `many` frame at seed 1 has, 14 of 26 on `resize`, and 73 of 136 -on `cold`. - -Bounded by deleting the `reads_placement = true` line -- unsound, since -nothing then redraws a widget whose placement really did move, but it prices -the dependency: - -| fixture, `many` | `e44dea3` | `0e107f0` | no placement pin | -| --- | ---: | ---: | ---: | -| seed 1, depth 8 | 0.318 ms / 157 draws | 0.667 / 274 | 0.127 / 78 | -| seed 2, depth 8 | 0.218 / 111 | 0.700 / 349 | 0.079 / 46 | -| seed 13, depth 8 | 1.088 / 524 | 6.351 / 2380 | 0.147 / 84 | - -`cold` at seed 1 falls from 484 draws to 280 and 12.5 ms to 11.1 ms with it -gone, and `resize` from 48 us to 12 us. Read it as an upper bound on what -`Pad` and `Stack` having no way to say "inside my extent" was costing, not as -what removing the read properly would buy -- see below for the difference. - -**That is what `c44bd19` was, and the 1.8% that got it reverted was measured -before the dependency split.** It is now landed as `e6ba570`, below, and the -bound above turned out to overstate it: deleting the read also deletes -invalidation that the extent semantics genuinely require, so it prices "no -dependency at all" rather than "the dependency expressed properly". - -### Landed on 2026-09-17: extent children, and an ordered walk - -Three commits on `wip/region-and-placement`, pushed, on top of `0e107f0`. - -**`e6ba570`, a child gets a part of the container's extent.** -`widget_within` takes a `DrawRegion`, and `DrawRegion::Extent(part)` gives -the child a part of the extent without reading it. What is retained is the -part rather than the box it resolved to, so moving the extent re-places the -child through the same rule: `inherited_children` became `extent_children`, -carrying `Inherit` for the wrapper case `Painter::widget` already had and -`Within(part)` for the new one. `Pad` and `Stack` use it and no longer read -`placement()`. The dependency that goes up is a range on the container's -*extent*, since only the part's length reaches the child. A declared length -is unchanged -- it is a length of the frame wherever its box came from. -What still pins the placement is a report with a fraction in it, and that -pin is on the answer rather than the drawing; the test from the first -attempt fails without it. - -**`3bf2293` and `34cafb6`, the walk takes the deepest mark from a -`BTreeSet` keyed by depth** rather than `max_by_key` over the whole set. -Every mark made while the walk runs goes through `mark`, which queues -itself; the set is still what says the walk is done, so a mark that arrived -another way cannot be left for the next frame. Depth reads per `many` frame -at seed 1 depth 8: 131/1,314/14,611 at 9/34/145 marks become 57/160/436. -What is drawn does not change at any load measured. Ties between equal -depths now break by widget id, which makes the walk deterministic. - -Cycles, medians of seven alternating runs, `perf stat -e cycles:u`, against -both the branch #18 would merge and the experiment as it stood: - -| phase | `e44dea3` | `0e107f0` | head | vs `0e107f0` | -| --- | ---: | ---: | ---: | ---: | -| `many`, seed 1 | 1.273 B | 2.543 B | 2.502 B | -1.6% | -| `many`, seed 13 | 1.323 B | 7.308 B | 5.931 B | -18.8% | -| `resize` | 0.269 B | 0.485 B | 0.389 B | -19.9% | -| `size` | 0.261 B | 0.205 B | 0.204 B | -0.2% | -| `scroll` | 1.730 B | 0.912 B | 0.939 B | +2.9% | - -Verified at each commit: fmt, clippy with `-D warnings`, 109 suite and 20 -core tests, the oracle at 100 seeds, the shrinker at 400 trees of depth 5, -1000 seeds at depth 6 and 2000 at depth 4 over all fifteen cases, and the -five reference renders plus `tabs` resized to 900x1200 and `random` to -1280x800, all byte-identical on Venus. - -### Where the `many` gap actually comes from (2026-09-17, traced) - -The two sections above blame the placement pin and then the extent contract. -Neither is the cause. Disabling the pin (unsound, a bound) still redraws -**487** distinct widgets a frame at seed 13 against `e44dea3`'s 159, and a -per-widget trace of one `many` frame shows what does it: **local redraws -defer to the parent, and the deferrals chain to the root.** - -`redraw` refuses a dirty widget whose given box is not as long as its offer -and marks its parent instead. Under this protocol that is nearly every -widget under a self-sized container. `Span` hands its children its own -placement across itself as their *frame* (`UiRegion::from_axis(axis, -UiSpan::FULL, *own.axis(!axis))`), and that placement is `FULL` while the -span is being measured and its answer once it is placed, so a child's offer -frame across the span is the whole window and its given frame is the span's -height or width. The trace has 43 deferrals in one frame, in chains such as -424 → 425 → 437 → 449 → 483 → 487 → 491 with `given=(52, 40) -offered=(1920, 40)` at every step. Each container reached that way runs its -body at both placements, redrawing its subtree at two geometries. -`e44dea3` defers under the same rule but its given and offer differ only by -the widget's own alignment slack, so its chains stop after a level or two: -19 deferrals, 159 distinct widgets. - -**Re-asking locally at the offer** is the fix that follows, on branch -`wip/local-reask` (one commit over `34cafb6`, pushed). `ActiveData` keeps -`offer_region` beside `given_region`; `redraw` draws the widget once in the -offer's frame at the offer's lengths and placement, and again at the given -box where the two differ. Uninstrumented, 200 frames, seed and depth as -before, against `e44dea3`: - -| phase | `e44dea3` | `34cafb6` | `wip/local-reask` | -| --- | ---: | ---: | ---: | -| `many`, seed 1, depth 8 | 0.263 ms / 157 draws / 95 distinct | 0.549 / 263 / 108 | 0.499 / 239 / 93 | -| `many`, seed 13, depth 8 | 0.896 / 524 / 159 | 4.727 / 1904 / 508 | 1.282 / 647 / 294 | -| `size`, seed 1 | 0.016 / 16 draws | | 0.010 / 3 | -| `size`, seed 13 | 0.094 | | 0.003 | -| `resize`, seed 1 | 0.019 / 13 draws | 27 draws | 0.028 / 22 | -| `scroll`, `repaint` | 1 us | | 1 us | - -With the pin also disabled on that branch (bound), draws fall a further 15% -and distinct widgets do not move. **What is left is the offer-then-place -double pass**: a dirty container is drawn at its offer placement, where its -children get the offer geometry, and again at its placed one, and any child -whose `Holds` is a point (`Branch`, `Scroll`, wrapping text at a width it -was not measured at) is drawn on both passes. That pass exists because the -frame a span gives its children across itself is derived from the span's -own answer, so it cannot be the same on both asks. - -**The branch is not sound.** The suite, the debug oracle, 100 seeds and the -shrinker at 400 trees of depth 5 pass; the oracle at 1000 seeds of depth 6 -diverges on two. Both reduce to a self-sized container whose answer changes -under a local redraw: - -- seed 532, `reorder`: `Pad{0} > Stack[ Span{RIGHT}[ Wrapped, Wrapped, - Rect[x:178px, y:123px] ] ]`, rotating the span's children. Warm places - a text at 947..1115 x 536..659, cold at 986..1076 x 483..712. -- seed 398, `every-size`: `Span{LEFT}[ Rect, Rect, Stack[ Rect, - Branch{ probe: Wrapped, wide: Rect, narrow: Span{DOWN}[ Rect[x:129px, - y:leftover] ] } ] ]` with a size rule on the stack. Warm places a widget - at 11..183, cold at 6.5..187.5 on x. - -Reduce them with `SHRINK_SEED= SHRINK_DEPTH=6 SHRINK_CASE=` on -that branch before building on it. - -**The cross-axis frame is the protocol question, not the pin.** Bryan's -rule that a widget's frame does not change between the measuring and the -placing ask is broken across every span, because the only sensible -reference for a child's fraction across a span is the span's own box, and -that box is the span's answer. `e44dea3` has the same double pass and pays -for it by deferring to the parent; what it does not have is a frame that -changes under a widget. Whether to keep frame and placement apart at all -turns on this and on nested-span semantics; see the 2026-09-17 assessment -handed to Bryan. +- **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 @@ -1276,14 +773,21 @@ is a useful regression target for the experimental recomposition. 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. -- `Painter::widget_at(child, frame, placement)` supplies an optional chosen - extent on each axis. Unchosen axes place the measured answer by the child's - alignment. `measure_len` leaves a fresh measurement at the offer until the - parent assigns a slot; it does not first place an intermediate answer. -- Placement must not replace the fraction reference. The failed earlier - attempts moved `ActiveData::region` with the drawing and made local redraws - ask differently rounded questions. The experiment retains `placement` - separately and replays original local frames when recomposing geometry. +- **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 @@ -1298,7 +802,8 @@ is a useful regression target for the experimental recomposition. - 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`. **Do not choose between a fixed and a relative + 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 @@ -1357,7 +862,17 @@ is a useful regression target for the experimental recomposition. ## Verification at the current head -At `e44dea3`: +At `0954770` 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 @@ -1549,16 +1064,21 @@ The replay used for the reference check: In order, from the review above and Bryan's steer (2026-09-17): -1. ~~A resize marks the root and goes through the walk.~~ Landed as - `a0693ac` and `e44dea3`; see the two sections above. The re-parenting - half turned out to be two defects rather than the predicted one, and - neither was the `depth()` assertion. -2. **Transparent frames**, the plan near the top of this document. This is - the fundamental change and comes before anything built on the placing - ask; it subsumes the parked `Inset`/`Outset` and stack-fraction branches - (their tests land with step 7 and 8) and the `wip/local-reask` branch, - which is superseded and should be deleted once step 6 passes. -3. Write `ActiveData::answer` in one place. +1. **Two questions for Bryan, both from "What is not done, and why".** 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