Replace the step 3 plan with the one-ask protocol and record why it failed

The handoff now describes wip/one-ask at 3091fb8: a widget draws once in
the box it is asked in, its answer is placed by re-expressing the drawing,
and nothing is drawn again in a box an answer chose. The log records what
the step 3 plan got wrong -- it kept the second draw whose measurement
bit could not be defined, demanded a contract of the answer box, and
narrowed frames by region -- and what fuzzing the new protocol found: the
Part::Of composition dropping a pinned length, and a local redraw that
must be put back in the parent's answer box. The Pad frame rule is left
as a decision for Bryan with its cost stated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-18 18:23:49 -04:00
1 parent 39978ff8ea
commit ef324aa88b
3 files changed
+370 -319

No files matched your search

+255 -298
View File
@@ -4,32 +4,33 @@ 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 transparent frames lands, after moving any fact that must survive.
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` (the
untracked `target-own/` there is its build output). It is the reviewed
baseline this work must preserve or improve. `/home/bob/repos/iris-pr18` has
since moved to a different WIP branch; do not use that checkout as #18.
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`, branch
`wip/transparent-frames`, committed head **`4328eac`**, five commits over
**`34cafb6`**, plus an uncommitted step 3/4 experiment. It implements
transparent frames, pins the two open failures, and corrects `Scroll`'s
content length, but it is not ready to replace #18:
The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch
**`wip/one-ask`** at **`3091fb8`**, one commit over `4328eac` (the head of
`wip/transparent-frames`, which is unchanged). It replaces the step 3 plan
below with a smaller protocol change, and it passes every fast check:
- the uncommitted experiment passes both focused regressions and the 114-test
suite, but every non-ignored fast-oracle case stops on a cold-layout
placement assertion, usually at `Text`;
- `many` and `resize` still do more work than #18 on a deep tree;
- step 3 exposed a widget-contract question that must be planned before the
retained protocol can change; see **Step 3 stop** below.
| check at `3091fb8` | result |
| --- | --- |
| `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean |
| `cargo test --workspace` (debug) | 114 suite, 20 core, 11 generated, all green |
| the two decided-box pins from step 1 | **pass** (red at `4328eac`) |
| `cargo test --release --test generated` | 11/11 |
| shrinker, 400 seeds, depth 5, all fifteen cases | agree, 56 s |
| 1000 seeds at depth 6, and the 2000-seed depth-4 scan | see `docs/LAYOUT_LOG.md`; they were running when this was written |
The app's Iris pin is unchanged. Keep the experiment's uncommitted changes as
evidence; do not commit them as the protocol until the stop below is resolved.
The worker's uncommitted 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
@@ -42,314 +43,287 @@ These outrank the accumulated machinery:
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. The investigation tried those
shapes and found that the protocol was asking an unanswerable question.
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 `3091fb8`:
```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, frame, 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.reposition(id, frame, 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 parent's frame, never a region, and
is put back into the part 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 in the box the sizing child decided, with
the frame narrowed to it on every axis that is not a share, 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 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.
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 second measure method would
duplicate layout and drift from drawing; a shared helper would merely move
that obligation without removing it.
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. Measuring asks may be
provisional while a parent is dividing room. Once the parent decides a slot,
the child is evaluated in that slot; placing an answer is reuse or translation,
not another `draw_at` in an answer-derived box.
The current `offer` bit cannot express this. It is derived from
`place == offer_place`, but one parent draw can evaluate a child in the
parent's room, in a slot the parent decided, and in a box derived from the
child's own answer. The fresh answer is right in some of those boxes and a
retained answer in others. Remove the question rather than adding state that
tries to answer it.
The target retained model has one answer per widget. Its `Holds` contract says
which parts it remains valid for. `redraw` re-asks it in the part of its last
parent ask. The offer machinery can then go: answer gating, `offer_place`,
`offer_part`, `at_offer`, and `measured()`.
Keep `Part::Of`. It expresses a part of a widget's own box without making the
container read that box's length, and was a sound addition to the experiment.
Its dropped `extent_len` pin exposed seed 2, but composing every such pin
through `Of` is not the repair: that experiment broke seed 220 and the
region-node regression.
`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
Containers that only divide room are transparent: absent a length decision,
they forward the parent's frame. A declared `px` or `rel` length narrows the
child's frame. **A resolved `leftover` share narrows it in exactly the same
way.** The code's exclusion of `leftover` in `declared_lens` is a bug.
A declared `px` or `rel`, a resolved share, and (new, planner's choice, not
yet Bryan's) the box a stack's sizing child decided 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.
A share is known only after the deciding span has measured fixed children and
divided its room. The measuring ask therefore cannot settle a `leftover`
child's frame. The placing ask supplies the resolved share as both its slot
and narrowed frame, and fully evaluates the child there. This matters across
the span too: a wrapping child's height may change once its width share is
known, so the span reads that child's cross-axis answer from the decided-box
evaluation, not from the provisional one.
### `Pad` remains an outset -- and is not yet what was decided
A box a widget merely reports does not narrow its descendants' frames. The
frame changes because a parent decided a declared length or a share, not
because the child's own answer happened to have that length.
Bryan: padding goes outside what it pads; the pad forwards its frame less the
padding and draws the child inside that area, so `rel(1.0)` inside `.pad(16)`
inside a 450 px share is 418 px. **The experiment does not implement this.**
`Pad` still forwards the frame whole and insets only the box:
### `Pad` remains an outset
```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),
)))
};
```
Padding goes outside what it pads. There is no mixed "outset pixels, inset
fractions and shares" `Pad`.
so `rel(1.0)` there is 450 and overflows by the padding -- the clipped
`text.rs` render. The decided rule cannot be written in the current
representation without a cost Bryan has not seen:
The clipped `examples/text.rs` render is evidence of the unresolved-share
bug, not intended behavior and not a reason to replace `rel(1.0)` with
`leftover`. If a 900 px row gives a padded child a 450 px share, that share
narrows the pad's frame; after 16 px padding on each side, `rel(1.0)` inside
the pad is 418 px and fits. Keep the example so it verifies that rule.
- Narrowing the child's frame by the padding (`narrow = FULL - 32px`) makes
that length the child's *box* too, since a narrowed frame is its own box.
In a share or a declared box, frame and box coincide and the rule holds.
As a fixed child of a span measured from its cursor, the padded child is
then asked in `row - 32` rather than `room - 32`: a padded wrapped text
after a 24 px icon wraps as if the icon were not there and overflows the
row by 24 px. That row is the commonest thing in the app.
- Keeping `Part::Of` (status quo) keeps the text wrapping in the room and
makes `rel(1.0)` under a pad mean the whole frame.
- Having both -- `rel` of the frame less padding *and* a box that is the
room less padding -- needs the frame to be a length and the box a region in
the parent's coordinates, with `Part::From` scaling its span by the frame
length. That is a coordinate rewrite of `painter.rs`/`render_state.rs`, not
a widget change.
Ask Bryan which. Do not decide it in a worker session.
### A share never adds room beyond the deciding box
`Scroll` currently calls `apply_leftover`, which turns any `leftover` in the
content answer into a whole additional viewport. A row reporting `600 px +
leftover` in a 900 px viewport therefore gets a 1500 px content box, its text
re-wraps in room it was not measured in, and layout advances one fixed-point
iteration each time it is evaluated.
That behavior is wrong. A share takes the room left in the viewport. Resolve
scroll content from only the fixed part of the answer and make it at least the
viewport:
```rust
let fixed = Len::from_parts(answer_len.rel, answer_len.px).to_px(container_len);
self.content_len = fixed.max(container_len);
```
For `600 px + leftover` this is 900 px: the share receives 300 px and nothing
scrolls. Pixel content still scrolls when its fixed length exceeds the
viewport. A lone share fills the viewport. The content box no longer invents
new room, so `Scroll` is not an own-answer-box exception to the decided-box
rule.
`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
The fixed-point grid, `Holds::through`, the pixel box threaded down the draw,
region nodes, and the warm/cold equality rule are sound. Their durable
invariants are in `docs/LAYOUT.md`. Transparent frames fixed fraction
resolution; the remaining defect is evaluating container bodies in multiple
boxes and trying to infer which evaluation counted as measurement.
## Why the open seeds fail
The complete traces, shrunk trees and counter-experiments are in
`docs/LAYOUT_LOG.md`. The short version a worker needs is:
- **Seed 2 (`repaint`)**: a stack measures a non-sizing subtree in room the
stack will never have, then reuses that answer in the stack's one-line box.
A dropped `Part::Of` pin makes the reuse look valid. Composing that pin fixes
this seed but breaks seed 220 and an existing region-node test, so it is not
the fix.
- **Seed 108 (`reorder`)**: a nested span correctly evaluates a branch in its
final 300 px box, but `draw_inner` discards the fresh 286 px answer for a
retained 438.9 px answer from an earlier 450 px evaluation because the
place expression changed. Always keeping the fresh answer fixes this seed
but breaks four seeds and two tests under `Scroll`.
- Those `Scroll` failures are the `apply_leftover` feedback loop above. Once
that loop is removed, no legitimate container needs to lay children out in
a box derived from its own answer.
The worker faithfully implemented the earlier plan, tried four definitions
of "measurement", restored the safe deferral when each failed elsewhere, and
stopped. Do not resume that search.
Unchanged; see `docs/LAYOUT.md`.
## Implementation plan
Work in `/home/bob/repos/iris-layout-experiment` from `49cec82`. 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.
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
before the next. If a step exposes a different mechanism, stop and update
this handoff rather than papering over it.
### 1. Pin the two failures as focused tests
### 1. Read the long fuzzer results
**Done in `b842e4f`.** The named tests reproduce the mismatch at `49cec82` and
remain intentionally red until the protocol repair:
`docs/LAYOUT_LOG.md` records the 1000/6 oracle and the 2000/4 scan at
`3091fb8` if they finished. If either found a seed, shrink it first
(`SHRINK_SEED=<seed> SHRINK_DEPTH=<depth> SHRINK_CASE=<case>`), pin it as a
named test in `tests/cases/unsettled.rs`, then fix it under the rule above.
Do not add a second draw back.
- `unsettled::repainting_a_stack_uses_the_box_its_sizing_child_decided`;
- `unsettled::reordering_nested_spans_keeps_the_answer_from_the_decided_box`.
### 2. Render and replay
Turn the shrunk seed 2 and seed 108 trees from `docs/LAYOUT_LOG.md` into fast,
named regression tests. Each must demonstrate the present warm/cold mismatch
at `49cec82`, then pass because both paths select the same tree and boxes—not
because the assertion was weakened.
Read the installed graphics skill and confirm the renderer. Render `view`,
`minimal`, `random`, `tabs` and `text` at 1920x1200 against `34cafb6` and
`e44dea3`, replay `tabs`, and compare a live resize of `random` with a cold
render at the same size (commands under **Full verification** below). A
text placed by re-expression rather than a second draw is the change most
likely to show here; inspect every intentional difference and record it.
Also retain these nearby regression tests while changing the protocol:
### 3. Rename and delete
- `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`
- `unsettled::a_span_given_the_box_its_answer_decided_matches_a_cold_layout`
- the seed 86 `Scroll` fixed-point case
- the tests for a length in pixels staying that many pixels and for an exact
leftover split
Rename `ActiveData::offer_part` to `part`, `offer_place` to `asked`, `place`
to `placed`, and `DrawInfo` likewise; delete `ActiveData::measured` in favour
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
the probe's diff readable; do this as one mechanical commit. Suite, oracle.
Check the ordinary suite and each new test individually.
### 4. Restore the expected retained cost
### 2. Correct `Scroll`'s content length
Work counters at `3091fb8`, seed 1 and 13, depth 8, widget draws / distinct
widgets, beside `e44dea3` (#18) and `49cec82` (the branch head before this):
**Done in `4328eac`.** The four focused cases pass, as do the release fast
oracle and the depth-5 counterexample seeds 184, 246, 292 and 372. The debug
suite has 112 passing tests and only the two intentionally red tests above.
| seed 1 | e44dea3 | 49cec82 | 3091fb8 |
| --- | --- | --- | --- |
| cold | 369/261 | 516/288 | 331/288 |
| many | 157/95 | 187/119 | 78/78 |
| size | 16/12 | 3/3 | 3/3 |
| scroll | 2 | 1 | 1 |
| resize | 13/13 | 24/76 | 40/15 |
Replace its `apply_leftover` content sizing with the fixed-part calculation
above. Add focused cases for:
| seed 13 | e44dea3 | 49cec82 | 3091fb8 |
| --- | --- | --- | --- |
| cold | 1330/707 | 2940/982 | 1179/982 |
| many | 524/159 | 1091/423 | 335/333 |
| resize | nothing | 2215/510 | nothing |
- `600 px + leftover` in 900 px resolves to 900 px;
- fixed content wider than the viewport still scrolls;
- a lone `leftover` child fills without scrolling;
- the wrapping-text-plus-share case is stable warm and cold.
`many` and `size` are better than #18 at seed 1 and `many` draws fewer times
at seed 13, but it touches twice as many distinct widgets there, and `resize`
at seed 1 draws 40 times where #18 drew 13. Two mechanisms, both understood:
The seed 86 test stays until the full protocol has landed, even if its old
special rule becomes moot.
- **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. 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`. Measure `many` at seed 13 before and after.
- **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.
Check the suite, fast oracle, and the known Scroll counterexamples from the
fresh-answer experiment (seeds 184, 246, 292 and 372).
Report every phase at both seeds, work counters first, medians only when the
work agrees.
### 3. Evaluate children in parent-decided boxes
**STOP: this step needs planning before implementation continues.** The first
17 suite failures were not sufficient evidence for stopping: most were test
fixtures that encoded the old paired-draw cost while their empty drawing used
only the answer. The experiment now separates answer-only pixel reads from
drawing reads, keeps answer and final-drawing contracts independent, asks
`Span` children provisionally with `Fill`, re-asks them in their decided
slots, narrows resolved-share frames, and re-asks local redraws at the
question their answer came from. It also fixes two real defects found along
the way: an overfull span falsely pinned itself to `far`, and a narrowed share
frame omitted a nonzero parent-extent start.
That version passes the full 114-test suite, both focused regressions, the
region-node test, the one-pixel chain, and the wrap-at-remaining-room test.
The tests were checked individually before migration; geometry assertions
were retained, and drawing-dependency fixtures still use ordinary `px_len`.
The release fast oracle then stopped in all eleven non-ignored cases during
*cold layout*, before any warm/cold comparison. The repeated shape is a leaf,
usually `Text`, asked provisionally in a `Within` box, reporting a smaller
answer, whose retained drawing contract covers only the provisional pixel
width. Placement into the reported box therefore hits the new
reuse-or-translate assertion. One representative assertion shows `Text` with
an X part of `FULL`, a centered answer span about 89.5 px wide, and a drawing
extent contract pinned at 45 px. A generated `Stack` also fails after
reporting a 178x21 px answer from `FULL` while retaining an `extent_len` pin
to `FULL`.
This is a real public-contract boundary, not a stale oracle: decide whether
every leaf as well as every container must guarantee that its provisional
drawing holds in the answer box it reports, and specify how `Text` establishes
that guarantee, or allow some answer-derived leaf evaluation while keeping
containers out of their own answers. The latter is a semantic distinction the
current `Widget` API does not express. Do not weaken the assertion, special
case `Text`, or resume the oracle until this choice is written into the plan.
Change placement so an answer-derived box never runs a container body.
Placing becomes reuse-or-translate. Remove the offer/measurement gate and
its retained bookkeeping only as each caller stops needing it; do not leave
a parallel old path.
Three current widgets must stop depending on measuring boxes they will never
own:
- `Span`: do not read `extent_len` unconditionally. Slots depend on `far`
only when shares exist (the decided slot fills its part) or for negative
direction; compute negative-direction slots from `total`. Pin the extent
length only in those cases.
- `Stack`: draw non-sizing children in `From(0..size)` on an axis where the
sizing child's answer is `px`/`rel`, and `All` where it is `leftover`,
instead of drawing them in `All` of the measuring room.
- `Branch` in the random rig: express "the rest of my box" as
`Of(40px..FULL)` rather than reading `extent_len(Y)`.
Every drawing must hold for the answer box it supplies. The two focused tests
from step 1 and the existing region-node and decided-box tests must pass here.
### 4. Make resolved shares narrow frames
Give a `leftover` child its resolved slot as its narrowed frame at the placing
ask. A span becomes a decided two-pass layout:
1. measure fixed children and collect share weights;
2. divide the deciding box's remaining room;
3. place/evaluate each child in its decided box, with a share child's frame
narrowed to that share;
4. derive the span's cross-axis answer from those decided evaluations where a
child's answer can depend on its share.
Do not put `leftover` back into a declaration helper before it has a resolved
length; unlike `px` and `rel`, its frame cannot be known during the first pass.
Add tests that a `rel(1.0)` child directly inside a half share is half the row,
and that the same child inside `.pad(16)` is the share less 32 px. The existing
`examples/text.rs` case should render inside its padding without changing its
width rule.
### 5. Remove obsolete machinery and settle the retained path
Once all callers use the decided-box path, delete answer gating,
`offer_place`, `offer_part`, `at_offer`, `measured()`, and the local-redraw
deferral whose only purpose was distinguishing measurement from placement.
Write `ActiveData::answer` in one place, and keep `DrawInfo` on `ActiveData`
rather than copying fields and reconstructing it in `redraw`.
Run the ordinary suite, fast oracle and shrinker before doing performance
work. Both new focused tests must pass on cold, repaint and reorder paths.
### 6. Restore the expected retained cost
Implement the known-length `Span` shortcut only after correctness is stable:
a `px` child after a `px` child should draw once cold and never on repaint.
Report cross-axis sizes from the decided evaluation, including wrapping
share children. Compare all six diagnostic phases with `e44dea3`; investigate
work-counter differences before interpreting time.
Expected direction, not a license to weaken correctness: `size` and `scroll`
keep their wins, `many` approaches #18's distinct-widget counts, and `resize`
returns to about #18's 13 draws at seed 1. Record final counters in the
temporary log and durable conclusions in `docs/LAYOUT.md`.
### 7. Full verification and landing
### 5. 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
```
Then repeat the 2000-seed depth-4 scan over all fifteen cases. It is the only
run that found seeds 1121 and 1839 before their fix; depth and breadth find
different defects. `Rng::new` uses `seed | 1`, so adjacent even/odd seed
pairs describe the same tree. The temporary scan target used this body and
was deleted after the run:
```rust
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());
}
}
});
```
Run all six `layout_diagnostics` phases at seeds 1 and 13, depth 8, against
`e44dea3`. Compare work counters first; use medians only after the work is the
same. Check that `UiSpan::within` still inlines with `nm`.
The last line is the 2000-seed depth-4 scan over all fifteen 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
@@ -377,35 +351,18 @@ The reference replay is:
880 up 1836 1116
```
Before submitting, run the pre-submit review. Once transparent frames 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.
## Verification already performed
At `49cec82`: formatting, warning-clean clippy, the debug suite (108 suite
tests and 20 core tests), the 11 generated cases, and all six diagnostic
phases ran. The 400-seed depth-5 shrinker fails at seed 2 (`repaint`) and 108
(`reorder`); the long 1000/6 oracle and 2000/4 scan were intentionally not
run after that. Reference renders against `34cafb6`: `view` and `minimal`
were byte-identical; `tabs` differed by 2,332 pixels; `text` exposed the
unresolved-share bug; `random` moved where nested spans do.
At `e44dea3`, the #18 baseline: format, clippy and workspace tests passed;
the release oracle at 100 seeds, debug oracle at 120 seeds, all fifteen
shrinker cases at 400/5, the oracle at 1000/6, and the 2000/4 scan passed.
The five reference renders and the `tabs` replay were byte-identical to their
baseline, and a live-resized `random` matched a cold render. These statements
describe those commits only; rerun them after changing layout.
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
- The `Pad` decision above, and the coordinate rewrite if Bryan wants both
halves of the rule.
- 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.
- Test-only `Inset` and `Outset` can demonstrate their semantics after the
protocol is stable. `Pad` itself remains an outset.
- 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.
+9 -5
View File
@@ -326,11 +326,15 @@ inside that area, and reports the child's used size plus padding. A
share less that padding. The mixed "outset pixels, inset fractions and
shares" interpretation is rejected.
The current experiment still redraws some widgets in boxes derived from their
own answers. That is the open protocol defect, not a design invariant. The
target in `docs/HANDOFF.md` evaluates container bodies only in boxes a parent
offered or decided; placing an answer reuses or translates its drawing rather
than running the body in an answer-derived box.
A widget draws once, in the box it is asked in; its answer is placed inside
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
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
never runs the body. A frame is narrowed by a length of the parent's frame,
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; whether
`Pad` narrows the frame by its padding is still open in `docs/HANDOFF.md`.
## Layout decisions and invariants (2026-09-15 to 2026-09-17)
+106 -16
View File
@@ -7,9 +7,91 @@ 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
are in `/home/bob/repos/iris-layout-experiment` unless said otherwise.
## The two open shrinker seeds on `wip/transparent-frames` (planner, 2026-09-18)
## What the one-ask protocol found (planner, 2026-09-18, third session)
Both are the rule in `draw_inner` that decides which answer places a widget's
Branch `wip/one-ask` at `3091fb8` over `4328eac`. The change is described in
`docs/HANDOFF.md`; this is what building and fuzzing it turned up, in order.
- **The step 3 assertion was the wrong check.** At the worker's stopping
point every generated case stopped on `assert_eq!(extent, info.part)` in
`place`, on a `Text` asked at 45 px in `Within(All)` whose longest word is
89.5 px: its drawing holds for `[45, 45]` and its answer box is 89.5 px
wide. No leaf can promise its drawing holds for a box chosen from its
answer; the plan's sentence "every drawing must hold for the answer box it
supplies" was the defect, not `Text`. Removing the check *and the redraw
it guarded* -- placement is re-expression, unconditionally -- made the
suite green including both decided-box pins, with nothing else changed.
- **Six retained tests encoded the paired draw** (`settled + 2`, "drawn
once" expecting two): they now expect one draw. Two more failed because
`Stack` had been changed to ask non-sizing children with `Fill(All)`; they
need `Within(All)` so a smaller answer sits inside the stack, with the
frame narrowed to the sizing answer. That is the whole of the suite churn.
- **Seed 2 at depth 4 (`repaint-some`, `region-node`)** shrinks to a `Stack`
sized by one text. A local redraw of the text asked it again in the box
the stack was *asked* in and left it filling that; cold puts it in the box
the stack's *answer* chose. A local redraw must put the fresh drawing
back at the retained place of the parent's answer box, always, not only
when the asked and placed places differ.
- **Six seeds at depth 5** (60 `repaint`, 308 `every-size`, 20
`region-node`, 248, 384, 162 `reorder`) shrink to one shape: a zero `Pad`
round a widget reading `extent_len` (a span with a share rect, or a
`Branch`), as a share child of a span beside a fixed sibling. Seed 248
reproduced: **cold** was wrong. In the measuring pass the inner span read
`far = rel 1 - 17.6px` and wrote slots in it; in the placing pass the pad
got a narrowed frame of the same pixel length, was reused (its contract
had no pin, because `in_parent`'s `Part::Of` arm drops a child's
`extent_len`), and its child was re-expressed into a frame where `rel 1`
means 17.6 px less. The inner span's own record had the pin; only the
composition lost it. Composing the pin through `Of` -- exactly where the
part is the whole box less pixels, `pinned - part.px`, and pinning the
parent's own length otherwise -- fixed all six; the planner's earlier
version of the same experiment broke seeds under the old protocol because
the second draw was still there.
- Cost after the fix (widget draws / distinct widgets, depth 8): seed 1
cold 331/288, many 78/78, size 3/3, scroll 1, resize 40/15; seed 13 cold
1179/982, many 335/333, resize nothing drawn. The table with the baselines
is in the handoff. `many` at seed 13 touches 333 distinct widgets because
a share child is asked twice per span draw, and `resize` at seed 1 draws
40 because positive-direction spans without shares pin `far`; both are
step 4 there.
- The 1000-seed depth-6 oracle and the 2000-seed depth-4 scan were started
at `3091fb8` and had not finished when this was written; the next session
reads `/tmp/oracle-1000-6.log` and `/tmp/shrink-2000-4.log` if they still
exist, or reruns them.
## What the step 3 plan got wrong (planner, 2026-09-18, third session)
Three things, each a different kind of mistake:
1. **It kept the question the machinery could not answer.** The offer bit
existed because a widget was drawn twice, once where asked and once where
its answer placed it, and something had to say which draw was the
measurement. Both plans of 2026-09-18 tried to define that bit better
(the worker's four rules, the planner's two experiments). The repair is
to have one draw, after which the bit has nothing to name. A plan that
proposes bookkeeping for a distinction should first ask whether the
distinction has to exist.
2. **It stated the contract at the wrong box.** "Every drawing must hold for
the answer box it supplies" reads as a tightening and is actually
unsatisfiable for any widget that reads its box. The right statement is
the opposite: the answer box is never a question, so nothing is demanded
of the drawing there; `Holds` is about the ask box only.
3. **It narrowed frames by region.** The worker's share frames were
`extent_part(axis, From(span))`, a position in the span's frame; a frame
given as a position does not move when its part moves, and `re_ask` had
no way to put it back. A narrowed frame must be a *length* of the
parent's frame, placed into the part by alignment on every placement,
exactly as a declared rule already was.
The requirements did change under the plans (transparent frames, then
shares narrowing frames, then `Scroll`'s content length), which explains
some churn but not the three above; those were reasoning errors that a
five-widget reproduction with the retained records printed would have
caught in minutes. Print the records before proposing the next rule.
## The two shrinker seeds that were open on `wip/transparent-frames` (planner, 2026-09-18) -- closed by `wip/one-ask`
Both were the rule in `draw_inner` that decided which answer places a widget's
box: `measured = if info.offer() { fresh } else { retained }` with
`offer() := place == offer_place`. That bit is meant to say "this ask is a
measurement" and has no consistent value once a container's body runs in
@@ -169,20 +251,24 @@ at depth 8; draw counts are deterministic so these are single runs.
`e44dea3` is #18's head, `34cafb6` the commit the branch starts from,
`49cec82` its head.
| seed 1 | e44dea3 | 34cafb6 | 49cec82 |
| --- | --- | --- | --- |
| cold | 369/261/10.6 | 463/274/13.3 | 516/288/12.0 |
| repaint | 1 | 1 | 1 |
| many | 157/95/0.33 | 263/108/0.59 | 187/119/0.52 |
| size | 16/12/0.018 | 3/3 | 3/3/0.010 |
| scroll | 2/0.002 | 1 | 1/0.004 |
| resize | 13/13/0.019 | 22/15/0.032 | 24/76/0.090 |
| seed 1 | e44dea3 | 34cafb6 | 49cec82 | 3091fb8 |
| --- | --- | --- | --- | --- |
| cold | 369/261/10.6 | 463/274/13.3 | 516/288/12.0 | 331/288/12.3 |
| repaint | 1 | 1 | 1 | 1 |
| many | 157/95/0.33 | 263/108/0.59 | 187/119/0.52 | 78/78/0.13 |
| size | 16/12/0.018 | 3/3 | 3/3/0.010 | 3/3/0.008 |
| scroll | 2/0.002 | 1 | 1/0.004 | 1/0.001 |
| resize | 13/13/0.019 | 22/15/0.032 | 24/76/0.090 | 40/15/0.096 |
| seed 13 | e44dea3 | 49cec82 |
| --- | --- | --- |
| cold | 1330/707/20.3 | 2940/982/28.3 |
| many | 524/159/1.09 | 1091/423/2.39 |
| resize | nothing drawn | 2215/510/6.56 |
| seed 13 | e44dea3 | 49cec82 | 3091fb8 |
| --- | --- | --- | --- |
| cold | 1330/707/20.3 | 2940/982/28.3 | 1179/982/24.6 |
| many | 524/159/1.09 | 1091/423/2.39 | 335/333/0.62 |
| resize | nothing drawn | 2215/510/6.56 | nothing drawn |
The `3091fb8` column is single runs from the third session; its `cold` and
`resize` at seed 1 are after the `Part::Of` pin composition, which added
18 and 19 draws respectively over the version without it.
Three findings, each applied on the branch:
@@ -268,7 +354,11 @@ is the padded share and fits. The clipped render records the current bug.
`unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`.
- **Four bookkeeping rules for "which draw is a measurement"** (worker) and
**the two experiments above** (planner): each fixes some seeds and breaks
others. The bit is undefinable; stop trying to define it.
others. The bit is undefinable; `wip/one-ask` removes the second draw, so
there is nothing left for it to name.
- **Asserting that a placed drawing holds for its answer box** (worker's
step 3): unsatisfiable for `Text` and for any widget that reads its box
and reports something else. The answer box is not a question.
- **Choosing between a fixed and a relative child in pixels at the span's
current width** admits multiple self-sizing fixed points; seed 13 settled
differently warm and cold under it. The same circularity is what a cap