Files
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

400 lines
14 KiB
Python

"""Offline tests for EmbeddinggemmaONNX.
The real ONNX model is ~300 MB and pulled from HuggingFace on first use, so
these tests mock huggingface_hub.hf_hub_download, tokenizers.Tokenizer, and
onnxruntime.InferenceSession to keep CI fast and network-free.
Skipped when the multilingual extra isn't installed (huggingface_hub/
tokenizers/numpy) — CI runs only core deps by default.
"""
import sys
import threading
import time
import pytest
np = pytest.importorskip("numpy")
pytest.importorskip("huggingface_hub")
pytest.importorskip("tokenizers")
import mempalace.embedding as embedding # noqa: E402 (after importorskip)
@pytest.fixture(autouse=True)
def isolate_embedding_state(monkeypatch):
monkeypatch.setattr(embedding, "_EF_CACHE", {})
monkeypatch.setattr(embedding, "_WARNED", set())
def _make_fake_session(out_dim=768):
"""Fake onnxruntime InferenceSession that returns a deterministic tensor.
Shape: (batch, out_dim). The values aren't important — tests check shape,
truncation, and L2-normalization, not numerical correctness.
"""
class _Output:
def __init__(self, name):
self.name = name
class _Session:
def __init__(self, *args, **kwargs):
pass
def get_outputs(self):
return [_Output("last_hidden_state"), _Output("sentence_embedding")]
def run(self, _output_names, feed):
batch = feed["input_ids"].shape[0]
# Deterministic non-trivial values so L2-norm isn't degenerate.
sent = np.arange(batch * out_dim, dtype=np.float32).reshape(batch, out_dim) + 1.0
last_hidden = np.zeros((batch, feed["input_ids"].shape[1], out_dim), dtype=np.float32)
return [last_hidden, sent]
return _Session
class _FakeTokenizer:
"""Stand-in for tokenizers.Tokenizer with the methods _lazy_load uses."""
def __init__(self):
self._padding_enabled = False
self._truncation_enabled = False
self._truncation_max = None
def enable_padding(self):
self._padding_enabled = True
def enable_truncation(self, max_length):
self._truncation_enabled = True
self._truncation_max = max_length
def encode_batch(self, texts):
class _Enc:
def __init__(self, n):
self.ids = [0] * n
self.attention_mask = [1] * n
# Same fixed length per batch — real tokenizers pad to the longest.
max_len = max(len(t.split()) for t in texts)
return [_Enc(max_len) for _ in texts]
@pytest.fixture
def patched_lazy_load(monkeypatch):
"""Patch the third-party deps imported inside EmbeddinggemmaONNX._lazy_load.
Returns a dict of recording counters so tests can assert how many times
each was called (e.g. confirm lazy-load caches after first call).
"""
calls = {"hf_hub_download": 0, "InferenceSession": 0, "Tokenizer.from_file": 0}
def fake_download(repo, filename=None, subfolder=None, **kwargs):
calls["hf_hub_download"] += 1
return f"/tmp/fake/{subfolder or ''}/{filename}"
fake_session_cls = _make_fake_session()
def fake_session_ctor(*args, **kwargs):
calls["InferenceSession"] += 1
return fake_session_cls()
def fake_tokenizer_from_file(_path):
calls["Tokenizer.from_file"] += 1
return _FakeTokenizer()
# huggingface_hub and tokenizers are real packages (installed via the
# multilingual extra), so we patch the functions in place rather than
# injecting stub modules.
import huggingface_hub
import onnxruntime
import tokenizers
monkeypatch.setattr(huggingface_hub, "hf_hub_download", fake_download)
monkeypatch.setattr(onnxruntime, "InferenceSession", fake_session_ctor)
monkeypatch.setattr(tokenizers.Tokenizer, "from_file", staticmethod(fake_tokenizer_from_file))
return calls
def test_name_is_stable():
"""ChromaDB persists this on the collection — changing it breaks reads."""
assert embedding.EmbeddinggemmaONNX.name() == "embeddinggemma_300m"
def test_lazy_load_runs_once(patched_lazy_load):
ef = embedding.EmbeddinggemmaONNX()
ef(["one"])
ef(["two"])
ef(["three"])
assert patched_lazy_load["hf_hub_download"] == 3 # model + weights + tokenizer, once
assert patched_lazy_load["InferenceSession"] == 1
assert patched_lazy_load["Tokenizer.from_file"] == 1
def test_output_shape_is_truncated_to_384(patched_lazy_load):
ef = embedding.EmbeddinggemmaONNX()
out = ef(["one", "two", "three"])
arr = np.asarray(out)
assert arr.shape == (3, 384), f"expected (3, 384) after MRL truncation, got {arr.shape}"
def test_output_is_l2_normalized(patched_lazy_load):
ef = embedding.EmbeddinggemmaONNX()
out = ef(["hello world", "another sentence"])
arr = np.asarray(out)
norms = np.linalg.norm(arr, axis=1)
assert np.allclose(norms, 1.0, atol=1e-5), f"vectors not unit-norm: {norms}"
def test_prefix_is_applied(patched_lazy_load, monkeypatch):
captured = []
original_encode_batch = _FakeTokenizer.encode_batch
def fake_encode_batch(self, texts):
captured.extend(texts)
return original_encode_batch(self, texts)
monkeypatch.setattr(_FakeTokenizer, "encode_batch", fake_encode_batch)
ef = embedding.EmbeddinggemmaONNX()
ef(["raw text one", "raw text two"])
assert all(t.startswith("task: sentence similarity | query: ") for t in captured)
# And the raw text is preserved after the prefix.
assert any("raw text one" in t for t in captured)
def test_call_chunks_large_batches(patched_lazy_load, monkeypatch):
"""A large input must be tokenized and run in bounded sub-batches.
One unchunked session.run over a repair-scale batch (5000 docs) allocates
attention buffers beyond available RAM and the kernel kills the process
(#1770) — so __call__ may never see more than _EMBEDDINGGEMMA_BATCH_SIZE
docs per forward pass.
"""
batch_sizes = []
captured_texts = []
original_encode_batch = _FakeTokenizer.encode_batch
def recording_encode_batch(self, texts):
batch_sizes.append(len(texts))
captured_texts.extend(texts)
return original_encode_batch(self, texts)
monkeypatch.setattr(_FakeTokenizer, "encode_batch", recording_encode_batch)
ef = embedding.EmbeddinggemmaONNX()
n = embedding._EMBEDDINGGEMMA_BATCH_SIZE * 2 + 6
docs = [f"doc {i}" for i in range(n)]
out = ef(docs)
assert batch_sizes == [
embedding._EMBEDDINGGEMMA_BATCH_SIZE,
embedding._EMBEDDINGGEMMA_BATCH_SIZE,
6,
], f"expected bounded sub-batches, got {batch_sizes}"
# Sub-batches must cover the input in order; combined with the per-chunk
# extend in __call__ this pins output row order to input order.
assert captured_texts == [embedding._EMBEDDINGGEMMA_PREFIX + d for d in docs]
arr = np.asarray(out)
assert arr.shape == (n, 384), f"chunked outputs must concatenate to (n, 384), got {arr.shape}"
assert np.allclose(np.linalg.norm(arr, axis=1), 1.0, atol=1e-5)
_B = 32 # mirrors _EMBEDDINGGEMMA_BATCH_SIZE; literal so the cases read plainly
@pytest.mark.parametrize(
("n", "expected_batches"),
[
(1, [1]),
(_B, [_B]),
(_B + 1, [_B, 1]),
(2 * _B, [_B, _B]),
],
)
def test_call_chunk_boundaries(patched_lazy_load, monkeypatch, n, expected_batches):
"""Exact-multiple and off-by-one inputs produce no empty or oversized runs."""
assert _B == embedding._EMBEDDINGGEMMA_BATCH_SIZE, "update _B alongside the constant"
batch_sizes = []
original_encode_batch = _FakeTokenizer.encode_batch
def recording_encode_batch(self, texts):
batch_sizes.append(len(texts))
return original_encode_batch(self, texts)
monkeypatch.setattr(_FakeTokenizer, "encode_batch", recording_encode_batch)
ef = embedding.EmbeddinggemmaONNX()
out = ef([f"doc {i}" for i in range(n)])
assert batch_sizes == expected_batches
assert len(out) == n
def test_custom_batch_size_is_honored(patched_lazy_load, monkeypatch):
"""The constructor knob must drive the sub-batch split."""
batch_sizes = []
original_encode_batch = _FakeTokenizer.encode_batch
def recording_encode_batch(self, texts):
batch_sizes.append(len(texts))
return original_encode_batch(self, texts)
monkeypatch.setattr(_FakeTokenizer, "encode_batch", recording_encode_batch)
ef = embedding.EmbeddinggemmaONNX(batch_size=10)
out = ef([f"doc {i}" for i in range(24)])
assert batch_sizes == [10, 10, 4]
assert len(out) == 24
def test_batch_size_below_one_is_rejected():
"""A zero or negative batch size would loop forever or embed nothing."""
with pytest.raises(ValueError, match="batch_size"):
embedding.EmbeddinggemmaONNX(batch_size=0)
with pytest.raises(ValueError, match="batch_size"):
embedding.EmbeddinggemmaONNX(batch_size=-3)
def test_call_empty_input_returns_empty(patched_lazy_load):
"""Zero docs must yield zero embeddings without loading the model."""
ef = embedding.EmbeddinggemmaONNX()
assert ef([]) == []
assert ef(None) == []
assert patched_lazy_load["hf_hub_download"] == 0, "empty input must not trigger the download"
def test_call_bare_string_is_wrapped(patched_lazy_load):
"""A single string is one document, not a sequence of characters."""
ef = embedding.EmbeddinggemmaONNX()
out = ef("standalone document")
assert np.asarray(out).shape == (1, 384)
def test_concurrent_first_calls_load_model_once(patched_lazy_load, monkeypatch):
"""Cold concurrent calls must build exactly one session.
Instances are shared across threads via _EF_CACHE; without the load
lock, two cold callers would transiently hold two full model sessions.
"""
import huggingface_hub
fixture_download = huggingface_hub.hf_hub_download
def slow_download(*args, **kwargs):
time.sleep(0.05) # widen the race window the lock must close
return fixture_download(*args, **kwargs)
monkeypatch.setattr(huggingface_hub, "hf_hub_download", slow_download)
ef = embedding.EmbeddinggemmaONNX()
barrier = threading.Barrier(2)
results = [None, None]
def worker(slot):
barrier.wait(timeout=5)
results[slot] = ef([f"doc {slot}"])
threads = [threading.Thread(target=worker, args=(slot,)) for slot in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert patched_lazy_load["InferenceSession"] == 1
assert all(r is not None and len(r) == 1 for r in results)
def test_concurrent_get_embedding_function_single_instance(monkeypatch):
"""Concurrent cache misses must converge on one shared EF instance.
The instance-level load lock is not enough on its own: if the factory's
check-then-construct is unsynchronized, each thread keeps its own
instance and each one later loads its own copy of the model.
"""
monkeypatch.setattr(
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
barrier = threading.Barrier(2)
instances = [None, None]
def worker(slot):
barrier.wait(timeout=5)
instances[slot] = embedding.get_embedding_function(device="cpu", model="embeddinggemma")
threads = [threading.Thread(target=worker, args=(slot,)) for slot in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert instances[0] is not None, "worker thread did not complete"
assert instances[0] is instances[1], "factory must hand every thread the same EF"
def test_get_embedding_function_dispatches_to_embeddinggemma(monkeypatch):
"""model='embeddinggemma' must build EmbeddinggemmaONNX, not the MiniLM EF."""
monkeypatch.setattr(
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
ef = embedding.get_embedding_function(device="cpu", model="embeddinggemma")
assert isinstance(ef, embedding.EmbeddinggemmaONNX)
assert ef.name() == "embeddinggemma_300m"
def test_cache_key_separates_models(monkeypatch):
"""Switching model must not return the cached EF for the other model.
The cache key changed from `providers` to `(model, providers)` for exactly
this reason — without it, the second call would silently reuse the wrong EF.
"""
class DummyMiniLM:
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
self.kind = "minilm"
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyMiniLM)
monkeypatch.setattr(
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
ml = embedding.get_embedding_function(device="cpu", model="minilm")
eg = embedding.get_embedding_function(device="cpu", model="embeddinggemma")
ml_again = embedding.get_embedding_function(device="cpu", model="minilm")
assert ml is ml_again, "minilm should cache-hit on second call"
assert isinstance(eg, embedding.EmbeddinggemmaONNX), (
"embeddinggemma should not collide with minilm cache"
)
assert ml is not eg
def test_missing_deps_raise_helpful_error(monkeypatch):
"""Multilingual deps now ship in core, but if a user ends up with a broken
install (uninstalled tokenizers, incompatible pin, etc.) the error should
tell them how to recover rather than spilling a bare ImportError."""
# Simulate a user with a broken install: drop tokenizers from sys.modules
# and block re-import. huggingface_hub and onnxruntime stay importable.
monkeypatch.setitem(sys.modules, "tokenizers", None)
ef = embedding.EmbeddinggemmaONNX()
with pytest.raises(ImportError, match=r"pip install.*mempalace"):
ef(["anything"])
def test_config_embedding_model_env_override(monkeypatch):
"""MEMPALACE_EMBEDDING_MODEL env var must override the config file default."""
from mempalace.config import MempalaceConfig
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "embeddinggemma")
assert MempalaceConfig().embedding_model == "embeddinggemma"
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "MiniLM") # case-insensitive
assert MempalaceConfig().embedding_model == "minilm"
def test_config_embedding_model_default_is_minilm(monkeypatch):
"""Back-compat: existing installs without explicit config get minilm."""
from mempalace.config import MempalaceConfig
monkeypatch.delenv("MEMPALACE_EMBEDDING_MODEL", raising=False)
assert MempalaceConfig().embedding_model == "minilm"