Repository: rhayes777/PyAutoFit (python)

Issue:
Generalize Aggregator directory and scrape API
Two examples of database use on the `autolens_workspace` only work when a .sqlite database is built and used:

https://github.com/Jammy2211/autolens_workspace/blob/release/scripts/results/examples/data_fitting.py
https://github.com/Jammy2211/autolens_workspace/blob/release/scripts/results/examples/models.py

This is because they use custom objects, like `FitImagingAgg`, when streamline standard calculations using the aggregator for the user:

https://github.com/Jammy2211/PyAutoLens/blob/main/autolens/aggregator/fit_imaging.py

I think the core part of the API where support for sqlite-only is here:

https://github.com/Jammy2211/PyAutoGalaxy/blob/main/autogalaxy/aggregator/agg_util.py

```
    fit_list = [fit] if not fit.children else fit.children

    if fit.value(name="adapt_images.adapt_images") is None:
        return [None] * len(fit_list)

    adapt_images_list = []

    for fit in fit_list:
```

I assume something here is tied to how the sqlite stores results.

Could you make it so that the above example scripts, and therefore these custom `Agg` objects, support Aggregator use which just reads directories? 

Its common to have users with ~5 - 10 datasets where the sqlite database is just adding complexity and over head without much benefit.

To generate the results for these example scripts you will need to run this:

https://github.com/Jammy2211/autolens_workspace/blob/release/scripts/results/start_here.py

Pull request description:
feature/search children
Should resolve #1093

SearchOutput (i.e. from a directory) and Fit (i.e. from the database) had different names for this attribute/property

I've made the name consistent (children) but also added a common ABC which asserts some interface that should be shared. I've swapped Fit (from the database) for Fit (the new ABC) in the autofit init such that the new type should be used implicitly.


Patch:
diff --git a/autofit/__init__.py b/autofit/__init__.py
index bfb40dbca..152fa6bf9 100644
--- a/autofit/__init__.py
+++ b/autofit/__init__.py
@@ -25,9 +25,8 @@ from .non_linear.samples import Sample
 from .non_linear.samples import load_from_table
 from .non_linear.samples import SamplesStored
 from .database.aggregator import Aggregator
-from .database.model import Fit
 from .database.aggregator import Query
-from .database.model.fit import Fit
+from autofit.aggregator.fit_interface import Fit
 from .aggregator.search_output import SearchOutput
 from .mapper import prior
 from .mapper.model import AbstractModel
diff --git a/autofit/aggregator/base.py b/autofit/aggregator/base.py
index 0ff630e5f..fcffaf325 100644
--- a/autofit/aggregator/base.py
+++ b/autofit/aggregator/base.py
@@ -80,7 +80,7 @@ class AggBase(ABC):
         """
 
         def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]:
-            samples = fit.value(name="samples")
+            samples = fit.samples
 
             weight_list = []
 
@@ -117,7 +117,7 @@ class AggBase(ABC):
         """
 
         def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]:
-            samples = fit.value(name="samples")
+            samples = fit.samples
 
             all_above_weight_list = []
 
@@ -156,7 +156,7 @@ class AggBase(ABC):
         """
 
         def func_gen(fit: af.Fit, total_samples: int) -> List[object]:
-            samples = fit.value(name="samples")
+            samples = fit.samples
 
             return [
                 self.object_via_gen_from(
diff --git a/autofit/aggregator/fit_interface.py b/autofit/aggregator/fit_interface.py
new file mode 100644
index 000000000..e5cdecbc2
--- /dev/null
+++ b/autofit/aggregator/fit_interface.py
@@ -0,0 +1,96 @@
+from abc import ABC, abstractmethod
+from typing import Any, List, Optional
+
+
+class Fit(ABC):
+    """
+    Common interface for objects representing the result of a "fit" or "search."
+    This interface is intentionally minimal, representing the smallest
+    set of attributes and methods that are conceptually shared by both
+    a database-backed Fit and a file-backed SearchOutput.
+    """
+
+    children: List["Fit"]
+
+    @property
+    @abstractmethod
+    def id(self) -> str:
+        """
+        A unique identifier for the fit/search output.
+        """
+
+    @property
+    @abstractmethod
+    def name(self) -> Optional[str]:
+        """
+        The name of the fit/search output.
+        """
+
+    @property
+    @abstractmethod
+    def unique_tag(self) -> Optional[str]:
+        """
+        A unique tag for differentiating this fit/search output from others.
+        """
+
+    @property
+    @abstractmethod
+    def path_prefix(self) -> Optional[str]:
+        """
+        A path prefix or directory path used by the search or fit.
+        """
+
+    @property
+    @abstractmethod
+    def is_complete(self) -> bool:
+        """
+        True if the fit/search completed successfully, False otherwise.
+        """
+
+    @property
+    @abstractmethod
+    def model(self) -> Any:
+        """
+        The model that was fit (database-backed or loaded from disk).
+        """
+
+    @property
+    @abstractmethod
+    def instance(self) -> Any:
+        """
+        The maximum-likelihood instance (database-backed or loaded from disk).
+        """
+
+    @property
+    @abstractmethod
+    def samples(self) -> Any:
+        """
+        The samples resulting from the fit/search.
+        """
+
+    @property
+    @abstractmethod
+    def latent_samples(self) -> Any:
+        """
+        An alternative set of samples (latent samples) if they exist, else None.
+        """
+
+    @abstractmethod
+    def child_values(self, name: str) -> List[Any]:
+        """
+        Get the values of a given key for all children, if the concept of children applies.
+        """
+
+    @property
+    @abstractmethod
+    def children(self) -> List[Any]:
+        """
+        Return a list of child fits/searches, if the concept of children applies.
+        """
+
+    @abstractmethod
+    def value(self, name: str) -> Any:
+        """
+        Retrieve a stored object by its name (Pickle, JSON, array, etc.).
+        Returns None if no value with that name is found.
+        """
diff --git a/autofit/aggregator/search_output.py b/autofit/aggregator/search_output.py
index b009b134d..0b216ac7c 100644
--- a/autofit/aggregator/search_output.py
+++ b/autofit/aggregator/search_output.py
@@ -2,6 +2,7 @@ import csv
 import json
 import logging
 import pickle
+from abc import ABC
 from os import path
 from pathlib import Path
 from typing import Generator, Tuple, Optional, List, cast, Type
@@ -22,6 +23,7 @@ from autofit.non_linear.samples.sample import samples_from_iterator
 from autoconf.dictable import from_dict
 from autofit.non_linear.samples.summary import SamplesSummary
 from autofit.non_linear.samples.util import simple_model_for_kwargs
+from . import fit_interface
 
 # noinspection PyProtectedMember
 original_create_file_handle = dill._dill._create_filehandle
@@ -46,7 +48,7 @@ def _create_file_handle(*args, **kwargs):
 dill._dill._create_filehandle = _create_file_handle
 
 
-class AbstractSearchOutput:
+class AbstractSearchOutput(ABC):
     def __init__(self, directory: Path, reference: Optional[dict] = None):
         self.directory = directory
         self._reference = reference
@@ -159,7 +161,7 @@ class AbstractSearchOutput:
         return None
 
 
-class SearchOutput(AbstractSearchOutput):
+class SearchOutput(AbstractSearchOutput, fit_interface.Fit):
     """
     @DynamicAttrs
     """
@@ -310,7 +312,7 @@ class SearchOutput(AbstractSearchOutput):
             yield name, file
 
     @property
-    def child_analyses(self):
+    def children(self):
         """
         A list of child analyses loaded from the analyses directory
         """
@@ -362,7 +364,7 @@ class SearchOutput(AbstractSearchOutput):
         """
         Get the values of a given key for all children
         """
-        return [getattr(child, name) for child in self.child_analyses]
+        return [getattr(child, name) for child in self.children]
 
     @property
     def path_prefix(self):
diff --git a/autofit/database/aggregator/scrape.py b/autofit/database/aggregator/scrape.py
index 69b48cca0..726dfb052 100644
--- a/autofit/database/aggregator/scrape.py
+++ b/autofit/database/aggregator/scrape.py
@@ -91,7 +91,7 @@ class Scraper:
                 )
 
             _add_files_fit(fit, item)
-            for i, child_analysis in enumerate(item.child_analyses):
+            for i, child_analysis in enumerate(item.children):
                 child_fit = m.Fit(
                     id=f"{item.id}_{i}",
                 )
diff --git a/autofit/database/model/fit.py b/autofit/database/model/fit.py
index 30b493a4c..60f2c9c6d 100644
--- a/autofit/database/model/fit.py
+++ b/autofit/database/model/fit.py
@@ -12,6 +12,7 @@ from autofit.non_linear.samples import Samples
 from .model import Base, Object
 from ..sqlalchemy_ import sa
 from .array import Array, HDU
+from ...aggregator import fit_interface
 from ...non_linear.samples.efficient import EfficientSamples
 
 
@@ -184,7 +185,7 @@ class NamedInstancesWrapper:
         raise KeyError(f"Instance {item} not found")
 
 
-class Fit(Base):
+class Fit(Base, fit_interface.Fit):
     __tablename__ = "fit"
 
     id = sa.Column(
diff --git a/autofit/database/model/model.py b/autofit/database/model/model.py
index 46c1a3cea..f04c572ec 100644
--- a/autofit/database/model/model.py
+++ b/autofit/database/model/model.py
@@ -1,6 +1,6 @@
 import abc
 import inspect
-from sqlalchemy.orm import Mapped
+from sqlalchemy.orm import Mapped, DeclarativeMeta
 from typing import List, Tuple, Any, Iterable, Union, ItemsView, Type
 
 import numpy as np
@@ -9,7 +9,12 @@ from autoconf.class_path import get_class, get_class_path
 from ..sqlalchemy_ import sa
 from sqlalchemy.orm import declarative_base
 
-Base = declarative_base()
+
+class DeclarativeABCMeta(DeclarativeMeta, abc.ABCMeta):
+    """A metaclass combining SQLAlchemy's DeclarativeMeta and ABCMeta."""
+
+
+Base = declarative_base(metaclass=DeclarativeABCMeta)
 
 _schema_version = 1
 


Test patch:
diff --git a/test_autofit/aggregator/test_child_analysis.py b/test_autofit/aggregator/test_child_analysis.py
index 3fa5ba9c2..e9e2a68b1 100644
--- a/test_autofit/aggregator/test_child_analysis.py
+++ b/test_autofit/aggregator/test_child_analysis.py
@@ -19,7 +19,7 @@ def make_search_output(directory):
 
 @pytest.fixture(name="child_analyses")
 def make_child_analyses(search_output):
-    return search_output.child_analyses
+    return search_output.children
 
 
 def test_child_analysis(child_analyses):
diff --git a/test_autofit/database/test_regression.py b/test_autofit/database/test_regression.py
index 119731d8a..b6d27f5a0 100644
--- a/test_autofit/database/test_regression.py
+++ b/test_autofit/database/test_regression.py
@@ -2,7 +2,8 @@ import pytest
 import numpy as np
 
 import autofit as af
-from autofit import database as db, Fit
+from autofit import database as db
+from autofit.database import Fit
 
 
 @pytest.fixture(name="model")