Record the report rule, the stale-answer guard, and two open seeds

The `rel` reading Bryan decided landed, and with it a defect the retained
machinery had all along: a span that can overflow itself makes the two asks'
boxes differ far enough that a reused answer is read before the subtree
under it has settled.

Widening the fuzzers to 2000 seeds at depth 4 then found two warm/cold
divergences that predate both commits, so the branch is not correct and the
routine verification does not say otherwise. They lead "Next".
This commit is contained in:
iris-ai committed 2026-09-17 02:58:25 -04:00
1 parent 4763e1a70d
commit f64d6a8d4d
1 file changed
+124 -115
+124 -115
View File
@@ -10,10 +10,37 @@ stayed.
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head
**`ea6dbae`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, **`ffd79f3`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`,
the `Holds` retained-layout contract, region nodes, built-in alignment and the `Holds` retained-layout contract, region nodes, built-in alignment and
size rules, fixed-point layout, and a box in pixels threaded down the draw. size rules, fixed-point layout, a box in pixels threaded down the draw, and
No PR review was present when checked on 2026-09-15. a report read as a fraction of the containing widget. No PR review was
present when checked on 2026-09-15.
**The branch has a warm/cold divergence nothing on it explains yet**, found
2026-09-17 by widening the fuzzers rather than by any change: seeds **1121**
and **1839** at **depth 4** fail `shuffle-swap-for-three` and
`shuffle-all-but-first` respectively, and they fail the same way on
`ea6dbae`, before either of the day's commits. Nothing in the routine
verification reaches them -- the fast oracle takes ten seeds, the shrinker
400 at depth 5 and the long oracle 1000 at depth 6, and these are past 400
at a depth neither long run uses. Chasing them is first in "Next". The scan
that found them, which is worth keeping as a pattern:
```rust
// tests/scan.rs, deleted once it had done its job
over_seeds((1..=2000).collect(), |seed| {
let grown = plan(seed, 4, &Edits::default());
for &case in ALL.iter() {
if let Some(how) = diverges(&grown, case, seed) {
println!("HIT seed {seed} case {} {how}", case.name());
}
}
});
```
262 s for 2000 seeds at depth 4 over all fifteen cases. `Rng::new` is
`seed | 1`, so an even seed and the odd one above it are one tree: 1120 and
1121 are the same counterexample, as are 1838 and 1839.
**Two ideas outrank everything else in this document** (Bryan, 2026-09-17). **Two ideas outrank everything else in this document** (Bryan, 2026-09-17).
First, a changed tree lays out exactly as if it had been drawn that way from First, a changed tree lays out exactly as if it had been drawn that way from
@@ -38,9 +65,9 @@ equality. Threading the pixel box down the draw, with `Holds::through` the
exact preimage of that one multiply, is the strongest idea in the code: exact preimage of that one multiply, is the strongest idea in the code:
layout has one route to every length and the reuse test is its exact layout has one route to every length and the reuse test is its exact
inverse. Offer, given and placed is the ordinary measure-then-arrange model. inverse. Offer, given and placed is the ordinary measure-then-arrange model.
What needs work is the bookkeeping around the second ask, one boundary in What needed work was the bookkeeping around the second ask, one boundary in
`Span` computed by an expression other than the drawing it guards, and a `Span` computed by an expression other than the drawing it guards, and a
`rel` that means two things. The two-step residual is structural and no `rel` that meant two things. The two-step residual is structural and no
grid width fixes it; where it becomes visible is the shader's snap. grid width fixes it; where it becomes visible is the shader's snap.
### Decided by Bryan ### Decided by Bryan
@@ -56,100 +83,64 @@ grid width fixes it; where it becomes visible is the shader's snap.
place them exactly where they were measured and so draw once, and that place them exactly where they were measured and so draw once, and that
must survive. The smaller items below come first. must survive. The smaller items below come first.
### `rel` means two things today ### A report is a fraction of the containing widget (landed, `ffd79f3`)
Measured in a 400 px row whose first child is 100 px wide: `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
taking half of what it was given took a quarter of a row whose first half
was spoken for -- where the same half written as a rule on the child took
half the row.
| second child | box | meaning | The offer is still the remainder, because a text has to wrap at the width
| --- | --- | --- | actually there. What separated from it is the base a report's fractions are
| `rect.width(rel(0.5))`, declared | 100 to 300 | half the row | of, which the ask now carries as `reports_of`:
| nested span reporting `rel(0.5)` | 100 to 250 | half the remainder |
The declared case is the rule; the reported case is wrong, and
`tests/cases/layout.rs::a_span_reads_a_child_report_as_a_fraction_of_what_it_offered`
pins the wrong behaviour and has to change. The two placement functions in
`core/src/ui/painter.rs` differ by one line:
```rust ```rust
// placed_box: a reported length is composed through the offered box pub fn widget_at<'s, W: ?Sized>(
let len = lens.axis(axis).within_len(span.len()); &'s mut self,
id: &'s StrongWidget<W>,
// declared_box: a declared length is a fraction of the parent's own box region: UiRegion,
let len = Len::from_parts(len.rel, len.px); reports_of: UiVec2,
decided: [bool; 2],
) -> DrawResult<'s, 'a, W>
``` ```
Making `declared_box` compose the same way would be the one-line fix in the It is `region.size()` wherever the box offered is the child's whole area --
*wrong* direction. The composition `in_parent_frame` does is right where the `Pad`'s inset, a `Stack` child, `Scroll`'s content -- and `Span` passes
offer is the child's whole area -- `Pad`'s inset, a `Stack` child, `Scroll`'s `UiVec2::FULL_SIZE` along its row. `widget_decided` is gone; `widget_at`
content -- and wrong where the offer is a positional remainder, which is says both things about an ask rather than one of them.
`Span` along its axis. `Span` offers each child the room from the cursor to
the end because a text has to wrap at the width actually left, so the pixel
width of the offer and the base its fractions are taken of have to be
separated: the remainder for one, the row for the other. Candidate: the ask
carries what a report's fractions are of, defaulting to the offered box, and
`Span` passes its own extent along the row. A child that drew at half of its
remainder is then placed at half the row and redrawn there by the placing
ask, which is one draw more, and exactly what `place` already does for any
child whose placed box differs from its offer. A nested span whose child
asks for half of it reports half of the row, gets it, and its child gets
half of that: no circularity, because the base is the row and not the
nested span's own answer. Not designed yet; it is first in "Next".
### The offer's answer is overwritten by every ask **A span can now overflow itself without bound**, which is the consequence
Bryan's rule asks for: two children reporting half each take the whole row
and a third starts past the end. Under the old reading `total.rel` could
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.
`ActiveData::answer` is documented as what the widget answered at its ### An answer is not an answer while anything under it is dirty (`0e0d4af`)
offer. `Painter::widget_at` guards its write with `answers_offer`, but
`UiRenderState::draw_inner` writes the field unconditionally and returns
the same value, so the guard is dead and the placing second ask overwrites
the offer's answer with one about the placed box. The guard is from
`29c7881`; the unconditional write arrived with `d3b0ebf`.
```rust `draw_inner` took an answer from `try_reuse`, which checks only whether the
// painter.rs, widget_at widget itself is marked, where `retained_answer` beside it also refused one
let answers_offer = self.at_offer && px == offered_px; while anything the widget read a size from was dirty. A widget whose drawing
let (size, holds) = self.state.draw_inner(...); happened to be reusable therefore handed back the answer it gave before that
if answers_offer { descendant changed, and nothing puts it right: the comparison that tells a
self.state.active.get_mut(&id.id()).unwrap().answer = (size, holds); reader its child's answer moved is in `redraw`, and a widget settled inside
} 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.
// render_state.rs, draw_inner, on every ask So `dirty_size_under` is not the optimization its comment claimed. It is
active.answer = settled; 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".
`known_len`'s first-ask write and `update`'s resize path also write the Found at seed 564, depth 6, `shuffle-every-other`, reachable only once a
field as though `draw_inner` did not; `known_len`'s stores the value it just span could overflow itself. **The fast test for it is still owed**: the
read from the same field. The oracle passes, so layout is not wrong. What it divergence needs a reader drawing while a size dependency two levels under
can cost is churn: an answer about the placed box can miss it is dirty, which no hand-built tree has reproduced yet, and the seeds at
`retained_answer` on the next offer ask and fall through to a remap and depth 4 above fail for some other reason.
back. Not confirmed against the counters. Write the field in one place, and
keep the guarded one.
### `redraw` reassembles `DrawInfo` by hand
`ActiveData` copies eight fields of `DrawInfo` and `redraw` rebuilds the
struct field by field. This is where the mask defect fixed in `ea6dbae`
lived for as long as there was a local-redraw path.
```rust
let info = DrawInfo {
layer: active.layer,
parent: active.parent,
depth: active.depth,
parent_move: active.parent_move,
region_node: rsc.widgets().is_region_node(id),
mask: active.parent_mask,
given_len: active.given_len,
offer_len: active.offer_len,
px: given_px,
offered_px,
decided: active.decided,
};
```
Store the `DrawInfo` on `ActiveData` and write
`DrawInfo { px, offered_px, ..active.info }`: the duplicated fields and the
reconstruction go, and a new field cannot be forgotten. The pixel pair stays
out on purpose; see "px is not stored" below.
### `Span`'s leftover boundary is a third expression for the room ### `Span`'s leftover boundary is a third expression for the room
@@ -455,22 +446,26 @@ above for what closing the rest would cost.
## Verification at the current head ## Verification at the current head
At `32542d0`, then `ea6dbae` on top: At `ffd79f3`:
- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --
-D warnings`, `cargo test --workspace`: green, 87 suite tests, 18 core -D warnings`, `cargo test --workspace`: green, 89 suite tests, 18 core
unit tests, 11 generated cases. Only the long runs and the profiling rigs unit tests, 11 generated cases. Only the long runs and the profiling rigs
are ignored; no known defect is. are ignored; no known defect is.
- The release oracle at 100 seeds in 14.4 s, and **120 seeds in debug** in - The release oracle at 100 seeds in 14.3 s, and **120 seeds in debug** in
59 s -- the debug run exercises the `Holds` assertion in `draw_at`. 59 s -- the debug run exercises the `Holds` assertion in `draw_at`.
- All fifteen shrinker cases at 400 seeds of depth 5 in 58 s, and at 1000 - All fifteen shrinker cases at 400 seeds of depth 5 in 56 s, and the
seeds of depth 6 in 147 s. oracle at 1000 seeds of depth 6 in 142 s.
- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at - `view`, `minimal`, `random`, `tabs` and `text` byte-identical at
1920x1200 against `5b78002`, and the `tabs` touch replay before and after 1920x1200 against `ea6dbae`. `random` live-resized from 1920x1200 to
the gesture. `random` live-resized from 1920x1200 to 1280x800 is 1280x800 is byte-identical to a cold 1280x800 render.
byte-identical to a cold 1280x800 render. - Twenty-five rig work counters identical on `cold`, `repaint`, `scroll`,
- Twenty-five rig work counters identical on the `cold` and `resize` `resize` and `size` across `0e0d4af`; `many` differs only in reuse
phases, which is what makes those rows under "Performance" a measurement. attempts.
**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.
**A claim about a render holds for the commit it was checked at and no **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 further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take
@@ -487,9 +482,10 @@ IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
``` ```
Depth is what finds things: nothing failed at 100 seeds of depth 4, which Depth is what finds things, but so is breadth: every late defect before
was all the oracle ever routinely ran, and every late defect surfaced at 2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by
depth 5 or 6. running 2000 seeds at depth 4, which nothing routine does. Widen one axis
at a time and record which.
## Performance ## Performance
@@ -636,18 +632,27 @@ The replay used for the reference check:
In order, from the review above and Bryan's steer (2026-09-17): In order, from the review above and Bryan's steer (2026-09-17):
1. **`rel` as a fraction of the containing widget's whole area**, on 1. **Seeds 1121 and 1839 at depth 4**, which fail on this head and on
`Span`'s row axis, per the decision above. Fix the test that pins the `ea6dbae` alike -- a warm/cold divergence older than either of the
remainder reading, and add the two-child case from the table. day's commits and the only known correctness defect on the branch.
2. Write `ActiveData::answer` in one place. Reduce each with `SHRINK_SEED`/`SHRINK_DEPTH=4`/`SHRINK_CASE`, turn it
3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the into a fast test, and add the seed to `generated.rs`'s `SEEDS` the way
86 and 20 are there. Then re-scan 2000 seeds at depth 4 before calling
it done.
2. **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.
3. Write `ActiveData::answer` in one place.
4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the
reconstruction in `redraw`. reconstruction in `redraw`.
4. `Span`'s leftover boundary through `Holds::through`. 5. `Span`'s leftover boundary through `Holds::through`.
5. **Round on the CPU and snap to the nearest pixel in the shader**, as 6. **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 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 (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 user's code says: three equal sections of 1000 px need one of them
rounded up). CPU rounding is recommended for a different reason: a rounded up), and to CPU rounding the same day. The reason for that one
is different: a
`Rel` is off by at most `2^-25` of its box, so with round-to-nearest `Rel` is off by at most `2^-25` of its box, so with round-to-nearest
every product whose true value is a whole number of steps is exact for every product whose true value is a whole number of steps is exact for
boxes under about 8,000 px, where truncation leaves half of them one boxes under about 8,000 px, where truncation leaves half of them one
@@ -658,17 +663,21 @@ 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` 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 still inlines; expect a couple of percent of instructions and re-run the
long fuzzers and the render set once for both. long fuzzers and the render set once for both.
6. The smaller items: the stale `f32` comment, the gap of an undrawn child, 7. The smaller items: the stale `f32` comment, the gap of an undrawn child,
confirm nested `leftover` weights, one zero-divisor fallback. confirm nested `leftover` weights, one zero-divisor fallback. Add to
7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable 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.
8. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable
subtree case; do not restore a separate child-placement API. subtree case; do not restore a separate child-placement API.
8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` 9. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height`
builders `8220a78` deleted. The clamp boundary is a hard layout decision builders `8220a78` deleted. The clamp boundary is a hard layout decision
with an exact `Holds` split at the crossover, both sides in `Px`. Still 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 awaiting Bryan: whether a `Max` narrows the box the child draws in, or
only what the parent reports for it. only what the parent reports for it.
9. `Scroll` taking a direction rather than one axis. 10. `Scroll` taking a direction rather than one axis.
10. The measure/draw split, once the above is in. 11. 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 `docs/LAYOUT.md` §4, §5 and the density section are stale: they name
`Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`, `Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`,