Skip to content

[final state] The vector store tree combined, for reference (not for merge) - #1814

Draft
edwinyyyu wants to merge 70 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:integration/vector-store-final-state
Draft

edwinyyyu wants to merge 70 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:integration/vector-store-final-state

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

One branch holding every open PR of the vector store tree, merged and consistent, so the end state can be read and tested as a whole. Not for merge. The tree's PRs merge on their own. This branch is the reference they are split from and checked against: a split is consistent when its last PR's tree equals this one.

Included, at their heads on 2026-10-13: #1788, #1702, #1670, #1627, #1622, #1625, #1663, #1460, #1469, #1672, #1673, #1674, #1675, #1676, #1618, #1741, #1628, and #1616.

Not included: the event memory stack (#1684 and above), the PRs on main (#1624, #1786, and #1792), and operator-declared indexed properties, which have no PR.

How the conflicts were resolved

The branch starts at #1616's head, which carries #1702, #1670, #1627, #1663, and #1628, and merges each other leaf with a merge commit. Every PR in it is at its head restacked onto feat/horizontal-scaling at 15f2c0686, where #1813 is merged. Where two PRs meet, the final state takes the later design:

Verification

Rebuilt on 2026-10-13 from the PR heads rebuilt onto #1702's routing at any depth (6631b5c77): #1616 ad48f4407, #1625 9d3d89bec, #1788 8257b8bda, #1676 d79a7da24, and #1741 2d0794bdd, with each merge's earlier resolution carried over. The head 5e638c3eb differs from the previous head 3e822399d only in #1702's routing files. There, ruff check and ruff format --check are clean, ty check is clean as CI runs it, uv lock --check is clean, and the episodic memory, filter, and vector store unit tests pass, 975 passed and 6 skipped. The results below ran at earlier heads.

Rebuilt on 2026-10-09 from the PR heads restacked onto 15f2c0686, #1813's merge: #1616 13d104245, #1625 cd75d4cb9, #1788 8257b8bda, #1676 a070372c4, and #1741 2c78deb61. The tree at the head 3e822399d is identical to the previous head 587dde06c, so the results below hold for it. At the restacked leaves, ruff, ruff format, and ty are clean, and their store suites pass, unit and integration, against Qdrant 1.19.1 with Cloud's strict-mode defaults and Milvus 2.6.24.

At a40e31a86, 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; the server and client suites without integration tests, 2535 passed; the integration tests of the vector stores, the resource manager, episodic memory, and semantic storage, 826 passed, 1 failed, and 13 skipped, against PostgreSQL 16 (pgvector), Neo4j 5.23, Qdrant 1.19.1, and Milvus 2.6.24 in containers. The failure was a Milvus test that the upsert validation merge had left without its asyncio mark. The head 3fab491a3 restores the mark and changes nothing else; there, ruff check is clean and the Milvus store's test file, unit and integration, 143 passed. At the head 587dde06c, after the re-merge of #1813, the Qdrant store's and the database manager's tests, unit and integration, 365 passed against the Qdrant container defaulting new collections to strict mode.

Stack

Directly on feat/horizontal-scaling, as a reference beside the vector store tree.

🤖 Generated with Claude Code

edwinyyyu and others added 6 commits October 7, 2026 09:43
…UUIDs and scores, and refuse invalid inputs (MemMachine#1733)

* Look up the segments that derivatives belong to in the segment store

Event memory resolves a search hit, a derivative's vector record, to the
derivative's segment through a `_segment_uuid` property it copies onto
every vector record. The segment store already holds that mapping, on the
derivative's own row.

`SegmentStorePartition.get_segment_uuids_by_derivative_uuids` reads it.
The SQLAlchemy partition answers with one query on the derivative table,
served by its primary key, and checks the partition's liveness in the same
statement, as its other reads do. A derivative the partition does not hold
is left out of the result. The in-memory partition the event memory tests
use implements it too.

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

* Resolve event memory's search hits through the segment store

A derivative's vector record carried its segment's UUID as the
`_segment_uuid` property, and a search read it back from each match. That
copy is only as fresh as the vector store's reads, and the segment store
holds the same mapping on the derivative's row.

A search now asks the vector store for no properties and maps the matched
derivatives to their segments with one
`get_segment_uuids_by_derivative_uuids` call. A match whose derivative the
segment store no longer holds, because its segment was deleted and its
vector outlived it, is dropped. Vector records no longer carry
`_segment_uuid`, and the schema event memory expects of its collection no
longer declares it; a collection created with it keeps declaring it.

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

* Resolve semantic search hits through a vector_uuid column on the feature row

A feature's vector record was keyed by a UUID derived from the feature's
id, and carried eleven properties copied from the feature's row, among
them `feature_id`, which a search read back to resolve a hit to its row.
The caller's feature metadata was merged into the same properties.
- `update_feature` read the stored vector back through
  `VectorStoreCollection.get` to write the record again with fresh
  properties, since an upsert replaces a whole record. The value read is
  written back, so a stale read becomes a lasting wrong write: of two
  concurrent updates of one feature, one with a new embedding and one
  without, the second can write the old embedding over the new one; and a
  read that misses the record fails the update after its row was
  committed (MemMachine#1721).
- Nothing filtered on the copied properties; the row is their authority.

The feature row now carries `vector_uuid`, a fresh UUID that keys its
vector record, and the record carries no properties. A search resolves
its hits through that column, in the order the search returned them,
dropping a hit whose row is gone. `update_feature` writes the vector
store only when given a new embedding, and reads nothing back. The delete
paths take the UUIDs of exactly the rows they delete with RETURNING. The
collection declares no indexed properties.

Breaking: a feature table created before this has no `vector_uuid`
column, and the table is created with `create_all`, which adds none; and
a collection created with the old declared properties no longer matches
the configuration `open_or_create_collection` asks for. No migration is
included.

Fixes MemMachine#1721.

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

* Answer a vector store query with record UUIDs and scores; remove get and close_collection

A query returned each match's record: its properties, and its vector on
request. They were copies of what the callers' own stores hold, only as
fresh as the vector store's reads, and since the previous two commits no
caller reads them. `get` has had no caller since semantic memory stopped
reading its vectors back, and a `get` safe to write from would need every
backend to reflect every write that returned before it, from any process.
Every store's `close_collection` did nothing, and nothing called it.

- `QueryMatch` carries a score and a `record_uuid`, and `query` loses
  `return_vector` and `return_properties`. Properties are still stored
  and filtered on.
- `VectorStoreCollection.get` and `VectorStore.close_collection` go, with
  what only served them: the stores' record parsing,
  `VectorSearchEngine.get_vectors`, and sqlite-vec's vector decoding.
- `Record` is input-only, so its vector is required and its properties
  default to `{}`. The model rejects a missing vector at construction, so
  the stores' checks for one and their coalescing of `None` properties go.

Mechanical: event memory and semantic memory read `match.record_uuid` in
place of `match.record.uuid` and stop passing the flags, and the
in-memory test collection follows the interface.

Tests of the return flags, of `get`, and of values read back through a
query go. Tests that check what a store holds read the backend past the
store: the SQLite stores' records tables, a Qdrant scroll of the
collection's partition, a Milvus `get` on the client. The tests of a
store refusing a `None` vector become tests that the model refuses a
missing one, plus one that properties default to `{}` and one that a
record without properties is stored.

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

* Refuse vector store inputs no collection can hold or search

Each store refuses, with a ValueError and before any backend call:
- a record whose declared property holds a value of another type, since
  the collection indexes and filters the property as its declared type
  (`require_declared_types`: the value's type is the declared type, and a
  bool is not an int);
- a record vector or a query vector whose width is not the collection's
  dimensions;
- a query vector with a coordinate that is not finite, or a score
  threshold that is not finite.

The model checks a record's own vector for finiteness: `Record.vector` is
`list[FiniteFloat]`, so pydantic refuses a NaN or infinite coordinate when
the record is built, in the pass that already validates each coordinate.
A query vector is a plain sequence, so the stores check it. An embedding
endpoint can return NaN whatever the caller does, so both kinds of vector
are checked.

Qdrant and Milvus check a filter's property keys with the other inputs,
before the early return for no query vectors.

`validate_identifier` uses `fullmatch`: `$` also matches before a trailing
newline, so `"name\n"` passed as a namespace, a collection name or a
property key.

The ABC states these refusals in `upsert` and `query`. Tests: each store
refuses each input, and a record refuses a coordinate that is not finite.

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

* State the vector store's consistency in its contract

The contract said nothing about what a query sees of earlier writes.
`VectorStoreCollection` now states what every store keeps:

  An `upsert` or `delete` is durable once it returns; queries may not
  reflect it right away. A store that guarantees more states it.

Neither event memory nor semantic memory reads its own writes back
through the vector store: a search's hits resolve through the segment
store or the feature row, which hold the mapping. Milvus reads at the
consistency level its collections are configured with, and a replicated
Qdrant may answer a query from a replica that has not applied a write
yet, so a stronger promise would bind every store to read settings with
costs of their own.

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

* Name the derivative-to-segment map segments_by_derivatives

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

* Drop a None check no property reaches, and a filter branch no caller takes

- The Qdrant store's payload skipped a None property, but a Record's
  properties are PropertyValues, which exclude None, so the model refuses
  one before the store sees it.
- Semantic storage's _apply_feature_filter took a Select or a Delete, but
  only selects reach it: delete_feature_set filters its DELETE ...
  RETURNING itself. It takes and answers a Select, and the cast at its
  caller goes.

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

* Tidy the tests the review found out of place

- The SQLite stores' four input-validation tests sat inside TestFilters,
  under a "Delete" banner meant for TestDelete; they move into
  TestInputValidation, and the banner moves above TestDelete.
- Two event memory schemas still declared `_segment_uuid`, which event
  memory no longer reserves: the context test declares `_timestamp`
  alone, and the missing-base-field test declares nothing.
- The 40,000-feature test's comment says which SQLite the bind limit is
  the feature store's.

Tests only; the same tests run and pass.

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

* Fold the declared-type check into the vector store utilities

declared_properties.py held one function beside utils.py, which holds
the other input checks every store runs (dimensions, query vector,
score threshold, identifiers). It imports only common.data_types,
which imports nothing back, so it moves into utils.py as is.

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

* Resolve semantic search hits in one statement

Since queries answer record UUIDs alone, a semantic search mapped its
hits to feature ids in one select, then loaded the features by id with
the filter in another: two statements in two sessions, and the first
ran even when the search found nothing. One select on the vector_uuid
column with the filter now loads the features, ordered as the hits
were, and no select runs for a search with no hits. Results are
unchanged: the same features, in the same order, under the same
filter.

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

* Refuse a query limit that is not positive

The contract said nothing of a limit at or below zero, and the stores
disagreed: the SQLite stores and Milvus answered empty results, and
Qdrant passed the limit on for the server to reject. A limit that is
not positive is now a ValueError in every store, stated in the query
contract beside the other refused inputs, and checked whether or not
there are query vectors.

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

* Refuse a search for no memories before it does any work

A search whose top_k is not positive asks for nothing, yet it reached
the vector store, which refuses a limit that is not positive, only
after event memory had embedded the query: a request for nothing still
cost an embedding call. SearchMemoriesSpec.top_k is now positive
(gt=0), so request validation refuses it with a 422 naming top_k, the
MCP tool included, and LongTermMemory.search_scored refuses a
num_episodes_limit that is not positive before the query is embedded,
for callers of the Python API. The event backend's window test no
longer runs a limit of 0 on the in-memory vector fake, which does not
check limits; its lower bound is now exercised with a negative
expand_context.

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

* Show the positive top_k in the OpenAPI document

docs/openapi.json, as docs/tools/generate_openapi.py writes it for the
search specification's top_k: exclusiveMinimum 0 and the description.
The generator's other differences under the locked FastAPI are left to
the regeneration that carries them.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rebased onto main. Main refuses a top_k that is not positive since MemMachine#1740,
with ge=1 and its own tests, so this commit keeps that bound and those tests
in place of the gt=0 bound and the test described above, and
docs/openapi.json shows minimum 1. vector_store_semantic_storage.py keeps
importing datetime, which the history methods MemMachine#1707 added use.
…SQL registry, with an incarnation per collection life and a purge (MemMachine#1734)

* State a collection's lifetime in the vector store contract

The contract had one process manage a collection, with the consumer
sharding names across processes, and said nothing of what deleting a
collection means for the handles and writes that outlive it. It now
states what a store guarantees across a deletion, and leaves which
processes may share a collection to each store:
- A collection created again under a deleted one's (namespace, name)
  starts empty, and reclaiming the deleted one's storage leaves it
  untouched.
- A handle whose collection was deleted may raise
  `VectorStoreCollectionHandleStaleError`, or, on a store that cannot
  tell, act on a collection created again under the same name.
- `delete_collection` makes the collection unreachable when it returns. A
  store that reclaims storage later does so in
  `purge_deleted_collections`, which does a bounded amount of work per
  call, returns whether it ran a round, and is safe to call from several
  processes at once.
- Creating a collection that is being created raises the already-exists
  error; opening one whose creation has not completed raises
  `VectorStoreCollectionPendingError`; a store that gives up after
  repeated attempts that made no progress raises
  `VectorStoreAttemptsExhaustedError`.
- A record's UUID names it in its collection only.

Every store keeps reclaiming storage in `delete_collection`, so
`purge_deleted_collections` returns False on each, and none raises the
pending or stale-handle error yet. Each store states which processes may
share a collection: the SQLite store's engine lives in the process that
opened the collection, sqlite-vec's database file in one host, and the
Qdrant and Milvus stores serialize creating and deleting a collection
within a process only. On each, a handle used after its collection is
deleted acts on a collection created again under its name.

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

* Add a SQL collection registry for vector stores whose backend cannot arbitrate one

Qdrant and Milvus hold only points: the stores keep their catalogs of
logical collections as points and entities, written without a
transaction, and serialize creating and deleting a collection with locks
in one process. Two processes creating or deleting one collection race,
and a write in flight when its collection is deleted can land after the
deletion, in a collection created again under the same name.

`VectorStoreCollectionRegistry` is that catalog, arbitrated across every
process that shares it:
- `register` mints a fresh incarnation, the value a collection's records
  carry, under a (namespace, name) no live or pending collection holds.
  The collection is pending until `mark_live` marks its storage prepared.
- `unregister` makes a collection unreachable when it returns and queues
  its incarnation as a tombstone; `unregister_incarnation` does the same
  for a caller holding the incarnation, and leaves a collection registered
  since under the same name.
- `claim_purgeable_incarnation` hands one due tombstone to a purge round.
  A tombstone comes due once a retention, longer than any write can be in
  flight, has passed since the deletion, and is removed when a round finds
  no records. Its incarnation is not minted again before then, so a
  collection created again starts empty and no purge reclaims its records.

`SQLAlchemyVectorStoreCollectionRegistry` keeps it in PostgreSQL or SQLite
(3.35 or later, for RETURNING): a table of collections keyed by vector
store, namespace and name, and a queue of tombstones claimed in the order
they come due on the database clock. The primary key arbitrates
registration, a conditional update marks a collection live,
unregistration is one transaction, and on PostgreSQL a claim is a row
lock. A round that raises backs its tombstone off, doubling up to a
bound; after ten consecutive failed rounds the tombstone is
dead-lettered: kept, its incarnation reserved, skipped by claims, and
reported in an error log.

No store uses it yet.

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

* Add RegistryBackedVectorStore, a base for stores whose collections a registry arbitrates

A store on the collection registry keeps the same lifecycle whatever its
backend, so the lifecycle lives in a base and a subclass supplies the
backend calls.

`RegistryBackedVectorStore` registers a collection pending, prepares its
storage (`_prepare_storage`), and marks it live; only a live collection
opens, and opening a pending one raises
`VectorStoreCollectionPendingError`. A collection deleted while its
storage is prepared is not marked live, so its creation counts as one
followed by a deletion. A preparation that raises or is cancelled
unregisters the incarnation, shielded so that a cancelled creation still
frees the name; a collection the registry cannot unregister stays
pending until it is deleted. `open_or_create_collection` retries a
bounded number of times, a second apart: it opens a pending collection
once another creator marks it live, creates again when it loses the name
or the mark, and raises the pending error or
`VectorStoreAttemptsExhaustedError` when the attempts run out.
`delete_collection` unregisters, and each `purge_deleted_collections`
call runs one round (`_purge_round`) on a due tombstone.

`RegistryBackedVectorStoreCollection` is a handle bound to one
incarnation. Each operation checks its inputs, then that the collection
registered under its name still carries that incarnation: `upsert` before
and after its backend call, `query` before, and `delete` after. A write
that raced the deletion raises `VectorStoreCollectionHandleStaleError`
instead of reporting success, and the purge reclaims whatever it landed.
A subclass implements `_upsert`, `_query` and `_delete`.

`require_identifiers` checks a namespace and a name together.

The tests drive the lifecycle through a store whose storage preparation
each test controls, on a SQLite registry.

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

* Run each vector store's purge from the resource manager

A store that reclaims a deleted collection's storage later does so only
when `purge_deleted_collections` is called. The resource manager starts
one sweeper per vector store the first time it hands the store out, as it
does for segment stores. A sweeper calls the purge again after a short
pause while rounds keep running, after the idle interval once nothing is
due, and logs a call that raises and retries it on the next tick.
Sweepers in other processes may run the same store's purge at the same
time, which the contract allows. Closing the manager cancels its
sweepers, and a sweeper holds no reference to the manager.

Every store returns False from the purge as of this commit, so a sweeper
idles.

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

* Open the event backend's collection a racing creator won

The event backend's service locator opens a session's collection, or
creates it when there is none and opens it after. Two callers building
one session's memory at once can both find none: the loser's create
raises the already-exists error, and an open while the winner is still
preparing the collection raises the pending error. Either failed the
build.

The locator now retries open-or-create on either error, up to ten
attempts a second apart: it opens the winner's collection once it is
live, and creates the collection itself if the winner's creation was
undone. A collection that stays pending through every attempt fails the
build with a RuntimeError naming the partition.

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

* Let the segment store's callers decide to mint again, not the insert

The collection registry follows the segment store's incarnation design,
with its retry decision in `register`, which counts the attempts. The
segment store had that decision in its callee: `_insert_partition_row`
logged "re-minting" and "retrying with a fresh incarnation", and its
error was documented as "retry with a fresh incarnation". The insert now
raises the error saying what happened (the incarnation awaits purge, or
the insert failed with no row under the key), and `create_partition` and
`_open_or_create_partition`, which count the attempts, log the rejection
and mint another. No behavior changes.

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

* Document the vector store's horizontal scaling design

Five shared documents, made with every backend in mind:
- horizontal scaling: the problem, the goals, a collection's lifecycle,
  and what each store guarantees;
- the collection registry: its tables, operations, creation races,
  handles and their fencing;
- purge: tombstones, the retention, the claim, backoff, dead-lettering
  and the sweeper, with the claim's measured cost;
- consistency: what a query sees of earlier writes, and why the contract
  has no `get`;
- isolation: between collections, and record UUIDs and their reuse.

They describe the design the Qdrant and Milvus stores complete when they
move onto the registry; each store's own document comes with that move.

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

* Put "pending" before the noun in the registry's docstrings

"Mark the collection registered, pending, under an incarnation live" set
"pending" off in commas, which made the sentence hard to parse. It and
its kind now read "the pending collection with the given incarnation",
"a new pending collection" and "registered as pending", in the registry
ABC, the registry-backed base and the horizontal scaling design.

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

* State what mark_live does with an incarnation that is not pending

The contract said what a successful call does, but not what happens when
no collection carries the incarnation or the one that does is already
live. In both cases nothing changes and the call returns False, which is
what the SQLAlchemy registry's conditional UPDATE does; the return value
now reads as whether this call marked a collection live.

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

* Raise from mark_live when the collection is no longer pending, not a bool

mark_live answered whether it marked the collection live. Its one caller
that read the answer, open-or-create, used False to mean that a deletion
had removed the collection while its storage was prepared, and
create_collection ignored it, so a creation a deletion undid reported
success. A method named for an action now succeeds or raises, as the
registry's other methods do.

mark_live takes the collection's (namespace, name) beside its
incarnation, matches on all three, and raises
VectorStoreCollectionDeletedError, naming the collection, when no
pending collection carries the incarnation. create_collection lets it
propagate and states it in the VectorStore contract; open-or-create and
the event backend's service locator catch it and create again.

Tests: a creation whose collection is deleted during preparation raises;
a second mark of one incarnation raises; the service locator creates
again after a deletion undid its creation (fails against the locator
before this commit).

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

* Answer registrations from the collection registry: pending from register, live from resolve

The registry had two addressing schemes. register, get and unregister
took a (namespace, name); mark_live and unregister_incarnation took the
incarnation register had minted, which every caller carried back by hand,
and get answered a record whose `live` field went stale as soon as the
creator's own mark_live ran. Any incarnation fit any call.

The registry is now addressed by name alone: register, resolve,
unregister and claim_purgeable_incarnation. What acts on one life of a
collection is a registration, a handle bound to its incarnation, split by
state:
- register answers a PendingRegistration, whose creator marks it live
  (mark_live, which answers the LiveRegistration) or abandons it
  (unregister, this life only);
- resolve answers a LiveRegistration, or None when no collection holds the
  name, and raises VectorStoreCollectionPendingError when the collection is
  pending; a LiveRegistration's require_current raises
  VectorStoreCollectionHandleStaleError once the collection is deleted.
A registration's fields (namespace, name, configuration, incarnation)
never change; the state is conveyed by which outcome a call takes, so an
open is still one read and the fence one. A live collection offers no
mark_live and no unregister: it is deleted by name only.

The pending error carries the collection's configuration, so
open-or-create still refuses a pending collection of another
configuration without waiting for it. A store's collection handle is
built from its live registration, and its fence is
`registration.require_current()`. The SQLAlchemy registry supplies both
registration types; the name-keyed and incarnation-keyed unregistrations
share one transaction. The registry design document records why handles,
why two types, and why no state fields.

Tests: the registry's tests drive registrations (SQLite and PostgreSQL),
with a new test that a live registration is current until its collection
is deleted; the base's tests patch the pending registration's unregister
where they patched the registry's.

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

* Name the registry's handles for their holders: a reservation and a registration

PendingRegistration and LiveRegistration named the collection's state
when the registry answered them, which a deletion by anyone falsifies:
a "live" registration may have been deleted since. What does not change
is what each handle's holder may do, so the handles are named for that,
in the words of two known patterns: Try-Confirm/Cancel for the creator,
and the stale handle for everyone else.

- `register` is `reserve`, and answers a `Reservation`: the creator's
  hold on the name while it prepares the collection's storage.
- `PendingRegistration.mark_live` is `Reservation.confirm`, which marks
  the collection live and answers its `Registration`.
- `PendingRegistration.unregister` is `Reservation.cancel`.
- `LiveRegistration` is `Registration`; `resolve`, `require_current` and
  `unregister(namespace, name)` keep their names.
- The fields both share sit in a private base, `_RegistryEntry`.

"Pending" stays the word for the collection's state, in the store
contract and VectorStoreCollectionPendingError. The base store's
creation flow, its task set and log text, the tests and the design
documents follow; the registry design records why the names are roles.

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

* Leave a confirmed collection alone when its reservation is cancelled

Reservation.cancel() deleted the row carrying the reservation's
incarnation whatever its state, while confirm() acted only on a pending
row. A cancel issued after a confirmation that committed but whose
answer was lost would have tombstoned a live collection outside the
deletion by name, the one path that ends a live collection. cancel()
now acts only while the row is pending, as confirm() does; once the
reservation is confirmed it does nothing.

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

* Refuse a query limit that is not positive in registry-backed handles

The handle answered empty results for a limit at or below zero, where
the query contract now refuses it. It raises ValueError with the other
input checks, before the liveness check and whether or not there are
query vectors, so _query is called with a positive limit.

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

* State how long the purge claim holds its transaction

The claim's SELECT ... FOR UPDATE SKIP LOCKED transaction stays open,
idle on PostgreSQL, through the backend's deletion, so a server-side
idle_in_transaction_session_timeout shorter than a round fails the
round, and one that keeps doing so dead-letters the tombstone. The
purge document states the requirement beside the measured round
durations.

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

* Chain the locator's give-up error to the last error it retried

When a session's collection stayed pending, or kept losing races, the
retry loop gave up with a RuntimeError that carried no cause, so the
log lost the pending error's registered_at, the time an operator needs
to tell an abandoned creation from a slow one. The RuntimeError is now
raised from the last error the loop caught; its type and message are
unchanged.

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

* Never shift the purge backoff by a negative count

The claim computes 1 << (failed_rounds - 1) for each candidate row,
rows at 0 failures included, where the shift count is -1: defined on
SQLite, undefined behavior inside PostgreSQL's int4shl. The row's first
disjunct made it claimable regardless, so no claim went wrong, but the
expression was undefined there. The count is now clamped at 0 with a
CASE, which both dialects evaluate alike; backoffs after a failure are
unchanged, and SQLite's plan for the claim is the same index range on
(vector_store_name, enqueued_at).

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

* Chain open-or-create's give-up error to the race it last lost

When every attempt lost the reservation or the confirmation to another
creator or a deleter, open_or_create_collection gave up with a
VectorStoreAttemptsExhaustedError that carried no cause. It is now
raised from the last lost race, as the event backend's locator chains
its own give-up error; its type and message are unchanged.

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

* Drop the limit of 0 from the registry doc's no-op calls

A query whose limit is not positive is refused with the other invalid
inputs, before the handle checks its liveness, so it is no longer an
example of a call with nothing to send that still checks.

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

* Report a dead-lettered tombstone at or past the bound

The error log fired only when a failed round brought the count to
exactly the bound. The claim takes only tombstones under the bound, so a
tombstone is dead-lettered once its count reaches the bound or goes
past it, as when two purgers fail one tombstone at once; the log now
fires in either case.

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

* Run a purge round inside the registry instead of handing out a claim

claim_purgeable_incarnation() was an async context manager, which cannot
see what its body returns, so a round reported whether it found records
through PurgeClaim.any_records_found: a field that started None and a
guard that raised when a round left it unset. run_purge_round() takes
the round as a callable instead. The registry claims the tombstone that
came due first, calls the round with its namespace, configuration and
incarnation, and records what the round returns under the claim. A
round that raises still counts as a failed round. PurgeClaim, its None
state and the guard go; the vector store's _purge_round hook is
unchanged.

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

* Claim a purge round on SQLite with a write, as the segment store does

On SQLite the claim was a SELECT whose FOR UPDATE SKIP LOCKED the
dialect drops, and the driver defers BEGIN until a write, so the claim
ran outside any transaction: two processes sharing a SQLite registry
could claim one tombstone at once. The round recorded afterwards then
decided the failed-round reset from what it read at claim time, so a
round that succeeded could skip the reset after a racing round failed.

On SQLite the claim is now the segment store's: an UPDATE ... RETURNING
on the oldest eligible tombstone, which opens the write transaction, so
purgers serialize at the claim and rounds run one at a time.
PostgreSQL keeps FOR UPDATE SKIP LOCKED. The cost on SQLite is that
its single write lock is held across the round's remote deletion: the
registry's other writers wait for it, and past the driver's busy
timeout they fail with a locked-database error. The purge document
states it.

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

* Report a failed reservation cancel from the cancel's own task

A creation whose storage preparation failed or was cancelled starts
the reservation's cancel as a shielded task and logged its failure
around the await. A creation cancelled again stops awaiting it, so a
cancel that then failed went unlogged in the store, surfacing only as
asyncio's "Task exception was never retrieved" without the collection's
name. The task's done-callback now reports the failure, whoever is
still awaiting it.

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

* Check identifiers with the shared helper in every store

utils.require_identifiers checked the namespace and name of the
registry-backed base's lifecycle calls, while the four stores kept the
check inline: Qdrant and Milvus with the helper's own body, the SQLite
stores with one combined check whose message, "Invalid namespace ... or
name ...", named neither the rule nor which identifier broke it. Every
store's lifecycle calls now use the helper, so an invalid identifier is
reported the same way everywhere, with the rule it breaks.

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

* Track open_collection like the other lifecycle calls

The registry-backed store's create, open-or-create, delete and purge
ran under the store's operation tracker, and open_collection, which
resolves the name in the registry and is the first call the event
backend's locator makes on every open, did not.

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

* Say to drop the native collections before upgrading

The deployment note said existing Qdrant and Milvus data is orphaned.
The native collections keep their names, so the upgrade's new data
lands beside the old: a Qdrant collection kept through it holds old
points no search sees and no purge reclaims, and dropping it afterwards
drops the new data too; an existing Milvus collection has the earlier
schema, which the store cannot prepare. The note now says to drop the
collections before upgrading, and why.

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

* Cancel the reservation when confirming a creation fails or is cancelled

A creation cancelled its reservation when preparing the collection's
storage raised or was cancelled, so the name was free for the next
attempt, but not when the confirmation that follows did: a failure or a
cancellation while confirming left the collection pending, its name
taken until someone deleted it. Preparation and confirmation now share
one cleanup, the same shielded, logged cancel. The cancel acts only on
a pending collection, so a confirmation that committed before its
failure or cancellation was observed stands.

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

* Say which writers wait on SQLite's lock during a purge round

The purge document said the registry's other writers wait while a
SQLite round holds the write lock. The lock covers the whole database
file, so every store writing to it waits, and under the configuration
wizard's defaults the episode store, session manager, segment store and
configuration database share the registry's file. The round holds the
lock across remote calls bounded by the store's request timeout, and
past SQLite's default 5 s busy timeout, which the server does not
change, those writers fail with a locked-database error.

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

* Wrap the registry doc's creation-cleanup paragraph

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

* Lease the registry's purge claims instead of holding a transaction across the round

The purge claim held its transaction for the whole round. On SQLite, whose
write lock covers the whole database file, every writer to the database
waited out the round's remote calls to Qdrant or Milvus, each bounded by the
request timeout, and failed past SQLite's 5 s busy timeout with "database is
locked"; with the registry in one SQLite file beside the other stores, as the
configuration wizard sets up, that reached every store. On PostgreSQL the
session sat idle in a transaction for the round, holding back vacuum.

The claim is now a lease. One UPDATE ... RETURNING, committed at once, takes
the oldest due tombstone that no unexpired claim holds (picked FOR UPDATE
SKIP LOCKED on PostgreSQL; SQLite drops the clause and serializes the
write), stamps its claimed_at with the database's now() and increments its
claim_generation. The round runs with no transaction open. Its outcome is
recorded in a short transaction: a round that found nothing removes the
tombstone under any claim; one that found records or raised ends its claim,
clearing or counting its failed rounds, only while claim_generation is still
its claim's. A round that outlasted its lease therefore neither ends the
claim taken after it nor counts a failure against it, and logs a warning.

The lease lets purgers split a backlog; correctness rests, as before, on rounds
being safe to repeat and to run on two purgers at once. A claim holds until
its round ends or purge_lease_seconds (a new registry parameter, default
300) has passed since claimed_at, applied when a claim is decided, on the
database clock, as the retention and the backoff are.

Behavior changes:
- SQLite purgers run rounds on different tombstones at once, where they ran
  one at a time, and the database stays writable during a round.
- A round that found records always writes, to end its claim.
- A cancelled or crashed round's tombstone is claimed again once its lease
  has passed, rather than at once.
- An error recording a round's outcome is not counted as a failed round.

collection_registry_gc gains claimed_at and claim_generation. The test that
pinned SQLite's one-at-a-time rounds is replaced. The new tests: SQLite
stays writable during a round to a connection with a 0.1 s busy timeout (the
old claim made it raise "database is locked"); no PostgreSQL backend is idle
in a transaction during a round; a claimed tombstone is skipped until its
round ends or its lease passes; a round that outlasted its lease leaves the
claim after it; purgers on separate engines claim each tombstone once; and
the lease runs by the database clock. Removing any one lease mechanism fails
at least one of them.

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

* Name the purge backoff's first delay base_purge_retry_backoff_seconds

purge_retry_backoff_seconds read as the backoff itself, though it is the
delay after the first failure, doubled for each further one. The base_
prefix says so and mirrors max_purge_retry_backoff_seconds, the cap. No
configuration sets it: the stores construct the registry with its default.

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

* Count a purge round that never ended as failed, and end a cancelled round's claim

A round whose purger died wrote nothing after its claim, so it was never
counted: a tombstone whose round killed its purger every time was claimed
again each time its lease passed and never dead-lettered, and nothing tied
the crash to the tombstone.

The claim now counts such a round, as job queues count attempts. A claim
that finds the oldest due tombstone's previous claim still open, its lease
passed, runs no round: it counts that round as failed, as of when it was
claimed (failed_rounds + 1, last_failed_at = the old claimed_at), ends the
claim, bumps the generation so the old round's writes are fenced, and logs
a warning naming the incarnation and the claim time, plus the dead-letter
error when the count reaches the bound. One UPDATE handles both cases: its
SET reads the row as it was, on PostgreSQL and SQLite alike, and leaves
claimed_at null for an open claim, which RETURNING reports. No column is
added.

A cancelled round now ends its claim uncounted, in a write shielded from the
cancellation, as a cancelled creation cancels its reservation, so its
tombstone is claimable at once and a shutdown does not look like a crash. A
release that fails is logged by its own task, and the round is then counted
once its lease passes.

Behavior changes:
- A round whose purger died counts as a failed round once its lease passes,
  and its tombstone backs off from the time the round was claimed.
- A round whose outcome could not be written counts the same way.
- A cancelled round's tombstone is claimable at once, rather than after the
  lease.

Tests, each failing when the behavior it pins is removed: a cancelled round
ends its claim uncounted and logs nothing; a round that never ended keeps its
tombstone until the lease passes, then is counted, reported and backed off
from its claim time, and a lost release is reported; rounds that never end
dead-letter the tombstone; purgers on four engines that find one unended claim
count it once; and the overrun tests now expect the stale round counted once.

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

* Test the registry's wiring, atomicity, isolation and behavior under concurrency

New tests, run on SQLite and PostgreSQL; each fails, on both, with the
behavior it pins removed:
- A changed lease applies to claims already held, with a one-minute lease
  against a five-minute claim on both sides of the boundary (fails when the
  lease parameter is ignored; the other tests use the default).
- The first purge backoff comes from its parameter, and doubles (fails when
  the parameter is ignored).
- A deletion whose tombstone cannot be queued changes nothing: the
  collection stays registered and nothing is queued (fails when the delete
  and the queue insert commit separately, which would leave a deleted
  incarnation without a tombstone).
- A reservation whose purge-queue check fails leaves the name free (fails
  when the insert commits before the check).
- A round whose outcome cannot be written propagates the error uncounted,
  holds its claim until the lease passes, and is then counted once (fails
  when a write error counts as a failed round at once).
- The same name in two namespaces is two collections that resolve, delete
  and purge apart (fails when the namespace is left out of a lookup).
- A reservation whose minted incarnation collides with a tombstone being
  purged mints again at once (fails with the claim held across the round:
  SQLite's write lock, or PostgreSQL's row lock under the queue check).
- Creators, deleters, readers and purgers on four engines race over shared
  names: only documented refusals, no database error or deadlock, no
  overlapping rounds, no incarnation both registered and queued, and the
  purge drains (fails without the lease).
- Seeded sequences of every operation agree with a model of the contract
  step by step (fails when a cancel ends a live collection, a failed
  tombstone is claimed during its backoff, or a round that found records
  removes its tombstone).

The fault-injection tests fail one statement on the engine by its prefix,
naming the registry's tables.

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

* Name the purge cutoffs and the consecutive count, and shorten comments

- `_now_less(period, key)` is now `_database_time_ago(seconds)`. It takes a
  number of seconds or a SQL expression, so the retention, the lease, and
  the per-tombstone backoff cutoffs go through one helper, with anonymous
  bound parameters in place of the named ones. The registry keeps its
  durations as seconds.
- The purge queue's `failed_rounds` is now `consecutive_failed_rounds`, the
  count it is: a round that finds records resets it. The dead-letter log
  names the new column.
- The purge code's comments are shorter, and the design documents and
  docstrings this stack adds use serial commas.

The claim's database cost is unchanged within run-to-run variation
(no-op rounds, 500 per case, alternated 8 times, AC power): SQLite 1.52 vs
1.54 ms and 1.66 vs 1.60 ms, PostgreSQL 2.23 vs 2.45 ms and 2.14 vs 2.16 ms,
for rounds that found records and found nothing.

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

* Split the purge round into named steps, and return a typed tombstone claim

`run_purge_round` now reads as its sequence: claim the oldest due tombstone,
report a round that never ended, run the round, then record its outcome,
count its failure, or end its claim after a cancellation. Each step is a
method named for what it acts on:

- `_claim_oldest_due_tombstone` builds the claim and returns a
  `_TombstoneClaim` (what the round needs, and the claim's generation), an
  `_UnendedPurgeRound` (an expired claim, counted as failed), or None, in
  place of one result row whose fields meant different things in each case.
- `_has_elapsed(seconds, since=column)` states each condition the claim
  checks (the retention since the deletion, the backoff since the last
  failure, the lease since the claim), with `_purge_retry_backoff_seconds`
  computing each tombstone's backoff. It replaces `_database_time_ago` and
  `_backoff_cutoff`; the SQL is unchanged.
- `_report_unended_purge_round`, `_count_failed_purge_round`,
  `_report_dead_lettered_tombstone`, `_end_tombstone_claim_after_cancellation`,
  `_end_tombstone_claim`, and `_record_purge_round` take the claim.
- `_insert` is `_insert_pending_collection`; the registry's and the store's
  task sets are `_tombstone_claim_endings` and `_reservation_cancellations`.

The namespace test's purge loop is bounded, so a claim that never stops
fails it instead of hanging it. Breaking the cancellation path, the
unended-round path, or the elapsed check each fails registry tests.

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

* Count each purge attempt in its claim

A purge round whose purger dies writes nothing after its claim. The claim
that later found such a claim past its lease counted the round, ran no
round of its own, and reported it. That took a claim statement writing
every column one of two ways, a second kind of claim result
(`_UnendedPurgeRound`), and `last_failed_at` holding a claim time in that
case so that `RETURNING` could report it.

Each claim now counts its attempt, as job queues do when work is taken:
`consecutive_failed_rounds` is `consecutive_attempts`, incremented by the
claim's plain `UPDATE`. A round that finds records resets it, a round that
raises ends its claim and sets `last_failed_at`, a cancelled round takes
its attempt back, and a round whose purger died writes nothing. Each column
has one meaning: `claimed_at` is the open claim's time, and
`last_failed_at` is when a round last raised.

- A tombstone whose round never ended is claimed again once its lease, and
  then the backoff, have passed. The backoff runs from `last_failed_at`
  after a raise and from the lease's end after a round that never ended.
- A claim of a second or later attempt logs a warning naming the
  incarnation and the attempt ("attempt 3 of 10"). A raised round's error
  carries a note with both, so the sweeper's log of it names the tombstone.
  Nothing is logged later about an attempt that never ended.
- After 10 attempts, claims skip the tombstone. A last attempt that raises
  logs the dead-letter error; one whose purger died is not reported, since
  nothing runs on the tombstone after it.

New tests: a round that never ended is retried as the next attempt only
after its lease and the backoff, with the retry warning; racing purgers
retry it once; a cancelled retry of it leaves the tombstone claimable;
resetting a dead-lettered tombstone's attempts returns it at once; and a
raised round's error names the tombstone. The test of a count past the
dead-letter bound is gone, since claims stop at the bound. Breaking the
count, the backoff from the lease's end, the retry warning, the error's
note, the cancel's take-back, the reset, a raise's date or end, or the
dead-letter report or bound each fails a registry test.

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

* Name the purge count attempts_without_progress, and tighten the purge docstrings

`consecutive_attempts` did not say what resets it. The column counts the
purge rounds claimed since a round last made progress, finding records
and deleting them, the open one included and a cancelled round's attempt
taken back: it is `attempts_without_progress`, and the dead-letter bound
is `_MAX_PURGE_ATTEMPTS_WITHOUT_PROGRESS`. The dead-letter error and the
design documents name the new column.

The base backoff's docstring and description state the rule alone:
seconds before a tombstone is claimed again after a failed round, doubled
for each further failed round in a row. Where the wait is measured from
stays in the purge design document. A raise on the last attempt is
described as reported, the claims skipping the tombstone from then on, and
the column and claim comments are shorter.

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

* Replace the purge claim's measured costs with figures from the current claim

The purge document's backoff and interference figures timed earlier
forms of the claim, some of them the query alone. They are now
measurements from 2026-10-06, each naming the commit whose code was
measured: the backoff from commit 96a8b5b's claim, as whole calls, and
the interference from commit 503687c's lease, whose rounds cost the same
database time as commit 96a8b5b's.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n registry (MemMachine#1735)

* Test the Qdrant store against Qdrant 1.19.1

The integration tests ran Qdrant 1.17.0. The Qdrant store's design,
which follows in this stack, was measured against 1.19.1: its one-shot
purge of an incarnation by filter, and the per-tenant index layout. 1.17.0
predates the filter-resolution fence that 1.19.0 added to filter deletes
(qdrant#9678), whose extra cost 1.19.1 no longer shows, so the tests
could not see the behavior the store is tuned for.

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

* Bound every Qdrant request by a configured timeout

The Qdrant client was built with qdrant-client's default timeout, so how
long a write to Qdrant can be in flight was nothing the configuration
stated. The purge that follows in this stack waits out a retention longer
than any write can be in flight, and the request timeout is the part of
that time the store controls.

`QdrantConf.request_timeout_seconds`, a positive whole number of seconds
defaulting to 30, is passed to the client. The sample configurations and
the configuration docs show it. The tests' Qdrant clients are built by
one fixture with the configuration's default, so they run with the
timeout a configured store has.

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

* Move the Qdrant store onto the collection registry

The Qdrant store kept its catalog in a `__registry` collection per
namespace and serialized creating and deleting a collection with locks in
one process. A collection's name was the tenant discriminator on its
points, so a handle kept writing into a collection deleted and created
again under its name (MemMachine#1563), and a write in flight during a deletion
outlived it.

`QdrantVectorStore` is now a `RegistryBackedVectorStore`, and any number
of processes sharing its registry may serve its collections:
- Its catalog is the collection registry in the relational database
  `QdrantConf.collection_registry` names. The `__registry` collections,
  `registry_replication_factor` and the process-local locks go. The
  database manager builds and starts the registry before it opens the
  client, so a registry database it cannot resolve leaves no client open.
- Every point carries its collection's incarnation in `sys-incarnation`,
  in place of the name, under the same tenant index, and every search
  filters on it.
- A point's id is `uuid5(incarnation, record UUID)`, and the record UUID
  is kept in the payload as `sys-record_uuid`, which a search returns.
  The logical collections sharing a native collection share its id space:
  with the record UUID as the id, an upsert of a UUID another collection
  held replaced that collection's point.
- Preparing a collection's storage creates its native collection and
  payload indexes, each under its own already-exists guard, so a creation
  that failed part way is completed by the next.
- A purge round looks for one point under the incarnation and, finding
  one, deletes the incarnation's points with one filter-delete.
  `QdrantConf.tombstone_retention_seconds`, a day by default, is the
  retention, and the configuration refuses one below 10 x
  `request_timeout_seconds` + 300 seconds.
- An upsert is halved only when Qdrant or a proxy refuses it as sent,
  with a 400 or a 413. Any other error raises at once: a timed-out upsert
  may still be applied, and sending it again adds load to a server
  already too slow.

The collection lifecycle contract (`collection_lifecycle_contract.py`),
which a store's tests mix in with hooks that read the backend directly,
runs on Qdrant: stale handles, a collection created again starting empty,
creation races and their outcomes, failed preparations, the purge, a
write landing under a dead incarnation, and the registry lookups each
operation makes. The store's own tests check what Qdrant holds by
scrolling the incarnation past the store.

Breaking: existing Qdrant data is orphaned, since its points carry names
and its catalog is in the `__registry` collections; no migration is
included. Every Qdrant store needs a relational database for its
registry, which the samples, the Helm chart, the configuration wizard
and the configuration docs name.

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

* Document how the Qdrant store meets the shared contracts

`design/qdrant_vector_store.md` records the Qdrant store's layout, its
derived point ids and why they are one-way, filtered-search correctness,
the purge by filter, and its consistency on one node and replicated,
with the measurements behind each choice.

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

* Build Qdrant handles from live registrations

The collection registry now answers registrations: a PendingRegistration
from register, which its creator marks live, and a LiveRegistration from
resolve and mark_live, whose require_current is a handle's fence. The
Qdrant handle takes its live registration in place of the namespace,
name, incarnation, configuration and registry lookup, and
_build_collection_handle builds it from one.

The collection lifecycle contract follows:
- the tests that fail a check or count checks patch the registration
  type's require_current, where they replaced the handle's lookup;
- the racing winners register and mark live through a pending
  registration, and the winner is found with resolve;
- churn counts VectorStoreCollectionDeletedError, which a creation
  undone by a concurrent deletion now raises, among the domain's
  outcomes.
The Qdrant tests that build a handle on a mocked client give it a live
registration that stays current.

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

* Use the registry's reservations and registrations in the Qdrant store and the contract

MemMachine#1734 names the registry's handles for their holders: `reserve` answers
a Reservation, whose `confirm` answers a Registration, and whose `cancel`
gives the name back. The Qdrant handle takes a Registration, and the
collection lifecycle contract's racing winners reserve, then confirm.
Names only.

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

* Drop the lifecycle contract's stale check of a query with limit 0

A limit at or below zero is now refused as invalid input, checked
before the handle's liveness, so it no longer stands for a query with
nothing to send to the backend; the query with no vectors still does.

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

* Say to drop the Qdrant collections before upgrading, and list the registry

The user docs did not say what happens to existing Qdrant data. The
store keeps its native collections' names, so points left in one stay,
invisible and never purged, and dropping the collection after the
upgrade drops the new data too; databases.mdx now says to drop the
collections before upgrading. The Helm README's configuration summary
also lists collection_registry, which the configmap template sets.

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

* Run every Qdrant store test against a real Qdrant server

Qdrant's local mode answers differently from a server: it ignores
payload indexes and raises its own exceptions where a server answers
404 or 409 over REST or NOT_FOUND or ALREADY_EXISTS over gRPC. The
store fixture's client is now a testcontainers Qdrant over REST or
gRPC, marked integration, so every store test exercises what a
deployment runs. The tests that build a handle on a mocked client stay
in the default suite.

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

* Give the wiring tests' collection registry a file database

The Qdrant wiring tests gave the collection registry an in-memory
SQLite database, which aiosqlite serves from one shared connection;
the segment store and the SQLite vector stores refuse such an engine,
and the registry is to do the same. Each test now puts the registry in
a SQLite file under its own tmp_path. What each test asserts is
unchanged.

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

* Refuse engines that share one connection or hold SQLite in memory

The registry's arbitration rests on each operation running in a transaction
of its own: a reservation's insert and queue check, a deletion's row and
tombstone, a purge claim. On an engine whose pool is StaticPool, every
session shares one connection, so concurrent operations would run inside one
another's transactions. On in-memory SQLite each connection gets a separate
database, so the registry's state would not be shared even within one
process. A SQLite configuration whose path is ":memory:" produces the former
(aiosqlite uses StaticPool for it). The segment store and both SQLite vector
stores already refuse both; the registry now does too, with the same
messages.

The dialect and SQLite-version refusal tests now use file engines, so each
checks only its own refusal. The new tests fail with either check removed.

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

* Pin the Qdrant client's timeout wiring with a non-default value

The wiring test asserted that the client got a 30 s timeout, which is
also QdrantConf's default, so a manager that ignored
request_timeout_seconds and passed 30 passed it. It now configures 7 s
and asserts the client is built with 7; hardcoding 30, or dropping the
timeout from the client's arguments, fails it.

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

* Bound the lifecycle contract's drain and settle before empty checks

The contract drained purges with an unbounded while loop, so a store
whose purge round kept reporting records hung the suite. The drain now
fails the test once 1,000 rounds have each found something, far more
than the few deleted collections a contract test leaves; a Qdrant
round that always reports records now fails the purge tests instead of
hanging them.

The two tests that check that a collection created again under a
deleted one's name starts empty now call the store's settle hook first.
On a store whose reads lag its writes, a query could miss the old
life's record for that reason alone, and the check would pass even if
the new life could reach it; after settling it fails only when the new
life cannot. Dropping the incarnation filter from Qdrant's search
fails both.

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

* Check a stale upsert by what it stores, not by its registry reads

The contract counted require_current calls: two per upsert, one per
query or delete. That pinned how a handle checks, not what the check
guarantees, and a store that checked differently but kept the
guarantee failed it. The test that replaces it pins the state: an
upsert through a handle whose collection is already deleted raises
StaleError, and the dead life's records, read past the store, are
exactly those it held before. Removing the handle's liveness check
before its write lets the record land under the dead incarnation and
fails it.

The rest of what the counts stood for is pinned by state elsewhere in
the contract: a stale query and a stale delete raise, and an upsert
whose collection is deleted between its check and its write raises.

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

* Test that filtered queries and namespaces keep Qdrant collections apart

Two collections of one namespace and configuration share a native
collection, and the existing isolation tests either queried without a
filter or used a filter only the querying collection's records met, so
a search whose property filter displaced the incarnation filter, or sat
beside it under `should`, passed them all.

- A filtered query returns only its own collection's records: both
  collections hold records meeting every filter (a comparison, In, Or,
  Not(IsNull) on an undeclared property, And), and each query must
  return exactly its own. Replacing the incarnation filter with the
  property filter, or putting both under `should`, fails it.
- Collections of two namespaces keep their records in separate storage,
  as VectorStore states: an upsert into one namespace's collection adds
  to that namespace's storage and leaves the other's count unchanged,
  under one name and configuration. Building the native collection's
  name without the namespace fails it.

The lifecycle contract's count_stored hook now uses the module's
_count_stored helper, which the namespace test also reads.

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

* Test Qdrant purge rounds on missing storage and on a write after a round

- A tombstone whose storage was never made is retired: a creation
  whose storage preparation fails before touching Qdrant cancels its
  reservation, queuing a tombstone for a native collection that does
  not exist. Its round must find nothing and retire it, raising
  nothing: the first purge call runs a round and the next finds none
  due. On Qdrant 1.19.1 the round's scroll of the missing collection
  raises UnexpectedResponse 404 over REST and AioRpcError NOT_FOUND
  over gRPC (a filter-delete raises the same). Removing the not-found
  handling around the scroll fails it on both transports; classifying
  only REST's 400, or gRPC's INVALID_ARGUMENT, as not found fails it
  on that transport.
- A write landing after a purge round is reclaimed by the next: after
  a round deletes a deleted collection's records, a write through the
  stale handle's backend path, past its liveness checks, lands under
  the dead incarnation, as a write in flight across the deletion does.
  Draining must leave nothing under the incarnation. A round that
  reports nothing found after deleting retires the tombstone on its
  first round and leaves the late write in place, failing it.

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

* Test that a Qdrant upsert raises when a point is refused on its own

An upsert refused with a 400 or 413 is halved until its halves fit or a
single point is refused, and the store's docstring says the single
point's refusal raises. The halving test refused only batches over a
size, so every point was eventually accepted. The new test refuses one
point whenever it is sent: the upsert must raise the refusal, with its
status, rather than return as if the point were accepted. Returning,
instead of raising, once halving reaches a single point fails it.

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

* Test ranking, scores and threshold direction for every similarity metric

The suite queried cosine collections only, so a metric mapped to the
wrong Qdrant distance, or a threshold kept on the wrong side for a
distance, went unseen. For each of COSINE, DOT, EUCLIDEAN and
MANHATTAN, on a real Qdrant over REST and gRPC, five vectors that the
four metrics rank in four different orders, with no ties, are queried
against one vector:

- matches come best first, highest for a similarity and lowest for a
  distance;
- each score is what QueryMatch defines: cosine similarity, dot
  product, Euclidean distance or Manhattan distance;
- a threshold halfway between the second and third best scores keeps
  exactly the best two: scores above it for a similarity, below it for
  a distance.

Mapping EUCLIDEAN to Qdrant's Manhattan distance or back, COSINE to
dot product or back, dropping the threshold, negating it for distances,
or applying it as a similarity for every metric fails it.

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

* Test a seeded Qdrant operation sequence against a model

Two collections of one namespace and configuration, so sharing a native
collection, take 200 seeded steps over a pool of 12 sorted uuid4s:
upserts and re-upserts that change or drop properties (one of them
undeclared), deletes of a collection's own UUIDs, the other
collection's and absent ones, filtered queries (comparisons, In, IsNull,
Not, And, Or) with a limit of the whole pool, deletion and re-creation
under the same name, and purge drains. After every step, each
collection's stored record UUIDs, read past the store, and an
unfiltered query's matches (each record once, scored by its latest
vector, best first) agree with a model of each collection's records; a
query step's filtered matches agree with the model's filter; a drain
leaves exactly the model's records in the native collection.

Merging an upsert into the stored point's payload, so that a dropped or
changed property keeps its old value, fails it; so does deriving point
ids from the record UUID alone, searching without the incarnation
filter, and a purge round that never deletes what it finds.

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

* Test concurrent churn across two Qdrant stores sharing one registry

Two stores, one on a REST and one on a gRPC client, each with its own
registry object and engine on one registry database (a SQLite file, and
PostgreSQL), serve eight seeded workers. Each worker owns four record
UUIDs, so its records in each collection life follow from its own
operations, and takes 50 steps over three collection names: upserts and
deletes of its records, queries filtered on its own records, and
deletion of a collection, half the time followed by its creation. A
purger per store drains whenever a collection is deleted.

- Every operation returns or raises a documented domain error (stale
  handle, already exists, pending, deleted, attempts exhausted); any
  backend or database error fails the test, and a deadline turns a
  deadlock into a failure.
- A query whose collection was live throughout it, which a delete of
  nothing confirms afterwards, returns exactly its worker's records;
  any query returns none of the worker's other lives' records.
- After quiescing and draining, each live collection's stored records
  and an unfiltered query equal what its workers wrote and kept, and a
  deleted life keeps no record but one whose upsert raised as stale: a
  write in flight across a deletion can land after a round found the
  incarnation empty, the race the tombstone retention closes, and the
  test runs with no retention.

Searching without the incarnation filter, deriving point ids from the
record UUID alone, misclassifying REST's 409 on an existing native
collection, a purge round that never deletes, and a purge round that
never returns each fail it.

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

* Make the Qdrant creation race tests race, and drop a dead index check

The two-workers test checked that the native collection ended up
indexed, but only the reservation's winner prepares storage, on a
native collection no one else touches, so that check could not fail
the way its docstring said; it is gone, and the docstring says what the
test does. Its creators now reserve the name together, behind a
barrier, so the loser always loses at the reservation rather than
finding the winner already pending or live. It also writes a record
through one handle and reads it through the other, so agreeing on one
collection is shown by state. An open-or-create that raises on losing
the reservation, and a strict create that treats a taken name as
created, each fail it.

A new test races what the old one meant to: two workers create two
collections of one namespace and configuration at once, both
preparations held at a barrier until both start, so both create the
shared native collection together and one finds it already there. Both
creations must succeed and each collection must hold its own records,
over REST and over gRPC. Checking whether the native collection exists
before creating it, without guarding the create, fails it on both
transports while every sequential test passes; so does misclassifying
the transport's already-exists answer.

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

* Keep a Qdrant match that scores exactly the score threshold

Qdrant rounds a query's score_threshold to single precision and keeps only
scores strictly better than it, on every metric and transport, so a match
scoring exactly the threshold was dropped, even a perfect cosine match under
a threshold of 1.0. The SQLite stores and Milvus keep it, and the contract
did not say which.

The contract now says a match scoring exactly the threshold is returned.
The Qdrant store sends the adjacent single-precision value on the worse side
(numpy's nextafter in float32), so Qdrant keeps a score equal to the
threshold, and checks the caller's threshold on the returned scores, so a
score Qdrant's rounding let through but that falls short of the threshold as
returned is dropped. A threshold beyond single precision is not sent; the
store's check applies it.

Sending the threshold, rather than only checking it in Python, keeps Qdrant
from returning matches the threshold cuts. Measured against Qdrant 1.19.1
(4 collections of 5,000 768-dimension points sharing one native collection,
300 queries per cell with the two variants alternated, a threshold passing a
quarter of the limit; AC power), p50 per query, threshold sent vs checked in
Python only: REST 2.89 vs 3.04 ms at limit 10, 2.85 vs 2.96 at 40, 3.13 vs
3.42 at 100; gRPC 2.86 vs 2.93 ms at 10, 3.36 vs 3.80 at 40, 3.33 vs 4.23 at
100. Both returned identical matches.

The new test, on every metric and on REST and gRPC, sets the threshold to a
match's reported score (the match is kept) and one step better (it is
dropped). It fails with the threshold sent unchanged, stepped the wrong way,
without the store's check, or with that check strict.

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

* Test that the SQLite stores keep a match scoring exactly the threshold

The contract says a match scoring exactly the score threshold is returned.
Both SQLite stores already keep it; each now has the test the Qdrant store
has, on every metric it supports (cosine, dot and Euclidean on the USearch
store; cosine and Euclidean on sqlite-vec): a threshold equal to a match's
reported score keeps the match, and one a step better drops it. Each fails
with its store's threshold check made strict.

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

* Use serial commas in the Qdrant document, and shorten the threshold comment

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ich Qdrant operations filter on the incarnation (MemMachine#1775)

* Accept an attempts-exhausted error in the lifecycle churn contract

create_collection and open_or_create_collection document
VectorStoreAttemptsExhaustedError as the outcome of creation races that
make no progress, and the Qdrant store's own churn test already accepts it.
The contract's churn test refused it, so it could fail on a documented
outcome.

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

* Say which Qdrant operations filter on the incarnation

The design said every search, scroll, and delete of a handle filters on the
incarnation. A handle's deletes name its points' derived ids, with no
filter; the scrolls and filtered deletes are the purge's.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The already-exists and not-found checks also matched a ValueError by its
message text. Only qdrant-client's local mode raises those, and the store
no longer runs in local mode: against a server, the client raises
UnexpectedResponse with an HTTP status over REST and AioRpcError with a
gRPC status code over gRPC. The message match and the ValueError the
preparation and purge caught for it go.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n registry, against a Milvus server (MemMachine#1736)

* Move the Milvus store onto the collection registry, against a Milvus server

The Milvus store kept its catalog in a `memmachine_<namespace>__registry`
collection per namespace, whose `insert` does not enforce primary-key
uniqueness, so two creators of one name both succeeded, and it serialized
creating and deleting a collection with locks in one process. A
collection's name was the partition key on its entities, so a handle kept
writing into a collection deleted and created again under its name, and a
write in flight during a deletion outlived it. Each call ran the sync
client on a thread of the event loop's default executor, so calls waiting
on Milvus queued every other `to_thread` call of the process behind them.

`MilvusVectorStore` is now a `RegistryBackedVectorStore`, and any number
of processes sharing its registry may serve its collections. The store
was rewritten rather than adapted; what changes:
- The catalog is the collection registry in the relational database
  `MilvusConf.collection_registry` names, built and started by the
  database manager before it opens the client. The registry collections
  and the process-local locks go.
- Every entity carries its collection's incarnation as the partition key,
  with partition-key isolation, and in its primary key,
  `"{incarnation}:{record_uuid}"`.
- Declared properties are typed, indexed fields (`_p_<name>`, a datetime
  as TIMESTAMPTZ with its UTC offset beside it in `_tz_<name>`), and
  undeclared ones live in the JSON field, still filterable. Negation is
  the complement, as on Qdrant: a negated condition holds where the
  property has no value.
- The vector index is HNSW_SQ with 4-bit codes and FP16 refinement, what
  AUTOINDEX builds on CPU from Milvus 2.6.10, named so every server builds
  the same; a search rescores `limit x 8` candidates. Scores are the
  server's.
- Reads run at Milvus's default consistency level, Bounded, which the
  store states: a query reflects every write made at least the server's
  `common.gracefulTime` before it. `MilvusConf.consistency_level` goes.
- The store calls Milvus through pymilvus's `AsyncMilvusClient`, and
  bounds every request by `MilvusConf.request_timeout_seconds`.
- A purge round lists a batch of the incarnation's primary keys, at most
  `MilvusConf.purge_batch_size`, and deletes them.
  `MilvusConf.tombstone_retention_seconds` is the retention, refused below
  10 x `request_timeout_seconds` + 300 seconds as for Qdrant. A declared
  string's VARCHAR length is `MilvusConf.max_varchar_length`. Limits the
  server configures stay the server's.
- A delete raises unless Milvus accepted every key sent.

Milvus Lite is dropped. It is a separate embedded engine that scores,
indexes and enforces collection properties differently, so a store tested
against it is not tested against what production runs; MemMachine's
local, single-node backend is the SQLite vector store. `MilvusConf.uri`
defaults to `http://localhost:19530`, a URI with no scheme, which pymilvus
reads as a Lite file, is refused, and the milvus extra no longer installs
milvus-lite (the lock drops it with the packages only it required).

The store's tests, including the collection lifecycle contract, run
against a Milvus 2.6.24 server container as integration tests, and read
past the store at Strong to check what Milvus holds.

Breaking: existing Milvus data is orphaned, and an existing native Milvus
collection has to be dropped; no migration is included. Every Milvus
store needs a relational database for its registry, which the samples,
the configuration wizard and the configuration docs name.

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

* Document how the Milvus store meets the shared contracts

`design/milvus_vector_store.md` records the Milvus store's layout: the
shared native collection and partition-key tenancy, the composite key,
the index and why it was chosen, declared properties as typed fields, the
purge in batches, consistency levels, and the async client, with the
measurements behind each choice.

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

* Build Milvus handles from live registrations

As for Qdrant: the Milvus handle takes its live registration in place of
the namespace, name, incarnation, configuration and registry lookup, and
_build_collection_handle builds it from one. The test that builds a
handle on a mocked client gives it a live registration that stays
current.

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

* Take a Registration in the Milvus handle

MemMachine#1734 renames the registry's LiveRegistration to Registration. Names only.

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

* Say to drop the Milvus collections before upgrading

The Milvus store keeps the native collection's name from main, so on
an upgraded server it finds main's collection, whose schema it cannot
prepare, and the first request of every new session fails. The user
docs' upgrade note now names Milvus beside Qdrant.

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

* Test that Milvus queries and deletes stay within their collection

Two collections of one namespace and configuration share a native
collection. test_a_query_returns_only_its_own_collections_records gives
both records that match the same filters and checks that each answers
with its own records alone, unfiltered, on a declared property, on an
undeclared one, and under a negation. It fails when _query lets the
property filter replace the incarnation filter, ORs the two, or drops
the incarnation filter: Milvus 2.6.24 refuses each of those searches
under partition key isolation, and with isolation off as well the query
returns the other collection's records.

test_same_uuid_can_exist_in_different_logical_collections now also
deletes the shared UUID through one collection and checks that the
other still holds and returns its record. It fails when _delete
deletes by a record_uuid filter in place of the composite key.

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

* Test the Milvus score threshold and ranking for every metric

test_a_threshold_keeps_the_matches_within_it_best_first upserts three
records at increasing distance from the query, out of order, and
queries with a threshold between the middle and the farthest: the two
nearest match, best first, with the metric's scores. For Euclidean the
distances are 1, 2 and 5 and the threshold 3, between the middle
distance and its square; dot product and cosine take their own
thresholds.

It fails when the threshold is applied to Milvus's squared Euclidean
distance before the square root, when every metric keeps scores at or
above the threshold, when every metric keeps scores at or below it,
when the matches are sorted in reverse, and when every metric's score
is the square root of Milvus's distance.

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

* Test that the Milvus store reads at Bounded or stronger

test_the_store_reads_at_bounded_or_stronger creates a native collection,
writes, queries, deletes the collection and drains the purge, then
checks that describe_collection reports Bounded and that no search,
query, get or hybrid search the store made names a level other than
Bounded or Strong. The purge's listing and the tombstone retention rely
on reads lagging writes by at most common.gracefulTime.

It fails when the native collection is created at Session or
Eventually, when the store's search names Eventually, and when the
purge round's listing names Session.

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

* Check random Milvus filters against the in-memory evaluator

test_random_filters_agree_with_the_model writes 48 records whose
properties cover every type, declared and undeclared, with datetimes at
offsets from -12:00 to +14:00, instants a microsecond and a second
apart, strings with quotes, backslashes and non-ASCII letters, and each
property missing a quarter of the time. 200 seeded filter trees of
Comparison, In, IsNull, And, Or and Not, with values of their
properties' types, must select the UUIDs evaluate_filter selects, after
one settle. The trees compare with != only through Not(=): on a missing
property the store's != holds, as the complement of =, while
evaluate_filter's does not.

It fails when a declared datetime is written without its offset, when
a negated condition no longer holds on a missing property, when
negation is not pushed through And and Or by De Morgan's laws, and when
undeclared datetimes are encoded at their own offset in place of UTC.

Dropping the store's own UTC normalization of datetime filter literals
is an equivalent mutant: Comparison and In normalize datetime values to
UTC when they are built, so the store's call never changes a value.

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

* Check a random sequence of Milvus operations against a model

test_a_random_sequence_of_operations_agrees_with_a_model runs 60 seeded
steps on two collections sharing a native collection: upserts of new
and replacing records, deletes of present and absent records, deletion
and recreation of a collection, which then reuses its UUIDs, and purge
drains. Twelve UUIDs serve both collections and every life of each.
After every step each collection holds exactly the model's records,
once each, read at Strong; its settled queries, unfiltered and under
random filters, select what evaluate_filter selects; a replaced
record's old property values no longer select it; and after a drain no
deleted life keeps a record. It fails when _upsert inserts in place of
upserting, when the primary key leaves out the incarnation, and, by
timing out its bounded drain, when the purge round never deletes.

test_upsert_removes_stale_filter_fields now also checks that a filter
on the replaced value finds nothing, which inserting in place of
upserting fails. test_upsert_calls_native_upsert compared the entity
the store built with itself; the model test checks the state an upsert
leaves instead, so it is removed.

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

* Test Milvus purge rounds on a dropped collection and on two purgers

The store fixture now builds its store with _started_store over a
per-test registry database, and other_store builds one as another
process would beside it: its own Milvus client and registry
connections, over the same Milvus and registry database.

test_a_purge_round_on_a_dropped_native_collection_retires_the_tombstone
deletes a collection, drops its native collection, and drains the
purge: the round raises nothing, and the next call finds nothing due.
It fails when _purge_round no longer checks that the native collection
exists, since Milvus refuses the listing.

test_purgers_on_two_stores_reclaim_the_deleted_collections_alone
deletes four collections of seven records each beside a live one in the
same native collection, and drains both stores' purges at once in
batches of 3. Every deleted collection ends empty, the live one keeps
its seven records, and neither store finds anything due. It fails when
a round reports finding nothing after deleting its batch, and when the
listing leaves out the incarnation filter.

test_a_purge_round_milvus_does_not_accept_in_full_raises gives a store
a mocked client whose delete accepts one of the two keys the round
listed: the purge raises. It fails when _purge_round no longer checks
the count. test_a_delete_milvus_does_not_accept_in_full_raises now has
Milvus accept one of two keys, so a check for zero alone fails it, and
matches the exception's type rather than its wording.

The drain in these tests is bounded, so a purge that never ends fails
the test instead of hanging it.

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

* Test concurrent preparations of one Milvus native collection

Milvus 2.6.24 answers two or more identical create requests for one
collection, sent at once from separate clients, with success every
time (four clients, five trials), and identical concurrent index
requests likewise; a create whose schema differs gets code 1100,
"create duplicate collection with different parameters". So a real
server cannot make a racing creation take the store's already-exists
branch.

test_a_creation_that_loses_the_native_collection_to_another_completes_it
covers that branch with a mocked client: the native collection is
missing when checked and its create is refused as already existing.
The creation indexes and loads the native collection, and the
collection opens. It fails when the already-exists tolerance is
dropped, and when a creation that loses the create returns without
indexing and loading.

test_a_creation_finding_the_native_collection_mid_creation_completes_it
runs two stores on separate clients and registry connections. The
first creates the native collection and is held before its indexes
until the second's creation of another collection, which finds the
native collection present, has returned and been written to and
queried. Both collections end usable with every index. It fails when a
creation that finds the native collection present skips its index and
load steps.

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

* Test Milvus settings with values other than their defaults

The configuration, database manager and store tests set the request
timeout, the declared-string length and the purge batch size to values
other than their defaults, so a default passed in place of the
configured value fails them.

- test_every_request_carries_the_timeout runs at a 17-second timeout.
  It fails when the store or the handles it builds use 30 in place of
  the configured timeout.
- test_milvus_client_kwargs_forwarded expects the configured 7-second
  timeout on the client, and test_milvus_creates_vector_store expects
  7, 2048 and 500 in the store's parameters. Each fails when
  async_get_milvus_client passes the default for that setting.
- test_parse_valid_storage_dict parses max_varchar_length 2048 and
  purge_batch_size 500 from the configuration. It fails when either
  field reads another key, so the configured value is ignored.

test_milvus_conf_rejects_a_length_or_batch_size_that_is_not_positive and
test_a_setting_that_is_not_positive_is_refused check that MilvusConf
and MilvusVectorStoreParams refuse 0 for each of these settings. Each
fails when that field's positivity constraint is dropped.

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

* Test concurrent churn across two Milvus stores with purgers running

test_churn_across_two_stores_with_purgers_keeps_every_collection_exact
runs six workers, three on each of two stores with their own clients
and registry connections over one registry database, for 40 seeded
steps each on three collections sharing a native collection: upserts
and deletes of records the worker alone owns, owner-filtered queries,
and deletion and recreation of the shared collections. Each store's
purger drains, in batches of 2, after every collection deletion, and
each purge round begins at a settled read, as the tombstone retention
would give it. A ledger records, by the incarnation each operation
reached, the records upserts and deletes that returned leave, the
records sent, and the upserts that raced their collection's deletion.

Only the documented outcomes of a lost race may be raised, and the
whole churn is bounded, so a deadlock fails it. During the churn, a
query returns only records its owner sent to its incarnation. Once the
churn is quiet, settled and drained, each live collection holds
exactly its ledger's records, once each, with their latest values, and
its settled owner and tag filters select them; the deleted
collections keep only records of upserts that raced their deletion.
In three runs the churn made 16 to 18 collection lives, 7 to 10
upserts raced a deletion, and queries, deletes and recreations met
stale handles, existing names and deleted reservations.

It fails when _upsert inserts in place of upserting, when the primary
key leaves out the incarnation, when the purge listing leaves out the
incarnation filter, when a round reports finding nothing after its
batch, and, by timing out, when the purge round never deletes.

The two-purgers test now builds its batched stores with the same
_with_purge_batch_size helper.

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

* Give the Milvus wiring tests' collection registry a file database

The Milvus wiring tests built their collection registry on the module's
in-memory SQLite configuration, which the Qdrant wiring tests' change below
replaced with a file per test, since the registry refuses an in-memory or
single-connection engine. Each Milvus wiring test now puts the registry in a
SQLite file under its own tmp_path, as the Qdrant ones do. What each test
asserts is unchanged.

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

* Test that the Milvus store keeps a match scoring exactly the threshold

The contract says a match scoring exactly the score threshold is returned,
and the Milvus store already keeps it. It now has the test the Qdrant and
SQLite stores have, on cosine, dot and Euclidean: a threshold equal to a
match's reported score keeps the match, and one a step better drops it. It
fails with the store's threshold check made strict.

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

* Use serial commas in the Milvus document and the store's comments

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

* Name the Milvus filter helpers for what they produce

The module's expression helpers had names that did not say what they make:
`_expr_string`, `_literal`, `_declared_literal`, `_fits`, `_absent`,
`_condition`, and `_milvus_filter` are now `_expression_string_literal`,
`_property_value_literal`, `_declared_property_value_literal`,
`_is_comparable_with_declared_type`, `_property_absent_expression`,
`_condition_expression`, and `_filter_expression`; the handle's `_score` is
`_score_from_distance`. No behavior changes.

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

* Send Milvus filter strings as UTF-8, not ASCII escapes

json.dumps escapes a character outside the Basic Multilingual Plane as a
UTF-16 surrogate pair, which Milvus's expression parser refuses, so a
filter on a string such as "café 😀" failed to parse on a declared and an
undeclared property alike. The literal now keeps its characters as UTF-8.

The new test filters on such a string on both kinds of property; it fails
with the ASCII escaping.

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

* Compare an undeclared Milvus property only with values of a comparable type

An undeclared property's condition compared only its stored value, so a
string equal to an undeclared datetime's stored text matched it, where the
declared path, the other stores' JSON properties, and the in-memory
evaluator match nothing. The condition now also requires the stored type
tag to be one the value compares with, by the rule the declared path
already uses: a bool with a bool, an int or float with either, and any other
value with its own type.

The new test filters an undeclared datetime by its stored text, and an
undeclared float by an int; it fails without the type check.

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

* Index and load a part-made Milvus collection before a purge round lists it

A creation that failed after creating the native collection, before its
indexes or its load, leaves it unindexed and unloaded, and its cancelled
collection's tombstone is due for purge. The round's listing query then
raised "collection not loaded" on every claim, so the tombstone was
dead-lettered unless another creation of the same namespace and
configuration completed the collection first. A round now creates the
missing indexes and loads the collection, the steps preparation already
takes, before it lists.

The new test fails a creation at its index step, then drains the purge: it
fails with "collection not loaded" without the change.

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

* Drop the Milvus store's already-exists guard, which no supported server reaches

Preparation caught a create refused as "already exist" by matching the
message text. Milvus answers a create of an existing collection with the
same schema with success, and refuses one with another schema as "create
duplicate collection with different parameters" (code 1100, Milvus
2.6.24), which the text did not match, so the branch ran only under the
mocked client of its own test. The guard, its message match, and that test
go; the concurrent preparation tests cover racing creators against a real
server.

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

* Refuse a metric Milvus does not support before reserving the collection

The store checked the metric while preparing storage, after the registry
had reserved the name, so a refused configuration cost a reservation and
its cancellation, and stayed pending until deleted if the cancellation
failed. create_collection and open_or_create_collection now check it first.

The test, for both operations, finds nothing to purge after the refusal; it
fails with the check back in preparation.

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

* Bound the Milvus connectivity check by the request timeout

validate_milvus_client listed collections with no timeout, and the
client's own timeout bounds only connecting, so a Milvus that accepts the
connection and never answers hung validation, the one request
request_timeout_seconds did not bound. The check now passes the configured
timeout, on a store's first build and in build_all's validation.

The new test checks both paths pass it; it fails with the timeout dropped.

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

* Require testcontainers 4.15.0, the first with the community modules

The shared conftest imports MilvusContainer from testcontainers.community,
which 4.14.2, the declared floor, does not have, so an install at the floor
could not import the conftest or collect any server test. The lock already
pins 4.15.0; only the floor moves.

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

* Dispose a mocked-client purge test's registry engine when it fails

The test disposed the registry engine it opened only after its last
assertion, so a failure left the engine and its aiosqlite thread open for
the rest of the session.

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

* Drop the Milvus store's re-sort of search results

The store sorted each query's matches by score, though Milvus returns hits
best first and the one transform the store applies, the square root of a
Euclidean distance, keeps their order. On Milvus 2.6.24, 3,000 random
vectors in each metric gave 450 of 450 result lists in order, filtered and
not, at limit 100.

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

* Say that a Milvus native collection keeps its VARCHAR length

The store sizes a declared string's VARCHAR field when it creates the
native collection, and a later preparation that finds the collection there
leaves it as it is, so a changed max_varchar_length applies only to native
collections created afterward. The setting's description, the
configuration docs, and the design document now say so.

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

* Match no declared Milvus int property with a float filter value

The declared path counted an int and a float as comparable either way, so
a float filter value on a declared int property reached Milvus as a float
literal against an INT64 field, which Milvus refuses to parse ("cannot
cast value to Int64", code 1100). A float now compares only with a float
property, and matches no int one, as a value of another type matches
nothing; an int still compares with a float property by value.

The new test filters a declared float property with an int and a declared
int property with a float; it fails with the float counted as comparable
with an int property.

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

* Create Milvus native collections at Bounded by name

The store's reads run at the collection's consistency level, and the
purge's listing and the tombstone retention rely on that level being
Bounded or stronger. The store named no level when it created a native
collection, so the level was whatever pymilvus defaults to, which a
pymilvus release could change without the store noticing. The store now
creates each native collection at Bounded.

The test checks the create request names Bounded; it fails with the
keyword removed.

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

* Report Milvus store operation latencies to a metrics factory

The database manager built the Milvus store's parameters without a
metrics factory, and MilvusConf had no metrics_factory_id, so a
deployment could not configure one and every timed Milvus operation
recorded nothing, where the Qdrant and Neo4j stores report theirs.
MilvusConf takes metrics_factory_id, and the manager passes the factory
it names, as it does for Qdrant.

The wiring test checks the factory reaches the store's parameters; it
fails with the factory not passed.

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

* Name Milvus's cap on a record's undeclared properties

A record's undeclared properties are stored in one JSON field, and Milvus
refuses an upsert whose JSON exceeds its common.JSONMaxLength setting,
65,536 bytes by default (internal/proxy/validate_util.go and
pkg/util/paramtable/component_param.go at v2.6.24). The design document's
list of server-configured limits and the database docs now name it.

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

* Refuse a Milvus Lite URI by its .db suffix, as pymilvus selects Lite

The configuration refused a URI with no "://" as a Milvus Lite file. pymilvus
serves a URI ending in .db with Lite and passes a unix: URI to the server as
a socket address, so a file:// URI ending in .db passed the check and reached
Lite, and a unix: socket URI was refused as a Lite file. The check now refuses
a URI ending in .db, and its message names the Lite file.

The tests refuse ./milvus.db, milvus.db, and a file:// URI ending in .db, and
accept http, https, and unix: URIs; the file:// and unix: cases fail with the
"://" check.

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

* Write a declared Milvus datetime as its UTC instant

The entity builder wrote a declared datetime's text with its own offset,
while both filter literals convert to UTC first. Milvus refuses a
TIMESTAMPTZ string whose offset has a seconds component ("invalid timezone
name", code 1100 on 2.6.24), as local mean time and other historical
offsets have, so such a record could not be upserted. The builder now
writes the UTC instant; the offset field already keeps the offset written.

The new test upserts a datetime at +00:19:32, reads back its instant and
offset, and matches it by its instant; it fails with the original offset
written, at Milvus's refusal.

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

* Halve a Milvus upsert refused as too large, as the Qdrant store does

Milvus's proxy refuses a request over proxy.grpc.serverMaxRecvSize (64 MiB
unless configured) with gRPC's RESOURCE_EXHAUSTED status, before writing any
of it, and pymilvus raises that status as it is: on 2.6.24 a 72 MB upsert was
refused in 0.2 s with nothing stored. So a batch the Qdrant store accepts by
halving it failed outright on Milvus. The store now halves an upsert refused
with RESOURCE_EXHAUSTED until its halves fit or a single entity is refused.
Any other error raises at once, since a timed-out request may still be
applied.

The store imports grpc for the status, so the milvus extra declares grpcio:
1.59.0 is the first release with Python 3.12 wheels, and grpc.aio.AioRpcError
and grpc.StatusCode are older.

The integration test upserts 1,200 records with a 60,000-character property,
about 72 MB, and fails with RESOURCE_EXHAUSTED without the halving. The
mocked-client tests pin the halving, which its test fails without, a single
refused entity raising, and a timeout or another refusal sent once.

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

* Name Milvus's datetime offset fields for their unit

A declared datetime's UTC offset was stored in an INT32 field named
_tz_<key>, which left its unit to the code. The field is now
_tz_offset_seconds_<key>, under the prefix _TZ_OFFSET_SECONDS_FIELD_PREFIX;
its value is unchanged, the offset in seconds. The schema test and the two
datetime round-trip tests read the new names.

A property key is at most 32 bytes, so a declared property's field names are
at most 51 characters, within Milvus's proxy.maxNameLength (255 unless
configured). A new test declares a datetime under a 32-byte key, writes it,
reads both fields back, and matches it by its instant; the design document
states the bound.

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

* Keep a declared Milvus datetime as its UTC instant alone

A declared datetime was stored as its UTC instant in a TIMESTAMPTZ field and
its UTC offset in an INT32 field beside it. A filter compares only instants,
and a search answers with record UUIDs and scores, so nothing reads the
offset back; the store the vector records are derived from keeps it. The
offset fields go: every declared property, a datetime included, is one
field, `_p_<key>`, of those proxy.maxFieldNum allows. Undeclared properties
keep their type-tagged JSON, offset included.

The schema test asserts the native collection's exact field set, the
datetime round-trip tests check that the stored instant is the written one,
in UTC, and the longest-key test checks the one field.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
edwinyyyu and others added 7 commits October 9, 2026 16:03
* Create Qdrant collections with strict mode off

A filter may name a property that has no payload index, and the Qdrant
store expects the server to scan for it. Qdrant Cloud turns strict mode
on for new collections, with unindexed_filtering_retrieve false, so it
refuses such a filter with 400 "Index required but not found" and the
search fails (MemMachine#1776). The store now creates its collections with
strict_mode_config enabled=False, which overrides the server's default.

A collection created before this change keeps its strict mode; the
collection update API can turn it off.

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

* Set the Qdrant collection's strict mode in-line

The setting has one use, at collection creation, so it is written there
with its reason beside it instead of as a class constant.

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

* Test strict mode against a server that defaults it on

The Qdrant test container now starts with Qdrant Cloud's strict-mode
defaults for new collections, so the store's tests run where a
collection that leaves strict mode on refuses a filter on an unindexed
key. The strict-mode test checks that the server default is on, that
the store's collection reads back with it off, and that the collection
serves a filter on an unindexed property. It fails without the previous
commits.

The test against qdrant-client's in-process local mode is removed: local
mode neither records nor enforces strict mode, so it could only check
the argument passed to create_collection.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Two versions of one record in one batch are ambiguous, and the stores
disagreed on them: SQLiteVectorStore and Qdrant kept the last,
SQLiteVecVectorStore failed partway with a raw sqlite3 UNIQUE
constraint error, and Milvus refused the batch. Every store now raises
ValueError naming the UUID before anything is sent to the backend, from
one helper beside the other upsert input checks; the check costs one
pass over the batch. The VectorStoreCollection.upsert docstring says
so, and the registry-backed _upsert hook may rely on distinct UUIDs.

Each store's test upserts a batch repeating a UUID after another
record, expects the error, and finds nothing stored. It fails with the
check removed: SQLiteVectorStore and Qdrant raise nothing,
SQLiteVecVectorStore raises sqlite3's OperationalError, and Milvus
2.6.24 raises its MilvusException.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stores disagreed on nan, inf, and -inf as property values: Qdrant
over REST stores them as null, so the property reads back absent, and
over gRPC refuses them with a raw error; Milvus 2.6.24 refuses one in a
declared float field, takes one in an undeclared property, and cannot
parse a filter on it; and the SQLite stores store them. Every store now
raises ValueError naming the property key before anything is sent to
the backend, for declared and undeclared properties alike, as the
query path already refuses a non-finite query vector coordinate or
score threshold. The VectorStoreCollection.upsert docstring says so,
and the registry-backed _upsert hook may rely on finite values.

Each store's test upserts a finite record and one with a non-finite
value of each kind, on a declared and an undeclared property, expects
the error, and finds nothing stored. It fails with the check removed:
the SQLite stores, Qdrant over REST, and Milvus on an undeclared
property raise nothing; Qdrant over gRPC raises grpc's AioRpcError; and
Milvus on a declared property raises its MilvusException.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A filter compares datetime values by their instant, for equality and
ordering alike, and a store keeps a datetime property's UTC offset as
written, taking a naive datetime as UTC. Every store behaves so: the
SQLite stores keep the offset beside the UTC instant in their
type-tagged JSON, Qdrant keeps the RFC 3339 text with its offset in the
payload, and Milvus keeps a declared datetime's offset in a field beside
its TIMESTAMPTZ and an undeclared one's in its properties JSON. The
VectorStoreCollection docstring says so.

Each store's tests write a datetime at +05:30, declared and undeclared,
read its offset back from the backend, and filter on it at other
offsets: the same instant at -08:00 matches =, <=, and >=, a later
instant at an earlier wall-clock time matches <, and an earlier instant
at a later wall-clock time matches >. The read-back tests fail with the
offset dropped at write (the type-tagged JSON's offset written as 0,
Qdrant's payload converted to UTC, or Milvus's offset field written as
0). The filter tests fail with the filter node reading a value's
wall-clock time as UTC, and on the SQLite stores also with the
wall-clock text stored in place of the UTC instant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A vector store'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 VectorStoreCollection docstring now
says a store keeps a datetime property's instant, taking a naive datetime
as UTC, and may drop its UTC offset; a filter compares datetime values by
their instant, as before.

Qdrant's read-back test checks the stored instant, not its offset. The
tests of the type-tagged JSON, which keeps the offset (the SQLite stores and
Milvus's undeclared properties), are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The event backend wrote every property of an event into its vector record
and mapped the caller's whole filter onto the vector store, so a user key
that a deployment never declared was both stored and filtered there: on
Qdrant and Milvus as unindexed payload a filtered query scans for. The
segment store already holds every property and already receives the whole
filter for the context windows, so the vector side only duplicated work the
segment store does anyway.

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
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 did not keep the instant the collection's contract
promises. 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 read-back test checks that the payload holds the written instant in
UTC. The new filter test writes a datetime at -00:44:30, under a declared
key and an undeclared one, over REST and gRPC, and filters on its instant
with =, <, and >. Without the change, all 8 of their cases fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the integration/vector-store-final-state branch from 587dde0 to 3e82239 Compare October 10, 2026 00:31
edwinyyyu and others added 13 commits October 12, 2026 13:33
The vector store got the conjuncts of a filter's top-level AND that name
only declared fields, so an OR or a NOT with an undeclared field anywhere
under it was dropped whole, and the vector search ran wider than it had
to.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck) (MemMachine#1606)

* Regenerate the OpenAPI document under the locked FastAPI

`docs/openapi.json` predates the FastAPI release in `uv.lock`
(0.141.1), whose `ValidationError` component carries `input` and `ctx`;
regenerating the document with `docs/tools/generate_openapi.py` adds the
two fields and changes nothing else. Separate from the API changes above
it so their diffs of this file show only what they change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

* Remove per-project filterable properties

A project could declare `properties_schema`, a set of caller property keys
with types, on its long-term memory configuration; the event backend merged
it into the vector store collection's indexed schema and rejected filters on
any other `m.<key>`. That let a tenant create database resources (indexes,
columns) by naming them in a request, which is what forced per-collection
native resources named by a hash of their schema on the backends that limit
them.

The option is removed from the server configuration, the project API and
the memory-configuration API, the Python SDK, the sample configurations,
the configuration docs and the OpenAPI document. A filter may name any
`m.<key>`; the stores evaluate it on the properties they hold. What a store
indexes is decided by the deployment, not per project.

A breaking API change on `speedkick`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Rebased onto MemMachine#1631: the per-project schema also leaves MemMachine#1631's service
locator, which creates the session's collection in a retry loop, and the
commented option goes from the event sample configuration MemMachine#1698 added.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a8322a7)
…res' lookup to get_partition

A vector store's logical collection becomes a partition, the segment
store's word for the same thing, and both stores' lookup is get_partition,
answering None like a Python get. Identifiers only, produced by the script
below; the (namespace, name) identity, the per-partition config and every
docstring are as they were, and the next change gives them their meaning.
The native clients' create_collection and delete_collection keep their
names.

The vocabulary also reaches what sits under the rename: the registry
package, its modules, classes and tables, the `collection_registry` option
of a Qdrant or Milvus backend and the store parameter and attribute that
carry it (`partition_registry`), the stale-handle and pending errors, the
registry-backed base and its handle lookup, the purge method
(`purge_deleted_partitions`, the segment store's name for it) and
open-or-create.

```sh
set -e
cd "$(git rev-parse --show-toplevel)"
git mv packages/server/server_tests/memmachine_server/common/vector_store/in_memory_vector_store_collection.py \
       packages/server/server_tests/memmachine_server/common/vector_store/in_memory_vector_store_partition.py
git mv packages/server/server_tests/memmachine_server/common/vector_store/collection_lifecycle_contract.py \
       packages/server/server_tests/memmachine_server/common/vector_store/partition_lifecycle_contract.py
git mv packages/server/src/memmachine_server/common/vector_store/collection_registry \
       packages/server/src/memmachine_server/common/vector_store/partition_registry
git mv packages/server/src/memmachine_server/common/vector_store/partition_registry/collection_registry.py \
       packages/server/src/memmachine_server/common/vector_store/partition_registry/partition_registry.py
git mv packages/server/src/memmachine_server/common/vector_store/partition_registry/sqlalchemy_collection_registry.py \
       packages/server/src/memmachine_server/common/vector_store/partition_registry/sqlalchemy_partition_registry.py
git mv packages/server/server_tests/memmachine_server/common/vector_store/collection_registry \
       packages/server/server_tests/memmachine_server/common/vector_store/partition_registry
git mv packages/server/server_tests/memmachine_server/common/vector_store/partition_registry/test_sqlalchemy_collection_registry.py \
       packages/server/server_tests/memmachine_server/common/vector_store/partition_registry/test_sqlalchemy_partition_registry.py
git ls-files -z 'packages/server/*.py' 'docs/*.mdx' 'sample_configs/*.sample' 'sample_configs/*.yml' 'deployments/helm/templates/*.yaml' | xargs -0 perl -0pi -e '
  s/VectorStoreCollection(?!Config)/VectorStorePartition/g;
  s/in_memory_vector_store_collection/in_memory_vector_store_partition/g;
  s/collection_lifecycle_contract/partition_lifecycle_contract/g;
  s/CollectionLifecycleContract/PartitionLifecycleContract/g;
  s/collection_registry/partition_registry/g;
  s/SQLAlchemyCollectionRegistry/SQLAlchemyPartitionRegistry/g;
  s/CollectionRegistry/PartitionRegistry/g;
  s/RegisteredCollection/RegisteredPartition/g;
  s/get_registered_collection/get_registered_partition/g;
  s/_build_collection_handle/_build_partition_handle/g;
  s/\bCollectionT\b/PartitionT/g;
  s/register_collection\b/register_partition/g;
  s/\b_Collection\b/_Partition/g;
  s/CollectionRow/PartitionRow/g;
  s/vector_store_collection(?!_schema|_namespace)/vector_store_partition/g;
  s/open_or_create_collection/open_or_create_partition/g;
  s/open_collection/get_partition/g;
  s/_purge_deleted_collections_forever/_purge_deleted_vector_store_partitions_forever/g;
  s/purge_deleted_collections/purge_deleted_partitions/g;
  s/def create_collection\(/def create_partition(/g;
  s/def delete_collection\(/def delete_partition(/g;
  s/\.create_collection\((\s*namespace=)/.create_partition($1/g;
  s/\.delete_collection\((\s*namespace=)/.delete_partition($1/g;
  s/\.create_collection(?=\s*=\s*AsyncMock|\.assert_|\.side_effect|\.await_count)/.create_partition/g;
  s/\.delete_collection(?=\s*=\s*AsyncMock|\.assert_|\.side_effect|\.await_count)/.delete_partition/g;
  s/"create_collection"/"create_partition"/g;
  s/"delete_collection"/"delete_partition"/g;
  s/"open_or_create_collection"/"open_or_create_partition"/g;
  s/only delete_collection is invoked/only delete_partition is invoked/g;
  s/test_delete_collection_/test_delete_partition_/g;
  s/open_partition/get_partition/g;
  s/(vector_store_partition \(VectorStorePartition\):\n\s+)Vector store collection\./$1Vector store partition./g;
'
uv run ruff check --fix --quiet packages/server
uv run ruff format --quiet packages/server
```

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
…uilt by the composition root

A vector store was a factory of logical collections, each identified by
a (namespace, name) pair and created with its own dimensions, metric and
schema; Qdrant and Milvus shared one native collection among logical
collections of equal configuration, under a name derived from a hash of
that configuration, and the registry mapped (namespace, name) to it.

A store is now one collection: `VectorStore(vector_store_name,
vector_dimensions, similarity_metric, indexed_properties)` names its one
native collection (or its tables and index files) at construction, every
partition of it shares the store's dimensions, metric and schema, and
`provision()` creates the store's durable resources idempotently, before
`startup`. `create_partition(key)`, `open_or_create_partition(key)`,
`get_partition(key)` and `delete_partition(key)` take a string key; a
partition is the records carrying its incarnation in the store's native
collection (Qdrant, Milvus) or a pair of tables (the SQLite stores), and
the registry records what each partition was created under, so a store
built with other dimensions, another metric or another schema raises
`VectorStorePartitionSchemaMismatchError` instead of reading columns and
vectors that are not there. "Collection" names only the native Qdrant or
Milvus collection. Vector store names match `[a-z0-9_]+` and are at most
32 bytes, the rule partition and property keys follow, so every name
works on every backend: the Qdrant store names its collection by the
vector store name, and the Milvus store by `sys_` followed by it, since
Milvus requires a leading letter or underscore and names its own
internals with a leading underscore. The hash-derived native names go,
and with them the per-partition config.

The partition registry is keyed by partition key within one store: its
tables, `partition_registry_pt` and `partition_registry_gc`, are shared
by every store on a database and keyed by vector store name, so the
registries of several stores share one database and nothing else.
`mark_live` and `unregister_incarnation` take the incarnation alone. On
the registry-backed base, the storage a store's partitions share is
prepared by `provision()` (`_prepare_storage()`), and the storage a
partition keeps of its own by `_prepare_partition_storage(key,
incarnation)`, between its registration and its mark; the Qdrant and
Milvus stores keep nothing per partition. A purge round works on one
incarnation in the store's own collection.

`DatabaseManager.get_vector_store(backend, vector_store_name=,
vector_dimensions=, similarity_metric=, indexed_properties=)` builds and
caches one store per (backend, vector store name), provisioning its
registry and then the store; asking for a name again with other
dimensions, another metric or other keys is a
`VectorStoreConfigurationError`. The event backend's store is named by a
UUIDv5 of its embedder id in the UUIDv5 of its backend's key in a namespace
fixed for the event backend, written as 32 hexadecimal digits, so neither
the key nor the id is constrained; stores on different backends never share
a name, so their registries stay apart when the backends share a registry
database, since a store name identifies one store among all the stores
whose registries share it.
Semantic memory's one store is named `semantic_memory` whatever its
embedder and holds every org's features in one partition,
`semantic_memory`, as main's one collection does.
The event backend opens a session's partition with
`open_or_create_partition`, which waits on one another worker is still
creating.

The SQLite stores change shape only: their registry tables become
`vector_store_sqlite_pt` and `vector_store_sqlite_vec_pt`, keyed by
vector store name and partition key, the pending-operation log is keyed
the same way, and per-partition table names embed the vector store name.
No migration is provided; existing SQLite vector data is orphaned.

Rebuilt as one change on MemMachine#1631: this PR's earlier history carried copies
of MemMachine#1631's commits as of `38684b385`, its own commits on them, and
mirrors of MemMachine#1631's later commits in its terms. Its code is that history's
final tree, which was verified, merged with main and without MemMachine#1624's
commits. The design documents are still MemMachine#1631's and describe collections.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
…up, not a separate provision

A vector store and its partition registry each had a provision() that
created their durable resources, run by the composition root before
startup(). The split between provisioning a store and starting it is
MemMachine#1570's to make for every store at once, so here startup() does both
again, as on main: a store's startup prepares the storage its
partitions share (the native collection and its indexes, or the SQLite
tables), and the registry's startup creates its tables, as MemMachine#1631's does.
The database manager starts the registry, then the store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
…er, live from resolve

The registry is addressed by partition key only. `register(key, schema)`
answers a PendingRegistration, whose `mark_live()` answers the same life
as a LiveRegistration or raises VectorStorePartitionDeletedError when the
partition was deleted meanwhile, and whose `unregister()` abandons that
life alone. `resolve(key)` answers the LiveRegistration of the live
partition, None when there is none, and raises
VectorStorePartitionPendingError, which now carries the pending
partition's schema, when it is pending. A LiveRegistration's
`require_current()` raises VectorStorePartitionHandleStaleError once its
partition is deleted. `get`, `mark_live(incarnation)`,
`unregister_incarnation` and RegisteredPartition are gone, so deletion is
by key or by a pending registration, never through a live one. The
SQLAlchemy registry supplies frozen-dataclass registrations holding the
engine and the vector store name, and a module function runs the
unregistration transaction.

The base handle takes its live registration, fences on
`require_current()`, and `_partition_handle` builds a handle from one.
`create_partition` lets VectorStorePartitionDeletedError propagate, and
the VectorStore interface names it; open-or-create catches it and creates
again, refuses a pending partition of another schema at once from the
pending error's schema, and re-raises the last pending error. A
`get_partition` of a pending partition of another schema still reports the
mismatch first. The Qdrant and Milvus handles take the registration in
place of the key, the incarnation and the registry lookup.

Tests follow: the registry's tests answer registrations and gain one for
`require_current`; the base's tests patch the pending registration type's
`unregister` and expect the deleted error from a creation a deletion
undid; the lifecycle contract patches the live registration type's
`require_current`, registers its racing winners through pending
registrations, and counts the deleted error among churn's outcomes; the
Qdrant and Milvus tests that build a handle on a mocked client give it a
registration that stays current.

The same change as MemMachine#1734's 735c649, f88aaa8, e25be60 and
b2bb2d8 and the handle halves of MemMachine#1735's 9096edd and MemMachine#1736's
5582cf4, for this PR's partition registry. The event-backend locator
change has no counterpart: the locator here opens its partition with
open-or-create, which creates again itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu and others added 28 commits October 13, 2026 17:04
MemMachine#1736's f56686f halves a Milvus upsert refused with RESOURCE_EXHAUSTED,
and this PR's base carries the halving into the partition handle's
`_upsert`. Its tests come here in partition terms: the integration test
upserts 1,200 records with a 60,000-character property, about 72 MB, and
fails with RESOURCE_EXHAUSTED without the halving; the mocked-client tests
pin the halving, a single refused entity raising, and a timeout or another
refusal sent once, on a handle `_partition_on` builds, which the mocked
delete test now uses too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
…nt alone

MemMachine#1736's 49b9ec2 drops the offset field beside a declared Milvus
datetime, keeping its UTC instant alone, and this PR's base carries that
into the store. The tests follow in partition terms: the native
collection's fields are exactly the fixed ones and one per declared
property; a declared datetime is stored as its instant in UTC; and a store
whose declared datetime has the longest property key, 32 bytes, writes its
one field, reads it back, and matches the record by its instant, the test
MemMachine#1736's b015229 adds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
The test of MemMachine#1813, merged into this PR's base, in partition terms: the
server defaults new collections to strict mode, the store's collection
reads back with it off, and a filter on a partition's unindexed property
is served.

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

The event backend created a session's vector store collection and segment
store partition on the first request that opened the session, so a search
or a write for an unknown session created storage as a side effect, and
the service locator was the only place that knew both stores' create paths.

The owner is the session. Every path that creates a session row runs
through EpisodicMemoryManager._create_session, which inserts the row and,
when the row is new, creates the session's partitions in its segment store
and its vector store (create_episodic_memory_storage); an equivalent
re-create accepts the row and leaves the storage as it is. The request
path binds handles with the stores' lookups and raises
SessionPartitionMissingError when a partition is absent: a session without
its storage is broken, not new. Deleting a session with no open instance
deletes its partitions by key, so a session whose storage was never fully
created can still be deleted. MemMachine.create_session goes through the
manager for the same reason.

The semantic manager owns its one collection and creates it, once, at the
storage's first use. With that, nothing calls the stores' open-or-create.

The API is unchanged: the manager's open-or-create still creates a session
a memory request names, now through the same path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
Nothing calls them since a session's storage is created with the session:
`open_or_create_partition` leaves the vector store interface, its
registry-backed base and the two SQLite stores; `open_or_create_partition`
and `close_partition` leave the segment store interface and its
implementation, and SegmentStorePartitionConfigMismatchError, which only
the segment store's open-or-create raised, goes with them. A store creates
on `create_partition`, strictly, and looks up on `get_partition`, answering
None; create-if-absent is the owner's, where the key's provenance is known.
With open-or-create go its bounded wait on a partition another caller is
still creating, where `get_partition` raises VectorStorePartitionPendingError
for one, as before, and its retry after a creation a deletion undid, which
`create_partition` reports with VectorStorePartitionDeletedError.

Source changes are deletions only. The tests that exercised open-or-create
as a fixture use a test-side create-if-absent instead, and the tests of its
own semantics go; the refusal of a pending partition of another schema is
tested through `get_partition`, which refuses it the same way. The
partition registry design document drops open-or-create too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
A partition stored every property of a record and filtered on any key,
which made a caller's arbitrary keys part of the store's schema: the
SQLite stores kept them in a JSON column and filtered with json_extract,
Milvus the keys its schema did not declare in a JSON field, and a filter
on a key the store never indexed scanned. Since EventMemory routes a
filter on an undeclared key to the segment store, the vector store need
not hold undeclared keys at all.

A partition now stores the properties its store declares and no others.
`upsert` raises UndeclaredPropertyKeyError before anything is sent for a
record naming an undeclared key, and PropertyTypeMismatchError for a
value of another type than its key declares; `query` raises
UndeclaredPropertyKeyError for a filter naming an undeclared key and
UnsupportedFilterError for a node outside the partition's
`supported_filter_nodes`. Both SQLite stores keep one typed, indexed,
nullable column per declared key on the records table (sql_columns.py);
sqlite-vec 0.1.9 rejects NULL in a vec0 metadata column and a declared
key is optional per record, so that store keeps the columns on the
records table and hands the KNN a `rowid IN (SELECT ...)` allowlist,
evaluating the filter during the search instead of after it. Qdrant
drops the JSON copy and keeps a payload field per declared key; Milvus
drops its JSON field and keeps its typed field per declared key.
Datetimes are stored as microseconds since the epoch where a backend has
no datetime type.

Since every key a filter may name is now indexed, the Qdrant store
creates its collection in strict mode (`unindexed_filtering_retrieve`
and `_update` false, Qdrant Cloud's default): a filter on an unindexed key is refused by the server instead
of scanned for. A leaf whose value is of another type than its key
declares matches nothing, as on the SQL stores; the Qdrant compiler
answers it with a filter no point satisfies, since the server would
refuse the condition for the field's index, and Milvus no longer
compares an int with a float key. Local mode does not record the
setting, so a unit test checks the request and integration tests the
server's answer.

`declared_schema_contract.py` states the contract every backend's test
module runs: which records a filtered search admits, over fixtures small
enough that every backend searches them exactly, checked after each
upsert so an approximate index fails on recall, by name, and not on the
filter.

On the registry-backed stores, the checks are the base handle's: `upsert`
runs require_declared_properties in place of the type check it ran, and
`query` runs require_supported_filter against the subclass's
`supported_filter_nodes`, which each subclass now implements. A datetime
column on the SQLite stores has a `tz_<key>` column beside it holding the
UTC offset in seconds, written with the value and read by no filter, so a
stored datetime is the value written, as on Milvus, Qdrant and the segment
store; the microseconds column alone would keep only the instant.

The Qdrant and Milvus design documents describe the declared-only
properties and Qdrant's strict mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
A declared datetime on the SQLite stores had a `tz_<key>` column beside its
microseconds column, holding the UTC offset in seconds that no filter
reads. The vector stores keep a datetime property's instant only, as
MemMachine#1736 now does for Milvus and MemMachine#1788 for Qdrant, and the segment store
keeps the offset, so the column, `offset_column_name`, and the value
written to it go: each declared property is one column, and a datetime
stays microseconds since the epoch.

The tests read a stored datetime back as its instant in UTC, and the
roundtrip test checks that the stored instant equals the written one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
Every embedder MemMachine ships produces vectors meant to be compared by
cosine -- OpenAI hard-coded it, Bedrock defaulted to it, SentenceTransformer
only reported what the model declared -- while every layer that touched a
score paid for the other three metrics in direction flags, threshold
directions, and per-backend tables mapping the enum onto native metric
names. `SimilarityMetric` is gone; scores are cosine similarities in
[-1, 1] and the names say so: `QueryMatch.score` and `SearchMatch.score`
become `cosine_similarity`, and `query(score_threshold=)` becomes
`query(min_cosine_similarity=)`, which no longer needs a direction to be
meaningful and is refused when not finite, as the threshold was. A store
and its partitions no longer have a `similarity_metric`, the schema a
partition is registered under no longer records one, and a search engine
factory takes the dimensions alone. The Bedrock embedder's
`similarity_metric` config key and semantic memory's
`vector_similarity_metric` go with it, and the install and configuration
docs drop them.

The vector graph stores carried a metric per stored embedding, as a
companion property beside every vector; that is gone and `Node.embeddings`
holds plain vectors. NebulaGraph's `cosine()` cannot take `APPROXIMATE` and
its vector indexes offer only L2 and IP. Cosine similarity between unit
vectors is their inner product, so embeddings are normalized on the way in
and compared with `inner_product()` against an IP index.

The cosine half of MemMachine#1598 (`abf92a3a4` on speedkick), re-derived on the
one-collection store: the rest of MemMachine#1598 (queries answer record UUIDs and
scores, no `get`, semantic memory's `vector_uuid`) and all of MemMachine#1603 are in
registry-backed base and its partition handle lose the metric, the stores'
own threshold checks become `require_valid_min_cosine_similarity`, and the
design documents describe cosine scoring and a schema without a metric.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
…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/sql_columns.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 `require_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 a node outside the store's
`supported_filter_nodes`, 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 `_require_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 `require_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 `_require_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 `_require_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
…emMachine#1588)

* Atomically swap vector search engine index files on save

SQLiteVectorStore persists each collection's index by calling the search
engine's save(), which wrote directly to the final path. A crash mid-write
left a truncated/corrupt file. Because index_saved=True makes the on-disk
index a durable contract (missing/corrupt is a hard IndexLoadError, not a
silent empty rebuild), an interrupted save could render a collection
unrecoverable.

Write the index to a sibling temp file and swap it into place with
os.replace (atomic on POSIX and Windows on the same filesystem), so a reader
sees either the old or new index, never a partial write; a failed save leaves
the previous index intact. Leftover temp files are cleared on load so a crash
does not leak them across restarts.

Implemented in the engines (shared index_persistence helper) rather than in
SQLiteVectorStore/SQLiteVectorStoreCollection, since the index save location
and number of files written differ across engine implementations.

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

* Make the index swap durable, not only atomic

The swap protects a reader from a torn index, but the vector store also
trims its pending-operation log once `save` returns -- and that log is the
only other copy of those vectors, since the records table stores no vector
column. So the swap reaching disk is load-bearing rather than a bonus:

- fsync the parent directory after the replace, since POSIX `rename(2)`
  leaves the new directory entry in the page cache. Best-effort and ignored
  on failure, matching SQLite's `unixSync`; a no-op on Windows, which has no
  equivalent operation.
- stop swallowing a failed fsync of the temp file. SQLite draws the same
  line -- a file fsync failure raises SQLITE_IOERR_FSYNC while a directory
  fsync failure is ignored -- and `EIO` means the writeback already failed
  and the dirty pages were dropped, which is exactly when the save must not
  be reported as committed. The existing cleanup then leaves the previous
  index in place with the log untrimmed, so the next save retries.
- use F_FULLFSYNC on macOS, where plain `fsync` leaves the data in the
  drive's volatile write cache, falling back when a filesystem refuses it.

State the resulting obligation on `VectorSearchEngine.save` itself, since
that is what the store now relies on: replace atomically, then make the
replacement as durable as the platform allows. An engine whose backend
already implements the whole protocol can delegate to it and skip these
helpers; the rest use `atomic_index_write`.

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

* Let the engine own index durability, not the vector store

The pending log holds the only durable copy of a vector between
checkpoints -- the records table has no vector column -- so trimming it is
safe only at an instant when the index provably holds those vectors. The
temp-write + rename protocol this PR shipped could not provide that
instant. A rename changes a directory entry, and Windows exposes no way
to flush one: os.fsync is _commit, which is FlushFileBuffers, which is
for file data, and you cannot open a directory to fsync it. The decisive
evidence is SQLite's own -- it threads a directory-sync flag through
every commit-relevant directory operation, honors it in unixDelete, and
declares it /* Not used on win32 */ in winDelete. So os.replace could
return, _save_collection_index could commit its trim durably behind it,
and a power cut could still roll the rename back: records forward, index
back, no copy of the difference left. MOVEFILE_WRITE_THROUGH is not a
fix; its documented guarantee covers copy-and-delete (cross-volume)
moves, not same-volume renames.

Take SQLite's answer, which was not to harden the directory operation
but to stop using one as a commit point (PERSIST commits by zeroing a
header, TRUNCATE by truncating, WAL by appending frames).

A base path now expands into two index slots plus a generation record
each, created once and thereafter only overwritten. A checkpoint writes
the index over the inactive slot and flushes it, then writes that slot's
generation record and flushes that. The record is the commit, and it is
a write into a file that already exists. It holds the generation and its
bitwise complement, so a torn write reads as absent rather than as some
other generation -- all or nothing without needing single-sector
atomicity from the hardware. load takes the highest believable
generation, and deliberately does not fall back to the older slot when
the published index will not parse: the log was trimmed against the
newer one, so the older is stale by exactly the ops that can no longer
be replayed.

Both backends already write straight to the path they are given, which
is what this protocol wants -- verified that repeated saves preserve the
inode and leave no stray files -- so no engine gains a temp file, a
buffer, or a rename.

Durability is entirely the engine's, including which artifact is live.
The store keeps no slot pointer, manifest, or generation, so no schema
change and no migration: what remains is one rule, never trim past what
save says is durable, and _save_collection_index already had that order.
index_path becomes index_base_path since it no longer names a file, and
discarding a collection asks the engine layer which files that covers.

BREAKING CHANGE: an index written by the previous protocol is not
published under the new one, so a collection with index_saved=True
raises IndexLoadError until its index directory is cleared and the
records re-ingested.

Anomaly tests walk every crash point in the publish sequence by
constructing the on-disk state each would leave, plus one that pins the
ordering itself (a failed index write must publish nothing) since
state-based tests cannot observe it. Verified against three deliberate
breaks -- dropping the complement check, writing the record first, and
reusing one slot instead of alternating -- each caught.

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

* Publish the index atomically, and stop promising durability

The two-slot generation-record protocol bought a guarantee we have
decided not to make: that a save survives a power failure. Every engine
would have to implement and maintain that protocol, and the failure it
buys out is bounded -- search recall for the records applied since the
last checkpoint, repaired by re-ingesting them. The direction that
actually costs, a published index that will not parse, is closed by the
atomic swap on its own.

So this returns to the temp-file-plus-rename publication and spends the
difference on stating the contract instead of strengthening it: `save`
publishes atomically, never durably; the store trims the pending log
behind a publication a power failure can revert; a record whose vector
is lost that way still resolves by uuid, is absent from search until it
is upserted again, and nothing here detects the gap for the caller.

Reverts the durability and engine-owned-publication commits, keeps the
atomic swap, and adds a store-level test that reconstructs a reverted
publication deterministically -- restore the previous index bytes after
the trim -- to pin the direction it fails in.

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

* Report a lost embedding as lost, not as a missing feature

`update_feature` reads the stored embedding back when a caller updates a
feature without supplying one, and that is the only place in the server
that depends on the index still holding a vector. With publication now
atomic rather than durable, a power failure can leave a feature whose
row is intact and whose vector is not -- a state this path reported as
"Vector record not found", which points the caller at the wrong thing
and hides the repair.

Split the two cases. A record that is genuinely absent keeps the old
message; a record whose embedding the index no longer holds says so and
names the fix, which is to pass a fresh embedding.

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

* Let a failed fsync fail the save, and cut the essay around it

`_flush_to_disk` wrapped its fsync in `contextlib.suppress(OSError)` and
called itself best-effort. A failed fsync is exactly the evidence that the
bytes are not safe to publish -- on Linux an EIO from fsync means writeback
failed, reported once and then cleared -- so swallowing it and renaming
anyway published a file we had positive evidence was bad. The safeguard
cost something and, in the one case it existed for, guaranteed nothing.
Nothing tested it either.

Let it propagate. `atomic_index_write` already unlinks the temp and
re-raises, so a failed flush now leaves the previously published index
standing, which is the correct outcome. A test pins that.

The fsync is not best-effort, and the docstring should not have said so: it
rules out a class rather than narrowing a window. Because the flush
completes before the rename is issued, and a durable write does not
un-happen, the new name can never appear over incomplete bytes. What the
missing directory fsync costs is the other direction -- the rename may not
survive, so the publish reverts -- and that is the benign one this store
already accepts.

The module docstring was 76 lines against 49 of everything else, most of it
argument rather than documentation: a walk through SQLite's `unixDelete` /
`winDelete` sync-flag handling, and a rejected two-slot commit protocol.
That is the PR's case for the design, not something to re-read every time
someone opens a 20-line module, and the PR body carries it. What a reader
here needs is the guarantee, the non-guarantee, and the cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

* Say why the swap is a rename, and what the reopened fd cannot see

Two things the module was silent on.

Why a rename at all. The stronger answer is to put the commit point inside
the file, where an fsync reaches it portably -- SQLite never renames, and
commits by truncating or zeroing its rollback journal, or in WAL mode by
appending frames whose checksums make a torn tail self-identifying. Both
need the writer to own the file format. A search engine owns its own and
exposes `save(path)`, so above that call a rename is the only atomicity
primitive left, and an engine whose format already commits that way needs
none of this. Worth saying, because "why not do the better thing" is the
first question the module invites.

What the reopened descriptor cannot see. Flushing is fine on a fresh fd --
dirty pages belong to the file, not to the descriptor that dirtied them --
but error reporting is not: Linux hands a writeback error to descriptors
open when it was recorded, so one recorded between the engine's close and
this open is never reported and the save proceeds on bytes already known
bad. Same shape as the 2018 PostgreSQL fsync report. It cannot be closed
from here: the engine writes through its own descriptor and closes it
before returning, and closing the window needs an engine that writes
through a handle the caller supplies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

* Fsync the index on a descriptor that predates the write

The fsync was on a descriptor opened after the engine had written and
closed its own, which flushes correctly -- dirty pages belong to the file,
not to the descriptor that dirtied them -- but reports nothing useful.
Linux samples the writeback error sequence when a file is opened, so a
descriptor opened after an error was recorded never learns of it: the fsync
returns success and the save publishes bytes already known bad. Same shape
as the 2018 PostgreSQL fsync report.

Open the temp before yielding it and hold it across the caller's write, so
the descriptor predates the bytes and any error from writing them is
reported here, where it fails the save.

That assumes the caller writes in place. Both engines do -- verified: the
inode is unchanged across `save_index` and `save`, and the held descriptor
sees the written size -- but it is their behaviour, not their contract. An
engine that built a file of its own and renamed it over the temp would
leave this descriptor on an orphaned inode, and the fsync would report on a
file nobody is about to publish. So it is checked before the fsync, and a
mismatch fails the save rather than passing it quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

* State the in-place rule where the caller reads it

The descriptor held across the body only flushes what the body wrote if the
body writes the yielded path in place, and that requirement was recorded in
`_flush_to_disk` -- a private function nobody writing an engine opens. It
belongs on `atomic_index_write`, which is the API they use, alongside what
happens when it is broken: an `OSError` and no publication, so the mistake
surfaces at the first save rather than at a power cut.

`_flush_to_disk` keeps the mechanism -- why the descriptor has to predate
the write -- and now points at the rule instead of restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

* Ask the drive to flush on macOS, where fsync does not

`os.fsync` is not the same guarantee on all three platforms this ships to.
On Linux it flushes to the device, and on Windows `FlushFileBuffers` does
the same. Darwin's `fsync` explicitly does not: it returns once the data
reaches the drive, which may hold it in a volatile write cache. So on macOS
the ordering this module is built on -- data durable before the rename is
issued -- did not hold at the device, which is exactly the case it claims
to rule out.

`F_FULLFSYNC` asks the drive to flush that cache. Filesystems that cannot
refuse it, and there `fsync` is the most that can be asked, so a refusal
falls back; any other error is a write failure and propagates, as before.

The flush-failure test patched `os.fsync`, which Darwin no longer reaches.
It patches the module's own `_fsync` instead, which every platform does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

* Stop guessing which errnos mean "this drive cannot do that"

The fallback from `F_FULLFSYNC` to `fsync` was gated on an errno allowlist
-- ENOTSUP, EOPNOTSUPP, EINVAL -- so that a genuine write failure would
propagate rather than be quietly downgraded. Probing the actual returns on
Darwin shows the list is both incomplete and partly invented:

  ENOTSUP=45  EOPNOTSUPP=102   distinct here, so both are needed
  /dev/null   F_FULLFSYNC -> ENODEV(19), while fsync succeeds
  pipe/socket F_FULLFSYNC -> EBADF(9)
  EINVAL      never came from F_FULLFSYNC at all; it came from fsync

So ENODEV -- a real refusal, on a path anyone can reproduce -- would have
raised instead of falling back, and EINVAL was in the list by analogy
rather than evidence. What a network mount answers is not knowable from
here, which makes the whole list a guess that fails closed on whatever it
missed. This codebase does not classify driver errors by guessing, and
this was that.

Fall through on any failure instead. It is not a suppression: `fsync` runs
on the same descriptor and raises in its turn, so a flush that cannot
happen still fails the save. What the fallback gives up is the drive-cache
flush -- the guarantee this had before `F_FULLFSYNC` was asked for at all.
That is also what SQLite does with this same call, for the same reason.

The test drives it through `/dev/null`, which refuses with ENODEV and
accepts `fsync`; it fails against the allowlist and passes without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

* Fail the save when the drive cannot be told to flush

`F_FULLFSYNC` failing fell through to `fsync`, on the reasoning that a
refusal is a statement about the filesystem rather than a write failure.
That reasoning does not survive asking what `fsync` alone actually buys on
Darwin.

Against a process or kernel crash it is enough: the data has left the OS
for the drive before the rename is issued, so the rename dies in the page
cache and the old index stands. Against power loss it is not. The data
sits in the drive's volatile cache, the rename's metadata joins it moments
later, and nothing orders them -- and the rename is a few bytes against an
index of megabytes, so a drive flushing as it pleases can easily put the
new name on media while the bytes behind it are still queued. That is the
torn publication this module exists to prevent, in precisely the scenario
its docstring is about.

So the fallback answered a request for ordering with a flush that does not
provide it, and said nothing. A filesystem that cannot order data ahead of
a rename is not one to publish an index onto; raise, and let the operator
point `index_directory` at storage that can.

This is also the simpler code. Refusal and failure now take the same path,
so no errno is inspected -- there is no line to draw and no list to get
wrong, which is what the previous two revisions kept getting wrong in
opposite directions. The `/dev/null` test went with the fallback it pinned.

The earlier defence of falling back rested on network and FUSE mounts
being a realistic home for an index directory. They are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKmF9xNph9QH3ozNw3ZnJL

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 21105d6)
Never reuse a row id in SQLiteVectorStore

The records table used a plain INTEGER PRIMARY KEY, which is SQLite's
rowid, assigned as max(rowid) + 1: deleting the highest row freed its id
for the very next insert. query() scores keys in the search engine and
resolves them to rows in a second step, holding nothing in between, so a
reused id let a record that was never scored come back wearing the score
of the record that was. Nothing about that result looks wrong: the record
exists and the score is in range.

Declare the table with sqlite_autoincrement=True so ids are never reused.
A stale engine key then matches no row and is dropped.

Both tests fail without the flag: one pins the id policy directly, the
other parks a query between scoring and row lookup, retires the scored
record, inserts another, and asserts the query returns nothing.

First half of MemMachine#1468.

Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…(speedkick) (MemMachine#1612)

Own the search engine's concurrency in the store, not in each engine

Each engine wrapped its five methods in its own read-write lock and the
abstract class promised concurrent use. The store is the only caller,
and it already knows which calls must exclude which: a search shares the
engine with other searches, and everything else runs alone. Holding that
lock in the store puts every exclusion in the one file that reads the
engine, and lets a rule the engines could not express hold: a rewrite's
remove and add are one step to a reader, where before a search could run
between them and see neither version.

Engines drop their lock and keep only the index calls; the abstract
class now states that the owner serializes. The store keeps one
read-write lock per collection beside the engine, kept for the store's
lifetime like the engine's other per-collection state, and takes it at
every engine call: searches on the read side; mutations, loads, and the
index save on the write side. A rewrite's remove and add sit under one
hold. The save's trim runs after the lock is released, so readers wait
for the file write and never for SQL.

The row-id regression test from MemMachine#1589 parked inside a wrapper engine's
search, outside the real engine's lock; under the store's lock that
parks the read side, and the writes it then awaits cannot proceed. It
now parks where it meant to, between the engine search and the row
lookup. One new test pins the one-step rewrite; it fails against the
engines' own locks.

The turbovec engine in flight carries the same lock and needs the same
subtraction.

Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…edkick) (MemMachine#1607)

Serialize a collection's writes so the engine sees them in order

A write commits to SQLite and only then applies to the search engine, so
two writers to one uuid could reach the engine in the opposite order to
the one they committed in: an upsert overtaking a delete re-adds a vector
for a record that is gone, and two upserts inverting leave the engine
serving the older vector. Never reusing a row id does not cover this,
because an upsert of an existing uuid keeps its row id.

A save in that window costs a write outright: it publishes the index and
trims every applied log row, and a write that applied after the index was
written is then in neither.

A per-collection asyncio.Lock now spans a write from SQL commit through
engine apply, mark-applied, and any save it triggers; shutdown's save
takes it too. The lock belongs to the store, not to a collection handle:
a handle is constructed per open_collection call, so several can address
one collection, and only a shared lock serializes them. Readers are
untouched.

Three tests fail without the lock, each interleaving made deterministic
by gating the engine: an upsert overtaking a delete of its uuid, a save
trimming a write it did not publish, and that overtake across two
handles, which a per-handle lock passes. The rest pin behavior the lock
must preserve: an upsert surviving a delete of another record, disjoint
concurrent upserts and deletes, writes racing a checkpoint, and batches
that name one uuid twice.

Fixes MemMachine#1468.

Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…edkick) (MemMachine#1608)

Refuse a pending row replay cannot honor, instead of dropping it

Replay matched `operation_type == "upsert" and vector is not None` and
let everything else fall through. An upsert row with no vector, a vector
that is not a whole number of float32s, or an unknown operation_type was
skipped, and a decodable vector of the wrong width reached the engine,
which refused it with its own error at startup. The skipped rows were
the worse case: and because the skipped row reached neither the engine remove
set nor the mark-applied update it survived the restart to be skipped
again on the next one. Between a write returning and the next index save
the log holds the only copy of the vector, so the outcome was a record
that exists in SQLite and can never be found by search.

That is damage to a durable record, not a state to heal. Replay now
raises PendingOperationCorruptError for all four, naming the collection,
the row and the fault, and leaves the log intact for whoever repairs it. Both error
types' docstrings now say what a caller should do: read the cause of an
IndexLoadError before choosing a remedy, and never clear the log to get
past a PendingOperationCorruptError.

Four tests corrupt a log row each way and assert the restart refuses.
The rest of TestPendingLogStates pins what replay guarantees for intact
rows: a rewritten uuid replays its last write, an upsert then delete
stays deleted, a failed save leaves the write replayable, and the save
threshold counts log rows rather than writes.

Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…tization

`QdrantConf` gains `hnsw_config`, `optimizers_config` and
`quantization_config`, plain mappings mirroring qdrant-client's
`HnswConfigDiff`, `OptimizersConfigDiff` and `QuantizationConfig`, so
qdrant-client stays optional for configuration parsing; the store's
params validate them against qdrant's own models. They apply to the
store's one collection, created at startup.

`m` must be 0 or unset: the collection is multi-tenant and disables the
global graph in favor of per-partition payload indexing, so a deployment
tunes `payload_m`, which defaults to 16 as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
The sample configurations gain a commented block with the three keys,
checked against qdrant-client's models.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
…MemMachine#1609)

Take SQLite's write lock at BEGIN, not at the first write

`delete` resolves row ids and then writes, in one transaction. Under
SQLite's default deferred BEGIN the write lock is taken at the first
write, so another writer can take it between that read and the write,
and the transaction that read first is the one that loses: its own write
cannot upgrade, and SQLite reports that immediately rather than waiting
out busy_timeout, because waiting could only deadlock. Reproduced in both
journal modes:

    journal_mode  BEGIN      other writer  our write
    delete        deferred   shut out      fails
    delete        IMMEDIATE  shut out      ok
    wal           deferred   commits       fails
    wal           IMMEDIATE  shut out      ok

The store now emits BEGIN explicitly and lets a transaction ask for
BEGIN IMMEDIATE, which every write path does. The mode is chosen per
transaction, not per engine: a hook that asked for IMMEDIATE
unconditionally would make every read take the write lock, and two
readers would then serialize against each other.

Two tests fail without it. One holds a competing lock across a
read-then-write transaction and asserts that transaction completes;
asserting instead that the other writer is excluded passes either way,
because a deferred BEGIN shuts it out too, later and by a different lock.
The other races four creates of one name: under a deferred BEGIN the
losers read no stored config, go on to CREATE TABLE, and fail there with
"table already exists" instead of VectorStoreCollectionAlreadyExistsError.

Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ches

`MilvusConf` gains `vector_index`, a plain mapping of `index_type`,
`params` (build parameters) and `search_params` (parameters every search
passes), so pymilvus stays optional for configuration parsing; the
store's params coerce it into `MilvusVectorIndex`. pymilvus has no models
for index or search parameters, so the unit is the whole spec: build and
search parameters come from one author for one index type. The index
applies when startup creates the collection's vector index, and the
search parameters to every query. Unset selects HNSW_SQ searched with
refine_k 8, as before.

`params` may not set `index_type` or `metric_type`, which pymilvus would
let override the spec's own index type and the store's cosine metric.
Milvus checks the parameters the index type takes when it creates the
index and when a search reaches an indexed segment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k) (MemMachine#1610)

Give every write a fresh row id, so a key names one version

An upsert of an existing uuid kept its row id, so one engine key spanned
every version of a record. A query reads the engine, then each
candidate's properties, then its uuid, at three instants with nothing
held between them, and a rewrite of the very record being returned could
land between those reads: the score of one version paired with the
filter verdict of another, or, when the overfetch loop rescored a key
whose verdict was already cached, the other way round. No lock covers
this, because reads deliberately take none.

Every write now deletes the previous row and inserts a new one in the
same transaction, stages a delete for the old key beside the upsert for
the new, and the engine removes the old key and adds the new. Rows are
immutable, so a key names one version: the score computed under it, the
filter verdict for it, and the uuid it resolves to belong to that
version, and a key whose version has been rewritten resolves to no row
and is dropped. AUTOINCREMENT remains what keeps a retired key from being
reissued, and the write lock what keeps two rewrites of one record in
order.

A batch that names a uuid twice is collapsed to its last record before
the insert, which the on-conflict update used to do implicitly. The save
threshold counts log rows and a rewrite now adds two, so a rewrite-heavy
workload checkpoints about twice as often; that test's expectation
changes accordingly.

Measured on this machine, medians of three runs, records per second,
64-dimensional vectors, file-backed store with an index directory:

    batch  save threshold  insert before / after  rewrite before / after
    500    1000            20477 / 19290          16450 / 12902
    500    none            21228 / 22486          19013 / 15899
    1      none              341 /   345            316 /   274

Inserts move within run-to-run noise, in both directions. Rewrites cost
13-22% more, the upper end where the doubled log rows double the saves.

Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The sample configurations gain a commented `vector_index` block under the
Milvus alternative, checked against `MilvusConf` and `MilvusVectorIndex`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he core final state

Open-or-create leaves the vector store interface; the service locator's
tests build the cosine-only search engine of MemMachine#1663, and the SQLite stores'
tests keep the cosine-only metric tests of MemMachine#1663.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every store refuses a batch naming a record UUID twice. A non-finite float
is already refused by the declared schema's type check, so its separate
check and tests go, and the datetime tests run on the declared key alone,
since an undeclared key is refused. The store keeps the strict mode of
MemMachine#1628 over the one MemMachine#1813 merged into the base, and MemMachine#1813's test of it off
stays out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…chine#1672 to MemMachine#1676) into the final state

Their upsert keeps the delete and fresh insert, writing the declared
columns, and refuses a repeated UUID as MemMachine#1788 does; open-or-create stays
out, as MemMachine#1625 removes it. Their tests create their partition as a session
does and build filters with the closed union of MemMachine#1616.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…achine#1741) into the final state

A Qdrant collection is created with the deployment's HNSW, optimizer and
quantization options and with the strict mode of MemMachine#1628.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the integration/vector-store-final-state branch from 3e82239 to 5e638c3 Compare October 14, 2026 00:23
@xiongzubiao
xiongzubiao force-pushed the feat/horizontal-scaling branch from 101182c to cf7ae15 Compare October 15, 2026 15:31

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant