Repository: expectedparrot/edsl

Issue:
cache not appended to results
https://www.expectedparrot.com/content/RobinHorton/knowledgeable-faded-occlusion

Hints from issue comments:
@rbyh: Code to reproduce:

```
from edsl import QuestionNumerical, Model

m = Model("gemini-1.5-flash")

q = QuestionNumerical(
question_name = "random",
question_text = "Please give me a random number between 1 and 100."
)

results = q.by(m).run()

results.cache
```

Error:
```
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
File ~/edsl/.venv/lib/python3.11/site-packages/IPython/core/formatters.py:344, in BaseFormatter.__call__(self, obj)
    342     method = get_real_method(obj, self.print_method)
    343     if method is not None:
--> 344         return method()
    345     return None
    346 else:

File ~/edsl/edsl/base/base_class.py:663, in RepresentationMixin._repr_html_(self)
    656     class_name = self.__class__.__name__
    657     docs = getattr(self, "__documentation__", "")
    658     return (
    659         "<p>"
    660         + f"<a href='{docs}'>{class_name}</a>"
    661         + summary_line
    662         + "</p>"
--> 663         + self.table()._repr_html_()
    664     )
    665 else:
    666     class_name = self.__class__.__name__

File ~/edsl/edsl/caching/cache.py:622, in Cache.table(self, tablefmt, pretty_labels, *fields)
    616 def table(
    617     self,
    618     *fields,
    619     tablefmt: Optional[str] = None,
    620     pretty_labels: Optional[dict] = None,
    621 ) -> str:
--> 622     return self.to_dataset().table(
    623         *fields, tablefmt=tablefmt, pretty_labels=pretty_labels
    624     )

File ~/edsl/edsl/caching/cache.py:633, in Cache.to_dataset(self)
    632 def to_dataset(self):
--> 633     return self.to_scenario_list().to_dataset()

File ~/edsl/edsl/scenarios/scenario_list.py:1097, in ScenarioList.to_dataset(self)
   1085 """
   1086 Convert the ScenarioList to a Dataset.
   1087 
   (...)
   1093 Dataset([{'a': [1, 2, 3]}, {'b': [4, 5, 6]}])
   1094 """
   1095 from ..dataset import Dataset
-> 1097 keys = list(self[0].keys())
   1098 for scenario in self:
   1099     new_keys = list(scenario.keys())

File ~/edsl/edsl/scenarios/scenario_list.py:1838, in ScenarioList.__getitem__(self, key)
   1836     return ScenarioList(super().__getitem__(key))
   1837 elif isinstance(key, int):
-> 1838     return super().__getitem__(key)
   1839 else:
   1840     return self.to_dict(add_edsl_version=False)[key]

File ~/.pyenv/versions/3.11.7/lib/python3.11/collections/__init__.py:1243, in UserList.__getitem__(self, i)
   1241     return self.__class__(self.data[i])
   1242 else:
-> 1243     return self.data[i]

IndexError: list index out of range
```@johnjosephhorton: So I think the issue here is not the cache is not being appended, but rather that it's empty for some reason@johnjosephhorton: @zer0dss I saw you moved this one - do you think the issue is on coopr or edsl? @johnjosephhorton: So I don't think the issue is on edsl

```python
from edsl import QuestionNumerical, Model
from edsl.language_models import LanguageModel

#m = Model("test",  canned_response = "10")
m = Model("test", canned_response=90)
#m = LanguageModel.example(test_model=True, canned_response="10")

q = QuestionNumerical(
question_name = "random",
question_text = "Please give me a random number between 1 and 100."
)
from edsl import Cache 
results = q.by(m).run(disable_remote_inference = True, disable_remote_cache = True, stop_on_exception = True, cache = Cache())

print(results.cache)

from edsl import Results

results.save("example_results.json.gz")
new_results = Results.load("example_results.json.gz")

print(new_results.cache)
```

I confirmed that the cache is appended to the results object & then when I serialize & de-serialize, it's still there (local inference): 
```
(edsl-py3.11) johnhorton@Johns-MacBook-Pro edsl % python issue_1789.py
Cache(data = {'650202a8660f503563eacd3050d4f37f': CacheEntry(model='test', parameters={'temperature': 0.5}, system_prompt='', user_prompt="Please give me a random number between 1 and 100.\n\nThis question requires a numerical response in the form of an integer or decimal (e.g., -12, 0, 1, 2, 3.45, ...).\nRespond with just your number on a single line.\nIf your response is equivalent to zero, report '0'\n\n\nAfter the answer, put a comment explaining your choice on the next line.", output='{"message": [{"text": "90"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}', iteration=0, timestamp=1742866790, service='test')}, immediate_write=True)
(edsl-py3.11) johnhorton@Johns-MacBook-Pro edsl % 
```
@zer0dss: @rbyh @johnjosephhorton  When running the job on remote inference, we don't store the cache data along with the result object. The user will not receive any cache data attached to the Results object. The error appears when running the code inside a Jupyter notebook.
Also this example fails on a jupyter notebook too.
```python
from edsl import Cache
c = Cache()
c
```

<img width="1270" alt="Image" src="https://github.com/user-attachments/assets/feb1c15b-4a67-4fb6-b1a2-3d96b74333bf" />@johnjosephhorton: I think we should return the cache associated with that particular job from coopr. I can't recall - is there some reason we do not do this currently? It gets appended during local inference & is serialized as part of Results serialization...@zer0dss: On the remote backend, the Results object is split into individual Result objects (which are actually the results after one interview) that are stored in the database. This was implemented to avoid loading large objects in the web interface and to support paginations. @zer0dss: @johnjosephhorton @rbyh After running the job remotely and retrieving the Results object locally, the cache attached to the Results object is equivalent to the data already present within it. I believe we can construct the cache using the already obtained data.

Solution patch:
diff --git a/edsl/base/base_class.py b/edsl/base/base_class.py
index 3712f6c77..d02cbb112 100644
--- a/edsl/base/base_class.py
+++ b/edsl/base/base_class.py
@@ -21,6 +21,7 @@
 from typing import Dict, Literal, List, Tuple
 from collections import UserList
 import inspect
+import hashlib
 
 from .. import logger
 
@@ -794,6 +795,14 @@ def __hash__(self) -> int:
 
         return dict_hash(self.to_dict(add_edsl_version=False))
 
+    def get_hash(self) -> str:
+        """Get a string hash representation of this object based on its content.
+
+        Returns:
+            str: A string representation of the hash value
+        """
+        return str(self.__hash__())
+
     def __eq__(self, other):
         """Compare this object with another for equality.
 
@@ -827,6 +836,30 @@ class Base(
     and capabilities across the framework.
     """
 
+    def get_uuid(self) -> str:
+        """
+        Get the UUID of this object from the Expected Parrot cloud service based on its hash.
+
+        This method calculates the hash of the object and queries the cloud service
+        to find if there's an uploaded version with the same content. If found,
+        it returns the UUID of that object.
+
+        Returns:
+            str: The UUID of the object in the cloud service if found
+
+        Raises:
+            CoopServerResponseError: If the object is not found or there's an error
+                                   communicating with the server
+        """
+        from edsl.coop import Coop
+
+        # Calculate the hash of the object
+        object_hash = self.get_hash()
+
+        # Query the cloud service to get the UUID based on the hash
+        coop = Coop()
+        return coop.get_uuid_from_hash(object_hash)
+
     def keys(self):
         """Get the key names in the object's dictionary representation.
 
diff --git a/edsl/coop/coop.py b/edsl/coop/coop.py
index 1ada105e6..9607f350f 100644
--- a/edsl/coop/coop.py
+++ b/edsl/coop/coop.py
@@ -503,6 +503,10 @@ def create(
         """
         object_type = ObjectRegistry.get_object_type_by_edsl_class(object)
         object_dict = object.to_dict()
+
+        # Get the object hash
+        object_hash = object.get_hash() if hasattr(object, "get_hash") else None
+
         if object_type == "scenario" and self._scenario_is_file_store(object_dict):
             file_store_metadata = {
                 "suffix": object_dict["suffix"],
@@ -528,6 +532,7 @@ def create(
                 "file_store_metadata": file_store_metadata,
                 "visibility": visibility,
                 "version": self._edsl_version,
+                "object_hash": object_hash,  # Include the object hash in the payload
             },
         )
         self._resolve_server_response(response)
@@ -1495,6 +1500,28 @@ def fetch_rate_limit_config_vars(self) -> dict:
         data = response.json()
         return data
 
+    def get_uuid_from_hash(self, hash_value: str) -> str:
+        """
+        Retrieve the UUID for an object based on its hash.
+
+        This method calls the remote endpoint to get the UUID associated with an object hash.
+
+        Args:
+            hash_value (str): The hash value of the object to look up
+
+        Returns:
+            str: The UUID of the object if found
+
+        Raises:
+            CoopServerResponseError: If the object is not found or there's an error
+                                   communicating with the server
+        """
+        response = self._send_server_request(
+            uri=f"api/v0/object/hash/{hash_value}", method="GET"
+        )
+        self._resolve_server_response(response)
+        return response.json().get("uuid")
+
     def _display_login_url(
         self, edsl_auth_token: str, link_description: Optional[str] = None
     ):
diff --git a/edsl/jobs/jobs_pricing_estimation.py b/edsl/jobs/jobs_pricing_estimation.py
index 27950f6ec..51d9e124c 100644
--- a/edsl/jobs/jobs_pricing_estimation.py
+++ b/edsl/jobs/jobs_pricing_estimation.py
@@ -1,7 +1,7 @@
 import logging
 import math
 
-from typing import List, TYPE_CHECKING
+from typing import List, TYPE_CHECKING, Union, Literal
 
 if TYPE_CHECKING:
     from .jobs import Jobs
@@ -26,53 +26,104 @@ class PromptCostEstimator:
     OUTPUT_TOKENS_PER_INPUT_TOKEN = 0.75
     PIPING_MULTIPLIER = 2
 
-    def __init__(self,      
+    def __init__(
+        self,
         system_prompt: str,
         user_prompt: str,
         price_lookup: dict,
         inference_service: str,
-        model: str):
+        model: str,
+    ):
         self.system_prompt = system_prompt
         self.user_prompt = user_prompt
         self.price_lookup = price_lookup
         self.inference_service = inference_service
         self.model = model
 
-    @staticmethod    
+    @staticmethod
     def get_piping_multiplier(prompt: str):
         """Returns 2 if a prompt includes Jinja braces, and 1 otherwise."""
 
         if "{{" in prompt and "}}" in prompt:
             return PromptCostEstimator.PIPING_MULTIPLIER
         return 1
-    
+
     @property
     def key(self):
         return (self.inference_service, self.model)
-    
+
     @property
     def relevant_prices(self):
         try:
             return self.price_lookup[self.key]
         except KeyError:
             return {}
-    
-    def input_price_per_token(self):
+
+    def _get_highest_price_for_service(self, price_type: str) -> Union[float, None]:
+        """Returns the highest price per token for a given service and price type (input/output).
+
+        Args:
+            price_type: Either "input" or "output"
+
+        Returns:
+            float | None: The highest price per token for the service, or None if not found
+        """
+        prices_for_service = [
+            prices[price_type]["service_stated_token_price"]
+            / prices[price_type]["service_stated_token_qty"]
+            for (service, _), prices in self.price_lookup.items()
+            if service == self.inference_service and price_type in prices
+        ]
+        return max(prices_for_service) if prices_for_service else None
+
+    def input_price_per_token(
+        self,
+    ) -> tuple[float, Literal["price_lookup", "highest_price_for_service", "default"]]:
         try:
-            return self.relevant_prices["input"]["service_stated_token_price"] / self.relevant_prices["input"]["service_stated_token_qty"]
+            return (
+                self.relevant_prices["input"]["service_stated_token_price"]
+                / self.relevant_prices["input"]["service_stated_token_qty"]
+            ), "price_lookup"
         except KeyError:
+            highest_price = self._get_highest_price_for_service("input")
+            if highest_price is not None:
+                import warnings
+
+                warnings.warn(
+                    f"Price data not found for {self.key}. Using highest available input price for {self.inference_service}: ${highest_price:.6f} per token"
+                )
+                return highest_price, "highest_price_for_service"
             import warnings
+
             warnings.warn(
-                "Price data could not be retrieved. Using default estimates for input and output token prices. Input: $1.00 / 1M tokens; Output: $1.00 / 1M tokens"
+                f"Price data not found for {self.inference_service}. Using default estimate for input token price: $1.00 / 1M tokens"
             )
-            return self.DEFAULT_INPUT_PRICE_PER_TOKEN
+            return self.DEFAULT_INPUT_PRICE_PER_TOKEN, "default"
 
-    def output_price_per_token(self):
+    def output_price_per_token(
+        self,
+    ) -> tuple[float, Literal["price_lookup", "highest_price_for_service", "default"]]:
         try:
-            return self.relevant_prices["output"]["service_stated_token_price"] / self.relevant_prices["output"]["service_stated_token_qty"]
+            return (
+                self.relevant_prices["output"]["service_stated_token_price"]
+                / self.relevant_prices["output"]["service_stated_token_qty"]
+            ), "price_lookup"
         except KeyError:
-            return self.DEFAULT_OUTPUT_PRICE_PER_TOKEN
-        
+            highest_price = self._get_highest_price_for_service("output")
+            if highest_price is not None:
+                import warnings
+
+                warnings.warn(
+                    f"Price data not found for {self.key}. Using highest available output price for {self.inference_service}: ${highest_price:.6f} per token"
+                )
+                return highest_price, "highest_price_for_service"
+            import warnings
+
+            warnings.warn(
+                f"Price data not found for {self.inference_service}. Using default estimate for output token price: $1.00 / 1M tokens"
+            )
+            return self.DEFAULT_OUTPUT_PRICE_PER_TOKEN, "default"
+
     def __call__(self):
         user_prompt_chars = len(str(self.user_prompt)) * self.get_piping_multiplier(
             str(self.user_prompt)
@@ -84,20 +135,37 @@ def __call__(self):
         input_tokens = (user_prompt_chars + system_prompt_chars) // self.CHARS_PER_TOKEN
         output_tokens = math.ceil(self.OUTPUT_TOKENS_PER_INPUT_TOKEN * input_tokens)
 
+        input_price_per_token, input_price_source = self.input_price_per_token()
+        output_price_per_token, output_price_source = self.output_price_per_token()
+
         cost = (
-            input_tokens * self.input_price_per_token()
-            + output_tokens * self.output_price_per_token()
+            input_tokens * input_price_per_token
+            + output_tokens * output_price_per_token
         )
         return {
+            "input_price_source": input_price_source,
+            "input_price_per_token": input_price_per_token,
             "input_tokens": input_tokens,
+            "output_price_source": output_price_source,
             "output_tokens": output_tokens,
+            "output_price_per_token": output_price_per_token,
             "cost_usd": cost,
         }
 
 
 class JobsPrompts:
 
-    relevant_keys = ["user_prompt", "system_prompt", "interview_index", "question_name", "scenario_index", "agent_index", "model", "estimated_cost", "cache_keys"]
+    relevant_keys = [
+        "user_prompt",
+        "system_prompt",
+        "interview_index",
+        "question_name",
+        "scenario_index",
+        "agent_index",
+        "model",
+        "estimated_cost",
+        "cache_keys",
+    ]
 
     """This generates the prompts for a job for price estimation purposes. 
     
@@ -105,7 +173,6 @@ class JobsPrompts:
     So assumptions are made about expansion of Jinja braces, etc.
     """
 
-
     @classmethod
     def from_jobs(cls, jobs: "Jobs"):
         """Construct a JobsPrompts object from a Jobs object."""
@@ -114,13 +181,16 @@ def from_jobs(cls, jobs: "Jobs"):
         scenarios = jobs.scenarios
         survey = jobs.survey
         return cls(
-            interviews=interviews,
-            agents=agents,
-            scenarios=scenarios,
-            survey=survey
+            interviews=interviews, agents=agents, scenarios=scenarios, survey=survey
         )
-    
-    def __init__(self, interviews: List['Interview'], agents:'AgentList', scenarios: 'ScenarioList', survey: 'Survey'):
+
+    def __init__(
+        self,
+        interviews: List["Interview"],
+        agents: "AgentList",
+        scenarios: "ScenarioList",
+        survey: "Survey",
+    ):
         """Initialize with extracted components rather than a Jobs object."""
         self.interviews = interviews
         self.agents = agents
@@ -143,17 +213,19 @@ def price_lookup(self) -> dict:
             self._price_lookup = c.fetch_prices()
         return self._price_lookup
 
-    def _process_one_invigilator(self, invigilator: 'Invigilator', interview_index: int, iterations: int = 1) -> dict:
+    def _process_one_invigilator(
+        self, invigilator: "Invigilator", interview_index: int, iterations: int = 1
+    ) -> dict:
         """Process a single invigilator and return a dictionary with all needed data fields."""
         prompts = invigilator.get_prompts()
         user_prompt = prompts["user_prompt"]
         system_prompt = prompts["system_prompt"]
-        
+
         agent_index = self._agent_lookup[invigilator.agent]
         scenario_index = self._scenario_lookup[invigilator.scenario]
         model = invigilator.model.model
         question_name = invigilator.question.question_name
-        
+
         # Calculate prompt cost
         prompt_cost = self.estimate_prompt_cost(
             system_prompt=system_prompt,
@@ -163,7 +235,7 @@ def _process_one_invigilator(self, invigilator: 'Invigilator', interview_index:
             model=model,
         )
         cost = prompt_cost["cost_usd"]
-        
+
         # Generate cache keys for each iteration
         cache_keys = []
         for iteration in range(iterations):
@@ -175,7 +247,7 @@ def _process_one_invigilator(self, invigilator: 'Invigilator', interview_index:
                 iteration=iteration,
             )
             cache_keys.append(cache_key)
-        
+
         d = {
             "user_prompt": user_prompt,
             "system_prompt": system_prompt,
@@ -200,7 +272,7 @@ def prompts(self, iterations=1) -> "Dataset":
         dataset_of_prompts = {k: [] for k in self.relevant_keys}
 
         interviews = self.interviews
-        
+
         # Process each interview and invigilator
         for interview_index, interview in enumerate(interviews):
             invigilators = [
@@ -210,11 +282,13 @@ def prompts(self, iterations=1) -> "Dataset":
 
             for invigilator in invigilators:
                 # Process the invigilator and get all data as a dictionary
-                data = self._process_one_invigilator(invigilator, interview_index, iterations)
+                data = self._process_one_invigilator(
+                    invigilator, interview_index, iterations
+                )
                 for k in self.relevant_keys:
                     dataset_of_prompts[k].append(data[k])
-                
-        return Dataset([{k:dataset_of_prompts[k]} for k in self.relevant_keys])
+
+        return Dataset([{k: dataset_of_prompts[k]} for k in self.relevant_keys])
 
     @staticmethod
     def estimate_prompt_cost(
@@ -230,13 +304,13 @@ def estimate_prompt_cost(
             user_prompt=user_prompt,
             price_lookup=price_lookup,
             inference_service=inference_service,
-            model=model
+            model=model,
         )()
-    
+
     @staticmethod
     def _extract_prompt_details(invigilator: FetchInvigilator) -> dict:
         """Extracts the prompt details from the invigilator.
-        
+
         >>> from edsl.invigilators import InvigilatorAI
         >>> invigilator = InvigilatorAI.example()
         >>> JobsPrompts._extract_prompt_details(invigilator)
@@ -276,11 +350,13 @@ def estimate_job_cost_from_external_prices(
             ]
             for invigilator in invigilators:
                 prompt_details = self._extract_prompt_details(invigilator)
-                prompt_cost = self.estimate_prompt_cost(**prompt_details, price_lookup=price_lookup)
+                prompt_cost = self.estimate_prompt_cost(
+                    **prompt_details, price_lookup=price_lookup
+                )
                 price_estimates = {
-                    'estimated_input_tokens': prompt_cost['input_tokens'],
-                    'estimated_output_tokens': prompt_cost['output_tokens'],
-                    'estimated_cost_usd': prompt_cost['cost_usd']
+                    "estimated_input_tokens": prompt_cost["input_tokens"],
+                    "estimated_output_tokens": prompt_cost["output_tokens"],
+                    "estimated_cost_usd": prompt_cost["cost_usd"],
                 }
                 data.append({**price_estimates, **prompt_details})
 
@@ -293,14 +369,18 @@ def estimate_job_cost_from_external_prices(
                     "model": item["model"],
                     "estimated_cost_usd": 0,
                     "estimated_input_tokens": 0,
-                    "estimated_output_tokens": 0
+                    "estimated_output_tokens": 0,
                 }
-            
+
             # Accumulate values
             model_groups[key]["estimated_cost_usd"] += item["estimated_cost_usd"]
-            model_groups[key]["estimated_input_tokens"] += item["estimated_input_tokens"]
-            model_groups[key]["estimated_output_tokens"] += item["estimated_output_tokens"]
-        
+            model_groups[key]["estimated_input_tokens"] += item[
+                "estimated_input_tokens"
+            ]
+            model_groups[key]["estimated_output_tokens"] += item[
+                "estimated_output_tokens"
+            ]
+
         # Apply iterations and convert to list
         estimated_costs_by_model = []
         for group_data in model_groups.values():
@@ -345,4 +425,5 @@ def estimate_job_cost(self, iterations: int = 1) -> dict:
 
 if __name__ == "__main__":
     import doctest
+
     doctest.testmod(optionflags=doctest.ELLIPSIS)
diff --git a/edsl/jobs/remote_inference.py b/edsl/jobs/remote_inference.py
index e4bfa8090..eec97c5b6 100644
--- a/edsl/jobs/remote_inference.py
+++ b/edsl/jobs/remote_inference.py
@@ -113,13 +113,18 @@ def create_remote_inference_job(
         )
         logger.add_info("job_uuid", job_uuid)
 
+        remote_inference_url = self.remote_inference_url
+        if "localhost" in remote_inference_url:
+            remote_inference_url = remote_inference_url.replace("8000", "1234")
         logger.update(
-            f"Job details are available at your Coop account. [Go to Remote Inference page]({self.remote_inference_url})",
+            f"Job details are available at your Coop account. [Go to Remote Inference page]({remote_inference_url})",
             status=JobsStatus.RUNNING,
         )
         progress_bar_url = (
             f"{self.expected_parrot_url}/home/remote-job-progress/{job_uuid}"
         )
+        if "localhost" in progress_bar_url:
+            progress_bar_url = progress_bar_url.replace("8000", "1234")
         logger.add_info("progress_bar_url", progress_bar_url)
         logger.update(
             f"View job progress [here]({progress_bar_url})", status=JobsStatus.RUNNING
@@ -270,6 +275,8 @@ def _fetch_results_and_log(
         job_info.logger.add_info("results_uuid", results_uuid)
         results = object_fetcher(results_uuid, expected_object_type="results")
         results_url = remote_job_data.get("results_url")
+        if "localhost" in results_url:
+            results_url = results_url.replace("8000", "1234")
         job_info.logger.add_info("results_url", results_url)
 
         if job_status == "completed":
diff --git a/edsl/results/results.py b/edsl/results/results.py
index 23581ae68..33772b7de 100644
--- a/edsl/results/results.py
+++ b/edsl/results/results.py
@@ -75,7 +75,6 @@
 
 
 class ResultsSQLList(SQLiteList):
-
     def serialize(self, obj):
         return json.dumps(obj.to_dict()) if hasattr(obj, "to_dict") else json.dumps(obj)
 
@@ -86,6 +85,7 @@ def deserialize(self, data):
             else json.loads(data)
         )
 
+
 def ensure_fetched(method):
     """A decorator that checks if remote data is loaded, and if not, attempts to fetch it.
 
@@ -314,7 +314,7 @@ def __init__(
         total_results: Optional[int] = None,
         task_history: Optional[TaskHistory] = None,
         sort_by_iteration: bool = False,
-        data_class: Optional[type] = list, #ResultsSQLList,
+        data_class: Optional[type] = list,  # ResultsSQLList,
     ):
         """Instantiate a Results object with a survey and a list of Result objects.
 
@@ -470,7 +470,7 @@ def _summary(self) -> dict:
         }
         return d
 
-    def _cache_keys(self) -> List[str]:# -> list:
+    def _cache_keys(self) -> List[str]:  # -> list:
         """Return a list of all cache keys from the results.
 
         This method collects all cache keys by iterating through each result in the data
@@ -525,7 +525,6 @@ def relevant_cache(self, cache: Cache) -> Cache:
     #     # Call the parent class's insert directly
     #     MutableSequence.insert(self, index, item)
 
-
     def extend_sorted(self, other):
         """Extend the Results list with items from another iterable.
 
@@ -706,7 +705,7 @@ def __add__(self, other: Results) -> Results:
             )
 
         # Create a new ResultsSQLList with the combined data
-        #combined_data = ResultsSQLList()
+        # combined_data = ResultsSQLList()
         combined_data = self._data_class()
         combined_data.extend(self.data)
         combined_data.extend(other.data)
@@ -873,7 +872,12 @@ def has_unfixed_exceptions(self) -> bool:
 
     def __hash__(self) -> int:
         return dict_hash(
-            self.to_dict(sort=True, add_edsl_version=False, include_cache_info=False)
+            self.to_dict(
+                sort=True,
+                add_edsl_version=False,
+                include_cache=False,
+                include_cache_info=False,
+            )
         )
 
     @property
@@ -1701,11 +1705,11 @@ def filter(self, expression: str) -> Results:
 
             >>> # Using multi-line string for complex conditions
             >>> r.filter('''
-            ...     how_feeling == 'Great' 
+            ...     how_feeling == 'Great'
             ...     or how_feeling == 'Terrible'
             ... ''').select('how_feeling')
             Dataset([{'answer.how_feeling': ['Great', 'Terrible']}])
-            
+
             >>> # Using template-style syntax with {{}}
             >>> r.filter("{{ answer.how_feeling }} == 'Great'").select('how_feeling')
             Dataset([{'answer.how_feeling': ['Great']}])
@@ -1718,11 +1722,13 @@ def filter(self, expression: str) -> Results:
             ResultsFilterError: You must use '==' instead of '=' in the filter expression.
         """
         # Normalize expression by removing extra whitespace and newlines
-        normalized_expression = ' '.join(expression.strip().split())
-        
+        normalized_expression = " ".join(expression.strip().split())
+
         # Remove template-style syntax (double curly braces)
-        normalized_expression = normalized_expression.replace('{{', '').replace('}}', '')
-        
+        normalized_expression = normalized_expression.replace("{{", "").replace(
+            "}}", ""
+        )
+
         if self.has_single_equals(normalized_expression):
             raise ResultsFilterError(
                 "You must use '==' instead of '=' in the filter expression."
@@ -1764,7 +1770,7 @@ def filter(self, expression: str) -> Results:
                 f"Error in filter. Exception:{e}.",
                 f"The expression you provided was: {expression}.",
                 "Please make sure that the expression is a valid Python expression that evaluates to a boolean.",
-                "For example, 'how_feeling == \"Great\"' is a valid expression, as is 'how_feeling in [\"Great\", \"Terrible\"]'.",
+                'For example, \'how_feeling == "Great"\' is a valid expression, as is \'how_feeling in ["Great", "Terrible"]\'.',
                 "However, 'how_feeling = \"Great\"' is not a valid expression.",
                 "See https://docs.expectedparrot.com/en/latest/results.html#filtering-results for more details.",
             )
@@ -2165,50 +2171,56 @@ def to_disk(self, filepath: str) -> None:
                 temp_path = Path(temp_dir)
 
                 # 1. Handle the SQLite database
-                db_path = temp_path / 'results.db'
-                
+                db_path = temp_path / "results.db"
+
                 if isinstance(self.data, list):
                     # If data is a list, create a new SQLiteList
-                    #from .sqlite_list import SQLiteList
+                    # from .sqlite_list import SQLiteList
                     new_db = data_class()
                     new_db.extend(self.data)
                     shutil.copy2(new_db.db_path, db_path)
-                elif hasattr(self.data, 'db_path') and os.path.exists(self.data.db_path):
+                elif hasattr(self.data, "db_path") and os.path.exists(
+                    self.data.db_path
+                ):
                     # If data is already a SQLiteList, copy its database
                     shutil.copy2(self.data.db_path, db_path)
                 else:
                     # If no database exists, create a new one
-                    #from .sqlite_list import SQLiteList
-                    #new_db = SQLiteList()
+                    # from .sqlite_list import SQLiteList
+                    # new_db = SQLiteList()
                     new_db = data_class()
                     new_db.extend(self.data)
                     shutil.copy2(new_db.db_path, db_path)
 
                 # 2. Create metadata.json
                 metadata = {
-                    'survey': self.survey.to_dict() if self.survey else None,
-                    'created_columns': self.created_columns,
-                    'cache': self.cache.to_dict() if hasattr(self, 'cache') else None,
-                    'task_history': self.task_history.to_dict() if hasattr(self, 'task_history') else None,
-                    'completed': self.completed,
-                    'job_uuid': self._job_uuid if hasattr(self, '_job_uuid') else None,
-                    'total_results': self._total_results if hasattr(self, '_total_results') else None,
+                    "survey": self.survey.to_dict() if self.survey else None,
+                    "created_columns": self.created_columns,
+                    "cache": self.cache.to_dict() if hasattr(self, "cache") else None,
+                    "task_history": self.task_history.to_dict()
+                    if hasattr(self, "task_history")
+                    else None,
+                    "completed": self.completed,
+                    "job_uuid": self._job_uuid if hasattr(self, "_job_uuid") else None,
+                    "total_results": self._total_results
+                    if hasattr(self, "_total_results")
+                    else None,
                 }
 
-                metadata_path = temp_path / 'metadata.json'
+                metadata_path = temp_path / "metadata.json"
                 metadata_path.write_text(json.dumps(metadata, indent=4))
 
                 # 3. Create the zip file
-                with zipfile.ZipFile(filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
+                with zipfile.ZipFile(filepath, "w", zipfile.ZIP_DEFLATED) as zipf:
                     # Add all files from temp directory to zip
-                    for file in temp_path.glob('*'):
+                    for file in temp_path.glob("*"):
                         zipf.write(file, file.name)
 
         except Exception as e:
             raise ResultsError(f"Error saving Results to disk: {str(e)}")
 
     @classmethod
-    def from_disk(cls, filepath: str) -> 'Results':
+    def from_disk(cls, filepath: str) -> "Results":
         """Load a Results object from a zip file.
 
         This method:
@@ -2241,25 +2253,31 @@ def from_disk(cls, filepath: str) -> 'Results':
                 temp_path = Path(temp_dir)
 
                 # Extract the zip file
-                with zipfile.ZipFile(filepath, 'r') as zipf:
+                with zipfile.ZipFile(filepath, "r") as zipf:
                     zipf.extractall(temp_path)
 
                 # 1. Load metadata
-                metadata_path = temp_path / 'metadata.json'
+                metadata_path = temp_path / "metadata.json"
                 metadata = json.loads(metadata_path.read_text())
 
                 # 2. Create a new Results instance
                 results = cls(
-                    survey=Survey.from_dict(metadata['survey']) if metadata['survey'] else None,
-                    created_columns=metadata['created_columns'],
-                    cache=Cache.from_dict(metadata['cache']) if metadata['cache'] else None,
-                    task_history=TaskHistory.from_dict(metadata['task_history']) if metadata['task_history'] else None,
-                    job_uuid=metadata['job_uuid'],
-                    total_results=metadata['total_results'],
+                    survey=Survey.from_dict(metadata["survey"])
+                    if metadata["survey"]
+                    else None,
+                    created_columns=metadata["created_columns"],
+                    cache=Cache.from_dict(metadata["cache"])
+                    if metadata["cache"]
+                    else None,
+                    task_history=TaskHistory.from_dict(metadata["task_history"])
+                    if metadata["task_history"]
+                    else None,
+                    job_uuid=metadata["job_uuid"],
+                    total_results=metadata["total_results"],
                 )
 
                 # 3. Set the SQLite database path if it exists
-                db_path = temp_path / 'results.db'
+                db_path = temp_path / "results.db"
                 if db_path.exists():
                     # Create a new ResultsSQLList instance
                     new_db = data_class()
@@ -2268,7 +2286,7 @@ def from_disk(cls, filepath: str) -> 'Results':
                     # Set the new database as the results data
                     results.data = new_db
 
-                results.completed = metadata['completed']
+                results.completed = metadata["completed"]
                 return results
 
         except Exception as e:


Test patch:
diff --git a/tests/coop/test_coop_uuid.py b/tests/coop/test_coop_uuid.py
new file mode 100644
index 000000000..c9e75ef53
--- /dev/null
+++ b/tests/coop/test_coop_uuid.py
@@ -0,0 +1,41 @@
+import unittest
+
+from edsl.surveys import Survey
+from edsl.results import Results
+from edsl.jobs import Jobs
+from edsl.scenarios import ScenarioList
+from edsl.notebooks import Notebook
+import pytest
+class TestCoopUUIDFromObjectHash(unittest.TestCase):
+    """Test getting UUID from object hash for various EDSL objects."""
+    @pytest.mark.coop
+    def test_push_get_uuid_pull(self):
+        """Test pushing objects to remote, getting their UUIDs, and pulling them back."""
+        # Create test objects
+        survey = Survey.example()
+        results = Results.example()
+        scenario_list = ScenarioList.example()
+        notebook = Notebook.example()
+        
+        test_objects = [survey, results, scenario_list, notebook]
+        
+        for obj in test_objects:
+            # Push the object to remote
+            response = obj.push(description=f"Test {obj.__class__.__name__}", visibility="unlisted")
+            
+            # Get UUID from the push response
+            uuid_from_push = response["uuid"]
+            # Get UUID using the get_uuid method (which uses object hash)
+            uuid_from_hash = obj.get_uuid()
+            
+            assert uuid_from_hash != None, f"UUID from hash is None for {obj.__class__.__name__}"
+            # Pull the object from remote using UUID
+            pulled_obj = obj.pull(uuid_from_push)
+            
+            # Verify pulled object has same hash
+            self.assertEqual(pulled_obj.get_hash(), obj.get_hash())
+            
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/jobs/test_job_cost.py b/tests/jobs/test_job_cost.py
index 1035af5ed..f1d1a3854 100644
--- a/tests/jobs/test_job_cost.py
+++ b/tests/jobs/test_job_cost.py
@@ -1,3 +1,4 @@
+import pytest
 from edsl.language_models import Model
 from edsl.jobs import Jobs
 from edsl.questions import QuestionFreeText
@@ -160,3 +161,71 @@ def test_job_cost_estimation_with_piping_and_iterations():
 
     cost = estimated_cost_dct["estimated_total_cost_usd"]
     assert cost > 60  # 96
+
+
+def test_prompt_cost_estimation_with_fallback_to_highest_service_price():
+    price_lookup_with_multiple = {
+        ("service1", "model1"): {
+            "input": {
+                "service_stated_token_qty": 1,
+                "service_stated_token_price": 1,
+            },
+            "output": {
+                "service_stated_token_qty": 1,
+                "service_stated_token_price": 2,
+            },
+        },
+        ("service1", "model2"): {
+            "input": {
+                "service_stated_token_qty": 1,
+                "service_stated_token_price": 3,
+            },
+            "output": {
+                "service_stated_token_qty": 1,
+                "service_stated_token_price": 4,
+            },
+        },
+        ("service2", "model1"): {
+            "input": {
+                "service_stated_token_qty": 1,
+                "service_stated_token_price": 5,
+            },
+            "output": {
+                "service_stated_token_qty": 1,
+                "service_stated_token_price": 6,
+            },
+        },
+    }
+
+    # Test fallback to highest prices for service1 when model not found
+    estimated_cost_dct = JobsPrompts.estimate_prompt_cost(
+        system_prompt="",
+        user_prompt="Test prompt",  # 3 tokens
+        price_lookup=price_lookup_with_multiple,
+        inference_service="service1",
+        model="unknown",
+    )
+
+    # Should use highest prices for service (service1): 3 USD per input token and 4 USD per output token
+    assert estimated_cost_dct["input_tokens"] == 2
+    assert estimated_cost_dct["output_tokens"] == 2
+    assert estimated_cost_dct["cost_usd"] == 14  # (2 * 3) + (2 * 4) = 14
+
+
+def test_prompt_cost_estimation_with_fallback_to_default_price():
+    # Empty price lookup to trigger default price fallback
+    empty_price_lookup = {}
+
+    estimated_cost_dct = JobsPrompts.estimate_prompt_cost(
+        system_prompt="",
+        user_prompt="Test prompt",  # 2 tokens
+        price_lookup=empty_price_lookup,
+        inference_service="unknown_service",
+        model="unknown_model",
+    )
+
+    # Should use default prices: 0.000001 USD per token for both input and output
+    assert estimated_cost_dct["input_tokens"] == 2
+    assert estimated_cost_dct["output_tokens"] == 2
+    # Cost should be (2 * 0.000001) + (2 * 0.000001) = 0.000004
+    assert estimated_cost_dct["cost_usd"] == pytest.approx(0.000004)
diff --git a/tests/serialization/test_task_history_serialization.py b/tests/serialization/test_task_history_serialization.py
new file mode 100644
index 000000000..d0786079a
--- /dev/null
+++ b/tests/serialization/test_task_history_serialization.py
@@ -0,0 +1,49 @@
+import pytest
+from edsl import Model, QuestionMultipleChoice
+from edsl.tasks import TaskHistory
+from edsl.interviews.exception_tracking import InterviewExceptionCollection
+
+def test_task_history_exception_serialization():
+    """
+    Test to verify that TaskHistory serialization and deserialization 
+    properly handles exceptions.
+    
+    This test ensures that the fix for the 'dict' object has no attribute 'num_unfixed'
+    bug is working correctly.
+    """
+    # Create a question and model that will produce validation errors
+    q1 = QuestionMultipleChoice(
+        question_name="q1",
+        question_text="What is your favorite color?",
+        question_options=["Red", "Blue", "Green", "Yellow"]
+    )
+    # Using a canned response that doesn't match the options to trigger validation error
+    model = Model("test", canned_response={"q1": ["White", "Black"]})
+    
+    # Run the question (this will produce exceptions)
+    res = q1.by(model).run(disable_remote_inference=True, cache=False, print_exceptions=False)
+    
+    # Verify we have exceptions
+    assert res.has_unfixed_exceptions, "Test should have unfixed exceptions"
+    
+    # Convert to dictionary
+    task_history_dict = res.task_history.to_dict()
+    
+    # This used to fail with AttributeError: 'dict' object has no attribute 'num_unfixed'
+    # Now it should work with our fix
+    t = TaskHistory.from_dict(task_history_dict)
+    
+    # Verify the deserialized object still has the exceptions
+    assert t.has_unfixed_exceptions, "Deserialized task history should have unfixed exceptions"
+    assert len(t.unfixed_exceptions) > 0, "Deserialized task history should have unfixed exceptions"
+    
+    # Verify exception structure
+    first_interview = t.total_interviews[0]
+    assert hasattr(first_interview.exceptions, "num_unfixed"), "Exceptions should have num_unfixed method"
+    assert callable(first_interview.exceptions.num_unfixed), "num_unfixed should be callable"
+    
+    # Verify we can access unfixed_exceptions without AttributeError
+    try:
+        _ = t.unfixed_exceptions
+    except AttributeError:
+        pytest.fail("Accessing unfixed_exceptions raised AttributeError")