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
This commit is contained in:
Alexander Hess 2024-10-14 15:17:42 +02:00
parent cbc1f8fd3a
commit 153094eef5
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
19 changed files with 1302 additions and 2 deletions

View file

@ -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",

16
poetry.lock generated
View file

@ -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"

View file

@ -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

View file

@ -1,5 +1,13 @@
"""Library-wide default settings."""
import math
NDIGITS = 12
THRESHOLD = 1 / (10**NDIGITS)
MAX_DENOMINATOR = math.trunc(1 / THRESHOLD)
del math

View file

@ -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

View file

@ -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",
)

227
src/lalib/fields/base.py Normal file
View file

@ -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 "<cls ...>" 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__

View file

@ -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()

View file

@ -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()

View file

@ -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()

97
src/lalib/fields/real.py Normal file
View file

@ -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()

22
src/lalib/fields/utils.py Normal file
View file

@ -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

View file

@ -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)

253
tests/fields/test_base.py Normal file
View file

@ -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}

View file

@ -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

100
tests/fields/test_galois.py Normal file
View file

@ -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

View file

@ -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)

75
tests/fields/utils.py Normal file
View file

@ -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)

View file

@ -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):