From 36ce66554b3ea86a53ee0c1404e244c3723824e0 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sat, 19 Sep 2026 00:21:44 -0400 Subject: [PATCH] Record what making the frame a window length corrected Step 2 of the layout plan is done in the experiment checkout, at 1512d84 and 23523ee. The handoff carries the commits and what is left; LAYOUT.md carries the settled rule, which is now one coordinate unit with the frame a length of it and both pins beside each other; the log carries what implementing the plan corrected in it, including the four that would have shipped as wrong layout and the two rig gaps that hid them. --- docs/HANDOFF.md | 192 ++++++++++++++------------------------------- docs/LAYOUT.md | 50 ++++++++---- docs/LAYOUT_LOG.md | 100 +++++++++++++++++++++++ 3 files changed, 196 insertions(+), 146 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1232960..90afa0d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -14,24 +14,31 @@ detached comparison checkout is `/home/bob/repos/iris-layout-baseline`. It is the reviewed baseline this work must preserve or improve. The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch -**`wip/one-ask`** at **`0ef87eb`**, two commits over `4328eac` (the head of -`wip/transparent-frames`, which is unchanged). It replaces the step 3 plan -below with a smaller protocol change, and it passes every check: +**`wip/one-ask`** at **`23523ee`**, four commits over `4328eac` (the head of +`wip/transparent-frames`, which is unchanged). It replaces the old step 3 +plan with the one-ask protocol below, and `1512d84` and `23523ee` make the +frame a length of the window. It passes every check: -| check at `0ef87eb` | result | +| check at `23523ee` | result | | --- | --- | | `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean | -| `cargo test --workspace` (debug) | 115 suite, 20 core, 11 generated, all green | -| the two decided-box pins from step 1, and the new seed 946 pin | **pass** (the first two were red at `4328eac`) | +| `cargo test --workspace` (debug) | 122 suite, 20 core, 11 generated, all green | | `cargo test --release --test generated` | 11/11 | -| shrinker, 400 seeds, depth 5, all fifteen cases | agree, 56 s | -| 2000-seed depth-4 scan, all fifteen cases | agree, 262 s | -| 1000 seeds at depth 6 | agree, 148 s (found seed 946 at `3091fb8`, fixed and pinned in `0ef87eb`) | +| shrinker, 400 seeds, depth 5, all sixteen cases | agree, 66 s | +| 1000 seeds at depth 6 | agree | +| 2000-seed depth-4 scan, all sixteen cases | agree | -Not done: the renders and the `tabs` replay (step 2), and the pre-submit -review of the two commits. +What implementing it corrected in the plan is in `docs/LAYOUT_LOG.md`; the +four that would have shipped as wrong layout are a share inside padding +losing the padding twice, a root resolving its own rule twice, a rule changed +over two pads relocating the column under them instead of dividing it again, +and a resize leaving a short scroll's window-tall content where it was. Each +is pinned as a named test. -The worker's uncommitted step 3/4 experiment is preserved as branch +Not done: the renders and the `tabs` replay, the retained-cost work, the +renames, and a pre-submit review of the three commits as one diff. + +The worker's older step 3/4 experiment is preserved as branch `wip/step3-experiment` (one commit over `4328eac`) and as `~/repos/iris-step3-experiment.patch`. It is evidence, not the protocol. The app's Iris pin is unchanged. @@ -77,7 +84,7 @@ is the third plan for this repair and the next one should not repeat it: **A widget draws once, in the box it is asked in. Its answer is placed inside that box by re-expressing the drawing. Nothing is drawn again in a box an answer chose.** `Holds` is a contract about the ask box alone, consulted only -to decide whether a re-ask can be skipped. This is `draw_inner` at `3091fb8`: +to decide whether a re-ask can be skipped. This is `draw_inner` at `1512d84`: ```rust let reused = (!stale) @@ -85,8 +92,7 @@ let reused = (!stale) .flatten() .and_then(|answer| { let extent = placed_extent(part, answer.0, declared, info.fill(), align); - self.try_reuse(id, frame, part, extent, info, rsc) - .map(|()| answer) + self.try_reuse(id, part, extent, info, rsc).map(|()| answer) }); let answer = reused.unwrap_or_else(|| { if old.is_none() { @@ -95,7 +101,7 @@ let answer = reused.unwrap_or_else(|| { let answer = self.draw_at(id, part, info, old.take(), rsc); let extent = placed_extent(part, answer.0, declared, info.fill(), align); if extent != part { - self.reposition(id, frame, extent, info, rsc); + self.relocate(id, extent, info, rsc); } answer }); @@ -118,8 +124,10 @@ with a new call that never runs the body: pub fn place_at(&mut self, id: &StrongWidget, place: [Place; 2]) ``` -A frame is narrowed by a *length* of the parent's frame, never a region, and -is put back into the part by the child's alignment on every placement: +A frame is narrowed by a *length of the window*, never a region and never a +fraction of the parent's frame -- a row's slot cannot be written as a +fraction of the row. The box stays whatever `place` names; only a declaration +places the box inside it, by the child's alignment, on every placement: ```rust pub fn widget_at<'s, W: ?Sized>( @@ -150,9 +158,11 @@ let used = match len.leftover > Weight::ZERO && shares { }; ``` -`Stack` asks non-sizing children in the box the sizing child decided, with -the frame narrowed to it on every axis that is not a share, and `Scroll` asks -its content once in the viewport and `place_at`s it to the scrolled offset. +`Stack` asks non-sizing children with `Part::Sized(len)` of what its sizing +child decided, on every axis that is not a share -- a box of that length +where their own alignment puts it, and that length as their frame -- and +`Scroll` asks its content once in the viewport and `place_at`s it to the +scrolled offset. A local redraw asks the retained question again -- the same place of the box the parent was *asked* in -- and, if the answer stands, puts the fresh drawing @@ -216,12 +226,11 @@ row by exactly the icon's width. With the pad in a share instead, both are the share less 32. An icon *after* the padded text overflows; a user who wanted otherwise meant `leftover`. -**The experiment does not implement this**, and cannot in its representation: -a narrowed frame is its own box there (`frame_and_extent` sets the extent to -`FULL` of it), so 868 for `rel` and 844 for the wrap cannot both hold. `Pad` -still insets only the box through `Part::Of` and forwards the frame whole, -which makes `rel(1.0)` under it 900. Step 2 below is the representation -change that expresses the rule. +Implemented at `1512d84`. `Pad` reads its own frame (`Painter::frame_len`, +which pins it), takes the padding off, and hands that down as the child's +frame, while the box it gives is the inset part of its own box. The two are +different lengths whenever the box is narrower than the frame -- which is +exactly the wrapping case above. ### A share never adds room beyond the deciding box @@ -235,114 +244,31 @@ Unchanged; see `docs/LAYOUT.md`. ## Implementation plan Work in `/home/bob/repos/iris-layout-experiment` on `wip/one-ask` from -`3091fb8`. Make each step a warning-clean commit and run its named checks +`23523ee`. Make each step a warning-clean commit and run its named checks before the next. If a step exposes a different mechanism, stop and update this handoff rather than papering over it. -### 1. Review the two commits +Anything a fuzzer finds is shrunk first (`SHRINK_SEED= +SHRINK_DEPTH= SHRINK_CASE=`, which now prints each level's +frame, ask, box and size warm against cold), pinned as a named test in +`tests/cases/unsettled.rs`, then fixed under the two rules above. Do not add +a second draw back. -Run the pre-submit review over `3091fb8` and `0ef87eb` as one diff against -`4328eac`; they were written as a probe and reviewed only by their tests. -Anything a fuzzer finds later is shrunk first (`SHRINK_SEED= -SHRINK_DEPTH= SHRINK_CASE=`), pinned as a named test in -`tests/cases/unsettled.rs`, then fixed under the rule above. Do not add a -second draw back. +### 1. Review the four commits + +Run the pre-submit review over `3091fb8`, `0ef87eb`, `1512d84` and `23523ee` +as one diff against `4328eac`. `1512d84` rewrote much of what the first two did to +`painter.rs` and `render_state.rs` and was reviewed as it was written, but +the three have never been read as one change, and the parts of the one-ask +protocol it did not touch -- `place_at`, the twice-asked deferral, `Span`'s +two passes -- were written as a probe and reviewed only by their tests. ### 2. Make the frame a length and the box a region -The frame stops being a coordinate system. **There is one coordinate unit, -the window**: every box in the tree is a region in window units, a widget's -frame is a *length* in the same units and is only what fractions resolve -against, and a region node's move entry is a *translation* -- a region `rel -1` long anchored at the node's box start -- rather than a box. The last -point is forced, not chosen: a node whose unit was its frame would need its -box expressed in frame units, which is a division of two lengths and not a -`rel + px` length (a `.region_node()` child of a pad in a span room is the -case). **Decided by Bryan, 2026-09-18: yes.** It changes one cost. A node that -moves, or scrolls, is one entry write whatever narrowed it (today only a -narrowed node gets that; a transparent one is re-expressed), and a node -whose box changes *length* at the same window size is re-expressed as a -subtree (today a narrowed node is one write). The shader's `resolve_move` -composes a `rel 1` entry as a translation already and does not change. - -```rust -pub struct Painter<'a> { - /// This widget's frame as a length of its region node's box, per axis: - /// what a fraction it declares or reports, and a `From` span under it, - /// are fractions of. A length and not a region, so the box need not be - /// the frame -- padding takes from both without either becoming the other. - pub(super) frame: UiVec2, - /// Where this widget's drawing goes, in the node's coordinates. - pub(super) extent: UiRegion, - /// The window in pixels: the frame in pixels is `frame.to_px(window)` - /// and the box's is `extent.size().to_px(window)`. Every `Holds` range - /// is in these pixels, so `in_parent` combines them without `through`, - /// and a widget's own frame reads convert once: `Holds::at(frame_px) - /// .through(frame)`. - pub(super) window: PxVec2, - ... -} - -``` - -`Part::of` does not change: with one unit a `From` span is already a -window-unit length, so a span's cursor, `far`, `room` and slots stay as they -are and only its shares boundary reads `room.to_px(window)`. Two places -resolve through a frame: a reported fraction (`in_parent_frame`, now -`size.within_len(child_frame)`) and a declared one (`narrowed_by`, then a -`Sized` part placed by alignment). A widget's own frame reads convert once, -`Holds::at(frame_px).through(frame)`, the exact preimage of the `to_px` that -made them. `recompose_subtree` and `reposition` become one operation, since -a child's box is always `Part::of(parent box, place)`; `asked_px` is the -window. - -A child's frame is `narrow.within_len(parent.frame)` where the caller or a -rule narrowed it, else the parent's; a declared length also decides the box -(`len` placed in the part by alignment, as `frame_and_extent` does today), -where a caller's `narrow` decides the frame alone and the place decides the -box. `Pad` then says both halves of the rule: - -```rust -let inset = |lead: Px, trail: Px| { - Place::Within(Part::Of(UiSpan::new( - Len::from_parts(Rel::ZERO, lead), - Len::from_parts(Rel::ONE, -trail), - ))) -}; -let less = |lead: Px, trail: Px| Some(Len::from_parts(Rel::ONE, -(lead + trail))); -let inner = painter - .widget_at( - &self.inner, - [less(self.padding.left, self.padding.right), less(self.padding.top, self.padding.bottom)], - [inset(self.padding.left, self.padding.right), inset(self.padding.top, self.padding.bottom)], - ) - .size(); -``` - -What this changes in the core, and why it is not merely more code: - -- `Painter::resolve` becomes `region.within(&self.extent)`; the second - `within` through the frame goes. -- `in_parent` no longer maps a child's frame holds through the frame length - per level: every widget holds for the window's pixels, so the holds `and` - directly, at region nodes too. -- `DrawInfo::frame`/`frame_abs`, `ActiveData::frame`/`frame_abs`, `local` - in `Placing`, `recompose_subtree` and `asked_px` all change meaning: - `frame` is a `UiVec2` length, `frame_abs` goes, a node's move entry is a - `rel 1` region at the node's box start, and `asked_px` becomes the window - size. -- `Stack` needs a part that is "a box of this length, placed by the child's - alignment" (`Part::Sized(Len)`, the same thing a declared length does), - since `narrow` no longer makes the box. - -The random rig and the shrunk tests do not change. Expected precision is the -same as today (one `rel × rel` per level of narrowing). Checks: the suite, -the fast oracle, the 400/5 shrinker, then both long fuzzers; the `many`, -`resize` and `cold` counters must not move by more than the reposition work -this removes; and two new tests, `rel(1.0)` inside `.pad(16)` after a 24 px -icon is 868 px while the wrapped text beside it wraps at 844, and the same -inside a 450 px share is 418 for both. `examples/text.rs` then renders -inside its padding. +**Done at `1512d84` and `23523ee`.** The settled rule is in `docs/LAYOUT.md` under +"Frames, decided boxes and padding"; what implementing it corrected in the +plan -- three of them wrong layout that would have shipped -- is in +`docs/LAYOUT_LOG.md`. ### 3. Render and replay @@ -358,8 +284,12 @@ likely to show here; inspect every intentional difference and record it. Rename `ActiveData::offer_part` to `part`, `offer_place` to `asked`, `place` to `placed`, and `DrawInfo` likewise; delete `ActiveData::measured` in favour of reading `answer`; delete `answers_at` if `resize` is its only caller and -inline it. Every use was written against the old names on purpose to keep -the probe's diff readable; do this as one mechanical commit. Suite, oracle. +inline it. Rename `LayoutHolds::frame` to `window` and `Painter::frame_own` +with it: since `1512d84` those ranges are window pixels, and the only thing +that writes them is `Painter::window_holds`; the frame's own entry is the +`frame_len` pin beside it. Every use was written against the old names on +purpose to keep the probe's diff readable; do this as one mechanical commit. +Suite, oracle. ### 5. Restore the expected retained cost @@ -423,7 +353,7 @@ SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \ cargo test --release --test shrink -- --ignored --nocapture ``` -The last line is the 2000-seed depth-4 scan over all fifteen cases; the +The last line is the 2000-seed depth-4 scan over all sixteen cases; the shrinker runs the same cases as the scan and reduces anything it finds, so no temporary test body is needed any more. `Rng::new` uses `seed | 1`, so adjacent even/odd seed pairs describe the same tree. diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 74f41fa..cd7fde8 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -333,11 +333,30 @@ 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. A frame is narrowed by a length of the parent's frame, -never by a region, and is put back into the part by the child's alignment on -every placement. This is `wip/one-ask` in the experiment checkout. The -padding rule above needs the frame to be a length and the box a region in -the region node's coordinates, which is the next step in `docs/HANDOFF.md`. +never runs the body. This is `wip/one-ask` in the experiment checkout. + +**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 +units and is only what fractions resolve against, so a widget's box need not +be its frame and padding can take from both. A region node's entry is a +translation -- a `rel 1` region anchored where its box starts -- so nothing +composes a frame back up a chain. + +A frame decided for a child is therefore a length of the window too: a row's +slot, padding's frame less its pixels, or the box a stack's sizing child +decided (which arrives as `Part::Sized`). It is never a fraction of the +parent's frame, because a slot of a row is not a fraction of anything the row +can name -- a division of two lengths, which a `rel + px` length cannot hold. +A widget's own declaration *is* a fraction, of whichever of those reached it, +and it is the only one of them that also places the box inside the part. + +Validity has a pin for each: `LayoutHolds::extent_len` for the box's symbolic +length and `frame_len` for the frame's. A range of window pixels cannot say +which frame an answer is a fraction of, since two frames are different +lengths at the same window size. Resolving any of these lengths in pixels is +a read of the window, and `Painter::to_px` is where that read is taken -- +pinning the window where the length has a fraction in it, and nothing where +it is only pixels. ## Layout decisions and invariants (2026-09-15 to 2026-09-17) @@ -389,14 +408,15 @@ floats. so a boundary derived with a division guards a drawing made with a multiply (`53b00c6`). -### A box in pixels is one multiply from its parent's +### A box in pixels is one multiply from the window -A draw threads pixel lengths down: the box a parent gave a widget, then the -part of that box its own answer placed its drawing in. `Painter::px_size` -and `px_len` read that value, and a local redraw takes the same steps back -up the parent chain (`asked_px`). Neither chain has a coordinate frame in it, -so a region node cannot break either, and warm and cold reach every length -by the same expression. +Every length in the tree is a length of the window, so it becomes pixels in +one multiply wherever it is read: `Painter::px_size` and `px_len` for the +box, `frame_len` for the frame. There is no chain to walk and no coordinate +frame in the way, so a region node cannot break it and warm and cold reach +every length by the same expression. (Before `1512d84` a frame was a length +of its parent's frame and a local redraw walked back up the parent chain in +`asked_px`; both are gone.) - **`Holds::through` is the exact preimage of `px + floor(rel * box)`**: `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and @@ -412,9 +432,9 @@ by the same expression. Nothing layout decides is composed back up the move chain. - **`px` is not stored on `ActiveData`, deliberately.** A resize every widget's `Holds` admits redraws nothing, so a stored pixel length would - be stale on every widget in the tree with nothing to say so. `asked_px` - walks up only where a widget is already being redrawn; the mean chain is - 2.8 levels. + be stale on every widget in the tree with nothing to say so. What is + stored is the symbolic frame, which a redraw resolves against the window + it has. - **The window is not a move entry** (`5b78002`). A chain bottoms out in `MoveIdx::NONE`; the window is applied where a fraction becomes pixels, `to_px(output_size)` on the CPU and the uniform in the shader. A resize diff --git a/docs/LAYOUT_LOG.md b/docs/LAYOUT_LOG.md index 6e86155..b1ebbb2 100644 --- a/docs/LAYOUT_LOG.md +++ b/docs/LAYOUT_LOG.md @@ -7,6 +7,100 @@ must outlive it (settled design, the measurement method) is already in `docs/LAYOUT.md`, and the current plan is in `docs/HANDOFF.md`. Commit ids are in `/home/bob/repos/iris-layout-experiment` unless said otherwise. +## What making the frame a length found (worker, 2026-09-19) + +Step 2 of the handoff, implemented over `0ef87eb` in +`/home/bob/repos/iris-layout-experiment`. Six of these are corrections to the +plan, not to the code that implemented it, so they are worth keeping even +after the step lands. + +- **A narrowed frame is a window length, not a fraction of the parent's + frame.** The plan said both at once: `Pad` narrowing by `rel 1 - 32px` (a + fraction) and `Span` narrowing by `slot.len()` (a window length). The first + implementation resolved every `narrow` against the parent frame, which took + padding off twice for anything inside a padded row -- a `leftover(1)` slot + of a 900 px row inside `pad(16)` gave its child a frame of 418 where the + slot is 434. A slot *cannot* be written as a fraction of the row's frame: + that is a division of two lengths, the same argument that forced region + node entries to be translations. So `narrow` is a window length, `Pad` reads + its own frame with the new `Painter::frame_len` and takes the pixels off, + and nothing resolves a narrow a second time. Pinned by + `layout::a_share_inside_padding_fills_the_slot_it_was_given`. +- **The root resolved its own rule twice**, because `root_layout` passed the + declaration as both the narrow and the declaration: a root of `rel(0.5)` in + a 900 px window came out 225 wide. Pinned by + `layout::a_root_with_a_fraction_rule_is_that_fraction_of_the_window`. +- **Which box arm `in_parent` takes is decided by the box, not the frame.** + The first implementation treated any decided frame -- a narrow, a share, a + declaration -- as a box this widget chose, so a `Pad` (which narrows) lost + its child's box pin, and a rule changed over two zero pads relocated the + column under them instead of dividing it again (seed 59, depth 5, + `resize-size`). Only a declaration places the box inside the part it was + given; a narrowed frame leaves the box exactly the part. Pinned by + `unsettled::changing_a_rule_over_two_pads_divides_the_column_again`. +- **Frame validity is a pin, not a range.** A range of window pixels cannot + say "this answer is a fraction of *that* frame": two frames are different + lengths at the same window size. `LayoutHolds` therefore carries + `frame_len` beside `extent_len`, set where a widget reads its frame + (`frame_len`, which `Pad` does) and where a rule that is a fraction of the + frame is answered with it, and composed up where a length of this frame is + what reached the child. It replaced a `holds_for_frame` helper that + compared expressions and asked `Holds::ANY` whether anything depended on + them, which missed the rule-answered case entirely. **No test in the repo + distinguishes the rule-answered half**: 400 seeds over sixteen cases at + depth 5 agree with it and without it, and hand-built trees for the shape + the seed-30 records showed (a stale `1 rel` answer beside a stack's sizing + child) do not reproduce it either, because a frame that changes almost + always changes a box as well and the box pins catch it. Kept anyway, + because "these two invalidations always coincide" is the kind of unstated + assumption the three earlier plans died of; it is one line at each of two + sites if it is ever shown to be dead. +- **`Scroll` resolved its content length against its own box.** With one unit + its child's answer is a window length, so it becomes pixels against the + window; the two differ wherever a scroll's box is not its frame. Fixing it + then broke `resize`, because resolving a length against the window is a + *read of the window* and nothing recorded it: a viewport 40 px tall inside + a branch's box does not change when the window does, so an end-snapped + scroll kept the offset it had (seed 942, depth 6). `Painter::to_px` takes + the read where the resolution happens and pins the window only where the + length has a fraction in it; `window_px_len`, which returned the number + and recorded nothing, is gone. Pinned as + `unsettled::resizing_under_a_short_scroll_snaps_its_window_tall_content_again`. +- **A local redraw resolves its own frame** from its record's narrow and + declaration (`ask_again`, shared with `place_in`) rather than reusing the + retained frame. That deleted `asked_px` and the assert that the two agree: + they legitimately disagree when an ancestor's frame changed and the + ancestor was reused because nothing under it pinned the frame, and the + freshly resolved one is the right one. +- **The clipping debug assert compared the reported size with the frame** + rather than with the box it is a claim about. + +Two rig changes came out of it, both kept: + +- The shrinker prints the *shrunk* tree's divergence rather than the grown + tree's, and the divergence now lists, for the failing widget and every + ancestor, warm and cold `frame`, `ask`, `box` and `size`. Reading that + table is what found the box-arm defect; reconstructing a 20-widget plan by + hand would not have. +- A `size-resize` case: a size change and *then* a resize. Every other case + changes something and compares at once or resizes first, so an answer kept + as a fraction of the wrong length agreed at the window it was made at and + parted from cold at every other one. + +Checks at the end of the step (`23523ee`): `cargo fmt --all --check`, clippy +with and without `layout-diagnostics`, 122 suite tests, 20 core, 11 +generated, the 400-seed depth-5 shrinker over all sixteen cases, the +1000-seed depth-6 oracle, and the 2000-seed depth-4 scan over all sixteen +cases. + +One thing is unexplained and worth knowing about: seed 30 at depth 5 +(`shuffle-all-but-first`) failed once, with a span keeping an answer of +`1 rel` for an axis whose frame had become `78 px`, and then stopped failing +before the pin was added -- reverting each part of the change since that run +did not bring it back. Either an edit fixed it that I could not identify, or +the rig is not as deterministic as it looks. If a seed ever fails once and +not again, suspect the second. + ## What the one-ask protocol found (planner, 2026-09-18, third session) Branch `wip/one-ask` at `3091fb8` over `4328eac`. The change is described in @@ -404,6 +498,12 @@ is the padded share and fits. The clipped render records the current bug. was checked this way and is new under the pin experiment). - Depth finds things and so does breadth; widen one axis at a time and record which. +- The cases are what a change is *then* compared at, and until 2026-09-19 + every one of them compared at the window the change was made in. A + `size-resize` case was added because a length kept as a fraction of the + wrong box agrees with cold at that window and parts from it at every other + one. There is still no case that changes a tree twice with a resize + between, and no case that resizes twice. ## Smaller open items (carried from the old handoff)