20 KiB
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 transparent frames 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.
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 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; manyandresizestill 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.
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 two rules to protect
These outrank the accumulated machinery:
- 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.
- Lengths are predictable.
pxis that many pixels;rel(0.5)is half of the frame decided for the widget, wherever it sits;leftoveris a share of the room left after every sibling'spxandrellengths 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.
Decisions
Decided with Bryan on 2026-09-17 and 2026-09-18.
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.
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 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.
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.
Pad remains an outset
Padding goes outside what it pads. There is no mixed "outset pixels, inset
fractions and shares" Pad.
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.
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:
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.
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 droppedPart::Ofpin 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, butdraw_innerdiscards 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 underScroll. - Those
Scrollfailures are theapply_leftoverfeedback 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.
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.
1. Pin the two failures as focused tests
Done in b842e4f. The named tests reproduce the mismatch at 49cec82 and
remain intentionally red until the protocol repair:
unsettled::repainting_a_stack_uses_the_box_its_sizing_child_decided;unsettled::reordering_nested_spans_keeps_the_answer_from_the_decided_box.
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.
Also retain these nearby regression tests while changing the protocol:
unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offeredunsettled::a_span_given_the_box_its_answer_decided_matches_a_cold_layout- the seed 86
Scrollfixed-point case - the tests for a length in pixels staying that many pixels and for an exact leftover split
Check the ordinary suite and each new test individually.
2. Correct Scroll's content length
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.
Replace its apply_leftover content sizing with the fixed-part calculation
above. Add focused cases for:
600 px + leftoverin 900 px resolves to 900 px;- fixed content wider than the viewport still scrolls;
- a lone
leftoverchild fills without scrolling; - the wrapping-text-plus-share case is stable warm and cold.
The seed 86 test stays until the full protocol has landed, even if its old special rule becomes moot.
Check the suite, fast oracle, and the known Scroll counterexamples from the fresh-answer experiment (seeds 184, 246, 292 and 372).
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 readextent_lenunconditionally. Slots depend onfaronly when shares exist (the decided slot fills its part) or for negative direction; compute negative-direction slots fromtotal. Pin the extent length only in those cases.Stack: draw non-sizing children inFrom(0..size)on an axis where the sizing child's answer ispx/rel, andAllwhere it isleftover, instead of drawing them inAllof the measuring room.Branchin the random rig: express "the rest of my box" asOf(40px..FULL)rather than readingextent_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:
- measure fixed children and collect share weights;
- divide the deciding box's remaining room;
- place/evaluate each child in its decided box, with a share child's frame narrowed to that share;
- 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
Run, in the experiment checkout:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -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
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:
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.
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:
./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:
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 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.
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::throughfor the new rounding and run both long fuzzers plus the render set. - Test-only
InsetandOutsetcan demonstrate their semantics after the protocol is stable.Paditself 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 stalef32identity comment. LazySpan, thenSizeRule::{Min, Max, Clamp}. A cap may not containleftover; whetherMaxnarrows the child's drawing box is still a real product decision.Scrolltaking 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.