The oracle and the shrinker had separate generators and separate copies of every scenario, so a failure one found could not be handed to the other: a seed cannot be made smaller. Iris `98d4e98` splits growing a tree into describing one and building it, and puts the cases in one place both rigs run. Any failing seed now reduces with one command, which is written down here. What it found first: marking one widget for redraw moves a span inside a scroll 24px against a cold tree of the same state -- a whole child's height, not a rounding. Five widgets, bisected to `95fb4f9`, where `Masked` began reporting its box rather than its inner's size. Written out and ignored in `tests/cases/unsettled.rs` until it is fixed. Two claims in the verification list were withdrawn against a re-run rather than an argument. The shrinker's "all five cases at 300 seeds of depth 5" does not carry to a rig that runs fifteen cases over different trees and fails at 400. The 1000-seed depth-6 line does not hold at `5ed9e87` either, which fails it on seed 40 after a resize -- so that run was never the clean baseline it was quoted as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
44 KiB
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 local
head is 98d4e98, seventy-two commits. 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.
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.
Scrollmust 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. ItsHoldsrange must use the same exact divided boundary as drawing; a tolerant endpoint retained zero-height children at the boundary in generated seed 16.5ed9e87moved that boundary rather than softening it, and39e4ca2removed 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.
A warm frame moves a scrolled span, and the rigs that found it
The defect. Marking one widget for redraw moves a span inside a scroll by
24px against a cold tree of the same state. Five widgets, in
tests/cases/unsettled.rs as
redrawing_one_widget_does_not_move_what_scrolls_around_it, ignored until
it is fixed so the suite stays honest rather than red:
Scroll(Y) { Span(DOWN) { Scroll(X){ text } as a region node, rect 87x24 } }
git bisect names 95fb4f9, which made Masked report Size::LEFTOVER
rather than its inner's size. Scroll clips through a Masked, so what the
outer scroll is told its content measures now depends on whether the inner
subtree was redrawn this frame. The reasoning in that commit is sound -- a
widget that clipped its contents cannot honestly ask for more than its box --
so the fix is not to revert it but to stop the outer scroll's content
measurement from going through the clipped report. Not attempted yet.
24px is exactly the sized child's height, so this is a whole child's worth of layout rather than a rounding.
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.
Unreduced leads, from the shrinker at 400 seeds of depth 5 -- a level it
had never run, since it used to grow its own trees and know five cases:
seeds 2 and 288 on region-node, and 174 and 175 on repaint-some.
The repaint-some pair is probably the defect above. The region-node pair
is not obviously the same thing and is the next one to reduce.
Depth is what finds these. Nothing fails at 100 seeds of depth 4, which is
all the oracle has ever routinely run. Seeds 18, 190 and 326 fail at depth 6
of 1000. Before fixed point, 5ed9e87 fails that same run on seed 40 after a
resize and on nothing else, so the partial-repaint family is new since then
and the resize one is older than all of it.
Verification at 394d514
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 --checkcargo build --workspace --all-featurescargo clippy --workspace --all-targets --all-features -- -D warningscargo 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 to98d4e98, 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,5ed9e87fails it too, on seed 40 after a resize. tabs,view,minimal,textandrandomrender 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.rspassed its 20,000-move exactness check in release mode.- The seeded
randomexample, live-resized from 1920x1200 to 1280x800, is byte-identical to a cold 1280x800 render. Both PNGs hash to1d397c57b9914a2e596fa907029bc6aab4629e4d74deb0715e81325c703bcdb3. The run used the Venus adapter backed by the host RX 7900 XT. minimal,textandviewrender byte-identical at 1920x1200 across8220a78.tabsdiffers 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.- At preceding head
29c7881, reference renders against/home/bob/repos/iris-main-cmpatca2b4b2covered:tabs,view,minimal, andtextat 1920x1200;tabscold 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 | |
|---|---|---|---|---|
5ed9e87, before fixed point |
100 | 4,272 | 1,763M | 693M |
4cbb242 |
97 | 3,951 | 2,093M | ~819M |
4febabf, wrapping |
97 | 3,951 | 1,912M | 771M |
The two ends were re-measured 2026-09-16 as medians of nine runs of two
freshly built binaries and are the numbers above; 4cbb242's row is still
the single reading it was taken from. Raw, the head is 1.085x the float head
on instructions and 1.113x on cycles. Normalised by primitive writes it is
1.17x and 1.20x; per drawn widget 1.12x. Take none of those for the grid's
cost. The two heads do different work rather than the same work at
different speeds -- 4febabf remaps 53 subtrees a frame and redraws 27
where 5ed9e87 remaps 38 and redraws 43 -- and 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 |
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. None of them is shippable
-- truncating breaks the one rounding the grid is for -- but that is where
to push, and 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. Take medians of nine or more, drop anything off by a factor, 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:
cb1bba4works a move out once per subtree rather than per scalar: -3.8% instructions, -0.8% cycles. The gap between those two is the finding.1940e85translates a whole region at once, since that is what a translation is: -4% instructions, -3.4% cycles.d75a1e2and4cbb242skip multiplies that cannot change anything -- composing through a full box, andlerpwhere the ends are equal: together -2.7% instructions, -1.8% cycles when they landed. That no longer reproduces at4febabf: 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
idivdid there. That wasapply_scalar's division; the one inHolds::throughis 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 keepswithinout of line, not its branch, so making it branchless makes it bigger and loses twice. -
Removing the per-child hash lookup in
remap_subtreewithmem::take: 0.0%.FxHashMapon a hot line is not the cost. -
Short-circuiting
apply_scalarwhere the fraction is nought or one: +17%. (From the previous session.) -
#[inline]onUiSpan::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.
One posture question is still open, with its number. shift_round
rounds halves away from zero so a value and its negation round to the same
distance, which is a sign branch on every multiply. Rounding halves up
instead measured -1.6% instructions, -2% cycles (1,888M, ~757M), and
would let a flipped span land a step from its mirror image at exact halves.
Not applied; Bryan's call.
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_ataddresses 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:
ScrollandMaskedboth reportLEFTOVER, and adebug_assertholds 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.Maskedis the only one that sets a mask;Scrollreports its box because a drawing placed in a longer box is scaled by recomposition, not because it masks. So aLazySpanis unaffected: it clips by not drawing, and may report its whole content length, which is what theScrollabove it measures. One that set its own mask would have to report its box and hand the content length up another way. -
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.
OrthoSizewas that second case written twice and is gone (9d8415d);Painter::ruledis 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
UiRegionin its parent node's coordinates.UiRegion::FULLis the identity. Widgets opt in with.region_node()orWidgets::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::regionis its box in its parent node. A region-node widget draws inFULL; 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_noderedraws 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; rawScroll::newdoes not, andScrollnever reasserts it while drawing. -
The first box a parent asks about is the offer. A later box chosen from the child's answer is the final box, not another independent answer. Dirty widgets are re-asked at the offer and only then drawn in the final box. An offer composes through its ancestors' offers, not through their current placed boxes.
-
An answer is reusable only where both its measurement and the drawing made in its final placed box remain valid. The final drawing's
Holdsinterval is translated back into lengths of the offered box and intersected with the answer's interval. -
Equal box lengths do not imply equal placement. An ordinary widget whose offered and current boxes differ in position must involve its parent again; a region node can settle itself only when its own alignment, rather than a container override, determines the final box.
-
A retained drawing can be reused only when its
Holdsinterval 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. -
Painterrecords 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_underprevents 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-
leftoverlengths 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/1024px, 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. -
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
TextViewreports that interval throughPainter::holds. -
Span's decision to distributeleftoveris a pixel question. The room to divide islen * fixed - total.px; pureleftoverchildren 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. -
ScrollreportsSize::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:
39e4ca2translates wherefrom.len() == to.len()and scales only where it must, which is what made the shrinker'sresizecase agree exactly. This inverts the float-era rule, and the measurements behind that rule are whytests/cases/drift.rsexists: 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, andtests/cases/drift.rspins 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::regionwith 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 as305.93604, which crossesSpan's leftover/no-leftover boundary -- the one that must be exact -- and draws a child a cold layout leaves undrawn. - Leave
regionalone, andplacedaccumulates without bound, becausetry_reusereturns a clean subtree's size without walking into it: only the top widget'splacedis 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_aligned(child, region, align) is the override: an
Option<RegionAlign> carried in DrawInfo and resolved once in draw_inner,
so the root resolves like anything else. Pad, Stack and Scroll pass the
near edge for children whose size they report. ActiveData keeps both the
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.
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 ani32counting1 / 2^SHIFT. Adding and subtracting are exact; a multiply or a conversion rounds once, back onto the same steps. Two routes to one place that come within half a step land on the same number, so everything downstream compares for equality.Pxis1/1024px,Relis1/2^24of a box,Weightis1/65536of a share.PX_SHIFTandREL_SHIFTare the only statement of the first two, and the shader's copy is prepended from them byrender::module_sourcerather than written again in WGSL.- A weight is not a fraction: a list divides its room by the total of its
weights, so
Weighttrades precision for the range to hold a whole list, andRel::ratioturns 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.MINandMAXstill stand in for an unbounded end, which is safe only because every use compares against them and none adds to them;from_f32is the one operation that clamps. Pxwas1/64first. The residue of a length reached two ways is one rounding, so it scales with the step: at1/64that was 0.016 px, enough to move a box, and at1/1024it is a thousandth of a pixel. Range is +/-2.1M px and conversion tof32is exact to 16,384 px.- What the fuzzers ask for is a step per level of nesting, which is two
for these trees. 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:Scrollwrote a box it had been given back out as its own length in pixels. Centring a part inrel 1lands a step from centring it inpx 900, becausea(x - y)andax - aydo not round alike. Content that fills the viewport unscrolled is handed back as it came, and therepaintandresize-repaintcases became exact.Spanplaced 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 a box centred in a fraction of its parent against the same box centred in its own pixels, one step per level between them. Closing it means alignment resolved in pixels everywhere -- which costs the retained resize path, since it is the fractional form that re-centres a subtree without redrawing it. Not worth it at a thousandth of a pixel.
Holds::throughinvertspx + rel * box, which rounds, so the answer is an interval even for a single length. It maps the half step either side, plus one more for the two ways above; inverting the length alone gives a point that need not contain the box the part was drawn in.- 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.
Vec2stays what the GPU and the platform speak;PxVec2is what layout decides in.
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:
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:
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.
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:
./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:
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:
-
Scrollshould take a direction rather than one axis: vertical, horizontal, or both. ReportingLEFTOVERon both axes is already the right shape for it. -
Restore
max_width/max_heightasSizeRule::{Min, Max, Clamp}.8220a78deletedMaxSizeand its builders, so that is public API owed back. A clamp resolved where the box is decided also fixesMaxSize's reading ofpx_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: itsHoldsrange must be exact and split at the crossover, the way generated seed 16 taught forSpan. 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 bePx.A cap may not contain
leftover(found 2026-09-16, answering whether the parent could resolve one).Exactworks 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 aLen:Lenis 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
Lenis sincea8898aa. Still awaiting Bryan: whether aMaxnarrows the box the child draws in, or only what the parent reports for it.
Other queued work, in dependency order:
UiRenderStatebehindRc<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.