From ddf746d8a475011c4fe0aff3fb4e9c879be0422a Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 03:02:46 -0400 Subject: [PATCH] Record fixed-point layout, and what it left open Four commits on #18 put layout on a grid: `Fixed`, then positions, lengths, and the last of the pixels with `Holds`. The invariants that were about float rounding are rewritten rather than annotated -- the 0.05 px comparison is equality now, a move is a translation rather than a re-expression, and the span boundary needs no margin. What is left open is written down where the next session will look for it: the remaining step of imprecision is one rounding between two ways of asking how long a box is, and `OrthoSize` is paused on a decision rather than started. Co-Authored-By: Claude Opus 5 --- docs/HANDOFF.md | 115 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 33 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3289430..5d21de1 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -8,7 +8,7 @@ log. Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local -head is **`5ed9e87`**, fifty-one commits. Built-in alignment is complete there; +head is **`cb955f1`**, fifty-five commits. Built-in alignment is complete there; see "Built-in alignment" below for the retained-layout details. No PR reviews were present when checked on 2026-09-15. @@ -65,25 +65,27 @@ rewrite: - `Span`'s leftover/no-leftover split is a strict layout decision, not a rounding tolerance. Its `Holds` range must use the same exact divided boundary as drawing; a tolerant endpoint retained zero-height children at - the boundary in generated seed 16. `5ed9e87` moves that boundary rather - than softening it -- see "The leftover boundary" below. + the boundary in generated seed 16. `5ed9e87` moved that boundary rather + than softening it, and `39e4ca2` removed the move: on the grid the box and + the sum are the same count. See "Fixed point" below. `core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle pin those rules. Seeds 10 and 86 are now in the ordinary generated set. -## Verification at `5ed9e87` +## Verification at `cb955f1` - `cargo fmt --all --check` - `cargo build --workspace --all-features` - `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- `cargo test --workspace --all-features`: 89 passed, 10 ignored -- The release generated cold-layout oracle passed 100 seeds in 68.5 s. +- `cargo test --workspace --all-features`: 102 passed, 10 ignored +- The release generated cold-layout oracle passed 100 seeds in 75 s. - The release shrinker passed **all five** cases -- `resize`, `repaint`, - `resize-repaint`, `reorder`, `size-change` -- at 1000 seeds of depth 6: - 159,024 widgets per case, largest tree 587 widgets. At the ordinary 300 - seeds of depth 5 it is 26,001 widgets per case, largest tree 331. + `resize-repaint`, `reorder`, `size-change` -- at 300 seeds of depth 5: + 26,001 widgets per case, largest tree 331. It passed 1000 seeds of depth 6 + (159,024 widgets per case, largest tree 587) at `5ed9e87`, before fixed + point; re-run that before quoting it again. - `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at - 1920x1200 against the same worktree without `5ed9e87`. + 1920x1200 across the whole fixed-point sequence. - `tests/drift.rs` passed its 20,000-move exactness check in release mode. - The seeded `random` example, live-resized from 1920x1200 to 1280x800, is byte-identical to a cold 1280x800 render. Both PNGs hash to @@ -175,33 +177,31 @@ tighter figures. validity mechanism. - Declared non-`leftover` lengths are resolved by the widget's parent where the widget is drawn. A declared-length change therefore redraws the parent. -- Pixel comparison uses a 0.05 physical-pixel tolerance, against the last - actual layout. Repeated subpixel changes accumulate and eventually redraw. +- A pixel comparison is equality: lengths are whole counts of `1/1024` px, + so a change too small to reach the next step is not a change and one that + reaches it is, however little of a pixel it is worth. - Text shaping is retained separately from line breaking. A greedy line break remains valid from its longest produced line through the width at which it was made, and `TextView` reports that interval through `Painter::holds`. - `Span`'s decision to distribute `leftover` is a pixel question. The room to - divide is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX` of it is - none; pure `leftover` children are undrawn on the no-room side. The validity - interval is split exactly at that moved boundary. Exact/open ranges are for - hard widget decisions; the ordinary tolerant ranges remain for accumulated - coordinate rounding. + divide is `len * fixed - total.px`; pure `leftover` children are undrawn + where there is none. The box a parent hands back and the sum of what the + children asked for are counts of the same step, so the boundary needs no + margin and the validity interval is split exactly at it. - `Scroll` reports its content's first measured size. Its drawing can survive container-length changes only over the interval in which clamping and its current offset do not change. -- **A move must re-express each part as the same fraction of its new box, not - add an offset to the last answer.** A subtree's stored regions are the only - record of where it is, so an offset integrates its own rounding and nothing - recomputes it. Measured 2026-09-15 on `tests/drift.rs`: offsetting both ends - of a span shortens that fixture's row by 0.071 over 20,000 moves and 0.712 - over 200,000, growing with the count; placing the far end from the near one - leaves 0.069, because the length is re-derived from the endpoints either - way. 20,000 moves is five minutes of scrolling at 60Hz, which is when it - passes the 0.05 physical pixels layout treats as the same place. The - fraction path is exact at 200,000. So `RegionRemap` is load-bearing for - accuracy rather than for generality, and its multiplies are not what is - being paid for. The generated oracle compares within 0.05 and `unsettled.rs` - runs too few frames to reach it, which is why `tests/drift.rs` exists. +- **A move that keeps a box's length is a translation, and an offset is + exact on the grid.** A box that also changed length has to re-express each + part as a fraction of the new one, and that division and multiplication + round: `39e4ca2` translates where `from.len() == to.len()` and scales only + where it must, which is what made the shrinker's `resize` case agree + exactly. This inverts the float-era rule, and the measurements behind that + rule are why `tests/drift.rs` exists: in floats, offsetting both ends of a + span shortened that fixture's row by 0.071 px over 20,000 moves and 0.712 + over 200,000, while re-expressing fractions stayed exact. 20,000 moves is + five minutes of scrolling at 60Hz. On the grid the drift is gone either + way, and `tests/drift.rs` pins that it stays gone. ## Built-in alignment @@ -292,6 +292,43 @@ length the box takes and the drawing holds for that length alone. A `debug_assert` that a reported non-leftover size does not exceed the box it drew in would have caught both immediately, and is still worth adding. +## Fixed point + +Layout decides on a grid rather than in floats, in four commits: `7548139` +the number, `4e28f10` positions, `bd6de71` lengths, `39e4ca2` the last of the +pixels and `Holds`. Decided with Bryan on 2026-09-15. + +- **`Fixed` is an `i32` counting `1 / 2^SHIFT`.** Adding and + subtracting are exact; a multiply or a conversion rounds once, back onto + the same steps. Two routes to one place that come within half a step land + on the same number, so everything downstream compares for equality. +- **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536` + of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the + first two, and the shader's copy is prepended from them by + `render::module_source` rather than written again in WGSL. +- A weight is not a fraction: a list divides its room by the total of its + weights, so `Weight` trades precision for the range to hold a whole list, + and `Rel::ratio` turns two weights into a share on the finer grid. +- **Arithmetic saturates rather than wrapping**, because a clamped coordinate + keeps the ordering a wrapped one inverts. +- `Px` was `1/64` first. The residue of a length reached two ways is one + rounding, so it scales with the step: at `1/64` that was 0.016 px, enough + to move a box, and at `1/1024` it is a thousandth of a pixel. Range is + +/-2.1M px and conversion to `f32` is exact to 16,384 px. +- **What the fuzzers ask for is a step per operation.** The shrinker's five + cases agree within one (`resize` exactly), the oracle's two-operation cases + within two. Closing that needs one way of asking how long a box is: today a + chain composed down through `within` and a length measured against the + window are two, and they round separately. That is the next real step in + precision and it is bigger than any of these four commits. +- `Holds::through` inverts `px + rel * box`, which rounds, so the answer is + an interval even for a single length. It maps the half step either side, + plus one more for the two ways above; inverting the length alone gives a + point that need not contain the box the part was drawn in. +- A pointer, a wheel notch, a shaped glyph advance and a window size arrive + as floats and are put on the grid where they arrive. `Vec2` stays what the + GPU and the platform speak; `PxVec2` is what layout decides in. + ## The leftover boundary Fixed in `5ed9e87`, which closed the shrinker's `reorder` case. Neither of its @@ -332,6 +369,13 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` +`cb955f1` put the ordinary tests in `tests/cases/`, as modules of one +`tests/suite.rs` target -- eleven links became one, and with +`profile.test`'s `debug = "line-tables-only"` a rebuild of `iris`'s test +targets went from 14.3 s to 7.7 s and `target/` from 45 GB to 13 GB. Pick a +module out with `cargo test --test suite layout::`. The fuzzers and the +`*_cost` measurements are still their own targets. + Run the long generated oracle only after ordinary tests pass: ```sh @@ -393,6 +437,11 @@ Queued from this work, in order: read its own rule on the orthogonal axis and, where that is fixed, skip reading its children's orthogonal sizes entirely, which is what `Full` does today. That also removes the span's own instance of the compounding above. + **Paused 2026-09-16 for a decision**: a widget deliberately cannot see its + own rule ("the widget under a rule never learns of it"), so either that + changes or the span reports something else on the orthogonal axis -- which + is what every span reports, and a column sized to its widest item is what + `Children` is for. - `Scroll` should take a direction rather than one axis: vertical, horizontal, or both. Reporting `LEFTOVER` on both axes is already the right shape for it. - Restore `max_width`/`max_height` as `SizeRule::{Min, Max, Clamp}`. `8220a78` @@ -401,9 +450,9 @@ Queued from this work, in order: `px_size`, which pinned its interval to one exact box on both axes and redrew its whole subtree on any resize. The clamp boundary is a hard layout decision: its `Holds` range must be exact and split at the crossover, the - way generated seed 16 taught for `Span` -- and the crossover itself has to - sit off the length a box structurally lands on, the way `5ed9e87` taught. - See "The leftover boundary". + way generated seed 16 taught for `Span`. On the grid the crossover no + longer needs moving off where boxes land -- see "Fixed point" -- but it + does need both sides of the comparison to be `Px`. Other queued work, in dependency order: