Files
rar-autopass/tests/test_embedding_cache.py
Claude Agent 705ad030fe feat(embeddings): cache persistent de vectori in SQLite + warmup in fundal
Vectorii corpusului k-NN persista in tabela embedding_cache (PK model+text_hash,
blob float32 LE); la warmup se vectorizeaza doar textele lipsa din cache, deci
restartul cu corpus neschimbat nu mai plateste ~1-2 min de embed (embed=0).

- app/embedding_cache.py: serializare array('f'), load/save/purge chunk 500 cu
  BEGIN/COMMIT explicit (conexiuni autocommit), validare dimensiune la scriere
  si citire, orchestrare sync_corpus_vectors cu embed_fn injectat
- index_corpus(vectors=): vectori precalculati cu validare aliniere; mismatch
  -> fallback embed complet
- ensure_embeddings_corpus: warmup in thread la startup (block=True), calea de
  request ne-blocanta (acquire non-blocking pe lock; warmup in curs -> return
  imediat); purjare orfane + modele vechi doar dupa indexare reusita
- log warmup: cache=N embed=M in Xs
- 31 teste noi (cold/warm/incremental, model schimbat, concurenta, ranking
  exact, echivalenta float32); suita completa 1596 passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:57:49 +00:00

278 lines
9.7 KiB
Python

"""Teste pentru app/embedding_cache.py -- cache persistent de vectori in SQLite.
Acopera: round-trip serializare, blob corupt = miss, validare dimensiune la scriere,
chunking >500 randuri, save partial esuat, purge orfane + modele vechi, purge NU
ruleaza cand embed_fn esueaza.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
from array import array
import pytest
from app.embedding_cache import (
EMB_DIM,
blob_to_vector,
load_cached_vectors,
purge_stale,
save_vectors,
sync_corpus_vectors,
text_hash,
vector_to_blob,
)
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "embcache.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true") # A13d: anti-vacuos
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db, get_connection
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _vec(seed: float = 1.0, dim: int = EMB_DIM) -> list[float]:
return [seed + i * 0.001 for i in range(dim)]
# --------------------------------------------------------------------------- #
# Serializare #
# --------------------------------------------------------------------------- #
def test_roundtrip_vector_to_blob_blob_to_vector():
v = _vec(3.5)
blob = vector_to_blob(v)
assert isinstance(blob, bytes)
assert len(blob) == EMB_DIM * 4
out = blob_to_vector(blob)
assert isinstance(out, array)
assert out == pytest.approx(v, rel=1e-6)
def test_text_hash_deterministic_and_distinct():
assert text_hash("SCHIMB ULEI") == text_hash("SCHIMB ULEI")
assert text_hash("SCHIMB ULEI") != text_hash("SCHIMB FILTRU")
# --------------------------------------------------------------------------- #
# load_cached_vectors #
# --------------------------------------------------------------------------- #
def test_load_cached_vectors_roundtrip(conn):
h = text_hash("SCHIMB ULEI")
save_vectors(conn, "model-a", [(h, _vec(1.0))])
out = load_cached_vectors(conn, "model-a", [h])
assert h in out
assert out[h] == pytest.approx(_vec(1.0), rel=1e-6)
def test_load_cached_vectors_miss_on_missing_hash(conn):
out = load_cached_vectors(conn, "model-a", [text_hash("NECUNOSCUT")])
assert out == {}
def test_load_cached_vectors_blob_corupt_e_miss(conn):
h = text_hash("SCHIMB ULEI")
conn.execute("BEGIN")
conn.execute(
"INSERT INTO embedding_cache (text_hash, model, vector) VALUES (?, ?, ?)",
(h, "model-a", b"\x00\x01\x02"), # lungime gresita
)
conn.execute("COMMIT")
out = load_cached_vectors(conn, "model-a", [h])
assert h not in out
def test_load_cached_vectors_scoped_pe_model(conn):
h = text_hash("SCHIMB ULEI")
save_vectors(conn, "model-a", [(h, _vec(1.0))])
out = load_cached_vectors(conn, "model-b", [h])
assert out == {}
# --------------------------------------------------------------------------- #
# save_vectors: validare dimensiune + chunking #
# --------------------------------------------------------------------------- #
def test_save_vectors_respinge_dimensiune_gresita(conn):
h = text_hash("SCHIMB ULEI")
save_vectors(conn, "model-a", [(h, [1.0, 2.0, 3.0])]) # nu e EMB_DIM
out = load_cached_vectors(conn, "model-a", [h])
assert h not in out
def test_save_vectors_chunking_peste_500_randuri(conn):
items = [(text_hash(f"text-{i}"), _vec(float(i))) for i in range(1200)]
save_vectors(conn, "model-a", items)
hashes = [h for h, _ in items]
out = load_cached_vectors(conn, "model-a", hashes)
assert len(out) == 1200
for h, vec in items:
assert out[h] == pytest.approx(vec, rel=1e-6)
def test_save_vectors_insert_or_replace_idempotent(conn):
h = text_hash("SCHIMB ULEI")
save_vectors(conn, "model-a", [(h, _vec(1.0))])
save_vectors(conn, "model-a", [(h, _vec(2.0))]) # populare intrerupta -> completare
row = conn.execute(
"SELECT COUNT(*) AS n FROM embedding_cache WHERE model=? AND text_hash=?",
("model-a", h),
).fetchone()
assert row["n"] == 1
out = load_cached_vectors(conn, "model-a", [h])
assert out[h] == pytest.approx(_vec(2.0), rel=1e-6)
# --------------------------------------------------------------------------- #
# purge_stale #
# --------------------------------------------------------------------------- #
def test_purge_stale_sterge_orfane_model_curent(conn):
h1, h2 = text_hash("A"), text_hash("B")
save_vectors(conn, "model-a", [(h1, _vec(1.0)), (h2, _vec(2.0))])
purge_stale(conn, "model-a", corpus_hashes=[h1]) # h2 nu mai e in corpus
out = load_cached_vectors(conn, "model-a", [h1, h2])
assert h1 in out
assert h2 not in out
def test_purge_stale_sterge_modele_vechi(conn):
h = text_hash("A")
save_vectors(conn, "model-old", [(h, _vec(1.0))])
save_vectors(conn, "model-a", [(h, _vec(2.0))])
purge_stale(conn, "model-a", corpus_hashes=[h])
assert load_cached_vectors(conn, "model-old", [h]) == {}
assert h in load_cached_vectors(conn, "model-a", [h])
# --------------------------------------------------------------------------- #
# sync_corpus_vectors #
# --------------------------------------------------------------------------- #
def test_sync_corpus_vectors_cold_start(conn):
calls = []
def embed_fn(texts):
calls.append(list(texts))
return [_vec(float(i)) for i in range(len(texts))]
texts = ["A", "B", "C"]
vecs = sync_corpus_vectors(conn, "model-a", texts, embed_fn)
assert len(vecs) == 3
assert calls == [texts] # toate 3 lipsesc din cache -> toate trimise la embed
hashes = [text_hash(t) for t in texts]
cached = load_cached_vectors(conn, "model-a", hashes)
assert len(cached) == 3
def test_sync_corpus_vectors_warm_start_zero_embed_calls(conn):
calls = []
def embed_fn(texts):
calls.append(list(texts))
return [_vec(float(i)) for i in range(len(texts))]
texts = ["A", "B", "C"]
sync_corpus_vectors(conn, "model-a", texts, embed_fn)
calls.clear()
vecs2 = sync_corpus_vectors(conn, "model-a", texts, embed_fn)
assert calls == [] # nimic nou de vectorizat
assert len(vecs2) == 3
def test_sync_corpus_vectors_incremental_un_text_nou(conn):
calls = []
def embed_fn(texts):
calls.append(list(texts))
return [_vec(float(i)) for i in range(len(texts))]
sync_corpus_vectors(conn, "model-a", ["A", "B"], embed_fn)
calls.clear()
sync_corpus_vectors(conn, "model-a", ["A", "B", "C"], embed_fn)
assert calls == [["C"]]
def test_sync_corpus_vectors_nu_purjeaza_singur_apelantul_decide(conn):
"""sync_corpus_vectors NU mai purjeaza -- e responsabilitatea apelantului, DUPA
o indexare reusita (vezi ensure_embeddings_corpus). purge_stale ramane apelabil
separat, explicit, de catre apelant."""
def embed_fn(texts):
return [_vec(float(i)) for i in range(len(texts))]
sync_corpus_vectors(conn, "model-a", ["A", "B"], embed_fn)
sync_corpus_vectors(conn, "model-a", ["A"], embed_fn) # B disparut din corpus solicitat
out = load_cached_vectors(conn, "model-a", [text_hash("A"), text_hash("B")])
assert text_hash("A") in out
assert text_hash("B") in out # nepurjat automat -- sync_corpus_vectors nu mai face asta
purge_stale(conn, "model-a", {text_hash("A")}) # apelantul purjeaza dupa indexare reusita
out2 = load_cached_vectors(conn, "model-a", [text_hash("A"), text_hash("B")])
assert text_hash("A") in out2
assert text_hash("B") not in out2
def test_sync_corpus_vectors_nu_purjeaza_cand_embed_fn_esueaza(conn):
def embed_fn_ok(texts):
return [_vec(float(i)) for i in range(len(texts))]
sync_corpus_vectors(conn, "model-a", ["A", "B"], embed_fn_ok)
def embed_fn_broken(texts):
raise RuntimeError("model indisponibil")
with pytest.raises(RuntimeError):
sync_corpus_vectors(conn, "model-a", ["A", "C"], embed_fn_broken)
# "B" nu a fost purjat -- sync_corpus_vectors nu purjeaza niciodata singur.
out = load_cached_vectors(conn, "model-a", [text_hash("A"), text_hash("B")])
assert text_hash("A") in out
assert text_hash("B") in out
class _LockingConn:
"""Wrapper peste o conexiune reala: simuleaza `database is locked` la BEGIN
(exercita try/except-ul din save_vectors, nu il ocoleste)."""
def __init__(self, real):
self._real = real
def execute(self, sql, *a, **kw):
if sql.strip() == "BEGIN":
raise sqlite3.OperationalError("database is locked")
return self._real.execute(sql, *a, **kw)
def executemany(self, *a, **kw):
return self._real.executemany(*a, **kw)
def __getattr__(self, name):
return getattr(self._real, name)
def test_sync_corpus_vectors_save_partial_esuat_continua_din_ram(conn):
"""Daca save_vectors esueaza (ex. DB locked la BEGIN), vectorii noi tot se
intorc din RAM -- indexarea continua, doar persistarea in cache rateaza."""
locking = _LockingConn(conn)
def embed_fn(texts):
return [_vec(float(i)) for i in range(len(texts))]
vecs = sync_corpus_vectors(locking, "model-a", ["A", "B"], embed_fn)
assert len(vecs) == 2
assert vecs[0] == pytest.approx(_vec(0.0), rel=1e-6)
# Nimic nu a fost persistat (BEGIN a esuat la fiecare chunk).
out = load_cached_vectors(conn, "model-a", [text_hash("A"), text_hash("B")])
assert out == {}