Commit Graph
100 Commits
Author SHA1 Message Date
iris-aiandClaude Opus 5 77ed7a24c0 Say how many widgets a shrunk fixture has, and share what tests repeat
An eighth sweep, over the part no earlier round named: the 6,300 lines of
tests, and once more over the seventh sweep's own commit, which was itself
unreviewed.

Four of the shrunk fuzz fixtures name one widget two or three times. `width`,
`sized` and `align` set a rule on the widget they are given and return its own
id -- only `pad` and `wrapper` make a new one -- so `let sized =
wrapped.width(76).add(..)` and the `let aligned = sized` beside it are three
names for one text. Each name then went into the list of ids the case compares
warm against cold, so a case that says it checks six boxes checks four, and
three doc comments quote that inflated count as the size of the tree the
shrinker reduced to. Measured: `plant` and `plant_fixed` list 6 and hold 4,
`plant_pair` lists 4 and holds 3, `plant_scrolled` lists 8 and holds 7. The
aliases are gone and the counts say what the fixtures build; each rebuilt
fixture was diffed against the old one, and both the widget slots and every
region are identical, for both settings of `swapped`.

`assert_same_regions` sits at the top of `unsettled.rs` and six tests call it.
Seven more spell its body out instead, byte for byte. They call it now, and it
is `#[track_caller]` so the panic names the case.

`tests/gpu/mod.rs` holds the adapter probe and the surface configuration that
`draw_cost` and `chain_cost` had a copy of each -- `config` identical, and the
probe identical but for the feature it asks for. The leak's justification lived
in one file with the other referring to it; it now sits on the thing it is
about. Shared through `#[path]`, the way `scenario/mod.rs` already is.

The mask a widget is clipped by was resolved in three places, two of them a
byte-identical closure. `mask_bounds` takes the slot rather than the widget,
because the third site deliberately reads the slot it saved before the frame:
that a redraw keeps the slot is what it is checking.

`Layered::_revision` was a field nothing reads, incremented to mark the widget
dirty. Two tests in the same file already do that with
`get_dyn_mut`, which is what the underscore was hiding.

`plan.rs` claimed every simplification is strictly smaller, and asserted `<=`.
Measured: 53 of one tree's 101 simplifications keep the widget count, since a
dropped alignment and a simpler leaf both do. The assertion is right and the
claim was not; the comment now gives the argument that does hold.

`generated.rs` said "Seven that have never failed" and "the nine the others
check" of a ten-seed array. The `should_panic` scroll test ended in an
`h.frame()` that cannot run, since `set_root` lays out and is where the panic
comes from. Two `drop(tree)` at the end of their own scope did nothing.

Format, clippy with and without layout-diagnostics, and the suite (131 + 19 +
13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical to
f8aa0c5 across all 34,490 boxes. No library code changed, so the seed scans
have nothing to find. Both GPU rigs were rebuilt and run: chain cost +470% at
depth 64, draw cost ~4.4 us per layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:18:18 -04:00
iris-aiandClaude Opus 5 f8aa0c5cdf Stop a scroll asking a question it has already answered
A seventh sweep, over the parts no earlier round named: the tree generator
and the scenario harness, `Fixed`, the headless rig, and once more over the
commit the sixth sweep left, which was itself unreviewed.

`Scroll`'s content box is `answer_px.max(container_len)`, so a scroll whose
content fits has nothing to scroll through and `update_amt` has already put
`amt` at zero. The test choosing between the viewport and a scrolled span
asked `amt != ZERO || content_len != container_len`, where the first
disjunct can never decide it -- the same defect `b7b8d09` removed from the
line above, one operand over. A `debug_assert` of the implication held
across the whole suite, including every scrolling test.

`Fixed::to_scale` and its private `shift_round` arrived on this branch with
no caller and never got one; the only thing that called either was the test
written for them.

`Len::align` wrote `Len` arithmetic out a component at a time, around an
`at.px` that is always zero, where `Len::scale` and the `Add`/`Sub` beside
it say the whole rule in two lines. `LayoutLen::without_leftover` took
`self` where the `apply_leftover` its own doc calls the opposite reading of
the same value takes `&self`.

`run-headless.sh --resize` changed the output's mode but not `out_w`/`out_h`,
which is the extent `replay-touch` scales a recording against -- so
`--resize` with `--replay` put every sample of the gesture somewhere else
and still finished like a run that worked. Both come from one function now.

The generator's plan/build split stranded a comment: "a row takes the height
it is given" describes the size rule `build` derives from `dir`, and it was
left above the `gap` draw, which is the one line it is not about and which
does consume randomness.

Format, clippy with and without layout-diagnostics, and the suite (131 + 19
+ 13 + 4) are clean. The cold dump over 400 depth-5 trees is byte-identical
to b7b8d09 across all 34,492 boxes, and all three seed scans pass: 400 at
depth 5 in 62.75s, 1,000 at depth 6 in 162.37s, 2,000 at depth 4 in 305.25s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:53:02 -04:00
iris-aiandClaude Opus 5 b7b8d09e40 Write a shared constant once, and stop a scroll placing its own content
Two findings from a sweep over the WGSL prelude and the position widgets,
scoped against upstream/main at ca2b4b2.

`module_source` already builds each shader's preamble from iris_core's own
constants, so the move-chain work's second copy of `MOVE_NONE` and
`CHAIN_LIMIT` -- under "keep in step with iris_core::CHAIN_LIMIT" -- asked a
reader by hand for what the mechanism beside it exists to do. Both are
injected now, with `MASK_NONE` beside them replacing a bare literal, and the
shader declares none of them.

`Scroll`'s `content_len` is never less than its box, so `slack` and the
`anchor` computed from it were always zero whatever the alignment: the
framework centres short content by placing the answer in the whole box, and
the comment credited arithmetic that could not have done it. The same belief
guarded the fits-in-the-box contract with `align == NEG`, so at the default
alignment -- the middle -- every box change redrew the scroll, measured as 1
widget against 0 at TOP_LEFT. `align` now has no reader at all.

`UiSpan::translated` and `UiRegion::translated` are reachable only from each
other and from nothing else.

Format, clippy with and without layout-diagnostics, and the 131-test suite
are clean. The cold dump over 400 depth-5 trees is byte-identical to
1096c31, and all three seed scans pass: 400 at depth 5 in 69.07s, 1,000 at
depth 6 in 169.29s, 2,000 at depth 4 in 300.75s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:09:41 -04:00
iris-ai 1096c3167a Drop the last thing nothing reads
`Painter::text_data` had no caller. It was left in the previous round because
it is the only way a widget inside `draw` can reach `TextData`, and the app's
pending integration might have wanted it; nothing in iris is kept for the
app's sake, since the app is to be largely rewritten against this API rather
than ported call by call (Bryan, 2026-09-20).
2026-09-20 01:30:35 -04:00
iris-ai 445287c95c Ask a value a question through a reference
A method taking `self` can only be called on a value, so anywhere the caller
holds a reference it has to dereference to ask -- which costs the caller
whether or not the type is `Copy` (Bryan, 2026-09-20, correcting the opposite
change made in 7502176).

So every method that answers a question about a value takes `&self`:
`Holds`'s four, `AxisHolds` and `LayoutHolds`'s three each, `LayoutLen`'s
`is_px`, `is_only_leftover`, `declared` and `fills`, and `Size::within_box`.
The two callers passing `LayoutLen::declared` as a function value say the
closure instead.

Builders that return a changed copy, and methods on a handle that is meant to
be given up, still take `self`.
2026-09-20 01:30:29 -04:00
iris-ai 750217631d Drop three things nothing reads, and say what a span makes scalable
`Axis::pair` and `RegionAlign::NEAR` arrived on this branch with no caller
and never got one. `Holds::contains` took `&self` where its five siblings on
the same `Copy` pair of pixels take `self`.

The comment beside a span's cross-axis accumulator said a scalable child
"makes Children scalable too"; `Children` names nothing here, and what it
makes scalable is the span.
2026-09-20 01:02:25 -04:00
iris-ai 9b4cc329ce Say the window when the window is why a drawing was refused
`AxisHolds` is four contracts, and `diag::outside` counted three: a refusal
because this window is outside the range the drawing was made for bumped
"reuse outside: a rel base". The two are different questions -- a window
range is pixels, a rel base pin is a window-unit length that an unchanged
window can still change -- so the rig answered "why did that redraw?" with
the wrong one for every resize.
2026-09-20 01:00:13 -04:00
iris-ai 02048eab77 Rebuild a suboptimal swapchain after presenting, not before
`Surface::configure` panics while a texture the surface handed out is still
alive, which wgpu says at both `configure` and `get_current_texture`. The
`Suboptimal` arm configured with the texture it was about to draw with in
hand, so the first suboptimal frame -- a resize or a display change on some
drivers -- takes the app down instead of rebuilding the swapchain.

The texture is good for this frame, so it is drawn with and presented, and
the rebuild happens once `present` has consumed it.
2026-09-20 01:00:05 -04:00
iris-ai d8d51221ee Keep a contract only where it still holds for this widget
`redraw` keeps the narrower of an old and a fresh contract so that widening
and narrowing back do not churn the parent that reads it. The drawing's half
asked whether the old range still covers this window and box before keeping
it; the answer's half did not, so a widget whose answer contract widened in
a frame that also resized the window kept a range the new window is outside.

The parent's next ask then refuses that answer and draws the whole subtree
again -- throwing away the drawing the widget had just made. Cost, not
geometry: the size kept is the size just reported.

`a_contract_this_window_is_outside_is_not_kept` draws the leaf twice before
the change and once after.
2026-09-20 01:00:01 -04:00
iris-aiandClaude Opus 5 781199a7c9 TODO: a prepare stage on Event, for the placeholder in CursorData
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:26:53 -04:00
iris-aiandClaude Opus 5 69ba91588a Run every generated case without the long seed scan
`Case::SizeResize` was in `ALL` and in none of the `case!` invocations, so
a size change followed by a resize -- the order the enum's own comment
argues is not the same test as the other one -- was only ever checked by
the ignored long run. The tests and the list of which cases have one come
from one macro invocation now, and a case missing from it fails a test
rather than going quiet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:20:23 -04:00
iris-aiandClaude Opus 5 8088a1fa59 Say why a reuse was refused, on every path that refuses one
Three of the eight rejections in `try_reuse` were invisible or half-visible
to the diagnostics: a changed inherited mask counted nothing and traced
nothing, an undrawn record traced without counting, and a changed
region-node choice counted without tracing. The mask one is the rejection
this branch's repair was about, so "why did that redraw?" was exactly the
question the rig could not answer.

Adding a counter meant editing a variant list and a name list at the same
index, which renames every total after a slip and says nothing. The two
lists are one declaration now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:20:23 -04:00
iris-aiandClaude Opus 5 713e3e747b Judge a kept contract against the box it was asked in
A local redraw keeps the narrower guarantee its parent holds when the new
drawing covers it, so widening and narrowing back do not churn the parent.
It checked that guarantee against `placement`, where the answer put the
drawing, rather than `region`, the box the drawing was made in and the box
both contracts are about. The two differ on every axis a widget reported
less than it was offered, so any such widget escalated to its parent every
time its contract widened -- which is the churn the retention exists to
avoid. `resize` and `try_reuse` both already ask about `region`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:18:18 -04:00
iris-aiandClaude Opus 5 aea0387567 Stop keeping what nothing reads back
`ActiveData::size_deps` was written on every draw, cleared on every undraw,
and read nowhere: a `Vec<WidgetId>` per active widget for a list only the
`Painter`'s own copy is used from, in `draw_at`, before the record is built.
What it looked like it was for -- reaching a widget whose size was read --
is already done there, by recording whoever asked about a child it did not
draw.

`SizeRule::apply` had no caller and would have been wrong if it found one:
it answers with the rule's own length, where `draw_at` resolves a fraction
against the rel base first. One rule, applied in one place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:18:18 -04:00
iris-ai 1ebd4d3a05 Match a place's own cases instead of asking it five questions
`PlaceSpan` and `RelBase` were private to `place.rs`, so `painter.rs`
reached them through six `pub(crate)` accessors -- `stated_rel_base`,
`narrows_rel_base`, `within_span`, `is_sized`, `does_fill`,
`with_rel_base` -- and `in_parent` re-derived a three-case enum from
five yes/no answers. The two enums are `pub` now and `ui/mod.rs`
re-exports `place` by name rather than by glob, the way it already did
for `painter`, so nothing new leaves the crate and there is no
visibility qualifier to get right. All six accessors are deleted:
`in_parent` matches `(at.span, declared)`, `resolve_rel_base` matches
`(rel_base, span)` and assigns, and the other three read the field.

`!at.is_sized()` was dead. `PlaceSpan::Sized` is built in exactly one
place, `Len::as_desc`, which sets `RelBase::Len(self)` in the same
literal, so `stated_rel_base().is_none()` already excluded it. Deleting
`with_rel_base` removes the only writer that could have separated the
two, so a named length now carries its own base by construction rather
than by habit -- which is what the comment on `RelBase` says.

The four `pub(crate)` methods left in `painter.rs` are inherent methods
on types the crate does export, so hiding the path is not available to
them; they are `pub(super)`, which is the module tree that calls them.

fmt, workspace clippy under `-D warnings` with and without
`layout-diagnostics`, and the workspace tests under both are clean. The
cold dump over 400 depth-5 trees is byte-identical: 34,492 boxes.
2026-09-19 23:51:58 -04:00
iris-ai 7e2b4cd9db Print the box a widget drew in, not the rel base labelled as it
`DrawInfo::px` was the child's rel base in pixels, resolved at all four
construction sites on every draw and read only by three diagnostics --
each of which called it the box: `diag::draw_request`'s `pixel_size`,
printed by `trace_unsettled` as "draw in"; and two `debug_assert`
messages saying "clips to" and "drew in". A rel base and a box differ
wherever a parent hands down part of its own, which is every child of a
span, so all three said something that was not true.

The field is gone and each site reads `region.to_px(window)`, which is
the box it claimed to be printing and costs nothing outside a failing
assert. The trace field is `region_px`. `Placing::window` existed only
to resolve that value and follows it out.

The 23-line counter block inside `try_reuse`'s "outside its range"
branch is `diag::outside`, beside the other diagnostics, so the decision
reads as its six checks.

fmt, workspace clippy under `-D warnings` with and without
`layout-diagnostics`, and the workspace tests under both are clean. The
cold dump over 400 depth-5 trees is byte-identical: 34,492 boxes. The
trace now prints "draw in 189.00x176.00" beside a region 189 by 176.
2026-09-19 23:28:36 -04:00
iris-ai 3da1c71870 Name the values layout carries, and say what a span's slot is
`along` said nothing about what it did. It is `Span::slot` now: the
stretch of the row between two distances from where the span starts
laying out, as a span of its own box, with the mirror for a negative
direction in one place. `far` is `row`, which is what the comment above
it already called it, and `shares` is `has_room` beside the
`any_leftover` it was folded into. `reached` now guards on the leftover
weight it divides by rather than on the numerator that happened to be
zero with it.

The pairs layout returns are named rather than positional: `Answer`
{size, holds} and `Drawn` {answer, drawing_holds} replace
`(Size, LayoutHolds)` and a three-tuple with two `LayoutHolds` in it,
which was the one shape the cold dump exists to catch. `try_reuse`
answers `bool` rather than `Option<()>`, and the four hand-written
copies of `move_idx != parent_move` are `ActiveData::is_region_node`.

`AXES` was declared in three modules; it is `Axis::BOTH`. `rel_min`,
`rel_max` and the unused `select_len` are gone -- `ZERO` and `FULL`
already said those. Three doc comments sat on `impl` blocks instead of
the single method inside them. `reposition` and `redepth` walked their
children by index, looking the parent up again per child; both take the
list and put it back. `Scroll`'s `fixed` and `fixed_len` are
`answer_px` and `answer_is_px`, which says which one is the length.

fmt, workspace clippy under `-D warnings` with and without
`layout-diagnostics`, and the workspace tests are clean. The cold dump
over 400 depth-5 trees is byte-identical to `6c84b6f`: 34,492 boxes,
no seed moved.
2026-09-19 23:24:55 -04:00
iris-aiandClaude Opus 5 6c84b6f2cb Read a slot's ends where they are used, not carry one between children
`start` looked like a third accumulator beside `fixed` and `taken`, carried
across iterations and assigned at three points. It was never independent:
every assignment was `shared(fixed, taken)`, so it was those two read
together. Reading it at each end of a slot instead drops the variable, drops
two of the three calls per child, and leaves the gap added after the last
child deriving nothing -- which was the thing that read as a bug, and is not
one because no end is taken from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:13:52 -04:00
iris-aiandClaude Opus 5 8d2b7a512b Sum a span's cursor by name, and guard a scroll's re-clamp
`cursor` added `px` and `rel` by hand where the placing loop below now says
`fixed += len.without_leftover()` -- the same sum, one of them named. And
`let along = total` shadowed the closure that makes a span along the row,
two meanings for one word in one function; the local said nothing `total`
did not.

A scroll's draw writes `amt` and `snap_end`, so a second draw at another
viewport reads what the first wrote. Warm still matches cold because
re-clamping is idempotent, but nothing said so and nothing checked it: the
seed scans build scrolls and never scroll them. The test scrolls four
distances, one past the end, and widens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:04:39 -04:00
iris-aiandClaude Opus 5 40b89c1f79 Say from_axes, which is what a constructor is called
`per_axis` on `PlaceDesc` and `Declared` builds a pair by asking for each
axis. `from_axis` beside it already names the three-argument form, so the
plural is the one that takes a function, and both follow Rust's convention
for a constructor rather than an invented word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:53:58 -04:00
iris-aiandClaude Opus 5 4d42f1c8ca Say on_axis for the lift, so it is not indexing's word
`PlaceDescAxis::axis(axis)` shared its name with `PlaceDesc`'s extraction,
which is now `Index<Axis>` and reads `place[axis]`. The two go opposite
directions, so they get different words: `on_axis` pairs with the
`from_axis` it is the shorthand for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:35:22 -04:00
iris-aiandClaude Opus 5 2807a925af Make a declared length one that cannot carry a share
`declared_lens` filtered `leftover` out of both its sources and every
consumer then re-dropped it, so the rule lived in two filters and a comment.
A declaration is a `Len`: `LayoutLen::declared` states the rule once and both
sources go through it, and `Declared` replaces the bare two-element array on
`ActiveData` and in four signatures.

The two sources stay one value deliberately. A rule decides the child's box;
a hint only promises what it will report -- but `size_hint` is by contract an
exact answer with no painter context, and `hints_agree` fails a widget that
draws something else, so narrowing the box to a hint cannot change what is
drawn. Every consumer asks about the length, never which said it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:06:31 -04:00
iris-aiandClaude Opus 5 55df32a33c Say layout's operations by name, and index a pair by its axis
Four rounds over the same idea: an expression that needed a comment to say
what it computed wanted to be a named operation.

The placement description is built by chaining off the value that says it.
`UiSpan::within_desc`/`shifted_desc` and `Len::as_desc` replace the
`PlaceDescAxis::` constructors, `PlaceDescAxis::axis` lifts one axis into a
pair with the whole box across it, and `PlaceDesc::per_axis` covers the case
where the two axes differ. `beside` is dropped: `from_axis` already said it.

Seven module-level functions become methods on the value each took first --
`Widgets::declared_lens`, `LayoutLen::fills`, `PlaceDesc::placement` and
`::rel_base_and_region`, `Size::within_box`, `UiRegion::at_origin` and
`::as_translation`.

`UiSpan::place` is the aligned-placement rule, which was written out three
times; `LayoutLen::without_leftover` is the sibling `apply_leftover` never
had, at six sites; `is_px` and `is_only_leftover` name field comparisons the
surrounding comments had to translate; `Holds::covers` was interval
containment spelled out by hand. A span's `shared` loses the two arguments
that did not vary across its loop.

`LayoutHolds` was four two-element arrays where every other pair here is a
struct of two per-axis values, so nothing it did could be written once.
It becomes `AxisHolds` on `x` and `y`, and `and`, `covers` and `contains`
lose their loops.

Every pair gets `Index<Axis>`/`IndexMut<Axis>` through one macro, and the
eighteen `axis`/`axis_mut` methods go. `const_index` keeps the accessors
usable in const context.

Cold layout is unchanged: `layout_dump` over 400 depth-5 trees is identical
to 58ce74d byte for byte, across all 34,492 boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:56:59 -04:00
iris-ai 58ce74dd7d One argument says where a child goes and what its fractions are of
`Place` was a product written as a sum -- a `Part` and a fill flag -- and
`Part` named three operations the geometry already had, under words that did
not match them. `Of` was `UiSpan::within`, `From` was `UiSpan::shift`, and
`Sized` was `placement`'s own body with the length given rather than
reported. Both enums are gone.

`PlaceDescAxis` says one axis, named after the operation it performs:
`within`, `shifted`, `sized`, and `WHOLE`. What is optional is a builder --
`fills` and `rel_base` -- so a caller writes only what it decided, and the
rel base it does not write follows the constructor: a span composed into the
caller's box narrows it, a span along a cursor does not, a decided length is
it. That was the one rule a caller could get wrong with nothing failing.

`PlaceDesc` says both axes with named fields, so `axis`, `axis_mut` and
`from_axis` work the way they do on every other pair here, and the joint
work -- resolving a region, reading the fill flags -- is written once rather
than per axis. `widget_at` and `place_at` take `impl Into<PlaceDesc>`, so a
wrapper passes a `UiRegion` and says nothing else. `widget_within` and
`ActiveData::narrow_rel_base` are deleted; `asked` and `placed` carry the
rel base their ask stated.

Cold layout is byte-identical to `84dad21`.
2026-09-19 17:59:26 -04:00
iris-ai c55be21761 Put place before the rel base, and say what a Place decides
An argument that is usually `None` goes last. So `widget_at` and `place_at`
take the place first, and `narrow_rel_base` after it.

`Place` and `Part` also now say which of the two boxes they decide, since
that is the question a caller has to answer to pick between them: `Part` is
the child's region, said as a part of the caller's own, and `Within`/`Fill`
is what becomes of the placement in it.
2026-09-19 17:16:41 -04:00
iris-ai beb138632a Say narrow_rel_base, and let a container pass None for it
`narrow` said what the argument did to a value it never named, so a reader
had to go and find out which value. It is `narrow_rel_base`, and the
resolved one stays the bare `rel_base` -- which is also the only one in
`Painter`, `Placing` and `LayoutHolds`, where there is nothing to tell it
apart from.

It takes `impl Into<Option<[Option<Len>; 2]>>`, so a container that does not
narrow anything writes `None` rather than `[None; 2]`, and `Span` builds the
one case that does with a `then` instead of a mutable array.

Cold layout is byte-identical to `84dad21`.
2026-09-19 16:55:48 -04:00
iris-ai aeb60e50f5 Say rel base, and give containers back a box to hand over
`frame` named a length, not a rectangle, which was the one word in the
layout vocabulary that lied about its own shape. It is `rel_base`: what a
fraction a widget declares or reports is a fraction of.

Three API changes with it, all for containers that do one simple thing:

- `widget_within(id, region)` returns, taking a box in the widget's own
  coordinates and deriving the child's rel base from it. `Offset` and `Pad`
  are one call each again. `Offset` also stops reading `region_len`, which
  pinned its drawing to a box length it does not care about.
- `place_at` takes the rel base, returns the answer, and asks the child
  where there is no answer to re-express. Which of the two happens is the
  painter's to work out, so `Span`'s second pass is one call and its
  `drawn_across` bookkeeping is gone.
- `Part::All` is a `Part::WHOLE` constant rather than a variant, since it
  was exactly `Of(UiSpan::FULL)` and bought a separate arm in two matches.
  Measured at 0.07% of instructions retired against 0.04% run-to-run noise.

Cold layout is byte-identical to `84dad21` over 400 depth-5 trees.
2026-09-19 16:33:49 -04:00
iris-ai a904cf4f36 TODO: transforms on a move entry, for stretch and rotation 2026-09-19 15:07:51 -04:00
iris-ai 5642f2010a Say region and placement, not extent
The split box was named `region` and `placement` on 2026-09-17; `frame`
came back as a length and survived, `extent` did not. It stayed as the
name for both halves, distinguished only by prose: `draw_at` bound the
caller's `part` to a parameter called `extent`, and `ActiveData` held two
`UiRegion`s that `draw_at` wrote `part: extent` from.

The box a parent asks a widget in is now the region, and where its
drawing ends up is its placement. `Painter`'s four holds accumulators
become the one `LayoutHolds` they were assembled into, which also drops
the name mapping between them.

The cold dump of 400 depth-5 trees is byte-identical across the change.
2026-09-19 14:49:56 -04:00
iris-ai 84dad211f5 Avoid repeated plan generation and unused diagnostics in layout fuzzers 2026-09-19 13:43:11 -04:00
iris-ai add6774980 Keep retained masks and reparented drawings alive, and advance collapsed slots 2026-09-19 13:43:11 -04:00
iris-ai cadfba05dd Keep only what a room drawing is still needed for
A span measuring a child in the room kept its whole `Size`, of which the
along axis is already in `lens` and only the across one is read again when
the drawing is placed. Keep that length alone, which also retires the
rebinding of the match's result and the one in the placing loop. The
placement comment already says what becomes of a drawing made in the room,
so the measuring pass no longer says it a second time.
2026-09-19 02:51:08 -04:00
iris-ai 38b3a81053 Pin a frame by the fraction the child declared
`size_hint` resolves a child's hint against the asking widget's frame and
pins that frame, so a later draw cannot reuse a resolution made against a
different one. It asked the *resolved* hint whether it still had a fraction,
which is false whenever the frame is itself pixels -- a slot of a row, or the
box a stack's sizing child decided -- and the pin was dropped there. Ask the
declared hint, which is what made this draw depend on the frame, and what
`ruled` in `render_state` already asks for a rule.

No generated tree distinguishes the two: the fuzzer grows no `rel` rules, and
a frame that changes almost always changes a box the other pins catch. Kept
for the reason the `frame_len` pin beside it is kept -- "these two
invalidations always coincide" is an assumption nothing states.
2026-09-19 02:51:08 -04:00
iris-ai f6242aa33c Take a span child's length from its hint, and ask it once in its slot
A share child was drawn in the measuring room and again in its slot, and
one record holding two questions made every local change under it defer
to the span. Where a rule or a hint gives the length along the span, the
first ask answers nothing the rule does not, so the child is asked once,
in its slot; the widgets that always report the whole of their box now
say so. A hint with a fraction resolves against the frame and pins it.

The dump rig prints every cold layout so a change to it shows in a diff.
2026-09-19 02:09:41 -04:00
iris-ai a888717ee9 Say window where these comments still say frame
Lengths became lengths of the window when the frame did, and `Part::From`'s
own documentation still described its spans as frame lengths -- which is
what the scroll above read them as.
2026-09-19 01:23:34 -04:00
iris-ai e8a5792dcb Place a scroll's fitting content in the viewport, not in the window
A scroll that has not been scrolled and whose content fits asked for its
content box as `Part::From(UiSpan::FULL)`. A `Part::From` span is in window
lengths, so `rel(1.0)` in one is the whole window rather than the whole box,
and the content landed in a window-tall box anchored at the viewport's
start -- 50 px low for a 300 px viewport in a 400 px window.

Saying the whole of the box as `Part::All` is the one expression that cannot
mean anything else, and it is also the place the child was already asked in,
so the placement becomes a no-op.
2026-09-19 01:23:34 -04:00
iris-aiandClaude Opus 5 a30971e4c5 Call the record's boxes what they are
The offer names are from the protocol before this one, where a widget was
drawn twice and the record had to say which drawing was the question. It
is asked once now, so offer_part is the part it was asked in, offer_place
the place it was asked at, and place where its drawing was put: part,
asked and placed. LayoutHolds::frame is a range on the window since the
frame became a length of one, and the frame's own entry is the frame_len
pin beside it, so it is window; Painter::frame_own goes with it.
answers_at had one caller and said less than the line that replaces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:40:23 -04:00
iris-aiandClaude Opus 5 adbedaf264 Say what the fuzzer's branching widget branched on
Branch reads a measurement in pixels and draws a different subtree either
side of a threshold, and it left that read as a pin on the window, so
every one of them redrew on every resize: at depth 8 that was seed 1's
resize going from 40 widget draws to 131 and seed 13's from nothing to
828. It now states the range it actually branched on, the way Span states
the one that decides whether its shares have room. A fixture that redraws
everything on a resize cannot tell a change that reuses well from one
that does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:24:10 -04:00
iris-aiandClaude Opus 5 23523eea29 Take a window read where a length is resolved against it
A widget that resolves a window length in pixels depends on that window
wherever the length is a fraction of it, and nothing was recording that:
Painter::to_px replaces window_px_len and pins the window it read, while
a length that is only pixels is that many pixels in any window and pins
nothing. Span still states the range it actually branched on, which
replaces the pin with something wider.

Scroll is where it showed: its content's answer is a window length now,
so a viewport whose own box does not change with the window -- 40 px of
a branch's box -- kept an end-snapped offset from the window before.
Seed 942 at depth 6 under resize, pinned as
unsettled::resizing_under_a_short_scroll_snaps_its_window_tall_content_again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:21:11 -04:00
iris-aiandClaude Opus 5 1512d8418b Make the frame a length of the window and the box a region
There is one coordinate unit, the window. Every box in the tree is a
region in window units and a widget's frame is a length in the same
units, which is only what fractions resolve against, so the box need not
be the frame and padding can take from both without either becoming the
other. A region node's entry is a translation -- a rel 1 region anchored
where its box starts -- rather than a box, so nothing composes a frame
back up a chain and a node that moves is one entry write.

Padding is then an inset of both: its pixels come off the frame, so
rel(1.0) under it fills the padded widget rather than overflowing it,
and off the box, so what is drawn sits inside. A length a container
decides for a child's frame is a length of the window like everything
else here -- a row's slot, padding's frame less its pixels, or the box a
stack's sizing child decided, which arrives as Part::Sized -- because a
slot of a row is not a fraction of anything the row can name, the same
reason a node entry is a translation. A declaration is a fraction of
whichever of those reached it, and is the only one that also places the
box.

Frame validity is a pin beside the box's, not a range: a range of window
pixels cannot say which frame an answer is a fraction of, since two
frames are different lengths at the same window size. A widget pins its
frame by reading it or by being answered with it under a fractional
rule, and the pin composes up wherever a length of this frame is what
reached the child.

Also here, because the diagnosis needed them: the shrinker reports the
shrunk tree's own divergence with each level's frame, ask, box and size
warm against cold, and there is a size-resize case -- a change and then a
resize, the order that shows an answer kept as a fraction of the wrong
length, which every other case compares at the window it was made at.

Three defects the reports found, each pinned: a rule changed over two
pads relocated the column under them instead of dividing it again (seed
59, depth 5, resize-size), a share inside padding had the padding taken
off twice, and a root resolved its own rule twice.

fmt and clippy clean with and without layout-diagnostics, 121 suite, 20
core, 11 generated, the 400-seed depth-5 shrinker over all sixteen cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 00:14:46 -04:00
iris-aiandClaude Fable 5.1 0ef87ebfcf Defer a twice-asked widget's local redraw to its parent
A span asks a share child twice in one draw: in the room, whose answer
its slots rest on, and in the decided slot, whose cross-axis answer it
reads. The record keeps only the second question, so a local redraw that
found that answer unchanged never told the row that the first had --
seed 946 at depth 6, where emptying a fixed-height column turns it from
a share into a fixed width as wide as the row. A widget its parent asked
more than once in one draw now defers to that parent, like one whose
declared length changed. Pinned as
unsettled::emptying_a_column_the_row_asked_twice_asks_the_row_again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:29:58 -04:00
iris-aiandClaude Fable 5.1 3091fb86df Ask each child once and place its answer by re-expression
A widget draws in the box it is asked in and its answer is placed inside
that box by re-expressing the drawing; nothing is drawn again in a box an
answer chose. The offer machinery, whose job was to tell a measuring draw
from a placing one, goes with the placing draw. A span measures each child
from its cursor and moves fixed children to their slots with place_at; a
share child is asked once more in its decided slot with its frame narrowed
to it. A stack asks non-sizing children in the box its sizing child
decided. A scroll asks its content once and moves it to the scrolled
offset. A local redraw asks the retained question again and puts the
answer back where the parent placed it.

A symbolic length a child pinned composes through Part::Of exactly where
the part is the whole box less pixels, and pins the parent's own length
otherwise; dropping it let a pad reuse a drawing across a narrowed frame
of the same pixel length (shrinker seeds 60, 248 and 384 at depth 5).

Suite 114/114 including the two decided-box pins, fast oracle 11/11,
shrinker 400 seeds at depth 5 over all fifteen cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:20:57 -04:00
iris-ai 4328eac756 Keep leftover shares inside scroll viewports 2026-09-18 14:11:02 -04:00
iris-ai b842e4f474 Pin decided-box warm/cold failures 2026-09-18 14:09:00 -04:00
iris-ai 49cec82c1b Say which box a pin reaches the parent through, and derive the offer
The review pass over the two commits before it:

- `LayoutHolds`'s comment said a pin does not compose into the parent. It
  does, where the box it pinned is the parent's own box, which is the one
  case where the parent's own length is what was pinned.
- `DrawInfo::offer` was stored beside the two fields it is computed from.
  It is a method now, with the open question written where it is asked
  rather than only in the handoff.
- `Painter::own` is `frame_own` beside `extent_own`, since a widget's own
  box is the extent and the frame is what it is a part of.
- One expression for the length a rule gives a frame (`narrowed_by`) and
  one for what a widget answered (`ActiveData::measured`), each of which
  had two.
- The counter said "the placement it was pinned to" for what is now a
  length; the deferral in `redraw` named the seeds that made it necessary
  before the last commit rather than the ones that do now; three comments
  claimed an inset that does not exist yet.

No behaviour change: 108 suite tests, 20 core, the 11 generated cases, the
same two shrinker seeds failing at 400/5, and `view` and `tabs` byte-identical
to the renders taken before it.
2026-09-18 01:21:03 -04:00
iris-ai 0954770ceb Say what a child gets of a container's box in that box's own lengths
Three things the measurements asked for, all about how much a box that came
from an answer costs.

**A part in the box's own coordinates.** Saying "less eleven pixels at the
end" in frame lengths from the box's start means reading how long the box is,
and a container whose box is its own answer then depends on its own answer:
`Pad` drew sixty-four times in one resize frame at seed 13, chasing its own
width. `Part::Of` says the same thing as a part of the box, which composes
without a length -- pixels are pixels wherever the box lands -- and what a
child under it holds for maps back through that part onto the container's
own box rather than onto the frame.

**One axis of the box at a time.** `extent_len` pinned both axes, so a span
dividing one of them held for one length of the other as well, and a resize
broke every span whose cross-axis answer moved.

**No lazy placement.** Leaving a child's answer to be placed at the end of
the parent's draw, rather than as the child answers, was meant to save a
recomposition. It costs one instead: the drawing is put in the part first
and in the answer's box after, and where it does not hold for both that is
two drawings rather than one. Seed 1 at depth 8 went from 391 widget draws
on a resize to 29 with it gone. The test that pinned three draws for a
numeric leaf in a span goes with it.

Seed 1 at depth 8, widget draws / distinct widgets / update, against #18's
head and against the commit this branch started from:

| phase   | e44dea3      | 34cafb6      | here          |
| ---     | ---          | ---          | ---           |
| cold    | 369/261/10.6 | 463/274/13.3 | 516/288/12.0  |
| repaint | 1            | 1            | 1             |
| many    | 157/95/0.33  | 263/108/0.59 | 187/119/0.52  |
| size    | 16/12/0.018  | 3/3          | 3/3/0.010     |
| scroll  | 2/0.002      | 1            | 1/0.004       |
| resize  | 13/13/0.019  | 22/15/0.032  | 24/76/0.090   |

Seed 13 at depth 8 is where the protocol still costs: `many` 1091 draws
against #18's 524, and `resize` 2215 against a frame #18 does not draw at
all. Both are the same shape -- an answer measured in one box and drawn in
another -- and the handoff says where that comes from.

Checked: fmt, clippy with -D warnings, 108 suite tests, 20 core tests, the
11 generated cases, and the shrinker at 400 trees of depth 5, which fails
seeds 2 (repaint) and 108 (reorder).
2026-09-18 01:06:05 -04:00
iris-ai 1956be3f3d Lay out in a frame that passes through and a box placed in it
A widget is asked in two boxes rather than one. Its frame is what a fraction
it declares or reports is a fraction of, and it passes through a span, a
stack and a scroll unchanged, so `rel(0.5)` is half the same area however
many containers sit between: a frame is narrowed only by what is decided
above the widget -- a declared length, the root. Its extent is where the
drawing goes, given as a `Place` per axis: a part of the parent's own box,
measured in frame lengths from where that box starts, which the child either
fills or has its answer placed inside.

What that buys is that nothing under a container depends on where the
container sits. A container reads `extent_len` for the length it divides and
nothing about the start, so moving it re-places its children by re-adding
that start and draws nobody again; and a fraction is resolved once, against
the frame, rather than once per box it is composed through -- a stack sized
by a child that reports `rel(0.5)` no longer takes half of half.

`Place` replaces `DrawRegion`, `ExtentPlacement`, `widget_within`,
`measure_len`, `region()`, `placement()` and `box_of`. Primitives and masks
are written in the widget's own box's coordinates alone, so the drawing has
one reference rather than two. The placement pin goes with them: reading the
extent's length pins that length symbolically, and pins compose only where a
child's box is its parent's own.

Placing an answer waits for the end of the parent's draw or for the next ask
of that child in it, so a span child is one drawing and one move rather than
two moves.

`Pad` is transparent: its padding goes around what it pads and its child
keeps the outer frame, which is where `Outset` was going anyway. A fraction
under a pad is now a fraction of the frame rather than of the inset box.

Checked: fmt, clippy with -D warnings, 109 suite tests and 20 core tests in
debug, the 11 generated cases, and the shrinker at 400 trees of depth 5 over
all fifteen cases -- which still finds seed 108 under `reorder`, where a
wrapping text measured in one box and drawn in another settles differently
warm than cold. `redraw` therefore keeps the baseline's deferral for a box
that is not as long as the one the widget was measured in; the plan's step
6 is not done, and the next commit message or the handoff says why.
2026-09-18 00:40:59 -04:00
iris-aiandClaude Opus 5 34cafb6edc Read the marks rather than the queue to decide the walk is done
Review of the two commits above. The queue was the walk's only record of
what was left, so a mark that reached `needs_redraw` without going through
`mark` -- an `on_undraw` handler is the reachable one -- would have waited
for the next frame. The set is read again once the queue drains, which is
what the scan it replaced did for free. `pop_last` takes the deepest entry
in one step rather than reading and then removing it.

The rest is comments: nine lines shorter, and the arm that takes an
ordinary ask said only what it does for a declared length.

Unchanged by all of it: 109 suite and 20 core tests, the four fuzzer runs
(100 seeds, 400 trees at depth 5, 1000 at depth 6, 2000 at depth 4), the
five reference renders and the resized `tabs`, and every counter on the
diagnostics rig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:31:29 -04:00
iris-aiandClaude Opus 5 3bf22935ce Take the deepest dirty widget from an ordered queue, not by scanning
The walk found the next widget to settle with `max_by_key` over the whole
`needs_redraw` set, and `depth` is a hash lookup, so a frame did a lookup
per marked widget per pop -- 131 depth reads for nine marks at seed 1
depth 8, 1,314 for 34, and 14,611 for 145. The set is scanned once now and
kept in a `BTreeSet` keyed by depth, and every mark made while the walk
runs goes through `mark`, which puts itself in place. The same three
counts become 57, 160 and 436.

Two things the scan gave for free are paid for explicitly: a widget that
was settled inside an ancestor's draw, or deferred to one, is dropped when
its entry comes up, and an entry whose widget has since changed depth --
a subtree that moved under a new parent -- is re-queued at the depth it now
has. What is drawn does not change: widget draws are identical at every
load measured.

Median frame at seed 1, depth 8: 0.955 -> 0.843 ms with 145 marks, 0.668 ->
0.666 with 34, and seed 13's default load 5.19 -> 4.86 ms. Ties between
equal depths now break by widget id rather than by hash order, which makes
the walk deterministic; nothing in the order within one depth was ever
relied on, since a widget at the same depth as another cannot contain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:16:40 -04:00
iris-aiandClaude Opus 5 e6ba570d07 Give a child a part of the container's extent rather than its raw box
`Pad` and `Stack` read `Painter::placement` to put their children inside
their own drawing, and reading it is what says the drawing holds for that
placement alone. So a pad or a stack anywhere in a row was drawn again --
with its whole subtree -- the moment an earlier sibling changed length,
however little else had moved.

`widget_within` now takes a `DrawRegion`, and `DrawRegion::Extent(part)`
gives the child a part of the extent without reading it. What is retained
is the part rather than the box it resolved to, so moving the extent
re-places the child through the same rule instead of redrawing the parent:
`inherited_children` becomes `extent_children`, carrying `Inherit` for the
wrapper case `Painter::widget` already had and `Within(part)` for the new
one.

The dependency that goes up is a range on the container's extent rather
than on its frame, since only the part's *length* reaches the child and
where the part sits is re-placed. A declared length is unchanged: it is a
length of the frame wherever the box it sits in came from. What still pins
the placement is a report with a fraction in it -- the same fraction of a
different extent is a different length -- and that pin is on the answer,
which `extent_frames_keep_fractional_reports_and_numeric_dependencies_valid`
fails without.

Three tests from the first attempt at this come with it, and the
diagnostics rig now says which of the three contracts refused a reuse,
which is what found the above.

Measured, seed 1 at depth 8, median frame: `many` 0.667 -> 0.613 ms and
`resize` 48 -> 32 us; seed 13's `many` 6.35 -> 5.15 ms. Green: fmt, clippy,
109 suite and 20 core tests, the oracle at 100 seeds, the shrinker at 400
trees of depth 5, 1000 seeds at depth 6, and 2000 seeds at depth 4 over all
fifteen cases. The five reference renders are byte-identical to `0e107f0`
on Venus, as are `tabs` resized to 900x1200 and `random` to 1280x800
against cold renders there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:15:17 -04:00
iris-ai 0e107f0e89 Keep valid layout guarantees when a redraw widens their range 2026-09-17 17:24:53 -04:00
iris-ai f860f716e6 Separate measured-answer dependencies from retained drawing validity 2026-09-17 17:10:55 -04:00
iris-ai c44bd198ee Retain child frames relative to the container extent 2026-09-17 16:40:54 -04:00
iris-ai a7307d95fd Resolve a text draw's glyph origin once 2026-09-17 16:17:47 -04:00
iris-ai 7601aa2a5d Measure container children without intermediate placement 2026-09-17 15:53:24 -04:00
iris-ai c330ecec2b Skip inverse arithmetic for unrestricted layout validity 2026-09-17 15:53:24 -04:00
iris-ai 39f7b08c6c Honor fixed child alignment inside parent-selected slots 2026-09-17 15:53:24 -04:00
iris-ai 2ed5503717 Recompose retained frames exactly and preserve text width validity
Keep each widget's original local frame and replay the same composition
order on reuse. Remove inverse region remapping, including its fixed-frame
fallback that forced otherwise valid subtrees to draw again.

Require exact pixel-region equality in the shared generated oracle. Check
primitive and mask geometry as well as draw reuse when fixed frames resize.
Publish text's retained line-break range, with no upper bound when there
are no soft breaks, and cover widening, explicit newlines, and empty text.

Compared with efb416b, the depth-8 diagnostic rig performs 7-9% fewer widget
evaluations in the affected phases. Uninstrumented release runs use 3.5%
fewer instructions for size changes and 5.0% fewer for resize. Repaint and
scroll use 0.7% and 0.6% more instructions. Container updates remain substantially more expensive than the e44dea3 baseline;
this is still an experimental continuation, not a production replacement.
2026-09-17 15:11:03 -04:00
iris-ai efb416bbc3 Retain frame and extent dependencies independently
Keep the original measurement placement separate from the assigned slot.
Validate frame and extent lengths before reusing an answer or drawing, and
represent hint-only records as having no measured answer.

Retain primitive and mask coordinates with their frame/extent reference.
Forwarded children follow a reused wrapper's placement without rerunning
valid draw bodies. Keep the single Widget::draw API.

Restore the eight failing suite cases from the region/placement prototype,
with regressions for mixed coordinate references, a changed inherited
extent, the sizing-stack fraction, and an undrawn share becoming visible.

This remains experimental: nested container updates do substantially more
work than e44dea3 despite restoring the leaf and wrapper reuse guarantees.
Do not merge it as a performance improvement.
2026-09-17 14:50:09 -04:00
iris-ai 5fcace1bfa WIP: a widget's region stays put and its placement moves in it
The protocol split: `region` is the box a parent gives a widget -- what a
fraction it declares or reports is a fraction of, and the coordinates
every region it writes composes within -- and it is the same box on the
ask that measures and the ask that places. `placement` is what of that
region the drawing takes, chosen by the parent per axis or by the
widget's own answer and alignment.

That is what stops a fraction being resolved twice: the placing ask no
longer hands the widget its own answer as its box, so nothing under it
re-resolves against a box that came from its own report. `reports_of`
and `decided` are gone, folded into the two regions; `box_of` is gone;
`declared_box` becomes `ask_box`, which gives a rule the region's length
and takes the position from the placement.

84 of 92 suite tests pass. Five text and region-node cases still diverge
warm against cold, and three count a second widget draw where a span's
measuring ask and its placing ask give different placements.
2026-09-17 13:53:14 -04:00
iris-ai e44dea34b4 Record which widget is drawing a subtree that changed hands
A subtree can be reused whole under a different parent -- same box, same
layer, same region node, clean -- and nothing in the drawing says it
moved. Two things read who its parent is, and both were wrong after one
of these.

The old parent still listed it as a child, and a parent's next draw
undraws whatever is missing from that list: two spans under one root,
with the root swapping which of them it holds, drew the subtree under
the new span and then erased it when the old one drew. The move is
recorded on both sides where `draw_inner` already writes what the ask
decided, rather than guarded at each reader.

Its depth was also the one it had under the old parent, which is what
the settling walk orders by, so a change made under it afterwards
settled at the wrong point in the frame. `try_reuse` re-walks the
subtree's depths, and only where the top of it moved, which is what
makes that free in the ordinary case.

Two tests: one shape where the subtree's box does not move and the span
it left erases it, one where it changes depth and the change made under
it has to reach the span it moved to. Each fails without one half.
2026-09-17 13:05:15 -04:00
iris-ai a0693acc56 Let a resize settle through the walk, and drop the stale-answer guard
A resize drew the root outside `redraw_updates`, top-down over a tree
with dirty widgets still in it, which is the one entry point
`dirty_size_under` was guarding: since `a92c6ac` settles a frame strictly
bottom-up, no fuzzer could tell whether that guard still did anything
anywhere else. Closing the entry point retires the guard rather than
keeping a check for a hole reasoned rather than measured.

The root is marked instead, and only where the new output falls outside
what its answer holds for. That range is the intersection of everything
under it, so admitting the new output says the whole tree still stands,
and nothing above the root moved -- the window is no entry to rewrite.
Marking it unconditionally would have cost the root its own `Holds`: a
leaf root that scales with its box was drawn again on every resize.

`dirty_size_under` goes at both call sites. `resize` takes `Widgets`
because a mark is what it now leaves behind.
2026-09-17 13:00:32 -04:00
iris-ai 25e456e0b5 Say what the fuzzers can no longer tell about the stale-answer guard
Dropping `dirty_size_under` from it now passes every run there is. It stays
for the one entry the bottom-up ordering does not reach -- `update` draws
the root for a resize before `redraw_updates` runs -- which is a hole
reasoned rather than measured, and the note says which.
2026-09-17 05:12:44 -04:00
iris-ai 53b00c68e9 Find a span's leftover boundary through the inverse it already has
The decision used a rounded division, `total.px.div(fixed)`, where the room
the children get is a floored multiply, so the boundary and the drawing it
guards were two expressions for one length and disagreed at the edge of it.
`room` is that length as a `Len`, `room.to_px` is the multiply, and
`Holds::through` is its exact preimage -- so ask `room` whether anything is
left and hand the answer back through the same expression.

The three branches go with the division. They were the sign of `1 - rel`:
the fixed parts growing slower than the box, faster, or exactly with it, and
`through` reads that sign already. Forty lines become twelve, one `div`
leaves layout, and the boundary is the drawing's own.

Green on the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000
seeds of depth 6 and 120 in debug, and 2000 seeds at depth 4 over all
fifteen cases. `tabs`, `view`, `minimal` and `random` byte-identical.
2026-09-17 05:02:45 -04:00
iris-ai a92c6acdbf Settle a frame strictly bottom-up rather than escalating into a parent
The queue was already deepest-first, but a widget that could not settle
where it was called `redraw` on its parent from inside itself. That drew a
shallow widget while dirty widgets deeper in other subtrees were still
pending, and a parent drawing over a subtree that has not settled reads
answers about to move: the one that settles does so inside the parent's
draw, where its mark comes off and nothing compares what it now answers.
Seed 564 was exactly that, and it is the second time this shape has been
found.

So a widget that cannot settle defers instead. It marks its parent, stays
marked itself, and waits in `deferred` until the walk down the depths
reaches the parent -- which cannot be before everything deeper has settled,
because the walk always takes the deepest widget that is not waiting. The
category stops being something to check for. (Bryan, 2026-09-17.)

`dirty_size_under` stays in `draw_inner` for now: `update` draws the root
for a resize before `redraw_updates` runs at all, so the ordering does not
cover that entry.

Green on the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000
seeds of depth 6, and 2000 seeds at depth 4 over all fifteen cases. Drawn
widgets, widget draws and primitive writes are unchanged on every rig phase;
`many` pays 51 queue pops for 27 and 1059 depth reads for 410, which is the
deferring and nothing else.
2026-09-17 04:29:41 -04:00
iris-ai c8beca5753 Give the text example's aligned labels the width to align in
All three sat in the middle of a box the width of the widest of them, so
left, centred and right were the same picture. `text_align` puts the glyphs
somewhere in the box the text is given, and a text that reports the width of
its own glyphs is given exactly that -- there is nowhere for it to sit.
Declaring `rel(1.0)` on each hands it the row instead. (Bryan, 2026-09-17.)
2026-09-17 03:21:17 -04:00
iris-ai 4bd8607968 Report the step at or above a text's longest line
A wrapping text reported the width it used rounded to the nearest step,
which is under the line it measured half the time. A parent that sizes
itself from that report then hands the text back a box its own longest line
does not fit in, and breaking there is a different break -- one line more.

Two tolerances were hiding it and both go. `TextBuffer::shape` answered a
width up to 0.05 px under the longest line from the break in hand, which is
a structural decision taken on a hair's breadth: it kept a warm tree
self-consistent while a cold tree at the same width broke differently, and
0.05 px is fifty steps of the grid. The `Holds` range the text declares
started at the nearest step to its longest line for the same reason, so it
admitted boxes the line does not fit in. Both are the line itself now,
exactly, because the report no longer lands under it.

Found by seeds 1121 and 1839 at depth 4, which fail on `ea6dbae` and every
commit before it: a defect older than anything on this branch, reached by
running 2000 seeds at a depth the long runs do not use. Shrunk to the eight
widgets `a_text_is_given_back_a_box_the_line_it_measured_fits_in` builds.
2000 seeds at depth 4 over all fifteen cases are clean now, as are the
three long runs.

`text` is the one reference render that moves: its lower paragraph shifts a
pixel, the box being a step wider and its left edge crossing a snap
boundary. Same words, same lines, same breaks; `tabs`, `view`, `minimal`
and `random` are byte-identical.
2026-09-17 03:18:53 -04:00
iris-ai ffd79f32d3 Read a child's report as a fraction of the containing widget
`rel(0.5)` is half the span whatever else is in it and wherever the child
sits among them (Bryan, 2026-09-17). It was half of what the span had left
at the point it asked, because a report came back composed through the box
it was offered and a span offers each child the room from its cursor -- so
a nested span taking half of what it was given took a quarter of a row
whose first half was already spoken for, where the same half written as a
rule on the child took half the row.

The offer stays the remainder: a text has to wrap at the width actually
there, and `a_text_in_a_span_wraps_at_the_room_left_rather_than_the_whole_row`
pins that. What separates from it is the base a report's fractions are of,
which the ask now carries. It is the box the child was given wherever that
box is the child's whole area -- a pad's inset, a stack child, a scroll's
content -- and a span passes its own extent along the row.

`widget_decided` becomes `widget_at`, which says both things about an ask
rather than one of them; `widget_within` is still the sugar for neither.

Two spans asking for half each now take the whole row between them and a
third overflows, which the rewritten
`a_span_reads_a_child_report_as_a_fraction_of_the_row` states outright.
The five reference renders are byte-identical at 1920x1200 and `random`
live-resized still matches a cold render, so nothing that exists reports a
fraction to a span today.
2026-09-17 02:56:51 -04:00
iris-ai 0e0d4af326 Refuse a retained answer while something the widget measured is dirty
`draw_inner` took an answer from `try_reuse`, which checks only whether the
widget itself is marked, where `retained_answer` beside it also refused one
while anything the widget read a size from was dirty. A widget whose drawing
happened to be reusable therefore handed back the answer it gave before that
descendant changed.

Nothing puts that right afterwards. The comparison that tells a reader its
child's answer moved is in `redraw`, and a widget settled inside its parent's
own draw never goes through it -- the placing ask redraws the subtree, the
descendant's mark is cleared there, and the parent keeps a number the tree no
longer agrees with. So the check is not the optimization its comment claimed;
it is what makes the answer an answer, and both retained routes are answers,
so it is asked once in `draw_inner` rather than by one of them.

Found by the generated oracle at seed 564, depth 6, `shuffle-every-other`,
while reading a child's report as a fraction of the containing widget: that
reading lets a span overflow itself, which makes the two asks' boxes differ
far enough for the placing one to redraw.

Twenty-five rig work counters are unchanged on `cold`, `repaint`, `scroll`,
`resize` and `size`; `many` makes 18 fewer reuse attempts, 17 of which
already reported "dirty". Both long fuzzers green.
2026-09-17 02:46:25 -04:00
iris-aiandClaude Opus 5 ea6dbae0dc Hand a redrawn widget the mask it inherited, not its own
`ActiveData::mask` is the mask a widget's drawing is clipped to, which is
either one it set itself or the one it inherited. `redraw` passed it back as
the *inherited* mask, so a `Masked` widget settled on its own was handed its
own mask and `set_mask` asserted -- a panic on any local redraw of one, for
as long as there has been a local-redraw path. The two are separate facts, so
`parent_mask` keeps the second.

That also states the question `remap_subtree` was asking. It compared a
widget's mask with the one threaded down from its parent to find out whether
the widget owned it; the comparison is now between the two fields on the
widget, which is the same question asked where the answer lives, and the
parameter goes.

Checked: fmt, clippy, 87 suite tests including the new one, which panics
without this; 18 core unit tests; the release oracle at 100 seeds; the
fifteen shrinker cases at 400 seeds of depth 5; and `tabs` renders
byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:18:39 -04:00
iris-aiandClaude Opus 5 32542d0c0b Thread a box in pixels down the draw, one multiply from its parent's
A box in pixels was composed back up the move chain, on a grid fine enough
that the walk rounded once, while a widget's offer was threaded down through
its ancestors' offers. Two routes to one length, which is what
`Holds::through` allowed for -- and the offer's route broke at a region node.
`offered_region` fell back to `UiRegion::FULL` there, and `redraw` resolved
that against the node's slot entry, which holds the box its parent *placed*
the node in. Under a `Scroll` that is as long as the content rather than the
viewport, so everything below was re-asked at a width its own answer had
produced and the old answer confirmed itself: shrinker seed 220 on `reorder`
left a widget 290px out.

`ActiveData` now 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 -- and
`DrawInfo` carries the pixel lengths, threaded down one `Len::to_px` at a
time: the box its parent gave it, then the part of that box its own answer
placed the drawing in, which `placed_lens` states once for both `placed_box`
and the walk. `Painter::px_size` and `px_len` read that value, and
`UiRenderState::asked_px` takes the same steps back up the parent chain where
a local redraw starts part-way down the tree. Neither chain has a coordinate
frame in it, so neither can break at a region node, and warm and cold reach
every length by the same expression.

Three things follow. `Holds::through` is the exact preimage of
`px + floor(rel * box)` -- two divisions, no allowance, the whole of a box
mapping back to itself. A local redraw asks in the box its parent gave it and
only where that box is as long as the offer, which retires `redraw`'s third
ask and the region-node exception beside it; `draw_inner` places the answer
inside that box itself. And symbolic regions are left to the GPU, hit testing
and remaps, where `Moves::resolve` is the only walk: `wide.rs`,
`Moves::compose`, `Moves::size_of`, `px_of`, `px_region`, `offered_region`
and `slot_wide` are gone, 252 lines of `core/` net.

`px` is deliberately not stored beside those lengths. A resize every widget's
`Holds` admits redraws nothing, so a stored pixel length would be stale on
every widget in the tree with nothing on it to say so, and refreshing it costs
a walk down every reused subtree on the resize path.

Instructions:u, medians of 21 runs, seed 1 at depth 8:

| 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` have all twenty-five work counters identical, so those
two rows say the draw path costs the same threaded as composed. The other
three do less work: `repaint` goes from 23 draw requests and 13 widget draws
a frame 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 unmoved in every phase.

Verified: `view`, `minimal`, `random`, `tabs` and `text` render
byte-identical at 1920x1200 against `5b78002`, as does the `tabs` touch
replay before and after the gesture, and a live resize of `random` to
1280x800 is identical both to the old head's and to a cold render at that
size. The oracle passes 100 seeds in release and 120 in debug -- the debug
run is the one that exercises the `Holds` assertion -- and the fifteen
shrinker cases pass at 400 seeds of depth 5 and 1000 of depth 6. Seed 220 is
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`,
which needs both halves of this to fail: the old chain with the old allowance
passes it, and the old chain with the exact preimage does not.

`AGREE_STEPS` stays 2. One step passes the 100-seed oracle and fails the
400-seed shrinker on `resize-size` by 0.002 px, so what is left there is the
resize path re-expressing a part as a fraction of a box that changed length,
not a length reached two ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:12:56 -04:00
iris-aiandClaude Fable 5.1 5b7800264d Read a child's answer in the asker's frame, and drop the root move entry
A widget reports a fraction of the box it was given. Span added that
fraction straight into a cursor that counts fractions of the row, and Pad
summed its padding onto it, both right only while the offer had the
parent's whole extent -- which a span's does not after a relative child.
DrawResult::size and known_len now compose the answer through the offer's
length, so a container reads lengths of its own box.

That exposed placed_box scaling a fractional answer against a box the
parent had already chosen from it, halving a nested span twice. The
near-edge alignment override becomes per-axis `decided` flags: a box the
parent chose from the answer is the answer, and is not placed again.
Span decides the row axis; Scroll and Stack's sizing child decide both.
Alignment is always the widget's own property now.

The window is no longer a move entry. Chains bottom out in MoveIdx::NONE
and the window is applied where a fraction becomes pixels, in to_px on the
CPU and by the uniform in the shader, which now snaps the summed coordinate
since a floor does not distribute over a sum. A resize rewrites no entry.

Verified: view, minimal, random, tabs and text render byte-identical at
1920x1200 against 5f16617, a live resize to 1280x800 is identical to a
cold render, and the 100-seed oracle, all fifteen shrinker cases at 400
seeds of depth 5, and 1000 seeds of depth 6 pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:00:15 -04:00
iris-aiandClaude Opus 5 5f16617511 Carry the composed box down the draw, rather than walking back up for it
Every widget that reads its box in pixels was making `Moves` compose its
slot's chain again, a mean of 2.8 levels, about eight hundred times a frame.
A draw already descends past every one of those entries on its way in, so
`DrawInfo` carries what the slot composes to and `draw_at` steps it one box
further -- which is a select where it was a walk. `Moves::size_of` and
`compose` are left for `redraw`, which starts mid-tree with nothing above it
in flight.

Measured on the fixed-shape fixture, seed 1 depth 8, 500 frames of `many`,
medians of 25 runs, twenty-five work counters identical throughout:

| | instructions | cycles |
| --- | ---: | ---: |
| `d21a215`, before exact composition | 1,908M | 760M |
| `45a7176`, composing on the fine grid | 1,880M | 755M |
| this | **1,840M** | **735M** |

So exact composition ends up 3.6% fewer instructions and 3.3% fewer cycles
than the rounding-per-level walk it replaced, and the widening it needed was
paid for twice over by not doing the walk.

`Holds::through`'s allowance does not move: two half steps is where shrinker
seed 220 pins it, not where the arithmetic does. `Painter` still composes a
child's region into its own on the grid before asking for it in pixels, which
is the last narrow step in that path; taking it out needs the child's region
as its parent stated it, which `draw_inner` is not handed.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text`, `random` and the tab
replay byte-identical at 1920x1200 against `45a7176`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:49:25 -04:00
iris-aiandClaude Opus 5 45a717695b Compose a box down its chain once, not once a level
Bryan's call, 2026-09-16, for correctness. `Moves` walked the move chain in
`Len`, so every level's four multiplies landed back on the grid before the
next started and the residue grew with the depth of the tree. `WideLen`
carries a length through the walk on a grid twenty-four bits of a box and
twenty-two of a pixel finer, and rounds once at the end.

What it buys, measured rather than argued: `Holds::through`'s allowance for
the two routes to a length drops from three half steps to two, and the whole
of a box now maps back to a range one step wide rather than one step per
level of nesting. One half step further is arithmetically available -- the
`Holds` assertion is quiet there and the whole-box case becomes an exact
identity -- and it is **not taken**, because shrinker seed 220 then lays out
differently warm than cold. Too narrow is meant to cost a redraw and no more;
there it re-breaks a wrapping text, whose reported width moves a `Branch`
onto its other subtree. That is the unsettled-text family, and closing it is
what would let this go lower. The note is in `through`.

`Moves` now answers three questions instead of one, and they are different
questions: `size_of` for how long a box is, which is what reads a box in
pixels; `compose` for where both of its ends are, which is what compares two
boxes; and `resolve`, unchanged, for the `Len` walk the vertex shader does
again in floats. A length composes on its own in two multiplies a level
rather than four, since where the parent sits falls out of the difference --
which is most of why this is not slower.

Measured on the fixed-shape fixture, seed 1 depth 8, 500 frames of `many`,
medians of 25 runs with all twenty-five work counters identical between the
two: 1,880M instructions and 755M cycles against 1,908M and 760M. So it is
free, and a little better on instructions. Three things were tried on the way
and two kept: composing the length alone rather than both ends (-111M
instructions), taking the pixel term's fraction on the ordinary grid so it
stays in an `i64` (-2M instructions, -8M cycles), and skipping a parent that
spans its own box, which **cost** 18M instructions and is not here -- the
same verdict a short-circuit got in `UiSpan::within`.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text` and `random`
byte-identical at 1920x1200 against `d21a215`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:30:45 -04:00
iris-aiandClaude Opus 5 d21a21524f Rename WidgetPtr to Wrapper and give it a builder
Bryan's call, 2026-09-16: a length and an alignment are properties of one
widget, so a widget cannot both be 100 wide and take two shares of a row --
that needs two widgets, and the second one should do as little as possible.
`WidgetPtr` already was that widget: it draws its child in the whole of its
box and reports what the child said. It only lacked a name that says so and
a way to make one around an existing widget.

`Wrapper` rather than `Wrap` so it cannot be read as the text setting, and
`.wrapper()` rather than `.wrapped()` for the same reason. Its child stays
optional, since being a swappable slot is what it was written for and what
the tab bar still uses it as.

`set_ptr` is deleted rather than renamed. It had no caller, and putting a
widget into an existing wrapper is what `Wrapper::set` already does.

`tabs` draws its centred square again: `.sized((100, 100)).center()
.wrapper().width(leftover(2))` is two widgets where the chain without
`.wrapper()` was one, and `.width` was overwriting what `.sized` set. That
was the last of the three ways `tabs` had drifted from canonical `main`
unnoticed; what is left between them is the truncated multiply's antialiased
edges and the widget count itself.

`widget_trait!` takes no attributes, so `.wrapper()` carries an ordinary
comment and the explanation lives on `Wrapper`.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds, and `tabs` rendered at 1920x1200 against `main`'s own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:48:07 -04:00
iris-aiandClaude Opus 5 e166e005dc Pin that a length in pixels is that many pixels
Asked of the `tabs` render: does a gap come out the same number of pixels
wherever it appears? For a length in pixels it does, 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. The test buries a row of five under
three containers that are each a fraction of their parent, so nothing
reaches the window without being composed and rounded, and checks every gap
and every declared width at five box widths. Swept over 2,100 widths when it
was written and exact at every one.

For a share it does not, and the second test pins by how much rather than
pretending otherwise: one or two steps between children that asked for the
same fraction, 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 that
is what costs it. Exact composition would shrink the spread, not remove it:
five equal lengths cannot fill a row whose step count is not a multiple of
five.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:16:18 -04:00
iris-aiandClaude Opus 5 38eba543f6 Tighten a validity range to what the arithmetic needs
`Holds::through`'s allowance for the two routes to a length was four half
steps either side, from a derivation that said each rounding now drops a
whole step where it used to drop half of one. That overshot: three is the
floor, two fires the `Holds` assertion in `draw_at` on eleven generated
cases, and four was never measured as necessary. Tightening both ends did
not move one of the rig's twenty-five work counters, so the extra half step
was not buying any reuse either.

It cannot go to zero. The range has to contain the box a drawing was made
in, which the assertion checks, and it must not contain a box the drawing
does not hold for, which the warm-against-cold oracle checks -- and those
two only coincide where a length reached two ways is the same number. It is
not, yet; composing in `i64` and narrowing once is the queued change that
would make it so, and shrinking this allowance is how to tell whether that
worked.

Checked: fmt, clippy, 81 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text`, `random` and the tab
replay byte-identical at 1920x1200 against `2bc6bdf`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:15:44 -04:00
iris-aiandClaude Opus 5 2bc6bdfc77 Let a child with room to move use its own alignment
`Stack` and `Pad` forced the near edge on every child. That override exists
so a container that reports a child's size and then hands it the box derived
from that report does not place its content twice -- and it is owed only
where the box really is the child's own answer.

`Stack` gives every child the box its sizing child defines. That box is
`box_of(child.size())`, so the sizing child has no room in it and needs the
override; every other child is handed a box that owes nothing to it, and
where it sits in one bigger than itself is its own business. With the
override it could not be aligned at all.

`Pad` reports its inner's size plus the padding, so where its box is that
answer the inset box is exactly the inner and alignment has nowhere to move
it. Where the box is bigger -- a share of a row, a rule over the pad -- the
slack belongs to the inner, and the override pinned it to a corner.

The `tabs` example is the visible case both ways: its counters asked for
`Align::RIGHT` inside a stack and sat at the left, and `text`'s narrow panel
filled a row it had asked to sit at the top of. Both match canonical `main`
again. Neither was noticed when `d3b0ebf` made alignment a property, and the
handoff's claim that `tabs` then "differs only in the widget count it prints
about itself" was wrong -- it was checked at `8220a78` and not re-checked
after the next commit.

Checked: fmt, clippy, 81 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, and all fifteen shrinker cases at
400 seeds of depth 5. `tabs`, `text` and `random` change exactly where a
child now honours its own alignment; `view` and `minimal` are unchanged.

`tabs` is still not `main`'s render: `.sized((100, 100)).center().width(
leftover(2))` on one widget no longer means a square centred in a two-share
box, because one widget carries one length per axis and `.width` overwrites
what `.sized` set. That one is an API question, not a bug, and is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:03:09 -04:00
iris-aiandClaude Opus 5 d8ae9c3bdd Place a locally redrawn widget once, in the box already chosen for it
`redraw` asks a dirty widget at its offer, and then again in the final box
its parent chose from that answer. The second ask handed that box over as if
it were an offer, so `draw_inner` ran `placed_box` on it and applied the
widget's own alignment to a box that had already been placed -- a second
placement on every local redraw of a widget that is not near-aligned. It
only showed where the widget's alignment was its own to apply: a container
override makes `draw_inner` take the box as given, and `Stack`, `Pad` and
`Scroll` override every child they hand a box to.

It is the fix for both of the handoff's standing warm-against-cold failures.
Shrinker seed 288 on `region-node` was an 8.8px inset at each end of a `Text`
under a `Span(Y-)` under two `Stack`s; oracle seed 326 at depth 6 was 88px on
a `Text` under two `Branch`es. Neither reduced below 11 and 43 widgets, and
both are this.

Checked: fmt, clippy, 80 suite tests, 17 core unit tests, the release oracle
at 100 seeds, **all fifteen shrinker cases at 400 seeds of depth 5**, and
**1000 seeds of depth 6** -- the last two for the first time. `tabs`, `text`,
`random` and the tab replay render byte-identical at 1920x1200 against
`08c9d5a`, since nothing about a cold layout changes.

Generated seed 20 at depth 4 catches it and joins the ordinary set, so
`cargo test` fails without this rather than only the ignored long run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:58:07 -04:00
iris-aiandClaude Opus 5 08c9d5aa32 Drop a multiply to the step below rather than rounding it
Bryan's call, 2026-09-16, taken for the cycles: a share now lands a
thousandth of a pixel short of its row instead of on it, which is less than
an even number of pixels draws.

`Fixed::mul` is a widening multiply and a shift, with the sign branch and the
half-step add gone. The two short-circuits priced against the old multiply go
with it: `UiSpan::within`'s test for a span that is the whole of its parent,
and `Fixed::scaled`'s test for nothing scaled by something, which was the
whole of `scaled` -- both cases come out of the truncating multiply unchanged,
and the bodies the comparisons cost were what kept the inliner from taking
`within` at all. `nm` is the check: `<UiSpan>::within` is a symbol in the
rounding head and in neither the float head nor this one.

`Holds::through` inverts the multiply, so its widening is re-derived: each
rounding now drops a whole step where it dropped half of one, which doubles
the allowance for the two routes to a length, and the multiply on the way in
drops only downward, so its own step goes at the top of the range alone. The
derived allowance for one truncation either side is measurably too narrow --
it excludes boxes drawings were made in, in eleven generated cases -- because
each route is a chain of multiplies rather than one.

Measured on the fixed-shape fixture (`Edits::fixed_branches`), seed 1 depth 8,
500 frames of `many`, medians of 25 runs of uninstrumented release binaries
with this VM's garbage `perf` readings dropped:

| | instructions | cycles | IPC |
| --- | ---: | ---: | ---: |
| `5ed9e87`, the float head | 1,761M | 688M | 2.561 |
| `60367d8`, rounding | 1,915M | 777M | 2.465 |
| this | 1,800M | 715M | 2.516 |

-6.0% instructions and -8.0% cycles against `60367d8`, whose twenty-five work
counters are identical to this one's, so that pair is the same work at a
different speed. It leaves +2.2% and +3.9% against the float head, from
+8.7% and +12.9% -- but the float head draws 100 widgets to this one's 97 and
writes 4,272 primitives to 3,951, so that pair is not, and the remainder is
not all arithmetic.

Checked: fmt, clippy, 80 suite tests and 18 core unit tests, the release
oracle at 100 seeds, all fifteen shrinker cases at 400 seeds of depth 5 (seed
288 on `region-node` still failing, unchanged), and depth-6 oracle seeds 18
and 190 passing with 326 still failing. `view`, `minimal`, `text`, `random`
and the tab replay render byte-identical at 1920x1200; `tabs` differs on
4,664 of 2,304,000 pixels, single-pixel-wide runs along 80 columns of one
band of rounded rects, which is an antialiased edge moved less than a pixel.

Three tests say what changed rather than being relaxed: a multiply drops on
both sides of zero, a division cannot put back what it dropped, and an
unevenly nested row's shares stay contiguous and end at its edge with each
edge on the even division or one step below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:03:45 -04:00
iris-aiandClaude Opus 5 60367d806e Do not widen a validity range where nothing rounded
`Holds::through` inverts `px + rel * box`, and allowed three half steps
either side: one for that multiply's rounding and two for the difference
between a length composed down the chain and the same length measured
against the window. The whole of a box has no multiply in it -- `rel` is one
and taking the pixels off again is exact -- so the first half step was being
allowed for a rounding that did not happen, and it compounded: a chain of
widgets each taking the whole of its parent grew the interval half a step a
level. Traced while making the multiply truncate, where the same compounding
moved the interval off the box the drawing was made in and fired the
`Holds` assertion in eleven generated cases.

A range wider than what a drawing holds for is one that admits reusing it
where it does not hold, so this is the unsound direction to be loose in.

Checked: fmt, clippy, 80 suite tests and 16 core unit tests, the release
oracle at 100 seeds, all fifteen shrinker cases at 400 seeds of depth 5
(seed 288 on `region-node` still failing and unchanged by this), and `tabs`,
`view`, `minimal`, `text`, `random` plus the tab replay byte-identical at
1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:46:44 -04:00
iris-aiandClaude Opus 5 aea878d141 Place a locally redrawn widget in its box, not just at its length
A dirty widget is asked again in the box its parent asked it in, and then
again in the box its parent chose from that answer. The second ask was
skipped whenever the two boxes were the same *length*, which is not the same
question: an offer as long as the final box but somewhere else is a different
box. `d3b0ebf` already compared whole boxes for `parent_must_place` and left
this one a length comparison, so the two halves of one decision disagreed.

It shows on a region node, which draws the box it drew in into its own move
entry. A scroll inside a scrolled span is offered the outer scroll's whole
viewport and placed 24px above it, the height of the sized child the outer
scroll snaps to the end of; redrawing only its text left it at the offer and
24px too low. `tests/cases/unsettled.rs` had that five-widget tree ignored as
a known defect and now runs it.

`px_region` names the walk both comparisons and `window_region` were writing
out.

Checked: fmt, clippy, 80 tests, the release oracle at 100 seeds, all fifteen
shrinker cases at 400 seeds of depth 5, and `tabs`, `view`, `minimal`,
`text`, `random` plus the tab replay byte-identical at 1920x1200 against
`98d4e98`. The `many` fixture's twenty-five counters are unchanged.

Fixed with it, from the handoff's unreduced leads: shrinker seeds 174 and 175
on `repaint-some` and seed 2 on `region-node`, and oracle seeds 18 and 190 at
depth 6. Still failing: shrinker seed 288 on `region-node`, and oracle seed
326 at depth 6, which reduces to 43 widgets around two `Branch`es and is not
this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:25:26 -04:00
iris-aiandClaude Opus 5 98d4e98a29 Describe a tree before building it, so a failing seed can be reduced
The oracle grew its trees from a seed and the shrinker grew its own, with
every scenario written out on each side. So a failure the oracle found could
not be handed to the shrinker: there was no tree to pass it, only a seed, and
a seed cannot be made smaller. The shrinker could only grow its own trees and
hope to meet the same shape, which it does not -- 20,000 of its trees never
reproduced what the oracle's seed 18 shows at depth 6.

`iris::random` now answers with a `Plan`: `plan(seed, depth, &edits)` draws one
out of the random stream and `build(rsc, &plan)` makes the widgets, where
`grow` did both at once. Every draw happens in the order it always has, so a
seed still means the tree it meant -- checked by running the oracle at 1000
seeds of depth 6 before and after and getting the same three failures with the
same boxes. `Plan::smaller` reduces one, `Plan::edited` applies an `Edits` to a
tree that already exists, and `tests/scenario/` holds the fifteen cases both
rigs now run over the same trees.

A span keeps the order it holds its children in apart from the children
themselves, so detaching, attaching and reordering leave the widgets made in
the same order and two builds still line up index for index. `Tree::detached`
is gone: `Spanned::spares` is everything made for a span that it does not
hold, which is what both of those were.

`tests/cases/plan.rs` pins the three properties the rest rests on: editing a
plan is growing one with those edits, every simplification is smaller than
what it came from, and reducing ends. The second caught this change's own
defect, where dropping a side of a `Branch` duplicated another and grew the
tree by four widgets.

What it found, on its first run: `SHRINK_SEED=18 SHRINK_DEPTH=6
SHRINK_CASE=repaint-some` reduces 277 widgets to 5. A scroll inside a scroll,
the inner one owning a movable region, and only the text at the bottom marked
for redraw -- and the span lands 24px out, which is exactly the sized child's
height. `git bisect` names `95fb4f9`, where `Masked` began reporting its box
rather than its inner's size, so what the outer scroll is told its content
measures now depends on whether the inner subtree was redrawn this frame.
`tests/cases/unsettled.rs` has it written out, ignored until it is fixed.

Checked: fmt, clippy over all targets with -D warnings, the workspace tests
(79 + 11 + 15, one ignored for the defect above), and the 100-seed oracle over
all fifteen cases at depth 4. The shrinker at 400 seeds of depth 5 now fails,
which it did not before running the oracle's trees and cases: seeds 2 and 288
on region-node and 174 and 175 on repaint-some are unreduced leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:58:31 -04:00
iris-aiandClaude Fable 5.1 4febabfd2e Wrap rather than saturate: nothing draws two million pixels out
A saturating add is five instructions where a wrapping one is one, and
it has no i32 vector form. Measured on the fixed-shape fixture, seed 1,
depth 8, 500 frames of `many`: 2,098M instructions and ~826M cycles down
to 1,918M and ~771M, with `random`, `tabs` and `text` byte-identical at
1920x1200 and the 100-seed oracle passing.

What saturating bought was ordering past the end of the range, where a
layout is already a defect; wrapping makes that defect obvious instead
of plausible. `from_f32` still clamps, since a float has the range to
come from anywhere, and `narrow` stays for `Holds`, whose range past
i32 really does mean unbounded. MIN and MAX remain unbounded ends only
where they are compared and never added to, which is every use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:22:48 -04:00
iris-aiandClaude Opus 5 394d5149a5 Measure a cost on a tree that does not move when layout does
`Branch` picks which of two subtrees to draw by comparing a measured pixel
length with a threshold. That is exactly what the oracle wants -- it is how a
widget believing a measurement a cold start would not have given it becomes a
different tree -- and exactly what a rig measuring cost must not have: the
fixture's shape moves with the thing being measured.

It has been moving. 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 -- so the handoff's "fixed point cost 3x" compared two
different workloads and is withdrawn. Measured on one tree instead, with
`Edits::fixed_branches`, `5ed9e87` is 1,761M instructions and ~699M cycles
against this head's 2,093M and ~819M, while drawing 100 widgets against 97
and writing 4,272 primitives against 3,951. Fixed point costs something like
a fifth to a quarter, not three times.

The oracle keeps measured branches: `fixed_branches` is false by default and
only the rig sets it. A branch consumes its randomness either way, so both
grow the same ids.

**Check the work counters before comparing two commits' times.** The rig
prints drawn widgets, widget draws and primitive writes for this reason;
an undrawn `leftover` child still moves them, which no flag can remove.

Checked: fmt, clippy, 105 tests, the 100-seed generated oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:06:44 -04:00
iris-aiandClaude Opus 5 4cbb242a5d Do not multiply by a part of nothing
`lerp` is `a + (b - a) * f`, and `b - a` is nothing often enough to be worth
asking: a box with the same pixels at both ends of an axis, a span with no
fraction of one, a part of a subtree whose box did not move on that axis.
`Fixed::scaled` is `mul` that answers a zero receiver without widening to
`i64`, rounding and narrowing back, and `lerp` uses it -- so every lerp in
layout gets it rather than the two places that were about to grow their own
comparison.

`many` over 500 frames: 1,705,786,553 instructions to 1,657,571,216, and
638.9M cycles against 657.9M, averaged over four runs each.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds, and
`tabs`, `text`, `random`, `minimal` and `view` byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:01:52 -04:00
iris-aiandClaude Opus 5 d75a1e2129 Do not multiply a box through the whole of its parent
Composing a box within another is four multiplies an axis, and two of the
shapes it is asked for compose to nothing: a part that is the whole box is
the box, and a box composed through the whole of its parent is itself. Both
are exact -- multiplying by one on the grid rounds to what it started as --
so four comparisons answer what four multiplies would have.

`many` over 500 frames: 1,742,553,104 instructions to 1,705,786,553, 2.1%
fewer, and 660M cycles to 658M. The cycles are the honest number and they
say this is worth little here; it is kept because instructions are what a
phone pays for and the check is four comparisons.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds, the
100-seed generated oracle, and `tabs`, `text` and `random` byte-identical at
1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:55:30 -04:00
iris-aiandClaude Opus 5 1940e85c70 Move a whole box at once, since that is what a move does
Profiling a move by cycles rather than by instructions says the cost is not
where the last session recorded it. In `apply_scalar` the `i64` division is
**0.00%** of cycles and the multiply 1.5%: the time is in `saturating_add`,
which is five instructions and no vector form for an `i32`, and a box that
only moved does eight of them. Asking for them one scalar at a time, each
behind a match on which kind of move this is, gives the compiler four short
sequences where it had four adds in a row to pair up.

So a translation is now asked for once for the whole region -- which is what
a translation is -- and the match happens once above it rather than per
scalar. `many` over 500 frames: 684M cycles to 660M, and 1,815,666,327
instructions to 1,742,553,104.

Cycle counts are worth trusting here, which is the other thing to keep: three
runs of one binary varied 0.23%. It is wall time that varies 2x on this
machine, not the counters, and instructions alone cannot see a stall.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds, and
`tabs`, `text` and `random` byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:50:58 -04:00
iris-aiandClaude Opus 5 cb1bba4682 Work a move out once for the subtree, not once for each part
`RegionRemap` re-derived the same things for every scalar of every part of a
moving subtree: the extent it divides by, whether the box only moved, whether
it spans the whole of its parent's, and the two ends of each `lerp`. All of
them are the same for the whole walk, because the walk is one box moving into
one other box. They are worked out once in `RegionRemap::new` now, as an
`AxisRemap` per axis that is either a translation or a scale.

Identical arithmetic in the same order, so the answers are unchanged: 500
frames of the `many` phase went from 1,886,328,855 instructions to
1,815,666,327, 3.8% fewer, and `tabs`, `text` and `random` are byte-identical
at 1920x1200.

Cycles moved 0.8%, which is the finding worth keeping: the surrounding
arithmetic was never the cost. The `i64` division is, and it is still there.

Checked: fmt, clippy, 105 tests, all five shrinker cases at 300 seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:46:23 -04:00
iris-aiandClaude Opus 5 490918b789 Ask whether a rule gives the length, not whether there is one
`Painter::ruled` answered "is there a rule beside me on this axis", which is
the same question as "is my report moot" only while `Exact` is the only rule
there is. `Min`, `Max` and `Clamp` are queued, and under one of those the
answer is still the widget's to give and a span across itself still has to
read its children -- so the name would have been true and the meaning wrong,
which is the worst way for a predicate to age.

It is `has_exact_size` now, over `SizeRule::exact` rather than `known`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:40:51 -04:00
iris-aiandClaude Opus 5 a8898aaa54 Give a length with no share in it its own type again
`UiScalar` was `Len` without the `leftover` weight, which is the separation
canonical `main` already had as `Len` beside `LayoutLen` and this branch
collapsed. It is needed back for the queued clamp: a cap may not contain a
share, because a cap has to read the report a rule otherwise makes moot, and
a share puts the container's division into the same equation -- two
self-consistent assignments, which is the multiple-fixed-point failure
generated seed 13 punished for orthogonal sizing. `min(report, cap)` is not
a `LayoutLen` either: it is a sum of parts, and the smaller of two of them
is not one.

So `UiScalar` is `Len`, what was `Len` is `LayoutLen`, and the two say in
their docs which is which: a `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 a `LayoutLen` is a `Len` plus a claim only a container
dividing its room can answer. `From<Len> for LayoutLen` is the one-way step
between them.

Names only; the shader's `UiScalar` is renamed with them. Checked: fmt,
clippy, 105 tests, and `tabs`, `minimal`, `view`, `text` and `random`
byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:40:15 -04:00
iris-aiandClaude Opus 5 4f5e27cba9 Do not divide by one to remap a box that spans its parent
A retained part is re-expressed as a fraction of its new box by dividing by
the old box's extent, and that extent is one whenever the box spans the whole
of its parent's -- which is the common shape. An integer division is the most
expensive thing in `apply_scalar` and it ran twice per span.

`perf stat -e instructions:u` over 500 frames: `many` 1,938,264,572 to
1,886,265,821, `scroll` 452,517,906 to 444,792,314.

Tried first and reverted: short-circuiting a fraction of nought or one, at
either end of the box. That is not the common case, and the two comparisons
cost 17% more than the divisions they were meant to save.

Checked: fmt, clippy, 105 tests, three shrinker cases at 300 seeds, 100
generated seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:13:51 -04:00
iris-aiandClaude Opus 5 11c55bcef9 Put a glyph's offset on the grid where it is placed
A placed glyph's offset is whole pixels by construction -- a floored pen
position plus the entry's integer bearing -- and `Painter::glyphs` was
converting it, and the entry's width and height, from `f32` on every frame
that drew the glyph. It is a `PxVec2` now, converted once when the text is
placed, and the size is two integer shifts.

Measured with `perf stat -e instructions:u`, since the difference is smaller
than this machine's clock: the `many` phase went from 2,013,099,594
instructions to 1,938,264,572 over 500 frames, 3.7% less. `scroll` and
`repaint` are unchanged to within noise, which is right -- they do not redraw
glyphs.

Checked: fmt, clippy, 105 tests, the reorder fuzzer at 300 seeds, and `tabs`,
`text` and `random` byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:11:07 -04:00
iris-aiandClaude Opus 5 f11f5f4825 Divide twice in a range's inverse, not four times
Which end of the answer each bound comes from is known from the sign of the
fraction before dividing; taking the min and max of four divisions asked the
question twice. A division is the most expensive thing in that function and
it runs per child per axis.

`many` 0.283 ms a frame to 0.278. Small, and strictly less work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:06:46 -04:00
iris-aiandClaude Opus 5 97cc8b32ed Measure a child on the layer it draws on, not twice on two
Fixed point cost 3x in layout: `many` went from 0.179 ms a frame to 0.544,
and `scroll` from 0.011 to 0.030. The counters said why -- eight more "placed
by redrawing" a frame -- and the reason was mine rather than the grid's. A
retained drawing belongs to the layer it was made on, which `4e28f10` started
enforcing, and `Stack` measures the child that sizes it by drawing it on its
own layer and then draws it again on the child layer. So every stacked child
redrew twice a frame, forever.

`Painter::child_layer_at` addresses a child's layer rather than walking to
it, and `Stack` measures on the layer that child ends up on. The second ask
is then a reuse. Its glyphs are written once rather than once under the
background and once over it.

Measured on the same fixture: `scroll` 0.031 ms to 0.020, `many` 0.570 to
0.283, and the scroll phase's counters are back to what they were before
fixed point -- 4 widget draws and 12 draw requests a frame, exactly. What is
left above that baseline is not this.

`ReuseOutcome` could not say "another layer" or "the region-node choice
changed"; both returned without a counter, which is why the first look at
this said nothing. They have counters now.

Checked: fmt, clippy, 105 tests, five shrinker cases at 300 seeds, 100
generated seeds, and the examples byte-identical but for 36 pixels of
`random` at one level -- edges that were being drawn twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:04:31 -04:00
iris-aiandClaude Opus 5 95fb4f962c Hold a clipping widget to its box, and check that it is
`Scroll` reports `LEFTOVER` on both axes because it clips its content to its
box: it can neither take less of one nor honestly ask for more. `Masked` is
the other widget that clips and was passing its inner's size up, so a mask
over something taller than its box asked to be placed at the length it had
just cut off. It reports its box now, for the same reason.

The `debug_assert` the handoff has been asking for is the one that would have
caught both, narrowed to what is actually true: a widget that set a mask this
draw has to report inside the box it drew in. Reported as "does not exceed
the box" it fires on ordinary overflow instead -- measured, a hundred fuzzer
trees produce thousands of them, every one a text too tall for the box it was
offered, which is what a text is meant to say.

`tests/cases/scroll.rs` has a clipping widget that reports its content, to
show the assertion catches it.

Checked: fmt, clippy, 104 tests, all five shrinker cases at 300 seeds, 100
generated seeds, five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:48:23 -04:00
iris-aiandClaude Opus 5 bdab55824f Take two roundings out of where a box comes from
Traced what was left of the warm-against-cold difference after fixed point.
It is not accumulation and not one place: it is the same box reached two
ways, and each way rounds where the other does not.

`Scroll` was writing a box it had been given back out as its own length in
pixels. That is the same box in another form, and centring a part in `rel 1`
lands a step from centring it in `px 900`, because halving a difference is
not halving each part of it. Content that fills the viewport and has not been
scrolled is now handed back as it came, which makes the shrinker's `repaint`
and `resize-repaint` cases agree exactly rather than within a step.

`Span` placed each child a step from where the last one ended, so the
rounding of every share was carried along the row. A position is now the
fixed parts before it -- a sum, exact -- plus one rounded share of the room.
Measured: two hundred equal shares of a 1000 px row ended at 999.999 and now
end at 1000, and `tests/cases/layout.rs` pins it at 2, 3, 7, 64 and 200.

What is left is a step per level of nesting between the two ways, which is
what the fuzzers now allow: four of the five shrinker cases pass at one step
and the fifth is five spans deep. Closing it needs one way of asking where a
box is, which is a bigger change than this.

Checked: fmt, clippy, 103 tests, all five shrinker cases at 300 seeds, 100
generated seeds, five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:42:09 -04:00
iris-aiandClaude Opus 5 9d8415d65f Delete OrthoSize, and run the seeds in parallel
A span is as long across itself as its longest child, unless a rule beside it
already says how long it is -- and then reading the children answers nothing
and only makes its size depend on theirs. `OrthoSize::Full` was that second
case written twice, once as an enum on the span and once as the rule that
actually decides; `Painter::ruled` lets the span ask which it is in. The
widget under a rule still does not learn what the rule says, only that its
answer for that axis is not wanted.

The fuzzers grow, lay out and drop a tree within one seed, so the seeds share
nothing and take a thread each, one short of every core. Measured here: the
generated oracle's hundred seeds went from 68 s to 10 s, and a shrinker case
at 300 seeds from 18 s to 3.5 s. A seed that fails still shrinks and panics
on its own thread, and `std::thread::scope` carries that out.

The shrinker now allows the two steps the oracle already did -- the deeper
trees these grow reach a second composition, and a step is a thousandth of a
pixel.

Checked: fmt, clippy, 102 tests, all five shrinker cases at 300 seeds, 100
generated seeds, and five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:18:49 -04:00
iris-aiandClaude Opus 5 cb955f1023 Link the ordinary tests once, and keep their debug info to line tables
Eleven `tests/*.rs` were eleven binaries, each linking the whole graph --
`wgpu` and all -- to run a handful of cases. They are modules of one target
now, under `tests/cases/`, and `cargo test --test suite layout::` still picks
one out. The fuzzers and the `*_cost` measurements stay their own targets:
they are run on their own and want to be selectable without building the
rest.

`profile.test` takes `debug = "line-tables-only"`, which is what a backtrace
here actually reads; the type and variable information was the bulk of what
the linker was writing.

Measured on this machine, rebuilding `iris`'s test targets after a change to
the crate: 14.3 s before, 9.8 s with one target, 7.7 s with both. `target/`
went from 45 GB to 13 GB. The suite still passes 102 tests, and the binary
still carries `.debug_line`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:00:43 -04:00
iris-aiandClaude Opus 5 39e4ca20e6 Decide layout on the grid end to end, and delete the tolerance
`Px` and `PxVec2` reach the last places a pixel was a float: the window, the
box a widget reads, the box it is compared against, and `PixelRegion`. A
pointer, a wheel notch and a shaped glyph advance still arrive as floats,
and each is put on the grid where it arrives.

`Holds` is an interval of `Px`. `HOLDS_EPSILON_PX` is gone with the
`exact`/tolerant split it existed for: `at` is the length a widget read, an
open end is the next step along, and `same_px` is equality. `Span`'s margin
from `5ed9e87` goes too -- the box a parent hands back and the sum of what
its children asked for are counts of the same step, so the boundary decides
the same way from either side.

Three things had to be true for that, and were not:

`Holds::through` inverts `px + rel * box`, which rounds -- so a part of a
given length came from a range of boxes, and inverting the length alone gave
a point that need not contain the box the part was drawn in. It now maps the
half step either side, and one more for a length composed down the chain
against the same length measured against the window.

`RegionRemap` translates when a box only moved, rather than dividing to find
each part's fraction and multiplying to place it again. Two roundings landed
a step from where growing the tree that way does; a move is exact on a grid,
which is the whole reason `tests/drift.rs` was written.

A pixel is `1/1024` rather than `1/64`. At `1/64` the residue of a length
reached two ways was one step, and one step was 0.016 px -- enough to move
a box. `PX_SHIFT` and `REL_SHIFT` are the only statement of the grid now,
and the shader's copy is prepended from them rather than written twice.

Checked: fmt, clippy, 102 tests, 100 generated seeds in 75 s, all five
shrinker cases at 300 seeds, and `tabs`, `view`, `minimal`, `text` and
`random` byte-identical at 1920x1200.

What the fuzzers ask for is now a step, not a twentieth of a pixel: the
shrinker's five cases agree within one (`resize` exactly), and the oracle's
two-operation cases within two. The residue is a single rounding either way
-- it scales with the grid rather than accumulating, which is why it is a
thousandth of a pixel now. Closing it means one way of asking how long a box
is, rather than a chain composed down and a length measured against the
window; that is a bigger change than this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:56:48 -04:00