Land the layout repair's findings and retire the working log
`docs/LAYOUT_LOG.md` existed until the one-ask protocol landed. It has, as Iris PR #19 replacing #18, so what has to outlive the log moves into `docs/LAYOUT.md`: the hint rule and the one ask a span makes, the reported shares that are left and the two-answers design that would remove them, what the fuzzers reach and what they cannot, the hypotheses not to repeat, and the counter and dump commands with the numbers they print now. The handoff becomes what it is for -- where the work stands, which is waiting on a review.
This commit is contained in:
1 parent
e4fada298d
commit
3ecd676bcf
3 files changed
+191
-1183
No files matched your search
+67
-504
@@ -1,516 +1,79 @@
|
||||
# Handoff
|
||||
|
||||
Where the Iris retained-layout work stands for a worker picking it up cold.
|
||||
This file contains current decisions, the implementation plan and its checks.
|
||||
The durable layout design and measurement method are in `docs/LAYOUT.md`.
|
||||
The temporary investigation record is in `docs/LAYOUT_LOG.md`; delete that
|
||||
log when the one-ask protocol lands, after moving any fact that must survive.
|
||||
Where the work in flight stands. The durable layout design, the measurement
|
||||
method and the findings that outlived the working log are in
|
||||
`docs/LAYOUT.md`.
|
||||
|
||||
## Where things stand
|
||||
## The Iris layout repair is submitted
|
||||
|
||||
Canonical upstream Iris `main` is **`ca2b4b2`** (#17, the headless rig). PR
|
||||
#18's pushed branch is `split/18-position-chain` at **`e44dea3`**. Its
|
||||
detached comparison checkout is `/home/bob/repos/iris-layout-baseline`. It is
|
||||
the reviewed baseline this work must preserve or improve.
|
||||
**Iris PR #19** (`layout/one-ask` at `cadfba0`, 123 commits over upstream
|
||||
`main` at `ca2b4b2`) is open and waiting on Bryan. It **replaces #18**, which
|
||||
is closed and whose branch `split/18-position-chain` stays as the reviewed
|
||||
baseline the newer work was measured against. Bryan decided on 2026-09-19
|
||||
that the experiment replaces it, because it is more performant where it
|
||||
matters.
|
||||
|
||||
The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch
|
||||
**`wip/hint-first`** at **`f6242aa`**, one commit over `wip/one-ask` at
|
||||
`a888717` (nine commits over `4328eac`, the head of `wip/transparent-frames`,
|
||||
which is unchanged). Both branches are pushed to `origin`. `wip/one-ask`
|
||||
replaced the old step 3 plan with the one-ask protocol below, `1512d84` and
|
||||
`23523ee` made the frame a length of the window, and `e8a5792` is what the
|
||||
step 1 review found. `f6242aa` is the step 5 result: a span takes a child's
|
||||
length from its hint or rule and asks it once, in its slot. It passes every
|
||||
check:
|
||||
The checkout is `/home/bob/repos/iris-layout-experiment`, on branch
|
||||
`wip/hint-first`, which is the same commit as `layout/one-ask`. Both are
|
||||
pushed to `origin` (`iris-ai/iris`). What the branch does and what it leaves
|
||||
undone is in the PR description; the settled protocol is in `docs/LAYOUT.md`
|
||||
under "Frames, decided boxes and padding".
|
||||
|
||||
| check at `f6242aa` | result |
|
||||
Verification at `cadfba0`, all run in this checkout:
|
||||
|
||||
| check | result |
|
||||
| --- | --- |
|
||||
| `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean |
|
||||
| `cargo test --workspace` (debug) | 123 suite, 20 core, 11 generated, all green |
|
||||
| `cargo test --workspace` | 123 suite, 20 core, 11 generated, green |
|
||||
| `cargo test --release --test generated` | 11/11 |
|
||||
| shrinker, 400 seeds, depth 5, all sixteen cases | agree, 63 s (34,488 widgets) |
|
||||
| 1000 seeds at depth 6 | agree, 277 s |
|
||||
| 2000-seed depth-4 scan, all sixteen cases | agree, 283 s (82,203 widgets) |
|
||||
| cold layout of 400 trees at depth 5, dumped and diffed against `a888717` | byte-identical (34,488 widgets) |
|
||||
| renders, the `tabs` replay and the `random` resize | **not re-run since `a888717`**; step 3 below |
|
||||
|
||||
The cold-layout dump is `tests/layout_dump.rs`, new at `f6242aa`: it prints
|
||||
every widget's box for many grown trees so two commits can be diffed on cold
|
||||
layout, which the warm/cold oracle cannot see a change to.
|
||||
|
||||
What implementing it corrected in the plan is in `docs/LAYOUT_LOG.md`; the
|
||||
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 renders and the replay are done and recorded in `docs/LAYOUT_LOG.md`:
|
||||
against #18, `minimal` and `tabs` are byte-identical (before and after the
|
||||
reference gesture), `random` differs in two pixels of glyph antialiasing,
|
||||
`text` moves one padded block one pixel, and a live resize of `random`
|
||||
matches a cold render at that size byte for byte. Re-run at `a888717`, all
|
||||
five are byte-identical to the same renders at `a30971e`, so the scroll fix
|
||||
below changed none of them.
|
||||
|
||||
Not done: the pre-submit review of `f6242aa`, the render set at it, the doc
|
||||
pruning and the landing (steps 1, 3 and 6 below). Step 5's measurements and
|
||||
the two ideas it tested are in `docs/LAYOUT_LOG.md` under "What the step 5
|
||||
measurements found".
|
||||
|
||||
The worker's older step 3/4 experiment is preserved as branch
|
||||
`wip/step3-experiment` (one commit over `4328eac`) and as
|
||||
`~/repos/iris-step3-experiment.patch`. It is evidence, not the protocol.
|
||||
The app's Iris pin is unchanged.
|
||||
|
||||
## The two rules to protect
|
||||
|
||||
These outrank the accumulated machinery:
|
||||
|
||||
1. A changed tree lays out exactly as if it had been drawn that way from the
|
||||
start. The warm/cold oracle and shrinker test this.
|
||||
2. Lengths are predictable. `px` is that many pixels; `rel(0.5)` is half of
|
||||
the frame decided for the widget, wherever it sits; `leftover` is a share
|
||||
of the room left after every sibling's `px` and `rel` lengths are resolved.
|
||||
|
||||
Do not fix a failure with a tolerance, another measurement flag, a special
|
||||
case in `Span`, or another layout method.
|
||||
|
||||
## What the previous plan got wrong
|
||||
|
||||
The full account is in `docs/LAYOUT_LOG.md`. The short version, because it
|
||||
is the third plan for this repair and the next one should not repeat it:
|
||||
|
||||
- **Every plan kept the second draw.** The old protocol drew a widget in the
|
||||
box it was asked in, then drew it *again* in the box its own answer placed
|
||||
it in whenever the first drawing's `Holds` did not cover that box. All the
|
||||
offer machinery -- `offer_place`, `offer_part`, `at_offer`, `measured()`,
|
||||
the local-redraw deferral -- existed to remember which of the two draws was
|
||||
the question. The plans tried to define that bit better; the defect was
|
||||
that there were two draws at all.
|
||||
- **The step 3 plan then over-corrected.** It said "every drawing must hold
|
||||
for the answer box it supplies", and the worker implemented exactly that as
|
||||
an assertion in `place`. A wrapped `Text` asked at 45 px whose longest word
|
||||
is 89.5 px cannot satisfy it, and neither can any widget that reads its box
|
||||
and reports something other than it. The answer box is not a question, so
|
||||
no contract about it can be demanded of the widget.
|
||||
- **It also let a caller narrow a frame by position.** A frame narrowed to a
|
||||
region (the worker's share frames) does not move when the part it sits in
|
||||
moves; only a frame narrowed to a *length*, put back into the part on
|
||||
every placement, does.
|
||||
|
||||
## The protocol now in the experiment
|
||||
|
||||
**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
|
||||
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 `1512d84`:
|
||||
|
||||
```rust
|
||||
let reused = (!stale)
|
||||
.then(|| self.retained_answer(id, part, info))
|
||||
.flatten()
|
||||
.and_then(|answer| {
|
||||
let extent = placed_extent(part, answer.0, declared, info.fill(), align);
|
||||
self.try_reuse(id, part, extent, info, rsc).map(|()| answer)
|
||||
});
|
||||
let answer = reused.unwrap_or_else(|| {
|
||||
if old.is_none() {
|
||||
old = self.remove(id, false, rsc);
|
||||
}
|
||||
let answer = self.draw_at(id, part, info, old.take(), rsc);
|
||||
let extent = placed_extent(part, answer.0, declared, info.fill(), align);
|
||||
if extent != part {
|
||||
self.relocate(id, extent, info, rsc);
|
||||
}
|
||||
answer
|
||||
});
|
||||
```
|
||||
|
||||
`try_reuse` checks the drawing against `part` and relocates it to `extent`;
|
||||
the old `place` (redraw in the answer box) is gone, and with it every offer
|
||||
field's purpose. `ActiveData` keeps `part` as the ask box, `asked` as where
|
||||
it was asked and `placed` as where it was put (renamed in step 4).
|
||||
|
||||
A container that puts an answer somewhere other than where it asked says so
|
||||
with a new call that never runs the body:
|
||||
|
||||
```rust
|
||||
/// Puts a child asked about in this draw somewhere else in this
|
||||
/// widget's box: its answer, placed in this part instead. The drawing
|
||||
/// is re-expressed there rather than made again -- what a row does once
|
||||
/// it knows every slot, having measured each child from its cursor.
|
||||
pub fn place_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, place: [Place; 2])
|
||||
```
|
||||
|
||||
A frame is narrowed by a *length of the window*, never a region and never a
|
||||
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
|
||||
pub fn widget_at<'s, W: ?Sized>(
|
||||
&'s mut self,
|
||||
id: &'s StrongWidget<W>,
|
||||
narrow: [Option<Len>; 2],
|
||||
place: [Place; 2],
|
||||
) -> DrawResult<'s, 'a, W>
|
||||
```
|
||||
|
||||
`Span` takes each child's length along itself from its hint where one says
|
||||
-- a rule, or a widget that always reports the whole of its box -- and
|
||||
otherwise asks the child once from its cursor (`Within(From(cursor..far))`).
|
||||
Then it moves a drawn fixed child to its slot, and asks every other child
|
||||
once in its decided slot, a share with the frame narrowed to it. This is
|
||||
`Span::draw` at `f6242aa`:
|
||||
|
||||
```rust
|
||||
let size = match painter.size_hint(child, axis) {
|
||||
Some(len) => {
|
||||
measured.push(None);
|
||||
len
|
||||
}
|
||||
None => {
|
||||
let room = Place::Within(Part::From(along(cursor, far)));
|
||||
let size = painter
|
||||
.widget_at(child, [None; 2], axis.pair(room, across))
|
||||
.size();
|
||||
measured.push(Some(size));
|
||||
size.axis(axis)
|
||||
}
|
||||
};
|
||||
...
|
||||
let slot = along(from, start);
|
||||
let place = axis.pair(Place::Fill(Part::From(slot)), across);
|
||||
let mut narrow = [None; 2];
|
||||
if len.leftover > Weight::ZERO && shares {
|
||||
narrow[axis as usize] = Some(slot.len());
|
||||
}
|
||||
let used = match (measured, narrow[axis as usize]) {
|
||||
(Some(size), None) => {
|
||||
painter.place_at(child, place);
|
||||
size.axis(!axis)
|
||||
}
|
||||
_ => painter.widget_at(child, narrow, place).len(!axis),
|
||||
};
|
||||
```
|
||||
|
||||
A hint is the length the child's draw would report: `Painter::size_hint`
|
||||
resolves a fraction in it against the asking widget's frame, which is the
|
||||
frame a child asked with nothing narrowed gets, and pins that frame where it
|
||||
did. `Scroll`, `Masked`, a `Stack` without a sizing child and the fixture's
|
||||
`Branch` hint `LEFTOVER`, since each always reports it; `Rect` and `()`
|
||||
already did. A widget that reports a share its children gave it -- a span
|
||||
with a `Rect` in it -- has no hint and is still asked twice, which is the
|
||||
whole of what step 5 left.
|
||||
|
||||
`Stack` asks non-sizing children with `Part::Sized(len)` of what its sizing
|
||||
child decided, on every axis that is not a share -- a box of that length
|
||||
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
|
||||
the parent was *asked* in -- and, if the answer stands, puts the fresh drawing
|
||||
back at the retained place of the box the parent's answer *chose*. Both halves
|
||||
are needed: seed 2 at depth 4 (a stack sized by its text) fails without the
|
||||
second.
|
||||
|
||||
A widget its parent asked more than once in one draw -- a share child, asked
|
||||
in the room and then in its slot -- has two questions and one record, so it
|
||||
cannot settle locally: `redraw` defers it to the parent the way it defers a
|
||||
widget whose declared length changed (`ActiveData::re_asked`, set by
|
||||
`widget_at` when the child is already in `children`). Seed 946 at depth 6
|
||||
found the case: a fixed-height column that is a share while its rect fits
|
||||
and a fixed width once it does not, so emptying it changes the room answer
|
||||
and not the slot answer.
|
||||
|
||||
A symbolic length a child pinned now composes through `Part::Of` where the
|
||||
part is the whole box less pixels, and pins the parent's own length otherwise
|
||||
(`in_parent`). Dropping it let a zero `Pad` reuse a drawing across a narrowed
|
||||
frame of the same pixel length; the shrinker found six such seeds at depth 5.
|
||||
|
||||
## Decisions
|
||||
|
||||
Decided with Bryan on 2026-09-17 and 2026-09-18, kept where still true.
|
||||
|
||||
### One draw method, in a box decided from above
|
||||
|
||||
`Widget::draw` remains the only layout method. A container's body runs only
|
||||
in a box its parent offered or decided, never in a box derived from the
|
||||
container's own answer. **The experiment extends this to every widget:** a
|
||||
leaf is not drawn in its answer box either. Its drawing is re-expressed
|
||||
there, which for a text means the block it shaped at the asked width is
|
||||
positioned inside the box its reported size chose, and its lines do not
|
||||
change. `examples/text.rs` and `random` have not been rendered since; do that
|
||||
before landing and inspect any change.
|
||||
|
||||
### Frames are narrowed by every length decided from above
|
||||
|
||||
A declared `px` or `rel`, a resolved share, and the box a stack's sizing
|
||||
child decided (Bryan, 2026-09-18: the sizing child, if any, determines how
|
||||
the rest are laid out) all narrow the frame.
|
||||
`declared_lens` still excludes `leftover`, which is right: a share has no
|
||||
length until the span divides its room, and it narrows the frame at the
|
||||
placing ask instead.
|
||||
|
||||
### Padding is an inset, and the frame is a length while the box is a region
|
||||
|
||||
Bryan, 2026-09-18: padding is an inset. It subtracts from both the child's
|
||||
frame and its box and adds itself to the reported size, so `rel(1.0)` inside
|
||||
padding fills the parent without overflowing. A span's frame never subtracts
|
||||
siblings; only the padding subtracts from it. No outset kind and no mixed
|
||||
kind for now; the name stays `Pad`. Worked example, 900 px row:
|
||||
|
||||
```rust
|
||||
let row = (rect(Color::RED).width(24), wtext(PARAGRAPH).wrap(true).pad(16)).span(Dir::RIGHT);
|
||||
```
|
||||
|
||||
The text is asked in 900 − 24 − 32 = **844** px and wraps there; a
|
||||
`rel(1.0)` inside the same padding is 900 − 32 = **868** px and overflows the
|
||||
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
|
||||
wanted otherwise meant `leftover`.
|
||||
|
||||
Implemented at `1512d84`. `Pad` reads its own frame (`Painter::frame_len`,
|
||||
which pins it), takes the padding off, and hands that down as the child's
|
||||
frame, while the box it gives is the inset part of its own box. The two are
|
||||
different lengths whenever the box is narrower than the frame -- which is
|
||||
exactly the wrapping case above.
|
||||
|
||||
### A share never adds room beyond the deciding box
|
||||
|
||||
`Scroll` resolves its content length from the fixed part of the answer and
|
||||
makes it at least the viewport (`4328eac`). Unchanged.
|
||||
|
||||
### Existing fixed-point and box-chain design stays
|
||||
|
||||
Unchanged; see `docs/LAYOUT.md`.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
Work in `/home/bob/repos/iris-layout-experiment` on `wip/hint-first` from
|
||||
`f6242aa`. Make each step a warning-clean commit, push it, and run its named
|
||||
checks before the next. If a step exposes a different mechanism, stop and
|
||||
update this handoff rather than papering over it.
|
||||
|
||||
The order is 1, 3, 6: review the step 5 commit, render at it, then land.
|
||||
Steps 2, 4 and 5 are done and kept below for what they decided.
|
||||
|
||||
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.
|
||||
|
||||
### 1. Review the step 5 commit
|
||||
|
||||
The six commits `4328eac..a888717` were reviewed as one diff (done at
|
||||
`a888717`; it found one wrong layout, fixed at `e8a5792` and pinned by
|
||||
`scroll::content_that_fits_is_placed_in_the_viewport_and_not_in_the_window`,
|
||||
and three stale comments). **Still to do: the pre-submit review of
|
||||
`a888717..f6242aa`**, which the planner wrote and measured but did not
|
||||
review as a separate pass. It touches `Span::draw`, `Painter::size_hint`,
|
||||
four `size_hint` implementations and the dump rig. Things to look at:
|
||||
|
||||
- `Painter::size_hint`'s pin: it sets `frame_own_len` where the hint has a
|
||||
fraction. A child asked in its slot afterwards pins the same frame through
|
||||
`in_parent` anyway, so the pin only matters for a caller that reads a hint
|
||||
and never asks the child. Confirm that reading is right, or delete the pin
|
||||
and say why.
|
||||
- A hinted share child with no room is never asked and `Span` calls
|
||||
`painter.undraw` on it, which is what takes its last frame's drawing down.
|
||||
A drawn-in-the-room child got the same call before; check nothing else
|
||||
relied on the room draw having happened first (`size_deps` records the
|
||||
hint read, and `draw_at`'s `asked` handles a dep that is not a child).
|
||||
- The two comments in `Span::draw` above the passes, and the `Stack` hint's
|
||||
comment.
|
||||
|
||||
Check: the ordinary suite, then `cargo test --release --test generated`.
|
||||
Anything the review changes in `Span` or `Painter` re-runs the three long
|
||||
fuzzers and the cold dump (commands under step 6).
|
||||
|
||||
### 2. Make the frame a length and the box a region
|
||||
|
||||
**Done at `1512d84` and `23523ee`.** The settled rule is in `docs/LAYOUT.md`
|
||||
under "Frames, decided boxes and padding"; what implementing it corrected in
|
||||
the plan -- four of them wrong layout that would have shipped -- is in
|
||||
`docs/LAYOUT_LOG.md`.
|
||||
|
||||
### 3. Render and replay
|
||||
|
||||
Done at `adbedaf` and re-run at `a888717`, against `e44dea3` (#18); what
|
||||
each render showed is in `docs/LAYOUT_LOG.md`. **Not yet run at `f6242aa`.**
|
||||
Render the five examples, the `tabs` replay and the `random` resize at the
|
||||
reviewed head and compare with the same renders at `a888717` (a
|
||||
`git worktree add ../iris-a888717 a888717` with its own target directory,
|
||||
one process at a time). Expected: byte-identical, every one -- the cold
|
||||
dump already says so for 400 random trees, but the fuzzer grows no `rel`
|
||||
rules and the examples do, so `text` is the render that can still differ.
|
||||
Any difference is a finding: read the records before touching anything.
|
||||
`view` has no counterpart in #18 and is compared with `a888717` only. The
|
||||
commands are under step 6.
|
||||
|
||||
### 4. Rename and delete
|
||||
|
||||
**Done at `a30971e`.** `ActiveData` and `DrawInfo` now say `part` for the box
|
||||
a widget was asked in, `asked` for the place it was asked at and `placed` for
|
||||
where its drawing was put; `LayoutHolds::frame` is `window`, since those
|
||||
ranges are window pixels and the frame's own entry is the `frame_len` pin
|
||||
beside them, and `Painter::frame_own` is `window_own`. `answers_at` had one
|
||||
caller and is inlined there. `ActiveData::measured` is kept: `place_in` reads
|
||||
it, and what it says -- the answer rather than the last drawing's report --
|
||||
is worth a name.
|
||||
|
||||
### 5. Restore the expected retained cost
|
||||
|
||||
**Done at `f6242aa`**, by one change: a span takes a child's length from
|
||||
its hint or rule and asks it once, in its slot, instead of drawing it in the
|
||||
measuring room first. Work counters, seeds 1 and 13, depth 8, widget draws /
|
||||
distinct widgets, beside `e44dea3` (#18) and `a888717` (before it):
|
||||
|
||||
| seed 1 | e44dea3 | a888717 | f6242aa |
|
||||
| --- | --- | --- | --- |
|
||||
| cold | 369/261 | 342/288 | 264/232 |
|
||||
| many | 157/95 | 118/95 | 41/41 |
|
||||
| size | 16/12 | 3/3 | 3/3 |
|
||||
| scroll | 2 | 1 | 1 |
|
||||
| resize | 13/13 | 44/13 | 36/13 |
|
||||
|
||||
| seed 13 | e44dea3 | a888717 | f6242aa |
|
||||
| --- | --- | --- | --- |
|
||||
| cold | 1330/707 | 1278/982 | 758/627 |
|
||||
| many | 524/159 | 429/366 | 16/16 |
|
||||
| resize | nothing | nothing | nothing |
|
||||
|
||||
The command is
|
||||
|
||||
```sh
|
||||
IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \
|
||||
--test layout_diagnostics -- --ignored --nocapture layout_cost
|
||||
```
|
||||
|
||||
reading `widget draws` and `distinct widgets` from each phase's block, and
|
||||
`hottest widget draws` with `IRIS_PHASE=resize IRIS_FRAMES=2` for who is
|
||||
drawn how often.
|
||||
|
||||
`many`, `size` and `scroll` are at their floor: every draw is a marked
|
||||
widget, or the parent a marked widget deferred to. `cold` is under #18 at
|
||||
both seeds. What is left is `resize` at seed 1, 36 draws of 13 widgets where
|
||||
#18 drew 13, and the `cold` draws over the distinct count (32 at seed 1,
|
||||
131 at seed 13). Both are one mechanism, understood and not yet worth a
|
||||
change: a **reported share** -- a span whose children report `leftover`, a
|
||||
stack sized by a child, a wrapper round either -- has no hint, so its parent
|
||||
still asks it in the room and again in its slot, and each of those asks
|
||||
draws, since the room drawing divided the room and does not hold for the
|
||||
slot. Under the root's resize redraw that multiplies down the tree: the
|
||||
hottest widget at seed 1 is a `Span` drawn 8 times in one frame, two
|
||||
levels of reported shares under it drawn 4. The design that would remove it
|
||||
is in `docs/LAYOUT_LOG.md` ("two answers per record"); do it only once an
|
||||
app screen shows the cost, and measure that screen first.
|
||||
|
||||
Two ideas from the previous version of this step were tested and are **not
|
||||
to be done**; the measurements are in `docs/LAYOUT_LOG.md`:
|
||||
|
||||
- Not reading the span's own length where no slot depends on it
|
||||
(`wip/inset` at `5b181bc`, pushed): identical counters at every phase.
|
||||
The symbolic pin survives a resize because every ask box is symbolically
|
||||
stable across one; the earlier "44 to 20" came from share spans reusing
|
||||
their room drawing in their slot, which is wrong layout rather than saved
|
||||
work.
|
||||
- Resolving a rule that is a fraction of the frame from the record instead
|
||||
of redrawing: `reuse outside: a frame length` is 3 draws of 264 cold at
|
||||
seed 1 and 1 of 758 at seed 13. Nothing to recover.
|
||||
|
||||
Report every phase at both seeds, work counters first, medians only when the
|
||||
work agrees.
|
||||
|
||||
### 6. Full verification and landing
|
||||
|
||||
Run, in the experiment checkout:
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo clippy --workspace --all-targets --features layout-diagnostics -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo test --release --test generated
|
||||
SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \
|
||||
cargo test --release --test shrink -- --ignored --nocapture
|
||||
IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \
|
||||
cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
|
||||
SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \
|
||||
cargo test --release --test shrink -- --ignored --nocapture
|
||||
```
|
||||
|
||||
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
|
||||
no temporary test body is needed any more. `Rng::new` uses `seed | 1`, so
|
||||
adjacent even/odd seed pairs describe the same tree. All of these passed at
|
||||
`f6242aa`; re-run whatever a later commit could affect.
|
||||
|
||||
A change that could move cold layout also gets the dump diff, which the
|
||||
oracle cannot replace:
|
||||
|
||||
```sh
|
||||
IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release --test layout_dump \
|
||||
-- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ ' > /tmp/after.txt
|
||||
```
|
||||
|
||||
once at the commit before and once after (check the earlier commit out in
|
||||
the same checkout so the build is incremental; the rig file is untracked
|
||||
there, copy it in with `git show wip/hint-first:tests/layout_dump.rs`), then
|
||||
`diff` the two. Zero differing lines is the expectation for a cost change.
|
||||
|
||||
Render `view`, `minimal`, `random`, `tabs` and `text` at 1920x1200 and inspect
|
||||
every intentional change. Also replay `tabs` and compare a live resize of
|
||||
`random` with a cold render at the same size. Read the installed graphics
|
||||
skill before rendering and confirm the renderer; an llvmpipe fallback can
|
||||
produce a plausible PNG. The headless rig reuses one compositor, so run one
|
||||
process at a time and give comparison worktrees separate target directories:
|
||||
|
||||
```sh
|
||||
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png
|
||||
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
|
||||
--resize 900x1200@60Hz --shot /tmp/resized.png
|
||||
./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \
|
||||
--replay /tmp/tabs.touch --shot /tmp/replay.png
|
||||
```
|
||||
|
||||
The reference replay is:
|
||||
|
||||
```text
|
||||
0 down 1728 24
|
||||
80 up 1728 24
|
||||
400 down 1836 1116
|
||||
480 up 1836 1116
|
||||
800 down 1836 1116
|
||||
880 up 1836 1116
|
||||
```
|
||||
|
||||
Before submitting, run the pre-submit review. Then land: move any surviving
|
||||
fact from `docs/LAYOUT_LOG.md` into `docs/LAYOUT.md` (the settled protocol,
|
||||
the measurement method including the dump rig, and the "not to be done"
|
||||
results under step 5), delete the log, update this handoff to the next
|
||||
actual task, and push every coherent commit. How the branch reaches
|
||||
upstream is Bryan's call and has not been asked: `wip/hint-first` is some
|
||||
sixty commits over #18's `split/18-position-chain`, which is still open, so
|
||||
either #18 lands first and this follows as one PR, or this replaces #18.
|
||||
Ask before opening anything. Update the app's Iris pin only when the Iris
|
||||
change is ready.
|
||||
|
||||
## Follow-on work, not part of this repair
|
||||
|
||||
- CPU round-to-nearest and shader nearest-pixel snapping are approved as one
|
||||
separately verified change. Neither has landed. Re-derive `Holds::through`
|
||||
for the new rounding and run both long fuzzers plus the render set.
|
||||
- Smaller layout items remain in `docs/LAYOUT_LOG.md`: an undrawn share's
|
||||
gap, nested share weights, inconsistent zero-divisor fallbacks, and the
|
||||
stale `f32` identity comment.
|
||||
| shrinker, 400 seeds, depth 5, sixteen cases | agree, 60 s |
|
||||
| 1000 seeds at depth 6 | agree, 177 s |
|
||||
| 2000-seed depth-4 scan, sixteen cases | agree, 295 s (82,203 widgets) |
|
||||
| cold dump of 400 trees at depth 5, diffed against `f6242aa` | identical, 34,488 widget boxes |
|
||||
| counters, seeds 1 and 13 at depth 8 | unchanged from `f6242aa` (the table is in `docs/LAYOUT.md`) |
|
||||
| five renders, the `tabs` replay, a live resize against a cold render | all byte-identical to `a888717` |
|
||||
|
||||
The renderer was Vulkan on the host's RX 7900 XT: `src/default/render.rs`
|
||||
asks for `Backends::PRIMARY` and `.expect()`s the adapter, so a PNG at all
|
||||
means it was not the software fallback.
|
||||
|
||||
## What is next, in order
|
||||
|
||||
1. **Bryan's review of #19.** Fixes to it are unreviewed code: repeat the
|
||||
`pre-submit-review` passes over whatever each round changes, and re-run
|
||||
the three fuzzers and the dump diff for anything that touches `Span`,
|
||||
`Painter` or `render_state`.
|
||||
2. **The app's Iris pin**, once #19 lands. It is still on the old submodule
|
||||
commit, and the branch changes API the app uses: `SizeRule` beside a
|
||||
widget rather than a wrapper, alignment as a widget property, `rest`
|
||||
renamed to `leftover`, `OrthoSize` gone.
|
||||
3. **Round-to-nearest**, CPU and shader together as one verified change.
|
||||
Bryan approved it on 2026-09-17 and neither half has landed; the
|
||||
derivation, the form to use and what to re-check are in `docs/LAYOUT.md`
|
||||
under "Rendering the grid (pending)".
|
||||
|
||||
## Smaller layout items, none urgent
|
||||
|
||||
- An undrawn `leftover` child still contributes its gap, so a vanished child
|
||||
leaves a double gap.
|
||||
- Nested spans pass `leftover` weight up, so three leftover children in one
|
||||
inner span beside one in another get three quarters to one quarter. No
|
||||
other layout system does that; confirm it is wanted.
|
||||
- A span can overflow itself without bound, so boxes of negative length reach
|
||||
children and nothing states what a widget may assume about one.
|
||||
- `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`; both
|
||||
are caller bugs under `debug_assert`, but the fallbacks differ.
|
||||
- The comment on the `local == UiRegion::FULL` shortcut in `widget_at` says
|
||||
composing through `FULL` "is not quite the identity in f32"; on the grid it
|
||||
is exact and the shortcut is performance only.
|
||||
- `docs/LAYOUT.md` §4, §5 and the density section name `Painter::place`,
|
||||
`SetSize`, `desired_width`, `apply_rest`, `Len::dp`, `Aligned` and
|
||||
`MaxSize`, none of which exist. Do not restore `OnResize::Translate` or
|
||||
`OrthoSize`.
|
||||
- `LazySpan`, then `SizeRule::{Min, Max, Clamp}`. A cap may not contain
|
||||
`leftover`; whether `Max` narrows the child's drawing box is still a real
|
||||
product decision.
|
||||
`leftover`; whether `Max` narrows the child's drawing box is a product
|
||||
decision.
|
||||
- `Scroll` taking a direction rather than one axis.
|
||||
|
||||
Other product work remains in `docs/PLAN.md` and the focused documents it
|
||||
links. Do not mix it into the Iris layout branch.
|
||||
Other product work is in `docs/PLAN.md` and the focused documents it links.
|
||||
Do not mix it into the Iris layout branch.
|
||||
Reference in new issue
Block a user