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.
This commit is contained in:
iris-ai committed 2026-09-17 13:12:55 -04:00
1 parent 8c16f9d0fd
commit 3084491b9b
1 file changed
+71 -100
+71 -100
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
**`25e456e`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
**`e44dea3`**, 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
@@ -132,7 +132,13 @@ not exceed one, so `Span`'s `fixed <= 0` branches were only reachable
through declared fractions; they are ordinary now, and with them boxes of
negative length passed down to children.
### An answer is not an answer while anything under it is dirty (`0e0d4af`)
### An answer is not an answer while anything under it is dirty (`0e0d4af`, superseded by `a0693ac`)
**Superseded: `dirty_size_under` is deleted.** Once a resize goes through
the settling walk the ordering makes the whole category unreachable rather
than checked, which is Bryan's steer and the better answer. The defect and
its reasoning are kept below because they say what the ordering is buying.
`draw_inner` took an answer from `try_reuse`, which checks only whether the
widget itself is marked, where `retained_answer` beside it also refused one
@@ -144,17 +150,13 @@ its parent's own draw never goes through it. The placing ask redraws the
subtree, the descendant's mark is cleared there, and the parent keeps a
number the tree no longer agrees with.
So `dirty_size_under` is not the optimization its comment claimed. It is
what makes an answer an answer, and it is asked once in `draw_inner` for
both retained routes. Twenty-five rig counters are unchanged on `cold`,
`repaint`, `scroll`, `resize` and `size`; `many` makes 18 fewer reuse
attempts, 17 of which already reported "dirty".
So `dirty_size_under` was not the optimization its comment claimed: it was
what made an answer an answer, until the walk made the state it guarded
against impossible to be in.
Found at seed 564, depth 6, `shuffle-every-other`, reachable only once a
span could overflow itself. **The fast test for it is still owed**: the
divergence needs a reader drawing while a size dependency two levels under
it is dirty, which no hand-built tree has reproduced yet, and the seeds at
depth 4 above fail for some other reason.
span could overflow itself. No hand-built tree ever reproduced it, and the
seeds at depth 4 above fail for some other reason.
### A text is handed back a box its own line fits in (landed, `4bd8607`)
@@ -216,78 +218,51 @@ loop {
Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check:
"then that entire category of issue can't even occur".
**The walk is sound on its own, and the guard is only there for a second
entry point** (read on 2026-09-17, Bryan asking for either a breaking case
or a proof). By induction on depth: when a widget at depth d draws fresh,
**The walk is sound on its own, and the second entry point is closed**
(`a0693ac`). By induction on depth: when a widget at depth d draws fresh,
every dirty widget deeper has been popped, so each is settled or deferred,
and a deferred one has marked its parent. A clean child asked by that draw
therefore has a clean subtree, because anything dirty under it would have a
dirty parent, and so on up to the child itself. `dirty_size_under` cannot
fire inside `redraw_updates`, which is what dropping it from the suite,
the shrinker at 400 seeds of depth 5, the oracle at 1000 of depth 6 and
2000 seeds at depth 4 measured.
dirty parent, and so on up to the child itself.
The entry point it guards is `update` drawing the root for a resize before
the walk runs, top-down over a tree with dirty widgets still in it. Most
routes through that are safe anyway: `retained_answer` succeeding at the
offer implies `try_reuse` succeeds at the placed box, since the settled
holds are cut by the drawing's holds taken through the placing length, so
a stale answer is reused
whole and the dirty descendant later reaches its readers through
`redraw`'s comparison. The route that is not safe is the one asymmetry
between the two: `try_reuse` refuses a drawing on another layer and
`retained_answer` does not. A resize frame in which a structural edit
shifted a clean child's layer, with a dirty descendant under that child
whose answer changes, hands back the old answer, redraws the subtree fresh
in `place`, clears the descendant's mark there, and tells nobody.
The entry point that was left was `update` drawing the root for a resize
before the walk ran, top-down over a tree with dirty widgets still in it.
It is closed by marking the root instead, so layout is one walk a frame and
`dirty_size_under` is gone at both call sites. **The root is marked only
where the new output falls outside what its answer holds for**: that range
is the intersection of everything under it, so admitting the new output
says the whole tree stands, and nothing above the root moved. Marking it
unconditionally cost the root its own `Holds` -- a leaf root that scales
with its box was drawn again on every resize, which two tests caught.
**Close the entry point rather than test the contrived case.** A resize
marks the root and nothing else, so layout has one walk and the induction
covers everything:
The rig says the cost is scheduling only: on `resize`, one queue pop, one
local redraw and one depth read appear and one failed reuse attempt goes,
and every other counter on `cold`, `repaint`, `many`, `size`, `scroll` and
`resize` is identical.
```rust
pub fn resize(&mut self, size: impl Into<Vec2>, widgets: &mut Widgets) {
let size = PxVec2::from_f32(size.into());
if size == self.output_size {
return;
}
self.output_size = size;
if let Some(root) = self.old_root {
widgets.needs_redraw.insert(root);
}
}
```
### A subtree that changes hands is recorded on both sides (landed, `e44dea3`)
`redraw` already asks a parentless widget again in `root_region` against
the output. Then `dirty_size_under` goes at both call sites, `draw_inner`
and `retained_size`. Bryan's question of whether a resize could instead
mark "the important widgets" up front answers itself: `needs_redraw` is the
mark, and `Holds` is the exact, per-widget, lazy computation of which
widgets a new output invalidates; nothing has to be worked out ahead of the
walk. What it costs: the root always runs its own `draw` on a resize where
today the whole tree can remap in one `try_reuse`, and a widget dirty in
the same frame as a resize may draw twice if the root's layout then gives
it a different box. Drawn widgets should otherwise be identical on the rig;
check the counters.
A subtree can be reused whole under a different parent -- same box, same
layer, same region node, clean -- and nothing in the drawing says it moved.
Two things read who its parent is, and both were wrong after one of these.
**Two facts the induction relies on are not pinned, and both are the shape
of `0e0d4af`: state that is right only because something else set it up.**
- The **old parent still listed it**, and a parent's next draw undraws
whatever is missing from that list. Two spans under one root, with the
root swapping which of them it holds, drew the subtree under the new span
and then erased it when the old one drew.
- Its **depth** was the one it had under the old parent, which is what the
settling walk orders by, so a change made under it afterwards settled at
the wrong point in the frame.
- `try_reuse` never writes `active.parent`, and neither does the tail of
`draw_inner`. A subtree reused under a different parent inside the same
region node keeps the old parent, so a later deferral marks the wrong
widget. The fuzzer never re-parents: `reshuffle` only trades children
between a span and its own spares. A hand-built test moving a child
between two spans should miss a redraw or trip the `depth()` assertion.
- `remap_subtree` never updates descendants' `depth`. The debug assertion
in `depth()` catches it only for a widget that later goes dirty.
Both are written where `draw_inner` already records what the ask decided:
`active.parent` is replaced and the old parent's `children` repaired, and
`try_reuse` re-walks the subtree's depths -- only where the top of it
moved, which is what makes that free in the ordinary case. Pinned by
`retained::a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left`
and `..._settles_at_the_depth_it_moved_to`; each fails without one half.
Both fixes are one line where `draw_inner` already writes `given` and
`answer`, plus a walk in `remap_subtree` or a depth kept relative to the
nearest region node.
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.
The fuzzer never re-parents (`reshuffle` only trades children between a
span and its own spares), which is why nothing generated reached either.
### A span's leftover boundary is its own inverse (landed, `53b00c6`)
@@ -799,34 +774,32 @@ above for what closing the rest would cost.
## Verification at the current head
At `25e456e`:
At `e44dea3`:
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
-D warnings`, `cargo test --workspace`: green, 90 suite tests, 19 core
-D warnings`, `cargo test --workspace`: green, 92 suite tests, 19 core
unit tests, 11 generated cases. Only the long runs and the profiling rigs
are ignored; no known defect is.
- The release oracle at 100 seeds in 14.3 s, and **120 seeds in debug** in
- The release oracle at 100 seeds in 14.2 s, and **120 seeds in debug** in
59 s -- the debug run exercises the `Holds` assertion in `draw_at`.
- All fifteen shrinker cases at 400 seeds of depth 5 in 56 s, the oracle at
1000 seeds of depth 6 in 142 s, and **2000 seeds at depth 4 over all
fifteen cases** in 262 s. The last is not routine and should be: it is
- All fifteen shrinker cases at 400 seeds of depth 5 in 57 s, the oracle at
1000 seeds of depth 6 in 143 s, and **2000 seeds at depth 4 over all
fifteen cases** in 260 s. The last is not routine and should be: it is
the only run that has ever found anything past seed 400.
- `view`, `minimal`, `random` and `tabs` byte-identical at 1920x1200
against `ea6dbae`. `random` live-resized from 1920x1200 to 1280x800 is
byte-identical to a cold 1280x800 render. **`text` is a new picture**:
`4bd8607` moved its lower paragraph one pixel, the box being a step wider
and its left edge crossing the shader's snap, and `c8beca5` rewrote the
alignment panel, which had all three labels in the middle of a box the
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. Across `a92c6ac` drawn widgets, widget draws and primitive
writes are identical on every phase, and only the scheduling counters
move.
- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at
1920x1200 against `25e456e`, as is `tabs` under the recorded replay.
`random` live-resized from 1920x1200 to 1280x800 is byte-identical to a
cold 1280x800 render. Rendered through Venus on the host's RX 7900 XT,
confirmed against `vulkaninfo --summary` in the same session -- an
llvmpipe fallback makes the same PNG and nothing in it says so.
- All rig counters identical on `cold`, `repaint`, `many`, `size` and
`scroll` across `25e456e`. `resize` gains one queue pop, one local
redraw and one depth read and loses one failed reuse attempt, which is
the root going through the walk; drawn widgets, widget draws, draw
requests and primitive writes do not 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
changed, not a claim that the branch is correct.
Everything above is verification of what was changed, not a claim that the
branch is correct.
**A claim about a render holds for the commit it was checked at and no
further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take
@@ -993,12 +966,10 @@ The replay used for the reference check:
In order, from the review above and Bryan's steer (2026-09-17):
1. **A resize marks the root and goes through the walk**; delete
`dirty_size_under` at both call sites; write `active.parent` where
`draw_inner` writes `given`, and descendants' `depth` in
`remap_subtree`; pin re-parenting with a test that moves a child between
two spans. Small, argued by the induction above, verified by the rig's
counters.
1. ~~A resize marks the root and goes through the walk.~~ Landed as
`a0693ac` and `e44dea3`; see the two sections above. The re-parenting
half turned out to be two defects rather than the predicted one, and
neither was the `depth()` assertion.
2. **Frame and extent**, as written above. This is the fundamental change
and comes before anything built on the placing ask. It lands the two
parked branches' tests (the stack test unchanged, `Inset`/`Outset` with