stat_power 0.1.0.alpha.1 → 0.1.0.alpha.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66d3d0229cc19ab03fc96007a171298ae29f1f6964d62d51d945799fa3a7a465
4
- data.tar.gz: 1567693499022a1831ebaf0c216819458e9ddfd6389843d7581cd2c4d9534c65
3
+ metadata.gz: 2c69ef6117ff1e0bf73f795f855383722810e4e0153d33fe65e5baf803c6554b
4
+ data.tar.gz: 68e88eb27fff0e23651c8ae4f9a954eb14718703287b9aaf596b251dd0d742af
5
5
  SHA512:
6
- metadata.gz: 17de540e8e06c53b81e1295c0e9176a09cbf1f25458900524d7e318a627da780bc82becc0d9963cbc9ac4ce3d334ecff2686630993d87149792e01f65afccd40
7
- data.tar.gz: 278d9c38fbcf57c6d7bf522ab8126da06e1720f79b8393198620bc33049bc68123e6297f743e90b98fca31822173f8e65ae4f74e87866101921f21d453a79334
6
+ metadata.gz: d8882a7378d4e07735877624fdb21ac512b9e54f5bb42a0342374fcd18e922e85b36710fe9e05fcf92a8b2a5abea3711198bc3df678a7d5796735581bfa9b108
7
+ data.tar.gz: b5f799b202dc11cc3a7b0290a2d90ff1061771f43cc021e7626e6c256213ff95a8a597f09f5034ec9b1e7f9b070aef0ef8f940fec7734b52263f589349ac86d8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.1.0.alpha.3 - 2026-09-20
8
+
9
+ ### Added
10
+
11
+ - Contribution guide, code of conduct, and security policy
12
+ - Issue templates, including a dedicated numerical-discrepancy report
13
+ - Pull request template requiring independent numerical validation
14
+ - `CODEOWNERS` and Dependabot configuration for Bundler and GitHub Actions
15
+ - CodeQL analysis workflow
16
+ - `bin/setup` and `bin/console` developer scripts
17
+ - `rake verify` task running every check that CI runs
18
+ - Steep type checking of `lib/` against `sig/`, now enforced in CI
19
+ - SimpleCov line and branch coverage with an enforced minimum
20
+ - `.gitattributes`, `.editorconfig`, and `.ruby-version` for reproducible
21
+ formatting and tooling across platforms
22
+
23
+ ### Changed
24
+
25
+ - CI now runs lint, type check, and coverage as dedicated jobs, declares
26
+ least-privilege permissions, and cancels superseded runs
27
+ - RuboCop runs with `NewCops: enable`
28
+ - Gem metadata now includes a contact address, bug tracker, and versioned
29
+ source URI; maintainer-only files are no longer packaged
30
+
31
+ ### Fixed
32
+
33
+ - **`sig/stat_power.rbs` shipped in 0.1.0.alpha.2 was not valid RBS.** The
34
+ block signature for `PowerCurve.generate` used an unparenthesized union
35
+ return type, so any consumer feeding the packaged signatures to `rbs` or
36
+ Steep hit a parse error at line 282. Runtime behaviour was unaffected;
37
+ 0.1.0.alpha.1 was not affected, because it predates `PowerCurve`. `rbs
38
+ validate` accepts the malformed form, which is why it reached a release;
39
+ `steep check` now runs in CI and in the release gate.
40
+ - `EffectSize::ChiSquare.association` computed column marginals by mutating an
41
+ accumulator array; it now uses `transpose`
42
+
43
+ ## 0.1.0.alpha.2 - 2026-09-20
44
+
45
+ ### Added
46
+
47
+ - Balanced one-way ANOVA power analysis compatible with `pwr.anova.test`
48
+ - Dedicated ANOVA result object with per-group and total sample-size helpers
49
+ - General linear-model power analysis compatible with `pwr.f2.test`
50
+ - Dedicated `F2Result` with implied total sample-size helpers
51
+ - Regularized incomplete gamma functions
52
+ - Central and noncentral chi-square distribution utilities
53
+ - Cohen's `w` effect-size helpers for goodness-of-fit and association tests
54
+ - Chi-square power analysis compatible with `pwr.chisq.test`
55
+ - Dedicated `ChiSquareResult` with total sample-size helper
56
+ - Data-first power-curve generation via `StatPower::PowerCurve.generate`
57
+ - Immutable `PowerCurvePoint` values for plotting-library-independent output
58
+ - Fixture-driven CRAN `pwr` parity validation across all implemented solver families
59
+ - Documented numerical tolerance and reference-provenance policy
60
+
7
61
  ## 0.1.0.alpha.1 - 2026-09-19
8
62
 
9
63
  ### Added
data/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # stat_power
2
2
 
3
+ [![CI](https://github.com/DiogoRibeiro7/stat_power/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DiogoRibeiro7/stat_power/actions/workflows/ci.yml)
4
+ [![RubyGems](https://img.shields.io/gem/v/stat_power?include_prereleases)](https://rubygems.org/gems/stat_power)
5
+ [![Gem Downloads](https://img.shields.io/gem/dt/stat_power)](https://rubygems.org/gems/stat_power)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
3
9
  Native Ruby statistical power analysis and sample-size determination.
4
10
 
5
11
  > **Alpha release:** `stat_power` is under active development. The public API
@@ -17,13 +23,13 @@ gem install stat_power --prerelease
17
23
  or pin the exact alpha:
18
24
 
19
25
  ```bash
20
- gem install stat_power -v 0.1.0.alpha.1
26
+ gem install stat_power -v 0.1.0.alpha.3
21
27
  ```
22
28
 
23
29
  With Bundler:
24
30
 
25
31
  ```ruby
26
- gem "stat_power", "0.1.0.alpha.1"
32
+ gem "stat_power", "0.1.0.alpha.3"
27
33
  ```
28
34
 
29
35
  ## Status
@@ -41,9 +47,16 @@ Implemented compatibility currently includes:
41
47
  - `pwr.t.test`
42
48
  - `pwr.t2n.test`
43
49
  - `pwr.r.test`
50
+ - `pwr.anova.test`
51
+ - `pwr.f2.test`
52
+ - `pwr.chisq.test`
53
+ - `ES.h`
54
+ - `ES.w1`
55
+ - `ES.w2`
56
+ - power-curve data generation equivalent in purpose to `plot.power.htest`
44
57
 
45
- The library also contains the numerical distribution machinery required for
46
- further power methods, including central/noncentral t and F distributions.
58
+ The library also contains the numerical distribution machinery required by
59
+ these methods, including central/noncentral t, F, and chi-square distributions.
47
60
 
48
61
  See [docs/pwr_parity.md](docs/pwr_parity.md) for the full compatibility matrix.
49
62
 
@@ -105,29 +118,39 @@ freeze candidate.
105
118
 
106
119
  ## Development
107
120
 
108
- Install dependencies:
121
+ Set up the checkout:
109
122
 
110
123
  ```bash
111
- bundle install
124
+ bin/setup
112
125
  ```
113
126
 
114
- Run the test suite:
127
+ Open a console with the library loaded:
115
128
 
116
129
  ```bash
117
- bundle exec rspec
130
+ bin/console
118
131
  ```
119
132
 
120
- Run lint and signature validation:
133
+ Run everything CI runs:
121
134
 
122
135
  ```bash
123
- bundle exec rubocop
124
- bundle exec rbs validate
136
+ bundle exec rake verify
125
137
  ```
126
138
 
127
- Validate that the library loads:
139
+ That is the same as running each check individually:
128
140
 
129
141
  ```bash
130
- ruby -Ilib -e 'require "stat_power"'
142
+ bundle exec rspec # specs
143
+ bundle exec rubocop # lint
144
+ bundle exec rbs validate # signature syntax
145
+ bundle exec steep check # lib/ type checked against sig/
146
+ ```
147
+
148
+ Measure test coverage. Instrumentation roughly doubles the runtime, so it is
149
+ opt-in; CI enforces a minimum in a dedicated job.
150
+
151
+ ```bash
152
+ COVERAGE=1 bundle exec rspec
153
+ open coverage/index.html
131
154
  ```
132
155
 
133
156
  Build the gem locally:
@@ -136,10 +159,36 @@ Build the gem locally:
136
159
  gem build stat_power.gemspec
137
160
  ```
138
161
 
162
+ ## Contributing
163
+
164
+ Contributions are welcome. Because this is a numerical library, the bar is
165
+ reproducibility: every statistical result must be justified by a published
166
+ formula or an independently generated reference value.
167
+
168
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow, the shape a new
169
+ power-analysis family is expected to take, and how to supply reference values.
170
+
171
+ Participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
172
+
173
+ ### Found a wrong number?
174
+
175
+ If `stat_power` disagrees with `pwr`, R, G*Power or a textbook, that is the
176
+ highest-priority class of bug here. Open an issue using the **Numerical
177
+ discrepancy** template.
178
+
179
+ ## Security
180
+
181
+ See [SECURITY.md](SECURITY.md) for supported versions and how to report a
182
+ vulnerability privately. A numerically incorrect result is a bug, not a
183
+ vulnerability; report those as ordinary issues.
184
+
139
185
  ## Mathematical conventions
140
186
 
141
187
  See [docs/mathematical_conventions.md](docs/mathematical_conventions.md).
142
188
 
189
+ For numerical reference fixtures and tolerance rules, see
190
+ [docs/validation.md](docs/validation.md).
191
+
143
192
  ## Roadmap
144
193
 
145
194
  See [ROADMAP.md](ROADMAP.md).
data/ROADMAP.md CHANGED
@@ -11,7 +11,7 @@
11
11
  - [x] Normal mean power analysis equivalent to `pwr.norm.test`
12
12
  - [x] Student t distribution utilities
13
13
  - [x] Noncentral t distribution utilities
14
- - [ ] Chi-square and noncentral chi-square utilities
14
+ - [x] Chi-square and noncentral chi-square utilities
15
15
  - [x] F and noncentral F utilities
16
16
 
17
17
  ### pwr compatibility
@@ -22,24 +22,24 @@
22
22
  - [x] `pwr.2p2n.test`
23
23
  - [x] `pwr.t.test`
24
24
  - [x] `pwr.t2n.test`
25
- - [ ] `pwr.anova.test`
25
+ - [x] `pwr.anova.test`
26
26
  - [x] `pwr.r.test`
27
- - [ ] `pwr.chisq.test`
28
- - [ ] `pwr.f2.test`
27
+ - [x] `pwr.chisq.test`
28
+ - [x] `pwr.f2.test`
29
29
  - [x] `ES.h`
30
- - [ ] `ES.w1`
31
- - [ ] `ES.w2`
32
- - [ ] power-curve data equivalent to `plot.power.htest`
30
+ - [x] `ES.w1`
31
+ - [x] `ES.w2`
32
+ - [x] power-curve data equivalent to `plot.power.htest`
33
33
 
34
34
  Each migrated family must support the inverse problems exposed by the reference
35
35
  method and include numerical parity tests.
36
36
 
37
37
  ## 0.2.x — Validation and usability
38
38
 
39
- - Automated parity fixtures generated from R `pwr`
39
+ - [x] Automated parity fixtures derived from CRAN `pwr` references
40
40
  - Published Cohen examples
41
41
  - Cross-validation against statsmodels where applicable
42
- - Documented numerical tolerance policy
42
+ - [x] Documented numerical tolerance policy
43
43
  - Stable result objects
44
44
  - User-facing method documentation
45
45
  - Benchmarks
data/docs/pwr_parity.md CHANGED
@@ -18,14 +18,14 @@ reference points.
18
18
  | `pwr.2p2n.test` | `StatPower::Proportion.two_sample_unequal` | Implemented |
19
19
  | `pwr.t.test` | `StatPower::TTest.one_sample`, `.paired`, `.two_sample` | Implemented |
20
20
  | `pwr.t2n.test` | `StatPower::TTest.two_sample_unequal` | Implemented |
21
- | `pwr.anova.test` | balanced one-way ANOVA power | Planned |
21
+ | `pwr.anova.test` | `StatPower::Anova.solve` | Implemented |
22
22
  | `pwr.r.test` | `StatPower::Correlation.solve` | Implemented |
23
- | `pwr.chisq.test` | chi-square power | Planned |
24
- | `pwr.f2.test` | general linear-model power | Planned |
23
+ | `pwr.chisq.test` | `StatPower::ChiSquare.solve` | Implemented |
24
+ | `pwr.f2.test` | `StatPower::F2.solve` | Implemented |
25
25
  | `ES.h` | `StatPower::EffectSize::Proportion.cohen_h` | Implemented |
26
- | `ES.w1` | Cohen w for goodness of fit | Planned |
27
- | `ES.w2` | Cohen w for contingency tables | Planned |
28
- | `plot.power.htest` | power-curve data generation | Planned |
26
+ | `ES.w1` | `StatPower::EffectSize::ChiSquare.goodness_of_fit` | Implemented |
27
+ | `ES.w2` | `StatPower::EffectSize::ChiSquare.association` | Implemented |
28
+ | `plot.power.htest` | `StatPower::PowerCurve.generate` | Implemented as data generation |
29
29
 
30
30
  ## Solver convention
31
31
 
@@ -60,3 +60,6 @@ Each migrated family should include:
60
60
  Parity means agreement in the statistical model and numerical result within a
61
61
  documented tolerance. Ruby naming and object design remain idiomatic rather
62
62
  than cloning R syntax.
63
+
64
+ See [validation.md](validation.md) for the fixture suite, reference provenance,
65
+ and numerical tolerance policy.
@@ -0,0 +1,60 @@
1
+ # Numerical validation
2
+
3
+ `stat_power` treats numerical validation as part of the public contract.
4
+
5
+ ## CRAN pwr reference fixtures
6
+
7
+ The first compatibility target is CRAN `pwr` 1.3-0. Reference cases live in
8
+ `spec/fixtures/pwr_parity.yml` and are exercised by
9
+ `spec/parity/pwr_parity_spec.rb`.
10
+
11
+ The fixture set deliberately uses public examples from the `pwr` reference
12
+ manual and Cohen (1988) where available. It covers every power-analysis family
13
+ implemented by `stat_power`, including both direct power calculations and
14
+ representative inverse problems.
15
+
16
+ The fixture file records:
17
+
18
+ - the corresponding `pwr` function,
19
+ - the source/example label,
20
+ - the Ruby public API call,
21
+ - the input arguments,
22
+ - the output field being checked,
23
+ - the expected numeric value,
24
+ - the accepted absolute tolerance.
25
+
26
+ Detailed unit tests remain alongside each implementation. The fixture suite is
27
+ an additional end-to-end compatibility layer, not a replacement for
28
+ distribution-level, solver-level, domain, or round-trip tests.
29
+
30
+ ## Tolerance policy
31
+
32
+ Numerical tolerances are explicit per fixture.
33
+
34
+ As a default policy:
35
+
36
+ - direct probabilities and distribution values use absolute tolerances around
37
+ `1e-8` or tighter when the reference is stable,
38
+ - inverse root solutions generally use `1e-5` because independent numerical
39
+ solvers can stop at slightly different points while agreeing statistically,
40
+ - looser tolerances are used only when the published reference itself is
41
+ reported with fewer digits.
42
+
43
+ A tolerance should not be widened merely to make a failing test pass. A change
44
+ must first be traced to one of:
45
+
46
+ 1. a documented difference in the statistical model,
47
+ 2. a known precision limit of the numerical method,
48
+ 3. a reference value with limited published precision,
49
+ 4. an implementation defect.
50
+
51
+ ## Reproducibility
52
+
53
+ The fixture values are versioned with the code so changes are reviewable in
54
+ pull requests. Adding a new statistical family requires adding at least one
55
+ forward reference case and one inverse reference case when the reference API
56
+ supports inverse solving.
57
+
58
+ The project does not execute R during normal gem CI. This keeps the Ruby test
59
+ suite self-contained and deterministic while preserving explicit provenance
60
+ for the reference numbers.
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Power analysis for balanced one-way analysis of variance.
5
+ #
6
+ # The statistical parameterisation follows CRAN pwr.anova.test. Exactly one
7
+ # of groups, sample_size, effect_size, alpha, and power must be nil.
8
+ module Anova
9
+ GROUPS_LOWER = 2.0 + 1e-10
10
+ GROUPS_UPPER = 100.0
11
+ SAMPLE_SIZE_LOWER = 2.0 + 1e-10
12
+ SAMPLE_SIZE_MAX = 1e9
13
+ EFFECT_SIZE_LOWER = 1e-7
14
+ EFFECT_SIZE_MAX = 1e4
15
+ PROBABILITY_EPSILON = 1e-10
16
+
17
+ module_function
18
+
19
+ # Solve one missing parameter of a balanced one-way ANOVA power analysis.
20
+ #
21
+ # @param groups [Numeric, nil] number of groups
22
+ # @param sample_size [Numeric, nil] observations per group
23
+ # @param effect_size [Numeric, Symbol, String, nil] Cohen's f
24
+ # @param alpha [Numeric, nil] Type I error probability
25
+ # @param power [Numeric, nil] statistical power
26
+ # @return [StatPower::AnovaResult]
27
+ def solve(
28
+ groups: nil,
29
+ sample_size: nil,
30
+ effect_size: nil,
31
+ alpha: 0.05,
32
+ power: nil
33
+ )
34
+ ensure_one_missing!(groups, sample_size, effect_size, alpha, power)
35
+
36
+ groups = optional_float(groups)
37
+ sample_size = optional_float(sample_size)
38
+ effect_size = normalize_effect_size(effect_size)
39
+ alpha = optional_float(alpha)
40
+ power = optional_float(power)
41
+
42
+ validate_known_values!(
43
+ groups:,
44
+ sample_size:,
45
+ effect_size:,
46
+ alpha:,
47
+ power:
48
+ )
49
+
50
+ groups, sample_size, effect_size, alpha, power = solve_missing(
51
+ groups:,
52
+ sample_size:,
53
+ effect_size:,
54
+ alpha:,
55
+ power:
56
+ )
57
+
58
+ AnovaResult.new(
59
+ groups:,
60
+ sample_size:,
61
+ power:,
62
+ effect_size:,
63
+ alpha:,
64
+ analysis_method: "balanced one-way analysis of variance power calculation"
65
+ )
66
+ end
67
+
68
+ def solve_missing(groups:, sample_size:, effect_size:, alpha:, power:)
69
+ if power.nil?
70
+ power = power_for(
71
+ groups:,
72
+ sample_size:,
73
+ effect_size:,
74
+ alpha:
75
+ )
76
+ elsif groups.nil?
77
+ groups = solve_groups(
78
+ sample_size:,
79
+ effect_size:,
80
+ alpha:,
81
+ power:
82
+ )
83
+ elsif sample_size.nil?
84
+ sample_size = solve_sample_size(
85
+ groups:,
86
+ effect_size:,
87
+ alpha:,
88
+ power:
89
+ )
90
+ elsif effect_size.nil?
91
+ effect_size = solve_effect_size(
92
+ groups:,
93
+ sample_size:,
94
+ alpha:,
95
+ power:
96
+ )
97
+ elsif alpha.nil?
98
+ alpha = solve_alpha(
99
+ groups:,
100
+ sample_size:,
101
+ effect_size:,
102
+ power:
103
+ )
104
+ end
105
+
106
+ [groups, sample_size, effect_size, alpha, power]
107
+ end
108
+ private_class_method :solve_missing
109
+
110
+ def power_for(groups:, sample_size:, effect_size:, alpha:)
111
+ numerator_df = groups - 1.0
112
+ denominator_df = (sample_size - 1.0) * groups
113
+ noncentrality = groups * sample_size * effect_size * effect_size
114
+
115
+ critical = Distributions::FDistribution.quantile(
116
+ 1.0 - alpha,
117
+ numerator_df:,
118
+ denominator_df:
119
+ )
120
+
121
+ Distributions::NoncentralF.survival(
122
+ critical,
123
+ numerator_df:,
124
+ denominator_df:,
125
+ noncentrality:
126
+ )
127
+ end
128
+ private_class_method :power_for
129
+
130
+ def solve_groups(sample_size:, effect_size:, alpha:, power:)
131
+ Solvers::Bisection.solve(
132
+ lower: GROUPS_LOWER,
133
+ upper: GROUPS_UPPER
134
+ ) do |candidate|
135
+ power_for(
136
+ groups: candidate,
137
+ sample_size:,
138
+ effect_size:,
139
+ alpha:
140
+ ) - power
141
+ end
142
+ end
143
+ private_class_method :solve_groups
144
+
145
+ def solve_sample_size(groups:, effect_size:, alpha:, power:)
146
+ upper = bracket_sample_size(
147
+ groups:,
148
+ effect_size:,
149
+ alpha:,
150
+ power:
151
+ )
152
+
153
+ Solvers::Bisection.solve(
154
+ lower: SAMPLE_SIZE_LOWER,
155
+ upper:,
156
+ absolute_tolerance: 1e-7,
157
+ relative_tolerance: 1e-9
158
+ ) do |candidate|
159
+ power_for(
160
+ groups:,
161
+ sample_size: candidate,
162
+ effect_size:,
163
+ alpha:
164
+ ) - power
165
+ end
166
+ end
167
+ private_class_method :solve_sample_size
168
+
169
+ def bracket_sample_size(groups:, effect_size:, alpha:, power:)
170
+ upper = 4.0
171
+
172
+ while upper < SAMPLE_SIZE_MAX
173
+ achieved = power_for(
174
+ groups:,
175
+ sample_size: upper,
176
+ effect_size:,
177
+ alpha:
178
+ )
179
+ return upper if achieved >= power
180
+
181
+ upper *= 2.0
182
+ end
183
+
184
+ raise StatPower::DomainError,
185
+ "target power cannot be bracketed within the supported sample-size range"
186
+ end
187
+ private_class_method :bracket_sample_size
188
+
189
+ def solve_effect_size(groups:, sample_size:, alpha:, power:)
190
+ upper = bracket_effect_size(
191
+ groups:,
192
+ sample_size:,
193
+ alpha:,
194
+ power:
195
+ )
196
+
197
+ Solvers::Bisection.solve(
198
+ lower: EFFECT_SIZE_LOWER,
199
+ upper:
200
+ ) do |candidate|
201
+ power_for(
202
+ groups:,
203
+ sample_size:,
204
+ effect_size: candidate,
205
+ alpha:
206
+ ) - power
207
+ end
208
+ end
209
+ private_class_method :solve_effect_size
210
+
211
+ def bracket_effect_size(groups:, sample_size:, alpha:, power:)
212
+ upper = 0.5
213
+
214
+ while upper < EFFECT_SIZE_MAX
215
+ achieved = power_for(
216
+ groups:,
217
+ sample_size:,
218
+ effect_size: upper,
219
+ alpha:
220
+ )
221
+ return upper if achieved >= power
222
+
223
+ upper *= 2.0
224
+ end
225
+
226
+ raise StatPower::DomainError,
227
+ "target power cannot be bracketed within the supported effect-size range"
228
+ end
229
+ private_class_method :bracket_effect_size
230
+
231
+ def solve_alpha(groups:, sample_size:, effect_size:, power:)
232
+ Solvers::Bisection.solve(
233
+ lower: PROBABILITY_EPSILON,
234
+ upper: 1.0 - PROBABILITY_EPSILON
235
+ ) do |candidate|
236
+ power_for(
237
+ groups:,
238
+ sample_size:,
239
+ effect_size:,
240
+ alpha: candidate
241
+ ) - power
242
+ end
243
+ end
244
+ private_class_method :solve_alpha
245
+
246
+ def ensure_one_missing!(*values)
247
+ return if values.count(&:nil?) == 1
248
+
249
+ raise StatPower::DomainError,
250
+ "exactly one of groups, sample_size, effect_size, alpha, and power must be nil"
251
+ end
252
+ private_class_method :ensure_one_missing!
253
+
254
+ def normalize_effect_size(value)
255
+ return nil if value.nil?
256
+ return EffectSize::Conventional.resolve(test: :anov, size: value) if value.is_a?(String) || value.is_a?(Symbol)
257
+
258
+ Float(value)
259
+ rescue ArgumentError, TypeError
260
+ raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
261
+ end
262
+ private_class_method :normalize_effect_size
263
+
264
+ def optional_float(value)
265
+ value.nil? ? nil : Float(value)
266
+ rescue ArgumentError, TypeError
267
+ raise StatPower::DomainError, "numeric parameters must be coercible to Float"
268
+ end
269
+ private_class_method :optional_float
270
+
271
+ def validate_known_values!(groups:, sample_size:, effect_size:, alpha:, power:)
272
+ validate_groups!(groups) if groups
273
+ validate_sample_size!(sample_size) if sample_size
274
+ validate_effect_size!(effect_size) if effect_size
275
+ validate_probability!("alpha", alpha) if alpha
276
+ validate_probability!("power", power) if power
277
+ end
278
+ private_class_method :validate_known_values!
279
+
280
+ def validate_groups!(groups)
281
+ return if groups.finite? && groups >= 2.0
282
+
283
+ raise StatPower::DomainError, "groups must be finite and at least 2"
284
+ end
285
+ private_class_method :validate_groups!
286
+
287
+ def validate_sample_size!(sample_size)
288
+ return if sample_size.finite? && sample_size >= 2.0
289
+
290
+ raise StatPower::DomainError, "sample_size must be finite and at least 2"
291
+ end
292
+ private_class_method :validate_sample_size!
293
+
294
+ def validate_effect_size!(effect_size)
295
+ return if effect_size.finite? && !effect_size.negative?
296
+
297
+ raise StatPower::DomainError, "effect_size must be finite and non-negative"
298
+ end
299
+ private_class_method :validate_effect_size!
300
+
301
+ def validate_probability!(name, value)
302
+ return if value.finite? && value.positive? && value < 1.0
303
+
304
+ raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
305
+ end
306
+ private_class_method :validate_probability!
307
+ end
308
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable result for balanced one-way ANOVA power analyses.
5
+ AnovaResult = Data.define(
6
+ :groups,
7
+ :sample_size,
8
+ :power,
9
+ :effect_size,
10
+ :alpha,
11
+ :analysis_method
12
+ ) do
13
+ # Smallest whole-number group count not below the continuous solution.
14
+ #
15
+ # @return [Integer]
16
+ def required_groups
17
+ groups.ceil
18
+ end
19
+
20
+ # Smallest whole-number per-group sample size not below the solution.
21
+ #
22
+ # @return [Integer]
23
+ def required_sample_size
24
+ sample_size.ceil
25
+ end
26
+
27
+ # Continuous total sample size.
28
+ #
29
+ # @return [Float]
30
+ def total_sample_size
31
+ groups * sample_size
32
+ end
33
+
34
+ # Practical total after independently rounding groups and per-group n up.
35
+ #
36
+ # @return [Integer]
37
+ def required_total_sample_size
38
+ required_groups * required_sample_size
39
+ end
40
+ end
41
+ end