Record four fixes that came out of one look at the tabs render

Iris #18 is at `e166e00`, and warm layout now agrees with cold on every rig
the repository has: all fifteen shrinker cases at 400 seeds of depth 5, and
the oracle at 1000 seeds of depth 6. Both pass for the first time. The
handoff's two standing leads, shrinker seed 288 and oracle seed 326, were one
defect and are gone.

What found them was asking why the `tabs` render differed, rather than
counting the pixels that differed:

- `d8ae9c3`: `redraw`'s second ask handed the final box over as if it were an
  offer, so a locally redrawn widget with its own alignment was placed twice.
- `2bc6bdf`: `Stack` and `Pad` overrode every child's alignment with the near
  edge, which is owed only where the box really is the child's own answer.
  `tabs`'s counters and `text`'s narrow panel match canonical `main` again.
- `38eba54`: `Holds::through`'s allowance was four half steps where three is
  the floor, and tightening it moved none of the rig's work counters. Why it
  cannot go to zero is recorded with it.
- `e166e00`: pins what the gap question was actually about -- a length in
  pixels is that many pixels at any nesting depth, and a share is one or two
  steps off, which is a trade for the row filling exactly.

Two corrections to this document. The bisect it reported for the
partial-repaint defect named a commit that cannot have caused it, and the
reasoning built on that was wrong. And its claim that `tabs` "differs only in
the widget count it prints about itself" was checked at `8220a78` and left to
cover `d3b0ebf`, which changed the render twice more.

One API question is open: `.width()` now overwrites what `.sized()` set,
since both are properties of one widget, so `tabs`'s centred square is a bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 18:18:26 -04:00
1 parent a271795408
commit 82c006cd59
1 file changed
+122 -46
+122 -46
View File
@@ -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.