Files
ai-app/docs/HANDOFF.md
T

689 lines
35 KiB
Markdown

# Handoff
Where the work in flight stands for a session picking it up cold. Keep current
invariants, measurements, and failed hypotheses here; this is not a decisions
log. Pruned on 2026-09-17: the chronicle of closed defects, superseded
verification lists and cross-fixture tables went, the rules and the lessons
stayed.
## Where things stand
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head
**`ea6dbae`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
the `Holds` retained-layout contract, region nodes, built-in alignment and
size rules, fixed-point layout, and a box in pixels threaded down the draw.
No PR review was present when checked on 2026-09-15.
**Two ideas outrank everything else in this document** (Bryan, 2026-09-17).
First, a changed tree lays out exactly as if it had been drawn that way from
the start; that is what the retained machinery is for and what the oracle
and shrinker check. Second, widgets are predictable: `px` is that many
pixels, `rel(0.5)` is half of the containing widget's area however many
siblings there are and wherever it sits among them, and `leftover` is a
share of the room left once every sibling's `px` and `rel` are resolved. The
invariants listed further down were accumulated by agents chasing single
failures; any of them may be simplified or deleted if those two ideas still
hold.
## Review of 2026-09-17
A fresh read of `core/src/fixed.rs`, `orientation/`, `ui/holds.rs`,
`ui/painter.rs`, `ui/render_state.rs` and the position widgets, outside of
doing work on them, with each finding checked by a scratch test.
**Verdict.** The concepts are sound and stay. Fixed point on a `1/1024`
grid is the right base for a layout that decides "same box or not" by
equality. Threading the pixel box down the draw, with `Holds::through` the
exact preimage of that one multiply, is the strongest idea in the code:
layout has one route to every length and the reuse test is its exact
inverse. Offer, given and placed is the ordinary measure-then-arrange model.
What needs work is the bookkeeping around the second ask, one boundary in
`Span` computed by an expression other than the drawing it guards, and a
`rel` that means two things. The two-step residual is structural and no
grid width fixes it; where it becomes visible is the shader's snap.
### Decided by Bryan
- **`rel` is a fraction of the containing widget's whole area.** In a span,
`rel(0.5)` is half the span whatever else is in it and wherever it sits.
It is never a fraction of what was left after earlier children. This
supersedes the 2026-09-16 reading that a report is a fraction of the box
the widget was given, wherever that box is a remainder rather than the
child's whole area.
- **Splitting `Widget::draw` into a measure and a draw is acceptable.** His
original reason against it: a span whose children have fixed sizes can
place them exactly where they were measured and so draw once, and that
must survive. The smaller items below come first.
### `rel` means two things today
Measured in a 400 px row whose first child is 100 px wide:
| second child | box | meaning |
| --- | --- | --- |
| `rect.width(rel(0.5))`, declared | 100 to 300 | half the row |
| nested span reporting `rel(0.5)` | 100 to 250 | half the remainder |
The declared case is the rule; the reported case is wrong, and
`tests/cases/layout.rs::a_span_reads_a_child_report_as_a_fraction_of_what_it_offered`
pins the wrong behaviour and has to change. The two placement functions in
`core/src/ui/painter.rs` differ by one line:
```rust
// placed_box: a reported length is composed through the offered box
let len = lens.axis(axis).within_len(span.len());
// declared_box: a declared length is a fraction of the parent's own box
let len = Len::from_parts(len.rel, len.px);
```
Making `declared_box` compose the same way would be the one-line fix in the
*wrong* direction. The composition `in_parent_frame` does is right where the
offer is the child's whole area -- `Pad`'s inset, a `Stack` child, `Scroll`'s
content -- and wrong where the offer is a positional remainder, which is
`Span` along its axis. `Span` offers each child the room from the cursor to
the end because a text has to wrap at the width actually left, so the pixel
width of the offer and the base its fractions are taken of have to be
separated: the remainder for one, the row for the other. Candidate: the ask
carries what a report's fractions are of, defaulting to the offered box, and
`Span` passes its own extent along the row. A child that drew at half of its
remainder is then placed at half the row and redrawn there by the placing
ask, which is one draw more, and exactly what `place` already does for any
child whose placed box differs from its offer. A nested span whose child
asks for half of it reports half of the row, gets it, and its child gets
half of that: no circularity, because the base is the row and not the
nested span's own answer. Not designed yet; it is first in "Next".
### The offer's answer is overwritten by every ask
`ActiveData::answer` is documented as what the widget answered at its
offer. `Painter::widget_at` guards its write with `answers_offer`, but
`UiRenderState::draw_inner` writes the field unconditionally and returns
the same value, so the guard is dead and the placing second ask overwrites
the offer's answer with one about the placed box. The guard is from
`29c7881`; the unconditional write arrived with `d3b0ebf`.
```rust
// painter.rs, widget_at
let answers_offer = self.at_offer && px == offered_px;
let (size, holds) = self.state.draw_inner(...);
if answers_offer {
self.state.active.get_mut(&id.id()).unwrap().answer = (size, holds);
}
// render_state.rs, draw_inner, on every ask
active.answer = settled;
```
`known_len`'s first-ask write and `update`'s resize path also write the
field as though `draw_inner` did not; `known_len`'s stores the value it just
read from the same field. The oracle passes, so layout is not wrong. What it
can cost is churn: an answer about the placed box can miss
`retained_answer` on the next offer ask and fall through to a remap and
back. Not confirmed against the counters. Write the field in one place, and
keep the guarded one.
### `redraw` reassembles `DrawInfo` by hand
`ActiveData` copies eight fields of `DrawInfo` and `redraw` rebuilds the
struct field by field. This is where the mask defect fixed in `ea6dbae`
lived for as long as there was a local-redraw path.
```rust
let info = DrawInfo {
layer: active.layer,
parent: active.parent,
depth: active.depth,
parent_move: active.parent_move,
region_node: rsc.widgets().is_region_node(id),
mask: active.parent_mask,
given_len: active.given_len,
offer_len: active.offer_len,
px: given_px,
offered_px,
decided: active.decided,
};
```
Store the `DrawInfo` on `ActiveData` and write
`DrawInfo { px, offered_px, ..active.info }`: the duplicated fields and the
reconstruction go, and a new field cannot be forgotten. The pixel pair stays
out on purpose; see "px is not stored" below.
### `Span`'s leftover boundary is a third expression for the room
The decision uses a rounded division, `total.px.div(fixed)`, while the room
the children get is a floored multiply, so the two disagree at the boundary.
Measured with 300 px, `rel(2/3)` and a `leftover` child:
| row width | leftover child | its threaded length |
| --- | --- | --- |
| 900.000 | undrawn | |
| 900.001 | drawn | 0 steps |
| 900.002 | drawn | 0 steps |
Harmless at two steps, but the three-branch block collapses into the inverse
that already exists. `room` is computed a few lines below the block as
`Len::rel_max() - Len::from_parts(total.rel, total.px)`, and its `to_px` is
exactly the threaded length the leftover children share:
```rust
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
let mut shares = false;
if total.leftover > Weight::ZERO {
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
let holds = match shares {
true => Holds::from(Px::STEP..=Px::MAX),
false => Holds::from(Px::MIN..=Px::ZERO),
};
painter.holds(axis, holds.through(room));
}
```
`through` already handles a negative fraction and a zero one, so the
`fixed < 0` and `fixed == 0` branches go with it. The general lesson: `mul`
floors while `div`, `div_int` and `ratio` round to nearest, so a boundary
derived with a division guards a drawing made with a multiply. Derive
boundaries through `through`, or make the grid floor everywhere.
### Where the residual comes from, and the snap
Both remaining steps are a symbolic region re-expressed by division rather
than recomputed the way a cold draw computes it: `AxisRemap::Scale` divides
to find a part's fraction of the old box, and `placed_box` scales a mixed
`Len` by the alignment. Neither touches the threaded pixel chain, so every
layout decision already agrees warm against cold; what differs is the
composed position the shader and hit testing see, by up to 0.002 px.
Closing `Scale` exactly is possible: keep each primitive's region in its
widget's own coordinates and recompose on a move with `within`, eight
multiplies and no division against the current four divisions and twelve
multiplies, exact by construction, sixteen bytes more per primitive. Closing
the alignment one means resolving alignment in pixels, which costs the
retained resize path. Neither is worth a thousandth of a pixel on its own.
Where it does matter is `snap_floor` in `prelude.wgsl`, which adds half a
layout step before flooring: that absorbs float error and not a layout
step, and truncation makes "one step under an integer" the common residue.
A third of 900 px is 299.999 on the grid and lands at 299 on screen, which
is the pixel `08c9d5a` moved `tabs`'s arcs by. Rounding to the nearest
pixel absorbs both the truncation and the two-step residual everywhere
except within two steps of a half pixel, where layout never lands on
purpose, and keeps integer widths for equal fractional parts:
```wgsl
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
return floor(v + 0.5);
}
```
Approved by Bryan on 2026-09-17, together with rounding on the CPU; see
item 5 under "Next" for why both, and what each does not fix. The check is
the reference render set plus the oracle; expect `tabs` to move its arcs
back.
### Smaller items
- The comment on the `local == UiRegion::FULL` shortcut in `widget_at` says
composing through `FULL` "is not quite the identity in f32". On the grid
it is exact; the shortcut is performance only now.
- An undrawn `leftover` child still contributes its gap, so a vanished
child leaves a double gap.
- Nested spans pass `leftover` weight up, so three leftover children in one
inner span beside one in another get three quarters to one quarter. No
other layout system does that, and the doc's old example of two and two
did not distinguish it from per-span division. Confirm it is wanted.
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`;
both are caller bugs under `debug_assert`, but the fallbacks differ.
### Measure and draw, later
Splitting `Widget::draw` into a measure and a draw would delete `at_offer`,
`offered`, `answers_offer`, the placing second ask and the reuse dance after
it. It costs two methods on every widget and is a rewrite of a core that
passes a thousand seeds at depth six. The one-draw property Bryan wants
kept holds either way: a child with a fixed size needs no measure, and a
measured child costs a measure plus a draw where it now costs a draw plus a
reuse or a redraw. Last in "Next".
## How layout is decided
### Fixed point
Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in
floats.
- **`Fixed<SHIFT>` is an `i32` counting `1 / 2^SHIFT`.** Adding and
subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16:
truncation is preferable); `div`, `div_int` and `ratio` round to nearest;
`to_scale` takes the nearest step. Two routes to one place that land on
one number are the same place, so everything downstream compares for
equality.
- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536`
of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the
first two; the shader's copy is prepended from them by
`render::module_source`. `Px` was `1/64` first, where one rounding's
residue was 0.016 px and enough to move a box. Range is +/-2.1M px and
conversion to `f32` is exact to 16,384 px.
- A weight is not a fraction: a list divides its room by the total of its
weights, and `Rel::ratio` turns two weights into a share on the finer
grid.
- **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will
not draw reasonably anyway, so wrap and break clearly). Saturating cost a
twelfth of layout's instructions. `MIN` and `MAX` stand in for an
unbounded end and are only ever compared against; `from_f32` is the one
operation that clamps, and `Holds` keeps a saturating `narrow`.
- A pointer, a wheel notch, a shaped glyph advance and a window size arrive
as floats and go on the grid where they arrive. `Vec2` is what the GPU
and the platform speak; `PxVec2` is what layout decides in.
- **Do not widen the grid to chase a residue.** Every failure this branch
saw was one value reached by two expressions, sitting on a boundary
defined by the same value coming back the other way. No precision shrinks
a residue that is the whole distance.
### A box in pixels is one multiply from its parent's
`ActiveData` keeps a widget's box as lengths of its parent's box --
`given_len`, and `offer_len` for the box it was first asked about --
`DrawInfo` carries the pixel lengths themselves (`px`, `offered_px`), and a
draw threads them down one `Len::to_px` at a time: the box its parent gave
it, then the part of that box its own answer placed its drawing in, which
`placed_lens` states once for both `placed_box` and the walk.
`Painter::px_size` and `px_len` read that value, and
`UiRenderState::asked_px` takes the same steps back up the parent chain
when a local redraw starts part-way down the tree. Neither chain has a
coordinate frame in it, so a region node cannot break either, and warm and
cold reach every length by the same expression.
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**:
`floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and
`floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two
`div_toward`s once the sign of `rel` has said which bound is which. The
answer is an interval even for a single length, because a floor is not
invertible. The range has to contain the box a drawing was made in (the
`Holds` assertion in `draw_at`, debug only) and must not contain a box
the drawing does not hold for (the oracle); being the preimage makes
those one statement rather than a trade-off.
- **Symbolic regions are for the GPU, hit testing and remaps alone.**
`Moves::resolve` is the only walk left and it is the vertex shader's.
Nothing layout decides is composed back up the move chain.
- **`px` is not stored on `ActiveData`, deliberately.** 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 to say so. `asked_px`
walks up only where a widget is already being redrawn; the mean chain is
2.8 levels.
- **The window is not a move entry** (`5b78002`). A chain bottoms out in
`MoveIdx::NONE`; the window is applied where a fraction becomes pixels,
`to_px(output_size)` on the CPU and the uniform in the shader. A resize
rewrites no retained entry and re-uploads nothing but the uniform; its
cost is whatever `Holds` redraws.
- Failed hypothesis, kept as the shape of the mistake: an offer composed
back up the chain fell back to `FULL` under a region node and was resolved
against that node's *placed* box, so everything under a `Scroll` was
re-asked at the content's width and confirmed its own answer. Pinned by
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`.
The old chain with an allowance in `through` passed that case and the old
chain with the exact `through` failed it; both halves had to land at once.
### What the fuzzers tolerate
`AGREE_STEPS` in `tests/scenario/mod.rs` is **2**, and both steps are
positions. One is a box centred in a fraction of its parent against the
same box centred in its own pixels, 0.001 px on a handful of seeds. The
other is `AxisRemap::Scale` re-expressing a part as a fraction of a box that
changed length; one step fails the 400-seed shrinker on `resize-size`,
seeds 384 and 162, by 0.002 px while passing the 100-seed oracle. Two of the
earlier sources were fixed rather than tolerated (`bdab558`): `Scroll` wrote
a box it had been given back out as its own length in pixels, and `Span`
placed each child a step from where the last ended rather than as the fixed
parts before it plus one rounded share. See "Where the residual comes from"
above for what closing the rest would cost.
## Retained-layout invariants
- `Holds` is the interval of box lengths for which a widget's drawing and
reported size stay valid. Reading `Painter::px_len` or `px_size` narrows
it to the length read; `Painter::holds` widens it. Parent validity is the
intersection of what its children induce. The contract is trusted: a
widget declaring a wrong range is a defective widget, and Iris adds no
defensive work to recover from one.
- A retained drawing can be reused only when its `Holds` contains the new
pixel box on both axes, its parent node is unchanged, its region-node
choice matches the retained structure, it is on the layer it is asked
for, and the widget is clean. A valid ordinary subtree moves without
redrawing by recursive remap; a region node moves by one entry.
- **A retained drawing belongs to the layer it was made on.** A container
that measures a child by drawing it measures on the layer that child will
draw on -- `Painter::child_layer_at` -- or it pays two draws a frame.
- The first box a parent asks about is the offer; a later box chosen from
the answer is the final box, not another answer. A dirty widget is
re-asked in the box its parent gave it, and only where that box is as
long as the offer; anything else is its parent's question, with the mark
left on. Lengths and not whole boxes: what a drawing depends on is its
lengths, so the same lengths elsewhere is the same question.
- An answer is reusable only where both its measurement and the drawing in
its final placed box remain valid; the drawing's `Holds` is translated
back through `placed_lens` and intersected with the answer's.
- `Painter::widget_decided(child, region, [bool; 2])` says the parent chose
this box from the child's own answer along those axes, so the answer is
not placed inside it again. A report of "half of what you give me" has no
fixed point but zero, so the framework asks exactly twice: at the offer,
and in the box chosen from the answer, final on the decided axes. `Span`
decides the row axis, `Scroll` both, `Stack` both for its sizing child.
`Pad` overrides nothing: its inset is exactly the inner where the box is
its answer, and the slack is the inner's to sit in otherwise.
- **Placement cannot be applied after the fact.** Three attempts at "draw
the widget, then move its drawing to where its alignment says" failed,
because the move is a change of frame and no split of the stored state
carries it: moving `ActiveData::region` with the drawing made a later
local redraw ask a differently rounded question, and leaving it made
`placed` accumulate without bound because `try_reuse` returns a clean
subtree's size without walking into it. Alignment is applied where the
size is known -- `declared_box` for a rule, the placing second ask
otherwise.
- A widget that clips to its box reports its box: `Scroll` and `Masked`
report `LEFTOVER` on both axes, and a `debug_assert` holds any widget
that set a mask this draw to it. Overflowing is otherwise ordinary, which
is why the assertion is narrowed to mask-setters. Where content shorter
than a `Scroll`'s viewport sits is the scroll's own alignment, and its
"fits at the start of any box" widening is gated on near alignment.
- **A widget's own mask is not the one it inherited.** `ActiveData` keeps
both; they differ exactly where the widget called `set_mask`, which says
whose mask a move rewrites, and a local redraw is handed the inherited
one. Pinned by
`retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`.
- A span is as long across itself as its longest fixed child, unless a rule
gives that length outright (`Painter::has_exact_size`), in which case it
does not read its children there at all. Any relative or `leftover` child
makes it report `leftover`. **Do not choose between a fixed and a relative
child in pixels at the span's current width**: that admits multiple
self-sizing fixed points, and generated seed 13 settled differently warm
and cold under it. The same circularity is what a cap containing
`leftover` would put into `SizeRule::Max`.
- `Span`'s leftover/no-leftover split is a strict layout decision, not a
rounding tolerance: its `Holds` range must use the same exact boundary as
drawing. A tolerant endpoint retained zero-height children in seed 16; a
boundary moved off where boxes land was needed in floats and is not on
the grid. **A structural decision may not be taken on a hair's breadth**
that two routes can disagree about. Pinned by
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
- A pixel comparison is equality. A length given in pixels is that many
pixels wherever it ends up, structurally: `Len::within` adds a part's own
pixels rather than scaling them. Pinned by
`a_length_in_pixels_is_that_many_pixels_however_it_is_nested`. A length
given as a share is not: equal shares come out one or two steps apart
because positions, not lengths, are what gets rounded, so the row fills
and no two children leave a seam
(`equal_shares_differ_by_at_most_two_steps_and_fill_the_row`).
- **A move that keeps a box's length is a translation, and exact.** A box
that changed length re-expresses each part as a fraction of the new one,
which rounds. This inverts the float-era rule; `tests/cases/drift.rs`
pins that the grid does not drift either way.
- `Scroll` must return the answer from the first box it asked about,
whether retained or fresh; returning the final placed answer advanced one
fixed-point iteration (seed 86). Content that fills the viewport unscrolled
is handed back as it came, because the same box written as its own
length in pixels does not round alike.
- An asked-but-undrawn size dependency names the widget that asked as its
parent (seed 10). `Painter` records size-dependency edges only when a
parent reads a child's size or hint; an undrawn measured child stays
recorded so a later change reaches whoever decided not to draw it.
- Dirty widgets settle deepest-first. `dirty_size_under` stops a reader
taking a retained answer while something below it is dirty; it is an
optimization against laying out twice, not a validity mechanism.
- Declared non-`leftover` lengths are resolved by the parent where the
widget is drawn, so a declared-length change redraws the parent. A rule
wins on the axis it names and the widget under it never learns of it.
**A cap may not contain `leftover`**: a cap must read the report, so rule
and report are one equation, and a share puts the row's division into it
-- the multiple-fixed-point failure again. A cap is pixels and a fraction,
which is what `Len` is.
- Text shaping is retained separately from line breaking; a greedy break
holds from its longest produced line through the width it was made at,
reported through `Painter::holds`.
- Region nodes: a node holds a whole `UiRegion` in its parent node's
coordinates, `FULL` is the identity, widgets opt in with `.region_node()`
or `Widgets::set_region_node`, and changing it redraws the subtree once.
`.scrollable()` sets it once; raw `Scroll::new` does not. A removed node's
move entry stays alive until every descendant has migrated. `Span` and
`Align` add no nodes.
- Alignment is one `f32` per axis (Bryan, 2026-09-15), default the middle
on both because the edges assume a direction. One widget keeps one length
per axis; a second length needs a second widget, `Wrapper` via
`.wrapper()` (Bryan, 2026-09-16, `d21a215`).
## Verification at the current head
At `32542d0`, then `ea6dbae` on top:
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
-D warnings`, `cargo test --workspace`: green, 87 suite tests, 18 core
unit tests, 11 generated cases. Only the long runs and the profiling rigs
are ignored; no known defect is.
- The release oracle at 100 seeds in 14.4 s, and **120 seeds in debug** in
59 s -- the debug run exercises the `Holds` assertion in `draw_at`.
- All fifteen shrinker cases at 400 seeds of depth 5 in 58 s, and at 1000
seeds of depth 6 in 147 s.
- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at
1920x1200 against `5b78002`, and the `tabs` touch replay before and after
the gesture. `random` live-resized from 1920x1200 to 1280x800 is
byte-identical to a cold 1280x800 render.
- Twenty-five rig work counters identical on the `cold` and `resize`
phases, which is what makes those rows under "Performance" a measurement.
**A claim about a render holds for the commit it was checked at and no
further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take
the oracle as the reference and the five renders as a spot check.
**Run the long two before believing a rounding change**, and run the
ordinary suite in debug:
```sh
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \
cargo test --release --test shrink -- --ignored --nocapture
IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
```
Depth is what finds things: nothing failed at 100 seeds of depth 4, which
was all the oracle ever routinely ran, and every late defect surfaced at
depth 5 or 6.
## Performance
**Threading a box in pixels down the draw is free on cold layout and 9-13%
off the retained paths** (2026-09-17). Instructions:u, medians of 21 runs of
binaries built in one worktree, seed 1 at depth 8, against `5b78002`:
| phase | before | after | |
| --- | --- | --- | --- |
| `cold`, 200 frames | 313.1M | 312.9M | -0.04% |
| `resize` | 408.1M | 405.6M | -0.61% |
| `many` | 1,924M | 1,756M | -8.75% |
| `scroll` | 357.3M | 323.4M | -9.49% |
| `repaint` | 363.3M | 315.4M | -13.18% |
`cold` and `resize` compare directly: all twenty-five work counters are
identical. The other three do less work: `repaint` goes from 23 draw
requests and 13 widget draws to 1 and 1, because `redraw` composes nothing
and a widget whose box moved without changing length settles itself instead
of escalating.
### How to measure here
- **Check the work counters before comparing two commits' times.** The rig
prints drawn widgets, widget draws and primitive writes; a comparison is
only worth reading when they match. `random.rs`'s `Branch` picks a
subtree by a measured pixel length, so the fixture's shape moves with the
thing measured; `Edits::fixed_branches` pins it for timing and the oracle
keeps measured branches on purpose. A 3x this section once reported was
that artifact.
- **`perf stat` in this VM returns garbage readings** for both
`instructions:u` and `cycles:u`, roughly a quarter of the time, off by a
factor of five to fifteen. Take medians of nine or more and report how
many readings a filter kept. Cycles spread 1-3% between sets of one
unchanged binary and 6.7% in the worst; instruction counts hold to 0.02%
within a binary and move 0.5% across a rebuild, so build the baseline
beside the thing measured and quote a delta. `ex_div_busy` held to 0.1%.
- **What moves cycles is whether `UiSpan::within` inlines.** It is the
hottest line in layout; `nm` shows it as a symbol when it does not.
Shrinking its body until the inliner takes it won; `#[inline]` on the
body it had lost 1.5% cycles. Shrink it, do not annotate it.
- `Holds::through` divides twice per call and accounts for essentially all
of a run's `i64` divisions: 21.3M cycles of a 500-frame `many`, 2.8%.
The float head divided twice there too.
### Tried and rejected, with numbers
- A float reciprocal for `AxisRemap::apply_scalar`'s division: +6% cycles.
`Holds::through`'s division has not been tried.
- Branchless `shift_round`: +6.7% cycles alone, and worse again with the
short-circuits removed. Size, not the branch, is what keeps `within` out
of line.
- Removing the per-child hash lookup in `remap_subtree`: 0.0%.
- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%.
- Short-circuits guarding a saturating multiply stopped paying once the
multiply wrapped. Re-price a short-circuit before keeping it.
- Rust does not contract `a + b * c`; the float head never had an FMA to
compare the grid's multiply against.
Wrapping (`4febabf`) was -8.6% instructions and -6.6% cycles. Truncating
(`08c9d5a`, with `Fixed::scaled`'s zero test and `within`'s `is_full` tests
removed as one commit, since they are worth 61M instructions apart and 115M
together) costs a share a thousandth of a pixel of its row, makes a flipped
span sit a step from its mirror, and moved an antialiased edge in `tabs` by
one pixel. See "Where the residual comes from" for what that last one is.
## Rigs and reproduction
Ordinary framework verification:
```sh
cd /home/bob/repos/iris-pr18
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
The ordinary tests are modules of one `tests/suite.rs` target; pick a module
with `cargo test --test suite layout::`. `profile.test` uses
`debug = "line-tables-only"`, which halved the test-target rebuild.
`tests/generated.rs` compares a warm incremental tree with a cold tree of
the same state; `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS` and
`IRIS_GENERATED_DEPTH` select what it covers. `tests/shrink.rs` reduces a
failing tree over the same fifteen cases and the same trees --
`iris::random::plan(seed, depth, &edits)` and `build(rsc, &plan)`, so a
failing seed reduces directly and the oracle prints the command:
```sh
SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \
cargo test --release --test shrink -- --ignored --nocapture
```
The cases live in `tests/scenario/mod.rs`, included by both targets by
`#[path]`; a case only one rig knows is how the two drifted apart once. Turn
what the shrinker finds into a test of its own rather than leaving a seed as
the record. Both fuzzers take a thread per core but one. A `git bisect`
once named a commit that could not be the cause; read the tree rather than
the bisect when that happens.
`tests/layout_diagnostics.rs` is the retained CPU rig: `IRIS_PHASE` selects
`cold`, `many`, `repaint`, `size`, `scroll` or `resize`, the
`layout-diagnostics` feature gives the explanatory counters, and an
uninstrumented release binary under `perf` gives totals. Dump the counters
with
```sh
IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \
<instrumented binary> --ignored --nocapture \
| grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort
```
and `diff` two runs; identical output is what says a change is free.
The float head is checked out at `/home/bob/repos/iris-float-cmp`, at
`5ed9e87` with `Edits::fixed_branches` applied uncommitted. Its counters do
not match the grid's and will not, so a comparison against it is a bound
rather than a measurement.
The headless reference set runs one process at a time because the rig
reuses one compositor; comparison worktrees need separate target
directories.
```sh
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
--resize 900x1200@60Hz --shot /tmp/resized.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
--replay /tmp/tabs.touch --shot /tmp/replay.png
```
The replay used for the reference check:
```text
0 down 1728 24
80 up 1728 24
400 down 1836 1116
480 up 1836 1116
800 down 1836 1116
880 up 1836 1116
```
## Next
In order, from the review above and Bryan's steer (2026-09-17):
1. **`rel` as a fraction of the containing widget's whole area**, on
`Span`'s row axis, per the decision above. Fix the test that pins the
remainder reading, and add the two-child case from the table.
2. Write `ActiveData::answer` in one place.
3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
reconstruction in `redraw`.
4. `Span`'s leftover boundary through `Holds::through`.
5. **Round on the CPU and snap to the nearest pixel in the shader**, as
one change with one verification. Bryan approved the snap on 2026-09-17
(rendering may change wherever it brings the screen closer to what the
user's code says: three equal sections of 1000 px need one of them
rounded up). CPU rounding is recommended for a different reason: a
`Rel` is off by at most `2^-25` of its box, so with round-to-nearest
every product whose true value is a whole number of steps is exact for
boxes under about 8,000 px, where truncation leaves half of them one
step short and layout then decides "does not fit" on a container the
user meant to fit exactly. Use the branchless round-half-up form,
`(a * b + (1 << (BY - 1))) >> BY`, not the sign-branching
`shift_round`; re-derive `Holds::through` for `round` (its two shifted
bounds move by half a `Rel` step); check with `nm` that `UiSpan::within`
still inlines; expect a couple of percent of instructions and re-run the
long fuzzers and the render set once for both.
6. The smaller items: the stale `f32` comment, the gap of an undrawn child,
confirm nested `leftover` weights, one zero-divisor fallback.
7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
subtree case; do not restore a separate child-placement API.
8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
builders `8220a78` deleted. The clamp boundary is a hard layout decision
with an exact `Holds` split at the crossover, both sides in `Px`. Still
awaiting Bryan: whether a `Max` narrows the box the child draws in, or
only what the parent reports for it.
9. `Scroll` taking a direction rather than one axis.
10. The measure/draw split, once the above is in.
`docs/LAYOUT.md` §4, §5 and the density section are stale: they name
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
`Aligned` and `MaxSize`, none of which exist. Do not restore
`OnResize::Translate` or `OrthoSize`.
Other queued work, in dependency order: `UiRenderState` behind
`Rc<RefCell<_>>`; density-independent pixels; input restructuring (pointer
capture, drag slop and axis, cancellation, mask-aware hit testing,
timestamps); retained paints, selection, overlays and shared runtime state;
generic desktop/Android hosts and reusable example/APK tooling;
application-owned fonts and replaceable glyph-atlas buckets; positioned
text overflow and cluster-safe ellipsis.
The archive is a reference, not a patch: it predates returned `Size`, the
current box chain and the current length types. Recreate changes on current
types and keep app/session concepts out of Iris.