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.
This commit is contained in:
iris-ai committed 2026-09-17 04:50:50 -04:00
1 parent faa4e98311
commit be895375ec
1 file changed
+107 -13
+107 -13
View File
@@ -10,7 +10,7 @@ stayed.
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head
**`c8beca5`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
**`a92c6ac`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
the `Holds` retained-layout contract, region nodes, built-in alignment and
size rules, fixed-point layout, a box in pixels threaded down the draw, and
a report read as a fraction of the containing widget. No PR review was
@@ -84,6 +84,14 @@ grid width fixes it; where it becomes visible is the shader's snap.
### A report is a fraction of the containing widget (landed, `ffd79f3`)
**Superseded in part.** Bryan refined the rule twice on 2026-09-17: a
fraction means whatever the *parent* says it means, and it need not even
mean the same thing for two siblings -- "Main point is truly that rel is
decided by parent". The span conclusion below is unchanged and the reason
for it stands (a span's offer differs per child and the row does not), but
`reports_of` is the wrong shape for saying it; see the parked padding
branch.
`rel(0.5)` is half the span whatever else is in it and wherever the child
sits. A report used to come back composed through the box it was offered,
and a span offers each child the room from its cursor, so a nested span
@@ -173,6 +181,82 @@ comes back as a box has to be rounded away from the measurement, not to the
nearest step.** Rounding to nearest is right for a value being carried;
it is wrong for a bound.
### A frame settles strictly bottom-up (landed, `a92c6ac`)
The queue was already deepest-first, but a widget that could not settle
where it was called `redraw` on its parent from inside itself, which drew a
shallow widget while dirty widgets deeper in other subtrees were still
pending. A parent drawing over a subtree that has not settled reads answers
about to move, and the one that settles does so inside the parent's draw --
where its mark comes off and nothing compares what it now answers.
A widget that cannot settle defers instead: it marks its parent, stays
marked, and waits in `deferred` until the walk reaches the parent's depth,
which cannot happen before everything deeper has settled.
```rust
loop {
let next = rsc.widgets().needs_redraw.iter().copied()
.filter(|id| !self.deferred.contains(id))
.max_by_key(|&id| self.depth(id));
let Some(id) = next else { break };
if !self.redraw(id, rsc) {
self.deferred.insert(id);
}
}
```
Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check:
"then that entire category of issue can't even occur". `dirty_size_under`
stays in `draw_inner` anyway, because `update` draws the root for a resize
*before* `redraw_updates` runs at all and the ordering does not cover that
entry. Whether it is still load-bearing is untested -- the experiment that
said it was not had a failed edit in it and measured nothing.
Drawn widgets, widget draws and primitive writes are unchanged on every rig
phase; `many` pays 51 queue pops for 27 and 1059 depth reads for 410.
### Two branches parked, both real, neither ready
**`wip/stack-fraction-twice`.** A stack sized by a child that reports a
*fraction* applies that fraction twice: its parent places the stack at the
reported length, and `box_of(size)` then takes the same fraction of that
box. Half a row becomes a quarter. Pixels are idempotent under a second
application, so only a share ever shrank -- and warm and cold shrink alike,
so **no oracle can see it**. Bryan: "I believe this exact thing has come up
multiple times for some reason."
The fix is that every child gets the whole box, plus `widget_at` not
resolving a rule into a box already chosen from it. `Painter::box_of` is
deleted rather than guarded: a first attempt made it answer the whole box on
a `decided` axis, which fails because `place` can reuse the stack's
*measuring* drawing by remapping it, so `Stack::draw` never re-runs. **A
drawing has to be a function of its box alone** -- if `decided` is in it, a
reused drawing is wrong.
What stops it landing: seed 1091 at depth 4, `shuffle-swap-for-three`,
disagrees by three steps where the oracle tolerates two (warm 1053.9971
against cold 1054). `box_of` was also making placement *exact*, by handing a
child a box of exactly the length it asked for, and the whole box puts a
rounding back at each nesting level. Find that composition; do not widen
`AGREE_STEPS`.
**`wip/padding-outset-and-inset`.** Padding goes outside what it pads and
never insets the child (Bryan, 2026-09-17): otherwise a child's `rel` and
`leftover` would mean the inner box while its `px` meant the outer one.
`Padding::region` moves the child's box in rather than shrinking it, a new
`Inset` widget with `.inset()` is the old behaviour, and `Pad` is to be
renamed **`Outset`** with `.outset()` to match. `in_parent_frame`'s
composition and the `reports_of` argument go with it -- a report comes up
raw and the parent says what it is a fraction of, which `Inset` does for
itself.
What stops it landing: a child declaring `rel(0.5)` under an `Inset` comes
out 47.5 px wide of the 190 inside rather than 95, and the second halving is
unaccounted for. The `Pad` half is green on its own; three tests moved to
`.inset()` because they were using padding as scaffolding rather than
testing it.
### `Span`'s leftover boundary is a third expression for the room
The decision uses a rounded division, `total.px.div(fixed)`, while the room
@@ -477,7 +561,7 @@ above for what closing the rest would cost.
## Verification at the current head
At `c8beca5`:
At `a92c6ac`:
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
-D warnings`, `cargo test --workspace`: green, 90 suite tests, 19 core
@@ -497,7 +581,9 @@ At `c8beca5`:
width of the widest of them.
- Twenty-five rig work counters identical on `cold`, `repaint`, `scroll`,
`resize` and `size` across `0e0d4af`; `many` differs only in reuse
attempts.
attempts. Across `a92c6ac` drawn widgets, widget draws and primitive
writes are identical on every phase, and only the scheduling counters
move.
**None of that reaches seeds 1121 and 1839 at depth 4**, which fail on this
head and on `ea6dbae` alike. Everything below is verification of what was
@@ -668,15 +754,23 @@ The replay used for the reference check:
In order, from the review above and Bryan's steer (2026-09-17):
1. **A fast test for `0e0d4af`**, the stale-answer guard, which went in
1. **`wip/stack-fraction-twice`**: find the composition that went from
exact to rounded when `box_of` stopped narrowing, and land it. This is
the one outright wrong layout known on the branch.
2. **`wip/padding-outset-and-inset`**: account for the second halving under
`Inset`, rename `Pad` to `Outset` and `.pad()` to `.outset()`, and audit
every `.pad()` in the examples for which of the two it meant.
3. **A fast test for `0e0d4af`**, the stale-answer guard, which went in
with only a fuzz seed behind it. The shape wanted is a widget drawing
while a size dependency two levels under it is dirty, where its
drawing is reusable at the measuring box and not at the placing one.
2. Write `ActiveData::answer` in one place.
3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
Check at the same time whether `a92c6ac`'s ordering has made the guard
dead outside the resize path.
4. Write `ActiveData::answer` in one place.
5. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
reconstruction in `redraw`.
4. `Span`'s leftover boundary through `Holds::through`.
5. **Round on the CPU and snap to the nearest pixel in the shader**, as
6. `Span`'s leftover boundary through `Holds::through`.
7. **Round on the CPU and snap to the nearest pixel in the shader**, as
one change with one verification. Bryan approved the snap on 2026-09-17
(rendering may change wherever it brings the screen closer to what the
user's code says: three equal sections of 1000 px need one of them
@@ -692,20 +786,20 @@ In order, from the review above and Bryan's steer (2026-09-17):
bounds move by half a `Rel` step); check with `nm` that `UiSpan::within`
still inlines; expect a couple of percent of instructions and re-run the
long fuzzers and the render set once for both.
6. The smaller items: the stale `f32` comment, the gap of an undrawn child,
8. The smaller items: the stale `f32` comment, the gap of an undrawn child,
confirm nested `leftover` weights, one zero-divisor fallback. Add to
them: a span that overflows itself hands a child a box of negative
length, which is ordinary now rather than a corner, and nothing states
what a widget may assume about one.
7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
9. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
subtree case; do not restore a separate child-placement API.
8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
10. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
builders `8220a78` deleted. The clamp boundary is a hard layout decision
with an exact `Holds` split at the crossover, both sides in `Px`. Still
awaiting Bryan: whether a `Max` narrows the box the child draws in, or
only what the parent reports for it.
9. `Scroll` taking a direction rather than one axis.
10. The measure/draw split, once the above is in. It deletes the two-ask
11. `Scroll` taking a direction rather than one axis.
12. The measure/draw split, once the above is in. It deletes the two-ask
protocol, which is what `0e0d4af` had to put a guard around.
`docs/LAYOUT.md` §4, §5 and the density section are stale: they name