Commit f488ce4
test: dedicated chi-square correctness review + Yates-clamp fix (chi_square_test, chi_square_goodness_of_fit) (#47)
## What & why
PR #41 merged `chi_square_test` and `chi_square_goodness_of_fit`
(`src/stats/inferential.jl`), plus the completed `frequency_table`
(`src/stats/descriptive.jl`), with only smoke-test coverage. These
produce numbers end users see verbatim, so this is the dedicated
correctness review the wave-1 audit called for
(`.claude/tasks/prod-readiness/w2-5-reference-validation.md` lineage /
split of PR #41's follow-up).
### Bugs found and fixed in `src/stats/inferential.jl`
1. **`1 - cdf(...)` underflow** — both functions computed the upper-tail
p-value as `1 - cdf(...)`, which underflows to exactly `0.0` for large
χ² statistics (verified interactively: `cdf` saturates to `1.0` in
`Float64` before the true tail probability reaches zero). Switched to
`ccdf(...)`, accurate into the 1e-22 range and beyond.
2. **`chi_square_test`** had no guard for `r<2` or `c<2`: `Chisq(0)`
throws an uncaught `DomainError`, and Cramér's V divides by zero via
`min(r,c)-1`. Now a clean `ArgumentError`.
3. **`chi_square_test`** silently treated a fully zero row/column as if
it didn't exist (via an `expected > 0` skip) while still charging its
degree of freedom to `df` — an undefined comparison presented as a
normal result. R's own `chisq.test` hits the same input and leaks
`NaN`/`NA`; this now returns `nothing` + a `"note"` instead of
fabricating a number.
4. **`chi_square_goodness_of_fit`** had no guard for `k<2` categories
(same `Chisq(0)` crash), no length/sum-to-1 validation on a
caller-supplied `expected_proportions`, and no guard for a
caller-supplied zero proportion (`(O-0)^2/0` is `Inf`/`NaN`).
5. Neither function warned when expected cell counts fall below 5
(Cochran's rule of thumb) — both now set a non-fatal `"warning"` key.
6. Added an optional, off-by-default Yates' continuity correction for
2×2 tables (`yates_correction=true`).
7. **[This push — reviewer must_fix]** The Yates implementation from
item 6 computed `(abs(O-E) - 0.5)^2 / E` with **no `max(0.0, ...)`
clamp**. Whenever every cell's `|O-E| < 0.5` (near-independence 2×2
tables), this squares a *negative* number into a *positive* correction,
**inflating** χ² instead of correcting it downward — the opposite of
what Yates' correction is for. Verified: `observed=[[10,10],[10,11]]`
(common `|O-E|=0.2439<0.5`) gave buggy χ²≈0.0256 vs R/SciPy ground truth
`0.0`. Fixed to `max(0.0, abs(O-E) - 0.5)^2 / E` (equivalently subtract
`min(0.5, |O-E|)` before squaring), matching R's
`chisq.test(correct=TRUE)` and SciPy's
`chi2_contingency(correction=True)` exactly.
`frequency_table` (`src/stats/descriptive.jl`) was reviewed and found
mathematically correct as-is — no production change needed there.
### Ground truth sources
Derived locally (2026-07-11, WSL Debian) with:
- **R 4.5.0**'s `chisq.test` (base `stats` package)
- **SciPy 1.15.3**'s `scipy.stats.chi2_contingency` / `chisquare`
Both agree to ≥12 significant figures on every case in the new test
file; only the constants (not the derivation scripts) are committed,
each with a source comment. New: the Yates-clamp regression case
(`[[10,10],[10,11]]`) was independently re-verified against both R 4.5.0
and SciPy 1.15.3 in this session (`chisq.test(obs, correct=TRUE)` →
`X-squared = 0, df = 1, p-value = 1`; `chi2_contingency(obs,
correction=True)` → `chi2=0.0, p=1.0, dof=1`).
## Test coverage (`test/chi_square_validation_test.jl`, wired into
`test/runtests.jl`)
- RxC independence example (3×3) vs R/SciPy.
- 2×2 independence, uncorrected, vs R/SciPy.
- 2×2 independence, Yates-corrected (`[[8,12],[15,5]]`, `|O-E|=3.5`), vs
R.
- **New**: 2×2 independence, Yates-corrected, near-independence
(`[[10,10],[10,11]]`, `|O-E|=0.2439<0.5` in every cell) — this is the
case that catches the missing clamp; the existing `[[8,12],[15,5]]` case
does not, since both the correct and buggy formulas agree once `|O-E| >
0.5`.
- `yates_correction` rejected on non-2×2 tables.
- Goodness-of-fit, non-uniform proportions, vs R/SciPy.
- Every degenerate guard (`ArgumentError` sites and `nothing`+`"note"`
sites), the low-expected-count warning, JSON-serialisable finiteness on
normal cases, and executor-dispatch parity.
## Verification run
Full suite, flock-serialized WSL Julia run (`julia --project=. -e 'using
Pkg; Pkg.test()'`):
```
Statistikles Full Test Suite 424/424
Statistikles E2E Pipeline Tests 155/155
Statistikles Property-Based Tests 3800/3800
Reference Validation (ground truth) 62/62
Degenerate Input Guards 943/943
Neural-Boundary Guardrail 80/80
Executor Router Coverage 131/131
Chi-Square Dedicated Validation 213/213 (was 208 before this push's regression test)
-----------------------------------------
Total: 5808 passing, 0 failing
```
## Skipped / out of scope
None. `frequency_table` review found no bug (documented above, not
re-litigated here).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>1 parent 49b8300 commit f488ce4
4 files changed
Lines changed: 467 additions & 19 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
144 | | - | |
| 144 | + | |
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
148 | | - | |
149 | | - | |
150 | | - | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
151 | 169 | | |
152 | | - | |
| 170 | + | |
| 171 | + | |
153 | 172 | | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
154 | 180 | | |
155 | | - | |
156 | | - | |
157 | | - | |
158 | | - | |
159 | | - | |
160 | | - | |
161 | | - | |
162 | | - | |
163 | | - | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
164 | 215 | | |
165 | 216 | | |
166 | | - | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
167 | 224 | | |
168 | 225 | | |
169 | 226 | | |
| |||
172 | 229 | | |
173 | 230 | | |
174 | 231 | | |
175 | | - | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
176 | 237 | | |
177 | 238 | | |
178 | 239 | | |
| |||
182 | 243 | | |
183 | 244 | | |
184 | 245 | | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
185 | 254 | | |
186 | 255 | | |
187 | 256 | | |
188 | 257 | | |
189 | 258 | | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
190 | 272 | | |
191 | | - | |
| 273 | + | |
| 274 | + | |
192 | 275 | | |
193 | 276 | | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
194 | 291 | | |
195 | 292 | | |
196 | | - | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
197 | 300 | | |
198 | 301 | | |
199 | 302 | | |
| |||
202 | 305 | | |
203 | 306 | | |
204 | 307 | | |
205 | | - | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
206 | 311 | | |
207 | 312 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
90 | 100 | | |
91 | 101 | | |
92 | 102 | | |
| |||
0 commit comments