Skip to content

Commit 77c999e

Browse files
test: table-driven executor router coverage + CI coverage reporting (#41)
## Summary Implements W1-3 (table-driven router tests + CI coverage), addressing the reviewer's `must_fix` from the first review pass. ### What changed - **`test/executor_router_test.jl` (new)**: programmatically enumerates every registered tool name from `Statistikles.get_tools()` (parsed from `src/tools/definitions.jl`, never hand-copied), maintains a table of minimal valid arguments per tool, and asserts `execute_tool(name, args)` returns a `Dict` without an `"error"` key for each. The skip-list is empty (kept `<= 3` by assertion) — every registered tool is genuinely exercisable in-process. Registry-coverage test fails if a new tool is added without a table entry or skip reason, per the requirement. Five high-traffic tools (`t_test`, `descriptive_statistics`, `correlation`, `regression`, `nonparametric_test`/mann-whitney) are cross-checked against their direct Julia function call. - **`.github/workflows/e2e.yml`**: switched to `Pkg.test(coverage=true)`, added a SHA-pinned `julia-actions/julia-processcoverage@03114f09f119417c3242a9fb6e0b722676aedf38 # v1.2.2` step, computes total coverage % and writes it to `GITHUB_STEP_SUMMARY` (informational only, no gate), and uploads an lcov artifact via SHA-pinned `actions/upload-artifact`. - **`src/stats/descriptive.jl` / `src/stats/inferential.jl`**: this branch also completes `frequency_table` (previously a stub returning `nothing`) and adds `chi_square_test` / `chi_square_goodness_of_fit`, needed because `frequency_analysis` and `chi_square` are registered tools that the router-coverage test must exercise. - **`test/reference_validation_test.jl`**: added ground-truth testsets for the three new/completed functions above, matching the existing anova/t_test pattern — hand-derived expected values, not copied from the library's own output: - *Frequency table*: exact category counts/percentages for a 6-observation categorical set. - *Chi-square test of independence*: a 2×3 contingency table engineered so every expected cell is exactly 4, giving χ² = 4.0 exactly (df=2); p-value checked against the closed-form χ²(df=2) survival function `e^(-x/2)` (independent of the library's own `Distributions.cdf` call path); Cramér's V checked against its closed form. - *Chi-square goodness-of-fit*: a 3-category uniform-expected case, same χ²=4.0/df=2 closed-form check. - *Executor-dispatch cross-checks* for the `frequency_analysis` and `chi_square` tool names (mirroring the existing `anova` dispatch test), confirming `execute_tool`'s argument coercion doesn't change the numbers. `chi_square_test`/`chi_square_goodness_of_fit` are not exported from the `Statistikles` module, so they're referenced qualified (`Statistikles.chi_square_test`), matching the existing precedent for `levenes_test` in the same file. ### Why (must_fix from first review) > `src/stats/inferential.jl` and `src/stats/descriptive.jl` are modified on this branch to add two brand-new functions ... and complete a previously-unimplemented stub ... These new/completed functions ship with ZERO correctness-specific tests ... a sign error, wrong df, or wrong Cramér's V formula would pass silently. Addressed via option (b) from the reviewer's remediation choices: added reference-value assertions for all three functions to this branch (see `test/reference_validation_test.jl` above), rather than splitting them into a separate task, since they're load-bearing for the router-coverage table this work order already requires. ### Also fixed: stale branch base This branch was originally cut before `#37` ("Enforce neural-boundary numeric provenance guardrail (P0)") landed on `main`. An unrebased diff would have shown `src/tools/guardrail.jl`, `test/guardrail_test.jl`, and related changes to `chat.jl`/`executor.jl`/`lmstudio.jl` being reverted. Merged current `main` into this branch (one trivial conflict in `test/runtests.jl`, resolved by keeping both `include("guardrail_test.jl")` and `include("executor_router_test.jl")`) so the diff against `main` is now scoped to just this work order's changes. ### Verification ``` flock /tmp/statistikles-julia.lock -c 'julia --project=. -e "using Pkg; Pkg.test()"' ``` Full suite green, 0 failures: | Testset | Pass | Total | |---|---|---| | Statistikles Full Test Suite | 424 | 424 | | Statistikles E2E Pipeline Tests | 145 | 145 | | Statistikles Property-Based Tests | 3800 | 3800 | | Reference Validation (ground truth) | 62 | 62 | | Neural-Boundary Guardrail | 80 | 80 | | Executor Router Coverage | 131 | 131 | | **Total** | **4642** | **4642** | `actionlint` run against `.github/workflows/e2e.yml`: exit 0 (one pre-existing SC2129 style note, unrelated to this change, not touched). ### Skipped / out of scope - No coverage threshold gate — informational only, per work order's explicit "Out of scope" section. - Did not extend ground-truth reference values beyond what's needed to cover the three new/completed functions this branch itself introduces (broader reference-value work is W2-5, out of scope here). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b08605f commit 77c999e

7 files changed

Lines changed: 391 additions & 4 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,43 @@ jobs:
4343
- name: Julia cache
4444
uses: julia-actions/cache@a45e8fa8be21c18a06b7177052533149e61e9b38 # v3.1.0
4545

46-
- name: Run Julia tests
47-
run: julia --project=. -e 'using Pkg; Pkg.test()'
46+
- name: Run Julia tests (with coverage)
47+
run: julia --project=. -e 'using Pkg; Pkg.test(coverage=true)'
48+
49+
- name: Process coverage → lcov
50+
uses: julia-actions/julia-processcoverage@03114f09f119417c3242a9fb6e0b722676aedf38 # v1.2.2
51+
52+
- name: Coverage summary
53+
if: always()
54+
run: |
55+
if [ -f lcov.info ]; then
56+
TOTAL=$(grep -c '^DA:' lcov.info || true)
57+
HIT=$(awk -F'[:,]' '/^DA:/ && $3 > 0 { c++ } END { print c + 0 }' lcov.info)
58+
TOTAL=${TOTAL:-0}
59+
HIT=${HIT:-0}
60+
if [ "$TOTAL" -gt 0 ]; then
61+
PCT=$(awk -v h="$HIT" -v t="$TOTAL" 'BEGIN { printf "%.2f", (h / t) * 100 }')
62+
else
63+
PCT="0.00"
64+
fi
65+
{
66+
echo "## Coverage (informational — not gated)"
67+
echo ""
68+
echo "Lines covered: **${HIT} / ${TOTAL}** (**${PCT}%**)"
69+
} >> "$GITHUB_STEP_SUMMARY"
70+
else
71+
echo "## Coverage" >> "$GITHUB_STEP_SUMMARY"
72+
echo "" >> "$GITHUB_STEP_SUMMARY"
73+
echo "No lcov.info was produced." >> "$GITHUB_STEP_SUMMARY"
74+
fi
75+
76+
- name: Upload lcov artifact
77+
if: always()
78+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
79+
with:
80+
name: julia-coverage-lcov
81+
path: lcov.info
82+
if-no-files-found: warn
4883

4984
aspect-safety:
5085
name: Aspect — Safety + SPDX

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ erl_crash.dump
3535

3636
# Julia
3737
*.jl.cov
38+
*.jl.*.cov
3839
*.jl.mem
40+
/lcov.info
3941
# Manifest.toml is COMMITTED here (supply-chain pinning of the compute half;
4042
# Dependabot has no Julia ecosystem, so the committed manifest + CI
4143
# Pkg.instantiate is the compensating control). Do not re-ignore it.

src/stats/descriptive.jl

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,29 @@ end
191191
"""
192192
frequency_table(data::Vector{String}) -> Dict
193193
194-
CATEGORICAL ANALYSIS: Computes frequencies and relative percentages
194+
CATEGORICAL ANALYSIS: Computes frequencies and relative percentages
195195
for discrete data sets.
196196
"""
197197
function frequency_table(data::Vector{String})
198-
# ... [Implementation using countmap]
198+
n = length(data)
199+
n == 0 && return Dict{String,Any}("error" => "Need at least 1 observation")
200+
201+
counts = StatsBase.countmap(data)
202+
categories = sort(collect(keys(counts)))
203+
freqs = [counts[c] for c in categories]
204+
rel_freqs = freqs ./ n .* 100.0
205+
cum_freqs = cumsum(freqs)
206+
cum_rel_freqs = cumsum(rel_freqs)
207+
208+
return Dict{String,Any}(
209+
"categories" => categories,
210+
"frequencies" => freqs,
211+
"relative_frequencies" => rel_freqs,
212+
"cumulative_frequencies" => cum_freqs,
213+
"cumulative_relative_frequencies" => cum_rel_freqs,
214+
"n" => n,
215+
"n_categories" => length(categories),
216+
"mode" => categories[argmax(freqs)],
217+
"test_type" => "Frequency table (categorical)"
218+
)
199219
end

src/stats/inferential.jl

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,69 @@ function one_way_anova(groups::Vector{Vector{Float64}}; alpha::Float64=0.05)
139139
"note" => note
140140
)
141141
end
142+
143+
"""
144+
chi_square_test(observed::Matrix{Int}; alpha=0.05) -> Dict
145+
146+
CHI-SQUARE TEST OF INDEPENDENCE: Tests whether two categorical variables are
147+
independent in an r×c contingency table of observed frequencies.
148+
- EFFECT SIZE: Reports Cramér's V.
149+
- OUTPUT: chi-squared statistic, df = (r-1)(c-1), and p-value from the
150+
chi-squared distribution.
151+
"""
152+
function chi_square_test(observed::Matrix{Int}; alpha::Float64=0.05)
153+
r, c = size(observed)
154+
n = sum(observed)
155+
row_sums = sum(observed, dims=2)
156+
col_sums = sum(observed, dims=1)
157+
158+
chi2 = 0.0
159+
for i in 1:r, j in 1:c
160+
expected = row_sums[i] * col_sums[j] / n
161+
if expected > 0
162+
chi2 += (observed[i, j] - expected)^2 / expected
163+
end
164+
end
165+
df = (r - 1) * (c - 1)
166+
p_value = 1 - cdf(Chisq(df), chi2)
167+
168+
return Dict{String,Any}(
169+
"chi_squared" => chi2,
170+
"df" => df,
171+
"p_value" => p_value,
172+
"significant" => p_value < alpha,
173+
"cramers_v" => sqrt(chi2 / (n * (min(r, c) - 1))),
174+
"n" => n,
175+
"test_type" => "Chi-square test of independence"
176+
)
177+
end
178+
179+
"""
180+
chi_square_goodness_of_fit(observed, expected_proportions=nothing; alpha=0.05) -> Dict
181+
182+
CHI-SQUARE GOODNESS-OF-FIT: Tests whether observed category counts match an
183+
expected distribution. Defaults to a uniform distribution across categories
184+
when `expected_proportions` is not supplied.
185+
"""
186+
function chi_square_goodness_of_fit(observed::Vector{Int},
187+
expected_proportions::Union{Vector{Float64},Nothing}=nothing;
188+
alpha::Float64=0.05)
189+
k = length(observed)
190+
n = sum(observed)
191+
props = isnothing(expected_proportions) ? fill(1.0 / k, k) : expected_proportions
192+
expected = n .* props
193+
194+
chi2 = sum((observed .- expected) .^ 2 ./ expected)
195+
df = k - 1
196+
p_value = 1 - cdf(Chisq(df), chi2)
197+
198+
return Dict{String,Any}(
199+
"chi_squared" => chi2,
200+
"df" => df,
201+
"p_value" => p_value,
202+
"significant" => p_value < alpha,
203+
"expected" => expected,
204+
"n" => n,
205+
"test_type" => "Chi-square goodness-of-fit"
206+
)
207+
end

test/executor_router_test.jl

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Executor router coverage — every registered LLM-facing tool exercised.
3+
#
4+
# execute_tool(name, args) is the precise layer the LLM drives: a bare string
5+
# tool name plus a loosely-typed argument Dict gets coerced into typed Julia
6+
# calls. Before this file, that string→function mapping and argument
7+
# coercion was exercised for exactly one tool ("anova"). This file
8+
# programmatically enumerates every tool registered in tools/definitions.jl
9+
# (parsed via get_tools(), never hand-copied) and calls execute_tool for
10+
# each with a table of minimal valid arguments, asserting a clean
11+
# (non-"error") Dict comes back. A small set of high-traffic tools are
12+
# additionally cross-checked against their direct Julia function call — the
13+
# same pattern already used for "anova" in reference_validation_test.jl.
14+
#
15+
# If a tool is ever added to get_tools() without a corresponding entry here
16+
# (either in ARG_TABLE or SKIP_LIST), the "every registered tool" testset
17+
# below fails. That is the point: coverage cannot silently rot.
18+
19+
@testset "Executor Router Coverage" begin
20+
21+
# ── Minimal valid arguments per registered tool name ──────────────────
22+
# Keep each entry the smallest input that exercises the tool's primary
23+
# code path without tripping degenerate-input guards.
24+
ARG_TABLE = Dict{String,Dict{String,Any}}(
25+
"descriptive_statistics" => Dict{String,Any}(
26+
"data" => [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]),
27+
"frequency_analysis" => Dict{String,Any}(
28+
"data" => ["a", "b", "a", "c", "b", "a"]),
29+
"t_test" => Dict{String,Any}(
30+
"type" => "independent",
31+
"group1" => [1.0, 2.0, 3.0, 4.0, 5.0],
32+
"group2" => [2.0, 4.0, 6.0, 8.0, 10.0]),
33+
"anova" => Dict{String,Any}(
34+
"groups" => [[1.0, 2.0, 3.0], [2.0, 3.0, 4.0], [3.0, 4.0, 5.0]]),
35+
"chi_square" => Dict{String,Any}(
36+
"type" => "independence",
37+
"observed" => [[10, 20], [30, 40]]),
38+
"correlation" => Dict{String,Any}(
39+
"x" => [1.0, 2.0, 3.0, 4.0, 5.0],
40+
"y" => [1.0, 2.0, 3.0, 4.0, 6.0]),
41+
"regression" => Dict{String,Any}(
42+
"x" => [1.0, 2.0, 3.0, 4.0, 5.0],
43+
"y" => [2.0, 4.0, 5.0, 4.0, 5.0]),
44+
"nonparametric_test" => Dict{String,Any}(
45+
"type" => "mann_whitney",
46+
"group1" => [1.0, 2.0, 3.0],
47+
"group2" => [4.0, 5.0, 6.0]),
48+
"permanova" => Dict{String,Any}(
49+
"distance_matrix" => [[0.0, 1.0, 2.0, 3.0],
50+
[1.0, 0.0, 1.5, 2.5],
51+
[2.0, 1.5, 0.0, 1.0],
52+
[3.0, 2.5, 1.0, 0.0]],
53+
"group_labels" => ["A", "A", "B", "B"],
54+
"n_permutations" => 49),
55+
"effect_size_calculator" => Dict{String,Any}(
56+
"cohens_d" => 0.5, "n1" => 20, "n2" => 20),
57+
"power_analysis" => Dict{String,Any}(
58+
"effect_size" => 0.5, "n" => 30),
59+
"sample_size_calculator" => Dict{String,Any}(
60+
"design" => "means", "effect_size" => 0.5),
61+
"test_assumptions" => Dict{String,Any}(
62+
"type" => "normality",
63+
"data" => collect(1.0:15.0)),
64+
"bayesian_analysis" => Dict{String,Any}(
65+
"prior" => [0.5, 0.5],
66+
"likelihood" => [[0.8, 0.2], [0.3, 0.7]],
67+
"data_index" => 1),
68+
"bayes_factor" => Dict{String,Any}(
69+
"r_squared_full" => 0.6, "r_squared_reduced" => 0.4,
70+
"n" => 50, "p_full" => 3, "p_reduced" => 1),
71+
"credible_intervals" => Dict{String,Any}(
72+
"samples" => collect(1.0:100.0)),
73+
"fuzzy_logic_analysis" => Dict{String,Any}(
74+
"value" => 0.5, "center" => 0.5, "width" => 0.2, "operation" => "membership"),
75+
"dempster_shafer" => Dict{String,Any}(
76+
"evidence1" => Dict("A" => 0.6, "B" => 0.4),
77+
"evidence2" => Dict("A" => 0.5, "B" => 0.5)),
78+
"granger_causality" => Dict{String,Any}(
79+
"series_x" => [1.0i + 0.1 * cos(i) for i in 1:20],
80+
"series_y" => [2.0i + 0.1 * sin(i) for i in 1:20],
81+
"lag" => 1),
82+
"james_stein" => Dict{String,Any}(
83+
"observations" => [1.0, 2.5, 3.0, 4.5, 5.0, 2.0]),
84+
"diagnostic_metrics" => Dict{String,Any}(
85+
"true_positive" => 50, "false_negative" => 10,
86+
"true_negative" => 80, "false_positive" => 5),
87+
"reliability_analysis" => Dict{String,Any}(
88+
"items" => [[3.0, 4.0, 5.0], [4.0, 4.0, 4.0], [2.0, 3.0, 3.0],
89+
[5.0, 5.0, 4.0], [3.0, 4.0, 4.0], [4.0, 3.0, 5.0]]),
90+
"measurement_analysis" => Dict{String,Any}(
91+
"type" => "sem", "reliability" => 0.8, "sd" => 10.0),
92+
"validity_assessment" => Dict{String,Any}(
93+
"n_essential" => 8, "n_total" => 10),
94+
"criterion_validity_test" => Dict{String,Any}(
95+
"predictor" => [1.0, 2.0, 3.0, 4.0, 5.0],
96+
"criterion" => [2.0, 4.0, 5.0, 4.0, 6.0]),
97+
"inter_rater_reliability" => Dict{String,Any}(
98+
"type" => "cohens_kappa",
99+
"rater1" => [1, 0, 1, 1, 0, 1],
100+
"rater2" => [1, 0, 0, 1, 0, 1]),
101+
"qualitative_analysis" => Dict{String,Any}(
102+
"themes_per_interview" => [5, 3, 2, 1, 1, 0, 1]),
103+
"calculate_pre" => Dict{String,Any}(
104+
"observed" => [1.0, 2.0, 3.0, 4.0, 5.0],
105+
"predicted" => [1.1, 2.1, 2.9, 4.2, 4.8]),
106+
"sampling_design" => Dict{String,Any}(
107+
"type" => "margin_of_error", "n" => 100,
108+
"proportion" => 0.5, "confidence" => 0.95),
109+
)
110+
111+
# ── Skip-list: tools genuinely needing external services/files ────────
112+
# Keep this EMPTY unless a tool truly cannot be exercised in-process.
113+
# A broken/undefined dispatch target is a bug to fix, not a skip
114+
# reason — the whole point of this file is to catch exactly that.
115+
SKIP_LIST = Dict{String,String}()
116+
117+
registered_tools = [t["function"]["name"] for t in Statistikles.get_tools()]
118+
119+
@testset "Registry coverage: every tool has a table entry or skip reason" begin
120+
@test !isempty(registered_tools)
121+
for name in registered_tools
122+
@test haskey(ARG_TABLE, name) || haskey(SKIP_LIST, name)
123+
end
124+
# The skip-list is an escape hatch, not a dumping ground.
125+
@test length(SKIP_LIST) <= 3
126+
for (name, reason) in SKIP_LIST
127+
@test !isempty(reason)
128+
end
129+
end
130+
131+
@testset "execute_tool succeeds for every non-skipped registered tool" begin
132+
for name in registered_tools
133+
haskey(SKIP_LIST, name) && continue
134+
@test haskey(ARG_TABLE, name)
135+
!haskey(ARG_TABLE, name) && continue # can't call without args
136+
result = Statistikles.execute_tool(name, ARG_TABLE[name])
137+
@test result isa Dict
138+
@test !haskey(result, "error")
139+
end
140+
end
141+
142+
# ── Cross-checks: router output vs. direct Julia function call ────────
143+
# For high-traffic tools, confirm execute_tool's argument coercion
144+
# doesn't silently change the numbers — the same pattern as the
145+
# "anova" cross-check in reference_validation_test.jl.
146+
@testset "Cross-check: t_test router vs. direct call" begin
147+
g1, g2 = [1.0, 2.0, 3.0, 4.0, 5.0], [2.0, 4.0, 6.0, 8.0, 10.0]
148+
direct = t_test_independent(g1, g2)
149+
via_tool = Statistikles.execute_tool("t_test",
150+
Dict{String,Any}("type" => "independent", "group1" => g1, "group2" => g2))
151+
@test isapprox(via_tool["t_stat"], direct["t_stat"]; atol = 1e-12)
152+
@test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
153+
@test isapprox(via_tool["cohens_d"], direct["cohens_d"]; atol = 1e-12)
154+
end
155+
156+
@testset "Cross-check: descriptive_statistics router vs. direct call" begin
157+
data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
158+
direct = descriptive_stats(data)
159+
via_tool = Statistikles.execute_tool("descriptive_statistics",
160+
Dict{String,Any}("data" => data))
161+
@test isapprox(via_tool["mean"], direct["mean"]; atol = 1e-12)
162+
@test isapprox(via_tool["variance"], direct["variance"]; atol = 1e-12)
163+
@test isapprox(via_tool["skewness"], direct["skewness"]; atol = 1e-12)
164+
end
165+
166+
@testset "Cross-check: correlation router vs. direct call" begin
167+
x, y = [1.0, 2.0, 3.0, 4.0, 5.0], [1.0, 2.0, 3.0, 4.0, 6.0]
168+
direct = pearson_correlation(x, y)
169+
via_tool = Statistikles.execute_tool("correlation",
170+
Dict{String,Any}("x" => x, "y" => y, "method" => "pearson"))
171+
@test isapprox(via_tool["r"], direct["r"]; atol = 1e-12)
172+
@test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
173+
end
174+
175+
@testset "Cross-check: regression router vs. direct call" begin
176+
x, y = [1.0, 2.0, 3.0, 4.0, 5.0], [2.0, 4.0, 5.0, 4.0, 5.0]
177+
direct = simple_linear_regression(x, y)
178+
via_tool = Statistikles.execute_tool("regression",
179+
Dict{String,Any}("x" => x, "y" => y))
180+
@test isapprox(via_tool["slope"], direct["slope"]; atol = 1e-12)
181+
@test isapprox(via_tool["intercept"], direct["intercept"]; atol = 1e-12)
182+
@test isapprox(via_tool["r_squared"], direct["r_squared"]; atol = 1e-12)
183+
end
184+
185+
@testset "Cross-check: mann_whitney router vs. direct call" begin
186+
g1, g2 = [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]
187+
direct = mann_whitney_u(g1, g2)
188+
via_tool = Statistikles.execute_tool("nonparametric_test",
189+
Dict{String,Any}("type" => "mann_whitney", "group1" => g1, "group2" => g2))
190+
@test isapprox(via_tool["U_statistic"], direct["U_statistic"]; atol = 1e-12)
191+
@test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
192+
end
193+
194+
end

0 commit comments

Comments
 (0)