Repository: QCHackers/tqec (python)

Issue:
`DetectorDatabase` is not translation invariant
## Describe the bug
The current implementation of `DetectorDatabase` (and `DetectorDatabaseKey`) is not translation invariant.

## Steps to reproduce the behavior:
The below test in `tqec.compile.detectors.database_test`

```py
def test_detector_database_translation_invariance() -> None:
    db = DetectorDatabase()
    db.add_situation(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1], DETECTORS[0])

    offset = 36
    translated_subtemplate = SUBTEMPLATES[0] + 36
    translated_plaquettes = Plaquettes(
        FrozenDefaultDict(
            {
                plaquette_index + offset: plaquette
                for plaquette_index, plaquette in PLAQUETTE_COLLECTIONS[
                    0
                ].collection.items()
            }
        )
    )
    detectors = db.get_detectors((translated_subtemplate,), (translated_plaquettes,))
    assert detectors is not None
```

fails with

```
_________________________________ test_detector_database_translation_invariance _________________________________

    def test_detector_database_translation_invariance() -> None:
        db = DetectorDatabase()
        db.add_situation(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1], DETECTORS[0])
    
        offset = 36
        translated_subtemplate = SUBTEMPLATES[0] + 36
        translated_plaquettes = Plaquettes(
            FrozenDefaultDict(
                {
                    plaquette_index + offset: plaquette
                    for plaquette_index, plaquette in PLAQUETTE_COLLECTIONS[
                        0
                    ].collection.items()
                }
            )
        )
        detectors = db.get_detectors((translated_subtemplate,), (translated_plaquettes,))
>       assert detectors is not None
E       assert None is not None

src/tqec/compile/detectors/database_test.py:233: AssertionError
```

## Expected behavior
`DetectorDatabase` should be invariant by translation. That would help reducing the number of subtemplates that need to be stored by the database.


Pull request:
`DetectorDatabase` is now implemented with a translation-invariant key system
This PR fixes #374 and refactor a little bit `DetectorDatabase` and its accompanying key data-structure `_DetectorDatabaseKey` to:

1. be translation free, meaning that now equivalent pairs of subtemplates/plaquettes are mapped to the same database entry.
2. use a simpler hashing method that is fully contained in the `database.py` (and not spread in the different data-structures used) and only require that `Plaquette.name` is a valid way of comparing/hashing plaquettes (i.e., the following assertion should hold: "two plaquettes have the same name iff they are equal").

Patch:
diff --git a/docs/quick_start.rst b/docs/quick_start.rst
index 1fb84f90..df1704c7 100644
--- a/docs/quick_start.rst
+++ b/docs/quick_start.rst
@@ -43,7 +43,7 @@ use the following code
 
 .. code-block:: python
 
-    observables, _ = block_graph.get_abstract_observables()
+    observables, correlation_surfaces = block_graph.get_abstract_observables()
 
 Any observable can be plotted using the ``tqec dae2observables`` command line. For our
 specific example, the command line
@@ -98,3 +98,76 @@ error-corrected implementation of a CNOT gate shown at the beginning of this pag
 
 You can download the circuit in a ``stim`` format here:
 :download:`media/quick_start/logical_cnot.stim <./media/quick_start/logical_cnot.stim>`.
+
+6. Simulate multiple experiments
+--------------------------------
+The circuit can be simulated using the ``stim`` and ``sinter`` libraries.
+Usually you want to simulate combinations of error rates and code distances, potentially
+for multiple observables.
+Multiple runs can be done in parallel using the ``sinter`` library using the ``start_simulation_using_sinter``.
+The compilation of the block graph is done automatically based on the inputs.
+
+.. code-block:: python
+
+    from multiprocessing import cpu_count()
+
+    import numpy as np
+
+    from tqec.noise_models import NoiseModel
+    from tqec.simulation.simulation import start_simulation_using_sinter
+    # returns a iterator
+    stats = start_simulation_using_sinter(
+        block_graph,
+        ks=range(1, 4), # k values for the code distance
+        ps=list(np.logspace(-4, -1, 10)), # error rates
+        noise_model_factory=NoiseModel.uniform_depolarizing # noise model
+        observables=[observables[1]], # observable of interest
+        decoders=["pymatching"],
+        num_workers=cpu_count(),
+        max_shots=10_000_000,
+        max_errors=5_000,
+        print_progress=True,
+    )
+
+``Sinter`` can be additionally supplied with simulation parameters, full interoperability with ``sinter`` is not yet implemented.
+`sinter_api https://github.com/quantumlib/Stim/blob/main/doc/sinter_api.md`_
+
+
+7. Plot the results
+-------------------
+Simulation Results can be plotted with ``matplolib`` using the ``plot_simulation_results``.
+
+.. code-block:: python
+
+    from tqec.simulation.plotting import plot_simulation_results
+
+    zx_graph = block_graph.to_zx_graph()
+
+    fig, ax = plt.subplots()
+    # len(stats) = 1 if we have multiple we can iterate over the results
+    sinter.plot_error_rate(
+        ax=ax,
+        stats=next(stats),
+        x_func=lambda stat: stat.json_metadata["p"],
+        group_func=lambda stat: stat.json_metadata["d"],
+    )
+    plot_observable_as_inset(ax, zx_graph, correlation_surfaces[1])
+    ax.grid(axis="both")
+    ax.legend()
+    ax.loglog()
+    ax.set_title(f"Logical CNOT Error Rate")
+    fig.savefig(f"logical_cnot_result_x_observable_{1}.png")
+
+8. Conclusion
+-------------
+This quick start guide has shown how to use the ``tqec`` library to define a computation,
+import it into the library, compile it to stim circuits.
+Simulations are run and visualized for multiple error rates and code distances.
+For an extensive example, see also the
+`tqec_example <https://github.com/QCHackers/tqec/blob/main/examples/logical_cnot.py`_.
+
+The process can be repeated through the cli using
+
+..code-block:: bash
+
+    tqec run-example --out-dir ./results
diff --git a/src/tqec/_cli/subcommands/run_example.py b/src/tqec/_cli/subcommands/run_example.py
new file mode 100644
index 00000000..3de1badf
--- /dev/null
+++ b/src/tqec/_cli/subcommands/run_example.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+
+import argparse
+import logging
+from multiprocessing import cpu_count
+from pathlib import Path
+
+import numpy as np
+import matplotlib.pyplot as plt
+import sinter
+from typing_extensions import override
+
+from tqec._cli.subcommands.base import TQECSubCommand
+from tqec.compile.specs.library.css import CSS_BLOCK_BUILDER, CSS_SUBSTITUTION_BUILDER
+from tqec.compile.specs.library.zxxz import (
+    ZXXZ_BLOCK_BUILDER,
+    ZXXZ_SUBSTITUTION_BUILDER,
+)
+from tqec.gallery import logical_cnot_block_graph
+from tqec.noise_models import NoiseModel
+from tqec.simulation.plotting.inset import plot_observable_as_inset
+from tqec.simulation.simulation import start_simulation_using_sinter
+
+
+class RunExampleTQECSubCommand(TQECSubCommand):
+    @staticmethod
+    @override
+    def add_subcommand(
+        main_parser: argparse._SubParsersAction[argparse.ArgumentParser],
+    ) -> None:
+        parser: argparse.ArgumentParser = main_parser.add_parser(
+            "run-example",
+            description=(
+                "Runs the full pipeline on the CNOT example. "
+                "block graph -> stim circuits and observables -> simulation -> plots"
+            ),
+        )
+        parser.add_argument(
+            "--out-dir",
+            help="Directory to save the generated outputs.",
+            type=Path,
+            required=True,
+        )
+        parser.add_argument(
+            "-k",
+            help="The scale factors applied to the circuits.",
+            nargs="+",
+            type=int,
+            default=[1, 2, 3, 4],
+        )
+
+        parser.add_argument(
+            "-p",
+            help="The noise levels applied to the simulation.",
+            nargs="+",
+            type=float,
+            default=list(np.logspace(-4, -1, 10)),
+        )
+
+        parser.add_argument(
+            "--obs-include",
+            help=(
+                "The observable indices to be included in the circuits. "
+                "If not provided, all potential observables will be included."
+            ),
+            nargs="*",
+            type=int,
+        )
+        parser.add_argument(
+            "--code-style",
+            help="Use the CSS or ZXXZ code style.",
+            choices=["CSS", "ZXXZ"],
+            default="CSS",
+        )
+        parser.add_argument(
+            "--basis",
+            help="Which basis to use for simulation.",
+            choices=["X", "Z"],
+            default="X",
+        )
+        parser.set_defaults(func=RunExampleTQECSubCommand.execute)
+
+    @staticmethod
+    @override
+    def execute(args: argparse.Namespace) -> None:
+        # Parse args
+        logging.info("Parsing arguments.")
+        out_dir: Path = args.out_dir.resolve()
+        obs_indices: list[int] = args.obs_include
+        style: str = args.code_style
+        basis_str: str = args.basis
+        z_basis: bool = basis_str == "Z"
+        ks: list[int] = args.k
+        ps: list[float] = args.p
+
+        # Set up the output directories
+        logging.info("Setting up output directories.")
+        if not out_dir.exists():
+            out_dir.mkdir(parents=True)
+
+        simulations_out_dir = out_dir / "simulations"
+        simulations_out_dir.mkdir(exist_ok=True)
+        plots_out_dir = out_dir / "plots"
+        plots_out_dir.mkdir(exist_ok=True)
+
+        logging.info("Generating CNOT block graph and zx graph.")
+        block_graph = logical_cnot_block_graph(z_basis)
+        zx_graph = block_graph.to_zx_graph()
+
+        # observables to a subdirectory
+        logging.info("Find the observables.")
+        observables, correlation_surfaces = block_graph.get_abstract_observables()
+        if not obs_indices:
+            obs_indices = list(range(len(observables)))
+        if max(obs_indices) >= len(observables):
+            raise ValueError(
+                f"Found {len(observables)} observables,"
+                + f"but requested indices up to {max(obs_indices)}."
+            )
+
+        block_builder = CSS_BLOCK_BUILDER if style == "CSS" else ZXXZ_BLOCK_BUILDER
+        substitution_builder = (
+            CSS_SUBSTITUTION_BUILDER if style == "CSS" else ZXXZ_SUBSTITUTION_BUILDER
+        )
+
+        logging.info("Start the simulation, this might take some time.")
+        stats = start_simulation_using_sinter(
+            block_graph,
+            ks,
+            ps,
+            NoiseModel.uniform_depolarizing,
+            block_builder=block_builder,
+            substitution_builder=substitution_builder,
+            observables=[observables[i] for i in obs_indices],
+            num_workers=cpu_count(),
+            max_shots=10_000_000,
+            max_errors=5_000,
+            decoders=["pymatching"],
+            print_progress=True,
+        )
+        logging.info("Simulation finished.")
+
+        logging.info("Write plots to %s.", plots_out_dir)
+        for i, stat in enumerate(stats):
+            with open(
+                simulations_out_dir
+                / f"{style}_logical_cnot_result_{basis_str}_observable_{i}.csv",
+                "w+",
+                encoding="utf-8",
+            ) as stats_file:
+                stats_file.write(sinter.CSV_HEADER)
+                for sub_stat in stat:
+                    stats_file.write(sub_stat.to_csv_line())
+
+            fig, ax = plt.subplots()
+            sinter.plot_error_rate(
+                ax=ax,
+                stats=stat,
+                x_func=lambda stat: stat.json_metadata["p"],
+                group_func=lambda stat: stat.json_metadata["d"],
+            )
+            plot_observable_as_inset(ax, zx_graph, correlation_surfaces[i])
+            ax.grid(axis="both")
+            ax.legend()
+            ax.loglog()
+            ax.set_title(f"{style} Logical CNOT Error Rate")
+            fig.savefig(
+                plots_out_dir
+                / f"{style}_logical_cnot_result_{basis_str}_observable_{i}.png"
+            )
diff --git a/src/tqec/_cli/tqec.py b/src/tqec/_cli/tqec.py
index 42558f63..12f5adc0 100644
--- a/src/tqec/_cli/tqec.py
+++ b/src/tqec/_cli/tqec.py
@@ -4,6 +4,7 @@ import sys
 from tqec._cli.subcommands.check_dae import CheckDaeTQECSubCommand
 from tqec._cli.subcommands.dae2observables import Dae2ObservablesTQECSubCommand
 from tqec._cli.subcommands.dae2circuits import Dae2CircuitsTQECSubCommand
+from tqec._cli.subcommands.run_example import RunExampleTQECSubCommand
 
 
 def main() -> None:
@@ -16,6 +17,7 @@ def main() -> None:
     Dae2ObservablesTQECSubCommand.add_subcommand(subparser)
     CheckDaeTQECSubCommand.add_subcommand(subparser)
     Dae2CircuitsTQECSubCommand.add_subcommand(subparser)
+    RunExampleTQECSubCommand.add_subcommand(subparser)
 
     args = parser.parse_args(args=None if sys.argv[1:] else ["--help"])
     args.func(args)
diff --git a/src/tqec/compile/detectors/database.py b/src/tqec/compile/detectors/database.py
index 4eee89bc..44cb5b2e 100644
--- a/src/tqec/compile/detectors/database.py
+++ b/src/tqec/compile/detectors/database.py
@@ -1,10 +1,10 @@
+from __future__ import annotations
+
 import hashlib
 from dataclasses import dataclass, field
+from functools import cached_property
 from typing import Sequence
 
-import numpy
-import numpy.typing as npt
-
 from tqec.circuit.generation import generate_circuit_from_instantiation
 from tqec.circuit.measurement_map import MeasurementRecordsMap
 from tqec.circuit.moment import Moment
@@ -20,12 +20,8 @@ from tqec.position import Displacement
 from tqec.templates.subtemplates import SubTemplateType
 
 
-def _NUMPY_ARRAY_HASHER(arr: npt.NDArray[numpy.int_]) -> int:
-    return int(hashlib.md5(arr.data.tobytes(), usedforsecurity=False).hexdigest(), 16)
-
-
 @dataclass(frozen=True)
-class DetectorDatabaseKey:
+class _DetectorDatabaseKey:
     """Immutable type used as a key in the database of detectors.
 
     This class represents a "situation" for which we might be able to compute
@@ -43,25 +39,19 @@ class DetectorDatabaseKey:
 
     ## Implementation details
 
-    This class stores data types that are not efficiently hashable (i.e., not in
-    constant time) when considering their values:
-
-    - `self.subtemplates` is a raw array of integers without any guarantee on the
-      stored values except that they are positive.
-    - `self.plaquettes_by_timestep` contains :class:`Plaquette` instances, each
-      containing a class:`ScheduledCircuit` instance, ultimately containing a
-      `stim.Circuit`. Hashing a quantum circuit cannot be performed in constant
-      time.
-
-    For `self.subtemplates`, we hash the `shape` of the array as well as the
-    hash of the array's data. This is a constant time operation, because
-    we only consider spatially local detectors at the moment and that
-    restriction makes sub-templates that are of constant size (w.r.t the number
-    of qubits).
-
-    For `self.plaquettes_by_timestep`, we rely on the hash implementation of
-    :class:`Plaquettes`. It is up to :class:`Plaquettes` to implement hash
-    efficiently.
+    This class uses a surjective representation to compare (`__eq__`) and hash
+    (`__hash__`) its instances. This representation is computed and cached using
+    the :meth:`_DetectorDatabaseKey.plaquette_names` property that basically
+    uses the provided subtemplates to build a nested tuple data-structure with
+    the same shape as `self.subtemplates` (3 dimensions, the first one being the
+    number of time steps, the next 2 ones being of odd and equal size and
+    depending on the radius used to build subtemplates) storing in each of its
+    entries the corresponding plaquette name.
+
+    This intermediate data-structure is not the most memory efficient one, but
+    it has the advantage of being easy to construct, trivially invariant to
+    plaquette re-indexing and easy to hash (with some care to NOT use Python's
+    default `hash` due to its absence of stability across different runs).
     """
 
     subtemplates: Sequence[SubTemplateType]
@@ -79,24 +69,28 @@ class DetectorDatabaseKey:
     def num_timeslices(self) -> int:
         return len(self.subtemplates)
 
-    def __hash__(self) -> int:
-        return hash(
-            (
-                tuple(st.shape for st in self.subtemplates),
-                tuple(_NUMPY_ARRAY_HASHER(st) for st in self.subtemplates),
-                tuple(self.plaquettes_by_timestep),
-            )
+    @cached_property
+    def plaquette_names(self) -> tuple[tuple[tuple[str, ...], ...], ...]:
+        return tuple(
+            tuple(tuple(plaquettes[pi].name for pi in row) for row in st)
+            for st, plaquettes in zip(self.subtemplates, self.plaquettes_by_timestep)
         )
 
+    def reliable_hash(self) -> int:
+        hasher = hashlib.md5()
+        for timeslice in self.plaquette_names:
+            for row in timeslice:
+                for name in row:
+                    hasher.update(name.encode())
+        return int(hasher.hexdigest(), 16)
+
+    def __hash__(self) -> int:
+        return self.reliable_hash()
+
     def __eq__(self, rhs: object) -> bool:
         return (
-            isinstance(rhs, DetectorDatabaseKey)
-            and len(self.subtemplates) == len(rhs.subtemplates)
-            and all(
-                bool(numpy.all(self_st == rhs_st))
-                for self_st, rhs_st in zip(self.subtemplates, rhs.subtemplates)
-            )
-            and self.plaquettes_by_timestep == rhs.plaquettes_by_timestep
+            isinstance(rhs, _DetectorDatabaseKey)
+            and self.plaquette_names == rhs.plaquette_names
         )
 
     def circuit(self, plaquette_increments: Displacement) -> ScheduledCircuit:
@@ -138,7 +132,7 @@ class DetectorDatabase:
     computation.
     """
 
-    mapping: dict[DetectorDatabaseKey, frozenset[Detector]] = field(
+    mapping: dict[_DetectorDatabaseKey, frozenset[Detector]] = field(
         default_factory=dict
     )
     frozen: bool = False
@@ -169,7 +163,7 @@ class DetectorDatabase:
         """
         if self.frozen:
             raise TQECException("Cannot add a situation to a frozen database.")
-        key = DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+        key = _DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
         self.mapping[key] = (
             frozenset([detectors]) if isinstance(detectors, Detector) else detectors
         )
@@ -195,7 +189,7 @@ class DetectorDatabase:
         """
         if self.frozen:
             raise TQECException("Cannot remove a situation to a frozen database.")
-        key = DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+        key = _DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
         del self.mapping[key]
 
     def get_detectors(
@@ -220,7 +214,7 @@ class DetectorDatabase:
             detectors associated with the provided situation or `None` if the
             situation is not in the database.
         """
-        key = DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
+        key = _DetectorDatabaseKey(subtemplates, plaquettes_by_timestep)
         return self.mapping.get(key)
 
     def freeze(self) -> None:
diff --git a/src/tqec/plaquette/frozendefaultdict.py b/src/tqec/plaquette/frozendefaultdict.py
index 1c4b9afd..93a7b8d8 100644
--- a/src/tqec/plaquette/frozendefaultdict.py
+++ b/src/tqec/plaquette/frozendefaultdict.py
@@ -82,17 +82,21 @@ class FrozenDefaultDict(Generic[K, V], Mapping[K, V]):
         return (
             isinstance(other, FrozenDefaultDict)
             and (
-                not operator.xor(
-                    self._default_factory is None, other._default_factory is None
+                (self._default_factory is None and other._default_factory is None)
+                or (
+                    self._default_factory is not None
+                    and other._default_factory is not None
+                    and (self._default_factory() == other._default_factory())
                 )
             )
-            and (
-                self._default_factory is not None
-                and other._default_factory is not None
-                and (self._default_factory() == other._default_factory())
-            )
             and self._dict == other._dict
         )
 
     def has_default_factory(self) -> bool:
         return self._default_factory is not None
+
+    def map_keys(self, callable: Callable[[K], K]) -> FrozenDefaultDict[K, V]:
+        return FrozenDefaultDict(
+            {callable(k): v for k, v in self.items()},
+            default_factory=self._default_factory,
+        )
diff --git a/src/tqec/plaquette/plaquette.py b/src/tqec/plaquette/plaquette.py
index d6ffe561..58ea90fe 100644
--- a/src/tqec/plaquette/plaquette.py
+++ b/src/tqec/plaquette/plaquette.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 import hashlib
 from collections import defaultdict
 from dataclasses import dataclass, field
-from typing import Mapping
+from typing import Callable, Mapping
 
 from typing_extensions import override
 
@@ -152,6 +152,9 @@ class Plaquettes:
     ) -> Plaquettes:
         return Plaquettes(self.collection | plaquettes_to_update)
 
+    def map_indices(self, callable: Callable[[int], int]) -> Plaquettes:
+        return Plaquettes(self.collection.map_keys(callable))
+
     def __eq__(self, rhs: object) -> bool:
         return isinstance(rhs, Plaquettes) and self.collection == rhs.collection
 


Test patch:
diff --git a/src/tqec/compile/detectors/database_test.py b/src/tqec/compile/detectors/database_test.py
index a9c28989..a0b47c98 100644
--- a/src/tqec/compile/detectors/database_test.py
+++ b/src/tqec/compile/detectors/database_test.py
@@ -4,7 +4,7 @@ import pytest
 from tqec.circuit.coordinates import StimCoordinates
 from tqec.circuit.measurement import Measurement
 from tqec.circuit.qubit import GridQubit
-from tqec.compile.detectors.database import DetectorDatabase, DetectorDatabaseKey
+from tqec.compile.detectors.database import DetectorDatabase, _DetectorDatabaseKey
 from tqec.compile.detectors.detector import Detector
 from tqec.compile.specs.library._utils import _build_plaquettes_for_rotated_surface_code
 from tqec.exceptions import TQECException
@@ -71,7 +71,6 @@ SUBTEMPLATES: list[SubTemplateType] = list(
                 QubitTemplate().instantiate(k=10), manhattan_radius=2
             ).subtemplates.values()
         ),
-        axis=0,
     )
 )
 
@@ -118,20 +117,20 @@ DETECTORS: list[frozenset[Detector]] = [
 
 
 def test_detector_database_key_creation() -> None:
-    DetectorDatabaseKey((SUBTEMPLATES[0],), (PLAQUETTE_COLLECTIONS[0],))
-    DetectorDatabaseKey(SUBTEMPLATES[1:5], PLAQUETTE_COLLECTIONS[1:5])
+    _DetectorDatabaseKey((SUBTEMPLATES[0],), (PLAQUETTE_COLLECTIONS[0],))
+    _DetectorDatabaseKey(SUBTEMPLATES[1:5], PLAQUETTE_COLLECTIONS[1:5])
     with pytest.raises(
         TQECException,
         match="^DetectorDatabaseKey can only store an equal number of "
         "subtemplates and plaquettes. Got 4 subtemplates and 3 plaquettes.$",
     ):
-        DetectorDatabaseKey(SUBTEMPLATES[1:5], PLAQUETTE_COLLECTIONS[1:4])
+        _DetectorDatabaseKey(SUBTEMPLATES[1:5], PLAQUETTE_COLLECTIONS[1:4])
 
 
 def test_detector_database_key_num_timeslices() -> None:
     for i in range(min(len(PLAQUETTE_COLLECTIONS), len(SUBTEMPLATES))):
         assert (
-            DetectorDatabaseKey(
+            _DetectorDatabaseKey(
                 SUBTEMPLATES[:i], PLAQUETTE_COLLECTIONS[:i]
             ).num_timeslices
             == i
@@ -139,19 +138,19 @@ def test_detector_database_key_num_timeslices() -> None:
 
 
 def test_detector_database_key_hash() -> None:
-    dbkey = DetectorDatabaseKey(SUBTEMPLATES[1:5], PLAQUETTE_COLLECTIONS[1:5])
+    dbkey = _DetectorDatabaseKey(SUBTEMPLATES[1:5], PLAQUETTE_COLLECTIONS[1:5])
     assert hash(dbkey) == hash(dbkey)
     # This is a value that has been pre-computed locally. It is hard-coded here
     # to check that the hash of a dbkey is reliable and does not change depending
     # on the Python interpreter, Python version, host OS, process ID, ...
-    assert hash(dbkey) == 6635855037027289589
+    assert hash(dbkey) == 1085786788918911944
 
-    dbkey = DetectorDatabaseKey(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1])
+    dbkey = _DetectorDatabaseKey(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1])
     assert hash(dbkey) == hash(dbkey)
     # This is a value that has been pre-computed locally. It is hard-coded here
     # to check that the hash of a dbkey is reliable and does not change depending
     # on the Python interpreter, Python version, host OS, process ID, ...
-    assert hash(dbkey) == -8009786746945676048
+    assert hash(dbkey) == 1699471538780763110
 
 
 def test_detector_database_creation() -> None:
@@ -210,3 +209,15 @@ def test_detector_database_freeze() -> None:
     detectors2 = db.get_detectors(SUBTEMPLATES[:4], PLAQUETTE_COLLECTIONS[:4])
     assert detectors2 is not None
     assert detectors2 == DETECTORS[1]
+
+
+def test_detector_database_translation_invariance() -> None:
+    db = DetectorDatabase()
+    db.add_situation(SUBTEMPLATES[:1], PLAQUETTE_COLLECTIONS[:1], DETECTORS[0])
+
+    offset = 36
+    translated_subtemplate = SUBTEMPLATES[0] + offset
+    translated_plaquettes = PLAQUETTE_COLLECTIONS[0].map_indices(lambda i: i + offset)
+    detectors = db.get_detectors((translated_subtemplate,), (translated_plaquettes,))
+    assert detectors is not None
+    assert detectors == DETECTORS[0]