Record fixed-point layout, and what it left open

Four commits on #18 put layout on a grid: `Fixed<SHIFT>`, 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 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 03:02:46 -04:00
1 parent 1a73aba1cd
commit ddf746d8a4
1 file changed
+82 -33
+82 -33
View File
@@ -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<SHIFT>` 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: