Land the layout repair's findings and retire the working log

`docs/LAYOUT_LOG.md` existed until the one-ask protocol landed. It has, as
Iris PR #19 replacing #18, so what has to outlive the log moves into
`docs/LAYOUT.md`: the hint rule and the one ask a span makes, the reported
shares that are left and the two-answers design that would remove them, what
the fuzzers reach and what they cannot, the hypotheses not to repeat, and the
counter and dump commands with the numbers they print now. The handoff becomes
what it is for -- where the work stands, which is waiting on a review.
This commit is contained in:
iris-ai committed 2026-09-19 02:57:17 -04:00
1 parent e4fada298d
commit 3ecd676bcf
3 files changed
+191 -1183

No files matched your search

+124 -8
View File
@@ -4,12 +4,13 @@ A widget draws once and records its size on the `Painter`. Reading a child
`DrawResult::size()` records a retained size dependency; drawing the child
without reading that result does not make the parent's size depend on it.
§1, §2 and §3 have landed in Iris (#16 and #18). §4 to §6 and the density
section retain the rationale of the design but still name types that have
since been replaced; they are not an API reference. `docs/HANDOFF.md` is the
current transparent-frames work and its checks; `docs/LAYOUT_LOG.md` is what
the sessions doing that work found, kept until it lands. The sections at the
end of this file are durable design moved out of the handoff on 2026-09-18.
§1 landed in Iris as #16. §2 and §3 were #18, which is closed: Iris PR #19
carries those commits whole plus the retained-layout repair built on them. §4 to §6 and the density section
retain the rationale of the design but still name types that have since been
replaced; they are not an API reference. `docs/HANDOFF.md` is where the work
in flight stands. The sections from "Frames, decided boxes and padding"
onwards are the settled design, the findings that outlived the working log,
and the measurement method.
## Design
@@ -333,7 +334,38 @@ 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. This is `wip/one-ask` in the experiment checkout.
never runs the body.
The answer box is never a question, so no contract can be demanded of a
widget there: a `Text` asked at 45 px whose longest word is 89.5 px cannot
promise its drawing holds for the box its own answer chose. An earlier plan
asserted exactly that and was unsatisfiable.
A container that can learn a child's length without drawing it does.
`Painter::size_hint` answers from the child's rule, or from
`Widget::size_hint` where it implements one, resolved against the asking
widget's frame; `Scroll`, `Masked` and a `Stack` without a sizing child hint
`LEFTOVER`, since each always reports the whole of its box. `Span` takes each
child's length from its hint where there is one and asks that child exactly
once, in the slot it decided; a child with no hint is drawn in the room left
from the cursor, because a text has to wrap at the width actually there, and
its drawing is moved to its slot with `place_at`. Reading a hint records a
size dependency and pins the frame where the hint declared a fraction, the
same pin a rule that is a fraction takes.
**What that leaves, and the design that would remove it.** A widget that
reports a share its children gave it -- a span whose children report
`leftover`, a stack sized by such a child, a wrapper round either -- can have
no hint, so its parent asks it in the room and again in its slot, and both
asks draw, since the room drawing divided the room. Under a resize that
multiplies down the tree: at seed 1, depth 8, the hottest widget in the
resize frame is a `Span` drawn 8 times. Removing it means two answers on one
record -- a room answer from an explicit measuring ask (`Painter::measure`,
so which ask is the measurement is stated rather than inferred) and a slot
answer from the placing ask, with the drawing belonging to the placing one --
plus a local redraw that re-asks both questions and marks the parent if
either answer moved, which also retires `re_asked`'s deferral. Do it only
once an app screen shows the cost, and measure that screen first.
**There is one coordinate unit, the window** (`1512d84`, `23523ee`). Every box in the
tree is a region in window units; a widget's frame is a *length* in the same
@@ -489,14 +521,68 @@ of its parent's frame and a local redraw walked back up the parent chain in
- Alignment is one value per axis and defaults to the middle because neither
edge is neutral without a direction. One widget has one length per axis; a
second length requires a second widget through `.wrapper()`.
- No measurement is a different state from a measured zero, so
`ActiveData::answer` is an `Option` rather than a zero size.
### What the fuzzers tolerate
### What the fuzzers tolerate, and what they reach
Warm and cold pixel regions must compare exactly; there is no step
allowance. When a row's grid-step count is not divisible by the number of
children, individual share widths differ, but every rerun of that layout
must still agree exactly.
- The routine runs are ten seeds (fast oracle), 400 at depth 5 through the
shrinker, and 1000 at depth 6. The 2000-seed depth-4 scan over every case
is what found seeds that had failed on every commit for a week, so run it
after any layout change. `Rng::new` is `seed | 1`, so an even seed and the
odd one above it are one tree.
- The shrinker panics per thread at the first failing seed, so a run reports
at most one seed per chunk of about 58. Check a single seed against the
unpatched code before calling it new.
- Depth finds things and so does breadth; widen one axis at a time and
record which.
- If a seed fails once and never again, suspect the rig's determinism before
believing an edit fixed it: seed 30 at depth 5 did exactly that on
2026-09-19 and reverting each part of the change did not bring it back.
- The cases are what a change is *then* compared at. Until the `size-resize`
case was added, every one of them compared at the window the change was
made in, and a length kept as a fraction of the wrong box agrees there and
parts from cold at every other window. There is still no case that changes
a tree twice with a resize between, and none that resizes twice.
- The fuzzer grows no `rel` rules and never re-parents a widget, so neither
a fraction resolved against a frame nor a subtree changing hands is
covered by any generated tree. Both have cost a defect that only a
hand-written expectation or a render caught.
### Failed hypotheses worth not repeating
- **"Which draw is the measurement" cannot be defined.** Four bookkeeping
rules and two experiments each fixed some seeds and broke others. The bit
existed only because a widget was drawn twice; one draw leaves it nothing
to name. A plan that proposes bookkeeping for a distinction should first
ask whether the distinction has to exist.
- **A frame narrowed by a region does not move when its part moves.** A
narrowed frame must be a *length*, put back into the part on every
placement, exactly as a declared rule already is.
- **Choosing between a fixed and a relative child in pixels at the span's
current width** admits multiple self-sizing fixed points; seed 13 settles
differently warm and cold under it. The same circularity is what a cap
containing `leftover` would put into `SizeRule::Max`.
- **A tolerant endpoint on the span's leftover split** retains zero-height
children (seed 16). The boundary is derived through the inverse of the
expression that places children, and is pinned by
`unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`.
- **Not reading a span's own length where no slot depends on it** saves
nothing (`wip/inset`, measured 2026-09-19): counters identical at every
phase. A symbolic pin survives a resize because every ask box is
symbolically stable across one, so the pin only fails where an answer
changed, which is a real relayout.
- **Resolving a rule that is a fraction of the frame from the record instead
of redrawing** recovers nothing: `reuse outside: a frame length` is 3 of
264 cold draws at seed 1 and 1 of 758 at seed 13.
- **A `git bisect`** once named a commit that could not be the cause; read
the tree rather than the bisect when that happens.
### Rendering the grid (pending)
`snap_floor` in `prelude.wgsl` adds half a layout step before flooring,
@@ -515,6 +601,36 @@ inlines.
## Measuring layout cost on this machine
- **Counters, at two seeds, before anything else.**
IRIS_SEED=1 IRIS_DEPTH=8 cargo test --release --features layout-diagnostics \
--test layout_diagnostics -- --ignored --nocapture layout_cost
reports each phase's `widget draws` and `distinct widgets`, and
`IRIS_PHASE=resize IRIS_FRAMES=2` names who is drawn how often. Where the
one-ask protocol stands, draws / distinct at depth 8, beside #18:
| seed 1 | #18 | now | | seed 13 | #18 | now |
| --- | --- | --- | --- | --- | --- | --- |
| cold | 369/261 | 264/232 | | cold | 1330/707 | 758/627 |
| many | 157/95 | 41/41 | | many | 524/159 | 16/16 |
| size | 16/12 | 3/3 | | resize | nothing | nothing |
| scroll | 2 | 1 | | | | |
| resize | 13/13 | 36/13 | | | | |
`many`, `size` and `scroll` are at their floor: every draw is a marked
widget, or the parent a marked widget deferred to. `resize` at seed 1 and
cold's draws over its distinct count are the reported-share cost above.
- **A change that could move cold layout gets the dump diff**, which the
warm/cold oracle cannot replace -- both of its sides move together:
IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release \
--test layout_dump -- --ignored --nocapture | grep -E '^[0-9]+ [0-9]+ '
once at each commit, then `diff`. Zero differing lines of 34,488 is the
expectation for a cost change.
- **Check the work counters before comparing two commits' times.**
`tests/layout_diagnostics.rs` prints drawn widgets, widget draws and
primitive writes; a comparison is only worth reading when they match.