Skip to main content
Glama
test_decay_analyzer.py12 kB
"""Contract tests for DecayAnalyzer (T023-T024). These tests verify the DecayAnalyzer conforms to the ConsolidationAgent contract defined in contracts/agent-api.md. Contract Requirements: - scan() MUST return list of memory IDs (may be empty) - scan() MUST NOT modify any data - process_item() MUST return DecayResult or raise exception - 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 DecayAction, DecayResult, Urgency if TYPE_CHECKING: from cortexgraph.agents.decay_analyzer import DecayAnalyzer # ============================================================================= # Contract Test Fixtures # ============================================================================= # Score mapping used by mock_compute_score MOCK_SCORES = { "mem-critical": 0.05, # Critical - near forget "mem-danger": 0.20, # Danger zone "mem-low": 0.30, # Low urgency "mem-healthy": 0.75, # Healthy - should NOT be scanned } def mock_compute_score(memory) -> float: """Mock score computation to return controlled values.""" return MOCK_SCORES.get(memory.id, 0.5) @pytest.fixture def mock_storage() -> MagicMock: """Create mock storage with test memories.""" storage = MagicMock() # Create test memories with various decay scores storage.memories = { "mem-critical": MagicMock( id="mem-critical", content="Critical memory", last_used=int(time.time()) - 86400 * 7, # 7 days ago use_count=1, strength=1.0, ), "mem-danger": MagicMock( id="mem-danger", content="Danger zone memory", last_used=int(time.time()) - 86400 * 3, # 3 days ago use_count=2, strength=1.0, ), "mem-low": MagicMock( id="mem-low", content="Low urgency memory", last_used=int(time.time()) - 86400, # 1 day ago use_count=3, strength=1.0, ), "mem-healthy": MagicMock( id="mem-healthy", content="Healthy memory", last_used=int(time.time()), use_count=10, strength=1.5, ), } return storage @pytest.fixture def decay_analyzer(mock_storage: MagicMock) -> DecayAnalyzer: """Create DecayAnalyzer with mock storage and controlled score computation.""" from cortexgraph.agents.decay_analyzer import DecayAnalyzer with patch("cortexgraph.agents.decay_analyzer.get_storage", return_value=mock_storage): analyzer = DecayAnalyzer(dry_run=True) analyzer._storage = mock_storage # Patch _compute_score to return controlled values analyzer._compute_score = mock_compute_score return analyzer # ============================================================================= # T023: Contract Test - scan() Returns Memory IDs # ============================================================================= class TestDecayAnalyzerScanContract: """Contract tests for DecayAnalyzer.scan() method (T023).""" def test_scan_returns_list(self, decay_analyzer: DecayAnalyzer) -> None: """scan() MUST return a list.""" result = decay_analyzer.scan() assert isinstance(result, list) def test_scan_returns_string_ids(self, decay_analyzer: DecayAnalyzer) -> None: """scan() MUST return list of string memory IDs.""" result = decay_analyzer.scan() for item in result: assert isinstance(item, str) def test_scan_may_return_empty(self, mock_storage: MagicMock) -> None: """scan() MAY return empty list when no memories need triage.""" from cortexgraph.agents.decay_analyzer import DecayAnalyzer # All memories healthy mock_storage.memories = { "mem-healthy": MagicMock(id="mem-healthy", score=0.9), } with patch("cortexgraph.agents.decay_analyzer.get_storage", return_value=mock_storage): analyzer = DecayAnalyzer(dry_run=True) analyzer._storage = mock_storage result = analyzer.scan() assert result == [] def test_scan_only_returns_low_score_memories( self, decay_analyzer: DecayAnalyzer, mock_storage: MagicMock ) -> None: """scan() MUST only return memories with score < threshold (0.35).""" result = decay_analyzer.scan() # Should include mem-critical (0.05), mem-danger (0.20), mem-low (0.30) # Should NOT include mem-healthy (0.75) assert "mem-healthy" not in result assert len(result) == 3 def test_scan_does_not_modify_data( self, decay_analyzer: DecayAnalyzer, mock_storage: MagicMock ) -> None: """scan() MUST NOT modify any data.""" # Take snapshot before original_scores = {k: v.score for k, v in mock_storage.memories.items()} # Run scan decay_analyzer.scan() # Verify no changes for mem_id, original_score in original_scores.items(): assert mock_storage.memories[mem_id].score == original_score def test_scan_is_subclass_of_consolidation_agent(self, decay_analyzer: DecayAnalyzer) -> None: """DecayAnalyzer MUST inherit from ConsolidationAgent.""" assert isinstance(decay_analyzer, ConsolidationAgent) # ============================================================================= # T024: Contract Test - process_item() Returns DecayResult # ============================================================================= class TestDecayAnalyzerProcessItemContract: """Contract tests for DecayAnalyzer.process_item() method (T024).""" def test_process_item_returns_decay_result(self, decay_analyzer: DecayAnalyzer) -> None: """process_item() MUST return DecayResult.""" result = decay_analyzer.process_item("mem-critical") assert isinstance(result, DecayResult) def test_process_item_result_has_required_fields(self, decay_analyzer: DecayAnalyzer) -> None: """DecayResult MUST have all required fields.""" result = decay_analyzer.process_item("mem-danger") # Required fields assert hasattr(result, "memory_id") assert hasattr(result, "score") assert hasattr(result, "urgency") assert hasattr(result, "action") # Types assert isinstance(result.memory_id, str) assert isinstance(result.score, float) assert isinstance(result.urgency, Urgency) assert isinstance(result.action, DecayAction) def test_process_item_score_in_range(self, decay_analyzer: DecayAnalyzer) -> None: """DecayResult.score MUST be in range [0.0, 1.0].""" result = decay_analyzer.process_item("mem-critical") assert 0.0 <= result.score <= 1.0 def test_process_item_memory_id_matches_input(self, decay_analyzer: DecayAnalyzer) -> None: """DecayResult.memory_id MUST match input memory_id.""" result = decay_analyzer.process_item("mem-danger") assert result.memory_id == "mem-danger" def test_process_item_raises_on_invalid_id(self, decay_analyzer: DecayAnalyzer) -> None: """process_item() MUST raise exception for invalid memory ID.""" with pytest.raises((ValueError, KeyError, RuntimeError)): decay_analyzer.process_item("nonexistent-memory") def test_process_item_dry_run_no_side_effects(self, mock_storage: MagicMock) -> None: """If dry_run=True, process_item() MUST NOT modify any data.""" from cortexgraph.agents.decay_analyzer import DecayAnalyzer with patch("cortexgraph.agents.decay_analyzer.get_storage", return_value=mock_storage): analyzer = DecayAnalyzer(dry_run=True) analyzer._storage = mock_storage # Track calls that would modify data mock_storage.update_memory = MagicMock() mock_storage.delete_memory = MagicMock() analyzer.process_item("mem-critical") # In dry_run mode, no modifications should occur mock_storage.update_memory.assert_not_called() mock_storage.delete_memory.assert_not_called() def test_process_item_completes_within_timeout(self, decay_analyzer: DecayAnalyzer) -> None: """process_item() SHOULD complete within 5 seconds.""" start = time.time() decay_analyzer.process_item("mem-critical") elapsed = time.time() - start assert elapsed < 5.0, f"process_item took {elapsed:.2f}s (limit: 5s)" # ============================================================================= # Contract Integration Tests # ============================================================================= class TestDecayAnalyzerFullContract: """Integration tests verifying full contract compliance.""" def test_run_method_uses_scan_and_process_item(self, decay_analyzer: DecayAnalyzer) -> None: """run() MUST call scan() then process_item() for each result.""" results = decay_analyzer.run() # Should have processed all scanned memories assert len(results) == 3 # mem-critical, mem-danger, mem-low # All results should be DecayResult for result in results: assert isinstance(result, DecayResult) def test_run_respects_rate_limit(self, mock_storage: MagicMock) -> None: """run() MUST respect rate_limit setting (adds delays, doesn't cap count).""" from cortexgraph.agents.decay_analyzer import DecayAnalyzer # Create a small number of memories to avoid long test mock_storage.memories = {} # Clear existing memories for i in range(3): mock_storage.memories[f"mem-{i}"] = MagicMock( id=f"mem-{i}", content=f"Memory {i}", use_count=1, strength=1.0, ) def rate_test_scores(memory) -> float: """Return danger zone score for all test memories.""" return 0.10 # All in danger zone with patch("cortexgraph.agents.decay_analyzer.get_storage", return_value=mock_storage): analyzer = DecayAnalyzer(dry_run=True, rate_limit=100) # High limit analyzer._storage = mock_storage analyzer._compute_score = rate_test_scores # Rate limiting doesn't cap results - it adds delays # All memories should still be processed results = analyzer.run() assert len(results) == 3 # All should be processed def test_run_handles_errors_gracefully( self, decay_analyzer: DecayAnalyzer, mock_storage: MagicMock ) -> None: """run() MUST handle errors per-item without aborting all.""" # Add a memory that will cause an error during score computation mock_storage.memories["mem-error"] = MagicMock( id="mem-error", use_count=None, # Invalid - will cause error during score computation strength=1.0, ) # Update _compute_score to raise for mem-error original_scores = dict(MOCK_SCORES) original_scores["mem-error"] = 0.10 # Would be scanned if no error def score_with_error(memory) -> float: if memory.id == "mem-error": raise TypeError("Cannot compute score for invalid memory") return original_scores.get(memory.id, 0.5) # Apply the error-causing score function decay_analyzer._compute_score = score_with_error # Should not raise - should skip error and continue results = decay_analyzer.run() # The 3 original memories should still succeed assert len(results) >= 3

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