diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index cd9f0dc..14f8939 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -10,21 +10,19 @@ stayed. Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head -**`ffd79f3`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, +**`c8beca5`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, the `Holds` retained-layout contract, region nodes, built-in alignment and size rules, fixed-point layout, a box in pixels threaded down the draw, and a report read as a fraction of the containing widget. No PR review was present when checked on 2026-09-15. -**The branch has a warm/cold divergence nothing on it explains yet**, found -2026-09-17 by widening the fuzzers rather than by any change: seeds **1121** -and **1839** at **depth 4** fail `shuffle-swap-for-three` and -`shuffle-all-but-first` respectively, and they fail the same way on -`ea6dbae`, before either of the day's commits. Nothing in the routine -verification reaches them -- the fast oracle takes ten seeds, the shrinker -400 at depth 5 and the long oracle 1000 at depth 6, and these are past 400 -at a depth neither long run uses. Chasing them is first in "Next". The scan -that found them, which is worth keeping as a pattern: +**Widen one fuzzer axis at a time, and record which.** Seeds **1121** and +**1839** at **depth 4** failed on `ea6dbae` and on every commit before it, +and nothing in the routine verification reached them: the fast oracle takes +ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6, +so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by +`4bd8607`. The scan that found them, worth running again after any change +to layout: ```rust // tests/scan.rs, deleted once it had done its job @@ -38,9 +36,10 @@ over_seeds((1..=2000).collect(), |seed| { }); ``` -262 s for 2000 seeds at depth 4 over all fifteen cases. `Rng::new` is -`seed | 1`, so an even seed and the odd one above it are one tree: 1120 and -1121 are the same counterexample, as are 1838 and 1839. +261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at +`4bd8607`. `Rng::new` is `seed | 1`, so an even seed and the odd one above +it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and +1839. **Two ideas outrank everything else in this document** (Bryan, 2026-09-17). First, a changed tree lays out exactly as if it had been drawn that way from @@ -142,6 +141,38 @@ divergence needs a reader drawing while a size dependency two levels under it is dirty, which no hand-built tree has reproduced yet, and the seeds at depth 4 above fail for some other reason. +### A text is handed back a box its own line fits in (landed, `4bd8607`) + +A wrapping text reported the width it used through `Px::from_f32`, which +takes the nearest step and is under the line the shaper measured half the +time. A parent that sizes itself from that report -- a stack taking a span's +width, the span taking its widest child's -- then hands the text back a box +its own longest line does not fit in, and a greedy break there is one line +longer. Warm kept the break it had; cold made the narrower one. + +Two tolerances were holding that together and both are gone: + +- `TextBuffer::shape` answered any width within `BREAK_EPSILON_PX = 0.05` of + the longest line from the break in hand. Fifty steps of the grid, and a + structural decision taken on a hair's breadth -- the thing the `Span` + boundary invariant below already forbids. It is `want >= layout.width()` + now, exactly. +- The `Holds` range the text declares started at the nearest step to its + longest line, so it admitted boxes that line does not fit in. It starts at + `Px::ceil_from_f32` of it now. + +Neither was the fix. **The fix is the report**: `Size::from_px( +PxVec2::ceil_from_f32(tex.size))`, the step at or above what was measured, +so the box that comes back fits. With it in place either tolerance could +have stayed and the case passes; both are wrong on their own terms, so both +went. `Fixed::ceil_from_f32` is new and is the only rounding on the grid +that is not to the nearest step. + +The general shape, and the third time this branch has hit it: **a value that +comes back as a box has to be rounded away from the measurement, not to the +nearest step.** Rounding to nearest is right for a value being carried; +it is wrong for a bound. + ### `Span`'s leftover boundary is a third expression for the room The decision uses a rounded division, `total.px.div(fixed)`, while the room @@ -446,19 +477,24 @@ above for what closing the rest would cost. ## Verification at the current head -At `ffd79f3`: +At `c8beca5`: - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- - -D warnings`, `cargo test --workspace`: green, 89 suite tests, 18 core + -D warnings`, `cargo test --workspace`: green, 90 suite tests, 19 core unit tests, 11 generated cases. Only the long runs and the profiling rigs are ignored; no known defect is. - The release oracle at 100 seeds in 14.3 s, and **120 seeds in debug** in 59 s -- the debug run exercises the `Holds` assertion in `draw_at`. -- All fifteen shrinker cases at 400 seeds of depth 5 in 56 s, and the - oracle at 1000 seeds of depth 6 in 142 s. -- `view`, `minimal`, `random`, `tabs` and `text` byte-identical at - 1920x1200 against `ea6dbae`. `random` live-resized from 1920x1200 to - 1280x800 is byte-identical to a cold 1280x800 render. +- All fifteen shrinker cases at 400 seeds of depth 5 in 56 s, the oracle at + 1000 seeds of depth 6 in 142 s, and **2000 seeds at depth 4 over all + fifteen cases** in 261 s. +- `view`, `minimal`, `random` and `tabs` byte-identical at 1920x1200 + against `ea6dbae`. `random` live-resized from 1920x1200 to 1280x800 is + byte-identical to a cold 1280x800 render. **`text` is a new picture**: + `4bd8607` moved its lower paragraph one pixel, the box being a step wider + and its left edge crossing the shader's snap, and `c8beca5` rewrote the + alignment panel, which had all three labels in the middle of a box the + width of the widest of them. - Twenty-five rig work counters identical on `cold`, `repaint`, `scroll`, `resize` and `size` across `0e0d4af`; `many` differs only in reuse attempts. @@ -632,22 +668,15 @@ The replay used for the reference check: In order, from the review above and Bryan's steer (2026-09-17): -1. **Seeds 1121 and 1839 at depth 4**, which fail on this head and on - `ea6dbae` alike -- a warm/cold divergence older than either of the - day's commits and the only known correctness defect on the branch. - Reduce each with `SHRINK_SEED`/`SHRINK_DEPTH=4`/`SHRINK_CASE`, turn it - into a fast test, and add the seed to `generated.rs`'s `SEEDS` the way - 86 and 20 are there. Then re-scan 2000 seeds at depth 4 before calling - it done. -2. **A fast test for `0e0d4af`**, the stale-answer guard, which went in +1. **A fast test for `0e0d4af`**, the stale-answer guard, which went in with only a fuzz seed behind it. The shape wanted is a widget drawing while a size dependency two levels under it is dirty, where its drawing is reusable at the measuring box and not at the placing one. -3. Write `ActiveData::answer` in one place. -4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the +2. Write `ActiveData::answer` in one place. +3. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the reconstruction in `redraw`. -5. `Span`'s leftover boundary through `Holds::through`. -6. **Round on the CPU and snap to the nearest pixel in the shader**, as +4. `Span`'s leftover boundary through `Holds::through`. +5. **Round on the CPU and snap to the nearest pixel in the shader**, as one change with one verification. Bryan approved the snap on 2026-09-17 (rendering may change wherever it brings the screen closer to what the user's code says: three equal sections of 1000 px need one of them @@ -663,20 +692,20 @@ In order, from the review above and Bryan's steer (2026-09-17): bounds move by half a `Rel` step); check with `nm` that `UiSpan::within` still inlines; expect a couple of percent of instructions and re-run the long fuzzers and the render set once for both. -7. The smaller items: the stale `f32` comment, the gap of an undrawn child, +6. The smaller items: the stale `f32` comment, the gap of an undrawn child, confirm nested `leftover` weights, one zero-divisor fallback. Add to them: a span that overflows itself hands a child a box of negative length, which is ordinary now rather than a corner, and nothing states what a widget may assume about one. -8. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable +7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable subtree case; do not restore a separate child-placement API. -9. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` +8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` builders `8220a78` deleted. The clamp boundary is a hard layout decision with an exact `Holds` split at the crossover, both sides in `Px`. Still awaiting Bryan: whether a `Max` narrows the box the child draws in, or only what the parent reports for it. -10. `Scroll` taking a direction rather than one axis. -11. The measure/draw split, once the above is in. It deletes the two-ask +9. `Scroll` taking a direction rather than one axis. +10. The measure/draw split, once the above is in. It deletes the two-ask protocol, which is what `0e0d4af` had to put a guard around. `docs/LAYOUT.md` §4, §5 and the density section are stale: they name