33#################
44from __future__ import annotations
55
6+ from dataclasses import dataclass
67from functools import cache
78from pathlib import Path
89from typing import Any
1415from scipy .stats ._distn_infrastructure import rv_continuous_frozen , rv_discrete_frozen
1516
1617
18+ @dataclass
19+ class TestResult :
20+ """Class to store metadata for individual tests run by FuzzyChecker."""
21+
22+ name : str
23+ """Name of the test proportion being calculated."""
24+ name_additional : str
25+ """Additional name for test, used for when the same proportion is calculated multiple times."""
26+ observed_proportion : float
27+ """The observed proportion of a specific event happening."""
28+ observed_numerator : int
29+ """Observed counts of the event happening."""
30+ observed_denominator : int
31+ """Total counts of opportunities for the event to happen."""
32+ target_lower_bound : float
33+ """Lower bound of the target proportion range."""
34+ target_upper_bound : float
35+ """Upper bound of the target proportion range."""
36+ bayes_factor : float
37+ """Calculated Bayes factor from the test for the observed proportion."""
38+ reject_null : bool
39+ """Whether the null hypothesis was rejected."""
40+ bug_issue_distribution : tuple [float , float ]
41+ """The bug/issue distribution used in the test."""
42+ no_bug_issue_distribution : rv_discrete_frozen
43+ """The no-bug/issue distribution used in the test."""
44+
45+
1746class FuzzyChecker :
1847 """
1948 This class manages "fuzzy" checks -- that is, checks of values that are
@@ -47,7 +76,7 @@ def fuzzy_checker(output_directory) -> FuzzyChecker:
4776 """
4877
4978 def __init__ (self ) -> None :
50- self .proportion_test_diagnostics : list [dict [ str , Any ] ] = []
79+ self .proportion_test_diagnostics : list [TestResult ] = []
5180
5281 def fuzzy_assert_proportion (
5382 self ,
@@ -114,6 +143,70 @@ def fuzzy_assert_proportion(
114143 Useful for e.g. specifying the timestep when an assertion happened.
115144
116145 """
146+ test_proportion = self .test_proportion (
147+ name = name ,
148+ name_additional = name_additional ,
149+ target_proportion = target_proportion ,
150+ observed_numerator = observed_numerator ,
151+ observed_denominator = observed_denominator ,
152+ bug_issue_beta_distribution_parameters = bug_issue_beta_distribution_parameters ,
153+ fail_bayes_factor_cutoff = fail_bayes_factor_cutoff ,
154+ )
155+ self .proportion_test_diagnostics .append (test_proportion )
156+
157+ if test_proportion .reject_null :
158+ if test_proportion .observed_proportion < test_proportion .target_lower_bound :
159+ raise AssertionError (
160+ f"{ name } value { test_proportion .observed_proportion :g} is significantly less than expected, bayes factor = { test_proportion .bayes_factor :g} "
161+ )
162+ else :
163+ raise AssertionError (
164+ f"{ name } value { test_proportion .observed_proportion :g} is significantly greater than expected, bayes factor = { test_proportion .bayes_factor :g} "
165+ )
166+
167+ if (
168+ test_proportion .target_lower_bound > 0
169+ and self ._calculate_bayes_factor (
170+ 0 ,
171+ test_proportion .bug_issue_distribution ,
172+ test_proportion .no_bug_issue_distribution ,
173+ )
174+ < fail_bayes_factor_cutoff
175+ ):
176+ logger .warning (
177+ f"Sample size too small to ever find that the simulation's '{ name } ' value is less than expected."
178+ )
179+
180+ if test_proportion .target_upper_bound < 1 and (
181+ self ._calculate_bayes_factor (
182+ observed_denominator ,
183+ test_proportion .bug_issue_distribution ,
184+ test_proportion .no_bug_issue_distribution ,
185+ )
186+ < fail_bayes_factor_cutoff
187+ ):
188+ logger .warning (
189+ f"Sample size too small to ever find that the simulation's '{ name } ' value is greater than expected."
190+ )
191+
192+ if (
193+ fail_bayes_factor_cutoff
194+ > test_proportion .bayes_factor
195+ > inconclusive_bayes_factor_cutoff
196+ ):
197+ logger .warning (f"Bayes factor for '{ name } ' is not conclusive." )
198+
199+ def test_proportion (
200+ self ,
201+ name : str = "" ,
202+ name_additional : str = "" ,
203+ target_proportion : float | tuple [float , float ] = 0.1 ,
204+ observed_numerator : int = 0 ,
205+ observed_denominator : int = 0 ,
206+ bug_issue_beta_distribution_parameters : tuple [float , float ] = (0.5 , 0.5 ),
207+ fail_bayes_factor_cutoff : float = 100.0 ,
208+ ) -> TestResult :
209+ """Convert a dictionary representation of a test result to a TestResult object."""
117210 if isinstance (target_proportion , tuple ):
118211 target_lower_bound , target_upper_bound = target_proportion
119212 else :
@@ -124,7 +217,7 @@ def fuzzy_assert_proportion(
124217 ), f"There cannot be more events ({ observed_numerator } ) than opportunities for events ({ observed_denominator } )"
125218 assert (
126219 target_upper_bound >= target_lower_bound
127- ), f"The lower bound of the V&V target ({ target_lower_bound } ) cannot be greater than the upper bound ({ target_upper_bound } )"
220+ ), f"The lower bound of the V& V target ({ target_lower_bound } ) cannot be greater than the upper bound ({ target_upper_bound } )"
128221
129222 bug_issue_alpha , bug_issue_beta = bug_issue_beta_distribution_parameters
130223 bug_issue_distribution = scipy .stats .betabinom (
@@ -150,54 +243,20 @@ def fuzzy_assert_proportion(
150243
151244 observed_proportion = observed_numerator / observed_denominator
152245 reject_null = bayes_factor > fail_bayes_factor_cutoff
153- self . proportion_test_diagnostics . append (
154- {
155- "name" : name ,
156- "name_addl" : name_additional ,
157- "observed_proportion" : observed_proportion ,
158- "observed_numerator" : observed_numerator ,
159- "observed_denominator" : observed_denominator ,
160- "target_lower_bound" : target_lower_bound ,
161- "target_upper_bound" : target_upper_bound ,
162- "bayes_factor" : bayes_factor ,
163- "reject_null" : reject_null ,
164- }
246+ return TestResult (
247+ name = name ,
248+ name_additional = name_additional ,
249+ observed_proportion = observed_proportion ,
250+ observed_numerator = observed_numerator ,
251+ observed_denominator = observed_denominator ,
252+ target_lower_bound = target_lower_bound ,
253+ target_upper_bound = target_upper_bound ,
254+ bayes_factor = bayes_factor ,
255+ reject_null = reject_null ,
256+ bug_issue_distribution = bug_issue_distribution ,
257+ no_bug_issue_distribution = no_bug_issue_distribution ,
165258 )
166259
167- if reject_null :
168- if observed_proportion < target_lower_bound :
169- raise AssertionError (
170- f"{ name } value { observed_proportion :g} is significantly less than expected, bayes factor = { bayes_factor :g} "
171- )
172- else :
173- raise AssertionError (
174- f"{ name } value { observed_proportion :g} is significantly greater than expected, bayes factor = { bayes_factor :g} "
175- )
176-
177- if (
178- target_lower_bound > 0
179- and self ._calculate_bayes_factor (
180- 0 , bug_issue_distribution , no_bug_issue_distribution
181- )
182- < fail_bayes_factor_cutoff
183- ):
184- logger .warning (
185- f"Sample size too small to ever find that the simulation's '{ name } ' value is less than expected."
186- )
187-
188- if target_upper_bound < 1 and (
189- self ._calculate_bayes_factor (
190- observed_denominator , bug_issue_distribution , no_bug_issue_distribution
191- )
192- < fail_bayes_factor_cutoff
193- ):
194- logger .warning (
195- f"Sample size too small to ever find that the simulation's '{ name } ' value is greater than expected."
196- )
197-
198- if fail_bayes_factor_cutoff > bayes_factor > inconclusive_bayes_factor_cutoff :
199- logger .warning (f"Bayes factor for '{ name } ' is not conclusive." )
200-
201260 def _calculate_bayes_factor (
202261 self ,
203262 numerator : int ,
0 commit comments