Record #18's three agreed changes, the measurement, and the wrapping-text divergence the random-tree rig found

This commit is contained in:
iris-ai committed 2026-09-14 13:06:49 -04:00
1 parent 5959d58e44
commit 19b9ca7413
1 file changed
+91 -36
+91 -36
View File
@@ -9,40 +9,76 @@ Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are
in; #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`** is open, worktree `/home/bob/repos/iris-pr18`, **#18 `split/18-position-chain`** is open, worktree `/home/bob/repos/iris-pr18`,
head `db1751f`, five commits. LAYOUT.md §2's O(1) subtree movement, plus the head `86a7e8d`, eight commits. LAYOUT.md §2's O(1) subtree movement, the
`Remap` retirement the owner asked for on top of it. `Remap` retirement, and then the three changes the owner chose on 2026-09-14:
the slot carries a box, slots are opt-in, and a widget's region is held in the
coordinates of the slot it draws in.
- Every active widget owns a slot in `UiRenderState::moves` -- a translation in - **A slot holds a box, not a translation** (`1f9dc48`), given in the
physical pixels and the slot it is relative to. A primitive instance and a coordinates of the slot it names, and `prelude.wgsl` composes the chain with
mask each name one, and `prelude.wgsl` walks the chain and adds the `within` instead of adding a delta. A translation is the special case where
accumulated delta. A mask resolves its own chain rather than the drawn the box has its parent's relative extent. Measured against the translate
primitive's, so a stationary viewport can clip content moving inside it. slot on the same binary: +0.5-0.8% at depth 1-4, +9.6% at 8, +32.2% at 64.
- `try_reuse`'s translation case writes one slot instead of remapping a Free where opt-in slots put the chain. The identity is `UiRegion::FULL`, not
subtree: 100 primitive region writes to 0 on a span of 20 rows five zero -- a zeroed entry collapses its subtree to a point.
primitives deep. - **`Painter::place` is how a container asks for a slot** (`d989691`): it
- `window_region` walks the same chain on the CPU, so hit testing and anyone draws a child it decides the box of and may decide again. `Span`, `Aligned`
asking in window pixels agree with the shader. `Moves::resolve` stops at and `Scroll` use it; everything else shares its nearest ancestor's slot,
`CHAIN_LIMIT` as the shader does, and debug-asserts that it reached the end. which `tests/layout.rs` pins at one deep with four widgets in between.
- `Vec2` is `repr(align(8))`, WGSL's alignment for a `vec2<f32>`, so a GPU Placing a child again is one entry to write, moved or resized alike.
struct holding one is laid out the way its shader reads it without saying so - **Nothing is inverted any more.** `UiRegion::stretch`, `stretchable` and
itself. `GlyphPrimitive` no longer states its own alignment; both it and `UiScalar::stretch` are gone, with the case they could not express: a
`MoveOffset` keep a manual `unsafe impl Pod`, because the trailing padding 40-tall row now stretches on its other axis, which `stretchable` refused.
that alignment introduces is what `derive(Pod)` refuses. **No manual padding - **`redraws_under` is the CPU walk that remains**, and it is a question
fields** -- the owner rejected one on 2026-09-14. rather than a marking: before keeping a drawing, ask whether anything under
- `Remap` is gone, with `UiScalar::outside`, `UiSpan::outside` and it would have to be drawn again for the new length, and give up the reuse if
`LerpUtil::lerp_inv`. A translation never needed an inversion: shifting a box so. Marking instead does not terminate -- the mark escalates to the
shifts everything composed into it by the same amount, since descendant's size reader, which re-places the child, which marks it again.
`lerp(s + d, e + d, t) == lerp(s, e, t) + d` on both channels, whatever the A part of a box with no relative extent holds its contents as offsets, and
box's relative extent. Only a change of length needs each part's fraction composing into it leaves none either, so the walk stops where a length did
recovered, which is `UiRegion::stretch` behind `UiRegion::stretchable`. The not change: an 80-wide child of a widened row is never asked.
decision is made once before the walk and neither relocation method - **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset`
branches, which is how the owner asked for it. say `Scale`.** Each places in fractions and offsets of its own box and none
reads its pixel length. `Scroll` and `MaxSize` do read pixels and stay
`Redraw`. Measured as not the cause of the divergence below: taking every
one of these claims back out leaves the failing set identical.
- **`OnResize::Scale` keeps its name.** The owner rejected `Stretch` on
2026-09-14: stretch has an opposite and scale does not, and the answer is
per axis so the axis is already established where it is read.
**Two invariants everything here rests on.** `resolved = region + **The open defect, and it is older than this branch.** `tests/generated.rs`
resolve(slot)`, so anything that rewrites a region owes that slot a zero -- grows a seeded random tree and checks that laying it out again lands where
`stretch` does it for the subtree it rewrites, `draw_inner` for the widget it growing it cold does. It does not always. Every case seen is a **wrapping text
draws. And a stretch is only expressible out of a box with a relative extent; on a span's own axis**: the span measures the child in the whole box, the
a fixed length holds its parts as offsets from its start and keeps no fraction. child shapes to that width and reports the width it used, the span places it
in exactly that width, which is a length change, so it shapes again and its
longest line is shorter than the box it was just given. Where it settles
depends on how many passes it has had, and a warm tree has had a different
number from a cold one -- so layout is not a function of the state, which it
has to be. A span whose axis is not the wrap axis is stable, which is every
real column of text, and why nothing had run into it.
seeds 1-30, three checks each db1751f (before) 86a7e8d (now)
diverging 7 of 90 30 of 90
The same defect either way: a placed child reaches the second shaping more
often now. It wants fixing where the two draws meet, LAYOUT.md §4, rather than
anywhere in the chain -- and **it should be fixed before #18 merges**, since
tripling how often a tree lands somewhere unrepeatable is not something to
land even on a defect that was already there.
`a_wrapping_child_of_a_row_settles_somewhere_else_each_time` is the ignored
reproduction; the six seeds the live tests use are ones that agree.
**Rigs added.** `iris::random` grows the tree (spans in every direction,
stacks, rects with varying opacity, text wrapping and overflowing, a declared
size over half of it, stopping at a depth), `examples/random.rs` draws one --
`IRIS_SEED` and `IRIS_DEPTH` pick it -- and `tests/generated.rs` compares warm
against cold under a size change, a resize, and both. It found the
non-terminating marking above immediately.
**Not verified yet:** the headless render shots and the live resize under
sway. The owner asked for those last, after the CPU side is right, and the CPU
side is not right until the divergence is.
### The plan after #18, agreed with the owner on 2026-09-14 ### The plan after #18, agreed with the owner on 2026-09-14
@@ -91,10 +127,29 @@ the row grows. The CPU work on a resize becomes asking `on_resize` down the
widgets and redrawing the ones that say so -- one ask for a transcript row widgets and redrawing the ones that say so -- one ask for a transcript row
against a few hundred glyph rewrites. It replaces #18's chain rather than against a few hundred glyph rewrites. It replaces #18's chain rather than
extending it, and it contradicts LAYOUT.md §2's line that slots carry extending it, and it contradicts LAYOUT.md §2's line that slots carry
translation only. Before choosing it, run `chain_cost` with a region per slot translation only -- a line an agent wrote into the design on 2026-09-04, not
in place of a `Vec2`: the per-level cost was measured as the dependent load something the owner asked for. Her own statement of the requirement was "if it
rather than the arithmetic, so the extra lerps should be free, but that is an needs to be moved then that can be done after the fact efficiently, or resized
assumption until the rig says so. just done after as well".
**Measured, 2026-09-14.** `MoveOffset` widened from a `Vec2` to a `UiRegion`
(36 bytes) and `resolve_move` composing with `within` instead of adding
deltas, against the same binary's translate slots: depth 1 78.5 us against
78.0, depth 2 78.7 against 78.3, depth 4 79.6 against 79.0, then depth 8 89.8
against 81.9, depth 16 128.6 against 111.3, depth 64 331.2 against 250.6. So a
box slot is free at the 2-4 depth opt-in slots produce (+0.5-0.8%) and costs
10-30% past depth 8, where the chain is already expensive. All 42 tests pass
against the composing shader and `tabs` at 1920x1200 is byte-identical,
because composing through a translation box is the same map as adding its
delta. The experiment is `stash@{0}` in `/home/bob/repos/iris-pr18`.
The slot has to carry the whole box rather than a scale and an offset: a
pixel-space affine map scales everything under it, including a child that must
keep its pixel length, and the `rel`/`abs` pair is exactly what distinguishes
the two. What a box slot does not buy is a shorter CPU walk. Asking each
widget whether its drawing depends on the length that changed is the same walk
either way; what the box removes is the region rewrite and the upload at the
widgets that answer `Scale`, and the inversion at the ones that do not.
What is settled either way: recalculating rather than repositioning is in, as What is settled either way: recalculating rather than repositioning is in, as
the `Remap` retirement above. It costs the per-axis carry: a box that changed the `Remap` retirement above. It costs the per-axis carry: a box that changed