Skip to content

[declared schema 2/2] Close the filter union, type each filter field when it is parsed, check records at upsert, and make negation the complement on every backend but NebulaGraph - #1616

Draft
edwinyyyu wants to merge 19 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/filter-union-speedkick
Draft

edwinyyyu wants to merge 19 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/filter-union-speedkick

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

The filter language becomes a closed union: Equals, Ordering, In, IsNull, n-ary And/Or, Not (common/filter/filter_expression.py); every compiler is an exhaustive match. != and <> parse as Not(Equals), NOT IN as Not(In), IS NOT NULL as Not(IsNull), so a negation means one thing. An ordering comparison takes a number or a date: field > 'string' and field > true, which parsed before, are now rejected by the parser.

A leaf on a field holding no value is false, IsNull on it is true, and Not is the complement over the record set on every vector store and the segment store. The SQL compilers render it as NOT COALESCE(x, FALSE): sql_filter_util.py compiled ~inner, three-valued, and a negated filter silently dropped rows holding no value on every SQL-backed store. Milvus already pushes Not to the leaves by De Morgan, since [vector store scale-out 5/6] (#1736), as its own not excludes entities lacking the field, and Qdrant's must_not already admits them. The Neo4j graph store and the Neo4j semantic storage render Not as NOT coalesce((x), false), the complement, and the NebulaGraph graph store keeps its NOT over a comparison three-valued (#1918), since its tests need a NebulaGraph Enterprise 5.0 server. A filter is bound to the store's declared schema before the store compiles it (bind_filter), and a record before the store writes it (bind_record): an int for a float key becomes the float it equals, and a float for an int key, a bool for a number, a non-finite value, or an int outside 64 signed bits raises PropertyTypeMismatchError, whose message says which, so no compiler meets a value of another type than its key declares. EventMemory binds an event's declared properties in _validate_events, before anything is written, so the segment store and the vector store hold the same value. An In over no values admits nothing: every compiler renders it as false, so EventMemory's empty id lists keep meaning "nothing", and its complement admits everything.

A filter's fields are resolved when it is parsed, so nothing below the parser reads how a client spelled a field. Each leaf names a SystemField(name) or a Property(key, type) (filter_expression.py). The parser turns m.<key> and metadata.<key> into a property and any other name into a system field, and normalize_filter_field, mangle_user_metadata_key, demangle_user_metadata_key, is_user_metadata_key and the USER_METADATA_* prefixes go. Each memory maps its own system fields: the event backend's to its stored keys, and the episode store's, the semantic storages' and short-term memory's to their columns and attributes. EventMemory takes only SystemField("timestamp") and properties, the segment store maps the timestamp to its column and a property to its JSON key, and the vector stores and graph stores refuse a system field.

A literal is typed by its field when the filter is parsed. parse_filter takes the target memory's system field types (EVENT_BACKEND_FILTER_FIELD_TYPES, EPISODE_STORAGE_FILTER_FIELD_TYPES, SEMANTIC_STORAGE_FILTER_FIELD_TYPES), and each store's resolver takes its field names from the same map. An int for a float field parses as the float it equals. A string for a datetime field (a datetime is written date('...')), a float for an int field, a bool for a number, or a number for a string field is a parse error, which the API answers with 422. A system field's name matches exactly: a mixed-case name such as Producer_ID, which the episode store matched before, is an unknown field. A user property's type is dynamic, and its numbers compare by value, an int and a float as one numeric type, exactly beyond 2⁵³ on SQLite and PostgreSQL.

An In takes no float and no datetime on either side: a membership test of floats, datetimes or bools, or on a float or datetime field, is refused, where an int test on a float key was a disjunction of float equalities. An ordering comparison (<, <=, >, >=) takes an int, a float or a datetime. An equality on a float or a datetime is exact on every store: Qdrant compiles it as a closed Range or DatetimeRange, since its MatchValue takes no float.

A vector store refuses an upsert batch that names a record UUID twice, with ValueError before anything is sent, where the stores disagreed: the SQLite store and Qdrant kept the last version, the sqlite-vec store failed partway with sqlite3's UNIQUE constraint error, and Milvus refused the batch with its own error. A store keeps a datetime property's instant, taking a naive datetime as UTC, and may drop its UTC offset; a filter compares datetimes by their instant (the VectorStorePartition docstring). Qdrant now writes a datetime in UTC: it keeps a written datetime's offset only to the minute, so one at an offset with a seconds component was stored up to 59 seconds off its instant, and an equality on that instant missed it. These come from [vector store write validation] (#1788), folded in here; its non-finite check is not, since bind_record already refuses a non-finite value at every store's upsert.

Last in the stack, because the store work is the priority and this change touches many files mechanically; the slices under it are written against the nodes main has today. The complement law is checked over thirteen filters in declared_schema_contract.py, across the column, JSON and properties encodings of the SQL stores.

Adaptation for main: the routing of [user properties 1/2] is rewritten on the closed union (And.operands; Equals for Comparison with =), and its extract_filter moves to filter_expression.py, a match over the n-ary nodes exported from common.filter, with its tests and random filters on the closed union; the speedkick-era commit 8ca5f06 was written on #1597's tree, and its rewrites of #1597's _system_predicates, _lift_typed_filters and their tests have nothing to apply to on main. The event memory test spells the field context.producer, main's name for it. The tests the rebuilt base adds use the closed union too, and the segment store's random context-read test models the new semantics: a condition on a property with no value is false and a negation is the complement, where the model followed SQL's three-valued logic. Through 3176c1a92, the same 60 files as the origin; the commits after it are new here, with no origin counterpart.

Position: directly on feat/horizontal-scaling, which has [declared schema 1/2] (#1628) merged, in parallel with the SQLite fixes; the fixes' tests, which the origin also converts to the closed union, are not in this branch.

Fixes #1780, #1781, #1783, and #1784.

Stack

24 open PRs: three independent PRs, and the vector store tree of short parallel branches. Every open 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. #1675 is closed, superseded by #1627. #1841 is closed: its request packing is dropped, and its cancellation contract is in #1840. #1828 and #1839 sit side by side on the branch; #1839 and #1840 are the event memory recovery stack, and #1684 heads the event memory stack, which its own PRs index, listed because it builds on #1840, its branch also carrying #1828's commits until one of the two merges; #1871 keeps its own Stack section. 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
[user properties 1/2] #1702 (merged into feat/horizontal-scaling) Keep undeclared properties out of the vector store feat/horizontal-scaling
[user properties 2/2] #1670 (merged into feat/horizontal-scaling) Remove per-project filterable properties (port of #1606) feat/horizontal-scaling
— #1824 (merged into feat/horizontal-scaling) Drop the unread logical key from the purge queues 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 —
[vector store scale-out 6] #1627 (merged into feat/horizontal-scaling) Give each vector store a fixed schema and string-keyed partitions, built from the deployment's configuration feat/horizontal-scaling
[declared schema 1/2] #1628 (merged into feat/horizontal-scaling) Make a vector store filter only on the properties it declares feat/horizontal-scaling
[sqlite write transactions 1] #1844 (merged into feat/horizontal-scaling) Take SQLite's write lock at BEGIN in every store on a relational SQLite engine feat/horizontal-scaling
[session storage 1/2] #1622 Create a session's storage with the session, never on a request feat/horizontal-scaling
[session storage 2/2] #1625 Look a partition up with get_partition, and remove open-or-create from both stores and close from the segment store #1622
[selective filter 1] #1871 Route filtered queries by selectivity, and search allowlists exhaustively feat/horizontal-scaling
[search results, deferred] #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 (this PR) Close the filter union, and make negation the complement on every backend feat/horizontal-scaling
[sqlite store fixes 1/7] #1460 Publish vector index files atomically (but not durably) feat/horizontal-scaling
[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 (closed) Superseded by #1627, which takes SQLite's write lock at BEGIN where the engine is created —
[sqlite store fixes 7/7] #1676 Give every write a fresh row id, so a key names one version (port of #1610) #1674
[sqlite store fixes 8] #1843 Hold the engine lock until a cancelled engine call's thread finishes #1676
[qdrant options] #1618 Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization feat/horizontal-scaling
[milvus options] #1741 Let a deployment tune a Milvus collection's vector index and its searches #1618
— #1841 (closed) Its request packing is dropped, and its cancellation contract is in #1840 —
[property keys] #1828 Refuse an illegal metadata key before any store writes, and reserve one key prefix feat/horizontal-scaling
[event memory recovery 1/2] #1839 Give EventMemory a store's shape, with a handle per partition feat/horizontal-scaling
[event memory recovery 2] #1840 Keep event memory's two stores consistent through failed ingests and forgets #1839
[event memory 1/6] #1684 Add time bounds, sources and expansion to EventMemory (port of #1597, without session) #1840
[schema migration 1/2] #1825 Add memmachine-server migrate, which creates every configured store's schema before servers start feat/horizontal-scaling
[schema migration 2/2] #1826 Make startup check what migrate created, and create nothing at startup #1825

This PR is its 16 commits, 2fd7f67f9, 197757ec7, b0f862135, 9a3efe165, 3176c1a92, 38bfc9e58, 79cf89dc4, e39f219a8, 2cc7d0fa9, 602386499, 3a0e53fd9, 04c252b9d, 4c0e9cbec, c093a94df, f09a4f7be, afac73b12, directly on feat/horizontal-scaling.

Verification

At this PR's head 2486d2d73, on 2026-11-03, its tree is afac73b12's with three commits from #1788 on top: an upsert batch naming a record UUID twice refused, Qdrant's datetimes written in UTC, and the datetime property contract stated. #1788's non-finite check is dropped: every store's upsert (the registry-backed base for Qdrant and Milvus, the SQLite store and the sqlite-vec store) binds each record with bind_record first, whose _validate_value refuses a non-finite float, and declared_schema_contract.py checks that on every store. The new contract tests run on every store: a batch repeating a UUID raises and stores nothing, and fails without the check on all five variants (the SQLite store and Qdrant over REST and gRPC raise nothing, the sqlite-vec store raises sqlite3's OperationalError, Milvus 2.6.24 its MilvusException); a datetime written at +05:30 compares by its instant at other offsets; a datetime written at -00:44:30 equals its instant, given at that offset or in UTC, and fails on Qdrant over REST and gRPC without the write in UTC. Qdrant's read-back test finds a datetime written at +05:30 held in UTC. Checks at the head: ruff check and ruff format --check clean; ty check clean as CI runs it; uv lock --check clean. The unit tests pass, 2596 tests (8 skipped), and the vector store integration tests pass against containers the tests start (Qdrant 1.19.1, Milvus 2.6.24): Qdrant 356, Milvus 149, and the partition registry 56.

Before #1788's commits: At this PR's head afac73b12, on 2026-11-03, its tree is 3176c1a92's with eleven commits on top: field references resolved at parse, the tests' nodes built from them, the refused-value wording, the In and equality rules, literals typed at parse, the tests' field types, numbers compared by value, events bound before any write, Neo4j's complement, each store's field names taken from its field types, and exact field names. Checks at the head: 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 unit tests pass at the head, 2590 tests (8 skipped). At c093a94df, the ninth of them, the unit tests pass, 2552 tests (8 skipped), and the integration tests pass against containers the tests start: Qdrant 348; the SQL filter, segment store and episode store tests on PostgreSQL 216; Neo4j 27 (graph store and declarative memory); semantic storage 1044 (pgvector, Neo4j and in-memory, 3 skipped by their Neo4j-specific skips, the two LLM-backed modules not run); and Milvus 146. The two commits after it were checked on what they touch. At f09a4f7be: the episode store, semantic, main and filter unit tests (790 passed, 2 skipped), and the episode store and semantic integration tests (1145 passed, 15 skipped by Neo4j-specific skips). At afac73b12: the episode store, main and server unit tests (453), and the episode store's integration tests on PostgreSQL (91). The NebulaGraph tests did not run, since they need an external NebulaGraph Enterprise 5.0 server.

Before the filter-reference commits: At this PR's head 3176c1a92, on 2026-11-02, rebased onto feat/horizontal-scaling at 0fa52b38b, where [sqlite write transactions 1] (#1844) is merged since 690116e37. Its own lines follow the base: the SQLite stores import FilterExpr from the filter package beside #1844's validate_engine_connections. Its commits are re-signed, with their messages and authors unchanged. Checks: 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 unit tests pass, 2479 tests (8 skipped). The integration tests did not run here.

Before the rebase onto #1844's merge: At this PR's head b566a2c15, on 2026-10-30, restacked directly on feat/horizontal-scaling at 690116e37, where [declared schema 1/2] (#1628) is merged as 5c9d3ae99, with #1831 before it and #1858 after it; [sqlite write transactions 1] (#1844) is no longer beneath it, since this PR uses none of #1844's code. Its own lines follow the base: the declared-schema contract keeps both #1628's re-upsert test and this PR's own tests where they meet, and keeps _presence_records, which #1628's last commit removed and this PR's tests call; with_has_set_ids, which #1831 made public, returns FilterExpr | None as this PR has it; and #1831's semantic storage test builds its membership filter with a tuple, as this PR's In holds. Its commits are re-signed, with their messages and authors unchanged. Checks: 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 unit tests pass, 2470 tests (8 skipped). The integration tests did not run here.

Before the restack onto feat/horizontal-scaling after #1628's merge: At this PR's head 526b3d52e, on 2026-10-28, restacked on [sqlite write transactions 1] (#1844) at 4f5d4430c, on #1628's df716e47b: its tree is the previous head cb0da1545's with #1628's two changes since 274264523 beneath it (is_valid_identifier's docstring, and the SQLite store's records table built without extend_existing), and its own lines are unchanged. Its commits are re-signed, with their messages and authors unchanged. Checks: 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.

Before the restack onto #1628's df716e47b: At this PR's head cb0da1545, on 2026-10-27, rebuilt on [sqlite write transactions 1] (#1844) at 0bcbf8b3a, with #1628's second round of review commits (the identifier rule in data_types.py, which imports nothing from the package, and the interface's Raises sections sorted by name) beneath it. Its own lines follow them: utils.py imports the errors, Record, and the int range from data_types.py by name, so bind_record annotates with Record itself; MIN_INT_VALUE and MAX_INT_VALUE sit in data_types.py with no import from the package; and the Raises entries this PR adds to upsert and query take their places by name. Its commits are re-signed, with their messages and authors unchanged. Checks: 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 unit tests pass, 2446 tests (8 skipped). The integration tests did not run here.

Before #1628's second round of review changes: At this PR's head 85d44a9a2, on 2026-10-27, rebuilt on [sqlite write transactions 1] (#1844) at 69e292d8a, with #1628's four commits from Edwin's review (no supported_filter_nodes, its checks in utils.py, sqlite_properties.py, and the strict-mode comment) beneath it. Its own lines follow them: bind_filter and bind_record, with their helpers, live in utils.py beside the other validators and raise their errors through the data_types module, which imports utils.py; bind_filter takes no supported nodes, since every store evaluates the whole filter language; and filter_expression.py and the filter package's exports drop filter_nodes. The first two commits' messages name sqlite_properties.py and no longer mention the node check. Its commits are re-signed, with their authors unchanged. Checks: 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 unit tests pass, 2446 tests (8 skipped). The integration tests did not run here.

Before #1628's review changes: At this PR's head 3a803816e, on 2026-10-27, moved onto [sqlite write transactions 1] (#1844) at 497a89ef1, which now sits on #1628: its tree is the previous head 08c6dc525's, so the checks and tests at that head stand. Its commits are re-signed, with their messages and authors unchanged.

Before #1844 moved above this PR: At this PR's head 08c6dc525, on 2026-10-27, replayed onto the replayed #1628's 135184cd6, after [vector store scale-out 6] (#1627) merged into feat/horizontal-scaling as 805ea2177, a squash whose tree is 8c2351dbe's: its tree is the previous head ac7c6eda7's with #1628's test-only commit beneath it, which gives the oversized Milvus upsert test's own store a longer request timeout. Its commits are re-signed, with their messages and authors unchanged. Checks: 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 integration tests did not run here.

Before the replay onto the merged #1627: At this PR's head ac7c6eda7, on the rebuilt #1628, on 2026-10-26: beneath it are #1627's changes since e38e8003e (each store's schema passed as one VectorStoreSchema, store_schema; SQLite partitions named by an incarnation and created as the other stores create theirs; the shared SQL engine setup and write transactions in common/sql.py; and every session's event memory served from the deployment's configuration, in one store) and #1844's SQLite write transactions. This PR's own lines follow them: the stores bind filters and records against store_schema.indexed_properties; the two SQLite store tests #1627 added with a Comparison filter use Equals; and the Qdrant store drops the Mapping and PropertyType imports with the compiler's schema argument. Checks: 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 unit tests pass, 2446 tests (8 skipped). The integration tests did not run here.

Before the rebuild onto #1627's 8c2351dbe: At this PR's head 8c76235b1, on 2026-10-20, its tree is the previous head 6d67208b1's with #1627's accuracy fixes, in text only: the partition registry and purge design documents, the database manager's comments and docstrings, the SQLite store's module docstring, the partition registry configuration's descriptions, and other docstrings that still described collections. Checks: 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 unit tests pass, 1355 tests (8 skipped). No integration tests ran this time, since only comments, docstrings, descriptions, and documentation changed.

Before #1627's accuracy fixes: At this PR's head 6d67208b1, on 2026-10-19, its tree is the previous head 5ddf3a290's with #1627's second review's fixes: the Qdrant and Milvus tests that partitions share the store's collection now write through two partitions and read the store's one native collection; the store tests name their partition fixture partition; and docstrings, comments, and the configuration and upgrade documentation are corrected. Checks: 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 unit tests pass, 1355 tests (8 skipped), and the vector store and segment store integration tests (the registry on PostgreSQL and SQLite, both SQLite stores, Qdrant, Milvus, and the SQLAlchemy segment store) pass, 642 tests.

Before #1627's second review's fixes: At this PR's head 5ddf3a290, on 2026-10-19, its tree is the previous head 5a9d2d9eb's with #1627's review fixes: the database manager answers a repeated (backend, vector store name) with the store it built, without comparing parameters, and keeps a backend's shared SQLite engine until it closes, even when a store's build fails; its test that one SQLite backend's stores share the engine can now fail; the partition registry's parameters check the vector store name with the shared validate_vector_store_name; and semantic memory's local name for the backend key is backend. Checks: 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 unit tests pass, 1355 tests (8 skipped).

Before #1627's review fixes: At this PR's head 5a9d2d9eb, on 2026-10-19, moved onto [declared schema 1/2] (#1628): its tree is the previous head 18ef07b12's without [search results] (#1663) beneath it. This PR's own lines keep SimilarityMetric beside its filter imports. Checks: 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 unit tests pass, 1355 tests (8 skipped), and the vector store and segment store integration tests (the registry on PostgreSQL and SQLite, both SQLite stores, Qdrant, Milvus, and the SQLAlchemy segment store) pass, 642 tests.

Before the move off [search results] (#1663): At this PR's head 18ef07b12, on 2026-10-19, its tree is the previous head 3ae24ee56's with design/vector_store_partition_registry.md listing the lookup as "open", the name it has from #1627 until #1625, where it said "get". Checks: 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. Outside that document the tree is the previous head's, so the test runs below stand.

Before the partition registry document's wording fix: At this PR's head 3ae24ee56, on 2026-10-19, its tree is the previous head 2af6ac444's with #1824 beneath it, which drops the purge queues' unread logical key, and #1627 dropping the same key from the partition registry's tombstones and removing the collection registry design document its partition registry document replaces. Checks: 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 unit tests pass, 1333 tests (8 skipped).

Before the rebase onto #1824's merge: At this PR's head 2af6ac444, on 2026-10-16, its tree is the previous head 291a4a103's with the configuration's check of a provided tombstone_retention_seconds named _validate_tombstone_retention, where it was _require_retention_floor. Checks: 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 unit tests pass, 1333 tests (8 skipped).

Before the retention check's rename: At this PR's head 291a4a103, on 2026-10-16, its tree is the previous head 85a4b756a's with every vector store check of a value that raises named validate_, as validate_partition_key already was: validate_declared_types, validate_query_vector, validate_score_threshold, validate_limit, and validate_dimensions from the base, and the PRs' own checks in their turn. Checks: 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 unit tests pass, 1333 tests (8 skipped).

Before the raising checks took the validate_ prefix: At this PR's head 85a4b756a, on 2026-10-16, its tree is the previous head a7950f283's with #1627's changes: the VectorStore interface drops vector_store_name, vector_dimensions, similarity_metric, and indexed_properties, and the partition handle drops partition_key; a partition key may be any string of up to 255 bytes without NUL, and the SQLite stores name a partition's tables and index file by a SHA-256 digest of it; and the checks that return a bool are is_valid_identifier and is_valid_filter. Checks: 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 unit tests pass, 1333 tests (8 skipped), and the vector store integration tests (the registry on PostgreSQL and SQLite, both SQLite stores, Qdrant, and Milvus) pass, 512 tests.

Before the store's properties left its interface: At this PR's head a7950f283, on 2026-10-15: its tree is the previous head 150acbc8e's without #1627's per-partition schema record (PartitionSchema, the registry's and the SQLite stores' schema column, and VectorStorePartitionSchemaMismatchError), and with require_partition_key named validate_partition_key; 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 unit tests pass, 1327 tests.

Before the per-partition schema record went: At this PR's head 150acbc8e, on 2026-10-15: its tree is the previous head 2b82dbde4's with the partition lookup named open_partition and the segment store's close_partition kept, since #1625 renames the one and removes the other; 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.

Before the lookup's rename moved into #1625: At this PR's head 2b82dbde4, on 2026-10-15: its tree is the previous head 580538da1's without the segment store's close_partition; 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.

Before #1627's first commit took the removal of close_partition: At this PR's head 580538da1, on 2026-10-15, restacked on #1627's rewritten history: its tree is the previous head f61920cf6's; 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.

Before #1627's history was rewritten: At this PR's head f61920cf6, on 2026-10-15, rebased onto feat/horizontal-scaling at cf7ae1579, which carries main through #1774: 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 MemMachine mock and session deletion tests pass, 48 tests (3 skipped).

Before the rebase onto cf7ae1579: At this PR's head 04943f87d, on 2026-10-15, rebased onto feat/horizontal-scaling with #1702 and #1670 merged (101182c18): 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.

Before the rebase onto #1670's merge: At this PR's head ad48f4407, on 2026-10-13, rebuilt on #1702's 6631b5c77: 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, 503 tests (1 skipped). Each of seven deliberately broken versions of extract_filter fails these tests, and the table of expected parts alone covers every line and branch but the closed match's unreachable fall-through.

Before the rebuild on #1702's filter routing: At this PR's head 13d104245, 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; the Qdrant store's, the registry-backed store's, and the database manager's tests, unit and integration, pass, 386 tests, against Qdrant 1.19.1 defaulting new collections to strict mode and Milvus 2.6.24 in containers.

Before the rebase onto #1813's merge: At this PR's head df21dcdda, on 2026-10-09: 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. Its tree is the previous head e9b2fc25c's: the server suite without integration tests last passed at d491433ce, on 2026-10-08, 2235 tests, and the integration tests of the vector stores, the resource manager, episodic memory, and semantic storage at d0ba0b451, on 2026-10-08, 840 tests, against PostgreSQL 16, Neo4j, Qdrant 1.19.1, and Milvus 2.6.24 in containers. The SQLite stores' tests last passed at 372d94164, on 2026-10-09, 270 tests, after the SQLite offset column was dropped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

@edwinyyyu
edwinyyyu marked this pull request as draft September 14, 2026 19:06
@edwinyyyu
edwinyyyu force-pushed the feat/filter-union-speedkick branch 5 times, most recently from e5f728a to bdadfe0 Compare September 14, 2026 20:33
@edwinyyyu
edwinyyyu force-pushed the feat/filter-union-speedkick branch from bdadfe0 to f8ca110 Compare September 14, 2026 21:42
@edwinyyyu edwinyyyu changed the title (Depends on #1606) Close the filter union, and make negation the complement on every backend (speedkick) [vector store 13/13] Close the filter union, and make negation the complement on every backend (speedkick) Sep 14, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/filter-union-speedkick branch 5 times, most recently from 219905a to 792c4e9 Compare September 14, 2026 23:16
@edwinyyyu edwinyyyu changed the title [vector store 13/13] Close the filter union, and make negation the complement on every backend (speedkick) [vector store 12/12] Close the filter union, and make negation the complement on every backend (speedkick) Sep 14, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/filter-union-speedkick branch 2 times, most recently from 219905a to ed99d4b Compare September 15, 2026 17:26
edwinyyyu and others added 16 commits November 2, 2026 10:04
…kend

The filter language is a closed union: Equals, Ordering, In, IsNull,
n-ary And and Or, and Not. Every compiler is an exhaustive match. The
parser maps `!=` and `<>` to Not(Equals), `NOT IN` to Not(In) and
`IS NOT NULL` to Not(IsNull), so a negation means one thing.

A leaf on a field holding no value is false, IsNull on it is true, and
Not is the ordinary complement over the record set. The SQL compilers
render Not as `NOT COALESCE(x, FALSE)`; they compiled `~inner`, which is
three-valued and dropped rows holding no value. Milvus already pushes
Not to the leaves by De Morgan, since its own `not` excludes entities
lacking the field, and Qdrant's must_not already admits them. A
predicate matches only a value of the compared type. The graph stores
and the Neo4j semantic storage get the union only; their NOT over a
comparison on a missing property stays three-valued, since no backend
here can test a change.

Where the compiled output changes: common/filter/sql_filter_util.py and
common/vector_store/sqlite_properties.py (COALESCE under Not), and the
parser's mapping of `!=`. Where behavior changes: the in-memory test
collection's evaluator matches values by type, the declared-schema
contract gains the complement law and the typed-match test, and two
semantic-storage tests that ordered string values go, since ordering
strings is not expressible in the union. An `In` over no values admits
nothing: every compiler renders it as false, so EventMemory's empty id
lists (session, source, block kind) keep meaning "nothing", and its
complement admits everything. Every other file in this commit changes
node names, import paths and dispatch shape only, with the same output
as before.

The tests the rebuilt base adds use the closed union too, and the segment
store's random context-read test models the new semantics: a condition on
a property with no value is false and a negation is the complement, where
the model had followed SQL's three-valued logic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
A leaf whose value was of another type than its key declares matched
nothing on every store, each compiler rendering it as a filter no record
satisfies, so a caller who compared a key with the wrong type got an
empty result and no sign of the mistake; and `score > 3` on a float key
matched nothing, since the parser reads `3` as an int. A NaN or an
infinity reached the backends too: it matched every held value on
Qdrant's REST transport, a NaN failed on its gRPC transport, and either
failed on Milvus.

`bind_filter` replaces `validate_supported_filter` at every store's
query and returns the filter the store compiles in place of the
caller's. It still refuses an undeclared key, and in the filter it
returns every value is of its key's declared type: an int for a float
key becomes the float it equals, and a membership test of ints on a
float key the disjunction of those floats' equalities, since a
membership test holds ints or strs. Any other value of another type than
its key declares, a bool for a number, and a float that is not finite
raise PropertyTypeMismatchError, which `upsert` already raises for a
record; the VectorStorePartition query documents it.

The compilers' branches that rendered a mistyped leaf as no match are
unreachable and go: the Qdrant store's no-match filter, the Milvus
store's filtering of values by type, and the SQL columns compiler's
false branches, keeping only an `In` over no values as false. None of
the three needs the declared schema any more. The Qdrant and Milvus
design documents describe the binding.

The declared-schema contract replaces its no-match test with tests every
store runs: a value of another type and a float that is not finite each
raise from `query`, and an int compares with a float key as the float it
equals, each filter and its complement. The first two fail with the
check in `_validate_value` removed, the non-finite cases also with its
finiteness clause removed, and the third fails on Qdrant, whose float
index refuses an int match, with the int-to-float conversion removed
from `_bound_value` or from the membership branch of `_bound_filter`.
The Milvus and Qdrant modules' own no-match tests go, the Qdrant one
with the strict-mode reason it stated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A record could not hold an int for a float key: upsert refused it as a
type mismatch, though a filter may compare a float key with an int. A
float property that is not finite reached the backends instead, and
each kept it its own way: the SQLite stores held a NaN as no value and
an infinity as written, Qdrant's REST transport held either as no
value, and Qdrant's gRPC transport and Milvus refused the upsert.

`bind_record` replaces `validate_declared_properties` at every store's
upsert and returns the record the store writes in place of the
caller's: its keys are declared and every value is of its key's
declared type, an int for a float key becoming the float it equals. Any
other value of another type, a bool for a number, and a float that is
not finite raise PropertyTypeMismatchError, by the same rule the bound
filter follows. The in-memory test partition binds its records too, and
the VectorStorePartition upsert and the error document the rule.

The declared-schema contract checks the rule on every store: an int
written to a float key is stored as the float it equals, read past the
store through a new `stored_value` hook each store's test module
supplies, and matched by a float filter, an int one, and a membership
test of ints; and each mistyped or non-finite value raises from
`upsert`. The per-store tests of refused values lose their
int-for-float case. The first test fails with the int-for-float clause
removed from `_validate_value`, and on Qdrant, which keeps an int
payload as an int, with `bind_record` returning the caller's record;
the second fails with the finiteness clause removed, and its bool cases
with a bool counted as an int.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every backend holds an int in 64 signed bits, and each answered a wider
one its own way. A record holding one failed to write on the SQLite
stores, on Qdrant's gRPC transport, and on Milvus, and Qdrant's REST
transport stored it as the largest int64, which an equality filter on
that value then matched. A filter holding one failed on the SQLite
stores and on Milvus, failed an equality on Qdrant, and answered an
ordering on Qdrant with its bound clamped, so `>= 2**63` matched the
largest int64.

The binding both `bind_record` and `bind_filter` apply now refuses an
int outside -2**63 to 2**63 - 1 for any key with
PropertyTypeMismatchError, before anything is sent; the bounds are
MIN_INT_VALUE and MAX_INT_VALUE beside the error. An int for a float key
is held to the same range, so its float always exists.

The declared-schema contract adds out-of-range ints to its mistyped
records and filters, and a test that every store compares the int64
extremes with filters at those extremes. The mistyped cases fail with
the range clause removed from `_validate_value`, and the extremes test
with the range made exclusive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
A filter leaf named its field by the client's spelling, and every
consumer re-derived what the name meant from the string:
`normalize_filter_field` and the mangling helpers told a user property
(`m.<key>`, `metadata.<key>`) from a system field (a bare name) in the
episode store, the semantic storages, short-term memory, LongTermMemory,
EventMemory and the segment store. A leaf's field is now a reference:
`SystemField(name)`, a field the consuming system defines, or
`Property(key, type)`, a property by key with its declared type where a
schema declares one. The parser resolves the syntax once, and nothing
below it parses it; only LongTermMemory's message for an unknown name
still spells it, as the hint it gave the client. The helpers that
re-derived the kind go.

Each consumer maps the references it defines and refuses any other.
The episode store, the semantic storages and short-term memory map a
SystemField to their columns or attributes and a Property to their
metadata, as they mapped the spelled names. The declarative backend
maps a SystemField to the filterable property of its name and a
Property to its stored `metadata.<key>` property, the layout it keeps.
The vector stores and graph stores compile a Property and refuse a
SystemField with ValueError, `bind_filter` before anything is sent;
DeclarativeMemory refuses one too.

LongTermMemory's event backend now resolves its system fields: each
becomes the Property of its stored key (`producer_id` the property
`_producer_id`), the timestamp stays `SystemField("timestamp")`, and an
unknown name raises ValueError as before. EventMemory takes only
properties and the timestamp, sending the vector store the timestamp as
its `_timestamp` property, and the segment store maps the timestamp to
its column and a property to its JSON key. Both raise ValueError for any
other system field, where each mapped a bare name `x` to the property
`_x`. That replaces the ruling that EventMemory takes the client's
names and the segment store resolves them.

Where output changes: filter_parser.py (leaves carry references, and
`to_property_filter` keys an equality on a property by its key, where
it kept the spelled `m.<key>`); event_memory.py and
sqlalchemy_segment_store.py (a system field other than the timestamp
raises); common/vector_store/utils.py, neo4j_vector_graph_store.py,
nebula_graph_vector_graph_store.py and declarative_memory.py (a system
field raises). long_term_memory.py moves the resolution the two event
memory modules did, so the stores see what they saw. Every other source
file changes names, dispatch and docstrings only, with the same output
for the same filter text. The public filter syntax is unchanged.

The in-memory fakes follow their stores: the vector store fake's
`evaluate_filter` reads a Property from the record's properties and a
system field from a mapping its caller passes, which the segment store
fake fills with the segment's timestamp. Tests are added for the
parser's resolution, the refusals and LongTermMemory's mapping; the
segment store's and EventMemory's tests that a context field matches
nothing now expect the refusal, and the tests of the removed helpers
go. Node constructions in the remaining tests follow in the next
commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical: every test that built a leaf from a spelled field name
builds it from the reference the parser makes of that name, so every
test asserts what it asserted. `m.<key>` and `metadata.<key>` become
`Property(key)`, and a bare name the parser reads `SystemField(name)`.
A store's own fields become `Property(key)` where the store filters on
properties, which is every vector store, graph store and declarative
memory test. The EventMemory tests name LongTermMemory's stored keys,
`Property("_episode_uid")` for `episode_uid`, now that LongTermMemory
resolves them, and the timestamp as `SystemField("timestamp")`.

The helpers that took a spelled name take a reference, with the same
results: `nodes.comparison`; `random_filter` and `random_properties`,
over keys; the SQL filter tests' field resolvers; the segment store
test's model of a filter; the Qdrant test's model, by `property_key`;
and the map_filter_fields tests, which swap the two kinds where they
upper-cased a name. Test docstrings that described the spelled names
describe the references. No test changes what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`_validate_value` refused a float that is not finite and an int outside
64 signed bits with the type mismatch's message, so a NaN for a float
key read "declared as float, got float nan". PropertyTypeMismatchError
stays the one error, and takes what else is wrong with a value of an
admitted type: its message then reads "got float nan, which is not
finite" or "got int 9223372036854775808, which is outside the 64-bit
signed range". A value of another type keeps the mismatch's message.

Where output changes: the two messages, in common/vector_store/utils.py
and data_types.py. A new test module checks each message from
`bind_record` and `bind_filter`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at and a datetime equal only themselves

A membership test holds ints or strs, which every store matches exactly.
`In` now refuses a value of any other type, a float or a datetime
included, where it refused only a bool, and a field referenced as a
float or datetime property. `bind_filter` refuses a membership test on a
key declared float or datetime with ValueError, before its values are
checked, and no longer turns ints on a float key into a disjunction of
float equalities. VectorStorePartition's query contract states it.

Ordering is unchanged: ints, floats and datetimes order, and strings and
bools do not. Equality on floats and datetimes stays, every backend
comparing them exactly, and tests now check that a float and a datetime
equal only themselves on the declared-schema contract (the SQLite
stores, Qdrant and Milvus), the typed-JSON compile on SQLite and
PostgreSQL, the segment store and the Neo4j graph store. The segment
store's floats stay below 1e16, which PostgreSQL's JSONB reads back as
an int (MemMachine#1797).

Where output changes: filter_expression.py (`In` construction) and
common/vector_store/utils.py (`bind_filter` on a float or datetime
key); vector_store.py changes its docstring only. The declared-schema
contract's int-for-float membership cases go, its mistyped membership
test on a float key moves to a test of the refusal, and the parser
tests check the `In` refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parser read `3` as an int whatever it was compared with, so a
condition on a float field reached the stores holding an int, and each
store chose what to do with it. `parse_filter` now takes the static
types of the target system's fields and types each literal compared
with one: an int for a float field is the float it equals, and any other
literal of another type is a FilterParseError, which the API answers
with 422 as it does every filter error. A string for a datetime field is
refused with the hint that a datetime is written `date('...')`, a float
for an int field and a bool for a number are refused, and so is a
membership test on a float or datetime field. A property (`m.<key>`)
stays dynamically typed, keeping its literal's own type, as does a name
no target types.

MemMachine parses a request's filter once per memory it reaches, before
any search starts: the episodic search by the event backend's fields
(`EVENT_BACKEND_FILTER_FIELD_TYPES`, its stored system fields by name
and EventMemory's datetime `timestamp`), the episode listing and count
by the episode storage's (`EPISODE_STORAGE_FILTER_FIELD_TYPES`), and
semantic memory by the semantic storage's
(`SEMANTIC_STORAGE_FILTER_FIELD_TYPES`).

A property reference carries its type wherever one is known:
LongTermMemory resolves a system field to `Property("_<name>", <its
declared type>)`, EventMemory sends the vector store its timestamp as
`Property("_timestamp", datetime)`, and `bind_filter` sets each
property's declared type. EventMemory picks the part of the filter the
vector store gets by each property's key, so a typed reference still
reaches it, and maps the filter's fields before it embeds the query, so
a system field it does not define raises before any work.

Where output changes: filter_parser.py, main/memmachine.py,
long_term_memory.py, event_memory.py and common/vector_store/utils.py.
episode_storage.py and storage_base.py add the field types, and the
package inits export them. Tests are added for the typing and its
refusals, the per-memory parse, a typed reference reaching the vector
store and the refusal before the embedding; the expectations of the
LongTermMemory and EventMemory routing tests carry the types. The other
tests' calls of `parse_filter` follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical: every test that parses a filter passes `parse_filter` the
field types of the memory it targets, now that the parser requires
them: the semantic tests the semantic storage's, the episode storage
test the episode storage's, and the short-term memory tests the event
backend's, which MemMachine parses an episodic search by. The parser's
own tests, the compile tests and the segment store's pass no types.
No test's filters type differently, so every test asserts what it did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A property no schema declares holds whatever type was written, and the
stores compared a number with it only when the types matched: the
segment store's typed JSON refused an int compared with a held float,
so `m.score > 3` dropped a segment holding 3.5 that the vector store had
admitted, and the episode store's and semantic storages' raw JSON cast
the held value to the literal's type, truncating 3.5 to 3 on SQLite and
failing on PostgreSQL. An int and a float are now one numeric type in
every store holding dynamic properties.

Both JSON encodings in sql_filter_util compare numbers as SQL NUMERIC,
exactly: the held value is cast from its JSON digits, and the literal
from its shortest round-trip digits, which order like the float itself,
so nothing beyond 2**53 rounds. The typed JSON admits a value tagged int
or float, the type check guarding the cast in a CASE. Short-term memory
orders numbers natively where it compared floats, and the in-memory
vector store partition, which the segment store fake shares, counts an
int and a float as one type. A declared key holds and is compared with
its declared type, so its results are unchanged.

Where output changes: common/filter/sql_filter_util.py and
short_term_memory.py; the in-memory partition is a test fake. Tests
check numbers by value and beyond 53 bits on the compile (SQLite and
PostgreSQL), the segment store, the episode store, the semantic
storages and short-term memory, with float and datetime equality on
the episode store and float equality on the semantic storages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EventMemory wrote an event's properties to the segment store as given
and let the vector store bind them at its upsert, after the segments
were written: an int for a float key became a float in the vector
record and stayed an int on the segment, and a value of another type
failed the upsert with its segments already stored. `_validate_events`
now binds each property the vector store declares, by the rules
`bind_record` applies, before anything is written: an int for a float
key is the float it equals in both stores, and any other mismatch
raises PropertyTypeMismatchError with nothing stored. A key the vector
store does not declare keeps its value.

`bind_properties` is `bind_record`'s binding of values, without its
refusal of undeclared keys; `bind_record` calls it.

Where output changes: event_memory.py; common/vector_store/utils.py
moves code without changing its output. Tests check both stores hold
the bound value and that a mismatch writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Neo4j graph store and the Neo4j semantic storage rendered `Not` as
Cypher's NOT, which is null over a comparison on a missing property, so
a negated condition dropped the nodes that lacked the property, where
every other store keeps them. Both now render
`NOT coalesce((<operand>), false)`: the operand is false where it was
null, and the negation is its complement, as on the SQL stores, Qdrant
and Milvus. The Nebula graph store is unchanged.

Where output changes: neo4j_vector_graph_store.py and
neo4j_semantic_storage.py. The graph store's test checks each node and
its negation against nodes missing the compared property, and the
semantic storages' test, which every storage runs, does the same for a
feature without the metadata key. Both fail on Neo4j with the change
reverted.

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

The episode store's and the semantic storages' resolvers each listed
their system fields' names, which EPISODE_STORAGE_FILTER_FIELD_TYPES
and SEMANTIC_STORAGE_FILTER_FIELD_TYPES list again for the parser, so a
field could drift to filterable but untyped at parse, or the reverse.
The type maps are now the one list: a resolver refuses a name the map
does not hold, and keeps only what the store adds, the column of each
name held under another (`id` is `uid`, `category` is
`semantic_category_id`) and the episode store's UUID encoding of `uid`.
The Neo4j semantic storage's own list of known fields goes the same
way; it still takes `created_at_ts` and `updated_at_ts`, the names of
its stored timestamps, which no other storage does.

The episode store still matches a system field's name case-insensitively
and the parser types it by its exact name; this commit leaves both rules
as they are.

Where output changes: none. episode_sqlalchemy_store.py,
sqlalchemy_pgvector_semantic.py, vector_store_semantic_storage.py and
neo4j_semantic_storage.py resolve the same names to the same columns.
Tests check that a filter resolves every name in each map, and the
episode store's by a name of another case; Neo4j skips the semantic
one, holding some of the aliases under no node property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The episode store matched a system field's name case-insensitively,
while the parser types a field by its exact name and every other filter
target matches names exactly. It now matches them exactly too, by one
rule everywhere, so a filter writes a field one way in every example and
document.

API change: a system field name in another case (`Producer_ID`,
`SESSION`) in the filter of an episode listing or count is now refused,
answered with 422 and "Unknown filter field", where it matched its
lowercase field. No API deletes episodes by a client's filter, and the
server's own deletion filter names `session_key` exactly. No document,
OpenAPI description or example shows a field name in another case.

Where output changes: episode_sqlalchemy_store.py. The resolution test
drops its mixed-case names, and a test checks the listing and the count
refuse them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edwinyyyu and others added 3 commits November 3, 2026 10:23
Two versions of one record in one batch are ambiguous, and the stores
disagreed on them: the SQLite store and Qdrant kept the last, the
sqlite-vec store failed partway with sqlite3's UNIQUE constraint error,
and Milvus refused the batch with its own error. Every store now raises
ValueError naming the UUID before anything is sent to the backend, from
one helper beside the other upsert checks, at a cost of one pass over
the batch. The VectorStorePartition.upsert docstring says so, and the
registry-backed `_upsert` hook may rely on distinct UUIDs.

The declared-schema contract upserts a batch repeating a UUID after
another record, expects the error, and finds nothing stored. Without
the check it fails on every store: the SQLite store and Qdrant, over
REST and gRPC, raise nothing, the sqlite-vec store raises sqlite3's
OperationalError, and Milvus 2.6.24 raises its MilvusException.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A datetime written to Qdrant keeps its UTC offset only to the minute, over
REST and gRPC alike: on 1.19.1, one written at -00:44:30 reads back at
-00:44. The store wrote a datetime property at its own offset, so one at
an offset with a seconds component, as local mean time had until 1972,
was stored up to 59 seconds off its instant, and a filter on that instant
missed it. The store now converts the datetime to UTC before writing it,
which applies the whole offset to the instant, so nothing is left for
Qdrant to cut. The store's design note says so.

The read-back test writes a datetime at +05:30 and finds the payload
holding the written instant in UTC, over REST and gRPC. Without the
change, the payload keeps +05:30.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A partition's properties exist for filters, which compare a datetime by
its instant, and a search answers with record UUIDs and scores, so no
caller reads a stored offset back. The VectorStorePartition docstring now
says a store keeps a datetime property's instant, taking a naive datetime
as UTC, and may drop its UTC offset, and that a filter compares datetime
values by their instant, for equality and ordering alike.

The declared-schema contract checks both on every store. A datetime
written at +05:30 matches the same instant at -08:00 under =, <=, and >=,
a later instant at an earlier wall-clock time under <, and an earlier
instant at a later wall-clock time under >. A datetime written at
-00:44:30 matches its instant under =, whether the filter gives it at
that offset or in UTC, and matches neither < nor >. On Qdrant, over REST
and gRPC, that test fails without the store's write in UTC.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

Negated vector store filters drop records without the property on the SQLite and Milvus stores

1 participant