From 153094eef52d6c1604a4493d2505f523983e9198 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Mon, 14 Oct 2024 15:17:42 +0200 Subject: [PATCH] Add `Q`, `R`, `C`, and `GF2` fields - add `lalib.fields.base.Field`, a blueprint for all concrete fields, providing a unified interface to be used outside of the `lalib.fields` sub-package - implement `lalib.fields.complex_.ComplexField`, or `C` for short, the field over the complex numbers (modeled as `complex` numbers) - implement `lalib.fields.galois.GaloisField2`, or `GF2` for short, the (finite) field over the two elements `one` and `zero` + adapt `lalib.elements.galois.GF2Element.__eq__()` to return `NotImplemented` instead of `False` for non-castable `other`s => this fixes a minor issue with `pytest.approx()` - implement `lalib.fields.rational.RationalField`, or `Q` for short, the field over the rational numbers (modeled as `fractions.Fraction`s) - implement `lalib.fields.real.RealField`, or `R` for short, the field over the real numbers (modeled as `float`s) - organize top-level imports for `lalib.fields`, making `Q`, `R`, `C`, and `GF2` importable with `from lalib.fields import *` - provide extensive unit and integration tests for the new objects: + test generic and common behavior in `tests.fields.test_base` + test specific behavior is other modules + test the well-known math axioms for all fields (integration tests) + test the new objects' docstrings + add "pytest-repeat" to run randomized tests many times --- noxfile.py | 2 + poetry.lock | 16 ++- pyproject.toml | 3 + src/lalib/config.py | 8 ++ src/lalib/elements/galois.py | 2 +- src/lalib/fields/__init__.py | 31 +++++ src/lalib/fields/base.py | 227 ++++++++++++++++++++++++++++++ src/lalib/fields/complex_.py | 110 +++++++++++++++ src/lalib/fields/galois.py | 45 ++++++ src/lalib/fields/rational.py | 90 ++++++++++++ src/lalib/fields/real.py | 97 +++++++++++++ src/lalib/fields/utils.py | 22 +++ tests/fields/test_axioms.py | 92 +++++++++++++ tests/fields/test_base.py | 253 ++++++++++++++++++++++++++++++++++ tests/fields/test_complex.py | 96 +++++++++++++ tests/fields/test_galois.py | 100 ++++++++++++++ tests/fields/test_rational.py | 29 ++++ tests/fields/utils.py | 75 ++++++++++ tests/test_docstrings.py | 6 + 19 files changed, 1302 insertions(+), 2 deletions(-) create mode 100644 src/lalib/fields/base.py create mode 100644 src/lalib/fields/complex_.py create mode 100644 src/lalib/fields/galois.py create mode 100644 src/lalib/fields/rational.py create mode 100644 src/lalib/fields/real.py create mode 100644 src/lalib/fields/utils.py create mode 100644 tests/fields/test_axioms.py create mode 100644 tests/fields/test_base.py create mode 100644 tests/fields/test_complex.py create mode 100644 tests/fields/test_galois.py create mode 100644 tests/fields/test_rational.py create mode 100644 tests/fields/utils.py diff --git a/noxfile.py b/noxfile.py index 24be972..fc0fee2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -231,6 +231,7 @@ TEST_DEPENDENCIES = ( "pytest", "pytest-cov", "pytest-randomly", + "pytest-repeat", "semver", 'typing-extensions; python_version < "3.11"', # to support Python 3.9 & 3.10 "xdoctest", @@ -290,6 +291,7 @@ def test_coverage_run(session: nox.Session) -> None: session.install(".") install_pinned(session, "coverage", *TEST_DEPENDENCIES) + session.env["N_RANDOM_DRAWS"] = "10" session.env["NO_CROSS_REFERENCE"] = "true" session.run( "python", diff --git a/poetry.lock b/poetry.lock index c402d6e..0c5eed0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1234,6 +1234,20 @@ files = [ importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} pytest = "*" +[[package]] +name = "pytest-repeat" +version = "0.9.3" +description = "pytest plugin for repeating tests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest_repeat-0.9.3-py3-none-any.whl", hash = "sha256:26ab2df18226af9d5ce441c858f273121e92ff55f5bb311d25755b8d7abdd8ed"}, + {file = "pytest_repeat-0.9.3.tar.gz", hash = "sha256:ffd3836dfcd67bb270bec648b330e20be37d2966448c4148c4092d1e8aba8185"}, +] + +[package.dependencies] +pytest = "*" + [[package]] name = "pyyaml" version = "6.0.2" @@ -1714,4 +1728,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "31a7b1ad8dd8949814d1f397022e400cd37643c6f5e9e438054de472091bcd69" +content-hash = "a10f3c13ed0c67e6ea65500335281cd269e3efdd0fae544ad667d07637ef118f" diff --git a/pyproject.toml b/pyproject.toml index 086646f..3114aae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ packaging = "^24.1" # to test the version identifier pytest = "^8.3" pytest-cov = "^5.0" pytest-randomly = "^3.15" +pytest-repeat = "^0.9" semver = "^3.0" # to test the version identifier tomli = [ { python = "<3.11", version = "^2.0" } ] xdoctest = { extras = ["colors"], version = "^1.2" } @@ -381,6 +382,8 @@ extend-ignore = [ # never check the following codes ] +allowed-confusables = ["ℂ", "ℝ", "ℚ"] + [tool.ruff.lint.flake8-pytest-style] # aligned with [tool.flake8] above diff --git a/src/lalib/config.py b/src/lalib/config.py index 5a20788..e27ced9 100644 --- a/src/lalib/config.py +++ b/src/lalib/config.py @@ -1,5 +1,13 @@ """Library-wide default settings.""" +import math + + NDIGITS = 12 THRESHOLD = 1 / (10**NDIGITS) + +MAX_DENOMINATOR = math.trunc(1 / THRESHOLD) + + +del math diff --git a/src/lalib/elements/galois.py b/src/lalib/elements/galois.py index dd2bfad..e2651b2 100644 --- a/src/lalib/elements/galois.py +++ b/src/lalib/elements/galois.py @@ -314,7 +314,7 @@ class GF2Element(metaclass=GF2Meta): try: other = GF2Element(other) except (TypeError, ValueError): - return False + return NotImplemented else: # TODO(webartifex): investigate the below issue # https://github.com/webartifex/lalib/issues/1 diff --git a/src/lalib/fields/__init__.py b/src/lalib/fields/__init__.py index 1db1a3b..c03b9b8 100644 --- a/src/lalib/fields/__init__.py +++ b/src/lalib/fields/__init__.py @@ -1 +1,32 @@ """A collection of common fields used in linear algebra.""" + +from lalib.fields import base +from lalib.fields import complex_ +from lalib.fields import galois +from lalib.fields import rational +from lalib.fields import real +from lalib.fields import utils + + +Field = base.Field + +Q = rational.Q +R = real.R +C = complex_.C +GF2 = galois.GF2 + + +del base +del complex_ +del galois +del rational +del real +del utils # `import`ed and `del`eted to not be in the final namespace + + +__all__ = ( + "Q", + "R", + "C", + "GF2", +) diff --git a/src/lalib/fields/base.py b/src/lalib/fields/base.py new file mode 100644 index 0000000..0e94569 --- /dev/null +++ b/src/lalib/fields/base.py @@ -0,0 +1,227 @@ +"""The abstract blueprint of a `Field`.""" + +import abc +import numbers +from typing import Any, Callable, Generic, TypeVar + + +T = TypeVar("T", bound=numbers.Real) + + +class Field(abc.ABC, Generic[T]): + """The abstract blueprint of a mathematical field.""" + + @property + @abc.abstractmethod + def _math_name(self) -> str: + """The common abbreviation used in math notation.""" + + @staticmethod + @abc.abstractmethod + def _dtype(value: Any, /) -> T: + """Data type to store the `Field` elements.""" + + def _cast_func(self, value: Any, /, **kwargs: Any) -> T: + """Function to cast `value`s as field elements.""" + return self._dtype(value, **kwargs) + + @staticmethod + def _post_cast_filter(possible_element: T, /) -> bool: + """Function to filter out castable `value`s. + + Called after a successfull call of the `._cast_func()`. + + For example, if one wants to avoid non-finite `Field` elements, + an overwriting `._post_cast_filter()` could return `False` for + non-finite `value`s like `float("NaN")`. + + By default, all castable `value`s may become `Field` elements. + """ + return True + + @property + @abc.abstractmethod + def _additive_identity(self) -> T: + """The field's additive identity.""" + + @property + @abc.abstractmethod + def _multiplicative_identity(self) -> T: + """The field's multiplicative identity.""" + + def cast( + self, + value: object, + /, + **cast_kwargs: Any, + ) -> T: + """Cast a (numeric) `value` as an element of the field. + + Args: + value: to be cast as the "right" data type + as defined in the concrete `Field` sub-class + **cast_kwargs: extra `kwargs` to the `._cast_func()` + + Returns: + element: of the concrete `Field` + + Raises: + ValueError: `value` is not an element of the field + """ + try: + element = self._cast_func(value, **cast_kwargs) # type: ignore[arg-type] + except (ArithmeticError, TypeError, ValueError): + msg = "`value` is not an element of the field" + raise ValueError(msg) from None + + if not self._post_cast_filter(element): + msg = "`value` is not an element of the field" + raise ValueError(msg) + + return element + + def validate( + self, + value: object, + /, + *, + silent: bool = True, + **cast_kwargs: Any, + ) -> bool: + """Check if a (numeric) `value` is an element of the `Field`. + + Wraps `.cast()`, catches the documented `ValueError`, + and returns a `bool`ean indicating field membership. + + Args: + value: see docstring for `.cast()` + silent: suppress the `ValueError` + **cast_kwargs: extra `kwargs` to `.cast()` the `value` + + Returns: + is_element + + Raises: + ValueError: `value` is not `.cast()`able + (suppressed by default) + """ + try: + self.cast(value, **cast_kwargs) + except ValueError: + if not silent: + raise + return False + + return True + + @property + def dtype(self) -> Callable[[Any], T]: + """Data type to store the `Field` elements.""" + return self._dtype + + @property + def zero(self) -> T: + """The field's additive identity.""" + return self._additive_identity + + def is_zero(self, value: T, /, **cast_kwargs: Any) -> bool: + """Check if `value` equals the `.zero`-like field element. + + This method, together with `.is_one()` below, provides a unified + way across the different `Field`s to check if a given `value` + equals the field's additive or multiplicative identity. + + Concrete `Field`s may use a different logic. For example, some + compare the absolute difference between the `value` and the + `.zero`-or-`.one`-like field element to a small `threshold`. + + Overwriting methods should + - check the `value` for field membership first, and + - accept arbitrary keyword-only arguments + that they may simply ignore + + Args: + value: see docstring for `.cast()` + **cast_kwargs: extra `kwargs` to `.cast()` the `value` + + Returns: + is_zero + + Raises: + ValueError: `value` is not `.cast()`able + """ + return self.cast(value, **cast_kwargs) == self._additive_identity + + @property + def one(self) -> T: + """The field's multiplicative identity.""" + return self._multiplicative_identity + + def is_one(self, value: T, /, **cast_kwargs: Any) -> bool: + """Check if `value` equals the `.one`-like field element. + + See docstring for `.is_zero()` above for more details. + + Args: + value: see docstring for `.cast()` + **cast_kwargs: extra `kwargs` to `.cast()` the `value` + + Returns: + is_one + + Raises: + ValueError: `value` is not `.cast()`able + """ + return self.cast(value, **cast_kwargs) == self._multiplicative_identity + + @abc.abstractmethod + def random( + self, + *, + lower: T, + upper: T, + **cast_kwargs: Any, + ) -> T: + """Draw a uniformly distributed random element from the field. + + `lower` and `upper` may come in reversed order. + Overwriting methods should sort them if necessary. + + Extra keyword arguments should be passed through to `.cast()`. + """ + + def _get_bounds( + self, + lower: T, + upper: T, + /, + **cast_kwargs: Any, + ) -> tuple[T, T]: + """Get the `lower` & `upper` bounds for `Field.random()`. + + Utility method to either + - resolve the given `lower` and `upper` bounds into a + `.cast()`ed element of the `Field`, or + - obtain their default `value`s, which are + + the `.additive_identity` for `lower`, and + + the `.multiplicative_identity` for `upper` + + Extra keyword arguments are passed through to `.cast()`. + """ + lower = lower if lower is not None else self._additive_identity + upper = upper if upper is not None else self._multiplicative_identity + + return (self.cast(lower, **cast_kwargs), self.cast(upper, **cast_kwargs)) + + def __repr__(self) -> str: + """Text representations: `repr(...)` and `str(...)`. + + The `.math_name` should be a valid name within `lalib`. + If not, use the "" convention; in other words, + the text representation is no valid code on its own. + + See: https://docs.python.org/3/reference/datamodel.html#object.__repr__ + """ + return self._math_name + + __str__ = __repr__ diff --git a/src/lalib/fields/complex_.py b/src/lalib/fields/complex_.py new file mode 100644 index 0000000..533f5bf --- /dev/null +++ b/src/lalib/fields/complex_.py @@ -0,0 +1,110 @@ +"""The concrete `ComplexField`.""" + +import cmath +import random +from typing import Any + +from lalib import config +from lalib.fields import base +from lalib.fields import utils + + +class ComplexField(utils.SingletonMixin, base.Field): + """The `Field` over ℂ, the complex numbers.""" + + _math_name = "ℂ" + _dtype = complex + _post_cast_filter = cmath.isfinite + _additive_identity = 0 + 0j + _multiplicative_identity = 1 + 0j + + def random( + self, + *, + lower: complex = _additive_identity, + upper: complex = _multiplicative_identity, + ndigits: int = config.NDIGITS, + **_cast_kwargs: Any, + ) -> complex: + """Draw a uniformly distributed random element from the field. + + The `.real` and `.imag`inary parts of the `lower` and `upper` + bounds evaluated separately; i.e., the `random_element` is drawn + from a rectangle with opposing corners `lower` and `upper`. + + `lower` and `upper` may come in reversed order. + + Args: + lower: bound of the random interval + upper: bound of the random interval + ndigits: no. of significant digits to the right of the "."; + both the `.real` and the `.imag`inary parts are rounded + + Returns: + random_element + + Raises: + ValueError: `lower` and `upper` are not `.cast()`able + """ + lower, upper = self._get_bounds(lower, upper) + + random_real, random_imag = ( + # `random.uniform()` can handle `upper < lower` + round(random.uniform(lower.real, upper.real), ndigits), # noqa: S311 + round(random.uniform(lower.imag, upper.imag), ndigits), # noqa: S311 + ) + + return complex(random_real, random_imag) + + def is_zero( + self, + value: complex, + /, + *, + threshold: float = config.THRESHOLD, + **_cast_kwargs: Any, + ) -> bool: + """Check if `value` equals `0.0 + 0.0j`. + + To be precise: Check if `value` deviates by less than the + `threshold` from `0.0 + 0.0j` in absolute terms. + + Args: + value: to be compared to `0.0 + 0.0j` + threshold: for the equality check + + Returns: + is_zero + + Raises: + ValueError: `value` is not `.cast()`able + """ + return abs(self.cast(value)) < threshold + + def is_one( + self, + value: complex, + /, + *, + threshold: float = config.THRESHOLD, + **_cast_kwargs: Any, + ) -> bool: + """Check if `value` equals `1.0 + 0.0j`. + + To be precise: Check if `value` deviates by less than the + `threshold` from `1.0 + 0.0j` in absolute terms. + + Args: + value: to be compared to `1.0 + 0.0j` + threshold: for the equality check + + Returns: + is_one + + Raises: + ValueError: `value` is not `.cast()`able + """ + return abs(self.cast(value) - (1.0 + 0j)) < threshold + + +C = ComplexField() diff --git a/src/lalib/fields/galois.py b/src/lalib/fields/galois.py new file mode 100644 index 0000000..cbfd3b3 --- /dev/null +++ b/src/lalib/fields/galois.py @@ -0,0 +1,45 @@ +"""The concrete `GaloisField2`.""" + +import random +from typing import Any + +from lalib.elements import galois as gf2_elements +from lalib.fields import base +from lalib.fields import utils + + +class GaloisField2(utils.SingletonMixin, base.Field): + """The Galois `Field` of 2 elements.""" + + _math_name = "GF2" + _dtype = gf2_elements.GF2Element + _additive_identity = gf2_elements.zero + _multiplicative_identity = gf2_elements.one + + def random( + self, + lower: gf2_elements.GF2Element = gf2_elements.zero, + upper: gf2_elements.GF2Element = gf2_elements.one, + **cast_kwargs: Any, + ) -> gf2_elements.GF2Element: + """Draw a uniformly distributed random element from the field. + + Args: + lower: bound of the random interval + upper: bound of the random interval + **cast_kwargs: extra `kwargs` to `.cast()` + the `lower` and `upper` bounds + + Returns: + random_element: either `one` or `zero` + + Raises: + ValueError: `lower` and `upper` are not `.cast()`able + """ + lower, upper = self._get_bounds(lower, upper, **cast_kwargs) + + # `random.choice()` can handle `upper < lower` + return random.choice((lower, upper)) # noqa: S311 + + +GF2 = GaloisField2() diff --git a/src/lalib/fields/rational.py b/src/lalib/fields/rational.py new file mode 100644 index 0000000..9894c6a --- /dev/null +++ b/src/lalib/fields/rational.py @@ -0,0 +1,90 @@ +"""The concrete `RationalField`.""" + +import fractions +import random +from typing import Any + +from lalib import config +from lalib.fields import base +from lalib.fields import utils + + +class RationalField(utils.SingletonMixin, base.Field): + """The `Field` over ℚ, the rational numbers. + + Although `Q.cast()` accepts `float`s as possible field elements, + do so only with care as `float`s are inherently imprecise numbers: + + >>> 0.1 + 0.2 + 0.30000000000000004 + + To mitigate this, `Q.cast()` cuts off the decimals, as configured + with the `MAX_DENOMINATOR` setting. So, `float`s with just a couple + of digits return the possibly desired field element. For example: + + >>> Q.cast(0.1) + Fraction(1, 10) + + Yet, with the hidden `max_denominator` argument, we can easily + see how `float`s may result in "weird" `Fraction`s. + + >>> Q.cast(0.1, max_denominator=1_000_000_000_000) + Fraction(1, 10) + >>> Q.cast(0.1, max_denominator=1_000_000_000_000_000_000) + Fraction(3602879701896397, 36028797018963968) + + It is recommended to use `str`ings instead: + + >>> Q.cast("0.1") + Fraction(1, 10) + >>> Q.cast("1/10") + Fraction(1, 10) + """ + + _math_name = "ℚ" + _dtype = fractions.Fraction + + def _cast_func( + self, + value: Any, + /, + max_denominator: int = config.MAX_DENOMINATOR, + **_kwargs: Any, + ) -> fractions.Fraction: + return fractions.Fraction(value).limit_denominator(max_denominator) + + _additive_identity = fractions.Fraction(0, 1) + _multiplicative_identity = fractions.Fraction(1, 1) + + def random( + self, + *, + lower: fractions.Fraction = _additive_identity, + upper: fractions.Fraction = _multiplicative_identity, + max_denominator: int = config.MAX_DENOMINATOR, + **_cast_kwargs: Any, + ) -> fractions.Fraction: + """Draw a uniformly distributed random element from the field. + + `lower` and `upper` may come in reversed order. + + Args: + lower: bound of the random interval + upper: bound of the random interval + max_denominator: maximum for `random_element.denominator` + + Returns: + random_element + + Raises: + ValueError: `lower` and `upper` are not `.cast()`able + """ + lower, upper = self._get_bounds(lower, upper) + + # `random.uniform()` can handle `upper < lower` + random_value = random.uniform(float(lower), float(upper)) # noqa: S311 + + return self._cast_func(random_value).limit_denominator(max_denominator) + + +Q = RationalField() diff --git a/src/lalib/fields/real.py b/src/lalib/fields/real.py new file mode 100644 index 0000000..aaa7c26 --- /dev/null +++ b/src/lalib/fields/real.py @@ -0,0 +1,97 @@ +"""The concrete `RealField`.""" + +import math +import random +from typing import Any + +from lalib import config +from lalib.fields import base +from lalib.fields import utils + + +class RealField(utils.SingletonMixin, base.Field): + """The `Field` over ℝ, the real numbers.""" + + _math_name = "ℝ" + _dtype = float + _post_cast_filter = math.isfinite + _additive_identity = 0.0 + _multiplicative_identity = 1.0 + + def random( + self, + *, + lower: float = _additive_identity, + upper: float = _multiplicative_identity, + ndigits: int = config.NDIGITS, + **_cast_kwargs: Any, + ) -> float: + """Draw a uniformly distributed random element from the field. + + `lower` and `upper` may come in reversed order. + + Args: + lower: bound of the random interval + upper: bound of the random interval + ndigits: no. of significant digits to the right of the "." + + Returns: + random_element + + Raises: + ValueError: `lower` and `upper` are not `.cast()`able + """ + lower, upper = self._get_bounds(lower, upper) + + # `random.uniform()` can handle `upper < lower` + lower, upper = float(lower), float(upper) + rand_value = random.uniform(lower, upper) # noqa: S311 + + return round(rand_value, ndigits) + + def is_zero( + self, + value: float, + /, + *, + threshold: float = config.THRESHOLD, + **_cast_kwargs: Any, + ) -> bool: + """Check if `value` equals `0.0`. + + Args: + value: to be compared to `0.0` + threshold: for the equality check + + Returns: + is_zero (boolean) + + Raises: + ValueError: `value` is not `.cast()`able + """ + return abs(self.cast(value)) < threshold + + def is_one( + self, + value: float, + /, + *, + threshold: float = config.THRESHOLD, + **_cast_kwargs: Any, + ) -> bool: + """Check if `value` equals `1.0`. + + Args: + value: to be compared to `1.0` + threshold: for the equality check + + Returns: + is_one + + Raises: + ValueError: `value` is not `.cast()`able + """ + return abs(self.cast(value) - 1.0) < threshold + + +R = RealField() diff --git a/src/lalib/fields/utils.py b/src/lalib/fields/utils.py new file mode 100644 index 0000000..5bf697a --- /dev/null +++ b/src/lalib/fields/utils.py @@ -0,0 +1,22 @@ +"""Generic utilities for the library.""" + +from typing import Any + + +try: + from typing import Self +except ImportError: # pragma: no cover to support Python 3.9 & 3.10 + from typing_extensions import Self + + +class SingletonMixin: + """Utility class to provide singleton pattern implementation.""" + + _instance: Self + + @staticmethod + def __new__(cls: type[Self], *args: Any, **kwargs: Any) -> Self: + """Check if the `_instance` already exists.""" + if getattr(cls, "_instance", None) is None: + cls._instance = super().__new__(cls, *args, **kwargs) + return cls._instance diff --git a/tests/fields/test_axioms.py b/tests/fields/test_axioms.py new file mode 100644 index 0000000..67ddf70 --- /dev/null +++ b/tests/fields/test_axioms.py @@ -0,0 +1,92 @@ +"""Ensure all `Field`s fulfill the axioms from math. + +Source: https://en.wikipedia.org/wiki/Field_(mathematics)#Classic_definition +""" + +import contextlib +import operator + +import pytest + +from tests.fields import utils + + +@pytest.mark.repeat(utils.N_RANDOM_DRAWS) +@pytest.mark.parametrize("field", utils.ALL_FIELDS) +class TestAllFieldsManyTimes: + """Run the tests many times for all `field`s.""" + + @pytest.mark.parametrize("opr", [operator.add, operator.mul]) + def test_associativity(self, field, opr): + """`a + (b + c) == (a + b) + c` ... + + ... and `a * (b * c) == (a * b) * c`. + """ + a, b, c = field.random(), field.random(), field.random() + + left = opr(a, opr(b, c)) + right = opr(opr(a, b), c) + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) + + @pytest.mark.parametrize("opr", [operator.add, operator.mul]) + def test_commutativity(self, field, opr): + """`a + b == b + a` ... + + ... and `a * b == b * a`. + """ + a, b = field.random(), field.random() + + left = opr(a, b) + right = opr(b, a) + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) + + def test_additive_identity(self, field): + """`a + 0 == a`.""" + a = field.random() + + left = a + field.zero + right = a + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) + + def test_multiplicative_identity(self, field): + """`a * 1 == a`.""" + a = field.random() + + left = a * field.one + right = a + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) + + def test_additive_inverse(self, field): + """`a + (-a) == 0`.""" + a = field.random() + + left = a + (-a) + right = field.zero + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) + + def test_multiplicative_inverse(self, field): + """`a * (1 / a) == 1`.""" + a = field.random() + + # Realistically, `ZeroDivisionError` only occurs for `GF2` + # => With a high enough `utils.N_RANDOM_DRAWS` + # this test case is also `assert`ed for `GF2` + with contextlib.suppress(ZeroDivisionError): + left = a * (field.one / a) + right = field.one + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) + + def test_distributivity(self, field): + """`a * (b + c) == (a * b) + (a * c)`.""" + a, b, c = field.random(), field.random(), field.random() + + left = a * (b + c) + right = (a * b) + (a * c) + + assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD) diff --git a/tests/fields/test_base.py b/tests/fields/test_base.py new file mode 100644 index 0000000..31ac80f --- /dev/null +++ b/tests/fields/test_base.py @@ -0,0 +1,253 @@ +"""Generic tests for all `lalib.fields.*.Field`s. + +The abstract base class `lalib.fields.base.Field` +defines generic behavior that all concrete `Field`s +in the `lalib.fields` sub-package must implement. +""" + +import random + +import pytest + +from lalib import fields +from tests.fields import utils + + +@pytest.mark.parametrize("field", utils.ALL_FIELDS) +class TestGenericClassBehavior: + """Generic `Field` behavior.""" + + def test_create_singletons(self, field): + """All `field`s so far are singletons.""" + cls = type(field) + new_field = cls() + + assert new_field is field + + @pytest.mark.parametrize("func", [repr, str]) + def test_text_repr(self, field, func): + """The text representations behave like Python literals.""" + new_field = eval(func(field), fields.__dict__) # noqa: S307 + + assert new_field is field + + +class TestCastAndValidateFieldElements: + """Test `Field.cast()` and `Field.validate()`. + + Every `field` must be able to tell if a given `value` is + an element of the `field`, and, if so, `.cast()` it as such. + """ + + @pytest.mark.parametrize("field", utils.NON_10_FIELDS) + @pytest.mark.parametrize("value", utils.NUMBERS) + def test_number_is_field_element(self, field, value): + """Common numbers are typically `field` elements. + + This is not true for `GF2`, which, by default, + only accepts `1`-like and `0`-like numbers. + """ + utils.is_field_element(field, value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("value", utils.ONES_N_ZEROS) + def test_one_and_zero_number_is_field_element(self, field, value): + """`1`-like and `0`-like numbers are always `field` elements.""" + utils.is_field_element(field, value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("value", ["abc", (1, 2, 3)]) + def test_non_numeric_value_is_not_field_element(self, field, value): + """Values of non-numeric data types are typically not `field` elements.""" + utils.is_not_field_element(field, value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"]) + def test_non_finite_number_is_not_field_element(self, field, pre_value): + """For now, we only allow finite numbers as `field` elements. + + Notes: + - `Q._cast_func()` cannot handle non-finite `value`s + and raises an `OverflowError` or `ValueError` + => `Field.cast()` catches these errors + and (re-)raises a `ValueError` instead + => no need to define a specific `._post_cast_filter()` + - `R._cast_func()` and `C._cast_func()` + handle non-finite `value`s without any complaints + => using a `._post_cast_filter()`, we don't allow + non-finite but castable `value`s to be `field` elements + - `GF2._cast_func()` handles non-finite `value`s + by raising a `ValueError` already + => `Field.cast()` re-raises it with an adapted message + => no need to define a specific `._post_cast_filter()` + """ + value = float(pre_value) + utils.is_not_field_element(field, value) + + +class TestDTypes: + """Test the `Field.dtype` property.""" + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_field_dtype(self, field): + """`field.dtype` must be a `type`.""" + assert isinstance(field.dtype, type) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_element_is_instance_of_field_dtype(self, field): + """Elements are an instance of `field.dtype`.""" + element = field.random() + + assert isinstance(element, field.dtype) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_element_dtype_is_subclass_of_field_dtype(self, field): + """Elements may have a more specific `.dtype` than their `field.dtype`.""" + element = field.random() + dtype = type(element) + + assert issubclass(dtype, field.dtype) + + +class TestIsZero: + """Test `Field.zero` & `Field.is_zero()`.""" + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("value", utils.ZEROS) + def test_is_exactly_zero(self, field, value): + """`value` is equal to `field.zero`.""" + assert field.zero == value + assert field.is_zero(value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_is_almost_zero(self, field): + """`value` is within an acceptable threshold of `field.zero`.""" + value = 0.0 + utils.WITHIN_THRESHOLD + + assert pytest.approx(field.zero, abs=utils.DEFAULT_THRESHOLD) == value + assert field.is_zero(value) + + @pytest.mark.parametrize("field", utils.NON_10_FIELDS) + def test_is_slightly_not_zero(self, field): + """`value` is not within an acceptable threshold of `field.zero`.""" + value = 0.0 + utils.NOT_WITHIN_THRESHOLD + + assert pytest.approx(field.zero, abs=utils.DEFAULT_THRESHOLD) != value + assert not field.is_zero(value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("value", utils.ONES) + def test_is_not_zero(self, field, value): + """`value` is not equal to `field.zero`.""" + assert field.zero != value + assert not field.is_zero(value) + + +class TestIsOne: + """Test `Field.one` & `Field.is_one()`.""" + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("value", utils.ONES) + def test_is_exactly_one(self, field, value): + """`value` is equal to `field.one`.""" + assert field.one == value + assert field.is_one(value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_is_almost_one(self, field): + """`value` is within an acceptable threshold of `field.one`.""" + value = 1.0 + utils.WITHIN_THRESHOLD + + assert pytest.approx(field.one, abs=utils.DEFAULT_THRESHOLD) == value + assert field.is_one(value) + + @pytest.mark.parametrize("field", utils.NON_10_FIELDS) + def test_is_slightly_not_one(self, field): + """`value` is not within an acceptable threshold of `field.one`.""" + value = 1.0 + utils.NOT_WITHIN_THRESHOLD + + assert pytest.approx(field.one, abs=utils.DEFAULT_THRESHOLD) != value + assert not field.is_one(value) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + @pytest.mark.parametrize("value", utils.ZEROS) + def test_is_not_one(self, field, value): + """`value` is not equal to `field.one`.""" + assert field.one != value + assert not field.is_one(value) + + +@pytest.mark.repeat(utils.N_RANDOM_DRAWS) +class TestDrawRandomFieldElement: + """Test `Field.random()`.""" + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_draw_element_with_default_bounds(self, field): + """Draw a random element from the `field`, ... + + ... within the `field`'s default bounds. + + Here, the default bounds come from the default arguments. + """ + element = field.random() + + assert field.validate(element) + + @pytest.mark.parametrize("field", utils.ALL_FIELDS) + def test_draw_element_with_default_bounds_set_to_none(self, field): + """Draw a random element from the `field`, ... + + ... within the `field`'s default bounds. + + If no default arguments are defined in `field.random()`, + the internal `Field._get_bounds()` method provides them. + """ + element = field.random(lower=None, upper=None) + + assert field.validate(element) + + @pytest.mark.parametrize("field", utils.NON_10_FIELDS) + def test_draw_element_with_custom_bounds(self, field): + """Draw a random element from the `field` ... + + ... within the bounds passed in as arguments. + + For `GF2`, this only works in non-`strict` mode. + """ + lower = 200 * random.random() - 100 # noqa: S311 + upper = 200 * random.random() - 100 # noqa: S311 + + # `field.random()` sorts the bounds internally + # => test both directions + element1 = field.random(lower=lower, upper=upper) + element2 = field.random(lower=upper, upper=lower) + + assert field.validate(element1) + assert field.validate(element2) + + # Done implicitly in `field.random()` above + lower, upper = field.cast(lower), field.cast(upper) + + # Not all data types behind the `Field._cast_func()` + # support sorting the numbers (e.g., `complex`) + try: + swap = upper < lower + except TypeError: + pass + else: + if swap: + lower, upper = upper, lower + + assert lower <= element1 <= upper + assert lower <= element2 <= upper + + +def test_numbers(): + """We use `0`, `1`, `+42`, and `-42` in different data types.""" + unique_one_and_zero = {int(n) for n in utils.ONES_N_ZEROS} + unique_non_one_and_zero = {int(n) for n in utils.NON_ONES_N_ZEROS} + unique_numbers = {int(n) for n in utils.NUMBERS} + + assert unique_one_and_zero == {0, 1} + assert unique_non_one_and_zero == {+42, -42} + assert unique_numbers == {0, 1, +42, -42} diff --git a/tests/fields/test_complex.py b/tests/fields/test_complex.py new file mode 100644 index 0000000..dc5b1f3 --- /dev/null +++ b/tests/fields/test_complex.py @@ -0,0 +1,96 @@ +"""Tests for the `lalib.fields.complex_.ComplexField` only.""" + +import random + +import pytest + +from lalib import fields +from tests.fields import utils + + +C = fields.C + + +class TestCastAndValidateFieldElements: + """Test specifics for `C.cast()` and `C.validate()`.""" + + @pytest.mark.parametrize("pre_value", [1, 0, +42, -42]) + def test_complex_number_is_field_element(self, pre_value): + """`C` must be able to process `complex` numbers.""" + value = complex(pre_value, 0) + utils.is_field_element(C, value) + + @pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"]) + def test_non_finite_complex_number_is_not_field_element(self, pre_value): + """For now, we only allow finite numbers as field elements. + + This also holds true for `complex` numbers + with a non-finite `.real` part. + """ + value = complex(pre_value) + utils.is_not_field_element(C, value) + + +class TestIsZero: + """Test specifics for `C.zero` and `C.is_zero()`.""" + + def test_is_almost_zero(self): + """`value` is within an acceptable threshold of `C.zero`.""" + value = 0.0 + utils.WITHIN_THRESHOLD + + assert pytest.approx(C.zero, abs=utils.DEFAULT_THRESHOLD) == value + assert C.is_zero(value) + + def test_is_slightly_not_zero(self): + """`value` is not within an acceptable threshold of `C.zero`.""" + value = 0.0 + utils.NOT_WITHIN_THRESHOLD + + assert pytest.approx(C.zero, abs=utils.DEFAULT_THRESHOLD) != value + assert not C.is_zero(value) + + +class TestIsOne: + """Test specifics for `C.one` and `C.is_one()`.""" + + def test_is_almost_one(self): + """`value` is within an acceptable threshold of `C.one`.""" + value = 1.0 + utils.WITHIN_THRESHOLD + + assert pytest.approx(C.one, abs=utils.DEFAULT_THRESHOLD) == value + assert C.is_one(value) + + def test_is_slightly_not_one(self): + """`value` is not within an acceptable threshold of `C.one`.""" + value = 1.0 + utils.NOT_WITHIN_THRESHOLD + + assert pytest.approx(C.one, abs=utils.DEFAULT_THRESHOLD) != value + assert not C.is_one(value) + + +@pytest.mark.repeat(utils.N_RANDOM_DRAWS) +class TestDrawRandomFieldElement: + """Test specifics for `C.random()`.""" + + def test_draw_elements_with_custom_bounds(self): + """Draw a random element from `C` ... + + ... within the bounds passed in as arguments. + + For `C`, the bounds are interpreted in a 2D fashion. + """ + lower = complex( + 200 * random.random() - 100, # noqa: S311 + 200 * random.random() - 100, # noqa: S311 + ) + upper = complex( + 200 * random.random() - 100, # noqa: S311 + 200 * random.random() - 100, # noqa: S311 + ) + + element = C.random(lower=lower, upper=upper) + + l_r, u_r = min(lower.real, upper.real), max(lower.real, upper.real) + l_i, u_i = min(lower.imag, upper.imag), max(lower.imag, upper.imag) + + assert l_r <= element.real <= u_r + assert l_i <= element.imag <= u_i diff --git a/tests/fields/test_galois.py b/tests/fields/test_galois.py new file mode 100644 index 0000000..5b73c73 --- /dev/null +++ b/tests/fields/test_galois.py @@ -0,0 +1,100 @@ +"""Tests for the `lalib.fields.galois.GaloisField2` only.""" + +import itertools + +import pytest + +from lalib import fields +from tests.fields import utils + + +GF2 = fields.GF2 + + +class TestCastAndValidateFieldElements: + """Test specifics for `GF2.cast()` and `GF2.validate()`.""" + + @pytest.mark.parametrize("value", utils.NUMBERS) + def test_number_is_field_element(self, value): + """Common numbers are always `GF2` elements in non-`strict` mode.""" + left = GF2.cast(value, strict=False) + right = bool(value) + + assert left == right + assert GF2.validate(value, strict=False) + + @pytest.mark.parametrize("value", utils.ONES_N_ZEROS) + def test_one_and_zero_number_is_field_element(self, value): + """`1`-like and `0`-like `value`s are `GF2` elements.""" + utils.is_field_element(GF2, value) + + @pytest.mark.parametrize("pre_value", [1, 0]) + def test_complex_number_is_field_element(self, pre_value): + """By design, `GF2` can process `complex` numbers.""" + value = complex(pre_value, 0) + utils.is_field_element(GF2, value) + + @pytest.mark.parametrize("pre_value", [+42, -42]) + def test_complex_number_is_not_field_element(self, pre_value): + """By design, `GF2` can process `complex` numbers ... + + ... but they must be `one`-like or `zero`-like + to become a `GF2` element. + """ + value = complex(pre_value, 0) + utils.is_not_field_element(GF2, value) + + @pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"]) + def test_non_finite_complex_number_is_not_field_element(self, pre_value): + """For now, we only allow finite numbers as field elements. + + This also holds true for `complex` numbers + with a non-finite `.real` part. + """ + value = complex(pre_value) + utils.is_not_field_element(GF2, value) + + +class TestIsZero: + """Test specifics for `GF2.zero` and `GF2.is_zero()`.""" + + def test_is_slightly_not_zero(self): + """`value` is not within an acceptable threshold of `GF2.zero`.""" + value = 0.0 + utils.NOT_WITHIN_THRESHOLD + + assert GF2.zero != value + + with pytest.raises(ValueError, match="not an element of the field"): + GF2.is_zero(value) + + +class TestIsOne: + """Test specifics for `GF2.one` and `GF2.is_one()`.""" + + def test_is_slightly_not_one(self): + """`value` is not within an acceptable threshold of `GF2.one`.""" + value = 1.0 + utils.NOT_WITHIN_THRESHOLD + + assert GF2.one != value + + with pytest.raises(ValueError, match="not an element of the field"): + GF2.is_one(value) + + +@pytest.mark.repeat(utils.N_RANDOM_DRAWS) +class TestDrawRandomFieldElement: + """Test specifics for `GF2.random()`.""" + + @pytest.mark.parametrize("bounds", itertools.product([0, 1], repeat=2)) + def test_draw_element_with_custom_bounds(self, bounds): + """Draw a random element from `GF2` in non-`strict` mode ... + + ... within the bounds passed in as arguments. + """ + lower, upper = bounds + element = GF2.random(lower=lower, upper=upper) + + if upper < lower: + lower, upper = upper, lower + + assert lower <= element <= upper diff --git a/tests/fields/test_rational.py b/tests/fields/test_rational.py new file mode 100644 index 0000000..6d3c850 --- /dev/null +++ b/tests/fields/test_rational.py @@ -0,0 +1,29 @@ +"""Tests for the `lalib.fields.rational.RationalField` only.""" + +import fractions + +import pytest + +from lalib import fields + + +Q = fields.Q + + +class TestCastAndValidateFieldElements: + """Test specifics for `Q.cast()` and `Q.validate()`.""" + + @pytest.mark.parametrize( + "value", + ["1", "0", "1/1", "0/1", "+42", "-42", "+42/1", "-42/1"], + ) + def test_str_is_field_element(self, value): + """`fractions.Fraction()` also accepts `str`ings. + + Source: https://docs.python.org/3/library/fractions.html#fractions.Fraction + """ + left = Q.cast(value) + right = fractions.Fraction(value) + + assert left == right + assert Q.validate(value) diff --git a/tests/fields/utils.py b/tests/fields/utils.py new file mode 100644 index 0000000..912a1cf --- /dev/null +++ b/tests/fields/utils.py @@ -0,0 +1,75 @@ +"""Utilities to test the `lalib.fields` sub-package.""" + +import decimal +import fractions +import os + +import pytest + +from lalib import config +from lalib import elements +from lalib import fields + + +ALL_FIELDS = (fields.Q, fields.R, fields.C, fields.GF2) +NON_10_FIELDS = (fields.Q, fields.R, fields.C) + +ONES = ( + 1, + 1.0, + fractions.Fraction(1, 1), + decimal.Decimal("1.0"), + elements.one, + True, +) + +ZEROS = ( + 0, + 0.0, + fractions.Fraction(0, 1), + decimal.Decimal("+0.0"), + decimal.Decimal("-0.0"), + elements.zero, + False, +) + +ONES_N_ZEROS = ONES + ZEROS + +NON_ONES_N_ZEROS = ( + +42, + +42.0, + fractions.Fraction(+42, 1), + decimal.Decimal("+42.0"), + -42, + -42.0, + fractions.Fraction(-42, 1), + decimal.Decimal("-42.0"), +) + +NUMBERS = ONES_N_ZEROS + NON_ONES_N_ZEROS + +DEFAULT_THRESHOLD = config.THRESHOLD +WITHIN_THRESHOLD = config.THRESHOLD / 10 +NOT_WITHIN_THRESHOLD = config.THRESHOLD * 10 + +N_RANDOM_DRAWS = os.environ.get("N_RANDOM_DRAWS") or 1 + + +def is_field_element(field, value): + """Utility method to avoid redundant logic in tests.""" + element = field.cast(value) + + assert element == value + assert field.validate(value) + + +def is_not_field_element(field, value): + """Utility method to avoid redundant logic in tests.""" + with pytest.raises(ValueError, match="not an element of the field"): + field.cast(value) + + assert not field.validate(value) + assert not field.validate(value, silent=True) + + with pytest.raises(ValueError, match="not an element of the field"): + field.validate(value, silent=False) diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index f5a3f2d..5eaf571 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -16,6 +16,12 @@ import xdoctest "lalib", "lalib.elements", "lalib.elements.galois", + "lalib.fields", + "lalib.fields.base", + "lalib.fields.complex_", + "lalib.fields.galois", + "lalib.fields.rational", + "lalib.fields.real", ], ) def test_docstrings(module):