scisynth.testing.invariants

Reusable invariant checks for observations and pipelines.

  1"""Reusable invariant checks for observations and pipelines."""
  2
  3from __future__ import annotations
  4
  5import numpy as np
  6
  7from ..core._coords import has_position
  8from ..core.observation import Observation
  9from ..core.rng import SeedLike
 10from ..latent.base import Latent
 11from ..observer.observer import Observer
 12from ..stages.base import Stage
 13
 14
 15class InvariantError(AssertionError):
 16    """An invariant of an Observation or pipeline was violated."""
 17
 18
 19def check_observation(obs: Observation) -> None:
 20    """Check that an observation is well formed.
 21
 22    ``values`` is ``(*spatial, d)``; ``mask`` must have the spatial shape and
 23    ``truth`` the shape of ``values``. ``ids`` must be unique integers with the
 24    spatial shape. Coordinates must match a grid or points layout, masked-out
 25    values must be NaN, valid locations must have finite coordinates, and the
 26    ``meta`` records must be well formed.
 27
 28    Parameters
 29    ----------
 30    obs : Observation
 31        The observation to check.
 32
 33    Raises
 34    ------
 35    InvariantError
 36        If any of these fails.
 37    """
 38    _check_arrays(obs)
 39    _check_ids(obs)
 40    _check_coords(obs)
 41    if not np.all(np.isnan(obs.values[~obs.mask])):
 42        raise InvariantError("masked-out values must be NaN")
 43    for entry in obs.meta:
 44        if set(entry) != {"name", "type", "params"}:
 45            raise InvariantError(f"malformed meta entry: {entry!r}")
 46
 47
 48def _check_arrays(obs: Observation) -> None:
 49    """Check that ``values``, ``mask`` and ``truth`` have matching shapes and types."""
 50    shape = obs.values.shape
 51    spatial = shape[:-1]
 52    if obs.mask.shape != spatial:
 53        raise InvariantError(
 54            f"mask shape {obs.mask.shape} != spatial shape {spatial} of values {shape}"
 55        )
 56    if obs.mask.dtype != np.bool_:
 57        raise InvariantError(f"mask dtype must be bool, got {obs.mask.dtype}")
 58    if obs.truth is not None and obs.truth.shape != shape:
 59        raise InvariantError(f"truth shape {obs.truth.shape} != values shape {shape}")
 60
 61
 62def _check_ids(obs: Observation) -> None:
 63    """Check that the ids are unique integers with the spatial shape."""
 64    spatial = obs.values.shape[:-1]
 65    if obs.ids.shape != spatial or not np.issubdtype(obs.ids.dtype, np.integer):
 66        raise InvariantError(f"ids must be integers of shape {spatial}")
 67    if np.unique(obs.ids).size != obs.ids.size:
 68        raise InvariantError("ids must be unique")
 69
 70
 71def _check_coords(obs: Observation) -> None:
 72    """Check that the coordinates fit a layout and valid locations have a position."""
 73    spatial = obs.values.shape[:-1]
 74    coords = list(obs.coords.values())
 75    if obs.layout == "points" and any(c.shape != spatial for c in coords):
 76        raise InvariantError(
 77            f"coords {[c.shape for c in coords]} match neither a grid nor points "
 78            f"layout for values of shape {obs.values.shape}"
 79        )
 80    if np.any(obs.mask & ~has_position(obs)):
 81        raise InvariantError("valid locations must have finite coordinates")
 82
 83
 84def observations_equal(a: Observation, b: Observation) -> bool:
 85    """Return whether two observations are exactly equal.
 86
 87    Compares values, mask, ids, coordinates, truth and meta, treating NaN as equal to
 88    NaN.
 89
 90    Parameters
 91    ----------
 92    a, b : Observation
 93        The observations to compare.
 94
 95    Returns
 96    -------
 97    bool
 98        True if they are exactly equal.
 99    """
100    if not (
101        np.array_equal(a.values, b.values, equal_nan=True)
102        and np.array_equal(a.mask, b.mask)
103        and np.array_equal(a.ids, b.ids)
104        and a.coords.keys() == b.coords.keys()
105        and all(np.array_equal(a.coords[k], b.coords[k]) for k in a.coords)
106        and a.meta == b.meta
107    ):
108        return False
109    if a.truth is None or b.truth is None:
110        return a.truth is b.truth
111    return np.array_equal(a.truth, b.truth, equal_nan=True)
112
113
114def assert_reproducible(observer: Observer, latent: Latent, seed: SeedLike) -> None:
115    """Check that two runs with the same seed give identical observations.
116
117    Parameters
118    ----------
119    observer : Observer
120        The pipeline to run.
121    latent : Latent
122        The ground truth to observe.
123    seed : int, SeedSequence or None
124        Seed for both runs.
125
126    Raises
127    ------
128    InvariantError
129        If the two runs differ.
130    """
131    if not observations_equal(observer.run(latent, seed), observer.run(latent, seed)):
132        raise InvariantError("same seed gave different observations")
133
134
135def assert_prefix_stable(observer: Observer, latent: Latent, seed: SeedLike) -> None:
136    """Check that removing trailing stages leaves earlier output unchanged.
137
138    Parameters
139    ----------
140    observer : Observer
141        The pipeline to run.
142    latent : Latent
143        The ground truth to observe.
144    seed : int, SeedSequence or None
145        Seed for all runs.
146
147    Raises
148    ------
149    InvariantError
150        If the observer built from the first ``k`` stages differs from the full run
151        at step ``k``.
152    """
153    full = observer.trace(latent, seed)
154    for k in range(len(observer.stages) + 1):
155        head = observer.with_stages(observer.stages[:k])
156        if not observations_equal(head.run(latent, seed), full[k]):
157            raise InvariantError(
158                f"the first {k} stage(s) differ from the full run at step {k}"
159            )
160
161
162def assert_append_stable(
163    observer: Observer, extra: Stage, latent: Latent, seed: SeedLike
164) -> None:
165    """Check that appending a stage leaves every existing step's output unchanged.
166
167    Parameters
168    ----------
169    observer : Observer
170        The pipeline to run.
171    extra : Stage
172        The stage to append.
173    latent : Latent
174        The ground truth to observe.
175    seed : int, SeedSequence or None
176        Seed for both runs.
177
178    Raises
179    ------
180    InvariantError
181        If any step of the run with ``extra`` differs from the run without it.
182    """
183    before = observer.trace(latent, seed)
184    taken = set(observer.names)
185    n = 1
186    while f"appended_{n}" in taken:
187        n += 1
188    after = observer.with_stages(
189        [*observer.stages.items(), (f"appended_{n}", extra)]
190    ).trace(latent, seed)
191    for k, obs in enumerate(before):
192        if not observations_equal(obs, after[k]):
193            raise InvariantError(f"appending {type(extra).__name__} changed step {k}")
class InvariantError(builtins.AssertionError):
16class InvariantError(AssertionError):
17    """An invariant of an Observation or pipeline was violated."""

An invariant of an Observation or pipeline was violated.

def check_observation(obs: scisynth.core.Observation) -> None:
20def check_observation(obs: Observation) -> None:
21    """Check that an observation is well formed.
22
23    ``values`` is ``(*spatial, d)``; ``mask`` must have the spatial shape and
24    ``truth`` the shape of ``values``. ``ids`` must be unique integers with the
25    spatial shape. Coordinates must match a grid or points layout, masked-out
26    values must be NaN, valid locations must have finite coordinates, and the
27    ``meta`` records must be well formed.
28
29    Parameters
30    ----------
31    obs : Observation
32        The observation to check.
33
34    Raises
35    ------
36    InvariantError
37        If any of these fails.
38    """
39    _check_arrays(obs)
40    _check_ids(obs)
41    _check_coords(obs)
42    if not np.all(np.isnan(obs.values[~obs.mask])):
43        raise InvariantError("masked-out values must be NaN")
44    for entry in obs.meta:
45        if set(entry) != {"name", "type", "params"}:
46            raise InvariantError(f"malformed meta entry: {entry!r}")

Check that an observation is well formed.

values is (*spatial, d); mask must have the spatial shape and truth the shape of values. ids must be unique integers with the spatial shape. Coordinates must match a grid or points layout, masked-out values must be NaN, valid locations must have finite coordinates, and the meta records must be well formed.

Parameters
  • obs (Observation): The observation to check.
Raises
  • InvariantError: If any of these fails.
def observations_equal( a: scisynth.core.Observation, b: scisynth.core.Observation) -> bool:
 85def observations_equal(a: Observation, b: Observation) -> bool:
 86    """Return whether two observations are exactly equal.
 87
 88    Compares values, mask, ids, coordinates, truth and meta, treating NaN as equal to
 89    NaN.
 90
 91    Parameters
 92    ----------
 93    a, b : Observation
 94        The observations to compare.
 95
 96    Returns
 97    -------
 98    bool
 99        True if they are exactly equal.
100    """
101    if not (
102        np.array_equal(a.values, b.values, equal_nan=True)
103        and np.array_equal(a.mask, b.mask)
104        and np.array_equal(a.ids, b.ids)
105        and a.coords.keys() == b.coords.keys()
106        and all(np.array_equal(a.coords[k], b.coords[k]) for k in a.coords)
107        and a.meta == b.meta
108    ):
109        return False
110    if a.truth is None or b.truth is None:
111        return a.truth is b.truth
112    return np.array_equal(a.truth, b.truth, equal_nan=True)

Return whether two observations are exactly equal.

Compares values, mask, ids, coordinates, truth and meta, treating NaN as equal to NaN.

Parameters
  • a, b (Observation): The observations to compare.
Returns
  • bool: True if they are exactly equal.
def assert_reproducible( observer: scisynth.observer.Observer, latent: scisynth.latent.Latent, seed: int | Sequence[int] | numpy.random.bit_generator.SeedSequence | None) -> None:
115def assert_reproducible(observer: Observer, latent: Latent, seed: SeedLike) -> None:
116    """Check that two runs with the same seed give identical observations.
117
118    Parameters
119    ----------
120    observer : Observer
121        The pipeline to run.
122    latent : Latent
123        The ground truth to observe.
124    seed : int, SeedSequence or None
125        Seed for both runs.
126
127    Raises
128    ------
129    InvariantError
130        If the two runs differ.
131    """
132    if not observations_equal(observer.run(latent, seed), observer.run(latent, seed)):
133        raise InvariantError("same seed gave different observations")

Check that two runs with the same seed give identical observations.

Parameters
  • observer (Observer): The pipeline to run.
  • latent (Latent): The ground truth to observe.
  • seed (int, SeedSequence or None): Seed for both runs.
Raises
  • InvariantError: If the two runs differ.
def assert_prefix_stable( observer: scisynth.observer.Observer, latent: scisynth.latent.Latent, seed: int | Sequence[int] | numpy.random.bit_generator.SeedSequence | None) -> None:
136def assert_prefix_stable(observer: Observer, latent: Latent, seed: SeedLike) -> None:
137    """Check that removing trailing stages leaves earlier output unchanged.
138
139    Parameters
140    ----------
141    observer : Observer
142        The pipeline to run.
143    latent : Latent
144        The ground truth to observe.
145    seed : int, SeedSequence or None
146        Seed for all runs.
147
148    Raises
149    ------
150    InvariantError
151        If the observer built from the first ``k`` stages differs from the full run
152        at step ``k``.
153    """
154    full = observer.trace(latent, seed)
155    for k in range(len(observer.stages) + 1):
156        head = observer.with_stages(observer.stages[:k])
157        if not observations_equal(head.run(latent, seed), full[k]):
158            raise InvariantError(
159                f"the first {k} stage(s) differ from the full run at step {k}"
160            )

Check that removing trailing stages leaves earlier output unchanged.

Parameters
  • observer (Observer): The pipeline to run.
  • latent (Latent): The ground truth to observe.
  • seed (int, SeedSequence or None): Seed for all runs.
Raises
  • InvariantError: If the observer built from the first k stages differs from the full run at step k.
def assert_append_stable( observer: scisynth.observer.Observer, extra: scisynth.stages.Stage, latent: scisynth.latent.Latent, seed: int | Sequence[int] | numpy.random.bit_generator.SeedSequence | None) -> None:
163def assert_append_stable(
164    observer: Observer, extra: Stage, latent: Latent, seed: SeedLike
165) -> None:
166    """Check that appending a stage leaves every existing step's output unchanged.
167
168    Parameters
169    ----------
170    observer : Observer
171        The pipeline to run.
172    extra : Stage
173        The stage to append.
174    latent : Latent
175        The ground truth to observe.
176    seed : int, SeedSequence or None
177        Seed for both runs.
178
179    Raises
180    ------
181    InvariantError
182        If any step of the run with ``extra`` differs from the run without it.
183    """
184    before = observer.trace(latent, seed)
185    taken = set(observer.names)
186    n = 1
187    while f"appended_{n}" in taken:
188        n += 1
189    after = observer.with_stages(
190        [*observer.stages.items(), (f"appended_{n}", extra)]
191    ).trace(latent, seed)
192    for k, obs in enumerate(before):
193        if not observations_equal(obs, after[k]):
194            raise InvariantError(f"appending {type(extra).__name__} changed step {k}")

Check that appending a stage leaves every existing step's output unchanged.

Parameters
  • observer (Observer): The pipeline to run.
  • extra (Stage): The stage to append.
  • latent (Latent): The ground truth to observe.
  • seed (int, SeedSequence or None): Seed for both runs.
Raises
  • InvariantError: If any step of the run with extra differs from the run without it.