Repository: kdunee/intentguard

Issue:
Implement Result Caching for Assertions
**Why:**

- Avoid redundant LLM calls for unchanged code.
- Speed up local execution by caching assertion results.
- Improve overall performance and efficiency.

**What:**

- Design a caching mechanism for storing assertion results.
- Integrate caching into the assertion evaluation process.
- Determine cache keys based on assertion input and code.
- Implement cache invalidation when code changes are detected.
- Handle cache misses and perform LLM calls when necessary.

**Acceptance Criteria:**

- Assertion results are cached successfully.
- Subsequent evaluations with the same input use cached results.
- Cache is invalidated when relevant code changes are made.

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 16dfdf8..f7bdbab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 /.ruff_cache
 /.venv
-__pycache__
\ No newline at end of file
+__pycache__
+.intentguard
diff --git a/ROADMAP.md b/ROADMAP.md
index 082c7ce..bec34bd 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,7 +4,7 @@
   - Detailed reasoning for why assertions failed
   - Suggestions for fixing the issues
 
-- [ ] Result Caching (https://github.com/kdunee/intentguard/issues/3)
+- [x] Result Caching (https://github.com/kdunee/intentguard/issues/3)
   - Speed up local execution by caching assertion results
   - Avoid redundant LLM calls for unchanged code
 
diff --git a/intentguard/cache.py b/intentguard/cache.py
new file mode 100644
index 0000000..f43f1bb
--- /dev/null
+++ b/intentguard/cache.py
@@ -0,0 +1,116 @@
+"""
+This module implements a caching system for IntentGuard results to avoid redundant API calls.
+It stores results in JSON files within a .intentguard directory, using SHA-256 hashes as cache keys.
+"""
+
+import os
+import hashlib
+import json
+
+# Directory where cache files will be stored
+CACHE_DIR = ".intentguard"
+
+
+def ensure_cache_dir_exists():
+    """
+    Creates the cache directory if it doesn't exist.
+    This is called before any cache operations to ensure the cache directory is available.
+    """
+    if not os.path.exists(CACHE_DIR):
+        os.makedirs(CACHE_DIR)
+
+
+def generate_cache_key(expectation: str, objects_text: str, options) -> str:
+    """
+    Generates a unique cache key based on the input parameters and model configuration.
+
+    The key includes a version prefix ('v1') to allow for cache invalidation if the
+    caching logic changes in future versions of the software. This ensures old cached
+    results won't be used if they're incompatible with newer versions.
+
+    Args:
+        expectation (str): The expectation string being tested
+        objects_text (str): The text of the objects being analyzed
+        options: Configuration object containing model and quorum settings
+
+    Returns:
+        str: A SHA-256 hash that serves as the cache key
+    """
+    key_string = (
+        f"v1:{expectation}:{objects_text}:{options.model}:{options.quorum_size}"
+    )
+    return hashlib.sha256(key_string.encode()).hexdigest()
+
+
+def read_cache(cache_key: str):
+    """
+    Retrieves cached results for a given cache key.
+
+    Args:
+        cache_key (str): The SHA-256 hash key to look up
+
+    Returns:
+        dict: The cached result if found, None otherwise
+    """
+    ensure_cache_dir_exists()
+    cache_file = os.path.join(CACHE_DIR, cache_key)
+    if os.path.exists(cache_file):
+        with open(cache_file, "r") as f:
+            return json.load(f)
+    return None
+
+
+def write_cache(cache_key: str, result):
+    """
+    Stores a result in the cache using the provided cache key.
+
+    Args:
+        cache_key (str): The SHA-256 hash key to store the result under
+        result: The data to cache (must be JSON serializable)
+    """
+    ensure_cache_dir_exists()
+    cache_file = os.path.join(CACHE_DIR, cache_key)
+    with open(cache_file, "w") as f:
+        json.dump(result, f)
+
+
+class CachedResult:
+    """
+    Represents a cached IntentGuard verification result.
+
+    This class provides a structured way to store and serialize verification results,
+    including both the boolean outcome and any explanation text.
+    """
+
+    def __init__(self, result: bool, explanation: str = ""):
+        """
+        Initialize a new CachedResult instance.
+
+        Args:
+            result (bool): The verification result (True/False)
+            explanation (str, optional): Explanation of the result. Defaults to empty string.
+        """
+        self.result = result
+        self.explanation = explanation
+
+    def to_dict(self):
+        """
+        Converts the CachedResult instance to a dictionary for JSON serialization.
+
+        Returns:
+            dict: Dictionary containing the result and explanation
+        """
+        return {"result": self.result, "explanation": self.explanation}
+
+    @classmethod
+    def from_dict(cls, data: dict):
+        """
+        Creates a CachedResult instance from a dictionary.
+
+        Args:
+            data (dict): Dictionary containing 'result' and optionally 'explanation'
+
+        Returns:
+            CachedResult: A new CachedResult instance
+        """
+        return cls(result=data["result"], explanation=data.get("explanation", ""))
diff --git a/intentguard/intentguard.py b/intentguard/intentguard.py
index 2ac452e..7dfbd96 100644
--- a/intentguard/intentguard.py
+++ b/intentguard/intentguard.py
@@ -1,113 +1,117 @@
-from typing import Dict
+from typing import Dict, List, Any, Optional
 import inspect
 import json
 from collections import Counter
+from dataclasses import dataclass
 
 from litellm import completion
+from litellm.main import ModelResponse
 
 from intentguard.intentguard_options import IntentGuardOptions
 from intentguard.prompts import system_prompt, reponse_schema, explanation_prompt
+from intentguard.cache import generate_cache_key, read_cache, write_cache, CachedResult
+
+
+@dataclass
+class LLMRequest:
+    """Configuration for an LLM API request"""
+
+    messages: List[Dict[str, str]]
+    model: str
+    temperature: float = 1e-3
+    response_format: Optional[Dict[str, Any]] = None
 
 
 class IntentGuard:
     """
     A class for performing code assertions using Language Models (LLMs).
 
-    This class allows for the evaluation of expectations against provided code objects
-    using LLM-based inference. It supports multiple inferences to achieve a quorum
-    and provides customizable options for the assertion process.
+    This class evaluates expectations against provided code objects using LLM-based inference.
+    It supports multiple inferences to achieve a consensus through voting and provides
+    customizable options for the assertion process.
     """
 
-    def __init__(self, options: IntentGuardOptions = None):
+    def __init__(self, options: Optional[IntentGuardOptions] = None):
         """
         Initialize the IntentGuard instance.
 
         Args:
-            options (IntentGuardOptions, optional): Configuration options for the assert.
-                If not provided, default options will be used.
+            options: Configuration options for assertions. Uses default options if None.
         """
-        if options is None:
-            options = IntentGuardOptions()
-        self.options = options
+        self.options = options or IntentGuardOptions()
 
     def assert_code(
         self,
         expectation: str,
         params: Dict[str, object],
-        options: IntentGuardOptions = None,
-    ):
+        options: Optional[IntentGuardOptions] = None,
+    ) -> None:
         """
-        Perform an assertion using LLM inference.
+        Assert that code meets an expected condition using LLM inference.
 
-        This method evaluates the given expectation against the provided parameters
-        using LLM-based inference. It performs multiple inferences based on the
-        quorum size and determines the final result through voting.
+        Performs multiple LLM inferences and uses majority voting to determine if the
+        code meets the specified expectation. Results are cached for performance.
 
         Args:
-            expectation (str): The condition to be evaluated.
-            params (Dict[str, object]): A dictionary of objects to be used in the evaluation.
-            options (IntentGuardOptions, optional): Custom options for this specific assertion.
-                If not provided, the instance's default options will be used.
+            expectation: The condition to evaluate, expressed in natural language
+            params: Dictionary mapping variable names to code objects for evaluation
+            options: Custom options for this assertion, falls back to instance defaults
 
         Raises:
-            AssertionError: If the final result of the assertion is False.
+            AssertionError: If the code does not meet the expected condition
         """
-        if options is None:
-            options = self.options
+        options = options or self.options
 
-        objects_text = self._generate_objects_text(params)
-        prompt = self._create_prompt(objects_text, expectation)
+        # Prepare evaluation context
+        objects_text = self._format_code_objects(params)
+        prompt = self._create_evaluation_prompt(objects_text, expectation)
+        cache_key = generate_cache_key(expectation, objects_text, options)
 
-        results = []
-        for _ in range(options.quorum_size):
-            result = self._send_completion_request(prompt, options)
-            results.append(result)
-
-        final_result = self._vote_on_results(results)
+        # Check cache or perform evaluation
+        final_result = self._get_cached_or_evaluate(
+            cache_key=cache_key, prompt=prompt, options=options
+        )
 
-        if not final_result:
-            explanation = self._generate_explanation(prompt, options)
+        # Handle failed assertions
+        if not final_result.result:
             raise AssertionError(
-                f'Expected "{expectation}" to be true, but it was false. Explanation: {explanation}'
+                f'Expected "{expectation}" to be true, but it was false.\n'
+                f"Explanation: {final_result.explanation}"
             )
 
-    def _generate_objects_text(self, params: Dict[str, object]) -> str:
+    def _format_code_objects(self, params: Dict[str, object]) -> str:
         """
-        Generate a formatted string representation of the provided objects.
+        Format code objects for LLM evaluation.
 
-        This method creates a string containing the source code of each object
-        in the params dictionary, formatted for use in the LLM prompt.
+        Extracts and formats the source code of each object in the params dictionary.
 
         Args:
-            params (Dict[str, object]): A dictionary of objects to be formatted.
+            params: Dictionary of named code objects
 
         Returns:
-            str: A formatted string containing the source code of all objects.
+            Formatted string containing source code of all objects
         """
-        objects_texts = []
+        formatted_objects = []
         for name, obj in params.items():
             source = inspect.getsource(obj)
-            object_text = f"""{{{name}}}:
+            formatted_objects.append(
+                f"""{{{name}}}:
 ```py
 {source}
-```
-"""
-            objects_texts.append(object_text)
-        return "\n".join(objects_texts)
+```"""
+            )
+        return "\n".join(formatted_objects)
 
-    def _create_prompt(self, objects_text: str, expectation: str) -> str:
+    def _create_evaluation_prompt(self, objects_text: str, expectation: str) -> str:
         """
-        Create the prompt for the LLM inference.
-
-        This method combines the formatted objects text and the expectation
-        into a single prompt string for the LLM.
+        Create the complete prompt for LLM evaluation.
 
         Args:
-            objects_text (str): The formatted string of object source codes.
-            expectation (str): The condition to be evaluated.
+            objects_text: Formatted source code of objects to evaluate
+            expectation: The condition to evaluate
 
         Returns:
-            str: The complete prompt for the LLM inference.
+            Complete prompt string for LLM
         """
         return f"""**Objects:**
 {objects_text}
@@ -116,77 +120,118 @@ class IntentGuard:
 "{expectation}"
 """
 
-    def _send_completion_request(
-        self, prompt: str, options: IntentGuardOptions
-    ) -> bool:
+    def _get_cached_or_evaluate(
+        self, cache_key: str, prompt: str, options: IntentGuardOptions
+    ) -> CachedResult:
         """
-        Send a completion request to the LLM and process the response.
-
-        This method sends the prepared prompt to the LLM using the specified options,
-        and processes the response to extract the boolean result.
+        Retrieve cached result or perform new evaluation.
 
         Args:
-            prompt (str): The prepared prompt for the LLM.
-            options (IntentGuardOptions): The options for the LLM request.
+            cache_key: Key for cache lookup
+            prompt: Evaluation prompt if cache miss
+            options: Configuration options for evaluation
 
         Returns:
-            bool: The boolean result of the LLM inference.
+            Evaluation result, either from cache or newly computed
         """
-        messages = [
-            {"content": system_prompt, "role": "system"},
-            {"content": prompt, "role": "user"},
+        if cached_result := read_cache(cache_key):
+            return CachedResult.from_dict(cached_result)
+
+        # Perform multiple evaluations for consensus
+        results = [
+            self._perform_single_evaluation(prompt, options)
+            for _ in range(options.quorum_size)
         ]
 
-        response = completion(
+        final_result = CachedResult(result=self._determine_consensus(results))
+
+        # Generate explanation for failed assertions
+        if not final_result.result:
+            final_result.explanation = self._generate_failure_explanation(
+                prompt, options
+            )
+
+        write_cache(cache_key, final_result.to_dict())
+        return final_result
+
+    def _perform_single_evaluation(
+        self, prompt: str, options: IntentGuardOptions
+    ) -> bool:
+        """
+        Perform a single LLM evaluation.
+
+        Args:
+            prompt: The evaluation prompt
+            options: Configuration options
+
+        Returns:
+            Boolean result of evaluation
+        """
+        request = LLMRequest(
+            messages=[
+                {"content": system_prompt, "role": "system"},
+                {"content": prompt, "role": "user"},
+            ],
             model=options.model,
-            messages=messages,
             response_format={
                 "type": "json_schema",
                 "json_schema": reponse_schema,
             },
-            temperature=1e-3,
         )
+
+        response = self._send_llm_request(request)
         return json.loads(response.choices[0].message.content)["result"]
 
-    def _generate_explanation(self, prompt: str, options: IntentGuardOptions) -> str:
+    def _generate_failure_explanation(
+        self, prompt: str, options: IntentGuardOptions
+    ) -> str:
         """
-        Generate a detailed explanation for a failed assertion using the LLM.
-
-        This method sends a request to the LLM to generate a human-readable explanation
-        for why the given expectation was not met based on the provided objects.
+        Generate explanation for failed assertion.
 
         Args:
-            objects_text (str): The formatted string of object source codes.
-            expectation (str): The condition that was evaluated.
-            options (IntentGuardOptions): The options for the LLM request.
+            prompt: The evaluation prompt
+            options: Configuration options
 
         Returns:
-            str: A detailed explanation of why the assertion failed.
+            Detailed explanation of why assertion failed
         """
-        messages = [
-            {"content": explanation_prompt, "role": "system"},
-            {"content": prompt, "role": "user"},
-        ]
-
-        response = completion(
+        request = LLMRequest(
+            messages=[
+                {"content": explanation_prompt, "role": "system"},
+                {"content": prompt, "role": "user"},
+            ],
             model=options.model,
-            messages=messages,
-            temperature=1e-3,
         )
+
+        response = self._send_llm_request(request)
         return response.choices[0].message.content
 
-    def _vote_on_results(self, results: list) -> bool:
+    def _send_llm_request(self, request: LLMRequest) -> ModelResponse:
         """
-        Determine the final result based on voting.
+        Send request to LLM API.
 
-        This method takes the list of boolean results from multiple LLM inferences
-        and determines the final result through a simple majority vote.
+        Args:
+            request: Configuration for the API request
+
+        Returns:
+            API response
+        """
+        return completion(
+            model=request.model,
+            messages=request.messages,
+            temperature=request.temperature,
+            response_format=request.response_format,
+        )
+
+    def _determine_consensus(self, results: List[bool]) -> bool:
+        """
+        Determine final result through majority voting.
 
         Args:
-            results (list): A list of boolean results from multiple LLM inferences.
+            results: List of boolean results from multiple evaluations
 
         Returns:
-            bool: The final result based on majority voting.
+            Consensus result
         """
         vote_count = Counter(results)
         return vote_count[True] > vote_count[False]


Test patch:
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..5357641
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,61 @@
+import unittest
+import os
+import json
+import hashlib
+from intentguard.cache import (
+    read_cache,
+    write_cache,
+    generate_cache_key,
+    ensure_cache_dir_exists,
+    CachedResult,
+)
+
+
+class TestCache(unittest.TestCase):
+    def setUp(self):
+        ensure_cache_dir_exists()
+        self.cache_key = "test_key"
+        self.cache_file = os.path.join(".intentguard", self.cache_key)
+        self.test_data = {"result": True}
+
+    def tearDown(self):
+        if os.path.exists(self.cache_file):
+            os.remove(self.cache_file)
+
+    def test_write_cache(self):
+        write_cache(self.cache_key, self.test_data)
+        self.assertTrue(os.path.exists(self.cache_file))
+        with open(self.cache_file, "r") as f:
+            data = json.load(f)
+        self.assertEqual(data, self.test_data)
+
+    def test_read_cache(self):
+        write_cache(self.cache_key, self.test_data)
+        data = read_cache(self.cache_key)
+        self.assertEqual(data, self.test_data)
+
+    def test_generate_cache_key(self):
+        expectation = "test_expectation"
+        objects_text = "test_objects_text"
+        options = type("Options", (object,), {"model": "test_model", "quorum_size": 1})
+        expected_key = hashlib.sha256(
+            f"v1:{expectation}:{objects_text}:test_model:1".encode()
+        ).hexdigest()
+        generated_key = generate_cache_key(expectation, objects_text, options)
+        self.assertEqual(generated_key, expected_key)
+
+    def test_cached_result_to_dict(self):
+        cached_result = CachedResult(result=True, explanation="Test explanation")
+        result_dict = cached_result.to_dict()
+        expected_dict = {"result": True, "explanation": "Test explanation"}
+        self.assertEqual(result_dict, expected_dict)
+
+    def test_cached_result_from_dict(self):
+        data = {"result": True, "explanation": "Test explanation"}
+        cached_result = CachedResult.from_dict(data)
+        self.assertTrue(cached_result.result)
+        self.assertEqual(cached_result.explanation, "Test explanation")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_intentguard.py b/tests/test_intentguard.py
index 1b10b1a..376309f 100644
--- a/tests/test_intentguard.py
+++ b/tests/test_intentguard.py
@@ -1,5 +1,6 @@
 import unittest
 from intentguard import IntentGuard, IntentGuardOptions
+from intentguard.cache import generate_cache_key, read_cache, write_cache
 
 
 class TestIntentGuard(unittest.TestCase):
@@ -52,6 +53,40 @@ class TestIntentGuard(unittest.TestCase):
             options=custom_options,
         )
 
+    def test_cache_key_generation(self):
+        expectation = "test_expectation"
+        objects_text = "test_objects_text"
+        options = IntentGuardOptions(model="test_model", quorum_size=1)
+        cache_key = generate_cache_key(expectation, objects_text, options)
+        self.assertIsInstance(cache_key, str)
+        self.assertEqual(len(cache_key), 64)  # SHA-256 hash length
+
+    def test_cache_retrieval(self):
+        expectation = "test_expectation"
+        objects_text = "test_objects_text"
+        options = IntentGuardOptions(model="test_model", quorum_size=1)
+        cache_key = generate_cache_key(expectation, objects_text, options)
+        result = {"result": True}
+        write_cache(cache_key, result)
+        cached_result = read_cache(cache_key)
+        self.assertEqual(cached_result, result)
+
+    def test_cache_invalidation(self):
+        expectation = "test_expectation"
+        objects_text = "test_objects_text"
+        options = IntentGuardOptions(model="test_model", quorum_size=1)
+        cache_key = generate_cache_key(expectation, objects_text, options)
+        result = {"result": True}
+        write_cache(cache_key, result)
+        cached_result = read_cache(cache_key)
+        self.assertEqual(cached_result, result)
+
+        # Simulate code change by modifying objects_text
+        new_objects_text = "new_test_objects_text"
+        new_cache_key = generate_cache_key(expectation, new_objects_text, options)
+        new_cached_result = read_cache(new_cache_key)
+        self.assertIsNone(new_cached_result)
+
 
 if __name__ == "__main__":
     unittest.main()