Skip to content

Commit b08605f

Browse files
W1-2: guard partial_correlation against NaN leak on zero-variance input (#40)
## Summary Fixes the two `must_fix` items from reviewer feedback on W1-2 (`.claude/tasks/prod-readiness/w1-2-stats-degenerate-inputs.md`): `partial_correlation` still leaked `NaN` on a degenerate `n>=4` input, and the degenerate-input test suite never exercised that path. - **`src/stats/correlation_regression.jl:274` (`partial_correlation`)** — the function guarded `n<4` and the final `denom==0` case, but computed `r_xy = cor(x,y)`, `r_xz = cor(x,z)`, `r_yz = cor(y,z)` directly via `Statistics.cor()`, which returns `NaN` (not an error) on a constant/zero-variance vector, and returned that `NaN` straight through in the `r_xy`/`r_xz`/`r_yz` fields — violating acceptance criterion #1 ("No core stat function returns NaN/Inf on degenerate input"). Replaced the three `cor()` calls with a guarded pairwise-correlation helper using the same `den > 0` pattern `pearson_correlation` already uses, so a constant `x`, `y`, or `z` now yields `r_xy`/`r_xz`/`r_yz` => `nothing` instead of `NaN`, with `r_partial`/`t_stat`/`p_value` cascading to `nothing` and a `"note"` explaining why. - **`test/degenerate_input_test.jl`** — the only existing `partial_correlation` degenerate test used `n=3` (the `n<4` early return), so the constant-vector/zero-variance case at `n>=4` was never tested. Added a new test reproducing the reviewer's exact repro (`partial_correlation(fill(2.0,5), [1,2,3,4,5], [2,1,4,3,5])`) that asserts `r_xy`, `r_xz`, and `r_partial` are `nothing` and runs the `assert_finite_and_serialisable` walk over the full returned `Dict`. Branch was rebuilt on the latest `main` (which had moved one commit ahead with the unrelated P0 guardrail work, #37) via a merge + cherry-pick — no force-push, no history rewrite of the already-pushed commit. ## Verification ``` flock /tmp/statistikles-julia.lock -c 'cd <repo> && julia --project=. -e "using Pkg; Pkg.test()"' ``` (WSL Debian login shell.) Full suite green, 0 failures: | Testset | Pass/Total | |---|---| | Statistikles Full Test Suite | 424/424 | | Statistikles E2E Pipeline Tests | 155/155 | | Statistikles Property-Based Tests | 3800/3800 | | Reference Validation (ground truth) | 35/35 | | Degenerate Input Guards | 943/943 | | Neural-Boundary Guardrail | 80/80 | Total: 5437/5437 passed. ## Nothing skipped Both `must_fix` items from the review are addressed; no other changes were needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 49841b4 commit b08605f

19 files changed

Lines changed: 771 additions & 92 deletions

src/Statistikles.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ using Random
3232
using UUIDs
3333

3434
# --- SYMBOLIC KERNEL: Verified Statistical Methods ---
35+
include("stats/validation.jl") # Shared ArgumentError input-validation helpers
3536
include("stats/descriptive.jl") # Mean, Median, Variance, Skewness
3637
include("stats/inferential.jl") # T-Tests, ANOVA, Chi-Square
3738
include("stats/correlation_regression.jl")

src/bridge/betlang_bridge.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ struct ImpreciseProbability
127127
lower::Float64
128128
upper::Float64
129129
function ImpreciseProbability(lo, hi)
130-
@assert 0.0 <= lo <= hi <= 1.0
130+
(0.0 <= lo <= hi <= 1.0) || throw(ArgumentError(
131+
"ImpreciseProbability bounds must satisfy 0 <= lower <= upper <= 1, got lower=$lo, upper=$hi"))
131132
new(lo, hi)
132133
end
133134
end

src/bridge/typell_levels.jl

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ end
5656
struct Probability
5757
value::Float64
5858
function Probability(v::Float64)
59-
@assert 0.0 <= v <= 1.0 "Probability must be in [0, 1], got $v"
59+
require_probability(v, "Probability")
6060
new(v)
6161
end
6262
end
@@ -126,8 +126,12 @@ struct ModularInt
126126
modulus::Int
127127
ModularInt(v::Int, m::Int) = new(mod(v, m), m)
128128
end
129-
Base.:+(a::ModularInt, b::ModularInt) = (@assert a.modulus == b.modulus; ModularInt(a.value + b.value, a.modulus))
130-
Base.:*(a::ModularInt, b::ModularInt) = (@assert a.modulus == b.modulus; ModularInt(a.value * b.value, a.modulus))
129+
Base.:+(a::ModularInt, b::ModularInt) = a.modulus == b.modulus ?
130+
ModularInt(a.value + b.value, a.modulus) :
131+
throw(ArgumentError("ModularInt addition requires equal moduli, got $(a.modulus) and $(b.modulus)"))
132+
Base.:*(a::ModularInt, b::ModularInt) = a.modulus == b.modulus ?
133+
ModularInt(a.value * b.value, a.modulus) :
134+
throw(ArgumentError("ModularInt multiplication requires equal moduli, got $(a.modulus) and $(b.modulus)"))
131135

132136
# ===========================================================================
133137
# Level 9-10: Verification provenance types

src/integrations/smtlib_integration.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ end
7979
Verify that p-value correction is monotone: adjusted ≥ raw for all i.
8080
"""
8181
function smt_verify_correction_monotone(raw_p::Vector{Float64}, adj_p::Vector{Float64})
82-
@assert length(raw_p) == length(adj_p)
82+
require_equal_length(raw_p, adj_p, "raw_p", "adj_p")
8383
violations = findall(adj_p .< raw_p .- 1e-15)
8484
return Dict{String,Any}(
8585
"monotone" => isempty(violations),

src/stats/assumptions.jl

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,42 @@
33

44
function test_normality(data::Vector{Float64})
55
n = length(data)
6+
7+
# DEGENERATE GUARD: the Jarque-Bera statistic needs both skewness
8+
# (n >= 3) and kurtosis (n >= 4); below that the moment formulas
9+
# divide by zero.
10+
if n < 4
11+
return Dict{String,Any}(
12+
"skewness" => nothing, "kurtosis" => nothing,
13+
"jarque_bera" => nothing, "JB_p_value" => nothing,
14+
"KS_statistic" => nothing, "n" => n,
15+
"normal_skew" => nothing, "normal_kurtosis" => nothing,
16+
"overall_assessment" => nothing,
17+
"recommendations" => "Insufficient data (need at least 4 observations) to assess normality",
18+
"note" => "Skewness/kurtosis-based normality test requires at least 4 observations"
19+
)
20+
end
21+
622
sorted = sort(data)
723
m = mean(data)
824
s = std(data)
25+
26+
# DEGENERATE GUARD: zero variance makes z-scores 0/0 and the Jarque-Bera
27+
# inputs undefined.
28+
if s == 0.0
29+
return Dict{String,Any}(
30+
"skewness" => nothing, "kurtosis" => nothing,
31+
"jarque_bera" => nothing, "JB_p_value" => nothing,
32+
"KS_statistic" => 0.0, "n" => n,
33+
"normal_skew" => nothing, "normal_kurtosis" => nothing,
34+
"overall_assessment" => "Data is constant; normality is not meaningfully defined",
35+
"recommendations" => n < 30 ?
36+
"Small sample — consider non-parametric alternatives" :
37+
"Large sample — parametric tests may be robust to mild violations",
38+
"note" => "Skewness/kurtosis undefined: zero variance in data"
39+
)
40+
end
41+
942
z_scores = (data .- m) ./ s
1043
skew = (n / ((n - 1) * (n - 2))) * sum(z_scores .^ 3)
1144
kurt = ((n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3))) * sum(z_scores .^ 4) -
@@ -26,7 +59,8 @@ function test_normality(data::Vector{Float64})
2659
"Data may violate normality assumption",
2760
"recommendations" => n < 30 ?
2861
"Small sample — consider non-parametric alternatives" :
29-
"Large sample — parametric tests may be robust to mild violations"
62+
"Large sample — parametric tests may be robust to mild violations",
63+
"note" => nothing
3064
)
3165
end
3266

src/stats/correlation_regression.jl

Lines changed: 162 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ Uses midranks for tied values. Equivalent to Pearson on ranks.
1414
"""
1515
function spearman_correlation(x::Vector{Float64}, y::Vector{Float64}; alpha::Float64=0.05)
1616
n = length(x)
17-
@assert length(y) == n "Vectors must be of the same length"
17+
require_equal_length(x, y, "x", "y")
18+
19+
# DEGENERATE GUARD: below n=2, midranks/mean on the ranks either errors
20+
# (empty collection) or feeds a negative (n-2) into the significance
21+
# formula's sqrt, which throws DomainError. Bail out gracefully instead.
22+
if n < 2
23+
return Dict{String,Any}(
24+
"rho" => nothing, "t_stat" => nothing, "df" => nothing,
25+
"p_value" => nothing, "significant" => false,
26+
"interpretation" => nothing,
27+
"test_type" => "Spearman rank correlation (midranks)",
28+
"note" => "Spearman correlation requires at least 2 observations"
29+
)
30+
end
1831

1932
rx = midranks(x)
2033
ry = midranks(y)
@@ -51,30 +64,54 @@ LINEAR ASSOCIATION: Computes the Pearson product-moment coefficient.
5164
"""
5265
function pearson_correlation(x::Vector{Float64}, y::Vector{Float64}; alpha::Float64=0.05)
5366
n = length(x)
54-
@assert length(y) == n "Vectors must be of the same length"
55-
67+
require_equal_length(x, y, "x", "y")
68+
69+
# DEGENERATE GUARD: below n=2 there is nothing to correlate.
70+
if n < 2
71+
return Dict{String,Any}(
72+
"r" => nothing, "r_squared" => nothing, "t_stat" => nothing,
73+
"df" => nothing, "p_value" => nothing, "significant" => false,
74+
"interpretation" => nothing,
75+
"note" => "Pearson correlation requires at least 2 observations"
76+
)
77+
end
78+
5679
mx, my = mean(x), mean(y)
5780
num = sum((x .- mx) .* (y .- my))
5881
den = sqrt(sum((x .- mx).^2) * sum((y .- my).^2))
59-
60-
r = num / den
61-
62-
# Statistical significance (t-test)
63-
# t = r * sqrt((n-2)/(1-r^2))
64-
t_stat = r * sqrt((n - 2) / (1 - r^2))
82+
83+
# DEGENERATE GUARD: den == 0 means zero variance in x or y (r = 0/0).
84+
r = den > 0 ? num / den : nothing
85+
r_squared = r === nothing ? nothing : r^2
86+
87+
# Statistical significance (t-test): t = r * sqrt((n-2)/(1-r^2))
88+
# DEGENERATE GUARD: needs df > 0 and a non-perfect correlation
89+
# (1 - r^2 > 0), else the formula divides by zero.
6590
df = n - 2
66-
p_val = 2 * (1 - cdf(TDist(df), abs(t_stat)))
67-
68-
interpretation = abs(r) > 0.7 ? "Strong" : abs(r) > 0.3 ? "Moderate" : "Weak"
69-
91+
t_stat = (r !== nothing && df > 0 && (1 - r^2) > 0) ?
92+
r * sqrt(df / (1 - r^2)) : nothing
93+
p_val = t_stat === nothing ? nothing : 2 * (1 - cdf(TDist(df), abs(t_stat)))
94+
95+
interpretation = r === nothing ? nothing :
96+
abs(r) > 0.7 ? "Strong" : abs(r) > 0.3 ? "Moderate" : "Weak"
97+
98+
note = if r === nothing
99+
"Correlation undefined: zero variance in x or y"
100+
elseif t_stat === nothing
101+
"Significance test undefined: perfect correlation or insufficient degrees of freedom"
102+
else
103+
nothing
104+
end
105+
70106
return Dict{String,Any}(
71107
"r" => r,
72-
"r_squared" => r^2,
108+
"r_squared" => r_squared,
73109
"t_stat" => t_stat,
74110
"df" => df,
75111
"p_value" => p_val,
76-
"significant" => p_val < alpha,
77-
"interpretation" => interpretation
112+
"significant" => p_val === nothing ? false : p_val < alpha,
113+
"interpretation" => interpretation,
114+
"note" => note
78115
)
79116
end
80117

@@ -85,24 +122,37 @@ BIVARIATE MODELING: Fits a straight line (y = beta0 + beta1*x).
85122
"""
86123
function simple_linear_regression(x::Vector{Float64}, y::Vector{Float64}; alpha::Float64=0.05)
87124
n = length(x)
125+
require_equal_length(x, y, "x", "y")
88126
mx, my = mean(x), mean(y)
89-
90-
beta1 = sum((x .- mx) .* (y .- my)) / sum((x .- mx).^2)
127+
128+
# DEGENERATE GUARD: zero variance in x makes the slope 0/0.
129+
ss_xx = sum((x .- mx) .^ 2)
130+
if ss_xx == 0
131+
return Dict{String,Any}(
132+
"intercept" => nothing, "slope" => nothing, "r_squared" => nothing,
133+
"n" => n, "test_type" => "Simple Linear Regression",
134+
"note" => "Regression undefined: zero variance in x"
135+
)
136+
end
137+
138+
beta1 = sum((x .- mx) .* (y .- my)) / ss_xx
91139
beta0 = my - beta1 * mx
92-
140+
93141
# Predictions and residuals
94142
y_pred = beta0 .+ beta1 .* x
95143
residuals = y .- y_pred
96144
ss_res = sum(residuals.^2)
97145
ss_tot = sum((y .- my).^2)
98-
r2 = 1 - (ss_res / ss_tot)
99-
146+
# DEGENERATE GUARD: zero variance in y makes R² a 0/0.
147+
r2 = ss_tot > 0 ? 1 - (ss_res / ss_tot) : nothing
148+
100149
return Dict{String,Any}(
101150
"intercept" => beta0,
102151
"slope" => beta1,
103152
"r_squared" => r2,
104153
"n" => n,
105-
"test_type" => "Simple Linear Regression"
154+
"test_type" => "Simple Linear Regression",
155+
"note" => r2 === nothing ? "R-squared undefined: zero variance in y" : nothing
106156
)
107157
end
108158

@@ -116,37 +166,57 @@ MULTIVARIATE MODELING: Performs Ordinary Least Squares (OLS) regression.
116166
"""
117167
function multiple_regression(X::Matrix{Float64}, y::Vector{Float64}; var_names=nothing)
118168
n, p = size(X)
169+
length(y) == n || throw(ArgumentError(
170+
"y must have length equal to the number of rows in X (got $(length(y)), expected $n)"))
171+
119172
# Add intercept column
120173
X_aug = hcat(ones(n), X)
121-
174+
122175
# Normal equation: beta = (XtX) \ Xt * y
123176
beta = (X_aug' * X_aug) \ (X_aug' * y)
124-
177+
125178
y_pred = X_aug * beta
126179
residuals = y .- y_pred
127180
ss_res = sum(residuals.^2)
128181
ss_tot = sum((y .- mean(y)).^2)
129-
130-
r2 = 1 - (ss_res / ss_tot)
131-
adj_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1)
132-
133-
# Standard errors of coefficients
134-
sigma2 = ss_res / (n - p - 1)
135-
se = sqrt.(diag(sigma2 * inv(X_aug' * X_aug)))
136-
t_stats = beta ./ se
137-
p_values = 2 .* (1 .- cdf.(TDist(n - p - 1), abs.(t_stats)))
138-
182+
183+
# DEGENERATE GUARD: zero variance in y makes R² a 0/0.
184+
r2 = ss_tot > 0 ? 1 - (ss_res / ss_tot) : nothing
185+
df_resid = n - p - 1
186+
adj_r2 = (r2 !== nothing && df_resid > 0) ? 1 - (1 - r2) * (n - 1) / df_resid : nothing
187+
139188
labels = isnothing(var_names) ? ["Intercept"; ["Var$i" for i in 1:p]] : ["Intercept"; var_names]
140-
189+
190+
# DEGENERATE GUARD: standard errors need positive residual degrees of
191+
# freedom (n - p - 1 > 0); otherwise sigma2's denominator is <= 0.
192+
note = nothing
193+
if r2 !== nothing && df_resid > 0
194+
sigma2 = ss_res / df_resid
195+
se = sqrt.(diag(sigma2 * inv(X_aug' * X_aug)))
196+
t_stats = beta ./ se
197+
p_values = 2 .* (1 .- cdf.(TDist(df_resid), abs.(t_stats)))
198+
std_errors = Dict(labels[i] => se[i] for i in 1:(p+1))
199+
t_stats_dict = Dict(labels[i] => t_stats[i] for i in 1:(p+1))
200+
p_values_dict = Dict(labels[i] => p_values[i] for i in 1:(p+1))
201+
else
202+
std_errors = Dict(labels[i] => nothing for i in 1:(p+1))
203+
t_stats_dict = Dict(labels[i] => nothing for i in 1:(p+1))
204+
p_values_dict = Dict(labels[i] => nothing for i in 1:(p+1))
205+
note = df_resid <= 0 ?
206+
"Standard errors undefined: insufficient residual degrees of freedom (n - p - 1 <= 0)" :
207+
"Standard errors undefined: zero variance in y"
208+
end
209+
141210
return Dict{String,Any}(
142211
"coefficients" => Dict(labels[i] => beta[i] for i in 1:(p+1)),
143-
"std_errors" => Dict(labels[i] => se[i] for i in 1:(p+1)),
144-
"t_stats" => Dict(labels[i] => t_stats[i] for i in 1:(p+1)),
145-
"p_values" => Dict(labels[i] => p_values[i] for i in 1:(p+1)),
212+
"std_errors" => std_errors,
213+
"t_stats" => t_stats_dict,
214+
"p_values" => p_values_dict,
146215
"r_squared" => r2,
147216
"adj_r_squared" => adj_r2,
148217
"n" => n,
149-
"p" => p
218+
"p" => p,
219+
"note" => note
150220
)
151221
end
152222

@@ -203,20 +273,61 @@ Removes the effect of confounding variable(s) z.
203273
"""
204274
function partial_correlation(x::Vector{Float64}, y::Vector{Float64}, z::Vector{Float64}; alpha::Float64=0.05)
205275
n = length(x)
206-
@assert length(y) == n && length(z) == n
276+
require_equal_length(x, y, "x", "y")
277+
require_equal_length(x, z, "x", "z")
278+
279+
# DEGENERATE GUARD: need at least 4 observations (df = n - 3 > 0).
280+
if n < 4
281+
return Dict{String,Any}(
282+
"r_partial" => nothing, "r_xy" => nothing, "r_xz" => nothing, "r_yz" => nothing,
283+
"t_stat" => nothing, "df" => nothing, "p_value" => nothing, "significant" => false,
284+
"test_type" => "Partial correlation (controlling for z)",
285+
"note" => "Partial correlation requires at least 4 observations"
286+
)
287+
end
288+
289+
# DEGENERATE GUARD: Statistics.cor() returns NaN (not an error) for a
290+
# constant/zero-variance input, which would otherwise leak straight into
291+
# r_xy/r_xz/r_yz. Compute each pairwise correlation the same guarded way
292+
# pearson_correlation does (den == 0 => nothing) so no NaN can enter the
293+
# partial-correlation formula below.
294+
safe_cor(a::Vector{Float64}, b::Vector{Float64}) = begin
295+
ma, mb = mean(a), mean(b)
296+
den = sqrt(sum((a .- ma) .^ 2) * sum((b .- mb) .^ 2))
297+
den > 0 ? sum((a .- ma) .* (b .- mb)) / den : nothing
298+
end
207299

208-
r_xy = cor(x, y)
209-
r_xz = cor(x, z)
210-
r_yz = cor(y, z)
300+
r_xy = safe_cor(x, y)
301+
r_xz = safe_cor(x, z)
302+
r_yz = safe_cor(y, z)
211303

212304
# Partial r formula: r_xy.z = (r_xy - r_xz * r_yz) / sqrt((1 - r_xz²)(1 - r_yz²))
213-
denom = sqrt((1 - r_xz^2) * (1 - r_yz^2))
214-
r_partial = denom > 0 ? (r_xy - r_xz * r_yz) / denom : 0.0
305+
# DEGENERATE GUARD: undefined if any pairwise correlation above is
306+
# undefined (zero variance in x, y, or z), or if the residual variance
307+
# denominator is zero.
308+
r_partial = if r_xy === nothing || r_xz === nothing || r_yz === nothing
309+
nothing
310+
else
311+
denom = sqrt((1 - r_xz^2) * (1 - r_yz^2))
312+
denom > 0 ? (r_xy - r_xz * r_yz) / denom : nothing
313+
end
215314

216-
# Significance test
315+
# Significance test. DEGENERATE GUARD: needs df > 0 and a non-perfect
316+
# partial correlation (1 - r_partial^2 > 0).
217317
df = n - 3
218-
t_stat = r_partial * sqrt(df / (1 - r_partial^2))
219-
p_val = df > 0 ? 2 * (1 - cdf(TDist(df), abs(t_stat))) : 1.0
318+
t_stat = (r_partial !== nothing && df > 0 && (1 - r_partial^2) > 0) ?
319+
r_partial * sqrt(df / (1 - r_partial^2)) : nothing
320+
p_val = t_stat === nothing ? nothing : 2 * (1 - cdf(TDist(df), abs(t_stat)))
321+
322+
note = if r_xy === nothing || r_xz === nothing || r_yz === nothing
323+
"Partial correlation undefined: zero variance in x, y, or z (constant input)"
324+
elseif r_partial === nothing
325+
"Partial correlation undefined: zero residual variance after controlling for z"
326+
elseif t_stat === nothing
327+
"Significance test undefined: perfect partial correlation or insufficient degrees of freedom"
328+
else
329+
nothing
330+
end
220331

221332
return Dict{String,Any}(
222333
"r_partial" => r_partial,
@@ -226,8 +337,9 @@ function partial_correlation(x::Vector{Float64}, y::Vector{Float64}, z::Vector{F
226337
"t_stat" => t_stat,
227338
"df" => df,
228339
"p_value" => p_val,
229-
"significant" => p_val < alpha,
230-
"test_type" => "Partial correlation (controlling for z)"
340+
"significant" => p_val === nothing ? false : p_val < alpha,
341+
"test_type" => "Partial correlation (controlling for z)",
342+
"note" => note
231343
)
232344
end
233345

0 commit comments

Comments
 (0)