From c0b09219737b1ff2836b27c5a6d2c94438a75d80 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 01:43:17 -0400 Subject: [PATCH] Record the 2026-09-17 layout review in the handoff and prune its history The review's verdict, Bryan's decisions on rel and on the measure/draw split, the four findings with their measurements, the residual and the shader snap, and the reordered queue. The chronicle of closed defects, superseded verification lists and the cross-fixture tables the document said not to compare are gone; the rules, lessons and current measurements stay. Co-Authored-By: Claude Fable 5.1 --- docs/HANDOFF.md | 1701 ++++++++++++++++------------------------------- 1 file changed, 569 insertions(+), 1132 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index b4dcc58..4b9bb90 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -2,186 +2,480 @@ 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. +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. ## 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`; its head -is **`ea6dbae`**, eighty-five commits, pushed. Built-in alignment is -complete there; see "Built-in alignment" below for the retained-layout -details. No PR reviews were present when checked on 2026-09-15. +`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head +**`ea6dbae`**, 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, and a box in pixels threaded down the draw. +No PR review was present when checked on 2026-09-15. -**`5b78002` on top of it** (2026-09-16) does two things Bryan asked for after -the frame and root questions below. A child's answer now comes back to the -widget that asked in *that widget's* frame, and the window is no longer a -move entry -- see "A report is a fraction of the box it was given" and the -root item under "Fixed point". `Holds::through` is untouched by either. +**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. -**And then the open defect closed**, in `32542d0` (2026-09-17). A box in -pixels is no longer composed back up the move chain at all: it is threaded -down the draw one multiply a level, and `UiRenderState::asked_px` walks the -same steps back up where a local redraw starts part-way down the tree. That -deletes `wide.rs`, `Moves::compose`, `Moves::size_of`, `px_of`, `px_region`, -`offered_region`, `slot_wide` and `redraw`'s third ask, makes -`Holds::through` the exact preimage of the map it inverts, and fixes the -offer chain under a region node -- 252 lines of `core/` net, free on cold -layout and 9-13% off the retained paths. It is its own section: "A box in -pixels is one multiply from its parent's". +## Review of 2026-09-17 -**`ea6dbae` is a defect the review of that one found**, unrelated to it and -older: `redraw` handed a widget its own mask back as the mask it inherited, -so a `Masked` widget settled on its own panicked. See the mask item under -"Retained-layout invariants". +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. -The current head completes LAYOUT.md §2's position chain and the requested -`leftover` behavior. A child whose length is only `leftover` is not drawn when -nothing is left. A child that also asks for pixels or a relative fraction keeps -that part and overflows as before. +**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 needs work is the bookkeeping around the second ask, one boundary in +`Span` computed by an expression other than the drawing it guards, and a +`rel` that means two things. The two-step residual is structural and no +grid width fixes it; where it becomes visible is the shader's snap. -`29c7881` replaces the parallel resize rules with one retained-layout contract, -`Holds`: the interval of box lengths for which a widget's drawing and reported -size stay valid. Reading `Painter::px_len` or `px_size` narrows the interval to -the length read; `Painter::holds` lets the widget widen it. Parent validity is -the intersection of the ranges its children induce. This contract is trusted. -A widget that declares an incorrect range is a defective widget; Iris does not -add defensive work to recover optimizations from a false declaration. +### Decided by Bryan -`9d8415d` deletes `OrthoSize`, which `f437495` had restored as an explicit -`Span::ortho(OrthoSize::{Children, Full})`. It was the rule beside a widget -written a second time: a span across itself is as long as its longest child -*unless a rule already says how long it is*, and `Painter::ruled(axis)` is how -it asks which case it is in. Under a rule it does not read its children across -that axis at all -- the answer is not wanted, and reading one is what would -make its size depend on theirs. Not under one, the largest fixed orthogonal -length is reported and any relative or `leftover` child makes it report -`leftover`, which is the old conservative behavior. Either way the children's -`Holds` ranges propagate through final-box drawing, so a resize repositions -them without redrawing when their own contracts permit it. +- **`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. +- **Splitting `Widget::draw` into a measure and a draw is acceptable.** His + original reason against it: a span whose children have fixed sizes can + place them exactly where they were measured and so draw once, and that + must survive. The smaller items below come first. -`a8898aa` gives a length with no share in it its own type again, which is the -separation canonical `main` had and this branch had collapsed: `Len` is pixels -plus a fraction of a box -- a position being the length from the box's start, -which is why a span is two of them -- and `LayoutLen` is a `Len` plus a claim -only a container dividing its room can answer. What was `UiScalar` is `Len`, -what was `Len` is `LayoutLen`, and `From for LayoutLen` is the one-way -step. `490918b` renames `Painter::ruled` to `has_exact_size`, over -`SizeRule::exact`: "is there a rule here" and "is my report moot" are the same -question only while `Exact` is the only rule, and `Min`/`Max`/`Clamp` are -queued. +### `rel` means two things today -`71c9c39` replaces the public `Painter::place` distinction with an opt-in -widget property. `.region_node()` gives a widget one independently movable -retained region; `Widgets::set_region_node` can change that choice at runtime -and causes one structural redraw. Widgets without the property remain at the -default shallow chain depth: Iris recursively remaps their retained primitive -and mask regions when they move. `.scrollable()` enables a region node on its -content once as its convenient default; raw `Scroll::new` respects the -caller's choice, and the property can be disabled later without breaking -scrolling. `Span` and `Align` do not add nodes to their children. +Measured in a 400 px row whose first child is 100 px wide: -Do not make a span choose its orthogonal size by comparing children in pixels -at its current width. A fixed child and a relative child can create multiple -self-sizing fixed points; generated seed 13 settled differently warm and cold -under that attempted implementation. A `Holds` interval says where an already -chosen answer stays valid, but cannot make that circular choice unique. The -same circularity is what a `leftover` cap would put into `SizeRule::Max` -- -see the clamp item under "Next". +| second child | box | meaning | +| --- | --- | --- | +| `rect.width(rel(0.5))`, declared | 100 to 300 | half the row | +| nested span reporting `rel(0.5)` | 100 to 250 | half the remainder | -The implementation also fixes three counterexamples found while finishing the -rewrite: +The declared case is the rule; the reported case is wrong, and +`tests/cases/layout.rs::a_span_reads_a_child_report_as_a_fraction_of_what_it_offered` +pins the wrong behaviour and has to change. The two placement functions in +`core/src/ui/painter.rs` differ by one line: -- An asked-but-undrawn size dependency must name the widget that asked as its - parent. Using the asker's parent skipped a reader and made generated seed 10 - settle differently warm and cold. -- `Scroll` must return the answer from the first box it asked about, whether - that answer came from a retained length or a fresh measurement. Returning - the final placed answer only on the retained path advanced one fixed-point - iteration and broke seed 86. -- A widget retains the layer it was entered on, not the last child layer its - painter visited. The old value drifted on local redraw and put a redrawn tab - background above its retained text. +```rust +// placed_box: a reported length is composed through the offered box +let len = lens.axis(axis).within_len(span.len()); + +// declared_box: a declared length is a fraction of the parent's own box +let len = Len::from_parts(len.rel, len.px); +``` + +Making `declared_box` compose the same way would be the one-line fix in the +*wrong* direction. The composition `in_parent_frame` does is right where the +offer is the child's whole area -- `Pad`'s inset, a `Stack` child, `Scroll`'s +content -- and wrong where the offer is a positional remainder, which is +`Span` along its axis. `Span` offers each child the room from the cursor to +the end because a text has to wrap at the width actually left, so the pixel +width of the offer and the base its fractions are taken of have to be +separated: the remainder for one, the row for the other. Candidate: the ask +carries what a report's fractions are of, defaulting to the offered box, and +`Span` passes its own extent along the row. A child that drew at half of its +remainder is then placed at half the row and redrawn there by the placing +ask, which is one draw more, and exactly what `place` already does for any +child whose placed box differs from its offer. A nested span whose child +asks for half of it reports half of the row, gets it, and its child gets +half of that: no circularity, because the base is the row and not the +nested span's own answer. Not designed yet; it is first in "Next". + +### The offer's answer is overwritten by every ask + +`ActiveData::answer` is documented as what the widget answered at its +offer. `Painter::widget_at` guards its write with `answers_offer`, but +`UiRenderState::draw_inner` writes the field unconditionally and returns +the same value, so the guard is dead and the placing second ask overwrites +the offer's answer with one about the placed box. The guard is from +`29c7881`; the unconditional write arrived with `d3b0ebf`. + +```rust +// painter.rs, widget_at +let answers_offer = self.at_offer && px == offered_px; +let (size, holds) = self.state.draw_inner(...); +if answers_offer { + self.state.active.get_mut(&id.id()).unwrap().answer = (size, holds); +} + +// render_state.rs, draw_inner, on every ask +active.answer = settled; +``` + +`known_len`'s first-ask write and `update`'s resize path also write the +field as though `draw_inner` did not; `known_len`'s stores the value it just +read from the same field. The oracle passes, so layout is not wrong. What it +can cost is churn: an answer about the placed box can miss +`retained_answer` on the next offer ask and fall through to a remap and +back. Not confirmed against the counters. Write the field in one place, and +keep the guarded one. + +### `redraw` reassembles `DrawInfo` by hand + +`ActiveData` copies eight fields of `DrawInfo` and `redraw` rebuilds the +struct field by field. This is where the mask defect fixed in `ea6dbae` +lived for as long as there was a local-redraw path. + +```rust +let info = DrawInfo { + layer: active.layer, + parent: active.parent, + depth: active.depth, + parent_move: active.parent_move, + region_node: rsc.widgets().is_region_node(id), + mask: active.parent_mask, + given_len: active.given_len, + offer_len: active.offer_len, + px: given_px, + offered_px, + decided: active.decided, +}; +``` + +Store the `DrawInfo` on `ActiveData` and write +`DrawInfo { px, offered_px, ..active.info }`: the duplicated fields and the +reconstruction go, and a new field cannot be forgotten. The pixel pair stays +out on purpose; see "px is not stored" below. + +### `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 + +Both remaining steps are a symbolic region re-expressed by division rather +than recomputed the way a cold draw computes it: `AxisRemap::Scale` divides +to find a part's fraction of the old box, and `placed_box` scales a mixed +`Len` by the alignment. Neither touches the threaded pixel chain, so every +layout decision already agrees warm against cold; what differs is the +composed position the shader and hit testing see, by up to 0.002 px. + +Closing `Scale` exactly is possible: keep each primitive's region in its +widget's own coordinates and recompose on a move with `within`, eight +multiplies and no division against the current four divisions and twelve +multiplies, exact by construction, sixteen bytes more per primitive. Closing +the alignment one means resolving alignment in pixels, which costs the +retained resize path. Neither is worth a thousandth of a pixel on its own. + +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); +} +``` + +A rendering decision, so proposed rather than made. 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. + +### Measure and draw, later + +Splitting `Widget::draw` into a measure and a draw would delete `at_offer`, +`offered`, `answers_offer`, the placing second ask and the reuse dance after +it. It costs two methods on every widget and is a rewrite of a core that +passes a thousand seeds at depth six. The one-draw property Bryan wants +kept holds either way: a child with a fixed size needs no measure, and a +measured child costs a measure plus a draw where it now costs a draw plus a +reuse or a redraw. Last in "Next". + +## 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 + +`AGREE_STEPS` in `tests/scenario/mod.rs` is **2**, and both steps are +positions. One is a box centred in a fraction of its parent against the +same box centred in its own pixels, 0.001 px on a handful of seeds. The +other is `AxisRemap::Scale` re-expressing a part as a fraction of a box that +changed length; one step fails the 400-seed shrinker on `resize-size`, +seeds 384 and 162, by 0.002 px while passing the 100-seed oracle. Two of the +earlier sources were fixed rather than tolerated (`bdab558`): `Scroll` wrote +a box it had been given back out as its own length in pixels, and `Span` +placed each child a step from where the last ended rather than as the fixed +parts before it plus one rounded share. See "Where the residual comes from" +above for what closing the rest would cost. + +## 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. 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 only where both its measurement and the drawing in + its final placed box remain valid; the drawing's `Holds` is translated + back through `placed_lens` and intersected with the answer's. +- `Painter::widget_decided(child, region, [bool; 2])` says the parent chose + this box from the child's own answer along those axes, so the answer is + not placed inside it again. A report of "half of what you give me" has no + fixed point but zero, so the framework asks exactly twice: at the offer, + and in the box chosen from the answer, final on the decided axes. `Span` + decides the row axis, `Scroll` both, `Stack` both for its sizing child. + `Pad` overrides nothing: its inset is exactly the inner where the box is + its answer, and the slack is the inner's to sit in otherwise. +- **Placement cannot be applied after the fact.** Three attempts at "draw + the widget, then move its drawing to where its alignment says" failed, + because the move is a change of frame and no split of the stored state + carries it: moving `ActiveData::region` with the drawing made a later + local redraw ask a differently rounded question, and leaving it made + `placed` accumulate without bound because `try_reuse` returns a clean + subtree's size without walking into it. Alignment is applied where the + size is known -- `declared_box` for a rule, the placing second ask + otherwise. +- 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`. **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 divided - boundary as drawing; a tolerant endpoint retained zero-height children at - the boundary in generated seed 16. `5ed9e87` moved that boundary rather - than softening it, and `39e4ca2` removed the move: on the grid the box and - the sum are the same count. See "Fixed point" below. + 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. `dirty_size_under` stops a reader + taking a retained answer while something below it is dirty; it is an + optimization against laying out twice, not a validity mechanism. +- 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`). -`core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle -pin those rules. Seeds 10 and 86 are now in the ordinary generated set. +## Verification at the current head -## Warm layout agreed with cold, and the rigs that got it there +At `32542d0`, then `ea6dbae` on top: -**Fixed in `aea878d`, and the bisect was a red herring.** `git bisect` named -`95fb4f9`, the commit that made `Masked` report `Size::LEFTOVER`; the failing -tree has no `Masked` in it and that commit changes nothing else but a -`debug_assert`, so the attribution was wrong and the write-up built on it -("`Scroll` clips through a `Masked`") was too. Read the tree rather than the -bisect next time a commit that cannot be the cause is named. +- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- + -D warnings`, `cargo test --workspace`: green, 87 suite tests, 18 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.4 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 58 s, and at 1000 + seeds of depth 6 in 147 s. +- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at + 1920x1200 against `5b78002`, and the `tabs` touch replay before and after + the gesture. `random` live-resized from 1920x1200 to 1280x800 is + byte-identical to a cold 1280x800 render. +- Twenty-five rig work counters identical on the `cold` and `resize` + phases, which is what makes those rows under "Performance" a measurement. -The tree, five widgets, now running in `tests/cases/unsettled.rs` as -`redrawing_one_widget_does_not_move_what_scrolls_around_it`: +**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. - Scroll(Y) { Span(DOWN) { Scroll(X){ text } as a region node, rect 87x24 } } - -`UiRenderState::redraw` asked a dirty widget in the box its parent had -*offered* it and then again in the box its parent chose from that answer, and -it skipped that second ask whenever the two were the same **length**. They -are not the same box: the outer scroll offers its whole viewport and places -the span 24px above it, snapping to the end of a content 24px longer than its -box, so the offer was as long as the final box and 24px below it. A region -node writes the box it drew in into its own move entry, so the inner scroll -stayed where the offer put it. `d3b0ebf` had already compared whole boxes for -`parent_must_place` and left this one a length comparison -- one decision with -its two halves disagreeing. - -**The pair of asks it was a disagreement between is gone** (2026-09-17): a -local redraw asks in the box its parent *gave*, which is where the widget -already sits, and hands anything else back to the parent. See "A box in -pixels is one multiply from its parent's". The test stays, and the lesson -above it does. - -**The rigs.** `98d4e98` splits `iris::random::grow` into `plan(seed, depth, -&edits)` and `build(rsc, &plan)`. A seed cannot be made smaller, which is why -a failure the oracle found could never be handed to the shrinker; a `Plan` -can, and `tests/scenario/` holds the fifteen cases both rigs now run over the -same trees. So any failing seed reduces directly: - - SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \ - cargo test --release --test shrink -- --ignored --nocapture - -That took seed 18's 277 widgets to 5. The oracle prints the command to reduce -whatever it failed on. Seeds still mean the trees they meant: the 1000-seed -depth-6 run gives the same three failures with the same boxes before and after -the split, which is the check to repeat if the generator is touched again. - -**Nothing is left, as of `d8ae9c3`.** All fifteen shrinker cases pass at 400 -seeds of depth 5 and the oracle passes 1000 seeds of depth 6, both for the -first time. What closed the last two was one line: `redraw`'s second ask, in -the box its parent chose from the widget's answer, handed that box over as if -it were an offer, so `draw_inner` ran `placed_box` on an already-placed box -and applied the widget's own alignment to it twice. That only showed where -the alignment was the widget's own to apply, which is why it stayed hidden -while `Stack`, `Pad` and `Scroll` overrode every child. (That ask no longer -exists at all, which is the same defect made unwritable rather than fixed -twice.) - -Closed by it: shrinker seed 288 on `region-node` (11 widgets from 42, a -`Text` inset 8.8px at each end) and oracle seed 326 at depth 6 (43 from 205, -88px out, around two `Branch`es). Neither reduced small enough to hand-write, -so **generated seed 20 at depth 4 is the record** -- it is in `SEEDS`, so -`cargo test` fails without the fix rather than only the ignored long run. -Closed earlier by `aea878d`: shrinker seeds 174, 175 and 2, oracle seeds 18 -and 190. - -**Depth is what found these.** Nothing failed at 100 seeds of depth 4, which -is all the oracle had ever routinely run. Before fixed point, `5ed9e87` fails -the 1000-seed depth-6 run on seed 40 after a resize; that one is older than -all of the rest and is also gone. - -**Run the long two before believing a rounding change.** They are 14 s, 57 s -and 145 s, which is nothing against how long the two above took to find: +**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 @@ -191,854 +485,74 @@ IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \ cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees ``` -And run the ordinary suite in **debug**: the `Holds` assertion in `draw_at` -is what says a validity range stopped containing the box a drawing was made -in, and it is compiled out of the release runs above. +Depth is what finds things: nothing failed at 100 seeds of depth 4, which +was all the oracle ever routinely ran, and every late defect surfaced at +depth 5 or 6. -## Verification - -**At `32542d0`**, a box in pixels threaded down the draw (2026-09-17): - -- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- - -D warnings`, `cargo test --workspace`: green, 86 suite tests, 18 core - unit tests, 11 generated cases. -- The release oracle at 100 seeds in 14.4 s, and **120 seeds in debug** in - 59 s -- the debug run is the one that exercises the `Holds` assertion in - `draw_at`, and the point of an exact range is that a too-narrow one fires - it. -- All fifteen shrinker cases at 400 seeds of depth 5 in 58 s, and at 1000 - seeds of depth 6 in 147 s. -- Shrinker seed 220 on `reorder`, the defect this closes, and it is a test - now: - `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered` - fails without the fix and passes with it. **The old offer chain with the - old allowance also passes seed 220** -- it is the exact `Holds::through` - that exposes it -- so the test was checked against that pair rather than - against the old head alone. -- `view`, `minimal`, `random`, `tabs` and `text` render byte-identical at - 1920x1200 against `5b78002`, and so does the `tabs` touch replay, before - and after the gesture. `random` live-resized from 1920x1200 to 1280x800 is - byte-identical both to the old head's resize and to a cold 1280x800 render - (`5a11a464...`). -- Twenty-five rig work counters identical on the `cold` and `resize` phases, - which is what makes those two rows of the table under "Performance" a - measurement rather than a bound. -**At `ea6dbae`**, the mask fix on top of it: the suite again at 87 tests, the -core unit tests, the release oracle at 100 seeds, the fifteen shrinker cases -at 400 seeds of depth 5, and `tabs` byte-identical at 1920x1200. - -**At `5f16617`**: - -- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- - -D warnings`, `cargo test --workspace`: sixteen targets green, 83 suite - tests, 17 core unit tests, 11 generated cases. What stays ignored is the - long runs and the profiling rigs; no known defect is ignored any more. -- The release oracle at 100 seeds in 14.3 s, **all fifteen shrinker cases at - 400 seeds of depth 5** in 57 s, and **1000 seeds of depth 6** in 143 s. The - last two pass for the first time, and have kept passing across every - commit since. -- `view`, `minimal` and `random` render byte-identical at 1920x1200 against - `98d4e98`. `tabs` and `text` changed twice on purpose: `08c9d5a` moved an - antialiased edge by less than a pixel (see "Performance") and `2bc6bdf` - gave stack and pad children their own alignment back, which is what `tabs` - and `text` were asking for and what canonical `main` draws. **Take the - oracle rather than a render as the reference from here**; `view`, - `minimal` and `random` are the three still worth comparing. -- Twenty-five rig work counters identical between `60367d8` and `08c9d5a` on - the `many` fixture, which is what makes their timings comparable, and - unmoved again by `38eba54`. - -**At `394d514`**, kept because several of these have not been re-run since: - -`4febabf` on top of it -- `Fixed` wrapping rather than saturating -- passed -the workspace tests with all features (105), the release 100-seed oracle in -9.5 s, and rendered `random`, `tabs` and `text` byte-identical at 1920x1200 -against `394d514`. The measurements are under "Performance". - -- `cargo fmt --all --check` -- `cargo build --workspace --all-features` -- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- `cargo test --workspace --all-features`: 105 passed, 10 ignored -- The release generated cold-layout oracle passed 100 seeds in 9.4 s, and a - shrinker case at 300 seeds in 3.5 s: both run a thread per core but one. -- The release shrinker passed **all five** cases it had then -- `resize`, - `repaint`, `resize-repaint`, `reorder`, `size-change` -- at 300 seeds of - depth 5. **That claim does not carry to `98d4e98`**, which runs fifteen - cases over the oracle's trees rather than five over its own, and fails at - 400 seeds of depth 5. Neither does the 1000-seed depth-6 line that used to - sit here: re-run, `5ed9e87` fails it too, on seed 40 after a resize. -- `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at - 1920x1200 across the whole fixed-point sequence, and across this session's - renames and arithmetic changes on top of it. -- `tests/cases/drift.rs` passed its 20,000-move exactness check in release - mode. -- The seeded `random` example, live-resized from 1920x1200 to 1280x800, is - byte-identical to a cold 1280x800 render. Both PNGs hash to - `1d397c57b9914a2e596fa907029bc6aab4629e4d74deb0715e81325c703bcdb3`. - The run used the Venus adapter backed by the host RX 7900 XT. -- `minimal`, `text` and `view` render byte-identical at 1920x1200 across - `8220a78`. `tabs` differed only in the widget count it printed about - itself, - which is two wrapper types smaller -- so it is no longer a byte-identical - reference and the generated oracle is the check that matters. - **That last claim was then left to cover `d3b0ebf` as well, and it does - not**: `tabs` changed twice more there, and nobody looked (2026-09-16). - See "Alignment as a property changed two examples" below. A claim about a - render holds for the commit it was checked at and no further. -- At preceding head `29c7881`, reference renders against - `/home/bob/repos/iris-main-cmp` at `ca2b4b2` covered: - `tabs`, `view`, `minimal`, and `text` at 1920x1200; `tabs` cold at - 900x1200; live resize from 1920x1200 to 900x1200; and the tab interaction - before and after replay. Every comparison had zero differing pixels. The - live-resize image is also byte-identical to the cold 900x1200 image. - -The built-in-alignment pre-submit review was run in four passes. It caught -three distinctions the smaller tests had missed: an answer's validity must -include the drawing made in its final placed box; a region-node box is resolved -through its parent move rather than through its own move twice; and boxes with -equal dimensions but different positions still need the parent that placed -them. The 100-seed oracle and both 300-seed shrink cases pass after those -fixes. The earlier region-node review -caught an index-reuse hazard when removing a node; its move entry now remains -alive until every descendant has migrated. The generated oracle then exposed -the exact `Span` threshold described above. The earlier retained-layout -review caught the layer defect above, corrected validity-range inversion for -negative relative extents, and removed an impossible-state `unwrap` from -`Scroll`. The orthogonal-sizing review caught both the circular longest-child -choice and the incompatible visual effect of making `Full` the default. - -### Performance - -**The 3x this section used to report was a fixture artifact, withdrawn -2026-09-16.** `random.rs`'s `Branch` picks which of two subtrees to draw by -comparing a measured pixel length with a threshold, so the fixture's shape -moves with the thing being measured. Seed 1 at depth 8 draws 88 widgets and -writes 2,298 primitives a frame at `5ed9e87`, and 115 and 8,209 at `bd6de71` --- three and a half times the work behind a number read as three and a half -times the cost. `394d514` gives the rig `Edits::fixed_branches`; the oracle -keeps measured branches, which is the whole point of them. - -**Check the work counters before comparing two commits' times.** The rig -prints drawn widgets, widget draws and primitive writes. An undrawn -`leftover` child still moves them and no flag removes that, so the trees are -near rather than equal -- and `9d8415d` changed the generator itself, since -an X span's `OrthoSize::Full` became a size rule. - -Measured on the fixed-shape fixture, seed 1, depth 8, 500 frames of `many`: - -| | drawn widgets | primitive writes | instructions | cycles | IPC | -| --- | ---: | ---: | ---: | ---: | ---: | -| `5ed9e87`, before fixed point | 100 | 4,272 | 1,761M | 688M | 2.561 | -| `4cbb242` | 97 | 3,951 | 2,093M | ~819M | | -| `4febabf`, wrapping | 97 | 3,951 | 1,912M | 771M | 2.48 | -| `60367d8`, rounding | 97 | 3,951 | 1,915M | 777M | 2.465 | -| `08c9d5a`, truncating | 97 | 3,951 | 1,800M | 715M | 2.516 | - -The three ends were measured as medians of 25 runs of binaries built for the -reading; `4cbb242`'s row is still the single reading it was taken from, and -`4febabf`'s is medians of nine from 2026-09-16. `5ed9e87`'s numbers -reproduced to within 0.1% and 0.7% across those two sessions, which is what -says the rig is sound. - -**`60367d8` and `08c9d5a` are the one pair here that can be compared -directly**: all twenty-five of the rig's work counters are identical between -them, so -6.0% instructions and -8.0% cycles is the same work at a different -speed. Against the float head the head is +2.2% and +3.9%, down from +8.7% -and +12.9% -- but **take neither for the grid's cost.** Those two do -different work: the float head draws 100 widgets to the head's 97, writes -4,272 primitives to 3,951, remaps 38 subtrees a frame and redraws 43 where -the head remaps 53 and redraws 27, and renders 49 texts to 42. Dividing by -primitive writes does not correct for a different mix of remapping and -redrawing. - -**The fused multiply-add this section used to name does not exist, -withdrawn 2026-09-16.** `5ed9e87`'s binary holds no FMA instruction at all, -and none appears when it is rebuilt with `-C target-feature=+fma,+avx2` -either. Rust does not contract `a + b * c`, so `LerpUtil::lerp`'s -`from + (to - from) * self` is `subss`, `mulss`, `addss` on every target -there is. The float side never had the single instruction the grid's -multiply was being compared against, here or anywhere. - -**Nor is the multiply the cost.** Each row below is `4febabf` with one -thing taken out, in one worktree so the rows compare to each other, 500 -frames of `many` at seed 1 depth 8, medians of nine to eleven runs. **Every -row leaves the work counters where they were**, so unlike the cross-commit -numbers above these compare one tree against itself: all twenty-seven -counters diffed identical for the all-three row that the conclusion rests -on, and six of them checked for the others. - -| | instructions | cycles | -| --- | ---: | ---: | -| `4febabf` | 1,921M | 772M | -| `shift_round` out of `Fixed::mul`, truncating instead | 1,878M | 774M | -| `is_full` short-circuits out of `UiSpan::within` | 1,910M | 765M | -| zero short-circuit out of `Fixed::scaled` | 1,914M | 768M | -| all three | **1,803M** | **715M** | -| branchless `shift_round`, both short-circuits out | 1,964M | 773M | - -The "all three" row is the head as of `08c9d5a`; it re-measures there at -1,800M and 715M, which is the 0.2% a rebuild moves. - -Taking the rounding out alone removes 43M instructions and moves cycles by -+2M, which is inside the noise: the grid's arithmetic is instructions the -machine has spare issue width for, not time. - -**IPC has reversed since this section recorded it**, and that is the same -finding from the other side. It was 2.58 before fixed point and 2.59 after, -which is what "the cost is instruction count at unchanged throughput" was -read from. On the re-measured medians it is **2.54 before and 2.48 after**: -the head both executes more instructions and retires them more slowly, so -there is something to find beyond counting them. The all-three build -recovers half of it against its own worktree's baseline, 2.49 to 2.52. - -**What moves cycles is whether `UiSpan::within` inlines, and the three cuts -are superadditive because of it.** Separately they are worth 61M -instructions and 8M cycles between them; together, 118M and 57M -- four -fifths of the instruction gap and two thirds to three quarters of the cycle -gap, which is the looser number. The mechanism is visible in `nm`: -`::within` is a symbol in `4febabf` and in neither `5ed9e87` nor the -all-three build. Shortening its body past the inliner's threshold is what -pays, and only the whole set of cuts reaches it. That is where to push: a -cheaper `Holds` or a smaller `Len::within` is worth trying for this reason -rather than for its own instruction count. - -**Cycle noise is larger than this section used to claim.** Across ten sets -of nine to eleven runs, each of one unchanged binary, cycles spread 1-3% -usually and 6.7% in the worst set -- not the 0.23% recorded from three -runs, which was a lucky set read as the machine's precision. Worse, -`instructions:u` in this VM intermittently returns a garbage value --- 11.0e9, 25.8e9 and 28.4e9 against a true 1.9e9, in roughly a quarter of -readings. **`cycles:u` does it too**, seen 2026-09-16: 7.1e9 and 11.3e9 -against a true 715M, and in one set of three runs all three cycle readings -were garbage while all three instruction readings were good -- so a filter -that drops anything off by a factor has to report how many readings it kept, -or an empty sample reads as a zero. Take medians of nine or more and do not -trust a single `perf stat`. `ex_div_busy` was the one counter that -held to 0.1% across runs. - -**Instruction counts hold to 0.02% within a binary and move 0.5% across a -rebuild of the same source**, which is why the two tables above disagree -about `4febabf` by 9M: their baselines were built in different worktrees. -The *difference* against `5ed9e87` was 149M in both builds. So compare -against a baseline built beside the thing being measured, quote a delta -rather than an absolute, and re-measure the baseline whenever anything is -rebuilt. - -**"The division is 0.00% of cycles" was read off the wrong function.** It -is true of `AxisRemap::apply_scalar`, where most moves are translations and -the multiply is 1.5%. It is not true of the run: `Holds::through` divides -twice on every call and accounts for essentially all of the 2.23M `i64` -divisions a 500-frame `many` executes, which hold the integer divider busy -**21.30M cycles** -- the steadiest counter on this machine, 0.1% across -runs, and 2.8% of the run. A controlled extra `div_toward` in `through`, -against a de-const control so the inlining does not move, costs 6.3 cycles -each, so the two real ones are about 14M. `5ed9e87` divides twice there too, -in floats, so this is a real cost but a smaller *difference* than the -source-line profile makes it look, where `div_toward`'s `num / den` is the -largest single grid line in the head at 106M of 5,361M over 4,000 frames, -against `shift_round`'s 71M. - -Recovered this session, each measured before and after on one commit with -byte-identical renders proving the layout unchanged -- on the old fixture, -689.6M cycles and 1,886M instructions down to 638.9M and 1,657M: - -- `cb1bba4` works a move out once per subtree rather than per scalar: -3.8% - instructions, -0.8% cycles. The gap between those two is the finding. -- `1940e85` translates a whole region at once, since that is what a - translation is: -4% instructions, -3.4% cycles. -- `d75a1e2` and `4cbb242` skip multiplies that cannot change anything -- - composing through a full box, and `lerp` where the ends are equal: - together -2.7% instructions, -1.8% cycles when they landed. **That no - longer reproduces at `4febabf`**: removing either one now *saves* a - little, and removing both is part of the 118M/57M above. Wrapping changed - what a multiply costs, and a guard priced against a saturating multiply is - priced against something that is gone. Re-price a short-circuit before - keeping it. - -Tried and rejected, with numbers, so they are not tried again: - -- A float reciprocal for the division: **+6% cycles**. The conversions cost - more than the `idiv` did there. That was `apply_scalar`'s division; the - one in `Holds::through` is a different question and has not been tried. -- Branchless `shift_round`: **+6.7% cycles** when tried alone, and - re-tested 2026-09-16 with both short-circuits also removed, where the - branchy form reaches 1,803M/715M: 1,964M instructions and 773M cycles, - worse than the head on both. It is the *size* of the rounding that keeps - `within` out of line, not its branch, so making it branchless makes it - bigger and loses twice. -- Removing the per-child hash lookup in `remap_subtree` with `mem::take`: - **0.0%**. `FxHashMap` on a hot line is not the cost. -- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%. - (From the previous session.) - -- `#[inline]` on `UiSpan::within`, whose prologue and epilogue were a sixth - of its own cycles: -0.2% instructions, **+1.5% cycles**. That is not in - tension with the inlining finding above: forcing the body it has now into - every caller loses, and shrinking the body until the inliner takes it of - its own accord wins. Shrink it; do not annotate it. - -**`4febabf` makes `Fixed` wrap rather than saturate** (Bryan, 2026-09-16: a -coordinate past the range is not going to draw reasonably anyway, so wrap -and break clearly): -8.6% instructions, -6.6% cycles, more on cycles than -the 2.7% the instruction share predicted. `from_f32` still clamps and -`Holds` keeps its saturating `narrow`, since a range of box lengths past -`i32` really is unbounded. - -**`08c9d5a` makes `Fixed::mul` drop to the step below rather than round** -(Bryan, 2026-09-16: truncation is preferable at this point). The three cuts -this section priced together landed as one commit, since separately they are -worth 61M instructions and 8M cycles and together 115M and 62M -- the whole -of the superadditivity above. `Fixed::scaled` was its own zero short-circuit -and is gone; `UiSpan::within` lost both `is_full` tests and is inlined again, -which `nm` confirms. - -What it costs, all of it measured rather than asserted: - -- A share lands a thousandth of a pixel short of its row rather than on it. - `an_uneven_nesting_still_gives_every_share_the_same_length` now says what - is still exact -- each share starts where the last ended, the row ends at - its own edge, each edge is on the even division or one step below. -- A value and its negation are no longer the same distance from where they - came, since the drop is toward negative infinity on both sides of zero, so - a flipped span can sit a step from its mirror image. The alternative that - keeps that symmetry is the sign branch, which is part of what was bought. -- `tabs` is no longer a byte-identical render: 4,664 of 2,304,000 pixels - differ, in single-pixel-wide runs along 80 columns of one band of rounded - rects, which is an antialiased edge moved less than a pixel. `view`, - `minimal`, `text`, `random` and the tab replay are unchanged. -- The warm-against-cold oracle is **not** worse: 100 seeds pass, the fifteen - shrinker cases at 400 seeds of depth 5 fail only on seed 288 as before, and - depth-6 seeds 18 and 190 pass with 326 failing as before. -- `Holds::through` had to be re-derived, and the derivation for one - truncation either side is measurably too narrow. See the item under - "Retained-layout invariants". +## 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` are the pair that compares directly**: all twenty-five -work counters are identical on both, so those two rows say the draw path is -no more expensive threaded than composed. The other three do *less* work -rather than the same work faster, and the counters say how much a frame: -`repaint` goes from 23 draw requests and 13 widget draws to **1 and 1**, -`scroll` from 20 and 11 to 8 and 2, `many` from 273 and 186 to 207 and 157. -Primitive writes are identical in every phase, and so are all five reference -renders, which together are what say the output did not move. +`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. -Two things account for it: `redraw` composes nothing any more -(`offered_region`, `px_of`, `px_region` and `Moves::compose` are gone), and a -widget whose box moved without changing length settles itself instead of -escalating to its parent -- which is what `repaint`'s other twelve draws -were. Cycles are not quoted because this VM's are not worth quoting: across -these runs one unchanged binary's `cycles:u` ranged from 136M to 2,236M, -with the garbage readings described below. +### How to measure here -Earlier, against #18's own history: the retained rewrite took `many` from -25.17M instructions a frame at `691e3eb` to 6.14M at `29c7881`, and `resize` -from 16.08M to 8.39M. That fixture has since changed twice; do not compare -across it. +- **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. -## Retained-layout invariants +### Tried and rejected, with numbers -- **A retained drawing belongs to the layer it was made on.** Asked for again - on another layer it is redrawn, since nothing about its geometry says it is - in a list that paints at a different moment. A container that measures a - child by drawing it therefore measures on the layer that child will draw on - -- `Painter::child_layer_at` addresses one -- or it pays two draws a frame - forever and keeps whichever the second ask left. -- A widget that clips its contents to its box reports its box: `Scroll` and - `Masked` both report `LEFTOVER`, and a `debug_assert` holds any widget that - **set a mask this draw** to it. Overflowing is otherwise ordinary and a text - too tall for its box says so -- which is why the assertion is narrowed to - mask-setters rather than to every widget. - `Masked` is the only one that sets a mask; `Scroll` reports its box because - a drawing placed in a longer box is *scaled* by recomposition, not because - it masks. So a `LazySpan` is unaffected: it clips by not drawing, and may - report its whole content length, which is what the `Scroll` above it - measures. One that set its own mask would have to report its box and hand - the content length up another way. -- **A widget's own mask is not the one it inherited**, and `ActiveData` keeps - both: `mask` is what its drawing is clipped to and `parent_mask` what - whoever drew it handed down. They differ exactly where the widget called - `set_mask`, which is what says whose mask a move rewrites -- and a local - redraw inherits the second, because handing back the first hands a widget - its own mask to set again, which `set_mask` asserts against. Found by - review on 2026-09-17: every `Masked` widget redrawn on its own panicked, - for as long as there has been a local-redraw path. Pinned by - `retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`. -- A span is as long across itself as its longest child, unless a rule beside - it says how long it is -- and then it does not read its children there at - all, since the answer is not wanted and reading one is what makes its size - depend on it. `OrthoSize` was that second case written twice and is gone - (`9d8415d`); `Painter::ruled` is how a container asks which it is in, and - the only thing a widget may learn about a rule over it. +- 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. -- A region node holds a whole `UiRegion` in its parent node's coordinates. - `UiRegion::FULL` is the identity. Widgets opt in with `.region_node()` or - `Widgets::set_region_node`; ordinary widgets share the nearest ancestor - node. Region nodes therefore add chain depth only where moving a whole - subtree through one entry is useful. -- A widget's `ActiveData::region` is its box in its parent node. A region-node - widget draws in `FULL`; its box lives in its node. Moving an ordinary - retained subtree instead remaps its primitive, mask, and active regions. - Remapping stops at a descendant region node after rewriting that one entry. -- Changing `region_node` redraws the subtree once to rebuild the coordinate - boundary. The property belongs to widget identity, which is safe because a - widget has one parent. `.scrollable()` sets it once; raw `Scroll::new` does - not, and `Scroll` never reasserts it while drawing. -- The first box a parent asks about is the offer. A later box chosen from the - child's answer is the final box, not another independent answer. An offer - composes through its ancestors' offers -- as *lengths* of them, which is a - chain of fractions with no coordinate frame in it, so a region node between - two widgets changes nothing about it. 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 to ask again. -- An answer is reusable only where both its measurement and the drawing made - in its final placed box remain valid. The final drawing's `Holds` interval - is translated back into lengths of the box the widget was asked in and - intersected with the answer's interval. -- **A container's near-edge override is owed only where the box really is the - child's own answer.** `Stack` gives every child the box `box_of` derives - from its sizing child, so that one child has no room in it and would be - placed twice; every other child is handed a box that owes nothing to it and - keeps its own alignment. `Pad` reports its inner plus the padding, so where - its box is that answer the inset box is exactly the inner, and where the box - is bigger the slack is the inner's to sit in -- it overrides nothing - (`2bc6bdf`). `Scroll` still overrides, on both axes and deliberately: the - box it hands its content along the scrolling axis is the content's own - length, and where shorter content sits across the viewport is `Scroll`'s own - alignment by design. -- **A box a parent has already placed is asked about at the near edge**, and - the ask that does it is `draw_inner`'s own second one, in the box the - answer chose out of the box the parent gave. `redraw` had a third ask for - this, in `ActiveData::region` with `decided` forced on, and it is gone: - applying the widget's alignment to an already-placed box places its - content twice (`d8ae9c3`), and not re-placing it is simpler than undoing - the placement. -- **What a drawing depends on is the lengths of its box**, so the same - lengths somewhere else is the same question and a local redraw of a widget - whose box has moved but not changed length asks it. The box it asks in is - the one its parent gave, which is where its parent put it, so nothing has - to reproduce a position. That replaces the whole-box comparison and the - region-node exception beside it: a region node drawn at its offer - (`aea878d`) and a widget placed twice (`d8ae9c3`) were both that pair - disagreeing about which box a redraw is in. **A box in pixels is not - compared with a composed one anywhere any more** -- see the section of - that name. -- A retained drawing can be reused only when its `Holds` interval contains the - new pixel box on both axes, its parent node is unchanged, its region-node - choice matches the retained structure, and the widget is clean. A valid - ordinary subtree may move without redrawing because its regions are - recursively remapped. -- `Painter` records size-dependency edges only when a parent reads a child's - size or hint. An undrawn measured child remains recorded so a later change - reaches the parent that decided whether to draw it. -- Dirty widgets settle deepest-first. `dirty_size_under` prevents a reader - from taking a retained answer while something below that answer is still - dirty; the walk is an optimization against laying out twice, not a second - validity mechanism. -- Declared non-`leftover` lengths are resolved by the widget's parent where the - widget is drawn. A declared-length change therefore redraws the parent. -- A pixel comparison is equality: lengths are whole counts of `1/1024` px, - so a change too small to reach the next step is not a change and one that - reaches it is, however little of a pixel it is worth. -- **A length given in pixels is that many pixels wherever it ends up**, and - structurally rather than by luck: `Len::within` adds a part's own pixels - rather than scaling them, and both ends of a gap carry the same fraction, - so the multiply that rounds is the same on each and cancels. Pinned by - `a_length_in_pixels_is_that_many_pixels_however_it_is_nested`, over a row - buried under three containers that are each a fraction of their parent; - swept over 2,100 box widths and exact at every one. -- **A report is a fraction of the box the widget was given, and comes back - to the asker in the asker's frame** (Bryan, 2026-09-16: `rel` has nothing - to do with the remainder; it is the proportion of the space the widget - was given). `placed_box` always read it that way. `Span` did not: it added - a drawn child's `rel` straight into a cursor that counts fractions of the - row, which is right only while the offer has the row's whole extent -- - and a `Span` offers what is left after the cursor, so it broke once a - relative child preceded another. `Pad` summed padding onto a fraction of - its inset the same way and reported its box plus the padding for a child - that filled the inset. `DrawResult::size`/`len` and `known_len` now - compose the answer through the offer's length before handing it back - (`in_parent_frame`), so a container reads lengths of its own box and the - box `Span` hands back afterwards is the one `placed_box` chose. A - declared axis is excepted: the parent resolved the rule in its own box - and the rule is what the report says there. A hint is used as a - declaration and so is in the parent's frame too; `hints_agree` compares - it with the raw report, which holds because every hint is pixels or - `leftover`. A hint with a fraction in it would need one frame chosen. -- **A length given as a share is not**, and cannot be: children asking for - the same fraction come out one or two steps apart, 0.001 to 0.002 px. A - position is the quantity that gets rounded, so the row fills exactly and no - two children leave a seam, and the spread between lengths is what that - costs. Exact composition would shrink it, not remove it -- five equal - lengths cannot fill a row whose step count is not a multiple of five. Both - halves of this are in - `equal_shares_differ_by_at_most_two_steps_and_fill_the_row`. -- Text shaping is retained separately from line breaking. A greedy line break - remains valid from its longest produced line through the width at which it - was made, and `TextView` reports that interval through `Painter::holds`. -- `Span`'s decision to distribute `leftover` is a pixel question. The room to - divide is `len * fixed - total.px`; pure `leftover` children are undrawn - where there is none. The box a parent hands back and the sum of what the - children asked for are counts of the same step, so the boundary needs no - margin and the validity interval is split exactly at it. -- `Scroll` reports `Size::LEFTOVER`; what it takes from the first box it asked - about is its *content's* length, which it clamps and holds by rather than - reports. Its drawing can survive container-length changes only over the - interval in which clamping and its current offset do not change. -- **A move that keeps a box's length is a translation, and an offset is - exact on the grid.** A box that also changed length has to re-express each - part as a fraction of the new one, and that division and multiplication - round: `39e4ca2` translates where `from.len() == to.len()` and scales only - where it must, which is what made the shrinker's `resize` case agree - exactly. This inverts the float-era rule, and the measurements behind that - rule are why `tests/cases/drift.rs` exists: in floats, offsetting both ends - of a span shortened that fixture's row by 0.071 px over 20,000 moves and - 0.712 over 200,000, while re-expressing fractions stayed exact. 20,000 - moves is five minutes of scrolling at 60Hz. On the grid the drift is gone - either way, and `tests/cases/drift.rs` pins that it stays gone. - -## Built-in alignment - -Committed as `d3b0ebf` in `/home/bob/repos/iris-pr18`. - -What is in it: `align` is a widget property beside `region_node` and the size -rule, `Aligned` is deleted, `.align()`/`.center()` set the property, and the -fuzzer covers size rules, alignment and region nodes and changes all three at -runtime. Region nodes had **no generated coverage at all** before that. - -### Alignment is two fractions, not four directions - -Decided 2026-09-15 (Bryan). A widget's alignment is one `f32` per axis, so a -quarter of the way along an axis is expressible. `AxisAlign`'s three familiar -positions are named constants over that number, which is what every expression -already uses: the layout math only ever reads `AxisAlign::rel()`, so nothing -downstream changes shape. - -The default is **the middle on both axes**, because the two edges are the ones -that assume a direction -- which edge is the near one depends on the writing -system and on which way a container runs. "Near edge" throughout this document -means the *start of the box in the box's own orientation*, which for a -reversed span is its visually far end, not "top left". - -### Placement cannot be applied after the fact - -**Do not re-attempt "draw the widget, then move its drawing to where its -alignment says".** Three attempts failed, and the reason is structural: the -move is a change of coordinate frame, and no consistent split of the stored -state carries it. - -- Move `ActiveData::region` with the drawing, and a later local redraw asks a - differently rounded question. Measured: `(0,0)..(0,305.936)` re-expressed as - `(0.5,-81.5)..(0.5,224.436)` reads its length back as `305.93604`, which - crosses `Span`'s leftover/no-leftover boundary -- the one that must be exact - -- and draws a child a cold layout leaves undrawn. -- Leave `region` alone, and `placed` accumulates without bound, because - `try_reuse` returns a clean subtree's size **without walking into it**: only - the top widget's `placed` is recomputed while an ancestor's shift carries the - whole subtree. Measured 7,048,813 where a cold layout says 456. - -The replacement applies alignment in the two places that -already exist and are exact. Where the size is known before drawing, from a -rule, `declared_box` hands the child its aligned box directly -- one draw, no -move. Where the size is only known after drawing, the widget is **re-asked in -its placed box**, with its alignment forced to the near edge on the second ask -so it terminates; that ask goes through `try_reuse`, which moves by -recomposing, which `tests/cases/drift.rs` pins as exact. That deletes -`ActiveData::placed` and `shift_subtree`. The cost is a second ask for a -measured widget that is not near-aligned, which is exactly what `Aligned` cost -before this work. - -### Placement compounds, which is what the align override is for - -A container that reports a child's size while handing that child a **bigger -box** gets its content placed twice: once by the child, once by the box around -it. Found three times before the class was fixed rather than the instances -- -`Stack::size(Child(i))`, `Scroll`'s orthogonal axis, and `Pad`. - -`Painter::widget_decided(child, region, [bool; 2])` is the override as of -`5b78002`, and it is per axis and not an alignment: it says the parent chose -this box from the child's own answer along those axes, so the answer is not -placed inside it again. It replaced `widget_aligned(child, region, NEAR)`, -which forced the near edge on *both* axes and, being an alignment, still let -`placed_box` scale a fractional answer against the box -- fine while every -overriding container handed a box its child had reported in pixels, and -wrong the moment `Span` had to use it. `Span` now decides the row axis alone -when it places a child at the length it reported, since across the row the -child sits where its alignment says; `Scroll` decides both, and `Stack` both -for its sizing child. `DrawInfo::decided` and `ActiveData::decided` carry it, -so a local redraw asks the question its parent asked; alignment is always the -widget's own property now, and `ActiveData::align` is gone. - -**A fractional answer taken of the parent's second box shrinks twice.** -Found by the frame fix's own test: a nested span reporting `rel(0.5)` was -placed by its parent at a quarter of the row, correctly, and then -`placed_box` took half of *that* box because the parent's second ask was not -an override. A report of "half of what you give me" has no fixed point but -zero, so the framework asks exactly twice -- once at the offer, once in the -box chosen from the answer -- and the second box is final on the axes the -parent decided. This is also why a root that reports a fraction of the -window is placed inside it by its own alignment and then scaled to fit, -which the two new tests in `tests/cases/layout.rs` avoid by ruling the root -to the window; that is old behaviour and was not changed. - -### Alignment as a property changed two examples, unnoticed - -Found 2026-09-16 by looking at `tabs` beside canonical `main` rather than at -its pixel count. `d3b0ebf` deleted `Aligned`, and `8220a78` deleted -`SetSize`, so `.align()`, `.center()`, `.sized()`, `.width()` and `.height()` -all set properties on **one** widget where each used to wrap it in another. -Chaining them therefore means something different now, and two of it showed: - -- **A stack or pad child could not be aligned at all**, because those - containers overrode every child with the near edge. `tabs`'s counters - asked for `Align::RIGHT` and sat at the left; `text`'s narrow panel asked - to sit at the top of its row and filled it. Fixed in `2bc6bdf`; both match - `main` again. -- **`.width()` overwrites what `.sized()` set on the same axis**, so - `tabs`'s `rrect.sized((100, 100)).center().width(leftover(2))` was a - 100-tall bar across two shares where `main` draws a 100x100 square centred - in them. **Answered by `d21a215`** (Bryan, 2026-09-16): one widget keeps - one length per axis, and the second length needs a second widget that does - as little as possible. `WidgetPtr` already was that widget and is now - `Wrapper`, with `.wrapper()` to make one -- `Wrapper` and not `Wrap`, and - `.wrapper()` and not `.wrapped()`, so neither reads as the text setting. - -`text` still differs from `main` in its `aligned` panel, and that one is a -decision rather than a defect: `9d8415d` deleted `OrthoSize`, so a Y span -across itself is as long as its longest child instead of full width. The -example needs a width, not the framework a change. - -### A widget occupies its box, and must not report more than it draws - -`Scroll` reports `Size::LEFTOVER` on **both** axes: it clips its content to its -box, so it can neither take less of one nor honestly ask for more. The -content's length is what it scrolls through, not what it is. Reporting the -content length instead made the framework place a 400-long drawing in a -200-long box, and placement by recomposition **scales** in that case, because a -part stored at fraction 2 of its box stays at fraction 2 of a box twice as -long. Reporting the content's *cross* length had the box around it place -content already placed. - -Where content shorter than the viewport sits is now `Scroll`'s own alignment. -Its `Holds` widening -- "content of a fixed length that fits sits at the start -of any box it fits in" -- is therefore gated on near alignment: anchored -anywhere else it is a part of the room left over, so it moves with every -length the box takes and the drawing holds for that length alone. - -`Stack` gives every child the box its sizing child defines, through the new -`Painter::box_of`, for the same reason. - -`95fb4f9` adds the `debug_assert` this asked for, and narrows it to what is -actually true: a widget that **set a mask** this draw must report inside the -box it drew in. As "a reported size does not exceed its box" it fires on -ordinary overflow instead -- measured, a hundred fuzzer trees produce -thousands, every one a text too tall for the box it was offered, which is -what a text is meant to say. The same commit fixed `Masked`, which was -passing its inner's size up; `tests/cases/scroll.rs` has a clipping widget -that reports its content, so the assertion is itself covered. - -## Fixed point - -Layout decides on a grid rather than in floats, in four commits: `7548139` -the number, `4e28f10` positions, `bd6de71` lengths, `39e4ca2` the last of the -pixels and `Holds`. Decided with Bryan on 2026-09-15. - -- **`Fixed` is an `i32` counting `1 / 2^SHIFT`.** Adding and - subtracting are exact; a multiply drops to the step below and a conversion - between grids takes the nearest step. Two routes to one place that land on - one number are the same place, so everything downstream compares for - equality. **The multiply truncates as of `08c9d5a`** -- see the item in - "Performance" for what that bought and what it costs. `shift_round` is - still there for `to_scale`, which has no caller outside its own test. -- **`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, and the shader's copy is prepended from them by - `render::module_source` rather than written again in WGSL. -- A weight is not a fraction: a list divides its room by the total of its - weights, so `Weight` trades precision for the range to hold a whole list, - and `Rel::ratio` turns two weights into a share on the finer grid. -- **Arithmetic wraps, as of `4febabf`.** It saturated first, so that a - clamped coordinate kept the ordering a wrapped one inverts, and that cost - a twelfth of layout's instructions to keep order two million pixels out - where nothing draws. `MIN` and `MAX` still stand in for an unbounded end, - which is safe only because every use compares against them and none adds - to them; `from_f32` is the one operation that clamps. -- `Px` was `1/64` first. The residue of a length reached two ways is one - rounding, so it scales with the step: at `1/64` that was 0.016 px, enough - to move a box, and at `1/1024` it is a thousandth of a pixel. Range is - +/-2.1M px and conversion to `f32` is exact to 16,384 px. -- **What the fuzzers ask for is two steps, and they are both positions - now.** It was a step per level of nesting -- two for these trees -- and - threading a box in pixels down the draw did not reduce the count: one step - fails the 400-seed shrinker on `resize-size`, seeds 384 and 162, by - 0.002 px, while passing the 100-seed oracle (tried 2026-09-17). Truncating - made each step a whole one rather than half of one; it did not add a level. - Traced on 2026-09-16 to the same box reached two ways, each rounding where - the other does not -- not accumulation, and not one place. Two of them are - fixed in `bdab558`: - - `Scroll` wrote a box it had been given back out as its own length in - pixels. Centring a part in `rel 1` lands a step from centring it in - `px 900`, because `a(x - y)` and `ax - ay` do not round alike. Content - that fills the viewport unscrolled is handed back as it came, and the - `repaint` and `resize-repaint` cases became exact. - - `Span` placed each child a step from where the last ended, carrying every - share's rounding along the row. A position is now the fixed parts before - it, exact, plus one rounded share. Two hundred equal shares of a 1000 px - row ended at 999.999 and now end at 1000. - What is left is two positions, and `AGREE_STEPS` stays **2** for them. One - is a box centred in a fraction of its parent against the same box centred - in its own pixels, which is what shows at zero tolerance -- 0.001 px on a - handful of seeds. Closing it means alignment resolved in pixels - everywhere, which costs the retained resize path, since it is the - fractional form that re-centres a subtree without redrawing it. The other - is `AxisRemap::Scale` re-expressing a part as a fraction of a box that - changed length, which is the one the `resize-size` seeds above need. - Neither is worth a thousandth of a pixel. -- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**, as - of `32542d0`. `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` - and `floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, which - is two `div_toward`s once the sign of `rel` has said which bound comes from - which end. No `ROUTES`, no `way_in`, and the whole of a box -- with or - without pixels taken off it -- maps back to itself. - - The answer is still an interval where this range is a single length, - because a floor is not invertible: many boxes give one length. What is gone - is the *allowance*, which was there because the box arrived by a second - route, composed down the move chain against threaded down the draw. There - is one route now; see "A box in pixels is one multiply from its parent's". - - **The range has to contain the box a drawing was made in**, which the - `Holds` assertion in `draw_at` checks, and it must not contain a box the - drawing does not hold for, which the warm-against-cold oracle checks. - Being the preimage rather than a margin is what makes those two one - statement instead of a trade-off between them. **Run the generated cases - in debug**, since the assertion is compiled out of every release run. -- **The window is not a move entry** (`5b78002`). It was one, holding the - output in pixels, so composing through it zeroed every `rel` below and the - shader's `rel * window.dim` and the CPU's final multiply by the output - were both dead. Now a chain bottoms out in `MoveIdx::NONE`, the window is - applied where a fraction becomes pixels -- `to_px(output_size)` on the - CPU, the uniform in the shader -- and a resize rewrites no retained entry - and re-uploads nothing but the uniform. Instances were never re-uploaded - on a resize either way; they are `(rel, px)` in their node's frame and the - shader walks the chain per vertex. The cost of a resize is whatever - `Holds` redraws. -- A pointer, a wheel notch, a shaped glyph advance and a window size arrive - as floats and are put on the grid where they arrive. `Vec2` stays what the - GPU and the platform speak; `PxVec2` is what layout decides in. - -## A box in pixels is one multiply from its parent's - -The rule the offer chain broke, and the one layout now rests on. `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. Two steps a level: the box its parent gave it, -and 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 rather than composing -anything, 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, which is what makes both immune to a -region node; warm and cold reach every length by the same expression, so -they agree by construction rather than by allowance. - -Three things follow, and all three are why it was worth doing: - -- **`Holds::through` is the exact preimage of `px + floor(rel * box)`** -- - two integer divisions, no `ROUTES`, no `way_in`, and the whole of a box - mapping back to itself. It can be exact because there is one route to a - length rather than two. -- **A local redraw asks the widget in the box its parent gave it**, and only - where that box is as long as the one it was offered; any other box is a - different question and its parent's to ask, with the mark left on. The - `decided`/region-node exception and the third ask in `redraw` went with - that: `draw_inner` places the answer inside the given box itself, which is - the ask that leaves the widget where its parent put it. -- **Symbolic regions are for the GPU, hit testing and remaps alone.** - `Moves::resolve` is the only walk left and it is the one the vertex shader - does. `wide.rs`, `Moves::compose`, `Moves::size_of`, `px_of`, `px_region`, - `offered_region` and `slot_wide` are all gone, 252 lines of `core/` net. - What their rounding is worth is cosmetic below a step, and nothing layout - decides turns on it. - -**`px` is not stored on `ActiveData`, deliberately.** The plan for this said -to keep it there; it cannot be kept. A resize every widget's `Holds` admits -redraws nothing, which is the point of `Holds`, so a stored pixel length -would then be stale on every widget in the tree with nothing on it to say -so -- and the next local redraw of any of them would lay out for the old -window. Refreshing it costs a walk down every reused subtree on the resize -path; `asked_px` costs a walk up the parent chain only where a widget is -already being redrawn, and the mean chain here is 2.8 levels. - -What it was, kept because it is the shape of the mistake. `offered_region` -composed a widget's offer through its ancestors' offers, which is the -invariant, except where the parent was a **region node**: there it fell back -to `UiRegion::FULL`, and `redraw` resolved that against the node's slot -entry, which holds the node's **placed** box. `Scroll` is where placed and -offered differ -- it offers its content the viewport and places it in a box -as long as the content -- so everything under such a node was re-asked at -the content's width, the texts kept the widths that width produced, the -content stayed the length those widths make, and the old answer confirmed -itself. On the shrinker's reduction of seed 220 at 900x1200, `reorder` left -widget 0 290.27px out; marking every widget dirty converged warm onto cold, -which is what said the retained path and not the arithmetic was at fault. -It is pinned as -`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`, -the shrinker's ten widgets transcribed: with no padding between the node and -the span inside it, nothing distinguished the placed box from the offer and -the span settled itself. **Both halves had to be in place at once** -- the -old chain with the old allowance passes seed 220, and the old chain with the -exact `through` is what fails it. - -**Do not widen the grid instead**: every failure in this document is one -value reached by two expressions, sitting exactly on a boundary because the -boundary was defined by the same value coming back the other way, and no -precision shrinks a residue that is the whole distance. - -## The leftover boundary - -Fixed in `5ed9e87`, which closed the shrinker's `reorder` case. Neither of its -two red seeds was about reordering. - -A span's box in pixels, compared with what its fixed and relative children -fill, is the same number whenever the parent sized that box from the span's own -answer -- and the box comes back through the chain a few bits off. So -`0.00003 px` decided whether a `leftover`-only child existed: warm rounded -under and left it undrawn, cold rounded over and drew it at zero length. Both -layouts are stable and the pixels are identical either way, which is why only -the warm-against-cold oracle could see it. - -**A structural decision may not be taken where boxes structurally land.** The -fix was not a tolerant comparison -- that is what generated seed 16 punished --- but a boundary moved by `HOLDS_EPSILON_PX` of room, with the validity range -split exactly at the moved boundary. **The move and the constant are gone -since `39e4ca2`**: on the grid the box a parent hands back and the sum of what -the children asked for are whole counts of the same step, and both routes land -on the same count, so the boundary needs no margin. What the section still -records is why a decision may not be taken on a hair's breadth, and the -regression that pins it. `tests/cases/unsettled.rs`'s -`a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over` is the -six-widget regression, shrunk from 266; it needs the span above the one that -divides, because without a box composed through it both trees round the same -way. - -`Scroll`'s `content_len <= container_len` sits on the same coincidence but is -continuous there -- it chooses between two `Holds` ranges that both contain the -current length, so a rounding difference costs a redraw rather than a -different layout. Checked while fixing this; nothing else reads a box in -pixels to decide something structural. +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 @@ -1051,49 +565,34 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` -`cb955f1` put the ordinary tests in `tests/cases/`, as modules of one -`tests/suite.rs` target -- eleven links became one, and with -`profile.test`'s `debug = "line-tables-only"` a rebuild of `iris`'s test -targets went from 14.3 s to 7.7 s and `target/` from 45 GB to 13 GB. Pick a -module out with `cargo test --test suite layout::`. The fuzzers and the -`*_cost` measurements are still their own targets. +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. -The fuzzers take a thread per core but one (`9d8415d`), since a seed grows, -lays out and drops its tree alone: the oracle's hundred seeds went from 68 s -to 9.4 s and a shrinker case at 300 seeds from 18 s to 3.5 s. A failing seed -still shrinks and panics on its own thread. - -Run the long generated oracle only after ordinary tests pass: +`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 -cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees +SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \ + cargo test --release --test shrink -- --ignored --nocapture ``` -`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 since `98d4e98`, -so a seed the oracle fails on goes straight to it -- the failure prints the -command. `SHRINK_SEED`, `SHRINK_SEEDS`, `SHRINK_DEPTH`, and `SHRINK_CASE` -(a case name, or `all`) select what it tries. Turn what it finds into a test -of its own rather than leaving a seed as the record. +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. -The fifteen cases live in `tests/scenario/mod.rs`, which both targets include -by `#[path]`. Adding one there gives it to both rigs at once; a case that only -one of them knows is how the two drifted apart in the first place. - -**The float head is checked out at `/home/bob/repos/iris-float-cmp`**, at -`5ed9e87` with `394d514`'s `Edits::fixed_branches` cherry-applied to -`src/random.rs` and `tests/layout_diagnostics.rs` but not committed, since -the flag is the only way to measure the two on one tree. Build it and -`iris-pr18` with `--test layout_diagnostics` and run the binaries directly -rather than through cargo. A comparison is only worth reading when the -`--features layout-diagnostics` counters match on both sides; check -`primitive writes`, `widget draws`, `reuse remapped` and `placed by -redrawing` before any timing. **Against the float head they do not match and -will not**, so that comparison is a bound rather than a measurement -- the -pair worth measuring is two fixed-point commits, where all twenty-five can -be diffed. Dump them with +`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 \ @@ -1101,22 +600,26 @@ IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \ | grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort ``` -and `diff` the two, which is the check that says a change is free. +and `diff` two runs; identical output is what says a change is free. -The headless reference set must be run one process at a time because the rig -reuses one compositor. Comparison worktrees need separate target directories. -Useful commands: +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. ```sh ./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png -./scripts/run-headless.sh tabs --mode 900x1200@60Hz --shot /tmp/cold.png ./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ --resize 900x1200@60Hz --shot /tmp/resized.png ./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ --replay /tmp/tabs.touch --shot /tmp/replay.png ``` -The replay used for the final check was: +The replay used for the reference check: ```text 0 down 1728 24 @@ -1127,110 +630,44 @@ The replay used for the final check was: 880 up 1836 1116 ``` -`tests/layout_diagnostics.rs` is the retained CPU rig. Select `cold`, `many`, -`repaint`, `size`, `scroll`, or `resize` with `IRIS_PHASE`; use the feature for -explanatory counters and an uninstrumented release binary under `perf` for -instruction totals. - ## Next -The next small LAYOUT.md §2 item is `LazySpan`. Region nodes now cover the -independently movable-subtree use case; do not restore a separate -child-placement API. `2d86058` brought `docs/LAYOUT.md` §2 and §3 to what -shipped; **§4, §5 and the density section are still stale** -- they name +In order, from the review above and Bryan's steer (2026-09-17): + +1. **`rel` as a fraction of the containing widget's whole area**, on + `Span`'s row axis, per the decision above. Fix the test that pins the + remainder reading, and add the two-child case from the table. +2. Write `ActiveData::answer` in one place. +3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the + reconstruction in `redraw`. +4. `Span`'s leftover boundary through `Holds::through`. +5. The shader snap, if Bryan takes it: change, then the render set and the + oracle. +6. The smaller items: the stale `f32` comment, the gap of an undrawn child, + confirm nested `leftover` weights, one zero-divisor fallback. +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. +10. The measure/draw split, once the above is in. + +`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. +`Aligned` and `MaxSize`, none of which exist. Do not restore +`OnResize::Translate` or `OrthoSize`. -**Built-in alignment and size goes on #18 rather than after it** (Bryan, -2026-09-15: #18 is unreviewed and already large enough that most lines get read -anyway). The size half landed as `8220a78`, the alignment half as `d3b0ebf`. - -Do not restore `OnResize::Translate`; retained translation is now expressed by -the same `Holds` contract and box chain. - -Queued from this work, in order: - -- `Scroll` should take a direction rather than one axis: vertical, horizontal, - or both. Reporting `LEFTOVER` on both axes is already the right shape for it. -- Restore `max_width`/`max_height` as `SizeRule::{Min, Max, Clamp}`. `8220a78` - deleted `MaxSize` **and its builders**, so that is public API owed back. A - clamp resolved where the box is decided also fixes `MaxSize`'s reading of - `px_size`, which pinned its interval to one exact box on both axes and - redrew its whole subtree on any resize. The clamp boundary is a hard layout - decision: its `Holds` range must be exact and split at the crossover, the - way generated seed 16 taught for `Span`. On the grid the crossover no - longer needs moving off where boxes land -- see "Fixed point" -- but it - does need both sides of the comparison to be `Px`. - - **A cap may not contain `leftover`** (found 2026-09-16, answering whether - the parent could resolve one). `Exact` works with a share in it because it - makes the widget's report moot -- `ruled()` is true, nothing reads the - report, and the parent divides a weight that does not depend on what it - divides. A cap must read the report, so rule and report are in one - equation, and a share puts the division into it too: a child capped at one - share contributes its drawn pixels to the row's total while it fits and a - weight once it does not, which moves the room, which moves the share. The - two assignments are each self-consistent, which is the multiple-fixed-point - failure that generated seed 13 punished for orthogonal sizing -- and - resolving it the way flexbox does, by freezing violated children and - dividing again, costs an ask per round where the retained contract allows - one offer per child. `min(report, cap)` is also not a `Len`: `Len` is a sum - of the three parts, and the smaller of two of them is not. - - So a cap takes pixels and a fraction and no share, which is what `Len` is - since `a8898aa`. Still awaiting Bryan: whether a `Max` narrows the box the - child draws in, or only what the parent reports for it. - -- ~~**Compose in `i64` and narrow only when storing**~~ -- **done in - `45a7176`, and then deleted with the composition it was for.** There is - nothing left to compose: a box in pixels is threaded down the draw one - multiply a level, so `wide.rs` went with `Moves::compose` and - `Moves::size_of`. What the widening bought while it was there -- - `Holds::through`'s allowance from three half steps to two, and the whole - of a box mapping back to one step rather than one a level -- is what - exactness now gives outright. The rest of this item is kept because the - measurement in it is what stopped the work being aimed at the wrong thing. - - **Widening the grid cannot make equal shares the same number of screen - pixels, because the CPU's grid is not what puts them on the screen.** - `prelude.wgsl` decodes the raw `rel`/`px` counts into `f32`, walks the move - chain in floats -- with the comment saying so, "what has to hold is that - this agrees with itself frame to frame, not that it matches the CPU to the - last bit" -- and then lands each edge with - `snap_floor(rel * dim + px)`, which is a whole pixel. (It floored the two - parts apart until `5b78002`, which was harmless only while the root entry - zeroed every `rel`.) - **Measured: every box edge in the `tabs` render is a hard edge**, no - partially covered column anywhere along the band, and the two `pad(10)` - gaps are exactly ten pixels on both sides of the truncation change. So the - screen invariant is already as good as integers allow: a length in pixels - is that many pixels, and equal shares differ by at most one whole pixel - because three equal integers cannot sum to 1000. Layout's own one-step - spread is below what the shader can express, and shows only where it - pushes a value across the `floor` -- which is what moved `tabs`'s corner - arcs by a pixel at `08c9d5a`. - - **And composing it down the draw rather than back up the chain made layout - faster**, which is the part that survives: `5f16617` took 1,840M - instructions and 735M cycles against 1,908M and 760M -- 3.6% and 3.3%, - medians of 25 with all twenty-five work counters identical -- because a - draw already descends past every move entry on its way in, so a widget's - own box was a select rather than a walk back up a mean of 2.8 levels, - eight hundred times a frame. Threading the pixels themselves took the - select out too, at no cost to cold layout and 9-13% off the retained - paths; the numbers are under "Performance". - -Other queued work, in dependency order: - -- `UiRenderState` behind `Rc>`. -- Split `Len`/layout length and add density-independent pixels. -- Input restructuring: pointer capture, drag slop and axis, cancellation, - mask-aware hit testing, and 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. +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. 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 +current box chain and the current length types. Recreate changes on current types and keep app/session concepts out of Iris.