Files
ai-app/docs/HANDOFF.md
T

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;
  • 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.

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:

  1. A changed tree lays out exactly as if it had been drawn that way from the start. The warm/cold oracle and shrinker test this.
  2. Lengths are predictable. px is that many pixels; rel(0.5) is half of the frame decided for the widget, wherever it sits; leftover is a share of the room left after every sibling's px and rel lengths are resolved.

Do not fix a failure with a tolerance, another measurement flag, a special case in Span, or another layout method. 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 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.

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_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

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 + 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.

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 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

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::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.
  • LazySpan, then SizeRule::{Min, Max, Clamp}. A cap may not contain leftover; whether Max narrows the child's drawing box is still a real product decision.
  • Scroll taking a direction rather than one axis.

Other product work remains in docs/PLAN.md and the focused documents it links. Do not mix it into the Iris layout branch.