docs(adr): ADR 009 analyses predecessor search and a top-left on a pruned row (improvise-felo.4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (adr9-steward)
2026-10-05 15:08:52 -07:00
committed by Edward Langley
parent 6305fa3bc7
commit de3b2bc323
+57
View File
@@ -1379,6 +1379,58 @@ analysis.*
and by the bottom-right corner of the axis space, so its cost is O(|S| × log n + k ×
|S|) per round, never a scan of empty space past the last occupied position.
- **Scrolling up or left, and a top-left that lands on a pruned row (the user, in
chat).** "The app is free to optimize fetching for up or left by just fetching the new
rows, until it finds one that has values in the viewport but, conceptually, scrolling up
or left would request a new viewport with a new top left corner. The requested top left
corner falling on a pruned row or column is an annoying edge case, maybe just fail the
fetch and retry?" (relayed on 2026-10-05; on no PR). The user's "maybe" is tentative,
and nothing here is decided. *The rest of this item is the author's analysis.*
- **The optimisation is a predecessor search.** Fetching only the new rows above the
window, and scanning upward until one has a value among the displayed columns, asks
"the previous row before p with a value in S". The forward-only queries above do not
serve it directly. There are two ways to serve it:
- **The client does it with forward requests.** It guesses an earlier top-left,
requests forward from there, and widens the guess if nothing qualifies. Each guess
costs one forward query, and the number of guesses grows with the gap. At the
fixture's density of 10^-4 a blind step of tens of rows usually finds nothing, so
it needs widening, about log₂(gap) guesses if each guess doubles the step.
- **The model offers a predecessor query.** It is cheap under every option. A sorted
`Vec` and a B-tree both give an upper-bound seek and a reverse iterator at the same
cost as the forward ones, O(log n) and then O(1) per step. Option (ii)'s per-column
sorted lists merge backwards exactly as they merge forwards. Options (i) and (iii)
walk backwards with the same per-row test. So offering it adds no state. It adds
only a second direction to the query surface, which the conceptual model (always
fill forward from a new top-left) does not need. *Author's lean:* offer it, because
it costs no state and saves the client a guessing loop. The user's to confirm.
- **The edge case: a top-left on a pruned (empty) row or column.** Listed neutrally:
- (a) **Fail, then retry** (the user's tentative "maybe"). The model answers with an
error. If the error names the nearest occupied successor (and predecessor), the
retry is one round trip. If it names nothing, the client must guess, as above.
- (b) **Snap forward, and report the actual top-left.** The model starts the fill at
the next occupied position at or after the requested one, and returns that
position in the response. No extra round trip. The client sees its window start
somewhere other than it asked.
- (c) **Snap toward the scroll direction.** Like (b), but snap backward when the
request came from an upward scroll. That needs the request to say which direction
it came from, which the request sketch does not carry.
Cost at density 10^-4: finding the nearest occupied neighbour is one seek in the
ordered index, O(log n), in either direction, so (a) with named neighbours and (b)
cost the same on the host. (a) adds a round trip, and (b) adds none. The expected gap
to the next occupied row is on the order of 10^4 cross-product rows, so a client that
guesses blind under (a) would need about log₂(10^4) ≈ 14 doubling retries. **Unpruned
space is not an edge case at all**: a top-left on an empty position is answered with
empty cells.
- **How it fits identity-keyed deltas and resumable streams.** Under (b) and under (a)
with named neighbours, the response's actual top-left is an absolute position, and
the tuple there is an identity. So the stream's resumable state stays `(request,
actual top-left, last position)`, and a later delta names rows by identity whichever
top-left was asked for. A top-left whose row is pruned after the stream started is the
same case in time. A delta reports the row's removal, the client's window keeps its
absolute top-left, and the next fill skips past the empty position. That is (b)'s
behaviour without a new request.
- **The fixpoint comes from the fill, not from one window.** *As first written*, this
item said that hiding a row can empty a column. **That was wrong.** A row dropped for
having no value among the window's columns contributes no value to any of those
@@ -1961,6 +2013,11 @@ revision, and none is renumbered.
Under the user's right-and-down growth (clause 12), the query is a forward successor
query only, and so are all three options; no rank or reverse scan is needed for
pruning.
The user's later chat adds a client-side optimisation for scrolling up or left (a
predecessor search, which every option serves as cheaply as a successor search). It
also raises the edge case of a top-left that lands on a pruned row: fail and retry,
snap forward, or snap toward the scroll direction (clause 12). That edge case is
part (d) of this question.
*Recommendation (the author's):* (a) yes, with (ii). (b) yes, once the user confirms
that pruning is the client's. (c) restate both in those terms, in ADR 008, since they
are ADR 008's answers.