Repository: OpenFreeEnergy/openfe

Issue:
Implement Scorer Base Implementation
```python

s = Scorer(stuff)

m = Mapper.suggest_mapping(m1, m2)

f : float = s.score(m)
```

Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index 525811b7..e13f9d46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# custom ignores
+.xxrun
+
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
diff --git a/openfe/setup/scorer.py b/openfe/setup/scorer.py
index de12be0d..f4e3d082 100644
--- a/openfe/setup/scorer.py
+++ b/openfe/setup/scorer.py
@@ -1,4 +1,74 @@
+# This code is part of OpenFE and is licensed under the MIT license.
+# For details, see https://github.com/OpenFreeEnergy/openfe
+
+"""
+Scorers
+=======
+
+A scorer is used to determine a score (and optionally, annotations) for a
+given AtomMapping.
+"""
+
+from typing import NamedTuple, Dict, Any, Union
+
+
+class ScoreAnnotation(NamedTuple):
+    """Container for a score from a mapping and any associated annotations.
+
+    Parameters
+    ----------
+    score : float
+        The score, or ``None`` if there is no score associated
+    annotation : Dict[str, Any]
+        Mapping of annotation label to annotation value for any annotations
+    """
+    score: Union[float, None]
+    annotation: Dict[str, Any]
+
+
 class Scorer:
-    def __call__(self, atommapping):
-        return 0.0
+    """Abstract base class for Scorers.
+
+    To implement a subclass, you must implement the ``score`` method. If
+    your ``Scorer`` only returns annotations, then return None from the
+    ``score`` method. You may optionally implement the ``annotation``
+    method. The default ``annotation`` returns an empty dictionary.
+
+    Use a ``Scorer`` by calling it as a function.
+    """
+    def score(self, atommapping) -> Union[float, None]:
+        """Calculate the score for an AtomMapping.
+
+        Parameters
+        ----------
+        atommapping : AtomMapping
+            AtomMapping to score
+
+        Returns
+        -------
+        Union[float, None] :
+            The score, or ``None`` if no score is calculated
+        """
+        raise NotImplementedError(
+            "'Scorer' is an abstract class and should not be used directly. "
+            "Please use a specific subclass of 'Scorer'."
+        )
+
+    def annotation(self, atommapping) -> Dict[str, Any]:
+        """Create annotation dict for an AtomMapping.
+
+        Parameters
+        ----------
+        atommapping : AtomMapping
+            Atommapping to annotate
+
+        Returns
+        -------
+        Dict[str, Any] :
+            Mapping of annotation labels to annotation values
+        """
+        return {}
 
+    def __call__(self, atommapping) -> ScoreAnnotation:
+        return ScoreAnnotation(score=self.score(atommapping),
+                               annotation=self.annotation(atommapping))


Test patch:
diff --git a/openfe/tests/setup/__init__.py b/openfe/tests/setup/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/openfe/tests/setup/conftest.py b/openfe/tests/setup/conftest.py
new file mode 100644
index 00000000..d74d1b89
--- /dev/null
+++ b/openfe/tests/setup/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+
+
+@pytest.fixture
+def mock_atommapping():
+    """Mock to functions that take an AtomMapping as input"""
+    # TODO: add internal structure of this once we have an AtomMapping class
+    return "foo"  # current tests using this just need a placeholder
diff --git a/openfe/tests/setup/test_scorer.py b/openfe/tests/setup/test_scorer.py
new file mode 100644
index 00000000..faf00255
--- /dev/null
+++ b/openfe/tests/setup/test_scorer.py
@@ -0,0 +1,40 @@
+import pytest
+from openfe.setup.scorer import Scorer
+
+
+class ConcreteScorer(Scorer):
+    """Test implementation of Scorer with a score"""
+    def score(self, atommapping):
+        return 3.14
+
+
+class ConcreteAnnotator(Scorer):
+    """Test implementation of Scorer with a custom annotation"""
+    def score(self, atommapping):
+        return None
+
+    def annotation(self, atommapping):
+        return {'annotation': 'data'}
+
+
+class TestScorer:
+    def test_abstract_error(self, mock_atommapping):
+        scorer = Scorer()
+        with pytest.raises(NotImplementedError, match="'Scorer'.*abstract"):
+            scorer(mock_atommapping)
+
+    def test_concrete_scorer(self, mock_atommapping):
+        # The ConcreteScorer class should give the implemented value for the
+        # score and the default empty dict for the annotation.
+        scorer = ConcreteScorer()
+        result = scorer(mock_atommapping)
+        assert result.score == 3.14
+        assert result.annotation == {}
+
+    def test_concrete_annotator(self, mock_atommapping):
+        # The ConcreteAnnotator class should give the implemented (None)
+        # value for the score and the implemented value of the annotation.
+        scorer = ConcreteAnnotator()
+        result = scorer(mock_atommapping)
+        assert result.score is None
+        assert result.annotation == {'annotation': 'data'}