# tests/unit/services/memory/indexing/test_index.py """Unit tests for memory indexing.""" from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest from app.services.memory.indexing.index import ( EntityIndex, MemoryIndexer, OutcomeIndex, TemporalIndex, VectorIndex, get_memory_indexer, ) from app.services.memory.types import Episode, Fact, MemoryType, Outcome, Procedure def _utcnow() -> datetime: """Get current UTC time.""" return datetime.now(UTC) def make_episode( embedding: list[float] | None = None, outcome: Outcome = Outcome.SUCCESS, occurred_at: datetime | None = None, ) -> Episode: """Create a test episode.""" return Episode( id=uuid4(), project_id=uuid4(), agent_instance_id=uuid4(), agent_type_id=uuid4(), session_id="test-session", task_type="test_task", task_description="Test task description", actions=[{"action": "test"}], context_summary="Test context", outcome=outcome, outcome_details="Test outcome", duration_seconds=10.0, tokens_used=100, lessons_learned=["lesson1"], importance_score=0.8, embedding=embedding, occurred_at=occurred_at or _utcnow(), created_at=_utcnow(), updated_at=_utcnow(), ) def make_fact( embedding: list[float] | None = None, subject: str = "test_subject", predicate: str = "has_property", obj: str = "test_value", ) -> Fact: """Create a test fact.""" return Fact( id=uuid4(), project_id=uuid4(), subject=subject, predicate=predicate, object=obj, confidence=0.9, source_episode_ids=[uuid4()], first_learned=_utcnow(), last_reinforced=_utcnow(), reinforcement_count=1, embedding=embedding, created_at=_utcnow(), updated_at=_utcnow(), ) def make_procedure( embedding: list[float] | None = None, success_count: int = 8, failure_count: int = 2, ) -> Procedure: """Create a test procedure.""" return Procedure( id=uuid4(), project_id=uuid4(), agent_type_id=uuid4(), name="test_procedure", trigger_pattern="test.*", steps=[{"step": 1, "action": "test"}], success_count=success_count, failure_count=failure_count, last_used=_utcnow(), embedding=embedding, created_at=_utcnow(), updated_at=_utcnow(), ) class TestVectorIndex: """Tests for VectorIndex.""" @pytest.fixture def index(self) -> VectorIndex[Episode]: """Create a vector index.""" return VectorIndex[Episode](dimension=4) @pytest.mark.asyncio async def test_add_item(self, index: VectorIndex[Episode]) -> None: """Test adding an item to the index.""" episode = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) entry = await index.add(episode) assert entry.memory_id == episode.id assert entry.memory_type == MemoryType.EPISODIC assert entry.dimension == 4 assert await index.count() == 1 @pytest.mark.asyncio async def test_remove_item(self, index: VectorIndex[Episode]) -> None: """Test removing an item from the index.""" episode = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) await index.add(episode) result = await index.remove(episode.id) assert result is True assert await index.count() == 0 @pytest.mark.asyncio async def test_remove_nonexistent(self, index: VectorIndex[Episode]) -> None: """Test removing a nonexistent item.""" result = await index.remove(uuid4()) assert result is False @pytest.mark.asyncio async def test_search_similar(self, index: VectorIndex[Episode]) -> None: """Test searching for similar items.""" # Add items with different embeddings e1 = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) e2 = make_episode(embedding=[0.9, 0.1, 0.0, 0.0]) e3 = make_episode(embedding=[0.0, 1.0, 0.0, 0.0]) await index.add(e1) await index.add(e2) await index.add(e3) # Search for similar to first results = await index.search([1.0, 0.0, 0.0, 0.0], limit=2) assert len(results) == 2 # First result should be most similar assert results[0].memory_id == e1.id @pytest.mark.asyncio async def test_search_min_similarity(self, index: VectorIndex[Episode]) -> None: """Test minimum similarity threshold.""" e1 = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) e2 = make_episode(embedding=[0.0, 1.0, 0.0, 0.0]) # Orthogonal await index.add(e1) await index.add(e2) # Search with high threshold results = await index.search([1.0, 0.0, 0.0, 0.0], min_similarity=0.9) assert len(results) == 1 assert results[0].memory_id == e1.id @pytest.mark.asyncio async def test_search_empty_query(self, index: VectorIndex[Episode]) -> None: """Test search with empty query.""" e1 = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) await index.add(e1) results = await index.search([], limit=10) assert len(results) == 0 @pytest.mark.asyncio async def test_clear(self, index: VectorIndex[Episode]) -> None: """Test clearing the index.""" await index.add(make_episode(embedding=[1.0, 0.0, 0.0, 0.0])) await index.add(make_episode(embedding=[0.0, 1.0, 0.0, 0.0])) count = await index.clear() assert count == 2 assert await index.count() == 0 class TestTemporalIndex: """Tests for TemporalIndex.""" @pytest.fixture def index(self) -> TemporalIndex[Episode]: """Create a temporal index.""" return TemporalIndex[Episode]() @pytest.mark.asyncio async def test_add_item(self, index: TemporalIndex[Episode]) -> None: """Test adding an item.""" episode = make_episode() entry = await index.add(episode) assert entry.memory_id == episode.id assert await index.count() == 1 @pytest.mark.asyncio async def test_search_by_time_range(self, index: TemporalIndex[Episode]) -> None: """Test searching by time range.""" now = _utcnow() old = make_episode(occurred_at=now - timedelta(hours=2)) recent = make_episode(occurred_at=now - timedelta(hours=1)) newest = make_episode(occurred_at=now) await index.add(old) await index.add(recent) await index.add(newest) # Search last hour results = await index.search( query=None, start_time=now - timedelta(hours=1, minutes=30), end_time=now, ) assert len(results) == 2 @pytest.mark.asyncio async def test_search_recent(self, index: TemporalIndex[Episode]) -> None: """Test searching for recent items.""" now = _utcnow() old = make_episode(occurred_at=now - timedelta(hours=2)) recent = make_episode(occurred_at=now - timedelta(minutes=30)) await index.add(old) await index.add(recent) # Search last hour (3600 seconds) results = await index.search(query=None, recent_seconds=3600) assert len(results) == 1 assert results[0].memory_id == recent.id @pytest.mark.asyncio async def test_search_order(self, index: TemporalIndex[Episode]) -> None: """Test result ordering.""" now = _utcnow() e1 = make_episode(occurred_at=now - timedelta(hours=2)) e2 = make_episode(occurred_at=now - timedelta(hours=1)) e3 = make_episode(occurred_at=now) await index.add(e1) await index.add(e2) await index.add(e3) # Descending order (newest first) results_desc = await index.search(query=None, order="desc", limit=10) assert results_desc[0].memory_id == e3.id # Ascending order (oldest first) results_asc = await index.search(query=None, order="asc", limit=10) assert results_asc[0].memory_id == e1.id class TestEntityIndex: """Tests for EntityIndex.""" @pytest.fixture def index(self) -> EntityIndex[Fact]: """Create an entity index.""" return EntityIndex[Fact]() @pytest.mark.asyncio async def test_add_item(self, index: EntityIndex[Fact]) -> None: """Test adding an item.""" fact = make_fact(subject="user", obj="admin") entry = await index.add(fact) assert entry.memory_id == fact.id assert await index.count() == 1 @pytest.mark.asyncio async def test_search_by_entity(self, index: EntityIndex[Fact]) -> None: """Test searching by entity.""" f1 = make_fact(subject="user", obj="admin") f2 = make_fact(subject="system", obj="config") await index.add(f1) await index.add(f2) results = await index.search( query=None, entity_type="subject", entity_value="user", ) assert len(results) == 1 assert results[0].memory_id == f1.id @pytest.mark.asyncio async def test_search_multiple_entities(self, index: EntityIndex[Fact]) -> None: """Test searching with multiple entities.""" f1 = make_fact(subject="user", obj="admin") f2 = make_fact(subject="user", obj="guest") await index.add(f1) await index.add(f2) # Search for facts about "user" subject results = await index.search( query=None, entities=[("subject", "user")], ) assert len(results) == 2 @pytest.mark.asyncio async def test_search_match_all(self, index: EntityIndex[Fact]) -> None: """Test matching all entities.""" f1 = make_fact(subject="user", obj="admin") f2 = make_fact(subject="user", obj="guest") await index.add(f1) await index.add(f2) # Search for user+admin (match all) results = await index.search( query=None, entities=[("subject", "user"), ("object", "admin")], match_all=True, ) assert len(results) == 1 assert results[0].memory_id == f1.id @pytest.mark.asyncio async def test_get_entities(self, index: EntityIndex[Fact]) -> None: """Test getting entities for a memory.""" fact = make_fact(subject="user", obj="admin") await index.add(fact) entities = await index.get_entities(fact.id) assert ("subject", "user") in entities assert ("object", "admin") in entities class TestOutcomeIndex: """Tests for OutcomeIndex.""" @pytest.fixture def index(self) -> OutcomeIndex[Episode]: """Create an outcome index.""" return OutcomeIndex[Episode]() @pytest.mark.asyncio async def test_add_item(self, index: OutcomeIndex[Episode]) -> None: """Test adding an item.""" episode = make_episode(outcome=Outcome.SUCCESS) entry = await index.add(episode) assert entry.memory_id == episode.id assert entry.outcome == Outcome.SUCCESS assert await index.count() == 1 @pytest.mark.asyncio async def test_search_by_outcome(self, index: OutcomeIndex[Episode]) -> None: """Test searching by outcome.""" success = make_episode(outcome=Outcome.SUCCESS) failure = make_episode(outcome=Outcome.FAILURE) await index.add(success) await index.add(failure) results = await index.search(query=None, outcome=Outcome.SUCCESS) assert len(results) == 1 assert results[0].memory_id == success.id @pytest.mark.asyncio async def test_search_multiple_outcomes(self, index: OutcomeIndex[Episode]) -> None: """Test searching with multiple outcomes.""" success = make_episode(outcome=Outcome.SUCCESS) partial = make_episode(outcome=Outcome.PARTIAL) failure = make_episode(outcome=Outcome.FAILURE) await index.add(success) await index.add(partial) await index.add(failure) results = await index.search( query=None, outcomes=[Outcome.SUCCESS, Outcome.PARTIAL], ) assert len(results) == 2 @pytest.mark.asyncio async def test_get_outcome_stats(self, index: OutcomeIndex[Episode]) -> None: """Test getting outcome statistics.""" await index.add(make_episode(outcome=Outcome.SUCCESS)) await index.add(make_episode(outcome=Outcome.SUCCESS)) await index.add(make_episode(outcome=Outcome.FAILURE)) stats = await index.get_outcome_stats() assert stats[Outcome.SUCCESS] == 2 assert stats[Outcome.FAILURE] == 1 assert stats[Outcome.PARTIAL] == 0 class TestMemoryIndexer: """Tests for MemoryIndexer.""" @pytest.fixture def indexer(self) -> MemoryIndexer: """Create a memory indexer.""" return MemoryIndexer() @pytest.mark.asyncio async def test_index_episode(self, indexer: MemoryIndexer) -> None: """Test indexing an episode.""" episode = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) results = await indexer.index(episode) assert "vector" in results assert "temporal" in results assert "entity" in results assert "outcome" in results @pytest.mark.asyncio async def test_index_fact(self, indexer: MemoryIndexer) -> None: """Test indexing a fact.""" fact = make_fact(embedding=[1.0, 0.0, 0.0, 0.0]) results = await indexer.index(fact) # Facts don't have outcomes assert "vector" in results assert "temporal" in results assert "entity" in results assert "outcome" not in results @pytest.mark.asyncio async def test_remove_from_all(self, indexer: MemoryIndexer) -> None: """Test removing from all indices.""" episode = make_episode(embedding=[1.0, 0.0, 0.0, 0.0]) await indexer.index(episode) results = await indexer.remove(episode.id) assert results["vector"] is True assert results["temporal"] is True assert results["entity"] is True assert results["outcome"] is True @pytest.mark.asyncio async def test_clear_all(self, indexer: MemoryIndexer) -> None: """Test clearing all indices.""" await indexer.index(make_episode(embedding=[1.0, 0.0, 0.0, 0.0])) await indexer.index(make_episode(embedding=[0.0, 1.0, 0.0, 0.0])) counts = await indexer.clear_all() assert counts["vector"] == 2 assert counts["temporal"] == 2 @pytest.mark.asyncio async def test_get_stats(self, indexer: MemoryIndexer) -> None: """Test getting index statistics.""" await indexer.index(make_episode(embedding=[1.0, 0.0, 0.0, 0.0])) stats = await indexer.get_stats() assert stats["vector"] == 1 assert stats["temporal"] == 1 assert stats["entity"] == 1 assert stats["outcome"] == 1 class TestGetMemoryIndexer: """Tests for singleton getter.""" def test_returns_instance(self) -> None: """Test that getter returns instance.""" indexer = get_memory_indexer() assert indexer is not None assert isinstance(indexer, MemoryIndexer) def test_returns_same_instance(self) -> None: """Test that getter returns same instance.""" indexer1 = get_memory_indexer() indexer2 = get_memory_indexer() assert indexer1 is indexer2