diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 216424e..5ec4cc2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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(&mut self, id: &StrongWidget, 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, + narrow: [Option; 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= SHRINK_DEPTH= SHRINK_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. diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index afa77d1..fa78871 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -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) diff --git a/docs/LAYOUT_LOG.md b/docs/LAYOUT_LOG.md index 111aa69..d65612c 100644 --- a/docs/LAYOUT_LOG.md +++ b/docs/LAYOUT_LOG.md @@ -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