Skip to content

Commit 3e4d0d5

Browse files
committed
Tidy up circularity testing of reciprocal function pairs (#2102)
* A circularity test acts as a functional sanity check. Each test acts on pairs of unary operator type functions which reciprocally convert float inputs (typically for the two directions of quantity conversion between two similar units Celsius <--> Fahrenheit could hypothetically be a valid example) * Replace sanity checks that were written from scratch independently with a set-once use-again, mutually-inverse function pairing * N.B. The use of a class for the pair rather than a 3 argument util function, places the two operator functions on a clearly equal footing, distinct from the arbitarily valued numerical probe argument used in verfication; whereas a util function muddies the waters, forcing cognitive load back onto the human reader. Fixes #2102
1 parent b3d163a commit 3e4d0d5

2 files changed

Lines changed: 91 additions & 21 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from collections.abc import Callable
2+
from dataclasses import dataclass
3+
4+
import pytest
5+
6+
@dataclass(frozen=True)
7+
class OperatorInversionPairing:
8+
"""Represents a pair of mutually reciprocal maths functions, with unique mappings of input to output.
9+
Useful for sanity check behavioural tests above the unit level but below integration level.
10+
A typical use case is to ensure that after two mutually cancelling numerical operations,
11+
an original input number is restored. This helps tests flag whenever one function breaks,
12+
for example - owing to a typo or incorrect or inverted multiplication factor.
13+
( Yes there are even numbers of self-cancelling mistakes which can still hide bugs but
14+
hopefully the individual functions, functional tests flush those out ).
15+
16+
Attributes:
17+
unary_op: A unary operation mathematical function (specifically with unique one-to-one mapping).
18+
inverse_op: The inverse unary operation.
19+
"""
20+
unary_op: Callable[[float], float]
21+
inverse_op: Callable[[float], float]
22+
23+
def composed_operator(self, x: float) -> float:
24+
"""Applies both the unary operation followed by the inverse operation on a numerical input.
25+
On, for example, the happy path of a test, this round-trip can be expected to result in the original value x.
26+
27+
Args:
28+
x (float): Any numerical argument suitable for the unary operations under test.
29+
30+
Returns:
31+
float: The result from nested application of first the unary operation and then its inverse on x.
32+
"""
33+
f_of_x = self.unary_op(x)
34+
return self.inverse_op(f_of_x)
35+
36+
def composed_operator_is_consistent_with_identity_operator(self, probe_x: float) -> bool:
37+
"""Assertion used in tests to verify that a pair of functions compose to act like the identity operator.
38+
Namely that for f, g where g is the inverse of f, asserts that g(f(x)) is consistent with x to good approximation.
39+
40+
Args:
41+
probe_x (float): Any numerical argument suitable for the unary operations under test.
42+
"""
43+
_round_trip_net_effect = self.composed_operator(probe_x)
44+
return _round_trip_net_effect == pytest.approx(probe_x)
45+

tests/common/general_maths/test_arithmetic_conversions.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import math
2+
from collections.abc import Callable
23

34
import pydantic
45
import pytest
@@ -14,6 +15,8 @@
1415
convert_percentage_to_factor,
1516
)
1617

18+
from .operator_inversion_pairing import OperatorInversionPairing
19+
1720

1821
# expected success tests (the 'Happy Path'): All numbers here are arbitrary
1922
@pytest.mark.parametrize("input,result", [(1.0, 0.1), (100.0, 10.0)])
@@ -56,29 +59,51 @@ def test_conversion_from_microns_to_centimetres(input, result):
5659
assert convert_microns_to_cm(input) == pytest.approx(result)
5760

5861

59-
# Circular tests (all numbers here arbitrary)
60-
61-
62-
@pytest.mark.parametrize("input", [0.0, 1.0, 10.0, 100.0])
63-
def test_circular_cm_to_mm_and_back(input):
64-
assert convert_cm_to_mm(convert_mm_to_cm(input)) == pytest.approx(input)
65-
assert convert_mm_to_cm(convert_cm_to_mm(input)) == pytest.approx(input)
66-
62+
# Circular "sanity check" tests, exercise pairs of reciprocating functions
63+
# proving the result of applying a function and its inverse results in the original value
6764

68-
@pytest.mark.parametrize("input", [0.0, 1.0, 10.0, 100.0])
69-
def test_circular_microns_to_mm_and_back(input):
70-
assert convert_microns_to_mm(convert_mm_to_microns(input)) == pytest.approx(input)
71-
assert convert_mm_to_microns(convert_microns_to_mm(input)) == pytest.approx(input)
7265

73-
74-
@pytest.mark.parametrize("input", [0.0, 1.0, 10.0, 100.0])
75-
def test_circular_percentage_to_factor_and_back(input):
76-
assert convert_percentage_to_factor(
77-
convert_factor_to_percentage(input)
78-
) == pytest.approx(input)
79-
assert convert_factor_to_percentage(
80-
convert_percentage_to_factor(input)
81-
) == pytest.approx(input)
66+
@pytest.mark.parametrize(
67+
"f, g, numerical_args",
68+
[
69+
(
70+
convert_ev_to_kev,
71+
lambda k: k * 1000.0,
72+
[16.83, 0.0, 0.037, 1.0, 6.208, 18, 12345.6, 28906.4],
73+
),
74+
(
75+
convert_mm_to_cm,
76+
convert_cm_to_mm,
77+
[-16.83, 0.0, 0.037, 1.0, 6.208, 18, 102.99],
78+
),
79+
(
80+
convert_microns_to_cm,
81+
lambda x: convert_mm_to_microns(convert_cm_to_mm(x)),
82+
[-6.119, 0.0, 0.764, 1.02, 62.45, 12754, 3154.59],
83+
),
84+
(
85+
convert_microns_to_mm,
86+
convert_mm_to_microns,
87+
[-12.38, 0.0, 0.307, 1.0, 6.45, 24, 231.089],
88+
),
89+
(
90+
convert_factor_to_percentage,
91+
convert_percentage_to_factor,
92+
[0.0, 1.0, 0.5, 0.367, 27.404, 100.0, 99.8, 53.647],
93+
),
94+
],
95+
)
96+
def test_reciprocal_function_pairs_nest_consistent_with_identity(
97+
f: Callable[[float], float],
98+
g: Callable[[float], float],
99+
numerical_args: list[float],
100+
):
101+
for op_pair in [
102+
OperatorInversionPairing(f, g),
103+
OperatorInversionPairing(g, f),
104+
]:
105+
for x in numerical_args:
106+
assert op_pair.composed_operator_is_consistent_with_identity_operator(x)
82107

83108

84109
# The inauspicuous path

0 commit comments

Comments
 (0)