Files
mempalace--mempalace/tests/test_embedder_identity.py
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

409 lines
16 KiB
Python

"""Embedder-identity persistence and three-state enforcement (RFC 001).
A same-dimension model swap (e.g. two 384-d models) silently corrupts
retrieval on the explicit-embedding backends, which have no native model
check. The contract records the model name and refuses a swap on open. These
tests avoid loading any embedding model: the enforcement *check* path needs
only the configured model name (cheap), and persistence is exercised with
``EmbedderIdentity`` objects and explicit vectors.
"""
import os
import warnings
import pytest
from mempalace.backends.base import (
DimensionMismatchError,
EmbedderIdentity,
EmbedderIdentityMismatchError,
EmbedderIdentityUnknownWarning,
PalaceRef,
check_embedder_identity,
)
# ---------------------------------------------------------------------------
# Three-state helper
# ---------------------------------------------------------------------------
def test_unknown_when_nothing_stored():
assert check_embedder_identity(None, EmbedderIdentity("minilm", 384)) == "unknown"
def test_unknown_when_current_is_nameless():
stored = EmbedderIdentity("minilm", 384)
assert check_embedder_identity(stored, EmbedderIdentity("", 384)) == "unknown"
assert check_embedder_identity(stored, None) == "unknown"
def test_known_match():
a = EmbedderIdentity("minilm", 384)
assert check_embedder_identity(a, a) == "known_match"
def test_match_skips_unknown_dimension():
# dimension 0 means "not probed" and must not be treated as a real conflict.
assert (
check_embedder_identity(EmbedderIdentity("minilm", 384), EmbedderIdentity("minilm", 0))
== "known_match"
)
def test_model_swap_raises_identity_error():
with pytest.raises(EmbedderIdentityMismatchError):
check_embedder_identity(EmbedderIdentity("minilm", 384), EmbedderIdentity("gemma", 384))
def test_dimension_change_raises_dimension_error_first():
# Width change is physically unusable — checked before the name swap.
with pytest.raises(DimensionMismatchError):
check_embedder_identity(EmbedderIdentity("a", 384), EmbedderIdentity("b", 768))
def test_force_returns_mismatch_without_raising():
assert (
check_embedder_identity(
EmbedderIdentity("minilm", 384),
EmbedderIdentity("gemma", 384),
force_model_swap=True,
)
== "known_mismatch"
)
# ---------------------------------------------------------------------------
# Per-backend persistence roundtrip (no model loads)
# ---------------------------------------------------------------------------
def _sqlite_collection(tmp_path):
from mempalace.backends.sqlite_exact import SQLiteExactBackend
backend = SQLiteExactBackend()
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
return backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
def _chroma_collection(tmp_path):
from mempalace.backends.chroma import ChromaBackend
backend = ChromaBackend()
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
return backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
def test_sqlite_identity_roundtrip(tmp_path):
col = _sqlite_collection(tmp_path)
assert col.get_stored_embedder_identity() is None
col.add(documents=["x"], ids=["a"], metadatas=[{}], embeddings=[[0.1, 0.2, 0.3, 0.4]])
col.set_embedder_identity(EmbedderIdentity("minilm", 4))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm" and got.dimension == 4
def test_sqlite_set_identity_ignores_nameless(tmp_path):
col = _sqlite_collection(tmp_path)
col.add(documents=["x"], ids=["a"], metadatas=[{}], embeddings=[[0.1, 0.2, 0.3, 0.4]])
col.set_embedder_identity(EmbedderIdentity("", 4))
assert col.get_stored_embedder_identity() is None
def test_chroma_identity_roundtrip_via_sidecar(tmp_path):
col = _chroma_collection(tmp_path)
assert col.get_stored_embedder_identity() is None
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm" and got.dimension == 384
assert os.path.isfile(os.path.join(str(tmp_path), "mempalace_embedder.json"))
def test_pgvector_identity_survives_marker_rewrite(tmp_path):
# Identity lives in a sidecar, separate from the mismatch marker, so a
# marker rebuild (which happens on every write) must not affect it.
from mempalace.backends.pgvector import PgVectorBackend, _PgVectorConfig
backend = PgVectorBackend()
cfg = _PgVectorConfig(dsn="postgresql://example", namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
# No marker needed to record identity — the sidecar is unguarded.
backend._set_embedder_identity(ref, "mempalace_drawers", EmbedderIdentity("minilm", 384))
backend._write_marker(ref, cfg)
got = backend._get_embedder_identity(ref, "mempalace_drawers")
assert got is not None and got.model_name == "minilm" and got.dimension == 384
def test_embeddingcollection_delegates_identity_not_shadowed():
# BaseCollection defines these as concrete methods, so __getattr__ never
# delegates them — the wrapper needs explicit forwarding or it silently
# reports the no-op default and masks the wrapped backend's identity.
from mempalace.backends.base import BaseCollection
from mempalace.backends.embedding_wrapper import EmbeddingCollection
class _Inner(BaseCollection):
def __init__(self):
self._ident = None
def add(self, **k): ...
def upsert(self, **k): ...
def query(self, **k): ...
def get(self, **k): ...
def delete(self, **k): ...
def count(self):
return 0
def get_stored_embedder_identity(self):
return self._ident
def set_embedder_identity(self, identity):
self._ident = identity
inner = _Inner()
wrapped = EmbeddingCollection(inner)
wrapped.set_embedder_identity(EmbedderIdentity("minilm", 384))
assert inner._ident is not None and inner._ident.model_name == "minilm"
assert wrapped.get_stored_embedder_identity().model_name == "minilm"
# ---------------------------------------------------------------------------
# Enforcement via palace.get_collection (sqlite_exact, no model load)
# ---------------------------------------------------------------------------
@pytest.fixture
def clear_identity_cache():
from mempalace import palace
palace._VALIDATED_IDENTITY.clear()
yield
palace._VALIDATED_IDENTITY.clear()
def _seed_sqlite_with_identity(tmp_path, model):
col = _sqlite_collection(tmp_path)
col.add(documents=["x"], ids=["a"], metadatas=[{}], embeddings=[[0.1, 0.2, 0.3, 0.4]])
if model is not None:
col.set_embedder_identity(EmbedderIdentity(model, 4))
return col
def test_enforcement_match_does_not_raise(tmp_path, monkeypatch, clear_identity_cache):
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
monkeypatch.setenv("MEMPALACE_BACKEND", "sqlite_exact")
from mempalace import palace as P
_seed_sqlite_with_identity(tmp_path, "minilm")
P._VALIDATED_IDENTITY.clear()
# Should not raise.
P.get_collection(str(tmp_path), collection_name="mempalace_drawers", create=False)
def test_enforcement_model_swap_raises(tmp_path, monkeypatch, clear_identity_cache):
monkeypatch.setenv("MEMPALACE_BACKEND", "sqlite_exact")
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
from mempalace import palace as P
_seed_sqlite_with_identity(tmp_path, "minilm")
P._VALIDATED_IDENTITY.clear()
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "embeddinggemma")
with pytest.raises(EmbedderIdentityMismatchError):
P.get_collection(str(tmp_path), collection_name="mempalace_drawers", create=False)
def test_enforcement_brand_new_records_current_model(tmp_path, monkeypatch, clear_identity_cache):
monkeypatch.setenv("MEMPALACE_BACKEND", "sqlite_exact")
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
from mempalace import palace as P
col = P.get_collection(str(tmp_path), collection_name="mempalace_drawers", create=True)
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm"
def test_enforcement_legacy_with_data_warns(tmp_path, monkeypatch, clear_identity_cache):
monkeypatch.setenv("MEMPALACE_BACKEND", "sqlite_exact")
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
from mempalace import palace as P
_seed_sqlite_with_identity(tmp_path, None) # data, but no recorded identity
P._VALIDATED_IDENTITY.clear()
with pytest.warns(EmbedderIdentityUnknownWarning):
P.get_collection(str(tmp_path), collection_name="mempalace_drawers", create=False)
def test_enforcement_nameless_model_is_a_noop(tmp_path, monkeypatch, clear_identity_cache):
monkeypatch.setenv("MEMPALACE_BACKEND", "sqlite_exact")
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
from mempalace import palace as P
_seed_sqlite_with_identity(tmp_path, None)
P._VALIDATED_IDENTITY.clear()
# A nameless current embedder cannot enforce — no raise, no warning.
monkeypatch.setattr("mempalace.embedding.current_model_name", lambda model=None: "")
with warnings.catch_warnings():
warnings.simplefilter("error")
P.get_collection(str(tmp_path), collection_name="mempalace_drawers", create=False)
# ---------------------------------------------------------------------------
# set_palace_embedder_identity override path
# ---------------------------------------------------------------------------
def test_set_palace_identity_override_requires_force(tmp_path, monkeypatch, clear_identity_cache):
monkeypatch.setenv("MEMPALACE_BACKEND", "sqlite_exact")
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
from mempalace import palace as P
_seed_sqlite_with_identity(tmp_path, "minilm")
# Recording a different model without force is refused.
with pytest.raises(EmbedderIdentityMismatchError):
P.set_palace_embedder_identity(str(tmp_path), model="embeddinggemma", force=False)
# With force it goes through, recording the name only (no foreign load).
old, new = P.set_palace_embedder_identity(str(tmp_path), model="embeddinggemma", force=True)
assert old.model_name == "minilm" and new.model_name == "embeddinggemma"
def test_set_palace_identity_empty_target_raises(tmp_path, monkeypatch):
# No model given and none configured: recording is a no-op in every backend,
# so refuse rather than claim a phantom success.
monkeypatch.setattr("mempalace.config.MempalaceConfig.embedding_model", property(lambda s: ""))
from mempalace import palace as P
with pytest.raises(ValueError):
P.set_palace_embedder_identity(str(tmp_path), model=None)
def test_enforcement_prefers_effective_identity(monkeypatch, clear_identity_cache):
# A server_embedder collection reports its own effective identity; the
# configured model must be ignored in favor of it. Here effective and
# stored disagree, so enforcement raises even though config says "minilm".
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "minilm")
from mempalace import palace as P
class _ServerCol:
def effective_embedder_identity(self):
return EmbedderIdentity("server-model", 768)
def get_stored_embedder_identity(self):
return EmbedderIdentity("other-model", 768)
def count(self):
return 5
def set_embedder_identity(self, identity):
raise AssertionError("must not record on a mismatch")
with pytest.raises(EmbedderIdentityMismatchError):
P._enforce_embedder_identity(_ServerCol(), "/tmp/x", "c", create=False)
def test_chroma_corrupt_sidecar_returns_none(tmp_path):
# A malformed sidecar (non-dict JSON) must not raise — degrade to unknown.
col = _chroma_collection(tmp_path)
path = os.path.join(str(tmp_path), "mempalace_embedder.json")
with open(path, "w", encoding="utf-8") as f:
f.write('["not", "a", "dict"]')
assert col.get_stored_embedder_identity() is None
# And a subsequent set still works (overwrites the junk).
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
assert col.get_stored_embedder_identity().model_name == "minilm"
# ---------------------------------------------------------------------------
# qdrant: identity persisted in the local marker (no live qdrant needed)
# ---------------------------------------------------------------------------
def _qdrant_collection(tmp_path, *, write_marker=True):
from mempalace.backends.qdrant import QdrantBackend, QdrantCollection, _QdrantConfig
backend = QdrantBackend()
config = _QdrantConfig(url="http://localhost:6333", api_key=None, namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
if write_marker:
backend._write_marker(ref, config)
# The identity methods read/write the local marker only; the client is
# never touched, so a placeholder stands in for a live REST connection.
return QdrantCollection(
backend=backend,
client=object(),
config=config,
palace=ref,
collection_name="mempalace_drawers",
remote_collection="mp_drawers_remote",
)
def test_qdrant_identity_survives_marker_rewrite(tmp_path):
from mempalace.backends.qdrant import QdrantBackend, _QdrantConfig
backend = QdrantBackend()
config = _QdrantConfig(url="http://localhost:6333", api_key=None, namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
backend._write_marker(ref, config)
backend._set_embedder_identity(ref, "mempalace_drawers", EmbedderIdentity("minilm", 384))
backend._write_marker(ref, config) # rebuild must not wipe embedders
got = backend._get_embedder_identity(ref, "mempalace_drawers")
assert got is not None and got.model_name == "minilm" and got.dimension == 384
def test_qdrant_collection_delegates_identity(tmp_path):
col = _qdrant_collection(tmp_path)
assert col.get_stored_embedder_identity() is None
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm" and got.dimension == 384
def test_qdrant_set_identity_creates_sidecar_when_missing(tmp_path):
# Brand-new palace whose first write hasn't created the marker yet:
# recording identity must create it, not silently no-op into permanent
# "unknown" (the marker-on-write vs record-on-open timing gap).
col = _qdrant_collection(tmp_path, write_marker=False)
assert not col._marker_exists()
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm"
def _pgvector_collection(tmp_path, *, write_marker=True):
from mempalace.backends.pgvector import PgVectorBackend, PgVectorCollection, _PgVectorConfig
backend = PgVectorBackend()
config = _PgVectorConfig(dsn="postgresql://example", namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
if write_marker:
backend._write_marker(ref, config)
return PgVectorCollection(
backend=backend,
client=object(),
config=config,
palace=ref,
collection_name="mempalace_drawers",
table="mp_drawers_t",
)
def test_pgvector_set_identity_creates_sidecar_when_missing(tmp_path):
# Same brand-new-palace timing gap as qdrant: recording must create the
# marker rather than no-op.
col = _pgvector_collection(tmp_path, write_marker=False)
assert not col._marker_exists()
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm"
def test_qdrant_enforcement_model_swap_raises(tmp_path, monkeypatch, clear_identity_cache):
# The enforcement check reads the marker (no server) and compares to the
# configured model — a swap raises just like the local backends.
from mempalace import palace as P
col = _qdrant_collection(tmp_path)
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "embeddinggemma")
with pytest.raises(EmbedderIdentityMismatchError):
P._enforce_embedder_identity(col, str(tmp_path), "mempalace_drawers", create=False)