Skip to content

(Depends on #1597) Declare indexed properties per store, close the filter union, and route undeclared predicates to the segment store (speedkick) - #1606

Draft
edwinyyyu wants to merge 2 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/vector-store-handoff-speedkick
Draft

(Depends on #1597) Declare indexed properties per store, close the filter union, and route undeclared predicates to the segment store (speedkick)#1606
edwinyyyu wants to merge 2 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/vector-store-handoff-speedkick

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Purpose of the change

Implements the records-and-queries side of the vector store redesign (design/vector_store_handoff.md on the design/tenant-lifecycle branch): what a record carries, which keys are indexed, what a filter may say, which nodes each backend evaluates during a search, how datetimes are stored, and what a client needs. Everything is written against VectorStoreCollection as it is today; the collections-and-keys side (UUID keys, registries, strict create, logical delete, stateless handles, shared tables) lands with the lifecycle work and replaces only how a handle is obtained.

Stacked on #1597 (its commit shows in this diff until it merges). #1602 is deferred to the lifecycle work; the typed, indexed columns added here are the foundation its selectivity probe needs.

What changed

  • Declared, not dynamic, indexes. A store declares one indexed_properties schema for every collection it holds, from deployment configuration (indexed_properties on QdrantConf, MilvusConf, SQLiteVectorStoreConf, SQLiteVecVectorStoreConf) merged with the system keys of the one service that uses it, at the point the store is built for that service: DatabaseManager.get_vector_store(name, indexed_properties=...). indexed_properties_schema leaves VectorStoreCollectionConfig. EventMemory keeps expected_vector_store_collection_schema as its declaration and raises InvalidCollectionSchemaError when the collection's store does not declare its four keys. A second service whose keys the built store does not declare is refused (VectorStoreConfigurationError): services do not share a vector store.
  • Undeclared keys are rejected. upsert raises UndeclaredPropertyKeyError before anything is sent; query raises it for a filter naming an undeclared key and UnsupportedFilterError for a node outside supported_filter_nodes. An undeclared key never exists in the vector store.
  • The filter language. common/filter/filter_expression.py is the closed union from default (822ccb6): Equals, NotEquals, Ordering, In, IsMissing, n-ary And/Or, Not; every compiler is an exhaustive match. The string parser stays as the server's translation into the union. split_declared and the tree helpers the routing needs live beside it.
  • Semantics. A predicate matches only a record holding a value of the compared type; NotEquals keeps records holding a differing value; Not(Equals) also keeps records holding none. The SQL column compiler renders a leaf of another type as FALSE and Not as NOT COALESCE(x, FALSE); Qdrant compiles NotEquals as "not the value and not empty"; Milvus pushes Not to the leaves by De Morgan (its own not excludes entities lacking the field, measured on Milvus Lite).
  • Datetimes. Where a backend has no datetime type, a datetime is stored as microseconds since the epoch; both SQLite stores keep one typed, indexed, nullable column per declared key on the records table (sql_columns.py).
  • Clients. request_timeout is required on QdrantConf and MilvusConf and passed to the client. Qdrant's hnsw_config, optimizers_config and quantization_config come over from default (a0753d3) with the m = 0 rule.
  • EventMemory. One plan in query: the vector store gets the system predicates and the declared part of the caller's filter; the segment store applies the whole filter to seeds and windows afterward; with an undeclared part the vector limit widens by four up to limit * FilterOptions.max_overfetch_factor and returns what survived at the cap; with none, the first query is the last. Vector records carry only declared caller keys. An empty id or kind list admits nothing without a query.

Where this departs from the handoff, and why

  • sqlite-vec evaluates every node, not four. The handoff planned vec0 metadata columns (comparisons joined by AND). sqlite-vec 0.1.9 rejects NULL in a metadata column:
    Expected text for TEXT metadata column name, received NULL
    
    and a declared key is optional per record (memmachine_event_session on its own). The store keeps the declared columns on the records table and hands the KNN rowid IN (SELECT rowid ... WHERE <filter>), which vec0 takes into the search (verified: k=1 with the nearest rows excluded returns the admitted row). Cost on 50k vectors, k=10: 0.5 ms unfiltered, 3.9 ms with a 1-in-7 allowlist, 7.9 ms with a 5-in-7 allowlist.
  • Two errors the doc does not name, both forced by typed columns: PropertyTypeMismatchError on upsert of a declared key with a value of another type (a column would otherwise coerce it, and Qdrant's typed index would not index it), and IndexedPropertiesMismatchError when a SQLite store built with another schema opens a collection (nothing here migrates a column). Flagging both as additions.
  • Milvus's properties JSON field is dropped (nothing read it since Answer with cosine scores and uuids, not vectors and stale properties (speedkick) #1598). Declared keys stay in the _p_ dynamic fields.
  • Two semantic-storage tests that ordered string values are removed: ordering strings is not expressible in the union, by 822ccb6's decision.
  • Native Qdrant and Milvus collection names still hash the collection config, which no longer carries a schema, so existing native collections are not reused. Nothing here migrates data.

Relation to the store-as-collection decisions

Decided in parallel with this PR: one store is one collection (a cell of the purpose x embedder matrix), handles are partitions, the collection name is a constructor parameter and the discriminator between stores sharing an engine or client, and namespace and container go. That is the handle side of the seam, so this PR keeps today's create_collection(namespace, name) shape and vocabulary rather than renaming half of it; the reshape lands with the lifecycle work and replaces how a handle is obtained. What it will find here:

  • The store-level indexed_properties schema is already one schema per store, which under the decision is one schema per collection. The composition root builds the store for its one service and merges that service's system keys in, which is where a cell's store gets built. The "second service refused" guard in get_vector_store becomes unreachable once a store is a cell by construction, and can go with the reshape.
  • The SQLite registry row records the schema the collection was created under (IndexedPropertiesMismatchError); with a registry table named by the collection, that record becomes per store.
  • The typed, indexed columns in sql_columns.py are layout-agnostic: they attach to whatever records table a store has, per-collection now, one per store later.
  • Native Qdrant and Milvus collection names still hash (namespace, config) here; the constructor-supplied collection name replaces that.

Tests

  • Per backend, mixed into each store's test module (declared_schema_contract.py): undeclared key on upsert and in a filter raises; a value of another type raises; every node in supported_filter_nodes evaluates during a search; NotEquals excludes absence and Not(Equals) includes it; IsMissing matches absence; a predicate of another type matches nothing; datetime bounds at microsecond precision and in another zone; a filtered search finds an admitted record behind nearer excluded ones. These exercise API this change introduces (supported_filter_nodes, the declared schema, the new errors), so they cannot run against the unfixed stores; the semantics they pin were checked by hand against the old compilers (Qdrant must_not and Milvus not both admitted missing keys, SQL NOT excluded them).
  • SQLite stores: a store built with another schema cannot open the collection. Qdrant: collection options coerce and forward, m is pinned; every declared key gets a payload index (integration).
  • EventMemory: an undeclared predicate never reaches the vector store and is never stored there; a fully declared filter issues one query; widening makes up for dropped seeds and stops at the cap returning what survived; every count is a maximum; an empty id list issues no query.
  • uv run pytest packages/server/server_tests: 2027 passed, 2 skipped. ruff check, ruff format --check clean. ty check packages reports only the diagnostics already on the base (spacy, sqlalchemy_segment_store.py:1045/1105, three test-side argument types).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DtbsPQafKpBUn7ksnDU2UY

edwinyyyu and others added 2 commits September 10, 2026 12:19
Implements design/event_memory_handoff.md from the tenant-lifecycle
branch: the accepted parts of the server redesign that live in
EventMemory, the segment store and their data models. Nothing is renamed
and nothing outside the event memory is rewired beyond calling the new
API.

Data models. `Event`, `Segment` and `Derivative` carry `session_id` and
`source_id` as first-class nullable fields, copied verbatim down the
pipeline. `Block` is an ABC with `kind` as its discriminator and a
`render`; the union stays closed over `TextBlock`. `Context` is a mapping
from part kind to a registered `ContextPart` (`Author` first; an
unregistered kind decodes to `UnknownPart` and round-trips), replacing
`ProducerContext`, `NullContext` and the discriminated union. `SearchHit`
replaces `ScoredSegmentContext` and `QueryResult`; `Neighborhood` and
`EvictionOptions` are added.

Reserved keys. Every system value a search filters on at the vector
stage sits in the record under a `memmachine_` key (`event_timestamp`,
`event_session`, `event_source`, `block_kind`); the collection schema
declares the four, and a caller key in the namespace is rejected at
`_validate_events`. The derivative's segment and event are not copied
into the record: on this base (MemMachine#1598) the vector store answers uuids and
scores, and the segment store owns those mappings, so seeds are resolved
through `get_segment_uuids_by_derivative_uuids` and eviction reads a
stored neighbor's timestamp from its segment. `system_filters.py` owns
the translation between the typed parameters (`since`, `until`,
`session_ids`, `source_ids`, `block_kinds`) and filter trees on the
reserved keys, in both directions, so either answer to "may a caller
name a system field in a tree" is a small change at an API boundary.

Segment store. `segment_store_sg` gains `session_id`, `source_id` and
`block_kind`, projected from the segment at insert, and the ordering
index becomes `(incarnation, session_id, timestamp, event_uuid, index,
offset)`, the one total order the store exposes. Windows and
neighborhoods are confined to the seed's session, with a null session
one stream. `get_segment_contexts` takes `before`/`after` and the typed
filters; `get_neighbourhoods` returns the neighbors and never the seed,
as two lists; `delete_derivatives` unlinks without touching segments.
No migration: `startup()` keeps `create_all`, and an existing speedkick
database is recreated; schema migration waits for the lifecycle/DDL
changes.

EventMemory. `encode_events` forgets the batch first, so a repeat leaves
one copy, and runs eviction from the agentic_expansion branch, cosine
only: batch predecessors, one bounded neighbor query per derivative, a
cluster over `target_size` trimmed from its temporal middle, displaced
records and their links deleted, skipped ones never written. `query` is
the vector stage and returns hits with the seed's index in its window;
`rerank` is the second stage, static, for a caller with a reranker;
`expand` walks a neighborhood from a segment or event anchor; `render`
replaces the string formatters. The reranker and the per-call format
options leave the constructor and the call, respectively; the deriver's
format is fixed per memory.

Server. `LongTermMemory` builds `Event.source_id` from the producer id
and adds no context; it reranks after `query` and reads hits.

Tests: the branch's neighbor and eviction tests ported to the new shapes
on both dialects, plus session confinement, half-open bounds, instant
comparison of zoned bounds on SQLite, source and kind filters, and link
deletion. Each new store assertion was checked to fail against a mutated store (no session
predicate, an inclusive `until`, a filtered neighborhood seed, an
unnormalized bound).

Rebased 2026-09-10 onto speedkick after MemMachine#1598 merged, adopting its
post-review names (`segment_by_derivative`, `seed_cosine_similarities`).
`common/property_keys.py` and its test, which MemMachine#1598 did not carry into
its merge, are included here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuAu353FiSmCJjLX1LWDQW
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
…te undeclared predicates to the segment store (speedkick)

The records-and-queries side of the vector store redesign
(design/vector_store_handoff.md), written against `VectorStoreCollection`
as it is today so the lifecycle work can later replace how a handle is
obtained without touching any of this.

Declared, not dynamic, indexes. A store declares one
`indexed_properties: Mapping[str, PropertyType]` for every collection it
holds, from deployment configuration merged with the system keys of the
one service that uses it, at the point the store is built for that
service (`DatabaseManager.get_vector_store(name, indexed_properties=...)`).
`indexed_properties_schema` leaves `VectorStoreCollectionConfig`, and no
request creates an index. EventMemory keeps
`expected_vector_store_collection_schema` as its declaration of the four
reserved keys and raises `InvalidCollectionSchemaError` at construction
when the collection's store does not declare them. Services do not share
a vector store: a second service whose keys the built store does not
declare is refused with `VectorStoreConfigurationError`.

Undeclared keys are rejected. `upsert` raises `UndeclaredPropertyKeyError`
before anything is sent when a record carries a key the store has not
declared, and `PropertyTypeMismatchError` when a declared key holds a
value of another type, since a typed column or index cannot hold it
without coercing it. `query` raises `UndeclaredPropertyKeyError` when a
filter names an undeclared key and `UnsupportedFilterError` when it uses a
node outside `supported_filter_nodes`. An undeclared key therefore never
exists in the vector store.

The filter language. `common/filter/filter_expression.py` is the closed
union from the `default` branch (822ccb6): `Equals`, `NotEquals`,
`Ordering` over numbers and datetimes, non-empty homogeneous `In`,
`IsMissing`, n-ary `And` and `Or`, `Not`. `Comparison` and `IsNull` go;
every compiler is an exhaustive `match`. The string parser stays as the
server's translation into the union, since the HTTP API still speaks it;
a same-operator chain parses to one n-ary node. `split_declared`,
`conjoin`, `conjuncts`, `filter_fields` and `filter_nodes` are the tree
helpers the routing needs. Ordering strings is no longer expressible,
which retires two semantic-storage tests that ordered string values.

Semantics. A predicate matches only a record holding a value of the
compared type: the SQL column compiler renders a leaf of another type as
FALSE rather than letting affinity coerce it, and `Not` is the complement
of a match (`NOT COALESCE(x, FALSE)`), so `Not(Equals)` keeps records
holding no value while `NotEquals` does not. Qdrant compiles `NotEquals`
as "not the value and not empty", since `must_not` alone admits points
lacking the field; Milvus pushes `Not` to the leaves by De Morgan, since
its own `not` excludes entities lacking the field (measured on Milvus
Lite). A datetime is normalized to a UTC instant at node construction.

Datetimes. Where a backend has no datetime type, a datetime property is
stored as an integer of microseconds since the epoch: the two SQLite
stores keep one typed, indexed, nullable column per declared key on the
collection's records table (`sql_columns.py`) and store datetimes that
way, so a `since` or `until` bound evaluates identically everywhere.

sqlite-vec. The handoff planned vec0 metadata columns, on which sqlite-vec
evaluates only comparisons joined by AND. sqlite-vec 0.1.9 rejects NULL in
a metadata column ("Expected text for TEXT metadata column name, received
NULL"), and a declared key is optional per record (`memmachine_event_session`
on its own), so the store keeps the declared columns on the records table
and hands the KNN `rowid IN (SELECT rowid ... WHERE <filter>)`, which vec0
takes into the search: with k=1 and a filter excluding the nearest rows the
admitted row is returned, and every node is evaluated during the search,
so the store reports the full node set.

Engine-backed store. The declared columns replace the JSON properties
column, each with its own index, and the per-candidate SQL predicate
compiles over them; MemMachine#1602's selectivity routing is deferred to the
lifecycle work and can rebase onto these columns. Both SQLite stores
record the schema a collection was created under and raise
`IndexedPropertiesMismatchError` when a store built with another schema
opens it, since nothing here migrates a column.

Clients. `request_timeout` is a required field of `QdrantConf` and
`MilvusConf`, passed to the client at construction. Qdrant's
`hnsw_config`, `optimizers_config` and `quantization_config` come over
from the `default` branch (a0753d3) as plain mappings validated against
the qdrant models when the store is built; `hnsw_config.m` must be 0 or
unset, and `payload_m` is the knob. Milvus drops the properties JSON
field nothing read.

EventMemory. One plan in `query`: the vector store gets the system
predicates and the part of the caller's filter naming declared keys; the
segment store, which holds every property, applies the whole filter to
the seeds and their windows afterward. When the filter has an undeclared
part, a seed the segment store drops leaves the search short and the
vector limit is widened by four each time, up to
`limit * FilterOptions.max_overfetch_factor`, where the search returns
what survived; with no undeclared part the first query is the last. An
empty id or kind list admits nothing and issues no query. Every count is
a maximum.

Not in this change: keys, registries, strict create, logical delete and
purge, stateless handles, one container per embedder, the SQLite stores
on shared tables, and the removal of content-addressed names and Qdrant
shard keys (the collections-and-keys side of the seam). Native Qdrant and
Milvus collection names still hash the collection config, which no longer
carries a schema, so existing native collections are not reused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtbsPQafKpBUn7ksnDU2UY
@edwinyyyu
edwinyyyu marked this pull request as draft September 10, 2026 20:37
@edwinyyyu edwinyyyu changed the title Declare indexed properties per store, close the filter union, and route undeclared predicates to the segment store (speedkick) (Depends on #1597) Declare indexed properties per store, close the filter union, and route undeclared predicates to the segment store (speedkick) Sep 11, 2026
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.

1 participant