exactpower 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Groundplane Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: exactpower
3
+ Version: 0.1.0
4
+ Summary: Exact power of Fisher's exact test for a planned 2x2 study (free core)
5
+ Author: Groundplane Labs
6
+ License: MIT
7
+ Project-URL: Homepage, https://groundplane0.gumroad.com/l/tyevqe
8
+ Keywords: power analysis,sample size,fisher exact test,exact test,statistics,biostatistics
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy
17
+ Requires-Dist: scipy
18
+ Dynamic: license-file
19
+
20
+ # exactpower
21
+
22
+ Free core of ExactPower: exact power of Fisher's exact test for a planned 2x2 study.
23
+
24
+ **The problem**
25
+ You are planning a study with two groups and a yes/no outcome, and you need to pick a sample size before you collect data. The usual power calculators use approximations that drift exactly where small samples matter most. Search terms: exact power Fisher test Python, sample size Fisher exact test, power analysis 2x2 Python.
26
+
27
+ **What it does**
28
+ `exact_power_fisher(n1, n2, p1, p2)` computes the exact rejection probability of the two-sided Fisher exact test under your planned design. Give it the group sizes and the event rates you expect, and it enumerates every possible outcome. No approximation, no simulation. The free version handles n1 + n2 up to 1000.
29
+
30
+ **Limits**
31
+ Free version: two-sided power only, n1 + n2 <= 1000. Larger studies are refused outright instead of hanging. No sample-size inversion, no Monte Carlo power estimator, no plots, no CLI.
32
+
33
+ **Install**
34
+
35
+ ```bash
36
+ pip install exactpower
37
+ ```
38
+
39
+ Needs Python 3.9 or newer, NumPy, and SciPy.
40
+
41
+ ```python
42
+ from exactpower import exact_power_fisher
43
+ print(exact_power_fisher(60, 60, 0.55, 0.30))
44
+ ```
45
+
46
+ This is the free core: two-sided exact power for a planned 2x2 design, capped at n1 + n2 <= 1000. The full version ($30, one-time) goes to n1 + n2 = 5000 and adds sample-size inversion, Monte Carlo power for r-by-c tables, and one-sided power: https://groundplane0.gumroad.com/l/tyevqe
@@ -0,0 +1,27 @@
1
+ # exactpower
2
+
3
+ Free core of ExactPower: exact power of Fisher's exact test for a planned 2x2 study.
4
+
5
+ **The problem**
6
+ You are planning a study with two groups and a yes/no outcome, and you need to pick a sample size before you collect data. The usual power calculators use approximations that drift exactly where small samples matter most. Search terms: exact power Fisher test Python, sample size Fisher exact test, power analysis 2x2 Python.
7
+
8
+ **What it does**
9
+ `exact_power_fisher(n1, n2, p1, p2)` computes the exact rejection probability of the two-sided Fisher exact test under your planned design. Give it the group sizes and the event rates you expect, and it enumerates every possible outcome. No approximation, no simulation. The free version handles n1 + n2 up to 1000.
10
+
11
+ **Limits**
12
+ Free version: two-sided power only, n1 + n2 <= 1000. Larger studies are refused outright instead of hanging. No sample-size inversion, no Monte Carlo power estimator, no plots, no CLI.
13
+
14
+ **Install**
15
+
16
+ ```bash
17
+ pip install exactpower
18
+ ```
19
+
20
+ Needs Python 3.9 or newer, NumPy, and SciPy.
21
+
22
+ ```python
23
+ from exactpower import exact_power_fisher
24
+ print(exact_power_fisher(60, 60, 0.55, 0.30))
25
+ ```
26
+
27
+ This is the free core: two-sided exact power for a planned 2x2 design, capped at n1 + n2 <= 1000. The full version ($30, one-time) goes to n1 + n2 = 5000 and adds sample-size inversion, Monte Carlo power for r-by-c tables, and one-sided power: https://groundplane0.gumroad.com/l/tyevqe
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "exactpower"
7
+ version = "0.1.0"
8
+ description = "Exact power of Fisher's exact test for a planned 2x2 study (free core)"
9
+ readme = "README.md"
10
+ authors = [{name = "Groundplane Labs"}]
11
+ license = {text = "MIT"}
12
+ requires-python = ">=3.9"
13
+ dependencies = ["numpy", "scipy"]
14
+ keywords = ["power analysis", "sample size", "fisher exact test", "exact test", "statistics", "biostatistics"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Intended Audience :: Science/Research",
19
+ "Topic :: Scientific/Engineering :: Mathematics",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://groundplane0.gumroad.com/l/tyevqe"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """exactpower: exact power of Fisher's exact test (free core)."""
2
+
3
+ from ._power import FREE_MAX_TOTAL_N, exact_power_fisher
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["exact_power_fisher", "FREE_MAX_TOTAL_N", "__version__"]
@@ -0,0 +1,65 @@
1
+ """Vectorized floating-point kernels for the free exactpower core.
2
+
3
+ :func:`hypergeom_pmf` builds the hypergeometric null pmf in log-space
4
+ (via ``scipy.special.gammaln``) with the mode subtracted before
5
+ exponentiating, so the result is finite and correctly normalized for
6
+ large N. :func:`minlike_pvalues` turns a pmf into two-sided
7
+ (minimum-likelihood) p-values, grouping probabilities that agree to
8
+ 1e-9 relative as ties (the hypergeometric pmf has exact rational ties
9
+ that floating point renders as differing by ~1e-17).
10
+ """
11
+
12
+ import numpy as np
13
+ from scipy.special import gammaln
14
+
15
+ __all__ = ["hypergeom_pmf", "minlike_pvalues"]
16
+
17
+
18
+ def hypergeom_pmf(N, K, n):
19
+ """Pmf of X ~ Hypergeometric(N, K, n) over its full support.
20
+
21
+ Returns ``(xs, pmf)`` with ``xs`` the support points (int array) and
22
+ ``pmf`` the probabilities (float array, sums to 1).
23
+ """
24
+ lo = max(0, n + K - N)
25
+ hi = min(n, K)
26
+ xs = np.arange(lo, hi + 1)
27
+ if xs.size == 1:
28
+ return xs, np.ones(1)
29
+ x = xs.astype(np.float64)
30
+ # log P(X = x) = log C(K, x) + log C(N-K, n-x) - log C(N, n).
31
+ # All gammaln arguments are >= 1 on the support, so this is safe.
32
+ logp = (
33
+ gammaln(K + 1) - gammaln(x + 1) - gammaln(K - x + 1)
34
+ + gammaln(N - K + 1) - gammaln(n - x + 1) - gammaln(N - K - n + x + 1)
35
+ - (gammaln(N + 1) - gammaln(n + 1) - gammaln(N - n + 1))
36
+ )
37
+ logp -= logp.max()
38
+ pmf = np.exp(logp)
39
+ total = pmf.sum()
40
+ if not np.isfinite(total) or total <= 0.0: # pragma: no cover
41
+ raise ArithmeticError("hypergeom_pmf failed to normalize")
42
+ pmf /= total
43
+ return xs, pmf
44
+
45
+
46
+ def minlike_pvalues(pmf):
47
+ """Two-sided (minimum-likelihood) p-value for each support point.
48
+
49
+ For support point i, returns the total probability of all support
50
+ points whose probability is <= pmf[i]. Probabilities that agree to
51
+ 1e-9 relative are treated as tied.
52
+ """
53
+ pmf = np.asarray(pmf, dtype=float)
54
+ order = np.argsort(pmf)
55
+ s = pmf[order]
56
+ prev = s[:-1]
57
+ cur = s[1:]
58
+ same = cur <= prev * (1.0 + 1e-9) + 1e-300
59
+ gid = np.concatenate(([0], np.cumsum(~same)))
60
+ gsum = np.bincount(gid, weights=s)
61
+ gc = np.cumsum(gsum)
62
+ p_sorted = gc[gid]
63
+ inv = np.empty_like(order)
64
+ inv[order] = np.arange(len(s))
65
+ return p_sorted[inv]
@@ -0,0 +1,133 @@
1
+ """Exact power of Fisher's exact test for a planned 2x2 study (free core).
2
+
3
+ The power law
4
+ -------------
5
+ Group sizes ``n1`` and ``n2`` are fixed by the study design; the event
6
+ counts are random. The alternative is the **binomial-product law**
7
+
8
+ A ~ Binomial(n1, p1), B ~ Binomial(n2, p2), independent,
9
+
10
+ i.e. the exact law of the data in a two-group comparative study. The
11
+ test itself conditions on the total number of events, as Fisher's test
12
+ always does; the power does not, because the total is not fixed by the
13
+ design.
14
+
15
+ Exact power is then the expected rejection probability under the
16
+ alternative:
17
+
18
+ power = sum_{a=0..n1} sum_{b=0..n2}
19
+ Bin(n1,p1)(a) * Bin(n2,p2)(b) * 1{p_F(a, b) <= alpha},
20
+
21
+ where ``p_F(a, b)`` is the two-sided Fisher p-value of the 2x2 table
22
+ ``[[a, n1-a], [b, n2-b]]``.
23
+
24
+ Computation groups outcome pairs by the total event count m1 = a + b;
25
+ for each m1 the hypergeometric null pmf is built once and the two-sided
26
+ p-values come from a sort/cumulative-sum pass.
27
+ """
28
+
29
+ import numpy as np
30
+ from scipy.stats import binom
31
+
32
+ from ._fisher import hypergeom_pmf, minlike_pvalues
33
+ from ._validate import (
34
+ check_alpha,
35
+ check_alternative,
36
+ check_group_size,
37
+ check_prob,
38
+ )
39
+
40
+ __all__ = ["exact_power_fisher", "FREE_MAX_TOTAL_N"]
41
+
42
+ #: Hard cap on n1 + n2 in the free version. Beyond this the function
43
+ #: raises ValueError immediately instead of attempting the calculation.
44
+ FREE_MAX_TOTAL_N = 1000
45
+
46
+ _PAID_URL = "https://groundplane0.gumroad.com/l/tyevqe"
47
+
48
+
49
+ def _resolve_probs(p1, p2, odds_ratio):
50
+ """Turn (p1, p2) or (odds_ratio, p2) into a validated (p1, p2) pair."""
51
+ if odds_ratio is not None:
52
+ if p1 is not None:
53
+ raise ValueError("specify either p1 or odds_ratio, not both")
54
+ if p2 is None:
55
+ raise ValueError("odds_ratio requires p2 (the reference-group probability)")
56
+ import math
57
+
58
+ if not isinstance(odds_ratio, (int, float)) or isinstance(odds_ratio, bool):
59
+ raise ValueError(f"odds_ratio must be a positive number, got {odds_ratio!r}")
60
+ odds_ratio = float(odds_ratio)
61
+ if math.isnan(odds_ratio) or not (odds_ratio > 0.0) or math.isinf(odds_ratio):
62
+ raise ValueError(f"odds_ratio must be a positive finite number, got {odds_ratio!r}")
63
+ p2 = check_prob(p2, "p2")
64
+ # OR = [p1/(1-p1)] / [p2/(1-p2)] => p1 = OR*p2 / (1-p2+OR*p2)
65
+ p1 = odds_ratio * p2 / (1.0 - p2 + odds_ratio * p2)
66
+ return p1, p2
67
+ if p1 is None or p2 is None:
68
+ raise ValueError("specify p1 and p2, or odds_ratio together with p2")
69
+ return check_prob(p1, "p1"), check_prob(p2, "p2")
70
+
71
+
72
+ def exact_power_fisher(n1, n2, p1=None, p2=None, alpha=0.05,
73
+ alternative="two-sided", *, odds_ratio=None):
74
+ """Exact power of Fisher's exact test for comparing two proportions.
75
+
76
+ Parameters
77
+ ----------
78
+ n1, n2 : int
79
+ Group sizes (number of subjects per group), each >= 1. The
80
+ free version requires ``n1 + n2 <= 1000``.
81
+ p1, p2 : float
82
+ Assumed true event probabilities in group 1 and group 2, each
83
+ in [0, 1]. Alternatively pass ``odds_ratio`` with ``p2`` and
84
+ leave ``p1`` unset.
85
+ alpha : float, default 0.05
86
+ Significance level, strictly between 0 and 1.
87
+ alternative : {"two-sided"}, default "two-sided"
88
+ Only the two-sided test is included in the free version.
89
+ One-sided power is part of the full version.
90
+ odds_ratio : float, optional
91
+ Assumed true odds ratio ``[p1/(1-p1)] / [p2/(1-p2)]``, a
92
+ positive finite number. Requires ``p2``; ``p1`` is derived
93
+ from it.
94
+
95
+ Returns
96
+ -------
97
+ float
98
+ The exact rejection probability under the binomial-product
99
+ alternative described in the module docstring.
100
+
101
+ Raises
102
+ ------
103
+ ValueError
104
+ On any invalid input, when ``n1 + n2`` exceeds 1000, or when a
105
+ one-sided alternative is requested.
106
+ """
107
+ n1 = check_group_size(n1, "n1")
108
+ n2 = check_group_size(n2, "n2")
109
+ if n1 + n2 > FREE_MAX_TOTAL_N:
110
+ raise ValueError(
111
+ f"Free version is capped at n1 + n2 <= {FREE_MAX_TOTAL_N}. "
112
+ f"The full version ($30, one-time) goes to n1 + n2 = 5000: {_PAID_URL}"
113
+ )
114
+ alternative = check_alternative(alternative)
115
+ if alternative != "two-sided":
116
+ raise ValueError(
117
+ f"Free version includes two-sided power only (got {alternative!r}). "
118
+ f"The full version ($30, one-time) adds one-sided power: {_PAID_URL}"
119
+ )
120
+ p1, p2 = _resolve_probs(p1, p2, odds_ratio)
121
+ alpha = check_alpha(alpha)
122
+
123
+ b1 = binom.pmf(np.arange(n1 + 1), n1, p1)
124
+ b2 = binom.pmf(np.arange(n2 + 1), n2, p2)
125
+ N = n1 + n2
126
+ power = 0.0
127
+ for m1 in range(N + 1):
128
+ xs, pmf = hypergeom_pmf(N, m1, n1)
129
+ pvals = minlike_pvalues(pmf)
130
+ # outcome pairs (a, b) with a + b = m1: a ranges over the support
131
+ w = b1[xs] * b2[m1 - xs]
132
+ power += w[pvals <= alpha].sum()
133
+ return float(power)
@@ -0,0 +1,78 @@
1
+ """Input validation helpers for the free exactpower core.
2
+
3
+ Every public function validates loudly and refuses to compute on bad
4
+ input: a ValueError is always better than a silent wrong number.
5
+ """
6
+
7
+ import math
8
+ import numbers
9
+
10
+ import numpy as np
11
+
12
+ __all__ = [
13
+ "check_prob",
14
+ "check_alpha",
15
+ "check_alternative",
16
+ "check_group_size",
17
+ ]
18
+
19
+
20
+ def _is_integral(x):
21
+ if isinstance(x, bool):
22
+ return False
23
+ if isinstance(x, numbers.Integral):
24
+ return True
25
+ if isinstance(x, numbers.Real):
26
+ return float(x).is_integer()
27
+ return False
28
+
29
+
30
+ def check_count(n, name):
31
+ """Validate a sample-size / count argument.
32
+
33
+ Accepts ints, numpy integers, and integral floats (10.0); anything
34
+ else (10.5, -3, "10", NaN) raises ValueError.
35
+ """
36
+ if isinstance(n, np.ndarray):
37
+ raise ValueError(f"{name} must be a single integer, got an array")
38
+ if not _is_integral(n):
39
+ raise ValueError(
40
+ f"{name} must be an integer count, got {n!r}"
41
+ )
42
+ n = int(n) if isinstance(n, numbers.Integral) else int(n)
43
+ return n
44
+
45
+
46
+ def check_group_size(n, name):
47
+ n = check_count(n, name)
48
+ if n < 1:
49
+ raise ValueError(f"{name} must be >= 1, got {n}")
50
+ return n
51
+
52
+
53
+ def check_prob(p, name):
54
+ """Validate a probability in [0, 1]."""
55
+ if isinstance(p, bool) or not isinstance(p, numbers.Real):
56
+ raise ValueError(f"{name} must be a number in [0, 1], got {p!r}")
57
+ p = float(p)
58
+ if math.isnan(p) or not (0.0 <= p <= 1.0):
59
+ raise ValueError(f"{name} must be in [0, 1], got {p!r}")
60
+ return p
61
+
62
+
63
+ def check_alpha(alpha):
64
+ if isinstance(alpha, bool) or not isinstance(alpha, numbers.Real):
65
+ raise ValueError(f"alpha must be a number in (0, 1), got {alpha!r}")
66
+ alpha = float(alpha)
67
+ if math.isnan(alpha) or not (0.0 < alpha < 1.0):
68
+ raise ValueError(f"alpha must be strictly between 0 and 1, got {alpha!r}")
69
+ return alpha
70
+
71
+
72
+ def check_alternative(alternative):
73
+ if alternative not in ("two-sided", "less", "greater"):
74
+ raise ValueError(
75
+ "alternative must be 'two-sided', 'less' or 'greater', "
76
+ f"got {alternative!r}"
77
+ )
78
+ return alternative
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: exactpower
3
+ Version: 0.1.0
4
+ Summary: Exact power of Fisher's exact test for a planned 2x2 study (free core)
5
+ Author: Groundplane Labs
6
+ License: MIT
7
+ Project-URL: Homepage, https://groundplane0.gumroad.com/l/tyevqe
8
+ Keywords: power analysis,sample size,fisher exact test,exact test,statistics,biostatistics
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy
17
+ Requires-Dist: scipy
18
+ Dynamic: license-file
19
+
20
+ # exactpower
21
+
22
+ Free core of ExactPower: exact power of Fisher's exact test for a planned 2x2 study.
23
+
24
+ **The problem**
25
+ You are planning a study with two groups and a yes/no outcome, and you need to pick a sample size before you collect data. The usual power calculators use approximations that drift exactly where small samples matter most. Search terms: exact power Fisher test Python, sample size Fisher exact test, power analysis 2x2 Python.
26
+
27
+ **What it does**
28
+ `exact_power_fisher(n1, n2, p1, p2)` computes the exact rejection probability of the two-sided Fisher exact test under your planned design. Give it the group sizes and the event rates you expect, and it enumerates every possible outcome. No approximation, no simulation. The free version handles n1 + n2 up to 1000.
29
+
30
+ **Limits**
31
+ Free version: two-sided power only, n1 + n2 <= 1000. Larger studies are refused outright instead of hanging. No sample-size inversion, no Monte Carlo power estimator, no plots, no CLI.
32
+
33
+ **Install**
34
+
35
+ ```bash
36
+ pip install exactpower
37
+ ```
38
+
39
+ Needs Python 3.9 or newer, NumPy, and SciPy.
40
+
41
+ ```python
42
+ from exactpower import exact_power_fisher
43
+ print(exact_power_fisher(60, 60, 0.55, 0.30))
44
+ ```
45
+
46
+ This is the free core: two-sided exact power for a planned 2x2 design, capped at n1 + n2 <= 1000. The full version ($30, one-time) goes to n1 + n2 = 5000 and adds sample-size inversion, Monte Carlo power for r-by-c tables, and one-sided power: https://groundplane0.gumroad.com/l/tyevqe
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/exactpower/__init__.py
5
+ src/exactpower/_fisher.py
6
+ src/exactpower/_power.py
7
+ src/exactpower/_validate.py
8
+ src/exactpower.egg-info/PKG-INFO
9
+ src/exactpower.egg-info/SOURCES.txt
10
+ src/exactpower.egg-info/dependency_links.txt
11
+ src/exactpower.egg-info/requires.txt
12
+ src/exactpower.egg-info/top_level.txt
13
+ tests/test_free.py
@@ -0,0 +1,2 @@
1
+ numpy
2
+ scipy
@@ -0,0 +1 @@
1
+ exactpower
@@ -0,0 +1,131 @@
1
+ """Tests for the free exactpower core.
2
+
3
+ Correctness is checked against the paid package (imported from its own
4
+ source tree under an alias, never copied). Cap behavior and input
5
+ validation are checked directly.
6
+ """
7
+
8
+ import importlib.util
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ import exactpower
15
+
16
+ HERE = Path(__file__).resolve().parent
17
+ PAID_SRC = HERE.parent.parent.parent / "exact-power" / "src" / "exactpower"
18
+
19
+
20
+ def _load_paid():
21
+ spec = importlib.util.spec_from_file_location(
22
+ "paid_exactpower",
23
+ PAID_SRC / "__init__.py",
24
+ submodule_search_locations=[str(PAID_SRC)],
25
+ )
26
+ mod = importlib.util.module_from_spec(spec)
27
+ sys.modules["paid_exactpower"] = mod
28
+ spec.loader.exec_module(mod)
29
+ return mod
30
+
31
+
32
+ paid = _load_paid()
33
+
34
+ PAID_URL = "https://groundplane0.gumroad.com/l/tyevqe"
35
+
36
+ CASES = [
37
+ (60, 60, 0.55, 0.30, 0.05),
38
+ (30, 30, 0.8, 0.2, 0.05),
39
+ (100, 50, 0.4, 0.35, 0.01),
40
+ (10, 10, 0.9, 0.1, 0.05),
41
+ (200, 200, 0.52, 0.48, 0.05),
42
+ (45, 60, 0.7, 0.5, 0.10),
43
+ ]
44
+
45
+
46
+ def test_matches_paid_exactly():
47
+ for n1, n2, p1, p2, alpha in CASES:
48
+ free = exactpower.exact_power_fisher(n1, n2, p1, p2, alpha=alpha)
49
+ ref = paid.exact_power_fisher(n1, n2, p1, p2, alpha=alpha)
50
+ assert free == pytest.approx(ref, rel=1e-12), (n1, n2, p1, p2, alpha)
51
+
52
+
53
+ def test_odds_ratio_matches_paid():
54
+ free = exactpower.exact_power_fisher(60, 60, None, 0.30, odds_ratio=2.85)
55
+ ref = paid.exact_power_fisher(60, 60, None, 0.30, odds_ratio=2.85)
56
+ assert free == pytest.approx(ref, rel=1e-12)
57
+
58
+
59
+ def test_over_cap_refuses_loudly():
60
+ with pytest.raises(ValueError, match="n1 \\+ n2 <= 1000"):
61
+ exactpower.exact_power_fisher(600, 600, 0.55, 0.45)
62
+ with pytest.raises(ValueError, match="n1 \\+ n2 <= 1000"):
63
+ exactpower.exact_power_fisher(1, 1000, 0.55, 0.45)
64
+ try:
65
+ exactpower.exact_power_fisher(600, 600, 0.55, 0.45)
66
+ except ValueError as e:
67
+ assert PAID_URL in str(e)
68
+ assert "30" in str(e)
69
+
70
+
71
+ def test_at_cap_still_works():
72
+ p = exactpower.exact_power_fisher(500, 500, 0.55, 0.45)
73
+ assert 0.0 <= p <= 1.0
74
+ assert p == pytest.approx(paid.exact_power_fisher(500, 500, 0.55, 0.45), rel=1e-12)
75
+
76
+
77
+ def test_one_sided_refused_with_paid_pointer():
78
+ with pytest.raises(ValueError, match="two-sided"):
79
+ exactpower.exact_power_fisher(60, 60, 0.55, 0.30, alternative="greater")
80
+ try:
81
+ exactpower.exact_power_fisher(60, 60, 0.55, 0.30, alternative="less")
82
+ except ValueError as e:
83
+ assert PAID_URL in str(e)
84
+
85
+
86
+ def test_bad_alternative_string_raises():
87
+ with pytest.raises(ValueError):
88
+ exactpower.exact_power_fisher(60, 60, 0.55, 0.30, alternative="bogus")
89
+
90
+
91
+ @pytest.mark.parametrize(
92
+ "args",
93
+ [
94
+ (0, 60, 0.55, 0.30), # n1 < 1
95
+ (60, -5, 0.55, 0.30), # negative n2
96
+ (60.5, 60, 0.55, 0.30), # fractional n1
97
+ (60, 60, 1.5, 0.30), # p1 > 1
98
+ (60, 60, 0.55, -0.1), # p2 < 0
99
+ (60, 60, float("nan"), 0.30),
100
+ (60, 60, 0.55, 0.30, 0.0), # alpha = 0 (positional alpha)
101
+ (60, 60, 0.55, 0.30, 1.0), # alpha = 1
102
+ ("60", 60, 0.55, 0.30), # string n1
103
+ ],
104
+ )
105
+ def test_garbage_raises(args):
106
+ n1, n2, p1, p2 = args[0], args[1], args[2], args[3]
107
+ alpha = args[4] if len(args) > 4 else 0.05
108
+ with pytest.raises(ValueError):
109
+ exactpower.exact_power_fisher(n1, n2, p1, p2, alpha=alpha)
110
+
111
+
112
+ def test_paid_only_symbols_absent():
113
+ for name in (
114
+ "sample_size_fisher",
115
+ "SampleSizeNotAchieved",
116
+ "power_mc",
117
+ "MCPowerResult",
118
+ "fisher_exact_2x2",
119
+ "fisher_exact_rc",
120
+ "count_tables",
121
+ ):
122
+ assert not hasattr(exactpower, name), name
123
+ assert set(exactpower.__all__) == {
124
+ "exact_power_fisher",
125
+ "FREE_MAX_TOTAL_N",
126
+ "__version__",
127
+ }
128
+
129
+
130
+ def test_free_cap_constant():
131
+ assert exactpower.FREE_MAX_TOTAL_N == 1000