Files
ai-app/docs/HANDOFF.md
T
iris-ai 2fa734cd63 Record what the step 1 review found
The pre-submit review of the six one-ask commits found a scroll placing
content that fits into a window-length box rather than its viewport, and
three comments still calling window lengths frame lengths.
2026-09-19 01:24:22 -04:00

430 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 things stand
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.
The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch
**`wip/one-ask`** at **`a888717`**, eight commits over `4328eac` (the head of
`wip/transparent-frames`, which is unchanged). It replaces the old step 3
plan with the one-ask protocol below, `1512d84` and `23523ee` make the frame
a length of the window, and `e8a5792` is what the step 1 review found. It
passes every check:
| check at `a888717` | 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 --release --test generated` | 11/11 |
| shrinker, 400 seeds, depth 5, all sixteen cases | agree, 73 s (34,488 widgets) |
| 1000 seeds at depth 6 | agree, 187 s |
| 2000-seed depth-4 scan, all sixteen cases | agree, 347 s (82,203 widgets) |
| renders, the `tabs` replay and the `random` resize against #18 | inspected, see below |
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 retained-cost work (step 5). Step 1's review is done and what
it found is in `docs/LAYOUT_LOG.md`.
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 `offer_part` as the ask box, `offer_place`
as where it was asked and `place` as where it was put; the names are the old
ones and should be renamed (`part`, `asked`, `placed`) when this lands.
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` asks every child once from its cursor (`Within(From(cursor..far))`),
then moves fixed children to their slots and asks share children once more
in their decided slot with the frame narrowed to it:
```rust
let slot = along(from, start);
let place = axis.pair(Place::Fill(Part::From(slot)), across);
let used = match len.leftover > Weight::ZERO && shares {
true => {
let mut narrow = [None; 2];
narrow[axis as usize] = Some(slot.len());
painter.widget_at(child, narrow, place).len(!axis)
}
false => {
painter.place_at(child, place);
size.axis(!axis)
}
};
```
`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/one-ask` from
`a30971e`. 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
this handoff rather than papering over it.
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 six commits
**Done at `a888717`.** The pre-submit review over the six commits as one
diff against `4328eac` found one wrong layout -- a scroll placing content
that fits into a window-length box rather than the viewport -- and three
comments left describing lengths as fractions of the frame. Both are in
`docs/LAYOUT_LOG.md`; the fix is `e8a5792`, pinned by
`scroll::content_that_fits_is_placed_in_the_viewport_and_not_in_the_window`.
### 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`**, against `e44dea3` (#18); what each render showed is
in `docs/LAYOUT_LOG.md`. `view` has no counterpart in the baseline, so it
was rendered but not compared. Run the set again after any further change
here -- the commands are under **Full verification** below, and a text
placed by re-expression rather than by a second draw is what shows first.
### 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
Work counters, seed 1 and 13, depth 8, widget draws / distinct widgets,
beside `e44dea3` (#18) and `0ef87eb` (before the frame became a length):
| seed 1 | e44dea3 | 0ef87eb | adbedaf |
| --- | --- | --- | --- |
| cold | 369/261 | 331/288 | 342/288 |
| many | 157/95 | 110/92 | 118/95 |
| size | 16/12 | 3/3 | 3/3 |
| scroll | 2 | 1 | 1 |
| resize | 13/13 | 40/15 | 44/13 |
| seed 13 | e44dea3 | 0ef87eb | adbedaf |
| --- | --- | --- | --- |
| cold | 1330/707 | 1179/982 | 1278/982 |
| many | 524/159 | 424/364 | 429/366 |
| resize | nothing | nothing | nothing |
`many`, `size` and `scroll` are better than #18 and within a few draws of
`0ef87eb`; `cold` is 3% and 8% more than `0ef87eb` for the same distinct
widgets, which is the frame pins making a widget answer again where it used
to be reused on a box that happened to match. `resize` at seed 1 still draws
44 times where #18 drew 13.
Measuring this is what found `adbedaf`: the fuzzer's own `Branch` pinned the
window rather than saying which side of its threshold it was on, which put
seed 1's resize at 131 and seed 13's at 828. A fixture that redraws
everything on a resize cannot tell a change that reuses well from one that
does not, so check the fixture before believing a regression.
Three mechanisms behind what is left, all understood:
- **A share child is asked twice per span draw** -- in the measuring room
with the frame forwarded, then in its slot with the frame narrowed. Each
ask that reads pixels or pins a length draws, and since `0ef87eb` every
local change inside a share child redraws its span as well. Give `Span` a
measure-only ask for the first pass: reuse the retained *answer* when its
holds contain the room, without validating or relocating the drawing, and
let the placing ask settle the drawing. The answer contract must then
carry no symbolic pin (a span's total does not depend on `far`; only its
slots do), which is the separation the worker's experiment made with
`answer_extent_len`. With that, a twice-asked child could keep both
answers and settle locally by re-asking both questions instead of
deferring. Measure `many` at seed 13 before and after; the seed 946 pin
must stay green throughout.
- **A positive-direction span with no shares pins `far`** it does not need,
so a resize redraws it. Read `extent_len` only where a slot depends on it
(shares, or `Sign::Neg`), and express the measuring room's far end without
the length. Measure `resize` at seed 1 before and after.
- **A rule that is a fraction of the frame pins the frame**, which is what
`cold` grew by. The answer for such an axis is a pure function of the rule
and the frame, so a reuse could resolve it again from the record instead
of redrawing -- `placed_extent` already takes `declared`. Worth trying
before anything subtler; the pin stays for the axes that read the frame.
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.
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. Once the protocol lands, move
any surviving fact from `docs/LAYOUT_LOG.md` into `docs/LAYOUT.md`, delete
the log, update this handoff to the next actual task, update the app's Iris
pin only when the Iris change is ready, and push every coherent commit.
## 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.
- `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.
- `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.