Bring the handoff to what the last session shipped

`OrthoSize` was deleted in `9d8415d` and the handoff still described it as
restored; the surviving rule is `Painter::ruled`, and the circularity lesson
is restated without the enum's names. `HOLDS_EPSILON_PX` went with
`39e4ca2`, the clipping `debug_assert` the alignment section asked for landed
as `95fb4f9` and is narrower than it asked, the glyph conversion queued under
"Next" landed as `11c55bc`, and `LAYOUT.md`'s stale sections are §4, §5 and
density rather than §2.

Also records why the grid costs 1.5x what floats did, from the disassembly
rather than from theory: `RegionRemap::apply_span` is 352 instructions with
two 64-bit `idiv`s in it, because a fixed-point rounding is code where a
float's is hardware, integer division has no vector form, and saturation
breaks the pairing the `f32` `Vec2` had.

And the finding that answers the queued clamp item's open half: a `Max` may
not take a `leftover`, because a cap must read the report a rule otherwise
makes moot, and a share puts the division into the same equation -- the
multiple-fixed-point failure seed 13 already punished once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 13:21:37 -04:00
1 parent f83be016ba
commit 388a6a060d
1 file changed
+98 -37
+98 -37
View File
@@ -25,13 +25,17 @@ 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.
`f437495` restores explicit `Span::ortho(OrthoSize::{Children, Full})` sizing.
`Children` remains the default and preserves the old conservative behavior:
the largest fixed orthogonal length is reported, while any relative or
`leftover` child makes the span report `leftover`. `Full` reports exactly
`Len::rel(1.0)`. It does not read the children's orthogonal sizes, but their
`Holds` ranges still propagate through final-box drawing, so a resize
repositions them without redrawing when their own contracts permit it.
`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.
`71c9c39` replaces the public `Painter::place` distinction with an opt-in
widget property. `.region_node()` gives a widget one independently movable
@@ -43,11 +47,13 @@ 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 change `Children` to select the pixel-longest arbitrary `Len` at the
span's current width. A fixed child and a relative child can create multiple
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.
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:
@@ -87,7 +93,8 @@ pin those rules. Seeds 10 and 86 are now in the ordinary generated set.
point; re-run that before quoting it again.
- `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
1920x1200 across the whole fixed-point sequence.
- `tests/drift.rs` passed its 20,000-move exactness check in release mode.
- `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`.
@@ -140,13 +147,39 @@ child ends up on, which puts the scroll phase's counters back exactly where
they were -- 4 widget draws, 12 draw requests.
What is left is per-operation cost, not more work: the `many` phase does 125
widget draws against 121 before, and takes 1.5x as long. The arithmetic is
the difference -- an `i64` multiply and a rounding branch where there was an
`f32` multiply, and an `i64` division in the remap. Three were taken out
after measuring (`11c55bc`, `4f5e27c`, and halving the divisions in
`Holds::through`); `RegionRemap::apply_span` is still a fifth of the phase,
and the rest is spread thin. Measure with `perf stat -e instructions:u`
rather than the clock, which varies 2x here.
widget draws against 121 before, and takes 1.5x as long. Three roundings and
divisions were taken out after measuring (`11c55bc`, `4f5e27c`, and halving
the divisions in `Holds::through`); `RegionRemap::apply_span` is still a
fifth of the phase, and the rest is spread thin. Measure with `perf stat -e
instructions:u` rather than the clock, which varies 2x here.
**Why the grid is slower than floats, from the disassembly at `4f5e27c`.**
`RegionRemap::apply_span` -- two scalars, every branch -- is 352
instructions, of which two are 64-bit `idiv`, six `imul`, and about sixty
are jumps and `cmov`s over twenty-eight compares. The same arithmetic in
`f32` is a couple of dozen SSE instructions. The difference is in three
places, and none of them is the grid being a worse idea:
- **A float's rounding is free and a fixed-point one is code.** `Fixed::mul`
widens to `i64`, multiplies, adds a half step, shifts back, and saturates
into `i32` -- roughly eight instructions and a sign branch where `mulss`
is one instruction with the rounding in hardware.
- **Integer division is the slowest instruction on the core**, is not
pipelined, and has no vector form; `divss` is pipelined and vectorises.
`div_round` also needs the remainder and its sign. This is why removing
divisions is where the wins have been, and why the remaining ones are
worth structural effort rather than micro-optimisation.
- **Saturation costs what floats get from infinities**, and it breaks the
pairing. Adding four `i32` lanes with saturation is a dozen SSE ops
(`pcmpgtd`/`paddd`/`pandn`/`psrad`/`pxor`/`por`) against one `addps`; and
the widening multiply cannot stay in a vector register at all, since there
is no packed 64-bit `imul` here, so lanes that an `f32` `Vec2` did at once
serialise.
"Fixed point is faster" is a rule from machines without an FPU. On this one
the grid buys exactness, and exactness is what the warm-against-cold oracle
demands -- so the cost is the price of the property, not a defect to chase
back to 1.0x.
One thing tried and reverted, recorded so it is not tried again: short-
circuiting `apply_scalar` where the fraction is nought or one. Those are not
@@ -237,11 +270,11 @@ it.
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/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/drift.rs` pins that it stays gone.
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
@@ -289,7 +322,7 @@ 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/drift.rs` pins as exact. That deletes
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.
@@ -329,8 +362,14 @@ 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.
A `debug_assert` that a reported non-leftover size does not exceed the box it
drew in would have caught both immediately, and is still worth adding.
`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
@@ -395,10 +434,14 @@ 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 is 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. What it gives up is a share of under a
twentieth of a pixel. `tests/unsettled.rs`'s
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
@@ -478,8 +521,10 @@ instruction totals.
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. `docs/LAYOUT.md` §2 is stale: it still describes
`Painter::place`, which `71c9c39` replaced.
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
@@ -490,11 +535,6 @@ the same `Holds` contract and box chain.
Queued from this work, in order:
- `Painter::glyphs` converts four `f32`s to `Px` per glyph, every frame that
draws it. A `GlyphEntry` holding `Px` would convert once, when the glyph is
rasterised. It is the largest single thing left in the layout profile after
`InstanceList::push`.
- `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`
@@ -507,6 +547,27 @@ Queued from this work, in order:
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 `UiScalar`'s
shape (`core/src/orientation/pos.rs`) -- either that type or a `Len`
without `leftover`. Awaiting Bryan: whether to split the type, and whether
a `Max` narrows the box the child draws in or only what the parent reports
for it.
Other queued work, in dependency order:
- `UiRenderState` behind `Rc<RefCell<_>>`.