Files
ai-app/docs/HANDOFF.md
T

407 lines
19 KiB
Markdown

# 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`, head **`4328eac`**, five commits over **`34cafb6`**.
It implements transparent frames, pins the two open failures, and corrects
`Scroll`'s content length, but it is not ready to replace #18:
- the shrinker disagrees warm versus cold at seed 2 (`repaint`) and seed 108
(`reorder`) at depth 5;
- `many` and `resize` still do more work than #18 on a deep tree;
- a resolved `leftover` share does not yet narrow its child's frame;
- 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. The step 3 experiment described below was
reverted; the experiment checkout was clean at `4328eac` when last inspected.
## 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:
```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.
### 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.** A direct
implementation was tried and fully reverted. It always kept the fresh answer,
made placement reuse-or-translate (asserting if the retained drawing did not
hold), asked `Span` children provisionally in the whole box before decided
placement, gave `Stack` overlays the sizing child's reported box, and changed
the random `Branch` tail to `Part::Of(40px..FULL)`. Both new focused tests
passed, but 17 ordinary suite tests failed.
Several failures state a load-bearing contract contrary to this plan rather
than exposing another container bug. `ReadsBox` and `ReadsWidth` deliberately
read the offered box, report one quarter of it, and their tests require two
draws: one to answer and one in the answer-derived box. The tests' own comment
says those draws come in pairs. `a_widget_asked_again_on_another_layer_is_drawn_there`
likewise requires a second draw when placement changes the layer. Other
failures include root resize/one-step validity tests, region-node placement,
and `a_one_pixel_line_keeps_its_pixel_through_a_chain`.
The unresolved decision is therefore public widget semantics, not another
definition of measurement: either a widget must now guarantee that a drawing
holds for the answer box it reports (and the contrary tests, documentation,
and any real widgets must be redesigned), or placement sometimes has to run
the body in an answer-derived box, contradicting the decided-box rule. Do not
resume step 3 until that choice and its migration are 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:
```sh
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:
```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`.
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:
```sh
./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:
```text
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.