From e062df784dbabbabd8e37f6b298a13acada7f42f Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Sat, 11 Jul 2026 00:57:00 +0100
Subject: [PATCH 1/2] test: table-driven executor router coverage + CI coverage
 reporting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

execute_tool() had ~75 dispatch arms but was exercised by exactly one
test (anova). Add test/executor_router_test.jl, which programmatically
enumerates every tool registered in tools/definitions.jl via
get_tools() (never hand-copied), calls execute_tool with a table of
minimal valid arguments for each, and asserts a clean Dict comes back.
The registry-coverage testset fails if a new tool is registered without
a table entry or skip reason, so coverage can't silently rot. Five
high-traffic tools (t_test, descriptive_statistics, correlation,
regression, mann_whitney) are additionally cross-checked against their
direct Julia function call, following the existing "anova" pattern in
reference_validation_test.jl.

Writing the argument table surfaced two registered tools that were
completely broken: "chi_square" dispatched to chi_square_test/
chi_square_goodness_of_fit, neither of which was ever defined (an
UndefVarError caught and masked by execute_tool's outer try/catch), and
"frequency_analysis" dispatched to frequency_table, whose body was a
comment with no implementation (silently returning nothing). Both are
now implemented for real in src/stats/inferential.jl and
src/stats/descriptive.jl rather than skip-listed, since the skip-list
is reserved for tools that genuinely need external services/files, and
these are pure computation. The skip-list therefore stays empty.

.github/workflows/e2e.yml switches to Pkg.test(coverage=true), adds a
SHA-pinned julia-actions/julia-processcoverage step to produce lcov.info,
computes total line coverage and writes it to GITHUB_STEP_SUMMARY, and
uploads the lcov file as a build artifact. This is informational only —
no coverage threshold is enforced. .gitignore is extended to actually
match Julia's real per-PID coverage filenames (file.jl.<pid>.cov) and
the generated lcov.info, since the existing `*.jl.cov` pattern never
matched anything Julia actually writes.

Local verification: flock-serialized `julia --project=. -e "using Pkg;
Pkg.test()"` in WSL — 4535/4535 tests green (baseline 4404 + 131 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/e2e.yml    |  39 ++++++-
 .gitignore                   |   2 +
 src/stats/descriptive.jl     |  24 ++++-
 src/stats/inferential.jl     |  66 ++++++++++++
 test/executor_router_test.jl | 194 +++++++++++++++++++++++++++++++++++
 test/runtests.jl             |   4 +
 6 files changed, 325 insertions(+), 4 deletions(-)
 create mode 100644 test/executor_router_test.jl

diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 16f0b06..8a49649 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -43,8 +43,43 @@ jobs:
       - name: Julia cache
         uses: julia-actions/cache@a45e8fa8be21c18a06b7177052533149e61e9b38  # v3.1.0
 
-      - name: Run Julia tests
-        run: julia --project=. -e 'using Pkg; Pkg.test()'
+      - name: Run Julia tests (with coverage)
+        run: julia --project=. -e 'using Pkg; Pkg.test(coverage=true)'
+
+      - name: Process coverage → lcov
+        uses: julia-actions/julia-processcoverage@03114f09f119417c3242a9fb6e0b722676aedf38  # v1.2.2
+
+      - name: Coverage summary
+        if: always()
+        run: |
+          if [ -f lcov.info ]; then
+            TOTAL=$(grep -c '^DA:' lcov.info || true)
+            HIT=$(awk -F'[:,]' '/^DA:/ && $3 > 0 { c++ } END { print c + 0 }' lcov.info)
+            TOTAL=${TOTAL:-0}
+            HIT=${HIT:-0}
+            if [ "$TOTAL" -gt 0 ]; then
+              PCT=$(awk -v h="$HIT" -v t="$TOTAL" 'BEGIN { printf "%.2f", (h / t) * 100 }')
+            else
+              PCT="0.00"
+            fi
+            {
+              echo "## Coverage (informational — not gated)"
+              echo ""
+              echo "Lines covered: **${HIT} / ${TOTAL}** (**${PCT}%**)"
+            } >> "$GITHUB_STEP_SUMMARY"
+          else
+            echo "## Coverage" >> "$GITHUB_STEP_SUMMARY"
+            echo "" >> "$GITHUB_STEP_SUMMARY"
+            echo "No lcov.info was produced." >> "$GITHUB_STEP_SUMMARY"
+          fi
+
+      - name: Upload lcov artifact
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7.0.1
+        with:
+          name: julia-coverage-lcov
+          path: lcov.info
+          if-no-files-found: warn
 
   aspect-safety:
     name: Aspect — Safety + SPDX
diff --git a/.gitignore b/.gitignore
index d1b3c31..80e1cd0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,7 +35,9 @@ erl_crash.dump
 
 # Julia
 *.jl.cov
+*.jl.*.cov
 *.jl.mem
+/lcov.info
 # Manifest.toml is COMMITTED here (supply-chain pinning of the compute half;
 # Dependabot has no Julia ecosystem, so the committed manifest + CI
 # Pkg.instantiate is the compensating control). Do not re-ignore it.
diff --git a/src/stats/descriptive.jl b/src/stats/descriptive.jl
index eeb879a..7fa36d0 100644
--- a/src/stats/descriptive.jl
+++ b/src/stats/descriptive.jl
@@ -144,9 +144,29 @@ end
 """
     frequency_table(data::Vector{String}) -> Dict
 
-CATEGORICAL ANALYSIS: Computes frequencies and relative percentages 
+CATEGORICAL ANALYSIS: Computes frequencies and relative percentages
 for discrete data sets.
 """
 function frequency_table(data::Vector{String})
-    # ... [Implementation using countmap]
+    n = length(data)
+    n == 0 && return Dict{String,Any}("error" => "Need at least 1 observation")
+
+    counts = StatsBase.countmap(data)
+    categories = sort(collect(keys(counts)))
+    freqs = [counts[c] for c in categories]
+    rel_freqs = freqs ./ n .* 100.0
+    cum_freqs = cumsum(freqs)
+    cum_rel_freqs = cumsum(rel_freqs)
+
+    return Dict{String,Any}(
+        "categories" => categories,
+        "frequencies" => freqs,
+        "relative_frequencies" => rel_freqs,
+        "cumulative_frequencies" => cum_freqs,
+        "cumulative_relative_frequencies" => cum_rel_freqs,
+        "n" => n,
+        "n_categories" => length(categories),
+        "mode" => categories[argmax(freqs)],
+        "test_type" => "Frequency table (categorical)"
+    )
 end
diff --git a/src/stats/inferential.jl b/src/stats/inferential.jl
index e50d6e3..e078661 100644
--- a/src/stats/inferential.jl
+++ b/src/stats/inferential.jl
@@ -103,3 +103,69 @@ function one_way_anova(groups::Vector{Vector{Float64}}; alpha::Float64=0.05)
         "test_type" => "One-way ANOVA (independent groups)"
     )
 end
+
+"""
+    chi_square_test(observed::Matrix{Int}; alpha=0.05) -> Dict
+
+CHI-SQUARE TEST OF INDEPENDENCE: Tests whether two categorical variables are
+independent in an r×c contingency table of observed frequencies.
+- EFFECT SIZE: Reports Cramér's V.
+- OUTPUT: chi-squared statistic, df = (r-1)(c-1), and p-value from the
+  chi-squared distribution.
+"""
+function chi_square_test(observed::Matrix{Int}; alpha::Float64=0.05)
+    r, c = size(observed)
+    n = sum(observed)
+    row_sums = sum(observed, dims=2)
+    col_sums = sum(observed, dims=1)
+
+    chi2 = 0.0
+    for i in 1:r, j in 1:c
+        expected = row_sums[i] * col_sums[j] / n
+        if expected > 0
+            chi2 += (observed[i, j] - expected)^2 / expected
+        end
+    end
+    df = (r - 1) * (c - 1)
+    p_value = 1 - cdf(Chisq(df), chi2)
+
+    return Dict{String,Any}(
+        "chi_squared" => chi2,
+        "df" => df,
+        "p_value" => p_value,
+        "significant" => p_value < alpha,
+        "cramers_v" => sqrt(chi2 / (n * (min(r, c) - 1))),
+        "n" => n,
+        "test_type" => "Chi-square test of independence"
+    )
+end
+
+"""
+    chi_square_goodness_of_fit(observed, expected_proportions=nothing; alpha=0.05) -> Dict
+
+CHI-SQUARE GOODNESS-OF-FIT: Tests whether observed category counts match an
+expected distribution. Defaults to a uniform distribution across categories
+when `expected_proportions` is not supplied.
+"""
+function chi_square_goodness_of_fit(observed::Vector{Int},
+                                    expected_proportions::Union{Vector{Float64},Nothing}=nothing;
+                                    alpha::Float64=0.05)
+    k = length(observed)
+    n = sum(observed)
+    props = isnothing(expected_proportions) ? fill(1.0 / k, k) : expected_proportions
+    expected = n .* props
+
+    chi2 = sum((observed .- expected) .^ 2 ./ expected)
+    df = k - 1
+    p_value = 1 - cdf(Chisq(df), chi2)
+
+    return Dict{String,Any}(
+        "chi_squared" => chi2,
+        "df" => df,
+        "p_value" => p_value,
+        "significant" => p_value < alpha,
+        "expected" => expected,
+        "n" => n,
+        "test_type" => "Chi-square goodness-of-fit"
+    )
+end
diff --git a/test/executor_router_test.jl b/test/executor_router_test.jl
new file mode 100644
index 0000000..fdf9d86
--- /dev/null
+++ b/test/executor_router_test.jl
@@ -0,0 +1,194 @@
+# SPDX-License-Identifier: MPL-2.0
+# Executor router coverage — every registered LLM-facing tool exercised.
+#
+# execute_tool(name, args) is the precise layer the LLM drives: a bare string
+# tool name plus a loosely-typed argument Dict gets coerced into typed Julia
+# calls. Before this file, that string→function mapping and argument
+# coercion was exercised for exactly one tool ("anova"). This file
+# programmatically enumerates every tool registered in tools/definitions.jl
+# (parsed via get_tools(), never hand-copied) and calls execute_tool for
+# each with a table of minimal valid arguments, asserting a clean
+# (non-"error") Dict comes back. A small set of high-traffic tools are
+# additionally cross-checked against their direct Julia function call — the
+# same pattern already used for "anova" in reference_validation_test.jl.
+#
+# If a tool is ever added to get_tools() without a corresponding entry here
+# (either in ARG_TABLE or SKIP_LIST), the "every registered tool" testset
+# below fails. That is the point: coverage cannot silently rot.
+
+@testset "Executor Router Coverage" begin
+
+    # ── Minimal valid arguments per registered tool name ──────────────────
+    # Keep each entry the smallest input that exercises the tool's primary
+    # code path without tripping degenerate-input guards.
+    ARG_TABLE = Dict{String,Dict{String,Any}}(
+        "descriptive_statistics" => Dict{String,Any}(
+            "data" => [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]),
+        "frequency_analysis" => Dict{String,Any}(
+            "data" => ["a", "b", "a", "c", "b", "a"]),
+        "t_test" => Dict{String,Any}(
+            "type" => "independent",
+            "group1" => [1.0, 2.0, 3.0, 4.0, 5.0],
+            "group2" => [2.0, 4.0, 6.0, 8.0, 10.0]),
+        "anova" => Dict{String,Any}(
+            "groups" => [[1.0, 2.0, 3.0], [2.0, 3.0, 4.0], [3.0, 4.0, 5.0]]),
+        "chi_square" => Dict{String,Any}(
+            "type" => "independence",
+            "observed" => [[10, 20], [30, 40]]),
+        "correlation" => Dict{String,Any}(
+            "x" => [1.0, 2.0, 3.0, 4.0, 5.0],
+            "y" => [1.0, 2.0, 3.0, 4.0, 6.0]),
+        "regression" => Dict{String,Any}(
+            "x" => [1.0, 2.0, 3.0, 4.0, 5.0],
+            "y" => [2.0, 4.0, 5.0, 4.0, 5.0]),
+        "nonparametric_test" => Dict{String,Any}(
+            "type" => "mann_whitney",
+            "group1" => [1.0, 2.0, 3.0],
+            "group2" => [4.0, 5.0, 6.0]),
+        "permanova" => Dict{String,Any}(
+            "distance_matrix" => [[0.0, 1.0, 2.0, 3.0],
+                                   [1.0, 0.0, 1.5, 2.5],
+                                   [2.0, 1.5, 0.0, 1.0],
+                                   [3.0, 2.5, 1.0, 0.0]],
+            "group_labels" => ["A", "A", "B", "B"],
+            "n_permutations" => 49),
+        "effect_size_calculator" => Dict{String,Any}(
+            "cohens_d" => 0.5, "n1" => 20, "n2" => 20),
+        "power_analysis" => Dict{String,Any}(
+            "effect_size" => 0.5, "n" => 30),
+        "sample_size_calculator" => Dict{String,Any}(
+            "design" => "means", "effect_size" => 0.5),
+        "test_assumptions" => Dict{String,Any}(
+            "type" => "normality",
+            "data" => collect(1.0:15.0)),
+        "bayesian_analysis" => Dict{String,Any}(
+            "prior" => [0.5, 0.5],
+            "likelihood" => [[0.8, 0.2], [0.3, 0.7]],
+            "data_index" => 1),
+        "bayes_factor" => Dict{String,Any}(
+            "r_squared_full" => 0.6, "r_squared_reduced" => 0.4,
+            "n" => 50, "p_full" => 3, "p_reduced" => 1),
+        "credible_intervals" => Dict{String,Any}(
+            "samples" => collect(1.0:100.0)),
+        "fuzzy_logic_analysis" => Dict{String,Any}(
+            "value" => 0.5, "center" => 0.5, "width" => 0.2, "operation" => "membership"),
+        "dempster_shafer" => Dict{String,Any}(
+            "evidence1" => Dict("A" => 0.6, "B" => 0.4),
+            "evidence2" => Dict("A" => 0.5, "B" => 0.5)),
+        "granger_causality" => Dict{String,Any}(
+            "series_x" => [1.0i + 0.1 * cos(i) for i in 1:20],
+            "series_y" => [2.0i + 0.1 * sin(i) for i in 1:20],
+            "lag" => 1),
+        "james_stein" => Dict{String,Any}(
+            "observations" => [1.0, 2.5, 3.0, 4.5, 5.0, 2.0]),
+        "diagnostic_metrics" => Dict{String,Any}(
+            "true_positive" => 50, "false_negative" => 10,
+            "true_negative" => 80, "false_positive" => 5),
+        "reliability_analysis" => Dict{String,Any}(
+            "items" => [[3.0, 4.0, 5.0], [4.0, 4.0, 4.0], [2.0, 3.0, 3.0],
+                        [5.0, 5.0, 4.0], [3.0, 4.0, 4.0], [4.0, 3.0, 5.0]]),
+        "measurement_analysis" => Dict{String,Any}(
+            "type" => "sem", "reliability" => 0.8, "sd" => 10.0),
+        "validity_assessment" => Dict{String,Any}(
+            "n_essential" => 8, "n_total" => 10),
+        "criterion_validity_test" => Dict{String,Any}(
+            "predictor" => [1.0, 2.0, 3.0, 4.0, 5.0],
+            "criterion" => [2.0, 4.0, 5.0, 4.0, 6.0]),
+        "inter_rater_reliability" => Dict{String,Any}(
+            "type" => "cohens_kappa",
+            "rater1" => [1, 0, 1, 1, 0, 1],
+            "rater2" => [1, 0, 0, 1, 0, 1]),
+        "qualitative_analysis" => Dict{String,Any}(
+            "themes_per_interview" => [5, 3, 2, 1, 1, 0, 1]),
+        "calculate_pre" => Dict{String,Any}(
+            "observed" => [1.0, 2.0, 3.0, 4.0, 5.0],
+            "predicted" => [1.1, 2.1, 2.9, 4.2, 4.8]),
+        "sampling_design" => Dict{String,Any}(
+            "type" => "margin_of_error", "n" => 100,
+            "proportion" => 0.5, "confidence" => 0.95),
+    )
+
+    # ── Skip-list: tools genuinely needing external services/files ────────
+    # Keep this EMPTY unless a tool truly cannot be exercised in-process.
+    # A broken/undefined dispatch target is a bug to fix, not a skip
+    # reason — the whole point of this file is to catch exactly that.
+    SKIP_LIST = Dict{String,String}()
+
+    registered_tools = [t["function"]["name"] for t in Statistikles.get_tools()]
+
+    @testset "Registry coverage: every tool has a table entry or skip reason" begin
+        @test !isempty(registered_tools)
+        for name in registered_tools
+            @test haskey(ARG_TABLE, name) || haskey(SKIP_LIST, name)
+        end
+        # The skip-list is an escape hatch, not a dumping ground.
+        @test length(SKIP_LIST) <= 3
+        for (name, reason) in SKIP_LIST
+            @test !isempty(reason)
+        end
+    end
+
+    @testset "execute_tool succeeds for every non-skipped registered tool" begin
+        for name in registered_tools
+            haskey(SKIP_LIST, name) && continue
+            @test haskey(ARG_TABLE, name)
+            !haskey(ARG_TABLE, name) && continue  # can't call without args
+            result = Statistikles.execute_tool(name, ARG_TABLE[name])
+            @test result isa Dict
+            @test !haskey(result, "error")
+        end
+    end
+
+    # ── Cross-checks: router output vs. direct Julia function call ────────
+    # For high-traffic tools, confirm execute_tool's argument coercion
+    # doesn't silently change the numbers — the same pattern as the
+    # "anova" cross-check in reference_validation_test.jl.
+    @testset "Cross-check: t_test router vs. direct call" begin
+        g1, g2 = [1.0, 2.0, 3.0, 4.0, 5.0], [2.0, 4.0, 6.0, 8.0, 10.0]
+        direct = t_test_independent(g1, g2)
+        via_tool = Statistikles.execute_tool("t_test",
+            Dict{String,Any}("type" => "independent", "group1" => g1, "group2" => g2))
+        @test isapprox(via_tool["t_stat"], direct["t_stat"]; atol = 1e-12)
+        @test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
+        @test isapprox(via_tool["cohens_d"], direct["cohens_d"]; atol = 1e-12)
+    end
+
+    @testset "Cross-check: descriptive_statistics router vs. direct call" begin
+        data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
+        direct = descriptive_stats(data)
+        via_tool = Statistikles.execute_tool("descriptive_statistics",
+            Dict{String,Any}("data" => data))
+        @test isapprox(via_tool["mean"], direct["mean"]; atol = 1e-12)
+        @test isapprox(via_tool["variance"], direct["variance"]; atol = 1e-12)
+        @test isapprox(via_tool["skewness"], direct["skewness"]; atol = 1e-12)
+    end
+
+    @testset "Cross-check: correlation router vs. direct call" begin
+        x, y = [1.0, 2.0, 3.0, 4.0, 5.0], [1.0, 2.0, 3.0, 4.0, 6.0]
+        direct = pearson_correlation(x, y)
+        via_tool = Statistikles.execute_tool("correlation",
+            Dict{String,Any}("x" => x, "y" => y, "method" => "pearson"))
+        @test isapprox(via_tool["r"], direct["r"]; atol = 1e-12)
+        @test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
+    end
+
+    @testset "Cross-check: regression router vs. direct call" begin
+        x, y = [1.0, 2.0, 3.0, 4.0, 5.0], [2.0, 4.0, 5.0, 4.0, 5.0]
+        direct = simple_linear_regression(x, y)
+        via_tool = Statistikles.execute_tool("regression",
+            Dict{String,Any}("x" => x, "y" => y))
+        @test isapprox(via_tool["slope"], direct["slope"]; atol = 1e-12)
+        @test isapprox(via_tool["intercept"], direct["intercept"]; atol = 1e-12)
+        @test isapprox(via_tool["r_squared"], direct["r_squared"]; atol = 1e-12)
+    end
+
+    @testset "Cross-check: mann_whitney router vs. direct call" begin
+        g1, g2 = [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]
+        direct = mann_whitney_u(g1, g2)
+        via_tool = Statistikles.execute_tool("nonparametric_test",
+            Dict{String,Any}("type" => "mann_whitney", "group1" => g1, "group2" => g2))
+        @test isapprox(via_tool["U_statistic"], direct["U_statistic"]; atol = 1e-12)
+        @test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
+    end
+
+end
diff --git a/test/runtests.jl b/test/runtests.jl
index 9218784..527fcd8 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -1341,3 +1341,7 @@ include("property_test.jl")
 # Reference validation: the trusted symbolic layer checked against
 # hand-derived / independently computed ground-truth values
 include("reference_validation_test.jl")
+
+# Executor router coverage: every LLM-facing tool name in definitions.jl
+# is exercised through execute_tool (or explicitly skipped with a reason)
+include("executor_router_test.jl")

From c366c568c4707b33574cfcd030f91beb7fb811ac Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Sat, 11 Jul 2026 01:14:17 +0100
Subject: [PATCH 2/2] test: add ground-truth reference tests for chi-square +
 frequency_table

Reviewer must_fix: this branch's chi_square_test, chi_square_goodness_of_fit,
and frequency_table (src/stats/inferential.jl, src/stats/descriptive.jl)
shipped with zero correctness-specific tests -- executor_router_test.jl only
asserted the router result "is a Dict" without an "error" key, so a sign
error, wrong df, or wrong Cramer's V formula would have passed silently.

Add hand-derived ground-truth testsets to test/reference_validation_test.jl,
matching the existing anova/t_test pattern:
- Frequency table: exact category counts/percentages for a 6-observation set.
- Chi-square test of independence: a 2x3 table engineered so every expected
  cell is exactly 4, giving chi-squared = 4.0 exactly (df=2); p-value checked
  against the closed-form chi-square(df=2) survival function e^(-x/2), which
  does not depend on the library's own Distributions.cdf call path; Cramer's
  V checked against its closed form.
- Chi-square goodness-of-fit: a 3-category uniform-expected case, same
  chi-squared = 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 --
  closing the gap the reviewer flagged since these two tools weren't among
  the 5 tools originally cross-checked in executor_router_test.jl.

chi_square_test and chi_square_goodness_of_fit are not exported from the
Statistikles module, so they're referenced qualified (Statistikles.foo),
matching the existing precedent for levenes_test in this same file.

Also rebases test/executor-router-coverage onto current main to pick up
the neural-boundary guardrail P0 work (#37) that landed after this branch
was cut -- the branch predated it, so an unrebased diff against main would
have shown guardrail.jl/guardrail_test.jl/chat.jl/lmstudio.jl/executor.jl
being reverted. Resolved one trivial conflict in test/runtests.jl (both
include("guardrail_test.jl") and include("executor_router_test.jl") are
kept).

Local verification: julia --project=. -e "using Pkg; Pkg.test()" -- full
suite green (4642 tests: 424 unit + 145 e2e + 3800 property + 62 reference
validation + 80 guardrail + 131 executor router, 0 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 test/reference_validation_test.jl | 66 +++++++++++++++++++++++++++++++
 1 file changed, 66 insertions(+)

diff --git a/test/reference_validation_test.jl b/test/reference_validation_test.jl
index 6e1108b..fc2d78b 100644
--- a/test/reference_validation_test.jl
+++ b/test/reference_validation_test.jl
@@ -98,6 +98,51 @@
         @test r["significant"] == false
     end
 
+    @testset "Frequency table vs reference" begin
+        data = ["a", "b", "a", "c", "b", "a"]
+        r = frequency_table(data)
+        # Hand count: a×3, b×2, c×1 (n=6); categories sort alphabetically
+        @test r["categories"] == ["a", "b", "c"]
+        @test r["frequencies"] == [3, 2, 1]
+        @test isapprox(r["relative_frequencies"], [50.0, 100 / 3, 100 / 6]; atol = 1e-9)
+        @test r["cumulative_frequencies"] == [3, 5, 6]
+        @test isapprox(r["cumulative_relative_frequencies"], [50.0, 250 / 3, 100.0]; atol = 1e-9)
+        @test r["n"] == 6
+        @test r["n_categories"] == 3
+        @test r["mode"] == "a"
+    end
+
+    @testset "Chi-square test of independence vs reference" begin
+        # 2×3 table engineered so every expected cell is exactly 4: row sums
+        # = [12, 12], col sums = [8, 8, 8], n = 24 ⇒ expected[i,j] = 12·8/24 = 4.
+        # Deviations from 4 are [+2,-2,0 / -2,+2,0] ⇒ χ² = (4+4+0+4+4+0)/4 = 4.0 exactly.
+        observed = [6 2 4; 2 6 4]
+        r = Statistikles.chi_square_test(observed)
+        @test r["chi_squared"] == 4.0
+        @test r["df"] == 2
+        # df=2 has a closed-form chi-square survival function: P(χ²₂ > x) = e^(-x/2)
+        # (independent of the library's own Distributions.cdf call path).
+        @test isapprox(r["p_value"], exp(-2.0); atol = 1e-12)
+        @test r["significant"] == false
+        # Cramér's V = √(χ² / (n·(min(r,c)-1))) = √(4/(24·1)) = √(1/6)
+        @test isapprox(r["cramers_v"], sqrt(1 / 6); atol = 1e-12)
+        @test r["n"] == 24
+    end
+
+    @testset "Chi-square goodness-of-fit vs reference" begin
+        # k=3 categories, uniform expected proportions, n=24 ⇒ expected=8 each.
+        # Deviations [+4,-4,0] ⇒ χ² = (16+16+0)/8 = 4.0 exactly (df=2, same
+        # closed-form survival function as the independence case above).
+        observed = [12, 4, 8]
+        r = Statistikles.chi_square_goodness_of_fit(observed)
+        @test r["chi_squared"] == 4.0
+        @test r["df"] == 2
+        @test isapprox(r["p_value"], exp(-2.0); atol = 1e-12)
+        @test r["significant"] == false
+        @test r["expected"] == [8.0, 8.0, 8.0]
+        @test r["n"] == 24
+    end
+
     @testset "Executor dispatch: anova tool" begin
         direct = one_way_anova([[1.0, 2.0, 3.0], [2.0, 3.0, 4.0], [3.0, 4.0, 5.0]])
         via_tool = Statistikles.execute_tool("anova",
@@ -106,4 +151,25 @@
         @test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
     end
 
+    @testset "Executor dispatch: frequency_analysis tool" begin
+        data = ["a", "b", "a", "c", "b", "a"]
+        direct = frequency_table(data)
+        via_tool = Statistikles.execute_tool("frequency_analysis",
+            Dict{String,Any}("data" => data))
+        @test via_tool["frequencies"] == direct["frequencies"]
+        @test via_tool["categories"] == direct["categories"]
+        @test via_tool["mode"] == direct["mode"]
+    end
+
+    @testset "Executor dispatch: chi_square tool" begin
+        observed_rows = [[6, 2, 4], [2, 6, 4]]
+        direct = Statistikles.chi_square_test([6 2 4; 2 6 4])
+        via_tool = Statistikles.execute_tool("chi_square",
+            Dict{String,Any}("type" => "independence", "observed" => observed_rows))
+        @test isapprox(via_tool["chi_squared"], direct["chi_squared"]; atol = 1e-12)
+        @test isapprox(via_tool["df"], direct["df"]; atol = 1e-12)
+        @test isapprox(via_tool["p_value"], direct["p_value"]; atol = 1e-12)
+        @test isapprox(via_tool["cramers_v"], direct["cramers_v"]; atol = 1e-12)
+    end
+
 end
