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.
**#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
`Remap` retirement the owner asked for on top of it.
head `86a7e8d`, eight commits. LAYOUT.md §2's O(1) subtree movement, the
`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
physical pixels and the slot it is relative to. A primitive instance and a
mask each name one, and `prelude.wgsl` walks the chain and adds the
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.
- **A slot holds a box, not a translation** (`1f9dc48`), given in the
coordinates of the slot it names, and `prelude.wgsl` composes the chain with
`within` instead of adding a delta. A translation is the special case where
the box has its parent's relative extent. Measured against the translate
slot on the same binary: +0.5-0.8% at depth 1-4, +9.6% at 8, +32.2% at 64.
Free where opt-in slots put the chain. The identity is `UiRegion::FULL`, not
zero -- a zeroed entry collapses its subtree to a point.
- **`Painter::place` is how a container asks for a slot** (`d989691`): it
draws a child it decides the box of and may decide again. `Span`, `Aligned`
and `Scroll` use it; everything else shares its nearest ancestor's slot,
which `tests/layout.rs` pins at one deep with four widgets in between.
Placing a child again is one entry to write, moved or resized alike.
- **Nothing is inverted any more.** `UiRegion::stretch`, `stretchable` and
`UiScalar::stretch` are gone, with the case they could not express: a
40-tall row now stretches on its other axis, which `stretchable` refused.
- **`redraws_under` is the CPU walk that remains**, and it is a question
rather than a marking: before keeping a drawing, ask whether anything under
it would have to be drawn again for the new length, and give up the reuse if
so. Marking instead does not terminate -- the mark escalates to the
descendant's size reader, which re-places the child, which marks it again.
A part of a box with no relative extent holds its contents as offsets, and
composing into it leaves none either, so the walk stops where a length did
not change: an 80-wide child of a widened row is never asked.
- **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset`
say `Scale`.** Each places in fractions and offsets of its own box and none
reads its pixel length. `Scroll` and `MaxSize` 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 +
resolve(slot)`, so anything that rewrites a region owes that slot a zero --
`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.
**The open defect, and it is older than this branch.** `tests/generated.rs`
grows a seeded random tree and checks that laying it out again lands where
growing it cold does. It does not always. Every case seen is a **wrapping text
on a span's own axis**: the span measures the child in the whole box, the
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
@@ -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
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
translation only. Before choosing it, run `chain_cost` with a region per slot
in place of a `Vec2`: the per-level cost was measured as the dependent load
rather than the arithmetic, so the extra lerps should be free, but that is an
assumption until the rig says so.
translation only -- a line an agent wrote into the design on 2026-09-04, not
something the owner asked for. Her own statement of the requirement was "if it
needs to be moved then that can be done after the fact efficiently, or resized
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
the `Remap` retirement above. It costs the per-axis carry: a box that changed