diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 90d2a4c..b4dcc58 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -7,8 +7,8 @@ log. ## 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 local -head is **`5f16617`**, eighty-two commits, pushed. Built-in alignment is +`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. @@ -18,6 +18,22 @@ 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. +**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". + +**`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". + 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 @@ -109,17 +125,23 @@ The tree, five widgets, now running in `tests/cases/unsettled.rs` as Scroll(Y) { Span(DOWN) { Scroll(X){ text } as a region node, rect 87x24 } } -`UiRenderState::redraw` asks a dirty widget in the box its parent asked it in -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 +`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` @@ -141,7 +163,9 @@ 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. +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, @@ -173,7 +197,37 @@ in, and it is compiled out of the release runs above. ## Verification -**At `5f16617`**, the current head: +**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 @@ -459,6 +513,35 @@ What it costs, all of it measured rather than asserted: truncation either side is measurably too narrow. See the item under "Retained-layout invariants". +**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. + +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. + 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 @@ -483,6 +566,15 @@ across it. 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 @@ -504,14 +596,16 @@ across it. 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. Dirty - widgets are re-asked at the offer and only then drawn in the final box. An - offer composes through its ancestors' offers, not through their current - placed boxes. + 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 offered box and intersected with the - answer's 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 @@ -523,16 +617,23 @@ across it. 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.** The - second ask in `redraw` is in the final box, not at an offer, so applying the - widget's alignment to it again places its content twice (`d8ae9c3`). -- Equal box lengths do not imply equal placement. An ordinary widget whose - offered and current boxes differ in position must involve its parent again; - a region node can settle itself only when its own alignment, rather than a - container override, determines the final box. **Both halves of that - decision compare whole boxes** -- whether the parent must place it, and - whether the offer already was the final box. Comparing lengths for the - second left a region node drawn at its offer (`aea878d`). +- **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 @@ -777,11 +878,15 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15. 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 a step per level of nesting**, which is two - for these trees. Truncating made each of those steps 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`: +- **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 @@ -791,41 +896,34 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15. 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 a box centred in a fraction of its parent against the same - box centred in its own pixels, one step per level between them. 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. Not worth it at a thousandth of a pixel. -- `Holds::through` inverts `px + rel * box`, which drops a step, so the answer - is an interval even for a single length: inverting the length alone gives a - point that need not contain the box the part was drawn in. What it allows - for has two parts and they are not the same shape. - - **The two routes to a length** -- composed down the chain against - measured against the window -- get two half steps either side, since - `45a7176` made the walk round once instead of once a level. It was three - while composing rounded per level, and four before that, which was - overshoot. Tightening it has never moved one of the rig's twenty-five - work counters, so slack here buys no reuse. + 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. - **One half step further is arithmetically available and deliberately not - taken.** The `Holds` assertion is quiet at one, and the whole-of-a-box - case becomes an exact identity. What stops it is shrinker seed 220 on - `reorder` -- diagnosed on 2026-09-16 and written up under "The offer - chain breaks at a region node" below. It is not a rounding question. - - **The multiply on the way in** gets one more step at the top of the range - and nothing at the bottom, since truncation only ever drops. The whole of - a box has no multiply in it, however many pixels are added to it, and - allowing for one there compounded a step per level down a chain of - widgets each taking the whole of its parent (`60367d8`). + 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". - **It cannot go to zero while a length reached two ways is two numbers.** - 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. Those two - only coincide where composition is exact -- the `i64` item under "Next" -- - and shrinking this allowance is the test of whether that worked. Too wide - is the unsound side of it. **Run the generated cases in debug**, since the - assertion is compiled out of every release run. + **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 @@ -840,87 +938,74 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15. 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. -## The offer chain breaks at a region node +## A box in pixels is one multiply from its parent's -**The open defect at `5f16617`**, and the reason `Holds::through` keeps a -half step it does not need. It is the fourth thing this session to come down -to the offer against the placed box, after `aea878d`, `d8ae9c3` and the -`Stack`/`Pad` overrides in `2bc6bdf`. +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. -`offered_region` composes a widget's offer through its ancestors' offers, -which is the invariant. Where the parent is a **region node** it does not: -it falls back to `UiRegion::FULL`, and `redraw` then resolves that against -the node's slot entry -- which holds the node's **placed** box. So a widget -under a region node is re-asked in the box its parent *placed* that node in, -not the box its parent *offered* it. +Three things follow, and all three are why it was worth doing: -That is only wrong where the two differ, and `Scroll` is where they do: it -offers its content the viewport and places it in a box as long as the -content. So the offer becomes a function of the answer being re-derived, and -the old answer confirms itself. +- **`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. -Reproduced from the shrinker's ten-widget reduction: +**`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. - Scroll(X) { Pad as a region node { Span(X) { - Branch(threshold 213), Span(X){ wrapped text, one-line text } } } } +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. -```sh -SHRINK_SEED=220 SHRINK_DEPTH=5 SHRINK_CASE=reorder \ - cargo test --release --test shrink -- --ignored --nocapture -``` - -Traced, at a 900x1200 window, with `reorder` rotating both spans: - -- Each order **does** settle, and to a different answer: `[0,1]` gives the - pad a 2080.18px box and `[1,0]` gives it 1789.90. So this is not an - unsettled tree; both are fixed points. -- Cold, rotated: the texts are asked at 900 and 594.06, shape, and report - 305.94 and 583.97, so their span reports 889.90. -- Warm, rotated: the outer span is re-asked in **2080.18**, the pre-rotation - content box. Its texts are asked at 305.94 and 977.42, both reuse retained - answers without reshaping, and the span reports 1283.36 -- the same as - before the rotation, so no answer changed and nothing propagated up to the - `Scroll`. Widget 0 lands 290.27px out and the `Branch` picks its other - subtree. -- Marking every widget dirty converges warm onto cold, which is what says - the retained path and not the arithmetic is at fault. - -**The fix is to stop recomputing the offer at all** (agreed with Bryan, -2026-09-16). `ActiveData::offer` is in the parent *widget's* coordinates and -`ActiveData::region` is in the parent *node's*, so "was this node placed -where it was offered" is not a comparison either field can answer alone -- -and neither answer is needed. By the retained contract a drawing and its -answer depend on the box's pixel *lengths* only; checked against every -widget, the pixel reads are `px_len` and `px_size` and nothing reads a -position. The offer's symbolic position was only ever an input to its pixel -size, and that the parent already knew when it asked. So: - -- Thread the placed box's pixel size down beside `offered_px` -- one - expression per level, `local.size().to_px(parent_px)` -- and keep both on - `ActiveData`. Cold layout computes them the same way, so warm and cold - agree by construction rather than by allowance. -- `at_offer` becomes `px == offered_px`. Equal lengths at a different - position no longer escalate: the widget draws at `region`, which is the - `aea878d` fix without the second draw. A region node whose lengths differ - still draws twice, and the first box only needs the right length. -- `redraw` reads the stored `offered_px`. `offered_region`, `px_of`, - `px_region`, `Moves::compose`/`size_of`, `slot_wide` and `wide.rs` go; a - narrow `resolve` stays for `window_region`, which is hit testing and not a - layout decision. -- `remap_subtree`'s `Translate` keeps lengths, so stored sizes stay valid - at no cost on the hot path; `Scale` is the resize path and recomputes - them down the subtree from the same expression. -- `Holds::through` becomes the exact preimage of the truncating map - `px + floor(rel * B)`: two integer divisions, no `ROUTES`, no `way_in`, - and the whole-of-a-box case is the identity. The allowance existed only - because the box arrived by a second route. - -Symbolic regions then exist for the GPU and for remaps alone, and their -rounding is cosmetic below a step. That is the separation the fixed-point -work kept reaching for. **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. +**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 @@ -1097,23 +1182,17 @@ Queued from this work, in order: child draws in, or only what the parent reports for it. - ~~**Compose in `i64` and narrow only when storing**~~ -- **done in - `45a7176`**, and what it bought is below. The rest of this item is kept - because the measurement in it is what stopped the work being aimed at the - wrong thing. + `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. - The range inside a single operation is already protected -- `Fixed::mul` - widens to `i64` for the product, `Holds::through` does all of its - arithmetic in `i64` and narrows once at the end, and `div_toward` takes - `i64`. What is not protected is *precision across a chain*: - `UiSpan::within` composes a box through its parent with four multiplies and - every one comes back to the `i32` grid before the next starts, which is the - "step per level of nesting" in "Fixed point" and what forces - `Holds::through`'s two-route allowance. A `Wide` held through a - composition and narrowed when it is written to `ActiveData` or a primitive - would round once instead of once a level, at no cost in stored size. - - **But it cannot make equal shares the same number of screen pixels, - because the CPU's grid is not what puts them on the screen.** + **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 @@ -1126,39 +1205,20 @@ Queued from this work, in order: 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-or-two - step spread is below what the shader can express, and shows only where it + 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`. - What it bought is **layout's own decisions**: `Holds::through`'s two-route - allowance went from three half steps to two, and the whole of a box now - maps back to one step rather than one step per level of nesting. - - **And it made layout faster.** 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. `5f16617` is where the win is: a draw already - descends past every move entry on its way in, so `DrawInfo` carries what - the slot composes to and a widget's own box is a select rather than a walk - back up a mean of 2.8 levels, eight hundred times a frame. Composing on - the fine grid alone cost 1,880M and 755M, so the widening was paid for - twice over by not doing the walk. - - **One narrow step is left in that path.** `Painter` still composes a - child's region into its own on the grid before asking for it in pixels. - Taking it out needs the child's region as its parent stated it, which - `draw_inner` is not handed -- it receives that box already composed into - the slot's frame, because that is the form `ActiveData` and the GPU want. - Worth doing after the region-node offer defect below, which is what pins - `Holds::through` at the moment. - Two things paid for the widening and are the reason to keep them: a - *length* composes on its own in two multiplies a level rather than the - four both ends cost, since where the parent sits falls out of the - difference; and the pixel term takes its fraction on the ordinary grid, so - only the fraction itself -- the part that compounds multiplicatively -- - needs the room. A third, skipping a parent that spans its own box, cost - 18M instructions and is not there, the same verdict `UiSpan::within`'s - short-circuits got. + **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: