Record what making the frame a window length corrected

Step 2 of the layout plan is done in the experiment checkout, at 1512d84
and 23523ee. The handoff carries the commits and what is left; LAYOUT.md
carries the settled rule, which is now one coordinate unit with the frame
a length of it and both pins beside each other; the log carries what
implementing the plan corrected in it, including the four that would have
shipped as wrong layout and the two rig gaps that hid them.
This commit is contained in:
iris-ai committed 2026-09-19 00:21:44 -04:00
1 parent da1807664a
commit 36ce66554b
3 files changed
+196 -146

No files matched your search

+61 -131
View File
@@ -14,24 +14,31 @@ detached comparison checkout is `/home/bob/repos/iris-layout-baseline`. It is
the reviewed baseline this work must preserve or improve. the reviewed baseline this work must preserve or improve.
The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch
**`wip/one-ask`** at **`0ef87eb`**, two commits over `4328eac` (the head of **`wip/one-ask`** at **`23523ee`**, four commits over `4328eac` (the head of
`wip/transparent-frames`, which is unchanged). It replaces the step 3 plan `wip/transparent-frames`, which is unchanged). It replaces the old step 3
below with a smaller protocol change, and it passes every check: plan with the one-ask protocol below, and `1512d84` and `23523ee` make the
frame a length of the window. It passes every check:
| check at `0ef87eb` | result | | check at `23523ee` | result |
| --- | --- | | --- | --- |
| `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean | | `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean |
| `cargo test --workspace` (debug) | 115 suite, 20 core, 11 generated, all green | | `cargo test --workspace` (debug) | 122 suite, 20 core, 11 generated, all green |
| the two decided-box pins from step 1, and the new seed 946 pin | **pass** (the first two were red at `4328eac`) |
| `cargo test --release --test generated` | 11/11 | | `cargo test --release --test generated` | 11/11 |
| shrinker, 400 seeds, depth 5, all fifteen cases | agree, 56 s | | shrinker, 400 seeds, depth 5, all sixteen cases | agree, 66 s |
| 2000-seed depth-4 scan, all fifteen cases | agree, 262 s | | 1000 seeds at depth 6 | agree |
| 1000 seeds at depth 6 | agree, 148 s (found seed 946 at `3091fb8`, fixed and pinned in `0ef87eb`) | | 2000-seed depth-4 scan, all sixteen cases | agree |
Not done: the renders and the `tabs` replay (step 2), and the pre-submit What implementing it corrected in the plan is in `docs/LAYOUT_LOG.md`; the
review of the two commits. four that would have shipped as wrong layout are a share inside padding
losing the padding twice, a root resolving its own rule twice, a rule changed
over two pads relocating the column under them instead of dividing it again,
and a resize leaving a short scroll's window-tall content where it was. Each
is pinned as a named test.
The worker's uncommitted step 3/4 experiment is preserved as branch Not done: the renders and the `tabs` replay, the retained-cost work, the
renames, and a pre-submit review of the three commits as one diff.
The worker's older step 3/4 experiment is preserved as branch
`wip/step3-experiment` (one commit over `4328eac`) and as `wip/step3-experiment` (one commit over `4328eac`) and as
`~/repos/iris-step3-experiment.patch`. It is evidence, not the protocol. `~/repos/iris-step3-experiment.patch`. It is evidence, not the protocol.
The app's Iris pin is unchanged. The app's Iris pin is unchanged.
@@ -77,7 +84,7 @@ is the third plan for this repair and the next one should not repeat it:
**A widget draws once, in the box it is asked in. Its answer is placed inside **A widget draws once, in the box it is asked in. Its answer is placed inside
that box by re-expressing the drawing. Nothing is drawn again in a box an that box by re-expressing the drawing. Nothing is drawn again in a box an
answer chose.** `Holds` is a contract about the ask box alone, consulted only answer chose.** `Holds` is a contract about the ask box alone, consulted only
to decide whether a re-ask can be skipped. This is `draw_inner` at `3091fb8`: to decide whether a re-ask can be skipped. This is `draw_inner` at `1512d84`:
```rust ```rust
let reused = (!stale) let reused = (!stale)
@@ -85,8 +92,7 @@ let reused = (!stale)
.flatten() .flatten()
.and_then(|answer| { .and_then(|answer| {
let extent = placed_extent(part, answer.0, declared, info.fill(), align); let extent = placed_extent(part, answer.0, declared, info.fill(), align);
self.try_reuse(id, frame, part, extent, info, rsc) self.try_reuse(id, part, extent, info, rsc).map(|()| answer)
.map(|()| answer)
}); });
let answer = reused.unwrap_or_else(|| { let answer = reused.unwrap_or_else(|| {
if old.is_none() { if old.is_none() {
@@ -95,7 +101,7 @@ let answer = reused.unwrap_or_else(|| {
let answer = self.draw_at(id, part, info, old.take(), rsc); let answer = self.draw_at(id, part, info, old.take(), rsc);
let extent = placed_extent(part, answer.0, declared, info.fill(), align); let extent = placed_extent(part, answer.0, declared, info.fill(), align);
if extent != part { if extent != part {
self.reposition(id, frame, extent, info, rsc); self.relocate(id, extent, info, rsc);
} }
answer answer
}); });
@@ -118,8 +124,10 @@ with a new call that never runs the body:
pub fn place_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, place: [Place; 2]) pub fn place_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, place: [Place; 2])
``` ```
A frame is narrowed by a *length* of the parent's frame, never a region, and A frame is narrowed by a *length of the window*, never a region and never a
is put back into the part by the child's alignment on every placement: fraction of the parent's frame -- a row's slot cannot be written as a
fraction of the row. The box stays whatever `place` names; only a declaration
places the box inside it, by the child's alignment, on every placement:
```rust ```rust
pub fn widget_at<'s, W: ?Sized>( pub fn widget_at<'s, W: ?Sized>(
@@ -150,9 +158,11 @@ let used = match len.leftover > Weight::ZERO && shares {
}; };
``` ```
`Stack` asks non-sizing children in the box the sizing child decided, with `Stack` asks non-sizing children with `Part::Sized(len)` of what its sizing
the frame narrowed to it on every axis that is not a share, and `Scroll` asks child decided, on every axis that is not a share -- a box of that length
its content once in the viewport and `place_at`s it to the scrolled offset. where their own alignment puts it, and that length as their frame -- and
`Scroll` asks its content once in the viewport and `place_at`s it to the
scrolled offset.
A local redraw asks the retained question again -- the same place of the box A local redraw asks the retained question again -- the same place of the box
the parent was *asked* in -- and, if the answer stands, puts the fresh drawing the parent was *asked* in -- and, if the answer stands, puts the fresh drawing
@@ -216,12 +226,11 @@ row by exactly the icon's width. With the pad in a share instead, both are
the share less 32. An icon *after* the padded text overflows; a user who the share less 32. An icon *after* the padded text overflows; a user who
wanted otherwise meant `leftover`. wanted otherwise meant `leftover`.
**The experiment does not implement this**, and cannot in its representation: Implemented at `1512d84`. `Pad` reads its own frame (`Painter::frame_len`,
a narrowed frame is its own box there (`frame_and_extent` sets the extent to which pins it), takes the padding off, and hands that down as the child's
`FULL` of it), so 868 for `rel` and 844 for the wrap cannot both hold. `Pad` frame, while the box it gives is the inset part of its own box. The two are
still insets only the box through `Part::Of` and forwards the frame whole, different lengths whenever the box is narrower than the frame -- which is
which makes `rel(1.0)` under it 900. Step 2 below is the representation exactly the wrapping case above.
change that expresses the rule.
### A share never adds room beyond the deciding box ### A share never adds room beyond the deciding box
@@ -235,114 +244,31 @@ Unchanged; see `docs/LAYOUT.md`.
## Implementation plan ## Implementation plan
Work in `/home/bob/repos/iris-layout-experiment` on `wip/one-ask` from Work in `/home/bob/repos/iris-layout-experiment` on `wip/one-ask` from
`3091fb8`. Make each step a warning-clean commit and run its named checks `23523ee`. Make each step a warning-clean commit and run its named checks
before the next. If a step exposes a different mechanism, stop and update before the next. If a step exposes a different mechanism, stop and update
this handoff rather than papering over it. this handoff rather than papering over it.
### 1. Review the two commits Anything a fuzzer finds is shrunk first (`SHRINK_SEED=<seed>
SHRINK_DEPTH=<depth> SHRINK_CASE=<case>`, which now prints each level's
frame, ask, box and size warm against cold), pinned as a named test in
`tests/cases/unsettled.rs`, then fixed under the two rules above. Do not add
a second draw back.
Run the pre-submit review over `3091fb8` and `0ef87eb` as one diff against ### 1. Review the four commits
`4328eac`; they were written as a probe and reviewed only by their tests.
Anything a fuzzer finds later is shrunk first (`SHRINK_SEED=<seed> Run the pre-submit review over `3091fb8`, `0ef87eb`, `1512d84` and `23523ee`
SHRINK_DEPTH=<depth> SHRINK_CASE=<case>`), pinned as a named test in as one diff against `4328eac`. `1512d84` rewrote much of what the first two did to
`tests/cases/unsettled.rs`, then fixed under the rule above. Do not add a `painter.rs` and `render_state.rs` and was reviewed as it was written, but
second draw back. the three have never been read as one change, and the parts of the one-ask
protocol it did not touch -- `place_at`, the twice-asked deferral, `Span`'s
two passes -- were written as a probe and reviewed only by their tests.
### 2. Make the frame a length and the box a region ### 2. Make the frame a length and the box a region
The frame stops being a coordinate system. **There is one coordinate unit, **Done at `1512d84` and `23523ee`.** The settled rule is in `docs/LAYOUT.md` under
the window**: every box in the tree is a region in window units, a widget's "Frames, decided boxes and padding"; what implementing it corrected in the
frame is a *length* in the same units and is only what fractions resolve plan -- three of them wrong layout that would have shipped -- is in
against, and a region node's move entry is a *translation* -- a region `rel `docs/LAYOUT_LOG.md`.
1` long anchored at the node's box start -- rather than a box. The last
point is forced, not chosen: a node whose unit was its frame would need its
box expressed in frame units, which is a division of two lengths and not a
`rel + px` length (a `.region_node()` child of a pad in a span room is the
case). **Decided by Bryan, 2026-09-18: yes.** It changes one cost. A node that
moves, or scrolls, is one entry write whatever narrowed it (today only a
narrowed node gets that; a transparent one is re-expressed), and a node
whose box changes *length* at the same window size is re-expressed as a
subtree (today a narrowed node is one write). The shader's `resolve_move`
composes a `rel 1` entry as a translation already and does not change.
```rust
pub struct Painter<'a> {
/// This widget's frame as a length of its region node's box, per axis:
/// what a fraction it declares or reports, and a `From` span under it,
/// are fractions of. A length and not a region, so the box need not be
/// the frame -- padding takes from both without either becoming the other.
pub(super) frame: UiVec2,
/// Where this widget's drawing goes, in the node's coordinates.
pub(super) extent: UiRegion,
/// The window in pixels: the frame in pixels is `frame.to_px(window)`
/// and the box's is `extent.size().to_px(window)`. Every `Holds` range
/// is in these pixels, so `in_parent` combines them without `through`,
/// and a widget's own frame reads convert once: `Holds::at(frame_px)
/// .through(frame)`.
pub(super) window: PxVec2,
...
}
```
`Part::of` does not change: with one unit a `From` span is already a
window-unit length, so a span's cursor, `far`, `room` and slots stay as they
are and only its shares boundary reads `room.to_px(window)`. Two places
resolve through a frame: a reported fraction (`in_parent_frame`, now
`size.within_len(child_frame)`) and a declared one (`narrowed_by`, then a
`Sized` part placed by alignment). A widget's own frame reads convert once,
`Holds::at(frame_px).through(frame)`, the exact preimage of the `to_px` that
made them. `recompose_subtree` and `reposition` become one operation, since
a child's box is always `Part::of(parent box, place)`; `asked_px` is the
window.
A child's frame is `narrow.within_len(parent.frame)` where the caller or a
rule narrowed it, else the parent's; a declared length also decides the box
(`len` placed in the part by alignment, as `frame_and_extent` does today),
where a caller's `narrow` decides the frame alone and the place decides the
box. `Pad` then says both halves of the rule:
```rust
let inset = |lead: Px, trail: Px| {
Place::Within(Part::Of(UiSpan::new(
Len::from_parts(Rel::ZERO, lead),
Len::from_parts(Rel::ONE, -trail),
)))
};
let less = |lead: Px, trail: Px| Some(Len::from_parts(Rel::ONE, -(lead + trail)));
let inner = painter
.widget_at(
&self.inner,
[less(self.padding.left, self.padding.right), less(self.padding.top, self.padding.bottom)],
[inset(self.padding.left, self.padding.right), inset(self.padding.top, self.padding.bottom)],
)
.size();
```
What this changes in the core, and why it is not merely more code:
- `Painter::resolve` becomes `region.within(&self.extent)`; the second
`within` through the frame goes.
- `in_parent` no longer maps a child's frame holds through the frame length
per level: every widget holds for the window's pixels, so the holds `and`
directly, at region nodes too.
- `DrawInfo::frame`/`frame_abs`, `ActiveData::frame`/`frame_abs`, `local`
in `Placing`, `recompose_subtree` and `asked_px` all change meaning:
`frame` is a `UiVec2` length, `frame_abs` goes, a node's move entry is a
`rel 1` region at the node's box start, and `asked_px` becomes the window
size.
- `Stack` needs a part that is "a box of this length, placed by the child's
alignment" (`Part::Sized(Len)`, the same thing a declared length does),
since `narrow` no longer makes the box.
The random rig and the shrunk tests do not change. Expected precision is the
same as today (one `rel × rel` per level of narrowing). Checks: the suite,
the fast oracle, the 400/5 shrinker, then both long fuzzers; the `many`,
`resize` and `cold` counters must not move by more than the reposition work
this removes; and two new tests, `rel(1.0)` inside `.pad(16)` after a 24 px
icon is 868 px while the wrapped text beside it wraps at 844, and the same
inside a 450 px share is 418 for both. `examples/text.rs` then renders
inside its padding.
### 3. Render and replay ### 3. Render and replay
@@ -358,8 +284,12 @@ likely to show here; inspect every intentional difference and record it.
Rename `ActiveData::offer_part` to `part`, `offer_place` to `asked`, `place` Rename `ActiveData::offer_part` to `part`, `offer_place` to `asked`, `place`
to `placed`, and `DrawInfo` likewise; delete `ActiveData::measured` in favour to `placed`, and `DrawInfo` likewise; delete `ActiveData::measured` in favour
of reading `answer`; delete `answers_at` if `resize` is its only caller and of reading `answer`; delete `answers_at` if `resize` is its only caller and
inline it. Every use was written against the old names on purpose to keep inline it. Rename `LayoutHolds::frame` to `window` and `Painter::frame_own`
the probe's diff readable; do this as one mechanical commit. Suite, oracle. with it: since `1512d84` those ranges are window pixels, and the only thing
that writes them is `Painter::window_holds`; the frame's own entry is the
`frame_len` pin beside it. Every use was written against the old names on
purpose to keep the probe's diff readable; do this as one mechanical commit.
Suite, oracle.
### 5. Restore the expected retained cost ### 5. Restore the expected retained cost
@@ -423,7 +353,7 @@ SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \
cargo test --release --test shrink -- --ignored --nocapture cargo test --release --test shrink -- --ignored --nocapture
``` ```
The last line is the 2000-seed depth-4 scan over all fifteen cases; the The last line is the 2000-seed depth-4 scan over all sixteen cases; the
shrinker runs the same cases as the scan and reduces anything it finds, so shrinker runs the same cases as the scan and reduces anything it finds, so
no temporary test body is needed any more. `Rng::new` uses `seed | 1`, so no temporary test body is needed any more. `Rng::new` uses `seed | 1`, so
adjacent even/odd seed pairs describe the same tree. adjacent even/odd seed pairs describe the same tree.
+35 -15
View File
@@ -333,11 +333,30 @@ that box by re-expressing the drawing, and nothing is drawn again in a box an
answer chose. `Holds` is a contract about the ask box alone, read only to answer chose. `Holds` is a contract about the ask box alone, read only to
decide whether a re-ask can be skipped. A container that puts an answer decide whether a re-ask can be skipped. A container that puts an answer
somewhere other than where it asked says so with `Painter::place_at`, which somewhere other than where it asked says so with `Painter::place_at`, which
never runs the body. A frame is narrowed by a length of the parent's frame, never runs the body. This is `wip/one-ask` in the experiment checkout.
never by a region, and is put back into the part by the child's alignment on
every placement. This is `wip/one-ask` in the experiment checkout. The **There is one coordinate unit, the window** (`1512d84`, `23523ee`). Every box in the
padding rule above needs the frame to be a length and the box a region in tree is a region in window units; a widget's frame is a *length* in the same
the region node's coordinates, which is the next step in `docs/HANDOFF.md`. units and is only what fractions resolve against, so a widget's box need not
be its frame and padding can take from both. A region node's entry is a
translation -- a `rel 1` region anchored where its box starts -- so nothing
composes a frame back up a chain.
A frame decided for a child is therefore a length of the window too: a row's
slot, padding's frame less its pixels, or the box a stack's sizing child
decided (which arrives as `Part::Sized`). It is never a fraction of the
parent's frame, because a slot of a row is not a fraction of anything the row
can name -- a division of two lengths, which a `rel + px` length cannot hold.
A widget's own declaration *is* a fraction, of whichever of those reached it,
and it is the only one of them that also places the box inside the part.
Validity has a pin for each: `LayoutHolds::extent_len` for the box's symbolic
length and `frame_len` for the frame's. A range of window pixels cannot say
which frame an answer is a fraction of, since two frames are different
lengths at the same window size. Resolving any of these lengths in pixels is
a read of the window, and `Painter::to_px` is where that read is taken --
pinning the window where the length has a fraction in it, and nothing where
it is only pixels.
## Layout decisions and invariants (2026-09-15 to 2026-09-17) ## Layout decisions and invariants (2026-09-15 to 2026-09-17)
@@ -389,14 +408,15 @@ floats.
so a boundary derived with a division guards a drawing made with a so a boundary derived with a division guards a drawing made with a
multiply (`53b00c6`). multiply (`53b00c6`).
### A box in pixels is one multiply from its parent's ### A box in pixels is one multiply from the window
A draw threads pixel lengths down: the box a parent gave a widget, then the Every length in the tree is a length of the window, so it becomes pixels in
part of that box its own answer placed its drawing in. `Painter::px_size` one multiply wherever it is read: `Painter::px_size` and `px_len` for the
and `px_len` read that value, and a local redraw takes the same steps back box, `frame_len` for the frame. There is no chain to walk and no coordinate
up the parent chain (`asked_px`). Neither chain has a coordinate frame in it, frame in the way, so a region node cannot break it and warm and cold reach
so a region node cannot break either, and warm and cold reach every length every length by the same expression. (Before `1512d84` a frame was a length
by the same expression. of its parent's frame and a local redraw walked back up the parent chain in
`asked_px`; both are gone.)
- **`Holds::through` is the exact preimage of `px + floor(rel * box)`**: - **`Holds::through` is the exact preimage of `px + floor(rel * box)`**:
`floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and
@@ -412,9 +432,9 @@ by the same expression.
Nothing layout decides is composed back up the move chain. Nothing layout decides is composed back up the move chain.
- **`px` is not stored on `ActiveData`, deliberately.** A resize every - **`px` is not stored on `ActiveData`, deliberately.** A resize every
widget's `Holds` admits redraws nothing, so a stored pixel length would widget's `Holds` admits redraws nothing, so a stored pixel length would
be stale on every widget in the tree with nothing to say so. `asked_px` be stale on every widget in the tree with nothing to say so. What is
walks up only where a widget is already being redrawn; the mean chain is stored is the symbolic frame, which a redraw resolves against the window
2.8 levels. it has.
- **The window is not a move entry** (`5b78002`). A chain bottoms out in - **The window is not a move entry** (`5b78002`). A chain bottoms out in
`MoveIdx::NONE`; the window is applied where a fraction becomes pixels, `MoveIdx::NONE`; the window is applied where a fraction becomes pixels,
`to_px(output_size)` on the CPU and the uniform in the shader. A resize `to_px(output_size)` on the CPU and the uniform in the shader. A resize
+100
View File
@@ -7,6 +7,100 @@ must outlive it (settled design, the measurement method) is already in
`docs/LAYOUT.md`, and the current plan is in `docs/HANDOFF.md`. Commit ids `docs/LAYOUT.md`, and the current plan is in `docs/HANDOFF.md`. Commit ids
are in `/home/bob/repos/iris-layout-experiment` unless said otherwise. are in `/home/bob/repos/iris-layout-experiment` unless said otherwise.
## What making the frame a length found (worker, 2026-09-19)
Step 2 of the handoff, implemented over `0ef87eb` in
`/home/bob/repos/iris-layout-experiment`. Six of these are corrections to the
plan, not to the code that implemented it, so they are worth keeping even
after the step lands.
- **A narrowed frame is a window length, not a fraction of the parent's
frame.** The plan said both at once: `Pad` narrowing by `rel 1 - 32px` (a
fraction) and `Span` narrowing by `slot.len()` (a window length). The first
implementation resolved every `narrow` against the parent frame, which took
padding off twice for anything inside a padded row -- a `leftover(1)` slot
of a 900 px row inside `pad(16)` gave its child a frame of 418 where the
slot is 434. A slot *cannot* be written as a fraction of the row's frame:
that is a division of two lengths, the same argument that forced region
node entries to be translations. So `narrow` is a window length, `Pad` reads
its own frame with the new `Painter::frame_len` and takes the pixels off,
and nothing resolves a narrow a second time. Pinned by
`layout::a_share_inside_padding_fills_the_slot_it_was_given`.
- **The root resolved its own rule twice**, because `root_layout` passed the
declaration as both the narrow and the declaration: a root of `rel(0.5)` in
a 900 px window came out 225 wide. Pinned by
`layout::a_root_with_a_fraction_rule_is_that_fraction_of_the_window`.
- **Which box arm `in_parent` takes is decided by the box, not the frame.**
The first implementation treated any decided frame -- a narrow, a share, a
declaration -- as a box this widget chose, so a `Pad` (which narrows) lost
its child's box pin, and a rule changed over two zero pads relocated the
column under them instead of dividing it again (seed 59, depth 5,
`resize-size`). Only a declaration places the box inside the part it was
given; a narrowed frame leaves the box exactly the part. Pinned by
`unsettled::changing_a_rule_over_two_pads_divides_the_column_again`.
- **Frame validity is a pin, not a range.** A range of window pixels cannot
say "this answer is a fraction of *that* frame": two frames are different
lengths at the same window size. `LayoutHolds` therefore carries
`frame_len` beside `extent_len`, set where a widget reads its frame
(`frame_len`, which `Pad` does) and where a rule that is a fraction of the
frame is answered with it, and composed up where a length of this frame is
what reached the child. It replaced a `holds_for_frame` helper that
compared expressions and asked `Holds::ANY` whether anything depended on
them, which missed the rule-answered case entirely. **No test in the repo
distinguishes the rule-answered half**: 400 seeds over sixteen cases at
depth 5 agree with it and without it, and hand-built trees for the shape
the seed-30 records showed (a stale `1 rel` answer beside a stack's sizing
child) do not reproduce it either, because a frame that changes almost
always changes a box as well and the box pins catch it. Kept anyway,
because "these two invalidations always coincide" is the kind of unstated
assumption the three earlier plans died of; it is one line at each of two
sites if it is ever shown to be dead.
- **`Scroll` resolved its content length against its own box.** With one unit
its child's answer is a window length, so it becomes pixels against the
window; the two differ wherever a scroll's box is not its frame. Fixing it
then broke `resize`, because resolving a length against the window is a
*read of the window* and nothing recorded it: a viewport 40 px tall inside
a branch's box does not change when the window does, so an end-snapped
scroll kept the offset it had (seed 942, depth 6). `Painter::to_px` takes
the read where the resolution happens and pins the window only where the
length has a fraction in it; `window_px_len`, which returned the number
and recorded nothing, is gone. Pinned as
`unsettled::resizing_under_a_short_scroll_snaps_its_window_tall_content_again`.
- **A local redraw resolves its own frame** from its record's narrow and
declaration (`ask_again`, shared with `place_in`) rather than reusing the
retained frame. That deleted `asked_px` and the assert that the two agree:
they legitimately disagree when an ancestor's frame changed and the
ancestor was reused because nothing under it pinned the frame, and the
freshly resolved one is the right one.
- **The clipping debug assert compared the reported size with the frame**
rather than with the box it is a claim about.
Two rig changes came out of it, both kept:
- The shrinker prints the *shrunk* tree's divergence rather than the grown
tree's, and the divergence now lists, for the failing widget and every
ancestor, warm and cold `frame`, `ask`, `box` and `size`. Reading that
table is what found the box-arm defect; reconstructing a 20-widget plan by
hand would not have.
- A `size-resize` case: a size change and *then* a resize. Every other case
changes something and compares at once or resizes first, so an answer kept
as a fraction of the wrong length agreed at the window it was made at and
parted from cold at every other one.
Checks at the end of the step (`23523ee`): `cargo fmt --all --check`, clippy
with and without `layout-diagnostics`, 122 suite tests, 20 core, 11
generated, the 400-seed depth-5 shrinker over all sixteen cases, the
1000-seed depth-6 oracle, and the 2000-seed depth-4 scan over all sixteen
cases.
One thing is unexplained and worth knowing about: seed 30 at depth 5
(`shuffle-all-but-first`) failed once, with a span keeping an answer of
`1 rel` for an axis whose frame had become `78 px`, and then stopped failing
before the pin was added -- reverting each part of the change since that run
did not bring it back. Either an edit fixed it that I could not identify, or
the rig is not as deterministic as it looks. If a seed ever fails once and
not again, suspect the second.
## What the one-ask protocol found (planner, 2026-09-18, third session) ## What the one-ask protocol found (planner, 2026-09-18, third session)
Branch `wip/one-ask` at `3091fb8` over `4328eac`. The change is described in Branch `wip/one-ask` at `3091fb8` over `4328eac`. The change is described in
@@ -404,6 +498,12 @@ is the padded share and fits. The clipped render records the current bug.
was checked this way and is new under the pin experiment). was checked this way and is new under the pin experiment).
- Depth finds things and so does breadth; widen one axis at a time and - Depth finds things and so does breadth; widen one axis at a time and
record which. record which.
- The cases are what a change is *then* compared at, and until 2026-09-19
every one of them compared at the window the change was made in. A
`size-resize` case was added because a length kept as a fraction of the
wrong box agrees with cold at that window and parts from it at every other
one. There is still no case that changes a tree twice with a resize
between, and no case that resizes twice.
## Smaller open items (carried from the old handoff) ## Smaller open items (carried from the old handoff)