Skip to content

feat(backends): embedder-identity contract + three-state enforcement (RFC 001) - #1731

Merged
igorls merged 1 commit into
developfrom
feat/embedder-identity
Jun 8, 2026
Merged

feat(backends): embedder-identity contract + three-state enforcement (RFC 001)#1731
igorls merged 1 commit into
developfrom
feat/embedder-identity

Conversation

@igorls

@igorls igorls commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

Implements the embedder-identity contract from RFC 001 (#743) — the "hard dependency" tracked in #1724.

The explicit-embedding backends (pgvector, qdrant, sqlite_exact) embed through EmbeddingCollection and persisted nothing about which model produced their vectors. Swapping two same-dimension models (minilmembeddinggemma, both 384-d) silently corrupted retrieval with no error — the worst failure class for a verbatim-recall system. This records the model name on write and refuses a swap on open.

What changed

  • Contract (backends/base.py): EmbedderIdentity dataclass, an Embedder protocol, EmbedderIdentityUnknownWarning, and a pure three-state check_embedder_identity()known_match / known_mismatch (raises EmbedderIdentityMismatchError, or DimensionMismatchError for a width change, checked first) / unknown. BaseCollection gains optional get/set/effective_embedder_identity hooks (default: no-op → permanently unknown, which is safe).
  • Identity source (embedding.py): current_model_name() (cheap, from config — the canonical name, not the EF's spoofed "default") and get_embedder_identity() (dimension from a one-time cached probe).
  • Persistence, one slot per backend: sqlite_exact meta table · pgvector marker JSON (preserved across marker rewrites) · chroma sidecar JSON in the palace dir. EmbeddingCollection forwards the hooks explicitly — BaseCollection defines them as concrete methods, so __getattr__ would otherwise shadow the wrapped backend (same trap caught on the previous metric PR).
  • Enforcement at palace.get_collection: a recorded model that differs from the current one raises (fail fast, before any silently-degraded query); a brand-new empty collection records the current model; a populated-but-unrecorded legacy palace warns and is resolved with the CLI. The check uses only the configured model name, so it needs no model load. A per-process cache keeps the hot path at one metadata read. Identity bookkeeping never breaks memory ops — only the deliberate mismatch propagates.
  • CLI: mempalace palace set-embedder [--model NAME] [--force] records or force-overrides identity. It does not mutate global config and never loads a foreign model (an explicit --model override records the name with dimension=0).

Scope / notes

  • Chroma already self-protects via ChromaDB's embedding-function-name check; this layer complements it (and gives a uniform get_stored_embedder_identity for tooling) without touching the fragile collection metadata.
  • Legacy palaces are safe by construction: no recorded identity → unknown → warn, never a hard fail. The check only raises once an identity has been recorded and the model then changes.
  • Qdrant identity persistence is deferred (remote-only, no local metadata slot) and tracked in Qdrant embedder-identity persistence (fast-follow from #1724) #1730. It stays unknown/safe until then.

Tests

tests/test_embedder_identity.py (18 tests, no model loads): the three-state helper; per-backend persistence roundtrips (sqlite, chroma sidecar, pgvector marker-preservation — all connection-free); EmbeddingCollection delegation; and enforcement via palace.get_collection (match / model-swap-raises / brand-new-records / legacy-warns / nameless-noop / forced override). Full suite green: 2469 passed, 82.45% coverage, ruff check + format --check clean.

Closes #1724. Refs #743, #1730.

Copilot AI review requested due to automatic review settings June 8, 2026 11:03
@igorls
igorls requested a review from milla-jovovich as a code owner June 8, 2026 11:03

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements RFC 001, introducing embedder-identity tracking and enforcement across various backends (Chroma, PGVector, and SQLite) to prevent silent retrieval degradation when swapping models of the same dimension. It adds the EmbedderIdentity dataclass, verification logic, a CLI command (mempalace palace set-embedder) to manage identities, and comprehensive tests. The review feedback highlights several robust error-handling improvements, specifically regarding the validation of parsed JSON structures (ensuring they are dictionaries before accessing keys) and using the self._collection_name() helper instead of accessing self._collection.name directly in the Chroma backend.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1761 to +1778
def get_stored_embedder_identity(self):
from .base import EmbedderIdentity

path = self._embedder_sidecar_path()
if not path or not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
entry = (data or {}).get(self._collection.name)
if not isinstance(entry, dict) or not entry.get("model_name"):
return None
return EmbedderIdentity(
model_name=str(entry["model_name"]),
dimension=int(entry.get("dimension") or 0),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

There are two issues in this method:

  1. json.load(f) can return a non-dictionary type (like a list or string) if the JSON file is corrupted or malformed. If data is not a dictionary, calling (data or {}).get(...) will raise an unhandled AttributeError.
  2. self._collection.name can be a callable in some environments or test doubles (which is why the self._collection_name() helper exists on line 1533). Accessing it directly instead of calling self._collection_name() will fail to retrieve the correct entry.

We should validate that data is a dictionary and use self._collection_name() to safely retrieve the collection name.

    def get_stored_embedder_identity(self):
        from .base import EmbedderIdentity

        path = self._embedder_sidecar_path()
        if not path or not os.path.isfile(path):
            return None
        try:
            with open(path, encoding=

Comment on lines +1780 to +1802
def set_embedder_identity(self, identity) -> None:
path = self._embedder_sidecar_path()
if not path or not identity or not identity.model_name:
return
data: dict = {}
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (OSError, json.JSONDecodeError):
data = {}
data[self._collection.name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

self._collection.name can be a callable in some environments or test doubles (which is why the self._collection_name() helper exists on line 1533). Accessing it directly instead of calling self._collection_name() will use the callable object as a dictionary key, which will raise a TypeError during json.dump because callables are not JSON-serializable.

We should use self._collection_name() to safely retrieve the collection name.

    def set_embedder_identity(self, identity) -> None:
        path = self._embedder_sidecar_path()
        if not path or not identity or not identity.model_name:
            return
        name = self._collection_name()
        if not name:
            return
        data: dict = {}
        if os.path.isfile(path):
            try:
                with open(path, encoding=

Comment on lines +1157 to +1172
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
from .base import EmbedderIdentity

try:
marker = self._read_marker(palace)
except BackendMismatchError:
return None
if not isinstance(marker, dict):
return None
entry = (marker.get("embedders") or {}).get(collection_name)
if not isinstance(entry, dict) or not entry.get("model_name"):
return None
return EmbedderIdentity(
model_name=str(entry["model_name"]),
dimension=int(entry.get("dimension") or 0),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If the marker file is corrupted or malformed, marker.get("embedders") might return a non-dictionary type (like a list or string). If that happens, calling .get(collection_name) on it will raise an unhandled AttributeError.

We should validate that embedders is a dictionary before calling .get().

    def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
        from .base import EmbedderIdentity

        try:
            marker = self._read_marker(palace)
        except BackendMismatchError:
            return None
        if not isinstance(marker, dict):
            return None
        embedders = marker.get("embedders")
        if not isinstance(embedders, dict):
            return None
        entry = embedders.get(collection_name)
        if not isinstance(entry, dict) or not entry.get("model_name"):
            return None
        return EmbedderIdentity(
            model_name=str(entry["model_name"]),
            dimension=int(entry.get("dimension") or 0),
        )

Comment on lines +1180 to +1191
try:
marker = self._read_marker(palace) or {}
except BackendMismatchError:
return
embedders = marker.get("embedders")
if not isinstance(embedders, dict):
embedders = {}
embedders[collection_name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
marker["embedders"] = embedders

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If _read_marker returns a non-dictionary type (such as a list or string) due to corruption or manual editing, marker will not be a dictionary. This will cause marker.get("embedders") to raise an AttributeError and marker["embedders"] = embedders to raise a TypeError.

We should validate that marker is a dictionary and default to an empty dictionary if it is not.

Suggested change
try:
marker = self._read_marker(palace) or {}
except BackendMismatchError:
return
embedders = marker.get("embedders")
if not isinstance(embedders, dict):
embedders = {}
embedders[collection_name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
marker["embedders"] = embedders
try:
marker = self._read_marker(palace)
except BackendMismatchError:
return
if not isinstance(marker, dict):
marker = {}
embedders = marker.get("embedders")
if not isinstance(embedders, dict):
embedders = {}
embedders[collection_name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
marker["embedders"] = embedders

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Implements RFC 001’s embedder-identity contract so collections persist which embedding model produced their vectors and fail fast on same-dimension model swaps that would otherwise silently degrade retrieval.

Changes:

  • Adds an EmbedderIdentity value object + three-state check_embedder_identity() helper and collection hooks for storing/enforcing identity.
  • Persists embedder identity for sqlite_exact, pgvector (marker JSON), and chroma (sidecar JSON), and wires enforcement into palace.get_collection.
  • Introduces a mempalace palace set-embedder CLI command plus a dedicated test suite covering the three-state logic, persistence, and enforcement.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/test_embedder_identity.py New test suite for identity helper, backend persistence, wrapper delegation, and palace enforcement.
pyproject.toml Adds pytest warning filter to reduce legacy “unknown identity” warning noise during tests.
mempalace/palace.py Adds per-process identity validation cache, enforcement on open, and set_palace_embedder_identity().
mempalace/embedding.py Adds cheap canonical model-name resolver and cached dimension probe for identity construction.
mempalace/cli.py Adds palace set-embedder subcommand wiring and output.
mempalace/backends/sqlite_exact.py Persists model identity in sqlite meta table keyed per collection.
mempalace/backends/pgvector.py Persists model identity in the local marker JSON and preserves it across marker rewrites.
mempalace/backends/embedding_wrapper.py Explicitly forwards identity methods to avoid BaseCollection method shadowing.
mempalace/backends/chroma.py Persists identity in a palace-dir sidecar JSON keyed by collection name.
mempalace/backends/base.py Defines identity dataclass/protocol/warning and default collection identity hooks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mempalace/palace.py
Comment on lines +103 to +121
try:
model_name = current_model_name()
except Exception:
return
if not model_name:
return # nameless embedder — cannot enforce identity
key = (str(palace_path), str(collection_name), model_name)
if key in _VALIDATED_IDENTITY:
return

try:
stored = collection.get_stored_embedder_identity()
except Exception:
logger.debug("embedder-identity read failed for %s", collection_name, exc_info=True)
return

current = EmbedderIdentity(model_name=model_name, dimension=0)
try:
state = check_embedder_identity(stored, current)
Comment thread mempalace/palace.py
Comment on lines +210 to +213
configured = MempalaceConfig().embedding_model
target = (model or configured or "").strip().lower()
if target and target == (configured or "").strip().lower():
# Recording the in-use model — probe its dimension (already loaded).
Comment thread mempalace/cli.py
Comment on lines +835 to +840
"""Record (or force-override) a palace's embedder identity (RFC 001).

Resolves the ``unknown`` state for a legacy palace, or switches a palace to
a new model with ``--model`` (which also updates the configured model so
subsequent opens stay consistent). ``--force`` overwrites an existing,
differently-named identity.
Comment thread mempalace/cli.py
Comment on lines +1748 to +1753
p_set_embedder.add_argument(
"--model",
default=None,
help="Embedder model to record (default: current configured model). "
"When given, also updates the configured model.",
)
…(RFC 001, #1724)

The explicit-embedding backends (pgvector, qdrant, sqlite_exact) embed through
EmbeddingCollection and persist nothing about which model produced their
vectors. Swapping two same-dimension models (minilm <-> embeddinggemma, both
384-d) silently corrupts retrieval with no error — the worst failure class for
a verbatim-recall system. This records the model name and refuses a swap on open.

- base.py: EmbedderIdentity dataclass, Embedder protocol,
  EmbedderIdentityUnknownWarning, and a three-state check_embedder_identity()
  helper (known_match / known_mismatch / unknown) raising
  EmbedderIdentityMismatchError / DimensionMismatchError. BaseCollection gains
  get/set/effective_embedder_identity hooks.
- embedding.py: current_model_name() (cheap) + get_embedder_identity() (probed
  dimension, cached per process).
- Persistence per backend: sqlite_exact meta table, pgvector marker JSON
  (preserved across rewrites), chroma sidecar JSON. EmbeddingCollection forwards
  the hooks explicitly (BaseCollection methods shadow __getattr__).
- palace.get_collection enforces at open: a model swap raises; a brand-new empty
  collection records the current model; a populated-but-unrecorded legacy palace
  warns and is resolved via the CLI. The check needs no model load.
- CLI: `mempalace palace set-embedder [--model NAME] [--force]` records/overrides
  identity without mutating global config or loading a foreign model.

Chroma additionally keeps its native embedding-function check. Qdrant identity
persistence is a fast-follow (no local metadata slot — needs a companion store).

Closes #1724. Refs #743.
@igorls
igorls force-pushed the feat/embedder-identity branch from a1bcc53 to 212051d Compare June 8, 2026 11:14
@igorls
igorls merged commit 13de7a6 into develop Jun 8, 2026
9 checks passed
@igorls
igorls deleted the feat/embedder-identity branch June 8, 2026 11:22
igorls added a commit that referenced this pull request Aug 11, 2026
Close the last open items on #743 before merge:

- Conformance: document two isolation arms (cross-id for all backends;
  same-id/different-namespace for supports_namespace_isolation advertisers).
- No silent drop: non-advertising backends must raise UnsupportedCapabilityError
  when PalaceRef.namespace is set, rather than accept-and-ignore.
- Wire require_namespace_support() into chroma/sqlite_exact; add conformance test.
- Refresh implementation-status banner now that #1727/#1731/#1732/#1734 landed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC 001 §1.5: implement Embedder protocol + identity enforcement

2 participants