Repository navigation
Take filtered context from a bounded window next to each seed (fixes #1712) - #1713
Merged
edwinyyyu merged 24 commits intoSep 30, 2026
Merged
Conversation
edwinyyyu
force-pushed
the
port/filtered-context-window-main
branch
2 times, most recently
from
September 25, 2026 00:26
db5d3ca to
471cca8
Compare
…'s position SQLite's context reads run two statements per seed, and each was built per seed with the seed's walk position as literals. Building a statement and computing its cache key costs several times what SQLite takes to run it: 0.16 ms against 0.04 ms per statement on a 200,000-segment partition, for a filtered statement. Each direction's statement is now built once per request and run per seed with the position bound. With 20 seeds, 2 back and 4 forward, a request takes 11.7 ms instead of 15.3 ms unfiltered and 12.4 ms instead of 17.0 ms with a property filter matching 20% (p50, one client). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…emMachine#1712) A context read with a property filter selected, per seed and direction, the first matching rows in walk order: ORDER BY the walk with the filter in the WHERE clause and LIMIT the context size. The planner has no statistics for property predicates; it estimates about one match per seed, costs the ordered walk as reading the seed's whole side, and on PostgreSQL plans a bitmap over that side and a sort instead. The read then grew with the partition rather than with the context asked for. With a filter, the context now comes from the seed's next 1,024 segments in walk order, read with no filter, and the filter and the context size apply over those rows. The inner read has no filter to misestimate, so it stays an ordered index scan, and it stops once the context is found or the window ends. A match further than 1,024 segments from the seed is no longer context; the segment store ABC now allows that bound. Unfiltered reads keep their statement. SQLite's per-seed reads take the same window, so both dialects return the same context. Measured against MemMachine#1661's tip, 20 seeds per request with 2 segments back and 4 forward, the rows of 21 partitions interleaved: - PostgreSQL 16: a filtered request takes 8-12 ms instead of 4.5-7.1 s on a 1,000,000-segment partition, and 8-13 ms instead of 103-115 ms on 20,000-segment ones; unfiltered, 7.6 ms against 7.8 ms. - SQLite, where the plan never read the whole side: filters matching 20% or 1% take 1.1-1.2x as long as with the previous commit alone (14.1 vs 12.4 ms, 28.4 vs 24.2 ms), while a property that only the seeds carry takes 36 ms instead of 602 ms on a 200,000-segment partition, since the walk no longer runs toward the partition's end. - For the same seeds, both dialects returned the same context as before, except where the bound applies: 12 forward with a filter matching every 100th segment returns the 10 matches within 1,024 segments. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The helper orders segment rows by their chronological columns (timestamp, event, index, offset), newest first when descending, the order the store's context reads called chronological_order before the window. _walk_order named no column or direction. The window query's docstring now says its result comes nearest the seed first, which holds on both sides. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Nothing depends on a power of two here: the bound is a LIMIT on the segments a filtered context read takes per seed and side, and 1,000 is the round number. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Both context reads built the same walk before handing it to _context_rows_query: the partition's segments past the seed on one side, nearest first. _context_rows_query now takes the seed's ordering values and the side, and builds the walk itself. Its callers pass only what differs between them, bound parameters on SQLite and the seeds subquery's columns on PostgreSQL, and no longer pair a range condition with a direction flag that has to agree with it. The walk carries the partition's liveness check, so every context statement carries it from one place. PostgreSQL's statement had it in its seeds subquery, which drops it; otherwise its SQL is unchanged, and SQLite's is byte-identical. correlate_except(SegmentRow) replaces the lateral's two correlate(seeds_subquery) calls: only the walk needed one, when a property filter nests it in the window. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The docstring now says what the statement selects, what the seed's ordering values may be, what the window bounds and why it is read without the filter, and that a deleted partition yields nothing. The account of how an unestimated filter turns the walk into a read of the seed's whole side stays in the PR and MemMachine#1712. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Calls like _context_rows_query(values, True, max_backward_segments, property_filter) did not say what True or the bound were. The side, limit and filter of _context_rows_query and of the lateral's per-direction helper, _chronological_order's descending, and _resolve_segment_field's row are now keyword-only; each helper's subject (the seed's ordering values, the row, the field) stays positional. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The comment now names what is bound per seed (its timestamp, event UUID, index and offset) and why: building a statement costs more CPU than SQLite spends running it, so building one per seed would dominate the read. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The existing context tests pass with the lateral's per-seed walk left uncorrelated to its seed (only one filtered test, and it has one seed) and with segments sharing a timestamp ordered by the wrong tie-breakers (every test gives each segment its own timestamp). The new test reads random contexts, several seeds at a time, with filters of every node type, from a partition where many segments share a timestamp and another partition holds segments at the same times, and compares each result with an in-memory model that evaluates filters in SQL's logic. Every partition is far smaller than the filtered read's window, so the model has no bound. It passes on both dialects here and at MemMachine#1661's tip, and fails on both with the window ordered the same way on either side, with the filter resolved against the table instead of the window, and with the tie-breakers reordered, and on PostgreSQL with the walk uncorrelated. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Building the window's statement through an ORM alias adapted every column the filter and the ordering read from it, and the walk went through the mapped class. With a property filter that made building a PostgreSQL request's two statements, with SQLAlchemy's cache key, cost 1.6-1.8 ms of Python against 0.86 ms at MemMachine#1661's tip. The walk and the window now use the table's and the window's own columns, and SQLite's per-seed statements load segment rows through select(SegmentRow).from_statement(), since building them from plain rows cost more than ORM loading. The SQL is byte-identical on both dialects. A filtered request now takes 1.15-1.21 ms to build, of which about 0.3 ms is the window itself: its subquery's columns and the larger statement to hash. Filtered reads on PostgreSQL partitions of 200 and 2,000 segments, 20 seeds, went from 4-28% slower than MemMachine#1661's tip to 1-9%; SQLite reads are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ReadOnlyColumnCollection, what FromClause.c returns, is importable only from sqlalchemy.sql.base and has no entry in SQLAlchemy's documentation. ColumnCollection, its base class, is documented and exported from sqlalchemy. Annotations only. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
edwinyyyu
force-pushed
the
port/filtered-context-window-main
branch
from
September 25, 2026 18:06
1eb1b82 to
3541435
Compare
The SegmentStore ABC allowed a bound only on filtered context. It now allows an implementation-defined bound on how many of the segments nearest each seed context may come from, whatever the filter: a segment that far from its seed is not a neighbor either way, and a hard bound keeps every context read's cost bounded. The store applies its window to unfiltered reads too by capping their limit at it: an unfiltered read already reads exactly its context size, so the cap needs no window subquery and costs nothing. The constant is renamed _MAX_CONTEXT_DISTANCE, and the window's test now checks that an unfiltered read stops at the bound; it fails with the cap removed. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The ABC now says what a caller can rely on, that each side's context may be limited to an implementation-defined number of the segments nearest the seed, rather than describing what an implementation does beyond the bound. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The ABC now says only that an implementation may bound how far from each seed it looks for context, so a side can come back with fewer segments than requested even when more exist further away. It no longer says how the bound is counted, so it neither reads as though context might not match property_filter nor rules out an implementation with no bound or with a bound on matching segments. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
edwinyyyu
marked this pull request as ready for review
September 25, 2026 18:25
marvinyu-memverge
approved these changes
Sep 25, 2026
marvinyu-memverge
left a comment
Collaborator
There was a problem hiding this comment.
Approving at 4b6d25e. No asks from me.
What I checked:
- Read the three files in full at head. On PostgreSQL the walk inside the window subquery correlates to the enclosing LATERAL's seed columns without re-selecting the seeds, so each seed gets its own bounded window. Moving the registry EXISTS out of the seeds subquery doesn't weaken liveness: the seed read still carries it, and the
_ensure_partition_liveread after the context statements still turns a mid-read delete into the stale-handle error. - Ran the module on SQLite at head: 101 pass.
- The model test only runs on partitions well under the bound, so I also ran it with
_MAX_CONTEXT_DISTANCEmonkeypatched to 1, 2, 3 and 5, against a model that keeps the nearest N segments on each side and only then applies the filter and context size (1-5 seeds per read, shared timestamps, another partition at the same times, up to 6 per side so the unfiltered cap kicks in too). 32/32 agree, and all 32 fail with the model's bound off by one, so on SQLite the bound is exactly "the N nearest segments per side, then filter + limit". For PostgreSQL I'm relying on CI running the module. - Nothing merged to main since 66d4a65 (#1541, #1671) touches these files, and it merges cleanly.
edwinyyyu
marked this pull request as draft
September 25, 2026 19:54
The loop built both directions' statements on every read and ran each only when its side asked for segments, so a read with no backward context (expand_context of 1 or 2) built a statement it never used. Each statement is now built only when its side asks for segments, as the PostgreSQL path already does. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The bound test used one seed, and the model test's partitions never reached the bound, so no test exercised the bound with several seeds, filters and shared timestamps together. Each model-test read now sets the bound at random, from one segment to the default, and the model applies it before the filter: the nearest segments on each side, then the filter and the requested count. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The lateral path built each context row by hand, passing ten keyword arguments to SegmentRow's instrumented constructor, as main did. It now selects each seed's UUID next to SegmentRow aliased to the lateral subquery, so the ORM loads the rows, as the SQLite path does. The SQL selects the same columns plus incarnation, and the contexts are identical. Measured against the previous commit in one quiet window, arms alternated (20 seeds, 2 back and 4 forward unless noted): p50 at one client 14-22% lower with a filter on the 1M-segment partition and the 20k one with a 20% filter (7.6 to 5.9 ms with 20%; 22.2 to 19.0 ms with 1%, 4 back and 12 forward), and requests/s at 16 clients 26-39% higher in every scenario. Building a request's two statements takes 0.95-0.98 ms with a filter instead of 1.18-1.22 ms, and 0.46-0.48 ms without one instead of 0.74-0.78 ms, since the select list is one entity instead of ten columns. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
_resolve_segment_field took either the mapped class or a column collection, defaulting to the class, because the seeds query passed the class and the window passed its columns. The seeds query now passes the table's columns too, so the resolver takes one kind of argument, named columns like _chronological_order's, with no default, and no longer needs .expression for the timestamp column. The compiled SQL is unchanged on both dialects. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The loop built and ran the backward and forward statements in two near-identical blocks each. It now has the PostgreSQL path's shape: a per-direction function that builds the direction's statement once and runs it for every seed, called for a side only when that side asks for segments. The statements are the same; all of a read's backward statements now run before its forward ones. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This reverts commit 745c679. The bound's size is not part of the contract, and bounds of a few segments are not realistic use. The bound test already fails with the window's limit off by one or removed and with the unfiltered cap removed, and the model test, without a bound, already fails on PostgreSQL with a seed's walk uncorrelated to its seed, the multi-seed risk the random bounds were meant to cover. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
edwinyyyu
marked this pull request as ready for review
September 25, 2026 22:41
The model test's timestamps, properties, seeds and filters came from a seeded generator, but segment, event and derivative identifiers came from uuid4(), so which of two events sharing a timestamp came first changed from run to run. They now come from the same generator, so every run generates the same data. _seg takes an optional uuid for it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This reverts commit 9aac74f. The seeded generator already makes every run test the same cases: the timestamps, properties, seeds, context sizes and filters. Identifiers stay random UUIDs from uuid4(); they only decide which of two events sharing a timestamp sorts first, which the model orders the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Events sharing a timestamp are ordered by their UUIDs, which came from uuid4() in generation order, so their order changed from run to run. The test now draws its events' UUIDs from uuid4(), sorts them, and hands them out in generation order, so every run tests the same cases, including the order of tied events, while the UUIDs stay random. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This was referenced Sep 28, 2026
malatewang
approved these changes
Sep 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose of the change
Fixes #1712. On PostgreSQL, a context read with a property filter could read every row on the seed's side of its partition and sort it: the planner has no statistics for property predicates, estimates about one match per seed, and plans a bitmap over that side instead of an ordered walk that stops at the context size. Requests took seconds on large partitions.
Description
get_segment_contextstakes each seed's context on each side from the 1,000 segments nearest it on that side, read with no filter; the filter and the context size apply over those rows (_context_rows_query). The inner read has no filter for the planner to misestimate, so it stays an ordered index scan and stops once the context is found or the window ends. No segment further than 1,000 segments from its seed is context, filtered or not; theSegmentStoreABC now allows an implementation-defined bound on every context read. The bound is fixed in the store; callers do not choose it._context_rows_query: the walk past the seed on one side, the partition's liveness check, and, with a property filter, the window over the walk. They differ only in where the seed's position comes from: the seeds subquery's columns on PostgreSQL, bound parameters on SQLite. On PostgreSQL the liveness check moves from the seeds subquery into the walk, where the plan evaluates it once per statement; SQLite's statements are byte-identical to before.SegmentRow's constructor, asmaindoes.Measurements
Measured on 2026-09-25 with this PR at
38d255252againstmain(whose segment store is unchanged since66d4a653d, #1661): 20 seeds per request, 2 segments back and 4 forward unless noted, the rows of 21 partitions interleaved on disk; property filters matching 20% and 1% of segments. p50 per request with one client, requests/s with concurrent clients. Arms alternated, on AC power, while another session's unit tests shared the CPU (1-minute load 2.3-6.8). This PR ran twice per table and the medians are shown;mainran twice on SQLite and once on PostgreSQL, where its filtered reads take seconds, and a morning run ofmainon a quiet host agreed within 10% on those rows.PostgreSQL 16 (128 MB shared buffers), one 1,000,000-segment partition and twenty 20,000-segment ones:
On small PostgreSQL partitions, where
main's plan is already cheap, this PR is as fast or faster. Medians of five runs ofmainand four of this PR;mainfirst, this PR second:The window adds a subquery to a filtered statement, but PostgreSQL now loads context rows through the ORM instead of building each by hand (
6c7f41f6b), so building a request's two statements takes 0.95-0.98 ms with a filter againstmain's 0.88-0.96 ms, and 0.46-0.48 ms without one against 0.77-0.80 ms.SQLite, one 200,000-segment partition and twenty 20,000-segment ones; p50 in ms with one client and with four, for
main, this PR's first commit, and this PR:SQLite's planner already walked the index in order, so there the window costs 1.0-1.25x over the first commit. With one client this PR is as fast as
mainor faster in every row; with four, a 1% filter is 3-13% slower thanmain. What the window buys on SQLite is the same context as PostgreSQL and a bounded worst case: with a property that only the 20 seeds carry, a request takes 37 ms instead of 613-617 ms on the 200,000-segment partition (and 36 ms instead of 70-78 ms on a 20,000-segment one), because the walk no longer runs toward the end of the partition.For the same seeds, both dialects returned the same context as
mainin every scenario but one, where the bound applies: 12 forward with the 1% filter, which matches every 100th segment, returns the 10 matches within 1,000 segments.Choosing the window's size
The bound is a cost limit that keeps what filters ask for, not a relevance cutoff. The window counts the segments the walk passes, so how much it keeps depends on whether the walk stays inside the seed's conversation.
The walk stays inside the seed's conversation. It does in this store when a partition holds one conversation, and in #1684's schema, which keeps many conversations in a partition with a session column the walk follows. On a real store of coding-agent sessions in #1684's schema (PostgreSQL, 20 seeds, 4 back and 12 forward), a 1,024-segment window returned what the current statement returns for a user-and-assistant-message filter (15.2 of 16 neighbors per seed) and 94% of it for filters on user messages alone, in less time than the current statement. A smaller window cuts sparse filters off sooner: 128 returned half as much for user messages alone.
The walk crosses conversations. It does in this store when a partition holds many conversations: with no session column, the conversation is only a filter, and the walk passes other conversations' segments. #1684 is meant to replace that layout. On a snapshot of such a store (SQLite, 917,610 segments from 2,039 conversations in one partition; 5,000 random message seeds), a 1,000-segment window kept this share of what an unbounded walk within the seed's conversation returns:
On that store, the unbounded filtered walk (that store's own build of the segment store, the same walk as #1661's) ran to the partition's start or end for 7.5-9.2% of seeds: whenever the seed's conversation had fewer matches left on a side than requested, it read on through every other conversation's segments. The worst seeds took 1.0-1.5 s; typical ones 1.0-1.5 ms at p50. The window stops such a walk after 1,000 segments, about 1-1.6 ms at the 1.1-1.6 µs per segment measured there.
The window is 1,000, the round number next to the 1,024 measured; nothing depends on a power of two.
Commits
Branched from
mainat66d4a653d, where #1661 merged;dd9851a4cmergesmainatd57f5cb36(#1671 and #1541), which touches none of this PR's files. This PR is its twenty-three commits:5a4addc3c,97c708767,9055c2ad8,ac96db241,ab58e5072,9f5a5c8cf,040f3fd55,e13a259a0,d47909804,b0ff12e49,35414350a,f92524a9a,74e91b678and4b6d25e39, then after the merge155a5a523,745c67921,6c7f41f6b,08420008f,38d255252,7189e656a,9aac74f77,862f15ae2anddddaecf86.Verification
At each of the first fourteen commits, on
mainat66d4a653d:ruff checkandruff format --checkclean;ty checkclean as CI runs it (uv run --frozen --all-extras ty check --project packages/server); the full server suite without integration tests passes, 1982 tests at the first commit, 1983 from the second (the window's test) and 1986 fromd47909804(the model test). The event memory and long-term memory integration tests pass against PostgreSQL at35414350aand74e91b678, 107 with 3 skipped.The window's test sets the bound to 4 segments and checks that a match beyond it is not context and that an unfiltered read stops at it; it fails on both dialects with the window's
LIMIToff by one or removed and with the unfiltered cap removed.d47909804addstest_random_context_reads_agree_with_a_model: random context reads, several seeds at a time, with filters of every node type, many segments sharing a timestamp, and another partition's segments at the same times, compared with an in-memory model of the timeline; every partition is far smaller than the bound. The existing context tests pass with the lateral's walk left uncorrelated to its seed and with ties ordered by the wrong columns; the new test fails on both dialects with ties misordered, with the window ordered the same way on either side, and with the filter resolved against the table instead of the window, and on PostgreSQL with the walk uncorrelated. It passes on both dialects at #1661's last commit, so outputs there and here agree wherever every match is within the bound.The refactors change no results.
ab58e5072builds each statement's walk in_context_rows_query: SQLite's context statements compile byte-identical to before it, on PostgreSQL only the liveness check moves (the plan evaluates it once per statement), and p50s stay within 0.2 ms ofac96db241's over five alternating rounds. Before and after040f3fd55(keyword-only flags and bounds) andb0ff12e49(statements built from Core columns, SQLite rows loaded throughselect(SegmentRow).from_statement()), every context statement compiles identically on both dialects;b0ff12e49cut the Python that builds a filtered PostgreSQL request's statements from 1.69-1.71 ms to 1.13-1.24 ms, measured on 2026-09-25 before6c7f41f6blowered it further.35414350aannotates with the documentedColumnCollectioninstead of the undocumentedReadOnlyColumnCollection.f92524a9acaps unfiltered reads at the same bound.9055c2ad8,9f5a5c8cf,e13a259a0,74e91b678and4b6d25e39rename a helper or change only comments and docstrings;ac96db241sets the bound to 1,000.After the merge:
155a5a523builds SQLite's statements only for the sides a read asks for;745c67921had each model-test read set the bound at random, down to one segment, and7189e656areverts it: the bound's size is not part of the contract and bounds of a few segments are not realistic use, while the window's test already catches the bound's failures and the model test, without a bound, the multi-seed one (a walk uncorrelated to its seed on PostgreSQL);6c7f41f6bloads PostgreSQL's context rows through the ORM.6c7f41f6b, against the commit before it in one quiet window: p50 at one client 14-22% lower with a filter on the 1M-segment partition and on the 20k one with the 20% filter, and requests/s at 16 clients 26-39% higher in every scenario, with identical contexts.08420008fresolves filter fields against columns only, and38d255252reads SQLite's context one direction at a time, as PostgreSQL does; both leave the SQL unchanged on both dialects (SQLite runs the same statements, all backward before forward), and against6c7f41f6bin the measurement window above, SQLite p50s are within 3% either way and PostgreSQL's equal or lower, with identical contexts. At745c67921,6c7f41f6band38d255252:ruff check,ruff format --checkandty checkclean; the full server suite without integration tests passes, 1994 tests (the merge brought in #1541's); the event memory and long-term memory integration tests pass against PostgreSQL, 107 with 3 skipped. At08420008f:ruff,tyand the event memory tests without integration (193) pass. At7189e656a(test file only):ruffandtyclean, and the segment store tests pass, 101 on SQLite and 104 against PostgreSQL.9aac74f77drew the model test's identifiers from its seeded generator, and862f15ae2reverts it: the seeded generator already makes every run test the same cases, and identifiers stay random UUIDs; at862f15ae2the test file is as at7189e656a, and the segment store tests pass, 101 on SQLite and 104 against PostgreSQL.dddaecf86gives the model test's events random UUIDs in sorted order, so events sharing a timestamp come in the same order every run and every run tests the same cases; at it,ruffandtyare clean, the segment store tests pass (101 and 104), and the model test still fails on both dialects with ties misordered, with the window ordered the same way on either side and with the filter resolved against the table, and on PostgreSQL with the walk uncorrelated.🤖 Generated with Claude Code