Bring the handoff up to date: #18's state, the open question, and the rigs

This commit is contained in:
iris-ai committed 2026-09-14 11:08:59 -04:00
1 parent e90b026969
commit c683e62e4f
1 file changed
+113 -63
+113 -63
View File
@@ -6,48 +6,84 @@ Not a decisions log; delete it when the extraction is done.
## Where things stand ## Where things stand
Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are
in, and #16's size work and #17's rig both merged on 2026-09-14. in; #16's size work and #17's rig both merged on 2026-09-14.
- **#18** `split/18-position-chain`, worktree `/home/bob/repos/iris-pr18`, head **#18 `split/18-position-chain`** is open, worktree `/home/bob/repos/iris-pr18`,
`8223a55`. LAYOUT.md §2's O(1) subtree movement: every active widget owns a head `db1751f`, five commits. LAYOUT.md §2's O(1) subtree movement, plus the
slot in `UiRenderState::moves`, `prelude.wgsl` walks the chain, and `Remap` retirement the owner asked for on top of it.
`try_reuse`'s translation case writes one slot instead of remapping a
subtree's primitives. Measured at 100 primitive region writes to 0 on a span
of 20 rows five primitives deep. `Vec2` is `repr(align(8))` so a GPU struct
holding one matches WGSL without saying so itself.
Not in it, both wanting the above first: `Painter::set_child_offset` for a - Every active widget owns a slot in `UiRenderState::moves` -- a translation in
container that moves its children as a group (the scrolling case), and physical pixels and the slot it is relative to. A primitive instance and a
`LazySpan`. Built-in alignment is after those -- the owner reordered it mask each name one, and `prelude.wgsl` walks the chain and adds the
behind the chain on 2026-09-14 as the more important of the two. accumulated delta. A mask resolves its own chain rather than the drawn
primitive's, so a stationary viewport can clip content moving inside it.
- `try_reuse`'s translation case writes one slot instead of remapping a
subtree: 100 primitive region writes to 0 on a span of 20 rows five
primitives deep.
- `window_region` walks the same chain on the CPU, so hit testing and anyone
asking in window pixels agree with the shader. `Moves::resolve` stops at
`CHAIN_LIMIT` as the shader does, and debug-asserts that it reached the end.
- `Vec2` is `repr(align(8))`, WGSL's alignment for a `vec2<f32>`, so a GPU
struct holding one is laid out the way its shader reads it without saying so
itself. `GlyphPrimitive` no longer states its own alignment; both it and
`MoveOffset` keep a manual `unsafe impl Pod`, because the trailing padding
that alignment introduces is what `derive(Pod)` refuses. **No manual padding
fields** -- the owner rejected one on 2026-09-14.
- `Remap` is gone, with `UiScalar::outside`, `UiSpan::outside` and
`LerpUtil::lerp_inv`. A translation never needed an inversion: shifting a box
shifts everything composed into it by the same amount, since
`lerp(s + d, e + d, t) == lerp(s, e, t) + d` on both channels, whatever the
box's relative extent. Only a change of length needs each part's fraction
recovered, which is `UiRegion::stretch` behind `UiRegion::stretchable`. The
decision is made once before the walk and neither relocation method
branches, which is how the owner asked for it.
The invariant the chain rests on: `resolved = region + resolve(slot)`, so **Two invariants everything here rests on.** `resolved = region +
anything that rewrites a region owes that slot a zero. `mov` does it for the resolve(slot)`, so anything that rewrites a region owes that slot a zero --
subtree it rewrites and `draw_inner` for the widget it draws. `stretch` does it for the subtree it rewrites, `draw_inner` for the widget it
draws. And a stretch is only expressible out of a box with a relative extent;
a fixed length holds its parts as offsets from its start and keeps no fraction.
**Waiting on the owner, and measured first.** `tests/chain_cost.rs` times the ### The open question on #18, and the numbers for it
pass on the GPU with timestamp queries: at 200,000 instances the walk is free
to about depth 8 (+5%) and then costs roughly 3 us per level -- +43% at 16,
+221% at 64. Each step is a storage load addressed by the previous one, so it
is the chaining that costs and not the arithmetic at a level; a slot carrying
a whole region would measure the same. Since #18 gives every active widget a
slot, a primitive resolves through its full tree depth, which LAYOUT.md notes
has exceeded 16. Irrelevant at an example's couple of hundred primitives,
squarely in that regime for a transcript's glyphs.
So the open question is how to keep the chain shallow -- a slot only where The owner proposed, and I agree with, **opt-in chaining that `Scroll` would
something is actually moved (needs re-parenting when an intermediate ancestor choose** plus **recalculating rather than repositioning** when a region cannot
later gains one), against §6's rejected flattening, which the number partly be moved. Neither is implemented. What is settled and what is not:
rehabilitates. Retiring `Remap` by way of region chaining is cheap on top of a
shallow chain and expensive without one. Put to her on #18; do not act before - **Recalculating rather than repositioning is in**, as the `Remap`
she picks. retirement above. It costs the per-axis carry: a box that changed length on
one axis and not the other is now redrawn rather than remapped. Six of
`tabs`'s fourteen relocations and five of `text`'s sixteen, and one extra
redraw per frame on `replace_cost` -- 354,310,889 instructions against
354,272,387, noise.
- **Opt-in chaining is not**, and the design question is *who may opt in*. A
widget can only move its subtree if its descendants chain through it, so
opting in has to be done by whoever performs the move. My recommendation on
the PR: any container that re-places a child after drawing it (`Span`,
`Aligned`, `Scroll` all do), not only `Scroll`. That keeps the chain 2-4
deep instead of full tree depth, and keeps the slot write for ordinary
re-placement.
- Giving **every** widget a slot, which is what #18 does and what I read §2 to
say, is the thing to change: it puts a primitive's walk at full tree depth
for no benefit, since almost every slot is zero.
Measured, so the next attempt is compared rather than argued:
| rig | what it says |
| --- | --- |
| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances: free to depth 8 (+5%), then ~3 us per level -- +42.6% at 16, +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level; a slot carrying a whole region would measure the same. |
| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. |
| `tests/draw_cost.rs` | Pre-existing: what recording a frame costs on the CPU by layer count. |
Irrelevant at an example's couple of hundred primitives; a transcript's glyphs
are tens of thousands, which is the regime `chain_cost` measures.
Check for a review before starting anything, and read the newest Check for a review before starting anything, and read the newest
`submitted_at` rather than the first result: `submitted_at` rather than the first result:
```sh ```sh
TOKEN=$(cat ~/.config/gitea/token) TOKEN=$(cat ~/.config/gitea/token)
N=12 N=18
curl -s -H "Authorization: token $TOKEN" \ curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews
curl -s -H "Authorization: token $TOKEN" \ curl -s -H "Authorization: token $TOKEN" \
@@ -115,8 +151,10 @@ Other standing instructions from the owner:
## The next slice ## The next slice
**`set_child_offset` and `LazySpan`**, finishing LAYOUT.md §2 on top of #18, **Nothing, until the owner answers the opt-in question on #18** -- the shape of
then built-in alignment. the slot set decides what `set_child_offset` even is, so building it first
risks writing it twice. After that: `set_child_offset` and `LazySpan` to finish
LAYOUT.md §2, then built-in alignment.
The archive is not a patch here: it writes `Widget::draw` against The archive is not a patch here: it writes `Widget::draw` against
`painter.set_size`, which #16 replaced with a returned `Size`, and it writes `painter.set_size`, which #16 replaced with a returned `Size`, and it writes
@@ -130,9 +168,8 @@ Still in the target, roughly in dependency order:
`LazySpan`. `LazySpan`.
- **Built-in alignment, and probably size**, after the chain rather than - **Built-in alignment, and probably size**, after the chain rather than
before it: the owner reordered the two on 2026-09-14. Reproduced in the before it: the owner reordered the two on 2026-09-14. Reproduced in the
harness -- a child of a span is handed the full extent on the ortho axis, so harness -- `.width(rel(0.5))` inside a `Dir::DOWN` span reports 200 of 400
`.width(rel(0.5))` inside a `Dir::DOWN` span changes what the child reports and is handed the whole 400, and a `Pad` in between does not change that. **Do not "fix" that by reading the child's ortho `size_hint`**: a `Pad`
and not the box it gets. **Do not "fix" that by reading the child's ortho `size_hint`**: a `Pad`
between the `SetSize` and the span has no hint of its own, so the declared between the `SetSize` and the span has no hint of its own, so the declared
width silently goes back to filling. It works only when nothing is in the width silently goes back to filling. It works only when nothing is in the
way. Alignment has to belong to the widget rather than be discovered through way. Alignment has to belong to the widget rather than be discovered through
@@ -205,8 +242,8 @@ Current invariants, not history. Worth reading before touching `core/render`.
knows nothing else. Dispatch per list was measured at 6 instructions, 0.1% of knows nothing else. Dispatch per list was measured at 6 instructions, 0.1% of
a frame at 256 and at 1024 layers, against the ~5,400 wgpu spends recording a frame at 256 and at 1024 layers, against the ~5,400 wgpu spends recording
one list; `tests/draw_cost.rs` is that measurement. one list; `tests/draw_cost.rs` is that measurement.
- **The shared bind group is the window and the masks**, given to every draw. - **The shared bind group is the window, the masks and the move chain**, given
A mask texture would go here too. What a primitive samples is its own group, to every draw. A mask texture would go here too. What a primitive samples is its own group,
and a primitive that samples nothing has no such group in its pipeline. and a primitive that samples nothing has no such group in its pipeline.
- **Every binding size is stated.** A `None` minimum puts the binding on - **Every binding size is stated.** A `None` minimum puts the binding on
wgpu-core's late-sized list, which `is_ready` scans on every draw. wgpu-core's late-sized list, which `is_ready` scans on every draw.
@@ -254,48 +291,59 @@ Current invariants, not history. Worth reading before touching `core/render`.
```sh ```sh
cd /home/bob/repos/iris && git fetch upstream cd /home/bob/repos/iris && git fetch upstream
git worktree add -b split/15-name /home/bob/repos/iris-pr15 upstream/main git worktree add -b split/19-name /home/bob/repos/iris-pr19 upstream/main
``` ```
`/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree
holds a merged branch; they are readable references, not places to build.
Every other `/home/bob/repos/iris-pr*` worktree holds a merged branch. They Every other `/home/bob/repos/iris-pr*` worktree holds a merged branch. They
are readable references; do not build new work on them. are readable references; do not build new work on them.
## Verifying a slice ## Verifying a slice
`iris` runs its own rig now (#17), so a rendering claim no longer has to be
driven from ai-app's submodule:
```sh
cd <iris-worktree>
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/out.png
./scripts/run-headless.sh tabs --replay /tmp/taps.touch --shot /tmp/out.png
```
The reference shots this session compared against are `tabs`, `view`, `minimal`
and `text` at 1920x1200, plus `tabs` with a replay that switches to the image
tab and adds two images. A `.touch` line is `<ms> down|move|up <x> <y>` in the
output's own pixels; the tab strip is at y=24 and the five tabs at x = 192,
576, 960, 1344 and 1728, with the image tab's add button near (1836, 1116).
**A resize is its own case and the harness cannot see it.** Start an example,
change the output mode under it with `swaymsg output HEADLESS-1 mode WxH@60Hz`,
screenshot, and compare against a cold start at that size -- they must match
byte for byte. That is what caught both of #16's defects, and neither showed up
in 40 tests.
```sh ```sh
cd <iris-worktree> cd <iris-worktree>
cargo fmt --all --check cargo fmt --all --check
cargo clippy --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
cargo test cargo test --workspace
``` ```
`--workspace` when the slice crosses workspace crates. A rendering claim needs 42 tests pass on #18's head. `--workspace` matters: `rig-input` is a crate of
a real run, which until the rig is extracted means driving it from ai-app's its own.
submodule:
```sh Two drawing paths still have no shot of their own, and each needs a ui the
cd /home/bob/repos/ai-app-2/iris examples do not have, so both are throwaway examples written into the worktree
./scripts/run-headless.sh tabs --dir /home/bob/repos/iris-prNN \ and deleted after:
--shot /tmp/out.png --seconds 3
./scripts/run-headless.sh tabs --dir ... --replay /tmp/taps.touch --shot ...
```
A `.touch` file is `<ms> down|move|up <x> <y>` in the output's own pixels. The 1. An image alone in a layer, which is the case that failed GPU validation
`tabs` example's five tabs sit at x = 192, 576, 960, 1344 and 1728 on a
1920x1200 output; tapping the third and then the bottom-right button twice
adds two images.
Three renders cover the drawing paths, and each needs a different ui, so two
of them are throwaway examples written into the worktree and deleted after:
1. `tabs` with that replay — rects, glyphs and images together.
2. An image alone in a layer, which is the case that failed GPU validation
when every other test happened to have a rectangle in the same layer. when every other test happened to have a rectangle in the same layer.
3. Six lines of 400px text, which forces the atlas to four pages and proves 2. Six lines of 400px text, which forces the atlas to four pages and proves
the array grew and its group was rebuilt. the array grew and its group was rebuilt.
Those three become ordinary tests with the harness slice, which is the `tabs` with the image replay covers rects, glyphs and images together, so that
argument for doing it next. one is an ordinary check now.
## Cautions ## Cautions
@@ -332,5 +380,7 @@ argument for doing it next.
| #14 | Rename the `Sized` widget to `SetSize` (`32b1038`) | | #14 | Rename the `Sized` widget to `SetSize` (`32b1038`) |
| #15 | Run a ui without a window, and test one (`c8ac669`) | | #15 | Run a ui without a window, and test one (`c8ac669`) |
| #12 | Route pointer input per kind (`43ce8c7`) | | #12 | Route pointer input per kind (`43ce8c7`) |
| #16 | Size a widget while drawing it, not in a pass of its own (`f942385`) |
| #17 | Bring the headless rig into the repository (`ca2b4b2`) |
URLs are `https://git.arirex.me/iris/iris/pulls/{number}`. URLs are `https://git.arirex.me/iris/iris/pulls/{number}`.