From 06c26192ad88100cf9df941bb55924441bb25575 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Wed, 16 Oct 2024 14:23:59 +0200 Subject: [PATCH] Make `gf2()` cast `value`s in non-`strict` mode - so far, `gf2()` runs in a `strict` mode by default => `gf2(42)` results in a `ValueError` - we adapt `gf2()` (a.k.a. `lalib.elements.galois.GF2Element`) such that it behaves more like the built-in `bool()` => `gf2(42)` returns `one`, like `bool(42)` returns `True` - further, the `GF2Element` class is adjusted such that `one` and `zero` assume their counterparts to be `1`-like or `0`-like objects during binary operations; other values result in an error => for example, `one + 1` works but `one + 42` does not => so, when used in binary operations, `one` and `zero` continue to cast their counterparts in `strict` mode - simplify the casting logic - make `value` a positional-only argument in `gf2()` (like most of the built-in constructors) - provide more usage examples in the docstrings clarifying when `strict` mode is used and when not - propagate the above changes to the test suite: + adapt test cases with regard to the `strict` mode logic + add new test cases to `assert` how `gf2()` can process `str`ings directly + rename two test cases involving `complex` numbers to mimic the naming from the newly added test cases --- src/lalib/elements/__init__.py | 21 +++++++- src/lalib/elements/galois.py | 88 ++++++++++++++++++++++++++++++---- src/lalib/fields/galois.py | 13 +++++ tests/elements/test_galois.py | 12 ++--- tests/fields/test_galois.py | 49 +++++++++++++++++-- 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/lalib/elements/__init__.py b/src/lalib/elements/__init__.py index 1e3366a..77c1f71 100644 --- a/src/lalib/elements/__init__.py +++ b/src/lalib/elements/__init__.py @@ -8,15 +8,32 @@ Then, use them: >>> one + zero one +>>> one + one +zero + +>>> type(one) +gf2 + +The `gf2` type is similar to the built-in `bool`. +To cast objects as `gf2` values: >>> gf2(0) zero +>>> gf2(1) +one + >>> gf2(42) +one +>>> gf2(-42) +one + +Yet, there is also a `strict` mode where values +not equal to `1` or `0` within a `threshold` are not accepted. + +>>> gf2(42, strict=True) Traceback (most recent call last): ... ValueError: ... ->>> gf2(42, strict=False) -one """ from lalib.elements import galois diff --git a/src/lalib/elements/galois.py b/src/lalib/elements/galois.py index 42aebab..594d58b 100644 --- a/src/lalib/elements/galois.py +++ b/src/lalib/elements/galois.py @@ -21,6 +21,14 @@ zero >>> 0 * zero zero +Yet, for numbers not equal to `1` or `0`, this does not work: + +>>> one + 42 +Traceback (most recent call last): +... +ValueError: ... + + Further usage explanations of `one` and `zero` can be found in the various docstrings of the `GF2Element` class. @@ -48,8 +56,8 @@ def _to_gf2( value: complex, # `mypy` reads `complex | float | int` /, *, - strict: bool = True, - threshold: float = config.THRESHOLD, + strict: bool, + threshold: float, ) -> int: """Cast a number as a possible Galois field value: `1` or `0`. @@ -98,7 +106,7 @@ def _to_gf2( msg = "`value` must be either `1`-like or `0`-like" raise ValueError(msg) - if abs(value) < threshold: + if abs(value) < threshold and not math.isinf(value): return 0 return 1 @@ -129,8 +137,9 @@ class GF2Element(metaclass=GF2Meta): def __new__( cls: type[Self], value: object = None, + /, *, - strict: bool = True, + strict: bool = False, threshold: float = config.THRESHOLD, ) -> Self: """See docstring for `.__init__()`.""" @@ -146,8 +155,8 @@ class GF2Element(metaclass=GF2Meta): except KeyError: msg = "Must create `one` and `zero` first (internal error)" raise RuntimeError(msg) from None - else: - value = _to_gf2(value, strict=strict, threshold=threshold) # type: ignore[arg-type] + + value = _to_gf2(value, strict=strict, threshold=threshold) # type: ignore[arg-type] try: return cls._instances[value] @@ -308,11 +317,14 @@ class GF2Element(metaclass=GF2Meta): True >>> one != zero True + >>> one == 1 True + >>> one == 42 + False """ try: - other = GF2Element(other) + other = GF2Element(other, strict=True) except (TypeError, ValueError): return NotImplemented else: @@ -333,11 +345,19 @@ class GF2Element(metaclass=GF2Meta): True >>> one < one False + >>> 0 < one True + + The `other` object must be either `1`-like or `0`-like: + + >>> one < 42 + Traceback (most recent call last): + ... + ValueError: ... """ try: - other = GF2Element(other) + other = GF2Element(other, strict=True) except TypeError: return NotImplemented except ValueError: @@ -357,8 +377,16 @@ class GF2Element(metaclass=GF2Meta): True >>> one <= zero False + >>> zero <= 1 True + + The `other` object must be either `1`-like or `0`-like: + + >>> zero <= 42 + Traceback (most recent call last): + ... + ValueError: ... """ # The `numbers.Rational` abstract base class requires both # `.__lt__()` and `.__le__()` to be present alongside @@ -377,7 +405,7 @@ class GF2Element(metaclass=GF2Meta): along the way. """ try: - other = GF2Element(other) + other = GF2Element(other, strict=True) except TypeError: return NotImplemented except ValueError: @@ -413,10 +441,18 @@ class GF2Element(metaclass=GF2Meta): one >>> zero - one one + >>> zero + 0 zero >>> 1 + one zero + + The `other` object must be either `1`-like or `0`-like: + + >>> zero + 42 + Traceback (most recent call last): + ... + ValueError: ... """ return self._compute(other, lambda s, o: (s + o) % 2) @@ -435,8 +471,16 @@ class GF2Element(metaclass=GF2Meta): one >>> zero * one zero + >>> 0 * one zero + + The `other` object must be either `1`-like or `0`-like: + + >>> one * 42 + Traceback (most recent call last): + ... + ValueError: ... """ return self._compute(other, lambda s, o: s * o) @@ -453,12 +497,21 @@ class GF2Element(metaclass=GF2Meta): one >>> zero // one zero + >>> one / zero Traceback (most recent call last): ... ZeroDivisionError: ... + >>> 1 // one one + + The `other` object must be either `1`-like or `0`-like: + + >>> 42 / one + Traceback (most recent call last): + ... + ValueError: ... """ return self._compute(other, lambda s, o: s / o) @@ -484,12 +537,21 @@ class GF2Element(metaclass=GF2Meta): zero >>> zero % one zero + >>> one % zero Traceback (most recent call last): ... ZeroDivisionError: ... + >>> 1 % one zero + + The `other` object must be either `1`-like or `0`-like: + + >>> 42 % one + Traceback (most recent call last): + ... + ValueError: ... """ return self._compute(other, lambda s, o: s % o) @@ -513,8 +575,16 @@ class GF2Element(metaclass=GF2Meta): zero >>> one ** zero one + >>> 1 ** one one + + The `other` object must be either `1`-like or `0`-like: + + >>> 42 ** one + Traceback (most recent call last): + ... + ValueError: ... """ return self._compute(other, lambda s, o: s**o) diff --git a/src/lalib/fields/galois.py b/src/lalib/fields/galois.py index cbfd3b3..f73fdd4 100644 --- a/src/lalib/fields/galois.py +++ b/src/lalib/fields/galois.py @@ -3,6 +3,7 @@ import random from typing import Any +from lalib import config from lalib.elements import galois as gf2_elements from lalib.fields import base from lalib.fields import utils @@ -13,6 +14,18 @@ class GaloisField2(utils.SingletonMixin, base.Field): _math_name = "GF2" _dtype = gf2_elements.GF2Element + + def _cast_func( + self, + value: Any, + /, + *, + strict: bool = True, + threshold: float = config.THRESHOLD, + **_kwargs: Any, + ) -> gf2_elements.GF2Element: + return gf2_elements.gf2(value, strict=strict, threshold=threshold) + _additive_identity = gf2_elements.zero _multiplicative_identity = gf2_elements.one diff --git a/tests/elements/test_galois.py b/tests/elements/test_galois.py index 4dff652..3c42f16 100644 --- a/tests/elements/test_galois.py +++ b/tests/elements/test_galois.py @@ -115,16 +115,16 @@ class TestGF2Casting: @pytest.mark.parametrize("value", one_like_values) def test_cast_ones_not_strictly(self, cls, value): """`gf2(value, strict=False)` returns `one`.""" - result = cls(value, strict=False) - assert result is one + result1 = cls(value) + assert result1 is one + + result2 = cls(value, strict=False) + assert result2 is one @pytest.mark.overlapping_test @pytest.mark.parametrize("value", non_strict_one_like_values) def test_cannot_cast_ones_strictly(self, cls, value): - """`gf2(value, strict=False)` returns `1`.""" - with pytest.raises(ValueError, match="`1`-like or `0`-like"): - cls(value) - + """`gf2(value, strict=True)` needs strict `1`-like values.""" with pytest.raises(ValueError, match="`1`-like or `0`-like"): cls(value, strict=True) diff --git a/tests/fields/test_galois.py b/tests/fields/test_galois.py index be69dfc..d708a17 100644 --- a/tests/fields/test_galois.py +++ b/tests/fields/test_galois.py @@ -33,14 +33,14 @@ class TestCastAndValidateFieldElements: 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.""" + def test_one_or_zero_like_complex_number_is_field_element(self, pre_value): + """`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 ... + def test_non_one_or_zero_like_complex_number_is_not_field_element(self, pre_value): + """`GF2` can process `complex` numbers ... ... but they must be `one`-like or `zero`-like to become a `GF2` element. @@ -48,6 +48,17 @@ class TestCastAndValidateFieldElements: value = complex(pre_value, 0) utils.is_not_field_element(GF2, value) + @pytest.mark.parametrize("pre_value", [+42, -42]) + def test_non_one_or_zero_like_complex_number_is_field_element(self, pre_value): + """`GF2` can process all `complex` numbers in non-`strict` mode.""" + value = complex(pre_value, 0) + + left = GF2.cast(value, strict=False) + right = bool(value) + + assert left == right + assert GF2.validate(value, strict=False) + @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. @@ -58,6 +69,36 @@ class TestCastAndValidateFieldElements: value = complex(pre_value) utils.is_not_field_element(GF2, value) + @pytest.mark.parametrize("value", ["1", "0"]) + def test_one_or_zero_like_numeric_str_is_field_element(self, value): + """`GF2` can process `str`ings resemling `1`s and `0`s.""" + utils.is_field_element(GF2, value) + + @pytest.mark.parametrize("value", ["+42", "-42"]) + def test_non_one_or_zero_like_numeric_str_is_not_field_element(self, value): + """`GF2` can process `str`ings resembling numbers ... + + ... but they must be `1`-like or `0`-like. + """ + utils.is_not_field_element(GF2, value) + + @pytest.mark.parametrize("value", ["+42", "-42"]) + def test_non_one_or_zero_like_numeric_str_is_field_element(self, value): + """`GF2` can process `str`ings resemling any number in non-`strict` mode.""" + left = GF2.cast(value, strict=False) + right = bool(float(value)) + + assert left == right + assert GF2.validate(value, strict=False) + + @pytest.mark.parametrize("value", ["NaN", "+inf", "-inf"]) + def test_non_finite_numeric_str_is_not_field_element(self, value): + """`GF2` can process `str`ings resemling numbers ... + + ... but they must represent finite numbers. + """ + utils.is_not_field_element(GF2, value) + class TestIsZero: """Test specifics for `GF2.zero` and `GF2.is_zero()`."""