diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 4136c90..31ad512 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -8,7 +8,7 @@ log. 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 **`08c9d5a`**, seventy-five commits, pushed. Built-in alignment is +head is **`e166e00`**, seventy-nine 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. @@ -89,7 +89,7 @@ rewrite: `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. -## A warm frame moved a scrolled span, and the rigs that found it +## Warm layout agreed with cold, and the rigs that got it there **Fixed in `aea878d`, and the bisect was a red herring.** `git bisect` named `95fb4f9`, the commit that made `Masked` report `Size::LEFTOVER`; the failing @@ -128,51 +128,64 @@ 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. -**What is left, at `08c9d5a`.** Two failures, both older than the fix above -and neither reduced to a test yet: +**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. -- **Shrinker seed 288 on `region-node`**, 400 seeds of depth 5. Reduces to 11 - widgets from 42, and widget 34 -- a `Text` under `Span(Y-)` under two - `Stack`s -- sits at y 1032.80..1261.60 warm against 1024..1270.40 cold, so - it is inset 8.8px at each end rather than displaced. The plan is a - `Scroll(X)` over a `Span` of three rects with `region_node` set on the - first and third, inside two `Stack`s. -- **Oracle seed 326 at depth 6 on `repaint-some`**. Reduces only to 43 - widgets from 205, around two `Branch`es -- the widget whose shape depends - on a measured length -- with the differing `Text` at - `Text < Branch < Branch < Scroll < Span < Pad`, 88px out. The `Branch`es - are why it will not reduce further, and probably why it diverges. +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. -Fixed with `aea878d`, from what this section used to list as unreduced leads: -shrinker seeds 174 and 175 on `repaint-some` and seed 2 on `region-node`, and -oracle seeds 18 and 190 at depth 6. The `repaint-some` guess was right. +**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. -**Depth is what finds these.** Nothing fails at 100 seeds of depth 4, which is -all the oracle has ever routinely run. Before fixed point, `5ed9e87` fails the -1000-seed depth-6 run on seed 40 after a resize and on nothing else, so the -partial-repaint family is newer than that and the resize one is older than all -of it. +**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: + +```sh +cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees +SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \ + cargo test --release --test shrink -- --ignored --nocapture +IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \ + cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees +``` + +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. ## Verification -**At `08c9d5a`**, the current head: +**At `e166e00`**, the current head: - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- - -D warnings`, `cargo test --workspace`: sixteen targets green, 80 suite + -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 81 s: seed 288 on - `region-node` fails and nothing else does. -- Depth-6 oracle seeds 18, 190 and 326 run one at a time: 18 and 190 pass, - 326 fails. -- `view`, `minimal`, `text`, `random` and the `tabs` touch replay render - byte-identical at 1920x1200 against `98d4e98`. `tabs` cold and - `tabs`-before-the-gesture differ on 4,664 of 2,304,000 pixels; see - "Performance" for why, and take the oracle rather than `tabs` as the check - from here. +- 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 145 s. The + last two pass for the first time. +- `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. + 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: @@ -206,6 +219,10 @@ against `394d514`. The measurements are under "Performance". `8220a78`. `tabs` differs only in the widget count it prints 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 @@ -487,6 +504,20 @@ across it. 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. +- **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.** 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 @@ -511,6 +542,21 @@ across it. - 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 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`. @@ -601,6 +647,34 @@ resolved alignment, so a local redraw asks the question its parent asked, and the widget's own, which is what a change is compared against -- an override means the answer is the parent's to give again. +### 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))` is a + 100-tall bar across two shares where `main` draws a 100x100 square centred + in them. **Open, and an API question rather than a bug**: one widget + carries one length per axis, so "a small thing centred in a big box" now + needs a container to hold the big box -- `(thing.sized(..).center(),) + .stack().width(leftover(2))` is the idiom, and it is what the old builders + were inserting silently. The alternatives are to update the example, or to + give a widget an outer length as well as its own. Awaiting Bryan. + +`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 @@ -684,22 +758,24 @@ pixels and `Holds`. Decided with Bryan on 2026-09-15. 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 steps either side. One step either - side is the derivation for a single rounding on each route, and it is - measurably too narrow: each route is a *chain* of multiplies, and a - generated tree wanted 2 steps where the derivation allowed 1. + measured against the window -- get three half steps either side. That is + the floor: two fires the `Holds` assertion on eleven generated cases. + Four was tried and was overshoot, and tightening it moved none of the + rig's twenty-five work counters, so slack here buys no reuse (`38eba54`). - **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`). - Too wide is the unsound direction: it admits reusing a drawing where it - does not hold. Too narrow costs a redraw and fires the `Holds` assertion in - a debug build, which is how both of these were found -- **run the generated - cases in debug before trusting a release measurement of a rounding - change**, since `debug_assert` is what says the contract broke and the - release oracle passed both of these anyway. + **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. - 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.