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:
1 parent
39978ff8ea
commit
ef324aa88b
3 files changed
+370
-319
No files matched your search
+255
-298
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user