chore: import upstream snapshot with attribution
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
"""Tests for SQLiteVectorIndex using sqlite-vec.
|
||||
|
||||
Tests verify:
|
||||
- Vector indexing and search
|
||||
- True CRUD operations (real deletes)
|
||||
- Filtering by user_id, session_id, etc.
|
||||
- Persistence across instances
|
||||
- Memory stats and bounding
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from headroom.memory.models import Memory
|
||||
from headroom.memory.ports import VectorFilter
|
||||
|
||||
# Check if sqlite-vec is available
|
||||
try:
|
||||
from headroom.memory.adapters.sqlite_vector import is_sqlite_vec_available
|
||||
|
||||
SQLITE_VEC_AVAILABLE = is_sqlite_vec_available()
|
||||
except ImportError:
|
||||
SQLITE_VEC_AVAILABLE = False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SQLITE_VEC_AVAILABLE, reason="sqlite-vec not available")
|
||||
class TestSQLiteVectorIndex:
|
||||
"""Tests for SQLiteVectorIndex."""
|
||||
|
||||
@pytest.fixture
|
||||
def index(self):
|
||||
"""Create a temporary SQLite vector index."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
from headroom.memory.adapters.sqlite_vector import SQLiteVectorIndex
|
||||
|
||||
index = SQLiteVectorIndex(dimension=384, db_path=db_path)
|
||||
yield index
|
||||
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_and_search(self, index):
|
||||
"""Test basic indexing and search."""
|
||||
np.random.seed(42)
|
||||
|
||||
# Create memories with random embeddings
|
||||
memories = []
|
||||
for i in range(10):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Test content {i}",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
memories.append(memory)
|
||||
|
||||
assert index.size == 10
|
||||
|
||||
# Search with first memory's embedding - should find itself
|
||||
filter = VectorFilter(
|
||||
query_vector=memories[0].embedding,
|
||||
top_k=3,
|
||||
user_id="alice",
|
||||
)
|
||||
results = await index.search(filter)
|
||||
|
||||
assert len(results) == 3
|
||||
assert results[0].memory.id == memories[0].id
|
||||
assert results[0].similarity > 0.99 # Should be ~1.0 for exact match
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_true_delete(self, index):
|
||||
"""Test that delete actually removes entries."""
|
||||
np.random.seed(42)
|
||||
|
||||
# Add memories
|
||||
memories = []
|
||||
for i in range(5):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
memories.append(memory)
|
||||
|
||||
assert index.size == 5
|
||||
|
||||
# Delete one
|
||||
result = await index.remove(memories[0].id)
|
||||
assert result is True
|
||||
assert index.size == 4
|
||||
|
||||
# Search should not find deleted memory
|
||||
filter = VectorFilter(
|
||||
query_vector=memories[0].embedding,
|
||||
top_k=10,
|
||||
user_id="alice",
|
||||
)
|
||||
results = await index.search(filter)
|
||||
|
||||
result_ids = {r.memory.id for r in results}
|
||||
assert memories[0].id not in result_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_embedding(self, index):
|
||||
"""Test updating an existing entry."""
|
||||
np.random.seed(42)
|
||||
|
||||
embedding1 = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content="Original content",
|
||||
user_id="alice",
|
||||
embedding=embedding1,
|
||||
)
|
||||
await index.index(memory)
|
||||
|
||||
# Update with new embedding
|
||||
embedding2 = np.random.randn(384).astype(np.float32)
|
||||
memory.embedding = embedding2
|
||||
memory.content = "Updated content"
|
||||
await index.index(memory)
|
||||
|
||||
# Should still be only 1 entry
|
||||
assert index.size == 1
|
||||
|
||||
# Get stored embedding
|
||||
stored = await index.get_embedding(memory.id)
|
||||
assert stored is not None
|
||||
np.testing.assert_array_almost_equal(stored, embedding2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_user(self, index):
|
||||
"""Test filtering search results by user_id."""
|
||||
np.random.seed(42)
|
||||
|
||||
# Create memories for different users with similar embeddings
|
||||
base_embedding = np.random.randn(384).astype(np.float32)
|
||||
|
||||
for user in ["alice", "bob", "charlie"]:
|
||||
# Slightly perturb embedding for each user
|
||||
embedding = base_embedding + np.random.randn(384).astype(np.float32) * 0.1
|
||||
memory = Memory(
|
||||
content=f"Content for {user}",
|
||||
user_id=user,
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
|
||||
# Search filtered by user
|
||||
filter = VectorFilter(
|
||||
query_vector=base_embedding,
|
||||
top_k=10,
|
||||
user_id="alice",
|
||||
)
|
||||
results = await index.search(filter)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].memory.user_id == "alice"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_session(self, index):
|
||||
"""Test filtering by session_id."""
|
||||
np.random.seed(42)
|
||||
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
|
||||
# Same user, different sessions
|
||||
for session in ["session1", "session2", None]:
|
||||
memory = Memory(
|
||||
content=f"Content for {session}",
|
||||
user_id="alice",
|
||||
session_id=session,
|
||||
embedding=embedding + np.random.randn(384).astype(np.float32) * 0.01,
|
||||
)
|
||||
await index.index(memory)
|
||||
|
||||
filter = VectorFilter(
|
||||
query_vector=embedding,
|
||||
top_k=10,
|
||||
user_id="alice",
|
||||
session_id="session1",
|
||||
)
|
||||
results = await index.search(filter)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].memory.session_id == "session1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_min_similarity_filter(self, index):
|
||||
"""Test minimum similarity threshold."""
|
||||
np.random.seed(42)
|
||||
|
||||
# Create memories with varying similarity to query
|
||||
query = np.random.randn(384).astype(np.float32)
|
||||
query = query / np.linalg.norm(query) # Normalize
|
||||
|
||||
# Very similar
|
||||
similar = query + np.random.randn(384).astype(np.float32) * 0.1
|
||||
similar = similar / np.linalg.norm(similar)
|
||||
|
||||
# Less similar
|
||||
less_similar = np.random.randn(384).astype(np.float32)
|
||||
less_similar = less_similar / np.linalg.norm(less_similar)
|
||||
|
||||
await index.index(Memory(content="Similar", user_id="alice", embedding=similar))
|
||||
await index.index(Memory(content="Less similar", user_id="alice", embedding=less_similar))
|
||||
|
||||
# High threshold should filter out dissimilar
|
||||
filter = VectorFilter(
|
||||
query_vector=query,
|
||||
top_k=10,
|
||||
min_similarity=0.8,
|
||||
)
|
||||
results = await index.search(filter)
|
||||
|
||||
# Only the similar one should pass
|
||||
assert len(results) <= 1
|
||||
if len(results) == 1:
|
||||
assert results[0].similarity >= 0.8
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SQLITE_VEC_AVAILABLE, reason="sqlite-vec not available")
|
||||
class TestSQLiteVectorIndexPersistence:
|
||||
"""Tests for persistence across index instances."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_persists_across_instances(self):
|
||||
"""Test that data survives index restart."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
try:
|
||||
from headroom.memory.adapters.sqlite_vector import SQLiteVectorIndex
|
||||
|
||||
# Create index and add data
|
||||
index1 = SQLiteVectorIndex(dimension=384, db_path=db_path)
|
||||
|
||||
np.random.seed(42)
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content="Persistent content",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index1.index(memory)
|
||||
|
||||
memory_id = memory.id
|
||||
|
||||
# Create new index instance
|
||||
index2 = SQLiteVectorIndex(dimension=384, db_path=db_path)
|
||||
|
||||
assert index2.size == 1
|
||||
|
||||
# Should find the memory
|
||||
filter = VectorFilter(
|
||||
query_vector=embedding,
|
||||
top_k=1,
|
||||
)
|
||||
results = await index2.search(filter)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].memory.id == memory_id
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SQLITE_VEC_AVAILABLE, reason="sqlite-vec not available")
|
||||
class TestSQLiteVectorIndexMemoryStats:
|
||||
"""Tests for memory statistics."""
|
||||
|
||||
@pytest.fixture
|
||||
def index(self):
|
||||
"""Create a temporary SQLite vector index."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
from headroom.memory.adapters.sqlite_vector import SQLiteVectorIndex
|
||||
|
||||
index = SQLiteVectorIndex(dimension=384, db_path=db_path, page_cache_size_kb=4096)
|
||||
yield index
|
||||
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_stats(self, index):
|
||||
"""Test memory statistics."""
|
||||
stats = index.get_memory_stats()
|
||||
|
||||
assert stats.name == "sqlite_vector_index"
|
||||
assert stats.entry_count == 0
|
||||
assert stats.budget_bytes == 4096 * 1024 # 4MB cache
|
||||
|
||||
# Add some entries
|
||||
np.random.seed(42)
|
||||
for i in range(10):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
|
||||
stats = index.get_memory_stats()
|
||||
assert stats.entry_count == 10
|
||||
assert stats.size_bytes > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats(self, index):
|
||||
"""Test index statistics."""
|
||||
np.random.seed(42)
|
||||
|
||||
for i in range(5):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice" if i < 3 else "bob",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
|
||||
stats = index.stats()
|
||||
|
||||
assert stats["size"] == 5
|
||||
assert stats["dimension"] == 384
|
||||
assert stats["users"] == 2
|
||||
assert stats["page_cache_size_kb"] == 4096
|
||||
assert stats["db_size_bytes"] > 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SQLITE_VEC_AVAILABLE, reason="sqlite-vec not available")
|
||||
class TestSQLiteVectorIndexEdgeCases:
|
||||
"""Tests for edge cases."""
|
||||
|
||||
@pytest.fixture
|
||||
def index(self):
|
||||
"""Create a temporary SQLite vector index."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
from headroom.memory.adapters.sqlite_vector import SQLiteVectorIndex
|
||||
|
||||
index = SQLiteVectorIndex(dimension=384, db_path=db_path)
|
||||
yield index
|
||||
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty_index(self, index):
|
||||
"""Test searching an empty index."""
|
||||
np.random.seed(42)
|
||||
query = np.random.randn(384).astype(np.float32)
|
||||
|
||||
filter = VectorFilter(query_vector=query, top_k=10)
|
||||
results = await index.search(filter)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nonexistent(self, index):
|
||||
"""Test removing a nonexistent entry."""
|
||||
result = await index.remove("nonexistent-id")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_dimension_raises(self, index):
|
||||
"""Test that wrong embedding dimension raises error."""
|
||||
wrong_embedding = np.random.randn(128).astype(np.float32) # Wrong dimension
|
||||
memory = Memory(
|
||||
content="Test",
|
||||
user_id="alice",
|
||||
embedding=wrong_embedding,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="dimension"):
|
||||
await index.index(memory)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_embedding_raises(self, index):
|
||||
"""Test that missing embedding raises error."""
|
||||
memory = Memory(
|
||||
content="Test",
|
||||
user_id="alice",
|
||||
embedding=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="no embedding"):
|
||||
await index.index(memory)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(self, index):
|
||||
"""Test clearing all entries."""
|
||||
np.random.seed(42)
|
||||
|
||||
for i in range(5):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
|
||||
assert index.size == 5
|
||||
|
||||
index.clear()
|
||||
|
||||
assert index.size == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_index(self, index):
|
||||
"""Test batch indexing."""
|
||||
np.random.seed(42)
|
||||
|
||||
memories = []
|
||||
for i in range(10):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
memories.append(memory)
|
||||
|
||||
# Add one without embedding
|
||||
memories.append(Memory(content="No embedding", user_id="alice"))
|
||||
|
||||
indexed = await index.index_batch(memories)
|
||||
|
||||
assert indexed == 10
|
||||
assert index.size == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_index_uses_single_connection(self, index, monkeypatch):
|
||||
"""Test batch indexing reuses a single sqlite-vec connection."""
|
||||
np.random.seed(42)
|
||||
memories = [
|
||||
Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=np.random.randn(384).astype(np.float32),
|
||||
)
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
original_get_conn = index._get_conn
|
||||
conn_calls = 0
|
||||
|
||||
def counting_get_conn():
|
||||
nonlocal conn_calls
|
||||
conn_calls += 1
|
||||
return original_get_conn()
|
||||
|
||||
monkeypatch.setattr(index, "_get_conn", counting_get_conn)
|
||||
|
||||
indexed = await index.index_batch(memories)
|
||||
|
||||
assert indexed == 10
|
||||
assert conn_calls == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_remove(self, index):
|
||||
"""Test batch removal."""
|
||||
np.random.seed(42)
|
||||
|
||||
memories = []
|
||||
for i in range(5):
|
||||
embedding = np.random.randn(384).astype(np.float32)
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=embedding,
|
||||
)
|
||||
await index.index(memory)
|
||||
memories.append(memory)
|
||||
|
||||
# Remove some
|
||||
ids_to_remove = [memories[0].id, memories[2].id, "nonexistent"]
|
||||
removed = await index.remove_batch(ids_to_remove)
|
||||
|
||||
assert removed == 2
|
||||
assert index.size == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_remove_uses_single_connection(self, index, monkeypatch):
|
||||
"""Test batch removal reuses a single sqlite-vec connection."""
|
||||
np.random.seed(42)
|
||||
memories = []
|
||||
for i in range(5):
|
||||
memory = Memory(
|
||||
content=f"Content {i}",
|
||||
user_id="alice",
|
||||
embedding=np.random.randn(384).astype(np.float32),
|
||||
)
|
||||
await index.index(memory)
|
||||
memories.append(memory)
|
||||
|
||||
original_get_conn = index._get_conn
|
||||
conn_calls = 0
|
||||
|
||||
def counting_get_conn():
|
||||
nonlocal conn_calls
|
||||
conn_calls += 1
|
||||
return original_get_conn()
|
||||
|
||||
monkeypatch.setattr(index, "_get_conn", counting_get_conn)
|
||||
|
||||
removed = await index.remove_batch([memories[0].id, memories[2].id, "nonexistent"])
|
||||
|
||||
assert removed == 2
|
||||
assert conn_calls == 1
|
||||
Reference in New Issue
Block a user