Files
ai-app/docs/HANDOFF.md
T
iris-aiandClaude Opus 5 4e75019b9a Record the pixel path closing, and the mask a redraw inherits
A box in pixels is threaded down the draw now rather than composed back up
the move chain, which closes the offer chain breaking at a region node,
makes `Holds::through` an exact preimage, and retires `redraw`'s third ask.
The section that diagnosed the defect becomes the one that states the rule,
with what the plan got wrong recorded beside it: `px` cannot be stored on
`ActiveData`, because a resize that every `Holds` admits redraws nothing and
would leave it stale everywhere.

`AGREE_STEPS` stays 2, measured rather than assumed: one step passes the
100-seed oracle and fails the 400-seed shrinker on `resize-size` by
0.002 px, so the second step belongs to the resize path re-expressing a part
as a fraction of a box that changed length.

The review of that change found an older defect of its own -- a `Masked`
widget settled on its own panicked, since `redraw` handed it its own mask as
the one it inherited -- and the invariant that closes it is here too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:21:29 -04:00

1237 lines
72 KiB
Markdown

# Handoff
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.
## 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.
**`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.
**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
that part and overflows as before.
`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.
`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.
`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<Len> 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.
`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.
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".
The implementation also fixes three counterexamples found while finishing the
rewrite:
- 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.
- `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.
`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.
## 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
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.
The tree, five widgets, now running in `tests/cases/unsettled.rs` as
`redrawing_one_widget_does_not_move_what_scrolls_around_it`:
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:
```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 `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`:
`<UiSpan>::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".
**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
across it.
## Retained-layout invariants
- **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 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<SHIFT>` 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.
## Rigs and reproduction
Ordinary framework verification:
```sh
cd /home/bob/repos/iris-pr18
cargo fmt --all --check
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 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:
```sh
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
```
`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 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
```sh
IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \
<instrumented binary> --ignored --nocapture \
| grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort
```
and `diff` the two, which is the check that 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:
```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:
```text
0 down 1728 24
80 up 1728 24
400 down 1836 1116
480 up 1836 1116
800 down 1836 1116
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
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
`Aligned` and `MaxSize`, none of which exist.
**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<RefCell<_>>`.
- 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.
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
types and keep app/session concepts out of Iris.