124 Commits
Author SHA1 Message Date
iris-ai 6450615434 Record the refcount removal beside the sweep that raised it 2026-09-21 00:16:55 -04:00
iris-ai d24ff83d6b Record the fourteenth sweep, over allocation and the cost per child
The sweep is Iris `97fca76`. Its two largest findings are both costs per
thing rather than per frame: a container's draw searched a list once per
child and so cost the square of its children, and a mask's rectangle was
resolved once per fragment rather than once per instance. Both now have a
rig that can see them -- `children_cost.rs` is the only one here that varies
width, and `chain_cost.rs` has a masked fixture beside its two-pixel quads.

The sweep also settles the `Fixed::div`-versus-`ratio` item this file listed,
by deleting the operation nothing performed, and adds to the pre-gate
review's waiting list the `Arc<AtomicU32>` every `StrongWidget` allocates for
a count it can never raise.
2026-09-20 23:58:34 -04:00
iris-aiandClaude Opus 5 ea66fc3f71 Withdraw the Moves::clear item from the thirteenth sweep
Bryan asked what that arena was and whether keeping its capacity had a
point. It does not: Moves::clear runs only when the root changes, beside a
rebuild of the whole tree, where RequestArena::reset runs every frame and
the allocation rig checks it. Arena also holds an IdTracker a clear has to
reset. The parallel was drawn on the shape of the code rather than on how
often it runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:47:15 -04:00
iris-aiandClaude Opus 5 e397680a19 Record the thirteenth sweep, a full pass over #19
Seven findings at cbccfb6, the largest being a length of zero that printed
as the empty string -- which the twelfth sweep made load-bearing by pointing
Debug at Display, and which scenario::describe rendered as "no rule at all".
Also re-measured the hole Sow::bound's comment recorded: the seeds it named
stopped naming those trees when the leaves grew images, and 600 depth-5
trees now agree with every bound a fraction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:05:10 -04:00
iris-aiandClaude Opus 5 b788cd1382 Record the twelfth sweep, over the request arena
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:35:36 -04:00
iris-ai 338dd00f76 Rewrap the handoff line the last commit left long 2026-09-20 21:08:03 -04:00
iris-ai a7da12a8ff Record the request arena, which replaces the second expression shape 2026-09-20 21:07:56 -04:00
iris-ai 5f01dc65c8 Record the eleventh sweep, over the built-in bounds work 2026-09-20 20:15:02 -04:00
iris-ai 17423a57b8 Document independent widget bounds and measured layout costs 2026-09-20 19:48:28 -04:00
iris-ai ddaf9b37d6 Put the tenth sweep last in the round list, where it landed 2026-09-20 18:25:27 -04:00
iris-ai 992a4a2e97 Record the tenth sweep, over the deferred request system 2026-09-20 18:25:03 -04:00
iris-ai e6a035d06e Remove superseded performance results from the handoff 2026-09-20 17:33:10 -04:00
iris-ai 048738bb9d Record PR 19 performance sweep and request invalidation fix 2026-09-20 17:32:55 -04:00
iris-ai 4c7072e62f Document deferred layout requests and verification on Iris PR 19 2026-09-20 16:50:50 -04:00
iris-aiandClaude Opus 5 ab8b05b762 Record the rule/widget split for bounds
A rule holds what a widget answers and a widget holds the box (Bryan,
2026-09-20). `layout/bounds` is green on all three scans; the hole left is a
retained answer re-placed under a different rel base, which predates bounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:14:06 -04:00
iris-aiandClaude Opus 5 149a6838af Record the root unification, and where bounds stand
`0d03267` in iris unifies the root's layout path with every other widget's:
`Placing::WINDOW` is the box nobody drew and `Placing::ask` the one place a
box is decided. The leftover-as-a-minimum gap the log recorded for
`root_layout` is closed by it rather than left.

`SizeRule::{Min, Max, Clamp}` is on `layout/bounds` and not in #19. Every
hand-written test passes and the 400-seed scan does not, for a reason that is
a design question: a bound is the first rule whose effect depends on the box
its parent gives it, and the retained machinery hands a widget a box by paths
that never ask it again. The log records the four readings measured and what
each one's seeds were, so none of them is tried twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:35:28 -04:00
iris-aiandClaude Opus 5 6a92857079 Record the leftover minimum and the images in the trees
Two rounds past the ninth sweep: `b295c8b` reads a share under a parent that
divides nothing as a minimum, which is what Bryan said it means and what a span
already did, sharing one comparison with it; `2dba90b` grows images in the
generated trees, which is the only widget here whose hint is a length in pixels
and so the only one that reaches the case.

The dump baseline is 34,571 boxes now that the trees carry images, and a seed no
longer names the tree it used to -- the log says what that costs and where those
defects live instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 04:51:13 -04:00
iris-aiandClaude Opus 5 da6b003a1e Say what a leftover means where nothing divides it
Bryan's rule, generalising the max he gave for Scroll's content length on
2026-09-18: a leftover under a parent that does not divide is still a leftover
and acts as a minimum, so the length is max(box, px + rel*box) -- it fills the
rest where the fixed parts are shorter and overflows where they are longer.

Measured against a span, which implements it, and against the non-dividing path,
which drops the overflow in the one row where the fixed part is longer than the
box. Recorded with the cause and the window contract a fix needs; not fixed,
because what a length means is Bryan's to settle and nothing here mixes the two
outside the span's own test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 04:09:27 -04:00
iris-aiandClaude Opus 5 8adad924b9 Record the ninth sweep, over the widget vocabulary
Eight findings in iris `c2b8bf8`, the largest a widget's own size hint
overriding a length rule that hint cannot express -- which no widget here but
`Image` can reach, so the dump is unchanged and the case builds its own widget.

The lesson for the next round is in the handoff beside the seventh and eighth
sweeps' own: where a round fixed a class of defect one instance at a time, the
check belongs in the shared helper, which closes the class and tests the
instances that round said were already fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:57:11 -04:00
iris-aiandClaude Opus 5 0b60713806 Record the eighth sweep, and count the cold dump's boxes properly
The sweep over the 6,300 lines of tests #19 adds, and over the seventh
sweep's own commit. Seven findings, in iris `77ed7a2`.

Also corrects a number four earlier rounds carried: the cold dump over 400
depth-5 trees is 34,488 boxes, not 34,492. The run prints eight lines that
are not a box, so `wc -l` gives 34,496 and a partial filter gives whatever it
gives. Nothing any round concluded moves, because each compared two dumps
rather than their line counts -- but the handoff now says how to count, so
the next round does not have to guess which figure was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:19:59 -04:00
iris-aiandClaude Opus 5 5b34d406c7 Record the seventh sweep, over the rigs and the sixth sweep's own fix
Six findings in iris f8aa0c5, and the seven things that tripped a rule and
were left. Three of those are named as work waiting for the pre-review-gate
pass, since they pre-date #19 and are outside its diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:54:22 -04:00
iris-aiandClaude Opus 5 29dd84e04e Point the fork's main at upstream too, so a fresh clone is right
The local fix left `origin/main` at the app's line, so a fresh clone of the
fork -- which has no `upstream` remote -- still resolved `main` to the wrong
base. Force-pushed to `ca2b4b2` with `origin/HEAD` following it; the app's
45 commits stay reachable through `app-pin`, which was pushed first.

Verified by cloning the fork: `git merge-base main <branch>` gives ca2b4b2
with only `origin` configured, the parley migration no longer appears in the
diff, and the pinned `32f6ad8` is still present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:21:10 -04:00
iris-aiandClaude Opus 5 5bf0491ee1 Name the app's Iris line app-pin, and let main mean the PR's base
The `iris` submodule's `main` was the fork's own line -- the app's 45
commits -- and not an ancestor of upstream's main, so it sat four merged
pull requests behind PR #19's actual base. Every obvious command was
therefore wrong in a way that looked plausible: `git diff main...` showed
#10, #12, #16 and #17 as the review branch's work, which is how the parley
text migration's undo path kept being reported as #19's.

The app's line is now `app-pin`, pushed to origin, and `main` tracks
`upstream/main`. `.gitmodules` follows `app-pin`, so `submodule update
--remote` cannot drag the pin onto upstream. The recorded commit is
unchanged at 32f6ad8; only the name it is reachable by has changed.

`origin/main` on the fork is left alone: moving it is a non-fast-forward on
a shared branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:17:08 -04:00
iris-aiandClaude Opus 5 adce0fcdbe Point the handoff at b7b8d09
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:10:22 -04:00
iris-aiandClaude Opus 5 0ace0e017c Record the sixth sweep, and the base every sweep should have used
PR #19's base is upstream/main at ca2b4b2, not the local `main`, which
tracks the fork's divergent line and sits four merged pull requests behind
it. Diffing against `main` shows #10, #12, #16 and #17 as this branch's
work; that is how the parley text migration's undo path kept surfacing in
sweeps. Git cannot record a pull request's base and `main` cannot be
renamed, so the handoff carries the note and the checkout carries
`git iris-base`/`git iris-diff`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:09:53 -04:00
iris-ai 734c521f5d Record the round's last two findings, and the review the old code is owed
`pre-submit-review` arrived after the Rust port and most of Iris were
written, so all of that went in unreviewed and no sweep has covered it. The
surface-texture defect is the argument for a pass of its own.

Nothing in Iris is kept for the app's sake either: the app is to be rewritten
against the new API rather than ported call by call.
2026-09-20 01:32:01 -04:00
iris-ai 718923c217 Record the fifth sweep and point the handoff at 7502176
The renderer, the text store, the input default and the harness, which the
four earlier rounds did not read, plus a second look at `redraw`.
2026-09-20 01:03:05 -04:00
iris-aiandClaude Opus 5 03c8933612 Record the quality sweep and point the handoff at 69ba915
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:21:13 -04:00
iris-ai 362191c3f3 Record the place.rs visibility round and point the handoff at 1ebd4d3 2026-09-19 23:52:13 -04:00
iris-ai 52a5c2f40c Record the scan results for the clarity sweep 2026-09-19 23:38:48 -04:00
iris-ai 55b51d9442 Record the clarity sweep and point the handoff at 7e2b4cd 2026-09-19 23:29:00 -04:00
iris-aiandClaude Opus 5 1442443e78 Move the round detail out of the handoff, and say when a scan is owed
The handoff said "three rounds" over four bullets and carried the detail of
each, which belongs in the findings log. It now names the four and their
commit ranges and points at `LAYOUT_LOG.md`, which gains the naming and
sweep round in full.

The check section split in two: the cheap gate runs on every change
including a rename, because the cold dump is the only thing that catches two
same-typed values being swapped; the seed scans run only when the change can
alter what layout computes, and never with the tree still moving under them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:27:34 -04:00
iris-aiandClaude Opus 5 d12cbcd499 Record the logic sweep and point the handoff at 6c84b6f
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:25:45 -04:00
iris-aiandClaude Opus 5 424f655d60 Say on_axis in the docs, and point the handoff at the tip
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:48:37 -04:00
iris-aiandClaude Opus 5 721a8fc50d Record the naming round in the handoff and LAYOUT.md
Iris PR #19's tip is 2807a92. LAYOUT.md's settled-design section named the
retired `PlaceDescAxis::within`/`shifted`/`sized` constructors and the
`axis`/`axis_mut` accessors; it now says the `_desc` chaining rule, the
`axis` lift, and that every pair is a struct of two per-axis values read
with `[axis]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:25:35 -04:00
iris-ai 61a2f601bb Move the settled layout vocabulary into LAYOUT.md and prune the handoff
The handoff had grown a chronicle of what each commit renamed. What is
durable about it -- the three names, and the one argument a container uses
to say them -- is design, so it goes to `docs/LAYOUT.md` as its own section
and the handoff keeps a pointer plus where the branch stands.

`docs/PLAN.md`'s "frame/extent experiment" entry described a prototype in
retired words; it becomes a pointer, and the one finding it held that was
not recorded elsewhere -- why the extent-relative child-frame trial was
removed -- joins the failed hypotheses in `docs/LAYOUT.md`.
2026-09-19 18:06:28 -04:00
iris-ai 265ce6f829 Record the PlaceDesc argument in the handoff 2026-09-19 17:59:40 -04:00
iris-ai 56437dc90f Point the handoff at the current tip 2026-09-19 17:16:48 -04:00
iris-ai 1f9eea879f Record narrow_rel_base and the None argument 2026-09-19 16:55:57 -04:00
iris-ai 93a6830fcd Record rel_base and the container API, and sweep LAYOUT.md
`frame` is gone from the layout vocabulary in `docs/LAYOUT.md` too, since
leaving the design document in the old words defeats the rename; the
rendered-frame sense of the word is untouched. The `local == UiRegion::FULL`
item is dropped from the handoff: that comment was #18's and is not on the
review branch.
2026-09-19 16:35:02 -04:00
iris-ai 90400f50ac Record the region/placement sweep and prune the handoff
The `extent` rename landed as iris `5642f20`; the handoff had not listed it
as pending at all, and its item 2 named renames that are already upstream.
Say which vocabulary the app has still to absorb, drop the retired-clone
and archive chronicle, and strike the three clarity findings now done.
2026-09-19 14:51:07 -04:00
iris-ai f3c9ab8106 Record Iris review fixes, validation, and consolidated checkout 2026-09-19 13:53:12 -04:00
iris-aiandClaude Opus 5 67584cedda Record what the review of Iris #19 found
Two live defects at cadfba0, each with the repro that showed it and the
fix that answered it: a span places the slot after a collapsed leftover
child a gap early and a gap too long, and a child reused under a parent
that redrew keeps the mask index that parent replaced, so its drawing is
clipped where the subtree used to be. Neither is covered by a test, which
is why the suite is green over both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:20:59 -04:00
iris-ai 3ecd676bcf Land the layout repair's findings and retire the working log
`docs/LAYOUT_LOG.md` existed until the one-ask protocol landed. It has, as
Iris PR #19 replacing #18, so what has to outlive the log moves into
`docs/LAYOUT.md`: the hint rule and the one ask a span makes, the reported
shares that are left and the two-answers design that would remove them, what
the fuzzers reach and what they cannot, the hypotheses not to repeat, and the
counter and dump commands with the numbers they print now. The handoff becomes
what it is for -- where the work stands, which is waiting on a review.
2026-09-19 02:57:17 -04:00
iris-ai e4fada298d Record the step 5 result: hints skip the room ask, and the far pin is not the cost
Measured at a888717 and at the new f6242aa on wip/hint-first, with the
inset experiment recorded as tested and rejected. The plan is now review,
render and land.
2026-09-19 02:24:09 -04:00
iris-ai 1ef31bc9e7 Measure step 5's span pin and say what blocks the sound version
Re-measured the work counters at a888717 (unchanged from adbedaf), and
tested the handoff's claim about a positive span pinning `far`: without the
pin, seed 1's resize goes from 44 draws of 13 widgets to 20 of 9. The pin is
worth removing, and what stops a sound removal is that no `Part` can say
"from the cursor to the end of the box" without naming the box's length.
2026-09-19 01:29:16 -04:00
iris-ai 2fa734cd63 Record what the step 1 review found
The pre-submit review of the six one-ask commits found a scroll placing
content that fits into a window-length box rather than its viewport, and
three comments still calling window lengths frame lengths.
2026-09-19 01:24:22 -04:00
iris-ai 2a3072b90a Record the clean verification at a30971e
Every check in the table was run over the branch head with nothing else
running and no edit in flight: fmt, both clippy feature sets, the suite,
and all three fuzzers over sixteen cases.
2026-09-19 00:50:23 -04:00
iris-ai 5798877829 Mark the rename step done and say what it renamed
The record's boxes are part, asked and placed, LayoutHolds carries window
ranges beside its two pins, and what is left of the layout plan is the
retained cost and the review of the branch as one diff.
2026-09-19 00:40:53 -04:00
iris-ai 150245071f Record the renders and the counters for the frame-as-length step
Step 3 is done: against #18 the render set is byte-identical but for two
pixels of antialiasing in random and one padded block in text moving a
pixel, and a live resize of random matches a cold render at that size
exactly. The cost table is re-measured at adbedaf, where the fuzzer's own
Branch was found pinning the window and hiding what the change costs.
2026-09-19 00:35:42 -04:00
iris-ai 36ce66554b Record what making the frame a window length corrected
Step 2 of the layout plan is done in the experiment checkout, at 1512d84
and 23523ee. The handoff carries the commits and what is left; LAYOUT.md
carries the settled rule, which is now one coordinate unit with the frame
a length of it and both pins beside each other; the log carries what
implementing the plan corrected in it, including the four that would have
shipped as wrong layout and the two rig gaps that hid them.
2026-09-19 00:21:44 -04:00
iris-aiandClaude Fable 5.1 da1807664a Correct the frame-as-length step: Part::of is untouched, movement paths merge
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:59:38 -04:00
iris-aiandClaude Fable 5.1 e0b4308de4 Record Bryan's yes to region nodes as translations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:58:30 -04:00
iris-aiandClaude Fable 5.1 aa1a85b316 Refine the frame-as-length step: one window unit, region nodes as translations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:53:48 -04:00
iris-aiandClaude Fable 5.1 f33981e1e6 Record that padding is an inset and plan the frame-as-length representation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:47:12 -04:00
iris-aiandClaude Fable 5.1 1c52bc4e19 Record that a stack's sizing child decides its siblings' boxes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:17:40 -04:00
iris-aiandClaude Fable 5.1 9f988a8315 Record that both long fuzzers agree at 0ef87eb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:36:33 -04:00
iris-aiandClaude Fable 5.1 82c0ac4960 Record the seed 946 deferral and the fuzzer results at 3091fb8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:30:51 -04:00
iris-aiandClaude Fable 5.1 ef324aa88b Replace the step 3 plan with the one-ask protocol and record why it failed
The handoff now describes wip/one-ask at 3091fb8: a widget draws once in
the box it is asked in, its answer is placed by re-expressing the drawing,
and nothing is drawn again in a box an answer chose. The log records what
the step 3 plan got wrong -- it kept the second draw whose measurement
bit could not be defined, demanded a contract of the answer box, and
narrowed frames by region -- and what fuzzing the new protocol found: the
Part::Of composition dropping a pinned length, and a local redraw that
must be put back in the parent's answer box. The Pad frame rule is left
as a decision for Bryan with its cost stated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:23:49 -04:00
iris-ai 39978ff8ea Record decided-box oracle blocker 2026-09-18 15:07:15 -04:00
iris-ai 3eda6f00c8 Record decided-box contract blocker 2026-09-18 14:15:18 -04:00
iris-ai 280fad7472 Finish the transparent-frames handoff 2026-09-18 13:49:38 -04:00
iris-aiandClaude Fable 5.1 bdddb610c0 Trace the scroll circularity to apply_leftover rather than the protocol
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:25:14 -04:00
iris-aiandClaude Fable 5.1 b2484b900e Record that a leftover share narrows the frame like px and rel
Bryan's decision of 2026-09-18, which answers the Pad question and
makes the code's leftover filter in declared_lens a bug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:14:09 -04:00
iris-aiandClaude Fable 5.1 4b976e240e Say what the two open transparent-frames seeds are and what they turn on
A second planning pass over 49cec82: both seeds are the rule that picks
which answer places a box, shown by shrunk trees, a draw trace and two
counter-experiments, with the recommendation they point at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:03:14 -04:00
iris-ai 152bed7ec4 Keep the transparent-frames plan as written beside what it landed as 2026-09-18 12:32:57 -04:00
iris-ai 1777a92205 Point the handoff at the reviewed head 2026-09-18 01:21:12 -04:00
iris-ai 398e4efaeb Record what transparent frames landed as, and what it found
The plan in this document is now a protocol in the code, on
`wip/transparent-frames` in the layout experiment: what it does, what the
rig measures it at, and the two things it ran into -- what a widget's answer
is the answer to when a container is drawn in two of its own boxes in one
frame, and what `Pad` should be now that a frame passes through. Both are
Bryan's to decide and both are at the top of Next.

The frame/extent prototype's chronicle goes with it: its four findings that
still hold and its two failed hypotheses stay, the rest was a record of how
the code got to a shape it no longer has.
2026-09-18 01:13:40 -04:00
iris-aiandClaude Fable 5.1 5c35efd498 Note that transparent leftover across a span is deferred
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:04:49 -04:00
iris-aiandClaude Fable 5.1 dc45f7bb3e Say what leftover means across a span in the transparent-frames plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:38:48 -04:00
iris-aiandClaude Fable 5.1 2bee0b4b53 Plan the transparent-frames layout protocol
Decided with Bryan on 2026-09-18: a child's frame is forwarded through
spans, stacks and scrolls and narrowed only by what is decided from above,
so rel is never a fraction of a self-sized box; placements are parts of
the parent's extent in frame units from its start, so a moved extent
re-places its children exactly; the placement pin becomes a symbolic
length; and a local redraw asks at the offer placement without deferring.
Lists the steps in order with the check for each and the failures that
mean stop rather than patch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:18:49 -04:00
iris-aiandClaude Fable 5.1 cb4557f1bc Trace the many-phase gap to local-redraw deferral, not the placement pin
The experiment's `many` loss against e44dea3 came from `redraw` refusing
any widget whose given frame is not its offer and marking its parent
instead, which under the frame/extent protocol chains to the root because
a span gives its children its own placement across itself as their frame.
Record the trace, the measured bound with the pin off, the local re-ask
branch `wip/local-reask` and its two remaining depth-6 counterexamples,
and point readers at this section before the earlier explanations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:52:15 -04:00
iris-aiandClaude Opus 5 12b7364998 Record the extent-child and ordered-walk landings, and where the gap really is
The placement pin was not most of it. Deleting the read prices no dependency
at all; expressing it properly is `e6ba570`, worth 19% of seed 13's `many`
and 20% of `resize`. What refuses most reuses is the extent contract -- 819
of 1,203 refusals a frame -- and that is the measuring ask and the placing
ask being two different extents, which `Span` is the remaining reader of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:33:33 -04:00
iris-aiandClaude Opus 5 384b6a1150 Price the frame/extent experiment against #18's head, and name the placement pin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:02:02 -04:00
iris-ai ccc42f34f8 Document the causes and correction of layout invalidation cascades 2026-09-17 17:27:27 -04:00
iris-ai 32151f7f9f Record retained extent-frame progress and remaining layout gap 2026-09-17 16:41:06 -04:00
iris-ai 035c7f20e8 Record glyph emission savings and remaining layout costs 2026-09-17 16:18:18 -04:00
iris-ai 264dc4f0c2 Record frame-extent performance gains and alignment verification 2026-09-17 15:54:23 -04:00
iris-ai 8f1a5e0a46 Record exact layout equality and measured reuse improvements 2026-09-17 15:11:23 -04:00
iris-ai 0080bcbcba Document tested frame and extent prototype and remaining costs 2026-09-17 14:50:22 -04:00
iris-ai 3084491b9b Record the resize walk and the re-parenting defects, and what verified them
The first item on the plan landed as two commits on #18. The re-parenting
half turned out to be two defects rather than the predicted one -- the old
parent's child list erasing a subtree it no longer draws, and the settling
walk ordering by a stale depth -- and the `depth()` assertion the note
expected to catch it catches only the second.

`dirty_size_under` is deleted, so the section that argued for it is marked
superseded rather than removed: it says what the bottom-up walk is buying.
2026-09-17 13:12:55 -04:00
iris-aiandClaude Fable 5.1 8c16f9d0fd Record the frame-and-extent finding and the resize walk, and drop the measure/draw split
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:34:41 -04:00
iris-ai 0cdda1713f Record the span boundary landing and what the guard experiment showed 2026-09-17 05:13:30 -04:00
iris-ai be895375ec Record the bottom-up settle, and park two real defects on branches
Bryan's ordering replaces the stale-answer check with something the shape
of the frame makes impossible: nothing shallower is drawn while anything
deeper is dirty, so a parent never reads an answer that is about to move.

The other two are written up rather than landed. A stack takes its sizing
child's fraction twice, which no oracle can see because warm and cold are
wrong alike; the fix is three grid steps short of the tolerance. And
padding is to go outside what it pads with an `Inset` beside it, which one
unaccounted halving is holding up. Both are pushed as `wip/` branches.
2026-09-17 04:50:50 -04:00
iris-ai faa4e98311 Record the text-width defect, and that the depth-4 seeds are fixed
The two seeds a wider scan found were one defect, older than the branch: a
text reported a width rounded to the step below the line it measured, so
the box that came back to it broke differently. Two tolerances were holding
that together and both are gone.
2026-09-17 03:21:35 -04:00
iris-ai f64d6a8d4d Record the report rule, the stale-answer guard, and two open seeds
The `rel` reading Bryan decided landed, and with it a defect the retained
machinery had all along: a span that can overflow itself makes the two asks'
boxes differ far enough that a reused answer is read before the subtree
under it has settled.

Widening the fuzzers to 2000 seeds at depth 4 then found two warm/cold
divergences that predate both commits, so the branch is not correct and the
routine verification does not say otherwise. They lead "Next".
2026-09-17 02:58:25 -04:00
iris-aiandClaude Fable 5.1 4763e1a70d Record the snap as approved and CPU rounding as recommended
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:49:20 -04:00
iris-aiandClaude Fable 5.1 c0b0921973 Record the 2026-09-17 layout review in the handoff and prune its history
The review's verdict, Bryan's decisions on rel and on the measure/draw
split, the four findings with their measurements, the residual and the
shader snap, and the reordered queue. The chronicle of closed defects,
superseded verification lists and the cross-fixture tables the document
said not to compare are gone; the rules, lessons and current measurements
stay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:43:17 -04:00
iris-aiandClaude Opus 5 4e75019b9a Record the pixel path closing, and the mask a redraw inherits
A box in pixels is threaded down the draw now rather than composed back up
the move chain, which closes the offer chain breaking at a region node,
makes `Holds::through` an exact preimage, and retires `redraw`'s third ask.
The section that diagnosed the defect becomes the one that states the rule,
with what the plan got wrong recorded beside it: `px` cannot be stored on
`ActiveData`, because a resize that every `Holds` admits redraws nothing and
would leave it stale everywhere.

`AGREE_STEPS` stays 2, measured rather than assumed: one step passes the
100-seed oracle and fails the 400-seed shrinker on `resize-size` by
0.002 px, so the second step belongs to the resize path re-expressing a part
as a fraction of a box that changed length.

The review of that change found an older defect of its own -- a `Masked`
widget settled on its own panicked, since `redraw` handed it its own mask as
the one it inherited -- and the invariant that closes it is here too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:21:29 -04:00
iris-aiandClaude Fable 5.1 9a26862bce Record the frame rule, the decided axes, and the root entry's removal
Iris 5b78002 on #18: a child's answer comes back in the asker's frame, the
near-edge override is per-axis decided flags, and the window is no longer
a move entry. Also records the threading plan that closes the region-node
offer defect, and why widening the grid would not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:02:39 -04:00
iris-aiandClaude Opus 5 82646e8408 Record the narrow step left in the pixel path
It was in 5f16617's message and not here, which is the wrong place for it:
the handoff is what a session picking this up cold reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:09:34 -04:00
iris-aiandClaude Opus 5 9f825ed6fb Diagnose seed 220: the offer chain breaks at a region node
`5f16617` carries the composed box down the draw instead of walking back up
for it, which made exact composition a 3.6%/3.3% speedup rather than a cost.
The numbers are in "Performance" and the i64 item.

And seed 220 is diagnosed rather than left as a seed. `offered_region`
composes a widget's offer through its ancestors' offers except where the
parent is a region node, where it falls back to the node's **placed** box.
`Scroll` is where placed and offered differ -- it offers its content the
viewport and places it in a box as long as the content -- so the offer
becomes a function of the answer being re-derived and the old answer
confirms itself. It is the fourth defect this session to come down to the
offer against the placed box.

The write-up has the trace both ways, the fact that each child order settles
to its own fixed point so the tree is not unsettled, that marking everything
dirty converges warm onto cold, and the coordinate-frame problem that makes
the two plausible fixes awkward -- `offer` is in the parent widget's frame
and `region` in the parent node's, so neither answers "was this placed where
it was offered" alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:50:22 -04:00
iris-aiandClaude Opus 5 da2f93254e Record the exact composition, and the seed it stops one step short of
`45a7176` composes a box down its move chain on a grid fine enough to round
once instead of once a level. `Holds::through`'s two-route allowance is two
half steps where it was three, and it cost nothing: 1,880M instructions and
755M cycles against 1,908M and 760M, with all twenty-five work counters
identical. What paid for the widening is that a length composes on its own in
half the multiplies both ends of a box need, and that only the fraction needs
the extra room.

The i64 item in "Next" keeps the measurement that redirected it, since
aiming the work at the screen invariant would have been wasted.

One half step further is arithmetically available and not taken: shrinker
seed 220 on `reorder` then diverges warm against cold, by re-breaking a
wrapping text whose reported width moves a `Branch` onto its other subtree.
That is written up under the `Holds::through` invariant with its reduction
command and its ten-widget tree, and it is the next thing to chase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:31:37 -04:00
iris-aiandClaude Opus 5 b0c13b85a9 Record that the shader, not the grid, decides screen pixels
`d21a215` answers the API question: one widget keeps one length per axis, and
`Wrapper` -- what `WidgetPtr` already was -- is the second widget the second
length needs.

The `i64` composition item is rewritten around a measurement that changes
what it is for. `prelude.wgsl` decodes the raw counts into `f32`, walks the
move chain in floats, and lands every edge on a whole pixel with
`snap_floor`. Checked in the render rather than read off the source: there is
no partially covered column anywhere along `tabs`'s band of rounded rects, so
every box edge is hard, and the two `pad(10)` gaps are exactly ten pixels on
both sides of the truncating multiply. The screen invariant is therefore
already as good as integers allow, and exact composition cannot improve it.

What it can still buy is layout's own decisions -- the `Span` leftover
boundary, the queued clamp crossover, `Holds` validity, warm-against-cold
agreement -- with `Holds::through`'s two-route allowance as the test of
whether it worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:50:50 -04:00
iris-aiandClaude Opus 5 82c006cd59 Record four fixes that came out of one look at the tabs render
Iris #18 is at `e166e00`, and warm layout now agrees with cold on every rig
the repository has: all fifteen shrinker cases at 400 seeds of depth 5, and
the oracle at 1000 seeds of depth 6. Both pass for the first time. The
handoff's two standing leads, shrinker seed 288 and oracle seed 326, were one
defect and are gone.

What found them was asking why the `tabs` render differed, rather than
counting the pixels that differed:

- `d8ae9c3`: `redraw`'s second ask handed the final box over as if it were an
  offer, so a locally redrawn widget with its own alignment was placed twice.
- `2bc6bdf`: `Stack` and `Pad` overrode every child's alignment with the near
  edge, which is owed only where the box really is the child's own answer.
  `tabs`'s counters and `text`'s narrow panel match canonical `main` again.
- `38eba54`: `Holds::through`'s allowance was four half steps where three is
  the floor, and tightening it moved none of the rig's work counters. Why it
  cannot go to zero is recorded with it.
- `e166e00`: pins what the gap question was actually about -- a length in
  pixels is that many pixels at any nesting depth, and a share is one or two
  steps off, which is a trade for the row filling exactly.

Two corrections to this document. The bisect it reported for the
partial-repaint defect named a commit that cannot have caused it, and the
reasoning built on that was wrong. And its claim that `tabs` "differs only in
the widget count it prints about itself" was checked at `8220a78` and left to
cover `d3b0ebf`, which changed the render twice more.

One API question is open: `.width()` now overwrites what `.sized()` set,
since both are properties of one widget, so `tabs`'s centred square is a bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:18:26 -04:00
iris-aiandClaude Opus 5 a271795408 Record the partial-repaint fix, the truncating multiply, and what is left
Three commits on iris #18, now at `08c9d5a`.

`aea878d` fixes the ignored `redrawing_one_widget_does_not_move_what_scrolls_
around_it`, and the bisect this document reported for it was a red herring:
`95fb4f9` cannot be the cause, since the failing tree has no `Masked` in it.
The cause was `redraw` skipping a dirty widget's second ask whenever the
offer and the final box were the same *length*. It also closed three of the
four unreduced shrinker leads and two of the three depth-6 oracle seeds; the
two that are left are written up with their reductions.

`60367d8` stops `Holds::through` allowing for a rounding that did not happen,
which compounded down a chain of widgets each taking the whole of its parent.

`08c9d5a` makes `Fixed::mul` drop to the step below (Bryan: truncation is
preferable at this point), with the two short-circuits priced against the old
multiply. Measured against the float head in `iris-float-cmp`, which is the
comparison that was asked for: 1,800M instructions and 715M cycles against
1,761M and 688M, from 1,915M and 777M -- three quarters of the instruction
gap and two thirds of the cycle gap closed. The float pair's work counters
differ and the fixed-point pair's twenty-five are identical, so only the
second number is same-work-at-a-different-speed; both are in the table.

Also recorded: `cycles:u` returns garbage readings in this VM the way
`instructions:u` does, and a whole set of three can be garbage at once;
`tabs` is no longer a byte-identical render and why; and Bryan's idea of
composing in `i64` and narrowing only when storing, with what is already
protected, what is not, and the test that would say it worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:09:45 -04:00
iris-aiandClaude Opus 5 06f9ae7799 Measure what truncating costs instead of asserting it
This section said truncating "breaks the one rounding the grid is for" and
left it at that, which is the same fault it withdraws the fused multiply-add
for: a claim nobody ran.

Run, it fails one exactness test by a step -- a share reaching 199.99902 of a
200px row -- and does not fail the warm-against-cold oracle any harder than
the head, which gives the same three failures on the same seeds within
0.002px. So the 57M cycles are not obviously bought with layout drift, and
whether a share stopping a thousandth of a pixel short is worth them is a
decision rather than a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:00:14 -04:00
iris-aiandClaude Opus 5 bb5b79f2f6 Record the drift a partial repaint causes, and the rigs that reduced it
The oracle and the shrinker had separate generators and separate copies of
every scenario, so a failure one found could not be handed to the other: a
seed cannot be made smaller. Iris `98d4e98` splits growing a tree into
describing one and building it, and puts the cases in one place both rigs run.
Any failing seed now reduces with one command, which is written down here.

What it found first: marking one widget for redraw moves a span inside a
scroll 24px against a cold tree of the same state -- a whole child's height,
not a rounding. Five widgets, bisected to `95fb4f9`, where `Masked` began
reporting its box rather than its inner's size. Written out and ignored in
`tests/cases/unsettled.rs` until it is fixed.

Two claims in the verification list were withdrawn against a re-run rather
than an argument. The shrinker's "all five cases at 300 seeds of depth 5" does
not carry to a rig that runs fifteen cases over different trees and fails at
400. The 1000-seed depth-6 line does not hold at `5ed9e87` either, which fails
it on seed 40 after a resize -- so that run was never the clean baseline it
was quoted as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:59:33 -04:00
iris-aiandClaude Opus 5 10e46522b4 Withdraw the fused multiply-add, and measure what the grid costs
The section claimed what was left of fixed point's cost was "an integer
multiply with a rounding against a float fused multiply-add". Both halves are
wrong and the evidence is in the binaries.

`5ed9e87` contains no FMA instruction, and rebuilding it with
`-C target-feature=+fma,+avx2` still produces none: Rust does not contract
`a + b * c`, so `LerpUtil::lerp` is `subss`, `mulss`, `addss` on any target.
The float side never had the instruction the grid was being compared against.

Nor is the multiply the cost. Replacing `shift_round` in `Fixed::mul` with a
truncating shift -- all twenty-seven work counters diffed identical -- takes
43M instructions off and leaves cycles inside the noise. What moves cycles is
whether `UiSpan::within` inlines: the rounding and both short-circuits removed
together are worth 118M instructions and 57M cycles where separately they are
worth 61M and 8M, and `<UiSpan>::within` stops being a symbol. None of that is
shippable, but it is where to push.

Three other entries corrected against measurement rather than argument. The
division is not 0.00% of cycles -- that was `apply_scalar`; `Holds::through`
runs 2.23M `i64` divisions a 500-frame `many` and holds the divider busy 21.3M
cycles. `d75a1e2` and `4cbb242`'s short-circuits no longer earn anything now
that `Fixed` wraps. Branchless `shift_round` still loses, for the opposite
reason to the one recorded: it is the rounding's size, not its branch.

And the measurement posture: cycles spread 1-3% across nine runs of one
binary rather than 0.23%, `instructions:u` returns a garbage value in roughly
a quarter of readings, and an instruction count moves 0.5% across a rebuild of
identical source. Quote deltas against a baseline built beside the thing
measured.

Checked: `/home/bob/repos/iris-float-cmp` carries `5ed9e87` with
`Edits::fixed_branches` applied, and is recorded under "Rigs and reproduction"
so the comparison can be re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:07:02 -04:00
iris-aiandClaude Fable 5.1 663a95f4a2 Record what wrapping saved, and the rounding question left open
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:23:22 -04:00
iris-aiandClaude Opus 5 9ed90505b3 Withdraw the 3x, and record what a move actually costs
The performance section reported a cost the fixture had invented. `Branch`
picks which subtree to draw from a measured length, so seed 1 draws 88
widgets at `5ed9e87` and 115 at `bd6de71` -- the ms table was comparing
three and a half times the work. Measured on one tree, fixed point costs
about a fifth to a quarter.

Also corrects where the cost is. Cycles say the `i64` division is 0.00% of
`apply_scalar` and the multiply 1.5%; it is `saturating_add`, five
instructions with no `i32` vector form. Cycle counts are steady here to
0.23% -- it is wall time that varies 2x, not the counters -- and IPC is
unchanged across fixed point, so this is instruction count and not stalls.

Records the four wins taken (-7.4% cycles, -12% instructions), the four
things tried that made it worse, and the one lever left that needs a
decision rather than a measurement.

Also: the `Len`/`LayoutLen` split and `has_exact_size`; that `Scroll`
reports `LEFTOVER` rather than its content's size, which this said wrongly
in one place and rightly in another; and that the clipping `debug_assert`
holds mask-setters only, which is why a `LazySpan` does not trip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:08:00 -04:00
iris-aiandClaude Opus 5 388a6a060d Bring the handoff to what the last session shipped
`OrthoSize` was deleted in `9d8415d` and the handoff still described it as
restored; the surviving rule is `Painter::ruled`, and the circularity lesson
is restated without the enum's names. `HOLDS_EPSILON_PX` went with
`39e4ca2`, the clipping `debug_assert` the alignment section asked for landed
as `95fb4f9` and is narrower than it asked, the glyph conversion queued under
"Next" landed as `11c55bc`, and `LAYOUT.md`'s stale sections are §4, §5 and
density rather than §2.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:21:37 -04:00
iris-aiandClaude Opus 5 f83be016ba Update the layout measurements to the current head
Three more roundings and divisions taken out after measuring each, one
optimisation tried and reverted with its number kept so it is not tried
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:14:18 -04:00
iris-aiandClaude Opus 5 6081726314 Record what fixed point cost, and what it was
Three times slower in layout, and two thirds of that was a stacked child
redrawing twice a frame rather than the arithmetic. The counters are what
found it, once they could say "another layer" at all.

The performance table is rewritten around the fixed-point commits and in
milliseconds a frame, since the fixture it used has changed and the old
instruction counts cannot be compared across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:07:27 -04:00
iris-aiandClaude Opus 5 2d860587a4 Bring LAYOUT.md §2 and §3 to what shipped
§2 described `Painter::place`, `move_offsets`, `resolved_region` and
`set_instance`; three of those four names no longer exist. What shipped is
the opt-in region node, a box rather than a translation, and remapping for
everything that did not opt in.

§3 described `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
answers, which are gone: the `Holds` interval says the same thing per drawing
rather than per widget type, and says how far. The 0.05 px comparison it
quoted is equality on the grid now.

The trait in §1 has two methods rather than three, and the line numbers it
cited have all moved; they are dropped rather than corrected, since the names
are enough to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:50:58 -04:00
iris-aiandClaude Opus 5 617331f913 Record what the last of the fixed-point imprecision is
Traced rather than guessed: the same box reached two ways, each rounding
where the other does not. Two of the three are fixed, the third is priced --
it costs the retained resize path, at a thousandth of a pixel.

`OrthoSize` is deleted rather than paused; the decision it was waiting on was
that a span asks whether a rule already answers for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:42:56 -04:00
iris-aiandClaude Opus 5 ddf746d8a4 Record fixed-point layout, and what it left open
Four commits on #18 put layout on a grid: `Fixed<SHIFT>`, then positions,
lengths, and the last of the pixels with `Holds`. The invariants that were
about float rounding are rewritten rather than annotated -- the 0.05 px
comparison is equality now, a move is a translation rather than a
re-expression, and the span boundary needs no margin.

What is left open is written down where the next session will look for it:
the remaining step of imprecision is one rounding between two ways of asking
how long a box is, and `OrthoSize` is paused on a decision rather than
started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:02:46 -04:00
iris-aiandClaude Opus 5 1a73aba1cd Record the leftover boundary, and close the reorder case
The shrinker's `reorder` case is no longer red: its two seeds were a span
deciding whether anything was left over on a box length its own answer had
produced, which comes back a few bits off. `5ed9e87` in `/home/bob/repos/
iris-pr18` moves that boundary by `HOLDS_EPSILON_PX` of room instead of
softening the comparison, so the validity range stays exact where generated
seed 16 needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:49:28 -04:00
iris-ai 76334cd5aa Update alignment handoff 2026-09-15 23:13:40 -04:00
iris-ai 085fc97334 Record why placement cannot be applied after a draw
Three attempts at "draw the widget, then move its drawing to where its
alignment says" failed, and the notes are worth more than the attempts: the
move is a change of coordinate frame, and no split of the stored state
carries it. Moving a widget's region with its drawing has a later local
redraw ask a differently rounded question, which crosses the one boundary
`Span` needs exact. Leaving the region alone lets the placed box accumulate,
because `try_reuse` returns a clean subtree's size without walking into it.
Both are measured.

Also recorded: that a container reporting a child's size while handing it a
bigger box places that content twice, which is what the align override is
for; that a widget must not report more than it occupies, which is why
`Scroll` now fills both axes; and Bryan's decision that alignment is one
fraction per axis rather than four directions, with the middle as the
default because the edges assume a direction.

The alignment work is in the working tree at `8220a78` and not committed.
Its one red case, and the four things queued behind it, are listed.
2026-09-15 21:26:04 -04:00
iris-ai cf7b33da39 Record why a move recomposes, and settle alignment's ownership
The measurement is the part worth keeping: adding an offset to a moved
subtree's stored regions drifts linearly and without bound, so the fraction
path is there for accuracy, not generality. `tests/drift.rs` on the Iris
branch pins it.

Built-in alignment and size moves onto #18 rather than waiting for it, with
ownership agreed -- properties on the `region_node` mechanism, a declared
length winning per axis, and a size rule that carries min/max/clamp so a
clamp is resolved where the box is decided instead of by a widget reading
its own pixels.
2026-09-15 19:27:30 -04:00
iris-ai 8db0969d9d Update region-node handoff 2026-09-15 18:03:37 -04:00
iris-ai 7b8b43a7d2 Record explicit span orthogonal sizing 2026-09-15 16:41:03 -04:00
iris-ai 3a5d96a8ae Update Iris extraction handoff 2026-09-15 16:07:17 -04:00
iris-aiandClaude Opus 5 af751599f6 Make the handoff enough to start the leftover work from cold
Rewrites the opening as state rather than a chronicle of the last few
commits, and gives the parked rule its own section: what the owner asked
for in her words, the half that is written and works with the code in
it, the four ways of asking that were measured and what each did, the
seed that shows it most clearly, and the design question underneath --
which is the same one holding OrthoSize. Also folds the declared-length
rule into the invariants, brings the vocabulary to leftover, says the
suite is eight seconds and must stay that way, and writes out the image
replay rather than pointing at a file in /tmp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:32:47 -04:00
iris-aiandClaude Opus 5 df55e181d9 Record the rename, and what four ways of asking about leftover did
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:27:08 -04:00
iris-aiandClaude Opus 5 a128648bd2 Record the declared-length fix, and park the rest-with-no-leftover rule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:52:04 -04:00
iris-aiandClaude Opus 5 324f0f02ef Sharpen the declared-length defect, and what a negative share does
Only rel doubles: apply_rest turns rest into the whole box and px
coincides. Aligned and Scroll honour a hint the same way Span does, so
all three double it. Also corrects the note about a fractional hairline,
which fails because a fraction is a fraction of its own box rather than
through any rounding, and records that a span whose shares run out walks
its children backwards instead of overflowing forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:44:55 -04:00
iris-aiandClaude Opus 5 e6b9cb29b7 Record the doubled declared fraction, and what a squeezed span does
The text reference render is not byte-identical after all: a declared
rel or rest length is applied by the span and then again inside SetSize,
so .width(rel(0.5)) draws its child at a quarter. Also replaces the
example that measured one-pixel lines with what the tests now say, since
a span short of room overlaps its fixed lengths rather than collapsing
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:32:12 -04:00
iris-aiandClaude Opus 5 89d20c31dc Re-measure #18 against the code it replaces, and retire the tabs divergence
The tabs difference does not reproduce at f61e893 against upstream/main
built in its own worktree, so nothing is waiting on a decision. Records
the instruction counts, frame times and memory taken today rather than
mid-branch, and what a one-pixel line and a one-pixel gap do under the
chain, which is the question the pixel difference was really about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 02:56:11 -04:00
iris-aiandClaude Opus 5 155b7650a9 Name the handoff doc in AGENTS.md and generalise it to HANDOFF.md
A fresh session had no way to learn the handoff existed: AGENTS.md named
only docs/PLAN.md, and the filename tied the document to one piece of work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 02:33:53 -04:00
iris-aiandClaude Fable 5.1 39409b6683 Record the derived-box rule and what the fuzzers found in the handoff
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:17:29 -04:00
iris-aiandClaude Opus 5 98dcb31c23 Bring the handoff up to date with what the fuzzer found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:42:13 -04:00
iris-aiandClaude Opus 5 1ffe6ea067 Note the immediate-mode path Iris proposed, and what it would not catch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:02:06 -04:00
8 changed files with 3938 additions and 921 deletions

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
[submodule "iris"]
path = iris
url = git@git.arirex.me:iris-ai/iris.git
branch = main
branch = app-pin
+5 -3
View File
@@ -6,9 +6,11 @@ submodule. The
Android app uses a thin Java activity and `android-view`; desktop uses winit.
`docs/PLAN.md` is the design source of truth. Read it before structural work
and update it when a decision changes. Working documents are pruned as work
lands: preserve current invariants, measurements, and failed hypotheses, not a
chronicle of completed tasks. Do not create a decisions log.
and update it when a decision changes. `docs/HANDOFF.md` is where the work in
flight stands; read it first in a fresh session and keep it current. Working
documents are pruned as work lands: preserve current invariants, measurements,
and failed hypotheses, not a chronicle of completed tasks. Do not create a
decisions log.
## Architecture
+466
View File
@@ -0,0 +1,466 @@
# Handoff
Where the work in flight stands. The settled layout design, the vocabulary
and the measurement method are in `docs/LAYOUT.md`; what the review of #19
found is in `docs/LAYOUT_LOG.md`.
## A sweep for allocation, and the cost per child, is on #19
The fourteenth sweep landed at `97fca76`, with the refcount removal below at
`429a4f1`. It read the whole branch again, aimed first at what allocates and
then at whatever the reading turned up. Nine findings, the largest two both
about cost per thing rather than per frame:
- **A container's draw was quadratic in its children.** Five per-child steps
each asked "have I done this one already?" by searching a list; at 1,600
children 70% of the redraw was those searches. A draw now takes a `DrawId`
and leaves it on every widget it asks about, which answers the question in
one read -- one note per widget suffices because the handle a container holds
a child by cannot be cloned. `tests/children_cost.rs` is new and is the only
rig here that varies width: 3.680 ms to 0.811 ms at 1,600 children, and flat
per child at every count. A transcript is exactly this shape.
- **A mask's rectangle was resolved once per fragment**, a walk up to 64 links
long, although it is the same rectangle for every fragment of an instance.
Resolved in the vertex stage now, which takes `masks` and `move_offsets` out
of the fragment stage entirely. GPU timestamps over one screenful of clipped
rows: 838.4 us to 95.7 us at chain depth 64, 141.2 to 69.4 at depth 8, and
unchanged at depth 1. `chain_cost.rs` could not see this -- its instances are
two pixels wide so vertex work dominates -- and has a masked fixture now.
The other six: `TextBuffer::shape` copying its attrs before the check that
would not need them, which was one allocation per named-family text per frame
and 800 to 0 over a measured hundred frames; `cargo test --release` failing,
because a `#[should_panic]` test asserts a `debug_assert` release does not
compile; `Fixed::div` and `Div for Fixed`, reached only by their own test,
whose deletion also settles the `div`-versus-`ratio` zero-divisor item this
file used to list; `Moves::remove` re-uploading an array it cannot have
changed; and two comments this branch's own commits falsified -- one saying
`widget_trait!` takes no attributes after `76aaf06` taught it to, one saying
`Scroll` clips when masking is a capability a caller opts into with `Masked`.
The ninth is the one Bryan asked for on 2026-09-21, having first been left for
the pre-gate pass: `StrongWidget` allocated a `RefCounter(Arc<AtomicU32>)` it
could never use, since it deliberately has no `Clone`, so every widget paid a
heap allocation and two atomic read-modify-writes for a count that stayed at
zero. It is the id, the sender and the type now, and `Drop` sends;
`size_of::<StrongWidget>()` is 32 rather than 40. `RefCounter` stays for
`TextureHandle`, which does clone, trimmed to what that needs.
It also left five things, with the numbers. Text allocates about six times per
re-broken paragraph per frame, and removing all of them is **0.07%** of
instructions (3.7107B to 3.7081B over 500 sweeping resize frames of 40
paragraphs), so the count is recorded rather than the machinery built --
allocation count and cost are not the same quantity, and this is where they
part. Seven dead items older than #19 are listed under the pre-gate review
below rather than fixed here.
`docs/LAYOUT_LOG.md` has all of it. Verified: format, clippy both ways, 190
ordinary, 193 diagnostic and 189 release tests, the cold dump byte-identical to
`cbccfb6` across all 34,986 boxes, all three seed scans, the three deferred
corpora, and `mask_clip.rs` reading the clipped pixels back off the GPU to the
same 6,000 in the same bounds before and after the shader change.
## Built-in bounds replace `MaxSize` on PR #19
Bryan clarified that the built-in attribute should replace the wrapper.
Work is in `/home/bob/repos/iris-deferred`, on `layout/one-ask`, at
`cbccfb6` (pushed). Bounds now constrain the offer in `Placing::ask`;
declarations are retained as resolved window lengths, so movement does not
decide the bound again.
`.max_width`/`.max_height` now edit the same widget, like the minimum
helpers, and `MaxSize` is removed. Preferred lengths and bounds are
independent, so either order of `.width` and `.max_width` works. The app pin
is unchanged.
The review fixed three cases: `window_holds` erasing an ask's bound crossing
(seed 104, depth 5), inactive bound edits failing to update the parent's offer
dependencies (seed 144, depth 6, reduced further to three widgets), and a
fractional intrinsic cap being read against its already-allocated slot.
The latter has an absolute 75-in-300 geometry check; warm/cold agreement
alone cannot catch it.
206 ordinary and 210 diagnostic tests pass, with warning-clean Clippy both
ways (207 and 211 at `2ac0843`, less the duplicate test the sweep merged).
All three layout scans pass (400 depth 5, 1,000 depth 6, 2,000 depth 4), as
do 400 depth-5 trees in each of three deferred-request corpora. All 34,986
unbounded cold boxes match `4cb6f68`; bounded offers intentionally change.
The final unbounded-path optimization preserves every bounded cold box too.
Five-run release instruction medians show capped rows using 21.7729.64%
less CPU work than the wrapper. Plain/fixed-width retained resizes differ by
under 0.05%; forced redraws improve about 0.3%. Text workloads differ by at
most 0.13%. Independent preferences and bounds cost 32 extra inline bytes per
widget; all three allocation fixtures remain allocation-free after warm-up.
`docs/LAYOUT.md` records the contract and measurements. Artifacts are in
`/tmp/attribute-bounds/`.
## A request is held in the arena its nodes are allocated in
`05e6ced`. `SizeRequest` was a second expression shape beside the one
`RequestArena` already had -- the same three operators, the same constant fold
written twice, and an `import` that walked a pointer tree rebuilding it as
arena nodes. Bryan settled it on 2026-09-20: one node type, an arena, and no
refcount, since nothing shares a request and nothing outside widget code holds
one. A rule's expression now holds a small arena of the pass's own node type,
and `import` grafts those nodes across. A plain length stays inline, so
`size_of::<SizeRule>()` is 40 either way. `bounds_cost` `MODE=cap` is -1.34%
and `revision_cost` resize -0.79% in release instruction medians.
`docs/LAYOUT_LOG.md` has the full entry. Verified with all three seed scans,
the three deferred corpora, 206 and 210 tests, and the cold dump byte-identical
to `2ac0843` across all 34,986 boxes.
## A full sweep of #19 is submitted on it
The thirteenth sweep landed at `cbccfb6`. Unlike the twelve before it, it
read the whole branch rather than one commit, and it is the first review of
`f48e04e`. Seven findings, the largest being that a length of zero printed as
the empty string: `Display for LayoutLen` leaves out each part that is zero,
and `f48e04e` had just pointed `Debug` at `Display`, so a request of zero
printed as nothing in the only place requests are compared -- and
`scenario::describe` printed a `.width(0)` rule as `-`, which is what it
prints for a widget with no rule at all. Also: `Fixed::ceil_from_f32` stepped
past the top of the grid and wrapped, so the largest measurement came back as
the most negative length; `Moves::depth` walked the move chain a second way
with its own copy of `CHAIN_LIMIT`; `Harness::set_len` claimed to set a
length "the way `.width()` sets one" and dropped any bound beside it; three
rigs each spelled "one seed, or a range of them" by hand; `diag::outside`
could count a refused reuse and explain it with nothing; and
`cases/deferred` sat outside `suite.rs`'s alphabetical list.
It also re-measured a recorded hole. `Sow::bound` grows bounds in pixels
because two depth-5 trees once disagreed warm against cold with fractions in
them -- named by seed, and `generated.rs` says in its own comment that seeds
stopped naming those trees when the leaves grew images. 600 depth-5 trees
over all sixteen cases now agree with every bound a fraction (93.18s). The
generator still grows pixels, because `deferred_generated.rs` already varies
that dimension and growing fractions here would move every box in the cold
dump; the comment says that instead of describing an open defect.
`docs/LAYOUT_LOG.md` has all seven, with the four things the sweep looked
at and left and the one it withdrew. Verified: format, clippy both ways,
208 and 212 tests, and the cold dump byte-identical to `f48e04e` across all
34,986 boxes.
## A quality sweep of the request arena is submitted on PR #19
The twelfth sweep landed at `f48e04e`. It is the first review of `05e6ced` --
one node type for a request, an arena per owner, and `Widgets::edit_bound`.
Four findings, the largest being that the one path copying a request into
another arena was run by nothing: all 206 tests passed with a `panic!` in it,
because every expression in the corpora and in the cases compares an
expression against a plain length. A fixture with absolute geometry and a
fourth arm in the deferred corpus cover it now, and the path was already
correct. The other three: `Debug` on a request still printed the derived
arena its own new `Display` was written to avoid, a method asked nothing of
its receiver, and a closure parameter shadowed the rule it was called on.
`docs/LAYOUT_LOG.md` has all four, with the four rules it tripped and left.
Verified: format, clippy both ways, 207 and 211 tests, the cold dump
byte-identical to `05e6ced` across all 34,986 boxes, and 400 depth-5 trees in
each of the three deferred corpora in 200.95s.
## A quality sweep of the bounds work is submitted on PR #19
The eleventh sweep landed at `ea1f836`. It is the first review of `2ac0843`
-- `SizeRule` from an enum to a preferred length beside an independent bound,
the offer constrained in `Placing::ask`, and `MaxSize` removed. Eight
findings, the two largest being a hint read that answered "cannot say" above
the diagnostics and so counted as no read at all, and five spellings of
reading one environment variable across the rigs, which is the class the
tenth sweep found one commit earlier -- there is now one `env` in
`tests/rig/`, used by all six. `bounds_cost` also verified 128 regions inside
its measured loop; the check moved out, and the 0.65% it measured is written
beside it. `docs/LAYOUT_LOG.md` has all eight, with the two rules it tripped
and left. Verified: format, clippy both ways, 206 and 210 tests, 400 depth-5
trees warm against cold in 64.19s, and the cold dump byte-identical to
`2ac0843` across all 34,986 boxes.
## A quality sweep of the deferred system is submitted on PR #19
The tenth sweep landed at `4cb6f68` in `/home/bob/repos/iris-deferred`. It is
the first review of `76aaf06` through `0e838e9` -- the bound rules,
the deferred request path and the invalidation fix. Ten findings, the largest
two being a bound's held length looked up a second time through a value that
could not promise the end it named (`Bound::at` `expect`ed it), and a bound in
pixels pinning the rel base it is not read against. `docs/LAYOUT_LOG.md` has
them all, with the two rules it tripped and left and two corpus questions for
Bryan. Verified: format, clippy both ways, 197 and 201 tests, all three seed
scans (69.02s, 174.87s, 330.39s), and the cold dump byte-identical to
`0e838e9` across all 34,986 boxes.
## Performance sweep is submitted on PR #19
The performance fix is `0e838e9`. It keeps dependencies only for size requests the allocator uses, and borrows
`Widgets::size_rules` rather than cloning both axes at every lookup. Used
hints retain their reader dependency; adding a cap after layout is checked for
both hinted and measured shares. Ordinary text measurement still tracks its
own dependencies. The app's Iris pin is untouched.
Repeated release instruction counts reproduce the reported +5.9% resize cost
and find a larger defect: an unchanged paragraph repaint invalidated its span,
turning 0.376B instructions into 3.010B in a 40-paragraph, 2,000-frame fixture.
The fix is 0.370B. Text resize is now +0.99% against `de1eb7e`, the width sweep
+1.23%, and edits/scroll are within 0.3%. `docs/LAYOUT_LOG.md` has the full
performance sweep, including deep-tree regressions, RSS, upstream-base
comparison, and GPU timestamp measurements. This is not a universal speedup.
Passed: format, workspace Clippy with and without diagnostics, all 197 ordinary
and 201 diagnostic workspace tests, zero steady allocation fixtures, 400
depth-5 / 1,000 depth-6 / 2,000 depth-4 trees, 400 depth-5 expression trees,
400 relative-bound trees, GPU recording and move-chain rigs, and identical
cold geometry for all 34,986 bounded boxes against `8780b40`. Code and comments
were reviewed separately; the final test/comment refinements passed their
focused checks. Artifacts are under `/tmp/pr19-perf/`.
## Deferred comparisons are submitted on PR #19
The deferred-comparison implementation landed at `8780b40`, before the
performance correction above.
`/home/bob/repos/iris` remains on `layout/bounds`; that branch's `de1eb7e` was
fast-forwarded into #19 before this work. The app's Iris pin is untouched.
The new request path composes min/max/clamp before assigning leftover slots,
while ordinary widgets keep returning `Size`. Known requests skip provisional
painting; measured leaves can complete a nested expression. The implementation
uses a reusable expression arena and retained draw buffers. The allocation rig
checks zero allocations after warm-up for unchanged plain and clamped trees.
Generalized cross-axis maxima are deferred because hidden main-axis shares
must not contribute to them. The current design is in `docs/LAYOUT.md`.
## The Iris layout repair is submitted
**Iris PR #19** (`layout/one-ask`) replaces closed #18. Before the deferred
comparison work above, its review rounds past `cadfba0` are described in
`docs/LAYOUT_LOG.md`:
- **The repair**, `add6774` and `84dad21` -- collapsed-share placement,
retained mask ownership, a redraw-on-reparent defect, and repeated work in
the test harness.
- **The vocabulary and the container API**, `5642f20` through `58ce74d`.
- **Naming**, `55df32a` through `40b89c1`.
- **A sweep over the logic those names exposed**, `8d2b7a5` and `6c84b6f`.
- **A clarity sweep**, `3da1c71` through `1ebd4d3` -- naming the pairs layout
returns, `Span::slot`, a diagnostic that printed the rel base while calling
it the box, and `in_parent` matching a place's own cases.
- **A quality sweep**, `aea0387` through `69ba915` -- a kept contract judged
against the placed box rather than the box asked about, two things nothing
read, three reuse rejections the diagnostics could not see, and a fuzz case
that ran only in the long scan.
- **A sweep over the renderer, the text store and the retained path**,
`d8d5122` through `1096c31` -- a contract kept where the new window left
it out, a surface configured under its own texture, a counter naming the
wrong contract, things nothing reads, and a question asked through a value
rather than a reference.
- **A sweep over the shader boundary and the position widgets**, `b7b8d09` --
two constants the shader and the CPU both count in written twice, and a
`Scroll` positioning content the framework positions, which cost a redraw
at the default alignment.
- **A sweep over the rigs, `Fixed`, and `b7b8d09` itself**, `f8aa0c5` -- the
other half of that same `Scroll` test, which could never decide it; a grid
conversion and its helper that only their own test called; `Len`
arithmetic written a component at a time; a question asked through a value
one line from its `&self` sibling; and `run-headless.sh --resize` leaving
a replayed gesture scaled against the mode the output used to have.
- **A sweep over the tests and the seventh sweep's own fix**, `77ed7a2` --
four shrunk fixtures naming one widget under three names and counting
each in the list of boxes the case compares, seven copies of a helper
sitting at the top of the same file, the GPU rigs' adapter probe written
twice, a mask resolved three times, a field nothing reads, a shrinker
claim its own assertion does not make, and three stale numbers.
- **A sweep over the widget vocabulary and the eighth sweep's own fix**,
`c2b8bf8` -- a widget's own size hint overriding a length rule the hint
cannot express, marking a widget for redraw having no name at twenty-one
call sites, the helper the eighth sweep shared being unable to see the
defect it had just fixed, two bare arrays where the framework names the
pair, forty-five lines nothing references, and one word for two things.
- **A leftover as a minimum, and images in the trees**, `b295c8b` and
`2dba90b` -- a share under a parent that divides nothing losing the
overflow its pixels asked for, said as the place the parent gives and
sharing one comparison with the span; and `Image` grown in the generated
trees, which is the only widget here whose hint is a length in pixels.
- **One ask, the root's included**, `0d03267` -- the root had a layout path
of its own, so a rule that reads the box it is offered reached every widget
but that one. `Placing::WINDOW` is the box nobody drew and `Placing::ask`
the one place a box is decided; the root's own path is now the bookkeeping
a widget with no parent keeps. Bryan asked for this rather than a widget
above the root, which would pin the tree to pixels.
- **The deferred request system's own sweep**, `4cb6f68` -- described at the
top of this file and in full in `docs/LAYOUT_LOG.md`.
- **The bounds work's own sweep**, `ea1f836` -- the same, over `2ac0843`.
- **One node type for a request**, `05e6ced` -- the expression a rule holds
and the expression the pass allocates were two shapes of one thing, with
the fold written twice and an `Arc` where nothing shares.
- **The request arena's own sweep**, `f48e04e` -- the join that copies one
request's nodes into another's, which nothing in the suite ran.
- **A full sweep of the branch**, `cbccfb6` -- a length of zero that printed
as nothing, a ceiling that stepped off the top of the grid, and a harness
setter that dropped the bound beside the length it set.
- **A sweep for allocation and the cost per child**, `97fca76` and `429a4f1`
-- a container's draw quadratic in its children, a mask's rectangle resolved
once per fragment, and the refcount a widget handle can never raise.
Described at the top of this file.
The settled design of the vocabulary rounds is in `docs/LAYOUT.md` under
"Three names, and the one argument that says them". Bryan settled the API
over 2026-09-17 to 19; it is current, not frozen.
The core design remains sound. Round-to-nearest is still unchanged.
The Iris worktrees share Git storage. `ai-app-2/iris` stays on `app-pin` at
the app's `32f6ad8` pin until the integration below is ready.
### The branch layout, and the trap that used to be here
`main` is PR #19's base and tracks `upstream/main` (`iris/iris`), so the
obvious commands are the right ones:
git merge-base main layout/one-ask # ca2b4b2, the PR's base
git diff main...layout/one-ask # exactly what #19 changes
It was not always so. Until 2026-09-20 `main` tracked `origin/main` -- the
*fork's* line, which carries the app's 45 commits, is not an ancestor of
upstream's main, and sat four merged pull requests behind it (#10 parley
text, #12 pointer routing, #16 draw/size merge, #17 headless rig). Diffing
against it showed all four as this branch's work, which is how the parley
migration's undo path kept being reported as #19's; the sixth sweep lost
half a session to it before Bryan caught it, and the fourth and fifth
sweeps deleted `Painter::text_data` partly on the same false reading.
Bryan asked for the setup fixed rather than documented. What changed:
- The app's line is now the branch **`app-pin`** (`32f6ad8`), pushed to
`origin`. That is the name to use for it; it is no longer called `main`.
- `main` points at and tracks `upstream/main`.
- `.gitmodules` pins the `iris` submodule to `branch = app-pin`, so
`git submodule update --remote` follows the app's line and cannot drag
the pin onto upstream.
- `git iris-base` and `git iris-diff` are still configured and still
correct; they now agree with plain `main`.
`origin/main` on the fork was force-pushed to match, and `origin/HEAD`
follows it, so a fresh clone of the fork with no `upstream` remote gets the
right base from the plain commands too -- verified by cloning one. The
fork is managed entirely by agents and nobody works from it directly, so
rearranging its branches is always safe where it improves the workflow
(Bryan, 2026-09-20); that licence does not extend to the `iris/`
repositories, which are what pull requests target.
The submodule's recorded commit is unchanged at `32f6ad8` and stays
reachable through `app-pin`, so nothing about the app's pin moved -- only
the name it is reachable by.
### How to check a round
**Always**, because they cost nothing: format, workspace clippy under
`-D warnings` with and without `layout-diagnostics`, the workspace tests, and
the **cold dump**. `layout_dump` over 400 depth-5 trees is **34,986** boxes
since `76aaf06` grew bounds in the trees, and is unchanged through `f48e04e`.
It was 34,571 from `2dba90b`, which grew the images, and 34,488 before those; the fourth through eighth sweeps all
repeated 34,492, which is a `wc -l` of the whole run rather than of its box
lines, so count the lines that are a box (`grep -cE '^[0-9]+ [0-9]+ '`). It is the only thing that catches two
same-typed values being swapped, which is the failure mode of a rename or a
move. The repair moved 650 of those boxes, all from the collapsed-share
correction. Subsequent review-only changes preserved cold geometry through
`4cb6f68`; the offered-bound change in `2ac0843` intentionally changes bounded
geometry while preserving all unbounded boxes.
**Only when the change can alter what layout computes**: the three seed scans
-- 400 at depth 5, 1,000 at depth 6, 2,000 at depth 4. They cost about a
quarter of an hour and they exist to find logic that is wrong on some tree
shape, so a rename has nothing for them to find (Bryan, 2026-09-19). Never
start one and then edit the tree: cargo rebuilds mid-flight and exits 1 from
a compile error, which reads exactly like a fuzzer failure.
## What is next, in order
1. **Bryan's review of #19.** Fixes are themselves unreviewed code: repeat
`pre-submit-review` over each round's changes, and apply the gate above to
whatever each one touched. The ordinary oracle does not replace absolute
geometry and retained-primitive expectations. The seventh sweep is the
evidence: reading `b7b8d09`, the sixth sweep's own fix, found that it had
removed one dead operand from a test and left the one beside it.
The eighth adds a second lesson, about the tests themselves: a fixture a
fuzzer shrank is a regression test, so a round that rewrites one has to
prove the tree is the same rather than that the case still passes, since
a fixture that quietly changed still passes and covers nothing. The ninth
adds a third: where a round fixed a class of defect one instance at a
time, put the check in the shared helper, which both closes the class and
tests the instances that round said were already fine.
The twelfth adds a fourth, about coverage rather than about a defect: a
branch only a combination of the public API reaches can be reached by
nothing and still look covered, since every test around it passes. Put a
`panic!` in the arm and run the suite; it costs one build and it answers
the question the test names cannot.
The thirteenth adds a fifth, about the record rather than the code: a
comment describing an open defect by the seed that found it stops being
true the moment the generator changes, and `generated.rs` says so in its
own comment. Re-run the measurement before repeating what one says.
2. **A review of everything written before the review gate existed.**
`pre-submit-review` and the rule that nothing is submitted unreviewed
arrived on 2026-09-13, well after the Rust port and most of Iris were
written, so all of that code went in unreviewed and none of the sweeps
above covered more than the layout branch. It wants a pass of its own
(Bryan, 2026-09-20). The surface-texture defect in `02048ea` is the
argument: nothing about that arm was hard, and it was written wrong
anyway, which is what a first reader catches and a later sweep of some
other subject does not. Three things it already has waiting, left out of
#19 because they are outside its diff: `Align::tuple` and both
`partial_align`s have no callers, `Vec2::align`/`partial_align` are
`UiVec2`'s with a conversion in front, and `impl_op!` carries four
grammars of which `core/src/util/vec2.rs` uses two, one line apart.
`Align` now has `Index<Axis>`, so the `if let Some` each
`partial_align` writes twice collapses when that sweep reaches them.
The fourteenth sweep adds more, all dead: `Size::to_uivec2`, `Size::rel`,
`Size::leftover`, `Len::to_uivec2`, `Vec2::with_x`, `Vec2::with_y`,
`Vec2::ceil` and `Layers::iter_orderless_mut`.
3. **Integrate the app's Iris capabilities before changing its pin.**
`32f6ad8` has 45 commits not reachable from the review branch; shared UI
ownership, richer masks, Android support, and app-side performance work
must survive the integration. What has to survive is those capabilities,
not the calls the app makes today: the app is to be largely rewritten
against the new API rather than ported call by call, so nothing in Iris
is kept alive for the app's sake (Bryan, 2026-09-20).
4. **Round-to-nearest**, CPU and shader together as one verified change.
Bryan approved it on 2026-09-17 and neither half has landed; the
derivation, the form to use and what to re-check are in `docs/LAYOUT.md`
under "Rendering the grid (pending)".
Wanted but not started, recorded in `iris/TODO`: transforms on a move entry,
so a whole subtree scales or rotates with one buffer write and no redraw.
Compose-style stretch at the end of a scroll area is the use that prompted
it. A move entry only translates today, and composing through one scales the
`rel` part while `px` passes through untouched, so fixed-size content and
glyphs do not follow a shortened entry.
## Smaller layout items, none urgent
- 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; confirm it is wanted.
- A span can overflow itself without bound, so boxes of negative length reach
children and nothing states what a widget may assume about one.
- A `leftover` under a parent that does not divide is a minimum size --
`max(box, px + rel*box)` (Bryan, 2026-09-20), which is a `SizeRule::Min` of
`rel(1.0)` and shares `Painter::longer_than` with the span. Done in
`b295c8b`, and in `0d03267` for the root as well, which used to read it the
old way.
- `docs/LAYOUT.md` §4, §5 and the density section name `Painter::place`,
`Painter::region()`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,
`Aligned`, which no longer exist; `MaxSize` now exists with the bounds API.
Do not restore `OnResize::Translate` or `OrthoSize`.
- **`layout/bounds` (`de1eb7e`) is included in #19** for the deferred
comparison implementation. A rule holds what a widget answers
(`SizeRule::{Min, Max, Clamp}`); a widget holds the box (`MaxSize`, which
`.max_width`/`.max_height` build). Bryan settled the split on 2026-09-20
after four readings were measured; the reasoning is in `docs/LAYOUT_LOG.md`.
Bryan superseded that split with the built-in bounds work above. The
deferred-request work fixes stale relative-bound reuse by tracking the
input base and comparing resolved bounds, with a reduced natural-size-hint
regression and 400 depth-5 relative-bound trees. Intrinsic bounds remain
`Len`s; comparisons involving leftover use `SizeRequest`. Ordinary generated
trees retain pixel bounds; `deferred_generated` supplies relative bounds and
expressions in separate corpora.
- `LazySpan`.
- `Scroll` taking a direction rather than one axis.
Other product work is in `docs/PLAN.md` and the focused documents it links.
Do not mix it into the Iris layout branch.
-818
View File
@@ -1,818 +0,0 @@
# Iris extraction handoff
Operational handoff for pulling Iris out of ai-app into a standalone framework.
Not a decisions log; delete it when the extraction is done.
## Where things stand
Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are
in.
**#18 `split/18-position-chain`** is open and finished apart from one decision:
worktree `/home/bob/repos/iris-pr18`, head `3f7cd82`, twenty-four commits,
workspace tests passing, fmt and clippy clean. It is LAYOUT.md §2's position
chain, generalised to boxes. The last six are a performance pass that changes
no layout: the two halves of the text fix (`e5f8b6b`, `2525637`), three
retained-layout details that claimed something they did not do (`f1a47e9`),
the many-widgets-dirty cases (`77bb75e`), what the settle order is holding up
(`bf94380`), and carrying a widget's depth rather than walking for it
(`3f7cd82`). Every one of them keeps the five reference renders and the resize
render byte-identical, and the hundred-seed sweep passes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean.
**The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer
byte-identical to `upstream/main`: 1,283 pixels of 2.3M (0.06%), two
one-pixel-wide panel edges shifted by a pixel, at x=1056 and x=1337. `view`,
`minimal` and `text` are identical. Composing a position through the chain in
the shader associates the arithmetic differently from collapsing it on the CPU,
so a value that used to land exactly on an integer falls the other side of the
shader's `floor`. The CPU and the GPU still agree with each other -- both walk
the chain bottom-up -- so hit testing matches what is drawn; what changed is
only the comparison against the old code. Matching it exactly means composing
root-down in the shader, which needs the chain collected into an array first.
Byte-identical against `upstream/main` has been the bar for every slice, so
this is hers to accept or to spend a commit on.
Check for a review before starting anything, and read the newest `submitted_at`
rather than the first result:
```sh
TOKEN=$(cat ~/.config/gitea/token)
N=18
curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews
curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews/<id>/comments
curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/issues/$N/comments
```
My replies are ordinary issue comments on the same PR and say what each change
was for.
## How a position resolves now
Invariants, not history. Everything in `core/src/ui` rests on them.
- **A slot holds a box, in the coordinates of the slot it names.** A primitive
instance and a mask each name one, and `prelude.wgsl` composes the chain with
`within`. A translation is the special case where the box has its parent's
relative extent. The identity is `UiRegion::FULL`, **not zero** -- a zeroed
entry is a box of no extent and collapses its subtree to a point, which
`MoveOffset`'s comment says beside the `Zeroable` that `Pod` requires.
- **A slot has to carry a whole box** rather than a scale and an offset: a
pixel-space affine map scales everything under it, including a child that
must keep its pixel length, and the `rel`/`abs` pair is exactly what
distinguishes the two.
- **Slots are opt-in.** `Painter::place` draws a child whose box its parent
decides and may decide again, and that child gets a slot; `widget` and
`widget_within` do not, and share the nearest ancestor's. `Span`, `Aligned`
and `Scroll` place. This is what keeps the chain 2-4 deep rather than full
tree depth, which is the difference between free and +42.6%.
- **A widget's region is held in the coordinates of the slot it draws in**, so
a placed widget draws against `UiRegion::FULL` and its box lives in its slot.
`ActiveData::region` is the box it was *offered*, in its parent's slot
coordinates, and `ActiveData::parent_move` is the slot that is in;
`window_region` composes the one through the other, which is the walk the
shader does.
- **Nothing inverts a lerp.** `UiRegion::stretch`, `stretchable` and
`UiScalar::stretch` are gone. A box that changed length is written to its
slot and the descendants recompose against it, which also covers the case the
old guard refused outright: a fixed length has no fraction to recover, so a
40-tall row could not be stretched on its other axis at all.
- **Reuse is decided on the box a widget drew against, in pixels**
(`ActiveData::px`). A region is a fraction of a slot's box, so an unchanged
region is *not* an unchanged box -- a child drawn at `FULL` of a slot that has
since halved compares equal to itself. This is the check everything else
rests on; do not weaken it back to comparing regions.
- **A size the parent learnt by drawing the child is an answer for that box
only.** `redraws_under` redraws a child whose size the widget read unless it
declares an exact `size_hint` for the changed axis -- the one case the parent
did not have to draw it to find out. The cost is that a size-reading
container gives up its reuse when its box changes length, which is every
span, so `OnResize::Scale` earns its keep on moves and on subtrees whose
sizes nobody read rather than on every stretch.
- **`redraws_under` is a question asked before reusing, never a marking.**
Marking descendants for redraw instead does not terminate: the mark escalates
to that descendant's size reader, which re-places the child, which marks it
again. Asking first and giving up the whole reuse adds no marks and stops.
- **The walk stops where a length did not change.** A part of a box with no
relative extent on an axis is a fixed length held as offsets from that box's
start, and composing anything into it leaves no relative extent either -- so a
widget whose own box did not change length has no descendant whose box did.
An 80-wide child of a widened row is never asked.
- **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset`
say `Scale`**; each places in fractions and offsets of its own box and none
reads its pixel length. `Scroll` and `MaxSize` read pixels and stay `Redraw`,
which is the general rule: `Scale` on an axis unless the draw reads the pixel
length of its box on that axis. The default stays `Redraw`.
- **`OnResize::Scale` keeps its name.** The owner rejected `Stretch` on
2026-09-14: stretch has an opposite and scale does not, and the answer is per
axis, so the axis is already established where it is read.
## Measured, so the next attempt is compared rather than argued
| rig | what it says |
| --- | --- |
| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances. Translate slots: free to depth 8 (+5%), then ~3 us per level, +42.6% at 16 and +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level. A box slot (36 bytes) against a translate slot on the same binary: +0.6% at depth 1, +0.5% at 2, +0.8% at 4, then +9.6% at 8 and +32.2% at 64 -- free where opt-in slots put it, and dear only where the chain already was. |
| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. Five primitives per row; the regime that decides whether the chain is worth it is a transcript row of a few hundred glyphs, **so re-run it with 200 characters of text per row before concluding anything from it**. |
| `tests/draw_cost.rs` | What recording a frame costs on the CPU by layer count. Dispatch per list is 6 instructions, 0.1% of a frame at 256 and at 1024 layers. |
A chain is irrelevant at an example's couple of hundred primitives; a
transcript's glyphs are tens of thousands, which is the regime `chain_cost`
measures.
### How much of that work is necessary
Measured 2026-09-14 on a random tree at seed 1, depth 7 -- 1061 widgets, 839
of them drawn, 10,872 primitive instances -- against the same rig at `43ce8c7`
(the last commit before #16 sized a widget by drawing it) and at `f942385`
(#16 itself). The rig is a `Harness` load counting widget draws, text shapes
and instance writes per frame, plus the GPU pass through timestamp queries;
it lives in the scratch worktrees `/home/bob/repos/iris-size-{old,new}` and is
not in the repository, because the counters it reads are patched into
`iris-core`.
| per frame | before #16 | #16 | #18 head |
| --- | --- | --- | --- |
| cold layout | 19.3 ms, 839 draws | 30.9 ms, 3317 | 23.6 ms, 2755 |
| repaint one leaf | 0.000 ms, 1 draw | 8.5 ms, 1683 | 6.5 ms, 1313 |
| scroll one scroller | 0.001 ms, 1 draw | 8.9 ms, 1683 | 6.5 ms, 1313 |
| resize the output | 3.2 ms, 839 draws | 31.8 ms, 6940 | 25.2 ms, 5512 |
| GPU pass | 0.129 ms | 0.114 ms | 0.115 ms |
**The GPU is not the subject.** The pass is a tenth of a millisecond at every
revision and every phase; all of this is the CPU laying out.
`a640c6c` and `84f589e` remove most of the CPU work without weakening the
retained-layout rules. Against the same seed-1/depth-7 load, widget draws are
now 2,117 cold, 1 for a leaf repaint, 11 for a scroll, and 3,139 for a resize
(from 2,755, 1,313, 1,313, and 5,512 respectively). Workspace tests pass; the
five reference renders, resize render, and image-tab replay are byte-identical
to the prior #18 head.
Those draw counts explain mechanism, not total layout cost. Before #16,
measurement was a separate operation: the cold and resize rows each made 839
draws plus 489 size queries, 314 of which hit the size cache. Current sizing is
a draw, so its draw count includes the provisional work and cannot be compared
directly with the old draw count.
### Against the old code, on a tree both revisions build
**The random generator is not a fixed load, so it cannot carry an old-versus-new
comparison.** Padding, scrolling and alignment grew into it during this work, so
`seed 1, depth 7` is 144 widgets today and was the 1061-widget tree the rows
above were measured on. Earlier statements here that the retained layout was
"about 1.6x the old 3.14 ms resize" compared a 260-widget depth-8 tree against
that 1061-widget depth-7 one, and are withdrawn.
`tests/revision_cost.rs` is the load that does carry it: one hand-written tree
-- 40 rows of `Dir::RIGHT` span holding a 40px rect and a `Dir::DOWN` span of
one wrapping and one short non-wrapping text, each its own random words -- in
source that compiles unchanged on `43ce8c7` and on #18. Drop it into an old
worktree and run it there; take the number from `perf stat -e instructions:u`
on the test binary rather than the clock. It also prints where the layout put
the paragraphs, which is the other half of the comparison, and `text_memory`
in the same file reports what the tree holds.
**The old code is faster because it skips the question, not because it answers
it more cheaply.** Two shortcuts pay for the 2.2x, and both are wrong:
- `SizeCtx::len_inner` keys its memo on the widget id alone. It stores the
constraint box the answer was computed under and never compares it, so the
first asker's box decides the answer for every later one -- 314 of those 489
hits per frame. The frame-local answer cache rejected on seed 98 at least
compared the box; this does not.
- `Span::len_sum` measures every child against the container's whole axis
rather than the part left for it, which its own comment records as a
deliberate choice ("tempting to subtract the abs & rel from the ctx outer,
but that would create inconsistent sizing"). In the fixture above that shapes
a paragraph at 900 rather than the 860 left beside the rect, and it draws
29 px past the edge of the output.
So the amplification #18 pays is the cost of asking in the right box. That does
not excuse the size of it, and most of it turns out not to be layout at all.
`480f0bc` retains that instrumentation behind the `layout-diagnostics` Cargo
feature; none of it is compiled into a normal Iris build. The ignored
`tests/layout_diagnostics.rs` rig selects `cold`, `repaint`, `size`, `scroll`,
or `resize` with `IRIS_PHASE`, plus seed, depth and frame count. Run it with the
feature for explanatory counters and inclusive phase timers, and without the
feature under `perf` for unperturbed CPU totals:
```sh
IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=100 \
cargo test --release --features layout-diagnostics \
--test layout_diagnostics -- --ignored --nocapture
IRIS_PHASE=resize IRIS_DEPTH=8 IRIS_FRAMES=1000 \
perf stat -e cycles:u,instructions:u cargo test --release \
--test layout_diagnostics -- --ignored --nocapture
```
`82fa6c1` adds targeted tracing to the same feature. Call
`layout_diagnostics::trace_widget(id)` before a frame; `take().traces()` then
returns the selected widgets' ordered draw requests and pixel boxes, reuse
outcomes, placements, hints, size reads and reported sizes, and text widths.
The selection is a set, survives `take()`, and is removed with
`untrace_widget` or `clear_traced_widgets`. This replaces temporary
text-specific logging without imposing anything on normal builds.
The first depth-8 resize run made the amplification concrete. A 260-widget
tree has 215 active widgets, but a resize averaged 1,395 widget draws, 913
placement calls, 1,313 reads of drawn sizes and 34,844 primitive writes. Only
12 distinct text widgets rendered, yet they rendered and reshaped 282 times
per frame with no shape-cache hits. Text rendering accounted for 11.6 of 13.2
ms, including 9.9 ms shaping and 1.7 ms placing glyphs. The hottest two text
widgets each drew 96 times below nested `Span`, `Aligned`, `Scroll`, `Pad`, and
`SetSize` ancestors.
Tracing one of them showed that 96 was two multipliers, not 96 necessary
layout iterations. One traversal of the nested positioning chain shaped it 32
times at 12 widths; three resize-dependent descendants then caused that whole
traversal to run three times through the same highest size reader. Resize
marking had queued each pixel reader independently, and each leaf discovered
and redrew the same reader path in turn.
`82fa6c1` coalesces that frontier by marking every resize-dependent leaf and
its size-reader chain first, then settling the shallowest shared reader once.
Ordinary content and size changes remain deepest-first. On the same depth-8
load, an actual resize now averages about 694 widget draws, 435 placement
calls, 649 drawn-size reads, 15,343 primitive writes, and 116 text shapes; the
hottest texts draw 32 times. Instrumented layout is about 5.5 ms instead of
13.2 ms. An uninstrumented 1,000-frame resize run measured a 5.61 ms median,
60.79B instructions total (60.8M/frame), and 23.85B cycles. This is about 58%
fewer instructions than the roughly 138M/frame #18 path before coalescing.
The remaining multiplier is within one constraint traversal. Re-sending the
existing output size also no longer starts a resize.
`b1b3eca` records pixel reads and retained size inputs per axis. `Text` says it
reads only its offered width, while `Scroll` says it reads only its scrolling
axis. `Span`, `Aligned`, and `Scroll` ask for an exact hint or a retained child
length valid under the box they are about to offer, and draw to measure only
when neither exists. On the same depth-8 load, resize now averages 449 widget
draws, 296 placement calls, 485 drawn-size reads, 12,164 primitive writes, and
107 text shapes. Cached uninstrumented 1,000-frame runs measured 4.955.24 ms
medians, 55.4B instructions total (55.4M/frame), and 21.521.6B cycles: about
9% fewer instructions than `82fa6c1`.
Pixel comparisons now use a 0.05 physical-pixel tolerance. The comparison is
against the last actual layout, not the preceding resize event or retained
move, so repeated subpixel changes accumulate and eventually redraw. The
generated cold-layout oracle uses the same visual tolerance per coordinate.
The generated tree now includes `Aligned` with every meaningful per-axis
alignment. That exposed two retained-layout ordering bugs which `84f589e`
fixes. The regular cold-layout equivalence suite and the ignored 100-seed
sweep pass. The latter previously stopped at seed 60 on `140.0` versus
`139.99996948242188`: exactly two `f32` ULPs from an equivalent composition
order, not a visible layout difference. The oracle now keeps draw presence
exact and allows at most 0.05 physical pixels per coordinate. Seed 98 is also
part of the ordinary suite because it catches stale retained size beneath an
aligned size-changing chain.
The branches are invariants rather than widget exceptions:
- A dirty widget draws locally first only while its retained box has the same
pixel size. If its returned `Size` is unchanged, no size reader can observe
the repaint and no ancestor draws. If the size changed, the dependent path
lays out. A changed pixel box takes the conservative path first, which is the
condition the discarded `/tmp/escalate.patch` missed on resize.
- Dirty widgets settle deepest-first. A changed size queues only its immediate
reader; propagation stops as soon as a reader's own answer stays unchanged.
Output resize is the distinct invariant: all pixel-dependent leaves and
their reader chains are marked together, then shared reader roots settle
shallowest-first under the new output. This both chooses the parent box
before drawing its descendant and coalesces overlapping resize paths.
- A span measures an unknown child in the part of its axis still available,
rather than giving every child the whole container and immediately taking
most of it away. This is the archive's faster shape, recreated on the current
types; it often makes the measurement box the final box without caching an
answer under different constraints.
A proposed `Scroll` shortcut that reused its direct child's retained size was
discarded. “Direct child is clean” is not strong enough while a dirty
descendant's structural change is still propagating; seed 52 demonstrated the
stale-size failure. Re-measuring the scroll subtree costs 11 draws rather than
1, but remains two orders of magnitude below the old 1,313 and follows the
actual dependency invariant.
A frame-local cache of several size answers under different constraint boxes
was also discarded. It reached about 3.5 ms in the instrumented resize rig,
but seed 98 showed that a valid answer is not enough: the child's one active
drawing may still realize another constraint, and a parent can then lay out
from an answer its final child placement does not realize. A correct version
needs an answer-plus-realization or verification protocol; adding dirtiness
conditions to the cache does not fix that invariant.
Do not restore the archive's old `DrawMode::Measure`. It skipped primitive and
retained-state writes while still walking widgets and shaping text, and once
improved a streamed-frame benchmark from 1.39 to 1.22 ms p50. Retained
placement later replaced it because a provisional draw is usually already
usable in its final box; measure-only mode would discard that useful output
and force another real traversal. The owner confirmed on 2026-09-14 that
direct placement is the intended path.
### What a resize frame is actually spent on
`perf record` on #18's head, resize phase, seed 1 depth 8: about 63% of the
frame is text -- shaping in parley, harfrust and icu\_segmenter, then
`TextData::place` at 10.8% on its own -- and `draw_inner` is 0.9%. **The
retained-layout machinery is not what the frame costs.** A container that
measures by drawing makes a text draw 32 times at about a dozen distinct
widths; every one of those re-runs the shaper, because the caches in front of
it (`TextView::width`/`tex` and `TextBuffer::layout_key`) each hold exactly one
entry and a trial width alternating with a final width evicts the answer that
is about to be asked for again.
`e5f8b6b` and `2525637` are the two halves of the answer.
**A new width is a line break, not a shaping.** Only the breaking depends on
the width -- the font selection, the unicode analysis and harfrust under it are
a function of the text and the attrs, and parley re-breaks them in place, which
is what its own editor does on every resize. On the depth-8 tree that is 107
breaks at 0.119 ms where the shapings they replace were 4.0 ms. This is the
half that holds however far the width moves.
**A bounded store on `TextData` holds what re-breaking still cannot avoid:**
the glyphs, placed per width, keyed by the text, the attrs and the width, with
`TextBuffer` holding the pair it is drawn as -- which is where `TextView::tex`
was, so there is one place to invalidate rather than two. Bounding the store
rather than each buffer is the point: a per-buffer cache of eight cost +79% on
a tree of 4,000 texts, and lazy eviction cannot fix that, because the texts
holding the memory are exactly the ones a retained layout has stopped drawing.
Instructions per frame over 500 resize frames of `tests/revision_cost.rs`, for
widths that alternate -- the friendly case for anything that remembers an
answer -- and widths that never repeat, which is a drag:
| | alternating | never repeating |
| --- | --- | --- |
| before #16 (`43ce8c7`) | 56.1M | 56.2M |
| #18 head (`b1b3eca`) | 124.2M | 123.6M |
| the store alone | 17.7M | 45.9M |
| re-breaking alone | 32.9M | 32.8M |
| both | 20.6M | 24.2M |
Neither alone is good at both, which is the reason for having both. The frame
times that go with the last row are 1.88 ms median and 2.54 ms worst
alternating, 2.46 and 3.24 never repeating, against 4.99 and 6.47 before #16 --
so the worst frame more than halves, and the two gestures are within a
millisecond of each other rather than a factor of two apart.
Memory on the same rig at 2,000 rows: 108.6 MB on #18's head, 111.6 MB with
both, and **132.0 MB before #16**, which grows 23 MB over the resizes where
neither of the others grows at all.
Widget draws do not move at all -- 449 either way -- so none of this touches a
retained-layout rule. That matters for more than purity: **text is the load
that makes a redundant draw expensive, and so the thing that shows when the
layout is drawing more than it needs to.** The counters keep saying so -- a
text render is counted per ask rather than per shaping, and breaks and glyph
placements are counted separately -- so neither half can hide the amplification
it is paying for.
This is not the frame-local answer cache seed 98 rejected. That one let a
parent lay out from a size the child's one drawing might not realize; this
holds a *drawing* keyed on what produced it, and the widget still draws. The
five reference renders and the resize render are byte-identical, and the
sweep passes.
### Settling a dirty set, and why the order is not free to change
**Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse`
asks whether the widget in front of it is dirty and, if not, hands its parent
the size it last reported. It does not ask whether a dirty widget sits under it
through the size dependencies -- which is the check `retained_size` makes, on
the path that does not draw, for exactly this reason. **The settle order is
what covers that gap**, and it was written down nowhere: taking the deepest
first means that by the time a reader draws, what it reads has drawn and
propagated.
The trace that shows it, on seed 2 of `tests/generated.rs`: with the deepest
first and with an arbitrary pick, the two are identical event for event until a
`Span` reports its size -- 147 px one way and 317 px the other, from the same
child sizes. It had reused a subtree exactly, and inside that subtree sat a
`SetSize` whose declared width had changed and which had not been drawn yet.
24 widgets end up wrong.
So the order is not about cost, and this is the thing to fix before changing
it. Adding the missing check to `try_reuse` does make any order correct -- all
six generated cases and the hundred-seed sweep pass with the dirty set taken in
whatever order it yields. But `dirty_size_under` walks the size-dependency
subtree on every reuse that hands a size back, and that costs about what the
sort saved: at 130 of 260 widgets dirty, 7.83M instructions per frame against
8.23M; at 32 dirty, 3.32M against 2.84M, so *worse* where the dirty set is
small. Two other shapes measured and rejected on the way: putting the check at
the top of `try_reuse` rather than on the two paths that return a size (7% dearer
again), and phrasing it as `size_is_invalid`, which also lets a resize mark
through and takes the resize phase from 7M to 106M instructions per frame.
**What would actually pay is a maintained count** rather than a walk: each
widget holding how many dirty widgets sit under it through size-dependency
edges, incremented up the reader chain when a mark is added and decremented
when one is consumed. That is O(depth) at a mark and O(1) at the check, where
today it is O(dirty x depth) per widget settled. It wants agreeing first: every
place that inserts into or removes from `needs_redraw` has to pair with it, and
a count that is too low is a stale size rather than a slow frame.
`3f7cd82` takes most of it without touching the order: a widget's depth is
known where it is drawn -- its parent's plus one -- so `Painter` carries it and
`ActiveData` keeps it, and the choice reads a field instead of walking an
ancestry. Being reused counts as being visited, so the two reuse paths keep it
current; only a subtree nothing looked at can hold an old one, and nothing
under an unvisited subtree is being ordered. `depth` asserts the kept value
against the ancestry in debug builds, and the hundred-seed sweep passes with
those assertions on, reshuffles included -- those being what moves a widget to
another parent. Same load at 130 of 260 dirty: 8.16M instructions per frame to
7.14M, median 0.813 ms to 0.639, and the choosing from 25.8% of the frame to
4.7%.
What is left of it is iterating the dirty set, which a `HashSet` walks by
capacity rather than by length. Ordering it -- a `BTreeSet` keyed by the kept
depth, or a bucket per depth -- would take that too, but `needs_redraw` lives
on `Widgets` and is inserted from places with no view of the tree, so either
means a second structure inside the render state kept in step with it. For 4.7%
that is not obviously worth the coupling.
**Going order-free costs more than the order now does.** Measured 2026-09-14
with the depth carried, instructions per frame, on the same three loads:
| | 32 dirty | 130 dirty | resize |
| --- | --- | --- | --- |
| ordered, no check (what is in) | 2.80M | 7.14M | 6.70M |
| ordered, with the check | 2.83M | 7.18M | 6.72M |
| arbitrary, with the check | 3.12M | 7.38M | 6.28M |
So the check is nearly free where the order already makes it redundant, and
dropping the order costs 11% on a small dirty set. **Keeping the answer does
not help: it never hits.** The dirty set moves about once per check -- every
widget drawn takes its own mark out -- so an answer worked out against one
state of it is asked for against another, and a generation stamp invalidates
everything each time. Only a count maintained up the reader chain as marks come
and go would answer in O(1) without an invalidation to lose, and that is the
version still unbuilt.
One thing not understood: the arbitrary-order rows above are consistently ~6%
cheaper with the kept answer in than without it, though the kept answer never
hits and only two lines differ between the builds. Nothing here rests on that
difference, but it means those two rows are worth re-measuring before anyone
builds on them.
### Dirtying many widgets at once
**A frame that dirties many widgets at once was not being checked, and it is
where the settle order shows.** `77bb75e` adds two generated cases -- every
declared size changing together, and a spread of widgets marked for redraw
together, where nothing changes and so no box may move -- and `IRIS_PHASE=many`
to the rig, with `IRIS_DIRTY` widgets marked per frame. At 130 of 260 widgets,
**choosing which dirty widget to settle next is 24.5% of the frame**: the dirty
set is scanned once per widget settled, and a `HashSet` is walked by capacity
rather than by length. Memoizing the depth walk within one scan does not pay
(it trades parent lookups for memo lookups, 4% more instructions); the fix is
to stop rescanning, which changes the order widgets settle in and is hers to
agree first.
**What a frame is made of now**, `perf record` on the `many` phase at 130 of
260 dirty, which is the heaviest thing the rig has: `Layers::write` 16%,
parley's `break_all_lines` 15%, `Painter::glyphs` 14%, `InstanceList::push` 7%,
`LayerDraws::free` 6%, `draw_inner` 5.5%, choosing the next dirty widget 4.7%.
Four fifths of it is primitive bookkeeping, and none of that has had a pass.
That is where to look next rather than at the settle loop.
The remaining costs after this are:
- **Writing a glyph instance per glyph per draw.** With shaping and placement
kept, `Painter::glyphs`, `Layers::write` and `InstanceList::push` are most of
what is left. A cold frame writes 48,050 instances for a tree that
holds 10,872. What reaches the GPU is 10,872, since a layer uploads whole and
`set_instance` cancels a dirty mark when the bytes are unchanged -- so this is
CPU cost only, but a one-leaf repaint still uploads 5,863 instances where the
pre-#16 code uploaded 106.
- **A container measures a child by drawing it in a box it will not keep.**
The remaining-region trial removes many mismatches, but an unknown child's
measured length can still make its final box differ, and nested containers
compound those redraws. The general form of the shaping store is a widget
answering from a box it has already drawn in, which -- unlike a size answer --
is realized by definition; the cost is retaining more than one drawing's
primitives, which is why text, where only the shaped layout has to be kept,
is where it is worth doing first.
## The random trees
`iris::random` grows a seeded tree -- spans in every direction holding two to
four children, stacks, scrolls on either axis, alignment on either axis,
padding with each of its four sides its own number,
rects with varying opacity, text both wrapping and overflowing, a declared size
over half of it, stopping at a depth. `examples/random.rs` draws one
(`IRIS_SEED`, `IRIS_DEPTH`). `tests/generated.rs` grows each seed twice -- once
and then changed, once with the change built in -- and compares every widget's
box across eight scenarios: a size change, a resize, both, and five ways of
changing what a span holds. `a_long_run_of_seeds_agrees` is the ignored sweep,
100 seeds across all eight, 800 comparisons, about four minutes.
The property is that laying a tree out again lands where growing it cold does,
which is the same thing as layout being a function of the state. It earned
itself immediately: it found the non-terminating marking, the pixel-box reuse
check and the measured-size rule above, none of which the hand-written tests
reached, and it says the result is better than what it started from -- 90 of 90
against 83 on `db1751f`.
Four things about it that are easy to get wrong:
- **Both trees must make the same widgets in the same order.** Comparison is
index for index, so a tree that makes fewer widgets, or frees one whose id is
then handed to the next, stops lining up at the first difference and every
comparison after it is against the wrong widget. Hence three spare leaves
grown beside every span whether they end up in it or not, and detached
children held until the comparison is over.
- **Attaching a spare moves it.** A widget belongs to one parent;
`WeakWidget::upgrade` registers an add and panics with "cannot add a widget
twice", so it is for a handle that was never added, not a second share.
- **Each shuffle asserts the tree actually changed** before comparing, or a
case that quietly did nothing passes green.
- **A failure prints the widget's ancestry**, marking the ones that own a slot,
because where two trees disagree is rarely where the cause is.
## Verifying a slice
```sh
cd <iris-worktree>
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
50 tests pass on #18's head. `--workspace` matters: `rig-input` is a crate of
its own.
Render checks are the last pass, not the iteration loop -- the owner asked for
that on 2026-09-14, since the layout tests cover the CPU part and the shots
cost real time:
```sh
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/out.png
./scripts/run-headless.sh tabs --replay /tmp/taps.touch --shot /tmp/out.png
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --resize 900x1200@60Hz --shot /tmp/rs.png
```
- The reference shots are `tabs`, `view`, `minimal` and `text` at 1920x1200,
plus `tabs` with a replay that switches to the image tab and adds two images.
A `.touch` line is `<ms> down|move|up <x> <y>` in the output's own pixels; the
tab strip is at y=24 and the five tabs at x = 192, 576, 960, 1344 and 1728,
with the image tab's add button near (1836, 1116).
- **A resize is its own case**, and `--resize` is it: the output changes under
the running app, and what it lands on must match a cold start at that size
byte for byte. That caught both of #16's defects and nothing in `cargo test`
can see it.
- **Run one at a time.** The rig reuses a single compositor and a single
output, so two invocations at once resize each other's window and quietly
screenshot the wrong thing. Two sets of shots were thrown away learning that.
- **Give a comparison worktree its own target dir.** While one was shared
between two checkouts I got results I could not reproduce afterwards; the
mechanism was never pinned down, so re-run any cross-checkout comparison in
isolation before believing it.
Two drawing paths still have no shot of their own, and each needs a ui the
examples do not have, so both are throwaway examples written into the worktree
and deleted after:
1. An image alone in a layer, which is the case that failed GPU validation when
every other test happened to have a rectangle in the same layer.
2. Six lines of 400px text, which forces the atlas to four pages and proves the
array grew and its group was rebuilt.
`tabs` with the image replay covers rects, glyphs and images together, so that
one is an ordinary check now.
## How the work is sequenced
**Most fundamental first**, from the owner on 2026-09-13: *"please do more
fundamental changes first, such as library updates and core framework changes,
so that code only has to be written once"*, and *"should probably start adding
tests early on rather than later, so you don't have to make separate test
scripts and stuff."* So slices are ordered by how much depends on them, not by
what is nearest ready, and a slice arrives with tests rather than with a script
in `/tmp`.
**Agree a design before sending another variation of it.** The owner stopped
the fourth round of #11 with *"we should probably agree on the design here
rather than you keep submitting variations that I review"*. When a review comes
back about the shape of something rather than a defect in it, put the options
and a recommendation in front of her and implement what she picks.
**Nothing is submitted without a separate review pass** -- the installed
`pre-submit-review` skill: build clean, review the code, review the comments on
their own once the code has settled, then verify the claim by running it. The
fixes a review produces are themselves unreviewed code, so the passes repeat
until a round finds nothing. It has earned its place repeatedly: four defects
on #11 that format, clippy, tests and five headless renders had all passed, and
on #12 a regression introduced by the review's own first draft. `audit.sh` in
the skill directory prints every comment line a branch adds against a base ref;
the owner's standing complaint is verbose agent comments, and the default
verdict is delete. **Machine-specific notes do not belong in the repository** --
they live in `~/.claude/MACHINE.md` or a `this-machine-*` skill.
Other standing instructions from the owner:
- Pull Iris out even if the Rust application switchover is not accepted. No
app, session, transcript, setup or server concepts in Iris; the dependency
runs one way from `app/` to Iris.
- **Small, coherent PRs.** The original extraction PR was too large to review.
A slice may be redone rather than transplanted, and need not remove every old
feature. Non-conflicting pull requests may be open at once -- disjoint path
sets, each branched from current `upstream/main` rather than stacked. She
reviews small ones as they arrive and only avoids two *large* ones in flight.
- **Order by dependency, largest reach first.** On 2026-09-13: *"do the large
reaching framework changes first so less has to be redone."*
- Do not recreate an `ai` branch in canonical Iris; the fork is the boundary.
- **Never rewrite a pushed branch.** Follow review with additive commits, and
merge `upstream/main` in rather than rebasing when a branch falls behind.
- Respond to each review finding with a fix or a concise explanation. Do not
add a ceremonial comment when the changed code already answers it.
- **A test has to guard something that could break again.** She deleted #14's
test as pointless: the rename it guarded cannot regress. When a fix is
structural, the structure is the test.
- **Say who decided a constraint.** LAYOUT.md §2's "move slots carry
translation only" was written by an agent on 2026-09-04, was never asked for,
and read as settled until she said *"I was not aware that an agent decided
position slots should be translate only."* Mark an agent's own choice as one.
## What is left
**Next, and small:** `LazySpan`, the last of LAYOUT.md §2. `set_child_offset`
is no longer part of it -- a child offset is just placing the child, which
`Painter::place` now does.
Then, roughly in dependency order:
- **Built-in alignment, and probably size**, which the owner moved ahead of the
rest on 2026-09-14. Reproduced in the harness: `.width(rel(0.5))` inside a
`Dir::DOWN` span reports 200 of 400 and is handed the whole 400, and a `Pad`
in between does not change that. **Do not "fix" it by reading the child's
ortho `size_hint`** -- a `Pad` between the `SetSize` and the span has no hint
of its own, so the declared width silently goes back to filling. It works
only when nothing is in the way. Alignment has to belong to the widget rather
than be discovered through whatever happens to sit on top of it.
Two things beyond the bug argue for it. Built-in size removes `SetSize`, and
with it a wrapper reporting one size while handing its child the whole box.
And built-in alignment is what would let `OnResize::Translate` apply to
centred content, which otherwise has to say `Redraw` because only its own
draw knows where the middle was. Size is the harder half: a declared size
beside the one `draw` returns is two sources of truth for one thing, so
settle what each means before building it. This is independent of #18's
retained-update fix now that `Aligned` is in its generated coverage; land
#18 first, then design built-in alignment and size together as the next
structural slice rather than mixing that representation change into this
performance correction.
- **`OnResize::Translate`, which still does nothing.** The chain removed half
its obstacle: a placed widget's slot holds the box it was offered while its
drawing is a set of fractions of that box, so the two are no longer one
field. What is still missing is a widget saying *where* in a bigger box its
unchanged drawing should sit, which is the alignment work above.
- **`UiRenderState` behind `Rc<RefCell<..>>`**, queued by the owner on
2026-09-13 as fundamental, and especially so for text.
- **`Len`, `LayoutLen` and dp.** The archive splits the type so that `rest` is
unrepresentable where it is meaningless (a padding), and folds a density in
at resolve time. 21 files mention `Len`, so it is wide but shallow.
- **The input restructure** -- `src/default/sense.rs` becomes `src/rsc/sense.rs`
(308 lines to 2313), plus `core/src/event/controller.rs`, `desktop/input.rs`,
`android/input.rs` and `sense_tests.rs`: pointer capture, drag slop and axis,
platform cancellation, mask-aware hit testing, event timestamps. The
archive's own `consumes` is what #12 landed, so that part transplants;
`tests/pointer_routing.rs` is the acceptance criterion.
- Retained span, scrolling and layout placement.
- Retained paints, selection, overlays and shared UI runtime state.
- Generic desktop/Android framework hosts and reusable example/APK tooling.
- Application-owned fonts and application-named font families.
- Shared resource-handle bookkeeping and replaceable glyph-atlas buckets.
- Positioned text overflow and cluster-safe ellipsis.
Dependencies are current apart from `winit`, which stays on 0.30.12 until 0.31
leaves prerelease. `parley` 0.11.1 and `image` 0.25.10 are latest.
The archive is a reference, not a patch to apply -- it writes `Widget::draw`
against `painter.set_size`, which #16 replaced with a returned `Size`, and
lengths against `LayoutLen` and `density`, which canonical does not have.
Recreate a change on today's types, leave app-specific behaviour out, and
verify it independently.
```sh
cd /home/bob/repos/iris && git fetch upstream
git diff --stat upstream/main..origin/archive/full-extraction
```
## How the renderer works now
Current invariants, not history. Worth reading before touching `core/render`.
- **A primitive registers itself by being drawn.** The type carries its own
WGSL, and `PrimitiveRegistry` keys ids by `TypeId`, so the kind comes from
the type and there are no `RECT`/`GLYPH`/`TEXTURE` constants. Nothing is
seeded, so an id depends on what a ui drew first and a ui pays only for the
pipelines it uses.
- **Each primitive records its own draws.** `Primitive::render` makes a
`PrimitiveRender` that states the layout its shader reads, uploads whatever
it owns, and records its draws. `GlyphRender` owns the atlas and binds it
once per list; `ImageRender` owns the images and binds one per instance; the
default owns nothing and draws every instance in one call. The renderer sets
the pipeline, the shared group, the list's data and its vertex buffer, and
knows nothing else.
- **The shared bind group is the window, the masks and the move chain**, given
to every draw. A mask texture would go here too. What a primitive samples is
its own group, and a primitive that samples nothing has no such group in its
pipeline.
- **Every binding size is stated.** A `None` minimum puts the binding on
wgpu-core's late-sized list, which `is_ready` scans on every draw.
- **`shader/prelude.wgsl` plus one file per primitive**, because one module
cannot declare two types at the same binding. The prelude carries only what
every primitive uses -- window, masks, the chain walk, the vertex shader,
`masked()` -- and its header is where binding numbers are written down.
- **A texture handle is drawn like anything else.** `Painter::primitive` takes
`impl PrimitiveLike`: a primitive, or something that yields one and does
whatever else drawing it needs -- a `&TextureHandle` retains its share on the
way through, which a `Pod` primitive cannot.
- **Order within a layer means nothing**, and the widgets do not rely on it:
`Stack` gives each child its own layer and `TextEdit` draws its view in a
child layer above the selection rectangles.
- **Images are one texture and one bind group each**, so each drawn image is a
draw call. The owner chose that on 2026-09-13 over packing images into arrays
like atlas pages; a bindless `binding_array` was ruled out by Android
support. Revisit only with her.
- **Layers are never freed** (`TODO` in `primitive/layer.rs`), so every layer a
session creates is walked every frame thereafter. Measured at ~2ns per empty
layer per frame, which is why it is the TODO's problem and not a bug of its
own.
## Repository topology
### ai-app checkout
- `/home/bob/repos/ai-app-2`, `origin = git@git.arirex.me:iris/ai-app.git`,
branch `rustify`.
- `iris/` is a submodule pinned at `32f6ad8`, the complete extracted snapshot,
and `.gitmodules` points at the **bot fork**, not canonical Iris.
- Do not change either casually: ai-app needs the complete snapshot while
canonical Iris is only partly caught up. Reconcile when canonical contains
what ai-app needs, or when the owner accepts a temporarily non-building pin.
### standalone Iris checkout
- `/home/bob/repos/iris`, `origin` = fork, `upstream` = canonical.
- Fork `main` and `origin/archive/full-extraction` both name `32f6ad8`, the
target snapshot. `history/full` names the source-history result `a615bcd`.
- **Do not reset, overwrite or force-push fork `main`**: it is both the target
reference and the commit ai-app pins.
- Start each new branch from current `upstream/main` in its own worktree:
```sh
cd /home/bob/repos/iris && git fetch upstream
git worktree add -b split/19-name /home/bob/repos/iris-pr19 upstream/main
```
`/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree
holds a merged branch; they are readable references, not places to build.
`/home/bob/repos/iris-perf2` is detached at `b1b3eca`, built, and is the
"before" side of any measurement of the last two commits; `iris-size-old` is
`43ce8c7` with the old patched counters, and is how the pre-#16 column above
was measured. Copy `tests/revision_cost.rs` into either to compare.
## Cautions
- Read `/home/bob/repos/ai-app-2/AGENTS.md` and the machine-wide rules first.
Anything about this machine -- the GPU that comes and goes, measuring a small
performance difference, the emulator -- is in `~/.claude/MACHINE.md` and the
`this-machine-*` skills, and belongs there rather than here.
- Keep Iris generic: session drivers, transcripts, setup and server concepts,
app icons and product fonts stay in ai-app. Android and desktop code is Iris
work only when it is a generic host or platform integration.
- Preserve the dirty-worktree rule. All worktrees were clean at handoff;
anything found later may be the owner's or another agent's.
- Do not delete the archived snapshot or the fork `main` ai-app pins.
- A complete target branch is not permission to recreate the giant PR.
- Another agent was freeing disk on this VM and removed `target/` from the
`iris-pr*` worktrees once. Sources and git state were untouched. Tell peers
before changing shared machine tooling, and expect a cold rebuild sometimes.
## Merged so far
| PR | On canonical `main` |
| --- | --- |
| #2 | Build on the current nightly (`4275314`) |
| #3 | Request a frame after resize (`936fbdd`) |
| #4 | Decouple `iris-core` from winit (`465e430`) |
| #5 | Use vsync by default (`ec2b5d4`) |
| #6 | Notify winit before presenting (`db9b0f2`) |
| #7 | Keep unsafe reference helpers internal (`0191f20`) |
| #8 | Initialize the window uniform from the surface (`6e271e8`) |
| #9 | Preserve primitive-count recursion (`b90c855`) |
| #10 | Text layout and rendering on Parley (`0f6a28b`) |
| #11 | Atlas as an array texture, and the primitive rendering overhaul (`b234497`) |
| #13 | Build on wgpu 30 (`00d2230`) |
| #14 | Rename the `Sized` widget to `SetSize` (`32b1038`) |
| #15 | Run a ui without a window, and test one (`c8ac669`) |
| #12 | Route pointer input per kind (`43ce8c7`) |
| #16 | Size a widget while drawing it, not in a pass of its own (`f942385`) |
| #17 | Bring the headless rig into the repository (`ca2b4b2`) |
URLs are `https://git.arirex.me/iris/iris/pulls/{number}`.
+630 -99
View File
@@ -4,10 +4,139 @@ A widget draws once and records its size on the `Painter`. Reading a child
`DrawResult::size()` records a retained size dependency; drawing the child
without reading that result does not make the parent's size depend on it.
§1 and §2 have landed in Iris (#16 and #18) and the notes below have been
brought to what shipped rather than what was proposed; §3 to §6 describe the
same design as it stands. `docs/IRIS_EXTRACTION_HANDOFF.md` has the invariants
the code now rests on and what is still to do.
§1 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19
carries those commits whole plus the retained-layout repair built on them. §4 to §6 and the density section
retain the rationale of the design but still name types that have since been
replaced; they are not an API reference. `docs/HANDOFF.md` is where the work
in flight stands. The sections from "Three names, and the one argument that
says them" onwards are the settled design, the findings that outlived the
working log, and the measurement method.
## Bounds on the offered box
`SizeRule` carries an optional preferred request and independent min/max bounds.
The bounds constrain both the box offered to a widget and its reported size.
`.min_width`, `.max_width` and their height variants edit that widget's rules
and preserve its identity and type; `MaxSize` is removed. Setting `.width` or `.height` preserves the bounds; setting a bound
preserves the preferred request and the other bound, in either call order.
`Widgets::set_len` makes the same edit at runtime; `set_size_rule` replaces
the complete axis. This replaces the rule/widget split recorded in
`LAYOUT_LOG.md` (Bryan, 2026-09-20).
The shared ask resolves declarations and bounds against the incoming rel
base, compares the offer, and retains the chosen box length in `Declared`.
These are window lengths. Placement aligns a retained length in its destination
without resolving it again or comparing the destination against the bound.
The comparison's window range and incoming-region dependency invalidate the
ask when its inputs change. Ask constraints are combined after the draw, so a
widget widening its own `window_holds` cannot erase a bound's crossing.
Fractional rules also track their incoming base. Allocated slots use the
allocator's base for bounds; applying a 25% cap to its own 25% slot would
shrink it twice. Changing an inactive bound also reaches the parent, because
it changes which offers the parent can safely reuse.
A cap constrains wrapping and scroll viewports, but short intrinsic content
can still report less than the cap. Shares remain deferred requests for the
parent to allocate; the offered box does not replace their reported weight.
Measured against `4cb6f68`, medians of five release-process instruction counts:
| Fixture | Before | After | Change |
| --- | ---: | ---: | ---: |
| 128 capped rows, 2,000 resizes | 7.387B | 5.779B | -21.77% |
| Same, every widget redrawn | 13.864B | 9.755B | -29.64% |
| 128 plain rows, every widget redrawn | 10.041B | 10.010B | -0.31% |
| 128 fixed-width rows, every widget redrawn | 10.085B | 10.056B | -0.29% |
| 40 text rows, 1,000 resizes | 9.460B | 9.472B | +0.13% |
Plain and fixed-width retained resizes differ by under 0.05%; text repaint,
edit and scroll differ by -0.05%, +0.07% and +0.06%. `tests/bounds_cost.rs`
asserts the same geometry for the old builders and new attributes, with 513
active widgets before and 385 after in the capped fixture. The text runs use
`tests/revision_cost.rs`. These include cold setup and do not measure GPU or
phone frame times. The unbounded cold dump remains identical at 34,986 boxes;
bounded geometry changes intentionally because the offer is now constrained.
The storage tradeoff is explicit: a `SizeRule` is now 40 bytes rather than 24,
so independent preferences and bounds add 32 inline bytes per widget across
both axes. Plain, expression-bounded and attribute-bounded fixtures each
perform zero allocations over 100 forced-redraw resize frames after warm-up.
Bounds introduce no heap allocation; an expression also loses
its former extra outer `Arc`. Artifacts are in `/tmp/attribute-bounds/`.
## Deferred size requests on PR #19
`Widget::draw` still returns the small, copyable `Size` of two `LayoutLen`s.
An optional `size_request` method describes an axis without drawing it. Its
context can query children and compose sums, minima and maxima; `None` means
that drawing in a concrete offer is necessary. Existing leaf widgets need no
new method: their `size_hint` supplies a plain request when available.
Declarations can use expressions, for example
`rect(color).width(leftover(1).clamp(40, 120))`. In a 300-pixel row beside an
uncapped equal share, that child takes 120 and its sibling takes 180. At 100
pixels they take 50 each. Comparisons can have shares on both sides, such as
`(px(30) + leftover(1)).min(leftover(2))`. Weights must be nonnegative.
A span discovers requests, measures any unknown content, solves the shared
allocation, then places children. Known requests avoid provisional painting.
After measuring a container, discovery can use its children's measured answers
to finish an expression that was previously incomplete. Thus a measured leaf
inside a nested capped row does not force the row's request to become a pixel
constant. A custom container must implement request composition to propagate
such expressions through itself.
Plain sums retain the existing symbolic allocation path. Comparisons with a
known ordering (pixels with equal fractional/share coefficients, or shares
with equal fixed terms) fold to ordinary lengths. Remaining expressions are
monotone piecewise linear functions of one share unit. The allocator advances
through exact rational crossings until the sum fills the offered room. Caps
return room to other shares; floors may overflow; all caps being reached may
leave unused room. Prefix rounding gives adjacent slots the same edge.
Persistent declarations share immutable expression nodes. Temporary composition
uses a reusable arena, and each retained drawing keeps its vector capacities.
Temporary request handles are valid only within that layout pass. An explicit
allocated placement distinguishes a solved slot from a box that merely fills
its parent's region, so declarations are not evaluated a second time against
the slot they already chose. Request dependencies invalidate the allocator when
a descendant's rules or contents change. A solved declaration retains its
pixel length when moved, including by scrolling; it must not become a fresh
fraction of the destination box. Relative intrinsic bounds record their input
base separately from the widget's resulting box, and retained answers compare
resolved bounds before reuse.
Two measured cases remain intentional. Intrinsic fixed content is drawn in the
remaining offer and then moved; drawing it in its reported size would change
wrapping and overflow. When no leftover room exists, discovered intrinsic
shares use that same measured path. Cross-axis span maxima also remain measured:
a main-axis share can be hidden, so the maximum of every child's request is
not necessarily the maximum of the children that actually draw. Generalizing
that would require carrying visibility through the expression system too.
The allocation rig forces every widget to redraw across 100 resize frames:
both the plain and clamped eight-row fixtures allocate zero times after warm-up.
This is a measured property of those stable visible trees, not a guarantee for
arbitrary widgets or visibility changes. An eight-level known nested span paints
its leaf once. Discovery retains dependencies only for requests it actually
uses; discarded discovery leaves dependency tracking to the measured draw.
Used hints still register request readers so a newly added cap reallocates the
parent even when the child's reported share is unchanged.
The performance sweep of 2026-09-20 found and removed an eightfold increase in
the unchanged-paragraph repaint fixture caused by discarded discovery
invalidating its span. The 40-row, 1,000-frame text resize rig now costs 9.432B
retired instructions against 9.340B at `de1eb7e` (+0.99%), down from 9.890B
at `8780b40` (+5.90%). Text draw, line-break and glyph-placement counts agree;
the remaining cost is layout bookkeeping. The width sweep is +1.23%, and
paragraph edits and scrolling are within 0.3% in the measured fixtures.
These are medians of nine process totals, not GPU or phone frame times.
Deep generated trees still have workload-dependent wins and regressions;
`docs/LAYOUT_LOG.md` records the matrix, storage costs and measurement limits.
The 400-tree unbounded cold dump preserves all 34,986 boxes against `de1eb7e`;
the performance correction also preserves all 34,986 bounded boxes against
`8780b40`.
The app's submodule pin is unchanged. This design lives on Iris PR #19.
## Design
@@ -36,11 +165,12 @@ pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter) -> Size;
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw }
}
```
Two methods, not three: `on_resize` was proposed here and shipped, and §3
below replaced it with the `Holds` interval a widget declares while drawing.
A widget returns what it used of the box it was given. A child
draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()`
on that result reads the child's retained size and records that the current
@@ -48,15 +178,14 @@ widget depends on it. Dropping the result without reading it draws the child
without making the parent's own size depend on the child's.
No `available` parameter: `Painter` already carries the region the parent
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
size (`output_size()`, `:152`). Passing it again would be the same value
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
handed down (`Painter::region()`) and already exposes the pixel-resolved form
(`px_size()`) and the output surface size (`output_size()`). Passing it again
would be the same value under a second name. `desired_width`/`desired_height`
and `WidgetAxisFns::desired_len` are deleted outright — not
deprecated, not kept as a fallback — because a widget that implements both
`draw` and `desired_*` for the same thing is exactly the "two names for one
concept" the code rules call out, and it is what today's `Span::desired_ortho`
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
(as it was then) already complains about in its
own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it.
@@ -67,97 +196,89 @@ lying hint fails a debug assertion when the widget is drawn.
### 2. O(1) subtree movement
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
absolute-pixel delta and its parent slot; each primitive instance stores the
slot of the widget that drew it. The vertex shader walks this bounded chain
and adds the accumulated translation. Moving a subtree therefore writes one
slot instead of rewriting every descendant primitive.
A widget opts into one independently movable region with `.region_node()`, or
`Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
node's coordinates, `UiRegion::FULL` being the identity -- and each primitive
instance names the node it was drawn under. Moving a subtree through a node
writes one entry. A widget without the property shares the nearest ancestor's
node, and moving it remaps its retained primitive, mask and active regions
instead, stopping at any descendant node after rewriting that one entry.
The parent chain is required for independently movable nested subtrees, such
as a swipeable row inside a scrolling list. A flat offset table would require
rewriting the row whenever an ancestor moved and would restore the very
O(subtree) work this design removes. Chain depth is bounded in both Rust and
WGSL.
A box rather than a translation, because a pixel-space offset would scale a
child that has to keep its pixel length; the fraction and the offset in a
`UiScalar` are what tell the two apart. The parent chain is what makes nested
movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
table would rewrite the row whenever an ancestor moved, which is the
`O(subtree)` work this removes. `CHAIN_LIMIT` bounds the walk at 64 in both
Rust (`core/src/ui/mod.rs`) and WGSL, so a malformed cycle resolves the same
way on each side.
`Painter::place` draws a child whose box its parent decides and may decide
again, and gives that child a slot of its own; `widget` and `widget_within` do
not, and share the nearest ancestor's. A slot carries a whole **box**, not a
translation: a pixel-space scale and offset would scale a child that has to
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two.
(That slots carry translation only was an agent's choice on 2026-09-04, never
asked for, and #18 replaced it.)
`Moves::resolve` performs the same walk on the CPU for hit testing,
accessibility and window-coordinate queries, and the shader's `resolve_move`
mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
a box, which the shader decodes from constants the Rust side prepends: the
grid is stated once. Masks carry their own node and resolve it independently,
so a stationary viewport clips content that moves inside it.
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
hit-testing, accessibility, and public window-coordinate queries. Masks store
the move slot of their owning widget and resolve it independently in the
fragment shader, so a stationary viewport can clip moving content.
Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires
its slot only after descendants are gone, and a reused arena slot is reset
before new primitives can reference it. `Primitives::set_instance` also
cancels a dirty mark when provisional layout restores the original bytes, so
CPU-only measurement positions are never uploaded.
Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
after every descendant has migrated, since reusing the index sooner would
make an old parent look current. Changing the property redraws the subtree
once, to rebuild the coordinate boundary; it belongs to widget identity,
which is safe because a widget has one parent.
### 3. Resize scope
A resize is "the region a widget's parent offers it changes such that the
widget's draw might produce different output" as opposed to a move, which
by construction cannot (§2 is scoped to pure translation). Two independent
narrowings apply, and both are real, measured properties of the code as it
stands rather than new machinery:
widget's draw might produce different output" -- as opposed to a move, which
by construction cannot. Two independent narrowings apply, and both are
measured properties of the code rather than new machinery:
**(a) A window resize does not, by itself, require touching most widgets.**
`shader.wgsl` recomputes every primitive's pixel position from `window.dim`
and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU.
A widget laid out purely in `rel`/`abs` terms is therefore already correct
after a resize with zero CPU work. Calls to `Painter::px_size` and
`Painter::output_size` mark both concrete-pixel axes; `px_len(axis)` and
`output_len(axis)` mark only the axis actually read. Only widgets whose read
axes changed by more than 0.05 physical pixels become dirty. The comparison
is against each widget's last actual draw, so smaller changes accumulate
rather than disappearing event by event.
The shader recomputes every primitive's position from `window.dim` and the
primitive's stored fraction and offset every frame, already, on the GPU. A
widget laid out purely in those terms is therefore correct after a resize
with no CPU work at all.
All pixel-dependent leaves are marked before layout begins, along with every
chain of parents that read their sizes. Resize then settles the shallowest
shared readers first, under the new output, so overlapping dependency paths
are drawn once. Ordinary content changes use the opposite order: deepest
dirty widgets first, with a changed returned size propagated one reader edge
at a time. Re-reporting the current output size is a no-op.
What decides the rest is `Holds`, one interval of box lengths per axis:
*give this widget any box in here and it draws the same thing and reports the
same size*. A widget that never reads its box in pixels holds for every
length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
to the length read, and `Painter::holds` is how a widget widens it again by
saying what its drawing actually depends on -- a greedy line break holds from
its longest line up to the width it was made at. A parent holds for whatever
keeps every child it asked about or drew inside its own range, each child's
interval translated into lengths of the parent's box.
**(b) A widget's `available` (its parent's offered region) can change
without the widget's *content* changing — this is what `Widget::on_resize`
answers, per axis.** When a container's own layout shifts (a sibling grew or
shrank, changing this widget's offered box), a widget that says `Scale` on the
axes that changed is not redrawn: everything it drew is a fraction of its own
slot's box, so writing that one box moves and stretches all of it. `Span`,
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`;
`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the
default, is correct always and free never. `Translate` — an unchanged drawing
placed somewhere else in a bigger box — is reserved: nothing reads it until a
widget can say where in that box its drawing belongs, which is the alignment
work.
This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
answers, which said the same thing per widget type and could not say *how
far*. There is no per-widget resize mode now: a widget that reads nothing is
never redrawn for a resize, one that reads its width is redrawn when its
width leaves the interval it declared, and the interval is the whole of the
statement. Do not restore `Translate`; a retained subtree that only moves is
remapped through the box chain of §2, exactly.
**Size invalidation travels upward before drawing; drawing itself travels only
downward.** Every active widget retains the direct children whose size it read
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes
one id from the dirty set, follows only those dependency edges upward and marks
that path dirty, then redraws its highest already-dirty ancestor. Drawing that
ancestor consumes the marks of every dirty descendant it reaches; the loop
then takes whatever remains. Drawing never synchronously invalidates or invokes
a parent, so there is no layout recursion and no provisional child draw on a
different layer.
Lengths are whole counts of `1/1024` px, so "the box changed" is equality
rather than a tolerance: a change too small to reach the next step is not a
change, and one that reaches it is, however little of a pixel it is worth.
An exact `size_hint` stops propagation when both axes still equal the retained
size. Otherwise propagation is deliberately conservative: the child may have
changed size, and only its dependent ancestors can assign the final boxes.
Unchanged descendants still take `draw_inner`'s retained skip-or-move path.
An active widget also retains which offered-box and output axes flowed into
the size it reported, directly or through a child size it read. A container
may use that answer for the same prospective box when every observed input is
still within 0.05 physical pixels; content dirtiness anywhere in its size
dependency subtree rejects the answer. This is a generic constraint rule, not
a text exception. Wrapped text is merely the common example: it reads width,
so changing only height leaves its answer valid.
**(b) Size invalidation travels upward before drawing; drawing itself travels
only downward.** Every active widget retains the direct children whose size it
read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates`
takes one id from the dirty set, follows only those dependency edges upward
and marks that path dirty, then redraws its highest already-dirty ancestor.
Drawing that ancestor consumes the marks of every dirty descendant it
reaches; the loop then takes whatever remains. Drawing never synchronously
invalidates or invokes a parent, so there is no layout recursion.
Dirty widgets settle deepest-first. `dirty_size_under` has been deleted;
settling consumes descendant marks bottom-up, so no clean retained answer can
hide an unsettled size dependency. An exact `size_hint` stops propagation when
both axes still equal the retained size; otherwise propagation is deliberately
conservative, since only a dependent ancestor can assign the final boxes.
This is a generic constraint rule, not a text exception. Wrapped text is
merely the common example: it reads width, so changing only height leaves its
answer valid.
### 4. Wrapped text, and "needs child height before choosing width"
@@ -307,6 +428,11 @@ physical pixels, unchanged.
## Masks
This section and "UI ownership and rel base access" describe the app's pinned
Iris implementation. The upstream #19 review branch still has single
rectangular masks and separate `UiData`/`UiRenderState`; adopting it requires
integrating the app-side capabilities as well as migrating layout calls.
A `Mask` references a rectangle primitive and its parent mask. Nested masks
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
widget's region; `.masked_by(shape)` draws the shape behind the content and
@@ -319,14 +445,419 @@ fragment stage cannot make. Rendering and hit-testing both traverse the full
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
checks the WGSL implementation against the CPU SDF.
## Offered boxes
## Three names, and the one argument that says them
`Pad` must work in every container: it offers an inset region to its child and
reports the child's used size plus padding. In a generous parent it behaves as
an inset; in a tight parent it grows the result outward.
Settled with Bryan on 2026-09-17 and 2026-09-19. Layout has exactly three
things, and only two of them are boxes:
When a widget does not fit its offered box, it is redrawn at the box implied by
its reported size in the same frame. Deferring would leave ordinary
`.background(rect(..))` surfaces one frame behind their content. The settling
draw occurs only when the widget's own size changes. Widgets whose size varies
with every offered box are therefore unsuitable as `LazySpan` rows.
- **region** — the box a parent asks a widget in. It draws there, and a text
wraps at it. `ActiveData::region`, `Painter::region`.
- **placement** — the box its drawing ends up in: a box the size it reported,
positioned inside the region by its own alignment, and equal to the region
wherever it fills one. It is computed after `draw` returns, is not on
`Painter`, and no widget mentions it. `ActiveData::placement` is what
`window_region` resolves for hit testing.
- **rel_base** — not a box. The length a fraction the widget declares or
reports is a fraction of, per axis, as a length of the window. It is the
one value that never changes coordinate space: a region node re-expresses
the region locally and takes the rel base untouched.
`frame` and `extent` are retired and must not come back in this sense; the
word "frame" for one rendered frame is unaffected.
A container says all three in one argument. `Painter::widget_at(id, place)`
and `place_at(id, place)` take `impl Into<PlaceDesc>`, so a wrapper that only
hands over a box passes a `UiRegion` and says nothing else — which is all
`Pad`, `Offset` and `Painter::widget` do.
`PlaceDescAxis` is one axis. It is built by chaining off the value that says
it, never by a constructor naming the type, because a constructor makes the
reader go back to the start of the line. The `_desc` suffix is what says which
type comes out. The three are named after the operations the geometry already
had, because two of them take the same span and differ only in how it is
applied:
- `UiSpan::within_desc()``UiSpan::within`: composed into the caller's box, so it
moves and scales with it. What an inset speaks.
- `UiSpan::shifted_desc()``UiSpan::shift`: window lengths from where the caller's
box starts. What a container dividing room speaks, and what makes a moved
box re-place every child by addition, exactly. Not directional: `Dir::Neg`
is handled by the span before the numbers get here.
- `Len::as_desc()` — the body of `placement` with the length given from above
rather than reported. What a stack's sizing child decides for the rest.
What is optional is a builder, so a caller writes only what it decided:
`.fills()` says the region is the placement, and `.rel_base(len)` names the
child's rel base outright. **The rel base a caller does not name follows the
constructor** — `within_desc` narrows it the way the box is narrowed,
`shifted_desc` passes it through, `as_desc` is it. That rule is what makes the common case
right by default; stating it by hand was the one thing a container could get
wrong with nothing failing.
`PlaceDesc` is the pair, with `x` and `y` fields and the `Index<Axis>` and
`from_axis` of every other pair here, so the joint work — resolving a
region, reading the fill flags — is written once rather than per axis.
`PlaceDescAxis::on_axis(axis)` goes the other way, lifting one axis into a pair
with the whole box across it.
**Every pair here is a struct of two per-axis values, read with `[axis]`.**
`impl_axis_index!` gives it `Index<Axis>`/`IndexMut<Axis>`; there are no
`axis`/`axis_mut` methods and no bare `[T; 2]`. A pair kept as arrays of its
fields instead — which `LayoutHolds` was, until `AxisHolds` — cannot write
any of its own operations once.
## Rel bases, decided boxes and padding
Containers that only divide room are transparent to fractions. A child rel base
is narrowed by a length its parent decided: a declared `px` or `rel` length,
or the resolved slot of a `leftover` child. A box a widget reports for itself
does not narrow its descendants' rel base.
`Pad` is an inset (Bryan, 2026-09-18): it subtracts the padding from both
the child's rel base and the child's box, and reports the child's size plus the
padding. A `rel(1.0)` child inside padding fills the pad without overflowing
it; a wrapping text inside padding wraps at the box the pad was given less
the padding. A span's rel base never subtracts siblings, so a padded fixed
child measured after a 24 px sibling in a 900 px row wraps at 844 while a
`rel(1.0)` inside it is 868. There is no outset kind and no mixed kind.
A widget draws once, in the box it is asked in; its answer is placed inside
that box by re-expressing the drawing, and nothing is drawn again in a box an
answer chose. `Holds` is a contract about the ask box alone, read only to
decide whether a re-ask can be skipped. A container that puts an answer
somewhere other than where it asked says so with `Painter::place_at`, which
never runs the body.
The answer box is never a question, so no contract can be demanded of a
widget there: a `Text` asked at 45 px whose longest word is 89.5 px cannot
promise its drawing holds for the box its own answer chose. An earlier plan
asserted exactly that and was unsatisfiable.
A container that can learn a child's length without drawing it does.
`Painter::size_hint` answers from the child's rule, or from
`Widget::size_hint` where it implements one, resolved against the asking
widget's rel base; `Scroll`, `Masked` and a `Stack` without a sizing child hint
`LEFTOVER`, since each always reports the whole of its box. `Span` takes each
child's length from its hint where there is one and asks that child exactly
once, in the slot it decided; a child with no hint is drawn in the room left
from the cursor, because a text has to wrap at the width actually there, and
its drawing is moved to its slot with `place_at`. Reading a hint records a
size dependency and pins the rel base where the hint declared a fraction, the
same pin a rule that is a fraction takes.
**What that leaves, and the design that would remove it.** A widget that
reports a share its children gave it -- a span whose children report
`leftover`, a stack sized by such a child, a wrapper round either -- can have
no hint, so its parent asks it in the room and again in its slot, and both
asks draw, since the room drawing divided the room. Under a resize that
multiplies down the tree: at seed 1, depth 8, the hottest widget in the
resize frame is a `Span` drawn 8 times. Removing it means two answers on one
record -- a room answer from an explicit measuring ask (`Painter::measure`,
so which ask is the measurement is stated rather than inferred) and a slot
answer from the placing ask, with the drawing belonging to the placing one --
plus a local redraw that re-asks both questions and marks the parent if
either answer moved, which also retires `re_asked`'s deferral. Do it only
once an app screen shows the cost, and measure that screen first.
**There is one coordinate unit, the window** (`1512d84`, `23523ee`). Every box in the
tree is a region in window units; a widget's rel base is a *length* in the same
units and is only what fractions resolve against, so a widget's box need not
be its rel base and padding can take from both. A region node's entry is a
translation -- a `rel 1` region anchored where its box starts -- so nothing
composes a rel base back up a chain.
A rel base decided for a child is therefore a length of the window too: a row's
slot, padding's rel base less its pixels, or the box a stack's sizing child
decided (which arrives as `PlaceDescAxis::sized`). It is never a fraction of the
parent's rel base, because a slot of a row is not a fraction of anything the row
can name -- a division of two lengths, which a `rel + px` length cannot hold.
A widget's own declaration *is* a fraction, of whichever of those reached it,
and it is the only one of them that also places the box inside the part.
Validity has a pin for each: `LayoutHolds::region_len` for the box's symbolic
length and `rel_base` for the rel base's. A range of window pixels cannot say
which rel base an answer is a fraction of, since two rel bases are different
lengths at the same window size. Resolving any of these lengths in pixels is
a read of the window, and `Painter::to_px` is where that read is taken --
pinning the window where the length has a fraction in it, and nothing where
it is only pixels.
## Layout decisions and invariants (2026-09-15 to 2026-09-17)
Moved here from the handoff on 2026-09-18. These are settled unless a
subsection explicitly says it is pending.
### 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 seen 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 value that comes back as a box is rounded away from the measurement,
not to the nearest step.** `Fixed::ceil_from_f32` exists for that and is
the only rounding on the grid that is not to nearest. Rounding to nearest
is right for a value being carried and wrong for a bound; a text reporting
`ceil` of its longest line is what keeps the box it is handed back one its
line fits in (`4bd8607`).
- **A structural decision may not be taken on a hair's breadth.** A
boundary that decides which children exist (a span's leftover split) is
derived through the inverse of the expression that draws, never by a
second expression for the same length: `mul` floors while `div` rounds,
so a boundary derived with a division guards a drawing made with a
multiply (`53b00c6`).
### A box in pixels is one multiply from the window
Every length in the tree is a length of the window, so it becomes pixels in
one multiply wherever it is read: `Painter::px_size` and `px_len` for the
box, `rel_base` for the rel base. There is no chain to walk and no coordinate
rel base in the way, so a region node cannot break it and warm and cold reach
every length by the same expression. (Before `1512d84` a rel base was a length
of its parent's rel base and a local redraw walked back up the parent chain in
`asked_px`; both are gone.)
- **`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. What is
stored is the symbolic rel base, which a redraw resolves against the window
it has.
- **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.
- **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. `tests/cases/drift.rs` pins that the grid does not drift
either way. A length given in pixels is that many pixels wherever it ends
up (`Len::within` adds a part's own pixels rather than scaling them);
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.
### Retained-layout invariants
- `Holds` is the interval of box lengths for which a widget's drawing and
reported size remain valid. Reading `Painter::px_len` or `px_size` narrows
it; `Painter::holds` widens it. The contract is trusted rather than checked
defensively on every use.
- A retained drawing is reusable only when its `Holds` contains the new box
on both axes, its parent and region-node choice match, it is on the layer it
was drawn on, its inherited mask matches, and the widget is clean. A valid
ordinary subtree moves by recursive remap; a region node moves by one entry. A container that draws a
child to learn its size uses `Painter::child_layer_at`, the layer the child
will actually occupy.
- An answer's validity and its final drawing's validity are independent. A
parent may reuse an answer while redrawing the placed output. Translate the
drawing contract back through its placement; do not intersect it into the
answer contract.
- A fraction resolves once against its rel base. A report returns raw and is
composed only where a parent narrowed that rel base. A part's own pixel length
is added rather than scaled, so a pixel length remains that many pixels at
every nesting depth.
- An asked-but-undrawn size dependency belongs to the widget that asked. Keep
it recorded so a later child change reaches the parent that decided not to
draw it. Dirty size dependencies settle deepest-first.
- A widget that creates a mask clips to and reports its box. Its own mask and
its inherited mask are distinct retained state: the former says which mask
a move rewrites, while a local redraw receives the latter. The owner retains
one mask reference and reuses its slot across redraws, independently of
primitive references; dropping the mask or undrawing the owner releases it.
- A span's leftover/no-leftover boundary is a strict structural decision, not
a tolerance. Derive the boundary through the inverse of the expression that
places children. A cap may not contain `leftover`, because feeding the
span's own room division back into a cap admits multiple fixed points.
- Text shaping is retained separately from line breaking. A greedy break
holds from its longest produced line through the width at which it was
made, expressed with `Painter::holds`.
- A region node stores one whole `UiRegion` in its parent node's coordinates;
`FULL` is the identity. Changing node ownership redraws the subtree once,
and a removed node's move entry remains alive until every descendant has
migrated.
- Alignment is one value per axis and defaults to the middle because neither
edge is neutral without a direction. One widget has one length per axis; a
second length requires a second widget through `.wrapper()`.
- No measurement is a different state from a measured zero, so
`ActiveData::answer` is an `Option` rather than a zero size.
### What the fuzzers tolerate, and what they reach
Warm and cold pixel regions must compare exactly; there is no step
allowance. When a row's grid-step count is not divisible by the number of
children, individual share widths differ, but every rerun of that layout
must still agree exactly.
- The routine runs are ten seeds (fast oracle), 400 at depth 5 through the
shrinker, and 1000 at depth 6. The 2000-seed depth-4 scan over every case
is what found seeds that had failed on every commit for a week, so run it
after any layout change. `Rng::new` is `seed | 1`, so an even seed and the
odd one above it are one tree.
- The shrinker panics per thread at the first failing seed, so a run reports
at most one seed per chunk of about 58. Check a single seed against the
unpatched code before calling it new.
- Depth finds things and so does breadth; widen one axis at a time and
record which.
- If a seed fails once and never again, suspect the rig's determinism before
believing an edit fixed it: seed 30 at depth 5 did exactly that on
2026-09-19 and reverting each part of the change did not bring it back.
- The cases are what a change is *then* compared at. Until the `size-resize`
case was added, every one of them compared at the window the change was
made in, and a length kept as a fraction of the wrong box agrees there and
parts from cold at every other window. There is still no case that changes
a tree twice with a resize between, and none that resizes twice.
- The fuzzer grows no `rel` rules and never re-parents a widget, so neither
a fraction resolved against a rel base nor a subtree changing hands is
covered by any generated tree. Both have cost a defect that only a
hand-written expectation or a render caught.
### Failed hypotheses worth not repeating
- **"Which draw is the measurement" cannot be defined.** Four bookkeeping
rules and two experiments each fixed some seeds and broke others. The bit
existed only because a widget was drawn twice; one draw leaves it nothing
to name. A plan that proposes bookkeeping for a distinction should first
ask whether the distinction has to exist.
- **A rel base narrowed by a region does not move when its part moves.** A
narrowed rel base must be a *length*, put back into the part on every
placement, exactly as a declared rule already is.
- **Choosing between a fixed and a relative child in pixels at the span's
current width** admits multiple self-sizing fixed points; seed 13 settles
differently warm and cold under it. The same circularity is what a cap
containing `leftover` would put into `SizeRule::Max`.
- **A tolerant endpoint on the span's leftover split** retains zero-height
children (seed 16). The boundary is derived through the inverse of the
expression that places children, and is pinned by
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
- **Not reading a span's own length where no slot depends on it** saves
nothing (`wip/inset`, measured 2026-09-19): counters identical at every
phase. A symbolic pin survives a resize because every ask box is
symbolically stable across one, so the pin only fails where an answer
changed, which is a real relayout.
- **Resolving a rule that is a fraction of the rel base from the record instead
of redrawing** recovers nothing: `reuse outside: a rel base` is 3 of
264 cold draws at seed 1 and 1 of 758 at seed 13.
- **A child rel base expressed as a fraction of the parent's region** (the
"extent-relative child frame" trial, 2026-09-17) was removed. The
size/resize cost it was aimed at came from conflating answer and drawing
dependencies, not from `Pad` and `Stack` reading their raw placement
conservatively. A decided rel base is a length of the window, not a
fraction of anything the parent can name.
- **A `git bisect`** once named a commit that could not be the cause; read
the tree rather than the bisect when that happens.
### Rendering the grid (pending)
`snap_floor` in `prelude.wgsl` adds half a layout step before flooring,
which absorbs float error and not a layout step, so a third of 900 px
(299.999 on the grid) lands at 299 on screen. Bryan approved on 2026-09-17
rounding to the nearest pixel in the shader together with round-to-nearest
in `Fixed::mul` on the CPU, as one change with one verification; neither has
landed. The reason for the CPU half: 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`; re-derive
`Holds::through` for it; check with `nm` that `UiSpan::within` still
inlines.
## Measuring layout cost on this machine
- **Counters, at two seeds, before anything else.**
IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \
--test layout_diagnostics -- --ignored --nocapture layout_cost
reports each phase's `widget draws` and `distinct widgets`, and
`IRIS_PHASE=resize IRIS_FRAMES=2` names who is drawn how often. Where the
one-ask protocol stands, draws / distinct at depth 8, beside #18:
| seed 1 | #18 | now | | seed 13 | #18 | now |
| --- | --- | --- | --- | --- | --- | --- |
| cold | 369/261 | 264/232 | | cold | 1330/707 | 758/627 |
| many | 157/95 | 41/41 | | many | 524/159 | 16/16 |
| size | 16/12 | 3/3 | | resize | nothing | nothing |
| scroll | 2 | 1 | | | | |
| resize | 13/13 | 36/13 | | | | |
`many`, `size` and `scroll` are at their floor: every draw is a marked
widget, or the parent a marked widget deferred to. `resize` at seed 1 and
cold's draws over its distinct count are the reported-share cost above.
- **A change that could move cold layout gets the dump diff**, which the
warm/cold oracle cannot replace -- both of its sides move together:
IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release \
--test layout_dump -- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ '
once at each commit, then `diff`. Zero differing lines of 34,488 is the
expectation for a cost change.
- **Check the work counters before comparing two commits' times.**
`tests/layout_diagnostics.rs` 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 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. 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.
- **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: 2.8% of a 500-frame `many`.
- Tried and rejected, with numbers: a float reciprocal for the remap
division, +6% cycles; branchless `shift_round`, +6.7%; 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`. Wrapping (`4febabf`) was -8.6% instructions; truncating
(`08c9d5a`) costs a share a thousandth of a pixel of its row.
- Threading the pixel box down the draw (2026-09-17) was free on cold
layout and 9-13% of instructions off the retained paths, measured against
`5b78002` at seed 1, depth 8, medians of 21.
+1675
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -864,6 +864,25 @@ such as corner radius and text's position within horizontal overflow use
`Len`. Flexible padding participates in the same proportional allocation as a
span rather than silently discarding its `rest` component.
**The one-ask layout design is settled, and lives in `docs/LAYOUT.md`**
(2026-09-17 to 2026-09-19). What this entry used to describe as the
"frame/extent experiment" is Iris PR #19, and it has its own vocabulary now:
the box a parent asks a widget in is its *region*, where the drawing ends up
is its *placement*, and the length a fraction resolves against is its *rel
base*. A container says all three in one `PlaceDesc` argument. `Widget::draw`
remains the only layout body. PR #19 also has optional size-request discovery
for deferred min/max/clamp expressions (2026-09-20); ordinary widgets still
return `Size`, and plain requests retain symbolic allocation. Built-in
min/max/clamp rules also constrain the offered box; the dimension helpers
edit the widget's own attributes, replacing the `MaxSize` wrapper. Preferred
lengths and bounds are independent, so setting `.width` preserves `.max_width`
and vice versa. Bound choices are retained as resolved lengths so moving a
drawing cannot resolve them against a different box. The invariants,
the measured costs, the fuzzing method and the failed hypotheses are all in
`docs/LAYOUT.md`; the app's pin at
`32f6ad8` predates every part of it, so nothing here depends on it until the
integration described in `docs/HANDOFF.md` lands.
**A `Span` leaves its children in the offered orthogonal region unless
explicitly compacted** (2026-09-12). It still reports the widest orthogonal
child as its intrinsic size, so a row nested in a column keeps its content
File diff suppressed because it is too large. Load diff