fix(ereader): replay the page chain instead of searching for it (S16)

render_page_backward searched for the previous page's start: estimate a block
index, lay out forward, bisect on the block difference, up to ten times. Both the
estimator and the adjuster pinned word_index to 0 and moved only block_index -
but pages routinely start mid-block, so the answer was not in the search space.
The loop could never match, exhausted its iterations and fell through to a
fallback that jumped to the start of the document.

Measured on a document with one 1200-word paragraph, whose page starts are
(0,0), (2,208), (2,494), (2,780): three of four backward calls returned (0,0),
each after ten full page layouts. The bisection was unsound in its own space too
- 40 small paragraphs, every page starting on a block boundary, also failed.

Pagination is a pure function, so the page before P is the q with next(q) == P,
and it is found by replaying the chain forward rather than guessing q. Three
sources: the chain recorded as pages are laid out forward (exact, one layout,
covers paging back and forth); replay from the block containing P and then from
earlier blocks (exact when P lies on that chain); and failing that, the last
start before P, which overlaps slightly rather than skipping content.

  warm:                          4/4 exact, 1 layout each
  cold, fresh layouter per call: 12/13 exact, worst 17 layouts
  cold, repeated back presses:   ~4 layouts per turn

Each step returns a page ending exactly where the reader is, so paging back never
skips or repeats content. That chain can differ from the one seen reading forward
from page one if the reader arrived by a jump - pagination from a different start
is a different chain, and nothing can recover the original without replaying the
whole document.

Complementary to S11 rather than caused by it: before S11 forward pagination
dead-ended at the first page-spanning block, so mid-block starts never arose and
the block-granular search looked adequate.
This commit is contained in:
2026-08-08 12:43:30 +02:00
parent 767e4c135c
commit 456824d6d6
3 changed files with 398 additions and 75 deletions
+98
View File
@@ -30,6 +30,7 @@ It is independent of every other spec here.
| [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 |
| [S14](#s14--vertical-centring-in-buttons-and-fields) | Vertical centring in buttons and fields | 0 |
| [S15](#s15--form-field-label-geometry) | Form field label geometry | 0 |
| [S16](#s16--backward-page-navigation) | Backward page navigation | 0 |
## Design invariants
@@ -1265,6 +1266,103 @@ between label and box smaller than the intended 5px.
---
## S16 — Backward page navigation
### Problem
`render_page_backward` *searched* for the previous page's start: estimate a block
index, lay out forward, compare the end against the target, bisect on the block
difference, repeat up to ten times. Both the estimator and the adjuster pinned
`word_index` to 0 and moved only `block_index`.
Pages routinely start mid-block. Any such start was therefore **not in the search
space**, the loop could never match, and it fell through to a fallback that
jumped several blocks back or to the document start.
### Evidence
A document of short paragraphs around one 1200-word paragraph. Forward pagination
gives page starts at `(0,0), (2,208), (2,494), (2,780), (2,1057)`. Asking for the
page that ends where each of those begins:
```
from page 1 -> got (0,0) expected (0,0) ok (1 forward layout)
from page 2 -> got (0,0) expected (2,208) WRONG (10 forward layouts)
from page 3 -> got (0,0) expected (2,494) WRONG (10 forward layouts)
from page 4 -> got (0,0) expected (2,780) WRONG (10 forward layouts)
```
Every mid-paragraph case threw the reader to the start of the document after ten
full page layouts. The bisection was also unsound within its own space: a
document of 40 small paragraphs, where every page *does* start on a block
boundary, failed too.
This is complementary to S11 rather than caused by it. Before S11 forward
pagination dead-ended at the first page-spanning block, so mid-block starts were
never produced and the block-granular search looked adequate.
### Design
Pagination is a pure function: laying out from `q` yields a page and the position
it stopped at, `next(q)`. The page before `P` is the `q` with `next(q) == P`.
That is found by **replaying the chain forward from an anchor**, not by guessing
`q`. Three sources, in order:
1. **The recorded chain.** `render_page_forward` now records
`(font_scale, next(q)) -> q`. Stepping back to anywhere the reader has been is
exact and costs one layout. Keyed by font scale, since changing it
repaginates.
2. **Replay from an anchor.** Anchors are block starts, nearest first: the block
containing `P`, then up to `MAX_BACKWARD_ANCHORS` earlier ones, then the
document start. Lay out forward from the anchor until a page ends exactly on
`P`; that page's start is the answer. `MAX_REPLAY_PAGES` caps the walk so one
page turn cannot traverse a whole chapter.
3. **Nearest start before `P`.** If no chain passes exactly through `P` — which
happens when `P` was reached by a jump or a restored bookmark rather than by
reading forward, so it lies on no natural chain — return the last page start
before it. That overlaps `P`'s page slightly rather than skipping content,
which is the safe direction to be wrong in.
The estimator and the bisecting adjuster are deleted.
**What "correct" means here.** Each backward step returns a page ending exactly
where the reader currently is, so paging back never skips or repeats content.
That chain can differ from the one you would have seen reading forward from page
one, if you entered the document by a jump — pagination from a different starting
point is genuinely a different chain, and no algorithm can recover the original
without replaying from the start.
### Measurements
Same document, after the change:
```
warm (chain recorded by the forward pass): 4/4 exact, 1 layout each
cold, fresh layouter per call: 12/13 exact, worst 17 layouts
cold, one layouter, repeated back presses: 4 layouts per turn typical
```
The single inexact case is a target that lies on the canonical chain but not on
any chain reachable from a nearby anchor; it returns a start 15 words early,
i.e. a slightly overlapping page.
### Acceptance criteria
- For every page of a document, `render_page_backward(start[i])` returns
`start[i-1]` — verified for both a mid-paragraph-paginating document and one
where every page starts on a block boundary.
- Laying out forward from the returned position ends exactly on the requested
position.
- Forward-then-back returns to the original position.
- At the document start, backward stays there; an empty document is safe.
- Cost stays within a small bounded number of forward layouts.
### Files
`pyWebLayout/layout/ereader_layout.py`
---
## Test plan
Findings were reproduced with four probe scripts; each becomes a regression test