Files
ai-app/docs/HANDOFF.md
T

54 KiB

Handoff

Where the work in flight stands for a session picking it up cold. Keep current invariants, measurements, and failed hypotheses here; this is not a decisions log. 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 25e456e, 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, a box in pixels threaded down the draw, and a report read as a fraction of the containing widget. No PR review was present when checked on 2026-09-15.

Widen one fuzzer axis at a time, and record which. Seeds 1121 and 1839 at depth 4 failed on ea6dbae and on every commit before it, and nothing in the routine verification reached them: the fast oracle takes ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6, so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by 4bd8607. The scan that found them, worth running again after any change to layout:

// tests/scan.rs, deleted once it had done its job
over_seeds((1..=2000).collect(), |seed| {
    let grown = plan(seed, 4, &Edits::default());
    for &case in ALL.iter() {
        if let Some(how) = diverges(&grown, case, seed) {
            println!("HIT seed {seed} case {} {how}", case.name());
        }
    }
});

261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at 4bd8607. Rng::new is seed | 1, so an even seed and the odd one above it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and 1839.

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 needed work was the bookkeeping around the second ask, one boundary in Span computed by an expression other than the drawing it guards, and a rel that meant 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.
  • draw stays the only layout method on Widget (Bryan, 2026-09-17, reversing the same day's acceptance of a measure/draw split). Ease of writing a widget is half the reason. The real one is that a second method holding the same layout drifts from the first, which a span makes extremely easy, and the shared logic then gets pulled into helpers both call that still have to be applied carefully in each. Where the framework needs a widget's layout twice it runs the same body again with the painter in a different state, or hands it more through the painter.
  • A widget's frame does not change between the ask that measures and the ask that places. Fractions are of the frame; the extent reaches the widget through the painter. See "Frame and extent" below.

A report is a fraction of the containing widget (landed, ffd79f3)

Superseded in part. Bryan refined the rule twice on 2026-09-17: a fraction means whatever the parent says it means, and it need not even mean the same thing for two siblings -- "Main point is truly that rel is decided by parent". The span conclusion below is unchanged and the reason for it stands (a span's offer differs per child and the row does not), but reports_of is the wrong shape for saying it; see the parked padding branch.

rel(0.5) is half the span whatever else is in it and wherever the child sits. A report used to come 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 spoken for -- where the same half written as a rule on the child took half the row.

The offer is still the remainder, because a text has to wrap at the width actually there. What separated from it is the base a report's fractions are of, which the ask now carries as reports_of:

pub fn widget_at<'s, W: ?Sized>(
    &'s mut self,
    id: &'s StrongWidget<W>,
    region: UiRegion,
    reports_of: UiVec2,
    decided: [bool; 2],
) -> DrawResult<'s, 'a, W>

It is region.size() wherever the box offered is the child's whole area -- Pad's inset, a Stack child, Scroll's content -- and Span passes UiVec2::FULL_SIZE along its row. widget_decided is gone; widget_at says both things about an ask rather than one of them.

A span can now overflow itself without bound, which is the consequence Bryan's rule asks for: two children reporting half each take the whole row and a third starts past the end. Under the old reading total.rel could not exceed one, so Span's fixed <= 0 branches were only reachable through declared fractions; they are ordinary now, and with them boxes of negative length passed down to children.

An answer is not an answer while anything under it is dirty (0e0d4af)

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, and nothing puts it right: 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 dirty_size_under is not the optimization its comment claimed. It is what makes an answer an answer, and it is asked once in draw_inner for both retained routes. Twenty-five rig counters are unchanged on cold, repaint, scroll, resize and size; many makes 18 fewer reuse attempts, 17 of which already reported "dirty".

Found at seed 564, depth 6, shuffle-every-other, reachable only once a span could overflow itself. The fast test for it is still owed: the divergence needs a reader drawing while a size dependency two levels under it is dirty, which no hand-built tree has reproduced yet, and the seeds at depth 4 above fail for some other reason.

A text is handed back a box its own line fits in (landed, 4bd8607)

A wrapping text reported the width it used through Px::from_f32, which takes the nearest step and is under the line the shaper measured half the time. A parent that sizes itself from that report -- a stack taking a span's width, the span taking its widest child's -- then hands the text back a box its own longest line does not fit in, and a greedy break there is one line longer. Warm kept the break it had; cold made the narrower one.

Two tolerances were holding that together and both are gone:

  • TextBuffer::shape answered any width within BREAK_EPSILON_PX = 0.05 of the longest line from the break in hand. Fifty steps of the grid, and a structural decision taken on a hair's breadth -- the thing the Span boundary invariant below already forbids. It is want >= layout.width() now, exactly.
  • The Holds range the text declares started at the nearest step to its longest line, so it admitted boxes that line does not fit in. It starts at Px::ceil_from_f32 of it now.

Neither was the fix. The fix is the report: Size::from_px( PxVec2::ceil_from_f32(tex.size)), the step at or above what was measured, so the box that comes back fits. With it in place either tolerance could have stayed and the case passes; both are wrong on their own terms, so both went. Fixed::ceil_from_f32 is new and is the only rounding on the grid that is not to the nearest step.

The general shape, and the third time this branch has hit it: a value that comes back as a box has to be rounded away from the measurement, not to the nearest step. Rounding to nearest is right for a value being carried; it is wrong for a bound.

A frame settles strictly bottom-up (landed, a92c6ac)

The queue was already deepest-first, but a widget that could not settle where it was called redraw on its parent from inside itself, which drew a shallow widget while dirty widgets deeper in other subtrees were still pending. A parent drawing over a subtree that has not settled reads answers about to move, and the one that settles does so inside the parent's draw -- where its mark comes off and nothing compares what it now answers.

A widget that cannot settle defers instead: it marks its parent, stays marked, and waits in deferred until the walk reaches the parent's depth, which cannot happen before everything deeper has settled.

loop {
    let next = rsc.widgets().needs_redraw.iter().copied()
        .filter(|id| !self.deferred.contains(id))
        .max_by_key(|&id| self.depth(id));
    let Some(id) = next else { break };
    if !self.redraw(id, rsc) {
        self.deferred.insert(id);
    }
}

Bryan's, 2026-09-17, and the right answer where 0e0d4af was a check: "then that entire category of issue can't even occur".

The walk is sound on its own, and the guard is only there for a second entry point (read on 2026-09-17, Bryan asking for either a breaking case or a proof). By induction on depth: when a widget at depth d draws fresh, every dirty widget deeper has been popped, so each is settled or deferred, and a deferred one has marked its parent. A clean child asked by that draw therefore has a clean subtree, because anything dirty under it would have a dirty parent, and so on up to the child itself. dirty_size_under cannot fire inside redraw_updates, which is what dropping it from the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000 of depth 6 and 2000 seeds at depth 4 measured.

The entry point it guards is update drawing the root for a resize before the walk runs, top-down over a tree with dirty widgets still in it. Most routes through that are safe anyway: retained_answer succeeding at the offer implies try_reuse succeeds at the placed box, since the settled holds are cut by the drawing's holds taken through the placing length, so a stale answer is reused whole and the dirty descendant later reaches its readers through redraw's comparison. The route that is not safe is the one asymmetry between the two: try_reuse refuses a drawing on another layer and retained_answer does not. A resize frame in which a structural edit shifted a clean child's layer, with a dirty descendant under that child whose answer changes, hands back the old answer, redraws the subtree fresh in place, clears the descendant's mark there, and tells nobody.

Close the entry point rather than test the contrived case. A resize marks the root and nothing else, so layout has one walk and the induction covers everything:

pub fn resize(&mut self, size: impl Into<Vec2>, widgets: &mut Widgets) {
    let size = PxVec2::from_f32(size.into());
    if size == self.output_size {
        return;
    }
    self.output_size = size;
    if let Some(root) = self.old_root {
        widgets.needs_redraw.insert(root);
    }
}

redraw already asks a parentless widget again in root_region against the output. Then dirty_size_under goes at both call sites, draw_inner and retained_size. Bryan's question of whether a resize could instead mark "the important widgets" up front answers itself: needs_redraw is the mark, and Holds is the exact, per-widget, lazy computation of which widgets a new output invalidates; nothing has to be worked out ahead of the walk. What it costs: the root always runs its own draw on a resize where today the whole tree can remap in one try_reuse, and a widget dirty in the same frame as a resize may draw twice if the root's layout then gives it a different box. Drawn widgets should otherwise be identical on the rig; check the counters.

Two facts the induction relies on are not pinned, and both are the shape of 0e0d4af: state that is right only because something else set it up.

  • try_reuse never writes active.parent, and neither does the tail of draw_inner. A subtree reused under a different parent inside the same region node keeps the old parent, so a later deferral marks the wrong widget. The fuzzer never re-parents: reshuffle only trades children between a span and its own spares. A hand-built test moving a child between two spans should miss a redraw or trip the depth() assertion.
  • remap_subtree never updates descendants' depth. The debug assertion in depth() catches it only for a widget that later goes dirty.

Both fixes are one line where draw_inner already writes given and answer, plus a walk in remap_subtree or a depth kept relative to the nearest region node.

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.

A span's leftover boundary is its own inverse (landed, 53b00c6)

The decision used a rounded division where the room the children get is a floored multiply, so the boundary and the drawing it guarded were two expressions for one length. room is that length as a Len, room.to_px is the multiply, and Holds::through is its exact preimage:

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));
}

The three branches were the sign of 1 - rel, which through reads already. Forty lines became twelve and one div left layout. The general rule stands and is now demonstrated: derive a boundary through the inverse of the expression that draws, never by a second expression for the same length.

Two branches parked, both real, neither ready

Superseded on 2026-09-17: the two branches are one defect, and it is in the protocol rather than in either widget. See "Frame and extent" below. Do not chase seed 1091's three steps or the Inset's 47.5 px; both are the placing ask resolving a fraction in the answer box. What each branch got right is kept: the Inset/Outset pair and its tests, and the stack test asserting that half stays half. Their painter changes go.

wip/stack-fraction-twice. A stack sized by a child that reports a fraction applies that fraction twice: its parent places the stack at the reported length, and box_of(size) then takes the same fraction of that box. Half a row becomes a quarter. Pixels are idempotent under a second application, so only a share ever shrank -- and warm and cold shrink alike, so no oracle can see it. Bryan: "I believe this exact thing has come up multiple times for some reason."

The fix is that every child gets the whole box, plus widget_at not resolving a rule into a box already chosen from it. Painter::box_of is deleted rather than guarded: a first attempt made it answer the whole box on a decided axis, which fails because place can reuse the stack's measuring drawing by remapping it, so Stack::draw never re-runs. A drawing has to be a function of its box alone -- if decided is in it, a reused drawing is wrong.

What stops it landing: seed 1091 at depth 4, shuffle-swap-for-three, disagrees by three steps where the oracle tolerates two (warm 1053.9971 against cold 1054). box_of was also making placement exact, by handing a child a box of exactly the length it asked for, and the whole box puts a rounding back at each nesting level. Find that composition; do not widen AGREE_STEPS.

wip/padding-outset-and-inset. Padding goes outside what it pads and never insets the child (Bryan, 2026-09-17): otherwise a child's rel and leftover would mean the inner box while its px meant the outer one. Padding::region moves the child's box in rather than shrinking it, a new Inset widget with .inset() is the old behaviour, and Pad is to be renamed Outset with .outset() to match. in_parent_frame's composition and the reports_of argument go with it -- a report comes up raw and the parent says what it is a fraction of, which Inset does for itself.

What stops it landing: a child declaring rel(0.5) under an Inset comes out 47.5 px wide of the 190 inside rather than 95, and the second halving is unaccounted for. The Pad half is green on its own; three tests moved to .inset() because they were using padding as scaffolding rather than testing it.

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:

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:

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.

Frame and extent

Read on 2026-09-17, against Bryan's question of whether the approach is fundamentally wrong or needs adjusting. The pieces the earlier review approved are sound and the fuzzers agree: fixed point, the pixel chain threaded down, Holds::through as the exact preimage, and the bottom-up settle, with every residual they leave one or two steps in a position. One thing is wrong, and it is the protocol rather than any widget: the placing ask replaces the box a widget's children resolve fractions against with the widget's own answer. Every "fraction twice" item is that defect, and it has lasted because it was fixed per widget four times (decided, reports_of, box_of, the stack branch's resolve, Inset against Pad), each of which stops the re-resolution at one level while it happens again inside the child's second draw, which no flag on the parent's ask can reach.

The repository asserts both answers. tests/cases/layout.rs pins a nested span's rel(0.5) child at a quarter of the row and calls it correct ("half of that final box is what its own child takes"); the parked stack branch's test pins the same shape as a half. Both are consequences of the one line where a child's region composes within the widget's current box, which the placing ask has made the answer:

// painter.rs, widget_at
let within = match local == UiRegion::FULL {
    true => self.region,
    false => local.within(&self.region),
};

The reuse path gives the same result, because AxisRemap::Scale re-expresses the child as a fraction of the new box. Len::within itself is correct geometry and was never the problem.

The protocol is one draw body evaluated twice, and that is right. The first evaluation measures and the second places, and the retained machinery skips the second wherever it can. Only what the second is told its box is has to change. The widget stays in its frame, the box it was offered, and is handed its extent as a region in the frame's coordinates:

pub struct Painter<'a> {
    /// This widget's frame: the box it was offered, in `move_idx`
    /// coordinates. Every region this draw writes is in its coordinates
    /// and every fraction in one is of it. It does not change between the
    /// ask that measures and the ask that places.
    pub(super) frame: UiRegion,
    /// What of the frame this widget's answer took, in the frame's own
    /// coordinates: `FULL` while the answer is not yet known, and the
    /// placed answer once its parent has chosen where it sits.
    extent: UiRegion,
    /// The frame in pixels; the extent is one `Len` of it.
    pub(super) px: PxVec2,
    /// Whether this draw read its extent, which makes the drawing one that
    /// holds only for that extent, the way `px_len` does for a length.
    reads_extent: bool,
}

impl Painter<'_> {
    pub fn extent(&mut self) -> UiRegion {
        self.reads_extent = true;
        self.extent
    }
}

px_len stays the frame's length, which is what a text wraps at and what Span's leftover decision reads. widget_at composes the child's region within self.frame and resolves the child's fractions, declared or reported, against the child's own offer, which ActiveData::offer_len already records. draw_inner places the answer as a length of the offer rather than of the region passed this time:

let lens = placed_lens(answer.0, declared, info.decided);
let extent = lens.within_len(info.offer_len);
let placed = placed_box(region, extent, align);

Where the parent hands back exactly the answer the slack is zero and the multiply exact, so decided stops being load-bearing. try_reuse compares frames, not placed boxes: a nested span's frame is the row on both asks, so nothing remaps and its rect stays at half the row. Remapping is for a frame that moved, a translation in the common case and a scale on a resize, the two cases AxisRemap has.

For a widget author the rule is one sentence: regions are written in the frame, and painter.extent() is "my box".

// span.rs: children sit across the row inside the span's extent.
let across = painter.extent().axis(!axis);
let region = UiRegion::from_axis(axis, span, across);

// pad.rs: the inner sits in the pad's extent less the padding.
painter.widget_within(&self.inner, self.padding.region_of(painter.extent()))

// stack.rs: every child gets the stack's extent; `box_of` is deleted.
let region = painter.extent();

Span's cursor arithmetic does not change: it is already a sum of the children's answers, which are fractions of the row.

Checked by hand against the failing shapes:

  • Nested span: the inner's frame is the row on both asks; its rect is 200 px. a_span_reads_a_child_report_as_a_fraction_of_the_row flips to assert 200..400 for the inner.
  • Stack sized by a rel(0.5) child: the child's extent and the stack's are the same half of the frame, zero slack, exact. Seed 1091's three steps were placed_box scaling a nonzero slack per level.
  • Pad and Inset: the inner's fraction is of its offer and its extent sits in extent().inset(padding). The 47.5 px was the offer changing between asks.
  • Pad around a 40 px rect, bottom aligned: on the second ask the pad's extent is 60 px, the inner region 40 px, slack zero, rect at 20..60. This is the case that needs the second evaluation at all, and why a frame alone is not enough.
  • A rule: a stack declared width(rel(0.5)) holding a rel(0.5) rect gives a quarter of the row, correctly. A rule sets the frame; a report does not.

Reading the extent narrows reuse the way reading a pixel length does. A widget that never calls extent() has a first drawing that holds for any extent and keeps it: Stack without a sizing child, Scroll, every leaf, Span along its axis. One that reads it is redrawn on the second ask only where the extent differs from the frame, and its children reuse through their own Holds since their frames did not move. Suppressing primitives on the first evaluation would be an optimization over this, not a requirement. The one place it costs more than today is a stack whose sizing child reports less than the frame: box_of narrowed the other children's first draw so they landed right at once, and with extent() they draw at FULL and again at the extent. They are usually a background rect; let the rig's counters say whether it matters.

Fixed point, the pixel chain and Holds::through are untouched. The chain already threads offer_len beside given_len and offered_px beside px; the change is that the offer chain becomes the coordinate base and the placed chain is derived from it, rather than the other way round.

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_towards 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, and that ordering is what makes an answer trustworthy; dirty_size_under is to be deleted once a resize goes through the same walk (see "A frame settles strictly bottom-up").
  • 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 25e456e:

  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace: green, 90 suite tests, 19 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.3 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 56 s, the oracle at 1000 seeds of depth 6 in 142 s, and 2000 seeds at depth 4 over all fifteen cases in 262 s. The last is not routine and should be: it is the only run that has ever found anything past seed 400.
  • view, minimal, random and tabs byte-identical at 1920x1200 against ea6dbae. random live-resized from 1920x1200 to 1280x800 is byte-identical to a cold 1280x800 render. text is a new picture: 4bd8607 moved its lower paragraph one pixel, the box being a step wider and its left edge crossing the shader's snap, and c8beca5 rewrote the alignment panel, which had all three labels in the middle of a box the width of the widest of them.
  • Twenty-five rig work counters identical on cold, repaint, scroll, resize and size across 0e0d4af; many differs only in reuse attempts. Across a92c6ac drawn widgets, widget draws and primitive writes are identical on every phase, and only the scheduling counters move.

None of that reaches seeds 1121 and 1839 at depth 4, which fail on this head and on ea6dbae alike. Everything below is verification of what was changed, not a claim that the branch is correct.

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:

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, but so is breadth: every late defect before 2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by running 2000 seeds at depth 4, which nothing routine does. Widen one axis at a time and record which.

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:

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:

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

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.

./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:

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. A resize marks the root and goes through the walk; delete dirty_size_under at both call sites; write active.parent where draw_inner writes given, and descendants' depth in remap_subtree; pin re-parenting with a test that moves a child between two spans. Small, argued by the induction above, verified by the rig's counters.
  2. Frame and extent, as written above. This is the fundamental change and comes before anything built on the placing ask. It lands the two parked branches' tests (the stack test unchanged, Inset/Outset with the rename and the .pad() audit), flips a_span_reads_a_child_report_as_a_fraction_of_the_row, and deletes box_of, reports_of's composition in in_parent_frame, and the through(lens) translation in draw_inner. Run the long fuzzers and the render set once for it.
  3. Write ActiveData::answer in one place.
  4. Keep the DrawInfo on ActiveData; delete the copied fields and the reconstruction in redraw.
  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), and to CPU rounding the same day. The reason for that one is different: 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. Add to them: a span that overflows itself hands a child a box of negative length, which is ordinary now rather than a corner, and nothing states what a widget may assume about one.
  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.

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.