Skip to content

(Depends on #1606) Make filter negation the complement on every backend, and name absence IsNull (speedkick) - #1615

Closed
edwinyyyu wants to merge 3 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/filter-negation-speedkick
Closed

edwinyyyu wants to merge 3 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/filter-negation-speedkick

Conversation

@edwinyyyu

Copy link
Copy Markdown
Contributor

Stacked on #1606 (feat/vector-store-handoff-speedkick, head c6af2c21), whose commits this branch includes; review only the top commit. #1606 in turn carries a stale copy of #1597 (commit 8ed2bfca; #1597's head is now 2f1ae2d1) which its owner will rebase, so the five pre-existing ty diagnostics about Block and EventMemoryParams come from that stale copy and are untouched here.

What changes

IsMissing is renamed IsNull. That is the name the textual filter language already uses (IS NULL, IS NOT NULL) and the name the design settles on. Its docstring now says what it means: the field holds no value, true for a record that does not carry the field, and property values are never null, so a missing key is the only no-value state. No occurrence of IsMissing or is_missing is left in the tree.

NotEquals is removed. The parser maps != and <> to Not(Equals(...)), NOT IN to Not(In(...)) and IS NOT NULL to Not(IsNull(...)), so all three negations mean one thing. Every compiler arm and every _SUPPORTED_FILTER_NODES entry for the node goes with it; the filter union is closed, so ty found the stragglers. The module docstring of filter_expression.py now states the two-valued rule instead of the sentences distinguishing NotEquals from Not(Equals). Short-term memory's value comparison loses its != branch, which only the removed arm reached.

sql_filter_util.py's Not is fixed. It compiled to ~inner, which is SQL three-valued logic: NOT (k = v) on a row whose k is NULL is unknown and the row is dropped, so a negated filter silently excluded rows holding no value. It now compiles to ~func.coalesce(inner, false()), the same shape common/vector_store/sql_columns.py already used. Totalizing at the Not node is enough; leaves are left alone. The segment store, the episode store, both SQLAlchemy semantic storages and short-term memory read through this compiler.

What each compiler's Not arm does, after this change:

Compiler Not compiles to Complement over the record set?
common/filter/sql_filter_util.py (segment store, episode store, pgvector and vector-store semantic storages) NOT COALESCE(<operand>, FALSE) yes — fixed here
common/vector_store/sql_columns.py (SQLite, sqlite-vec vector stores) NOT COALESCE(<operand>, FALSE) yes — already
common/vector_store/milvus_vector_store.py De Morgan down to the leaves; each negated leaf is (<inverse leaf>) || (<field> is null) yes — already; Milvus's own not and != are both false on a missing field
common/vector_store/qdrant_vector_store.py Filter(must_not=[<operand>]), which admits points lacking the field yes — already
episodic_memory/short_term_memory/short_term_memory.py Python not over the episode predicate, whose leaves are false on a missing field yes — already
server_tests/.../in_memory_vector_store_collection.py (test double) Python not yes — already
common/vector_graph_store/neo4j_vector_graph_store.py NOT (<cypher comparison>) no — see "Not done"
common/vector_graph_store/nebula_graph_vector_graph_store.py NOT (<nGQL comparison>) no — see "Not done"
semantic_memory/storage/neo4j_semantic_storage.py NOT (<cypher comparison>) no — see "Not done"

Conformance suite. declared_schema_contract.py replaces its two absence tests with the complement law over a fixture of one record holding every declared key, one record lacking each declared key in turn, and one record holding none. For each of twelve filters — Equals on a string, a bool and a datetime, Ordering on an int, a float and a datetime, In, IsNull on a key some records hold and on a key of a record that holds none, an And, an Or, and a nested Not — it asserts that Not(F) admits every record minus the ones F admits, and that Not(Not(F)) admits exactly what F does. Two direct assertions stay: IsNull matches exactly the records without the key, and Not(Equals(...)) keeps a record without the key. The same law is asserted for compile_sql_filter over all three of its encodings (column, json, properties_json), and the segment store gains a context query whose negated filter keeps a segment carrying no property at all — the ->>-on-PostgreSQL path the COALESCE guards. Parser tests assert that k != 'v' and k <> 'v' yield Not(Equals(...)).

Decisions

The one semantic change relative to #1606 is that != no longer excludes absence. On #1606, k != v kept only records holding a comparable value other than v, while k NOT IN (v) and NOT (k = v) also kept records holding nothing — the same negation written three ways meant two different things. The rule this follows is the settled one: the grammar is two-valued, a leaf on a field with no value is false, IsNull on it is true, and Not is the ordinary boolean complement, so != and NOT IN are literally NOT = and NOT IN. A caller who means "present and other than x" now writes k IS NOT NULL AND k != x. If you would rather keep the old !=, reverting is a self-contained change: restore the NotEquals node and its compiler arms and point the parser's NE branch back at it; the sql_filter_util Not fix and the IsNull rename stand on their own.

Two smaller calls, both noted because they were judgment rather than instruction: the dead != branch of ShortTermMemory._compare is deleted, since the removed NotEquals arm was its only caller; and common/neo4j_utils.render_comparison keeps its != handling, because three call sites outside the filter compiler still pass it operator strings.

Verification

Every command below was run from the worktree root as uv run --frozen --all-extras ....

Command Result
ruff check packages/server All checks passed
ruff format --check packages/server 358 files already formatted
ty check --project packages/server Found 5 diagnostics — the same 5 invalid-argument-type errors a detached worktree at c6af2c21 reports, all from #1606's stale copy of #1597; no new ones
pytest packages/server/server_tests/memmachine_server/common packages/server/server_tests/memmachine_server/episodic_memory 1399 passed, 574 deselected
pytest .../episodic_memory/event_memory/segment_store -m integration 112 passed, 112 deselected (PostgreSQL via testcontainers)
pytest .../common/filter/test_sql_filter_util.py .../common/vector_store/test_qdrant_vector_store.py -m integration 209 passed, 161 deselected (PostgreSQL, and Qdrant over HTTP and gRPC via testcontainers)
pytest packages/server/server_tests (whole suite, default markers) 2097 passed, 2 skipped, 1638 deselected

Against the unfixed compiler. The sql_filter_util complement assertions were written and run before the COALESCE change, with only the IsMissing → IsNull rename applied: pytest .../test_sql_filter_util.py -k complement gave 15 failed, 7 passed. Every failure was a negation dropping the row that holds no value, for example assert {'alpha'} == {'alpha', 'epsilon'} for Not(Equals(field="tag", value="a")) over the properties-JSON encoding. The seven that passed are the cases with no absent value to lose (IsNull leaves, and leaves on a NOT NULL column). After the fix all 22 pass, on SQLite and on PostgreSQL.

Which backends ran the conformance suite. The contract mixin is mixed into four vector store test modules, and each ran its 14 negation assertions: SQLite, sqlite-vec, Milvus Lite (behind importorskip, installed here), and Qdrant against an in-memory client by default plus a qdrant/qdrant:v1.17.0 testcontainer over both HTTP and gRPC under -m integration. The testcontainer is started by the suite's own session fixture; no container already running on this machine was touched.

Which it did not. The in-memory vector store collection is a test double and does not mix in DeclaredSchemaContract — contrary to what one might expect from the file's placement — so the complement law does not run against it; its Not is Python not and its leaves are false on a missing key, so it already satisfies the law, but nothing asserts it. Neo4j, NebulaGraph and the Neo4j semantic storage have no backend in this environment and their filter tests are -m integration; they were not executed.

Not done

The two graph stores and the Neo4j semantic storage get the rename and the NotEquals removal only. Their NOT compilation is left exactly as it was, because there is no backend here to test a change against.

  • common/vector_graph_store/neo4j_vector_graph_store.py emits NOT (<condition>) around a Cypher comparison such as n.city = $p. On a node with no city, null = $p is null and NOT null is null, so the node is dropped: not the complement. NOT (<ref> IS NULL) is fine, since IS NULL is already two-valued.
  • common/vector_graph_store/nebula_graph_vector_graph_store.py emits NOT (<condition>) around an nGQL comparison such as v.prop = 'x', with the same three-valued outcome on a missing property: not the complement.
  • semantic_memory/storage/neo4j_semantic_storage.py emits NOT ({condition}) around the same kind of Cypher comparison: not the complement.

Fixing those means either making each leaf total (coalesce(<comparison>, false) in Cypher, or <condition> OR <ref> IS NULL under negation) or pushing negation to the leaves the way the Milvus compiler does, and it should land with a run against a real Neo4j and NebulaGraph.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE

edwinyyyu and others added 3 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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DtbsPQafKpBUn7ksnDU2UY
…e IsNull (speedkick)

The filter grammar is two-valued: a property has a value or is absent, a
leaf predicate on a field with no value is false, `IsNull` on it is true,
and `And`, `Or` and `Not` are the ordinary boolean connectives over the
records. `Not` is therefore the exact complement of a match, and
`Not(Not(x))` is `x`. Three of the pieces did not follow that rule.

The absence node is renamed `IsMissing` to `IsNull`, the name the textual
filter language already uses for it and the one the design settles on.

`NotEquals` is removed. The parser maps `!=` and `<>` to
`Not(Equals(...))`, as it already mapped `NOT IN` to `Not(In(...))` and
`IS NOT NULL` to `Not(IsNull(...))`, so all three negations mean the same
thing. A caller who wants only records that hold some other value conjoins
`Not(IsNull(field))`. Every compiler arm and every `_SUPPORTED_FILTER_NODES`
entry for the node goes with it; the union is closed, so the type checker
finds them all. The dead `!=` branch of short-term memory's value
comparison, which only the removed arm reached, goes too.

`compile_sql_filter` compiled `Not` to `~inner`, which is SQL three-valued
logic: `NOT (k = v)` on a row whose `k` is NULL is unknown and the row is
dropped, so a negated filter silently excluded rows that hold no value.
It now compiles to `~func.coalesce(inner, false())`, making the operand
total before negating it, which is what the vector stores' column compiler
already did. The segment store, the episode store, both SQLAlchemy semantic
storages and short-term memory read through that compiler.

The vector store conformance contract now asserts the complement law over a
fixture of records that hold every declared key, records that lack each key
in turn, and a record that holds none: for each of a list of filters
covering `Equals`, `Ordering`, `In`, `IsNull`, `And`, `Or` and a nested
`Not`, the records `Not(F)` admits are every record minus the ones `F`
admits, and `Not(Not(F))` admits exactly what `F` does. It runs on SQLite,
sqlite-vec, Milvus Lite and Qdrant. The same law is asserted for
`compile_sql_filter` over its column, raw-JSON and properties-JSON
encodings, on SQLite and on PostgreSQL, and the segment store gains a
context query whose negated filter keeps a segment carrying no property at
all.

The Neo4j and NebulaGraph graph stores and the Neo4j semantic storage get
the rename and the node removal only. Each still emits `NOT (<comparison>)`,
which on a missing property is Cypher's and nGQL's unknown and drops the
row, so their negation is not yet the complement; there is no backend for
them in this test environment, so the fix is left for a change that can be
tested against one.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
@edwinyyyu edwinyyyu changed the title Make filter negation the complement on every backend, and name absence IsNull (speedkick) (Depends on #1606) Make filter negation the complement on every backend, and name absence IsNull (speedkick) Sep 11, 2026
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Sep 14, 2026
…, and close the filter union (speedkick)

A vector store is built for one collection, named at construction with its
vector dimensions and its declared `indexed_properties`; the composition
root builds one store per purpose-by-embedder cell. Within it, a partition
holds one tenant's records, and `VectorStorePartition` is the handle a
data consumer holds. Lifecycle on both storage components:

    provision()                                        # idempotent DDL
    create_partition(key)                              # strict
    get_partition(key) -> Partition | None             # None when absent
    delete_partition(key)                              # idempotent

Declared keys are typed, indexed columns of the SQLite records tables and
payload indexes on Qdrant and Milvus; a record or a filter naming an
undeclared key is rejected before anything is sent, so an undeclared key
never exists in a vector store. EventMemory routes the declared part of a
filter to the vector store and the rest to the segment store's
post-filter, widening the search up to `max_overfetch_factor`.

The filter language is a closed union: Equals, Ordering, In, IsNull, And,
Or, Not. `!=` parses as Not(Equals); Not is the complement on every
backend, so a record holding no value passes a negated predicate.
Datetimes are stored as microseconds since the epoch where a backend has
no datetime type. Each backend states its `supported_filter_nodes`.

Folds MemMachine#1613 and MemMachine#1615 into MemMachine#1606 and moves the whole off MemMachine#1597: the
routing is written against speedkick's EventMemory, and the session,
source and expansion parameters return with MemMachine#1597.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Folded into #1606 (the IsNull rename, Not(Equals) for !=, and the COALESCE fix in sql_filter_util are all in it). The old tip is kept as tag pre-reorg-1615 locally.

@edwinyyyu edwinyyyu closed this Sep 14, 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