diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a8449a8..20ed6bc 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -16,6 +16,12 @@ 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 separate frame/extent experiment now addresses the dominant size/resize +redraw cascade through dependency tracking and invalidation, not glyph emission. +Its current head is `0e107f0` in `/home/bob/repos/iris-layout-experiment`. +See **Frame and extent: retained prototype** for the mechanism, measured costs, +and the remaining cold/many-update work. The app's framework pin is unchanged. + **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 @@ -423,7 +429,7 @@ The original proposal was implemented by Claude as `5fcace1` on `wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction reference stable but failed five layout cases and three draw-count cases. The first correction is `efb416b`, exact recomposition is `2ed5503`, and the -current performance continuation is **`c44bd19`**, pushed to origin's +current performance continuation is **`0e107f0`**, pushed to origin's `wip/region-and-placement`, in the isolated checkout `/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original checkout is unchanged. Keep the experiment's build directory separate: Cargo @@ -506,179 +512,131 @@ red slot. Red is centered in that slot, 5 px right of the visible black gap's center. Do not describe those two centers as the same thing or compensate with an arbitrary offset. The parked padding API change is still separate. -Ordinary verification passes: formatting, workspace clippy with -`layout-diagnostics`, workspace tests (101 suite tests and 21 core tests), and -the fast generated cases. The corrected `tabs` resize from 1920x1200 to -900x1200 matches a cold image with zero differing pixels. The release oracle -passes 2000 seeds at depth 4 and 1000 at depth 6; the shrinker passes 400 at -depth 5, and the debug oracle passes 120 at depth 4 with assertions enabled. -Every scan runs all fifteen scenarios with exact equality. The measurement -regression fails with four draws instead of three when the optimization is -disabled. +**The major update gap was over-invalidation, not a required correctness cost.** +Two different dependency graphs had been accumulated into one range. A child's +size answer constrains its parent only when the parent reads that answer; +the child's drawing constrains the parent's retained drawing whether its size +was read or not. A stack sized by one child must not remeasure because an +unmeasured overlay wrapped at another width during provisional layout. -**Still not ready to replace PR #18.** The prototype does substantially more -nested layout work than `e44dea3`, even after retaining extent-relative child -frames (seed 1, depth 8): +`Painter` now collects these contracts separately. `DrawResult::size()` and +`measure_len` contribute answer dependencies; every child draw contributes +drawing dependencies. Direct numeric and raw-placement reads conservatively +constrain both. The original offer retains the answer contract, and final +placement retains the drawing contract. Previous attempts separated storage +without separating which child dependencies entered each contract, so they +continued to invalidate measurements for unrelated drawing work. -| phase | widget draws at e44dea3 | current | primitive writes at e44dea3 | current | +**A valid answer does not certify a valid drawing.** Root resize checks both. +Local updates propagate an invalidated drawing contract as well as an invalidated +answer; otherwise an ancestor can retain a broad resize range after a descendant +begins reading its pixel width. Focused regressions cover both paths and fail when +those checks are removed. No geometry check or equality requirement was relaxed. + +**A wider validity range does not invalidate an existing guarantee.** When a +local redraw returns the same size under a contract covering the old one, the +old answer contract remains sufficient. Keep it, and likewise keep a still-valid +old drawing contract. Adopting the wider range without telling the parent would +lose which guarantee the parent relies on; keeping it prevents both widening +and narrowing back from causing layout. Scroll alternates between an exact +width and a wider interval as it enters/leaves end anchoring. Comparing whole +contracts by equality caused four draws and doubled scroll cycles after the +initial dependency split (`f860f71`). Retaining the valid guarantee reduces +scroll to one draw, below the previous baseline's two. It introduces no retained +state or separate propagation subsystem. A focused test widens/narrows several +times without redrawing the parent, then resizes and requires the leaf to redraw. + +**Declared-size changes use ordinary bottom-up propagation.** The changed child +and its direct parent remain marked until the parent resolves the new rule. +Further propagation depends on the parent's new answer and drawing contract. +The blanket ancestor walk, `answer_invalid` set and frame-global +`replace_answers` flag are gone. They predated strict bottom-up settlement and +original-offer replay; a parent's independent answer no longer invalidates the +whole tree merely because a descendant declared a new width. A regression +checks the changed geometry and that propagation stops at an independent parent. + +**The `c44bd19` extent-child extension was removed.** It changed +`widget_within` to accept `DrawRegion::Extent` and enlarged retained child records, +but saved only 1.8% resize cycles and no primitive writes. The dependency fixes +address the major cost with the original `widget_within(UiRegion)` API and +widget-id-only inherited-child list. Pad and Stack again read raw placement; +those conservative reads are not the cause of the pathological update counts. + +Instrumented work, seed 1/depth 8, 50 frames per phase (cold once): + +| phase | draws at c44bd19 | current draws | primitive writes at c44bd19 | current writes | | --- | ---: | ---: | ---: | ---: | -| cold | 369 | 463 | 9635 | 9179 | +| cold | 463 | 484 | 9179 | 9179 | | repaint | 1 | 1 | 1 | 1 | -| many | 157 | 263.1 | 4721 | 5873 | -| size | 16 | 240 | 106 | 5200 | -| scroll | 2 | 2 | 0 | 0 | -| resize | 13 | 278 | 0 | 5730 | +| many | 263.1 | 274 | 5873 | 5873 | +| size | 240 | 3 | 5200 | 105 | +| scroll | 2 | 1 | 0 | 0 | +| resize | 278 | 27 | 5730 | 0 | -These are instrumented work counts, not speedups (50 frames per phase except -cold, which is one; 578 generated widgets, 191 finally active). The -measurement-only change at `7601aa2` removed 7 widget draws and 525 -primitive writes per size frame, and 8 draws and 525 writes per resize frame, -relative to `2ed5503`. The extent-relative continuation below skips additional -container evaluations but does not reduce primitive writes. +Resize performs no text renders. Size updates render one text. Removing the +extent-child extension increases cold/many container evaluations again. Do not +hide those costs behind the large size/resize gains. The original `e44dea3` +measured 16 draws/106 writes for size and 13/0 for resize, but does not implement +the corrected frame/extent semantics, so it is context rather than a correctness +baseline. -**Measure cycles as well as instructions** (Bryan, 2026-09-17). The following -are whole-process medians from seven alternating before/after runs of direct, -uninstrumented release test executables under `perf stat -e -cycles:u,instructions:u`. The baseline is `39f7b08`: `2ed5503` with the same -centering fix, so both sides draw the corrected layout. CPU-heavy scans were stopped -during measurement. Counts include fixture setup; size, resize and many run -5000 frames, repaint and scroll run 1,000,000. These are VM CPU measurements, -not phone frame times. +Nine alternating pairs of direct, uninstrumented release executables compare +`c44bd19` with `0e107f0` under `perf stat -e cycles:u,instructions:u`. All samples +were retained, with no scans or builds running during measurement. Counts include +fixture setup; these are VM CPU measurements, not phone frame times. -| phase | cycles before → after (billions) | instructions before → after (billions) | +| phase (frames) | median cycles before → after (billions) | median instructions before → after (billions) | | --- | ---: | ---: | -| size | 12.8008 → 11.0344 (-13.8%) | 34.3660 → 29.9450 (-12.9%) | -| resize | 14.9368 → 13.0376 (-12.7%) | 39.8487 → 35.0032 (-12.2%) | -| many | 14.8875 → 12.9579 (-13.0%) | 39.6021 → 34.7499 (-12.3%) | -| repaint | 2.7416 → 2.6626 (-2.9%) | 6.0266 → 6.0197 (-0.1%) | -| scroll | 9.7848 → 8.4829 (-13.3%) | 21.7203 → 20.9527 (-3.5%) | +| size (2,000) | 4.0774 → 0.2021 (-95.0%) | 10.4017 → 0.4993 (-95.2%) | +| resize (2,000) | 4.7070 → 0.4930 (-89.5%) | 11.9383 → 1.3009 (-89.1%) | +| many (2,000) | 4.7718 → 5.0276 (+5.4%) | 12.0567 → 12.4463 (+3.2%) | +| repaint (1,000,000) | 2.6803 → 2.9086 (+8.5%) | 6.0224 → 6.4382 (+6.9%) | +| scroll (300,000) | 2.6252 → 1.3443 (-48.8%) | 6.5802 → 3.0649 (-53.4%) | -The size/resize/many cycle samples have nonoverlapping before/after ranges, -with each range less than 1.3% of its median. Additional depth-6 fixtures, -10,000 frames and seven alternating pairs each: seed 3 improves cycles by -7.0% for size and 8.4% for resize; seed 13 improves resize by 9.2%, while its -size samples overlap and establish no cycle improvement. +All five before/after cycle ranges are disjoint. The main gains coexist with +5.4% more cycles for `many` and 8.5% more for repaint. No claim is made that the +prototype is now uniformly faster. -**Glyph emission now composes its origin once per text draw.** Glyph offsets -and bitmap sizes are pixel lengths, so composition adds them unchanged. The -fresh draw resolves the shared frame/extent origin once, then adds each glyph's -offset and size. Retained local geometry is unchanged; a regression compares -fresh drawing with retained recomposition for fractional origins, negative -pixel offsets, changing frames and extents, and region nodes. This adds no -retained state and changes no layout decisions. +Additional depth-6 fixtures, nine alternating pairs and 4000 frames: seed 3 +size updates use 20.2% fewer cycles and 23.0% fewer instructions, while its resize +case costs 5.4% more cycles and 3.5% more instructions. Seed 13 resize uses 6.6% +fewer cycles and 7.7% fewer instructions. Those cycle ranges are disjoint too. +Seed 13's size case performs no widget draws and its cycle ranges overlap. -Against `7601aa2`, nine alternating pairs of direct, uninstrumented release -executables under `perf stat -e cycles:u,instructions:u`, seed 1, depth 8, -2000 frames per process, including setup: +Verification: workspace formatting, clippy with `layout-diagnostics`, and tests +(106 suite tests, 21 core tests, fast generated cases) pass. Exact release scans +pass 2000 seeds/depth 4 and 1000/depth 6; the shrinker passes 400 trees/depth 5, +and the debug scan passes 120 seeds/depth 4 with assertions enabled. Every scan +runs all fifteen scenarios without a position tolerance. Each of the five new +regressions fails when its corresponding behavior is deliberately broken. +All five reference renders (`view`, `minimal`, `random`, `tabs`, `text`) match +`a7307d9` pixel for pixel. Resizing `tabs` from 1920x1200 to 900x1200 matches a +cold render there, also with zero differing pixels. The GPU probe reports Venus +on the RX 7900 XT. -| phase | cycles before → after (billions) | instructions before → after (billions) | -| --- | ---: | ---: | -| size | 4.5164 → 4.0909 (-9.4%) | 12.1709 → 10.3993 (-14.6%) | -| resize | 5.3025 → 4.8044 (-9.4%) | 14.2150 → 12.1999 (-14.2%) | -| many | 5.2743 → 4.8071 (-8.9%) | 14.0989 → 12.0868 (-14.3%) | +**Remaining work is narrower, but not proved unavoidable.** Cold layout and +widespread content updates still run descendants while evaluating provisional +sizes, including children whose answers will never be read. A disposable trial +made child requests lazy during measurement and skipped primitive emission, but +reused the same active records for measurement and painting. It failed nine +oracle scenarios and made dirty updates much worse. Measurement cannot erase +or replace the final drawing's dependency/lifetime state. The trial was removed; +there is no second widget layout body or measurement phase in this change. -All nine samples per side were retained; before/after cycle ranges do not -overlap. No scans or builds ran during measurement. All instrumented work -counters match `7601aa2` on all six phases (50 frames, cold once), including -the primitive counts in the table above: this reduces the cost of -emitting the repeated glyphs, not the repeated layout evaluations themselves. +The hot wrapping text really does return different heights at 68 and 89.53613 px, +so some width-dependent evaluation is necessary. That does not justify repeating +it after an unchanged size answer. The resize trace and reduced stack/overlay +case distinguished those two situations and led to this fix. Future work on +cold/many should preserve that distinction rather than weakening raw-placement +checks or memoizing arbitrary width results without a validity model. -Additional depth-6 fixtures, nine alternating pairs and 4000 frames each: -seed 3's size updates improve cycles by 5.0% and instructions by 8.0%; seed -13's resizes improve cycles by 8.5% and instructions by 13.6%, both with -nonoverlapping cycle ranges. Seed 3's resize and seed 13's size ranges overlap. -On seed 1/depth 8, cold (one frame/process) and scroll (300,000 frames) also -have overlapping cycle ranges. Repaint (1,000,000 frames) measures 1.1% more -cycles, with nonoverlapping ranges and just 0.02% more instructions: this slice -has a small measured repaint cost alongside the text-heavy gains. All nine -samples per side were retained in these checks too. - -The verification runs above pass again at this head. All five reference -renders (`view`, `minimal`, -`random`, `tabs`, `text`) match `7601aa2` pixel for pixel at 1920x1200; `tabs` -resized to 900x1200 matches a cold render there. The GPU probe reported Venus -on the RX 7900 XT in this session. - -Two trials were removed: combining frame recomposition with extent movement -saved under 0.8% of instructions and no cycles; special-casing equal endpoint -fractions in `UiSpan::within` cost 11% cycles and 7% instructions despite still -inlining. Keep that hot general composition small. - -**Child frames can now follow the extent without evaluating their container.** -`widget_within` accepts the same `DrawRegion::{Frame, Extent}` reference as -primitives and masks. `Pad` retains its pixel inset in extent coordinates; -`Stack` inherits placement for its sizing child and retains extent-relative -frames for its other children. Moving the extent replays those requests through -`ask_box`, preserving child alignment and region nodes. This extends the existing -inherited-child walk rather than adding a separate layout body or a second cache. -It stores a region-bearing relation per extent-following child, so retained -child records are larger than the old widget-id-only list. - -Two dependencies stay conservative. Fractional endpoints compose before pixel -evaluation, so their bounds cannot in general be pulled back through the extent -length alone; those frames still depend on raw placement. Reading a child's -fractional report also depends on placement because composing that report into -the parent's frame can change its value without changing the child's answer. -Pixel-inset frame bounds use the existing exact `Holds::through`. - -**Keep the measured slot when repositioning.** A first trial recomputed it from -`active.size`, which is the final draw's report. Text measured narrow and placed -wider may report a different height at the latter width; the slot is still the -one chosen from measurement. Seed 20/depth 4 caught this. The retained slot is -now preserved on axes not explicitly assigned by the parent. A small synthetic -width-dependent widget pins the same failure without fonts. - -Against `a7307d9`, seed 1/depth 8 skips 21 draws cold, 10.9 per `many` frame, -7 per size frame and 14 per resize frame. Repaint, scroll, and all primitive-write -counts are unchanged. Nine alternating pairs of direct uninstrumented release -executables under `perf stat -e cycles:u,instructions:u`, including setup: - -| phase | median cycles before → after (billions) | median instructions before → after (billions) | -| --- | ---: | ---: | -| size (2000 frames) | 4.0819 → 4.0714 | 10.4107 → 10.4017 | -| resize (2000 frames) | 4.8108 → 4.7237 | 12.1854 → 11.9252 | -| many (2000 frames) | 4.7918 → 4.7787 | 12.0925 → 12.0567 | -| repaint (1,000,000 frames) | 2.6701 → 2.6851 | 6.0175 → 6.0195 | -| scroll (300,000 frames) | 2.6296 → 2.6273 | 6.5025 → 6.5817 | - -Only resize establishes a cycle improvement: **1.8%**, with nonoverlapping sample -ranges, and 2.1% fewer instructions. Every other phase's cycle ranges overlap; -scroll uses 1.2% more instructions. All samples were retained and no scans or -builds ran during measurement. This is a small reduction in work, not closure of -the main gap. - -Verification: workspace formatting, clippy with `layout-diagnostics`, tests -(104 suite tests, 21 core tests), exact release scans of 2000 seeds/depth 4 and -1000/depth 6, the shrinker over 400 trees/depth 5, and a debug scan of 120 -seeds/depth 4 with assertions enabled. -The new no-redraw test checks nested padding/stacks, fixed child -alignment, primitives, masks, and region nodes; restoring the old container -calls makes it fail with three draws instead of two. Additional tests cover -fractional reports and numeric dependencies. All five reference renders match -`a7307d9` pixel for pixel at 1920x1200, and the resized 900x1200 `tabs` matches a -cold render there. The GPU probe reports Venus on the RX 7900 XT. - -**The remaining gap has not been established as an unavoidable correctness -cost.** Before the extent-relative change, a temporary failure trace of one -frame on seed 1, depth 8 counted 39/108 failed reuse attempts in size, 65/151 in resize, and 45/109 in many -where only the raw placement snapshot differed: both numeric frame and extent -ranges still held. Containers read that snapshot to construct child positions, -so discarding the check would be wrong. Padding and stacks now retain their -extent-relative child frames, but `Span` still constructs its mixed-reference -slots from a raw placement read: along the row, child fractions refer to the -frame while cursor positions and leftover room refer to the extent. Retaining -those expressions without changing their fixed-point evaluation order is the -next protocol question. There is also necessary width-dependent work: the hot text at widget 199 visits 68 px and 89.53613 px -and reports different heights (316.80078 and 228.80078). It is evaluated -repeatedly at those widths, so that fact alone does not justify all the repeats. - -Keeping measurement validity separate from final drawing validity and deferring -to an already dirty parent were previously tried without improving draw counts. -Measuring first in local redraw and then restoring its slot saved only one draw -in `many` and added another request on simple repaints; that trial was removed. -Retaining local coordinates still costs memory per primitive. Optimize under -the exact oracle before adopting the prototype; the `Inset`/`Outset` changes -remain parked. +The earlier glyph-origin composition optimization (`a7307d9`) remains; it changes +no layout decisions or retained state. Two removed arithmetic trials remain +unpromising: combining frame recomposition with extent movement saved under 0.8% +instructions and no cycles; special-casing equal endpoint fractions in +`UiSpan::within` cost 11% cycles and 7% instructions. Retained local coordinates +still cost memory per primitive. The prototype remains separate from PR #18; +the `Inset`/`Outset` changes remain parked. ## How layout is decided @@ -774,7 +732,8 @@ is a useful regression target for the experimental recomposition. - `Holds` is the interval of box lengths for which a widget's drawing and reported size stay valid. Reading `Painter::px_len` or `px_size` narrows it to the length read; `Painter::holds` widens it. Parent validity is the - intersection of what its children induce. The contract is trusted: a + intersection of what its children induce: measured children for an answer, + all painted children for a drawing. The contract is trusted: a widget declaring a wrong range is a defective widget, and Iris adds no defensive work to recover from one. - A retained drawing can be reused only when its `Holds` contains the new @@ -791,26 +750,18 @@ is a useful regression target for the experimental recomposition. long as the offer; anything else is its parent's question, with the mark left on. Lengths and not whole boxes: what a drawing depends on is its lengths, so the same lengths elsewhere is the same question. -- An answer is reusable only where both its measurement and the drawing in - its final placed box remain valid; the drawing's `Holds` is translated - back through `placed_lens` and intersected with the answer's. -- `Painter::widget_decided(child, region, [bool; 2])` says the parent chose - this box from the child's own answer along those axes, so the answer is - not placed inside it again. A report of "half of what you give me" has no - fixed point but zero, so the framework asks exactly twice: at the offer, - and in the box chosen from the answer, final on the decided axes. `Span` - decides the row axis, `Scroll` both, `Stack` both for its sizing child. - `Pad` overrides nothing: its inset is exactly the inner where the box is - its answer, and the slack is the inner's to sit in otherwise. -- **Placement cannot be applied after the fact.** Three attempts at "draw - the widget, then move its drawing to where its alignment says" failed, - because the move is a change of frame and no split of the stored state - carries it: moving `ActiveData::region` with the drawing made a later - local redraw ask a differently rounded question, and leaving it made - `placed` accumulate without bound because `try_reuse` returns a clean - subtree's size without walking into it. Alignment is applied where the - size is known -- `declared_box` for a rule, the placing second ask - otherwise. +- An answer is reusable where its measurement contract holds. Its final + drawing is checked independently and may need redrawing even while the answer + stands. Drawing validity is translated back through the chosen placement for + the parent's drawing contract, not intersected into the retained answer. +- `Painter::widget_at(child, frame, placement)` supplies an optional chosen + extent on each axis. Unchosen axes place the measured answer by the child's + alignment. `measure_len` leaves a fresh measurement at the offer until the + parent assigns a slot; it does not first place an intermediate answer. +- Placement must not replace the fraction reference. The failed earlier + attempts moved `ActiveData::region` with the drawing and made local redraws + ask differently rounded questions. The experiment retains `placement` + separately and replays original local frames when recomposing geometry. - A widget that clips to its box reports its box: `Scroll` and `Masked` report `LEFTOVER` on both axes, and a `debug_assert` holds any widget that set a mask this draw to it. Overflowing is otherwise ordinary, which @@ -858,9 +809,9 @@ is a useful regression target for the experimental recomposition. parent (seed 10). `Painter` records size-dependency edges only when a parent reads a child's size or hint; an undrawn measured child stays recorded so a later change reaches whoever decided not to draw it. -- Dirty widgets settle deepest-first, and that ordering is what makes an - answer trustworthy; `dirty_size_under` is to be deleted once a resize goes - through the same walk (see "A frame settles strictly bottom-up"). +- Dirty widgets settle deepest-first, including during resize. A deferred + child leaves its parent marked, so no clean answer can hide an unsettled + size dependency. `dirty_size_under` has been deleted. - Declared non-`leftover` lengths are resolved by the parent where the widget is drawn, so a declared-length change redraws the parent. A rule wins on the axis it names and the widget under it never learns of it. diff --git a/docs/PLAN.md b/docs/PLAN.md index 9bc092f..2fc0092 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -879,14 +879,19 @@ prototype has significant container-update costs and is not yet adopted into the pinned framework. A container's length query can evaluate the offered extent without first placing the child's reported size; the parent's subsequent draw assigns the final slot using the same `Widget::draw` body. A parent-selected slot -still honors a fixed child's alignment within that slot. Child frames can also -retain an extent-relative region through `widget_within(DrawRegion::Extent)`; -padding and stacks use this instead of baking the current placement into child -coordinates. Pixel insets carry numeric validity through the extent. General -fractional endpoints and fractional reports retain conservative placement -dependencies; fixed-point composition must not be reassociated to remove them. -Retained movement preserves the slot chosen from measurement, even if the final -draw reports another size. +still honors a fixed child's alignment within that slot. Answer dependencies +are collected only from child sizes actually read; drawing dependencies +include all painted children. Root resize and local propagation check both +contracts. An unchanged answer with a wider validity range retains its old +contract: the parent's guarantee is still valid, and narrowing back must not +trigger layout. The same rule applies to retained drawing validity. A changed +declared size defers to its direct parent, then ordinary bottom-up propagation +stops when both contracts remain unchanged. Blanket ancestor invalidation is +unnecessary. The extent-relative child-frame API trial was removed: the major +size/resize cost came from conflating these dependencies, not from Pad and +Stack's conservative raw-placement reads. Cold layout and widespread content +changes still perform excess provisional work; that residual has not been +established as necessary for correctness. **A `Span` leaves its children in the offered orthogonal region unless explicitly compacted** (2026-09-12). It still reports the widest orthogonal