121 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
7 changed files with 2796 additions and 1137 deletions

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
[submodule "iris"] [submodule "iris"]
path = iris path = iris
url = git@git.arirex.me:iris-ai/iris.git 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. 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 `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 and update it when a decision changes. `docs/HANDOFF.md` is where the work in
lands: preserve current invariants, measurements, and failed hypotheses, not a flight stands; read it first in a fresh session and keep it current. Working
chronicle of completed tasks. Do not create a decisions log. 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 ## 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.
File diff suppressed because it is too large. Load diff
+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 `DrawResult::size()` records a retained size dependency; drawing the child
without reading that result does not make the parent's size depend on it. 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 §1 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19
brought to what shipped rather than what was proposed; §3 to §6 describe the carries those commits whole plus the retained-layout repair built on them. §4 to §6 and the density section
same design as it stands. `docs/IRIS_EXTRACTION_HANDOFF.md` has the invariants retain the rationale of the design but still name types that have since been
the code now rests on and what is still to do. 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 ## Design
@@ -36,11 +165,12 @@ pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter) -> Size; fn draw(&mut self, painter: &mut Painter) -> Size;
fn size_hint(&self, axis: Axis) -> Option<Len> { None } 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 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()` 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 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. without making the parent's own size depend on the child's.
No `available` parameter: `Painter` already carries the region the parent No `available` parameter: `Painter` already carries the region the parent
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already handed down (`Painter::region()`) and already exposes the pixel-resolved form
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface (`px_size()`) and the output surface size (`output_size()`). Passing it again
size (`output_size()`, `:152`). Passing it again would be the same value would be the same value under a second name. `desired_width`/`desired_height`
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`) and `WidgetAxisFns::desired_len` are deleted outright — not
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
deprecated, not kept as a fallback — because a widget that implements both 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 `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` 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 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 set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it. `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 ### 2. O(1) subtree movement
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an A widget opts into one independently movable region with `.region_node()`, or
absolute-pixel delta and its parent slot; each primitive instance stores the `Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its
slot of the widget that drew it. The vertex shader walks this bounded chain convenient default. A node holds a whole **box** -- a `UiRegion` in its parent
and adds the accumulated translation. Moving a subtree therefore writes one node's coordinates, `UiRegion::FULL` being the identity -- and each primitive
slot instead of rewriting every descendant 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 A box rather than a translation, because a pixel-space offset would scale a
as a swipeable row inside a scrolling list. A flat offset table would require child that has to keep its pixel length; the fraction and the offset in a
rewriting the row whenever an ancestor moved and would restore the very `UiScalar` are what tell the two apart. The parent chain is what makes nested
O(subtree) work this design removes. Chain depth is bounded in both Rust and movable subtrees work -- a swipeable row inside a scrolling list -- and a flat
WGSL. 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 `Moves::resolve` performs the same walk on the CPU for hit testing,
again, and gives that child a slot of its own; `widget` and `widget_within` do accessibility and window-coordinate queries, and the shader's `resolve_move`
not, and share the nearest ancestor's. A slot carries a whole **box**, not a mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of
translation: a pixel-space scale and offset would scale a child that has to a box, which the shader decodes from constants the Rust side prepends: the
keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two. grid is stated once. Masks carry their own node and resolve it independently,
(That slots carry translation only was an agent's choice on 2026-09-04, never so a stationary viewport clips content that moves inside it.
asked for, and #18 replaced it.)
`UiRenderState::resolved_region` performs the same chain walk on the CPU for Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only
hit-testing, accessibility, and public window-coordinate queries. Masks store after every descendant has migrated, since reusing the index sooner would
the move slot of their owning widget and resolve it independently in the make an old parent look current. Changing the property redraws the subtree
fragment shader, so a stationary viewport can clip moving content. once, to rebuild the coordinate boundary; it belongs to widget identity,
which is safe because a widget has one parent.
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.
### 3. Resize scope ### 3. Resize scope
A resize is "the region a widget's parent offers it changes such that the 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 widget's draw might produce different output" -- as opposed to a move, which
by construction cannot (§2 is scoped to pure translation). Two independent by construction cannot. Two independent narrowings apply, and both are
narrowings apply, and both are real, measured properties of the code as it measured properties of the code rather than new machinery:
stands rather than new machinery:
**(a) A window resize does not, by itself, require touching most widgets.** **(a) A window resize does not, by itself, require touching most widgets.**
`shader.wgsl` recomputes every primitive's pixel position from `window.dim` The shader recomputes every primitive's position from `window.dim` and the
and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU. primitive's stored fraction and offset every frame, already, on the GPU. A
A widget laid out purely in `rel`/`abs` terms is therefore already correct widget laid out purely in those terms is therefore correct after a resize
after a resize with zero CPU work. Calls to `Painter::px_size` and with no CPU work at all.
`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.
All pixel-dependent leaves are marked before layout begins, along with every What decides the rest is `Holds`, one interval of box lengths per axis:
chain of parents that read their sizes. Resize then settles the shallowest *give this widget any box in here and it draws the same thing and reports the
shared readers first, under the new output, so overlapping dependency paths same size*. A widget that never reads its box in pixels holds for every
are drawn once. Ordinary content changes use the opposite order: deepest length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval
dirty widgets first, with a changed returned size propagated one reader edge to the length read, and `Painter::holds` is how a widget widens it again by
at a time. Re-reporting the current output size is a no-op. 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 This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
without the widget's *content* changing — this is what `Widget::on_resize` answers, which said the same thing per widget type and could not say *how
answers, per axis.** When a container's own layout shifts (a sibling grew or far*. There is no per-widget resize mode now: a widget that reads nothing is
shrank, changing this widget's offered box), a widget that says `Scale` on the never redrawn for a resize, one that reads its width is redrawn when its
axes that changed is not redrawn: everything it drew is a fraction of its own width leaves the interval it declared, and the interval is the whole of the
slot's box, so writing that one box moves and stretches all of it. `Span`, statement. Do not restore `Translate`; a retained subtree that only moves is
`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`; remapped through the box chain of §2, exactly.
`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.
**Size invalidation travels upward before drawing; drawing itself travels only Lengths are whole counts of `1/1024` px, so "the box changed" is equality
downward.** Every active widget retains the direct children whose size it read rather than a tolerance: a change too small to reach the next step is not a
through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes change, and one that reaches it is, however little of a pixel it is worth.
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.
An exact `size_hint` stops propagation when both axes still equal the retained **(b) Size invalidation travels upward before drawing; drawing itself travels
size. Otherwise propagation is deliberately conservative: the child may have only downward.** Every active widget retains the direct children whose size it
changed size, and only its dependent ancestors can assign the final boxes. read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates`
Unchanged descendants still take `draw_inner`'s retained skip-or-move path. takes one id from the dirty set, follows only those dependency edges upward
An active widget also retains which offered-box and output axes flowed into and marks that path dirty, then redraws its highest already-dirty ancestor.
the size it reported, directly or through a child size it read. A container Drawing that ancestor consumes the marks of every dirty descendant it
may use that answer for the same prospective box when every observed input is reaches; the loop then takes whatever remains. Drawing never synchronously
still within 0.05 physical pixels; content dirtiness anywhere in its size invalidates or invokes a parent, so there is no layout recursion.
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, Dirty widgets settle deepest-first. `dirty_size_under` has been deleted;
so changing only height leaves its answer valid. 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" ### 4. Wrapped text, and "needs child height before choosing width"
@@ -307,6 +428,11 @@ physical pixels, unchanged.
## Masks ## 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 A `Mask` references a rectangle primitive and its parent mask. Nested masks
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
widget's region; `.masked_by(shape)` draws the shape behind the content and 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` mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
checks the WGSL implementation against the CPU SDF. 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 Settled with Bryan on 2026-09-17 and 2026-09-19. Layout has exactly three
reports the child's used size plus padding. In a generous parent it behaves as things, and only two of them are boxes:
an inset; in a tight parent it grows the result outward.
When a widget does not fit its offered box, it is redrawn at the box implied by - **region** — the box a parent asks a widget in. It draws there, and a text
its reported size in the same frame. Deferring would leave ordinary wraps at it. `ActiveData::region`, `Painter::region`.
`.background(rect(..))` surfaces one frame behind their content. The settling - **placement** — the box its drawing ends up in: a box the size it reported,
draw occurs only when the widget's own size changes. Widgets whose size varies positioned inside the region by its own alignment, and equal to the region
with every offered box are therefore unsuitable as `LazySpan` rows. 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 `Len`. Flexible padding participates in the same proportional allocation as a
span rather than silently discarding its `rest` component. 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 **A `Span` leaves its children in the offered orthogonal region unless
explicitly compacted** (2026-09-12). It still reports the widest orthogonal 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 child as its intrinsic size, so a row nested in a column keeps its content