Skip to content

[user properties 1/2] Keep undeclared properties out of the vector store - #1702

Merged
edwinyyyu merged 9 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/vector-store-declared-routing-main
Oct 15, 2026
Merged

edwinyyyu merged 9 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/vector-store-declared-routing-main

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

The event backend wrote every property of an event into its vector record and mapped the caller's whole filter onto the vector store, so a user key a deployment never declared was both stored and filtered there: on Qdrant and Milvus as unindexed payload a filtered query scans for. Now an undeclared key stays off the vector store, so a tenant's undeclared properties cannot shape what it stores or scans; #1670, stacked on this, removes the per-project declaration, so no tenant key reaches it at all.

The vector record now carries the keys the collection declares: EventMemory's reserved timestamp, the _-prefixed system properties an adapter stamps on the event, and the keys a project's properties_schema declares, until #1670 removes that option (collection.config.indexed_properties_schema). The vector store is queried with the part of the filter on such fields, the way a database planner uses an index for part of a condition: an AND keeps every operand that has such a part, an OR only when every operand has one, and a NOT applies the opposite rule to its operand, at any depth. So (a = 1 AND u = 1) OR (a = 2 AND u = 2) gives a = 1 OR a = 2. Every record that matches the filter matches the part, so the vector search is only ever wider, and the segment store narrows it back on the windows. A condition the vector store cannot evaluate is applied to the seeds its search returns, so a selective filter on an undeclared field can return fewer hits than vector_search_limit, or none, while matching episodes exist further down the ranking. A query's cost stays bounded by the limit; filtering on a key during the search needs the key declared on the store. An undeclared key never reaches the vector store.

filter_fields and extract_filter join the filter parser: every field name a tree addresses, and the part of a filter that names only allowed fields, the name PostgreSQL, DataFusion, and Spark give that operation. The segment store's get_segment_contexts contract now says that a seed failing the filter has no entry, which EventMemory relies on for every condition the vector store cannot evaluate. Tests: an undeclared property stays on the segment and off the record; a conjunct on an undeclared property reaches only the segment store; an OR with an arm on undeclared fields alone leaves the vector search unfiltered; the vector store gets the declared part of an OR of ANDs, a negated OR, a negated AND, and a negation under a negated OR; filter_fields names every field under every node. extract_filter is checked against the in-memory stores' filter evaluator: a table of expected parts that alone reaches every line and branch; 2,000 random filters up to four levels deep, every record each one matches also matching its part; a filter on allowed fields alone coming back unchanged; and the part being the tightest possible when each other field appears once. Random filters through EventMemory answer exactly, with and without the vector store's part, when the search reaches every record, and the part only adds hits when the search reaches fewer. With an embedder that ranks the matching event below the limit, a filter on an undeclared field finds nothing, the same filter finds the event when the search reaches every seed, and a filter on a declared field finds it within the limit. A seed the filter rejects is left out even when a neighbor in its context matches, and a user key the collection declares is on the record and reaches the vector search under its bare name.

This is the routing half of what was #1628 on speedkick (its store-side half, the stores rejecting undeclared keys, is deferred to after #1627); it is re-derived onto #1736's collection shape from the same commit, where the segment store, not the vector record, maps a derivative to its segment.

Part of the fix for #1781.

Stack

25 open PRs: three independent PRs, and the vector store tree of short parallel branches. Every PR in the tree has feat/horizontal-scaling as its GitHub base, and the independent PRs have main. The branches are in a fork, and a pull request can target only this repository's branches, so the on column gives the order the PRs build on each other. A stacked PR's diff on GitHub includes the PRs under it until they merge.

Independent of the vector store tree, directly on main:

# PR change on
— #1624 Make no memory request create a project main
— #1786 Refuse a filter value of the wrong type for a datetime column, and answer an invalid list filter with 422 (port of #1620) main
— #1792 Refuse property values that some store refuses or alters where an episode enters main

The vector store tree. Each PR builds on the one in its on column; PRs on the same parent are parallel branches and do not depend on each other. #1631 is closed, superseded by #1733–#1736, which hold its changes split in four, with review changes since. #1702 and #1670 sit beneath #1627, whose code depends on them. #1684 heads the event memory stack, which its own PRs index; it is listed here because #1828 builds on it. Until the PRs under it merge, their changes show in a stacked PR's diff.

# PR change on
[vector store scale-out 1/6] #1671 (merged) Remove custom sharding from the Qdrant store (port of #1654) main
[vector store scale-out 2/6] #1733 (merged into feat/horizontal-scaling) Answer vector store queries with record UUIDs and scores, and refuse invalid inputs feat/horizontal-scaling
[vector store scale-out 3/6] #1734 (merged into feat/horizontal-scaling) Arbitrate vector store collections in a SQL registry, with an incarnation per collection life and a purge feat/horizontal-scaling
[vector store scale-out 4/6] #1735 (merged into feat/horizontal-scaling) Move the Qdrant store onto the collection registry feat/horizontal-scaling
[vector store scale-out 5/6] #1736 (merged into feat/horizontal-scaling) Move the Milvus store onto the collection registry, against a Milvus server feat/horizontal-scaling
— #1775 (merged into feat/horizontal-scaling) Accept attempts exhausted in the lifecycle churn contract, and say which Qdrant operations filter on the incarnation feat/horizontal-scaling
— #1779 (merged into feat/horizontal-scaling) Classify Qdrant errors by status code alone feat/horizontal-scaling
— #1813 (merged into feat/horizontal-scaling) Create Qdrant collections with strict mode off feat/horizontal-scaling
— #1820 (merged into feat/horizontal-scaling) List only active projects in projects/list feat/horizontal-scaling
— #1788 Refuse a repeated record UUID or a non-finite property value at upsert, and state the datetime property contract feat/horizontal-scaling
— #1631 (closed) Superseded by #1733–#1736, which hold its changes split in four, with review changes since —
[user properties 1/2] #1702 (this PR) Keep undeclared properties out of the vector store feat/horizontal-scaling
[user properties 2/2] #1670 Remove per-project filterable properties (port of #1606) #1702
[vector store scale-out 6/6] #1627 Make a vector store one collection, with string-keyed partitions #1670
[session storage 1/2] #1622 Create a session's storage with the session, never on a request #1627
[session storage 2/2] #1625 Remove open-or-create from both stores, and close from the segment store #1622
[declared schema 1/2] #1628 Make a vector store filter only on the properties it declares #1627
[search results] #1663 Score every vector search by cosine similarity, and name scores for it (port of #1598's cosine half) #1628
[declared schema 2/2] #1616 Close the filter union, and make negation the complement on every backend #1663
[sqlite store fixes 1/7] #1460 Publish vector index files atomically (but not durably) #1663
[sqlite store fixes 2/7] #1469 Never reuse a row id in SQLiteVectorStore #1460
[sqlite store fixes 3/7] #1672 Own the search engine's concurrency in the store, not in each engine (port of #1612) #1469
[sqlite store fixes 4/7] #1673 Serialize a partition's writes so the engine sees them in order (port of #1607) #1672
[sqlite store fixes 5/7] #1674 Refuse a pending row replay cannot honor, instead of dropping it (port of #1608) #1673
[sqlite store fixes 6/7] #1675 Take SQLite's write lock at BEGIN, not at the first write (port of #1609) #1674
[sqlite store fixes 7/7] #1676 Give every write a fresh row id, so a key names one version (port of #1610) #1675
[qdrant options] #1618 Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization #1663
[milvus options] #1741 Let a deployment tune a Milvus collection's vector index and its searches #1618
[event memory 1/7] #1684 Add time bounds, sources and expansion to EventMemory (port of #1597, without session) #1663
[property keys] #1828 Refuse an illegal metadata key before any store writes, and reserve one key prefix #1684
[schema migration 1/2] #1825 Add memmachine-server migrate, which creates every configured store's schema before servers start #1628
[schema migration 2/2] #1826 Make startup check what migrate created, and create nothing at startup #1825

This PR is its 9 commits, b1c3242fe, e5c2372d9, bbc9c1b8e, 8f53209b0, e777aec48, 3c68d451c, a82df2c3b, e681e983d, 6631b5c77, directly on feat/horizontal-scaling. #1670 is stacked on it.

Verification

At this PR's head 6631b5c77, on 2026-10-13: ruff check and ruff format --check clean; ty check clean as CI runs it (uv run --frozen --all-extras ty check --project packages/server); uv lock --check clean; the episodic memory and filter tests pass, 483 tests (1 skipped). At 8f53209b0, each of six deliberately broken versions of the extraction failed those tests. The full suites last passed, with #1670 beneath it, at 9031d25f4, on 2026-10-08: the server suite without integration tests, 2166 tests, and the integration tests of the vector stores, the resource manager, episodic memory, and semantic storage, 689 tests, against PostgreSQL 16, Neo4j, Qdrant 1.19.1, and Milvus 2.6.24 in containers.

Before the routing at any depth: At this PR's head b1c3242fe, on 2026-10-09, on feat/horizontal-scaling with #1813 merged: ruff check and ruff format --check clean; ty check clean as CI runs it (uv run --frozen --all-extras ty check --project packages/server); uv lock --check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

@edwinyyyu edwinyyyu changed the title [vector store 3/9] Keep user properties out of the vector store [vector store scale-out 3/4] Keep user properties out of the vector store Sep 21, 2026
This was referenced Sep 21, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-declared-routing-main branch from 48951bf to c164c56 Compare September 21, 2026 22:44
@edwinyyyu edwinyyyu changed the title [vector store scale-out 3/4] Keep user properties out of the vector store [vector store scale-out 4/5] Keep user properties out of the vector store Sep 21, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-declared-routing-main branch from c164c56 to 2dc8c96 Compare September 25, 2026 23:10
@edwinyyyu edwinyyyu changed the title [vector store scale-out 4/5] Keep user properties out of the vector store [user properties 2/2] Keep user properties out of the vector store Sep 25, 2026
@edwinyyyu edwinyyyu added the horizontal scaling Wrong or unsafe when more than one server process serves the same backends (replicas or workers) label Sep 29, 2026
The event backend wrote every property of an event into its vector record
and mapped the caller's whole filter onto the vector store, so a user key
that a deployment never declared was both stored and filtered there: on
Qdrant and Milvus as unindexed payload a filtered query scans for. The
segment store already holds every property and already receives the whole
filter for the context windows, so the vector side only duplicated work the
segment store does anyway.

The vector record now carries the keys the collection declares:
EventMemory's reserved timestamp, the `_`-prefixed system properties an
adapter stamps on the event, and the keys a project's `properties_schema`
declares. The vector store is queried with the conjuncts of the filter
that name only such fields; a conjunct is dropped whole when any field
under it is undeclared, so dropping only ever widens the vector search,
and the segment store narrows it back on the windows. An undeclared key
never reaches the vector store, so a tenant's undeclared properties cannot
shape what it stores or scans.

`filter_fields` joins the filter parser: every field name a tree addresses.

Rebased onto MemMachine#1631, where the vector record no longer carries the segment
uuid (the segment store maps a derivative to its segment).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-declared-routing-main branch from 6cdbe07 to b1c3242 Compare October 10, 2026 00:32

@marvinyu-memverge marvinyu-memverge left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One ask before I approve, reviewed at b1c3242.

Keeping undeclared keys off the vector record makes sense to me, and the conjunct split is sound: whatever reaches the vector store is implied by the full filter, and the segment store still applies the whole thing, so nothing that fails the filter gets through.

1. A filter on an undeclared key can now come back short, or empty, while matching episodes exist.

Once the m.<key> conjunct is dropped, the vector search returns the top vector_search_limit over the whole collection, and get_segment_contexts then drops every seed whose segment fails the filter. Nothing refills those slots, so matches that aren't in the unfiltered top K never come back. The event backend's collection is per session and a REST v2 session is a whole org/project, and with #1670 on top every m.<key> is undeclared, so "filter on one user's metadata in a big project" is the normal case for this. LTM's 4x over-fetch is sized for episode dedup, so it only softens it.

Repro at this head, on the existing event_memory fixture but with an embedder that separates the two texts (FakeEmbedder maps every input onto the same direction, so the current tests can't see ranking): 10 events "blue thing {i}" with color=blue, one "red thing" with color=red, query "blue thing", vector_search_limit=3, filter m.color = 'red' -> []. With feat/horizontal-scaling's event_memory.py swapped in, the same test returns ["red thing"].

The body says the segment store "narrows it back", which holds for which results come back but not for how many. Either of these would work for me:

  • when a conjunct was dropped, keep fetching from the vector store until vector_search_limit seeds survive the segment filter or the collection runs out, or
  • if the trade-off is intended (no payload scans on tenant keys), say in the body that a filter on an undeclared key is applied after the top K, and pin it with a test like the one above.

Verified: the record keeps _timestamp plus every declared key, and EVENT_BACKEND_SYSTEM_FIELDS are declared at collection create, so _episode_uid, _producer_id etc. still reach the record and filters on system fields still go to the vector store; field names are mapped before the declared check, so producer_id / timestamp are compared as _producer_id / _timestamp; an undeclared key under OR or NOT drops its whole conjunct, which only widens; filter_fields covers every node type map_filter_fields does. CI green at b1c3242 (35/35).

@edwinyyyu
edwinyyyu marked this pull request as draft October 12, 2026 20:00
edwinyyyu and others added 3 commits October 12, 2026 13:33
The vector store got the conjuncts of a filter's top-level AND that name
only declared fields, so an OR or a NOT with an undeclared field anywhere
under it was dropped whole, and the vector search ran wider than it had
to.

It now gets the part of the whole tree on declared fields, the way a
database planner uses an index for part of a condition: an AND keeps
every operand that has such a part, an OR only when every operand has
one, and a NOT applies the opposite rule to its operand. So
`(a = 1 AND u = 1) OR (a = 2 AND u = 2)` gives `a = 1 OR a = 2`, and
`NOT (u = 1 OR a = 1)` gives `NOT a = 1`. Every record that matches the
filter still matches the part, and the segment store, which gets the
whole filter, narrows the result back as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking the part of a filter that names only some fields depends on
nothing but the filter and the fields, so it moves next to
`filter_fields` and `map_filter_fields` as `extract_filter`, the name
PostgreSQL, DataFusion, and Spark give the same operation.
`EventMemory._vector_store_filter` had one caller, so `query` now maps
the filter's fields and extracts its part on the declared ones itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hout it

Rewriting a filter is checked the ways database engines check a
planner's rewrites, against the in-memory stores' filter evaluator:

- A table of filters and their expected parts covers every node kind
  under both parities of negation, with each operand kept or dropped,
  and alone reaches every line and branch of `extract_filter`.
- Random filters up to four levels deep, over allowed and other fields
  and every kind of leaf: every record a filter matches, its part
  matches, and the part names only allowed fields; a filter on allowed
  fields alone is its own part; and when each other field appears
  once, a record matches the part exactly when some values of the other
  fields would make it match the filter.
- Random filters through EventMemory, with the vector store getting the
  part and with it getting no filter: when the search reaches every
  record, both answer the filter exactly, and when it reaches fewer, the
  part only ever adds hits.

The random filters and records come from a shared test module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu marked this pull request as ready for review October 12, 2026 22:50
… seeds

A vector search returns at most `vector_search_limit` seeds, and a
condition on a field the vector store does not declare is applied to
those seeds afterward, so a selective one can leave fewer seeds than the
limit, or none, while matching segments exist further down the ranking.
That bounds a query's cost by the limit. `query`'s docstring now says so,
and a test pins it with an embedder that ranks the matching event below
the limit: the condition on an undeclared field finds nothing, the same
condition finds the event when the search reaches every seed, and a
condition on a declared field finds it within the limit.

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

Copy link
Copy Markdown
Contributor Author

@marvinyu-memverge Thanks, the analysis is right, and the trade-off is intended. A query's cost is bounded by vector_search_limit: the vector search fetches that many seeds once, and a condition the vector store can't evaluate is applied to those seeds, with no refetching. So I've taken your second option, at e777aec48:

  • query's docstring now says that a condition on a field the vector store doesn't declare applies to the seeds the vector search returns, so fewer than vector_search_limit seeds, or none, may survive it.
  • A test pins it with your repro, using an embedder that ranks the red event below the limit. With vector_search_limit=3, m.color = 'red' finds nothing. The same filter finds "red thing" when the limit reaches every seed. A filter on a declared field (episode_uid) finds it within the limit.
  • The description's Purpose now says that a filter on an undeclared key is applied after the top K, and that filtering on a key during the search needs the key declared on the store. Whether a deployment can declare user keys for that is a separate, open decision.

Since your review at b1c3242, #1702 also changed how the vector store's part of the filter is taken. It's now extracted at any depth, the way PostgreSQL and Spark push a condition down, instead of dropping a whole top-level conjunct. An undeclared key under an OR or a NOT no longer drops the whole conjunct: (a = 1 AND m.x = 1) OR (a = 2 AND m.x = 2) sends a = 1 OR a = 2.

  • e5c2372d9 makes that change.
  • bbc9c1b8e moves it into the filter module as extract_filter.
  • 8f53209b0 tests it against the in-memory stores' evaluator, with a table of expected parts and random filters. Every record the full filter matches still matches what the vector store gets.

🤖 Written by Claude Code (Claude Opus 5.5) on behalf of @edwinyyyu.

@marvinyu-memverge marvinyu-memverge left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at e777aec. Thanks, the docstring, the test and the Purpose paragraph cover it.

I also went through the new extract_filter: flipping which side of an And/Or may be left out under a NOT keeps the part implied by the full filter at any depth, and the random-filter check against the in-memory evaluator is a good way to pin that.

The red unit-test and package jobs at this head aren't from this change. Every build fails in setuptools-scm with "choosing custom numbers for the .devX distance is not supported": the v0.4.1-dev1 tag pushed on feat/horizontal-scaling today is now the nearest tag, and setuptools-scm won't derive a version from a dev tag at distance > 0. Anything based on that branch will hit it until the tag changes.

edwinyyyu and others added 4 commits October 13, 2026 16:00
…agree

EventMemory leaves to the segment store every condition the vector
store cannot evaluate, so it relies on `get_segment_contexts` leaving
out a seed that fails the filter. The SQL store does, but the contract
only said the filter applies to the segments, and the in-memory fake
kept a rejected seed whenever a neighbor in its window matched. The
contract now says a seed that does not match has no entry, the fake
checks the seed, and a test pins it through EventMemory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`query` said only conditions on undeclared fields wait for the seeds,
but a declared condition in an OR with an undeclared one, or in a
negated AND with one, waits too: the vector search applies only the
part of the filter that names declared fields alone. And the expected
collection schema said declaring a property makes it efficiently
filterable, when a property left undeclared is not on the vector record
at all, so the search cannot filter on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Building a vector record and extracting the vector store's part of a
filter both read the collection's declared schema again, once per
derivative for the record. `_schema_fields` already holds those keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture's collection declares only system keys, so no test had a
user key reaching the vector store; a wrong mapping of `m.<key>` would
have dropped its condition from the search with every test passing. A
test now declares `color` and checks that the record carries it and
that `m.color` reaches the search as `color`. The three tests that
spied on the vector store's queries share one helper.

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

Copy link
Copy Markdown
Contributor Author

@marvinyu-memverge Four commits landed after your approval at e777aec48, from a second review pass. None changes what reaches the vector store:

  • 3c68d451c: get_segment_contexts now states that a seed failing the filter has no entry, which EventMemory relies on for every condition the vector store can't evaluate. The SQL store already did this. The in-memory test store kept a rejected seed whenever a neighbor in its window matched, and now checks the seed. A test pins it through EventMemory.
  • a82df2c3b: query's docstring now says the vector search applies only the part of the filter that names declared fields alone. A declared condition in an OR with an undeclared one also waits for the seeds. The collection-schema docstring no longer says that declaring a property only makes it faster to filter.
  • e681e983d: the declared keys come from _schema_fields instead of a second read of the config.
  • 6631b5c77: a test that a user key the collection declares is on the record and reaches the search as color. The three pasted query spies are now one helper.

CI's unit-test jobs had been failing at install because of the v0.4.1-dev1 tag. That tag is now v0.4.1-dev0, and CI is running again on 6631b5c77.


🤖 Written by Claude Code (Claude Opus 5.5) on behalf of @edwinyyyu.

@edwinyyyu
edwinyyyu merged commit d624d75 into MemMachine:feat/horizontal-scaling Oct 15, 2026
39 checks passed
xiongzubiao pushed a commit that referenced this pull request Oct 15, 2026
…ore (#1702)

* Keep undeclared properties out of the vector store

The event backend wrote every property of an event into its vector record
and mapped the caller's whole filter onto the vector store, so a user key
that a deployment never declared was both stored and filtered there: on
Qdrant and Milvus as unindexed payload a filtered query scans for. The
segment store already holds every property and already receives the whole
filter for the context windows, so the vector side only duplicated work the
segment store does anyway.

The vector record now carries the keys the collection declares:
EventMemory's reserved timestamp, the `_`-prefixed system properties an
adapter stamps on the event, and the keys a project's `properties_schema`
declares. The vector store is queried with the conjuncts of the filter
that name only such fields; a conjunct is dropped whole when any field
under it is undeclared, so dropping only ever widens the vector search,
and the segment store narrows it back on the windows. An undeclared key
never reaches the vector store, so a tenant's undeclared properties cannot
shape what it stores or scans.

`filter_fields` joins the filter parser: every field name a tree addresses.

Rebased onto #1631, where the vector record no longer carries the segment
uuid (the segment store maps a derivative to its segment).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

* Send the vector store the part of a filter it can evaluate, at any depth

The vector store got the conjuncts of a filter's top-level AND that name
only declared fields, so an OR or a NOT with an undeclared field anywhere
under it was dropped whole, and the vector search ran wider than it had
to.

It now gets the part of the whole tree on declared fields, the way a
database planner uses an index for part of a condition: an AND keeps
every operand that has such a part, an OR only when every operand has
one, and a NOT applies the opposite rule to its operand. So
`(a = 1 AND u = 1) OR (a = 2 AND u = 2)` gives `a = 1 OR a = 2`, and
`NOT (u = 1 OR a = 1)` gives `NOT a = 1`. Every record that matches the
filter still matches the part, and the segment store, which gets the
whole filter, narrows the result back as before.

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

* Extract a filter's part on allowed fields in the filter module

Taking the part of a filter that names only some fields depends on
nothing but the filter and the fields, so it moves next to
`filter_fields` and `map_filter_fields` as `extract_filter`, the name
PostgreSQL, DataFusion, and Spark give the same operation.
`EventMemory._vector_store_filter` had one caller, so `query` now maps
the filter's fields and extracts its part on the declared ones itself.

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

* Test filter extraction against an evaluator, and queries with and without it

Rewriting a filter is checked the ways database engines check a
planner's rewrites, against the in-memory stores' filter evaluator:

- A table of filters and their expected parts covers every node kind
  under both parities of negation, with each operand kept or dropped,
  and alone reaches every line and branch of `extract_filter`.
- Random filters up to four levels deep, over allowed and other fields
  and every kind of leaf: every record a filter matches, its part
  matches, and the part names only allowed fields; a filter on allowed
  fields alone is its own part; and when each other field appears
  once, a record matches the part exactly when some values of the other
  fields would make it match the filter.
- Random filters through EventMemory, with the vector store getting the
  part and with it getting no filter: when the search reaches every
  record, both answer the filter exactly, and when it reaches fewer, the
  part only ever adds hits.

The random filters and records come from a shared test module.

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

* State that a condition on an undeclared field applies to the searched seeds

A vector search returns at most `vector_search_limit` seeds, and a
condition on a field the vector store does not declare is applied to
those seeds afterward, so a selective one can leave fewer seeds than the
limit, or none, while matching segments exist further down the ranking.
That bounds a query's cost by the limit. `query`'s docstring now says so,
and a test pins it with an embedder that ranks the matching event below
the limit: the condition on an undeclared field finds nothing, the same
condition finds the event when the search reaches every seed, and a
condition on a declared field finds it within the limit.

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

* Say that a seed the filter rejects has no context, and make the fake agree

EventMemory leaves to the segment store every condition the vector
store cannot evaluate, so it relies on `get_segment_contexts` leaving
out a seed that fails the filter. The SQL store does, but the contract
only said the filter applies to the segments, and the in-memory fake
kept a rejected seed whenever a neighbor in its window matched. The
contract now says a seed that does not match has no entry, the fake
checks the seed, and a test pins it through EventMemory.

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

* Say what the vector search filters on, in EventMemory's docstrings

`query` said only conditions on undeclared fields wait for the seeds,
but a declared condition in an OR with an undeclared one, or in a
negated AND with one, waits too: the vector search applies only the
part of the filter that names declared fields alone. And the expected
collection schema said declaring a property makes it efficiently
filterable, when a property left undeclared is not on the vector record
at all, so the search cannot filter on it.

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

* Use the declared fields EventMemory already holds

Building a vector record and extracting the vector store's part of a
filter both read the collection's declared schema again, once per
derivative for the record. `_schema_fields` already holds those keys.

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

* Test a declared user key on the record and in the search

The fixture's collection declares only system keys, so no test had a
user key reaching the vector store; a wrong mapping of `m.<key>` would
have dropped its condition from the search with every test passing. A
test now declares `color` and checks that the record carries it and
that `m.color` reaches the search as `color`. The three tests that
spied on the vector store's queries share one helper.

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

---------

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants