Skip to content

Posters: a fast scroll stops asking for art it does not already have - #187

Merged
GLinnik21 merged 3 commits into
mainfrom
scroll-motion-gate
Sep 21, 2026
Merged

GLinnik21 merged 3 commits into
mainfrom
scroll-motion-gate

Conversation

@GLinnik21

@GLinnik21 GLinnik21 commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

A 1000-movie library dropped ~95 frames per 18 s sweep of fps:library-scroll and ran at
55 fps, where the 37-movie dev library held 60 with 11 drops. This makes the large library
behave like the small one.

What the cost actually is

Not the CPU, which is why the obvious fixes did nothing. diag::spans reads prep:0.0 on
the very frames whose FRAMEDROP prepare= field says 7 — that span wraps prepare_pass,
which contains the GL upload. What moves instead is clearx2, 0.2 ms → 7-22 ms: the CPU
blocking on a GPU that is behind. A poster upload is cheap to issue and expensive to have
issued, because warm_tex forces the tile conversion and the GPU pays for it later.

The span profile is the same shape at 37 movies as at 1000. Only the number of frames
that miss differs — 1% against 9%.

So the lever is not a bigger cache and not a cheaper upload path. It is not asking.

The change

A draw that misses while the document scrolls faster than the shared threshold claims no
slot: no worker, no fetch, no decode, no upload. The demand is deferred rather than dropped —
nothing about the miss is recorded, so the next frame asks again and art resolves as the view
slows. That is also what the reference clients do.

ui::card_row owns one motion signal, reset per iteration, so a still child cannot overrule a
moving document and no consumer keeps a second opinion beside it. Every axis that moves a
document carrying art writes to it: the page scrolls through Spring::step_scroll, a shelf's
own horizontal scroll_x, and Search, which drives its scroll through motion::spring and so
reports by hand. Chrome deliberately does not — the A-Z rail, the tab strips and the text
panels draw no art and animate over a page that is holding still, so letting one of them raise
the signal would decline posters for a document that is not moving.

Device measurements

Dev set, 1000-movie mock library, panel off, two runs each, dropped frames after warmup:

drops median fps
a9d4e8a2, no gate 90, 97 55
slot + cache caps 64 → 160 (probe-slot-cap) 226, 231 50
rate-limit the uploads after decode (probe-upload-rate-limit) 172, 156 53, 55
allow ONE visible request in flight while scrolling 204, 196 55, 54
this branch 21, 23 and 31, 16 60

Real 37-movie dev library: 13 drops at 60 fps against 11 ungated — the small case never
reaches the gate and is untouched.

The two failed directions are pushed as branches rather than described, so the numbers are
readable without re-running them. The informative row is the fourth: one request in flight
paced the pipeline perfectly (budget= 15-18 admitted, zero refused, against 15-18
admitted and 11-15 refused ungated) and still cost twice the baseline's dropped frames.
Pacing the work does not make it affordable.

One caveat that should travel with the 60 fps

fps:library-scroll's oscillator reverses at the document's own ends and never settles, so
under this gate that scene does no poster work at all (budget=0/0 for the full 18 s). The
fps figure therefore partly measures work not done, and is not by itself proof of a product
improvement. The product evidence is separate: stepping 18 rows into a 1000-item grid leaves
the grid fully populated, and the real library's numbers are unchanged.

Review round (2/2 Codex P2s, both real)

Codex and the doc-claim-auditor independently found the same hole, and Codex found a second.
Both are fixed in 51138943, each with a regression test observed red first.

A shelf did not report. CardRow::update stepped scroll_x with plain Spring::step, so
walking focus along a Home row moved the shelf at 1805 px/s — fifteen times the threshold —
while scrolling_fast() read false, because the grid's vertical spring was parked at its
target. That is the one axis that reveals a screenful of art with no document-level motion at
all, so the gate slept through precisely the reveal it was built for.

The gate guarded only the fresh miss. The two re-arm transitions in the matching-slot branch
return long before it. Those matter more than the miss, not less: a fast scroll is what evicts
textures, so scrolling back across the same rows finds P_EVICTED slots — and a first eviction
sets no cooldown — so each one re-armed a full fetch, decode and upload. A due P_RETRY
re-queued the same way. The decision is now taken once, before the slot loop, and every
initiation point answers from it. A hit is deliberately untouched: resident art still draws and
still takes its LRU touch, so declining never blanks a tile that had its picture.

The doc audit's other three findings were prose this change falsified, corrected in the same
commit: tex::Source::probe no longer promises that a miss always starts a fetch (None can
now mean deferred), lookup's doc no longer calls touch the only difference between the
paths, and docs/poster-scheduler-design.md no longer tells the next implementer to preserve
an admission rule this branch removes, or to A/B a queue ordering against a baseline the gate
has emptied.

Verification

  • make check green on the committed tree; cargo test --lib 4024 passed; 0 failed; 1 ignored
  • cargo check --lib --no-default-features clean
  • make ARM cross-build links
  • Device runs above, taken on the committed tree with the panel and sound off
  • Four host tests, each watched fail against the unfixed code first:
    a_fast_scroll_declines_a_visible_miss_and_a_settle_takes_it,
    a_fast_scroll_declines_to_re_arm_an_evicted_slot (re-armed to P_WANT 1 against P_EVICTED 8),
    a_fast_scroll_declines_to_re_queue_a_due_retry (1 against P_RETRY 7), and
    a_shelf_reports_its_own_horizontal_scroll_to_the_motion_signal (silent at 1805 px/s)
  • The device table above predates the review round; it was measured on ce48077f. The two
    fixes only ever widen where the gate applies, so they cannot move those numbers upward,
    but the re-scroll-over-evicted-rows case they repair is not one fps:library-scroll drives —
    it is unmeasured on the set, and I would not claim otherwise.

🤖 Generated with Claude Code

GLinnik21 and others added 2 commits September 21, 2026 06:04
…ndex

The poster worker pool used to claim the lowest-index pending slot, which is
a priority inversion the moment claims have more than one origin: a
speculative warm queued into a low slot could be picked ahead of a visible
tile a draw is actually waiting on, purely by index luck. Each slot now
carries a monotonic visible bit set the moment any draw touches its key, and
workers scan for a visible P_WANT slot before falling back to a speculative
one. Speculative admission is refused outright while any visible request is
outstanding, and even in a quiet queue it is capped at
PREFETCH_OUTSTANDING_MAX = 1, so speculation can never occupy both workers
and a late-arriving visible miss always finds a worker free.

The tile-lookahead / prefetch feature that originally exposed this ordering
bug is NOT part of this change and does not land: it doubled render-thread
texture uploads during a continuous scroll and dropped frames against the
scrolling baseline on the television. With no lookahead consumer, every
claim in this tree still originates from a visible tile or from the existing
speculative call sites (hero backdrop art, hero logo, an up-next episode
thumbnail), so behaviour here is unchanged from before except for the
ordering guarantee itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 1000-movie library dropped ~95 frames per 18s sweep of `fps:library-scroll` and
ran at 55 fps, where a 37-movie library held 60 with 11. The whole difference is
poster work: the small library reports `budget=0/0` through the sweep because
everything it needs is already resident, the large one reports `budget=15/12`
every heartbeat and 72 of its 97 dropped frames carry a texture upload.

The cost is not on the CPU, which is why the obvious fixes did nothing. `diag::spans`
reads `prep:0.0` on the very frames whose FRAMEDROP `prepare=` field says 7 — that
span wraps `prepare_pass`, which contains the GL upload. What moves instead is
`clearx2`, from 0.2 ms to 7-22 ms: the CPU blocking on a GPU that is behind. An
upload is cheap to issue and expensive to have issued, and the span profile is the
same SHAPE at 37 movies as at 1000. Only the number of frames that miss differs.

So the lever is not a bigger cache and not a cheaper upload path, it is not asking.
A draw that misses while the document is scrolling faster than the shared threshold
now claims no slot: no worker, no fetch, no decode, no upload. The demand is
deferred rather than dropped — nothing about the miss is recorded, so the next frame
asks again and art resolves as the view slows. That is also what the reference
clients do; it is not a concession.

Measured on the dev set, 1000-movie mock, panel off, two runs each:

                                                    drops      median fps
  a9d4e8a, no gate                                 90, 97     55
  slot + cache caps 64 -> 160                       226, 231   50
  rate-limit the uploads after decode               172, 156   53, 55
  allow ONE visible request in flight               204, 196   55, 54
  decline the request outright                      21, 23     60

and on the real 37-movie dev library, 13 drops at 60 fps against 11 ungated — the
small case is untouched, as it must be, since it never reaches the gate.

The three failed rows are kept as branches rather than prose: `probe-slot-cap` and
`probe-upload-rate-limit` carry their own numbers in their commit messages. The
informative one is the fourth: allowing a single visible request in flight paced the
pipeline perfectly (`budget=` 15-18 admitted, ZERO refused, against 15-18 admitted
and 11-15 refused ungated) and still cost twice the baseline's dropped frames.
Pacing the work does not make it affordable.

`ui::card_row` owns the one motion signal, reset per iteration and written by every
scrolling axis through `Spring::step_scroll`, so a still child cannot overrule a
moving document and no consumer keeps a second opinion beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 21, 2026 03:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-21T03:59:31.836034Z 5113894 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce48077fe7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust-modules/src/app/adapters/poster.rs Outdated
Comment thread rust-modules/src/ui/card_row.rs
Review of #187 found the gate in two places it did not reach, and both were
found independently by the Codex connector and the doc audit.

A shelf's own horizontal scroll did not report into the motion signal.
`CardRow::update` stepped `scroll_x` with plain `Spring::step`, so walking
focus along a Home row moved the shelf at 1805 px/s -- fifteen times the
threshold -- while `scrolling_fast()` read false, because the grid's vertical
spring was sitting at its target. That is the one axis that reveals a screenful
of new art with no document-level motion at all, so the gate slept through
precisely the reveal it was built for.

The gate also guarded only the fresh miss, not the two re-arm transitions in
the matching-slot branch. Those matter more than the miss rather than less: a
fast scroll is what evicts textures, so scrolling back across the same rows
finds `P_EVICTED` slots -- and a first eviction sets no cooldown, so each one
re-armed a full fetch, decode and upload through a branch that returns long
before the miss path is reached. A due `P_RETRY` re-queued the same way. The
decision is now taken once, before the slot loop, and every initiation point
answers from it; a hit is deliberately untouched, so art that is already
resident still draws and still takes its LRU touch.

Three regression tests, each observed red first: the evicted slot re-armed to
P_WANT under a fast scroll (1 against 8), the due retry re-queued (1 against
7), and the shelf reporting nothing while travelling at 1805 px/s.

Also corrects the prose the change falsified: `PREFETCH_MAX_SCROLL_PX_S` now
names which axes report and says the rail, tab strips and text panels are
excluded deliberately (they animate over a still page and carry no art);
`tex::Source::probe` no longer promises that a miss always starts a fetch, since
`None` can now mean deferred; `lookup`'s doc no longer calls `touch` the only
difference between the paths; and the scheduler design record no longer tells
the next implementer to preserve an admission rule this branch removed, or to
A/B a queue ordering against a baseline the gate has emptied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5113894319

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust-modules/src/ui/card_row.rs
@GLinnik21
GLinnik21 merged commit c9b595e into main Sep 21, 2026
5 checks passed
@GLinnik21
GLinnik21 deleted the scroll-motion-gate branch September 21, 2026 04:09
GLinnik21 added a commit that referenced this pull request Sep 21, 2026
Follow-up to #187, which gated poster requests on a shared fast-scroll signal. Three review
findings, all the same shape: a motion source the signal never saw.

The hero-to-grid dive is the largest reveal of poster art the application performs, and it
reported nothing. `snap` travels 0 -> 1, so the spring's own velocity is a couple of FRACTIONS
per second, while `layout_grid` turns that fraction into `GRID_TOP_Y - PEEK_Y` = 617 px of
vertical travel for every shelf. Both `grid.scroll_y` and every `scroll_x` can hold perfectly
still through it. Reporting `snap.vel` would not have fixed it -- the units are wrong -- so what
is reported is the displacement the layout actually realises.

The dive scales the shelves' retained HORIZONTAL offsets by the same fraction: `Grid::eff_scroll`
is `scroll_x * snap`, so returning to the hero from the far right of a row sweeps its cards that
offset across the screen while the shelf spring is stationary. The axes do not settle together --
at snap 0.9 the vertical term measures 88 px/s, already under the threshold, while a 4000 px
offset swept by the same step is ~570 px/s. Both are reported and `note_scroll` keeps the larger,
so the gate answers from the document's fastest axis.

Between those two, sharing one expression between `layout_grid` and the motion report turned out
to be unsafe: it reassociated `(top + flow) - scroll * snap` into `(top - scroll * snap) + flow`,
which differs in f32 for 26% of realistic triples, and `base_y` is serialised into `LogicalState`
through `CardRow::write_motion` -- so every committed replay recording that scrolls the Home grid
would have hash-diverged. `layout_grid` is byte-identical to main again; `grid_origin` serves the
motion report alone and the two are pinned by a bit-exact test at row 0, where `flow` is 0.0 and
the forms genuinely agree.

Worth recording: nothing replays those recordings. `tests/test_harness.py` uses the fixture NAMES
against a fake simulator in a temp dir, so `make check` and all five CI checks passed on the
divergence. A LogicalState change is invisible to every gate until someone re-records.

Audited and deliberately left alone: `hero_slide` is a 0..1 fraction times `SCR_W`, 1920 px, and
looks exactly like the `snap` bug -- but its consumers move only the backdrop, hero content and
hero hit rects, while grid cards are placed from `base_y` and `eff_scroll`, so a flip creates no
new tile demand.

Three host tests, each observed red first. Not measured on the television: no fps scene drives a
hero-to-grid dive, so this is host-tested only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants