Skip to content

Commit 2141003

Browse files
test: add property-based tests for evaluators using Hypothesis
1 parent c31f93d commit 2141003

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ filterwarnings = [
6767

6868
[dependency-groups]
6969
dev = [
70+
"hypothesis>=6.152.1",
7071
"mkdocs>=1.6.1",
7172
"mkdocs-material>=9.0",
7273
"mkdocstrings[python]>=0.25",

tests/test_evaluators_property.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""Property-based tests for evalwire evaluators using Hypothesis.
2+
3+
These tests verify invariants that must hold for *all* inputs, catching
4+
edge cases that hand-crafted examples miss.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import ast
10+
import json
11+
import math
12+
import re
13+
14+
from hypothesis import assume, given, settings
15+
from hypothesis import strategies as st
16+
17+
from evalwire.evaluators.contains import make_contains_evaluator
18+
from evalwire.evaluators.exact_match import make_exact_match_evaluator
19+
from evalwire.evaluators.json_match import make_json_match_evaluator
20+
from evalwire.evaluators.membership import make_membership_evaluator
21+
from evalwire.evaluators.numeric_tolerance import make_numeric_tolerance_evaluator
22+
from evalwire.evaluators.regex import make_regex_evaluator
23+
from evalwire.evaluators.top_k import make_top_k_evaluator
24+
25+
26+
def _expected_dict(value: str | list[str]) -> dict:
27+
"""Wrap a value into the ``{"expected_output": ...}`` format."""
28+
return {"expected_output": value}
29+
30+
31+
def _survives_literal_eval(s: str) -> bool:
32+
"""Return True if ``_parse_expected`` will keep *s* as a ``str`` element.
33+
34+
``_parse_expected`` runs ``ast.literal_eval`` on string values. Strings
35+
that evaluate to non-string Python literals (e.g. ``"0"`` -> ``int(0)``)
36+
are silently converted, causing type mismatches in downstream evaluators.
37+
"""
38+
try:
39+
return isinstance(ast.literal_eval(s), str)
40+
except (ValueError, SyntaxError):
41+
return True
42+
43+
44+
# Finite floats only (no NaN, no inf) -- mirrors real-world data.
45+
finite_floats = st.floats(allow_nan=False, allow_infinity=False)
46+
47+
48+
class TestNumericToleranceProperties:
49+
@given(value=finite_floats)
50+
def test_exact_match_always_passes(self, value: float):
51+
"""A value compared to itself should always pass with default tolerance."""
52+
evaluator = make_numeric_tolerance_evaluator()
53+
result = evaluator(str(value), _expected_dict(str(value)))
54+
assert result is True
55+
56+
@given(value=finite_floats, atol=st.floats(min_value=0, max_value=1e10))
57+
def test_result_is_bool(self, value: float, atol: float):
58+
"""Return type is always bool, never crashes."""
59+
assume(not math.isnan(atol))
60+
evaluator = make_numeric_tolerance_evaluator(atol=atol)
61+
result = evaluator(str(value), _expected_dict(str(value)))
62+
assert isinstance(result, bool)
63+
64+
@given(
65+
value=finite_floats,
66+
delta=st.floats(min_value=0, max_value=1e-8),
67+
)
68+
def test_within_default_tolerance(self, value: float, delta: float):
69+
"""Values within default atol (1e-6) of each other should pass."""
70+
assume(abs(delta) <= 1e-6)
71+
evaluator = make_numeric_tolerance_evaluator()
72+
result = evaluator(str(value + delta), _expected_dict(str(value)))
73+
assert result is True
74+
75+
@given(output=st.text())
76+
def test_non_numeric_string_returns_false(self, output: str):
77+
"""Non-numeric output should return False, not crash."""
78+
assume(not _is_numeric(output))
79+
evaluator = make_numeric_tolerance_evaluator()
80+
result = evaluator(output, _expected_dict("42.0"))
81+
assert result is False
82+
83+
@given(
84+
a=finite_floats,
85+
b=finite_floats,
86+
atol=st.floats(min_value=0, max_value=1e10),
87+
rtol=st.floats(min_value=0, max_value=1.0),
88+
)
89+
def test_tolerance_formula_matches_definition(
90+
self, a: float, b: float, atol: float, rtol: float
91+
):
92+
"""Result matches the formula: |a - b| <= atol + rtol * |b|."""
93+
assume(not math.isnan(atol) and not math.isnan(rtol))
94+
evaluator = make_numeric_tolerance_evaluator(atol=atol, rtol=rtol)
95+
result = evaluator(str(a), _expected_dict(str(b)))
96+
expected = abs(a - b) <= atol + rtol * abs(b)
97+
assert result == expected
98+
99+
100+
def _is_numeric(s: str) -> bool:
101+
try:
102+
float(s)
103+
return True
104+
except (ValueError, TypeError):
105+
return False
106+
107+
108+
class TestTopKProperties:
109+
@given(
110+
output=st.lists(st.text(min_size=1), min_size=1, max_size=50),
111+
expected=st.lists(st.text(min_size=1), min_size=1, max_size=10),
112+
k=st.integers(min_value=1, max_value=100),
113+
)
114+
def test_score_in_unit_interval(
115+
self, output: list[str], expected: list[str], k: int
116+
):
117+
"""Score must always be in [0.0, 1.0]."""
118+
evaluator = make_top_k_evaluator(K=k)
119+
score = evaluator(output, _expected_dict(expected))
120+
assert 0.0 <= score <= 1.0
121+
122+
@given(k=st.integers(min_value=1, max_value=100))
123+
def test_perfect_score_when_all_at_top(self, k: int):
124+
"""If all expected items are at position 0, score should be 1.0."""
125+
items = ["item"]
126+
evaluator = make_top_k_evaluator(K=k)
127+
score = evaluator(items, _expected_dict(items))
128+
assert score == 1.0
129+
130+
@given(
131+
expected=st.lists(st.text(min_size=1), min_size=1, max_size=5),
132+
k=st.integers(min_value=1, max_value=50),
133+
)
134+
def test_score_zero_when_nothing_matches(self, expected: list[str], k: int):
135+
"""If output contains none of the expected items, score should be 0.0."""
136+
output = ["__no_match__" + str(i) for i in range(k)]
137+
assume(not any(item in output for item in expected))
138+
evaluator = make_top_k_evaluator(K=k)
139+
score = evaluator(output, _expected_dict(expected))
140+
assert score == 0.0
141+
142+
@given(output=st.lists(st.text(), max_size=20))
143+
def test_none_output_returns_zero(self, output: list[str]):
144+
"""None output should return 0.0."""
145+
evaluator = make_top_k_evaluator()
146+
score = evaluator(None, _expected_dict(["anything"])) # ty: ignore[invalid-argument-type]
147+
assert score == 0.0
148+
149+
150+
class TestJsonMatchProperties:
151+
@given(data=st.dictionaries(st.text(min_size=1), st.text(), min_size=1))
152+
def test_score_in_unit_interval(self, data: dict):
153+
"""Score must always be in [0.0, 1.0]."""
154+
json_str = json.dumps(data)
155+
evaluator = make_json_match_evaluator()
156+
score = evaluator(json_str, _expected_dict(json_str))
157+
assert 0.0 <= score <= 1.0
158+
159+
@given(data=st.dictionaries(st.text(min_size=1), st.text(), min_size=1))
160+
def test_identical_json_scores_one(self, data: dict):
161+
"""Identical JSON objects should score 1.0."""
162+
# Include a boolean so the JSON contains ``true`` which is not a valid
163+
# Python literal, preventing ``ast.literal_eval`` from converting the
164+
# expected string into a dict inside ``_parse_expected``.
165+
data = {**data, "__sentinel__": True}
166+
json_str = json.dumps(data)
167+
evaluator = make_json_match_evaluator()
168+
score = evaluator(json_str, _expected_dict(json_str))
169+
assert score == 1.0
170+
171+
@given(output=st.text())
172+
def test_invalid_json_returns_zero(self, output: str):
173+
"""Invalid JSON output should return 0.0, not crash."""
174+
assume(not _is_valid_json_object(output))
175+
evaluator = make_json_match_evaluator()
176+
score = evaluator(output, _expected_dict('{"key": "val"}'))
177+
assert score == 0.0
178+
179+
180+
def _is_valid_json_object(s: str) -> bool:
181+
try:
182+
obj = json.loads(s)
183+
return isinstance(obj, dict)
184+
except (json.JSONDecodeError, TypeError):
185+
return False
186+
187+
188+
class TestRegexProperties:
189+
@given(output=st.text())
190+
@settings(max_examples=200)
191+
def test_never_crashes_on_arbitrary_output(self, output: str):
192+
"""Evaluator should not crash on any output string."""
193+
evaluator = make_regex_evaluator()
194+
result = evaluator(output, _expected_dict(r"\d+"))
195+
assert isinstance(result, bool)
196+
197+
@given(literal=st.text(min_size=1, max_size=20))
198+
def test_literal_pattern_matches_itself(self, literal: str):
199+
"""A regex-escaped literal should always match itself."""
200+
pattern = re.escape(literal)
201+
assume(_survives_literal_eval(pattern))
202+
evaluator = make_regex_evaluator()
203+
result = evaluator(literal, _expected_dict(pattern))
204+
assert result is True
205+
206+
@given(output=st.text())
207+
def test_none_output_returns_false(self, output: str):
208+
"""None output always returns False."""
209+
evaluator = make_regex_evaluator()
210+
result = evaluator(None, _expected_dict(r".*")) # ty: ignore[invalid-argument-type]
211+
assert result is False
212+
213+
214+
class TestExactMatchProperties:
215+
@given(value=st.text())
216+
def test_identity(self, value: str):
217+
"""A string always exactly matches itself."""
218+
assume(_survives_literal_eval(value))
219+
evaluator = make_exact_match_evaluator()
220+
assert evaluator(value, _expected_dict(value)) is True
221+
222+
@given(a=st.text(min_size=1), b=st.text(min_size=1))
223+
def test_different_strings_do_not_match(self, a: str, b: str):
224+
"""Different strings should not match."""
225+
assume(a != b)
226+
evaluator = make_exact_match_evaluator()
227+
assert evaluator(a, _expected_dict(b)) is False
228+
229+
230+
class TestContainsProperties:
231+
@given(haystack=st.text(min_size=1), needle=st.text(min_size=1))
232+
def test_substring_detected(self, haystack: str, needle: str):
233+
"""If needle is a substring of haystack, evaluator returns True."""
234+
assume(_survives_literal_eval(needle))
235+
full = haystack + needle + haystack
236+
evaluator = make_contains_evaluator()
237+
assert evaluator(full, _expected_dict(needle)) is True
238+
239+
@given(output=st.text())
240+
def test_none_output_returns_false(self, output: str):
241+
"""None output always returns False."""
242+
evaluator = make_contains_evaluator()
243+
assert evaluator(None, _expected_dict("x")) is False # ty: ignore[invalid-argument-type]
244+
245+
246+
class TestMembershipProperties:
247+
@given(items=st.lists(st.text(min_size=1), min_size=1, max_size=10))
248+
def test_member_is_found(self, items: list[str]):
249+
"""The first item should always be found in the expected set."""
250+
evaluator = make_membership_evaluator()
251+
assert evaluator(items[0], _expected_dict(items)) is True
252+
253+
@given(
254+
items=st.lists(st.text(min_size=1), min_size=1, max_size=10),
255+
output=st.text(min_size=1),
256+
)
257+
def test_non_member_not_found(self, items: list[str], output: str):
258+
"""A string not in the expected set should not be found."""
259+
assume(output not in items)
260+
evaluator = make_membership_evaluator()
261+
assert evaluator(output, _expected_dict(items)) is False

uv.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)