Skip to main content
Glama
test_relationship_discovery.py20.9 kB
"""Contract tests for RelationshipDiscovery (T069-T070). These tests verify the RelationshipDiscovery agent conforms to the ConsolidationAgent contract defined in contracts/agent-api.md. Contract Requirements (RelationshipDiscovery-specific): - scan() MUST find memories with potential connections - scan() MUST NOT return already-related pairs - scan() MUST return list of memory ID pairs (may be empty) - scan() MUST NOT modify any data - process_item() MUST return RelationResult or raise exception - process_item() MUST calculate relation strength - process_item() MUST provide reasoning for relation - process_item() MUST respect confidence threshold - process_item() MUST NOT create spurious relations (precision > 0.8) - If dry_run=True, process_item() MUST NOT modify any data - process_item() SHOULD complete within 5 seconds """ from __future__ import annotations import time from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest from cortexgraph.agents.base import ConsolidationAgent from cortexgraph.agents.models import RelationResult from cortexgraph.storage.models import MemoryStatus if TYPE_CHECKING: from cortexgraph.agents.relationship_discovery import RelationshipDiscovery # ============================================================================= # Contract Test Fixtures # ============================================================================= @pytest.fixture def mock_storage() -> MagicMock: """Create mock storage with memories that have potential relationships.""" storage = MagicMock() now = int(time.time()) # Memory about FastAPI backend storage.memories = { "mem-fastapi": MagicMock( id="mem-fastapi", content="FastAPI backend with PostgreSQL database connection", entities=["FastAPI", "PostgreSQL", "Backend"], tags=["api", "database", "python"], strength=1.2, use_count=5, created_at=now - 86400 * 5, last_used=now - 3600, status=MemoryStatus.ACTIVE, ), # Memory about PostgreSQL configuration - shares entity with mem-fastapi "mem-postgres": MagicMock( id="mem-postgres", content="PostgreSQL configuration for production deployment", entities=["PostgreSQL", "Production", "Database"], tags=["database", "config", "production"], strength=1.3, use_count=8, created_at=now - 86400 * 7, last_used=now - 7200, status=MemoryStatus.ACTIVE, ), # Memory about Backend architecture - shares entity with mem-fastapi "mem-backend": MagicMock( id="mem-backend", content="Backend architecture design decisions", entities=["Backend", "Architecture", "Design"], tags=["architecture", "design"], strength=1.1, use_count=3, created_at=now - 86400 * 10, last_used=now - 86400, status=MemoryStatus.ACTIVE, ), # Unrelated memory - no shared entities "mem-unrelated": MagicMock( id="mem-unrelated", content="Random notes about cooking recipes", entities=["Cooking", "Recipes"], tags=["personal", "food"], strength=1.0, use_count=2, created_at=now - 86400 * 15, last_used=now - 86400 * 3, status=MemoryStatus.ACTIVE, ), # Archived memory - should be excluded "mem-archived": MagicMock( id="mem-archived", content="Archived database notes", entities=["Database", "Archive"], tags=["archive"], strength=1.0, use_count=1, created_at=now - 86400 * 30, last_used=now - 86400 * 20, status=MemoryStatus.ARCHIVED, ), } # Mock existing relations storage.relations = {} def get_memory(memory_id: str) -> MagicMock | None: return storage.memories.get(memory_id) storage.get = get_memory storage.get_memory = get_memory # Mock get_relations_for_memory to return empty for most def get_relations_for_memory(memory_id: str) -> list: return [] storage.get_relations_for_memory = get_relations_for_memory return storage @pytest.fixture def mock_beads_integration() -> MagicMock: """Create mock beads integration.""" beads = MagicMock() beads.create_consolidation_issue = MagicMock(return_value="cortexgraph-relations-001") beads.close_issue = MagicMock() return beads @pytest.fixture def relationship_discovery( mock_storage: MagicMock, mock_beads_integration: MagicMock, ) -> RelationshipDiscovery: """Create RelationshipDiscovery with mocked dependencies.""" from cortexgraph.agents.relationship_discovery import RelationshipDiscovery with ( patch( "cortexgraph.agents.relationship_discovery.get_storage", return_value=mock_storage, ), patch( "cortexgraph.agents.relationship_discovery.create_consolidation_issue", mock_beads_integration.create_consolidation_issue, ), patch( "cortexgraph.agents.relationship_discovery.close_issue", mock_beads_integration.close_issue, ), ): discovery = RelationshipDiscovery(dry_run=True) discovery._storage = mock_storage discovery._beads = mock_beads_integration return discovery # ============================================================================= # T069: Contract Test - scan() Finds Relationship Candidates # ============================================================================= class TestRelationshipDiscoveryScanContract: """Contract tests for RelationshipDiscovery.scan() method (T069).""" def test_scan_returns_list(self, relationship_discovery: RelationshipDiscovery) -> None: """scan() MUST return a list.""" result = relationship_discovery.scan() assert isinstance(result, list) def test_scan_returns_string_pairs(self, relationship_discovery: RelationshipDiscovery) -> None: """scan() MUST return list of string pair identifiers.""" result = relationship_discovery.scan() # Each item should be a string (pair identifier like "mem-1:mem-2") for item in result: assert isinstance(item, str) def test_scan_may_return_empty(self, mock_beads_integration: MagicMock) -> None: """scan() MAY return empty list when no relationship candidates.""" from cortexgraph.agents.relationship_discovery import RelationshipDiscovery # Create storage with only one memory (can't have relationships) storage = MagicMock() now = int(time.time()) storage.memories = { "mem-1": MagicMock( id="mem-1", content="Single memory", entities=["Entity"], tags=["tag"], strength=1.0, use_count=1, created_at=now - 86400, last_used=now - 3600, status=MemoryStatus.ACTIVE, ), } storage.get_relations_for_memory = MagicMock(return_value=[]) with ( patch( "cortexgraph.agents.relationship_discovery.get_storage", return_value=storage, ), patch( "cortexgraph.agents.relationship_discovery.create_consolidation_issue", mock_beads_integration.create_consolidation_issue, ), ): discovery = RelationshipDiscovery(dry_run=True) discovery._storage = storage result = discovery.scan() # May be empty if no relationship candidates assert isinstance(result, list) def test_scan_excludes_already_related_pairs( self, mock_storage: MagicMock, mock_beads_integration: MagicMock ) -> None: """scan() MUST NOT return already-related pairs.""" from cortexgraph.agents.relationship_discovery import RelationshipDiscovery # Mock existing relation between mem-fastapi and mem-postgres existing_relation = MagicMock() existing_relation.from_memory_id = "mem-fastapi" existing_relation.to_memory_id = "mem-postgres" existing_relation.relation_type = "related" # Add to relations dict (implementation checks this) mock_storage.relations = {"existing-rel-1": existing_relation} def get_relations_for_memory(memory_id: str) -> list: if memory_id == "mem-fastapi": return [existing_relation] if memory_id == "mem-postgres": return [existing_relation] return [] mock_storage.get_relations_for_memory = get_relations_for_memory with ( patch( "cortexgraph.agents.relationship_discovery.get_storage", return_value=mock_storage, ), patch( "cortexgraph.agents.relationship_discovery.create_consolidation_issue", mock_beads_integration.create_consolidation_issue, ), ): discovery = RelationshipDiscovery(dry_run=True) discovery._storage = mock_storage result = discovery.scan() # Already-related pair should not appear for pair_id in result: # Check both orderings assert pair_id != "mem-fastapi:mem-postgres" assert pair_id != "mem-postgres:mem-fastapi" def test_scan_excludes_archived_memories( self, relationship_discovery: RelationshipDiscovery ) -> None: """scan() MUST NOT include archived memories.""" result = relationship_discovery.scan() # No pair should include the archived memory for pair_id in result: assert "mem-archived" not in pair_id def test_scan_does_not_modify_data( self, relationship_discovery: RelationshipDiscovery, mock_storage: MagicMock ) -> None: """scan() MUST NOT modify any data.""" # Take snapshot before original_count = len(mock_storage.memories) original_relations = len(mock_storage.relations) # Run scan relationship_discovery.scan() # Verify no changes assert len(mock_storage.memories) == original_count assert len(mock_storage.relations) == original_relations def test_scan_is_subclass_of_consolidation_agent( self, relationship_discovery: RelationshipDiscovery ) -> None: """RelationshipDiscovery MUST inherit from ConsolidationAgent.""" assert isinstance(relationship_discovery, ConsolidationAgent) # ============================================================================= # T070: Contract Test - process_item() Returns RelationResult # ============================================================================= class TestRelationshipDiscoveryProcessItemContract: """Contract tests for RelationshipDiscovery.process_item() method (T070).""" def test_process_item_returns_relation_result( self, relationship_discovery: RelationshipDiscovery ) -> None: """process_item() MUST return RelationResult.""" pair_ids = relationship_discovery.scan() if pair_ids: result = relationship_discovery.process_item(pair_ids[0]) assert isinstance(result, RelationResult) def test_process_item_result_has_required_fields( self, relationship_discovery: RelationshipDiscovery ) -> None: """RelationResult MUST have all required fields.""" pair_ids = relationship_discovery.scan() if pair_ids: result = relationship_discovery.process_item(pair_ids[0]) # Required fields from contracts/agent-api.md assert hasattr(result, "from_memory_id") assert hasattr(result, "to_memory_id") assert hasattr(result, "relation_id") assert hasattr(result, "strength") assert hasattr(result, "reasoning") assert hasattr(result, "confidence") # Types assert isinstance(result.from_memory_id, str) assert isinstance(result.to_memory_id, str) assert isinstance(result.relation_id, str) assert isinstance(result.strength, float) assert isinstance(result.reasoning, str) assert isinstance(result.confidence, float) def test_process_item_strength_in_valid_range( self, relationship_discovery: RelationshipDiscovery ) -> None: """strength MUST be in range [0.0, 1.0].""" pair_ids = relationship_discovery.scan() if pair_ids: result = relationship_discovery.process_item(pair_ids[0]) assert 0.0 <= result.strength <= 1.0 def test_process_item_confidence_in_valid_range( self, relationship_discovery: RelationshipDiscovery ) -> None: """confidence MUST be in range [0.0, 1.0].""" pair_ids = relationship_discovery.scan() if pair_ids: result = relationship_discovery.process_item(pair_ids[0]) assert 0.0 <= result.confidence <= 1.0 def test_process_item_reasoning_not_empty( self, relationship_discovery: RelationshipDiscovery ) -> None: """reasoning MUST NOT be empty.""" pair_ids = relationship_discovery.scan() if pair_ids: result = relationship_discovery.process_item(pair_ids[0]) assert len(result.reasoning) > 0 def test_process_item_raises_on_invalid_pair_id( self, relationship_discovery: RelationshipDiscovery ) -> None: """process_item() MUST raise exception for invalid pair ID.""" with pytest.raises((ValueError, KeyError, RuntimeError)): relationship_discovery.process_item("nonexistent:pair") def test_process_item_dry_run_no_side_effects( self, mock_storage: MagicMock, mock_beads_integration: MagicMock, ) -> None: """If dry_run=True, process_item() MUST NOT modify any data.""" from cortexgraph.agents.relationship_discovery import RelationshipDiscovery with ( patch( "cortexgraph.agents.relationship_discovery.get_storage", return_value=mock_storage, ), patch( "cortexgraph.agents.relationship_discovery.create_consolidation_issue", mock_beads_integration.create_consolidation_issue, ), patch( "cortexgraph.agents.relationship_discovery.close_issue", mock_beads_integration.close_issue, ), ): discovery = RelationshipDiscovery(dry_run=True) discovery._storage = mock_storage discovery._beads = mock_beads_integration # Track calls that would modify data mock_storage.create_relation = MagicMock() pair_ids = discovery.scan() if pair_ids: discovery.process_item(pair_ids[0]) # In dry_run mode, no relations should be created mock_storage.create_relation.assert_not_called() def test_process_item_completes_within_timeout( self, relationship_discovery: RelationshipDiscovery ) -> None: """process_item() SHOULD complete within 5 seconds.""" pair_ids = relationship_discovery.scan() if pair_ids: start = time.time() relationship_discovery.process_item(pair_ids[0]) elapsed = time.time() - start assert elapsed < 5.0, f"process_item took {elapsed:.2f}s (limit: 5s)" # ============================================================================= # Contract Integration Tests # ============================================================================= class TestRelationshipDiscoveryFullContract: """Integration tests verifying full contract compliance.""" def test_run_method_uses_scan_and_process_item( self, relationship_discovery: RelationshipDiscovery ) -> None: """run() MUST call scan() then process_item() for each result.""" results = relationship_discovery.run() # All results should be RelationResult for result in results: assert isinstance(result, RelationResult) def test_shared_entities_populated(self, relationship_discovery: RelationshipDiscovery) -> None: """shared_entities SHOULD be populated for related memories.""" pair_ids = relationship_discovery.scan() if pair_ids: result = relationship_discovery.process_item(pair_ids[0]) # If relation was created, shared_entities should be populated # (may be empty if relation based on other factors) assert isinstance(result.shared_entities, list) def test_precision_threshold_respected( self, mock_storage: MagicMock, mock_beads_integration: MagicMock ) -> None: """MUST NOT create spurious relations (precision > 0.8 requirement).""" from cortexgraph.agents.relationship_discovery import RelationshipDiscovery # Create memories with very weak potential relationship now = int(time.time()) mock_storage.memories = { "mem-weak-1": MagicMock( id="mem-weak-1", content="Something about topic A", entities=["TopicA"], tags=["tag1"], strength=1.0, use_count=1, created_at=now - 86400, last_used=now - 3600, status=MemoryStatus.ACTIVE, ), "mem-weak-2": MagicMock( id="mem-weak-2", content="Something about topic B", entities=["TopicB"], # No overlap tags=["tag2"], # No overlap strength=1.0, use_count=1, created_at=now - 86400, last_used=now - 3600, status=MemoryStatus.ACTIVE, ), } mock_storage.get_relations_for_memory = MagicMock(return_value=[]) with ( patch( "cortexgraph.agents.relationship_discovery.get_storage", return_value=mock_storage, ), patch( "cortexgraph.agents.relationship_discovery.create_consolidation_issue", mock_beads_integration.create_consolidation_issue, ), ): discovery = RelationshipDiscovery(dry_run=True, min_confidence=0.8) discovery._storage = mock_storage # With no shared entities and high confidence threshold, # should not find this as a candidate results = discovery.run() # If any results, confidence should meet threshold for result in results: assert result.confidence >= 0.8 def test_run_handles_errors_gracefully( self, mock_storage: MagicMock, mock_beads_integration: MagicMock, ) -> None: """run() MUST handle errors per-item without aborting all.""" from cortexgraph.agents.relationship_discovery import RelationshipDiscovery # Add a broken memory that might cause issues mock_storage.memories["mem-broken"] = MagicMock( id="mem-broken", content=None, # This might cause issues entities=None, tags=None, strength=None, use_count=None, created_at=None, last_used=None, status=MemoryStatus.ACTIVE, ) with ( patch( "cortexgraph.agents.relationship_discovery.get_storage", return_value=mock_storage, ), patch( "cortexgraph.agents.relationship_discovery.create_consolidation_issue", mock_beads_integration.create_consolidation_issue, ), patch( "cortexgraph.agents.relationship_discovery.close_issue", mock_beads_integration.close_issue, ), ): discovery = RelationshipDiscovery(dry_run=True) discovery._storage = mock_storage discovery._beads = mock_beads_integration # Should not raise - should skip errors and continue results = discovery.run() # Should still produce some results from valid memories assert isinstance(results, list)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/prefrontalsys/mnemex'

If you have feedback or need assistance with the MCP directory API, please join our Discord server