statwarrant 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ """statwarrant: what your statistical result warrants you to say.
2
+
3
+ Declare the smallest effect size of interest and its reason, analyse a result,
4
+ and get the claims it supports, the claims it does not, and why.
5
+
6
+ >>> import statwarrant as sw
7
+ >>> plan = sw.declare(sesoi=3, sesoi_source="smallest score gain teachers act on",
8
+ ... units="points", scope="first-year students, one university, one term")
9
+ >>> res = plan.analyze(estimate=4.2, se=1.6, df=58)
10
+ >>> res.check("There is no difference between the groups.").level
11
+ 'conflict'
12
+ """
13
+
14
+ from ._core import Evidence, Thresholds, evaluate
15
+ from ._version import __version__
16
+ from .adapters import AdapterMismatch
17
+ from .claims import CheckResult, Claim, Claims, Part
18
+ from .design import DesignPoint, DesignTable, design_point
19
+ from .plan import Plan, Result, declare
20
+
21
+
22
+ __all__ = [
23
+ "declare",
24
+ "Plan",
25
+ "Result",
26
+ "Evidence",
27
+ "Thresholds",
28
+ "Claim",
29
+ "Claims",
30
+ "CheckResult",
31
+ "Part",
32
+ "AdapterMismatch",
33
+ "DesignPoint",
34
+ "DesignTable",
35
+ "design_point",
36
+ "evaluate",
37
+ "__version__",
38
+ ]
statwarrant/_core.py ADDED
@@ -0,0 +1,491 @@
1
+ """Core inference: difference from zero, equivalence (TOST) and minimal effect (MET).
2
+
3
+ Every decision is read from a confidence interval.
4
+
5
+ * Zero test: the (1 - alpha) interval excludes zero.
6
+ * Minimal-effect test (MET): the (1 - alpha) interval lies entirely beyond one of
7
+ the SESOI bounds. The test can succeed in either direction, so alpha is split
8
+ across the two directions (alpha / 2 per side). This keeps the type I error at
9
+ or below alpha for every true effect inside the bounds, under both policies.
10
+ * Equivalence test (TOST, Schuirmann 1987): the interval lies entirely inside
11
+ the SESOI bounds. Both one-sided tests must reject (an intersection-union
12
+ test), so each may run at the full per-side level. The interval policy
13
+ decides which level:
14
+
15
+ - ``"single"`` (default): alpha / 2 per side, i.e. the same (1 - alpha)
16
+ interval as every other decision. One picture, no contradictions,
17
+ slightly less power for equivalence.
18
+ - ``"standard"``: alpha per side, i.e. the (1 - 2 alpha) interval, as in
19
+ Schuirmann's TOST and the TOSTER package. When equivalence is established
20
+ with that interval but the (1 - alpha) interval reaches outside the bounds,
21
+ the result reports a level conflict.
22
+
23
+ Version 0.1.0 also ran the minimal-effect test at alpha per side under
24
+ ``"standard"``; its type I error could then approach 2 alpha. That was removed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ import struct
31
+ import sys
32
+ import warnings
33
+ from dataclasses import dataclass, field, replace
34
+ from typing import Optional, Tuple
35
+
36
+ from scipy import integrate, special, stats
37
+
38
+ from ._precision import checked_float
39
+
40
+ POLICIES = ("single", "standard")
41
+ MIN_ALPHA = 1e-10
42
+
43
+
44
+ def level_text(level: float) -> str:
45
+ """An interval level as a percentage with the digits it needs (95%, 99.9%, 99.99999%)."""
46
+ return f"{level * 100:.10g}%"
47
+
48
+
49
+ def _dist(df: Optional[float]):
50
+ """Reference distribution: Student t with ``df`` degrees of freedom, or normal."""
51
+ if df is None:
52
+ return stats.norm
53
+ try:
54
+ df = float(df)
55
+ except (TypeError, ValueError):
56
+ raise ValueError(f"df must be a positive number or None, got {df!r}") from None
57
+ if math.isnan(df):
58
+ raise ValueError("df must be a positive number or None, got nan")
59
+ if math.isinf(df) and df > 0:
60
+ return stats.norm
61
+ if not df > 0:
62
+ raise ValueError(f"df must be positive, got {df!r}")
63
+ return stats.t(df)
64
+
65
+
66
+ def _log_t_tail(x: float, df: float) -> float:
67
+ """Log one-sided Student t tail, including when SciPy's sf/logsf underflow.
68
+
69
+ Integrate the density on a log-distance scale, factoring out its value at x.
70
+ This avoids forming x*x, the tiny tail, or a huge unscaled integral. It is
71
+ used only in the subnormal/zero tail region, not for ordinary decisions.
72
+ """
73
+ if not (math.isfinite(x) and x > 1 and math.isfinite(df) and df > 0):
74
+ return -math.inf
75
+ try:
76
+ ratio = math.sqrt(df) / x
77
+ w = 1.0 / (1.0 + ratio * ratio)
78
+ k = (df + 1.0) * w - 1.0
79
+ if not (0 < w <= 1 and math.isfinite(k) and k > 0):
80
+ return -math.inf
81
+ r = 2 * math.log(x) - math.log(df)
82
+ log1pr = math.log1p(math.exp(r)) if r < 0 else r + math.log1p(math.exp(-r))
83
+ logpdf = -math.log(df) / 2 - float(special.betaln(0.5, df / 2)) - (df / 2 + 0.5) * log1pr
84
+
85
+ def integrand(u):
86
+ v = u / k
87
+ if v < 300:
88
+ delta = math.log1p(w * math.expm1(2 * v))
89
+ else:
90
+ delta = 2 * v + math.log(w + (1 - w) * math.exp(-2 * v))
91
+ exponent = v - (df / 2 + 0.5) * delta
92
+ return math.exp(exponent) if exponent > -746 else 0.0
93
+
94
+ with warnings.catch_warnings():
95
+ warnings.simplefilter("error", integrate.IntegrationWarning)
96
+ integral, error = integrate.quad(integrand, 0, math.inf, epsabs=1e-11, epsrel=1e-11, limit=150)
97
+ if not (math.isfinite(integral) and integral > 0 and error <= 1e-9 * integral):
98
+ return -math.inf
99
+ result = logpdf + math.log(x) - math.log(k) + math.log(integral)
100
+ return result if math.isfinite(result) and result <= 0 else -math.inf
101
+ except (ArithmeticError, ValueError, integrate.IntegrationWarning):
102
+ return -math.inf
103
+
104
+
105
+ def _log_tail(dist, x: float, df: Optional[float]) -> float:
106
+ """Log sf, with a fallback where SciPy's Student t logsf loses the tail."""
107
+ log_sf = float(dist.logsf(x))
108
+ if df is not None and (not math.isfinite(log_sf) or log_sf < math.log(sys.float_info.min)):
109
+ recovered = _log_t_tail(x, df)
110
+ if math.isfinite(recovered):
111
+ return recovered
112
+ return log_sf
113
+
114
+
115
+ def _tail_p(dist, x: float, df: Optional[float], two_sided: bool = False) -> float:
116
+ """Use ordinary sf except in the extreme tail; multiply on the log scale there."""
117
+ sf = float(dist.sf(x))
118
+ if sf >= sys.float_info.min:
119
+ return min(1.0, sf * (2 if two_sided else 1))
120
+ log_sf = _log_tail(dist, x, df)
121
+ if math.isfinite(log_sf):
122
+ return min(1.0, math.exp(log_sf + (math.log(2.0) if two_sided else 0.0)))
123
+ # The log tail may itself be unavailable; retain any nonzero SciPy result.
124
+ return min(1.0, sf * (2 if two_sided else 1))
125
+
126
+
127
+ def _critical_from_tail(tail: float, df: Optional[float]) -> float:
128
+ """Refuse a critical value if the distribution cannot invert its own tail.
129
+
130
+ Some SciPy versions return a finite but grossly inaccurate Student-t isf
131
+ for extraordinarily small df. A self-consistency check prevents reporting
132
+ an inference based on that result; it is not proof of general accuracy.
133
+ """
134
+ dist = _dist(df)
135
+ c = float(dist.isf(tail))
136
+ if not math.isfinite(c) or c < 0:
137
+ raise ValueError("the reference distribution's critical value is not finite; check df and alpha")
138
+ observed_tail = float(dist.sf(c))
139
+ if not math.isfinite(observed_tail) or not math.isclose(observed_tail, tail, rel_tol=1e-6, abs_tol=0.0):
140
+ raise ValueError("the reference distribution's critical value cannot be verified for this df and alpha")
141
+ return c
142
+
143
+
144
+ def critical_value(two_sided_level: float, df: Optional[float]) -> float:
145
+ """Critical value for a two-sided interval at ``two_sided_level`` (e.g. 0.95)."""
146
+ if not 0 < two_sided_level < 1:
147
+ raise ValueError("interval level must be between 0 and 1")
148
+ return _critical_from_tail((1 - two_sided_level) / 2, df)
149
+
150
+
151
+ def interval(estimate: float, se: float, level: float, df: Optional[float]) -> Tuple[float, float]:
152
+ c = critical_value(level, df)
153
+ return (estimate - c * se, estimate + c * se)
154
+
155
+
156
+ def _interval_tail(estimate: float, se: float, tail: float, df: Optional[float]) -> Tuple[float, float]:
157
+ """Interval whose two tails each hold ``tail``; uses the survival function, stable for tiny alpha."""
158
+ c = _critical_from_tail(tail, df)
159
+ return (estimate - c * se, estimate + c * se)
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class Thresholds:
164
+ """The decision for every symmetric threshold (-d, d) at once.
165
+
166
+ Both tests are read from intervals, so the whole sensitivity to the choice of
167
+ threshold is fixed by two numbers:
168
+
169
+ * ``met_below``: an effect beyond +/-d is established for every positive
170
+ representable d below this value; None when the zero test is not established.
171
+ * ``equivalence_above``: equivalence within +/-d is established for every d
172
+ above this value, among positive finite representable thresholds.
173
+
174
+ Both cut-offs are strict: at a positive finite cut-off the decision is
175
+ "undetermined", and between the cut-offs neither is established. At the
176
+ domain limits the open side can be empty: ``met_below`` can be the smallest
177
+ positive float, or ``equivalence_above`` the largest finite float. Conversely,
178
+ ``equivalence_above`` can be zero if all positive finite floats are equivalent;
179
+ zero is not itself a valid input to :meth:`decision`. This re-reads the same
180
+ intervals; it is not a family of new tests, and choosing a threshold after
181
+ seeing these numbers is not a confirmatory claim.
182
+ """
183
+
184
+ met_below: Optional[float]
185
+ met_level: float
186
+ equivalence_above: float
187
+ equivalence_level: float
188
+ # The same arithmetic as evaluate(), so decision(d) always agrees with it
189
+ estimate: float = field(default=0.0, repr=False)
190
+ se: float = field(default=1.0, repr=False)
191
+ met_critical: float = field(default=math.inf, repr=False)
192
+ equivalence_critical: float = field(default=math.inf, repr=False)
193
+
194
+ def decision(self, d: float) -> str:
195
+ """"exceeds", "equivalent" or "undetermined" for the symmetric threshold (-d, d).
196
+
197
+ Computed like evaluate(): the bound is subtracted from the estimate before
198
+ dividing by the standard error, which keeps precision near a cut-off.
199
+ """
200
+ d = checked_float(d, "threshold")
201
+ if not (math.isfinite(d) and d > 0):
202
+ raise ValueError("a threshold must be a finite positive number")
203
+ if (abs(self.estimate) - d) / self.se > self.met_critical:
204
+ return "exceeds"
205
+ if (d - abs(self.estimate)) / self.se > self.equivalence_critical:
206
+ return "equivalent"
207
+ return "undetermined"
208
+
209
+ def to_dict(self) -> dict:
210
+ return {
211
+ "met_below": self.met_below,
212
+ "met_interval_level": self.met_level,
213
+ "equivalence_above": self.equivalence_above,
214
+ "equivalence_interval_level": self.equivalence_level,
215
+ }
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class Evidence:
220
+ """What the data say about the effect, under the declared plan.
221
+
222
+ Attributes are plain numbers and booleans so the object can be serialised
223
+ and tested. ``claim_class`` is the label the claim contract is built on.
224
+ p-values for the equivalence and minimal-effect tests are one-sided and are
225
+ compared with the per-side thresholds reported next to them.
226
+ """
227
+
228
+ estimate: float
229
+ se: float
230
+ df: Optional[float]
231
+ alpha: float
232
+ policy: str
233
+ sesoi: Optional[Tuple[float, float]]
234
+
235
+ zero_level: float
236
+ zero_interval: Tuple[float, float]
237
+ p_zero: float
238
+ differs_from_zero: bool
239
+
240
+ equivalence_level: Optional[float]
241
+ equivalence_interval: Optional[Tuple[float, float]]
242
+ equivalence_threshold: Optional[float]
243
+ p_equivalence: Optional[float]
244
+ equivalent: Optional[bool]
245
+
246
+ met_level: Optional[float]
247
+ met_interval: Optional[Tuple[float, float]]
248
+ met_threshold: Optional[float]
249
+ p_minimal_effect: Optional[float]
250
+ exceeds_sesoi: Optional[bool]
251
+
252
+ claim_class: str
253
+ level_conflict: bool
254
+
255
+ def thresholds(self) -> Thresholds:
256
+ """Decisions for every symmetric threshold at once (see :class:`Thresholds`)."""
257
+ lo, hi = self.zero_interval
258
+ met_below = min(abs(lo), abs(hi)) if self.differs_from_zero else None
259
+ eq_tail = self.alpha / 2 if self.policy == "single" else self.alpha
260
+ eq_level = 1 - 2 * eq_tail
261
+ elo, ehi = _interval_tail(self.estimate, self.se, eq_tail, self.df)
262
+ th = Thresholds(met_below, self.zero_level, max(abs(elo), abs(ehi)), eq_level,
263
+ self.estimate, self.se, _critical_from_tail(self.alpha / 2, self.df),
264
+ _critical_from_tail(eq_tail, self.df))
265
+ # Interval and test-statistic arithmetic can diverge by many floats near
266
+ # cancellation. Find the exact transitions of decision(), not just nearby
267
+ # interval endpoints, so the printed cut-offs cannot contradict it.
268
+ return replace(th, met_below=None if met_below is None else _edge(th, met_below, "exceeds", below=True),
269
+ equivalence_above=_edge(th, th.equivalence_above, "equivalent", below=False))
270
+
271
+ @property
272
+ def direction(self) -> int:
273
+ """+1 or -1 when the difference from zero is established, otherwise 0."""
274
+ if not self.differs_from_zero:
275
+ return 0
276
+ return 1 if self.estimate > 0 else -1
277
+
278
+ def to_dict(self) -> dict:
279
+ out = {
280
+ "estimate": self.estimate,
281
+ "se": self.se,
282
+ "df": self.df,
283
+ "alpha": self.alpha,
284
+ "interval_policy": self.policy,
285
+ "sesoi": list(self.sesoi) if self.sesoi else None,
286
+ "zero_test": {
287
+ "interval_level": self.zero_level,
288
+ "interval": list(self.zero_interval),
289
+ "p_value": self.p_zero,
290
+ "differs_from_zero": self.differs_from_zero,
291
+ },
292
+ "equivalence_test": None,
293
+ "minimal_effect_test": None,
294
+ "claim_class": self.claim_class,
295
+ "level_conflict": self.level_conflict,
296
+ }
297
+ if self.p_zero == 0.0:
298
+ out["zero_test"]["p_value_underflow_or_unavailable"] = True
299
+ if self.sesoi is not None:
300
+ out["equivalence_test"] = {
301
+ "interval_level": self.equivalence_level,
302
+ "interval": list(self.equivalence_interval),
303
+ "per_side_threshold": self.equivalence_threshold,
304
+ "p_value": self.p_equivalence,
305
+ "equivalent": self.equivalent,
306
+ }
307
+ out["minimal_effect_test"] = {
308
+ "interval_level": self.met_level,
309
+ "interval": list(self.met_interval),
310
+ "per_side_threshold": self.met_threshold,
311
+ "p_value": self.p_minimal_effect,
312
+ "exceeds_sesoi": self.exceeds_sesoi,
313
+ }
314
+ if self.p_equivalence == 0.0:
315
+ out["equivalence_test"]["p_value_underflow_or_unavailable"] = True
316
+ if self.p_minimal_effect == 0.0:
317
+ out["minimal_effect_test"]["p_value_underflow_or_unavailable"] = True
318
+ return out
319
+
320
+
321
+ _MAX_FINITE_BITS = 0x7FEFFFFFFFFFFFFF
322
+
323
+
324
+ def _positive_float(bits: int) -> float:
325
+ """Decode a nonnegative finite IEEE-754 binary64 by its ordered bit pattern."""
326
+ return struct.unpack(">d", struct.pack(">Q", bits))[0]
327
+
328
+
329
+ def _edge(th: "Thresholds", start: float, label: str, below: bool) -> float:
330
+ """Find the strict boundary on the positive finite binary64 domain.
331
+
332
+ For MET, the labelled region is a prefix; return its first non-labelled
333
+ float. For equivalence, the labelled region is a suffix; return its last
334
+ non-labelled float. Rounded subtraction and division by a positive SE are
335
+ monotone, so both predicates are monotone on this domain. A zero sentinel
336
+ represents an empty non-labelled prefix; the largest finite float represents
337
+ an empty labelled suffix. No decision is ever requested for zero or infinity.
338
+ """
339
+ # Most interval-derived guesses are already adjacent to the transition.
340
+ # Check the invariant, rather than assuming a small rounding discrepancy.
341
+ if math.isfinite(start) and start >= 0:
342
+ if start > 0 and th.decision(start) == label:
343
+ candidate = math.nextafter(start, math.inf if below else 0.0)
344
+ if candidate == 0.0 and not below:
345
+ return 0.0
346
+ if math.isfinite(candidate) and candidate > 0 and th.decision(candidate) != label:
347
+ return candidate
348
+ elif start > 0 or not below:
349
+ neighbor = math.nextafter(start, 0.0 if below else math.inf)
350
+ if (below and neighbor == 0.0) or (not below and not math.isfinite(neighbor)):
351
+ return start
352
+ if neighbor > 0 and math.isfinite(neighbor) and th.decision(neighbor) == label:
353
+ return start
354
+
355
+ # Nonnegative binary64 encodings have the same order as their values. For
356
+ # MET, lo is labelled (or the zero sentinel), and hi is not labelled: max
357
+ # is never labelled because abs(estimate) <= max. For equivalence, lo is
358
+ # unlabelled (or zero), and hi is labelled (or the MAX+1 sentinel). The
359
+ # sentinels are never passed to decision. Each iteration maintains those
360
+ # invariants and halves an integer range smaller than 2**63; 63 suffice.
361
+ lo, hi = (0, _MAX_FINITE_BITS) if below else (0, _MAX_FINITE_BITS + 1)
362
+ for _ in range(63):
363
+ if hi - lo == 1:
364
+ return _positive_float(hi if below else lo)
365
+ mid = (lo + hi) // 2
366
+ is_labelled = th.decision(_positive_float(mid)) == label
367
+ if is_labelled == below:
368
+ lo = mid
369
+ else:
370
+ hi = mid
371
+ if hi - lo == 1:
372
+ return _positive_float(hi if below else lo)
373
+ raise RuntimeError("could not establish a finite threshold boundary")
374
+
375
+
376
+ def evaluate(
377
+ estimate: float,
378
+ se: float,
379
+ df: Optional[float],
380
+ alpha: float,
381
+ policy: str,
382
+ sesoi: Optional[Tuple[float, float]],
383
+ ) -> Evidence:
384
+ """Run the three tests and classify the result."""
385
+ if policy not in POLICIES:
386
+ raise ValueError(f"interval_policy must be one of {POLICIES}, got {policy!r}")
387
+ estimate = checked_float(estimate, "estimate")
388
+ se = checked_float(se, "se")
389
+ df = None if df is None else checked_float(df, "df")
390
+ if sesoi is not None:
391
+ low, high = sesoi
392
+ sesoi = (checked_float(low, "sesoi lower bound"), checked_float(high, "sesoi upper bound"))
393
+ if not (math.isfinite(estimate) and math.isfinite(se)):
394
+ raise ValueError("estimate and se must be finite numbers")
395
+ if se <= 0:
396
+ raise ValueError("se must be strictly positive")
397
+ if not MIN_ALPHA <= alpha < 0.5:
398
+ raise ValueError(f"alpha must be at least {MIN_ALPHA:g} and below 0.5")
399
+ if df is not None and math.isinf(float(df)) and float(df) > 0:
400
+ df = None # infinite degrees of freedom: the normal reference
401
+ if sesoi is not None:
402
+ low, high = sesoi
403
+ if not (math.isfinite(low) and math.isfinite(high) and low < 0 < high):
404
+ raise ValueError("sesoi bounds must be finite with low < 0 < high")
405
+
406
+ # Every decision is made from a test statistic in which the bound is subtracted
407
+ # before dividing by the standard error; intervals are for reporting. This keeps a
408
+ # decision and its p-value in agreement even when estimate and bound are large and close.
409
+ dist = _dist(df)
410
+ zero_level = 1 - alpha
411
+ c_zero = _critical_from_tail(alpha / 2, df)
412
+ zero_iv = _interval_tail(estimate, se, alpha / 2, df)
413
+ p_zero = _tail_p(dist, abs(estimate) / se, df, two_sided=True)
414
+ differs = abs(estimate) / se > c_zero
415
+ if not all(math.isfinite(v) for v in zero_iv):
416
+ raise ValueError("the estimate or standard error is too large for floating point; rescale the outcome")
417
+
418
+ if sesoi is None:
419
+ if differs and estimate > 0:
420
+ cls = "differs_positive"
421
+ elif differs:
422
+ cls = "differs_negative"
423
+ else:
424
+ cls = "inconclusive_no_sesoi"
425
+ return Evidence(
426
+ estimate, se, df, alpha, policy, None,
427
+ zero_level, zero_iv, p_zero, differs,
428
+ None, None, None, None, None,
429
+ None, None, None, None, None,
430
+ cls, False,
431
+ )
432
+
433
+ low, high = sesoi
434
+
435
+ # Equivalence (TOST): reject both H0: theta <= low and H0: theta >= high.
436
+ eq_threshold = alpha / 2 if policy == "single" else alpha
437
+ eq_level = 1 - 2 * eq_threshold
438
+ eq_iv = _interval_tail(estimate, se, eq_threshold, df)
439
+ c_eq = _critical_from_tail(eq_threshold, df)
440
+ z_low, z_high = (estimate - low) / se, (high - estimate) / se
441
+ if not (math.isfinite(z_low) and math.isfinite(z_high) and all(math.isfinite(v) for v in eq_iv)):
442
+ raise ValueError("the inputs are too large for floating point; rescale the outcome and the SESOI")
443
+ p_eq = max(_tail_p(dist, z_low, df), _tail_p(dist, z_high, df))
444
+ equivalent = z_low > c_eq and z_high > c_eq
445
+
446
+ # Minimal effect (MET): reject H0: low <= theta <= high in either direction,
447
+ # alpha / 2 per direction, i.e. the (1 - alpha) interval beyond a bound.
448
+ met_threshold = alpha / 2
449
+ met_level = 1 - alpha
450
+ met_iv = zero_iv
451
+ p_met = min(_tail_p(dist, -z_high, df), _tail_p(dist, -z_low, df))
452
+ met_pos = -z_high > c_zero
453
+ met_neg = -z_low > c_zero
454
+ exceeds = met_pos or met_neg
455
+
456
+ # Wording only: an interval that passes a bound by floating-point noise
457
+ # (e.g. ppf versus isf in the last digit) touches it rather than extends beyond it.
458
+ # Each side uses its own scale, so a huge lower bound cannot hide an upper crossing.
459
+ past_low = zero_iv[0] < low - 1e-12 * max(abs(low), se)
460
+ past_high = zero_iv[1] > high + 1e-12 * max(abs(high), se)
461
+ if met_pos:
462
+ cls = "exceeds_sesoi_positive"
463
+ elif met_neg:
464
+ cls = "exceeds_sesoi_negative"
465
+ elif equivalent and differs:
466
+ cls = "nonzero_negligible"
467
+ elif equivalent:
468
+ cls = "equivalent"
469
+ elif differs and estimate > 0:
470
+ cls = "positive_importance_undetermined"
471
+ elif differs:
472
+ cls = "negative_importance_undetermined"
473
+ elif past_low and past_high:
474
+ cls = "inconclusive_both"
475
+ elif past_low or past_high:
476
+ cls = "inconclusive_one_side"
477
+ else:
478
+ # The interval touches a bound without extending beyond it.
479
+ cls = "inconclusive_at_bound"
480
+
481
+ # Only under "standard": equivalence read from the (1 - 2 alpha) interval
482
+ # while the (1 - alpha) interval reaches outside the bounds.
483
+ conflict = bool(equivalent and not (z_low > c_zero and z_high > c_zero))
484
+
485
+ return Evidence(
486
+ estimate, se, df, alpha, policy, (low, high),
487
+ zero_level, zero_iv, p_zero, differs,
488
+ eq_level, eq_iv, eq_threshold, p_eq, equivalent,
489
+ met_level, met_iv, met_threshold, p_met, exceeds,
490
+ cls, conflict,
491
+ )
@@ -0,0 +1,62 @@
1
+ """Conservative checks at boundaries that convert numeric inputs to float."""
2
+
3
+ import math
4
+ from decimal import Decimal, InvalidOperation
5
+ from numbers import Integral, Rational
6
+
7
+ import numpy as np
8
+
9
+
10
+ def checked_float(value, name):
11
+ """Convert to float without silently narrowing an exact integral input.
12
+
13
+ Fractional inputs may undergo the ordinary rounding inherent in binary64.
14
+ A float that was rounded before reaching this function cannot be recovered.
15
+ """
16
+ if type(value) is float:
17
+ return value
18
+ try:
19
+ result = float(value)
20
+ except OverflowError as exc:
21
+ raise ValueError(f"{name} is too large to represent as a finite float; rescale the inputs") from exc
22
+
23
+ # Decimal strings have long been accepted by explicit numeric inputs. Treat
24
+ # integral strings like their Decimal equivalents, without requiring exact
25
+ # representation of ordinary non-integral decimal values.
26
+ original = value
27
+ if isinstance(original, np.ndarray) and original.size == 1:
28
+ original = original.item()
29
+ if isinstance(value, str):
30
+ try:
31
+ original = Decimal(value)
32
+ except InvalidOperation:
33
+ return result # float() above remains the authority on accepted syntax
34
+
35
+ if isinstance(original, Integral):
36
+ exact_integer = int(original)
37
+ elif isinstance(original, Decimal):
38
+ if not original.is_finite():
39
+ return result
40
+ if not math.isfinite(result):
41
+ raise ValueError(f"{name} is too large to represent as a finite float; rescale the inputs")
42
+ if original == original.to_integral_value() and original != Decimal.from_float(result):
43
+ raise ValueError(f"{name} is an integer that cannot be represented exactly as a float; rescale the inputs")
44
+ return result
45
+ elif isinstance(original, Rational):
46
+ if original.denominator != 1:
47
+ return result
48
+ exact_integer = original.numerator
49
+ elif isinstance(original, np.floating):
50
+ if not np.isfinite(original):
51
+ return result
52
+ if not math.isfinite(result):
53
+ raise ValueError(f"{name} is too large to represent as a finite float; rescale the inputs")
54
+ if original != np.trunc(original):
55
+ return result
56
+ exact_integer = int(original)
57
+ else:
58
+ return result
59
+
60
+ if not math.isfinite(result) or int(result) != exact_integer:
61
+ raise ValueError(f"{name} is an integer that cannot be represented exactly as a float; rescale the inputs")
62
+ return result