Files
ai-app/docs/HANDOFF.md
T
iris-ai 36ce66554b Record what making the frame a window length corrected
Step 2 of the layout plan is done in the experiment checkout, at 1512d84
and 23523ee. The handoff carries the commits and what is left; LAYOUT.md
carries the settled rule, which is now one coordinate unit with the frame
a length of it and both pins beside each other; the log carries what
implementing the plan corrected in it, including the four that would have
shipped as wrong layout and the two rig gaps that hid them.
2026-09-19 00:21:44 -04:00

18 KiB
Raw Blame History

Handoff

Where the Iris retained-layout work stands for a worker picking it up cold. This file contains current decisions, the implementation plan and its checks. The durable layout design and measurement method are in docs/LAYOUT.md. The temporary investigation record is in docs/LAYOUT_LOG.md; delete that log when the one-ask protocol lands, after moving any fact that must survive.

Where things stand

Canonical upstream Iris main is ca2b4b2 (#17, the headless rig). PR #18's pushed branch is split/18-position-chain at e44dea3. Its detached comparison checkout is /home/bob/repos/iris-layout-baseline. It is the reviewed baseline this work must preserve or improve.

The continuation is /home/bob/repos/iris-layout-experiment, now on branch wip/one-ask at 23523ee, four commits over 4328eac (the head of wip/transparent-frames, which is unchanged). It replaces the old step 3 plan with the one-ask protocol below, and 1512d84 and 23523ee make the frame a length of the window. It passes every check:

check at 23523ee result
cargo fmt --all --check, clippy -D warnings, with and without layout-diagnostics clean
cargo test --workspace (debug) 122 suite, 20 core, 11 generated, all green
cargo test --release --test generated 11/11
shrinker, 400 seeds, depth 5, all sixteen cases agree, 66 s
1000 seeds at depth 6 agree
2000-seed depth-4 scan, all sixteen cases agree

What implementing it corrected in the plan is in docs/LAYOUT_LOG.md; the four that would have shipped as wrong layout are a share inside padding losing the padding twice, a root resolving its own rule twice, a rule changed over two pads relocating the column under them instead of dividing it again, and a resize leaving a short scroll's window-tall content where it was. Each is pinned as a named test.

Not done: the renders and the tabs replay, the retained-cost work, the renames, and a pre-submit review of the three commits as one diff.

The worker's older step 3/4 experiment is preserved as branch wip/step3-experiment (one commit over 4328eac) and as ~/repos/iris-step3-experiment.patch. It is evidence, not the protocol. The app's Iris pin is unchanged.

The two rules to protect

These outrank the accumulated machinery:

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

Do not fix a failure with a tolerance, another measurement flag, a special case in Span, or another layout method.

What the previous plan got wrong

The full account is in docs/LAYOUT_LOG.md. The short version, because it is the third plan for this repair and the next one should not repeat it:

  • Every plan kept the second draw. The old protocol drew a widget in the box it was asked in, then drew it again in the box its own answer placed it in whenever the first drawing's Holds did not cover that box. All the offer machinery -- offer_place, offer_part, at_offer, measured(), the local-redraw deferral -- existed to remember which of the two draws was the question. The plans tried to define that bit better; the defect was that there were two draws at all.
  • The step 3 plan then over-corrected. It said "every drawing must hold for the answer box it supplies", and the worker implemented exactly that as an assertion in place. A wrapped Text asked at 45 px whose longest word is 89.5 px cannot satisfy it, and neither can any widget that reads its box and reports something other than it. The answer box is not a question, so no contract about it can be demanded of the widget.
  • It also let a caller narrow a frame by position. A frame narrowed to a region (the worker's share frames) does not move when the part it sits in moves; only a frame narrowed to a length, put back into the part on every placement, does.

The protocol now in the experiment

A widget draws once, in the box it is asked in. Its answer is placed inside that box by re-expressing the drawing. Nothing is drawn again in a box an answer chose. Holds is a contract about the ask box alone, consulted only to decide whether a re-ask can be skipped. This is draw_inner at 1512d84:

let reused = (!stale)
    .then(|| self.retained_answer(id, part, info))
    .flatten()
    .and_then(|answer| {
        let extent = placed_extent(part, answer.0, declared, info.fill(), align);
        self.try_reuse(id, part, extent, info, rsc).map(|()| answer)
    });
let answer = reused.unwrap_or_else(|| {
    if old.is_none() {
        old = self.remove(id, false, rsc);
    }
    let answer = self.draw_at(id, part, info, old.take(), rsc);
    let extent = placed_extent(part, answer.0, declared, info.fill(), align);
    if extent != part {
        self.relocate(id, extent, info, rsc);
    }
    answer
});

try_reuse checks the drawing against part and relocates it to extent; the old place (redraw in the answer box) is gone, and with it every offer field's purpose. ActiveData keeps offer_part as the ask box, offer_place as where it was asked and place as where it was put; the names are the old ones and should be renamed (part, asked, placed) when this lands.

A container that puts an answer somewhere other than where it asked says so with a new call that never runs the body:

/// Puts a child asked about in this draw somewhere else in this
/// widget's box: its answer, placed in this part instead. The drawing
/// is re-expressed there rather than made again -- what a row does once
/// it knows every slot, having measured each child from its cursor.
pub fn place_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, place: [Place; 2])

A frame is narrowed by a length of the window, never a region and never a fraction of the parent's frame -- a row's slot cannot be written as a fraction of the row. The box stays whatever place names; only a declaration places the box inside it, by the child's alignment, on every placement:

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:

let slot = along(from, start);
let place = axis.pair(Place::Fill(Part::From(slot)), across);
let used = match len.leftover > Weight::ZERO && shares {
    true => {
        let mut narrow = [None; 2];
        narrow[axis as usize] = Some(slot.len());
        painter.widget_at(child, narrow, place).len(!axis)
    }
    false => {
        painter.place_at(child, place);
        size.axis(!axis)
    }
};

Stack asks non-sizing children with Part::Sized(len) of what its sizing child decided, on every axis that is not a share -- a box of that length where their own alignment puts it, and that length as their frame -- and Scroll asks its content once in the viewport and place_ats it to the scrolled offset.

A local redraw asks the retained question again -- the same place of the box the parent was asked in -- and, if the answer stands, puts the fresh drawing back at the retained place of the box the parent's answer chose. Both halves are needed: seed 2 at depth 4 (a stack sized by its text) fails without the second.

A widget its parent asked more than once in one draw -- a share child, asked in the room and then in its slot -- has two questions and one record, so it cannot settle locally: redraw defers it to the parent the way it defers a widget whose declared length changed (ActiveData::re_asked, set by widget_at when the child is already in children). Seed 946 at depth 6 found the case: a fixed-height column that is a share while its rect fits and a fixed width once it does not, so emptying it changes the room answer and not the slot answer.

A symbolic length a child pinned now composes through Part::Of where the part is the whole box less pixels, and pins the parent's own length otherwise (in_parent). Dropping it let a zero Pad reuse a drawing across a narrowed frame of the same pixel length; the shrinker found six such seeds at depth 5.

Decisions

Decided with Bryan on 2026-09-17 and 2026-09-18, kept where still true.

One draw method, in a box decided from above

Widget::draw remains the only layout method. A container's body runs only in a box its parent offered or decided, never in a box derived from the container's own answer. The experiment extends this to every widget: a leaf is not drawn in its answer box either. Its drawing is re-expressed there, which for a text means the block it shaped at the asked width is positioned inside the box its reported size chose, and its lines do not change. examples/text.rs and random have not been rendered since; do that before landing and inspect any change.

Frames are narrowed by every length decided from above

A declared px or rel, a resolved share, and the box a stack's sizing child decided (Bryan, 2026-09-18: the sizing child, if any, determines how the rest are laid out) all narrow the frame. declared_lens still excludes leftover, which is right: a share has no length until the span divides its room, and it narrows the frame at the placing ask instead.

Padding is an inset, and the frame is a length while the box is a region

Bryan, 2026-09-18: padding is an inset. It subtracts from both the child's frame and its box and adds itself to the reported size, so rel(1.0) inside padding fills the parent without overflowing. A span's frame never subtracts siblings; only the padding subtracts from it. No outset kind and no mixed kind for now; the name stays Pad. Worked example, 900 px row:

let row = (rect(Color::RED).width(24), wtext(PARAGRAPH).wrap(true).pad(16)).span(Dir::RIGHT);

The text is asked in 900 24 32 = 844 px and wraps there; a rel(1.0) inside the same padding is 900 32 = 868 px and overflows the row by exactly the icon's width. With the pad in a share instead, both are the share less 32. An icon after the padded text overflows; a user who wanted otherwise meant leftover.

Implemented at 1512d84. Pad reads its own frame (Painter::frame_len, which pins it), takes the padding off, and hands that down as the child's frame, while the box it gives is the inset part of its own box. The two are different lengths whenever the box is narrower than the frame -- which is exactly the wrapping case above.

A share never adds room beyond the deciding box

Scroll resolves its content length from the fixed part of the answer and makes it at least the viewport (4328eac). Unchanged.

Existing fixed-point and box-chain design stays

Unchanged; see docs/LAYOUT.md.

Implementation plan

Work in /home/bob/repos/iris-layout-experiment on wip/one-ask from 23523ee. Make each step a warning-clean commit and run its named checks before the next. If a step exposes a different mechanism, stop and update this handoff rather than papering over it.

Anything a fuzzer finds is shrunk first (SHRINK_SEED=<seed> SHRINK_DEPTH=<depth> SHRINK_CASE=<case>, which now prints each level's frame, ask, box and size warm against cold), pinned as a named test in tests/cases/unsettled.rs, then fixed under the two rules above. Do not add a second draw back.

1. Review the four commits

Run the pre-submit review over 3091fb8, 0ef87eb, 1512d84 and 23523ee as one diff against 4328eac. 1512d84 rewrote much of what the first two did to painter.rs and render_state.rs and was reviewed as it was written, but the three have never been read as one change, and the parts of the one-ask protocol it did not touch -- place_at, the twice-asked deferral, Span's two passes -- were written as a probe and reviewed only by their tests.

2. Make the frame a length and the box a region

Done at 1512d84 and 23523ee. The settled rule is in docs/LAYOUT.md under "Frames, decided boxes and padding"; what implementing it corrected in the plan -- three of them wrong layout that would have shipped -- is in docs/LAYOUT_LOG.md.

3. Render and replay

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.

4. Rename and delete

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. Rename LayoutHolds::frame to window and Painter::frame_own with it: since 1512d84 those ranges are window pixels, and the only thing that writes them is Painter::window_holds; the frame's own entry is the frame_len pin beside 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.

5. Restore the expected retained cost

Work counters at 3091fb8, seed 1 and 13, depth 8, widget draws / distinct widgets, beside e44dea3 (#18) and 49cec82 (the branch head before this):

seed 1 e44dea3 49cec82 0ef87eb
cold 369/261 516/288 331/288
many 157/95 187/119 110/92
size 16/12 3/3 3/3
scroll 2 1 1
resize 13/13 24/76 40/15
seed 13 e44dea3 49cec82 0ef87eb
cold 1330/707 2940/982 1179/982
many 524/159 1091/423 424/364
resize nothing 2215/510 nothing

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:

  • A share child is asked twice per span draw -- in the measuring room with the frame forwarded, then in its slot with the frame narrowed. Each ask that reads pixels or pins a length draws, and since 0ef87eb every local change inside a share child redraws its span as well. Give Span a measure-only ask for the first pass: reuse the retained answer when its holds contain the room, without validating or relocating the drawing, and let the placing ask settle the drawing. The answer contract must then carry no symbolic pin (a span's total does not depend on far; only its slots do), which is the separation the worker's experiment made with answer_extent_len. With that, a twice-asked child could keep both answers and settle locally by re-asking both questions instead of deferring. Measure many at seed 13 before and after; the seed 946 pin must stay green throughout.
  • A positive-direction span with no shares pins far it does not need, so a resize redraws it. Read extent_len only where a slot depends on it (shares, or Sign::Neg), and express the measuring room's far end without the length. Measure resize at seed 1 before and after.

Report every phase at both seeds, work counters first, medians only when the work agrees.

6. Full verification and landing

Run, in the experiment checkout:

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --features layout-diagnostics -- -D warnings
cargo test --workspace
cargo test --release --test generated
SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \
  cargo test --release --test shrink -- --ignored --nocapture
IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \
  cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees
SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \
  cargo test --release --test shrink -- --ignored --nocapture

The last line is the 2000-seed depth-4 scan over all sixteen cases; the shrinker runs the same cases as the scan and reduces anything it finds, so no temporary test body is needed any more. Rng::new uses seed | 1, so adjacent even/odd seed pairs describe the same tree.

Render view, minimal, random, tabs and text at 1920x1200 and inspect every intentional change. Also replay tabs and compare a live resize of random with a cold render at the same size. Read the installed graphics skill before rendering and confirm the renderer; an llvmpipe fallback can produce a plausible PNG. The headless rig reuses one compositor, so run one process at a time and give comparison worktrees separate target directories:

./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 the protocol lands, move any surviving fact from docs/LAYOUT_LOG.md into docs/LAYOUT.md, delete the log, update this handoff to the next actual task, update the app's Iris pin only when the Iris change is ready, and push every coherent commit.

Follow-on work, not part of this repair

  • CPU round-to-nearest and shader nearest-pixel snapping are approved as one separately verified change. Neither has landed. Re-derive Holds::through for the new rounding and run both long fuzzers plus the render set.
  • Smaller layout items remain in docs/LAYOUT_LOG.md: an undrawn share's gap, nested share weights, inconsistent zero-divisor fallbacks, and the stale f32 identity comment.
  • LazySpan, then SizeRule::{Min, Max, Clamp}. A cap may not contain leftover; whether Max narrows the child's drawing box is still a real product decision.
  • Scroll taking a direction rather than one axis.

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