Skip to content

Commit d06e94b

Browse files
committed
Make # abs tol compare over the complex numbers
For calculations over complex numbers that generate numeric noise, one tends to create small but non-zero imaginary parts. This PR updates the "# abs tol" tolerance setting to work over the complex numbers, as the "abs" suggests complex numbers. The real and imaginary parts are compared separately. The ordinary "# tol" and "# rel tol" are left as is. Fixes #36631
1 parent 79c047c commit d06e94b

6 files changed

Lines changed: 526 additions & 208 deletions

File tree

pkgs/sagemath-categories/known-test-failures.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,10 @@
807807
"failed": true,
808808
"ntests": 0
809809
},
810+
"sage.doctest.check_tolerance": {
811+
"failed": true,
812+
"ntests": 19
813+
},
810814
"sage.doctest.control": {
811815
"failed": true,
812816
"ntests": 230
@@ -821,13 +825,21 @@
821825
"failed": true,
822826
"ntests": 413
823827
},
828+
"sage.doctest.marked_output": {
829+
"failed": true,
830+
"ntests": 21
831+
},
824832
"sage.doctest.parsing": {
825833
"failed": true,
826-
"ntests": 313
834+
"ntests": 275
827835
},
828836
"sage.doctest.reporting": {
829837
"ntests": 115
830838
},
839+
"sage.doctest.rif_tol": {
840+
"failed": true,
841+
"ntests": 18
842+
},
831843
"sage.doctest.sources": {
832844
"failed": true,
833845
"ntests": 343
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# sage_setup: distribution = sagemath-repl
2+
"""
3+
Check tolerance when parsing docstrings
4+
"""
5+
6+
# ****************************************************************************
7+
# Copyright (C) 2012-2018 David Roe <roed.math@gmail.com>
8+
# 2012 Robert Bradshaw <robertwb@gmail.com>
9+
# 2012 William Stein <wstein@gmail.com>
10+
# 2013 R. Andrew Ohana
11+
# 2013 Volker Braun
12+
# 2013-2018 Jeroen Demeyer <jdemeyer@cage.ugent.be>
13+
# 2016-2021 Frédéric Chapoton
14+
# 2017-2018 Erik M. Bray
15+
# 2020 Marc Mezzarobba
16+
# 2020-2023 Matthias Koeppe
17+
# 2022 John H. Palmieri
18+
# 2022 Sébastien Labbé
19+
# 2023 Kwankyu Lee
20+
#
21+
# Distributed under the terms of the GNU General Public License (GPL)
22+
# as published by the Free Software Foundation; either version 2 of
23+
# the License, or (at your option) any later version.
24+
# https://www.gnu.org/licenses/
25+
# ****************************************************************************
26+
27+
import re
28+
from sage.doctest.rif_tol import RIFtol, add_tolerance
29+
from sage.doctest.marked_output import MarkedOutput
30+
31+
32+
# Regex pattern for float without the (optional) leading sign
33+
float_without_sign = r'((\d*\.?\d+)|(\d+\.?))([eE][+-]?\d+)?'
34+
35+
36+
# Regular expression for floats
37+
float_regex = re.compile(r'\s*([+-]?\s*' + float_without_sign + r')')
38+
39+
40+
class ToleranceExceededError(BaseException):
41+
pass
42+
43+
44+
def check_tolerance_real_domain(want: MarkedOutput, got: str) -> tuple[str, str]:
45+
"""
46+
Compare want and got over real domain with tolerance
47+
48+
INPUT:
49+
50+
- ``want`` -- a string, what you want
51+
- ``got`` -- a string, what you got
52+
53+
OUTPUT:
54+
55+
The strings to compare, but with matching float numbers replaced by asterisk.
56+
57+
EXAMPLES::
58+
59+
sage: from sage.doctest.check_tolerance import check_tolerance_real_domain
60+
sage: from sage.doctest.marked_output import MarkedOutput
61+
sage: check_tolerance_real_domain(
62+
....: MarkedOutput('foo:0.2').update(abs_tol=0.3),
63+
....: 'bar:0.4')
64+
['foo:*', 'bar:*']
65+
sage: check_tolerance_real_domain(
66+
....: MarkedOutput('foo:0.2').update(abs_tol=0.3),
67+
....: 'bar:0.6')
68+
Traceback (most recent call last):
69+
...
70+
sage.doctest.check_tolerance.ToleranceExceededError
71+
"""
72+
# First check that the number of occurrences of floats appearing match
73+
want_str = [g[0] for g in float_regex.findall(want)]
74+
got_str = [g[0] for g in float_regex.findall(got)]
75+
if len(want_str) != len(got_str):
76+
raise ToleranceExceededError()
77+
78+
# Then check the numbers
79+
want_values = [RIFtol(g) for g in want_str]
80+
want_intervals = [add_tolerance(v, want) for v in want_values]
81+
got_values = [RIFtol(g) for g in got_str]
82+
# The doctest is not successful if one of the "want" and "got"
83+
# intervals have an empty intersection
84+
if not all(a.overlaps(b) for a, b in zip(want_intervals, got_values)):
85+
raise ToleranceExceededError()
86+
87+
# Then check the part of the doctests without the numbers
88+
# Continue the check process with floats replaced by stars
89+
want = float_regex.sub('*', want)
90+
got = float_regex.sub('*', got)
91+
return [want, got]
92+
93+
94+
# match 1.0 or 1.0 + I or 1.0 + 2.0*I
95+
real_plus_optional_imag = ''.join([
96+
r'\s*(?P<real>[+-]?\s*',
97+
float_without_sign,
98+
r')(\s*(?P<real_imag_coeff>[+-]\s*',
99+
float_without_sign,
100+
r')\*I|\s*(?P<real_imag_unit>[+-])\s*I)?',
101+
])
102+
103+
104+
# match - 2.0*I
105+
only_imag = ''.join([
106+
r'\s*(?P<only_imag>[+-]?\s*',
107+
float_without_sign,
108+
r')\*I',
109+
])
110+
111+
112+
# match I or -I (no digits), require a non-word part before and after for specificity
113+
imaginary_unit = r'(?P<unit_imag_pre>^|\W)(?P<unit_imag>[+-]?)I(?P<unit_imag_post>$|\W)'
114+
115+
116+
complex_regex = re.compile(''.join([
117+
'(',
118+
only_imag,
119+
'|',
120+
imaginary_unit,
121+
'|',
122+
real_plus_optional_imag,
123+
')',
124+
]))
125+
126+
127+
def complex_match_to_real_and_imag(m: re.Match) -> tuple[str, str]:
128+
"""
129+
Extract real and imaginary part from match
130+
131+
INPUT:
132+
133+
- ``m`` -- match from ``complex_regex``
134+
135+
OUTPUT:
136+
137+
Pair of real and complex parts (as string)
138+
139+
EXAMPLES::
140+
141+
sage: from sage.doctest.check_tolerance import complex_match_to_real_and_imag, complex_regex
142+
sage: complex_match_to_real_and_imag(complex_regex.match('1.0'))
143+
('1.0', '0')
144+
sage: complex_match_to_real_and_imag(complex_regex.match('-1.0 - I'))
145+
('-1.0', '-1')
146+
sage: complex_match_to_real_and_imag(complex_regex.match('1.0 - 3.0*I'))
147+
('1.0', '- 3.0')
148+
sage: complex_match_to_real_and_imag(complex_regex.match('1.0*I'))
149+
('0', '1.0')
150+
sage: complex_match_to_real_and_imag(complex_regex.match('- 2.0*I'))
151+
('0', '- 2.0')
152+
sage: complex_match_to_real_and_imag(complex_regex.match('-I'))
153+
('0', '-1')
154+
sage: for match in complex_regex.finditer('[1, -1, I, -1, -I]'):
155+
....: print(complex_match_to_real_and_imag(match))
156+
('1', '0')
157+
('-1', '0')
158+
('0', '1')
159+
('-1', '0')
160+
('0', '-1')
161+
sage: for match in complex_regex.finditer('[1, -1.3, -1.5 + 0.1*I, 0.5 - 0.1*I, -1.5*I]'):
162+
....: print(complex_match_to_real_and_imag(match))
163+
('1', '0')
164+
('-1.3', '0')
165+
('-1.5', '+ 0.1')
166+
('0.5', '- 0.1')
167+
('0', '-1.5')
168+
"""
169+
real = m.group('real')
170+
if real is not None:
171+
real_imag_coeff = m.group('real_imag_coeff')
172+
real_imag_unit = m.group('real_imag_unit')
173+
if real_imag_coeff is not None:
174+
return (real, real_imag_coeff)
175+
elif real_imag_unit is not None:
176+
return (real, real_imag_unit + '1')
177+
else:
178+
return (real, '0')
179+
only_imag = m.group('only_imag')
180+
if only_imag is not None:
181+
return ('0', only_imag)
182+
unit_imag = m.group('unit_imag')
183+
if unit_imag is not None:
184+
return ('0', unit_imag + '1')
185+
assert False, 'unreachable'
186+
187+
188+
def complex_star_repl(m: re.Match):
189+
"""
190+
Replace the complex number in the match with '*'
191+
"""
192+
if m.group('unit_imag') is not None:
193+
# preserve the matched non-word part
194+
return ''.join([
195+
(m.group('unit_imag_pre') or '').strip(),
196+
'*',
197+
(m.group('unit_imag_post') or '').strip(),
198+
])
199+
else:
200+
return '*'
201+
202+
203+
def check_tolerance_complex_domain(want: MarkedOutput, got: str) -> tuple[str, str]:
204+
"""
205+
Compare want and got over complex domain with tolerance
206+
207+
INPUT:
208+
209+
- ``want`` -- a string, what you want
210+
- ``got`` -- a string, what you got
211+
212+
OUTPUT:
213+
214+
The strings to compare, but with matching complex numbers replaced by asterisk.
215+
216+
EXAMPLES::
217+
218+
sage: from sage.doctest.check_tolerance import check_tolerance_complex_domain
219+
sage: from sage.doctest.marked_output import MarkedOutput
220+
sage: check_tolerance_complex_domain(
221+
....: MarkedOutput('foo:[0.2 + 0.1*I]').update(abs_tol=0.3),
222+
....: 'bar:[0.4]')
223+
['foo:[*]', 'bar:[*]']
224+
sage: check_tolerance_complex_domain(
225+
....: MarkedOutput('foo:-0.5 - 0.1*I').update(abs_tol=2),
226+
....: 'bar:1')
227+
['foo:*', 'bar:*']
228+
sage: check_tolerance_complex_domain(
229+
....: MarkedOutput('foo:[1.0*I]').update(abs_tol=0.3),
230+
....: 'bar:[I]')
231+
['foo:[*]', 'bar:[*]']
232+
sage: check_tolerance_complex_domain(MarkedOutput('foo:0.2 + 0.1*I').update(abs_tol=0.3), 'bar:0.6')
233+
Traceback (most recent call last):
234+
...
235+
sage.doctest.check_tolerance.ToleranceExceededError
236+
"""
237+
want_str = []
238+
for match in complex_regex.finditer(want):
239+
want_str.extend(complex_match_to_real_and_imag(match))
240+
got_str = []
241+
for match in complex_regex.finditer(got):
242+
got_str.extend(complex_match_to_real_and_imag(match))
243+
if len(want_str) != len(got_str):
244+
raise ToleranceExceededError()
245+
246+
# Then check the numbers
247+
want_values = [RIFtol(g) for g in want_str]
248+
want_intervals = [add_tolerance(v, want) for v in want_values]
249+
got_values = [RIFtol(g) for g in got_str]
250+
# The doctest is not successful if one of the "want" and "got"
251+
# intervals have an empty intersection
252+
if not all(a.overlaps(b) for a, b in zip(want_intervals, got_values)):
253+
raise ToleranceExceededError()
254+
255+
# Then check the part of the doctests without the numbers
256+
# Continue the check process with floats replaced by stars
257+
want = complex_regex.sub(complex_star_repl, want)
258+
got = complex_regex.sub(complex_star_repl, got)
259+
return [want, got]

src/sage/doctest/control.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ def expand_files_into_sources(self):
971971
sage: DC = DocTestController(DD, [dirname])
972972
sage: DC.expand_files_into_sources()
973973
sage: len(DC.sources)
974-
12
974+
15
975975
sage: DC.sources[0].options.optional
976976
True
977977
@@ -1072,13 +1072,16 @@ def sort_sources(self):
10721072
sage.doctest.util
10731073
sage.doctest.test
10741074
sage.doctest.sources
1075+
sage.doctest.rif_tol
10751076
sage.doctest.reporting
10761077
sage.doctest.parsing_test
10771078
sage.doctest.parsing
1079+
sage.doctest.marked_output
10781080
sage.doctest.forker
10791081
sage.doctest.fixtures
10801082
sage.doctest.external
10811083
sage.doctest.control
1084+
sage.doctest.check_tolerance
10821085
sage.doctest.all
10831086
sage.doctest
10841087
"""

0 commit comments

Comments
 (0)