diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 4da8e1a..8945885 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -1,11 +1,28 @@ name: JS CI +# NOTE: The build/test commands below were verified by running them locally +# against this branch. They are NOT exercised on GitHub Actions until this +# workflow runs there, so the exact runner setup may still need tweaks. +# +# Build: a *real* wasm-pack (installed via jetli/wasm-pack-action) plus the +# wasm32-unknown-unknown target build the package into crates/ci-js/pkg, which +# the vitest suite imports as ../pkg/ci_js.js. `wasm-opt` is disabled via the +# crate's package metadata (crates/ci-js/Cargo.toml) because the locally +# installed wasm-opt is too old to parse the modern wasm the core emits; it only +# shrinks the binary, so disabling it does not affect correctness. CI may +# re-enable it once a newer wasm-opt is guaranteed on the runner. +# +# Tests live in crates/ci-js/tests (their own package.json + lockfile); the +# golden parity suite (golden.test.js) covers all 73 fixture cases (74 vitest +# test cases: 73 parametrized + 1 coverage assertion). + on: push: branches: [master, development] paths: - "crates/ci-js/**" - "crates/ci-core/**" + - "tests/fixtures/**" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/js.yml" @@ -13,6 +30,7 @@ on: paths: - "crates/ci-js/**" - "crates/ci-core/**" + - "tests/fixtures/**" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/js.yml" @@ -66,24 +84,32 @@ jobs: with: node-version: lts/* cache: npm - cache-dependency-path: crates/ci-js/package-lock.json + cache-dependency-path: crates/ci-js/tests/package-lock.json - name: Install Rust uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 + # A real wasm-pack release (not the placeholder installer script). - name: Install wasm-pack - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: latest + # Verified locally: builds into crates/ci-js/pkg (wasm-opt disabled via the + # crate's package metadata). The vitest suite imports ../pkg/ci_js.js. - name: Build WASM package - working-directory: crates/ci-js - run: wasm-pack build --target nodejs --out-dir pkg + run: wasm-pack build crates/ci-js --target nodejs - - name: Install dependencies - working-directory: crates/ci-js + # Verified locally: `npm ci && npm test` in crates/ci-js/tests runs vitest; + # the golden suite covers all 73 fixture cases. + - name: Install test dependencies + working-directory: crates/ci-js/tests run: npm ci - name: Run tests - working-directory: crates/ci-js - run: npx vitest run --passWithNoTests + working-directory: crates/ci-js/tests + run: npm test diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 11c2808..773cf07 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -1,11 +1,24 @@ name: Python CI +# NOTE: The build/test commands below were verified by running them locally +# against this branch. They are NOT exercised on GitHub Actions until this +# workflow runs there, so the exact runner setup may still need tweaks. +# +# The bindings are built with maturin. On a Python newer than the installed +# PyO3 supports, set PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 (the crate already +# enables the `abi3-py310` feature, so a single abi3 wheel covers 3.10+). On CI +# we pin a supported Python (3.11-3.13) where that env var is not needed. +# +# The redesign removed the generated stubs / stub_gen binary, so there is no +# longer a "stubs up-to-date" job; the checked-in .pyi is maintained by hand. + on: push: branches: [master, development] paths: - 'crates/ci-python/**' - 'crates/ci-core/**' + - 'tests/fixtures/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/python.yml' @@ -13,6 +26,7 @@ on: paths: - 'crates/ci-python/**' - 'crates/ci-core/**' + - 'tests/fixtures/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/python.yml' @@ -34,11 +48,12 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - name: Install ruff run: pip install ruff + # Verified locally: `ruff format --check .` / `ruff check .` in crates/ci-python. - name: Check formatting working-directory: crates/ci-python run: ruff format --check . @@ -47,27 +62,6 @@ jobs: working-directory: crates/ci-python run: ruff check . - python-stubs: - name: Python Stubs Up-to-date - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Regenerate stubs - run: cargo run --release --bin stub_gen - - - name: Check for diff - run: git diff --exit-code -- crates/ci-python/ - python-test: name: Python Tests needs: [python-lint] @@ -75,12 +69,15 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] + # Use a Python supported by the installed PyO3; no forward-compat env + # var needed here. The abi3-py310 wheel also works on 3.10+ at runtime. + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: ${{ matrix.python-version }} - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -90,6 +87,10 @@ jobs: - name: Install Python dependencies run: pip install maturin pytest numpy mypy + # Verified locally: maturin builds the extension; the golden parity test + # (crates/ci-python/test/test_golden.py) reproduces all 73 fixture cases. + # On a too-new interpreter, prefix with + # PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 (not required for 3.11-3.13). - name: Build and install extension working-directory: crates/ci-python run: pip install -e . --no-build-isolation @@ -98,7 +99,6 @@ jobs: working-directory: crates/ci-python run: mypy test/ + # Verified locally: `pytest crates/ci-python/test/` -> golden = 73/73. - name: Run tests - working-directory: crates/ci-python - shell: bash - run: pytest || [ "$?" -eq 5 ] + run: pytest crates/ci-python/test/ diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index d419044..035b793 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -1,11 +1,31 @@ name: R CI +# NOTE: The build/test commands below were verified by running them locally +# against this branch. They are NOT exercised on GitHub Actions until this +# workflow runs there, so the exact runner setup may still need tweaks. +# +# Toolchain: R + rextendr (which compiles the embedded Rust crate), devtools, +# testthat, and jsonlite (the golden suite reads tests/fixtures/golden.json via +# jsonlite::fromJSON). rextendr::document() regenerates the extendr wrappers, +# then devtools::test() runs the package suite (469 tests, including the 73-case +# golden parity gate). +# +# We use r-lib/actions/setup-r (a standard CRAN R), which does NOT require the +# LD_LIBRARY_PATH workaround. (A *conda* R does: there you must export +# LD_LIBRARY_PATH=$(Rscript -e 'cat(R.home("lib"))') so the embedded Rust can +# link libR — not needed with the standard action below.) +# +# Commands are run from the repo root with the package path passed explicitly +# (rextendr::document("crates/ci-r") / devtools::test("crates/ci-r")) so the +# golden suite reliably resolves the repo-root fixture. + on: push: branches: [master, development] paths: - 'crates/ci-r/**' - 'crates/ci-core/**' + - 'tests/fixtures/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/r.yml' @@ -13,6 +33,7 @@ on: paths: - 'crates/ci-r/**' - 'crates/ci-core/**' + - 'tests/fixtures/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/r.yml' @@ -74,18 +95,21 @@ jobs: - uses: Swatinem/rust-cache@v2 + # jsonlite is required by the golden suite but is not a package dependency, + # so install it alongside devtools/rextendr/testthat. - name: Install R package dependencies uses: r-lib/actions/setup-r-dependencies@v2 with: working-directory: crates/ci-r - extra-packages: any::devtools, any::rextendr + extra-packages: any::devtools, any::rextendr, any::testthat, any::jsonlite + # Verified locally: regenerate the extendr wrappers (compiles the Rust). - name: Generate Makevars and R wrappers - working-directory: crates/ci-r - run: rextendr::document() + run: rextendr::document("crates/ci-r") shell: Rscript {0} + # Verified locally: `devtools::test("crates/ci-r")` -> 469 tests incl. the + # 73-case golden parity gate (tests/fixtures/golden.json). - name: Run R tests - working-directory: crates/ci-r - run: devtools::test(reporter = "summary") + run: devtools::test("crates/ci-r", reporter = "summary") shell: Rscript {0} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a726cfa..23cc959 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,5 +1,16 @@ name: Rust CI +# NOTE: The build/test/lint commands below were verified by running them locally +# against this branch's toolchain. They are NOT exercised on GitHub Actions until +# this workflow runs there, so the exact runner setup (toolchain versions, OS +# package availability) may still need tweaks. +# +# The workspace is no longer built/tested as a single step: the binding crates +# (ci_python / cir / ci_js) each need their own non-cargo toolchain (maturin, +# rextendr/R, wasm-pack) and are covered by python.yml / r.yml / js.yml. This +# workflow builds and gates only the pure-Rust core crate (ci_core), whose tests +# include the 73-case shared golden parity fixture. + on: push: branches: [master, development] @@ -8,6 +19,7 @@ on: - 'crates/ci-python/**' - 'crates/ci-r/**' - 'crates/ci-js/**' + - 'tests/fixtures/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/rust.yml' @@ -17,6 +29,7 @@ on: - 'crates/ci-python/**' - 'crates/ci-r/**' - 'crates/ci-js/**' + - 'tests/fixtures/**' - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/rust.yml' @@ -43,27 +56,16 @@ jobs: - uses: Swatinem/rust-cache@v2 + # Verified locally: `cargo fmt --all -- --check` - name: Check formatting run: cargo fmt --all -- --check clippy: - name: Rust Clippy Lints + name: Rust Clippy Lints (ci_core) runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install system dependencies - run: sudo apt-get install -y libopenblas-dev - - - name: Install R - uses: r-lib/actions/setup-r@v2 - with: - r-version: release - - name: Install Rust uses: dtolnay/rust-toolchain@stable with: @@ -71,11 +73,14 @@ jobs: - uses: Swatinem/rust-cache@v2 + # Verified locally: `cargo clippy -p ci_core --all-targets -- -D warnings`. + # Scoped to ci_core so this job needs no Python/R/wasm toolchain; the + # binding crates are linted by their own workflows. - name: Run Clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy -p ci_core --all-targets -- -D warnings rust_test: - name: Rust Tests + name: Rust Tests (ci_core) needs: [rust_fmt, clippy] runs-on: ${{ matrix.os }} strategy: @@ -92,8 +97,10 @@ jobs: - uses: Swatinem/rust-cache@v2 + # Verified locally: `cargo test -p ci_core` (includes the 73-case golden + # parity test in crates/ci-core/tests/golden.rs). - name: Build - run: cargo build --workspace --exclude ci_python --exclude cir --exclude ci_js --verbose + run: cargo build -p ci_core --verbose - name: Run tests - run: cargo test --workspace --exclude ci_python --exclude cir --exclude ci_js --verbose + run: cargo test -p ci_core --verbose diff --git a/.gitignore b/.gitignore index 0718f59..0546615 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,8 @@ todo.txt # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode *.code-workspace + +# R: generated at install time by tools/config.R via configure; never track +# (a stale tracked copy can silently force a debug build). +crates/ci-r/src/Makevars +crates/ci-r/src/Makevars.win diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a706b3..700e954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,14 +29,14 @@ This document provides guidelines and instructions for contributing to the CI Te cd Conditional-Independence-Testing ``` -2. **Build the workspace**: +2. **Build the core crate** (each binding has its own toolchain; see [Testing](#testing)): ```bash - cargo build --workspace + cargo build -p ci_core ``` 3. **Run tests to verify setup**: ```bash - cargo test --workspace + cargo test -p ci_core ``` 4. **Install development tools**: @@ -55,11 +55,11 @@ Run these commands to ensure everything works: # Check formatting cargo fmt --all -- --check -# Run linter -cargo clippy --workspace --all-targets -- -D warnings +# Run linter on the core crate +cargo clippy -p ci_core --all-targets -- -D warnings -# Run all tests -cargo test --workspace +# Run the core crate's tests (includes the golden parity test) +cargo test -p ci_core ``` If all commands complete successfully, your environment is ready! @@ -67,14 +67,15 @@ If all commands complete successfully, your environment is ready! ## Repository Structure ``` Conditional-Independence-Testing/ -├── crates/ # Rust workspace -│ ├── ci-core/ # Core CI test implementations -│ ├── ci-python/ # Python bindings (PyO3) -│ ├── ci-r/ # R bindings (extendr) -│ └── ci-js/ # JavaScript/WASM bindings (wasm-pack) -├── examples/ # Usage examples per language -├── scripts/ # Build and development utilities -└── .github/ # CI/CD workflows +├── crates/ # Rust workspace +│ ├── ci-core/ # Core CI test implementations, Dataset, CITest trait, registry +│ ├── ci-python/ # Python bindings (PyO3 + maturin) +│ ├── ci-r/ # R bindings (extendr); R package name is `cir` +│ └── ci-js/ # JavaScript/WASM bindings (wasm-pack) +├── tests/fixtures/ # Shared golden.json + generate_golden.py (cross-language parity) +├── benchmarks/ # Value-parity and runtime benchmarks +├── docs/ # API examples and design docs +└── .github/ # CI/CD workflows (one per language) ``` See individual `README.md` files in each directory for more details. @@ -95,7 +96,8 @@ We follow the official Rust style guide, enforced by `rustfmt`: We use Clippy with strict settings: - **All Clippy warnings must be fixed** before merging -- Run `cargo clippy --workspace --all-targets -- -D warnings` +- Run `cargo clippy -p ci_core --all-targets -- -D warnings` (and the relevant binding crate + for binding changes) - If you believe a warning is a false positive, discuss in your PR ### Code Quality @@ -139,7 +141,11 @@ test(core): add property tests for chi-squared test ## Adding a New CI Test -Follow these steps to add a new conditional independence test: +The library is **data-bound**: a test holds only its own configuration and operates on a +`Dataset` passed to it at query time. Adding a test means implementing the `CITest` trait in +the core, registering it, and adding a thin wrapper in each binding. Unlike the old codegen +path, the bindings are now hand-written (one small wrapper per language), so a new test is a +handful of mechanical additions. ### 1. Create the Core Implementation @@ -150,43 +156,64 @@ crates/ci-core/src/ci_tests/students_t.rs ### 2. Implement the `CITest` Trait -Your test must implement the `CITest` trait defined in `crates/ci-core/src/strategy.rs`: +Your test struct holds **only** its configuration and implements the `CITest` trait defined in +`crates/ci-core/src/strategy.rs`. You implement the required `test_impl` method; the provided +`test` method validates the `(x, y, z)` query and delegates to it, so implementations may +assume a well-formed query. Per-test config is constructor fields, the decision rule lives in +`TestMeta`, and `is_independent` has a default impl (no per-test branching at the call site): ```rust -use crate::strategy::{CITest, CITestDataType, TestResult}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, DataType, IndependenceRule, TestMeta}; -pub struct StudentsT { /* fields */ } +pub struct StudentsT { /* config fields only */ } impl CITest for StudentsT { - fn run_test( - &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - // ... + fn test_impl(&self, data: &Dataset, x: usize, y: usize, z: &[usize]) -> Result { + // ... return CiResult { statistic, p_value, dof, effect_size } } - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Continuous] + fn meta(&self) -> TestMeta { + TestMeta { + name: "students_t", + data_types: &[DataType::Continuous], + symmetric: true, + rule: IndependenceRule::PValueGe, // or PValueLt for an equivalence/TOST test + } } } ``` -See existing tests (e.g., `chi_squared.rs`) as examples. +See existing tests (e.g., `chi_squared.rs`, or `pearson_equivalence.rs` for the inverted rule) +as examples. Return `CiError` rather than panicking; never let a panic escape into a binding. -### 3. Export from the Module +### 3. Export and Register the Test -Add your new test to `crates/ci-core/src/ci_tests/mod.rs` so it is publicly accessible: +- Add your test to `crates/ci-core/src/ci_tests/mod.rs` so it is publicly accessible: -```rust -pub mod students_t; -pub use students_t::StudentsT; -``` + ```rust + pub mod students_t; + pub use students_t::StudentsT; + ``` -### 4. Add Python Bindings +- Register it in `crates/ci-core/src/registry.rs` (`default_tests()`), the single source of + truth that lets callers enumerate tests and construct one by its stable `meta().name`. Add + the name to the registry's test assertions too. -Python bindings are automatically generated by [build.rs](crates/ci-python/build.rs). Make sure that they implement the [`CITest`](crates/ci-core/src/strategy.rs#L25) trait and are at the root of the [`::ci_core::ci_tests`](crates/ci-core/src/ci_tests) module. +### 4. Add the Binding Wrappers + +Each binding has one small wrapper per test; add yours next to the existing ones: + +- **Python** — add a `ci_test_class!(...)` invocation in `crates/ci-python/src/lib.rs` (mapping + the constructor kwargs onto your config) and register the generated class in the `#[pymodule]` + at the bottom of that file. Re-export it from `crates/ci-python/ci_python/__init__.py` and add + it to the `.pyi` stub. +- **R** — add a factory function in `crates/ci-r/R/cir.R` (following `chi_squared()` / + `pearson_equivalence()`), and export the extendr handle in the Rust glue + (`crates/ci-r/src/rust/src/lib.rs`). +- **JavaScript** — add a `ci_test_class!(...)` invocation in `crates/ci-js/src/lib.rs` (with a + `serde`-derived config struct if the test takes options). ### 5. Add Tests @@ -195,29 +222,53 @@ Add test cases for each language: - **Rust**: In a `#[cfg(test)] mod tests { }` block in your implementation file - **Python**: In [`crates/ci-python/test`](crates/ci-python/test). - **R**: In `crates/ci-r/tests/testthat/` +- **JavaScript**: In [`crates/ci-js/tests`](crates/ci-js/tests). -Test with known inputs and expected outputs, and cover edge cases (empty data, NaN values, etc.). +Test with known inputs and expected outputs, and cover edge cases (single category, +zero-variance, NaN, sparse strata). If your test maps to a scipy/standard reference, add rows +for it to the shared golden fixture (see [Testing](#testing)) so every language checks it. ### 6. Update Documentation - Add doc comments to your test struct and methods -- Add usage examples in `examples/` +- Add the test to the "Available Tests" table in `README.md` +- If it has a notable usage pattern, document it (e.g. in `docs/api-examples.md`) ## Testing -### Rust Tests +Because each crate uses a different toolchain, there is no single workspace-wide test command; +run each language's suite with its own tooling, as below. + +### Shared golden fixture (cross-language parity gate) + +The file `tests/fixtures/golden.json` (at the repository root) holds reference values generated +from scipy / standard references by `tests/fixtures/generate_golden.py` — for each case: the +test name, its params (`yates` / `delta_threshold`), the columns (with kinds), `X` / `Y` / `Z`, +and the expected `statistic` / `p_value` / `dof` / `effect_size`. **Every** language test suite +reads this same fixture and asserts its binding reproduces the values (within `1e-7`), so it is +the cross-language numeric parity gate: + +- Rust: `crates/ci-core/tests/golden.rs` +- Python: `crates/ci-python/test/test_golden.py` +- R: `crates/ci-r/tests/testthat/test-golden.R` +- JavaScript: `crates/ci-js/tests/golden.test.js` + +If you change a statistic or add a test, regenerate the fixture and re-run every suite: + ```bash -# Run all Rust tests -cargo test --workspace +python tests/fixtures/generate_golden.py # requires numpy + scipy +``` -# Run tests for a specific crate +### Rust Tests +```bash +# Run the core crate's tests (includes the golden parity test) cargo test -p ci_core # Run a specific test -cargo test test_chi_squared +cargo test -p ci_core test_chi_squared # Run with output (see println! statements) -cargo test -- --nocapture +cargo test -p ci_core -- --nocapture ``` ### Python Tests @@ -232,11 +283,11 @@ cd crates/ci-python pip install maturin maturin develop -# Run tests -pytest +# Run tests (includes test_golden.py) +pytest test/ # Type-check the test suite -mypy tests/ +mypy test/ ``` The CI pipeline also checks formatting and linting: @@ -253,20 +304,44 @@ R tests use [testthat](https://testthat.r-lib.org/) via the ```r # From an R session in crates/ci-r/ rextendr::document() # Recompile the Rust code and regenerate wrappers -devtools::test() # Run all tests +devtools::test() # Run all tests (includes test-golden.R) ``` +> **Benchmarking warning:** `rextendr::document()` and `devtools::load_all()` +> compile the Rust core **without optimizations** (debug profile) — fine for +> tests, useless for benchmarks. To benchmark, install a release build: +> `NOT_CRAN=true R CMD INSTALL crates/ci-r` (the `configure` script selects +> `--release` whenever the `DEBUG` env var is unset) and `library(cir)`. +> `src/Makevars` is generated by `tools/config.R` at install time and must not +> be committed. + The CI pipeline also checks style and linting: ```r styler::style_pkg() lintr::lint_package() ``` +### JavaScript Tests + +JavaScript tests use [vitest](https://vitest.dev/) and require the WASM package to be built +first with [wasm-pack](https://rustwasm.github.io/wasm-pack/): + +```bash +# Build the WASM package into crates/ci-js/pkg +wasm-pack build crates/ci-js --target nodejs + +# From crates/ci-js/tests +npm ci +npm test # vitest, includes golden.test.js +``` + ### Test Organisation - **Unit tests**: Inline in each source file, inside `#[cfg(test)] mod tests { }` +- **Golden parity tests**: one per language, all reading `tests/fixtures/golden.json` - **Python integration tests**: [`crates/ci-python/test`](crates/ci-python/test) - **R tests**: `crates/ci-r/tests/testthat/` +- **JavaScript tests**: [`crates/ci-js/tests`](crates/ci-js/tests) ### Writing Tests @@ -286,11 +361,12 @@ lintr::lint_package() 2. **Make your changes** and commit with clear messages -3. **Ensure all checks pass locally**: +3. **Ensure all checks pass locally** (run the suites for the languages you touched — see + [Testing](#testing); for a core change): ```bash cargo fmt --all - cargo clippy --workspace --all-targets -- -D warnings - cargo test --workspace + cargo clippy -p ci_core --all-targets -- -D warnings + cargo test -p ci_core ``` 4. **Push your branch**: diff --git a/Cargo.lock b/Cargo.lock index d128252..2ad0680 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anes" version = "0.1.6" @@ -86,87 +77,28 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", -] - [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "cc" -version = "1.2.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "ci_core" version = "0.1.0" dependencies = [ - "anyhow", "criterion", - "libm", - "nalgebra", - "ndarray", - "ordered-float", "proptest", - "rand 0.9.4", - "rand_distr 0.5.1", + "serde", + "serde_json", "statrs", + "thiserror", ] [[package]] @@ -178,7 +110,8 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "js-sys", - "ndarray", + "serde", + "serde-wasm-bindgen", "wasm-bindgen", ] @@ -187,14 +120,8 @@ name = "ci_python" version = "0.1.0" dependencies = [ "ci_core", - "ndarray", "numpy", - "proc-macro2", "pyo3", - "pyo3-stub-gen", - "pyo3-stub-gen-derive", - "quote", - "syn", ] [[package]] @@ -228,7 +155,6 @@ dependencies = [ name = "cir" version = "0.1.0" dependencies = [ - "anyhow", "ci_core", "extendr-api", ] @@ -268,12 +194,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "criterion" version = "0.5.1" @@ -286,7 +206,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools 0.10.5", + "itertools", "num-traits", "once_cell", "oorandom", @@ -307,7 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools 0.10.5", + "itertools", ] [[package]] @@ -371,7 +291,6 @@ checksum = "803569de0d273b4bf281871046a7d63a23cc12776bdb5b63de5c1e81aae30728" dependencies = [ "extendr-ffi", "extendr-macros", - "ndarray", "once_cell", "paste", "readonly", @@ -401,12 +320,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "fnv" version = "1.0.7" @@ -521,30 +434,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "id-arena" version = "2.3.0" @@ -572,15 +461,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -601,15 +481,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -664,12 +535,6 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - [[package]] name = "matrixmultiply" version = "0.3.10" @@ -703,27 +568,15 @@ checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" dependencies = [ "approx", "matrixmultiply", - "nalgebra-macros", "num-complex", "num-rational", "num-traits", "rand 0.8.6", - "rand_distr 0.4.3", + "rand_distr", "simba", "typenum", ] -[[package]] -name = "nalgebra-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ndarray" version = "0.16.1" @@ -800,7 +653,7 @@ dependencies = [ "num-integer", "num-traits", "pyo3", - "pyo3-build-config 0.24.2", + "pyo3-build-config", "rustc-hash", ] @@ -816,15 +669,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "ordered-float" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" -dependencies = [ - "num-traits", -] - [[package]] name = "paste" version = "1.0.15" @@ -939,7 +783,7 @@ dependencies = [ "memoffset", "once_cell", "portable-atomic", - "pyo3-build-config 0.24.2", + "pyo3-build-config", "pyo3-ffi", "pyo3-macros", "unindent", @@ -955,15 +799,6 @@ dependencies = [ "target-lexicon", ] -[[package]] -name = "pyo3-build-config" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" -dependencies = [ - "target-lexicon", -] - [[package]] name = "pyo3-ffi" version = "0.24.2" @@ -971,7 +806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" dependencies = [ "libc", - "pyo3-build-config 0.24.2", + "pyo3-build-config", ] [[package]] @@ -994,44 +829,7 @@ checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" dependencies = [ "heck", "proc-macro2", - "pyo3-build-config 0.24.2", - "quote", - "syn", -] - -[[package]] -name = "pyo3-stub-gen" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da99110990aded329ea6e5e6567bcab1577a2109253cebbd54d23cd61951752" -dependencies = [ - "anyhow", - "cargo_metadata", - "chrono", - "either", - "indexmap", - "inventory", - "itertools 0.13.0", - "log", - "maplit", - "num-complex", - "numpy", - "pyo3", - "pyo3-build-config 0.28.3", - "pyo3-stub-gen-derive", - "semver", - "serde", - "toml", -] - -[[package]] -name = "pyo3-stub-gen-derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a036cb01c21f3014989614036a69f1467bfbfde608a37f98eaefb016b1abfe" -dependencies = [ - "heck", - "proc-macro2", + "pyo3-build-config", "quote", "syn", ] @@ -1132,16 +930,6 @@ dependencies = [ "rand 0.8.6", ] -[[package]] -name = "rand_distr" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" -dependencies = [ - "num-traits", - "rand 0.9.4", -] - [[package]] name = "rand_xorshift" version = "0.4.0" @@ -1277,10 +1065,6 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "serde" @@ -1292,6 +1076,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1325,21 +1120,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "simba" version = "0.9.1" @@ -1431,47 +1211,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "typenum" version = "1.20.1" @@ -1653,65 +1392,12 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1721,15 +1407,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/README.md b/README.md index aa87d74..8336607 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,14 @@ through thin, idiomatic bindings for **Python, R, and JavaScript/WebAssembly**. ## Quick Start +The API is **data-bound**: you bind a dataset once (its discrete columns are factorized a +single time), then query any `X ⟂ Y | Z` against it. Each test exposes the same surface — +`run_test(X, Y, Z)` returns a uniform result (`statistic`, `p_value`, `dof`, `effect_size`), +and `is_independent(X, Y, Z, significance_level)` applies that test's own decision rule. +Per-test configuration (e.g. `yates`, `delta_threshold`) lives in the constructor. + Each binding compiles the Rust core from source, so you need a Rust toolchain installed -(via [rustup](https://rustup.rs)). In every language a test takes the observation vectors -`x` and `y` and a conditioning matrix `z` whose columns are the conditioning variables; -pass a zero-column matrix for an unconditional test. +(via [rustup](https://rustup.rs)). ### Python @@ -29,59 +33,79 @@ maturin develop -m crates/ci-python/Cargo.toml ``` ```python -import numpy as np -from ci_python import ChiSquared - -x = np.array([1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0]) -y = np.array([1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0]) - -test = ChiSquared(boolean=False, significance_level=0.05) - -# Unconditional: pass an (n, 0) matrix for an empty conditioning set -p_value, statistic, dof = test.run_test(x, y, np.empty((len(x), 0))) -print(f"p={p_value:.4f}, chi2={statistic:.4f}, df={dof}") - -# Conditional: each column of z is one conditioning variable -z = np.array([[1.0]] * 4 + [[2.0]] * 4) -p_value, statistic, dof = test.run_test(x, y, z) +import numpy as np, pandas as pd +from ci_python import Dataset, ChiSquared, FisherZ, PearsonEquivalence + +rng = np.random.default_rng(0) +df = pd.DataFrame({ + "A": rng.integers(0, 2, 500), "B": rng.integers(0, 3, 500), "C": rng.integers(0, 2, 500), # int -> discrete + "X": rng.standard_normal(500), "Y": rng.standard_normal(500), "Z": rng.standard_normal(500), # float -> continuous +}) + +# Bind once; column kinds are inferred from dtypes. +data = Dataset.from_pandas(df) +# You can also pass the DataFrame straight to a test constructor — `ChiSquared(df)` binds it +# implicitly (building a fresh Dataset; share one explicitly to factorize once). + +# Discrete chi-squared (Yates' correction on by default). +chi = ChiSquared(data) +res = chi.run_test("A", "C", ["B"]) # A ⟂ C | B +print(res.statistic, res.p_value, res.dof, res.effect_size) +chi.is_independent("A", "C", ["B"], significance_level=0.05) # -> bool (independent ⇔ p ≥ α) + +# Continuous Pearson equivalence (TOST); per-test config is constructor-only. +eqv = PearsonEquivalence(data, delta_threshold=0.1) +res = eqv.run_test("X", "Y", ["Z"]) +print(res.statistic, res.p_value, res.effect_size) # res.dof is None for continuous tests +eqv.is_independent("X", "Y", ["Z"], significance_level=0.05) # -> bool (independent ⇔ p < α) ``` -Continuous tests (`PearsonCorrelation`, `PearsonEquivalence`) share the same interface but -return `(p_value, coefficient)`. In boolean mode (`boolean=True`) every test returns just a -`bool` independence verdict. +`x` / `y` accept a column name or an integer index, and `z` is a sequence of names/indices +(default: empty conditioning set). You can also build a `Dataset` directly from a +`{name: (kind, values)}` mapping, or pass that mapping straight to a test constructor; one +`Dataset` can be shared across several tests so the factorization happens once. ### R -Install the package from the repository root: +Install the package from the repository root (the R package is named `cir`): ```r # install.packages("devtools") devtools::install("crates/ci-r") ``` +A `fisher_z(data)` factory accompanies the factories below, mirroring the Python/JS `FisherZ`. + ```r library(cir) -x <- c(1, 1, 2, 2, 1, 1, 2, 2) -y <- c(1, 2, 1, 2, 1, 2, 1, 2) - -# Unconditional: a 0-column matrix is an empty conditioning set -z <- matrix(nrow = length(x), ncol = 0) -result <- chi_squared_test(x, y, z, boolean = FALSE, significance_level = 0.05) -cat("p =", result$p_value, " chi2 =", result$statistic, " df =", result$df, "\n") - -# Conditional: each column of z is one conditioning variable -z <- matrix(c(1, 1, 1, 1, 2, 2, 2, 2), ncol = 1) -result <- chi_squared_test(x, y, z, boolean = FALSE, significance_level = 0.05) - -# Boolean mode returns only an independence verdict -verdict <- pearson_correlation_test(x, y, z, boolean = TRUE, significance_level = 0.05) -cat("independent:", verdict$independent, "\n") +set.seed(0) +df <- data.frame( + A = sample(0:1, 500, TRUE), B = sample(0:2, 500, TRUE), C = sample(0:1, 500, TRUE), # integer -> discrete + X = rnorm(500), Y = rnorm(500), Z = rnorm(500) # numeric -> continuous +) + +# Bind once; column kinds are inferred from column classes. +data <- dataset(df) + +# Discrete chi-squared (Yates' correction on by default). +chi <- chi_squared(data) +res <- run_test(chi, "A", "C", c("B")) # A ⟂ C | B +c(res$statistic, res$p_value, res$dof, res$effect_size) +is_independent(chi, "A", "C", c("B"), significance_level = 0.05) # -> logical (p >= alpha) + +# Continuous Pearson equivalence (TOST); config is constructor-only. +eqv <- pearson_equivalence(df, delta_threshold = 0.1) +res <- run_test(eqv, "X", "Y", c("Z")) +c(res$statistic, res$p_value) # res$dof is NULL for continuous tests +is_independent(eqv, "X", "Y", c("Z"), significance_level = 0.05) # -> logical (p < alpha) + +# Any test adapts to pcalg's indepTest(x, y, S, suffStat) interface: +indepTest <- as_pcalg(chi) ``` -Each function returns a named list with a `kind` field: `"statistic"` (with `p_value`, -`statistic`, `df`), `"pvalue"` (with `p_value`, `coefficient`), or `"boolean"` (with -`independent`). +`run_test` / `is_independent` return / consume column **names**; `z` is a character vector of +conditioning names. A factory accepts either a pre-built `dataset()` or a raw `data.frame`. ### JavaScript @@ -93,74 +117,101 @@ wasm-pack build crates/ci-js --target web # for browsers wasm-pack build crates/ci-js --target nodejs # for Node.js ``` +There are no dtypes in JS, so each column carries its `kind`. You can pass columns directly +to a constructor — `new ChiSquared(cols)` (implicit; copies once per test object) — or build +a shared `Dataset` first — `new ChiSquared(data)` — so the arrays cross the JS↔wasm boundary +only once and can be reused across multiple tests: + ```js -import init, { chi_squared_test } from "./pkg/ci_js.js"; +import init, { Dataset, ChiSquared, FisherZ, PearsonEquivalence } from "./pkg/ci_js.js"; await init(); // load the WebAssembly module (web target) -const x = new Float64Array([1, 1, 2, 2, 1, 1, 2, 2]); -const y = new Float64Array([1, 2, 1, 2, 1, 2, 1, 2]); +const data = new Dataset({ + A: { kind: "discrete", values: [/* ... */] }, B: { kind: "discrete", values: [/* ... */] }, C: { kind: "discrete", values: [/* ... */] }, + X: { kind: "continuous", values: [/* ... */] }, Y: { kind: "continuous", values: [/* ... */] }, Z: { kind: "continuous", values: [/* ... */] }, +}); -// Unconditional: pass an empty Float64Array; z_rows = z_cols = 0 -const [pValue, statistic, dof] = chi_squared_test(new Float64Array(0), 0, 0, x, y, false, 0.05); -console.log(`p=${pValue.toFixed(4)}, chi2=${statistic.toFixed(4)}, df=${dof}`); +// Discrete chi-squared (config object optional: { yates }). +const chi = new ChiSquared(data); +const r1 = chi.runTest("A", "C", ["B"]); // { statistic, pValue, dof, effectSize } +chi.isIndependent("A", "C", ["B"], 0.05); // -> boolean (p >= alpha) -// Conditional: z is a row-major flattened (z_rows x z_cols) matrix -const z = new Float64Array([1, 1, 1, 1, 2, 2, 2, 2]); -const [pCond] = chi_squared_test(z, 8, 1, x, y, false, 0.05); +// Continuous Pearson equivalence (TOST); config is constructor-only. +const eqv = new PearsonEquivalence(data, { deltaThreshold: 0.1 }); +const r2 = eqv.runTest("X", "Y", ["Z"]); // r2.dof === null +eqv.isIndependent("X", "Y", ["Z"], 0.05); // -> boolean (p < alpha) ``` -The conditioning matrix is passed flattened (`z_flat`, `z_rows`, `z_cols`) because -WebAssembly has no native 2-D array type. Discrete tests return `[p_value, statistic, dof]`, -continuous tests return `[p_value, coefficient]`, and boolean mode returns a `bool`. +`runTest` / `isIndependent` take column names (`x`, `y`) and an array of conditioning names +(`z`); the result is a plain object with `statistic`, `pValue`, `dof`, and `effectSize` +(`null` for fields a test does not define). ## Available Tests -| Test | Data type | Numeric output | -|---|---|---| -| `chi_squared` | Discrete | `(p_value, statistic, dof)` | -| `log_likelihood` (G-test) | Discrete | `(p_value, statistic, dof)` | -| `cressie_read` | Discrete | `(p_value, statistic, dof)` | -| `freeman_tukey` | Discrete | `(p_value, statistic, dof)` | -| `modified_likelihood` | Discrete | `(p_value, statistic, dof)` | -| `pearson_correlation` | Continuous | `(p_value, coefficient)` | -| `pearson_equivalence` | Continuous | `(p_value, coefficient)` | - -- **Conditioning.** Every test accepts a conditioning matrix `Z` (each column is one - conditioning variable). For conditional discrete tests the statistic is summed over the - strata defined by `Z`; for continuous tests the partial correlation is taken on the - regression residuals. +| Test | Data type | Constructor config | Decision rule | +|---|---|---|---| +| `chi_squared` | Discrete | `yates` (default `true`) | `p ≥ α` | +| `log_likelihood` (G-test) | Discrete | `yates` (default `true`) | `p ≥ α` | +| `cressie_read` | Discrete | `yates` (default `true`) | `p ≥ α` | +| `freeman_tukey` | Discrete | `yates` (default `true`) | `p ≥ α` | +| `modified_likelihood` | Discrete | `yates` (default `true`) | `p ≥ α` | +| `pearson_correlation` | Continuous | — | `p ≥ α` | +| `fisher_z` | Continuous | — | `p ≥ α` | +| `pearson_equivalence` | Continuous | `delta_threshold` (default `0.1`) | `p < α` (TOST) | + +- **Uniform result.** Every `run_test(X, Y, Z)` returns the same `CiResult`: `statistic`, + `p_value`, `dof`, and `effect_size`. Fields a test does not define are absent + (`None` / `NULL` / `null`) — e.g. continuous tests report no `dof`. The discrete tests + report Cramér's V and the continuous tests the (partial) correlation as `effect_size`. +- **Independence decision.** `significance_level` (α) is **not** baked into the test; it is + passed to `is_independent(X, Y, Z, significance_level)`, which applies that test's own rule + from its metadata — the normal `p ≥ α` for most tests and the inverted `p < α` for the + equivalence test — so the caller never writes the rule. +- **Conditioning.** Every test accepts a conditioning set `Z` (a list/vector of variables). + For conditional discrete tests the statistic is summed over the strata defined by `Z`; for + continuous tests the partial correlation is derived from a lazily cached covariance matrix + (O(|Z|³) per query after the first continuous test; falls back to per-query regression for + datasets with more than 2048 continuous columns), with `dof = n − |Z| − 2`. +- **Missing data.** NaN (Python/JS) and NA (R) are rejected when the dataset is bound — drop + or impute first. Discrete columns accept strings everywhere (factorized to integer codes + internally). - **Discrete family.** The discrete tests are members of the power-divergence family and differ only in the $\lambda$ parameter: `chi_squared` ($1$), `log_likelihood` ($0$), - `cressie_read` ($2/3$), `freeman_tukey` ($-1/2$), `modified_likelihood` ($-1$). -- **Boolean mode.** With `boolean=true`, a test returns a single independence verdict at the - given `significance_level` instead of the numeric tuple. -- **Equivalence test.** `pearson_equivalence` is an equivalence (TOST) test: it additionally - takes a `delta_threshold` and declares independence when the partial correlation lies - within that margin of zero. -- **Naming.** Python exposes these as classes (`ChiSquared`, `LogLikelihood`, …); R and - JavaScript expose them as functions with a `_test` suffix (`chi_squared_test`, …). + `cressie_read` ($2/3$), `freeman_tukey` ($-1/2$), `modified_likelihood` ($-1$). They share a + `yates` option (default `true`) applying Yates' continuity correction on 2×2 (sub-)tables, + matching `scipy.stats.chi2_contingency` and pgmpy. +- **Equivalence test.** `pearson_equivalence` is an equivalence (TOST) test: it takes a + `delta_threshold` (the equivalence margin on the correlation scale) and uses the **inverted** + decision rule — it declares independence when `p < α`, i.e. when the partial correlation is + confidently *within* that margin of zero. +- **Naming.** Python and JavaScript expose these as classes (`ChiSquared`, `LogLikelihood`, + …); R exposes them as factory functions (`chi_squared`, `log_likelihood`, …). ## Package Structure & Contributing ``` -ci-core Rust core: all test implementations and the CITest trait -ci-python Python bindings (PyO3) -> import ci_python -ci-r R package (extendr) -> library(cir) -ci-js JavaScript / WASM (wasm-pack) +crates/ci-core Rust core: all test implementations, the CITest trait, the Dataset, and the registry +crates/ci-python Python bindings (PyO3) -> import ci_python +crates/ci-r R package (extendr) -> library(cir) +crates/ci-js JavaScript / WASM (wasm-pack) ``` -All bindings are thin wrappers that depend only on `ci-core`, so the statistics live in a -single place. The Rust core can also be used directly as a crate. Full API documentation is +All three bindings are thin wrappers that depend only on `ci-core`, so the statistics live in a +single place; each binding maps its idiomatic input (a pandas/`data.frame`/typed columns) onto +the core `Dataset` and forwards `run_test` / `is_independent`. The Rust core can also be used +directly as a crate. A shared golden fixture (`tests/fixtures/golden.json`) is consumed by every +language's test suite as the cross-language numeric parity gate. Full API documentation is published at . Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup, -coding standards, and how to add a new test. Before opening a PR: +coding standards, and how to add a new test. Each crate uses its own toolchain, so checks run +per crate rather than across the whole workspace. For a change to `ci-core`, before opening a PR: ```bash -cargo fmt --all -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace +cargo fmt --all -- --check +cargo clippy -p ci_core --all-targets -- -D warnings +cargo test -p ci_core # includes the shared golden parity test ``` ## License diff --git a/crates/ci-core/Cargo.toml b/crates/ci-core/Cargo.toml index c080046..c1c7f3a 100644 --- a/crates/ci-core/Cargo.toml +++ b/crates/ci-core/Cargo.toml @@ -12,39 +12,14 @@ categories = ["science"] exclude = [""] [dependencies] -anyhow = { workspace = true} -ndarray = { workspace = true } statrs = { workspace = true } -ordered-float = "5.3.0" -rand = "0.9" -libm = "0.2.16" -nalgebra = "0.33" - - +thiserror = "2" [dev-dependencies] criterion = { workspace = true } proptest = { workspace = true } -rand_distr = "0.5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [lints] workspace = true - -[lib] -# The library itself has no benchmarks (they live in benches/); disable its -# auto-generated bench target so `cargo bench` runs only the Criterion benches. -bench = false - -# Benchmarks use the Criterion harness, which provides its own `main`, so the -# default libtest harness must be disabled for each bench target. -[[bench]] -name = "power_divergence_bench" -harness = false - -[[bench]] -name = "contingency_test_bench" -harness = false - -[[bench]] -name = "contingency_table_bench" -harness = false \ No newline at end of file diff --git a/crates/ci-core/benches/contingency_table_bench.rs b/crates/ci-core/benches/contingency_table_bench.rs deleted file mode 100644 index ec9c9a1..0000000 --- a/crates/ci-core/benches/contingency_table_bench.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![allow(clippy::cast_precision_loss)] -#![allow(clippy::cast_possible_truncation)] -#![allow(clippy::cast_lossless)] -//otherwise we run into problems with clippy, but actual problems would only occur after 9 quadrillion - -use ci_core::utils::contingency_table::contingency_table; -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use ndarray::Array1; - -fn generate_arrays(size: usize, num_categories: usize) -> (Array1, Array1) { - let col1 = Array1::from_shape_fn(size, |i| (i % num_categories) as f64); - //Multiply by an arbitrary prime to mix up the pairings - let col2 = Array1::from_shape_fn(size, |i| ((i * 7) % num_categories) as f64); - - (col1, col2) -} - -fn benchmark_contingency_table(c: &mut Criterion) { - let (col1, col2) = generate_arrays(10_000, 50); - - let mut group = c.benchmark_group("Contingency Table (N=10k)"); - - //call original function to see performance - group.bench_function("contingency_original", |b| { - b.iter(|| { - let result = contingency_table(black_box(&col1), black_box(&col2)); - black_box(result) - }); - }); - - group.finish(); -} - -fn custom_criterion() -> Criterion { - //give criterion a bit more time to run the function more times to get a more accurate benchmark - Criterion::default().measurement_time(std::time::Duration::from_secs(10)) -} - -criterion_group! { - name = benches; - config = custom_criterion(); - targets = benchmark_contingency_table -} -criterion_main!(benches); diff --git a/crates/ci-core/benches/contingency_test_bench.rs b/crates/ci-core/benches/contingency_test_bench.rs deleted file mode 100644 index 88bb38b..0000000 --- a/crates/ci-core/benches/contingency_test_bench.rs +++ /dev/null @@ -1,53 +0,0 @@ -#![allow(clippy::cast_precision_loss)] -#![allow(clippy::cast_possible_truncation)] -#![allow(clippy::cast_lossless)] -//otherwise we run into problems with clippy, but actual problems would only occur after 9 quadrillion - -use ci_core::utils::contingency_test::contingency_test; -use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; -use ndarray::Array2; - -fn generate_matrix(n: usize, m: usize) -> Array2 { - Array2::from_shape_fn((n, m), |(i, j)| (i + j + 1) as f64) -} - -// Helper to avoid duplication -fn bench_lambda(c: &mut Criterion, name: &str, lambda: f64, observed: &Array2) { - c.bench_function(name, |b| { - b.iter_batched_ref( - || observed.clone(), - |data| { - let result = contingency_test(black_box(data), black_box(lambda)); - black_box(result) - }, - BatchSize::SmallInput, - ); - }); -} - -fn benchmark_contingency_test(c: &mut Criterion) { - let observed = generate_matrix(100, 100); - - let cases = [ - ("contingency_lambda_0", 0.0), - ("contingency_lambda_-1", -1.0), - ("contingency_lambda_2", 2.0), - ]; - - //benchmark for all values of lambda - for (name, lambda) in cases { - bench_lambda(c, name, lambda, &observed); - } -} - -fn custom_criterion() -> Criterion { - //gives criterion a bit more time to get a more accurate benchmark - Criterion::default().measurement_time(std::time::Duration::from_secs(10)) -} - -criterion_group! { - name = benches; - config = custom_criterion(); - targets = benchmark_contingency_test -} -criterion_main!(benches); diff --git a/crates/ci-core/benches/power_divergence_bench.rs b/crates/ci-core/benches/power_divergence_bench.rs deleted file mode 100644 index c122832..0000000 --- a/crates/ci-core/benches/power_divergence_bench.rs +++ /dev/null @@ -1,76 +0,0 @@ -#![allow(clippy::cast_precision_loss)] -#![allow(clippy::cast_possible_truncation)] -#![allow(clippy::cast_lossless)] -//otherwise we run into problems with clippy, but actual problems would only occur after 9 quadrillion - -use ci_core::utils::power_divergence::power_divergence; -use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; -use ndarray::{Array1, Array2}; - -fn generate_categorical_vector(len: usize, categories: usize) -> Array1 { - Array1::from_shape_fn(len, |i| (i % categories) as f64) -} - -fn generate_conditioning_matrix(rows: usize, cols: usize, categories: usize) -> Array2 { - Array2::from_shape_fn((rows, cols), |(i, j)| ((i + j) % categories) as f64) -} - -fn bench_power_divergence( - c: &mut Criterion, - name: &str, - lambda: f64, - x_values: &Array1, - y_values: &Array1, - z: &Array2, -) { - c.bench_function(name, |b| { - b.iter_batched_ref( - || (x_values.clone(), y_values.clone(), z.clone()), - |(x, y, z_data)| { - let result = power_divergence( - black_box(x), - black_box(y), - black_box(z_data), - black_box(false), - black_box(0.05), - black_box(lambda), - ); - - black_box(result) - }, - BatchSize::SmallInput, - ); - }); -} - -fn benchmark_power_divergence(c: &mut Criterion) { - let n = 100; - - let x_values = generate_categorical_vector(n, 10); - let y_values = generate_categorical_vector(n, 8); - - // Conditional case with 2 conditioning variables - let z = generate_conditioning_matrix(n, 2, 5); - - let cases = [ - ("power_divergence_lambda_0", 0.0), - ("power_divergence_lambda_-1", -1.0), - ("power_divergence_lambda_2", 2.0), - ]; - - for (name, lambda) in cases { - bench_power_divergence(c, name, lambda, &x_values, &y_values, &z); - } -} - -fn custom_criterion() -> Criterion { - Criterion::default().measurement_time(std::time::Duration::from_secs(10)) -} - -criterion_group! { - name = benches; - config = custom_criterion(); - targets = benchmark_power_divergence -} - -criterion_main!(benches); diff --git a/crates/ci-core/src/ci_tests/chi_squared.rs b/crates/ci-core/src/ci_tests/chi_squared.rs index 7bc2e82..965eb66 100644 --- a/crates/ci-core/src/ci_tests/chi_squared.rs +++ b/crates/ci-core/src/ci_tests/chi_squared.rs @@ -1,167 +1,145 @@ -use crate::strategy::{CITest, CITestDataType, TestResult}; -use crate::utils::power_divergence::power_divergence; +//! Pearson chi-squared conditional-independence test (power divergence, λ = 1). -use ndarray::{Array1, Array2}; +use crate::ci_tests::discrete_common::{discrete_meta, run_power_divergence}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, TestMeta}; -const CHI_SQUARED_LAMBDA: f64 = 1.0; +/// The power-divergence parameter for the Pearson chi-squared statistic. +const LAMBDA: f64 = 1.0; -/// Pearson chi-squared conditional independence test (λ = 1). +/// Pearson chi-squared test for discrete data (power divergence with λ = 1). /// -/// Operates on discrete data only. Delegates to the power-divergence family -/// with λ = 1, which is the classical chi-squared statistic. -#[derive(Debug, Clone, PartialEq)] +/// When `yates` is set, Yates' continuity correction is applied on 2×2 +/// (sub-)tables, matching `scipy.stats.chi2_contingency(correction=True)` and +/// pgmpy. Cramér's V is reported as the effect size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ChiSquared { - pub boolean: bool, - pub significance_level: f64, + /// Whether to apply Yates' continuity correction on 2×2 (sub-)tables. + pub yates: bool, } impl ChiSquared { + /// Construct the test with Yates' continuity correction enabled (the + /// scipy/pgmpy default). #[must_use] - pub fn new(boolean: bool, significance_level: f64) -> Self { - Self { - boolean, - significance_level, - } + pub fn new() -> Self { + Self { yates: true } + } +} + +impl Default for ChiSquared { + fn default() -> Self { + Self::new() } } impl CITest for ChiSquared { - fn run_test( + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - power_divergence( - &x_values, - &y_values, - &z, - self.boolean, - self.significance_level, - CHI_SQUARED_LAMBDA, - ) + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + run_power_divergence(data, x, y, z, LAMBDA, self.yates) } - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Discrete] + fn meta(&self) -> TestMeta { + discrete_meta("chi_squared") } } #[cfg(test)] -#[allow(clippy::many_single_char_names)] mod tests { use super::*; - use crate::utils::EPS; - use ndarray::{array, Array2}; - - fn unwrap_correlated(r: &TestResult) -> (f64, f64, usize) { - match r { - TestResult::Statistic(a, b, c) => (*a, *b, *c), - _ => panic!("expected Correlated2"), - } - } - - #[test] - fn uncond_independent_data_accepted() { - let t = ChiSquared { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!(stat.abs() < EPS, "stat should be ~0, got {stat}"); - assert!(p > 0.99); - assert_eq!(dof, 1); + use crate::dataset::ColumnKind; + use crate::strategy::{DataType, IndependenceRule}; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Discrete, v)) + .collect(), + ) + .unwrap() } #[test] - fn cond_independent_data_accepted() { - let t = ChiSquared { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!(stat.abs() < EPS, "stat should be ~0, got {stat}"); - assert!(p > 0.99); - assert_eq!(dof, 2); + fn unconditional_independent() { + let data = ds(vec![ + ("x", vec![1., 1., 2., 2., 1., 1., 2., 2.]), + ("y", vec![1., 2., 1., 2., 1., 2., 1., 2.]), + ]); + let r = ChiSquared::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap().abs() < 1e-9); + assert_eq!(r.dof, Some(1)); + assert!(r.p_value > 0.99); } #[test] - fn uncond_dependent_data_rejected() { - let t = ChiSquared { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 2., 2., 2., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!((stat - 8.0).abs() < EPS, "got {stat}"); - assert!((p - 0.004_677_734_981_047_276).abs() < EPS, "got {p}"); - assert_eq!(dof, 1); + fn unconditional_dependent_with_yates() { + // [[4,0],[0,4]]: Yates makes stat = 4 * (1.5^2 / 2) = 4.5. + let data = ds(vec![ + ("x", vec![1., 1., 1., 1., 2., 2., 2., 2.]), + ("y", vec![1., 1., 1., 1., 2., 2., 2., 2.]), + ]); + let r = ChiSquared::new().test(&data, 0, 1, &[]).unwrap(); + assert!( + (r.statistic.unwrap() - 4.5).abs() < 1e-9, + "got {:?}", + r.statistic + ); + assert_eq!(r.dof, Some(1)); } #[test] - fn cond_dependent_data_rejected() { - let t = ChiSquared { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!((stat - 8.0).abs() < EPS, "stat {stat} should be larger"); + fn yates_off_changes_statistic() { + // Same [[4,0],[0,4]] without Yates -> full chi-square = 8. + let data = ds(vec![ + ("x", vec![1., 1., 1., 1., 2., 2., 2., 2.]), + ("y", vec![1., 1., 1., 1., 2., 2., 2., 2.]), + ]); + let r = ChiSquared { yates: false }.test(&data, 0, 1, &[]).unwrap(); assert!( - (p - 0.018_315_638_888_734_193).abs() < EPS, - "rejected p value {p}" + (r.statistic.unwrap() - 8.0).abs() < 1e-9, + "got {:?}", + r.statistic ); - assert_eq!(dof, 2); } #[test] - fn uncond_boolean_mode() { - let t = ChiSquared { - boolean: true, - significance_level: 0.05, - }; - let empty = Array2::::zeros((0, 0)); - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let r = t.run_test(x, y, empty.clone()).unwrap(); - assert!(matches!(r, TestResult::Boolean(true))); - - let x = array![1., 1., 1., 1., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 2., 2., 2., 2.]; - let r = t.run_test(x, y, empty).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn conditional_independent() { + let data = ds(vec![ + ("x", vec![1., 1., 2., 2., 1., 1., 2., 2.]), + ("y", vec![1., 2., 1., 2., 1., 2., 1., 2.]), + ("z", vec![1., 1., 1., 1., 2., 2., 2., 2.]), + ]); + let r = ChiSquared::new().test(&data, 0, 1, &[2]).unwrap(); + assert!(r.statistic.unwrap().abs() < 1e-9); + assert_eq!(r.dof, Some(2)); + assert!(r.p_value > 0.99); } #[test] - fn cond_boolean_mode() { - let t = ChiSquared { - boolean: true, - significance_level: 0.05, - }; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(true))); + fn wrong_column_kind_errors() { + let data = Dataset::from_columns(vec![ + ("x".into(), ColumnKind::Continuous, vec![1., 2., 3.]), + ("y".into(), ColumnKind::Discrete, vec![1., 2., 3.]), + ]) + .unwrap(); + assert!(matches!( + ChiSquared::new().test(&data, 0, 1, &[]), + Err(CiError::WrongColumnKind(_)) + )); + } - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + #[test] + fn meta_is_correct() { + let m = ChiSquared::new().meta(); + assert_eq!(m.name, "chi_squared"); + assert_eq!(m.data_types, &[DataType::Discrete]); + assert!(m.symmetric); + assert_eq!(m.rule, IndependenceRule::PValueGe); } } diff --git a/crates/ci-core/src/ci_tests/cressie_read.rs b/crates/ci-core/src/ci_tests/cressie_read.rs index d8408b6..755de39 100644 --- a/crates/ci-core/src/ci_tests/cressie_read.rs +++ b/crates/ci-core/src/ci_tests/cressie_read.rs @@ -1,182 +1,138 @@ -use crate::strategy::{CITest, CITestDataType, TestResult}; -use crate::utils::power_divergence::power_divergence; -use ndarray::{Array1, Array2}; +//! Cressie-Read conditional-independence test (power divergence, λ = 2/3). -const CRESSIE_READ_LAMBDA: f64 = 2.0 / 3.0; +use crate::ci_tests::discrete_common::{discrete_meta, run_power_divergence}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, TestMeta}; -/// Cressie–Read conditional independence test (λ = 2/3). +/// The power-divergence parameter for the Cressie-Read statistic. +const LAMBDA: f64 = 2.0 / 3.0; + +/// Cressie-Read power-divergence test for discrete data (λ = 2/3), the +/// recommended compromise between Pearson and the likelihood-ratio statistics. /// -/// Operates on discrete data only. Delegates to the power-divergence family -/// with λ = 2/3, the Cressie–Read statistic. -#[derive(Debug, Clone, PartialEq)] +/// When `yates` is set, Yates' continuity correction is applied on 2×2 +/// (sub-)tables. Cramér's V is reported as the effect size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CressieRead { - pub boolean: bool, - pub significance_level: f64, + /// Whether to apply Yates' continuity correction on 2×2 (sub-)tables. + pub yates: bool, } impl CressieRead { + /// Construct the test with Yates' continuity correction enabled. #[must_use] - pub fn new(boolean: bool, significance_level: f64) -> Self { - Self { - boolean, - significance_level, - } + pub fn new() -> Self { + Self { yates: true } + } +} + +impl Default for CressieRead { + fn default() -> Self { + Self::new() } } impl CITest for CressieRead { - fn run_test( + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - power_divergence( - &x_values, - &y_values, - &z, - self.boolean, - self.significance_level, - CRESSIE_READ_LAMBDA, - ) + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + run_power_divergence(data, x, y, z, LAMBDA, self.yates) } - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Discrete] + + fn meta(&self) -> TestMeta { + discrete_meta("cressie_read") } } #[cfg(test)] mod tests { use super::*; - use crate::utils::EPS; - use ndarray::array; - - fn unwrap_correlated(result: &TestResult) -> (f64, f64, usize) { - match result { - TestResult::Statistic(a, b, c) => (*a, *b, *c), - _ => panic!("expected Correlated2"), - } - } - - fn unwrap_boolean(result: &TestResult) -> bool { - match result { - TestResult::Boolean(b) => *b, - _ => panic!("expected Boolean"), - } + use crate::dataset::ColumnKind; + use crate::strategy::{DataType, IndependenceRule}; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Discrete, v)) + .collect(), + ) + .unwrap() } #[test] - fn unconditional_independent_data_is_not_rejected() { - let test = CressieRead { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let empty_z = Array2::::zeros((0, 0)); - - let (p_value, statistic, dof) = unwrap_correlated( - &test - .run_test(x.clone(), y.clone(), empty_z.clone()) - .unwrap(), - ); - assert!( - statistic.abs() < EPS, - "expected statistic ~0, got {statistic}" - ); - assert!(p_value > 0.99, "expected p ~1, got {p_value}"); - assert_eq!(dof, 1); - - let independent = unwrap_boolean( - &CressieRead { - boolean: true, - significance_level: 0.05, - } - .run_test(x, y, empty_z) - .unwrap(), - ); - assert!(independent, "expected fail-to-reject (independent=true)"); + fn unconditional_independent_near_zero() { + let data = ds(vec![ + ("x", vec![1., 1., 2., 2., 1., 1., 2., 2.]), + ("y", vec![1., 2., 1., 2., 1., 2., 1., 2.]), + ]); + let r = CressieRead::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap().abs() < 1e-9); + assert_eq!(r.dof, Some(1)); + assert!(r.p_value > 0.99); } #[test] - fn unconditional_dependent_data_is_rejected() { - let test = CressieRead { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 2., 2., 2., 2.]; - let empty_z = Array2::::zeros((0, 0)); - - let (p_value, statistic, _dof) = unwrap_correlated( - &test - .run_test(x.clone(), y.clone(), empty_z.clone()) - .unwrap(), - ); - assert!(statistic > 5.0, "expected large statistic, got {statistic}"); + fn balanced_independent_table_has_finite_effect_size() { + // Cressie-Read on a balanced independent 2x2 ([[4,4],[4,4]]): O == E, so + // the power-divergence statistic rounds to a tiny *negative* value. The + // Cramér's V effect size must still be a finite 0.0, not NaN (regression + // test for the cramers_v radicand clamp). + let data = ds(vec![ + ( + "x", + vec![ + 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., + ], + ), + ( + "y", + vec![ + 0., 0., 0., 0., 1., 1., 1., 1., 0., 0., 0., 0., 1., 1., 1., 1., + ], + ), + ]); + let r = CressieRead::new().test(&data, 0, 1, &[]).unwrap(); + let effect = r.effect_size.expect("effect size should be present"); assert!( - p_value < test.significance_level, - "expected p < {}, got {p_value}", - test.significance_level + effect.is_finite(), + "effect size must be finite, got {effect}" ); - - let independent = unwrap_boolean( - &CressieRead { - boolean: true, - significance_level: 0.05, - } - .run_test(x, y, empty_z) - .unwrap(), + assert!( + effect < 1e-9, + "balanced independent table -> ~0 effect, got {effect}" ); - assert!(!independent, "expected reject (independent=false)"); } #[test] - fn conditional_independent_per_group() { - let test = CressieRead { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let z = Array2::from_shape_vec((8, 1), vec![0., 0., 0., 0., 1., 1., 1., 1.]).unwrap(); - - let (p_value, statistic, dof) = - unwrap_correlated(&test.run_test(x.clone(), y.clone(), z.clone()).unwrap()); - assert!( - statistic.abs() < EPS, - "expected statistic ~0, got {statistic}" - ); - assert!(p_value > 0.99, "expected p ~1, got {p_value}"); - assert_eq!(dof, 2); - - let independent = unwrap_boolean( - &CressieRead { - boolean: true, - significance_level: 0.05, - } - .run_test(x, y, z) - .unwrap(), - ); - assert!(independent); + fn dependent_table_pins_lambda_and_effect_size() { + // 2x3 dependent table (n=27, dof=2, Yates inert) pinning the Cressie-Read + // lambda wiring and Cramér's V effect size against scipy references. + let x = vec![ + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., + ]; + let y = vec![ + 0., 0., 0., 0., 0., 0., 1., 1., 2., 2., 2., 2., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., + 2., 2., 2., 2., 2., + ]; + let data = ds(vec![("x", x), ("y", y)]); + let r = CressieRead::new().test(&data, 0, 1, &[]).unwrap(); + assert_eq!(r.dof, Some(2)); + assert!((r.statistic.unwrap() - 3.629_764_546_5).abs() < 1e-6); + assert!((r.effect_size.unwrap() - 0.366_654_774_9).abs() < 1e-6); } #[test] - fn conditional_dependent_per_group() { - let test = CressieRead { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let z = Array2::from_shape_vec((8, 1), vec![0., 0., 0., 0., 1., 1., 1., 1.]).unwrap(); - - let (p_value, statistic, _dof) = unwrap_correlated(&test.run_test(x, y, z).unwrap()); - assert!(statistic > 5.0, "expected large statistic, got {statistic}"); - assert!( - p_value < test.significance_level, - "expected p < {}, got {p_value}", - test.significance_level - ); + fn meta_is_correct() { + let m = CressieRead::new().meta(); + assert_eq!(m.name, "cressie_read"); + assert_eq!(m.data_types, &[DataType::Discrete]); + assert!(m.symmetric); + assert_eq!(m.rule, IndependenceRule::PValueGe); } } diff --git a/crates/ci-core/src/ci_tests/discrete_common.rs b/crates/ci-core/src/ci_tests/discrete_common.rs new file mode 100644 index 0000000..76af003 --- /dev/null +++ b/crates/ci-core/src/ci_tests/discrete_common.rs @@ -0,0 +1,105 @@ +//! Shared implementation for the power-divergence family of discrete tests. +//! +//! Every member ([`crate::ci_tests::ChiSquared`], [`LogLikelihood`], etc.) is a +//! struct carrying a single `yates: bool` flag and delegating to the +//! λ-parameterized discrete back-end with its fixed `λ`. This module factors out +//! the shared statistic → [`CiResult`] conversion (p-value from the chi-squared +//! survival function, Cramér's V effect size) and the [`crate::strategy::TestMeta`] +//! construction so each test file stays focused on its name and `λ`. +//! +//! [`LogLikelihood`]: crate::ci_tests::LogLikelihood + +use statrs::distribution::{ChiSquared as ChiSquaredDist, ContinuousCDF}; + +use crate::dataset::Dataset; +use crate::discrete::{ + power_divergence_conditional, power_divergence_unconditional, DiscreteOutcome, +}; +use crate::error::CiError; +use crate::strategy::{CiResult, DataType, IndependenceRule, TestMeta}; + +/// Cramér's V = sqrt(stat / (n * (min(kx, ky) - 1))). Returns `None` when the +/// smaller cardinality is < 2 (V undefined) or there are no rows. +/// +/// The statistic is mathematically non-negative, but `powf` rounding in the +/// power-divergence back-end can make it a tiny *negative* value on tables where +/// observed == expected (e.g. a balanced independent table under Cressie-Read), +/// which would turn the `sqrt` into `NaN`. Clamp the radicand at 0 so the effect +/// size is a clean `0.0` there. +#[allow(clippy::cast_precision_loss)] +fn cramers_v(statistic: f64, n: usize, kx: usize, ky: usize) -> Option { + let k = kx.min(ky); + if k < 2 || n == 0 { + return None; + } + Some((statistic.max(0.0) / (n as f64 * (k as f64 - 1.0))).sqrt()) +} + +/// Survival function `P(χ²_dof > statistic)`, with the degenerate and infinite +/// cases handled exactly: `dof == 0 ⇒ 1.0`, `statistic == +∞ ⇒ 0.0`. +fn chi_squared_sf(statistic: f64, dof: usize) -> Result { + if dof == 0 { + return Ok(1.0); + } + if statistic.is_infinite() { + return Ok(0.0); + } + #[allow(clippy::cast_precision_loss)] + let p = ChiSquaredDist::new(dof as f64) + .map_err(|e| CiError::Numeric(format!("chi-squared distribution: {e}")))? + .sf(statistic); + Ok(p) +} + +/// Run a power-divergence test with parameter `lambda` and Yates flag `yates`, +/// returning the full [`CiResult`] (statistic, p-value via the chi-squared +/// survival function, dof, Cramér's V effect size). +/// +/// # Errors +/// +/// Propagates [`CiError`] from reading the columns or constructing the +/// chi-squared distribution. +#[allow( + clippy::many_single_char_names, + reason = "x, y, z are the standard conditional-independence variable names from the contract" +)] +pub(crate) fn run_power_divergence( + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + lambda: f64, + yates: bool, +) -> Result { + let (x_codes, kx) = data.discrete(x)?; + let (y_codes, ky) = data.discrete(y)?; + + let DiscreteOutcome { statistic, dof } = if z.is_empty() { + power_divergence_unconditional(x_codes, y_codes, kx, ky, lambda, yates) + } else { + let partition = data.strata_partition(z)?; + power_divergence_conditional(x_codes, y_codes, kx, ky, &partition, lambda, yates) + }; + + let p_value = chi_squared_sf(statistic, dof)?; + let effect_size = cramers_v(statistic, data.n_rows(), kx, ky); + + Ok(CiResult { + statistic: Some(statistic), + p_value, + dof: Some(dof), + effect_size, + }) +} + +/// Build the [`TestMeta`] common to every discrete power-divergence test: the +/// given stable `name`, `[Discrete]`, symmetric, and the standard +/// null-of-independence [`IndependenceRule::PValueGe`]. +pub(crate) fn discrete_meta(name: &'static str) -> TestMeta { + TestMeta { + name, + data_types: &[DataType::Discrete], + symmetric: true, + rule: IndependenceRule::PValueGe, + } +} diff --git a/crates/ci-core/src/ci_tests/fisher_z.rs b/crates/ci-core/src/ci_tests/fisher_z.rs new file mode 100644 index 0000000..8948420 --- /dev/null +++ b/crates/ci-core/src/ci_tests/fisher_z.rs @@ -0,0 +1,160 @@ +//! Fisher-z conditional-independence test (continuous). + +use statrs::distribution::{ContinuousCDF, Normal}; + +use crate::ci_tests::pearson_correlation::{partial_correlation, RHO_CLIP_EPS}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, DataType, IndependenceRule, TestMeta}; + +/// Fisher-z test on the (partial) correlation: `z = √(n − |Z| − 3) · atanh(ρ)` +/// is approximately standard normal under `X ⊥ Y | Z`. The de-facto standard +/// continuous CI test in causal discovery (pcalg's `gaussCItest`, +/// causal-learn's `fisherz`, pgmpy's `FisherZ`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FisherZ; + +impl FisherZ { + /// Construct the test (no configuration). + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl CITest for FisherZ { + #[allow( + clippy::many_single_char_names, + reason = "x, y, z are the standard conditional-independence variable names from the contract" + )] + fn test_impl( + &self, + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + let n = data.n_rows(); + let n_z = z.len(); + + let (r, _dof) = partial_correlation(data, x, y, z)?; + let rho = r.clamp(-1.0 + RHO_CLIP_EPS, 1.0 - RHO_CLIP_EPS); + + // partial_correlation guarantees n >= |Z| + 3, so the radicand is >= 0 + // (n == |Z| + 3 gives statistic 0 and p = 1, matching pgmpy). + #[allow(clippy::cast_precision_loss)] + let scale = ((n - n_z - 3) as f64).sqrt(); + let statistic = scale * rho.atanh(); + + let normal = + Normal::new(0.0, 1.0).map_err(|e| CiError::Numeric(format!("standard normal: {e}")))?; + let p_value = 2.0 * normal.sf(statistic.abs()); + + Ok(CiResult { + statistic: Some(statistic), + p_value, + dof: None, + effect_size: Some(r.abs()), + }) + } + + fn meta(&self) -> TestMeta { + TestMeta { + name: "fisher_z", + data_types: &[DataType::Continuous], + symmetric: true, + rule: IndependenceRule::PValueGe, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ci_tests::PearsonCorrelation; + use crate::dataset::ColumnKind; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Continuous, v)) + .collect(), + ) + .unwrap() + } + + #[test] + fn meta_is_correct() { + let m = FisherZ::new().meta(); + assert_eq!(m.name, "fisher_z"); + assert_eq!(m.data_types, &[DataType::Continuous]); + assert!(m.symmetric); + assert_eq!(m.rule, IndependenceRule::PValueGe); + } + + #[test] + fn statistic_is_scaled_atanh_of_r() { + let data = ds(vec![ + ("x", vec![0.2, 1.1, -0.4, 0.9, 1.8, -0.7, 0.3, 1.2]), + ("y", vec![1.0, 0.1, 0.8, -0.2, 0.4, 1.1, -0.3, 0.6]), + ]); + let fz = FisherZ::new().test(&data, 0, 1, &[]).unwrap(); + let pc = PearsonCorrelation::new().test(&data, 0, 1, &[]).unwrap(); + let r = pc.statistic.unwrap(); + let expected = (8.0_f64 - 3.0).sqrt() * r.atanh(); + assert!((fz.statistic.unwrap() - expected).abs() < 1e-12); + assert!(fz.dof.is_none()); + assert!((fz.effect_size.unwrap() - r.abs()).abs() < 1e-12); + assert!(fz.p_value > 0.0 && fz.p_value <= 1.0); + } + + #[test] + fn conditional_uses_n_minus_z_minus_3() { + let z: Vec = (0..20u8).map(|i| f64::from(i) * 0.37 % 1.9).collect(); + let x: Vec = z + .iter() + .enumerate() + .map(|(i, v)| v + 0.31 * f64::from(u8::try_from(i).unwrap() % 5)) + .collect(); + let y: Vec = z + .iter() + .enumerate() + .map(|(i, v)| v - 0.27 * f64::from(u8::try_from(i).unwrap() % 7)) + .collect(); + let data = ds(vec![("x", x), ("y", y), ("z", z)]); + let fz = FisherZ::new().test(&data, 0, 1, &[2]).unwrap(); + let pc = PearsonCorrelation::new().test(&data, 0, 1, &[2]).unwrap(); + let r = pc.statistic.unwrap(); + let expected = (20.0_f64 - 1.0 - 3.0).sqrt() * r.atanh(); + assert!((fz.statistic.unwrap() - expected).abs() < 1e-12); + } + + #[test] + fn perfect_correlation_is_clamped_finite() { + // y = 2x: r == 1 exactly. PearsonCorrelation errors here (t undefined); + // FisherZ instead clamps rho and reports a large finite statistic. + let x: Vec = (0..30u8).map(f64::from).collect(); + let y: Vec = x.iter().map(|v| 2.0 * v).collect(); + let data = ds(vec![("x", x), ("y", y)]); + let res = FisherZ::new().test(&data, 0, 1, &[]).unwrap(); + let stat = res.statistic.unwrap(); + assert!(stat.is_finite() && stat > 0.0); + assert!(res.p_value < 1e-12); + // effect_size reports the raw (unclipped) |r| — pgmpy convention. + assert!((res.effect_size.unwrap() - 1.0).abs() < 1e-12); + } + + #[test] + fn is_independent_applies_p_value_ge_rule() { + let x: Vec = (0..24u8).map(|i| f64::from(i % 4)).collect(); + let y: Vec = (0..24u8).map(|i| f64::from((i / 4) % 3)).collect(); + let data = ds(vec![("x", x), ("y", y)]); + let fz = FisherZ::new(); + let res = fz.test(&data, 0, 1, &[]).unwrap(); + // The decision must equal the PValueGe rule applied to the p-value. + assert_eq!( + fz.is_independent(&data, 0, 1, &[], 0.05).unwrap(), + res.p_value >= 0.05 + ); + } +} diff --git a/crates/ci-core/src/ci_tests/freeman_tukey.rs b/crates/ci-core/src/ci_tests/freeman_tukey.rs index 174d3c1..8665ffa 100644 --- a/crates/ci-core/src/ci_tests/freeman_tukey.rs +++ b/crates/ci-core/src/ci_tests/freeman_tukey.rs @@ -1,182 +1,105 @@ -use crate::strategy::{CITest, CITestDataType, TestResult}; -use crate::utils::power_divergence::power_divergence; -use ndarray::{Array1, Array2}; +//! Freeman-Tukey conditional-independence test (power divergence, λ = −1/2). -const FREEMAN_TUKEY_LAMBDA: f64 = -1.0 / 2.0; +use crate::ci_tests::discrete_common::{discrete_meta, run_power_divergence}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, TestMeta}; -/// Freeman–Tukey conditional independence test (λ = −1/2). +/// The power-divergence parameter for the Freeman-Tukey statistic. +const LAMBDA: f64 = -0.5; + +/// Freeman-Tukey power-divergence test for discrete data (λ = −1/2). /// -/// Operates on discrete data only. Delegates to the power-divergence family -/// with λ = −1/2, the Freeman–Tukey statistic. -#[derive(Debug, Clone, PartialEq)] +/// When `yates` is set, Yates' continuity correction is applied on 2×2 +/// (sub-)tables. Cramér's V is reported as the effect size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FreemanTukey { - pub boolean: bool, - pub significance_level: f64, + /// Whether to apply Yates' continuity correction on 2×2 (sub-)tables. + pub yates: bool, } impl FreemanTukey { + /// Construct the test with Yates' continuity correction enabled. #[must_use] - pub fn new(boolean: bool, significance_level: f64) -> Self { - Self { - boolean, - significance_level, - } + pub fn new() -> Self { + Self { yates: true } + } +} + +impl Default for FreemanTukey { + fn default() -> Self { + Self::new() } } impl CITest for FreemanTukey { - fn run_test( + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - power_divergence( - &x_values, - &y_values, - &z, - self.boolean, - self.significance_level, - FREEMAN_TUKEY_LAMBDA, - ) + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + run_power_divergence(data, x, y, z, LAMBDA, self.yates) } - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Discrete] + fn meta(&self) -> TestMeta { + discrete_meta("freeman_tukey") } } #[cfg(test)] -#[allow(clippy::many_single_char_names)] mod tests { use super::*; - use crate::utils::EPS; - use ndarray::{array, Array2}; - - fn unwrap_correlated(r: &TestResult) -> (f64, f64, usize) { - match r { - TestResult::Statistic(a, b, c) => (*a, *b, *c), - _ => panic!("expected Correlated2"), - } - } - - #[test] - fn uncond_independent_data_not_rejected() { - let t = FreemanTukey { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!(stat.abs() < EPS); - assert!(p > 0.99); - assert_eq!(dof, 1); - } - - #[test] - fn cond_independent_not_rejected() { - let t = FreemanTukey { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.],]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!(stat.abs() < EPS); - assert!(p > 0.99); - assert_eq!(dof, 2); + use crate::dataset::ColumnKind; + use crate::strategy::{DataType, IndependenceRule}; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Discrete, v)) + .collect(), + ) + .unwrap() } #[test] - fn uncond_dependent_rejected() { - let t = FreemanTukey { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!((stat - 6.319_453_539_579_289).abs() < EPS, "got {stat}"); - assert!((p - 0.011_942_042_564_347_121).abs() < EPS, "got {p}"); - assert_eq!(dof, 1); + fn unconditional_independent_near_zero() { + let data = ds(vec![ + ("x", vec![1., 1., 2., 2., 1., 1., 2., 2.]), + ("y", vec![1., 2., 1., 2., 1., 2., 1., 2.]), + ]); + let r = FreemanTukey::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap().abs() < 1e-9); + assert_eq!(r.dof, Some(1)); + assert!(r.p_value > 0.99); } #[test] - fn cond_dependent_rejected() { - let t = FreemanTukey { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 2., 1., 1., 2., 2., 1., 2.]; - let y = array![1., 2., 1., 2., 2., 1., 1., 2., 1., 2., 2., 1.]; - let z = array![ - [1.], - [1.], - [1.], - [1.], - [1.], - [1.], - [2.], - [2.], - [2.], - [2.], - [2.], - [2.] + fn dependent_table_pins_lambda_and_effect_size() { + // 2x3 dependent table (n=27, dof=2, Yates inert) pinning the Freeman-Tukey + // lambda wiring and Cramér's V effect size against scipy references. + let x = vec![ + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., ]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!( - (stat - 1.382_538_273_265_069_5).abs() < EPS, - "got stat {stat}" - ); - assert!((p - 0.500_939_904_278_208_8).abs() < EPS, "got p value {p}"); - assert_eq!(dof, 2); - } - - #[test] - fn uncond_boolean_accepts_independent() { - let t = FreemanTukey { - boolean: true, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let empty = Array2::::zeros((0, 0)); - let r = t.run_test(x, y, empty).unwrap(); - assert!(matches!(r, TestResult::Boolean(true))); + let y = vec![ + 0., 0., 0., 0., 0., 0., 1., 1., 2., 2., 2., 2., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., + 2., 2., 2., 2., 2., + ]; + let data = ds(vec![("x", x), ("y", y)]); + let r = FreemanTukey::new().test(&data, 0, 1, &[]).unwrap(); + assert_eq!(r.dof, Some(2)); + assert!((r.statistic.unwrap() - 3.868_242_083_0).abs() < 1e-6); + assert!((r.effect_size.unwrap() - 0.378_507_893_3).abs() < 1e-6); } #[test] - fn cond_boolean_rejects_dependent() { - let t = FreemanTukey { - boolean: true, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 2., 2., 2., 1., 1., 1., 2., 2., 2.]; - let y = array![1., 1., 2., 2., 2., 2., 1., 1., 2., 2., 2., 2.]; - let z = array![ - [1.], - [1.], - [1.], - [1.], - [1.], - [1.], - [2.], - [2.], - [2.], - [2.], - [2.], - [2.] - ]; - - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn meta_is_correct() { + let m = FreemanTukey::new().meta(); + assert_eq!(m.name, "freeman_tukey"); + assert_eq!(m.data_types, &[DataType::Discrete]); + assert!(m.symmetric); + assert_eq!(m.rule, IndependenceRule::PValueGe); } } diff --git a/crates/ci-core/src/ci_tests/log_likelihood.rs b/crates/ci-core/src/ci_tests/log_likelihood.rs index 7b3bf02..5e1d3a5 100644 --- a/crates/ci-core/src/ci_tests/log_likelihood.rs +++ b/crates/ci-core/src/ci_tests/log_likelihood.rs @@ -1,169 +1,106 @@ -use crate::strategy::{CITest, CITestDataType, TestResult}; -use crate::utils::power_divergence::power_divergence; -use ndarray::{Array1, Array2}; +//! Log-likelihood (G-test) conditional-independence test (power divergence, λ = 0). -const LOG_LIKELIHOOD_LAMBDA: f64 = 0.0; +use crate::ci_tests::discrete_common::{discrete_meta, run_power_divergence}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, TestMeta}; -/// Log-likelihood ratio (G-test) conditional independence test (λ = 0). +/// The power-divergence parameter for the log-likelihood (G-test) statistic. +const LAMBDA: f64 = 0.0; + +/// Log-likelihood ratio (G-test) for discrete data (power divergence with λ = 0). /// -/// Operates on discrete data only. Delegates to the power-divergence family -/// with λ = 0, which corresponds to the G-test / log-likelihood ratio statistic. -#[derive(Debug, Clone, PartialEq)] +/// The per-cell statistic is `2 · Σ O·ln(O/E)`. When `yates` is set, Yates' +/// continuity correction is applied on 2×2 (sub-)tables. Cramér's V is reported +/// as the effect size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LogLikelihood { - pub boolean: bool, - pub significance_level: f64, + /// Whether to apply Yates' continuity correction on 2×2 (sub-)tables. + pub yates: bool, } impl LogLikelihood { + /// Construct the test with Yates' continuity correction enabled. #[must_use] - pub fn new(boolean: bool, significance_level: f64) -> Self { - Self { - boolean, - significance_level, - } + pub fn new() -> Self { + Self { yates: true } + } +} + +impl Default for LogLikelihood { + fn default() -> Self { + Self::new() } } impl CITest for LogLikelihood { - fn run_test( + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - power_divergence( - &x_values, - &y_values, - &z, - self.boolean, - self.significance_level, - LOG_LIKELIHOOD_LAMBDA, - ) + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + run_power_divergence(data, x, y, z, LAMBDA, self.yates) } - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Discrete] + fn meta(&self) -> TestMeta { + discrete_meta("log_likelihood") } } #[cfg(test)] -#[allow(clippy::many_single_char_names)] mod tests { use super::*; - use crate::utils::EPS; - use ndarray::{array, Array2}; - - fn unwrap_correlated(r: &TestResult) -> (f64, f64, usize) { - match r { - TestResult::Statistic(a, b, c) => (*a, *b, *c), - _ => panic!("expected Correlated2"), - } - } - - #[test] - fn uncond_independent_data_accepted() { - let t = LogLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!(stat.abs() < EPS); - assert!(p > 0.99); - assert_eq!(dof, 1); - } - - #[test] - fn cond_independent_data_accepted() { - let t = LogLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!((stat).abs() < EPS, " got {stat}"); - assert!(p > 0.99); - assert_eq!(dof, 2); - } - - #[test] - fn uncond_dependent_data_rejected() { - let t = LogLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!((stat - 5.822_063_320_647_374).abs() < EPS, "got {stat}"); - assert!((p - 0.015_826_368_796_540_195).abs() < EPS, "got {p}"); - assert_eq!(dof, 1); + use crate::dataset::ColumnKind; + use crate::strategy::{DataType, IndependenceRule}; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Discrete, v)) + .collect(), + ) + .unwrap() } #[test] - fn cond_dependent_data_rejected() { - let t = LogLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!( - (stat - 11.090_354_888_959_125).abs() < EPS, - "for stat got {stat}" - ); - assert!((p - 0.003_906_249_999_999_994).abs() < EPS, "for p got {p}"); - assert_eq!(dof, 2); + fn unconditional_independent_near_zero() { + let data = ds(vec![ + ("x", vec![1., 1., 2., 2., 1., 1., 2., 2.]), + ("y", vec![1., 2., 1., 2., 1., 2., 1., 2.]), + ]); + let r = LogLikelihood::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap().abs() < 1e-9); + assert_eq!(r.dof, Some(1)); + assert!(r.p_value > 0.99); } #[test] - fn uncond_bool_rejects_dependent() { - let t = LogLikelihood { - boolean: true, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.]; - let empty = Array2::::zeros((0, 0)); - let r = t.run_test(x, y, empty).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn dependent_table_pins_lambda_and_effect_size() { + // 2x3 dependent table (n=27, dof=2, Yates inert) pinning the log-likelihood + // lambda wiring and Cramér's V effect size against scipy references. + let x = vec![ + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., + ]; + let y = vec![ + 0., 0., 0., 0., 0., 0., 1., 1., 2., 2., 2., 2., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., + 2., 2., 2., 2., 2., + ]; + let data = ds(vec![("x", x), ("y", y)]); + let r = LogLikelihood::new().test(&data, 0, 1, &[]).unwrap(); + assert_eq!(r.dof, Some(2)); + assert!((r.statistic.unwrap() - 3.738_650_145_2).abs() < 1e-6); + assert!((r.effect_size.unwrap() - 0.372_113_590_0).abs() < 1e-6); } #[test] - fn cond_bool_rejects_dependent() { - let t = LogLikelihood { - boolean: true, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 2., 2., 2., 1., 1., 1., 2., 2., 2.]; - let y = array![1., 1., 2., 2., 2., 2., 1., 1., 2., 2., 2., 2.]; - let z = array![ - [1.], - [1.], - [1.], - [1.], - [1.], - [1.], - [2.], - [2.], - [2.], - [2.], - [2.], - [2.] - ]; - - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn meta_is_correct() { + let m = LogLikelihood::new().meta(); + assert_eq!(m.name, "log_likelihood"); + assert_eq!(m.data_types, &[DataType::Discrete]); + assert!(m.symmetric); + assert_eq!(m.rule, IndependenceRule::PValueGe); } } diff --git a/crates/ci-core/src/ci_tests/mod.rs b/crates/ci-core/src/ci_tests/mod.rs index 7b44b43..5475553 100644 --- a/crates/ci-core/src/ci_tests/mod.rs +++ b/crates/ci-core/src/ci_tests/mod.rs @@ -1,5 +1,16 @@ +//! Concrete conditional-independence tests. +//! +//! Five discrete power-divergence tests ([`ChiSquared`], [`LogLikelihood`], +//! [`CressieRead`], [`FreemanTukey`], [`ModifiedLikelihood`]) share the +//! λ-parameterized discrete back-end via [`discrete_common`]; three continuous +//! tests ([`PearsonCorrelation`], [`FisherZ`], [`PearsonEquivalence`]) cover the +//! partial-correlation path. + +mod discrete_common; + pub mod chi_squared; pub mod cressie_read; +pub mod fisher_z; pub mod freeman_tukey; pub mod log_likelihood; pub mod modified_likelihood; @@ -8,6 +19,7 @@ pub mod pearson_equivalence; pub use chi_squared::ChiSquared; pub use cressie_read::CressieRead; +pub use fisher_z::FisherZ; pub use freeman_tukey::FreemanTukey; pub use log_likelihood::LogLikelihood; pub use modified_likelihood::ModifiedLikelihood; diff --git a/crates/ci-core/src/ci_tests/modified_likelihood.rs b/crates/ci-core/src/ci_tests/modified_likelihood.rs index d31b97a..fdb7dfa 100644 --- a/crates/ci-core/src/ci_tests/modified_likelihood.rs +++ b/crates/ci-core/src/ci_tests/modified_likelihood.rs @@ -1,170 +1,104 @@ -use crate::strategy::{CITest, CITestDataType, TestResult}; -use crate::utils::power_divergence::power_divergence; -use ndarray::{Array1, Array2}; +//! Modified log-likelihood (Neyman) conditional-independence test +//! (power divergence, λ = −1). -const MODIFIED_LIKELIHOOD_LAMBDA: f64 = -1.0; +use crate::ci_tests::discrete_common::{discrete_meta, run_power_divergence}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, TestMeta}; -/// Modified log-likelihood ratio conditional independence test (λ = −1). +/// The power-divergence parameter for the modified log-likelihood statistic. +const LAMBDA: f64 = -1.0; + +/// Modified log-likelihood (Neyman) power-divergence test for discrete data +/// (λ = −1). The per-cell statistic is `2 · Σ E·ln(E/O)`; a structural zero +/// (`O = 0` with `E > 0`) drives the statistic to `+∞` and hence the p-value to +/// `0`. /// -/// Operates on discrete data only. Delegates to the power-divergence family -/// with λ = −1, the modified log-likelihood ratio statistic. -#[derive(Debug, Clone, PartialEq)] +/// When `yates` is set, Yates' continuity correction is applied on 2×2 +/// (sub-)tables. Cramér's V is reported as the effect size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ModifiedLikelihood { - pub boolean: bool, - pub significance_level: f64, + /// Whether to apply Yates' continuity correction on 2×2 (sub-)tables. + pub yates: bool, } impl ModifiedLikelihood { + /// Construct the test with Yates' continuity correction enabled. #[must_use] - pub fn new(boolean: bool, significance_level: f64) -> Self { - Self { - boolean, - significance_level, - } + pub fn new() -> Self { + Self { yates: true } + } +} + +impl Default for ModifiedLikelihood { + fn default() -> Self { + Self::new() } } impl CITest for ModifiedLikelihood { - fn run_test( + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - power_divergence( - &x_values, - &y_values, - &z, - self.boolean, - self.significance_level, - MODIFIED_LIKELIHOOD_LAMBDA, - ) + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + run_power_divergence(data, x, y, z, LAMBDA, self.yates) } - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Discrete] + fn meta(&self) -> TestMeta { + discrete_meta("modified_likelihood") } } #[cfg(test)] -#[allow(clippy::many_single_char_names)] mod tests { use super::*; - use crate::utils::EPS; - use ndarray::{array, Array2}; - - fn unwrap_correlated(r: &TestResult) -> (f64, f64, usize) { - match r { - TestResult::Statistic(a, b, c) => (*a, *b, *c), - _ => panic!("expected Correlated2"), - } - } - - #[test] - fn uncond_independent_data_accepted() { - let t = ModifiedLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!(stat.abs() < EPS); - assert!(p > 0.99); - assert_eq!(dof, 1); - } - - #[test] - fn cond_independent_data_accepted() { - let t = ModifiedLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - - assert!(stat.abs() < EPS, " got stat {stat}"); - assert!(p > 0.99, " got p {p}"); - assert_eq!(dof, 2); - } - - #[test] - fn uncond_dependent_data_rejected() { - let t = ModifiedLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.]; - let empty = Array2::::zeros((0, 0)); - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, empty).unwrap()); - assert!((stat - 7.053_439_978_825_427).abs() < EPS, "got {stat}"); - assert!((p - 0.007_911_317_670_556_329).abs() < EPS, "got {p}"); - assert_eq!(dof, 1); + use crate::dataset::ColumnKind; + use crate::strategy::{DataType, IndependenceRule}; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Discrete, v)) + .collect(), + ) + .unwrap() } #[test] - fn cond_dependent_data_rejected() { - let t = ModifiedLikelihood { - boolean: false, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 2., 2., 2., 1., 1., 1., 2., 2., 2.]; - let y = array![1., 1., 2., 2., 2., 1., 1., 1., 2., 2., 2., 1.]; - let z = array![ - [1.], - [1.], - [1.], - [1.], - [1.], - [1.], - [2.], - [2.], - [2.], - [2.], - [2.], - [2.] - ]; - - let (p, stat, dof) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - - assert!( - (stat - 1.413_396_427_876_601_6).abs() < EPS, - "got stat {stat}" - ); - assert!((p - 0.493_270_184_272_571_97).abs() < EPS, "got p {p}"); - assert_eq!(dof, 2); + fn unconditional_independent_near_zero() { + let data = ds(vec![ + ("x", vec![1., 1., 2., 2., 1., 1., 2., 2.]), + ("y", vec![1., 2., 1., 2., 1., 2., 1., 2.]), + ]); + let r = ModifiedLikelihood::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap().abs() < 1e-9); + assert_eq!(r.dof, Some(1)); + assert!(r.p_value > 0.99); } #[test] - fn uncond_bool_rejects_dependent() { - let t = ModifiedLikelihood { - boolean: true, - significance_level: 0.05, - }; - let x = array![1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.]; - let y = array![1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.]; - let empty = Array2::::zeros((0, 0)); - let r = t.run_test(x, y, empty).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn structural_zero_gives_p_zero() { + // A 3×3 table (dof != 1, so no Yates) with a structural zero in an + // active row/column -> statistic +∞ -> p = 0. + let data = ds(vec![ + ("x", vec![0., 0., 0., 0., 1., 1., 1., 1., 2., 2., 2., 2.]), + ("y", vec![0., 0., 1., 1., 0., 0., 1., 1., 2., 2., 2., 2.]), + ]); + let r = ModifiedLikelihood::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap().is_infinite()); + // p is exactly 0 for an infinite statistic. + assert!(r.p_value.total_cmp(&0.0).is_eq()); } #[test] - fn cond_bool_accepts_independent() { - let t = ModifiedLikelihood { - boolean: true, - significance_level: 0.05, - }; - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn meta_is_correct() { + let m = ModifiedLikelihood::new().meta(); + assert_eq!(m.name, "modified_likelihood"); + assert_eq!(m.data_types, &[DataType::Discrete]); + assert!(m.symmetric); + assert_eq!(m.rule, IndependenceRule::PValueGe); } } diff --git a/crates/ci-core/src/ci_tests/pearson_correlation.rs b/crates/ci-core/src/ci_tests/pearson_correlation.rs index 1f31420..3c0b0ae 100644 --- a/crates/ci-core/src/ci_tests/pearson_correlation.rs +++ b/crates/ci-core/src/ci_tests/pearson_correlation.rs @@ -1,364 +1,598 @@ -use crate::{ - strategy::{CITest, CITestDataType, TestResult}, - utils::EPS, -}; -use anyhow::ensure; -use nalgebra::{DMatrix, DVector}; -use ndarray::{Array1, Array2, ArrayView1}; -use statrs::distribution::{ContinuousCDF, StudentsT}; +//! Pearson / partial-correlation conditional-independence test (continuous). -const SVD_TOLERANCE: f64 = 1e-10; -const MIN_SAMPLE_SIZE: usize = 3; +use statrs::distribution::{ContinuousCDF, StudentsT}; -/// Pearson correlation conditional independence test. -/// -/// Should be used only on continuous data. When the conditioning set is non-empty, -/// uses linear regression to compute residuals and tests the Pearson correlation -/// on those residuals (partial correlation). -/// -/// # References -/// -/// - [Pearson correlation coefficient](https://en.wikipedia.org/wiki/Pearson_correlation_coefficient) -/// - [Partial correlation using linear regression](https://en.wikipedia.org/wiki/Partial_correlation#Using_linear_regression) -#[derive(Debug, Clone, PartialEq)] -pub struct PearsonCorrelation { - pub boolean: bool, - pub significance_level: f64, -} +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::gram::GramCache; +use crate::strategy::{CITest, CiResult, DataType, IndependenceRule, TestMeta}; + +/// Relative tolerance for declaring a post-conditioning residual vector +/// "constant": its deviation energy is negligible compared to the original +/// variable's. This catches the case where Z perfectly explains X or Y, leaving +/// residuals that are pure floating-point noise (~1e-15). It sits ~30 orders of +/// magnitude below the smallest genuine residual ratio in the golden fixture, so +/// it never affects a real partial correlation. +const VARIANCE_REL_EPS: f64 = 1e-12; + +/// Clipping bound for `rho` before the Fisher z-transform: `[-1 + EPS, 1 - EPS]`. +/// Matches the reference's `np.clip(rho, -0.999999, 0.999999)`. Shared by the +/// Fisher-z and equivalence tests. +pub(crate) const RHO_CLIP_EPS: f64 = 1e-6; + +/// Pearson correlation test. With a non-empty conditioning set it computes the +/// partial correlation by regressing X and Y on `[1, Z]` (intercept included) +/// and correlating the residuals. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PearsonCorrelation; impl PearsonCorrelation { + /// Construct the test (no configuration in this slice). #[must_use] - pub fn new(boolean: bool, significance_level: f64) -> Self { - Self { - boolean, - significance_level, - } + pub fn new() -> Self { + Self } } -impl CITest for PearsonCorrelation { - fn run_test( - &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - if z.is_empty() { - let (coefficient, p_value) = pearsonr(&x_values.view(), &y_values.view())?; - Ok(wrap_result( - self.boolean, - p_value, - coefficient, - self.significance_level, - )) - } else { - let z_na = DMatrix::from_row_iterator(z.nrows(), z.ncols(), z.iter().copied()); - let x_na = DVector::from_iterator(x_values.len(), x_values.iter().copied()); - let y_na = DVector::from_iterator(y_values.len(), y_values.iter().copied()); - - let svd = z_na.svd(true, true); - let x_coefficient = svd - .solve(&x_na, SVD_TOLERANCE) - .map_err(|e| anyhow::anyhow!("least squares failed for x: {e}"))?; - let y_coefficient = svd - .solve(&y_na, SVD_TOLERANCE) - .map_err(|e| anyhow::anyhow!("least squares failed for y: {e}"))?; - - let x_coef_nd = Array1::from_vec(x_coefficient.iter().copied().collect()); - let y_coef_nd = Array1::from_vec(y_coefficient.iter().copied().collect()); - - let residual_x = x_values - z.dot(&x_coef_nd); - let residual_y = y_values - z.dot(&y_coef_nd); - - let (coefficient, p_value) = pearsonr(&residual_x.view(), &residual_y.view())?; - Ok(wrap_result( - self.boolean, - p_value, - coefficient, - self.significance_level, - )) +/// Sum of squared deviations from the mean, `Σ (v − v̄)²`. +fn sum_sq_deviations(v: &[f64]) -> f64 { + #[allow(clippy::cast_precision_loss)] + let mean = v.iter().sum::() / v.len() as f64; + v.iter().map(|&vi| (vi - mean) * (vi - mean)).sum() +} + +/// The "input is constant; Pearson correlation is undefined" error for input +/// `which` (`"x"` or `"y"`). +fn constant_input_error(which: &str) -> CiError { + CiError::DegenerateData(format!( + "input `{which}` is constant; Pearson correlation is undefined" + )) +} + +/// Enforce the minimum row count for a (partial) correlation: 3 rows +/// unconditionally, or `|Z| + 3` when conditioning on `n_z` columns. +/// +/// # Errors +/// +/// Returns [`CiError::DegenerateData`] if there are too few rows. +fn check_row_count(n: usize, n_z: usize) -> Result<(), CiError> { + if n_z == 0 { + if n < 3 { + return Err(CiError::DegenerateData(format!( + "need at least 3 rows for correlation, got {n}" + ))); } + } else if n < n_z + 3 { + return Err(CiError::DegenerateData(format!( + "need at least |Z| + 3 = {} rows, got {n}", + n_z + 3 + ))); } + Ok(()) +} - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Continuous] +/// Reject a post-conditioning residual whose deviation energy is negligible +/// relative to the original variable's (Z explains essentially all variation). +/// +/// # Errors +/// +/// Returns [`CiError::DegenerateData`] if `residual_var <= VARIANCE_REL_EPS * +/// original_var`. +fn check_residual_variance( + which: &str, + residual_var: f64, + original_var: f64, +) -> Result<(), CiError> { + if residual_var <= VARIANCE_REL_EPS * original_var { + return Err(CiError::DegenerateData(format!( + "residual `{which}` is constant after conditioning on Z; \ + partial correlation is undefined" + ))); } + Ok(()) } -#[must_use] -pub fn wrap_result( - boolean: bool, - p_value: f64, - coefficient: f64, - significance_level: f64, -) -> TestResult { - if boolean { - return TestResult::Boolean(p_value >= significance_level); +/// Pearson correlation coefficient of two equal-length slices. +/// +/// # Errors +/// +/// Returns [`CiError::DegenerateData`] if either input has zero variance. +fn pearson_r(x: &[f64], y: &[f64]) -> Result { + #[allow(clippy::cast_precision_loss)] + let n = x.len() as f64; + let x_mean = x.iter().sum::() / n; + let y_mean = y.iter().sum::() / n; + + let mut var_x = 0.0; + let mut var_y = 0.0; + let mut covariance = 0.0; + for (&xi, &yi) in x.iter().zip(y) { + let dx = xi - x_mean; + let dy = yi - y_mean; + var_x += dx * dx; + var_y += dy * dy; + covariance += dx * dy; + } + + if var_x == 0.0 || var_y == 0.0 { + return Err(constant_input_error(if var_x == 0.0 { "x" } else { "y" })); } - TestResult::PValue(p_value, coefficient) + + Ok(covariance / (var_x * var_y).sqrt()) } -/// Compute the Pearson correlation coefficient and its two-tailed p-value. +/// Ordinary-least-squares residuals of `target` regressed on the +/// `rows × cols` design matrix `design` (stored row-major, `rows >= cols`), +/// computed via Householder QR. /// -/// Tests H₀: ρ = 0 using the t-distribution with n − 2 degrees of freedom. -/// Returns `(coefficient, p_value)`. +/// Returns the length-`rows` residual vector `target − design · β̂`, where `β̂` +/// minimizes `‖design · β − target‖₂`. The design's first column is the +/// intercept in our use, so `cols ≤ |Z| + 1` is small and the QR is +/// well-conditioned. /// /// # Errors /// -/// Returns an error if the input has fewer than 3 elements (degrees of freedom < 1). -fn pearsonr(x_values: &ArrayView1, y_values: &ArrayView1) -> anyhow::Result<(f64, f64)> { - let n = x_values.len(); - ensure!( - x_values.len() == y_values.len() && x_values.len() >= MIN_SAMPLE_SIZE, - "pearsonr requires equal-length inputs with n >= 3" - ); - - #[allow( - clippy::cast_precision_loss, - reason = "array length most likely won't exceed 2^53" - )] - let number_of_elements = n as f64; +/// Returns [`CiError::Numeric`] if the design is rank-deficient (a zero pivot on +/// the diagonal of `R`), which would make the residuals ill-defined. +fn ols_residuals( + design: &[f64], + target: &[f64], + rows: usize, + cols: usize, +) -> Result, CiError> { + let rank_deficient = + || CiError::Numeric("rank-deficient design matrix in partial correlation".to_string()); + + // Work on mutable copies: `mat` is triangularized in place, `rhs` is the + // transformed right-hand side. Row-major index: mat[i * cols + j]. + let mut mat = design.to_vec(); + let mut rhs = target.to_vec(); + + // Householder QR: zero out below the diagonal column by column. + for k in 0..cols { + // 2-norm of the sub-column mat[k..rows, k]. + let mut norm_sq = 0.0; + for i in k..rows { + let value = mat[i * cols + k]; + norm_sq += value * value; + } + let norm = norm_sq.sqrt(); + if norm == 0.0 { + return Err(rank_deficient()); + } + // Householder reflector w = col − alpha·e1, alpha = −sign(pivot)·‖col‖, + // where `pivot` is the on-diagonal entry of the column being reduced. + let pivot = mat[k * cols + k]; + let alpha = if pivot >= 0.0 { -norm } else { norm }; + let mut reflector = vec![0.0; rows - k]; + reflector[0] = pivot - alpha; + for i in (k + 1)..rows { + reflector[i - k] = mat[i * cols + k]; + } + let reflector_norm_sq: f64 = reflector.iter().map(|w| w * w).sum(); + if reflector_norm_sq == 0.0 { + // Column already in upper-triangular form; nothing to reflect. + continue; + } - let x_mean = x_values.sum() / number_of_elements; - let y_mean = y_values.sum() / number_of_elements; + // Apply H = I − 2·w·wᵀ / (wᵀw) to the trailing columns of `mat`. + for j in k..cols { + let mut dot = 0.0; + for i in k..rows { + dot += reflector[i - k] * mat[i * cols + j]; + } + let factor = 2.0 * dot / reflector_norm_sq; + for i in k..rows { + mat[i * cols + j] -= factor * reflector[i - k]; + } + } + // Apply the same reflection to the right-hand side. + let mut dot = 0.0; + for i in k..rows { + dot += reflector[i - k] * rhs[i]; + } + let factor = 2.0 * dot / reflector_norm_sq; + for i in k..rows { + rhs[i] -= factor * reflector[i - k]; + } + } - let mut sum_sq_x = 0.0; - let mut sum_sq_y = 0.0; - let mut sum_coproduct = 0.0; + // Back-substitute R·β = rhs[..cols] (R is the upper-left cols×cols of `mat`). + let mut beta = vec![0.0; cols]; + for i in (0..cols).rev() { + let mut acc = rhs[i]; + for j in (i + 1)..cols { + acc -= mat[i * cols + j] * beta[j]; + } + let diag = mat[i * cols + i]; + if diag == 0.0 { + return Err(rank_deficient()); + } + beta[i] = acc / diag; + } - for (&x, &y) in x_values.iter().zip(y_values.iter()) { - let dx = x - x_mean; - let dy = y - y_mean; + // Residuals target − design·β using the original design matrix. + let mut residual = target.to_vec(); + for i in 0..rows { + let mut fitted = 0.0; + for j in 0..cols { + fitted += design[i * cols + j] * beta[j]; + } + residual[i] -= fitted; + } + Ok(residual) +} - sum_sq_x += dx * dx; - sum_sq_y += dy * dy; - sum_coproduct += dx * dy; +/// Compute the (partial) correlation `r` and its degrees of freedom for +/// `x ⊥ y | z`. +/// +/// With empty `z` this is the plain Pearson r with `dof = n - 2`. With `z` it +/// is the partial correlation given Z with `dof = n - |Z| - 2`. Dispatches to +/// the O(|Z|³) sufficient-statistic fast path when the dataset's Gram cache is +/// available (see [`crate::gram`]), and to the O(n·|Z|²) residual-regression +/// path otherwise; both are algebraically identical for finite inputs (see +/// [`crate::gram`] for the non-finite caveat). Shared by the Pearson, +/// Fisher-z and equivalence tests. +/// +/// # Errors +/// +/// Returns [`CiError::DegenerateData`] on constant input / residuals or too +/// few rows, [`CiError::WrongColumnKind`] for discrete columns, and +/// [`CiError::Numeric`] for a rank-deficient conditioning set. +#[allow( + clippy::many_single_char_names, + reason = "x, y, z are the standard conditional-independence variable names from the contract" +)] +pub(crate) fn partial_correlation( + data: &Dataset, + x: usize, + y: usize, + z: &[usize], +) -> Result<(f64, usize), CiError> { + match data.gram() { + Some(gram) => partial_correlation_gram(gram, data.n_rows(), x, y, z), + None => partial_correlation_residual(data, x, y, z), } +} - // If one of the datasets is constant, pearson coefficient is undefined. - if sum_sq_x == 0.0 || sum_sq_y == 0.0 { - let array_name = if sum_sq_x == 0.0 { "x" } else { "y" }; - panic!("Array {array_name} is constant, so the pearson coëfficient is undefined."); +/// Fast path: partial correlation from the Gram cache's Schur complements. +#[allow( + clippy::many_single_char_names, + reason = "x, y, z, a, b, c, r are standard CI and linear-algebra variable names" +)] +fn partial_correlation_gram( + gram: &GramCache, + n: usize, + x: usize, + y: usize, + z: &[usize], +) -> Result<(f64, usize), CiError> { + let n_z = z.len(); + check_row_count(n, n_z)?; + + let (s_xx, s_yy, a, b, c) = gram.schur_xy_given_z(x, y, z)?; + + if z.is_empty() { + // Mirror `pearson_r`'s constant-input error. + if s_xx == 0.0 || s_yy == 0.0 { + return Err(constant_input_error(if s_xx == 0.0 { "x" } else { "y" })); + } + } else { + // Mirror the residual-constant check (the relative eps also catches + // fp-negative Schur complements and the constant-input case). + check_residual_variance("x", a, s_xx)?; + check_residual_variance("y", b, s_yy)?; } - // Calculate correlation directly - let mut coefficient = sum_coproduct / (sum_sq_x * sum_sq_y).sqrt(); + let r = c / (a * b).sqrt(); + Ok((r, n - n_z - 2)) +} - // Floating-point math can sometimes drift slightly outside (-1.0, 1.0), so then we clamp. - // By adding/subtracting EPS we prevent divide by 0 errors. - coefficient = coefficient.clamp(-1.0 + EPS, 1.0 - EPS); +/// O(n·|Z|²) fallback: compute partial correlation via Householder QR residual +/// regression on `[1, Z]`. +/// +/// With empty `z` this is the plain Pearson r with `dof = n - 2`. With `z` it is +/// the correlation of the residuals after regressing X and Y on `[1, Z]`, with +/// `dof = n - |Z| - 2`. Shared with the equivalence test. +/// +/// This is the O(n·|Z|²) fallback used when the dataset's Gram cache is +/// unavailable; the normal entry point is [`partial_correlation`]. +/// +/// # Errors +/// +/// Returns [`CiError::DegenerateData`] on constant input or when there are too +/// few rows, and [`CiError::Numeric`] if the least-squares solve fails. +#[allow( + clippy::many_single_char_names, + reason = "x, y, z are the standard conditional-independence variable names from the contract" +)] +pub(crate) fn partial_correlation_residual( + data: &Dataset, + x: usize, + y: usize, + z: &[usize], +) -> Result<(f64, usize), CiError> { + let x_vals = data.continuous(x)?; + let y_vals = data.continuous(y)?; + let n = x_vals.len(); + check_row_count(n, z.len())?; + + if z.is_empty() { + let r = pearson_r(x_vals, y_vals)?; + return Ok((r, n - 2)); + } - let t_statistic = - coefficient * (number_of_elements - 2.0).sqrt() / (1.0 - coefficient.powi(2)).sqrt(); + let n_z = z.len(); - let t_distribution = StudentsT::new(0.0, 1.0, number_of_elements - 2.0)?; - let p_value = 2.0 * t_distribution.sf(t_statistic.abs()); + // Design matrix [1, Z] (n × (n_z + 1)), stored row-major. + let n_cols = n_z + 1; + let mut design = vec![0.0; n * n_cols]; + for row in 0..n { + design[row * n_cols] = 1.0; + } + for (j, &zi) in z.iter().enumerate() { + let z_vals = data.continuous(zi)?; + for row in 0..n { + design[row * n_cols + j + 1] = z_vals[row]; + } + } - Ok((coefficient, p_value)) + // Residuals of X and Y after regressing each on [1, Z] (intercept included), + // then correlate the residuals. + let residual_x = ols_residuals(&design, x_vals, n, n_cols)?; + let residual_y = ols_residuals(&design, y_vals, n, n_cols)?; + + // If Z explains essentially all of X's (or Y's) variation, the residuals are + // pure floating-point noise and the partial correlation is undefined. Detect + // this relative to the *original* variable's deviation energy (the SVD path + // truncated such residuals to exactly zero; QR leaves ~1e-15 noise). The + // threshold sits far below any genuine residual ratio in the fixture. + check_residual_variance( + "x", + sum_sq_deviations(&residual_x), + sum_sq_deviations(x_vals), + )?; + check_residual_variance( + "y", + sum_sq_deviations(&residual_y), + sum_sq_deviations(y_vals), + )?; + + let r = pearson_r(&residual_x, &residual_y)?; + Ok((r, n - n_z - 2)) } -#[cfg(test)] -#[allow(clippy::many_single_char_names)] -mod tests { - use super::*; - use crate::utils::EPS; - use ndarray::{array, Array1, Array2}; +/// Two-tailed p-value for H₀: ρ = 0 from `r` and `dof` via the t-distribution. +/// +/// # Errors +/// +/// Returns [`CiError::DegenerateData`] if `|r| == 1` (t undefined) and +/// [`CiError::Numeric`] if the t-distribution construction fails. +pub(crate) fn correlation_p_value(r: f64, dof: usize) -> Result { + if (1.0 - r * r) <= 0.0 { + return Err(CiError::DegenerateData( + "correlation is ±1; t-statistic is undefined".to_string(), + )); + } + #[allow(clippy::cast_precision_loss)] + let dof_f = dof as f64; + let t = r * (dof_f / (1.0 - r * r)).sqrt(); + let dist = StudentsT::new(0.0, 1.0, dof_f) + .map_err(|e| CiError::Numeric(format!("Student's t distribution: {e}")))?; + Ok(2.0 * dist.sf(t.abs())) +} - const SIGNIFICANCE_LEVEL: f64 = 0.05; +impl CITest for PearsonCorrelation { + fn test_impl( + &self, + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + let (r, dof) = partial_correlation(data, x, y, z)?; + let p_value = correlation_p_value(r, dof)?; + Ok(CiResult { + statistic: Some(r), + p_value, + dof: Some(dof), + effect_size: Some(r.abs()), + }) + } - fn unwrap_correlated(r: &TestResult) -> (f64, f64) { - match r { - TestResult::PValue(p, coef) => (*p, *coef), - _ => panic!("expected TestResult::PValue"), + fn meta(&self) -> TestMeta { + TestMeta { + name: "pearson_correlation", + data_types: &[DataType::Continuous], + symmetric: true, + rule: IndependenceRule::PValueGe, } } +} - #[test] - fn uncond_independent_data_accepted() { - let t = PearsonCorrelation { - boolean: false, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![2., 4., 1., 5., 3., 8., 6., 7., 9., 10.]; - let y = array![5., 3., 7., 2., 8., 1., 9., 4., 6., 10.]; - - let (p, coef) = unwrap_correlated(&t.run_test(x, y, Array2::zeros((0, 0))).unwrap()); - assert!(p > SIGNIFICANCE_LEVEL, "got {p}"); - assert!( - coef.abs() < 0.1, - "coef={coef} should be near 0 for uncorrelated data" - ); +#[cfg(test)] +mod tests { + use super::*; + use crate::dataset::ColumnKind; + + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Continuous, v)) + .collect(), + ) + .unwrap() } #[test] - fn uncond_boolean_mode() { - let t = PearsonCorrelation { - boolean: true, - significance_level: SIGNIFICANCE_LEVEL, - }; - // independent -> true - let x = array![2., 4., 1., 5., 3., 8., 6., 7., 9., 10.]; - let y = array![5., 3., 7., 2., 8., 1., 9., 4., 6., 10.]; - let r = t.run_test(x, y, Array2::zeros((0, 0))).unwrap(); - assert!(matches!(r, TestResult::Boolean(true))); - - // dependent -> false - let x = array![1., 2., 3., 4., 5.]; - let y = array![2., 4., 6., 8., 10.]; - let r = t.run_test(x, y, Array2::zeros((0, 0))).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn unconditional_strong_correlation() { + // Strongly (but not perfectly) correlated, so 1 - r^2 > 0. + let data = ds(vec![ + ("x", vec![1., 2., 3., 4., 5.]), + ("y", vec![2., 4.1, 5.9, 8.2, 9.8]), + ]); + let r = PearsonCorrelation::new().test(&data, 0, 1, &[]).unwrap(); + assert!(r.statistic.unwrap() > 0.9); + assert_eq!(r.dof, Some(3)); + assert!(r.p_value < 0.05); + assert!((r.effect_size.unwrap() - r.statistic.unwrap().abs()).abs() < 1e-12); } #[test] - fn uncond_dependent_data_rejected() { - let t = PearsonCorrelation { - boolean: false, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![1., 2., 3., 4., 5.]; - let y = array![2., 4., 6., 8., 10.]; - - let (p, coef) = unwrap_correlated(&t.run_test(x, y, Array2::zeros((0, 0))).unwrap()); - assert!(p < SIGNIFICANCE_LEVEL, "got {p}"); - assert!( - coef.abs() > 0.9, - "coef={coef} should be high for perfectly correlated data" - ); + fn perfect_correlation_is_degenerate() { + // r == 1 exactly -> t-statistic undefined; report a clear error. + let data = ds(vec![ + ("x", vec![1., 2., 3., 4., 5.]), + ("y", vec![2., 4., 6., 8., 10.]), + ]); + assert!(matches!( + PearsonCorrelation::new().test(&data, 0, 1, &[]), + Err(CiError::DegenerateData(_)) + )); } - // Z is a confounder: X = 3*Z + noise, Y = 2*Z + noise. After conditioning, residuals are independent. #[test] - fn cond_independent_data_accepted() { - let t = PearsonCorrelation { - boolean: false, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![ - 2.019_608, 1.039_216, 4.058_824, 3.078_431, 6.098_039, 5.117_647, 8.137_255, 7.156_863 - ]; - let y = array![ - 2.059_406, 3.059_406, 2.138_614, 3.138_614, 6.217_822, 7.217_822, 6.297_030, 7.297_030 - ]; - let z = array![[1.], [2.], [3.], [4.], [5.], [6.], [7.], [8.]]; - let (p, coef) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!(p > SIGNIFICANCE_LEVEL, "got {p}"); - assert!( - coef.abs() < 0.1, - "coef={coef} should be near 0 after conditioning" - ); + fn constant_input_is_degenerate_not_panic() { + let data = ds(vec![ + ("x", vec![1., 1., 1., 1., 1.]), + ("y", vec![2., 4., 6., 8., 10.]), + ]); + assert!(matches!( + PearsonCorrelation::new().test(&data, 0, 1, &[]), + Err(CiError::DegenerateData(_)) + )); } #[test] - fn cond_boolean_mode() { - // accepted - let t = PearsonCorrelation { - boolean: true, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![ - 2.019_608, 1.039_216, 4.058_824, 3.078_431, 6.098_039, 5.117_647, 8.137_255, 7.156_863 - ]; - let y = array![ - 2.059_406, 3.059_406, 2.138_614, 3.138_614, 6.217_822, 7.217_822, 6.297_030, 7.297_030 - ]; - let z = array![[1.], [2.], [3.], [4.], [5.], [6.], [7.], [8.]]; - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(true))); - - // rejected - let x = array![1., 2., 3., 4., 5., 6., 7., 8.]; - let y = array![8., 7., 6., 5., 4., 3., 2., 1.]; - let z = array![[4.5], [4.5], [4.5], [4.5], [4.5], [4.5], [4.5], [4.5]]; - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); + fn conditional_uses_intercept() { + // y = 2*z + 5, x = 3*z + 1: after regressing out [1, z] residuals are ~0. + let z = vec![1., 2., 3., 4., 5., 6., 7., 8.]; + let x: Vec = z.iter().map(|v| 3.0 * v + 1.0).collect(); + let y: Vec = z.iter().map(|v| 2.0 * v + 5.0).collect(); + let data = ds(vec![("x", x), ("y", y), ("z", z)]); + // residuals are ~0 -> constant -> degenerate (exact collinear case). + let res = PearsonCorrelation::new().test(&data, 0, 1, &[2]); + assert!(matches!(res, Err(CiError::DegenerateData(_)))); } - // Z = 2*X + 2*Y + noise is a collider; conditioning on it induces dependence between X and Y. #[test] - fn cond_dependent_data_rejected() { - let t = PearsonCorrelation { - boolean: false, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![1., 2., 3., 4., 5., 6., 7., 8.]; - let y = array![8., 7., 6., 5., 4., 3., 2., 1.]; - let z = array![[9.], [9.], [9.], [9.], [9.], [9.], [9.], [9.]]; - - let (p, coef) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!(p < SIGNIFICANCE_LEVEL, "got {p}"); - assert!( - coef.abs() > 0.9, - "coef={coef} should be high for collider structure" - ); + fn dof_accounts_for_conditioning_set() { + let z1 = vec![0.1, 0.9, 0.2, 0.8, 0.3, 0.7, 0.4, 0.6, 0.5, 1.1]; + let z2 = vec![1.1, 0.2, 0.7, 0.3, 0.9, 0.4, 0.8, 0.1, 0.6, 0.5]; + let x = vec![0.5, 0.4, 0.6, 0.2, 0.9, 0.1, 0.7, 0.3, 0.8, 0.2]; + let y = vec![0.2, 0.7, 0.1, 0.8, 0.3, 0.9, 0.4, 0.6, 0.5, 0.7]; + let data = ds(vec![("x", x), ("y", y), ("z1", z1), ("z2", z2)]); + let r = PearsonCorrelation::new() + .test(&data, 0, 1, &[2, 3]) + .unwrap(); + // n - |Z| - 2 = 10 - 2 - 2 = 6. + assert_eq!(r.dof, Some(6)); } #[test] - fn cond_bool_rejects_dependent() { - let t = PearsonCorrelation { - boolean: true, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![1., 2., 3., 4., 5., 6., 7., 8.]; - let y = array![8., 7., 6., 5., 4., 3., 2., 1.]; - let z = array![[9.], [9.], [9.], [9.], [9.], [9.], [9.], [9.]]; - - let r = t.run_test(x, y, z).unwrap(); - assert!(matches!(r, TestResult::Boolean(false))); - } - #[test] - fn cond_multiple_vars_independent_accepted() { - let t = PearsonCorrelation { - boolean: false, - significance_level: SIGNIFICANCE_LEVEL, - }; - let x = array![2.5, 2.5, 2.5, 4.0, 4.0, 4.0, 4.0, 5.5]; - let y = array![2.4, 2.4, 0.8, 2.8, 5.2, 5.2, 3.6, 5.6]; - let z = array![ - [1., 0., 1.], - [1., 1., 0.], - [2., 0., 0.], - [2., 1., 1.], - [3., 0., 1.], - [3., 1., 0.], - [4., 0., 0.], - [4., 1., 1.], - ]; - - let (p, coef) = unwrap_correlated(&t.run_test(x, y, z).unwrap()); - assert!(p > SIGNIFICANCE_LEVEL, "got {p}"); - assert!( - coef.abs() < 0.1, - "coef={coef} should be near 0 after conditioning on all confounders" - ); + fn wrong_kind_errors() { + let data = Dataset::from_columns(vec![ + ("x".into(), ColumnKind::Discrete, vec![1., 2., 3.]), + ("y".into(), ColumnKind::Continuous, vec![1., 2., 3.]), + ]) + .unwrap(); + assert!(matches!( + PearsonCorrelation::new().test(&data, 0, 1, &[]), + Err(CiError::WrongColumnKind(_)) + )); } - #[test] - fn pearsonr_errors_on_empty_input() { - let x: Array1 = Array1::zeros(0); - let y: Array1 = Array1::zeros(0); - assert!(pearsonr(&x.view(), &y.view()).is_err()); + /// Deterministic pseudo-random doubles in (-1, 1) without a rand dep. + #[allow( + clippy::unreadable_literal, + clippy::cast_precision_loss, + reason = "LCG constants must be exact; precision loss is intentional for the RNG output" + )] + fn lcg_f64(seed: &mut u64, n: usize) -> Vec { + (0..n) + .map(|_| { + *seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((*seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0 + }) + .collect() } #[test] - fn pearsonr_errors_on_too_few_elements() { - let x = Array1::from_vec(vec![1.0, 2.0]); - let y = Array1::from_vec(vec![3.0, 4.0]); - assert!(pearsonr(&x.view(), &y.view()).is_err()); + fn gram_fast_path_matches_residual_path() { + let mut seed = 0xFEED_u64; + let n = 120; + let z1 = lcg_f64(&mut seed, n); + let z2 = lcg_f64(&mut seed, n); + let noise_x = lcg_f64(&mut seed, n); + let noise_y = lcg_f64(&mut seed, n); + let x: Vec = (0..n) + .map(|i| 1.3 * z1[i] - 0.7 * z2[i] + 0.5 * noise_x[i]) + .collect(); + let y: Vec = (0..n) + .map(|i| -0.4 * z1[i] + 0.9 * z2[i] + 0.5 * noise_y[i]) + .collect(); + let data = ds(vec![("x", x), ("y", y), ("z1", z1), ("z2", z2)]); + + for z in [vec![], vec![2], vec![2, 3]] { + let (r_fast, dof_fast) = partial_correlation(&data, 0, 1, &z).unwrap(); + let (r_slow, dof_slow) = partial_correlation_residual(&data, 0, 1, &z).unwrap(); + assert_eq!(dof_fast, dof_slow); + assert!( + (r_fast - r_slow).abs() < 1e-12, + "|Z|={}: fast {r_fast} vs slow {r_slow}", + z.len() + ); + } } #[test] - fn pearsonr_errors_on_mismatched_lengths() { - let x = Array1::from_vec(vec![1.0, 2.0, 3.0]); - let y = Array1::from_vec(vec![1.0, 2.0]); - assert!(pearsonr(&x.view(), &y.view()).is_err()); + fn gram_path_degenerate_errors_match() { + // Constant x, unconditional -> "input is constant". + let data = ds(vec![ + ("x", vec![1., 1., 1., 1., 1.]), + ("y", vec![2., 4., 6., 8., 10.]), + ]); + assert!(matches!( + partial_correlation(&data, 0, 1, &[]), + Err(CiError::DegenerateData(_)) + )); + + // Z perfectly explains x -> "residual is constant". + let z = vec![1., 2., 3., 4., 5., 6., 7., 8.]; + let x: Vec = z.iter().map(|v| 3.0 * v + 1.0).collect(); + let y = vec![0.3, -0.1, 0.9, 0.2, -0.5, 0.7, 0.1, -0.2]; + let data = ds(vec![("x", x), ("y", y), ("z", z)]); + assert!(matches!( + partial_correlation(&data, 0, 1, &[2]), + Err(CiError::DegenerateData(_)) + )); + + // Collinear Z (z2 = 2*z1) -> rank-deficient. + let z1 = vec![1., 2., 3., 4., 5., 6.]; + let z2: Vec = z1.iter().map(|v| 2.0 * v).collect(); + let x = vec![0.4, 0.1, 0.8, 0.2, 0.9, 0.3]; + let y = vec![0.2, 0.7, 0.1, 0.8, 0.3, 0.9]; + let data = ds(vec![("x", x), ("y", y), ("z1", z1), ("z2", z2)]); + assert!(matches!( + partial_correlation(&data, 0, 1, &[2, 3]), + Err(CiError::Numeric(_)) + )); } #[test] - fn pearsonr_succeeds_with_minimum_input() { - let x = Array1::from_vec(vec![1.0, 2.0, 3.0]); - let y = Array1::from_vec(vec![1.0, 2.0, 3.0]); - let (coef, p_value) = pearsonr(&x.view(), &y.view()).unwrap(); - assert!((coef - 1.0).abs() < EPS, "perfect positive correlation"); - assert!(p_value < SIGNIFICANCE_LEVEL, "got {p_value}"); + fn residual_path_collinear_z_is_numeric() { + // Directly exercise the non-gram residual path's rank-deficient outcome: + // collinear Z (z2 = 2*z1) makes the OLS design rank-deficient. + let z1 = vec![1., 2., 3., 4., 5., 6.]; + let z2: Vec = z1.iter().map(|v| 2.0 * v).collect(); + let x = vec![0.4, 0.1, 0.8, 0.2, 0.9, 0.3]; + let y = vec![0.2, 0.7, 0.1, 0.8, 0.3, 0.9]; + let data = ds(vec![("x", x), ("y", y), ("z1", z1), ("z2", z2)]); + assert!(matches!( + partial_correlation_residual(&data, 0, 1, &[2, 3]), + Err(CiError::Numeric(_)) + )); } } diff --git a/crates/ci-core/src/ci_tests/pearson_equivalence.rs b/crates/ci-core/src/ci_tests/pearson_equivalence.rs index 36c52a7..ed112dd 100644 --- a/crates/ci-core/src/ci_tests/pearson_equivalence.rs +++ b/crates/ci-core/src/ci_tests/pearson_equivalence.rs @@ -1,386 +1,181 @@ -use crate::ci_tests::PearsonCorrelation; -use crate::strategy::{CITest, CITestDataType, TestResult}; -use crate::utils::EPS; +//! Pearson equivalence (TOST) conditional-independence test (continuous). -const FISHER_Z_DOF_OFFSET: usize = 3; -use anyhow::bail; -use ndarray::{Array1, Array2, Axis}; use statrs::distribution::{ContinuousCDF, Normal}; -/// Pearson equivalence (TOST) conditional independence test. -/// -/// Uses the Two One-Sided Tests (TOST) framework with Fisher's z-transformation -/// to test whether the partial correlation is small enough to declare independence. -/// -/// **Note**: the p-value convention is inverted relative to the other tests. A *low* -/// p-value (below `significance_level`) means the correlation is within `delta_threshold` -/// of zero and the null of dependence is rejected — i.e. the variables are declared -/// independent. -#[derive(Debug, Clone, PartialEq)] +use crate::ci_tests::pearson_correlation::{partial_correlation, RHO_CLIP_EPS}; +use crate::dataset::Dataset; +use crate::error::CiError; +use crate::strategy::{CITest, CiResult, DataType, IndependenceRule, TestMeta}; + +/// Two One-Sided Tests (TOST) equivalence test on the partial correlation, +/// using Fisher's z-transform. A *low* p-value (below the significance level) +/// declares independence, so the rule is [`IndependenceRule::PValueLt`]. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct PearsonEquivalence { - pub boolean: bool, - pub significance_level: f64, + /// Equivalence margin on the correlation scale (the "negligible" effect). pub delta_threshold: f64, } impl PearsonEquivalence { + /// Construct the test with the given equivalence margin. #[must_use] - pub fn new(boolean: bool, significance_level: f64, delta_threshold: f64) -> Self { - Self { - boolean, - significance_level, - delta_threshold, - } + pub fn new(delta_threshold: f64) -> Self { + Self { delta_threshold } } } impl CITest for PearsonEquivalence { - fn run_test( + #[allow( + clippy::many_single_char_names, + reason = "x, y, z are the contract variable names; c is the Fisher-z scale factor" + )] + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result { - let n = x_values.len(); - let s = z.axis_iter(Axis(1)).len(); - - let pearsonr = PearsonCorrelation { - boolean: false, - significance_level: self.significance_level, + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result { + if !(self.delta_threshold > 0.0 && self.delta_threshold < 1.0) { + return Err(CiError::DegenerateData(format!( + "delta_threshold must be in (0, 1), got {}", + self.delta_threshold + ))); } - .run_test(x_values, y_values, z); - let statistic = match pearsonr { - Ok(TestResult::PValue(_, statistic)) => statistic, - Ok(_) => 0.0, - Err(e) => return Err(e), - }; - let rho = if statistic <= -1.0 { - -1.0 + EPS - } else if statistic >= 1.0 { - 1.0 - EPS - } else { - statistic - }; - let coefficient = rho.atanh(); - let z_delta = self.delta_threshold.atanh(); + let n = data.continuous(x)?.len(); + let n_z = z.len(); - #[allow( - clippy::cast_precision_loss, - reason = "array length and number of variables most likely won't exceed 2^53" - )] - let argument = (n - s - FISHER_Z_DOF_OFFSET) as f64; - let std_error_factor = if argument >= 0.0 { - argument.sqrt() - } else { - bail!("The length of the data should be at least 3 greater than the number of conditional variables"); - }; + let (rho_raw, _dof) = partial_correlation(data, x, y, z)?; + let rho = rho_raw.clamp(-1.0 + RHO_CLIP_EPS, 1.0 - RHO_CLIP_EPS); - let normal = Normal::new(0.0, 1.0).unwrap(); + let z_rho = rho.atanh(); + let z_delta = self.delta_threshold.atanh(); - let z_score_lower = std_error_factor * (coefficient + z_delta); - let z_score_upper = std_error_factor * (coefficient - z_delta); + // partial_correlation guarantees n >= |Z| + 3, so the radicand is >= 0 + // (matching FisherZ, which relies on the same invariant). + #[allow(clippy::cast_precision_loss)] + let c = ((n - n_z - 3) as f64).sqrt(); - let p_value_lower = 1.0 - normal.cdf(z_score_lower); - let p_value_upper = normal.cdf(z_score_upper); + let normal = + Normal::new(0.0, 1.0).map_err(|e| CiError::Numeric(format!("standard normal: {e}")))?; - let p_value = if p_value_lower > p_value_upper { - p_value_lower - } else { - p_value_upper - }; + let p_lower = 1.0 - normal.cdf(c * (z_rho + z_delta)); + let p_upper = normal.cdf(c * (z_rho - z_delta)); + let p_value = p_lower.max(p_upper); - Ok(wrap_result( - self.boolean, + Ok(CiResult { + statistic: Some(z_rho), p_value, - coefficient, - self.significance_level, - )) - } - - fn data_types(&self) -> &'static [CITestDataType] { - &[CITestDataType::Continuous] - } -} - -#[must_use] -pub fn wrap_result( - boolean: bool, - p_value: f64, - coefficient: f64, - significance_level: f64, -) -> TestResult { - if boolean { - return TestResult::Boolean(p_value < significance_level); + dof: None, + // effect_size reports the observed (un-clipped) partial correlation, + // matching Fisher-Z; the clip only guards the atanh statistic above. + effect_size: Some(rho_raw.abs()), + }) + } + + fn meta(&self) -> TestMeta { + TestMeta { + name: "pearson_equivalence", + data_types: &[DataType::Continuous], + symmetric: true, + rule: IndependenceRule::PValueLt, + } } - TestResult::PValue(p_value, coefficient) } #[cfg(test)] mod tests { use super::*; - use ndarray::{array, stack, Array1, Array2, Axis}; - use rand::rngs::SmallRng; - use rand::SeedableRng; - use rand_distr::{Distribution, Normal}; - - const SIGNIFICANCE_LEVEL: f64 = 0.05; - const DELTA_THRESHOLD: f64 = 0.1; - // Specific tests imported from pgmpy fail with default epsilon - const PGMPY_EPS: f64 = 1e-8; - - const N: usize = 1000; - - #[test] - fn basic_test() { - let x_vals = array![1.0, 2.0, 3.0, 4.0]; - let y_vals = array![1.0, 1.0, 2.0, 2.0]; - let empty_z = array![[]]; - - let test = PearsonEquivalence { - boolean: false, - significance_level: 0.05, - delta_threshold: DELTA_THRESHOLD, - }; - let result = test.run_test(x_vals, y_vals, empty_z); - - let (p_value, statistic) = match result { - Ok(TestResult::PValue(a, b)) => (a, b), - _ => (0.0, 0.0), - }; - - // values taken from pgmpy - assert!((p_value - 0.910_412_594_569_001_1).abs() < PGMPY_EPS); - assert!((statistic - 1.443_635_475_178_810_7).abs() < PGMPY_EPS); - } - - fn seeded_rng() -> SmallRng { - SmallRng::seed_from_u64(40) - } - - fn gen_normal(n: usize, mean: f64, std_dev: f64, rng: &mut SmallRng) -> Array1 { - let dist = Normal::new(mean, std_dev).unwrap(); - Array1::from_vec((0..n).map(|_| dist.sample(rng)).collect()) - } - - fn empty_array() -> Array2 { - Array2::zeros((0, 0)) - } - - fn pearson() -> PearsonEquivalence { - PearsonEquivalence { - boolean: false, - significance_level: 0.05, - delta_threshold: DELTA_THRESHOLD, - } - } + use crate::dataset::ColumnKind; - fn pearson_boolean() -> PearsonEquivalence { - PearsonEquivalence { - boolean: true, - significance_level: 0.05, - delta_threshold: DELTA_THRESHOLD, - } - } - - #[test] - fn unconditional_independent_data_is_not_rejected() { - let mut rng = seeded_rng(); - let x = gen_normal(N, 0.0, 1.0, &mut rng); - let y = gen_normal(N, 0.0, 1.0, &mut rng); - - let result = pearson().run_test(x, y, empty_array()).unwrap(); - match result { - TestResult::PValue(p_value, coefficient) => { - assert!( - p_value <= SIGNIFICANCE_LEVEL, - "p_value {p_value} should be <= 0.05 for independent data" - ); - assert!( - coefficient.abs() < DELTA_THRESHOLD, - "coefficient {coefficient} should be near 0 for independent data" - ); - } - _ => panic!("Expected TestResult::PValue"), - } - } - - #[test] - fn unconditional_boolean_accepts_independent() { - let mut rng = seeded_rng(); - let x = gen_normal(N, 0.0, 1.0, &mut rng); - let y = gen_normal(N, 0.0, 1.0, &mut rng); - - let result = pearson_boolean().run_test(x, y, empty_array()).unwrap(); - match result { - TestResult::Boolean(independent) => { - assert!(independent, "Independent data should return true"); - } - _ => panic!("Expected TestResult::Boolean"), - } - } - - #[test] - fn unconditional_dependent_data_is_rejected() { - let mut rng = seeded_rng(); - let x = gen_normal(N, 0.0, 1.0, &mut rng); - let noise = gen_normal(N, 0.0, 0.1, &mut rng); - let y = &x * 3.0 + &noise; - - let result = pearson().run_test(x, y, empty_array()).unwrap(); - match result { - TestResult::PValue(p_value, coefficient) => { - assert!( - p_value >= SIGNIFICANCE_LEVEL, - "p_value {p_value} should be >= 0.05 for correlated data" - ); - assert!( - coefficient.abs() > 0.9, - "coefficient {coefficient} should be high for correlated data" - ); - } - _ => panic!("Expected TestResult::PValue"), - } + fn ds(cols: Vec<(&str, Vec)>) -> Dataset { + Dataset::from_columns( + cols.into_iter() + .map(|(n, v)| (n.to_string(), ColumnKind::Continuous, v)) + .collect(), + ) + .unwrap() } #[test] - fn unconditional_boolean_rejects_dependent() { - let mut rng = seeded_rng(); - let x = gen_normal(N, 0.0, 1.0, &mut rng); - let noise = gen_normal(N, 0.0, 0.1, &mut rng); - let y = &x * 3.0 + &noise; - - let result = pearson_boolean().run_test(x, y, empty_array()).unwrap(); - match result { - TestResult::Boolean(independent) => { - assert!(!independent, "Correlated data should return false"); - } - _ => panic!("Expected TestResult::Boolean"), - } + fn meta_uses_pvalue_lt() { + let m = PearsonEquivalence::new(0.1).meta(); + assert_eq!(m.name, "pearson_equivalence"); + assert_eq!(m.rule, IndependenceRule::PValueLt); + assert_eq!(m.data_types, &[DataType::Continuous]); + assert!(m.symmetric); } - // Z is a confounder: X = 3*Z + noise, Y = 2*Z + noise. After conditioning, residuals are independent. #[test] - fn conditional_independent_data_is_not_rejected() { - let mut rng = seeded_rng(); - let z = gen_normal(N, 0.0, 1.0, &mut rng); - let noise_x = gen_normal(N, 0.0, 0.1, &mut rng); - let noise_y = gen_normal(N, 0.0, 0.1, &mut rng); - let x = &z * 3.0 + &noise_x; - let y = &z * 2.0 + &noise_y; - let array = z.insert_axis(Axis(1)); - - let result = pearson().run_test(x, y, array).unwrap(); - match result { - TestResult::PValue(p_value, coefficient) => { - assert!( - p_value <= SIGNIFICANCE_LEVEL, - "p_value {p_value} should be <= 0.05 after conditioning" - ); - assert!( - coefficient.abs() < DELTA_THRESHOLD, - "coefficient {coefficient} should be near 0 after conditioning" - ); - } - _ => panic!("Expected TestResult::PValue"), - } + fn reports_fisher_z_statistic_and_no_dof() { + let data = ds(vec![ + ("x", vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]), + ("y", vec![2.0, 1.0, 4.0, 3.0, 6.0, 5.0]), + ]); + let r = PearsonEquivalence::new(0.1).test(&data, 0, 1, &[]).unwrap(); + assert!(r.dof.is_none()); + // statistic is atanh(rho); effect_size is |rho|. + let rho = r.statistic.unwrap().tanh(); + assert!((r.effect_size.unwrap() - rho.abs()).abs() < 1e-9); } #[test] - fn conditional_boolean_accepts_independent() { - let mut rng = seeded_rng(); - let z = gen_normal(N, 0.0, 1.0, &mut rng); - let noise_x = gen_normal(N, 0.0, 0.1, &mut rng); - let noise_y = gen_normal(N, 0.0, 0.1, &mut rng); - let x = &z * 3.0 + &noise_x; - let y = &z * 2.0 + &noise_y; - let array = z.insert_axis(Axis(1)); - - let result = pearson_boolean().run_test(x, y, array).unwrap(); - match result { - TestResult::Boolean(independent) => { - assert!( - independent, - "Conditionally independent data should return true" - ); - } - _ => panic!("Expected TestResult::Boolean"), - } + fn out_of_range_delta_errors() { + // delta_threshold must lie in (0, 1); 1.5 is rejected before any compute. + let data = ds(vec![ + ("x", vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]), + ("y", vec![2.0, 1.0, 4.0, 3.0, 6.0, 5.0]), + ]); + assert!(matches!( + PearsonEquivalence::new(1.5).test(&data, 0, 1, &[]), + Err(CiError::DegenerateData(_)) + )); } - // Z = 2*X + 2*Y + noise is a collider; conditioning on it induces dependence between X and Y. #[test] - fn conditional_dependent_data_is_rejected() { - let mut rng = seeded_rng(); - let x = gen_normal(N, 0.0, 1.0, &mut rng); - let y = gen_normal(N, 0.0, 1.0, &mut rng); - let noise = gen_normal(N, 0.0, 0.1, &mut rng); - let z = &x * 2.0 + &y * 2.0 + &noise; - let array = z.insert_axis(Axis(1)); - - let result = pearson().run_test(x, y, array).unwrap(); - match result { - TestResult::PValue(p_value, coefficient) => { - assert!( - p_value >= SIGNIFICANCE_LEVEL, - "p_value {p_value} should be >= 0.05 for v-structure" - ); - assert!( - coefficient.abs() > 0.9, - "coefficient {coefficient} should be high for v-structure" - ); - } - _ => panic!("Expected TestResult::PValue"), + fn near_zero_correlation_declares_independence() { + // Exactly-zero correlation by construction: each x value pairs with +1 + // and -1 in y, so the covariance is 0. With delta 0.2 and n = 100 the + // TOST p-value falls below 0.05, so PValueLt declares independence. + let mut x = Vec::new(); + let mut y = Vec::new(); + for i in 0..50 { + x.push(f64::from(i)); + y.push(1.0); + x.push(f64::from(i)); + y.push(-1.0); } + let data = ds(vec![("x", x), ("y", y)]); + assert!(PearsonEquivalence::new(0.2) + .is_independent(&data, 0, 1, &[], 0.05) + .unwrap()); } #[test] - fn conditional_boolean_rejects_dependent() { - let mut rng = seeded_rng(); - let x = gen_normal(N, 0.0, 1.0, &mut rng); - let y = gen_normal(N, 0.0, 1.0, &mut rng); - let noise = gen_normal(N, 0.0, 0.1, &mut rng); - let z = &x * 2.0 + &y * 2.0 + &noise; - let array = z.insert_axis(Axis(1)); - - let result = pearson_boolean().run_test(x, y, array).unwrap(); - match result { - TestResult::Boolean(independent) => { - assert!( - !independent, - "V-structure conditioned on collider should return false" - ); - } - _ => panic!("Expected TestResult::Boolean"), - } + fn strong_correlation_not_independent_under_small_delta() { + // A near-perfect correlation with a tiny equivalence margin: the TOST + // p-value is large, so PValueLt does not declare independence. + let data = ds(vec![ + ("x", vec![1., 2., 3., 4., 5.]), + ("y", vec![2., 4.1, 5.9, 8.2, 9.8]), + ]); + assert!(!PearsonEquivalence::new(0.05) + .is_independent(&data, 0, 1, &[], 0.05) + .unwrap()); } #[test] - fn conditional_multiple_vars_independent_is_not_rejected() { - let mut rng = seeded_rng(); - let z_1 = gen_normal(N, 0.0, 1.0, &mut rng); - let z_2 = gen_normal(N, 0.0, 1.0, &mut rng); - let z_3 = gen_normal(N, 0.0, 1.0, &mut rng); - let noise_x = gen_normal(N, 0.0, 0.1, &mut rng); - let noise_y = gen_normal(N, 0.0, 0.1, &mut rng); - let x = 0.5 * &z_1 + 0.5 * &z_2 + 0.5 * &z_3 + &noise_x; - let y = 0.5 * &z_1 + 0.5 * &z_2 + 0.5 * &z_3 + &noise_y; - - let array = stack(Axis(1), &[z_1.view(), z_2.view(), z_3.view()]).unwrap(); - - let result = pearson().run_test(x, y, array).unwrap(); - match result { - TestResult::PValue(p_value, coefficient) => { - assert!( - p_value < SIGNIFICANCE_LEVEL, - "p_value {p_value} should be < 0.05 after conditioning on all confounders" - ); - assert!( - coefficient.abs() <= DELTA_THRESHOLD, - "coefficient {coefficient} should be near 0 after conditioning on all confounders" - ); - } - _ => panic!("Expected TestResult::PValue"), - } + fn too_few_rows_is_degenerate() { + // n=3, |Z|=1 -> n - |Z| - 3 = -1 < 0. + let data = ds(vec![ + ("x", vec![1.0, 2.0, 3.0]), + ("y", vec![3.0, 2.0, 1.0]), + ("z", vec![1.0, 2.0, 3.0]), + ]); + assert!(matches!( + PearsonEquivalence::new(0.1).test(&data, 0, 1, &[2]), + Err(CiError::DegenerateData(_)) + )); } } diff --git a/crates/ci-core/src/dataset.rs b/crates/ci-core/src/dataset.rs new file mode 100644 index 0000000..86c0af2 --- /dev/null +++ b/crates/ci-core/src/dataset.rs @@ -0,0 +1,496 @@ +//! Data-bound container shared by every conditional-independence test. +//! +//! A [`Dataset`] is built once from named columns. Discrete columns are +//! *factorized* up front into contiguous integer codes (`0..cardinality`) so +//! the discrete tests can operate on cheap `usize` codes instead of repeatedly +//! hashing floats. Continuous columns keep their raw `f64` values. +//! NaN values are rejected at construction ([`CiError::MissingData`]); choose +//! and apply a missing-data convention (drop / impute) before binding. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, RwLock}; + +use crate::discrete::{build_strata_partition, StrataPartition}; +use crate::error::CiError; +use crate::gram::GramCache; + +/// Total byte budget for cached stratum partitions (see +/// [`Dataset::strata_partition`]): partitions are cached until the budget is +/// reached; further conditioning sets compute on the fly without caching +/// (monotone — no eviction). +pub(crate) const MAX_STRATA_CACHE_BYTES: usize = 256 << 20; + +/// Keyed cache of stratum partitions plus its current byte footprint. +#[derive(Debug, Default)] +pub(crate) struct StrataCacheInner { + pub(crate) map: HashMap, Arc>, + bytes: usize, +} + +impl StrataCacheInner { + /// Insert `partition` under `key` iff it fits in `budget`; returns whether + /// it was cached. Factored out so tests can drive a tiny budget. + /// + /// The caller must ensure `key` is absent (the cache's double-checked + /// lookup guarantees this); inserting an existing key would double-count + /// `bytes`. + pub(crate) fn insert_within_budget( + &mut self, + key: Vec, + partition: &Arc, + budget: usize, + ) -> bool { + debug_assert!( + !self.map.contains_key(&key), + "insert_within_budget requires an absent key" + ); + let size = partition.approx_bytes(); + if self.bytes + size > budget { + return false; + } + self.bytes += size; + self.map.insert(key, Arc::clone(partition)); + true + } +} + +/// Whether a column holds categorical (discrete) or numeric (continuous) data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColumnKind { + /// Categorical data; values are factorized into integer codes. + Discrete, + /// Numeric data; values are kept as `f64`. + Continuous, +} + +/// Stored representation of a single column. +#[derive(Debug, Clone)] +pub(crate) enum Column { + /// Factorized discrete column: per-row codes plus the number of distinct + /// categories. + Discrete { + codes: Vec, + cardinality: usize, + }, + /// Raw continuous column. + Continuous { values: Vec }, +} + +/// A named, typed, immutable table of columns. +/// +/// Build one with [`Dataset::from_columns`]; tests then refer to columns by +/// their integer index (see [`Dataset::index_of`]). +#[derive(Debug)] +pub struct Dataset { + names: Vec, + name_to_index: HashMap, + columns: Vec, + n_rows: usize, + /// Lazily built Gaussian sufficient-statistic cache (means + centered + /// cross-product matrix). Populated on first access via [`Dataset::gram`]. + gram: OnceLock>, + /// Lazily built, budget-bounded cache of stratum partitions keyed by the + /// sorted conditioning-set indices (see [`Dataset::strata_partition`]). + strata_cache: RwLock, +} + +/// Canonicalize a float for factorization so that `-0.0` and `0.0` share a +/// code. NaN never reaches this function: `from_columns` rejects NaN columns +/// up front (strict missing-data policy). +fn canonical_bits(v: f64) -> u64 { + if v == 0.0 { + // Collapse -0.0 and 0.0. + 0.0_f64.to_bits() + } else { + v.to_bits() + } +} + +impl Dataset { + /// Build a dataset from `(name, kind, values)` triples. + /// + /// Discrete columns are factorized into contiguous codes (`0..cardinality`) + /// in first-seen order; `-0.0`/`0.0` are grouped together. + /// Continuous columns store their raw values. + /// + /// # Errors + /// + /// Returns [`CiError::DimensionMismatch`] if the columns do not all share a + /// common length, or if two columns share a name (names must be unique). + /// Returns [`CiError::MissingData`] if a discrete column contains NaN, or a + /// continuous column contains any non-finite value (NaN or ±inf). + pub fn from_columns(cols: Vec<(String, ColumnKind, Vec)>) -> Result { + let n_rows = cols.first().map_or(0, |(_, _, v)| v.len()); + for (name, _, values) in &cols { + if values.len() != n_rows { + return Err(CiError::DimensionMismatch(format!( + "column `{name}` has length {} but expected {n_rows}", + values.len(), + ))); + } + } + + let mut names = Vec::with_capacity(cols.len()); + let mut name_to_index = HashMap::with_capacity(cols.len()); + let mut columns = Vec::with_capacity(cols.len()); + + for (idx, (name, kind, values)) in cols.into_iter().enumerate() { + // Discrete columns reject NaN (missing); continuous columns reject any + // non-finite value (NaN or ±inf), which would corrupt the Gram path. + let bad = match kind { + ColumnKind::Continuous => values.iter().position(|v| !v.is_finite()), + ColumnKind::Discrete => values.iter().position(|v| v.is_nan()), + }; + if let Some(row) = bad { + return Err(CiError::MissingData(format!( + "column `{name}` contains NaN/missing values (first at row {row}); \ + remove or impute before building the Dataset" + ))); + } + let column = match kind { + ColumnKind::Continuous => Column::Continuous { values }, + ColumnKind::Discrete => { + let mut lookup: HashMap = HashMap::new(); + let mut codes = Vec::with_capacity(values.len()); + for v in values { + let key = canonical_bits(v); + let next = lookup.len(); + let code = *lookup.entry(key).or_insert(next); + codes.push(code); + } + Column::Discrete { + cardinality: lookup.len(), + codes, + } + } + }; + if name_to_index.insert(name.clone(), idx).is_some() { + return Err(CiError::DimensionMismatch(format!( + "duplicate column name `{name}`; column names must be unique" + ))); + } + names.push(name); + columns.push(column); + } + + Ok(Self { + names, + name_to_index, + columns, + n_rows, + gram: OnceLock::new(), + strata_cache: RwLock::new(StrataCacheInner::default()), + }) + } + + /// Number of rows (observations). + #[must_use] + pub fn n_rows(&self) -> usize { + self.n_rows + } + + /// Number of columns. + #[must_use] + pub fn n_cols(&self) -> usize { + self.columns.len() + } + + /// Name of the column at `index`, if any. + #[must_use] + pub fn name_of(&self, index: usize) -> Option<&str> { + self.names.get(index).map(String::as_str) + } + + /// Index of the column named `name`, if present. + #[must_use] + pub fn index_of(&self, name: &str) -> Option { + self.name_to_index.get(name).copied() + } + + /// Read a discrete column as `(codes, cardinality)`. + /// + /// # Errors + /// + /// Returns [`CiError::UnknownColumn`] if `index` is out of range, or + /// [`CiError::WrongColumnKind`] if the column is continuous. + /// Borrow the column at `index`, mapping an out-of-range index to + /// [`CiError::UnknownColumn`]. + fn column(&self, index: usize) -> Result<&Column, CiError> { + self.columns + .get(index) + .ok_or_else(|| CiError::UnknownColumn(format!("column index {index}"))) + } + + pub(crate) fn discrete(&self, index: usize) -> Result<(&[usize], usize), CiError> { + match self.column(index)? { + Column::Discrete { codes, cardinality } => Ok((codes, *cardinality)), + Column::Continuous { .. } => Err(CiError::WrongColumnKind(format!( + "column {index} is continuous but a discrete column was required" + ))), + } + } + + /// Read a continuous column's values. + /// + /// # Errors + /// + /// Returns [`CiError::UnknownColumn`] if `index` is out of range, or + /// [`CiError::WrongColumnKind`] if the column is discrete. + pub(crate) fn continuous(&self, index: usize) -> Result<&[f64], CiError> { + match self.column(index)? { + Column::Continuous { values } => Ok(values), + Column::Discrete { .. } => Err(CiError::WrongColumnKind(format!( + "column {index} is discrete but a continuous column was required" + ))), + } + } + + /// The lazily built Gaussian sufficient-statistic cache, or `None` when + /// the dataset has no continuous columns / too many of them (see + /// [`crate::gram::MAX_GRAM_COLS`]). + /// + /// `OnceLock::get_or_init` runs the build exactly once, blocking any + /// concurrent callers until it completes; the stored cache is therefore + /// built a single time and is deterministic. + pub(crate) fn gram(&self) -> Option<&GramCache> { + self.gram.get_or_init(|| GramCache::build(self)).as_ref() + } + + /// The stratum partition for conditioning set `z`, cached per distinct + /// (order-insensitive) set of column indices. On a miss the partition is + /// built outside the lock and inserted only while the total cache stays + /// within [`MAX_STRATA_CACHE_BYTES`]; once the budget is reached, further + /// sets are computed per call without caching. + /// + /// # Errors + /// + /// Returns the usual column errors ([`CiError::WrongColumnKind`] / + /// [`CiError::UnknownColumn`]) if any `z` column is not discrete. + pub(crate) fn strata_partition(&self, z: &[usize]) -> Result, CiError> { + let mut key: Vec = z.to_vec(); + key.sort_unstable(); + + if let Some(hit) = self + .strata_cache + .read() + .expect("strata cache lock poisoned") + .map + .get(&key) + { + return Ok(Arc::clone(hit)); + } + + // Build outside the lock; gather columns in sorted order so equal sets + // yield byte-identical partitions. + let z_columns: Vec<(&[usize], usize)> = key + .iter() + .map(|&zi| self.discrete(zi)) + .collect::>()?; + let partition = Arc::new(build_strata_partition(&z_columns, self.n_rows)); + + let mut cache = self + .strata_cache + .write() + .expect("strata cache lock poisoned"); + if let Some(hit) = cache.map.get(&key) { + return Ok(Arc::clone(hit)); // another thread won the race + } + cache.insert_within_budget(key, &partition, MAX_STRATA_CACHE_BYTES); + Ok(partition) + } +} + +impl Clone for Dataset { + /// Clones the column data; the lazy caches (Gram matrix, stratum + /// partitions) start fresh in the clone and are rebuilt on demand. + fn clone(&self) -> Self { + Self { + names: self.names.clone(), + name_to_index: self.name_to_index.clone(), + columns: self.columns.clone(), + n_rows: self.n_rows, + gram: OnceLock::new(), + strata_cache: RwLock::new(StrataCacheInner::default()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn factorizes_discrete_first_seen() { + let ds = Dataset::from_columns(vec![( + "a".to_string(), + ColumnKind::Discrete, + vec![5.0, 5.0, 2.0, 2.0, 5.0], + )]) + .unwrap(); + let (codes, card) = ds.discrete(0).unwrap(); + assert_eq!(card, 2); + assert_eq!(codes, &[0, 0, 1, 1, 0]); + assert_eq!(ds.n_rows(), 5); + } + + #[test] + fn groups_neg_zero() { + let ds = Dataset::from_columns(vec![( + "a".to_string(), + ColumnKind::Discrete, + vec![0.0, -0.0, 1.0], + )]) + .unwrap(); + let (codes, card) = ds.discrete(0).unwrap(); + // 0.0 and -0.0 share a code; 1.0 is its own. + assert_eq!(card, 2); + assert_eq!(codes, &[0, 0, 1]); + } + + #[test] + fn nan_errors_in_any_column_kind() { + for kind in [ColumnKind::Discrete, ColumnKind::Continuous] { + let err = + Dataset::from_columns(vec![("a".to_string(), kind, vec![1.0, f64::NAN, 2.0])]) + .unwrap_err(); + assert!( + matches!(err, CiError::MissingData(_)), + "kind {kind:?}: {err}" + ); + let msg = err.to_string(); + assert!(msg.contains("`a`"), "message should name the column: {msg}"); + assert!(msg.contains("row 1"), "message should give the row: {msg}"); + } + } + + #[test] + fn duplicate_column_names_error() { + let err = Dataset::from_columns(vec![ + ("a".to_string(), ColumnKind::Continuous, vec![1.0, 2.0]), + ("a".to_string(), ColumnKind::Continuous, vec![3.0, 4.0]), + ]) + .unwrap_err(); + assert!(matches!(err, CiError::DimensionMismatch(_)), "{err}"); + assert!(err.to_string().contains("`a`")); + } + + #[test] + fn infinite_continuous_value_errors() { + let err = Dataset::from_columns(vec![( + "a".to_string(), + ColumnKind::Continuous, + vec![1.0, f64::INFINITY, 2.0], + )]) + .unwrap_err(); + assert!(matches!(err, CiError::MissingData(_)), "{err}"); + assert!(err.to_string().contains("row 1")); + } + + #[test] + fn continuous_round_trips() { + let ds = Dataset::from_columns(vec![( + "x".to_string(), + ColumnKind::Continuous, + vec![1.5, 2.5, 3.5], + )]) + .unwrap(); + assert_eq!(ds.continuous(0).unwrap(), &[1.5, 2.5, 3.5]); + assert_eq!(ds.index_of("x"), Some(0)); + assert_eq!(ds.index_of("nope"), None); + } + + #[test] + fn mismatched_lengths_error() { + let err = Dataset::from_columns(vec![ + ("a".to_string(), ColumnKind::Continuous, vec![1.0, 2.0]), + ("b".to_string(), ColumnKind::Continuous, vec![1.0]), + ]) + .unwrap_err(); + assert!(matches!(err, CiError::DimensionMismatch(_))); + } + + #[test] + fn wrong_kind_errors() { + let ds = Dataset::from_columns(vec![( + "a".to_string(), + ColumnKind::Discrete, + vec![1.0, 2.0], + )]) + .unwrap(); + assert!(matches!(ds.continuous(0), Err(CiError::WrongColumnKind(_)))); + assert!(matches!(ds.discrete(9), Err(CiError::UnknownColumn(_)))); + } + + #[test] + fn strata_partition_is_cached_and_order_insensitive() { + let ds = Dataset::from_columns(vec![ + ( + "x".into(), + ColumnKind::Discrete, + vec![1., 2., 1., 2., 1., 2.], + ), + ( + "z1".into(), + ColumnKind::Discrete, + vec![1., 1., 2., 2., 1., 2.], + ), + ( + "z2".into(), + ColumnKind::Discrete, + vec![2., 1., 2., 1., 1., 2.], + ), + ]) + .unwrap(); + let a = ds.strata_partition(&[1, 2]).unwrap(); + let b = ds.strata_partition(&[1, 2]).unwrap(); + let c = ds.strata_partition(&[2, 1]).unwrap(); + assert!( + std::sync::Arc::ptr_eq(&a, &b), + "repeat query must hit the cache" + ); + assert!(std::sync::Arc::ptr_eq(&a, &c), "z order must not matter"); + // A continuous column in z surfaces the usual WrongColumnKind. + let ds2 = Dataset::from_columns(vec![ + ("x".into(), ColumnKind::Discrete, vec![1., 2.]), + ("c".into(), ColumnKind::Continuous, vec![0.1, 0.2]), + ]) + .unwrap(); + assert!(matches!( + ds2.strata_partition(&[1]), + Err(CiError::WrongColumnKind(_)) + )); + } + + #[test] + fn clone_starts_with_fresh_caches() { + let ds = Dataset::from_columns(vec![ + ("x".into(), ColumnKind::Discrete, vec![1., 2., 1., 2.]), + ("z".into(), ColumnKind::Discrete, vec![1., 1., 2., 2.]), + ]) + .unwrap(); + let before = ds.strata_partition(&[1]).unwrap(); + let cloned = ds.clone(); + let after = cloned.strata_partition(&[1]).unwrap(); + // Same content, but the clone rebuilt its own partition. + assert_eq!(before.starts, after.starts); + assert_eq!(before.rows, after.rows); + assert!(!std::sync::Arc::ptr_eq(&before, &after)); + } + + #[test] + fn strata_cache_respects_budget() { + let p = std::sync::Arc::new(crate::discrete::StrataPartition { + starts: vec![0, 2, 4], + rows: vec![0, 1, 2, 3], + }); + let mut inner = StrataCacheInner::default(); + // A zero budget rejects even the first insert (strict `>` boundary). + assert!(!inner.insert_within_budget(vec![0], &p, 0)); + assert_eq!(inner.bytes, 0); + // Budget fits exactly one copy (7 u32s = 28 bytes). + assert!(inner.insert_within_budget(vec![1], &p, 28)); + assert!(!inner.insert_within_budget(vec![2], &p, 28), "over budget"); + assert!(inner.map.contains_key(&vec![1])); + assert!(!inner.map.contains_key(&vec![2])); + } +} diff --git a/crates/ci-core/src/discrete.rs b/crates/ci-core/src/discrete.rs new file mode 100644 index 0000000..40d6dad --- /dev/null +++ b/crates/ci-core/src/discrete.rs @@ -0,0 +1,517 @@ +//! Discrete contingency-table machinery operating on factorized `Dataset` codes. +//! +//! This is the discrete back-end for the power-divergence family of +//! conditional-independence tests (see [`crate::ci_tests`]): it builds `(X, Y)` +//! count tables from integer codes, applies the power-divergence statistic for a +//! given parameter `λ` (with optional Yates' continuity correction on 2×2 +//! tables), and aggregates over the strata defined by the conditioning set `Z`. +//! +//! The statistic matches `scipy.stats.chi2_contingency(table, lambda_=λ, +//! correction=yates)`, which is how the golden fixture was generated. For a table +//! with observed counts `O`, marginals `R_i`, `C_j`, total `N` and expected +//! `E_ij = R_i * C_j / N`: +//! +//! - `λ = 0` (log-likelihood / G-test): `2 * Σ O·ln(O/E)`. +//! - `λ = −1` (modified log-likelihood): `2 * Σ E·ln(E/O)`. +//! - otherwise (incl. `λ = 1`, `2/3`, `−1/2`): +//! `(2 / (λ·(λ+1))) · Σ ( O^(λ+1)/E^λ − O )`. +//! +//! The `O^(λ+1)/E^λ − O` form is used (rather than `O·((O/E)^λ − 1)`) because it +//! is `O = 0`-safe for every `λ > −1`: at `O = 0` the term is `0` instead of the +//! `0·∞ = NaN` the naive form would produce for `λ < 0`. +//! +//! ## Conditional stratification strategy +//! +//! Stratification is split into a build phase and a consume phase so that the +//! grouping work can be cached on the [`crate::dataset::Dataset`]. The build +//! phase ([`build_strata_partition`]) produces a [`StrataPartition`] — a +//! counting-sort permutation of the row indices by their Z-code combination — +//! and the consume phase ([`power_divergence_conditional`]) sweeps the +//! partition, tabulating each stratum in turn. When the mixed-radix product +//! `∏ k_zi` overflows `usize` (very many / very-high-cardinality Z columns) +//! the builder falls back to a [`HashMap`]-based grouping internally; the API +//! is unchanged. + +/// The parameter `λ` selecting a member of the power-divergence family. +const LAMBDA_LOG_LIKELIHOOD: f64 = 0.0; +/// The parameter `λ` for the modified log-likelihood (Neyman) statistic. +const LAMBDA_MODIFIED: f64 = -1.0; + +/// A dense `rows × cols` contingency table of counts, stored row-major. +pub(crate) struct ContingencyTable { + rows: usize, + cols: usize, + counts: Vec, +} + +impl ContingencyTable { + /// Allocate a zeroed `rows × cols` table. + fn zeros(rows: usize, cols: usize) -> Self { + Self { + rows, + cols, + counts: vec![0.0; rows * cols], + } + } + + #[inline] + fn add(&mut self, r: usize, c: usize) { + self.counts[r * self.cols + c] += 1.0; + } + + /// Zero the table for reuse across strata. + fn reset(&mut self) { + self.counts.fill(0.0); + } + + /// Iterate over the rows of the table as count slices. + fn rows(&self) -> impl Iterator { + self.counts.chunks_exact(self.cols) + } +} + +/// Per-table result: `(statistic, degrees_of_freedom)`. +type TableStat = (f64, usize); + +/// One cell's contribution to the power-divergence statistic, given the +/// (possibly Yates-corrected) observed count `observed` and expected `expected`. +/// +/// `expected` is guaranteed `> 0` by the caller (active marginals and total are +/// positive). Returns `f64::INFINITY` for the `λ = −1`, `O = 0`, `E > 0` cell, +/// which propagates to a `+∞` statistic and hence `p = 0`. +#[inline] +fn power_divergence_term(observed: f64, expected: f64, lambda: f64) -> f64 { + if lambda == LAMBDA_LOG_LIKELIHOOD { + // 2 * O * ln(O / E), with O*ln(O) -> 0 at O = 0. + if observed == 0.0 { + 0.0 + } else { + 2.0 * observed * (observed / expected).ln() + } + } else if lambda == LAMBDA_MODIFIED { + // 2 * E * ln(E / O); at O = 0 (E > 0) this is +inf. + if observed == 0.0 { + f64::INFINITY + } else { + 2.0 * expected * (expected / observed).ln() + } + } else { + // (2 / (λ(λ+1))) * ( O^(λ+1)/E^λ − O ). + // O = 0-safe for λ > −1: the bracket is 0 at O = 0. + let bracket = observed.powf(lambda + 1.0) / expected.powf(lambda) - observed; + (2.0 / (lambda * (lambda + 1.0))) * bracket + } +} + +/// Power-divergence statistic and dof for one table, applying Yates' continuity +/// correction on 2×2 tables when `yates` is set. +/// +/// Returns `None` if the table is degenerate for testing: fewer than 2 active +/// rows/columns, or a zero total (the stratum should then be skipped). dof is +/// computed over the *active* sub-table (rows/columns with a non-zero marginal), +/// matching scipy/pgmpy which drop empty categories. +fn power_divergence_table(table: &ContingencyTable, lambda: f64, yates: bool) -> Option { + // Marginals over the global shape. + let mut row_sums = vec![0.0; table.rows]; + let mut col_sums = vec![0.0; table.cols]; + for (row, row_sum) in table.rows().zip(row_sums.iter_mut()) { + for (&v, col_sum) in row.iter().zip(col_sums.iter_mut()) { + *row_sum += v; + *col_sum += v; + } + } + let total: f64 = row_sums.iter().sum(); + if total == 0.0 { + return None; + } + + // Active rows/cols are those with a non-zero marginal. + let active_rows = row_sums.iter().filter(|&&s| s > 0.0).count(); + let active_cols = col_sums.iter().filter(|&&s| s > 0.0).count(); + if active_rows < 2 || active_cols < 2 { + return None; + } + + let dof = (active_rows - 1) * (active_cols - 1); + // Yates' correction applies to the active 2×2 table (dof == 1), for all λ. + let use_yates = yates && dof == 1; + + let mut statistic = 0.0; + for (row, &row_sum) in table.rows().zip(row_sums.iter()) { + if row_sum == 0.0 { + continue; + } + for (&observed, &col_sum) in row.iter().zip(col_sums.iter()) { + if col_sum == 0.0 { + continue; + } + let expected = row_sum * col_sum / total; + // Active marginals are > 0 and total > 0, so expected > 0. + let corrected = if use_yates { + // Yates: shrink O toward E by clamping (O − E) to [−0.5, 0.5]. + observed - (observed - expected).clamp(-0.5, 0.5) + } else { + observed + }; + statistic += power_divergence_term(corrected, expected, lambda); + } + } + + Some((statistic, dof)) +} + +/// Rows grouped by the observed combinations of a conditioning set `Z`: +/// `rows[starts[s] .. starts[s + 1]]` are the row indices of stratum `s` +/// (original row order within each stratum). Built once per distinct `Z` and +/// cached on the [`crate::dataset::Dataset`]. +#[derive(Debug)] +pub(crate) struct StrataPartition { + pub(crate) starts: Vec, + pub(crate) rows: Vec, +} + +impl StrataPartition { + /// Number of observed strata. + fn n_strata(&self) -> usize { + self.starts.len().saturating_sub(1) + } + + /// Approximate heap size, for the cache budget. + pub(crate) fn approx_bytes(&self) -> usize { + 4 * (self.starts.len() + self.rows.len()) + } +} + +/// Result of the discrete power-divergence test. +pub(crate) struct DiscreteOutcome { + pub statistic: f64, + pub dof: usize, +} + +/// Unconditional `(X, Y)` power-divergence statistic for parameter `lambda`, +/// with Yates' correction when `yates` is set. Returns a zero statistic with +/// `dof == 0` when the table is degenerate (single active row/column). +pub(crate) fn power_divergence_unconditional( + x_codes: &[usize], + y_codes: &[usize], + kx: usize, + ky: usize, + lambda: f64, + yates: bool, +) -> DiscreteOutcome { + let mut table = ContingencyTable::zeros(kx, ky); + for (&xc, &yc) in x_codes.iter().zip(y_codes) { + table.add(xc, yc); + } + let (statistic, dof) = power_divergence_table(&table, lambda, yates).unwrap_or((0.0, 0)); + DiscreteOutcome { statistic, dof } +} + +/// When the folded space (`∏ k_zi`) fits within this many times the row count +/// plus slack, use a flat `u32::MAX`-sentinel remap array instead of a `HashMap`. +const DENSE_REMAP_SLACK: usize = 4096; + +/// Mixed-radix strides over the Z cardinalities, and the total `∏ k_zi`. +/// `None` if the product overflows `usize` (caller falls back to hashing). +fn fold_strides(z_columns: &[(&[usize], usize)]) -> Option<(Vec, usize)> { + let mut strides = Vec::with_capacity(z_columns.len()); + let mut acc: usize = 1; + for &(_, card) in z_columns { + strides.push(acc); + // card >= 1 whenever there are rows; max(1) keeps n == 0 safe. + acc = acc.checked_mul(card.max(1))?; + } + Some((strides, acc)) +} + +/// Map each key to a first-seen dense id in `[0, n_strata)`, pushing the ids +/// onto `dense_ids`; returns the number of distinct keys (`n_strata`). +fn densify_hashed( + keys: impl IntoIterator, + dense_ids: &mut Vec, +) -> usize { + let mut remap: std::collections::HashMap = std::collections::HashMap::new(); + for key in keys { + let next = u32::try_from(remap.len()).expect("strata bounded by row count"); + dense_ids.push(*remap.entry(key).or_insert(next)); + } + remap.len() +} + +/// Group rows by the combination of the `Z` columns' codes. Strata are +/// identified by a mixed-radix fold (no per-row allocation); if `∏ k_zi` +/// overflows `usize` (only reachable with very many / very-high-cardinality Z +/// columns), falls back to hashing the per-row code tuple. Rows are then +/// grouped with a counting sort. +pub(crate) fn build_strata_partition(z_columns: &[(&[usize], usize)], n: usize) -> StrataPartition { + let mut dense_ids: Vec = Vec::with_capacity(n); + let n_strata: usize; + + if let Some((strides, total)) = fold_strides(z_columns) { + // Pass 1: folded stratum id per row. + let folded: Vec = (0..n) + .map(|row| { + z_columns + .iter() + .zip(&strides) + .map(|((codes, _), stride)| codes[row] * stride) + .sum() + }) + .collect(); + // Pass 2: densify (flat remap when the folded space is small). + if total <= 4 * n + DENSE_REMAP_SLACK { + let mut remap = vec![u32::MAX; total]; + let mut next = 0u32; + for &f in &folded { + if remap[f] == u32::MAX { + remap[f] = next; + next += 1; + } + dense_ids.push(remap[f]); + } + n_strata = next as usize; + } else { + n_strata = densify_hashed(folded.iter().copied(), &mut dense_ids); + } + } else { + // Radix overflow: hash the per-row code tuple to first-seen dense ids. + let keys = (0..n).map(|row| { + z_columns + .iter() + .map(|(codes, _)| codes[row]) + .collect::>() + }); + n_strata = densify_hashed(keys, &mut dense_ids); + } + + // Pass 3: counting-sort row indices by dense stratum id. + let mut starts = vec![0u32; n_strata + 1]; + for &d in &dense_ids { + starts[d as usize + 1] += 1; + } + for s in 0..n_strata { + starts[s + 1] += starts[s]; + } + let mut cursor = starts.clone(); + let mut rows = vec![0u32; n]; + for (row, &d) in dense_ids.iter().enumerate() { + let slot = cursor[d as usize] as usize; + rows[slot] = u32::try_from(row).expect("row count fits in u32"); + cursor[d as usize] += 1; + } + + StrataPartition { starts, rows } +} + +/// Conditional power-divergence statistic for parameter `lambda`: tabulate +/// each stratum of `partition` into one reused table over the *global* X/Y +/// cardinalities, skip degenerate strata, and sum the statistic and dof. +/// Yates' correction is applied per active 2×2 stratum when `yates` is set. +pub(crate) fn power_divergence_conditional( + x_codes: &[usize], + y_codes: &[usize], + kx: usize, + ky: usize, + partition: &StrataPartition, + lambda: f64, + yates: bool, +) -> DiscreteOutcome { + let mut table = ContingencyTable::zeros(kx, ky); + let mut statistic = 0.0; + let mut dof = 0; + for s in 0..partition.n_strata() { + table.reset(); + let lo = partition.starts[s] as usize; + let hi = partition.starts[s + 1] as usize; + for &row in &partition.rows[lo..hi] { + table.add(x_codes[row as usize], y_codes[row as usize]); + } + if let Some((stat, table_dof)) = power_divergence_table(&table, lambda, yates) { + statistic += stat; + dof += table_dof; + } + } + DiscreteOutcome { statistic, dof } +} + +#[cfg(test)] +mod tests { + use super::*; + + const LAMBDA_PEARSON: f64 = 1.0; + const LAMBDA_CRESSIE_READ: f64 = 2.0 / 3.0; + const LAMBDA_FREEMAN_TUKEY: f64 = -0.5; + + fn table(rows: usize, cols: usize, counts: &[f64]) -> ContingencyTable { + ContingencyTable { + rows, + cols, + counts: counts.to_vec(), + } + } + + #[test] + fn yates_2x2_matches_scipy_pearson() { + // Table [[10, 0], [0, 10]] with Yates: each |O-E|=5 shrinks by 0.5. + // E = 5 everywhere; corrected diff = 4.5; stat = 4*(4.5^2/5) = 16.2. + let x = vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; + let y = vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; + let out = power_divergence_unconditional(&x, &y, 2, 2, LAMBDA_PEARSON, true); + assert!((out.statistic - 16.2).abs() < 1e-9, "got {}", out.statistic); + assert_eq!(out.dof, 1); + } + + #[test] + fn yates_can_be_disabled() { + // Same table without Yates: stat = 4*(5^2/5) = 20. + let x = vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; + let y = vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; + let out = power_divergence_unconditional(&x, &y, 2, 2, LAMBDA_PEARSON, false); + assert!((out.statistic - 20.0).abs() < 1e-9, "got {}", out.statistic); + assert_eq!(out.dof, 1); + } + + #[test] + fn independent_table_zero_statistic() { + // Perfectly balanced 2x2 -> chi-square 0 (after Yates, still ~0). + let x = vec![0, 0, 1, 1]; + let y = vec![0, 1, 0, 1]; + let out = power_divergence_unconditional(&x, &y, 2, 2, LAMBDA_PEARSON, true); + assert!(out.statistic.abs() < 1e-12); + assert_eq!(out.dof, 1); + } + + #[test] + fn single_category_degenerate() { + let x = vec![0, 0, 0, 0]; + let y = vec![0, 1, 0, 1]; + let out = power_divergence_unconditional(&x, &y, 1, 2, LAMBDA_PEARSON, true); + assert_eq!(out.dof, 0); + assert!(out.statistic.abs() < 1e-12); + } + + #[test] + fn modified_likelihood_zero_cell_is_infinite() { + // λ = −1 with a structural zero where E > 0 -> +∞ statistic. + // 3×3 (dof != 1 so no Yates) with a zero in an active row/col. + let t = table(3, 3, &[5.0, 5.0, 0.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]); + let (stat, dof) = power_divergence_table(&t, LAMBDA_MODIFIED, true).unwrap(); + assert!(stat.is_infinite() && stat > 0.0, "got {stat}"); + assert_eq!(dof, 4); + } + + #[test] + fn zero_cell_finite_for_lambda_gt_minus_one() { + // For λ > −1 a zero observed cell contributes a finite (0) term, never NaN. + let t = table(3, 3, &[5.0, 5.0, 0.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]); + for lambda in [ + LAMBDA_PEARSON, + LAMBDA_LOG_LIKELIHOOD, + LAMBDA_CRESSIE_READ, + LAMBDA_FREEMAN_TUKEY, + ] { + let (stat, _) = power_divergence_table(&t, lambda, true).unwrap(); + assert!(stat.is_finite(), "lambda {lambda} gave {stat}"); + } + } + + /// Deterministic LCG so the parity tests need no rand dependency. + fn lcg_codes(seed: &mut u64, n: usize, card: usize) -> Vec { + (0..n) + .map(|_| { + *seed = seed + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((*seed >> 33) as usize) % card + }) + .collect() + } + + /// Independent naive reference: group rows by the Z-code tuple with a + /// `HashMap`, tabulate each group, and sum statistic/dof. + fn naive_conditional( + x: &[usize], + y: &[usize], + kx: usize, + ky: usize, + z: &[(&[usize], usize)], + lambda: f64, + yates: bool, + ) -> DiscreteOutcome { + let n = x.len(); + let mut strata: std::collections::HashMap, Vec> = + std::collections::HashMap::new(); + for i in 0..n { + let key: Vec = z.iter().map(|(codes, _)| codes[i]).collect(); + strata.entry(key).or_default().push(i); + } + let mut statistic = 0.0; + let mut dof = 0; + for idx in strata.values() { + let mut table = ContingencyTable::zeros(kx, ky); + for &i in idx { + table.add(x[i], y[i]); + } + if let Some((stat, table_dof)) = power_divergence_table(&table, lambda, yates) { + statistic += stat; + dof += table_dof; + } + } + DiscreteOutcome { statistic, dof } + } + + fn assert_outcomes_match(fast: &DiscreteOutcome, slow: &DiscreteOutcome, label: &str) { + assert_eq!(fast.dof, slow.dof, "dof {label}"); + let scale = fast.statistic.abs().max(slow.statistic.abs()).max(1.0); + assert!( + (fast.statistic - slow.statistic).abs() <= 1e-9 * scale + || (fast.statistic.is_infinite() && slow.statistic.is_infinite()), + "stat {label}: {} vs {}", + fast.statistic, + slow.statistic + ); + } + + #[test] + fn partition_matches_naive_grouping() { + let mut seed = 0x00C0_FFEE_u64; + for (n, kx, ky, z_cards) in [ + (200, 2, 2, vec![2]), + (500, 3, 4, vec![2, 3]), + (350, 2, 3, vec![3, 2, 4]), + (64, 4, 4, vec![5]), + (30, 2, 2, vec![100, 100]), // exercises the HashMap densify arm + ] { + let x = lcg_codes(&mut seed, n, kx); + let y = lcg_codes(&mut seed, n, ky); + let z: Vec<(Vec, usize)> = z_cards + .iter() + .map(|&c| (lcg_codes(&mut seed, n, c), c)) + .collect(); + let z_ref: Vec<(&[usize], usize)> = z.iter().map(|(v, c)| (v.as_slice(), *c)).collect(); + let partition = build_strata_partition(&z_ref, n); + for lambda in [1.0, 0.0, -1.0, 2.0 / 3.0, -0.5] { + let fast = power_divergence_conditional(&x, &y, kx, ky, &partition, lambda, true); + let slow = naive_conditional(&x, &y, kx, ky, &z_ref, lambda, true); + assert_outcomes_match(&fast, &slow, &format!("λ={lambda} n={n}")); + } + } + } + + #[test] + fn overflow_path_matches_naive() { + // 64 conditioning columns of cardinality 2 -> 2^64 overflows usize, + // forcing the in-builder hashed grouping. 8 rows. + let n = 8; + let x: Vec = (0..n).map(|i| i % 2).collect(); + let y: Vec = (0..n).map(|i| (i / 2) % 2).collect(); + let z_cols: Vec> = (0..64) + .map(|c| (0..n).map(|i| (i >> (c % 3)) % 2).collect()) + .collect(); + let z_ref: Vec<(&[usize], usize)> = z_cols.iter().map(|v| (v.as_slice(), 2)).collect(); + let partition = build_strata_partition(&z_ref, n); + let fast = power_divergence_conditional(&x, &y, 2, 2, &partition, 1.0, true); + let slow = naive_conditional(&x, &y, 2, 2, &z_ref, 1.0, true); + assert_outcomes_match(&fast, &slow, "overflow"); + } +} diff --git a/crates/ci-core/src/error.rs b/crates/ci-core/src/error.rs new file mode 100644 index 0000000..c8f2322 --- /dev/null +++ b/crates/ci-core/src/error.rs @@ -0,0 +1,40 @@ +//! Error type for the conditional-independence core. + +use thiserror::Error; + +/// Errors that can arise when constructing a [`crate::dataset::Dataset`] or +/// running a conditional-independence test. +#[derive(Debug, Error)] +pub enum CiError { + /// Columns (or X/Y/Z vectors) did not share a common length. + #[error("dimension mismatch: {0}")] + DimensionMismatch(String), + + /// The data is degenerate for the requested test (e.g. a column with zero + /// variance for a correlation test, or too few rows). + #[error("degenerate data: {0}")] + DegenerateData(String), + + /// A numerical routine failed (e.g. distribution construction or least + /// squares). + #[error("numeric error: {0}")] + Numeric(String), + + /// A column name was requested that does not exist in the dataset. + #[error("unknown column: {0}")] + UnknownColumn(String), + + /// A column was used with a test that expects a different kind (e.g. a + /// continuous column passed to a discrete test). + #[error("wrong column kind: {0}")] + WrongColumnKind(String), + + /// A column contained NaN / missing values at `Dataset` construction. + #[error("missing data: {0}")] + MissingData(String), + + /// The (x, y, z) query itself is malformed (x == y, x/y inside z, or + /// duplicate entries in z). + #[error("invalid query: {0}")] + InvalidQuery(String), +} diff --git a/crates/ci-core/src/gram.rs b/crates/ci-core/src/gram.rs new file mode 100644 index 0000000..5b213af --- /dev/null +++ b/crates/ci-core/src/gram.rs @@ -0,0 +1,194 @@ +//! Lazy Gaussian sufficient statistics for the continuous tests. +//! +//! On the first continuous query a [`GramCache`] is built over **all** +//! continuous columns of the [`Dataset`]: per-column means plus the centered +//! cross-product matrix `S` (`S[i][j] = Σ_r (x_ri − m_i)(x_rj − m_j)`). Every +//! (partial) correlation then reduces to a gather + Cholesky solve on the +//! `(|Z|+2)²` submatrix — O(|Z|³) per query, flat in the number of rows — the +//! same sufficient-statistic trick as pcalg's `gaussCItest`, but automatic. +//! +//! Centered cross-products are algebraically identical to residual regression +//! on `[1, Z]` (the intercept is the centering), so for **finite** inputs the +//! results match the residual path to floating-point accuracy. Non-finite +//! values (`±inf` — NaN is already rejected at [`Dataset`] construction) +//! yield unspecified non-finite results on *both* paths; neither errors. + +use crate::dataset::Dataset; +use crate::error::CiError; + +/// Maximum number of continuous columns for which the full matrix is cached: +/// `2048² × 8 B = 32 MiB`. Wider datasets silently use the per-query +/// residual-regression path instead. The cap also bounds the one-shot +/// `O(n·p²)` build cost paid on the first continuous query, which is then +/// amortized across every subsequent query. +pub(crate) const MAX_GRAM_COLS: usize = 2048; + +/// Means + centered cross-product matrix over the continuous columns. +#[derive(Debug, Clone)] +pub(crate) struct GramCache { + /// Global column index -> dense continuous index (None for discrete). + col_to_dense: Vec>, + /// Number of continuous columns (`p`). + p: usize, + /// `p × p` centered cross-products, row-major, symmetric. + s: Vec, +} + +impl GramCache { + /// Build the cache, or `None` when there is nothing to cache (no rows, no + /// continuous columns) or the dataset is too wide (`p > MAX_GRAM_COLS`). + pub(crate) fn build(data: &Dataset) -> Option { + let n = data.n_rows(); + let n_cols = data.n_cols(); + let mut col_to_dense = vec![None; n_cols]; + let mut continuous: Vec<&[f64]> = Vec::new(); + for (col, slot) in col_to_dense.iter_mut().enumerate() { + if let Ok(values) = data.continuous(col) { + *slot = Some(u32::try_from(continuous.len()).expect("p bounded by MAX_GRAM_COLS")); + continuous.push(values); + } + } + let p = continuous.len(); + if n == 0 || p == 0 || p > MAX_GRAM_COLS { + return None; + } + + #[allow(clippy::cast_precision_loss)] + let n_f = n as f64; + let means: Vec = continuous + .iter() + .map(|col| col.iter().sum::() / n_f) + .collect(); + + // Note: `s[i][j]` only ever reads columns `i` and `j`, so a + // non-finite value in some other column cannot affect a query on + // disjoint columns — matching the residual path, which never touches + // unqueried columns at all. + let mut s = vec![0.0; p * p]; + for i in 0..p { + let xi = continuous[i]; + let mi = means[i]; + for j in i..p { + let xj = continuous[j]; + let mj = means[j]; + let acc: f64 = xi + .iter() + .zip(xj) + .map(|(&vi, &vj)| (vi - mi) * (vj - mj)) + .sum(); + s[i * p + j] = acc; + s[j * p + i] = acc; + } + } + Some(Self { col_to_dense, p, s }) + } + + /// Dense continuous index of global column `col`, or an error mirroring + /// [`Dataset::continuous`] when the column is discrete. + fn dense(&self, col: usize) -> Result { + match self.col_to_dense.get(col) { + Some(Some(d)) => Ok(*d as usize), + Some(None) => Err(CiError::WrongColumnKind(format!( + "column {col} is discrete but a continuous column was required" + ))), + None => Err(CiError::UnknownColumn(format!("column index {col}"))), + } + } + + #[inline] + fn s_at(&self, i: usize, j: usize) -> f64 { + self.s[i * self.p + j] + } + + /// Centered (co)variances of `x` and `y` given `z`: + /// `(s_xx, s_yy, a, b, c)` where `a = S_xx·Z`, `b = S_yy·Z`, `c = S_xy·Z` + /// are the Schur complements after eliminating `Z` (with empty `z`, + /// `a = s_xx`, `b = s_yy`, `c = s_xy`). + /// + /// # Errors + /// + /// [`CiError::WrongColumnKind`] for a discrete column, and + /// [`CiError::Numeric`] when `S_zz` is not positive definite + /// (rank-deficient / collinear conditioning set). + #[allow( + clippy::many_single_char_names, + clippy::similar_names, + reason = "x, y, z, k, l, u, v, a, b, c are the standard linear-algebra / CI variable names" + )] + pub(crate) fn schur_xy_given_z( + &self, + x: usize, + y: usize, + z: &[usize], + ) -> Result<(f64, f64, f64, f64, f64), CiError> { + let dx = self.dense(x)?; + let dy = self.dense(y)?; + let dz: Vec = z + .iter() + .map(|&zi| self.dense(zi)) + .collect::>()?; + + let s_xx = self.s_at(dx, dx); + let s_yy = self.s_at(dy, dy); + let s_xy = self.s_at(dx, dy); + let k = dz.len(); + if k == 0 { + return Ok((s_xx, s_yy, s_xx, s_yy, s_xy)); + } + + let rank_deficient = + || CiError::Numeric("rank-deficient design matrix in partial correlation".to_string()); + + // In-place Cholesky of the k×k S_zz gather (lower triangle). + let mut l = vec![0.0; k * k]; + for i in 0..k { + for j in 0..k { + l[i * k + j] = self.s_at(dz[i], dz[j]); + } + } + for j in 0..k { + let mut diag = l[j * k + j]; + for t in 0..j { + diag -= l[j * k + t] * l[j * k + t]; + } + // Deliberately an *absolute* (not relative) threshold: it errors + // only for exact-or-rounding-singular S_zz, matching the QR + // fallback, which likewise only errors on an exactly zero pivot. + // A relative threshold would reject near-singular Z that the + // residual path accepts (returning a large finite r) — a + // behavior regression. Downstream, the relative + // `VARIANCE_REL_EPS` Schur check is the degeneracy gate. + if diag <= 0.0 { + return Err(rank_deficient()); + } + let pivot = diag.sqrt(); + l[j * k + j] = pivot; + for i in (j + 1)..k { + let mut v = l[i * k + j]; + for t in 0..j { + v -= l[i * k + t] * l[j * k + t]; + } + l[i * k + j] = v / pivot; + } + } + + // Forward-solve L·u = S_zx and L·v = S_zy. + let mut u = vec![0.0; k]; + let mut v = vec![0.0; k]; + for i in 0..k { + let mut ui = self.s_at(dz[i], dx); + let mut vi = self.s_at(dz[i], dy); + for t in 0..i { + ui -= l[i * k + t] * u[t]; + vi -= l[i * k + t] * v[t]; + } + u[i] = ui / l[i * k + i]; + v[i] = vi / l[i * k + i]; + } + + let a = s_xx - u.iter().map(|w| w * w).sum::(); + let b = s_yy - v.iter().map(|w| w * w).sum::(); + let c = s_xy - u.iter().zip(&v).map(|(ui, vi)| ui * vi).sum::(); + Ok((s_xx, s_yy, a, b, c)) + } +} diff --git a/crates/ci-core/src/lib.rs b/crates/ci-core/src/lib.rs index a17c274..f51159b 100644 --- a/crates/ci-core/src/lib.rs +++ b/crates/ci-core/src/lib.rs @@ -1,37 +1,54 @@ -//! Core conditional independence tests for causal discovery. +//! Core conditional-independence tests for causal discovery. //! -//! Provides a collection of statistical tests for determining whether two -//! variables X and Y are independent given a conditioning set Z (X ⊥ Y | Z). +//! Provides statistical tests for whether two variables X and Y are independent +//! given a conditioning set Z (X ⊥ Y | Z). Data is held in a [`dataset::Dataset`] +//! built once from named, typed columns; tests refer to variables by column +//! index and return a numeric [`strategy::CiResult`]. //! //! # Tests //! //! | Test | Data type | Module | //! |------|-----------|--------| -//! | Chi-squared | Discrete | [`ci_tests::ChiSquared`] | -//! | Log-likelihood (G-test) | Discrete | [`ci_tests::LogLikelihood`] | -//! | Cressie-Read | Discrete | [`ci_tests::CressieRead`] | -//! | Freeman-Tukey | Discrete | [`ci_tests::FreemanTukey`] | -//! | Modified log-likelihood | Discrete | [`ci_tests::ModifiedLikelihood`] | +//! | Chi-squared (λ = 1) | Discrete | [`ci_tests::ChiSquared`] | +//! | Log-likelihood / G-test (λ = 0) | Discrete | [`ci_tests::LogLikelihood`] | +//! | Cressie-Read (λ = 2/3) | Discrete | [`ci_tests::CressieRead`] | +//! | Freeman-Tukey (λ = −1/2) | Discrete | [`ci_tests::FreemanTukey`] | +//! | Modified log-likelihood (λ = −1) | Discrete | [`ci_tests::ModifiedLikelihood`] | //! | Pearson correlation | Continuous | [`ci_tests::PearsonCorrelation`] | +//! | Fisher-z | Continuous | [`ci_tests::FisherZ`] | //! | Pearson equivalence (TOST) | Continuous | [`ci_tests::PearsonEquivalence`] | //! -//! # Usage +//! [`registry`] enumerates all eight and constructs a default-configured one by +//! name. //! -//! All tests implement the [`strategy::CITest`] trait. Construct a test, -//! then call [`strategy::CITest::run_test`] with your data arrays. +//! # Usage //! //! ```rust //! use ci_core::ci_tests::ChiSquared; +//! use ci_core::dataset::{ColumnKind, Dataset}; //! use ci_core::strategy::CITest; -//! use ndarray::{array, Array2}; //! -//! let test = ChiSquared { boolean: false, significance_level: 0.05 }; -//! let x = array![1., 1., 2., 2.]; -//! let y = array![1., 2., 1., 2.]; -//! let z = Array2::zeros((0, 0)); // unconditional -//! let result = test.run_test(x, y, z).unwrap(); +//! let data = Dataset::from_columns(vec![ +//! ("x".into(), ColumnKind::Discrete, vec![1., 1., 2., 2.]), +//! ("y".into(), ColumnKind::Discrete, vec![1., 2., 1., 2.]), +//! ]) +//! .unwrap(); +//! +//! let x = data.index_of("x").unwrap(); +//! let y = data.index_of("y").unwrap(); +//! let result = ChiSquared::new().test(&data, x, y, &[]).unwrap(); +//! assert!(result.p_value > 0.0); //! ``` pub mod ci_tests; +pub mod dataset; +pub mod discrete; +pub mod error; +pub(crate) mod gram; +pub mod registry; pub mod strategy; -pub mod utils; + +pub use dataset::{ColumnKind, Dataset}; +pub use error::CiError; +pub use registry::{all_metas, make_default}; +pub use strategy::{validate_query, CITest, CiResult, DataType, IndependenceRule, TestMeta}; diff --git a/crates/ci-core/src/registry.rs b/crates/ci-core/src/registry.rs new file mode 100644 index 0000000..dab27db --- /dev/null +++ b/crates/ci-core/src/registry.rs @@ -0,0 +1,94 @@ +//! Registry of the built-in closed-form conditional-independence tests. +//! +//! A single source of truth listing the eight tests that ship with this crate, +//! so callers (and the language bindings) can enumerate the available tests and +//! construct a default-configured one by its stable [`TestMeta::name`] without +//! depending on every concrete struct. +//! +//! The default configuration for each test matches the scipy/pgmpy convention: +//! the discrete power-divergence tests enable Yates' continuity correction, and +//! [`PearsonEquivalence`] uses an equivalence margin of `0.1`. + +use crate::ci_tests::{ + ChiSquared, CressieRead, FisherZ, FreemanTukey, LogLikelihood, ModifiedLikelihood, + PearsonCorrelation, PearsonEquivalence, +}; +use crate::strategy::{CITest, TestMeta}; + +/// Default equivalence margin for [`PearsonEquivalence`] in [`make_default`]. +const DEFAULT_DELTA_THRESHOLD: f64 = 0.1; + +/// Construct every built-in test in its default configuration as a boxed +/// [`CITest`]. The single source of truth that [`all_metas`] and +/// [`make_default`] are derived from. +fn default_tests() -> Vec> { + vec![ + Box::new(ChiSquared::new()), + Box::new(LogLikelihood::new()), + Box::new(CressieRead::new()), + Box::new(FreemanTukey::new()), + Box::new(ModifiedLikelihood::new()), + Box::new(PearsonCorrelation::new()), + Box::new(FisherZ::new()), + Box::new(PearsonEquivalence::new(DEFAULT_DELTA_THRESHOLD)), + ] +} + +/// Metadata for every built-in closed-form test, in registry order. +#[must_use] +pub fn all_metas() -> Vec { + default_tests().iter().map(|t| t.meta()).collect() +} + +/// Construct a default-configured boxed test by its [`TestMeta::name`]. +/// +/// Returns `None` if `name` does not match any built-in test. Default +/// configuration: `chi_squared`/`log_likelihood`/`cressie_read`/ +/// `freeman_tukey`/`modified_likelihood` have Yates enabled, and +/// `pearson_equivalence` uses `delta_threshold = 0.1`. +#[must_use] +pub fn make_default(name: &str) -> Option> { + default_tests().into_iter().find(|t| t.meta().name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + /// The eight stable names the registry must expose. + const EXPECTED_NAMES: [&str; 8] = [ + "chi_squared", + "log_likelihood", + "cressie_read", + "freeman_tukey", + "modified_likelihood", + "pearson_correlation", + "fisher_z", + "pearson_equivalence", + ]; + + #[test] + fn exposes_eight_unique_metas() { + let metas = all_metas(); + assert_eq!(metas.len(), 8, "expected exactly 8 registered tests"); + let names: BTreeSet<&str> = metas.iter().map(|m| m.name).collect(); + assert_eq!(names.len(), 8, "test names must be unique"); + for expected in EXPECTED_NAMES { + assert!(names.contains(expected), "missing test `{expected}`"); + } + } + + #[test] + fn make_default_resolves_all_names() { + for name in EXPECTED_NAMES { + let test = make_default(name).unwrap_or_else(|| panic!("`{name}` did not resolve")); + assert_eq!(test.meta().name, name); + } + } + + #[test] + fn make_default_unknown_is_none() { + assert!(make_default("not_a_real_test").is_none()); + } +} diff --git a/crates/ci-core/src/strategy.rs b/crates/ci-core/src/strategy.rs index 2c6b808..2762cfa 100644 --- a/crates/ci-core/src/strategy.rs +++ b/crates/ci-core/src/strategy.rs @@ -1,38 +1,246 @@ -use ndarray::{Array1, Array2}; +//! The data-bound conditional-independence test contract. +//! +//! Every test implements [`CITest`], operating on a shared [`Dataset`] and +//! referring to the variables under test (`x`, `y`) and the conditioning set +//! (`z`) by column index. Configuration lives on the test struct; the data +//! lives on the [`Dataset`]. The provided [`CITest::test`] validates the query +//! via [`validate_query`] before delegating to the required +//! [`CITest::test_impl`]. The default [`CITest::is_independent`] turns a +//! numeric [`CiResult`] into a boolean using the test's [`IndependenceRule`]. -/// The outcome of a conditional independence test. +use crate::dataset::Dataset; +use crate::error::CiError; + +/// The numeric outcome of a conditional-independence test. #[derive(Debug, Clone, PartialEq)] -pub enum TestResult { - PValue(f64, f64), - Statistic(f64, f64, usize), - Boolean(bool), +pub struct CiResult { + /// The test statistic (e.g. chi-squared statistic, Pearson r, Fisher-z), + /// when the test defines one. + pub statistic: Option, + /// The p-value. Interpretation depends on the test's [`IndependenceRule`]. + pub p_value: f64, + /// Degrees of freedom, when applicable. + pub dof: Option, + /// An effect-size summary (e.g. Cramér's V, |r|, |rho|), when applicable. + pub effect_size: Option, } -/// Data types that a `CITest` can be performed on. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CITestDataType { - Continuous, +/// How a p-value is turned into an independence decision at level `alpha`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndependenceRule { + /// Standard null-of-independence tests: independent when `p >= alpha`. + PValueGe, + /// Equivalence / TOST tests: independent when `p < alpha`. + PValueLt, +} + +impl IndependenceRule { + /// Whether independence holds for the given `p` at significance `alpha`. + #[must_use] + pub fn holds(self, p: f64, alpha: f64) -> bool { + match self { + IndependenceRule::PValueGe => p >= alpha, + IndependenceRule::PValueLt => p < alpha, + } + } +} + +/// The kind of data a test consumes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataType { + /// Categorical data. Discrete, - Mixed, + /// Numeric data. + Continuous, +} + +/// Static description of a test: its name, supported data types, whether it is +/// symmetric in `x`/`y`, and its independence rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TestMeta { + /// Stable identifier (e.g. `"chi_squared"`). + pub name: &'static str, + /// Data types the test supports. + pub data_types: &'static [DataType], + /// Whether swapping `x` and `y` leaves the result unchanged. + pub symmetric: bool, + /// How the p-value maps to an independence decision. + pub rule: IndependenceRule, } -/// Trait defining the interface for conditional independence tests. +/// Validate a `(x, y, z)` query against `data` before running a test. /// -/// All statistical tests for conditional independence must implement this trait -/// to be compatible with the registry system. +/// Checks, in order: all column indices are in range +/// ([`CiError::UnknownColumn`]); `x != y`; `x`/`y` do not appear in `z`; `z` +/// has no duplicates (all [`CiError::InvalidQuery`]). Called by the provided +/// [`CITest::test`] so every test is validated uniformly. +/// +/// # Errors +/// +/// Returns the first violated rule as described above. +pub fn validate_query(data: &Dataset, x: usize, y: usize, z: &[usize]) -> Result<(), CiError> { + let n_cols = data.n_cols(); + for idx in [x, y].into_iter().chain(z.iter().copied()) { + if idx >= n_cols { + return Err(CiError::UnknownColumn(format!("column index {idx}"))); + } + } + if x == y { + return Err(CiError::InvalidQuery(format!( + "x and y must be different columns (both are column index {x})" + ))); + } + for (label, idx) in [("x", x), ("y", y)] { + if z.contains(&idx) { + return Err(CiError::InvalidQuery(format!( + "{label} (column index {idx}) must not appear in the conditioning set z" + ))); + } + } + for i in 0..z.len() { + if z[i + 1..].contains(&z[i]) { + return Err(CiError::InvalidQuery(format!( + "conditioning set z contains column index {} more than once", + z[i] + ))); + } + } + Ok(()) +} + +/// A conditional-independence test bound to a [`Dataset`]. pub trait CITest: Send + Sync { - /// Runs a conditional independence test on the given data. + /// Test-specific computation for `x ⊥ y | z`. Implementations may assume + /// the query has already been validated by [`CITest::test`]; call sites + /// should use [`CITest::test`], not this method. /// /// # Errors /// - /// Returns an error if the test computation fails (e.g., invalid input dimensions or numerical issues). - fn run_test( + /// Returns a [`CiError`] if the data is unsuitable (wrong column kind, + /// degenerate input, dimension mismatch) or a numerical routine fails. + fn test_impl( &self, - x_values: Array1, - y_values: Array1, - z: Array2, - ) -> anyhow::Result; + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + ) -> Result; + + /// Run the test for `x ⊥ y | z` after validating the query + /// (see [`validate_query`]). + /// + /// Implementors should not override this method; implement + /// [`CITest::test_impl`] instead, so the uniform validation is preserved. + /// + /// # Errors + /// + /// Returns [`CiError::UnknownColumn`] / [`CiError::InvalidQuery`] for a + /// malformed query, or any error from [`CITest::test_impl`]. + fn test(&self, data: &Dataset, x: usize, y: usize, z: &[usize]) -> Result { + validate_query(data, x, y, z)?; + self.test_impl(data, x, y, z) + } + + /// Static metadata describing this test. + fn meta(&self) -> TestMeta; + + /// Convenience wrapper returning an independence decision at level `alpha`. + /// + /// # Errors + /// + /// Propagates any error from [`CITest::test`]. + fn is_independent( + &self, + data: &Dataset, + x: usize, + y: usize, + z: &[usize], + alpha: f64, + ) -> Result { + Ok(self + .meta() + .rule + .holds(self.test(data, x, y, z)?.p_value, alpha)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_ge() { + assert!(IndependenceRule::PValueGe.holds(0.5, 0.05)); + assert!(IndependenceRule::PValueGe.holds(0.05, 0.05)); + assert!(!IndependenceRule::PValueGe.holds(0.01, 0.05)); + } + + #[test] + fn rule_lt() { + assert!(IndependenceRule::PValueLt.holds(0.01, 0.05)); + assert!(!IndependenceRule::PValueLt.holds(0.05, 0.05)); + assert!(!IndependenceRule::PValueLt.holds(0.5, 0.05)); + } + + use crate::ci_tests::ChiSquared; + use crate::dataset::{ColumnKind, Dataset}; + + fn two_col_data() -> Dataset { + Dataset::from_columns(vec![ + ("a".into(), ColumnKind::Discrete, vec![1., 2., 1., 2.]), + ("b".into(), ColumnKind::Discrete, vec![1., 1., 2., 2.]), + ("c".into(), ColumnKind::Discrete, vec![1., 2., 2., 1.]), + ]) + .unwrap() + } + + #[test] + fn rejects_x_equals_y() { + let data = two_col_data(); + let err = ChiSquared::new().test(&data, 0, 0, &[]).unwrap_err(); + assert!( + matches!(err, crate::error::CiError::InvalidQuery(_)), + "{err}" + ); + } + + #[test] + fn rejects_x_or_y_in_z() { + let data = two_col_data(); + assert!(matches!( + ChiSquared::new().test(&data, 0, 1, &[0]), + Err(crate::error::CiError::InvalidQuery(_)) + )); + assert!(matches!( + ChiSquared::new().test(&data, 0, 1, &[1]), + Err(crate::error::CiError::InvalidQuery(_)) + )); + } + + #[test] + fn rejects_duplicate_z() { + let data = two_col_data(); + assert!(matches!( + ChiSquared::new().test(&data, 0, 1, &[2, 2]), + Err(crate::error::CiError::InvalidQuery(_)) + )); + } - /// Data types that a test supports. - fn data_types(&self) -> &'static [CITestDataType]; + #[test] + fn rejects_out_of_range_indices() { + let data = two_col_data(); + assert!(matches!( + ChiSquared::new().test(&data, 9, 1, &[]), + Err(crate::error::CiError::UnknownColumn(_)) + )); + assert!(matches!( + ChiSquared::new().test(&data, 0, 1, &[9]), + Err(crate::error::CiError::UnknownColumn(_)) + )); + // is_independent goes through the same validation. + assert!(matches!( + ChiSquared::new().is_independent(&data, 0, 0, &[], 0.05), + Err(crate::error::CiError::InvalidQuery(_)) + )); + } } diff --git a/crates/ci-core/src/utils/contingency_table.rs b/crates/ci-core/src/utils/contingency_table.rs deleted file mode 100644 index 5b5810d..0000000 --- a/crates/ci-core/src/utils/contingency_table.rs +++ /dev/null @@ -1,97 +0,0 @@ -use ndarray::{Array, Array1, Array2}; -use ordered_float::OrderedFloat; -use std::collections::HashMap; -use std::hash::BuildHasher; - -/// Count how often each pair `(col1[i], col2[i])` occurs and return the -/// counts as a 2D table. Rows and columns are ordered by value, so the -/// result does not depend on the order of the inputs. -#[must_use] -pub fn contingency_table(col1: &Array1, col2: &Array1) -> Array2 { - let col1_map = build_global_category_map(col1); - let col2_map = build_global_category_map(col2); - contingency_table_with_categories(col1, col2, &col1_map, &col2_map) -} - -//Great replacement of the contingency_table_with_categories function in power divergence, but cannot -//be used when contingency_table_with_categories is needed within another function -#[must_use] -pub fn contingency_table_from_indices( - indices: &[usize], - x_values: &Array1, - y_values: &Array1, - x_categories: &HashMap, usize, S1>, - y_categories: &HashMap, usize, S2>, -) -> Array2 { - let mut table = Array2::::zeros((x_categories.len(), y_categories.len())); - - for &i in indices { - let r = x_categories[&OrderedFloat(x_values[i])]; - let c = y_categories[&OrderedFloat(y_values[i])]; - - table[[r, c]] += 1.0; - } - - table -} - -/// Number every distinct value in `arr` (smallest gets 0, next gets 1, -/// and so on). Pass the same map to several calls of -/// `contingency_table_with_categories` to get tables that share the -/// exact same rows and columns. -#[must_use] -pub fn build_global_category_map(arr: &Array1) -> HashMap, usize> { - let mut map = HashMap::new(); - - for &v in arr { - let key = OrderedFloat(v); - let next_index = map.len(); - map.entry(key).or_insert(next_index); - } - - map -} - -/// Like `contingency_table`, but the rows and columns are fixed by the -/// maps you pass in. Use this when several tables need the same shape, -/// even if some values are missing from a particular table. -#[must_use] -pub fn contingency_table_with_categories( - col1: &Array1, - col2: &Array1, - col1_map: &HashMap, usize, S1>, - col2_map: &HashMap, usize, S2>, -) -> Array2 { - let mut result = Array::zeros((col1_map.len(), col2_map.len())); - for i in 0..col1.len() { - if let (Some(&r), Some(&c)) = ( - col1_map.get(&OrderedFloat(col1[i])), - col2_map.get(&OrderedFloat(col2[i])), - ) { - result[[r, c]] += 1.; - } - } - result -} - -#[cfg(test)] -mod tests { - use super::*; - use ndarray::array; - - #[test] - fn basic_test() { - let test1_x: Array1 = array![1.0, 2.0, 3.0, 1.0, 1.0]; - let test1_y: Array1 = array![1.0, 2.0, 3.0, 1.0, 2.0]; - let test1_expected: Array2 = array![[2.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - assert_eq!(test1_expected, contingency_table(&test1_x, &test1_y)); - } - - #[test] - fn single_value() { - let test3_x = array![1.0, 1.0, 1.0]; - let test3_y = array![2.0, 2.0, 2.0]; - let test3_expected = array![[3.0]]; - assert_eq!(test3_expected, contingency_table(&test3_x, &test3_y)); - } -} diff --git a/crates/ci-core/src/utils/contingency_test.rs b/crates/ci-core/src/utils/contingency_test.rs deleted file mode 100644 index 9092d4f..0000000 --- a/crates/ci-core/src/utils/contingency_test.rs +++ /dev/null @@ -1,361 +0,0 @@ -use crate::utils::EPS; -use anyhow::{bail, Result}; - -const MODIFIED_LIKELIHOOD_LAMBDA: f64 = -1.0; -const FREEMAN_TUKEY_LAMBDA: f64 = -0.5; -use ndarray::{Array1, Array2, Axis}; -use statrs::distribution::{ChiSquared, ContinuousCDF}; - -/// Compute a Cressie-Read power-divergence statistic for a contingency table. -/// -/// # Errors -/// Returns an error when the table is empty, contains negatives, sums to zero, -/// or has a zero expected frequency. -pub fn contingency_test(observed: &Array2, lambda: f64) -> Result<(f64, f64, usize)> { - // Check whether contingency test is applicable - if observed.is_empty() { - bail!("No data; `observed` has size 0."); - } - if observed.iter().any(|&x| x < 0.0) { - bail!("All values in `observed` must be nonnegative."); - } - - let (nrows, ncols) = observed.dim(); - let row_sums = observed.sum_axis(Axis(1)); - let col_sums = observed.sum_axis(Axis(0)); - let total: f64 = row_sums.sum(); - if total == 0.0 { - bail!("Total sum of observed frequencies must be > 0."); - } - let inverse_total = 1.0 / total; - - let col_times_total = &col_sums * inverse_total; - let ln_total = total.ln(); - let ln_row_sums = row_sums.mapv(f64::ln); - let ln_col_sums = col_sums.mapv(f64::ln); - - let statistic: f64 = if lambda.abs() < EPS { - g_test( - observed, - &row_sums, - &col_times_total, - ln_total, - &ln_row_sums, - &ln_col_sums, - )? - } else if (lambda - MODIFIED_LIKELIHOOD_LAMBDA).abs() < EPS { - modified_log_likelihood_ratio_test( - observed, - &row_sums, - &col_times_total, - ln_total, - &ln_row_sums, - &ln_col_sums, - )? - } else if (lambda - FREEMAN_TUKEY_LAMBDA).abs() < EPS { - freeman_tukey(lambda, observed, &row_sums, &col_times_total)? - } else { - cressie_read(lambda, observed, &row_sums, &col_times_total)? - }; - - let degrees_of_freedom = if nrows < 2 || ncols < 2 { - 0 - } else { - (nrows - 1) * (ncols - 1) - }; - - let p_value = if degrees_of_freedom == 0 { - 1.0 - } else { - #[allow(clippy::cast_precision_loss)] - ChiSquared::new(degrees_of_freedom as f64)?.sf(statistic) - }; - - Ok((statistic, p_value, degrees_of_freedom)) -} - -/// # Errors -/// -/// Returns an error if an expected frequency evaluates to zero, or if the -/// calculated statistic results in a mathematically impossible value. -pub fn g_test( - observed: &Array2, - row_sums: &Array1, - col_times_total: &Array1, - ln_total: f64, - ln_row_sums: &Array1, - ln_col_sums: &Array1, -) -> anyhow::Result { - // G-test: 2 * sum(O * ln(O / E)) - let (nrows, ncols) = observed.dim(); - let mut temp_stat: f64 = 0.0; - for i in 0..nrows { - for j in 0..ncols { - let temp_expected: f64 = row_sums[i] * col_times_total[j]; // division is worse than multiplication in rust - let temp_observed = observed[[i, j]]; - if temp_expected == 0. { - bail!("Expected frequency is zero at position [{i}, {j}]"); - } - if temp_observed == 0. { - continue; - } - temp_stat += - temp_observed * (temp_observed.ln() + ln_total - ln_row_sums[i] - ln_col_sums[j]); - //used logarithmic rules to get rid of division and mulitplication - } - } - let final_stat = 2.0 * temp_stat; - if final_stat < -EPS { - bail!("Statistic evaluated to {final_stat}, which should be impossible."); - //make sure it bails when the negative number is not negligibly small - } - Ok(final_stat.max(0.0)) -} - -/// # Errors -/// -/// Returns an error if an expected frequency evaluates to zero, or if the -/// calculated statistic results in a mathematically impossible value. -pub fn modified_log_likelihood_ratio_test( - observed: &Array2, - row_sums: &Array1, - col_times_total: &Array1, - ln_total: f64, - ln_row_sums: &Array1, - ln_col_sums: &Array1, -) -> anyhow::Result { - let (nrows, ncols) = observed.dim(); - let mut temp_stat: f64 = 0.0; - for i in 0..nrows { - for j in 0..ncols { - let temp_expected: f64 = row_sums[i] * col_times_total[j]; //multiplication instead of division - let temp_observed = observed[[i, j]]; - if temp_expected == 0. { - continue; - } - if temp_observed == 0. { - // log of temp_observed will be -infinity, causing temp_stat to become infinity. - // However the math still works, so this is not an error. - return Ok(f64::INFINITY); - } - temp_stat += - temp_expected * (ln_row_sums[i] + ln_col_sums[j] - temp_observed.ln() - ln_total); - } - } - let final_stat = 2.0 * temp_stat; - if final_stat < -EPS { - bail!("Statistic evaluated to {final_stat}, which should be impossible."); - } - Ok(final_stat.max(0.0)) -} - -/// # Errors -/// -/// Returns an error if an expected frequency evaluates to zero, or if the -/// calculated statistic results in a mathematically impossible value. -pub fn freeman_tukey( - lambda: f64, - observed: &Array2, - row_sums: &Array1, - col_times_total: &Array1, -) -> anyhow::Result { - let (nrows, ncols) = observed.dim(); - let mut temp_stat: f64 = 0.0; - for i in 0..nrows { - for j in 0..ncols { - let temp_expected: f64 = row_sums[i] * col_times_total[j]; //again the multiplication instead of division - let temp_observed = observed[[i, j]]; - if temp_expected == 0.0 { - bail!("Expected frequency is zero at position [{i}, {j}]"); - } - temp_stat += temp_observed.sqrt() * temp_expected.sqrt() - temp_observed; - } - } - let final_stat = (2.0 * temp_stat) / (lambda * (lambda + 1.0)); - if final_stat < -EPS { - bail!("Statistic evaluated to {final_stat}, which should be impossible."); - } - Ok(final_stat.max(0.0)) -} - -/// # Errors -/// -/// Returns an error if an expected frequency evaluates to zero, or if the -/// calculated statistic results in a mathematically impossible value. -pub fn cressie_read( - lambda: f64, - observed: &Array2, - row_sums: &Array1, - col_times_total: &Array1, -) -> anyhow::Result { - let (nrows, ncols) = observed.dim(); - let mut temp_stat: f64 = 0.0; - for i in 0..nrows { - for j in 0..ncols { - let temp_expected: f64 = row_sums[i] * col_times_total[j]; //again the multiplication instead of division - let temp_observed = observed[[i, j]]; - if temp_expected == 0.0 { - bail!("Expected frequency is zero at position [{i}, {j}]"); - } - temp_stat += temp_observed * ((temp_observed / temp_expected).powf(lambda) - 1.0); - } - } - let final_stat = (2.0 * temp_stat) / (lambda * (lambda + 1.0)); - if final_stat < -EPS { - bail!("Statistic evaluated to {final_stat}, which should be impossible."); - } - Ok(final_stat.max(0.0)) -} - -#[cfg(test)] -mod tests { - use super::*; - use ndarray::array; - - #[test] - fn test_empty_table_error() { - let observed = Array2::::zeros((0, 0)); - let result = contingency_test(&observed, 0.0); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("size 0")); - } - - #[test] - fn test_negative_values_error() { - let observed = array![[1.0, -1.0], [2.0, 3.0]]; - let result = contingency_test(&observed, 0.0); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("nonnegative")); - } - - #[test] - fn test_zero_total_error() { - let observed = array![[0.0, 0.0], [0.0, 0.0]]; - let result = contingency_test(&observed, 0.0); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("> 0")); - } - - #[test] - fn test_zero_expected_frequency_error() { - let observed = array![[10.0, 0.0], [0.0, 0.0]]; - let result = contingency_test(&observed, 1.0); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Expected frequency is zero")); - } - - #[test] - fn test_g_test_zero_expected_frequency() { - let observed = array![[5.0, 0.0], [0.0, 0.0]]; - let result = contingency_test(&observed, 0.0); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Expected frequency is zero")); - } - - #[test] - fn test_modified_log_likelihood_zero_observed() { - let observed = array![[5.0, 1.0], [2.0, 0.0]]; // contains zero observed - let (statistic, p_value, _dof) = contingency_test(&observed, -1.0).unwrap(); - - assert!(statistic.is_infinite() && p_value < EPS); - } - - #[test] - fn test_g_test_valid() { - let observed = array![[10.0, 20.0], [20.0, 40.0]]; - let result = contingency_test(&observed, 0.0).unwrap(); - - let (stat, p, dof) = result; - assert!(stat >= 0.0); - assert!((0.0..=1.0).contains(&p)); - assert_eq!(dof, 1); - } - - #[test] - fn test_modified_log_likelihood_valid() { - let observed = array![[10.0, 20.0], [20.0, 40.0]]; - let result = contingency_test(&observed, -1.0).unwrap(); - - let (stat, p, dof) = result; - assert!(stat >= 0.0); - assert!((0.0..=1.0).contains(&p)); - assert_eq!(dof, 1); - } - - #[test] - fn test_freeman_tukey_valid() { - let observed = array![[5.0, 1.0], [1.0, 5.0]]; - let result = contingency_test(&observed, -0.5).unwrap(); - - let (stat, p, dof) = result; - assert!(stat >= 0.0); - assert!((stat - 6.319_453_539_579_289).abs() < EPS); // Validated against scipy - assert!((0.0..=1.0).contains(&p)); - assert_eq!(dof, 1); - } - - #[test] - fn test_freeman_tukey_zero_observed() { - let observed = array![[2.0, 1.0], [0.0, 3.0]]; // contains zero observed - let result = contingency_test(&observed, -0.5); - - assert!(result.is_ok()); - let (stat, p, dof) = result.unwrap(); - - assert!(stat >= 0.0); - assert!((0.0..=1.0).contains(&p)); - assert_eq!(dof, 1); - - // Manual calculation - let expected_stat = 4.0 - * ((2.0f64.sqrt() - 1.0).powi(2) + - (1.0 - 2.0f64.sqrt()).powi(2) + - 1.0 + // (0.0 - sqrt(1.0))^2 - (3.0f64.sqrt() - 2.0f64.sqrt()).powi(2)); - assert!((stat - expected_stat).abs() < EPS); - } - - #[test] - fn test_cressie_read_valid() { - let observed = array![[10.0, 20.0], [20.0, 40.0]]; - let result = contingency_test(&observed, 0.5).unwrap(); - - let (stat, p, dof) = result; - assert!(stat >= 0.0); - assert!((0.0..=1.0).contains(&p)); - assert_eq!(dof, 1); - } - - #[test] - fn test_degrees_of_freedom_2x2() { - let observed = array![[1.0, 2.0], [3.0, 4.0]]; - let (_, _, dof) = contingency_test(&observed, 0.0).unwrap(); - assert_eq!(dof, 1); // (2-1)*(2-1) - } - - #[test] - fn test_degrees_of_freedom_3x4() { - let observed = array![ - [1.0, 2.0, 3.0, 4.0], - [2.0, 3.0, 4.0, 5.0], - [3.0, 4.0, 5.0, 6.0] - ]; - let (_, _, dof) = contingency_test(&observed, 0.0).unwrap(); - assert_eq!(dof, (3 - 1) * (4 - 1)); // 2 * 3 = 6 - } - - #[test] - fn test_degrees_of_freedom_degenerate() { - let observed = array![[1.0, 2.0, 3.0]]; // 1x3 - let (_, p, dof) = contingency_test(&observed, 0.0).unwrap(); - - assert_eq!(dof, 0); - assert!((p - 1.0).abs() < 1e12); // By definition in your implementation - } -} diff --git a/crates/ci-core/src/utils/mod.rs b/crates/ci-core/src/utils/mod.rs deleted file mode 100644 index 085608c..0000000 --- a/crates/ci-core/src/utils/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod contingency_table; -pub mod contingency_test; -pub mod partition_indices; -pub mod power_divergence; - -pub(crate) const EPS: f64 = 1e-12; diff --git a/crates/ci-core/src/utils/partition_indices.rs b/crates/ci-core/src/utils/partition_indices.rs deleted file mode 100644 index 9850c10..0000000 --- a/crates/ci-core/src/utils/partition_indices.rs +++ /dev/null @@ -1,136 +0,0 @@ -use ndarray::{Array2, Axis}; -use ordered_float::OrderedFloat; -use std::collections::HashMap; - -/// Partition the rows of `data` (indexed as `data[row][col]`) into groups -/// that share the same combination of column values. -/// -/// Returns `indices[partition][i]`, where each inner `Vec` holds the row -/// indices that belong to that partition. The order of partitions and the -/// order of indices within a partition are both unspecified. -#[must_use] -pub fn partition_indices(data: &Array2) -> Vec> { - let mut groups: HashMap>, Vec> = HashMap::new(); - for (i, row) in data.axis_iter(Axis(0)).enumerate() { - let key: Vec> = row.iter().map(|&v| OrderedFloat(v)).collect(); - groups.entry(key).or_default().push(i); - } - groups.into_values().collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use ndarray::array; - use std::collections::HashSet; - - #[test] - fn simple_grouping() { - let data = array![[1.0, 10.0], [1.0, 10.0], [2.0, 30.0],]; - - let result = partition_indices(&data); - - assert_eq!(result.len(), 2); - } - - #[test] - fn test_empty_array() { - let data: Array2 = Array2::zeros((0, 2)); - let result = partition_indices(&data); - - assert!(result.is_empty()); - } - - #[test] - fn test_singleton_groups() { - let data = array![[1.0, 10.0], [2.0, 20.0], [3.0, 30.0],]; - - let result = partition_indices(&data); - - assert_eq!(result.len(), 3); - assert!(result.iter().all(|g| g.len() == 1)); - } - - #[test] - fn test_single_partition() { - let data = array![[1.0, 10.0], [1.0, 10.0], [1.0, 10.0],]; - - let result = partition_indices(&data); - - assert_eq!(result.len(), 1); - let mut group = result[0].clone(); - group.sort_unstable(); - assert_eq!(group, vec![0, 1, 2]); - } - - #[test] - fn float_rounding() { - let data = array![[1.0, 10.0], [1.000_000_000_1, 10.0],]; - - let result = partition_indices(&data); - - //To test whether it rounds it up - assert_eq!(result.len(), 2); - } - - #[test] - fn multiple_columns() { - let data = array![ - [1.0, 2.0, 10.0], - [1.0, 2.0, 10.0], - [1.0, 3.0, 30.0], - [2.0, 2.0, 40.0], - ]; - - let result = partition_indices(&data); - assert_eq!(result.len(), 3); - - // Find the group that has 2 rows (the [1.0, 2.0, 10.0] group) - assert!(result.iter().any(|g| g.len() == 2)); - } - - #[test] - fn order_dependence() { - let data = array![[1.0, 2.0], [2.0, 1.0], [2.0, 1.0], [1.0, 2.0]]; - let expected: HashSet> = [vec![0, 3], vec![1, 2]].into_iter().collect(); - let result: HashSet> = partition_indices(&data).into_iter().collect(); - - assert_eq!(expected, result); - } - - #[test] - fn zero_column_rows() { - let data: Array2 = Array2::zeros((3, 0)); - let result = partition_indices(&data); - assert_eq!(result.len(), 1); - let mut group = result[0].clone(); - group.sort_unstable(); - assert_eq!(group, vec![0, 1, 2]); - } - - #[test] - fn single_column_rows() { - let data = array![[1.0], [2.0], [1.0]]; - let result = partition_indices(&data); - assert_eq!(result.len(), 2); - let result_set: HashSet> = result.into_iter().collect(); - let expected: HashSet> = [vec![0, 2], vec![1]].into_iter().collect(); - assert_eq!(result_set, expected); - } - - #[test] - fn negative_values() { - let data = array![ - [-1.0, 2.0], - [0.0, -0.0], - [0.0, 0.0], - [-1.0, 2.0], - [-1.0, -2.0], - ]; - - let expected: HashSet> = [vec![0, 3], vec![1, 2], vec![4]].into_iter().collect(); - let result: HashSet> = partition_indices(&data).into_iter().collect(); - - assert_eq!(expected, result); - } -} diff --git a/crates/ci-core/src/utils/power_divergence.rs b/crates/ci-core/src/utils/power_divergence.rs deleted file mode 100644 index bd9e055..0000000 --- a/crates/ci-core/src/utils/power_divergence.rs +++ /dev/null @@ -1,203 +0,0 @@ -use crate::strategy::TestResult; -use crate::utils::contingency_table::{ - build_global_category_map, contingency_table, contingency_table_from_indices, -}; -use crate::utils::contingency_test::contingency_test; -use crate::utils::partition_indices::partition_indices; -use anyhow::ensure; -use ndarray::{Array1, Array2}; -use statrs::distribution::{ChiSquared, ContinuousCDF}; - -/// Run a power-divergence based conditional independence test. -/// -/// # Errors -/// Returns an error if the inputs are invalid or if the underlying contingency -/// test or chi-squared distribution construction fails. -pub fn power_divergence( - x_values: &Array1, - y_values: &Array1, - z: &Array2, - boolean: bool, - significance_level: f64, - lambda: f64, -) -> anyhow::Result { - ensure!( - x_values.len() == y_values.len(), - "x and y must have the same length, got {} and {}", - x_values.len(), - y_values.len(), - ); - ensure!( - z.ncols() == 0 || z.nrows() == x_values.len(), - "z must have the same number of rows as x and y ({}), got {}", - x_values.len(), - z.nrows(), - ); - if z.ncols() == 0 { - let table = contingency_table(x_values, y_values); - let (statistic, p_value, degrees_of_freedom) = contingency_test(&table, lambda)?; - return Ok(wrap_result( - boolean, - p_value, - statistic, - degrees_of_freedom, - significance_level, - )); - } - - let x_categories = build_global_category_map(x_values); - let y_categories = build_global_category_map(y_values); - - let mut statistic = 0.0; - let mut degrees_of_freedom = 0; - - for indices in partition_indices(z) { - let table = contingency_table_from_indices( - &indices, - x_values, - y_values, - &x_categories, - &y_categories, - ); - let Ok((stat, _p, dof)) = contingency_test(&table, lambda) else { - continue; - }; - - if dof == 0 { - continue; - } - statistic += stat; - degrees_of_freedom += dof; - } - let p_value = if degrees_of_freedom == 0 { - 1.0 - } else { - #[allow(clippy::cast_precision_loss)] - ChiSquared::new(degrees_of_freedom as f64)?.sf(statistic) - }; - Ok(wrap_result( - boolean, - p_value, - statistic, - degrees_of_freedom, - significance_level, - )) -} - -fn wrap_result( - boolean: bool, - p_value: f64, - coefficient: f64, - degrees_of_freedom: usize, - significance_level: f64, -) -> TestResult { - if boolean { - return TestResult::Boolean(p_value >= significance_level); - } - TestResult::Statistic(p_value, coefficient, degrees_of_freedom) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::utils::EPS; - use ndarray::{array, Array2}; - - const LAMBDA: f64 = 1.0; - const SIGNIFICANCE_LEVEL: f64 = 0.05; - - fn empty_z() -> Array2 { - Array2::zeros((0, 0)) - } - - fn unwrap_statistic(r: &TestResult) -> (f64, f64, usize) { - match r { - TestResult::Statistic(a, b, c) => (*a, *b, *c), - _ => panic!("expected Statistic"), - } - } - - #[test] - fn unconditional_mismatched_lengths_returns_error() { - let x = array![1., 2., 3.]; - let y = array![1., 2.]; - let result = power_divergence(&x, &y, &empty_z(), false, SIGNIFICANCE_LEVEL, LAMBDA); - assert!(result.is_err()); - } - - // A single observation produces a 1x1 table with dof=0. - #[test] - fn unconditional_single_element_has_zero_dof() { - let x = array![1.]; - let y = array![1.]; - let result = - power_divergence(&x, &y, &empty_z(), false, SIGNIFICANCE_LEVEL, LAMBDA).unwrap(); - let (p, stat, dof) = unwrap_statistic(&result); - assert_eq!(dof, 0); - assert!((p - 1.0).abs() < EPS); - assert!(stat.abs() < EPS); - } - - // When X has only one distinct value the table has one row → dof=0. - #[test] - fn unconditional_single_category_in_x_has_zero_dof() { - let x = array![1., 1., 1., 1.]; - let y = array![1., 2., 1., 2.]; - let result = - power_divergence(&x, &y, &empty_z(), false, SIGNIFICANCE_LEVEL, LAMBDA).unwrap(); - let (p, _stat, dof) = unwrap_statistic(&result); - assert_eq!(dof, 0); - assert!((p - 1.0).abs() < EPS); - } - - // When Y has only one distinct value the table has one column → dof=0. - #[test] - fn unconditional_single_category_in_y_has_zero_dof() { - let x = array![1., 2., 1., 2.]; - let y = array![1., 1., 1., 1.]; - let result = - power_divergence(&x, &y, &empty_z(), false, SIGNIFICANCE_LEVEL, LAMBDA).unwrap(); - let (p, _stat, dof) = unwrap_statistic(&result); - assert_eq!(dof, 0); - assert!((p - 1.0).abs() < EPS); - } - - // NaN in x_values becomes its own category via OrderedFloat. - // The result is a valid (though likely meaningless) statistic, not a panic. - #[test] - fn unconditional_nan_in_x_does_not_panic() { - let x = array![1., f64::NAN, 2., 1.]; - let y = array![1., 2., 1., 2.]; - let result = power_divergence(&x, &y, &empty_z(), false, SIGNIFICANCE_LEVEL, LAMBDA); - assert!(result.is_ok()); - } - - // NaN in the conditioning set creates its own partition group. - // Global category maps include both X categories, so a singleton - // group gets a table with a zero-expected cell. That group is - // skipped, so the overall result is valid (p=1 when all groups are skipped). - #[test] - fn conditional_nan_in_z_skips_bad_groups() { - let x = array![1., 1., 2., 2.]; - let y = array![1., 2., 1., 2.]; - let z = Array2::from_shape_vec((4, 1), vec![0., f64::NAN, 0., 1.]).unwrap(); - let result = power_divergence(&x, &y, &z, false, SIGNIFICANCE_LEVEL, LAMBDA).unwrap(); - let (p, _stat, dof) = unwrap_statistic(&result); - assert_eq!(dof, 0); - assert!((p - 1.0).abs() < EPS); - } - - // Each Z-group has only one X value. Global category maps force a 2x2 - // table per group with a zero row → zero expected frequency. All - // groups are skipped, yielding dof=0 and p=1. - #[test] - fn conditional_all_groups_single_category_skips_all() { - let x = array![1., 1., 2., 2.]; - let y = array![1., 2., 1., 2.]; - let z = Array2::from_shape_vec((4, 1), vec![0., 0., 1., 1.]).unwrap(); - let result = power_divergence(&x, &y, &z, false, SIGNIFICANCE_LEVEL, LAMBDA).unwrap(); - let (p, _stat, dof) = unwrap_statistic(&result); - assert_eq!(dof, 0); - assert!((p - 1.0).abs() < EPS); - } -} diff --git a/crates/ci-core/tests/golden.rs b/crates/ci-core/tests/golden.rs new file mode 100644 index 0000000..c1a972a --- /dev/null +++ b/crates/ci-core/tests/golden.rs @@ -0,0 +1,212 @@ +//! Fixture-driven acceptance gate. +//! +//! Parses the language-agnostic reference fixture (`tests/fixtures/golden.json` +//! at the repository root) and checks **every** case against the scipy-derived +//! expectations: +//! +//! - The five discrete power-divergence tests (`chi_squared`, `log_likelihood`, +//! `cressie_read`, `freeman_tukey`, `modified_likelihood`) are run with the +//! case's own `params.yates`, so both Yates-on and Yates-off rows are covered. +//! - All `pearson_correlation`, `pearson_equivalence`, and `fisher_z` rows. +//! +//! Every non-null `statistic`/`p_value`/`dof`/`effect_size` field is asserted to +//! within `1e-7`, with the `+∞` statistic / `p = 0` degenerate case handled +//! exactly. The test asserts it exercised all 80 fixture cases. + +use std::collections::BTreeMap; + +use ci_core::ci_tests::{ + ChiSquared, CressieRead, FisherZ, FreemanTukey, LogLikelihood, ModifiedLikelihood, + PearsonCorrelation, PearsonEquivalence, +}; +use ci_core::dataset::{ColumnKind, Dataset}; +use ci_core::strategy::{CITest, CiResult}; +use serde::Deserialize; + +const TOL: f64 = 1e-7; +const EXPECTED_CASE_COUNT: usize = 80; + +#[derive(Debug, Default, Deserialize)] +struct Params { + #[serde(default)] + delta_threshold: Option, + #[serde(default)] + yates: Option, +} + +#[derive(Debug, Deserialize)] +struct ColumnSpec { + kind: String, + values: Vec, +} + +#[derive(Debug, Deserialize)] +struct Expected { + statistic: Option, + p_value: Option, + dof: Option, + effect_size: Option, +} + +#[derive(Debug, Deserialize)] +struct Case { + test: String, + #[serde(default)] + params: Params, + columns: BTreeMap, + x: String, + y: String, + z: Vec, + expected: Expected, +} + +fn load_cases() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/golden.json" + ); + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("failed to read fixture at {path}: {e}")); + serde_json::from_str(&raw).expect("failed to parse golden.json") +} + +/// Build a `Dataset` plus a name->index map from a case's columns, preserving a +/// deterministic ordering so X/Y/Z names resolve correctly. +fn build_dataset(case: &Case) -> (Dataset, BTreeMap) { + let mut cols = Vec::new(); + let mut index = BTreeMap::new(); + for (i, (name, spec)) in case.columns.iter().enumerate() { + let kind = match spec.kind.as_str() { + "discrete" => ColumnKind::Discrete, + "continuous" => ColumnKind::Continuous, + other => panic!("unknown column kind {other}"), + }; + cols.push((name.clone(), kind, spec.values.clone())); + index.insert(name.clone(), i); + } + (Dataset::from_columns(cols).unwrap(), index) +} + +/// The `yates` flag for a discrete case (defaults to `true` if unspecified). +fn case_yates(case: &Case) -> bool { + case.params.yates.unwrap_or(true) +} + +fn run_case(case: &Case) -> CiResult { + let (data, index) = build_dataset(case); + let x = index[&case.x]; + let y = index[&case.y]; + let z: Vec = case.z.iter().map(|n| index[n]).collect(); + + // Construct each discrete test with the case's own Yates flag so both the + // Yates-on and Yates-off fixture rows are exercised. + let test: Box = match case.test.as_str() { + "chi_squared" => Box::new(ChiSquared { + yates: case_yates(case), + }), + "log_likelihood" => Box::new(LogLikelihood { + yates: case_yates(case), + }), + "cressie_read" => Box::new(CressieRead { + yates: case_yates(case), + }), + "freeman_tukey" => Box::new(FreemanTukey { + yates: case_yates(case), + }), + "modified_likelihood" => Box::new(ModifiedLikelihood { + yates: case_yates(case), + }), + "pearson_correlation" => Box::new(PearsonCorrelation::new()), + "pearson_equivalence" => { + let delta = case + .params + .delta_threshold + .expect("equivalence case missing delta_threshold"); + Box::new(PearsonEquivalence::new(delta)) + } + "fisher_z" => Box::new(FisherZ::new()), + other => panic!("run_case called for unsupported test {other}"), + }; + + test.test(&data, x, y, &z).unwrap() +} + +fn assert_field(name: &str, case: &Case, expected: Option, actual: Option) { + let Some(exp) = expected else { return }; + let act = actual + .unwrap_or_else(|| panic!("[{}] expected {name}={exp} but result had None", case.test)); + + // Handle the degenerate `+∞` statistic / `p = 0` case exactly: an infinite + // expectation requires a matching infinite actual of the same sign (and + // vice versa). `total_cmp` gives an exact ordering without a float `==`. + if exp.is_infinite() || act.is_infinite() { + assert!( + exp.total_cmp(&act).is_eq(), + "[{}] {name} mismatch: expected {exp}, got {act} (x={}, y={}, z={:?})", + case.test, + case.x, + case.y, + case.z, + ); + return; + } + + assert!( + (act - exp).abs() < TOL, + "[{}] {name} mismatch: expected {exp}, got {act} (x={}, y={}, z={:?})", + case.test, + case.x, + case.y, + case.z, + ); +} + +#[test] +fn golden_fixture_matches() { + let cases = load_cases(); + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + + for case in &cases { + let result = run_case(case); + + assert_field("statistic", case, case.expected.statistic, result.statistic); + assert_field("p_value", case, case.expected.p_value, Some(result.p_value)); + #[allow(clippy::cast_precision_loss)] + let dof_expected = case.expected.dof.map(|d| d as f64); + #[allow(clippy::cast_precision_loss)] + let dof_actual = result.dof.map(|d| d as f64); + assert_field("dof", case, dof_expected, dof_actual); + assert_field( + "effect_size", + case, + case.expected.effect_size, + result.effect_size, + ); + + *counts.entry(case.test.as_str()).or_default() += 1; + } + + eprintln!("golden fixture: asserted cases per test: {counts:?}"); + + // Every test in the fixture must be covered, and every single case asserted. + let total: usize = counts.values().sum(); + assert_eq!( + total, EXPECTED_CASE_COUNT, + "expected to assert all {EXPECTED_CASE_COUNT} fixture cases, asserted {total}: {counts:?}" + ); + for test in [ + "chi_squared", + "log_likelihood", + "cressie_read", + "freeman_tukey", + "modified_likelihood", + "pearson_correlation", + "pearson_equivalence", + "fisher_z", + ] { + assert!( + counts.get(test).copied().unwrap_or(0) > 0, + "no cases asserted for {test}" + ); + } +} diff --git a/crates/ci-core/tests/rust_integration.rs b/crates/ci-core/tests/rust_integration.rs deleted file mode 100644 index 2e040f7..0000000 --- a/crates/ci-core/tests/rust_integration.rs +++ /dev/null @@ -1,63 +0,0 @@ -use ci_core::ci_tests::*; -use ci_core::strategy::{CITest, TestResult}; -use ndarray::{array, Array1, Array2}; -use rand::rngs::SmallRng; -use rand::SeedableRng; -use rand_distr::{Distribution, Normal}; - -fn gen_normal(n: usize, mean: f64, std_dev: f64, rng: &mut SmallRng) -> Array1 { - let dist = Normal::new(mean, std_dev).unwrap(); - Array1::from_vec((0..n).map(|_| dist.sample(rng)).collect()) -} - -#[test] -fn test_all_discrete_tests_accept_independent_data() { - let discrete_tests: Vec> = vec![ - Box::new(ChiSquared::new(true, 0.05)), - Box::new(FreemanTukey::new(true, 0.05)), - Box::new(LogLikelihood::new(true, 0.05)), - Box::new(ModifiedLikelihood::new(true, 0.05)), - Box::new(CressieRead::new(true, 0.05)), - ]; - - let x = array![1., 1., 2., 2., 1., 1., 2., 2.]; - let y = array![1., 2., 1., 2., 1., 2., 1., 2.]; - let z = array![[1.], [1.], [1.], [1.], [2.], [2.], [2.], [2.]]; - - for (index, test) in discrete_tests.iter().enumerate() { - let result = test - .run_test(x.clone(), y.clone(), z.clone()) - .unwrap_or_else(|err| panic!("Discrete test at index {index} crashed: {err}")); - - assert!( - matches!(result, TestResult::Boolean(true)), - "Discrete test at index {index} failed to return true!" - ); - } -} - -#[test] -fn test_all_continuous_tests_accept_independent_data() { - let mut rng = SmallRng::seed_from_u64(40); - let n = 1000; - - let continuous_tests: Vec> = vec![ - Box::new(PearsonCorrelation::new(true, 0.05)), - Box::new(PearsonEquivalence::new(true, 0.05, 0.1)), - ]; - - let x = gen_normal(n, 0.0, 1.0, &mut rng); - let y = gen_normal(n, 0.0, 1.0, &mut rng); - let empty_z = Array2::::zeros((0, 0)); - - for (index, test) in continuous_tests.iter().enumerate() { - let result = test - .run_test(x.clone(), y.clone(), empty_z.clone()) - .unwrap_or_else(|err| panic!("Continuous test at index {index} crashed: {err}")); - - assert!( - matches!(result, TestResult::Boolean(true)), - "Continuous test at index {index} failed to return true!" - ); - } -} diff --git a/crates/ci-js/Cargo.toml b/crates/ci-js/Cargo.toml index f867770..dfab2ec 100644 --- a/crates/ci-js/Cargo.toml +++ b/crates/ci-js/Cargo.toml @@ -10,11 +10,22 @@ repository = "" [lib] crate-type = ["cdylib"] +# wasm-pack tooling configuration (NOT a cargo `[profile.*]`). The redesigned +# core emits modern wasm (bulk-memory) that pre-0.110 `wasm-opt` cannot parse; +# wasm-opt only shrinks the binary, so disabling it keeps the build portable +# without affecting correctness. Re-enable once a newer wasm-opt is guaranteed. +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[package.metadata.wasm-pack.profile.dev] +wasm-opt = false + [dependencies] ci_core = { path = "../ci-core"} wasm-bindgen = "0.2" js-sys = "0.3" -ndarray = {workspace = true} +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" getrandom = { version = "0.2", features = ["js"] } console_error_panic_hook = "0.1" diff --git a/crates/ci-js/src/lib.rs b/crates/ci-js/src/lib.rs index 762af60..bc41862 100644 --- a/crates/ci-js/src/lib.rs +++ b/crates/ci-js/src/lib.rs @@ -1,98 +1,580 @@ -use ci_core::ci_tests::{ - chi_squared::ChiSquared, cressie_read::CressieRead, freeman_tukey::FreemanTukey, - log_likelihood::LogLikelihood, modified_likelihood::ModifiedLikelihood, - pearson_correlation::PearsonCorrelation, pearson_equivalence::PearsonEquivalence, -}; -use ci_core::strategy::CITest; -use js_sys::Float64Array; +//! JavaScript / WebAssembly bindings for the data-bound conditional-independence +//! testing core. +//! +//! The surface mirrors `ci_core`'s redesigned contract: a [`Dataset`] is built +//! once from named, typed columns, and each test is a *data-bound* class that +//! takes the dataset (plus its own configuration) in its constructor and then +//! answers `runTest(x, y, z)` / `isIndependent(...)` queries. +//! +//! Each test constructor accepts either a [`Dataset`] instance (cheaply shared +//! across tests without re-copying) or a raw columns object `{ name: { kind, +//! values } }` for one-shot usage. +//! +//! ```js +//! import { Dataset, ChiSquared, PearsonCorrelation, FisherZ } from "../pkg/ci_js.js"; +//! +//! // Option A: shared Dataset (preferred when reusing across tests) +//! const data = new Dataset({ +//! A: { kind: "discrete", values: [0, 1, 0, 1] }, +//! B: { kind: "discrete", values: [1, 0, 1, 0] }, +//! }); +//! const chi = new ChiSquared(data); // optional config: { yates } +//! const r = chi.runTest("A", "B", []); // { statistic, pValue, dof, effectSize } +//! chi.isIndependent("A", "B", [], 0.05); // boolean +//! +//! // Option B: inline raw object (one-shot; columns cross the wasm boundary once) +//! const chi2 = new ChiSquared({ +//! A: { kind: "discrete", values: [0, 1, 0, 1] }, +//! B: { kind: "discrete", values: [1, 0, 1, 0] }, +//! }); +//! +//! // FisherZ (continuous): +//! const fz = new FisherZ(data); +//! fz.runTest("X", "Y", ["Z"]); // { statistic, pValue, dof: null, effectSize } +//! ``` +//! +//! Discrete column `values` may be a `number[]`, a `Float64Array`, **or** a +//! `string[]` (first-seen string-to-code factorization). Continuous columns +//! require numeric values. Core [`CiError`]s and Rust panics surface as thrown +//! JS `Error`s. + +// This crate is a thin wasm-bindgen FFI layer: every fallible function returns +// the same `Result<_, JsValue>` (a thrown JS `Error`) whose failure modes — +// unknown column, wrong column kind, dimension mismatch, degenerate data — are +// documented once on the types/module rather than repeated per method. +#![allow(clippy::missing_errors_doc)] + +use std::rc::Rc; + +use ci_core::dataset::{ColumnKind, Dataset as CoreDataset}; +use ci_core::error::CiError as CoreError; +use ci_core::strategy::{CITest, CiResult as CoreResult, DataType, IndependenceRule, TestMeta}; +use serde::Deserialize; +use wasm_bindgen::convert::TryFromJsValue; use wasm_bindgen::prelude::*; -mod util; +use wasm_bindgen::JsCast; +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &'static str = r#" +export type ColumnKind = "discrete" | "continuous"; +export interface ColumnSpec { kind: ColumnKind; values: number[] | Float64Array | string[]; } +export type ColumnsObject = Record; +export type DataInput = Dataset | ColumnsObject; +export interface DiscreteOptions { yates?: boolean; } +export interface EquivalenceOptions { deltaThreshold?: number; } +"#; + +/// Install the panic hook so Rust panics surface as readable JS errors. Called +/// automatically the first time a [`Dataset`] is constructed; also exported for +/// callers that want to install it eagerly. #[wasm_bindgen] pub fn init() { console_error_panic_hook::set_once(); } -macro_rules! wasm_ci_test { - ($fn_name:ident, $inner:ty) => { - /// Runs the CI test and returns the result as a JS value. - /// - /// Pass an empty `Float64Array` for `z_flat` to run unconditionally. When `boolean` is `true`, - /// returns an independence verdict instead of the raw p-value and correlation. - /// - /// # Returns - /// - /// - `boolean` if `boolean=true` - /// - `[p_value, coefficient]` if `boolean=false` - /// - /// # Errors - /// - /// Returns a `JsValue` error if: - /// - the input arrays have invalid dimensions, - /// - the statistical computation fails, - /// - serialization to JavaScript fails. - #[wasm_bindgen] - pub fn $fn_name( - z_flat: &Float64Array, - z_rows: usize, - z_cols: usize, - x: &Float64Array, - y: &Float64Array, - boolean: bool, - significance_level: f64, - ) -> Result { - let (x, y, z) = util::convert_to_ndarray(z_flat, x, y, z_rows, z_cols)?; - let test = <$inner>::new(boolean, significance_level); - let result = test - .run_test(x, y, z) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - util::convert_to_jsvalue(&result) - } - }; +/// Map a core [`CoreError`] onto a thrown JS `Error`. +fn to_js_error(err: &CoreError) -> JsValue { + js_sys::Error::new(&err.to_string()).into() } -wasm_ci_test!(chi_squared_test, ChiSquared); -wasm_ci_test!(log_likelihood_test, LogLikelihood); -wasm_ci_test!(cressie_read_test, CressieRead); -wasm_ci_test!(pearson_correlation_test, PearsonCorrelation); -wasm_ci_test!(freeman_tukey_test, FreemanTukey); -wasm_ci_test!(modified_likelihood_test, ModifiedLikelihood); +/// Build a JS `Error` from an arbitrary message. +fn js_error(msg: &str) -> JsValue { + js_sys::Error::new(msg).into() +} -/// Pearson equivalence CI test (TOST): declares independence when the partial correlation -/// lies within `[-delta_threshold, delta_threshold]`. -/// -/// Pass an empty `Float64Array` for `z_flat` to run unconditionally. When `boolean` is `true`, -/// returns an independence verdict instead of the raw p-value and correlation. -/// -/// # Returns -/// -/// - `boolean` if `boolean=true` -/// - `[p_value, coefficient]` if `boolean=false` +// --------------------------------------------------------------------------- +// Column / dataset deserialization +// --------------------------------------------------------------------------- + +/// Parse a kind string into a [`ColumnKind`]. +fn parse_kind(kind: &str) -> Result { + match kind { + "discrete" => Ok(ColumnKind::Discrete), + "continuous" => Ok(ColumnKind::Continuous), + other => Err(js_error(&format!( + "column kind must be \"discrete\" or \"continuous\", got {other:?}" + ))), + } +} + +/// Read one column's `values` array: a `Float64Array`, an array of numbers, or +/// (for discrete columns) an array of strings factorized to first-seen codes +/// (the core re-factorizes anyway, so codes only need to be value-distinct). +fn extract_values(name: &str, kind: ColumnKind, values: &JsValue) -> Result, JsValue> { + if let Some(arr) = values.dyn_ref::() { + return Ok(arr.to_vec()); + } + let Some(arr) = values.dyn_ref::() else { + return Err(js_error(&format!( + "column {name:?}: values must be an array or Float64Array" + ))); + }; + let len = arr.length() as usize; + let mut out = Vec::with_capacity(len); + // Decide numeric vs string mode from the first element. + let string_mode = len > 0 && arr.get(0).as_string().is_some(); + if string_mode { + if kind != ColumnKind::Discrete { + return Err(js_error(&format!( + "column {name:?}: continuous column values must be numeric" + ))); + } + let mut lookup: std::collections::HashMap = std::collections::HashMap::new(); + for (i, v) in arr.iter().enumerate() { + let Some(s) = v.as_string() else { + return Err(js_error(&format!( + "column {name:?}: mixed string/number values at index {i}" + ))); + }; + #[allow(clippy::cast_precision_loss)] + let next = lookup.len() as f64; + out.push(*lookup.entry(s).or_insert(next)); + } + } else { + for (i, v) in arr.iter().enumerate() { + let Some(num) = v.as_f64() else { + let hint = if v.as_string().is_some() { + "mixed string/number values" + } else { + "values must be finite numbers (no null/undefined)" + }; + return Err(js_error(&format!("column {name:?}: {hint} at index {i}"))); + }; + out.push(num); + } + } + Ok(out) +} + +/// Build a core [`CoreDataset`] from a `{ name: { kind, values } }` JS object. /// -/// # Errors +/// Columns are read via `Object.entries`, which preserves the object's +/// (insertion) key order, so the resulting column indices match the order the +/// caller wrote — matching the Python binding. (Every test refers to columns by +/// name, so ordering does not affect results, only `indexOf`.) +fn dataset_from_value(value: &JsValue) -> Result { + if !value.is_object() { + return Err(js_error( + "expected a columns object { name: { kind, values } }", + )); + } + let obj: &js_sys::Object = value.unchecked_ref(); + let entries = js_sys::Object::entries(obj); + let mut cols: Vec<(String, ColumnKind, Vec)> = + Vec::with_capacity(entries.length() as usize); + for entry in entries.iter() { + // Each `entry` is a `[name, { kind, values }]` pair. + let pair: js_sys::Array = entry.unchecked_into(); + let name = pair + .get(0) + .as_string() + .ok_or_else(|| js_error("column names must be strings"))?; + let spec = pair.get(1); + let field = |key: &str| { + js_sys::Reflect::get(&spec, &JsValue::from_str(key)) + .map_err(|_| js_error(&format!("column {name:?} must be {{ kind, values }}"))) + }; + let kind_str = field("kind")? + .as_string() + .ok_or_else(|| js_error(&format!("column {name:?}: kind must be a string")))?; + let kind = parse_kind(&kind_str)?; + let values = extract_values(&name, kind, &field("values")?)?; + cols.push((name, kind, values)); + } + CoreDataset::from_columns(cols).map_err(|e| to_js_error(&e)) +} + +// --------------------------------------------------------------------------- +// Dataset wasm class +// --------------------------------------------------------------------------- + +/// A named, typed, immutable table of columns shared by the test classes. /// -/// Returns a `JsValue` error if: -/// - the input arrays have invalid dimensions, -/// - the statistical computation fails, -/// - serialization to JavaScript fails. -#[allow(clippy::too_many_arguments)] +/// Construct from a JS object `{ name: { kind, values } }` where `kind` is +/// `"discrete"` or `"continuous"` and `values` is a `number[]`, `Float64Array`, +/// or (for discrete columns) a `string[]` (factorized first-seen). Discrete +/// columns are factorized into integer codes inside the core. Reuse one +/// `Dataset` across several tests to avoid re-copying the column arrays across +/// the wasm boundary. #[wasm_bindgen] -pub fn pearson_equivalence_test( - z_flat: &Float64Array, - z_rows: usize, - z_cols: usize, - x: &Float64Array, - y: &Float64Array, - boolean: bool, - significance_level: f64, - delta_threshold: f64, -) -> Result { - let (x, y, z) = util::convert_to_ndarray(z_flat, x, y, z_rows, z_cols)?; - let citest = PearsonEquivalence::new(boolean, significance_level, delta_threshold); +#[derive(Clone)] +pub struct Dataset { + inner: Rc, +} + +#[wasm_bindgen] +impl Dataset { + /// Build a dataset from a JS object `{ name: { kind, values } }`. + #[wasm_bindgen(constructor)] + pub fn new( + #[wasm_bindgen(unchecked_param_type = "ColumnsObject")] columns: &JsValue, + ) -> Result { + console_error_panic_hook::set_once(); + Ok(Dataset { + inner: Rc::new(dataset_from_value(columns)?), + }) + } + + /// Number of rows (observations). + #[wasm_bindgen(js_name = nRows)] + #[must_use] + pub fn n_rows(&self) -> usize { + self.inner.n_rows() + } - let result = citest - .run_test(x, y, z) - .map_err(|e| JsError::new(&e.to_string()))?; + /// Number of columns. + #[wasm_bindgen(js_name = nCols)] + #[must_use] + pub fn n_cols(&self) -> usize { + self.inner.n_cols() + } + + /// Index of the column named `name`, or `undefined` if it is not present. + #[wasm_bindgen(js_name = indexOf)] + #[must_use] + pub fn index_of(&self, name: &str) -> Option { + self.inner.index_of(name) + } + + /// Internal: return a fresh handle sharing this dataset (used by the test + /// constructors to accept a `Dataset` without consuming the caller's + /// object). Stable marker for instance detection; not part of the public + /// API surface. + #[wasm_bindgen(js_name = _cloneHandle)] + #[must_use] + pub fn clone_handle(&self) -> Dataset { + self.clone() + } +} + +/// Resolve a constructor `data` argument: a `Dataset` instance (detected by +/// its `_cloneHandle` marker; we consume a *fresh clone*, never the caller's +/// object) or a raw `{ name: { kind, values } }` columns object. +fn coerce_dataset(value: &JsValue) -> Result, JsValue> { + let marker = js_sys::Reflect::get(value, &JsValue::from_str("_cloneHandle")) + .unwrap_or(JsValue::UNDEFINED); + if let Some(f) = marker.dyn_ref::() { + let cloned = f.call0(value)?; + let ds = + Dataset::try_from_js_value(cloned).map_err(|_| js_error("invalid Dataset handle"))?; + return Ok(ds.inner); + } + Ok(Rc::new(dataset_from_value(value)?)) +} + +/// Resolve a single column reference (a name `string`) to a validated column +/// index within `data`. +fn resolve_column(data: &CoreDataset, name: &str) -> Result { + data.index_of(name) + .ok_or_else(|| js_error(&format!("unknown column name: {name:?}"))) +} + +/// Resolve a conditioning-set argument: a JS array of column names. +fn resolve_z(data: &CoreDataset, z: &[String]) -> Result, JsValue> { + z.iter().map(|name| resolve_column(data, name)).collect() +} - util::convert_to_jsvalue(&result) +/// Resolve the `x`, `y`, and `z` column references of a query in one step, +/// short-circuiting on the first unknown name (x, then y, then z). +fn resolve_xyz( + data: &CoreDataset, + x: &str, + y: &str, + z: &[String], +) -> Result<(usize, usize, Vec), JsValue> { + Ok(( + resolve_column(data, x)?, + resolve_column(data, y)?, + resolve_z(data, z)?, + )) } + +/// Convert a core [`CoreResult`] into a plain JS object +/// `{ statistic, pValue, dof, effectSize }` (`null` for `None`). +#[allow( + clippy::cast_precision_loss, + reason = "dof is a small count; it never approaches f64's 2^53 exact-integer limit" +)] +fn result_to_js(result: &CoreResult) -> JsValue { + let obj = js_sys::Object::new(); + let set = |key: &str, value: JsValue| { + // Setting a property on a fresh Object never fails. + let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(key), &value); + }; + set( + "statistic", + result.statistic.map_or(JsValue::NULL, JsValue::from_f64), + ); + set("pValue", JsValue::from_f64(result.p_value)); + set( + "dof", + result + .dof + .map_or(JsValue::NULL, |d| JsValue::from_f64(d as f64)), + ); + set( + "effectSize", + result.effect_size.map_or(JsValue::NULL, JsValue::from_f64), + ); + obj.into() +} + +/// Convert a [`TestMeta`] into a plain JS object +/// `{ name, dataTypes, symmetric, rule }`. +fn meta_to_js(meta: &TestMeta) -> JsValue { + let obj = js_sys::Object::new(); + let set = |key: &str, value: JsValue| { + let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(key), &value); + }; + set("name", JsValue::from_str(meta.name)); + let types = js_sys::Array::new(); + for t in meta.data_types { + let s = match t { + DataType::Discrete => "discrete", + DataType::Continuous => "continuous", + }; + types.push(&JsValue::from_str(s)); + } + set("dataTypes", types.into()); + set("symmetric", JsValue::from_bool(meta.symmetric)); + set( + "rule", + JsValue::from_str(match meta.rule { + IndependenceRule::PValueGe => "p_value_ge", + IndependenceRule::PValueLt => "p_value_lt", + }), + ); + obj.into() +} + +/// Generate a data-bound wasm test class for a concrete [`CITest`]. +/// +/// Each generated class stores the shared [`CoreDataset`] plus a constructed +/// inner test, and exposes the uniform `runTest` / `isIndependent` / `meta` +/// surface. The constructor accepts a [`Dataset`] instance *or* a raw columns +/// object `{ name: { kind, values } }`; passing a shared `Dataset` avoids +/// re-copying the column arrays across the wasm boundary when the same data is +/// used by multiple tests. +macro_rules! ci_test_class { + ( + $js_name:literal, + $wrapper:ident, + $core:ty, + $config:ident, + $cfg:ident, + $config_ts:literal, + $allowed_keys:expr, + $build:expr + ) => { + #[doc = concat!("Data-bound `", $js_name, "` conditional-independence test.")] + #[wasm_bindgen(js_name = $js_name)] + pub struct $wrapper { + data: Rc, + inner: $core, + } + + #[wasm_bindgen(js_class = $js_name)] + impl $wrapper { + /// Construct the test bound to `data` (a `Dataset` instance or a raw + /// columns object `{ name: { kind, values } }`) with an optional + /// `config` object. Pass a shared `Dataset` to avoid re-copying the + /// column arrays across the wasm boundary when several tests share the + /// same data. + #[wasm_bindgen(constructor)] + pub fn new( + #[wasm_bindgen(unchecked_param_type = "DataInput")] data: &JsValue, + #[wasm_bindgen(unchecked_param_type = $config_ts)] config: Option, + ) -> Result<$wrapper, JsValue> { + console_error_panic_hook::set_once(); + let data = coerce_dataset(data)?; + // serde-wasm-bindgen reads only the *known* fields off a JS + // object, so unknown keys would be silently ignored; reject + // them explicitly (config typos must not pass unnoticed). + let allowed: &[&str] = $allowed_keys; + if let Some(ref obj) = config { + for key in js_sys::Object::keys(obj).iter() { + let key = key.as_string().unwrap_or_default(); + if !allowed.contains(&key.as_str()) { + return Err(js_error(&format!( + "invalid config object: unknown key {key:?} (allowed: {allowed:?})" + ))); + } + } + } + let $cfg: $config = match config { + Some(obj) => serde_wasm_bindgen::from_value(obj.into()) + .map_err(|e| js_error(&format!("invalid config object: {e}")))?, + None => $config::default(), + }; + let inner = $build; + Ok($wrapper { data, inner }) + } + + /// Run the test for `x ⟂ y | z`, returning a plain object + /// `{ statistic, pValue, dof, effectSize }` (`null` for absent + /// fields). `x`/`y` are column names; `z` is an array of names. + #[wasm_bindgen(js_name = runTest)] + pub fn run_test(&self, x: &str, y: &str, z: Vec) -> Result { + let (xi, yi, zi) = resolve_xyz(&self.data, x, y, &z)?; + self.inner + .test(&self.data, xi, yi, &zi) + .map(|r| result_to_js(&r)) + .map_err(|e| to_js_error(&e)) + } + + /// Decide independence at `significanceLevel` (default `0.05` when + /// omitted, matching the Python binding) using the test's rule. + /// Throws if `significanceLevel` is not a finite number. + #[wasm_bindgen(js_name = isIndependent)] + pub fn is_independent( + &self, + x: &str, + y: &str, + z: Vec, + significance_level: Option, + ) -> Result { + let significance_level = significance_level.unwrap_or(0.05); + if !significance_level.is_finite() { + return Err(js_error(&format!( + "significanceLevel must be a finite number, got {significance_level}" + ))); + } + let (xi, yi, zi) = resolve_xyz(&self.data, x, y, &z)?; + self.inner + .is_independent(&self.data, xi, yi, &zi, significance_level) + .map_err(|e| to_js_error(&e)) + } + + /// Static metadata: `{ name, dataTypes, symmetric, rule }`. + #[wasm_bindgen] + #[must_use] + pub fn meta(&self) -> JsValue { + meta_to_js(&self.inner.meta()) + } + } + }; +} + +/// Config for the discrete power-divergence tests: `{ yates }` (default `true`). +#[derive(Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct DiscreteConfig { + yates: bool, +} + +impl Default for DiscreteConfig { + fn default() -> Self { + // Yates' continuity correction on by default (scipy/pgmpy convention). + Self { yates: true } + } +} + +/// Config for [`PearsonCorrelation`] and [`FisherZ`]: no options. +#[derive(Deserialize, Default)] +#[serde(default)] +struct EmptyConfig {} + +/// Config for [`PearsonEquivalence`]: `{ deltaThreshold }` (default `0.1`). +#[derive(Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct EquivalenceConfig { + delta_threshold: f64, +} + +impl Default for EquivalenceConfig { + fn default() -> Self { + Self { + delta_threshold: 0.1, + } + } +} + +ci_test_class!( + "ChiSquared", + ChiSquared, + ci_core::ci_tests::ChiSquared, + DiscreteConfig, + cfg, + "DiscreteOptions | undefined", + &["yates"], + ci_core::ci_tests::ChiSquared { yates: cfg.yates } +); + +ci_test_class!( + "LogLikelihood", + LogLikelihood, + ci_core::ci_tests::LogLikelihood, + DiscreteConfig, + cfg, + "DiscreteOptions | undefined", + &["yates"], + ci_core::ci_tests::LogLikelihood { yates: cfg.yates } +); + +ci_test_class!( + "CressieRead", + CressieRead, + ci_core::ci_tests::CressieRead, + DiscreteConfig, + cfg, + "DiscreteOptions | undefined", + &["yates"], + ci_core::ci_tests::CressieRead { yates: cfg.yates } +); + +ci_test_class!( + "FreemanTukey", + FreemanTukey, + ci_core::ci_tests::FreemanTukey, + DiscreteConfig, + cfg, + "DiscreteOptions | undefined", + &["yates"], + ci_core::ci_tests::FreemanTukey { yates: cfg.yates } +); + +ci_test_class!( + "ModifiedLikelihood", + ModifiedLikelihood, + ci_core::ci_tests::ModifiedLikelihood, + DiscreteConfig, + cfg, + "DiscreteOptions | undefined", + &["yates"], + ci_core::ci_tests::ModifiedLikelihood { yates: cfg.yates } +); + +ci_test_class!( + "PearsonCorrelation", + PearsonCorrelation, + ci_core::ci_tests::PearsonCorrelation, + EmptyConfig, + _cfg, + "Record | undefined", + &[], + ci_core::ci_tests::PearsonCorrelation::new() +); + +ci_test_class!( + "PearsonEquivalence", + PearsonEquivalence, + ci_core::ci_tests::PearsonEquivalence, + EquivalenceConfig, + cfg, + "EquivalenceOptions | undefined", + &["deltaThreshold"], + ci_core::ci_tests::PearsonEquivalence { + delta_threshold: cfg.delta_threshold + } +); + +ci_test_class!( + "FisherZ", + FisherZ, + ci_core::ci_tests::FisherZ, + EmptyConfig, + _cfg, + "Record | undefined", + &[], + ci_core::ci_tests::FisherZ::new() +); diff --git a/crates/ci-js/src/util.rs b/crates/ci-js/src/util.rs deleted file mode 100644 index 27bc53b..0000000 --- a/crates/ci-js/src/util.rs +++ /dev/null @@ -1,84 +0,0 @@ -use ci_core::strategy::TestResult; -use js_sys::{Array, Float64Array}; -use ndarray::{Array1, Array2}; -use wasm_bindgen::prelude::*; - -/// Converts JavaScript `Float64Array` inputs into `ndarray` types for use in CI tests. -/// -/// # Parameters -/// -/// - `z_flat` - Row-major flattened conditioning matrix. Pass an empty `Float64Array` -/// for unconditional testing. -/// - `x` - First variable as a `Float64Array`. -/// - `y` - Second variable as a `Float64Array`. -/// - `z_rows` - Number of rows in the conditioning matrix. Ignored if `z_flat` is empty. -/// - `z_cols` - Number of columns in the conditioning matrix. Ignored if `z_flat` is empty. -/// -/// # Returns -/// -/// A tuple `(x, y, z)` where: -/// - `x` and `y` are `Array1` vectors -/// - `z` is an `Array2` matrix with shape `(n, 0)` for unconditional tests, -/// or `(z_rows, z_cols)` for conditional tests -/// -/// # Errors -/// -/// Returns a `JsValue` error if `z_flat.len() != z_rows * z_cols`. -#[allow(clippy::type_complexity)] -pub fn convert_to_ndarray( - z_flat: &Float64Array, - x: &Float64Array, - y: &Float64Array, - z_rows: usize, - z_cols: usize, -) -> Result<(Array1, Array1, Array2), JsValue> { - if u32::try_from(z_rows * z_cols).unwrap_or(u32::MAX) != z_flat.length() { - return Err(JsValue::from_str( - "z_rows*z_cols doesnt match the size of z_flat", - )); - } - let x_vec: Vec = x.to_vec(); - let y_vec: Vec = y.to_vec(); - let z: Array2 = if z_flat.length() == 0 { - Array2::zeros((x_vec.len(), 0)) - } else { - let z_vec: Vec = z_flat.to_vec(); - Array2::from_shape_vec((z_rows, z_cols), z_vec) - .map_err(|e| JsValue::from_str(&e.to_string()))? - }; - let x: Array1 = Array1::from_vec(x_vec); - let y: Array1 = Array1::from_vec(y_vec); - - Ok((x, y, z)) -} - -/// Converts a `TestResult` into a JavaScript value. -/// -/// # Returns -/// -/// The return type depends on the `TestResult` variant: -/// -/// - `TestResult::Boolean` — returns a JS `boolean` -/// - `TestResult::PValue` — returns a JS `Array` of `[p_value, coefficient]` -/// - `TestResult::Statistic` — returns a JS `Array` of `[p_value, statistic, dof]` -#[allow(clippy::cast_precision_loss)] -#[allow(clippy::unnecessary_wraps)] -pub fn convert_to_jsvalue(result: &TestResult) -> Result { - match result { - TestResult::Boolean(b) => Ok(JsValue::from_bool(*b)), - TestResult::PValue(p_value, coefficient) => { - let array = Array::new(); - array.push(&JsValue::from_f64(*p_value)); - array.push(&JsValue::from_f64(*coefficient)); - Ok(array.into()) - } - - TestResult::Statistic(p_value, statistic, dof) => { - let array = Array::new(); - array.push(&JsValue::from_f64(*p_value)); - array.push(&JsValue::from_f64(*statistic)); - array.push(&JsValue::from_f64(*dof as f64)); - Ok(array.into()) - } - } -} diff --git a/crates/ci-js/tests/api.test.js b/crates/ci-js/tests/api.test.js new file mode 100644 index 0000000..9f7289c --- /dev/null +++ b/crates/ci-js/tests/api.test.js @@ -0,0 +1,274 @@ +// API-surface tests for the data-bound JS bindings: implicit construction +// (raw columns object vs shared Dataset), string categoricals, the strict +// NaN policy, query validation, and fisher_z. + +import { describe, test, expect, beforeAll } from "vitest"; +import pkg from "../pkg/ci_js.js"; +const { Dataset, ChiSquared, FisherZ, PearsonCorrelation, PearsonEquivalence, init } = pkg; + +beforeAll(() => init()); + +const DISCRETE_COLS = { + A: { kind: "discrete", values: [0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0] }, + B: { kind: "discrete", values: [1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0] }, +}; + +describe("implicit construction", () => { + test("raw columns object and shared Dataset give identical results", () => { + const viaRaw = new ChiSquared(DISCRETE_COLS); + const data = new Dataset(DISCRETE_COLS); + const viaDataset = new ChiSquared(data); + const a = viaRaw.runTest("A", "B", []); + const b = viaDataset.runTest("A", "B", []); + expect(a.statistic).toBeCloseTo(b.statistic, 12); + expect(a.pValue).toBeCloseTo(b.pValue, 12); + }); + + test("one Dataset can be shared across several tests without being consumed", () => { + const data = new Dataset(DISCRETE_COLS); + const chi1 = new ChiSquared(data); + const chi2 = new ChiSquared(data, { yates: false }); + expect(chi1.runTest("A", "B", []).pValue).toBeGreaterThan(0); + expect(chi2.runTest("A", "B", []).pValue).toBeGreaterThan(0); + // The caller's Dataset must remain usable afterwards. + expect(data.nRows()).toBe(12); + }); +}); + +describe("string categoricals", () => { + test("discrete columns accept string values", () => { + const data = new Dataset({ + A: { kind: "discrete", values: ["yes", "no", "yes", "no", "maybe", "yes"] }, + B: { kind: "discrete", values: [0, 1, 0, 1, 0, 1] }, + }); + const r = new ChiSquared(data).runTest("A", "B", []); + expect(r.pValue).toBeGreaterThanOrEqual(0); + expect(r.pValue).toBeLessThanOrEqual(1); + }); + + test("continuous columns reject string values", () => { + expect( + () => new Dataset({ X: { kind: "continuous", values: ["a", "b"] } }), + ).toThrow(/numeric/); + }); + + test("mixed string/number arrays are rejected either way around", () => { + expect( + () => new Dataset({ A: { kind: "discrete", values: [1, "a"] } }), + ).toThrow(/mixed string\/number/); + expect( + () => new Dataset({ A: { kind: "discrete", values: ["a", 1] } }), + ).toThrow(/mixed string\/number/); + }); +}); + +describe("typed arrays", () => { + test("Float64Array values match the plain-array form", () => { + const plain = [0.4, 1.3, -0.2, 0.9, 0.1, -0.7, 0.6, 1.1]; + const other = [1.0, -0.3, 0.8, 0.2, -0.9, 0.5, -0.1, 0.7]; + const viaArray = new Dataset({ + X: { kind: "continuous", values: plain }, + Y: { kind: "continuous", values: other }, + }); + const viaTyped = new Dataset({ + X: { kind: "continuous", values: new Float64Array(plain) }, + Y: { kind: "continuous", values: new Float64Array(other) }, + }); + const a = new PearsonCorrelation(viaArray).runTest("X", "Y", []); + const b = new PearsonCorrelation(viaTyped).runTest("X", "Y", []); + expect(b.statistic).toBeCloseTo(a.statistic, 14); + expect(b.pValue).toBeCloseTo(a.pValue, 14); + }); +}); + +describe("strict NaN policy and validation", () => { + test("NaN errors at Dataset construction", () => { + expect( + () => new Dataset({ X: { kind: "continuous", values: [1, NaN, 2] } }), + ).toThrow(/missing data/); + }); + + test("null errors at Dataset construction", () => { + expect( + () => new Dataset({ X: { kind: "continuous", values: [1, null, 2] } }), + ).toThrow(); + }); + + test("x == y is an invalid query", () => { + const chi = new ChiSquared(DISCRETE_COLS); + expect(() => chi.runTest("A", "A", [])).toThrow(/invalid query/); + }); +}); + +describe("fisher_z", () => { + test("runs and reports no dof", () => { + const n = 60; + const X = [], Y = [], Z = []; + let s = 42; + const rand = () => { + // deterministic LCG in (-1, 1) + s = (s * 48271) % 2147483647; + return (s / 2147483647) * 2 - 1; + }; + for (let i = 0; i < n; i++) { + const z = rand(); + Z.push(z); + X.push(1.2 * z + 0.3 * rand()); + Y.push(0.7 * z + 0.3 * rand()); + } + const data = new Dataset({ + X: { kind: "continuous", values: X }, + Y: { kind: "continuous", values: Y }, + Z: { kind: "continuous", values: Z }, + }); + const fz = new FisherZ(data); + const r = fz.runTest("X", "Y", ["Z"]); + expect(r.dof).toBeNull(); + expect(r.pValue).toBeGreaterThan(0); + const pc = new PearsonCorrelation(data).runTest("X", "Y", ["Z"]); + // statistic = sqrt(n - 1 - 3) * atanh(r) + expect(r.statistic).toBeCloseTo(Math.sqrt(n - 4) * Math.atanh(pc.statistic), 9); + }); +}); + +describe("strict config", () => { + test("unknown config keys throw instead of being ignored", () => { + expect( + () => new ChiSquared(DISCRETE_COLS, { yate: false }), // typo of yates + ).toThrow(/invalid config object/); + expect( + () => new FisherZ(new Dataset(DISCRETE_COLS), { foo: 1 }), + ).toThrow(/invalid config object/); + }); + + test("valid configs still work", () => { + expect(new ChiSquared(DISCRETE_COLS, {}).runTest("A", "B", []).pValue).toBeGreaterThan(0); + expect( + new ChiSquared(DISCRETE_COLS, { yates: false }).runTest("A", "B", []).pValue, + ).toBeGreaterThan(0); + }); +}); + +describe("Dataset introspection", () => { + test("nCols and indexOf resolve names to insertion-order indices", () => { + const data = new Dataset(DISCRETE_COLS); + expect(data.nCols()).toBe(2); + expect(data.nRows()).toBe(12); + // Object.entries preserves insertion order, so A -> 0, B -> 1. + expect(data.indexOf("A")).toBe(0); + expect(data.indexOf("B")).toBe(1); + // A missing name resolves to `undefined` (Option::None), not an error. + expect(data.indexOf("nope")).toBeUndefined(); + }); +}); + +describe("meta", () => { + test("a discrete test reports its name, data types, symmetry, and PValueGe rule", () => { + const m = new ChiSquared(DISCRETE_COLS).meta(); + expect(m.name).toBe("chi_squared"); + expect(m.dataTypes).toEqual(["discrete"]); + expect(m.symmetric).toBe(true); + expect(m.rule).toBe("p_value_ge"); + }); + + test("a continuous test reports a continuous data type and PValueGe rule", () => { + const m = new PearsonCorrelation(DISCRETE_COLS).meta(); + expect(m.name).toBe("pearson_correlation"); + expect(m.dataTypes).toEqual(["continuous"]); + expect(m.symmetric).toBe(true); + expect(m.rule).toBe("p_value_ge"); + }); + + test("the equivalence test advertises the inverted PValueLt rule", () => { + const m = new PearsonEquivalence(DISCRETE_COLS).meta(); + expect(m.name).toBe("pearson_equivalence"); + expect(m.rule).toBe("p_value_lt"); + }); +}); + +describe("effectSize result field", () => { + test("ChiSquared populates effectSize (Cramér's V) for a 2x2 table", () => { + const r = new ChiSquared(DISCRETE_COLS).runTest("A", "B", []); + expect(typeof r.effectSize).toBe("number"); + expect(Number.isFinite(r.effectSize)).toBe(true); + expect(r.effectSize).toBeGreaterThanOrEqual(0); + }); + + test("PearsonCorrelation populates effectSize (|r|) for continuous data", () => { + const data = new Dataset({ + X: { kind: "continuous", values: [0.4, 1.3, -0.2, 0.9, 0.1, -0.7, 0.6, 1.1] }, + Y: { kind: "continuous", values: [1.0, -0.3, 0.8, 0.2, -0.9, 0.5, -0.1, 0.7] }, + }); + const r = new PearsonCorrelation(data).runTest("X", "Y", []); + expect(typeof r.effectSize).toBe("number"); + expect(Number.isFinite(r.effectSize)).toBe(true); + expect(r.effectSize).toBeGreaterThanOrEqual(0); + expect(r.effectSize).toBeLessThanOrEqual(1); + }); + + test("effectSize is null when Cramér's V is undefined (a constant column)", () => { + // A constant column has cardinality 1, so min(kx, ky) < 2 and Cramér's V is + // undefined; the core returns None, which surfaces as JS `null`. + const data = new Dataset({ + C: { kind: "discrete", values: [0, 0, 0, 0, 0, 0] }, + B: { kind: "discrete", values: [0, 1, 0, 1, 0, 1] }, + }); + const r = new ChiSquared(data).runTest("C", "B", []); + expect(r.effectSize).toBeNull(); + }); +}); + +describe("isIndependent", () => { + // Exactly-zero correlation by construction: each x value pairs with +1 and -1 + // in y, so the covariance is 0 and the standard p-value is 1. + function nearZeroCorrelation() { + const X = []; + const Y = []; + for (let i = 0; i < 50; i++) { + X.push(i); + Y.push(1); + X.push(i); + Y.push(-1); + } + return new Dataset({ + x: { kind: "continuous", values: X }, + y: { kind: "continuous", values: Y }, + }); + } + + test("a standard test and PearsonEquivalence reach opposite decisions on the same data", () => { + const data = nearZeroCorrelation(); + const pc = new PearsonCorrelation(data); + // A tiny equivalence margin: r == 0 cannot be shown to lie *within* it, so + // the TOST p-value stays large and the PValueLt rule refuses independence. + const eq = new PearsonEquivalence(data, { deltaThreshold: 0.001 }); + + const pcIndep = pc.isIndependent("x", "y", [], 0.05); + const eqIndep = eq.isIndependent("x", "y", [], 0.05); + + // Standard (PValueGe): r == 0 gives p == 1 >= 0.05 -> independent. + expect(pcIndep).toBe(true); + // Equivalence (PValueLt): TOST p stays >= 0.05 -> NOT independent. + expect(eqIndep).toBe(false); + // The two rules therefore disagree on identical data. + expect(pcIndep).not.toBe(eqIndep); + + // The decision must match each test's documented rule applied to its p-value. + const alpha = 0.05; + expect(pcIndep).toBe(pc.runTest("x", "y", []).pValue >= alpha); + expect(eqIndep).toBe(eq.runTest("x", "y", []).pValue < alpha); + }); + + test("omitting significanceLevel defaults to 0.05 (not NaN)", () => { + const chi = new ChiSquared(DISCRETE_COLS); + // Omitting the level must behave like passing 0.05 explicitly, not coerce to + // NaN (which would make every comparison false and silently return false). + expect(chi.isIndependent("A", "B", [])).toBe(chi.isIndependent("A", "B", [], 0.05)); + }); + + test("a non-finite significanceLevel throws instead of silently returning false", () => { + const chi = new ChiSquared(DISCRETE_COLS); + expect(() => chi.isIndependent("A", "B", [], NaN)).toThrow(/finite/); + expect(() => chi.isIndependent("A", "B", [], Infinity)).toThrow(/finite/); + }); +}); diff --git a/crates/ci-js/tests/golden.test.js b/crates/ci-js/tests/golden.test.js new file mode 100644 index 0000000..ed08547 --- /dev/null +++ b/crates/ci-js/tests/golden.test.js @@ -0,0 +1,152 @@ +// Golden parity test for the data-bound JavaScript / WebAssembly bindings. +// +// Loads the shared cross-language fixture `tests/fixtures/golden.json` (at the +// repository root) and, for each of the eight tests, builds a `Dataset` from the +// case's columns, constructs the test with the case's parameters, runs it, and +// asserts that `statistic` / `pValue` / `dof` match the recorded `expected` +// values within 1e-7. This is the binding's numeric parity gate against the +// scipy/pgmpy reference, mirroring the Rust (`crates/ci-core/tests/golden.rs`) +// and Python (`crates/ci-python/test/test_golden.py`) gates. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, test, expect, beforeAll } from "vitest"; + +// The wasm package is generated by `wasm-pack build crates/ci-js --target nodejs` +// into `crates/ci-js/pkg`. It is a CommonJS module; default-import then +// destructure so the named classes resolve regardless of interop quirks. +import pkg from "../pkg/ci_js.js"; +const { + Dataset, + ChiSquared, + LogLikelihood, + CressieRead, + FreemanTukey, + ModifiedLikelihood, + PearsonCorrelation, + PearsonEquivalence, + FisherZ, + init, +} = pkg; + +const TOL = 1e-7; +const EXPECTED_CASE_COUNT = 80; + +// Map the fixture's stable test name to its binding class. +const TEST_CLASSES = { + chi_squared: ChiSquared, + log_likelihood: LogLikelihood, + cressie_read: CressieRead, + freeman_tukey: FreemanTukey, + modified_likelihood: ModifiedLikelihood, + pearson_correlation: PearsonCorrelation, + pearson_equivalence: PearsonEquivalence, + fisher_z: FisherZ, +}; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// crates/ci-js/tests/golden.test.js -> repo root is three parents up. +const GOLDEN_PATH = resolve(__dirname, "..", "..", "..", "tests", "fixtures", "golden.json"); + +const GOLDEN_CASES = JSON.parse(readFileSync(GOLDEN_PATH, "utf8")); + +beforeAll(() => { + // Install the panic hook so any Rust panic surfaces as a readable JS error. + init(); +}); + +/** Build a `Dataset` wasm object from a fixture case's `columns`. */ +function buildDataset(columns) { + const spec = {}; + for (const [name, col] of Object.entries(columns)) { + spec[name] = { kind: col.kind, values: col.values }; + } + return new Dataset(spec); +} + +/** Construct the test class for `name`, bound to `data`, with the case params. */ +function construct(name, data, params) { + const cls = TEST_CLASSES[name]; + if (name === "pearson_correlation" || name === "fisher_z") { + return new cls(data); + } + if (name === "pearson_equivalence") { + return new cls(data, { deltaThreshold: params.delta_threshold }); + } + // Discrete power-divergence family: configured by `yates`. + return new cls(data, { yates: params.yates }); +} + +/** + * Assert `actual` matches `expected` for `field` within TOL. Null/undefined + * expectations are skipped (the test does not define that field). Infinity / NaN + * are handled exactly (none appear in the current fixture, but be defensive). + */ +function assertClose(actual, expected, field, caseId) { + if (expected === null || expected === undefined) { + return; // Field not defined for this test; skip. + } + expect(actual, `${caseId}: expected ${field}=${expected}, got ${actual}`).not.toBeNull(); + expect(actual, `${caseId}: ${field} should be defined`).not.toBeUndefined(); + + const exp = Number(expected); + if (!Number.isFinite(exp)) { + // Infinite or NaN expectation: require an exactly-matching actual. + if (Number.isNaN(exp)) { + expect(Number.isNaN(actual), `${caseId}: ${field} expected NaN`).toBe(true); + } else { + expect(actual, `${caseId}: ${field} expected ${exp}`).toBe(exp); + } + return; + } + expect( + Math.abs(actual - exp), + `${caseId}: ${field} mismatch: got ${actual}, expected ${exp}`, + ).toBeLessThanOrEqual(TOL); +} + +describe("golden fixture parity", () => { + const counts = {}; + + test.each(GOLDEN_CASES.map((c, i) => [i, c]))( + "case[%i] %o", + (index, testCase) => { + const caseId = `case[${index}] ${testCase.test}`; + expect(TEST_CLASSES, `${caseId}: unknown test name`).toHaveProperty(testCase.test); + + const data = buildDataset(testCase.columns); + const test_ = construct(testCase.test, data, testCase.params); + const result = test_.runTest(testCase.x, testCase.y, testCase.z); + const expected = testCase.expected; + + assertClose(result.statistic, expected.statistic, "statistic", caseId); + + // p-value: handle exact-zero defensively, otherwise compare within TOL. + const expP = Number(expected.p_value); + if (expP === 0.0) { + expect( + Math.abs(result.pValue), + `${caseId}: pValue expected ~0, got ${result.pValue}`, + ).toBeLessThanOrEqual(TOL); + } else { + assertClose(result.pValue, expP, "pValue", caseId); + } + + assertClose( + result.dof, + expected.dof === null || expected.dof === undefined ? expected.dof : Number(expected.dof), + "dof", + caseId, + ); + + counts[testCase.test] = (counts[testCase.test] ?? 0) + 1; + }, + ); + + test("all fixture cases were exercised", () => { + expect(GOLDEN_CASES.length).toBe(EXPECTED_CASE_COUNT); + const names = new Set(GOLDEN_CASES.map((c) => c.test)); + expect(names).toEqual(new Set(Object.keys(TEST_CLASSES))); + }); +}); diff --git a/crates/ci-js/tests/package-lock.json b/crates/ci-js/tests/package-lock.json index 71bc8e9..56b389a 100644 --- a/crates/ci-js/tests/package-lock.json +++ b/crates/ci-js/tests/package-lock.json @@ -554,9 +554,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -571,9 +568,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -588,9 +582,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -605,9 +596,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -622,9 +610,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -639,9 +624,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -656,9 +638,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -673,9 +652,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -690,9 +666,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -707,9 +680,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -724,9 +694,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -741,9 +708,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +722,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1185,6 +1146,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1347,6 +1309,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/crates/ci-js/tests/package.json b/crates/ci-js/tests/package.json index 0d004bf..18cbfbe 100644 --- a/crates/ci-js/tests/package.json +++ b/crates/ci-js/tests/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "vitest" + "test": "vitest run" }, "keywords": [], "author": "", diff --git a/crates/ci-js/tests/test-chi_squared.test.js b/crates/ci-js/tests/test-chi_squared.test.js deleted file mode 100644 index b29cf26..0000000 --- a/crates/ci-js/tests/test-chi_squared.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import init, { chi_squared_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let precision = 1e-9; -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); - -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("chi_squared_test", () => { - test("independent data is not rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = chi_squared_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeLessThan(precision); - expect(p_value).toBeGreaterThanOrEqual(0.99); - expect(dof).toBe(1); - }); - - test("dependent data is rejected", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = chi_squared_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(Math.abs(statistic - 8.0)).toBeLessThan(precision); - expect(Math.abs(p_value - 0.004677734981047276)).toBeLessThan(1e-12); - expect(dof).toBe(1); - }); - - test("boolean mode returns true for independent data", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const result = chi_squared_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(true); - }); - - test("boolean mode returns false for dependent data", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const z = new Float64Array(0); - - const result = chi_squared_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(false); - }); -}); diff --git a/crates/ci-js/tests/test-cressie_read.test.js b/crates/ci-js/tests/test-cressie_read.test.js deleted file mode 100644 index 988d15c..0000000 --- a/crates/ci-js/tests/test-cressie_read.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import init, { cressie_read_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let precision = 1e-9; -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("cressie_read_test", () => { - test("unconditional independent data is not rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = cressie_read_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeLessThan(precision); - expect(p_value).toBeGreaterThanOrEqual(0.99); - expect(dof).toBe(1); - }); - - test("unconditional boolean accepts independent data", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const result = cressie_read_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(true); - }); - - test("unconditional dependent data is rejected", () => { - const x = new Float64Array([1, 1, 1, 1, 2, 2, 2, 2]); - const y = new Float64Array([1, 1, 1, 1, 2, 2, 2, 2]); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = cressie_read_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeGreaterThan(5.0); - expect(p_value).toBeLessThan(global_p); - expect(dof).toBe(1); - }); - - test("unconditional boolean rejects dependent data", () => { - const x = new Float64Array([1, 1, 1, 1, 2, 2, 2, 2]); - const y = new Float64Array([1, 1, 1, 1, 2, 2, 2, 2]); - const z = new Float64Array(0); - - const result = cressie_read_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(false); - }); - - test("conditional independent data is not rejected", () => { - const x = new Float64Array([1, 1, 2, 2, 1, 1, 2, 2]); - const y = new Float64Array([1, 2, 1, 2, 1, 2, 1, 2]); - const z = new Float64Array([0, 0, 0, 0, 1, 1, 1, 1]); // 8×1, row-major - - const [p_value, statistic, dof] = cressie_read_test( - z, - 8, - 1, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeLessThan(precision); - expect(p_value).toBeGreaterThan(0.99); - expect(dof).toBe(2); - }); - - test("conditional boolean accepts conditionally independent data", () => { - const x = new Float64Array([1, 1, 2, 2, 1, 1, 2, 2]); - const y = new Float64Array([1, 2, 1, 2, 1, 2, 1, 2]); - const z = new Float64Array([0, 0, 0, 0, 1, 1, 1, 1]); - - const result = cressie_read_test(z, 8, 1, x, y, true, global_p); - - expect(result).toBe(true); - }); - - test("conditional dependent data is rejected", () => { - const x = new Float64Array([1, 1, 2, 2, 1, 1, 2, 2]); - const y = new Float64Array([1, 1, 2, 2, 1, 1, 2, 2]); - const z = new Float64Array([0, 0, 0, 0, 1, 1, 1, 1]); - - const [p_value, statistic] = cressie_read_test( - z, - 8, - 1, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeGreaterThan(5.0); - expect(p_value).toBeLessThan(global_p); - }); -}); diff --git a/crates/ci-js/tests/test-error-handling.test.js b/crates/ci-js/tests/test-error-handling.test.js deleted file mode 100644 index bdae341..0000000 --- a/crates/ci-js/tests/test-error-handling.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import init, { - pearson_correlation_test, - chi_squared_test, - pearson_equivalence_test, -} from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -beforeAll(async () => { - await wasm.default.init(); -}); - -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("error handling", () => { - test("throws when z_flat length does not match z_rows * z_cols", () => { - const x = toFloat64(1, 2, 3, 4, 5); - const y = toFloat64(1, 2, 3, 4, 5); - // z_flat has 6 elements but z_rows * z_cols = 5 * 2 = 10 - const z_flat = toFloat64(1, 2, 3, 4, 5, 6); - - expect(() => - pearson_correlation_test(z_flat, 5, 2, x, y, false, 0.05), - ).toThrow(); - }); - - test("throws when x and y have different lengths", () => { - const x = toFloat64(1, 2, 3, 4, 5); - const y = toFloat64(1, 2, 3); - const z = new Float64Array(0); - - expect(() => - pearson_correlation_test(z, 0, 0, x, y, false, 0.05), - ).toThrow(); - }); - - test("throws when x is empty", () => { - const x = new Float64Array(0); - const y = new Float64Array(0); - const z = new Float64Array(0); - - expect(() => - pearson_correlation_test(z, 0, 0, x, y, false, 0.05), - ).toThrow(); - }); - - test("throws when z_flat length does not match for chi_squared", () => { - const x = toFloat64(1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2); - const z_flat = toFloat64(1, 2, 3, 4); - - expect(() => chi_squared_test(z_flat, 4, 2, x, y, false, 0.05)).toThrow(); - }); - - test("throws when z_flat length does not match for pearson_equivalence", () => { - const x = toFloat64(1, 2, 3, 4, 5); - const y = toFloat64(1, 2, 3, 4, 5); - const z_flat = toFloat64(1, 2, 3); - - expect(() => - pearson_equivalence_test(z_flat, 5, 1, x, y, false, 0.05, 0.1), - ).toThrow(); - }); - - test("does not throw for valid conditional test", () => { - const x = toFloat64(1, 2, 3, 4, 5); - const y = toFloat64(1, 2, 3, 4, 5); - const z_flat = toFloat64(1, 2, 3, 4, 6); - - expect(() => - pearson_correlation_test(z_flat, 5, 1, x, y, false, 0.05), - ).not.toThrow(); - }); - - test("does not throw for valid unconditional test", () => { - const x = toFloat64(1, 2, 3, 4, 5); - const y = toFloat64(1, 2, 3, 4, 5); - const z = new Float64Array(0); - - expect(() => - pearson_correlation_test(z, 0, 0, x, y, false, 0.05), - ).not.toThrow(); - }); -}); diff --git a/crates/ci-js/tests/test-freeman_tukey.test.js b/crates/ci-js/tests/test-freeman_tukey.test.js deleted file mode 100644 index 8cf5d81..0000000 --- a/crates/ci-js/tests/test-freeman_tukey.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import init, { freeman_tukey_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); - -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("freeman_tukey_test", () => { - test("unconditional independent data is not rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = freeman_tukey_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeLessThan(1e-9); - expect(p_value).toBeGreaterThanOrEqual(0.99); - expect(dof).toBe(1); - }); - - test("dependent data is rejected", () => { - const x = new Float64Array([1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]); - const y = new Float64Array([1, 1, 1, 1, 1, 2, 1, 2, 2, 2, 2, 2]); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = freeman_tukey_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeGreaterThan(5.0); - expect(p_value).toBeLessThan(global_p); - expect(dof).toBe(1); - }); - - test("boolean mode returns independent=TRUE for independent data", () => { - const x = new Float64Array([1, 1, 2, 2, 1, 1, 2, 2]); - const y = new Float64Array([1, 2, 1, 2, 1, 2, 1, 2]); - const z = new Float64Array(0); - - const result = freeman_tukey_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(true); - }); -}); diff --git a/crates/ci-js/tests/test-log_likelihood.test.js b/crates/ci-js/tests/test-log_likelihood.test.js deleted file mode 100644 index a2e128c..0000000 --- a/crates/ci-js/tests/test-log_likelihood.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import init, { log_likelihood_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let precision = 1e-9; -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("log_likelihood_test", () => { - test("independent data is not rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = log_likelihood_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeLessThan(precision); - expect(p_value).toBeGreaterThanOrEqual(0.99); - expect(dof).toBe(1); - }); - - test("dependent data is rejected", () => { - const x = toFloat64(1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 1, 2, 1, 2, 2, 2, 2, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = log_likelihood_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(Math.abs(statistic - 5.822063320647374)).toBeLessThan(precision); - expect(Math.abs(p_value - 0.015826368796540195)).toBeLessThan(1e-12); - expect(dof).toBe(1); - }); - - test("boolean mode returns false for dependent data", () => { - const x = toFloat64(1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 1, 2, 1, 2, 2, 2, 2, 2); - const z = new Float64Array(0); - - const result = log_likelihood_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(false); - }); -}); diff --git a/crates/ci-js/tests/test-modified_likelihood.test.js b/crates/ci-js/tests/test-modified_likelihood.test.js deleted file mode 100644 index 17f92d0..0000000 --- a/crates/ci-js/tests/test-modified_likelihood.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import init, { modified_likelihood_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let precision = 1e-9; -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); - -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("modified_likelihood_test", () => { - test("independent data is not rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = modified_likelihood_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(statistic).toBeLessThan(precision); - expect(p_value).toBeGreaterThanOrEqual(0.99); - expect(dof).toBe(1); - }); - - test("dependent data is rejected", () => { - const x = toFloat64(1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 1, 2, 1, 2, 2, 2, 2, 2); - const z = new Float64Array(0); - - const [p_value, statistic, dof] = modified_likelihood_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(Math.abs(statistic - 7.053439978825427)).toBeLessThan(precision); - expect(Math.abs(p_value - 0.007911317670556329)).toBeLessThan(1e-12); - expect(dof).toBe(1); - }); - - test("boolean mode returns false for dependent data", () => { - const x = toFloat64(1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 1, 2, 1, 2, 2, 2, 2, 2); - const z = new Float64Array(0); - - const result = modified_likelihood_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(false); - }); -}); diff --git a/crates/ci-js/tests/test-pearson_correlation.test.js b/crates/ci-js/tests/test-pearson_correlation.test.js deleted file mode 100644 index c493441..0000000 --- a/crates/ci-js/tests/test-pearson_correlation.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import init, { pearson_correlation_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let precision = 1e-9; -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); - -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("pearson_correlation_test", () => { - test("independent data is not rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, coefficient] = pearson_correlation_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(Math.abs(coefficient)).toBeLessThan(precision); - expect(p_value).toBeGreaterThanOrEqual(0.99); - }); - - test("dependent data is rejected", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const z = new Float64Array(0); - - const [p_value, coefficient] = pearson_correlation_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(Math.abs(coefficient - 1.0)).toBeLessThan(precision); - expect(p_value).toBeLessThan(global_p); - }); - - test("negatively correlated data is rejected", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(2, 2, 2, 2, 1, 1, 1, 1); - const z = new Float64Array(0); - - const [p_value, coefficient] = pearson_correlation_test( - z, - 0, - 0, - x, - y, - false, - global_p, - ); - - expect(Math.abs(coefficient + 1.0)).toBeLessThan(precision); - expect(p_value).toBeLessThan(global_p); - }); - - test("boolean mode returns true for independent data", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const result = pearson_correlation_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(true); - }); - - test("boolean mode returns false for dependent data", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const z = new Float64Array(0); - - const result = pearson_correlation_test(z, 0, 0, x, y, true, global_p); - - expect(result).toBe(false); - }); - - test("conditional boolean accepts conditionally independent data", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array([0, 0, 0, 0, 1, 1, 1, 1]); - - const result = pearson_correlation_test(z, 8, 1, x, y, true, global_p); - - expect(result).toBe(true); - }); -}); diff --git a/crates/ci-js/tests/test-pearson_equivalence.test.js b/crates/ci-js/tests/test-pearson_equivalence.test.js deleted file mode 100644 index 0dac15a..0000000 --- a/crates/ci-js/tests/test-pearson_equivalence.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import init, { pearson_equivalence_test } from "../pkg/ci_js.js"; -import { beforeAll, describe, test, expect } from "vitest"; - -const wasm = await import("../pkg/ci_js.js"); - -let global_p = 0.05; - -beforeAll(async () => { - await wasm.default.init(); -}); - -const toFloat64 = (...vals) => new Float64Array(vals); - -describe("pearson_equivalence_tests", () => { - test("independent_data_is_not_rejected", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const [p_value, coefficient] = pearson_equivalence_test( - z, - 0, - 0, - x, - y, - false, - 0.05, - 0.8, - ); - - expect(p_value).toBeLessThanOrEqual(0.05); - expect(Math.abs(coefficient)).toBeLessThan(0.1); - }); - - test("dependent_data_is_rejected", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(3, 3, 3, 3, 6, 6, 6, 6); - const z = new Float64Array(0); - - const [p_value, coefficient] = pearson_equivalence_test( - z, - 0, - 0, - x, - y, - false, - 0.05, - 0.1, - ); - - expect(p_value).toBeGreaterThanOrEqual(0.05); - expect(Math.abs(coefficient)).toBeGreaterThan(0.9); - }); - - test("boolean mode returns true for independent data", () => { - const x = toFloat64(1, 1, 2, 2, 1, 1, 2, 2); - const y = toFloat64(1, 2, 1, 2, 1, 2, 1, 2); - const z = new Float64Array(0); - - const result = pearson_equivalence_test(z, 0, 0, x, y, true, 0.05, 0.8); - - expect(result).toBe(true); - }); - - test("boolean mode returns false for dependent data", () => { - const x = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const y = toFloat64(1, 1, 1, 1, 2, 2, 2, 2); - const z = new Float64Array(0); - - const result = pearson_equivalence_test(z, 0, 0, x, y, true, 0.05, 0.1); - - expect(result).toBe(false); - }); -}); diff --git a/crates/ci-python/Cargo.toml b/crates/ci-python/Cargo.toml index bce25eb..fc4269d 100644 --- a/crates/ci-python/Cargo.toml +++ b/crates/ci-python/Cargo.toml @@ -9,11 +9,8 @@ repository = "" [dependencies] ci_core = { path = "../ci-core" } -ndarray = { workspace = true } numpy = { version = "0.24" } -pyo3 = { version = "0.24", features = ["extension-module"] } -pyo3-stub-gen = "0.9" -pyo3-stub-gen-derive = "0.9" +pyo3 = { version = "0.24", features = ["extension-module", "abi3-py310"] } [lints] workspace = true @@ -21,8 +18,3 @@ workspace = true [lib] name = "ci_python" crate-type = ["cdylib", "rlib"] - -[build-dependencies] -proc-macro2 = "1.0.106" -quote = "1.0.45" -syn = { version = "2.0.117", features = ["full", "visit"] } diff --git a/crates/ci-python/README.md b/crates/ci-python/README.md index 70f7cfd..7b6bafd 100644 --- a/crates/ci-python/README.md +++ b/crates/ci-python/README.md @@ -1,20 +1,27 @@ # ci-python -Python bindings for the [Conditional Independence Testing](../../README.md) library. Wraps the Rust core via [PyO3](https://pyo3.rs) and accepts NumPy arrays directly. +Python bindings for the [Conditional Independence Testing](../../README.md) library. +Wraps the data-bound Rust core via [PyO3](https://pyo3.rs). + +The API is **data-bound**: build a `Dataset` once from named, typed columns, then +construct any test bound to that data and query it with `run_test` / +`is_independent`. Each test declares its own independence rule, so the caller never +writes the `p >= alpha` (vs. `p < alpha`) logic. ## Available tests -| Class | Data type | Numeric output | -|---|---|---| -| `ChiSquared` | Discrete | `(p_value, statistic, dof)` | -| `CressieRead` | Discrete | `(p_value, statistic, dof)` | -| `FreemanTukey` | Discrete | `(p_value, statistic, dof)` | -| `LogLikelihood` | Discrete | `(p_value, statistic, dof)` | -| `ModifiedLikelihood` | Discrete | `(p_value, statistic, dof)` | -| `PearsonCorrelation` | Continuous | `(p_value, coefficient)` | -| `PearsonEquivalence` | Continuous | `(p_value, coefficient)` | +| Class | Data type | Constructor config | `dof` | +|---|---|---|---| +| `ChiSquared` | Discrete | `yates=True` | int | +| `LogLikelihood` | Discrete | `yates=True` | int | +| `CressieRead` | Discrete | `yates=True` | int | +| `FreemanTukey` | Discrete | `yates=True` | int | +| `ModifiedLikelihood` | Discrete | `yates=True` | int | +| `PearsonCorrelation` | Continuous | — | `None` | +| `PearsonEquivalence` | Continuous | `delta_threshold=0.1` | `None` | -All tests support an optional conditioning matrix Z. Pass an empty matrix for unconditional tests. +`run_test` returns a `CiResult` with attributes `statistic` (float | None), +`p_value` (float), `dof` (int | None), and `effect_size` (float | None). ## Requirements @@ -32,76 +39,65 @@ pip install maturin maturin develop -m crates/ci-python/Cargo.toml ``` -## Usage - -### Numeric mode +> On Python 3.14 with PyO3 0.24, build with +> `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` set (the crate already requests the +> `abi3-py310` feature). -Numeric mode returns the raw test statistic alongside the p-value. Construct a test with `boolean=False`: +## Usage ```python import numpy as np -from ci_python import ChiSquared - -test = ChiSquared(boolean=False, significance_level=0.05) - -x = np.array([0.0, 1.0, 0.0, 1.0, 0.0], dtype=np.float64) -y = np.array([1.0, 0.0, 1.0, 0.0, 1.0], dtype=np.float64) -z = np.empty((len(x), 0), dtype=np.float64) # unconditional - -p_value, statistic, dof = test.run_test(x, y, z) -print(f"p={p_value:.4f}, chi2={statistic:.4f}, df={dof}") -``` - -For continuous data, the return type is `(p_value, coefficient)` rather than a triple: - -```python -from ci_python import PearsonCorrelation - -test = PearsonCorrelation(boolean=False, significance_level=0.05) -p_value, coefficient = test.run_test(x, y, z) +from ci_python import Dataset, ChiSquared, PearsonEquivalence + +data = Dataset({ + "A": ("discrete", np.array([0, 1, 0, 1, 0, 1, 1, 0], dtype=float)), + "B": ("discrete", np.array([1, 1, 0, 0, 1, 0, 1, 0], dtype=float)), + "X": ("continuous", np.random.default_rng(0).standard_normal(8)), +}) + +# Discrete: chi-squared with Yates' correction (the default). +chi = ChiSquared(data) # yates=True +res = chi.run_test("A", "B", ["X"]) # A ⟂ B | X +res.statistic, res.p_value, res.dof, res.effect_size +chi.is_independent("A", "B", ["X"], significance_level=0.05) # -> bool (p >= alpha) + +# Continuous equivalence (TOST): extra arg lives in the constructor. +eqv = PearsonEquivalence(data, delta_threshold=0.1) +res = eqv.run_test("X", "A") # res.dof is None +eqv.is_independent("X", "A", significance_level=0.05) # -> bool (p < alpha) ``` -### Boolean mode +- `x` and `y` accept a column **name** (`str`) or an integer **index**; `z` is a + sequence of names/indices (default: empty conditioning set). +- A test constructor accepts either a `Dataset` or a raw + `{name: (kind, values)}` mapping. +- Core errors (degenerate data, wrong column kind, …) raise `ci_python.CiError`; + unknown columns / out-of-range indices raise `ValueError`. -Boolean mode returns a single `bool`: `True` if the null hypothesis of independence is not rejected, `False` if it is rejected. Construct the test with `boolean=True`: +### From a pandas DataFrame -```python -from ci_python import CressieRead - -test = CressieRead(boolean=True, significance_level=0.05) -independent: bool = test.run_test(x, y, z) -``` - -### Conditional tests - -Pass a conditioning matrix Z where each column is one conditioning variable. The matrix must have the same number of rows as x and y: +`Dataset.from_pandas` infers kinds from dtypes (integer / bool / categorical → +discrete, float → continuous). `pandas` is imported lazily and is not a hard +dependency of the package. ```python -import numpy as np -from ci_python import ChiSquared +import pandas as pd +from ci_python import Dataset, ChiSquared -x = np.array([1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0], dtype=np.float64) -y = np.array([1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0], dtype=np.float64) -z = np.array([[0.0], [0.0], [0.0], [0.0], [1.0], [1.0], [1.0], [1.0]], dtype=np.float64) - -test = ChiSquared(boolean=False, significance_level=0.05) -p_value, statistic, dof = test.run_test(x, y, z) -``` - -To condition on multiple variables, stack them as columns: - -```python -z = np.column_stack([z1, z2]) # shape (n, 2) -test.run_test(x, y, z) +df = pd.DataFrame({"A": [0, 1, 0, 1], "B": [1, 1, 0, 0]}) +ChiSquared(Dataset.from_pandas(df)).run_test("A", "B") ``` ## Running tests ```bash pip install -e "crates/ci-python[test]" -pytest crates/ci-python +pytest crates/ci-python/test ``` +The golden test (`test/test_golden.py`) checks numeric parity against the shared +scipy/pgmpy fixture (`tests/fixtures/golden.json`). + ## License Licensed under the [MIT license](../../LICENSE). diff --git a/crates/ci-python/build.rs b/crates/ci-python/build.rs deleted file mode 100644 index b97431e..0000000 --- a/crates/ci-python/build.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! Build script that automatically generates `PyO3` bindings for `ci-core`'s CI tests. -//! -//! At compile time this script recursively scans the `../ci-core/src` directory and -//! parses every `.rs` file to gather every struct that implements the `CITest` -//! trait and their definitions. -//! -//! Currently, the generated code imports all tests from `::ci_core::ci_tests`; -//! therefore, the compilation of `OUT_DIR/ci_tests.rs` fails with an import error -//! if there are CI tests located somewhere else. -//! -//! Only structs with named fields are supported; unnamed (tuple) structs cause -//! a panic. A `CITest` impl whose struct definition cannot be found in -//! `ci-core/src` is also treated as a hard error. -//! -//! Two files are written into `OUT_DIR` for later `include!`ing: -//! - `ci_tests.rs` – the generated `Py` classes and their methods -//! - `ci_tests_init.rs` – an `init` function that registers every generated -//! class with a `PyModule`. -//! -//! `lib.rs` `include!`s both `ci_tests.rs` and calls the `init` function so that -//! both `PyO3` *AND* `pyo3_stub_gen` properly register the tests. -//! -//! -//! ////////// Program Flow ////////// -//! -//! `main` calls `parse_dir` on `../ci_core/src` with a new `CITestCollector`. -//! -//! `parse_dir` recursively iterates over all files in `../ci_core/src`. If the -//! file is a `.rs` file, its abstract syntax tree (AST) is visited by the -//! `CITestCollector`. `cargo::rerun-if-changed` is emitted for every parsed -//! source file so the bindings are regenerated whenever the underlying Rust -//! sources change. -//! -//! `CITestCollector` uses the visitor pattern to traverse the AST and collect -//! (a) the names of all structs implementing `CITest` (`citest_structs`) and -//! (b) all struct definitions (`struct_defs`). -//! -//! `main` then iterates over all collected `CITest` structs and calls -//! `generate_pyo3_wrapper` with each struct's definition to generate the bindings. -//! -//! `generate_pyo3_wrapper` emits a `Py` wrapper class (e.g. -//! `PyChiSquared`) via `quote!`. The wrapper holds the original type in -//! an `inner` field and exposes the following: -//! - `#[new]` constructor mirroring the struct's named fields -//! - `#[getter]`/`#[setter]` pair for each field -//! - `run_test` method that converts `NumPy` arrays to owned `ndarray`s, -//! passes them to `inner.run_test`, and maps results/errors back to Python. -//! -//! `main` then collects and saves all results from `generate_pyo3_wrapper` to -//! `ci_tests.rs`. It then generates the `init` function and saves it to -//! `ci_tests_init.rs`. -//! -//! -//! ////////// Example: Generated Bindings for `ChiSquared` ////////// -//! -//! ```rust -//! #[gen_stub_pyclass] -//! #[pyclass(name = "ChiSquared", module = "ci_python._ci_python")] -//! pub struct PyChiSquared { -//! inner: ::ci_core::ci_tests::ChiSquared, -//! } -//! -//! #[gen_stub_pymethods] -//! #[pymethods] -//! impl PyChiSquared { -//! #[new] -//! pub fn new(boolean: bool, significance_level: f64) -> Self { -//! Self { -//! inner: ::ci_core::ci_tests::ChiSquared { -//! boolean, -//! significance_level, -//! }, -//! } -//! } -//! #[allow(clippy::needless_pass_by_value)] -//! fn run_test( -//! &self, -//! py: Python<'_>, -//! x_values: PyReadonlyArray1<'_, f64>, -//! y_values: PyReadonlyArray1<'_, f64>, -//! z: PyReadonlyArray2<'_, f64>, -//! ) -> PyResult> { -//! test_result_to_pyobj( -//! &self -//! .inner -//! .run_test( -//! x_values.as_array().to_owned(), -//! y_values.as_array().to_owned(), -//! z.as_array().to_owned(), -//! ) -//! .map_err(|e| PyErr::new::(e.to_string()))?, -//! py, -//! ) -//! } -//! #[getter] -//! pub fn boolean(&self) -> bool { -//! #[allow(clippy::clone_on_copy)] -//! self.inner.boolean.clone() -//! } -//! #[setter] -//! pub fn set_boolean(&mut self, boolean: bool) { -//! self.inner.boolean = boolean; -//! } -//! #[getter] -//! pub fn significance_level(&self) -> f64 { -//! #[allow(clippy::clone_on_copy)] -//! self.inner.significance_level.clone() -//! } -//! #[setter] -//! pub fn set_significance_level(&mut self, significance_level: f64) { -//! self.inner.significance_level = significance_level; -//! } -//! } -//! ``` -//! -//! ////////// Example: Generated `ci_tests_init.rs` File ////////// -//! -//! ```rust -//! use pyo3::prelude::*; -//! pub fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { -//! m.add_class::()?; -//! m.add_class::()?; -//! ... -//! -//! Ok(()) -//! } -//! ``` - -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use std::{ - collections::HashMap, - env, fs, - path::{Path, PathBuf}, -}; -use syn::{ - visit::{self, Visit}, - Fields, File, ItemImpl, ItemStruct, Type, TypePath, -}; - -/// Visitor that traverses the AST and collects all structs implementing `CITest` (`citest_structs`) -/// all struct definitions (`struct_defs`). -struct CITestCollector { - citest_structs: Vec, - struct_defs: HashMap, -} - -impl<'ast> Visit<'ast> for CITestCollector { - /// Collect all structs implementing `CITest`. - fn visit_item_impl(&mut self, node: &'ast ItemImpl) { - if let Some((None, trait_path, _)) = &node.trait_ { - let is_citest = trait_path - .segments - .last() - .is_some_and(|s| s.ident == "CITest"); - - if is_citest { - if let Type::Path(TypePath { path, .. }) = node.self_ty.as_ref() { - if let Some(seg) = path.segments.last() { - self.citest_structs.push(seg.ident.to_string()); - } - } - } - } - visit::visit_item_impl(self, node); - } - - /// Collect all struct definitions. - fn visit_item_struct(&mut self, node: &'ast ItemStruct) { - self.struct_defs - .insert(node.ident.to_string(), node.clone()); - visit::visit_item_struct(self, node); - } -} - -/// Run the `CITestCollector` recursively on the specified directory. -fn parse_dir(dir: &Path, collector: &mut CITestCollector) { - for entry in - fs::read_dir(dir).unwrap_or_else(|e| panic!("Failed to read {}: {}", dir.display(), e)) - { - let path = entry - .unwrap_or_else(|e| panic!("Failed to read {}: {}", dir.display(), e)) - .path(); - if path.is_dir() { - parse_dir(&path, collector); - } else if path.extension().is_some_and(|e| e == "rs") { - let src = fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("Failed to read {}: {}", path.display(), e)); - let file: File = syn::parse_file(&src) - .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path.display(), e)); - visit::visit_file(collector, &file); - println!("cargo::rerun-if-changed={}", path.display()); - } - } -} - -/// Generate the `TokenStream` for the specified struct `s`. -fn generate_pyo3_wrapper(s: &ItemStruct) -> TokenStream { - let struct_name = &s.ident.to_string(); - let struct_ident = format_ident!("{}", struct_name); - let py_ident = format_ident!("Py{}", struct_name); - let py_name = syn::LitStr::new(struct_name, proc_macro2::Span::call_site()); - - let Fields::Named(named) = &s.fields else { - panic!( - "Encountered unnamed field when processing `{struct_name}`. Unnamed fields aren't supported (yet).", - ) - }; - - let field_names: Vec<_> = named - .named - .iter() - .map(|f| f.ident.as_ref().expect("Named fields always have idents.")) - .collect(); - let field_types = named.named.iter().map(|f| &f.ty); - - let getters_setters = named.named.iter().map(|f| { - let fname = f.ident.as_ref().expect("Named fields always have idents."); - let ftype = &f.ty; - let setter_ident = format_ident!("set_{}", fname); - quote! { - #[getter] - pub fn #fname(&self) -> #ftype { - #[allow(clippy::clone_on_copy)] // The object does not necessarily implement `Copy` and this is easier than case distinction. - self.inner.#fname.clone() - } - #[setter] - pub fn #setter_ident(&mut self, #fname: #ftype) { - self.inner.#fname = #fname; - } - } - }); - - let constructor_args = field_names.iter().zip(field_types).map(|(n, t)| { - quote! { #n: #t } - }); - let constructor_init = field_names.iter().map(|n| quote! { #n }); - - quote! { - #[gen_stub_pyclass] - #[pyclass(name = #py_name, module = "ci_python._ci_python")] - pub struct #py_ident { - inner: ::ci_core::ci_tests::#struct_ident, - } - - #[gen_stub_pymethods] - #[pymethods] - impl #py_ident { - #[new] - pub fn new(#(#constructor_args),*) -> Self { - Self { - inner: ::ci_core::ci_tests::#struct_ident { #(#constructor_init),* }, - } - } - - // Raises e.g. the following if passed by reference: - // the trait `pyo3::impl_::extract_argument::PyFunctionArgument<'_, '_, '_, _>` is not implemented for `&numpy::PyReadonlyArray<'_, f64, numpy::ndarray::Dim<[usize; 2]>>` - #[allow(clippy::needless_pass_by_value)] - fn run_test( - &self, - py: Python<'_>, - x_values: PyReadonlyArray1<'_, f64>, - y_values: PyReadonlyArray1<'_, f64>, - z: PyReadonlyArray2<'_, f64>, - ) -> PyResult> { - test_result_to_pyobj( - &self.inner - .run_test( - x_values.as_array().to_owned(), - y_values.as_array().to_owned(), - z.as_array().to_owned(), - ) - .map_err(|e| { - PyErr::new::(e.to_string()) - })?, - py - ) - } - - #(#getters_setters)* - } - } -} - -fn main() { - let out_dir = PathBuf::from( - env::var("OUT_DIR").unwrap_or_else(|e| panic!("Couldn't find output directory: {e}")), - ); - - // Generate ci_tests.rs. - let manifest_dir = PathBuf::from( - env::var("CARGO_MANIFEST_DIR") - .unwrap_or_else(|e| panic!("Couldn't find directory of crate: {e}")), - ); - let ci_core_src = manifest_dir.join("../ci-core/src"); - assert!( - ci_core_src.exists(), - "`ci-core/src` not found at {}", - ci_core_src.display() - ); - - let mut collector = CITestCollector { - citest_structs: Vec::new(), - struct_defs: HashMap::new(), - }; - parse_dir(&ci_core_src, &mut collector); - - let mut tokens = TokenStream::new(); - - for name in &collector.citest_structs { - if let Some(def) = collector.struct_defs.get(name) { - tokens.extend(generate_pyo3_wrapper(def)); - } else { - panic!("Struct `{name}` implements `CITest` but its definition was not found in `ci-core/src`."); - } - } - - fs::write(out_dir.join("ci_tests.rs"), tokens.to_string()) - .unwrap_or_else(|e| panic!("Couldn't save `ci_tests.rs`: {e}")); - - // Generate ci_tests_init.rs. - let mut tokens_init = TokenStream::new(); - let py_class_idents: Vec<_> = collector - .citest_structs - .iter() - .map(|n| format_ident!("Py{}", n)) - .collect(); - - tokens_init.extend(quote! { - use pyo3::prelude::*; - - pub fn init( - m: &Bound<'_, PyModule>, - ) -> PyResult<()> { - #(m.add_class::()?;)* - Ok(()) - } - }); - fs::write(out_dir.join("ci_tests_init.rs"), tokens_init.to_string()) - .unwrap_or_else(|e| panic!("Couldn't save `ci_tests_init.rs`: {e}")); -} diff --git a/crates/ci-python/ci_python/__init__.py b/crates/ci-python/ci_python/__init__.py index f852af2..3785708 100644 --- a/crates/ci-python/ci_python/__init__.py +++ b/crates/ci-python/ci_python/__init__.py @@ -1,13 +1,157 @@ -# This file is automatically generated by pyo3_stub_gen -# ruff: noqa: F401 +"""Data-bound conditional-independence testing. + +Build a :class:`Dataset` once from named, typed columns, then construct any of +the eight tests bound to that data and query ``run_test`` / ``is_independent``:: + + import numpy as np + from ci_python import Dataset, ChiSquared, PearsonEquivalence + + data = Dataset({ + "A": ("discrete", np.array([0, 1, 0, 1], dtype=float)), + "B": ("discrete", np.array([1, 1, 0, 0], dtype=float)), + }) + res = ChiSquared(data).run_test("A", "B") + res.statistic, res.p_value, res.dof, res.effect_size + +The per-test classes are data-bound: they take the :class:`Dataset` (or a raw +``{name: (kind, values)}`` mapping or a :class:`pandas.DataFrame`) plus their +own configuration in the constructor. ``x`` / ``y`` accept a column name or +integer index; ``z`` is a sequence of names/indices. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ci_python._ci_python import ( + ChiSquared, + CiError, + CiResult, + CressieRead, + Dataset as _Dataset, + FisherZ, + FreemanTukey, + LogLikelihood, + ModifiedLikelihood, + PearsonCorrelation, + PearsonEquivalence, +) + +if TYPE_CHECKING: # pragma: no cover - typing-only import + import pandas as pd -from ci_python._ci_python import ChiSquared, CressieRead, FreemanTukey, LogLikelihood, ModifiedLikelihood, PearsonCorrelation, PearsonEquivalence __all__ = [ "ChiSquared", + "CiError", + "CiResult", "CressieRead", + "Dataset", + "FisherZ", "FreemanTukey", "LogLikelihood", "ModifiedLikelihood", "PearsonCorrelation", "PearsonEquivalence", ] + + +def _is_numpy_numeric(dtype: Any) -> bool: # noqa: ANN401 + """Return True iff *dtype* is a numpy numeric (number or bool) dtype. + + Delegates to :func:`_safe_issubdtype`, which returns ``False`` (rather than + raising ``TypeError``) for pandas extension dtypes such as ``StringDtype`` + or ``ArrowDtype`` that numpy cannot interpret. + """ + import numpy as np + + return _safe_issubdtype(dtype, np.number) or _safe_issubdtype(dtype, np.bool_) + + +def _safe_issubdtype(dtype: Any, base: Any) -> bool: # noqa: ANN401 + """`np.issubdtype` that returns False (instead of raising) for pandas + extension dtypes it cannot interpret.""" + import numpy as np + + try: + return bool(np.issubdtype(dtype, base)) + except TypeError: + return False + + +def _infer_kind(dtype: Any) -> str: # noqa: ANN401 - numpy/pandas dtype is opaque here + """Infer a column kind from a numpy/pandas dtype. + + Integer, boolean, categorical, object and string dtypes map to + ``"discrete"``; floating dtypes map to ``"continuous"``. Anything else + raises ``TypeError``. + """ + import numpy as np + + name = str(getattr(dtype, "name", dtype)) + if name in ("category", "string", "str"): + return "discrete" + if dtype == np.object_ or _safe_issubdtype(dtype, np.str_): + return "discrete" + # Temporal dtypes are neither categorical nor plain-numeric; reject them + # explicitly (timedelta64 would otherwise pass the integer check below). + if _safe_issubdtype(dtype, np.datetime64) or _safe_issubdtype(dtype, np.timedelta64): + msg = f"cannot infer column kind for temporal dtype {dtype!r}; convert it explicitly first" + raise TypeError(msg) + if _safe_issubdtype(dtype, np.floating): + return "continuous" + if _safe_issubdtype(dtype, np.integer) or _safe_issubdtype(dtype, np.bool_): + return "discrete" + msg = f"cannot infer column kind for dtype {dtype!r}; pass an explicit mapping" + raise TypeError(msg) + + +class Dataset(_Dataset): + """A named, typed, immutable table shared by the test classes. + + See the native :class:`ci_python._ci_python.Dataset` for the primary + constructor (a ``{name: (kind, values)}`` mapping). This subclass adds the + pure-Python :meth:`from_pandas` convenience. + """ + + __slots__ = () + + @classmethod + def from_pandas(cls, df: pd.DataFrame) -> Dataset: + """Build a :class:`Dataset` from a :class:`pandas.DataFrame`. + + Column kinds are inferred from dtypes: integer / boolean / categorical + / object / string columns become ``"discrete"`` and floating columns + become ``"continuous"``. Categorical and object/string columns are + factorized to integer codes; **missing values are mapped to NaN and + rejected by the core** (strict missing-data policy — drop or impute + first). ``pandas`` is imported lazily, so it is not a hard dependency + of this package. + + .. note:: + Passing a :class:`pandas.DataFrame` directly to any test + constructor (e.g. ``ChiSquared(df)``) is equivalent to + ``ChiSquared(Dataset.from_pandas(df))`` — a fresh :class:`Dataset` + is built per constructor call; to share data across tests, build + a :class:`Dataset` explicitly. + """ + import numpy as np + import pandas as pd + + columns: dict[str, tuple[str, Any]] = {} + for name in df.columns: + series = df[name] + kind = _infer_kind(series.dtype) + dtype_name = str(getattr(series.dtype, "name", "")) + if dtype_name == "category": + codes = np.asarray(series.cat.codes, dtype=np.float64) + codes[codes == -1.0] = np.nan # missing category -> NaN -> core error + values = codes + elif kind == "discrete" and not _is_numpy_numeric(series.dtype): + raw, _ = pd.factorize(series) # -1 marks missing + codes = raw.astype(np.float64) + codes[codes == -1.0] = np.nan + values = codes + else: + values = np.asarray(series, dtype=np.float64) + columns[str(name)] = (kind, values) + return cls(columns) diff --git a/crates/ci-python/ci_python/_ci_python/__init__.pyi b/crates/ci-python/ci_python/_ci_python/__init__.pyi index 6a20085..db24450 100644 --- a/crates/ci-python/ci_python/_ci_python/__init__.pyi +++ b/crates/ci-python/ci_python/_ci_python/__init__.pyi @@ -1,112 +1,76 @@ -# This file is automatically generated by pyo3_stub_gen -# ruff: noqa: E501, F401, F403, F405 +# Type stubs for the native `ci_python._ci_python` extension module. +# Hand-maintained to match `crates/ci-python/src/lib.rs`. +# ruff: noqa: D101, D102, D107, PYI021, ANN401 -import builtins -import numpy -import numpy.typing -import typing -__all__ = [ - "ChiSquared", - "CressieRead", - "FreemanTukey", - "LogLikelihood", - "ModifiedLikelihood", - "PearsonCorrelation", - "PearsonEquivalence", -] +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any -@typing.final -class ChiSquared: - @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... - @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float) -> ChiSquared: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... +if TYPE_CHECKING: + import pandas as pd -@typing.final -class CressieRead: - @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... - @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float) -> CressieRead: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... +ColumnRef = str | int +ColumnSpec = tuple[str, Any] -@typing.final -class FreemanTukey: - @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... - @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float) -> FreemanTukey: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... +class CiError(Exception): ... -@typing.final -class LogLikelihood: +class CiResult: @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... + def statistic(self) -> float | None: ... @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float) -> LogLikelihood: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... - -@typing.final -class ModifiedLikelihood: + def p_value(self) -> float: ... @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... + def dof(self) -> int | None: ... @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float) -> ModifiedLikelihood: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... + def effect_size(self) -> float | None: ... -@typing.final -class PearsonCorrelation: +class Dataset: + def __init__(self, columns: dict[str, ColumnSpec]) -> None: ... @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... + def n_rows(self) -> int: ... @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float) -> PearsonCorrelation: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... + def n_cols(self) -> int: ... + def index_of(self, name: str) -> int: ... -@typing.final -class PearsonEquivalence: - @property - def boolean(self) -> builtins.bool: ... - @boolean.setter - def boolean(self, value: builtins.bool) -> None: ... - @property - def significance_level(self) -> builtins.float: ... - @significance_level.setter - def significance_level(self, value: builtins.float) -> None: ... - @property - def delta_threshold(self) -> builtins.float: ... - @delta_threshold.setter - def delta_threshold(self, value: builtins.float) -> None: ... - def __new__(cls, boolean: builtins.bool, significance_level: builtins.float, delta_threshold: builtins.float) -> PearsonEquivalence: ... - def run_test(self, x_values: numpy.typing.NDArray[numpy.float64], y_values: numpy.typing.NDArray[numpy.float64], z: numpy.typing.NDArray[numpy.float64]) -> typing.Any: ... +class _BaseTest: + def run_test( + self, + x: ColumnRef, + y: ColumnRef, + z: Sequence[ColumnRef] | None = ..., + ) -> CiResult: ... + def is_independent( + self, + x: ColumnRef, + y: ColumnRef, + z: Sequence[ColumnRef] | None = ..., + significance_level: float = ..., + ) -> bool: ... + def meta(self) -> dict[str, Any]: ... + +class ChiSquared(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame, yates: bool = ...) -> None: ... + +class LogLikelihood(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame, yates: bool = ...) -> None: ... + +class CressieRead(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame, yates: bool = ...) -> None: ... + +class FisherZ(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame) -> None: ... + +class FreemanTukey(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame, yates: bool = ...) -> None: ... + +class ModifiedLikelihood(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame, yates: bool = ...) -> None: ... + +class PearsonCorrelation(_BaseTest): + def __init__(self, data: Dataset | dict[str, ColumnSpec] | pd.DataFrame) -> None: ... +class PearsonEquivalence(_BaseTest): + def __init__( + self, + data: Dataset | dict[str, ColumnSpec] | pd.DataFrame, + delta_threshold: float = ..., + ) -> None: ... diff --git a/crates/ci-python/pyproject.toml b/crates/ci-python/pyproject.toml index 322f9b8..b25c689 100644 --- a/crates/ci-python/pyproject.toml +++ b/crates/ci-python/pyproject.toml @@ -22,9 +22,6 @@ build-backend = "maturin" module-name = "ci_python._ci_python" python-source = "." -[tool.pyo3-stub-gen] -generate-init-py = true - [project.optional-dependencies] test = [ "pytest", @@ -36,10 +33,9 @@ testpaths = ["test"] [tool.ruff] line-length = 120 -# Exclude generated files. +# Exclude the generated native stub package. extend-exclude = [ "ci_python/_ci_python", - "ci_python/__init__.py", ] [tool.ruff.lint] diff --git a/crates/ci-python/src/bin/stub_gen.rs b/crates/ci-python/src/bin/stub_gen.rs deleted file mode 100644 index 319e76d..0000000 --- a/crates/ci-python/src/bin/stub_gen.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Run this using `cargo run --bin stub_gen` to generate the stub files. - -use ci_python::stub_info; -use pyo3_stub_gen::Result; - -fn main() -> Result<()> { - let stub = stub_info()?; - stub.generate()?; - Ok(()) -} diff --git a/crates/ci-python/src/ci_tests_init.rs b/crates/ci-python/src/ci_tests_init.rs deleted file mode 100644 index bea6d4f..0000000 --- a/crates/ci-python/src/ci_tests_init.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Wrap the generated `ci_tests_init.rs` file so that the `init` function can -//! be included and called properly by `lib.rs`. - -include!(concat!(env!("OUT_DIR"), "/ci_tests_init.rs")); diff --git a/crates/ci-python/src/lib.rs b/crates/ci-python/src/lib.rs index f608e9a..1031161 100644 --- a/crates/ci-python/src/lib.rs +++ b/crates/ci-python/src/lib.rs @@ -1,30 +1,504 @@ -//! Python bindings for the conditional independence testing library. +//! Python bindings for the data-bound conditional-independence testing core. //! -//! Exposes CI test functions to Python via the `pyo3` framework. -//! Each CI test's `run_test` method accepts paired observation vectors and a conditioning -//! matrix, returning a Python object whose shape depends on whether the test runs in -//! boolean or numeric mode. +//! The surface mirrors `ci_core`'s redesigned contract: a [`Dataset`] is built +//! once from named, typed columns, and each test is a *data-bound* class that +//! takes the dataset (plus its own configuration) in its constructor and then +//! answers `run_test(x, y, z)` / `is_independent(...)` queries. +//! +//! Column references (`x`, `y`, and the entries of `z`) accept either a column +//! name (`str`) or an integer index, resolved against the bound dataset via +//! [`Dataset::index_of`]. Core [`ci_core::error::CiError`]s surface as the +//! Python [`CiError`] exception; no Rust panic is allowed to escape. +//! +//! # Exposed tests +//! +//! Discrete: [`ChiSquared`], [`LogLikelihood`], [`CressieRead`], +//! [`FreemanTukey`], [`ModifiedLikelihood`]. +//! Continuous: [`PearsonCorrelation`], [`FisherZ`], [`PearsonEquivalence`]. + +use std::sync::Arc; + +use ci_core::dataset::{ColumnKind, Dataset as CoreDataset}; +use ci_core::error::CiError as CoreError; +use ci_core::strategy::{CITest, CiResult as CoreResult, DataType, IndependenceRule, TestMeta}; +use numpy::PyReadonlyArray1; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PySequence, PyString, PyTuple}; + +pyo3::create_exception!( + _ci_python, + CiError, + pyo3::exceptions::PyException, + "Raised when the core conditional-independence engine reports an error." +); + +/// Map a core [`CoreError`] onto the Python [`CiError`] exception. +fn map_err(err: &CoreError) -> PyErr { + CiError::new_err(err.to_string()) +} + +/// Resolve a single column reference (a `str` name or an integer index) to a +/// validated column index within `data`. +fn resolve_column(data: &CoreDataset, obj: &Bound<'_, PyAny>) -> PyResult { + if let Ok(name) = obj.downcast::() { + let name = name.to_cow()?; + data.index_of(&name) + .ok_or_else(|| PyValueError::new_err(format!("unknown column name: {name:?}"))) + } else if let Ok(idx) = obj.extract::() { + let n_cols = data.n_cols(); + let out_of_range = || { + PyValueError::new_err(format!( + "column index {idx} out of range for dataset with {n_cols} columns" + )) + }; + // Allow Python-style negative indexing without lossy isize/usize casts. + let resolved = if idx < 0 { + let back = idx.unsigned_abs(); + n_cols.checked_sub(back).ok_or_else(out_of_range)? + } else { + usize::try_from(idx).map_err(|_| out_of_range())? + }; + if resolved >= n_cols { + return Err(out_of_range()); + } + Ok(resolved) + } else { + Err(PyValueError::new_err( + "column reference must be a str name or an integer index", + )) + } +} + +/// Resolve a conditioning-set argument: a sequence of names/indices (or `None`). +fn resolve_z(data: &CoreDataset, z: Option<&Bound<'_, PyAny>>) -> PyResult> { + let Some(z) = z.filter(|z| !z.is_none()) else { + return Ok(Vec::new()); + }; + // A bare string would iterate character-by-character, which is never intended. + if z.is_instance_of::() { + return Err(PyValueError::new_err( + "z must be a sequence of column references, not a single string", + )); + } + let seq = z + .downcast::() + .map_err(|_| PyValueError::new_err("z must be a sequence of column names or indices"))?; + let len = seq.len()?; + let mut indices = Vec::with_capacity(len); + for i in 0..len { + let item = seq.get_item(i)?; + indices.push(resolve_column(data, &item)?); + } + Ok(indices) +} + +/// Resolve the `x`, `y`, and `z` query arguments to column indices in one shot, +/// short-circuiting on the first failure (in `x`, `y`, `z` order). +fn resolve_xyz( + data: &CoreDataset, + x: &Bound<'_, PyAny>, + y: &Bound<'_, PyAny>, + z: Option<&Bound<'_, PyAny>>, +) -> PyResult<(usize, usize, Vec)> { + Ok(( + resolve_column(data, x)?, + resolve_column(data, y)?, + resolve_z(data, z)?, + )) +} + +/// The numeric outcome of a conditional-independence test. +/// +/// Mirrors [`ci_core::strategy::CiResult`]: `statistic`, `dof`, and +/// `effect_size` are `None` when the test does not define them. +#[pyclass(name = "CiResult", module = "ci_python._ci_python", frozen)] +#[derive(Clone)] +pub struct PyCiResult { + #[pyo3(get)] + statistic: Option, + #[pyo3(get)] + p_value: f64, + #[pyo3(get)] + dof: Option, + #[pyo3(get)] + effect_size: Option, +} -mod util; -use pyo3_stub_gen::define_stub_info_gatherer; -mod ci_tests_init; +impl From for PyCiResult { + fn from(r: CoreResult) -> Self { + Self { + statistic: r.statistic, + p_value: r.p_value, + dof: r.dof, + effect_size: r.effect_size, + } + } +} -#[pyo3::pymodule] -mod _ci_python { - use crate::util::test_result_to_pyobj; - use ci_core::strategy::CITest; - use numpy::{PyReadonlyArray1, PyReadonlyArray2}; - use pyo3::prelude::*; - use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +#[pymethods] +impl PyCiResult { + fn __repr__(&self) -> String { + format!( + "CiResult(statistic={:?}, p_value={}, dof={:?}, effect_size={:?})", + self.statistic, self.p_value, self.dof, self.effect_size + ) + } +} - use crate::ci_tests_init; +/// A named, typed, immutable table of columns shared by the test classes. +/// +/// Build one from a mapping `{name: (kind, values)}` where `kind` is +/// `"discrete"` or `"continuous"` and `values` is a 1-D float64 array, a +/// sequence of numbers, or — for discrete columns — a sequence of strings. +/// Alternatively, pass a `pandas.DataFrame` and kinds are inferred from +/// dtypes. Discrete columns are factorized into integer codes inside the core. +#[pyclass(name = "Dataset", module = "ci_python._ci_python", frozen, subclass)] +#[derive(Clone)] +pub struct PyDataset { + inner: Arc, +} + +/// Whether `obj` is a `pandas.DataFrame`, detected structurally (type name + +/// defining module) so pandas is never imported here. +fn is_pandas_dataframe(obj: &Bound<'_, PyAny>) -> PyResult { + let cls = obj.get_type(); + if cls.name()?.to_cow()? != "DataFrame" { + return Ok(false); + } + let module: String = cls.getattr("__module__")?.extract()?; + Ok(module.starts_with("pandas")) +} - include!(concat!(env!("OUT_DIR"), "/ci_tests.rs")); +/// Parse a kind string into a [`ColumnKind`]. +fn parse_kind(kind: &str) -> PyResult { + match kind { + "discrete" => Ok(ColumnKind::Discrete), + "continuous" => Ok(ColumnKind::Continuous), + other => Err(PyValueError::new_err(format!( + "column kind must be 'discrete' or 'continuous', got {other:?}" + ))), + } +} - #[pymodule_init] - fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { - ci_tests_init::init(m) +/// Extract column values as `Vec` from a numpy array (fast path), any +/// numeric sequence, or — for discrete columns — a sequence of strings, which +/// is factorized to first-seen integer codes (the core re-factorizes anyway, +/// so codes only need to be value-distinct). +fn extract_values(kind: ColumnKind, obj: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(arr) = obj.extract::>() { + // `as_slice()` requires a C-contiguous layout; `as_array()` iterates in + // logical order for any layout, so strided views (`a[::2]`), Fortran + // order, and float64 columns sliced out of a 2-D array all work. + return Ok(arr.as_array().iter().copied().collect()); + } + let items: Vec> = obj.try_iter()?.collect::>()?; + let numeric: PyResult> = items.iter().map(PyAnyMethods::extract::).collect(); + if let Ok(values) = numeric { + return Ok(values); + } + match kind { + ColumnKind::Discrete => { + let mut lookup: std::collections::HashMap = + std::collections::HashMap::new(); + let mut codes = Vec::with_capacity(items.len()); + for item in &items { + let key: String = item.extract().map_err(|_| { + PyValueError::new_err("discrete column values must be numbers or strings") + })?; + #[allow(clippy::cast_precision_loss)] + let next = lookup.len() as f64; + codes.push(*lookup.entry(key).or_insert(next)); + } + Ok(codes) + } + ColumnKind::Continuous => Err(PyValueError::new_err( + "continuous column values must be numeric", + )), } } -define_stub_info_gatherer!(stub_info); +#[pymethods] +impl PyDataset { + /// Build a dataset from a mapping `{name: (kind, values)}`. + /// + /// `kind` is `"discrete"` or `"continuous"`; `values` is a float64 array, + /// a numeric sequence, or — for discrete columns — a sequence of strings + /// (factorized to first-seen integer codes). Insertion order of the mapping + /// is preserved as the column order. + #[new] + fn new(columns: &Bound<'_, PyDict>) -> PyResult { + let mut cols: Vec<(String, ColumnKind, Vec)> = Vec::with_capacity(columns.len()); + for (key, value) in columns.iter() { + let name: String = key + .extract() + .map_err(|_| PyValueError::new_err("Dataset column names must be strings"))?; + let spec = value.downcast::().map_err(|_| { + PyValueError::new_err(format!( + "column {name:?} must map to a (kind, values) tuple" + )) + })?; + if spec.len() != 2 { + return Err(PyValueError::new_err(format!( + "column {name:?} must map to a (kind, values) 2-tuple" + ))); + } + let kind = parse_kind(&spec.get_item(0)?.extract::()?)?; + let values = extract_values(kind, &spec.get_item(1)?)?; + cols.push((name, kind, values)); + } + let inner = CoreDataset::from_columns(cols).map_err(|e| map_err(&e))?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + /// Number of rows (observations). + #[getter] + fn n_rows(&self) -> usize { + self.inner.n_rows() + } + + /// Number of columns. + #[getter] + fn n_cols(&self) -> usize { + self.inner.n_cols() + } + + /// Resolve a column name to its integer index, or raise `ValueError`. + fn index_of(&self, name: &str) -> PyResult { + self.inner + .index_of(name) + .ok_or_else(|| PyValueError::new_err(format!("unknown column name: {name:?}"))) + } + + fn __repr__(&self) -> String { + format!( + "Dataset(n_cols={}, n_rows={})", + self.inner.n_cols(), + self.inner.n_rows() + ) + } +} + +impl PyDataset { + /// Resolve a value that is *either* an existing [`PyDataset`], a column + /// mapping `{name: (kind, values)}`, or a `pandas.DataFrame` into a shared + /// [`CoreDataset`]. This lets every test constructor accept `Test(data)`, + /// `Test({...})`, or `Test(df)` uniformly. + fn coerce(obj: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(ds) = obj.extract::>() { + Ok(Arc::clone(&ds.inner)) + } else if let Ok(dict) = obj.downcast::() { + Ok(PyDataset::new(dict)?.inner) + } else if is_pandas_dataframe(obj)? { + // Route through the Python-side `Dataset.from_pandas` so dtype + // inference lives in one place; pandas stays a soft dependency + // (this branch only runs when a DataFrame was passed). + let py = obj.py(); + let dataset_cls = PyModule::import(py, "ci_python")?.getattr("Dataset")?; + let built = dataset_cls.call_method1("from_pandas", (obj,))?; + let ds = built.extract::>()?; + Ok(Arc::clone(&ds.inner)) + } else { + Err(PyValueError::new_err( + "expected a Dataset, a {name: (kind, values)} mapping, or a pandas DataFrame", + )) + } + } +} + +/// Build the Python dict `{name, data_types, symmetric, rule}` for a meta. +fn meta_to_py(py: Python<'_>, meta: &TestMeta) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("name", meta.name)?; + let types: Vec<&str> = meta + .data_types + .iter() + .map(|t| match t { + DataType::Discrete => "discrete", + DataType::Continuous => "continuous", + }) + .collect(); + dict.set_item("data_types", types)?; + dict.set_item("symmetric", meta.symmetric)?; + dict.set_item( + "rule", + match meta.rule { + IndependenceRule::PValueGe => "p_value_ge", + IndependenceRule::PValueLt => "p_value_lt", + }, + )?; + Ok(dict.unbind()) +} + +/// Generate a data-bound `#[pyclass]` wrapper for a concrete [`CITest`]. +/// +/// Each generated class stores the shared [`CoreDataset`] plus a constructed +/// inner test, and exposes the uniform `run_test` / `is_independent` / `meta` +/// surface. The constructor body (`$ctor`) maps the Python kwargs onto the +/// concrete test's configuration. +macro_rules! ci_test_class { + ( + $py_name:literal, + $wrapper:ident, + $core:path, + new($($arg:ident : $arg_ty:ty = $default:expr),* $(,)?) $ctor:block + ) => { + #[doc = concat!("Data-bound `", $py_name, "` conditional-independence test.")] + #[pyclass(name = $py_name, module = "ci_python._ci_python", frozen)] + pub struct $wrapper { + data: Arc, + inner: $core, + } + + #[pymethods] + impl $wrapper { + #[new] + #[pyo3(signature = (data $(, $arg = $default)*))] + fn new(data: &Bound<'_, PyAny> $(, $arg: $arg_ty)*) -> PyResult { + let data = PyDataset::coerce(data)?; + let inner = $ctor; + Ok(Self { data, inner }) + } + + /// Run the test for `x ⟂ y | z`, returning a [`CiResult`]. + /// + /// `x` and `y` are column names or indices; `z` is a sequence of + /// names/indices (default: empty conditioning set). + #[pyo3(signature = (x, y, z = None))] + fn run_test( + &self, + py: Python<'_>, + x: &Bound<'_, PyAny>, + y: &Bound<'_, PyAny>, + z: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + let (xi, yi, zi) = resolve_xyz(&self.data, x, y, z)?; + let inner = &self.inner; + let data = &self.data; + py.allow_threads(|| inner.test(data.as_ref(), xi, yi, &zi)) + .map(PyCiResult::from) + .map_err(|e| map_err(&e)) + } + + /// Decide independence at `significance_level` using the test's rule. + #[pyo3(signature = (x, y, z = None, significance_level = 0.05))] + fn is_independent( + &self, + py: Python<'_>, + x: &Bound<'_, PyAny>, + y: &Bound<'_, PyAny>, + z: Option<&Bound<'_, PyAny>>, + significance_level: f64, + ) -> PyResult { + let (xi, yi, zi) = resolve_xyz(&self.data, x, y, z)?; + let inner = &self.inner; + let data = &self.data; + py.allow_threads(|| { + inner.is_independent(data.as_ref(), xi, yi, &zi, significance_level) + }) + .map_err(|e| map_err(&e)) + } + + /// Static metadata: `{name, data_types, symmetric, rule}`. + fn meta(&self, py: Python<'_>) -> PyResult> { + meta_to_py(py, &self.inner.meta()) + } + + fn __repr__(&self) -> String { + concat!($py_name, "(...)").to_string() + } + } + }; +} + +ci_test_class!( + "ChiSquared", + PyChiSquared, + ci_core::ci_tests::ChiSquared, + new(yates: bool = true) { + ci_core::ci_tests::ChiSquared { yates } + } +); + +ci_test_class!( + "LogLikelihood", + PyLogLikelihood, + ci_core::ci_tests::LogLikelihood, + new(yates: bool = true) { + ci_core::ci_tests::LogLikelihood { yates } + } +); + +ci_test_class!( + "CressieRead", + PyCressieRead, + ci_core::ci_tests::CressieRead, + new(yates: bool = true) { + ci_core::ci_tests::CressieRead { yates } + } +); + +ci_test_class!( + "FreemanTukey", + PyFreemanTukey, + ci_core::ci_tests::FreemanTukey, + new(yates: bool = true) { + ci_core::ci_tests::FreemanTukey { yates } + } +); + +ci_test_class!( + "ModifiedLikelihood", + PyModifiedLikelihood, + ci_core::ci_tests::ModifiedLikelihood, + new(yates: bool = true) { + ci_core::ci_tests::ModifiedLikelihood { yates } + } +); + +ci_test_class!( + "PearsonCorrelation", + PyPearsonCorrelation, + ci_core::ci_tests::PearsonCorrelation, + new() { + ci_core::ci_tests::PearsonCorrelation::new() + } +); + +ci_test_class!( + "FisherZ", + PyFisherZ, + ci_core::ci_tests::FisherZ, + new() { + ci_core::ci_tests::FisherZ::new() + } +); + +ci_test_class!( + "PearsonEquivalence", + PyPearsonEquivalence, + ci_core::ci_tests::PearsonEquivalence, + new(delta_threshold: f64 = 0.1) { + ci_core::ci_tests::PearsonEquivalence { delta_threshold } + } +); + +/// The native extension module (`ci_python._ci_python`). +#[pymodule] +#[pyo3(name = "_ci_python")] +fn ci_python(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("CiError", m.py().get_type::())?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/crates/ci-python/src/util.rs b/crates/ci-python/src/util.rs deleted file mode 100644 index ca55f7f..0000000 --- a/crates/ci-python/src/util.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Conversion utilities from core result types to Python objects. - -use ci_core::strategy::TestResult; -use pyo3::prelude::*; - -/// Converts a [`TestResult`] into a Python object. -/// -/// - [`TestResult::Boolean`] — returns a `bool`. -/// - [`TestResult::PValue`] — returns a `(p_value, coefficient)` tuple. -/// - [`TestResult::Statistic`] — returns a `(p_value, statistic, dof)` tuple. -pub fn test_result_to_pyobj(result: &TestResult, py: Python<'_>) -> PyResult> { - match result { - TestResult::Boolean(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), - TestResult::PValue(p_value, coefficient) => Ok((*p_value, *coefficient) - .into_pyobject(py)? - .into_any() - .unbind()), - TestResult::Statistic(p_value, statistic, dof) => Ok((*p_value, *statistic, *dof) - .into_pyobject(py)? - .into_any() - .unbind()), - } -} diff --git a/crates/ci-python/test/test_api.py b/crates/ci-python/test/test_api.py new file mode 100644 index 0000000..ed405ad --- /dev/null +++ b/crates/ci-python/test/test_api.py @@ -0,0 +1,303 @@ +"""Surface tests for the data-bound Python API (name/index handling, errors).""" + +from __future__ import annotations + +import numpy as np +import pytest + +from ci_python import ChiSquared, CiError, Dataset, FisherZ, PearsonCorrelation, PearsonEquivalence + + +def _discrete_data() -> Dataset: + rng = np.random.default_rng(0) + return Dataset( + { + "A": ("discrete", rng.integers(0, 2, 200).astype(np.float64)), + "B": ("discrete", rng.integers(0, 2, 200).astype(np.float64)), + "C": ("discrete", rng.integers(0, 3, 200).astype(np.float64)), + } + ) + + +def _continuous_data() -> Dataset: + rng = np.random.default_rng(1) + return Dataset( + { + "X": ("continuous", rng.standard_normal(200)), + "Y": ("continuous", rng.standard_normal(200)), + "Z": ("continuous", rng.standard_normal(200)), + } + ) + + +def test_dataset_shape_and_index() -> None: + data = _discrete_data() + assert data.n_rows == 200 + assert data.n_cols == 3 + assert data.index_of("A") == 0 + assert data.index_of("C") == 2 + + +def test_run_test_accepts_names_and_indices() -> None: + data = _discrete_data() + chi = ChiSquared(data) + by_name = chi.run_test("A", "B", ["C"]) + by_index = chi.run_test(0, 1, [2]) + assert by_name.statistic == pytest.approx(by_index.statistic) + assert by_name.p_value == pytest.approx(by_index.p_value) + assert by_index.dof == by_name.dof + + +def test_result_fields_present_for_discrete() -> None: + chi = ChiSquared(_discrete_data()) + res = chi.run_test("A", "B") + assert isinstance(res.statistic, float) + assert isinstance(res.p_value, float) + assert isinstance(res.dof, int) + + +def test_effect_size_present_for_discrete_and_none_when_undefined() -> None: + # Cramér's V is defined (a float) for a discrete test on 2+-level columns. + res = ChiSquared(_discrete_data()).run_test("A", "B") + assert isinstance(res.effect_size, float) + # A single-level (constant) column has cardinality 1, so Cramér's V is + # undefined and the core returns None for effect_size (statistic collapses + # to 0, dof 0, p-value 1) rather than erroring. + const_data = Dataset( + { + "A": ("discrete", np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0])), + "K": ("discrete", np.zeros(6)), + } + ) + res_none = ChiSquared(const_data).run_test("A", "K") + assert res_none.effect_size is None + + +def test_negative_index_matches_name_resolution() -> None: + # Python-style negative indices resolve identically to resolution by name: + # -1 -> last column ("C"), -2 -> second-to-last ("B"). + chi = ChiSquared(_discrete_data()) + by_negative = chi.run_test(-1, -2) + by_name = chi.run_test("C", "B") + assert by_negative.statistic == pytest.approx(by_name.statistic) + assert by_negative.p_value == pytest.approx(by_name.p_value) + assert by_negative.dof == by_name.dof + + +def test_bare_string_z_raises_value_error() -> None: + # A bare string as z must be rejected, not iterated character-by-character. + chi = ChiSquared(_discrete_data()) + with pytest.raises(ValueError, match="not a single string"): + chi.run_test("A", "B", "C") + + +def test_z_defaults_to_empty() -> None: + chi = ChiSquared(_discrete_data()) + assert chi.run_test("A", "B").p_value == pytest.approx(chi.run_test("A", "B", []).p_value) + + +def test_is_independent_uses_rule() -> None: + chi = ChiSquared(_discrete_data()) + assert isinstance(chi.is_independent("A", "C", ["B"], significance_level=0.05), bool) + + +def test_pearson_equivalence_dof_is_none() -> None: + eqv = PearsonEquivalence(_continuous_data(), delta_threshold=0.1) + res = eqv.run_test("X", "Y", ["Z"]) + assert res.dof is None + assert isinstance(res.p_value, float) + + +def test_pearson_correlation_takes_no_config() -> None: + res = PearsonCorrelation(_continuous_data()).run_test("X", "Y") + assert res.dof is None or isinstance(res.dof, int) + + +def test_meta_reports_rule_and_types() -> None: + chi_meta = ChiSquared(_discrete_data()).meta() + assert chi_meta["name"] == "chi_squared" + assert chi_meta["data_types"] == ["discrete"] + assert chi_meta["symmetric"] is True + assert chi_meta["rule"] == "p_value_ge" + + eqv_meta = PearsonEquivalence(_continuous_data(), delta_threshold=0.1).meta() + assert eqv_meta["rule"] == "p_value_lt" + assert eqv_meta["data_types"] == ["continuous"] + + +def test_unknown_column_name_raises() -> None: + chi = ChiSquared(_discrete_data()) + with pytest.raises(ValueError, match="unknown column"): + chi.run_test("A", "nope") + + +def test_out_of_range_index_raises() -> None: + chi = ChiSquared(_discrete_data()) + with pytest.raises(ValueError, match="out of range"): + chi.run_test(0, 99) + + +def test_wrong_column_kind_raises_cierror() -> None: + # A continuous column handed to the (discrete) chi-squared test. + chi = ChiSquared(_continuous_data()) + with pytest.raises(CiError): + chi.run_test("X", "Y") + + +def test_dataset_can_be_shared_across_tests() -> None: + data = _continuous_data() + a = PearsonCorrelation(data).run_test("X", "Y") + b = PearsonEquivalence(data, delta_threshold=0.2).run_test("X", "Y") + assert isinstance(a.p_value, float) + assert isinstance(b.p_value, float) + + +def test_test_accepts_raw_mapping() -> None: + # A test constructor may take a raw {name: (kind, values)} mapping directly. + chi = ChiSquared( + { + "A": ("discrete", np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0])), + "B": ("discrete", np.array([1.0, 1.0, 0.0, 0.0, 1.0, 0.0])), + } + ) + assert isinstance(chi.run_test("A", "B").p_value, float) + + +def test_fisher_z_concurrent_calls_are_safe() -> None: + """fisher_z is exposed; concurrent run_test calls are consistent. + + Exercises the GIL-free path under thread contention and asserts results + stay identical. (It cannot observe the GIL release itself; a wall-clock + speedup assertion on a microsecond-scale call would be hopelessly flaky.) + """ + import threading + + data = _continuous_data() + fz = FisherZ(data) + res = fz.run_test("X", "Y", ["Z"]) + assert res.dof is None + assert 0.0 <= res.p_value <= 1.0 + + # Per-thread result lists: safe regardless of free-threaded CPython, + # where concurrent list.append on a shared list is not guaranteed atomic. + per_thread: list[list[float]] = [[] for _ in range(4)] + + def worker(bucket: list[float]) -> None: + for _ in range(50): + bucket.append(fz.run_test("X", "Y", ["Z"]).p_value) + + threads = [threading.Thread(target=worker, args=(b,)) for b in per_thread] + for t in threads: + t.start() + for t in threads: + t.join() + results = [p for bucket in per_thread for p in bucket] + assert len(results) == 200 + assert all(r == results[0] for r in results) + + +def test_invalid_queries_raise() -> None: + chi = ChiSquared(_discrete_data()) + with pytest.raises(CiError, match="invalid query"): + chi.run_test("A", "A") + with pytest.raises(CiError, match="invalid query"): + chi.run_test("A", "B", ["A"]) + with pytest.raises(CiError, match="invalid query"): + chi.run_test("A", "B", ["C", "C"]) + + +def test_nan_rejected_at_dataset_construction() -> None: + with pytest.raises(CiError, match="missing data"): + Dataset({"A": ("continuous", np.array([1.0, np.nan, 2.0]))}) + with pytest.raises(CiError, match="missing data"): + Dataset({"A": ("discrete", np.array([1.0, np.nan, 2.0]))}) + + +def test_constructor_accepts_dataframe_directly() -> None: + pd = pytest.importorskip("pandas") + rng = np.random.default_rng(3) + df = pd.DataFrame( + { + "A": rng.integers(0, 2, 100), + "B": rng.integers(0, 3, 100), + "X": rng.standard_normal(100), + "Y": rng.standard_normal(100), + } + ) + chi = ChiSquared(df) # implicit: DataFrame -> Dataset inside the ctor + via_dataset = ChiSquared(Dataset.from_pandas(df)) + a = chi.run_test("A", "B") + b = via_dataset.run_test("A", "B") + assert a.statistic == pytest.approx(b.statistic) + assert a.p_value == pytest.approx(b.p_value) + + +def test_dataset_accepts_string_discrete_columns() -> None: + data = Dataset( + { + "A": ("discrete", ["yes", "no", "yes", "no", "maybe", "yes"]), + "B": ("discrete", np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0])), + } + ) + res = ChiSquared(data).run_test("A", "B") + assert 0.0 <= res.p_value <= 1.0 + + +def test_continuous_string_values_rejected() -> None: + with pytest.raises(ValueError, match="numeric"): + Dataset({"X": ("continuous", ["a", "b", "c"])}) + + +def test_from_pandas_object_and_category_columns() -> None: + pd = pytest.importorskip("pandas") + df = pd.DataFrame( + { + "A": ["x", "y", "x", "y", "x", "y"], # object -> discrete + "B": pd.Categorical(["u", "v", "u", "v", "u", "v"]), # category -> discrete + "C": [0.1, 0.4, 0.2, 0.8, 0.5, 0.9], # float -> continuous + } + ) + data = Dataset.from_pandas(df) + assert data.n_cols == 3 + res = ChiSquared(data).run_test("A", "B") + assert 0.0 <= res.p_value <= 1.0 + + +def test_from_pandas_missing_categorical_errors() -> None: + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"A": pd.Categorical(["u", None, "v"]), "B": [1.0, 2.0, 3.0]}) + with pytest.raises(CiError, match="missing data"): + Dataset.from_pandas(df) + df2 = pd.DataFrame({"A": ["u", None, "v"], "B": [1.0, 2.0, 3.0]}) + with pytest.raises(CiError, match="missing data"): + Dataset.from_pandas(df2) + + +def test_from_pandas_extension_dtype_gives_friendly_error() -> None: + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"A": pd.array([1, 2, 3], dtype="Int64"), "B": [0.1, 0.2, 0.3]}) + with pytest.raises(TypeError, match="cannot infer column kind"): + Dataset.from_pandas(df) + + +def test_from_pandas_temporal_dtypes_rejected() -> None: + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"A": pd.to_timedelta([1, 2, 3], unit="s"), "B": [0.1, 0.2, 0.3]}) + with pytest.raises(TypeError, match="cannot infer column kind"): + Dataset.from_pandas(df) + df2 = pd.DataFrame({"A": pd.to_datetime(["2024-01-01", "2024-01-02"]), "B": [0.1, 0.2]}) + with pytest.raises(TypeError, match="cannot infer column kind"): + Dataset.from_pandas(df2) + + +def test_from_pandas_string_dtype_column() -> None: + pd = pytest.importorskip("pandas") + df = pd.DataFrame( + { + "A": pd.array(["u", "v", "u", "w", "v", "u"], dtype="string"), + "B": [0, 1, 0, 1, 0, 1], + } + ) + data = Dataset.from_pandas(df) + res = ChiSquared(data).run_test("A", "B") + assert 0.0 <= res.p_value <= 1.0 diff --git a/crates/ci-python/test/test_citest.py b/crates/ci-python/test/test_citest.py deleted file mode 100644 index 2dd60bf..0000000 --- a/crates/ci-python/test/test_citest.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Unit tests for ``CITest``s.""" - -import numpy as np -import pytest - -from ci_python import CressieRead, PearsonEquivalence - - -def check_numeric_results( - result: tuple[float, float, float], - expected_stat_approx_zero: bool = False, - expected_dof: int | None = None, - significance_level: float = 0.05, -) -> None: - """Helper function to evaluate contents of numeric tuple returned by various functions.""" - assert isinstance(result, tuple) - assert len(result) == 3 - - p_value, statistic, dof = result - - assert isinstance(p_value, float) - assert isinstance(statistic, float) - assert isinstance(dof, (int, float)) # Accept float if PyO3 casted usize dynamically. - - if expected_dof is not None: - assert int(dof) == expected_dof - if expected_stat_approx_zero: - assert abs(statistic) < 1e-9, f"expected statistic ~0, got {statistic}" - assert p_value > 0.99, f"expected p ~1, got {p_value}" - else: - assert statistic > 5.0, f"expected large statistic, got {statistic}" - assert p_value < significance_level, f"expected p < {significance_level}, got {p_value}" - - -def test_unconditional_independent_data_is_not_rejected() -> None: - """Test that python bindings behave correctly when given unconditional independent data.""" - x = np.array([1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0], dtype=np.float64) - y = np.array([1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0], dtype=np.float64) - empty_z = np.zeros((0, 0), dtype=np.float64) - - # Numeric mode. - test_numeric = CressieRead(boolean=False, significance_level=0.05) - result_numeric = test_numeric.run_test(x, y, empty_z) - check_numeric_results(result_numeric, expected_stat_approx_zero=True, expected_dof=1) - - # Boolean mode. - test_boolean = CressieRead(boolean=True, significance_level=0.05) - result_boolean = test_boolean.run_test(x, y, empty_z) - assert isinstance(result_boolean, bool) - assert result_boolean is True, "expected fail-to-reject (independent=True)" - - -def test_unconditional_dependent_data_is_rejected() -> None: - """Test that python bindings behave correctly when given unconditional dependent data.""" - x = np.array([1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0], dtype=np.float64) - y = np.array([1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0], dtype=np.float64) - empty_z = np.zeros((0, 0), dtype=np.float64) - - # Numeric mode. - test_numeric = CressieRead(boolean=False, significance_level=0.05) - result_numeric = test_numeric.run_test(x, y, empty_z) - check_numeric_results(result_numeric, expected_stat_approx_zero=False) - - # Boolean mode. - test_boolean = CressieRead(boolean=True, significance_level=0.05) - result_boolean = test_boolean.run_test(x, y, empty_z) - assert isinstance(result_boolean, bool) - assert result_boolean is False, "expected reject (independent=False)" - - -def test_conditional_independent_per_group() -> None: - """Test that python bindings behave correctly when given conditional independent data.""" - x = np.array([1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0], dtype=np.float64) - y = np.array([1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0], dtype=np.float64) - z = np.array([[0.0], [0.0], [0.0], [0.0], [1.0], [1.0], [1.0], [1.0]], dtype=np.float64) - - # Numeric mode. - test_numeric = CressieRead(boolean=False, significance_level=0.05) - result_numeric = test_numeric.run_test(x, y, z) - check_numeric_results(result_numeric, expected_stat_approx_zero=True, expected_dof=2) - - # Boolean mode. - test_boolean = CressieRead(boolean=True, significance_level=0.05) - result_boolean = test_boolean.run_test(x, y, z) - assert isinstance(result_boolean, bool) - assert result_boolean is True, "expected conditional independence to hold" - - -def test_conditional_dependent_per_group() -> None: - """Test that python bindings behave correctly when given conditional dependent data.""" - x = np.array([1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0], dtype=np.float64) - y = np.array([1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0], dtype=np.float64) - z = np.array([[0.0], [0.0], [0.0], [0.0], [1.0], [1.0], [1.0], [1.0]], dtype=np.float64) - - # Numeric mode. - test_numeric = CressieRead(boolean=False, significance_level=0.05) - result_numeric = test_numeric.run_test(x, y, z) - check_numeric_results(result_numeric, expected_stat_approx_zero=False) - - -def test_error_handling() -> None: - """Test that python bindings correctly return errors encountered in Rust.""" - x = np.array([1.0, 2.0], dtype=np.float64) - y = np.array([-1.0, 2.0], dtype=np.float64) - z = np.array([[]], dtype=np.float64) - - test_numeric = PearsonEquivalence(boolean=False, significance_level=0.05, delta_threshold=0.1) - - with pytest.raises(RuntimeError): - test_numeric.run_test(x, y, z) diff --git a/crates/ci-python/test/test_golden.py b/crates/ci-python/test/test_golden.py new file mode 100644 index 0000000..af24c36 --- /dev/null +++ b/crates/ci-python/test/test_golden.py @@ -0,0 +1,140 @@ +"""Golden parity test for the data-bound Python bindings. + +Loads the shared cross-language fixture ``tests/fixtures/golden.json`` and, for +each of the eight tests, builds a :class:`Dataset` from the case's columns, +constructs the test with the case's parameters, runs it, and asserts that +``statistic`` / ``p_value`` / ``dof`` match the recorded ``expected`` values. +This is the binding's numeric parity gate against the scipy/pgmpy reference. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from ci_python import ( + ChiSquared, + CressieRead, + Dataset, + FisherZ, + FreemanTukey, + LogLikelihood, + ModifiedLikelihood, + PearsonCorrelation, + PearsonEquivalence, +) + +# Locate the repo-root fixture relative to this file: +# crates/ci-python/test/test_golden.py -> repo root is three parents up. +REPO_ROOT = Path(__file__).resolve().parents[3] +GOLDEN_PATH = REPO_ROOT / "tests" / "fixtures" / "golden.json" + +TOL = 1e-7 +EXPECTED_CASE_COUNT = 80 + +# Map the fixture's stable test name to its binding class. +TEST_CLASSES = { + "chi_squared": ChiSquared, + "log_likelihood": LogLikelihood, + "cressie_read": CressieRead, + "fisher_z": FisherZ, + "freeman_tukey": FreemanTukey, + "modified_likelihood": ModifiedLikelihood, + "pearson_correlation": PearsonCorrelation, + "pearson_equivalence": PearsonEquivalence, +} + + +def _load_cases() -> list[dict[str, Any]]: + with GOLDEN_PATH.open() as fh: + return json.load(fh) + + +def _build_dataset(columns: dict[str, dict[str, Any]]) -> Dataset: + spec = { + name: (col["kind"], np.asarray(col["values"], dtype=np.float64)) + for name, col in columns.items() + } + return Dataset(spec) + + +def _construct(name: str, data: Dataset, params: dict[str, Any]) -> Any: + cls = TEST_CLASSES[name] + if name in ("pearson_correlation", "fisher_z"): + return cls(data) + if name == "pearson_equivalence": + return cls(data, delta_threshold=params["delta_threshold"]) + # Discrete power-divergence family: configured by `yates`. + return cls(data, yates=params["yates"]) + + +def _assert_close(actual: float | None, expected: Any, field: str, case_id: str) -> None: + if expected is None: + # Null fields are skipped (the test does not define them). + return + assert actual is not None, f"{case_id}: expected {field}={expected}, got None" + exp = float(expected) + if math.isinf(exp) or math.isnan(exp): + # Defensive handling for inf statistic / nan (none in the current fixture). + assert math.isinf(actual) == math.isinf(exp), f"{case_id}: {field} inf mismatch" + assert math.isnan(actual) == math.isnan(exp), f"{case_id}: {field} nan mismatch" + if math.isinf(exp): + assert math.copysign(1.0, actual) == math.copysign(1.0, exp), ( + f"{case_id}: {field} inf sign mismatch" + ) + return + assert actual == pytest.approx(exp, abs=TOL, rel=0.0), ( + f"{case_id}: {field} mismatch: got {actual!r}, expected {exp!r}" + ) + + +GOLDEN_CASES = _load_cases() + + +@pytest.mark.parametrize( + ("index", "case"), + list(enumerate(GOLDEN_CASES)), + ids=[f"{i:02d}-{c['test']}" for i, c in enumerate(GOLDEN_CASES)], +) +def test_golden_case(index: int, case: dict[str, Any]) -> None: + """Each fixture case reproduces its recorded statistic / p_value / dof.""" + case_id = f"case[{index}] {case['test']}" + data = _build_dataset(case["columns"]) + test = _construct(case["test"], data, case["params"]) + + result = test.run_test(case["x"], case["y"], case["z"]) + expected = case["expected"] + + _assert_close(result.statistic, expected.get("statistic"), "statistic", case_id) + + # p-value: handle exact-zero / inf defensively, otherwise compare within TOL. + exp_p = float(expected["p_value"]) + if exp_p == 0.0: + assert result.p_value == pytest.approx(0.0, abs=TOL), ( + f"{case_id}: p_value expected ~0, got {result.p_value!r}" + ) + else: + _assert_close(result.p_value, exp_p, "p_value", case_id) + + _assert_close( + None if result.dof is None else float(result.dof), + expected.get("dof"), + "dof", + case_id, + ) + + +def test_golden_covers_all_cases() -> None: + """The fixture has exactly the expected number of cases, all dispatched.""" + assert len(GOLDEN_CASES) == EXPECTED_CASE_COUNT, ( + f"expected {EXPECTED_CASE_COUNT} cases, found {len(GOLDEN_CASES)}" + ) + names = {c["test"] for c in GOLDEN_CASES} + assert names == set(TEST_CLASSES), ( + f"fixture tests {names} do not match binding classes {set(TEST_CLASSES)}" + ) diff --git a/crates/ci-r/DESCRIPTION b/crates/ci-r/DESCRIPTION index 3f20518..bb57726 100644 --- a/crates/ci-r/DESCRIPTION +++ b/crates/ci-r/DESCRIPTION @@ -10,7 +10,7 @@ Description: R bindings for a collection of conditional independence tests License: MIT Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 +RoxygenNote: 7.3.2 Config/rextendr/version: 0.5.0 SystemRequirements: Cargo (Rust's package manager), rustc >= 1.65.0, xz Depends: diff --git a/crates/ci-r/NAMESPACE b/crates/ci-r/NAMESPACE index 5d83515..cee8e0d 100644 --- a/crates/ci-r/NAMESPACE +++ b/crates/ci-r/NAMESPACE @@ -1,3 +1,36 @@ # Generated by roxygen2: do not edit by hand +S3method("$",ChiSquared) +S3method("$",CressieRead) +S3method("$",Dataset) +S3method("$",FisherZ) +S3method("$",FreemanTukey) +S3method("$",LogLikelihood) +S3method("$",ModifiedLikelihood) +S3method("$",PearsonCorrelation) +S3method("$",PearsonEquivalence) +S3method("[[",ChiSquared) +S3method("[[",CressieRead) +S3method("[[",Dataset) +S3method("[[",FisherZ) +S3method("[[",FreemanTukey) +S3method("[[",LogLikelihood) +S3method("[[",ModifiedLikelihood) +S3method("[[",PearsonCorrelation) +S3method("[[",PearsonEquivalence) +S3method(print,cir_dataset) +S3method(print,cir_test) +export(as_pcalg) +export(chi_squared) +export(ci_test) +export(cressie_read) +export(dataset) +export(fisher_z) +export(freeman_tukey) +export(is_independent) +export(log_likelihood) +export(modified_likelihood) +export(pearson_correlation) +export(pearson_equivalence) +export(run_test) useDynLib(cir, .registration = TRUE) diff --git a/crates/ci-r/R/cir.R b/crates/ci-r/R/cir.R new file mode 100644 index 0000000..9a4b3ba --- /dev/null +++ b/crates/ci-r/R/cir.R @@ -0,0 +1,420 @@ +#' cir: data-bound conditional-independence tests +#' +#' A small, function-style R surface over the Rust core. Bind a data.frame once +#' with [dataset()], construct a test with one of the factory functions +#' ([chi_squared()], [pearson_correlation()], ...), then query it with +#' [run_test()] / [is_independent()]. [as_pcalg()] adapts any test into the +#' `indepTest` callback shape expected by the \pkg{pcalg} package. +#' +#' Column kinds are inferred from the data.frame: `integer`/`logical`/`factor`/ +#' `character` columns are treated as **discrete**, `double` columns as +#' **continuous**. Discrete factor/character columns are coded to integer codes +#' on the R side before they reach the Rust core (which only handles numbers). +#' +#' @keywords internal +"_PACKAGE" + +# --------------------------------------------------------------------------- +# Dataset +# --------------------------------------------------------------------------- + +# The generated extendr wrappers expose `Dataset`, `ChiSquared`, ... as +# environments with a `$new()` constructor returning an external pointer. The +# user-facing objects below wrap that pointer together with the ordered column +# names (needed to map pcalg's integer node indices back to names). + +#' Infer the column kind ("discrete" or "continuous") for one data.frame column. +#' +#' `double` columns are continuous; everything else +#' (`integer`/`logical`/`factor`/`character`) is discrete. +#' +#' @param col A single data.frame column. +#' @return A scalar string, `"discrete"` or `"continuous"`. +#' @keywords internal +.cir_infer_kind <- function(col) { + if (is.factor(col) || is.character(col) || is.logical(col) || is.integer(col)) { + "discrete" + } else if (is.double(col)) { + "continuous" + } else { + stop( + sprintf("unsupported column type: %s", paste(class(col), collapse = "/")), + call. = FALSE + ) + } +} + +#' Code one data.frame column to a numeric vector for the Rust core. +#' +#' Discrete factor/character/logical columns are mapped to integer codes (the +#' core re-factorizes them anyway, so the exact codes only need to be +#' value-distinct); numeric columns pass through as doubles. +#' +#' @param col A single data.frame column. +#' @return A numeric vector the same length as `col`. +#' @keywords internal +.cir_code_column <- function(col) { + if (is.factor(col)) { + as.numeric(as.integer(col)) + } else if (is.character(col)) { + as.numeric(as.integer(factor(col))) + } else { + # logical and numeric columns both coerce straight to double. + as.numeric(col) + } +} + +#' Bind a data.frame as a `cir` dataset. +#' +#' Builds the data-bound dataset shared by the test factories. Column kinds are +#' inferred from the column classes (integer/logical/factor/character -> +#' discrete, double -> continuous); discrete factor/character columns are coded +#' to integer codes before reaching the Rust core. +#' +#' Missing values are **rejected**: any `NA` (in a numeric column, a factor, +#' or after coding a character column) reaches the core as NaN and errors with +#' "missing data: ...". Choose a missing-data convention (e.g. +#' `na.omit(df)`) before binding. +#' +#' @param df A data.frame (or object coercible to one). +#' @return An object of class `cir_dataset` wrapping the bound dataset and its +#' column names. +#' @examples +#' df <- data.frame(A = sample(0:1, 50, TRUE), X = rnorm(50)) +#' data <- dataset(df) +#' @export +dataset <- function(df) { + df <- as.data.frame(df) + if (ncol(df) == 0L) { + stop("`df` must have at least one column", call. = FALSE) + } + names_vec <- colnames(df) + kinds <- vapply(df, .cir_infer_kind, character(1), USE.NAMES = FALSE) + coded <- lapply(df, .cir_code_column) + values <- matrix( + as.numeric(unlist(coded, use.names = FALSE)), + nrow = nrow(df), + ncol = ncol(df) + ) + ptr <- Dataset$new(names_vec, kinds, values) + structure( + list(ptr = ptr, names = names_vec, kinds = kinds), + class = "cir_dataset" + ) +} + +#' @export +print.cir_dataset <- function(x, ...) { + cat(sprintf( + "\n", + x$ptr$n_cols(), x$ptr$n_rows() + )) + cat(" columns:", paste(x$names, collapse = ", "), "\n") + invisible(x) +} + +#' Coerce a `dataset()` result or a data.frame into a `cir_dataset`. +#' +#' Lets every test factory accept either a pre-built [dataset()] or a raw +#' data.frame uniformly. +#' +#' @param data A `cir_dataset` or a data.frame. +#' @return A `cir_dataset`. +#' @keywords internal +.cir_as_dataset <- function(data) { + if (inherits(data, "cir_dataset")) { + data + } else { + dataset(data) + } +} + +# --------------------------------------------------------------------------- +# Test factories +# --------------------------------------------------------------------------- + +#' Build a `cir_test` wrapper around a constructed extendr test handle. +#' +#' @param handle The external-pointer test handle from `$new(...)`. +#' @param ds The bound `cir_dataset` (carries column names for pcalg mapping). +#' @param name The test's stable name (e.g. `"chi_squared"`). +#' @return An object of class `cir_test`. +#' @keywords internal +.cir_make_test <- function(handle, ds, name) { + structure( + list(handle = handle, dataset = ds, name = name), + class = "cir_test" + ) +} + +#' Chi-squared (Pearson, lambda = 1) discrete CI test. +#' +#' @param data A [dataset()] result or a data.frame. +#' @param yates Whether to apply Yates' continuity correction on 2x2 +#' sub-tables (default `TRUE`, the scipy/pgmpy convention). +#' @return A `cir_test` bound to `data`. +#' @examples +#' df <- data.frame(A = sample(0:1, 80, TRUE), B = sample(0:1, 80, TRUE)) +#' chi <- chi_squared(dataset(df)) +#' run_test(chi, "A", "B") +#' @export +chi_squared <- function(data, yates = TRUE) { + ds <- .cir_as_dataset(data) + .cir_make_test(ChiSquared$new(ds$ptr, yates), ds, "chi_squared") +} + +#' Log-likelihood / G-test (lambda = 0) discrete CI test. +#' +#' @inheritParams chi_squared +#' @return A `cir_test` bound to `data`. +#' @export +log_likelihood <- function(data, yates = TRUE) { + ds <- .cir_as_dataset(data) + .cir_make_test(LogLikelihood$new(ds$ptr, yates), ds, "log_likelihood") +} + +#' Cressie-Read (lambda = 2/3) discrete CI test. +#' +#' @inheritParams chi_squared +#' @return A `cir_test` bound to `data`. +#' @export +cressie_read <- function(data, yates = TRUE) { + ds <- .cir_as_dataset(data) + .cir_make_test(CressieRead$new(ds$ptr, yates), ds, "cressie_read") +} + +#' Freeman-Tukey (lambda = -1/2) discrete CI test. +#' +#' @inheritParams chi_squared +#' @return A `cir_test` bound to `data`. +#' @export +freeman_tukey <- function(data, yates = TRUE) { + ds <- .cir_as_dataset(data) + .cir_make_test(FreemanTukey$new(ds$ptr, yates), ds, "freeman_tukey") +} + +#' Modified log-likelihood (lambda = -1) discrete CI test. +#' +#' @inheritParams chi_squared +#' @return A `cir_test` bound to `data`. +#' @export +modified_likelihood <- function(data, yates = TRUE) { + ds <- .cir_as_dataset(data) + .cir_make_test(ModifiedLikelihood$new(ds$ptr, yates), ds, "modified_likelihood") +} + +#' Pearson partial-correlation continuous CI test. +#' +#' @param data A [dataset()] result or a data.frame. +#' @return A `cir_test` bound to `data`. +#' @export +pearson_correlation <- function(data) { + ds <- .cir_as_dataset(data) + .cir_make_test(PearsonCorrelation$new(ds$ptr), ds, "pearson_correlation") +} + +#' Fisher-z continuous CI test. +#' +#' Applies the Fisher z-transform to the (partial) correlation: +#' `sqrt(n - |Z| - 3) * atanh(rho)` is approximately standard normal under +#' independence. This matches `pcalg::gaussCItest`'s statistic and is the +#' de-facto standard continuous CI test in constraint-based causal discovery. +#' +#' @param data A [dataset()] result or a data.frame. +#' @return A `cir_test` bound to `data`. +#' @export +fisher_z <- function(data) { + ds <- .cir_as_dataset(data) + .cir_make_test(FisherZ$new(ds$ptr), ds, "fisher_z") +} + +#' Pearson equivalence (TOST) continuous CI test. +#' +#' Declares independence when the partial correlation is within the equivalence +#' margin. Note the inverted decision rule: independence holds when the p-value +#' is **below** the significance level (handled by [is_independent()]). +#' +#' @param data A [dataset()] result or a data.frame. +#' @param delta_threshold Equivalence margin on the correlation scale +#' (default `0.1`). +#' @return A `cir_test` bound to `data`. +#' @export +pearson_equivalence <- function(data, delta_threshold = 0.1) { + ds <- .cir_as_dataset(data) + .cir_make_test( + PearsonEquivalence$new(ds$ptr, delta_threshold), ds, "pearson_equivalence" + ) +} + +#' @export +print.cir_test <- function(x, ...) { + cat(sprintf("\n", x$name)) + invisible(x) +} + +# --------------------------------------------------------------------------- +# Queries +# --------------------------------------------------------------------------- + +#' Run a conditional-independence test. +#' +#' @param test A `cir_test` from one of the factory functions. +#' @param x,y Column **names** (length-1 character) for the two variables. +#' @param z Character vector of conditioning column names (default: none). +#' @return A named list `list(statistic, p_value, dof, effect_size)`; absent +#' fields are `NULL`. +#' @examples +#' df <- data.frame(A = sample(0:1, 80, TRUE), B = sample(0:1, 80, TRUE)) +#' run_test(chi_squared(dataset(df)), "A", "B") +#' @export +run_test <- function(test, x, y, z = character()) { + stopifnot(inherits(test, "cir_test")) + test$handle$run_test(x, y, .cir_as_names(z)) +} + +#' Decide conditional independence at a significance level. +#' +#' Applies the test's own decision rule: `p >= significance_level` for the +#' standard tests, and the inverted `p < significance_level` for +#' [pearson_equivalence()]. +#' +#' @param test A `cir_test` from one of the factory functions. +#' @param x,y Column names for the two variables. +#' @param z Character vector of conditioning column names (default: none). +#' @param significance_level Significance level alpha (default `0.05`). +#' @return A single logical: `TRUE` if independent at `significance_level`. +#' @export +is_independent <- function(test, x, y, z = character(), significance_level = 0.05) { + stopifnot(inherits(test, "cir_test")) + test$handle$is_independent(x, y, .cir_as_names(z), significance_level) +} + +#' Normalize a conditioning-set argument to a character vector. +#' +#' Accepts `NULL`, a character vector, or a single name; never a non-character. +#' +#' @param z The `z` argument as supplied by the caller. +#' @return A character vector (possibly empty). +#' @keywords internal +.cir_as_names <- function(z) { + if (is.null(z)) { + character() + } else if (is.character(z)) { + z + } else { + stop("`z` must be a character vector of column names", call. = FALSE) + } +} + +# --------------------------------------------------------------------------- +# pcalg adapter +# --------------------------------------------------------------------------- + +# Test names whose decision rule is inverted relative to pcalg's standard +# convention: they declare independence when p < alpha, not p >= alpha. Mirrors +# the core `IndependenceRule::PValueLt` tests. Keep in sync if another +# inverted-rule test is added to the factories above. +.cir_inverted_rule_tests <- c("pearson_equivalence") + +#' Adapt a `cir_test` into a \pkg{pcalg} `indepTest` callback. +#' +#' Returns a closure `function(x, y, S, suffStat)` matching the interface +#' [pcalg::pc()] / [pcalg::skeleton()] expect: `x` and `y` are **1-based** +#' integer node indices, `S` is an integer vector of conditioning node indices +#' (possibly empty), and the return value is the test's p-value. The node +#' indices are mapped back to the bound dataset's column names in their bound +#' order, so pass `labels = colnames(df)` (in the same order) to the pcalg +#' driver. `suffStat` is accepted for interface compatibility and ignored (the +#' data is already captured in `test`); pass `suffStat = list()`. +#' +#' @param test A `cir_test` from one of the factory functions. Must be a +#' standard null-of-independence test; [pearson_equivalence()] is rejected +#' because its inverted decision rule (independence when `p < alpha`) would be +#' interpreted backwards by \pkg{pcalg}. +#' @return A function `(x, y, S, suffStat) -> numeric` p-value. +#' @examples +#' \dontrun{ +#' df <- data.frame(X = rnorm(200), Y = rnorm(200), Z = rnorm(200)) +#' indepTest <- as_pcalg(pearson_correlation(dataset(df))) +#' pcalg::pc(suffStat = list(), indepTest = indepTest, +#' labels = colnames(df), alpha = 0.05) +#' } +#' @export +as_pcalg <- function(test) { + stopifnot(inherits(test, "cir_test")) + # pcalg interprets the returned p-value with the standard convention (remove an + # edge / declare independence when p >= alpha). A test whose own rule is + # inverted -- independence when p < alpha, i.e. the core's + # `IndependenceRule::PValueLt` -- would have every decision silently flipped, + # yielding a wrong skeleton/CPDAG with no error. Refuse them. + if (test$name %in% .cir_inverted_rule_tests) { + stop(sprintf( + paste0( + "as_pcalg() supports only standard null-of-independence tests; `%s` ", + "uses an inverted decision rule (independence when p < alpha), which ", + "pcalg would interpret backwards and silently invert every edge ", + "decision. Use a standard test such as fisher_z() or ", + "pearson_correlation() for constraint-based discovery." + ), + test$name + ), call. = FALSE) + } + cols <- test$dataset$names + handle <- test$handle + function(x, y, S, suffStat) { + xi <- cols[[x]] + yi <- cols[[y]] + zi <- if (length(S) > 0L) cols[as.integer(S)] else character() + res <- handle$run_test(xi, yi, zi) + res$p_value + } +} + +# --------------------------------------------------------------------------- +# Optional base-R `htest` adapter +# --------------------------------------------------------------------------- + +#' Run a test and return a base-R `htest` object. +#' +#' A convenience wrapper for base-R / \pkg{bnlearn}-style workflows: the result +#' carries the `statistic` and the `parameter` (degrees of freedom) alongside +#' the `p.value`. +#' +#' @param test A `cir_test` from one of the factory functions. +#' @param x,y Column names for the two variables. +#' @param z Character vector of conditioning column names (default: none). +#' @return An object of class `htest`. +#' @export +ci_test <- function(test, x, y, z = character()) { + stopifnot(inherits(test, "cir_test")) + res <- run_test(test, x, y, z) + + statistic <- res$statistic + if (!is.null(statistic)) { + names(statistic) <- "statistic" + } + parameter <- res$dof + if (!is.null(parameter)) { + parameter <- as.numeric(parameter) + names(parameter) <- "df" + } + + znames <- .cir_as_names(z) + zlab <- if (length(znames) > 0L) { + paste0(" | ", paste(znames, collapse = ", ")) + } else { + "" + } + + structure( + list( + statistic = statistic, + parameter = parameter, + p.value = res$p_value, + estimate = if (is.null(res$effect_size)) NULL else c(effect_size = res$effect_size), + method = sprintf("cir %s conditional independence test", test$name), + data.name = sprintf("%s vs %s%s", x, y, zlab) + ), + class = "htest" + ) +} diff --git a/crates/ci-r/R/extendr-wrappers.R b/crates/ci-r/R/extendr-wrappers.R index 80d017a..4f88bed 100644 --- a/crates/ci-r/R/extendr-wrappers.R +++ b/crates/ci-r/R/extendr-wrappers.R @@ -5,23 +5,282 @@ #' @useDynLib cir, .registration = TRUE NULL -chi_squared_test <- function(x_values, y_values, z, boolean, significance_level) .Call(wrap__chi_squared_test, x_values, y_values, z, boolean, significance_level) +#' A named, typed, immutable table of columns shared by the test handles. +#' +#' Built from R via the `new` constructor, which takes the column names, a +#' parallel vector of kind strings (`"discrete"` / `"continuous"`), and the +#' columns flattened into a single `f64` matrix (column-major, one dataset +#' column per matrix column). Discrete columns are factorized into integer +#' codes inside the core. The handle is cheap to clone (it is `Arc`-shared) so +#' several tests can bind the same factorization. +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Build a dataset from `names`, parallel `kinds`, and a `values` matrix +#'whose `j`-th column holds the values of column `names[j]`. +#' +#'The R wrapper (`dataset()`) is responsible for coding factor/character +#'columns to numbers before calling this, so `values` is always numeric. +#'} +#' +#'\subsection{Method `n_rows`}{ +#'Number of rows (observations). +#'} +#' +#'\subsection{Method `n_cols`}{ +#'Number of columns. +#'} +#' +#'\subsection{Method `index_of`}{ +#'1-based index of the column named `name`, or `NA` if it is absent. +#'} +#' +Dataset <- new.env(parent = emptyenv()) + +Dataset$new <- function(names, kinds, values) .Call(wrap__Dataset__new, names, kinds, values) + +Dataset$n_rows <- function() .Call(wrap__Dataset__n_rows, self) + +Dataset$n_cols <- function() .Call(wrap__Dataset__n_cols, self) + +Dataset$index_of <- function(name) .Call(wrap__Dataset__index_of, self, name) + +#' @export +`$.Dataset` <- function (self, name) { func <- Dataset[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.Dataset` <- `$.Dataset` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +ChiSquared <- new.env(parent = emptyenv()) + +ChiSquared$new <- function(data, yates) .Call(wrap__ChiSquared__new, data, yates) + +ChiSquared$run_test <- function(x, y, z) .Call(wrap__ChiSquared__run_test, self, x, y, z) + +ChiSquared$is_independent <- function(x, y, z, significance_level) .Call(wrap__ChiSquared__is_independent, self, x, y, z, significance_level) + +#' @export +`$.ChiSquared` <- function (self, name) { func <- ChiSquared[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.ChiSquared` <- `$.ChiSquared` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +LogLikelihood <- new.env(parent = emptyenv()) + +LogLikelihood$new <- function(data, yates) .Call(wrap__LogLikelihood__new, data, yates) + +LogLikelihood$run_test <- function(x, y, z) .Call(wrap__LogLikelihood__run_test, self, x, y, z) + +LogLikelihood$is_independent <- function(x, y, z, significance_level) .Call(wrap__LogLikelihood__is_independent, self, x, y, z, significance_level) + +#' @export +`$.LogLikelihood` <- function (self, name) { func <- LogLikelihood[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.LogLikelihood` <- `$.LogLikelihood` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +CressieRead <- new.env(parent = emptyenv()) + +CressieRead$new <- function(data, yates) .Call(wrap__CressieRead__new, data, yates) + +CressieRead$run_test <- function(x, y, z) .Call(wrap__CressieRead__run_test, self, x, y, z) + +CressieRead$is_independent <- function(x, y, z, significance_level) .Call(wrap__CressieRead__is_independent, self, x, y, z, significance_level) + +#' @export +`$.CressieRead` <- function (self, name) { func <- CressieRead[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.CressieRead` <- `$.CressieRead` -log_likelihood_test <- function(x_values, y_values, z, boolean, significance_level) .Call(wrap__log_likelihood_test, x_values, y_values, z, boolean, significance_level) +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +FreemanTukey <- new.env(parent = emptyenv()) + +FreemanTukey$new <- function(data, yates) .Call(wrap__FreemanTukey__new, data, yates) + +FreemanTukey$run_test <- function(x, y, z) .Call(wrap__FreemanTukey__run_test, self, x, y, z) + +FreemanTukey$is_independent <- function(x, y, z, significance_level) .Call(wrap__FreemanTukey__is_independent, self, x, y, z, significance_level) + +#' @export +`$.FreemanTukey` <- function (self, name) { func <- FreemanTukey[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.FreemanTukey` <- `$.FreemanTukey` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +ModifiedLikelihood <- new.env(parent = emptyenv()) -cressie_read_test <- function(x_values, y_values, z, boolean, significance_level) .Call(wrap__cressie_read_test, x_values, y_values, z, boolean, significance_level) +ModifiedLikelihood$new <- function(data, yates) .Call(wrap__ModifiedLikelihood__new, data, yates) -pearson_correlation_test <- function(x_values, y_values, z, boolean, significance_level) .Call(wrap__pearson_correlation_test, x_values, y_values, z, boolean, significance_level) +ModifiedLikelihood$run_test <- function(x, y, z) .Call(wrap__ModifiedLikelihood__run_test, self, x, y, z) -freeman_tukey_test <- function(x_values, y_values, z, boolean, significance_level) .Call(wrap__freeman_tukey_test, x_values, y_values, z, boolean, significance_level) +ModifiedLikelihood$is_independent <- function(x, y, z, significance_level) .Call(wrap__ModifiedLikelihood__is_independent, self, x, y, z, significance_level) -modified_likelihood_test <- function(x_values, y_values, z, boolean, significance_level) .Call(wrap__modified_likelihood_test, x_values, y_values, z, boolean, significance_level) +#' @export +`$.ModifiedLikelihood` <- function (self, name) { func <- ModifiedLikelihood[[name]]; environment(func) <- environment(); func } -#' Pearson equivalence CI test (TOST): declares independence when the partial correlation -#' lies within `[-delta_threshold, delta_threshold]`. +#' @export +`[[.ModifiedLikelihood` <- `$.ModifiedLikelihood` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} #' -#' Pass a 0-column matrix for `z` to run unconditionally. When `boolean` is `true`, -#' returns an independence verdict instead of the raw p-value and correlation. -pearson_equivalence_test <- function(x_values, y_values, z, boolean, significance_level, delta_threshold) .Call(wrap__pearson_equivalence_test, x_values, y_values, z, boolean, significance_level, delta_threshold) +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +PearsonCorrelation <- new.env(parent = emptyenv()) + +PearsonCorrelation$new <- function(data) .Call(wrap__PearsonCorrelation__new, data) + +PearsonCorrelation$run_test <- function(x, y, z) .Call(wrap__PearsonCorrelation__run_test, self, x, y, z) + +PearsonCorrelation$is_independent <- function(x, y, z, significance_level) .Call(wrap__PearsonCorrelation__is_independent, self, x, y, z, significance_level) + +#' @export +`$.PearsonCorrelation` <- function (self, name) { func <- PearsonCorrelation[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.PearsonCorrelation` <- `$.PearsonCorrelation` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +FisherZ <- new.env(parent = emptyenv()) + +FisherZ$new <- function(data) .Call(wrap__FisherZ__new, data) + +FisherZ$run_test <- function(x, y, z) .Call(wrap__FisherZ__run_test, self, x, y, z) + +FisherZ$is_independent <- function(x, y, z, significance_level) .Call(wrap__FisherZ__is_independent, self, x, y, z, significance_level) + +#' @export +`$.FisherZ` <- function (self, name) { func <- FisherZ[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.FisherZ` <- `$.FisherZ` + +#' +#' @section Methods: +#'\subsection{Method `new`}{ +#'Construct the handle bound to `data` with the given config. +#'} +#' +#'\subsection{Method `run_test`}{ +#'Run the test for `x ⟂ y | z` (column names; `z` a character +#'vector), returning `list(statistic, p_value, dof, effect_size)`. +#'} +#' +#'\subsection{Method `is_independent`}{ +#'Decide independence at `significance_level` using the test's rule. +#'} +#' +PearsonEquivalence <- new.env(parent = emptyenv()) + +PearsonEquivalence$new <- function(data, delta_threshold) .Call(wrap__PearsonEquivalence__new, data, delta_threshold) + +PearsonEquivalence$run_test <- function(x, y, z) .Call(wrap__PearsonEquivalence__run_test, self, x, y, z) + +PearsonEquivalence$is_independent <- function(x, y, z, significance_level) .Call(wrap__PearsonEquivalence__is_independent, self, x, y, z, significance_level) + +#' @export +`$.PearsonEquivalence` <- function (self, name) { func <- PearsonEquivalence[[name]]; environment(func) <- environment(); func } + +#' @export +`[[.PearsonEquivalence` <- `$.PearsonEquivalence` # nolint end diff --git a/crates/ci-r/README.md b/crates/ci-r/README.md index 50bdd2d..d49056d 100644 --- a/crates/ci-r/README.md +++ b/crates/ci-r/README.md @@ -1,111 +1,79 @@ -# cir +# cir — conditional-independence tests for R -R bindings for the [Conditional Independence Testing](../../README.md) library. Wraps the Rust core via [extendr](https://extendr.github.io) and accepts standard R vectors and matrices directly. +R bindings for the `ci-core` Rust library: data-bound conditional-independence +(CI) tests with one uniform surface. Bind a data.frame once, then run any +number of `X ⟂ Y | Z` queries against it. See the +[repository README](../../README.md) for the cross-language story. -## Available tests - -| Function | Data type | Numeric output fields | -|---|---|---| -| `chi_squared_test` | Discrete | `$statistic`, `$p_value`, `$df` | -| `cressie_read_test` | Discrete | `$statistic`, `$p_value`, `$df` | -| `freeman_tukey_test` | Discrete | `$statistic`, `$p_value`, `$df` | -| `log_likelihood_test` | Discrete | `$statistic`, `$p_value`, `$df` | -| `modified_likelihood_test` | Discrete | `$statistic`, `$p_value`, `$df` | -| `pearson_correlation_test` | Continuous | `$p_value`, `$coefficient` | -| `pearson_equivalence_test` | Continuous | `$p_value`, `$coefficient` | - -All tests return a named list. Every result also has a `$kind` field: `"statistic"`, `"pvalue"`, or `"boolean"` depending on the mode and test type. - -All tests support an optional conditioning matrix `z`. Pass a 0-column matrix for unconditional tests. - -## Requirements - -- R >= 4.2 -- Rust (stable), installed via [rustup](https://rustup.rs) -- `devtools` and `rextendr` R packages - -## Installation +## Install -Install the required R packages, then load the package from the repository root: +Requires a Rust toolchain ([rustup](https://rustup.rs)). From the repository root: ```r -install.packages("pak", repos = "https://cloud.r-project.org") -pak::pak(c("devtools", "rextendr")) +# install.packages("devtools") +devtools::install("crates/ci-r") ``` -```r -setwd("crates/ci-r") -devtools::load_all() # compiles Rust and loads the package -``` - -## Usage - -### Numeric mode +> Benchmarking note: `devtools::load_all()` / `rextendr::document()` compile a +> **debug** build. For benchmarks, install a release build: +> `NOT_CRAN=true R CMD INSTALL crates/ci-r` and `library(cir)`. -Numeric mode returns the raw test statistic and p-value as a named list. Pass `boolean = FALSE`: +## Quick start ```r library(cir) -x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) -y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) -z <- matrix(0, nrow = length(x), ncol = 0) # unconditional +set.seed(0) +df <- data.frame( + A = sample(0:1, 500, TRUE), B = sample(0:2, 500, TRUE), # integer -> discrete + X = rnorm(500), Y = rnorm(500), Z = rnorm(500) # double -> continuous +) -result <- chi_squared_test(x, y, z, FALSE, 0.05) -cat("p =", result$p_value, " statistic =", result$statistic, " df =", result$df, "\n") -``` +data <- dataset(df) # bind once; factor/character columns are coded for you -For continuous data, the result contains `$p_value` and `$coefficient` instead: +chi <- chi_squared(data) # Yates' correction on by default +res <- run_test(chi, "A", "B") # list(statistic, p_value, dof, effect_size) +is_independent(chi, "A", "B", significance_level = 0.05) -```r -result <- pearson_correlation_test(x, y, z, FALSE, 0.05) -cat("p =", result$p_value, " r =", result$coefficient, "\n") +fz <- fisher_z(data) # the pcalg/causal-learn standard test +run_test(fz, "X", "Y", c("Z")) # res$dof is NULL for continuous tests ``` -### Boolean mode - -Boolean mode returns a list with a single `$independent` field: `TRUE` if the null hypothesis of independence is not rejected, `FALSE` if it is rejected. Pass `boolean = TRUE`: +Column kinds are inferred: `integer`/`logical`/`factor`/`character` columns are +**discrete**, `double` columns **continuous**. Missing values are rejected when +the dataset is bound — `na.omit(df)` (or impute) first. Every factory also +accepts a raw data.frame directly (`chi_squared(df)`). -```r -result <- chi_squared_test(x, y, z, TRUE, 0.05) -cat("independent:", result$independent, "\n") -``` - -### Conditional tests - -Pass a conditioning matrix `z` where each column is one conditioning variable: +## Available tests -```r -x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) -y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) -z <- matrix(c(0, 0, 0, 0, 1, 1, 1, 1), nrow = 8, ncol = 1) +| Factory | Data | Config | Independent when | +|---|---|---|---| +| `chi_squared(data, yates = TRUE)` | discrete | `yates` | `p >= alpha` | +| `log_likelihood(data, yates = TRUE)` | discrete | `yates` | `p >= alpha` | +| `cressie_read(data, yates = TRUE)` | discrete | `yates` | `p >= alpha` | +| `freeman_tukey(data, yates = TRUE)` | discrete | `yates` | `p >= alpha` | +| `modified_likelihood(data, yates = TRUE)` | discrete | `yates` | `p >= alpha` | +| `pearson_correlation(data)` | continuous | — | `p >= alpha` | +| `fisher_z(data)` | continuous | — | `p >= alpha` | +| `pearson_equivalence(data, delta_threshold = 0.1)` | continuous | `delta_threshold` | `p < alpha` (TOST) | -result <- chi_squared_test(x, y, z, FALSE, 0.05) -``` +`is_independent()` applies each test's own decision rule, so the caller never +writes it — including the equivalence test's inverted rule. -To condition on multiple variables, bind them as columns with `cbind`: +## Integration ```r -z <- cbind(z1, z2, z3) # matrix with 3 conditioning columns -result <- pearson_correlation_test(x, y, z, FALSE, 0.05) -``` - -### Pearson equivalence test - -`pearson_equivalence_test` takes one extra argument, `delta_threshold`, which sets the equivalence margin for the TOST procedure. Independence is declared when the partial correlation falls within `[-delta_threshold, delta_threshold]`: +# pcalg: any test adapts to the indepTest(x, y, S, suffStat) callback shape. +indepTest <- as_pcalg(fisher_z(dataset(df))) +# pcalg::pc(suffStat = list(), indepTest = indepTest, labels = colnames(df), alpha = 0.05) -```r -result <- pearson_equivalence_test(x, y, z, FALSE, 0.05, 0.1) -cat("p =", result$p_value, " r =", result$coefficient, "\n") +# base-R / bnlearn-style: an htest object. +ci_test(chi, "A", "B") ``` -## Running tests +## Testing ```r -setwd("crates/ci-r") -devtools::test() +rextendr::document("crates/ci-r") # recompile + regenerate wrappers +devtools::test("crates/ci-r") # includes the shared 80-case golden fixture ``` - -## License - -Licensed under the [MIT license](../../LICENSE). diff --git a/crates/ci-r/inst/examples/pcalg_demo.R b/crates/ci-r/inst/examples/pcalg_demo.R new file mode 100644 index 0000000..26290f2 --- /dev/null +++ b/crates/ci-r/inst/examples/pcalg_demo.R @@ -0,0 +1,49 @@ +# pcalg integration demo for the cir package. +# +# Builds a continuous dataset with a known chain structure X -> Y -> Z, adapts a +# cir Pearson-correlation test into a pcalg `indepTest` callback via as_pcalg(), +# and runs pcalg::pc() to recover the skeleton/CPDAG. Run with: +# +# conda run -n expert Rscript crates/ci-r/inst/examples/pcalg_demo.R +# +# (load_all is used so the demo works straight from the source tree; once the +# package is installed, `library(cir)` is enough.) + +if (requireNamespace("devtools", quietly = TRUE) && + dir.exists("crates/ci-r")) { + suppressMessages(devtools::load_all("crates/ci-r", quiet = TRUE)) +} else { + library(cir) +} + +stopifnot(requireNamespace("pcalg", quietly = TRUE)) + +set.seed(42) +n <- 500 +# Chain X -> Y -> Z: Y depends on X, Z depends on Y. Conditioning on Y should +# render X and Z independent. +X <- rnorm(n) +Y <- X + rnorm(n) +Z <- Y + rnorm(n) +df <- data.frame(X = X, Y = Y, Z = Z) + +data <- dataset(df) +indepTest <- as_pcalg(pearson_correlation(data)) + +# suffStat is ignored by our adapter (the data is captured in the test), so an +# empty list suffices. +fit <- pcalg::pc( + suffStat = list(), + indepTest = indepTest, + labels = colnames(df), + alpha = 0.05 +) + +cat("pcalg::pc() completed.\n") +cat("Estimated CPDAG adjacency matrix:\n") +print(as(fit@graph, "matrix")) + +# Sanity touch on the adapter independence direction. +cat(sprintf("\nMarginal X--Z p-value: %.4g\n", indepTest(1, 3, integer(0), list()))) +cat(sprintf("X--Z | Y p-value (should be high): %.4g\n", indepTest(1, 3, c(2L), list()))) +cat("\npcalg demo OK\n") diff --git a/crates/ci-r/man/Dataset.Rd b/crates/ci-r/man/Dataset.Rd new file mode 100644 index 0000000..4dcc683 --- /dev/null +++ b/crates/ci-r/man/Dataset.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/extendr-wrappers.R +\docType{data} +\name{Dataset} +\alias{Dataset} +\title{A named, typed, immutable table of columns shared by the test handles.} +\format{ +An object of class \code{environment} of length 4. +} +\usage{ +Dataset +} +\description{ +Built from R via the \code{new} constructor, which takes the column names, a +parallel vector of kind strings (\code{"discrete"} / \code{"continuous"}), and the +columns flattened into a single \code{f64} matrix (column-major, one dataset +column per matrix column). Discrete columns are factorized into integer +codes inside the core. The handle is cheap to clone (it is \code{Arc}-shared) so +several tests can bind the same factorization. +} +\section{Methods}{ + +\subsection{Method \code{new}}{ +Build a dataset from \code{names}, parallel \code{kinds}, and a \code{values} matrix +whose \code{j}-th column holds the values of column \code{names[j]}. + +The R wrapper (\code{dataset()}) is responsible for coding factor/character +columns to numbers before calling this, so \code{values} is always numeric. +} + +\subsection{Method \code{n_rows}}{ +Number of rows (observations). +} + +\subsection{Method \code{n_cols}}{ +Number of columns. +} + +\subsection{Method \code{index_of}}{ +1-based index of the column named \code{name}, or \code{NA} if it is absent. +} +} + +\keyword{datasets} diff --git a/crates/ci-r/man/as_pcalg.Rd b/crates/ci-r/man/as_pcalg.Rd new file mode 100644 index 0000000..4a0dad3 --- /dev/null +++ b/crates/ci-r/man/as_pcalg.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{as_pcalg} +\alias{as_pcalg} +\title{Adapt a \code{cir_test} into a \pkg{pcalg} \code{indepTest} callback.} +\usage{ +as_pcalg(test) +} +\arguments{ +\item{test}{A \code{cir_test} from one of the factory functions.} +} +\value{ +A function \verb{(x, y, S, suffStat) -> numeric} p-value. +} +\description{ +Returns a closure \verb{function(x, y, S, suffStat)} matching the interface +\code{\link[pcalg:pc]{pcalg::pc()}} / \code{\link[pcalg:skeleton]{pcalg::skeleton()}} expect: \code{x} and \code{y} are \strong{1-based} +integer node indices, \code{S} is an integer vector of conditioning node indices +(possibly empty), and the return value is the test's p-value. The node +indices are mapped back to the bound dataset's column names in their bound +order, so pass \code{labels = colnames(df)} (in the same order) to the pcalg +driver. \code{suffStat} is accepted for interface compatibility and ignored (the +data is already captured in \code{test}); pass \code{suffStat = list()}. +} +\examples{ +\dontrun{ +df <- data.frame(X = rnorm(200), Y = rnorm(200), Z = rnorm(200)) +indepTest <- as_pcalg(pearson_correlation(dataset(df))) +pcalg::pc(suffStat = list(), indepTest = indepTest, + labels = colnames(df), alpha = 0.05) +} +} diff --git a/crates/ci-r/man/chi_squared.Rd b/crates/ci-r/man/chi_squared.Rd new file mode 100644 index 0000000..1827e9d --- /dev/null +++ b/crates/ci-r/man/chi_squared.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{chi_squared} +\alias{chi_squared} +\title{Chi-squared (Pearson, lambda = 1) discrete CI test.} +\usage{ +chi_squared(data, yates = TRUE) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} + +\item{yates}{Whether to apply Yates' continuity correction on 2x2 +sub-tables (default \code{TRUE}, the scipy/pgmpy convention).} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Chi-squared (Pearson, lambda = 1) discrete CI test. +} +\examples{ +df <- data.frame(A = sample(0:1, 80, TRUE), B = sample(0:1, 80, TRUE)) +chi <- chi_squared(dataset(df)) +run_test(chi, "A", "B") +} diff --git a/crates/ci-r/man/ci_test.Rd b/crates/ci-r/man/ci_test.Rd new file mode 100644 index 0000000..a1feff1 --- /dev/null +++ b/crates/ci-r/man/ci_test.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{ci_test} +\alias{ci_test} +\title{Run a test and return a base-R \code{htest} object.} +\usage{ +ci_test(test, x, y, z = character()) +} +\arguments{ +\item{test}{A \code{cir_test} from one of the factory functions.} + +\item{x, y}{Column names for the two variables.} + +\item{z}{Character vector of conditioning column names (default: none).} +} +\value{ +An object of class \code{htest}. +} +\description{ +A convenience wrapper for base-R / \pkg{bnlearn}-style workflows: the result +carries the \code{statistic} and the \code{parameter} (degrees of freedom) alongside +the \code{p.value}. +} diff --git a/crates/ci-r/man/cir-package.Rd b/crates/ci-r/man/cir-package.Rd new file mode 100644 index 0000000..17a9305 --- /dev/null +++ b/crates/ci-r/man/cir-package.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\docType{package} +\name{cir-package} +\alias{cir} +\alias{cir-package} +\title{cir: data-bound conditional-independence tests} +\description{ +A small, function-style R surface over the Rust core. Bind a data.frame once +with \code{\link[=dataset]{dataset()}}, construct a test with one of the factory functions +(\code{\link[=chi_squared]{chi_squared()}}, \code{\link[=pearson_correlation]{pearson_correlation()}}, ...), then query it with +\code{\link[=run_test]{run_test()}} / \code{\link[=is_independent]{is_independent()}}. \code{\link[=as_pcalg]{as_pcalg()}} adapts any test into the +\code{indepTest} callback shape expected by the \pkg{pcalg} package. +} +\details{ +Column kinds are inferred from the data.frame: \code{integer}/\code{logical}/\code{factor}/ +\code{character} columns are treated as \strong{discrete}, \code{double} columns as +\strong{continuous}. Discrete factor/character columns are coded to integer codes +on the R side before they reach the Rust core (which only handles numbers). +} +\author{ +\strong{Maintainer}: GIP House \email{giphouse@example.com} + +} +\keyword{internal} diff --git a/crates/ci-r/man/cressie_read.Rd b/crates/ci-r/man/cressie_read.Rd new file mode 100644 index 0000000..10dbc15 --- /dev/null +++ b/crates/ci-r/man/cressie_read.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{cressie_read} +\alias{cressie_read} +\title{Cressie-Read (lambda = 2/3) discrete CI test.} +\usage{ +cressie_read(data, yates = TRUE) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} + +\item{yates}{Whether to apply Yates' continuity correction on 2x2 +sub-tables (default \code{TRUE}, the scipy/pgmpy convention).} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Cressie-Read (lambda = 2/3) discrete CI test. +} diff --git a/crates/ci-r/man/dataset.Rd b/crates/ci-r/man/dataset.Rd new file mode 100644 index 0000000..58b17ac --- /dev/null +++ b/crates/ci-r/man/dataset.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{dataset} +\alias{dataset} +\title{Bind a data.frame as a \code{cir} dataset.} +\usage{ +dataset(df) +} +\arguments{ +\item{df}{A data.frame (or object coercible to one).} +} +\value{ +An object of class \code{cir_dataset} wrapping the bound dataset and its +column names. +} +\description{ +Builds the data-bound dataset shared by the test factories. Column kinds are +inferred from the column classes (integer/logical/factor/character -> +discrete, double -> continuous); discrete factor/character columns are coded +to integer codes before reaching the Rust core. +} +\details{ +Missing values are \strong{rejected}: any \code{NA} (in a numeric column, a factor, +or after coding a character column) reaches the core as NaN and errors with +"missing data: ...". Choose a missing-data convention (e.g. +\code{na.omit(df)}) before binding. +} +\examples{ +df <- data.frame(A = sample(0:1, 50, TRUE), X = rnorm(50)) +data <- dataset(df) +} diff --git a/crates/ci-r/man/dot-cir_as_dataset.Rd b/crates/ci-r/man/dot-cir_as_dataset.Rd new file mode 100644 index 0000000..3a4f906 --- /dev/null +++ b/crates/ci-r/man/dot-cir_as_dataset.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{.cir_as_dataset} +\alias{.cir_as_dataset} +\title{Coerce a \code{dataset()} result or a data.frame into a \code{cir_dataset}.} +\usage{ +.cir_as_dataset(data) +} +\arguments{ +\item{data}{A \code{cir_dataset} or a data.frame.} +} +\value{ +A \code{cir_dataset}. +} +\description{ +Lets every test factory accept either a pre-built \code{\link[=dataset]{dataset()}} or a raw +data.frame uniformly. +} +\keyword{internal} diff --git a/crates/ci-r/man/dot-cir_as_names.Rd b/crates/ci-r/man/dot-cir_as_names.Rd new file mode 100644 index 0000000..b5b08fe --- /dev/null +++ b/crates/ci-r/man/dot-cir_as_names.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{.cir_as_names} +\alias{.cir_as_names} +\title{Normalize a conditioning-set argument to a character vector.} +\usage{ +.cir_as_names(z) +} +\arguments{ +\item{z}{The \code{z} argument as supplied by the caller.} +} +\value{ +A character vector (possibly empty). +} +\description{ +Accepts \code{NULL}, a character vector, or a single name; never a non-character. +} +\keyword{internal} diff --git a/crates/ci-r/man/dot-cir_code_column.Rd b/crates/ci-r/man/dot-cir_code_column.Rd new file mode 100644 index 0000000..0595968 --- /dev/null +++ b/crates/ci-r/man/dot-cir_code_column.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{.cir_code_column} +\alias{.cir_code_column} +\title{Code one data.frame column to a numeric vector for the Rust core.} +\usage{ +.cir_code_column(col) +} +\arguments{ +\item{col}{A single data.frame column.} +} +\value{ +A numeric vector the same length as \code{col}. +} +\description{ +Discrete factor/character/logical columns are mapped to integer codes (the +core re-factorizes them anyway, so the exact codes only need to be +value-distinct); numeric columns pass through as doubles. +} +\keyword{internal} diff --git a/crates/ci-r/man/dot-cir_infer_kind.Rd b/crates/ci-r/man/dot-cir_infer_kind.Rd new file mode 100644 index 0000000..1e5df9e --- /dev/null +++ b/crates/ci-r/man/dot-cir_infer_kind.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{.cir_infer_kind} +\alias{.cir_infer_kind} +\title{Infer the column kind ("discrete" or "continuous") for one data.frame column.} +\usage{ +.cir_infer_kind(col) +} +\arguments{ +\item{col}{A single data.frame column.} +} +\value{ +A scalar string, \code{"discrete"} or \code{"continuous"}. +} +\description{ +\code{double} columns are continuous; everything else +(\code{integer}/\code{logical}/\code{factor}/\code{character}) is discrete. +} +\keyword{internal} diff --git a/crates/ci-r/man/dot-cir_make_test.Rd b/crates/ci-r/man/dot-cir_make_test.Rd new file mode 100644 index 0000000..773bd41 --- /dev/null +++ b/crates/ci-r/man/dot-cir_make_test.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{.cir_make_test} +\alias{.cir_make_test} +\title{Build a \code{cir_test} wrapper around a constructed extendr test handle.} +\usage{ +.cir_make_test(handle, ds, name) +} +\arguments{ +\item{handle}{The external-pointer test handle from \verb{$new(...)}.} + +\item{ds}{The bound \code{cir_dataset} (carries column names for pcalg mapping).} + +\item{name}{The test's stable name (e.g. \code{"chi_squared"}).} +} +\value{ +An object of class \code{cir_test}. +} +\description{ +Build a \code{cir_test} wrapper around a constructed extendr test handle. +} +\keyword{internal} diff --git a/crates/ci-r/man/fisher_z.Rd b/crates/ci-r/man/fisher_z.Rd new file mode 100644 index 0000000..7285f96 --- /dev/null +++ b/crates/ci-r/man/fisher_z.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{fisher_z} +\alias{fisher_z} +\title{Fisher-z continuous CI test.} +\usage{ +fisher_z(data) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Applies the Fisher z-transform to the (partial) correlation: +\verb{sqrt(n - |Z| - 3) * atanh(rho)} is approximately standard normal under +independence. This matches \code{pcalg::gaussCItest}'s statistic and is the +de-facto standard continuous CI test in constraint-based causal discovery. +} diff --git a/crates/ci-r/man/freeman_tukey.Rd b/crates/ci-r/man/freeman_tukey.Rd new file mode 100644 index 0000000..b01a8b9 --- /dev/null +++ b/crates/ci-r/man/freeman_tukey.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{freeman_tukey} +\alias{freeman_tukey} +\title{Freeman-Tukey (lambda = -1/2) discrete CI test.} +\usage{ +freeman_tukey(data, yates = TRUE) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} + +\item{yates}{Whether to apply Yates' continuity correction on 2x2 +sub-tables (default \code{TRUE}, the scipy/pgmpy convention).} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Freeman-Tukey (lambda = -1/2) discrete CI test. +} diff --git a/crates/ci-r/man/is_independent.Rd b/crates/ci-r/man/is_independent.Rd new file mode 100644 index 0000000..6ba5251 --- /dev/null +++ b/crates/ci-r/man/is_independent.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{is_independent} +\alias{is_independent} +\title{Decide conditional independence at a significance level.} +\usage{ +is_independent(test, x, y, z = character(), significance_level = 0.05) +} +\arguments{ +\item{test}{A \code{cir_test} from one of the factory functions.} + +\item{x, y}{Column names for the two variables.} + +\item{z}{Character vector of conditioning column names (default: none).} + +\item{significance_level}{Significance level alpha (default \code{0.05}).} +} +\value{ +A single logical: \code{TRUE} if independent at \code{significance_level}. +} +\description{ +Applies the test's own decision rule: \code{p >= significance_level} for the +standard tests, and the inverted \code{p < significance_level} for +\code{\link[=pearson_equivalence]{pearson_equivalence()}}. +} diff --git a/crates/ci-r/man/log_likelihood.Rd b/crates/ci-r/man/log_likelihood.Rd new file mode 100644 index 0000000..3afa4e9 --- /dev/null +++ b/crates/ci-r/man/log_likelihood.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{log_likelihood} +\alias{log_likelihood} +\title{Log-likelihood / G-test (lambda = 0) discrete CI test.} +\usage{ +log_likelihood(data, yates = TRUE) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} + +\item{yates}{Whether to apply Yates' continuity correction on 2x2 +sub-tables (default \code{TRUE}, the scipy/pgmpy convention).} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Log-likelihood / G-test (lambda = 0) discrete CI test. +} diff --git a/crates/ci-r/man/modified_likelihood.Rd b/crates/ci-r/man/modified_likelihood.Rd new file mode 100644 index 0000000..62d1d66 --- /dev/null +++ b/crates/ci-r/man/modified_likelihood.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{modified_likelihood} +\alias{modified_likelihood} +\title{Modified log-likelihood (lambda = -1) discrete CI test.} +\usage{ +modified_likelihood(data, yates = TRUE) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} + +\item{yates}{Whether to apply Yates' continuity correction on 2x2 +sub-tables (default \code{TRUE}, the scipy/pgmpy convention).} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Modified log-likelihood (lambda = -1) discrete CI test. +} diff --git a/crates/ci-r/man/pearson_correlation.Rd b/crates/ci-r/man/pearson_correlation.Rd new file mode 100644 index 0000000..87ac378 --- /dev/null +++ b/crates/ci-r/man/pearson_correlation.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{pearson_correlation} +\alias{pearson_correlation} +\title{Pearson partial-correlation continuous CI test.} +\usage{ +pearson_correlation(data) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Pearson partial-correlation continuous CI test. +} diff --git a/crates/ci-r/man/pearson_equivalence.Rd b/crates/ci-r/man/pearson_equivalence.Rd new file mode 100644 index 0000000..b2f67c2 --- /dev/null +++ b/crates/ci-r/man/pearson_equivalence.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{pearson_equivalence} +\alias{pearson_equivalence} +\title{Pearson equivalence (TOST) continuous CI test.} +\usage{ +pearson_equivalence(data, delta_threshold = 0.1) +} +\arguments{ +\item{data}{A \code{\link[=dataset]{dataset()}} result or a data.frame.} + +\item{delta_threshold}{Equivalence margin on the correlation scale +(default \code{0.1}).} +} +\value{ +A \code{cir_test} bound to \code{data}. +} +\description{ +Declares independence when the partial correlation is within the equivalence +margin. Note the inverted decision rule: independence holds when the p-value +is \strong{below} the significance level (handled by \code{\link[=is_independent]{is_independent()}}). +} diff --git a/crates/ci-r/man/pearson_equivalence_test.Rd b/crates/ci-r/man/pearson_equivalence_test.Rd deleted file mode 100644 index f345329..0000000 --- a/crates/ci-r/man/pearson_equivalence_test.Rd +++ /dev/null @@ -1,20 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/extendr-wrappers.R -\name{pearson_equivalence_test} -\alias{pearson_equivalence_test} -\title{Pearson equivalence CI test (TOST): declares independence when the partial correlation -lies within \verb{[-delta_threshold, delta_threshold]}.} -\usage{ -pearson_equivalence_test( - x_values, - y_values, - z, - boolean, - significance_level, - delta_threshold -) -} -\description{ -Pass a 0-column matrix for \code{z} to run unconditionally. When \code{boolean} is \code{true}, -returns an independence verdict instead of the raw p-value and correlation. -} diff --git a/crates/ci-r/man/run_test.Rd b/crates/ci-r/man/run_test.Rd new file mode 100644 index 0000000..df72699 --- /dev/null +++ b/crates/ci-r/man/run_test.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cir.R +\name{run_test} +\alias{run_test} +\title{Run a conditional-independence test.} +\usage{ +run_test(test, x, y, z = character()) +} +\arguments{ +\item{test}{A \code{cir_test} from one of the factory functions.} + +\item{x, y}{Column \strong{names} (length-1 character) for the two variables.} + +\item{z}{Character vector of conditioning column names (default: none).} +} +\value{ +A named list \code{list(statistic, p_value, dof, effect_size)}; absent +fields are \code{NULL}. +} +\description{ +Run a conditional-independence test. +} +\examples{ +df <- data.frame(A = sample(0:1, 80, TRUE), B = sample(0:1, 80, TRUE)) +run_test(chi_squared(dataset(df)), "A", "B") +} diff --git a/crates/ci-r/src/Makevars.in b/crates/ci-r/src/Makevars.in index a687d41..1eda3d5 100644 --- a/crates/ci-r/src/Makevars.in +++ b/crates/ci-r/src/Makevars.in @@ -13,8 +13,8 @@ CARGOTMP = $(CURDIR)/.cargo VENDOR_DIR = $(CURDIR)/vendor -# RUSTFLAGS appends --print=native-static-libs to ensure that -# the correct linkers are used. Use this for debugging if need. +# RUSTFLAGS appends --print=native-static-libs to ensure that +# the correct linkers are used. Use this for debugging if need. # # CRAN note: Cargo and Rustc versions are reported during # configure via tools/msrv.R. diff --git a/crates/ci-r/src/Makevars.win.in b/crates/ci-r/src/Makevars.win.in index 6f72063..5a69d38 100644 --- a/crates/ci-r/src/Makevars.win.in +++ b/crates/ci-r/src/Makevars.win.in @@ -36,10 +36,13 @@ $(STATLIB): export LIBRARY_PATH="$(LIBRARY_PATH);$(CURDIR)/$(TARGET_DIR)/libgcc_mock" && \ RUSTFLAGS="$(RUSTFLAGS) --print=native-static-libs" cargo build @CRAN_FLAGS@ --target=$(TARGET) --lib @PROFILE@ --manifest-path=rust/Cargo.toml --target-dir=$(TARGET_DIR) - # Generate wrappers + # Generate wrappers. + # NOTE: RUSTFLAGS and @PROFILE@ must match the `cargo build --lib` invocation + # above; otherwise cargo treats this as a fresh fingerprint and rebuilds every + # dependency from scratch (#1087). export CARGO_HOME=$(CARGOTMP) && \ export PATH="$(PATH):$(HOME)/.cargo/bin" && \ - cargo run @CRAN_FLAGS@ --bin document --target $(TARGET) --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR) + RUSTFLAGS="$(RUSTFLAGS)" cargo run @CRAN_FLAGS@ --bin document --target $(TARGET) @PROFILE@ --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR) # Always clean up CARGOTMP rm -Rf $(CARGOTMP); diff --git a/crates/ci-r/src/rust/Cargo.toml b/crates/ci-r/src/rust/Cargo.toml index eab52f0..c86b08b 100644 --- a/crates/ci-r/src/rust/Cargo.toml +++ b/crates/ci-r/src/rust/Cargo.toml @@ -15,5 +15,4 @@ path = 'document.rs' [dependencies] ci_core = { path = "../../../ci-core" } -anyhow = "1" -extendr-api = { version = '0.9.0', features = ['ndarray'] } +extendr-api = '0.9.0' diff --git a/crates/ci-r/src/rust/src/lib.rs b/crates/ci-r/src/rust/src/lib.rs index 23f04ae..e4fa300 100644 --- a/crates/ci-r/src/rust/src/lib.rs +++ b/crates/ci-r/src/rust/src/lib.rs @@ -1,123 +1,276 @@ -//! R bindings for the conditional independence testing library. +//! R bindings for the data-bound conditional-independence testing core. //! -//! Exposes CI test functions to R via the `extendr` framework. -//! Each CI test accepts paired observation vectors and a conditioning matrix, returning -//! a named R list whose shape depends on whether the test runs in boolean or numeric mode. +//! The surface mirrors `ci_core`'s redesigned contract: a [`Dataset`] is built +//! once from named, typed columns, and each test is a *data-bound* handle that +//! captures the dataset (plus its own configuration) and then answers +//! `run_test(x, y, z)` / `is_independent(...)` queries by column **name**. +//! +//! These extendr exports are the low-level layer; the user-facing R API +//! (`dataset()`, the test factories, `run_test()`, `is_independent()`, +//! `as_pcalg()`) lives in `R/cir.R` and wraps the external-pointer objects +//! created here. +//! +//! Discrete columns must already be numeric when they reach Rust: the R side +//! factor/character → integer coding happens before [`Dataset::new`], so the +//! core only ever sees `f64` values (which it factorizes into contiguous codes). +//! Core [`ci_core::error::CiError`]s and Rust panics surface as R errors via +//! extendr's `Result` support. -use ci_core::ci_tests::{ - ChiSquared, CressieRead, FreemanTukey, LogLikelihood, ModifiedLikelihood, PearsonCorrelation, - PearsonEquivalence, -}; -use ci_core::strategy::CITest; +// Every fallible export returns `extendr_api::Result<_>`, which extendr turns +// into an R error; the failure modes (unknown column, wrong column kind, +// dimension mismatch, degenerate data) are documented once here rather than +// repeated on every method. +#![allow(clippy::missing_errors_doc)] + +use std::sync::Arc; + +use ci_core::dataset::{ColumnKind, Dataset as CoreDataset}; +use ci_core::strategy::{CITest, CiResult as CoreResult}; use extendr_api::prelude::*; -use ndarray::{ArrayView1, ArrayView2}; +use extendr_api::Result; + mod util; -/// Generates an R-callable wrapper for a [`CITest`] implementation. +use util::{result_to_list, to_r_error}; + +/// A named, typed, immutable table of columns shared by the test handles. /// -/// The generated function signature is: -/// ```text -/// fn $fn_name(x_values, y_values, z, boolean, significance_level) -> Robj -/// ``` -/// - `x_values` / `y_values`: paired observation vectors. -/// - `z`: conditioning matrix; pass a 0-column matrix for unconditional tests. -/// - `boolean`: when `true`, returns only an independence verdict at `significance_level` -/// instead of the raw test statistic and p-value. -/// - `significance_level`: threshold used only when `boolean` is `true`. -macro_rules! r_ci_test { - ($(#[$meta:meta])* $fn_name:ident, $inner:ty) => { - $(#[$meta])* +/// Built from R via the `new` constructor, which takes the column names, a +/// parallel vector of kind strings (`"discrete"` / `"continuous"`), and the +/// columns flattened into a single `f64` matrix (column-major, one dataset +/// column per matrix column). Discrete columns are factorized into integer +/// codes inside the core. The handle is cheap to clone (it is `Arc`-shared) so +/// several tests can bind the same factorization. +#[extendr] +#[derive(Clone)] +struct Dataset { + inner: Arc, +} + +/// Parse a kind string into a [`ColumnKind`]. +fn parse_kind(kind: &str) -> Result { + match kind { + "discrete" => Ok(ColumnKind::Discrete), + "continuous" => Ok(ColumnKind::Continuous), + other => Err(Error::Other(format!( + "column kind must be \"discrete\" or \"continuous\", got {other:?}" + ))), + } +} + +#[extendr] +impl Dataset { + /// Build a dataset from `names`, parallel `kinds`, and a `values` matrix + /// whose `j`-th column holds the values of column `names[j]`. + /// + /// The R wrapper (`dataset()`) is responsible for coding factor/character + /// columns to numbers before calling this, so `values` is always numeric. + fn new(names: Strings, kinds: Strings, values: RMatrix) -> Result { + let n_cols = names.len(); + if kinds.len() != n_cols { + return Err(Error::Other(format!( + "names ({n_cols}) and kinds ({}) must have the same length", + kinds.len() + ))); + } + let nrow = values.nrows(); + let ncol = values.ncols(); + if ncol != n_cols { + return Err(Error::Other(format!( + "values has {ncol} columns but {n_cols} names were given" + ))); + } + let data = values.data(); + let mut cols: Vec<(String, ColumnKind, Vec)> = Vec::with_capacity(n_cols); + for (j, (name, kind)) in names.iter().zip(kinds.iter()).enumerate() { + let kind = parse_kind(kind.as_ref())?; + // Column-major: column `j` occupies the contiguous slice + // `[j*nrow, (j+1)*nrow)`. + let start = j * nrow; + let col = data[start..start + nrow].to_vec(); + cols.push((name.to_string(), kind, col)); + } + let inner = CoreDataset::from_columns(cols).map_err(to_r_error)?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + /// Number of rows (observations). + fn n_rows(&self) -> i32 { + // Row counts here are far below i32::MAX; the cast cannot overflow. + i32::try_from(self.inner.n_rows()).unwrap_or(i32::MAX) + } + + /// Number of columns. + fn n_cols(&self) -> i32 { + i32::try_from(self.inner.n_cols()).unwrap_or(i32::MAX) + } + + /// 1-based index of the column named `name`, or `NA` if it is absent. + fn index_of(&self, name: &str) -> Robj { + match self.inner.index_of(name) { + Some(idx) => { + // 1-based for R. Counts stay well within i32. + let one_based = i32::try_from(idx + 1).unwrap_or(i32::MAX); + one_based.into() + } + None => Robj::from(NA_INTEGER), + } + } +} + +impl Dataset { + /// Resolve a single column name to a validated 0-based core index. + fn column(&self, name: &str) -> Result { + self.inner + .index_of(name) + .ok_or_else(|| Error::Other(format!("unknown column name: {name:?}"))) + } + + /// Resolve a conditioning set (a vector of names) to 0-based core indices. + fn columns(&self, names: &Strings) -> Result> { + names.iter().map(|n| self.column(n.as_ref())).collect() + } + + /// Resolve `x`, `y`, and the conditioning names `z` to 0-based indices. + fn resolve(&self, x: &str, y: &str, z: &Strings) -> Result<(usize, usize, Vec)> { + Ok((self.column(x)?, self.column(y)?, self.columns(z)?)) + } +} + +/// Run an inner test for `x ⟂ y | z` (names) against `data`, returning the +/// uniform result list. Shared by every test handle's `run_test`. +fn run_inner(test: &dyn CITest, data: &Dataset, x: &str, y: &str, z: &Strings) -> Result { + let (xi, yi, zi) = data.resolve(x, y, z)?; + let result: CoreResult = test.test(&data.inner, xi, yi, &zi).map_err(to_r_error)?; + Ok(result_to_list(&result)) +} + +/// Decide independence for `x ⟂ y | z` at `significance_level` using the inner +/// test's declared rule. Shared by every test handle's `is_independent`. +fn is_independent_inner( + test: &dyn CITest, + data: &Dataset, + x: &str, + y: &str, + z: &Strings, + significance_level: f64, +) -> Result { + let (xi, yi, zi) = data.resolve(x, y, z)?; + test.is_independent(&data.inner, xi, yi, &zi, significance_level) + .map_err(to_r_error) +} + +/// Generate a data-bound extendr test handle for a concrete [`CITest`]. +/// +/// Each generated struct stores the shared [`Dataset`] handle plus a constructed +/// inner test, and exposes the uniform `run_test` / `is_independent` surface. +/// The struct definition carries `#[extendr]` (so it gains `Robj` conversions) +/// and the impl block carries `#[extendr]` (so its methods become R-callable). +/// The constructor (`new`) takes a `Dataset` external pointer plus the test's +/// configuration arguments, mapped onto the concrete test via `$build`. +macro_rules! ci_test_handle { + ( + $wrapper:ident, + $core:ty, + new($($arg:ident : $arg_ty:ty),* $(,)?) $build:block + ) => { + #[doc = concat!("Data-bound `", stringify!($wrapper), "` test handle.")] + #[extendr] + struct $wrapper { + data: Dataset, + inner: $core, + } + #[extendr] - fn $fn_name( - x_values: ArrayView1, - y_values: ArrayView1, - z: ArrayView2, - boolean: bool, - significance_level: f64, - ) -> anyhow::Result { - let citest = <$inner>::new(boolean, significance_level); - let result = citest.run_test(x_values.to_owned(), y_values.to_owned(), z.to_owned())?; - Ok(util::test_result_to_robj(result)) + impl $wrapper { + /// Construct the handle bound to `data` with the given config. + fn new(data: &Dataset $(, $arg: $arg_ty)*) -> Self { + let data = data.clone(); + let inner = $build; + Self { data, inner } + } + + /// Run the test for `x ⟂ y | z` (column names; `z` a character + /// vector), returning `list(statistic, p_value, dof, effect_size)`. + fn run_test(&self, x: &str, y: &str, z: Strings) -> Result { + run_inner(&self.inner, &self.data, x, y, &z) + } + + /// Decide independence at `significance_level` using the test's rule. + fn is_independent( + &self, + x: &str, + y: &str, + z: Strings, + significance_level: f64, + ) -> Result { + is_independent_inner(&self.inner, &self.data, x, y, &z, significance_level) + } } }; } -r_ci_test!( - /// Pearson chi-squared conditional independence test (λ = 1). - /// - /// Operates on discrete data only. Pass a 0-column matrix for `z` to run - /// unconditionally. When `boolean` is `true`, returns an independence verdict - /// instead of the raw test statistic and p-value. - chi_squared_test, ChiSquared +ci_test_handle!( + ChiSquared, + ci_core::ci_tests::ChiSquared, + new(yates: bool) { ci_core::ci_tests::ChiSquared { yates } } ); -r_ci_test!( - /// Log-likelihood ratio (G-test) conditional independence test (λ = 0). - /// - /// Operates on discrete data only. Pass a 0-column matrix for `z` to run - /// unconditionally. When `boolean` is `true`, returns an independence verdict - /// instead of the raw test statistic and p-value. - log_likelihood_test, LogLikelihood + +ci_test_handle!( + LogLikelihood, + ci_core::ci_tests::LogLikelihood, + new(yates: bool) { ci_core::ci_tests::LogLikelihood { yates } } ); -r_ci_test!( - /// Cressie–Read conditional independence test (λ = 2/3). - /// - /// Operates on discrete data only. Pass a 0-column matrix for `z` to run - /// unconditionally. When `boolean` is `true`, returns an independence verdict - /// instead of the raw test statistic and p-value. - cressie_read_test, CressieRead + +ci_test_handle!( + CressieRead, + ci_core::ci_tests::CressieRead, + new(yates: bool) { ci_core::ci_tests::CressieRead { yates } } ); -r_ci_test!( - /// Pearson partial correlation conditional independence test. - /// - /// Should be used on continuous data only. When `z` is non-empty, uses linear - /// regression residuals to compute the partial correlation. Pass a 0-column - /// matrix for `z` to run unconditionally. When `boolean` is `true`, returns an - /// independence verdict instead of the raw test statistic and p-value. - pearson_correlation_test, PearsonCorrelation + +ci_test_handle!( + FreemanTukey, + ci_core::ci_tests::FreemanTukey, + new(yates: bool) { ci_core::ci_tests::FreemanTukey { yates } } ); -r_ci_test!( - /// Freeman–Tukey conditional independence test (λ = −1/2). - /// - /// Operates on discrete data only. Pass a 0-column matrix for `z` to run - /// unconditionally. When `boolean` is `true`, returns an independence verdict - /// instead of the raw test statistic and p-value. - freeman_tukey_test, FreemanTukey + +ci_test_handle!( + ModifiedLikelihood, + ci_core::ci_tests::ModifiedLikelihood, + new(yates: bool) { ci_core::ci_tests::ModifiedLikelihood { yates } } ); -r_ci_test!( - /// Modified log-likelihood ratio conditional independence test (λ = −1). - /// - /// Operates on discrete data only. Pass a 0-column matrix for `z` to run - /// unconditionally. When `boolean` is `true`, returns an independence verdict - /// instead of the raw test statistic and p-value. - modified_likelihood_test, ModifiedLikelihood + +ci_test_handle!( + PearsonCorrelation, + ci_core::ci_tests::PearsonCorrelation, + new() { ci_core::ci_tests::PearsonCorrelation::new() } ); -/// Pearson equivalence CI test (TOST): declares independence when the partial correlation -/// lies within `[-delta_threshold, delta_threshold]`. -/// -/// Pass a 0-column matrix for `z` to run unconditionally. When `boolean` is `true`, -/// returns an independence verdict instead of the raw p-value and correlation. -#[extendr] -fn pearson_equivalence_test( - x_values: ArrayView1, - y_values: ArrayView1, - z: ArrayView2, - boolean: bool, - significance_level: f64, - delta_threshold: f64, -) -> anyhow::Result { - let citest = PearsonEquivalence::new(boolean, significance_level, delta_threshold); - let result = citest.run_test(x_values.to_owned(), y_values.to_owned(), z.to_owned())?; - Ok(util::test_result_to_robj(result)) -} +ci_test_handle!( + FisherZ, + ci_core::ci_tests::FisherZ, + new() { ci_core::ci_tests::FisherZ::new() } +); + +ci_test_handle!( + PearsonEquivalence, + ci_core::ci_tests::PearsonEquivalence, + new(delta_threshold: f64) { + ci_core::ci_tests::PearsonEquivalence { delta_threshold } + } +); extendr_module! { mod cir; - fn chi_squared_test; - fn log_likelihood_test; - fn cressie_read_test; - fn pearson_correlation_test; - fn freeman_tukey_test; - fn modified_likelihood_test; - fn pearson_equivalence_test; + impl Dataset; + impl ChiSquared; + impl LogLikelihood; + impl CressieRead; + impl FreemanTukey; + impl ModifiedLikelihood; + impl PearsonCorrelation; + impl FisherZ; + impl PearsonEquivalence; } diff --git a/crates/ci-r/src/rust/src/util.rs b/crates/ci-r/src/rust/src/util.rs index 4804aaa..0815c81 100644 --- a/crates/ci-r/src/rust/src/util.rs +++ b/crates/ci-r/src/rust/src/util.rs @@ -1,26 +1,36 @@ -//! Conversion utilities from core result types to R objects. +//! Conversion helpers from core types to R objects and errors. -use ci_core::strategy::TestResult; +use ci_core::error::CiError as CoreError; +use ci_core::strategy::CiResult as CoreResult; use extendr_api::prelude::*; -/// Converts a [`TestResult`] into a named R list. +/// Map a core [`CoreError`] onto an extendr [`Error`], which extendr raises as +/// an R error at the call site. +pub fn to_r_error(err: CoreError) -> Error { + Error::Other(err.to_string()) +} + +/// Convert a core [`CoreResult`] into the uniform named R list +/// `list(statistic, p_value, dof, effect_size)`. /// -/// The returned list always contains a `kind` field identifying the variant: -/// - `"pvalue"` — also contains `p_value` and `coefficient`. -/// - `"statistic"` — also contains `statistic`, `p_value`, and `df` (degrees of freedom). -/// - `"boolean"` — also contains `independent` (logical). -pub fn test_result_to_robj(r: TestResult) -> Robj { - match r { - TestResult::PValue(p, coef) => { - list!(kind = "pvalue", p_value = p, coefficient = coef,).into() - } - TestResult::Statistic(p, stat, df) => list!( - kind = "statistic", - statistic = stat, - p_value = p, - df = df as i32, - ) - .into(), - TestResult::Boolean(b) => list!(kind = "boolean", independent = b,).into(), - } +/// Absent optional fields (`statistic`, `dof`, `effect_size`) become R `NULL`; +/// `p_value` is always present. `dof` is returned as an integer. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "dof is a small degrees-of-freedom count, far below i32::MAX" +)] +pub fn result_to_list(result: &CoreResult) -> Robj { + list!( + statistic = opt_or_null(result.statistic), + p_value = result.p_value, + dof = opt_or_null(result.dof.map(|d| d as i32)), + effect_size = opt_or_null(result.effect_size), + ) + .into() +} + +/// Wrap `Some(v)` as its R scalar and `None` as R `NULL` (not `NA`). +fn opt_or_null>(v: Option) -> Robj { + v.map_or_else(|| r!(NULL), Into::into) } diff --git a/crates/ci-r/tests/testthat/test-api.R b/crates/ci-r/tests/testthat/test-api.R new file mode 100644 index 0000000..f3a133a --- /dev/null +++ b/crates/ci-r/tests/testthat/test-api.R @@ -0,0 +1,197 @@ +# Surface tests for the data-bound R API: dataset binding, result shapes, +# factor coding, the equivalence decision inversion, error handling, the htest +# adapter, and the pcalg closure. + +library(cir) + +discrete_df <- function() { + set.seed(0) + data.frame( + A = sample(0:1, 200, TRUE), + B = sample(0:1, 200, TRUE), + C = sample(0:2, 200, TRUE) + ) +} + +continuous_df <- function() { + set.seed(1) + data.frame( + X = rnorm(200), + Y = rnorm(200), + Z = rnorm(200) + ) +} + +test_that("dataset() infers kinds and reports shape / index", { + data <- dataset(discrete_df()) + expect_s3_class(data, "cir_dataset") + expect_equal(data$ptr$n_rows(), 200L) + expect_equal(data$ptr$n_cols(), 3L) + expect_equal(data$ptr$index_of("A"), 1L) # 1-based + expect_equal(data$ptr$index_of("C"), 3L) + expect_true(is.na(data$ptr$index_of("nope"))) + expect_equal(data$kinds, c("discrete", "discrete", "discrete")) +}) + +test_that("continuous columns are inferred as continuous", { + data <- dataset(continuous_df()) + expect_equal(unname(data$kinds), c("continuous", "continuous", "continuous")) +}) + +test_that("run_test returns the uniform list for discrete tests", { + chi <- chi_squared(dataset(discrete_df())) + res <- run_test(chi, "A", "B") + expect_named(res, c("statistic", "p_value", "dof", "effect_size")) + expect_true(is.numeric(res$statistic)) + expect_true(is.numeric(res$p_value)) + expect_true(is.numeric(res$dof)) +}) + +test_that("z defaults to an empty conditioning set", { + chi <- chi_squared(dataset(discrete_df())) + expect_equal(run_test(chi, "A", "B")$p_value, run_test(chi, "A", "B", character())$p_value) +}) + +test_that("conditioning on a name works", { + chi <- chi_squared(dataset(discrete_df())) + res <- run_test(chi, "A", "C", c("B")) + expect_true(is.numeric(res$p_value)) +}) + +test_that("is_independent returns a single logical", { + chi <- chi_squared(dataset(discrete_df())) + out <- is_independent(chi, "A", "C", c("B"), significance_level = 0.05) + expect_true(is.logical(out)) + expect_length(out, 1L) +}) + +test_that("pearson_equivalence has NULL dof and the inverted rule", { + eqv <- pearson_equivalence(dataset(continuous_df()), delta_threshold = 0.1) + res <- run_test(eqv, "X", "Y", c("Z")) + expect_null(res$dof) + expect_true(is.numeric(res$p_value)) + # The equivalence rule is independent <=> p < alpha. With alpha = 1 every + # finite p-value is < 1, so it must report independent; with alpha = 0 none + # can be, so it must report dependent. This pins the inversion direction. + expect_true(is_independent(eqv, "X", "Y", c("Z"), significance_level = 1)) + expect_false(is_independent(eqv, "X", "Y", c("Z"), significance_level = 0)) +}) + +test_that("the standard rule is the non-inverted direction", { + chi <- chi_squared(dataset(discrete_df())) + # Standard rule: independent <=> p >= alpha. alpha = 0 => always independent; + # alpha = 1 => never (finite p < 1). + expect_true(is_independent(chi, "A", "B", character(), significance_level = 0)) + expect_false(is_independent(chi, "A", "B", character(), significance_level = 1)) +}) + +test_that("pearson_correlation takes no config", { + res <- run_test(pearson_correlation(dataset(continuous_df())), "X", "Y") + # The Pearson correlation t-test reports dof = n - |Z| - 2 (here 200 - 0 - 2). + expect_equal(res$dof, 198L) + expect_true(is.numeric(res$p_value)) +}) + +test_that("factory functions accept a raw data.frame", { + chi <- chi_squared(discrete_df()) + expect_s3_class(chi, "cir_test") + expect_true(is.numeric(run_test(chi, "A", "B")$p_value)) +}) + +test_that("factor and character columns are coded to discrete", { + set.seed(3) + df <- data.frame( + F = factor(sample(c("a", "b", "c"), 200, TRUE)), + G = sample(c("yes", "no"), 200, TRUE), + stringsAsFactors = FALSE + ) + data <- dataset(df) + expect_equal(unname(data$kinds), c("discrete", "discrete")) + res <- run_test(chi_squared(data), "F", "G") + expect_true(is.numeric(res$statistic)) + expect_equal(res$dof, 2L) # (3-1)*(2-1) +}) + +test_that("a dataset can be shared across tests", { + data <- dataset(continuous_df()) + a <- run_test(pearson_correlation(data), "X", "Y") + b <- run_test(pearson_equivalence(data, delta_threshold = 0.2), "X", "Y") + expect_true(is.numeric(a$p_value)) + expect_true(is.numeric(b$p_value)) +}) + +test_that("unknown column names raise an error", { + chi <- chi_squared(dataset(discrete_df())) + expect_error(run_test(chi, "A", "nope"), "unknown column") +}) + +test_that("a continuous column handed to a discrete test errors", { + chi <- chi_squared(dataset(continuous_df())) + expect_error(run_test(chi, "X", "Y"), "wrong column kind") +}) + +test_that("ci_test returns an htest with statistic, parameter, p.value", { + chi <- chi_squared(dataset(discrete_df())) + # Unconditional 2x2 table (A, B both binary) => dof = (2-1)*(2-1) = 1. + h <- ci_test(chi, "A", "B") + expect_s3_class(h, "htest") + expect_true(is.numeric(h$statistic)) + expect_equal(unname(h$parameter), 1) + expect_true(is.numeric(h$p.value)) +}) + +test_that("as_pcalg returns a (x, y, S, suffStat) -> p.value closure", { + data <- dataset(continuous_df()) + indep <- as_pcalg(pearson_correlation(data)) + expect_type(indep, "closure") + # 1-based node indices into the bound columns (X=1, Y=2, Z=3). + p_uncond <- indep(1, 2, integer(0), list()) + p_cond <- indep(1, 2, c(3L), list()) + expect_true(is.numeric(p_uncond) && length(p_uncond) == 1L) + expect_true(is.numeric(p_cond) && length(p_cond) == 1L) + # Matches the direct run_test p-value. + expect_equal(p_uncond, run_test(pearson_correlation(data), "X", "Y")$p_value) +}) + +test_that("fisher_z runs and reports no dof", { + set.seed(11) + n <- 80 + z <- rnorm(n) + df <- data.frame( + X = 1.2 * z + 0.4 * rnorm(n), + Y = 0.7 * z + 0.4 * rnorm(n), + Z = z + ) + fz <- fisher_z(dataset(df)) + res <- run_test(fz, "X", "Y", c("Z")) + expect_null(res$dof) + expect_true(res$p_value >= 0 && res$p_value <= 1) + # statistic = sqrt(n - |Z| - 3) * atanh(r) + pc <- run_test(pearson_correlation(dataset(df)), "X", "Y", c("Z")) + expect_equal(res$statistic, sqrt(n - 1 - 3) * atanh(pc$statistic), tolerance = 1e-9) +}) + +test_that("NA values error at dataset construction", { + df <- data.frame(A = c(1L, NA_integer_, 0L), X = c(0.1, 0.2, 0.3)) + expect_error(dataset(df), "missing data") + df2 <- data.frame(A = c(0L, 1L, 0L), X = c(0.1, NA_real_, 0.3)) + expect_error(dataset(df2), "missing data") + df3 <- data.frame(A = factor(c("u", NA, "v")), X = c(0.1, 0.2, 0.3)) + expect_error(dataset(df3), "missing data") + # Character columns are coded via a separate branch of .cir_code_column. + df4 <- data.frame(A = c("u", NA, "v"), X = c(0.1, 0.2, 0.3), + stringsAsFactors = FALSE) + expect_error(dataset(df4), "missing data") +}) + +test_that("invalid queries error", { + df <- data.frame( + A = sample(0:1, 40, TRUE), + B = sample(0:1, 40, TRUE), + C = sample(0:1, 40, TRUE) + ) + chi <- chi_squared(dataset(df)) + expect_error(run_test(chi, "A", "A"), "invalid query") + expect_error(run_test(chi, "A", "B", c("A")), "invalid query") + expect_error(run_test(chi, "A", "B", c("C", "C")), "invalid query") +}) diff --git a/crates/ci-r/tests/testthat/test-chi_squared.R b/crates/ci-r/tests/testthat/test-chi_squared.R deleted file mode 100644 index 7997f36..0000000 --- a/crates/ci-r/tests/testthat/test-chi_squared.R +++ /dev/null @@ -1,45 +0,0 @@ -library(cir) - -test_that("independent data is not rejected", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- chi_squared_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic < EPS) - expect_true(result$p_value >= 0.99) - expect_equal(result$df, 1) -}) - -test_that("dependent data is rejected", { - x <- c(1., 1., 1., 1., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 2., 2., 2., 2.) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- chi_squared_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(abs(result$statistic - 8.0) < EPS) - expect_true(abs(result$p_value - 0.004677734981047276) < EPS) - expect_equal(result$df, 1) -}) - -test_that("boolean mode returns independent=TRUE for independent data", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- chi_squared_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_true(result$independent) -}) - -test_that("boolean mode returns independent=FALSE for dependent data", { - x <- c(1., 1., 1., 1., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 2., 2., 2., 2.) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- chi_squared_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_false(result$independent) -}) diff --git a/crates/ci-r/tests/testthat/test-cressie_read.R b/crates/ci-r/tests/testthat/test-cressie_read.R deleted file mode 100644 index a74da06..0000000 --- a/crates/ci-r/tests/testthat/test-cressie_read.R +++ /dev/null @@ -1,78 +0,0 @@ -library(cir) - -test_that("unconditional independent data is not rejected", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- cressie_read_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic < EPS) - expect_true(result$p_value > 0.99) - expect_equal(result$df, 1) -}) - -test_that("unconditional boolean accepts independent data", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- cressie_read_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_true(result$independent) -}) - -test_that("unconditional dependent data is rejected", { - x <- c(1., 1., 1., 1., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 2., 2., 2., 2.) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- cressie_read_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic > 5.0) - expect_true(result$p_value < 0.05) - expect_equal(result$df, 1) -}) - -test_that("unconditional boolean rejects dependent data", { - x <- c(1., 1., 1., 1., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 2., 2., 2., 2.) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- cressie_read_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_false(result$independent) -}) - -test_that("conditional independent data is not rejected", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(c(0., 0., 0., 0., 1., 1., 1., 1.), nrow = 8, ncol = 1) - - result <- cressie_read_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic < EPS) - expect_true(result$p_value > 0.99) - expect_equal(result$df, 2) -}) - -test_that("conditional boolean accepts conditionally independent data", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(c(0., 0., 0., 0., 1., 1., 1., 1.), nrow = 8, ncol = 1) - - result <- cressie_read_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_true(result$independent) -}) - -test_that("conditional dependent data is rejected", { - x <- c(1., 1., 2., 2., 1., 1., 2., 2.) - y <- c(1., 1., 2., 2., 1., 1., 2., 2.) - z <- matrix(c(0., 0., 0., 0., 1., 1., 1., 1.), nrow = 8, ncol = 1) - - result <- cressie_read_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic > 5.0) - expect_true(result$p_value < 0.05) -}) diff --git a/crates/ci-r/tests/testthat/test-freeman_tukey.R b/crates/ci-r/tests/testthat/test-freeman_tukey.R deleted file mode 100644 index e1f7622..0000000 --- a/crates/ci-r/tests/testthat/test-freeman_tukey.R +++ /dev/null @@ -1,35 +0,0 @@ -library(cir) - -test_that("independent data is not rejected", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- freeman_tukey_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic < EPS) - expect_true(result$p_value >= 0.99) - expect_equal(result$df, 1) -}) - -test_that("dependent data is rejected", { - x <- c(1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.) - z <- matrix(0, nrow = 12, ncol = 0) - - result <- freeman_tukey_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(abs(result$statistic - 6.319453539579289) < EPS) - expect_true(abs(result$p_value - 0.011942042564347121) < EPS) - expect_equal(result$df, 1) -}) - -test_that("boolean mode returns independent=TRUE for independent data", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- freeman_tukey_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_true(result$independent) -}) diff --git a/crates/ci-r/tests/testthat/test-golden.R b/crates/ci-r/tests/testthat/test-golden.R new file mode 100644 index 0000000..cd7c872 --- /dev/null +++ b/crates/ci-r/tests/testthat/test-golden.R @@ -0,0 +1,151 @@ +# Golden parity test for the data-bound R bindings. +# +# Loads the shared cross-language fixture `tests/fixtures/golden.json` and, for +# each of the eight tests, builds a `dataset()` from the case's columns, +# constructs the test with the case's parameters, runs it, and asserts that +# `statistic` / `p_value` / `dof` match the recorded `expected` values within a +# tight tolerance. This is the binding's numeric parity gate against the +# scipy/pgmpy reference. + +library(cir) + +TOL <- 1e-7 +EXPECTED_CASE_COUNT <- 80L + +# Map the fixture's stable test name to its binding factory function. +TEST_FACTORIES <- list( + chi_squared = chi_squared, + log_likelihood = log_likelihood, + cressie_read = cressie_read, + freeman_tukey = freeman_tukey, + modified_likelihood = modified_likelihood, + pearson_correlation = pearson_correlation, + fisher_z = fisher_z, + pearson_equivalence = pearson_equivalence +) + +# Locate the repo-root fixture. Under `devtools::test()`, `test_path()` points +# at this directory (crates/ci-r/tests/testthat), so the repo root is four +# levels up; fall back to walking up from the working directory. +find_golden <- function() { + candidate <- testthat::test_path("..", "..", "..", "..", "tests", "fixtures", "golden.json") + if (file.exists(candidate)) { + return(normalizePath(candidate)) + } + dir <- normalizePath(getwd()) + repeat { + candidate <- file.path(dir, "tests", "fixtures", "golden.json") + if (file.exists(candidate)) { + return(candidate) + } + parent <- dirname(dir) + if (identical(parent, dir)) { + break + } + dir <- parent + } + stop("could not locate tests/fixtures/golden.json") +} + +load_cases <- function() { + jsonlite::fromJSON(find_golden(), simplifyVector = FALSE) +} + +# Build a `cir_dataset` from a fixture case's `columns` map. Discrete columns +# are coerced to integer (so `dataset()` infers "discrete"); continuous columns +# stay double ("continuous"). +build_dataset <- function(columns) { + cols <- lapply(columns, function(col) { + values <- as.numeric(unlist(col$values)) + if (identical(col$kind, "discrete")) { + as.integer(values) + } else { + as.double(values) + } + }) + df <- as.data.frame(cols, stringsAsFactors = FALSE, check.names = FALSE) + dataset(df) +} + +construct_test <- function(name, data, params) { + factory <- TEST_FACTORIES[[name]] + if (name %in% c("pearson_correlation", "fisher_z")) { + factory(data) + } else if (name == "pearson_equivalence") { + factory(data, delta_threshold = params$delta_threshold) + } else { + # Discrete power-divergence family: configured by `yates`. + factory(data, yates = params$yates) + } +} + +# Compare one numeric field against its expected value. Null expectations are +# skipped (the test does not define them); Inf / NaN are matched defensively. +assert_close <- function(actual, expected, field, case_id) { + if (is.null(expected)) { + return(invisible()) + } + expect_false(is.null(actual), + label = sprintf("%s: expected %s=%s, got NULL", case_id, field, expected) + ) + exp <- as.numeric(expected) + act <- as.numeric(actual) + if (is.infinite(exp) || is.nan(exp)) { + expect_equal(is.infinite(act), is.infinite(exp), + label = sprintf("%s: %s inf mismatch", case_id, field) + ) + expect_equal(is.nan(act), is.nan(exp), + label = sprintf("%s: %s nan mismatch", case_id, field) + ) + if (is.infinite(exp)) { + expect_equal(sign(act), sign(exp), + label = sprintf("%s: %s inf sign mismatch", case_id, field) + ) + } + return(invisible()) + } + expect_equal(act, exp, + tolerance = TOL, + label = sprintf("%s: %s got %s, expected %s", case_id, field, act, exp) + ) +} + +GOLDEN_CASES <- load_cases() + +test_that("golden fixture covers exactly the expected cases", { + expect_equal(length(GOLDEN_CASES), EXPECTED_CASE_COUNT) + names_seen <- unique(vapply(GOLDEN_CASES, function(c) c$test, character(1))) + expect_setequal(names_seen, names(TEST_FACTORIES)) +}) + +test_that("each golden case reproduces its recorded statistic / p_value / dof", { + for (i in seq_along(GOLDEN_CASES)) { + case <- GOLDEN_CASES[[i]] + case_id <- sprintf("case[%d] %s", i - 1L, case$test) + + data <- build_dataset(case$columns) + test <- construct_test(case$test, data, case$params) + + z <- as.character(unlist(case$z)) + if (length(z) == 0L) { + z <- character() + } + result <- run_test(test, case$x, case$y, z) + expected <- case$expected + + assert_close(result$statistic, expected$statistic, "statistic", case_id) + + # p-value: handle an exact-zero expectation defensively. + exp_p <- as.numeric(expected$p_value) + if (identical(exp_p, 0)) { + expect_equal(as.numeric(result$p_value), 0, tolerance = TOL, + label = sprintf("%s: p_value expected ~0, got %s", case_id, result$p_value) + ) + } else { + assert_close(result$p_value, exp_p, "p_value", case_id) + } + + dof_actual <- if (is.null(result$dof)) NULL else as.numeric(result$dof) + assert_close(dof_actual, expected$dof, "dof", case_id) + } +}) diff --git a/crates/ci-r/tests/testthat/test-log_likelihood.R b/crates/ci-r/tests/testthat/test-log_likelihood.R deleted file mode 100644 index 3afc365..0000000 --- a/crates/ci-r/tests/testthat/test-log_likelihood.R +++ /dev/null @@ -1,35 +0,0 @@ -library(cir) - -test_that("independent data is not rejected", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- log_likelihood_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic < EPS) - expect_true(result$p_value >= 0.99) - expect_equal(result$df, 1) -}) - -test_that("dependent data is rejected", { - x <- c(1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.) - z <- matrix(0, nrow = 12, ncol = 0) - - result <- log_likelihood_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(abs(result$statistic - 5.822063320647374) < EPS) - expect_true(abs(result$p_value - 0.015826368796540195) < EPS) - expect_equal(result$df, 1) -}) - -test_that("boolean mode returns independent=FALSE for dependent data", { - x <- c(1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.) - z <- matrix(0, nrow = 12, ncol = 0) - - result <- log_likelihood_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_false(result$independent) -}) diff --git a/crates/ci-r/tests/testthat/test-modified_likelihood.R b/crates/ci-r/tests/testthat/test-modified_likelihood.R deleted file mode 100644 index 096b320..0000000 --- a/crates/ci-r/tests/testthat/test-modified_likelihood.R +++ /dev/null @@ -1,35 +0,0 @@ -library(cir) - -test_that("independent data is not rejected", { - x <- c(1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0) - y <- c(1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0) - z <- matrix(0, nrow = 8, ncol = 0) - - result <- modified_likelihood_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(result$statistic < EPS) - expect_true(result$p_value >= 0.99) - expect_equal(result$df, 1) -}) - -test_that("dependent data is rejected", { - x <- c(1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.) - z <- matrix(0, nrow = 12, ncol = 0) - - result <- modified_likelihood_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "statistic") - expect_true(abs(result$statistic - 7.053439978825427) < EPS) - expect_true(abs(result$p_value - 0.007911317670556329) < EPS) - expect_equal(result$df, 1) -}) - -test_that("boolean mode returns independent=FALSE for dependent data", { - x <- c(1., 1., 1., 1., 1., 1., 2., 2., 2., 2., 2., 2.) - y <- c(1., 1., 1., 1., 1., 2., 1., 2., 2., 2., 2., 2.) - z <- matrix(0, nrow = 12, ncol = 0) - - result <- modified_likelihood_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_false(result$independent) -}) diff --git a/crates/ci-r/tests/testthat/test-pearson_correlation.R b/crates/ci-r/tests/testthat/test-pearson_correlation.R deleted file mode 100644 index 0ff8bcb..0000000 --- a/crates/ci-r/tests/testthat/test-pearson_correlation.R +++ /dev/null @@ -1,134 +0,0 @@ -library(cir) -n <- 1000 - -test_that("unconditional independent data is not rejected", { - set.seed(42) - x <- rnorm(n) - y <- rnorm(n) - z <- matrix(0, nrow = n, ncol = 0) - - result <- pearson_correlation_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "pvalue") - expect_true(result$p_value > 0.05) - expect_true(abs(result$coefficient) < 0.1) -}) - -test_that("unconditional boolean accepts independent data", { - set.seed(42) - x <- rnorm(n) - y <- rnorm(n) - z <- matrix(0, nrow = n, ncol = 0) - - result <- pearson_correlation_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_true(result$independent) -}) - -test_that("unconditional dependent data is rejected", { - set.seed(42) - x <- rnorm(n) - noise <- rnorm(n, sd = 0.1) - y <- 3 * x + noise - z <- matrix(0, nrow = n, ncol = 0) - - result <- pearson_correlation_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "pvalue") - expect_true(result$p_value < 0.05) - expect_true(abs(result$coefficient) > 0.9) -}) - -test_that("unconditional boolean rejects dependent data", { - set.seed(42) - x <- rnorm(n) - noise <- rnorm(n, sd = 0.1) - y <- 3 * x + noise - z <- matrix(0, nrow = n, ncol = 0) - - result <- pearson_correlation_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_false(result$independent) -}) - -test_that("conditional independent data is not rejected", { - set.seed(42) - z_col <- rnorm(n) - noise_x <- rnorm(n, sd = 0.1) - noise_y <- rnorm(n, sd = 0.1) - x <- 3 * z_col + noise_x - y <- 2 * z_col + noise_y - z <- matrix(z_col, nrow = n, ncol = 1) - - result <- pearson_correlation_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "pvalue") - expect_true(result$p_value > 0.05) - expect_true(abs(result$coefficient) < 0.1) -}) - -test_that("conditional boolean accepts conditionally independent data", { - set.seed(42) - z_col <- rnorm(n) - noise_x <- rnorm(n, sd = 0.1) - noise_y <- rnorm(n, sd = 0.1) - x <- 3 * z_col + noise_x - y <- 2 * z_col + noise_y - z <- matrix(z_col, nrow = n, ncol = 1) - - result <- pearson_correlation_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_true(result$independent) -}) - -test_that("conditional dependent data (v-structure collider) is rejected", { - set.seed(42) - x <- rnorm(n) - y <- rnorm(n) - noise <- rnorm(n, sd = 0.1) - z_col <- 2 * x + 2 * y + noise - z <- matrix(z_col, nrow = n, ncol = 1) - - result <- pearson_correlation_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "pvalue") - expect_true(result$p_value < 0.05) - expect_true(abs(result$coefficient) > 0.9) -}) - -test_that("conditional boolean rejects dependent data (v-structure collider)", { - set.seed(42) - x <- rnorm(n) - y <- rnorm(n) - noise <- rnorm(n, sd = 0.1) - z_col <- 2 * x + 2 * y + noise - z <- matrix(z_col, nrow = n, ncol = 1) - - result <- pearson_correlation_test(x, y, z, TRUE, 0.05) - expect_equal(result$kind, "boolean") - expect_false(result$independent) -}) - -test_that("conditional independent data with multiple conditioning variables is not rejected", { - set.seed(42) - z1 <- rnorm(n) - z2 <- rnorm(n) - z3 <- rnorm(n) - noise_x <- rnorm(n, sd = 0.1) - noise_y <- rnorm(n, sd = 0.1) - x <- 0.5 * z1 + 0.5 * z2 + 0.5 * z3 + noise_x - y <- 0.5 * z1 + 0.5 * z2 + 0.5 * z3 + noise_y - z <- cbind(z1, z2, z3) - - result <- pearson_correlation_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "pvalue") - expect_true(result$p_value >= 0.05) - expect_true(abs(result$coefficient) <= 0.1) -}) - -test_that("minimum input (n=3) with perfect correlation returns coefficient near 1", { - x <- c(1.0, 2.0, 3.0) - y <- c(1.0, 2.0, 3.0) - z <- matrix(0, nrow = 3, ncol = 0) - - result <- pearson_correlation_test(x, y, z, FALSE, 0.05) - expect_equal(result$kind, "pvalue") - expect_true(abs(result$coefficient - 1.0) < 1e-10) - expect_true(result$p_value < 0.05) -}) diff --git a/crates/ci-r/tests/testthat/test-pearson_equivalence.R b/crates/ci-r/tests/testthat/test-pearson_equivalence.R deleted file mode 100644 index 3d51b12..0000000 --- a/crates/ci-r/tests/testthat/test-pearson_equivalence.R +++ /dev/null @@ -1,25 +0,0 @@ -library(cir) -n <- 1000 - -test_that("can call pearson_equivalence_test and get a pvalue result", { - set.seed(42) - x <- rnorm(n) - y <- rnorm(n) - z <- matrix(0, nrow = n, ncol = 0) - - result <- pearson_equivalence_test(x, y, z, FALSE, 0.05, 0.1) - expect_equal(result$kind, "pvalue") - expect_true(is.numeric(result$p_value)) - expect_true(is.numeric(result$coefficient)) -}) - -test_that("can call pearson_equivalence_test and get a boolean result", { - set.seed(42) - x <- rnorm(n) - y <- rnorm(n) - z <- matrix(0, nrow = n, ncol = 0) - - result <- pearson_equivalence_test(x, y, z, TRUE, 0.05, 0.1) - expect_equal(result$kind, "boolean") - expect_true(is.logical(result$independent)) -}) diff --git a/docs/superpowers/plans/2026-07-18-golden-fixture-parity.md b/docs/superpowers/plans/2026-07-18-golden-fixture-parity.md new file mode 100644 index 0000000..df1c28e --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-golden-fixture-parity.md @@ -0,0 +1,1659 @@ +# Golden Fixture Parity Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore a deterministic 80-case SciPy/pgmpy parity fixture, verify it independently in CI, and make every binding assert the complete result contract. + +**Architecture:** A standalone Python reference generator owns fixed input scenarios and computes expected statistics with NumPy/SciPy, never with Rust. It commits a canonical JSON artifact consumed read-only by Rust, Python, R, and JavaScript; a separate CI job regenerates the artifact in check mode to detect drift. + +**Tech Stack:** Python 3.12, NumPy 2.4.2, SciPy 1.17.0, pytest, Ruff, Rust/serde, PyO3/pytest, extendr/testthat, wasm-bindgen/Vitest, GitHub Actions. + +## Global Constraints + +- Preserve the existing public Rust, Python, R, and JavaScript APIs. +- Emit exactly 80 cases: 12 for each of five discrete tests, 7 Pearson correlation, 6 Pearson equivalence, and 7 Fisher-Z. +- Compute reference values without importing `ci_core` or any binding. +- Keep every JSON number finite; error, NaN, and infinity behavior stays in unit tests. +- Give every case a unique lowercase kebab-case `id` and report it in binding failures. +- Canonicalize computed floats to 12 significant digits and serialize strict JSON with sorted keys, two-space indentation, and a trailing newline. +- Compare binding results with absolute tolerance `1e-7` and zero relative tolerance. +- Pin fixture generation to `numpy==2.4.2` and `scipy==1.17.0`. +- Normal binding tests consume the committed JSON and must not require NumPy or SciPy solely for reference generation. +- Do not restore or add benchmarks in this change. + +--- + +## File Structure + +- `tests/fixtures/generate_golden.py`: deterministic scenario catalog, independent reference formulas, validation, canonical serialization, atomic write, and `--check` CLI. +- `tests/fixtures/test_generate_golden.py`: focused tests for the generator contract, determinism, strict serialization, and drift behavior. +- `tests/fixtures/requirements.txt`: exact NumPy/SciPy oracle versions. +- `tests/fixtures/golden.json`: generated 80-case cross-language contract. +- `crates/ci-core/tests/golden.rs`: Rust consumer; deserialize and report stable IDs. +- `crates/ci-python/test/test_golden.py`: Python consumer; report IDs and assert effect size. +- `crates/ci-js/tests/golden.test.js`: JavaScript consumer; report IDs and assert effect size. +- `crates/ci-r/tests/testthat/test-golden.R`: R consumer; report IDs and assert effect size. +- `crates/ci-r/DESCRIPTION`: declare `jsonlite` as a test dependency. +- `.github/workflows/python.yml`: add the platform-independent fixture drift job and correct case counts. +- `.github/workflows/{rust,r,js}.yml`: correct stale case-count comments. +- `README.md`, `CONTRIBUTING.md`: document generation/checking and remove the nonexistent benchmark-tree entry. + +--- + +### Task 1: Build the independent 80-case reference catalog + +**Files:** + +- Create: `tests/fixtures/requirements.txt` +- Create: `tests/fixtures/test_generate_golden.py` +- Create: `tests/fixtures/generate_golden.py` + +**Interfaces:** + +- Consumes: NumPy and SciPy only. +- Produces: `build_cases() -> list[dict[str, Any]]` and `validate_cases(cases: list[dict[str, Any]]) -> None`. + +- [ ] **Step 1: Add the pinned oracle dependencies** + +Create `tests/fixtures/requirements.txt`: + +```text +numpy==2.4.2 +scipy==1.17.0 +``` + +Install the focused test tools and pinned references: + +```bash +python -m pip install pytest ruff -r tests/fixtures/requirements.txt +``` + +Expected: pip installs the two pinned reference libraries plus pytest and Ruff without dependency conflicts. + +- [ ] **Step 2: Write the failing catalog test** + +Create `tests/fixtures/test_generate_golden.py`: + +```python +from __future__ import annotations + +import importlib.util +from collections import Counter +from copy import deepcopy +from pathlib import Path +from types import ModuleType + +import pytest + + +FIXTURE_DIR = Path(__file__).resolve().parent +GENERATOR_PATH = FIXTURE_DIR / "generate_golden.py" +GOLDEN_PATH = FIXTURE_DIR / "golden.json" + +EXPECTED_COUNTS = { + "chi_squared": 12, + "log_likelihood": 12, + "cressie_read": 12, + "freeman_tukey": 12, + "modified_likelihood": 12, + "pearson_correlation": 7, + "pearson_equivalence": 6, + "fisher_z": 7, +} + + +def load_generator() -> ModuleType: + assert GENERATOR_PATH.exists(), f"missing generator: {GENERATOR_PATH}" + spec = importlib.util.spec_from_file_location("generate_golden", GENERATOR_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def by_id(cases: list[dict], case_id: str) -> dict: + return next(case for case in cases if case["id"] == case_id) + + +def test_reference_catalog_has_the_approved_contract() -> None: + generator = load_generator() + cases = generator.build_cases() + generator.validate_cases(cases) + + assert len(cases) == 80 + assert Counter(case["test"] for case in cases) == Counter(EXPECTED_COUNTS) + assert len({case["id"] for case in cases}) == 80 + + balanced = by_id(cases, "discrete-balanced-2x2-yates-chi-squared") + assert balanced["expected"] == { + "statistic": pytest.approx(0.0, abs=1e-12), + "p_value": pytest.approx(1.0, abs=1e-12), + "dof": 1, + "effect_size": pytest.approx(0.0, abs=1e-12), + } + + positive = by_id(cases, "pearson-correlation-positive-unconditional") + assert positive["expected"]["statistic"] == pytest.approx(0.8, abs=1e-10) + assert positive["expected"]["effect_size"] == pytest.approx(0.8, abs=1e-10) + assert positive["expected"]["dof"] == 14 + + perfect = by_id(cases, "fisher-z-perfect-correlation") + assert perfect["expected"]["statistic"] > 0.0 + assert perfect["expected"]["effect_size"] == pytest.approx(1.0, abs=1e-10) + assert perfect["expected"]["dof"] is None + + +def test_validation_rejects_duplicate_ids() -> None: + generator = load_generator() + cases = deepcopy(generator.build_cases()) + cases[1]["id"] = cases[0]["id"] + + with pytest.raises(ValueError, match="duplicate case id"): + generator.validate_cases(cases) + + +def test_validation_rejects_nonfinite_results() -> None: + generator = load_generator() + cases = deepcopy(generator.build_cases()) + cases[0]["expected"]["statistic"] = float("inf") + + with pytest.raises(ValueError, match="statistic must be finite"): + generator.validate_cases(cases) + + +def test_validation_rejects_mismatched_column_lengths() -> None: + generator = load_generator() + cases = deepcopy(generator.build_cases()) + cases[0]["columns"]["X"]["values"].pop() + + with pytest.raises(ValueError, match="column lengths differ"): + generator.validate_cases(cases) +``` + +- [ ] **Step 3: Run the test and verify the missing generator is the failure** + +Run: + +```bash +pytest tests/fixtures/test_generate_golden.py::test_reference_catalog_has_the_approved_contract -v +``` + +Expected: FAIL at `assert GENERATOR_PATH.exists()` with `missing generator`, proving the test covers the missing pipeline. + +- [ ] **Step 4: Implement the deterministic catalog and reference formulas** + +Create `tests/fixtures/generate_golden.py` with this catalog/oracle layer. Serialization and CLI functions are deliberately deferred to Task 2 so their tests can be written first. + +```python +#!/usr/bin/env python3 +"""Generate the language-neutral CI-test parity fixture from independent references.""" + +from __future__ import annotations + +import math +import re +from collections import Counter +from typing import Any + +import numpy as np +from scipy import stats + + +DISCRETE_LAMBDAS = { + "chi_squared": 1.0, + "log_likelihood": 0.0, + "cressie_read": 2.0 / 3.0, + "freeman_tukey": -0.5, + "modified_likelihood": -1.0, +} + +EXPECTED_COUNTS = { + "chi_squared": 12, + "log_likelihood": 12, + "cressie_read": 12, + "freeman_tukey": 12, + "modified_likelihood": 12, + "pearson_correlation": 7, + "pearson_equivalence": 6, + "fisher_z": 7, +} + +CASE_ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +EXPECTED_KEYS = {"statistic", "p_value", "dof", "effect_size"} + + +def _column(kind: str, values: list[int] | list[float]) -> dict[str, Any]: + return {"kind": kind, "values": values} + + +def _discrete_columns( + strata: list[tuple[tuple[int, ...], list[int], list[int], list[list[int]]]], +) -> tuple[dict[str, dict[str, Any]], list[str]]: + z_width = len(strata[0][0]) + z_names = [f"Z{i + 1}" for i in range(z_width)] + x_values: list[int] = [] + y_values: list[int] = [] + z_values: list[list[int]] = [[] for _ in range(z_width)] + + for z_key, x_levels, y_levels, counts in strata: + if len(z_key) != z_width or len(counts) != len(x_levels): + raise ValueError("invalid discrete scenario shape") + if any(len(row) != len(y_levels) for row in counts): + raise ValueError("invalid discrete table width") + for x_index, x_level in enumerate(x_levels): + for y_index, y_level in enumerate(y_levels): + count = counts[x_index][y_index] + if count <= 0: + raise ValueError("active contingency cells must be positive") + x_values.extend([x_level] * count) + y_values.extend([y_level] * count) + for z_index, z_level in enumerate(z_key): + z_values[z_index].extend([z_level] * count) + + columns = { + "X": _column("discrete", x_values), + "Y": _column("discrete", y_values), + } + columns.update( + { + name: _column("discrete", values) + for name, values in zip(z_names, z_values, strict=True) + } + ) + return columns, z_names + + +def _discrete_scenario( + scenario_id: str, + yates: bool, + strata: list[tuple[tuple[int, ...], list[int], list[int], list[list[int]]]], +) -> dict[str, Any]: + columns, z_names = _discrete_columns(strata) + return { + "scenario_id": scenario_id, + "yates": yates, + "columns": columns, + "x": "X", + "y": "Y", + "z": z_names, + } + + +def _discrete_scenarios() -> list[dict[str, Any]]: + associated = [((), [0, 1], [0, 1], [[12, 3], [4, 11]])] + unbalanced = [((), [0, 1], [0, 1], [[18, 4], [7, 9]])] + return [ + _discrete_scenario( + "discrete-balanced-2x2-yates", + True, + [((), [0, 1], [0, 1], [[8, 8], [8, 8]])], + ), + _discrete_scenario("discrete-associated-2x2-yates", True, associated), + _discrete_scenario("discrete-associated-2x2-no-yates", False, associated), + _discrete_scenario("discrete-unbalanced-2x2-yates", True, unbalanced), + _discrete_scenario("discrete-unbalanced-2x2-no-yates", False, unbalanced), + _discrete_scenario( + "discrete-2x3", + True, + [((), [0, 1], [0, 1, 2], [[9, 4, 7], [3, 8, 5]])], + ), + _discrete_scenario( + "discrete-3x2", + True, + [((), [0, 1, 2], [0, 1], [[8, 3], [4, 9], [6, 5]])], + ), + _discrete_scenario( + "discrete-3x3", + True, + [((), [0, 1, 2], [0, 1, 2], [[8, 3, 4], [5, 9, 2], [4, 6, 10]])], + ), + _discrete_scenario( + "discrete-conditioned-binary", + True, + [ + ((0,), [0, 1], [0, 1], [[8, 3], [4, 7]]), + ((1,), [0, 1], [0, 1], [[5, 6], [7, 4]]), + ], + ), + _discrete_scenario( + "discrete-conditioned-three-level", + False, + [ + ((0,), [0, 1], [0, 1], [[7, 2], [3, 6]]), + ((1,), [0, 1], [0, 1], [[4, 5], [6, 3]]), + ((2,), [0, 1], [0, 1], [[5, 3], [2, 8]]), + ], + ), + _discrete_scenario( + "discrete-conditioned-two-columns", + True, + [ + ((0, 0), [0, 1], [0, 1], [[5, 2], [3, 6]]), + ((0, 1), [0, 1], [0, 1], [[4, 3], [5, 2]]), + ((1, 0), [0, 1], [0, 1], [[6, 4], [2, 5]]), + ((1, 1), [0, 1], [0, 1], [[3, 5], [6, 4]]), + ], + ), + _discrete_scenario( + "discrete-sparse-active-levels", + True, + [ + ((0,), [0, 1], [0, 1], [[4, 2], [3, 5]]), + ((1,), [1, 2], [1, 2], [[2, 4], [5, 3]]), + ], + ), + ] + + +def _strata_indices(case: dict[str, Any]) -> list[np.ndarray]: + row_count = len(case["columns"][case["x"]]["values"]) + if not case["z"]: + return [np.arange(row_count)] + + groups: dict[tuple[float, ...], list[int]] = {} + for row in range(row_count): + key = tuple(case["columns"][name]["values"][row] for name in case["z"]) + groups.setdefault(key, []).append(row) + return [np.asarray(rows, dtype=np.int64) for rows in groups.values()] + + +def _discrete_expected(case: dict[str, Any], lambda_: float) -> dict[str, Any]: + x = np.asarray(case["columns"][case["x"]]["values"]) + y = np.asarray(case["columns"][case["y"]]["values"]) + statistic = 0.0 + dof = 0 + + for indices in _strata_indices(case): + x_active = np.unique(x[indices]) + y_active = np.unique(y[indices]) + observed = np.zeros((len(x_active), len(y_active)), dtype=np.float64) + x_index = {value: index for index, value in enumerate(x_active)} + y_index = {value: index for index, value in enumerate(y_active)} + for row in indices: + observed[x_index[x[row]], y_index[y[row]]] += 1.0 + + result = stats.chi2_contingency( + observed, + correction=case["yates"], + lambda_=lambda_, + ) + statistic += float(result.statistic) + dof += int(result.dof) + + p_value = float(stats.chi2.sf(statistic, dof)) + cardinality = min(len(np.unique(x)), len(np.unique(y))) + effect_size = math.sqrt(statistic / (len(x) * max(cardinality - 1, 1))) + return { + "statistic": statistic, + "p_value": p_value, + "dof": dof, + "effect_size": effect_size, + } + + +U = np.asarray( + [ + -0.40674460841, + -0.352511993955, + -0.298279379501, + -0.244046765046, + -0.189814150591, + -0.135581536137, + -0.081348921682, + -0.0271163072273, + 0.0271163072273, + 0.081348921682, + 0.135581536137, + 0.189814150591, + 0.244046765046, + 0.298279379501, + 0.352511993955, + 0.40674460841, + ] +) +V = np.asarray( + [ + 0.463099108522, + 0.277859465113, + 0.119082627906, + -0.0132314031006, + -0.119082627906, + -0.198471046509, + -0.251396658912, + -0.277859465113, + -0.277859465113, + -0.251396658912, + -0.198471046509, + -0.119082627906, + -0.0132314031006, + 0.119082627906, + 0.277859465113, + 0.463099108522, + ] +) +W = np.asarray( + [ + -0.453244808633, + -0.0906489617267, + 0.142448368428, + 0.265970030561, + 0.299838873404, + 0.263977745688, + 0.178309496144, + 0.0627569735031, + -0.0627569735031, + -0.178309496144, + -0.263977745688, + -0.299838873404, + -0.265970030561, + -0.142448368428, + 0.0906489617267, + 0.453244808633, + ] +) +T = np.asarray( + [ + 0.398089477628, + -0.132696492543, + -0.322262910461, + -0.293098846166, + -0.14727852469, + 0.0335386739394, + 0.188108214703, + 0.275600407589, + 0.275600407589, + 0.188108214703, + 0.0335386739394, + -0.14727852469, + -0.293098846166, + -0.322262910461, + -0.132696492543, + 0.398089477628, + ] +) + + +def _linear(*terms: tuple[float, np.ndarray], offset: float = 0.0) -> np.ndarray: + values = np.full(U.shape, offset, dtype=np.float64) + for coefficient, vector in terms: + values += coefficient * vector + return values + + +def _continuous_scenario( + scenario_id: str, + x: np.ndarray, + y: np.ndarray, + z: tuple[np.ndarray, ...] = (), +) -> dict[str, Any]: + columns = { + "X": _column("continuous", x.astype(float).tolist()), + "Y": _column("continuous", y.astype(float).tolist()), + } + z_names = [] + for index, values in enumerate(z, start=1): + name = f"Z{index}" + columns[name] = _column("continuous", values.astype(float).tolist()) + z_names.append(name) + return { + "scenario_id": scenario_id, + "columns": columns, + "x": "X", + "y": "Y", + "z": z_names, + } + + +def _pearson_scenarios() -> list[dict[str, Any]]: + return [ + _continuous_scenario("orthogonal-unconditional", U, V), + _continuous_scenario("positive-unconditional", U, _linear((0.8, U), (0.6, V))), + _continuous_scenario("negative-unconditional", U, _linear((-0.6, U), (0.8, V))), + _continuous_scenario( + "confounding-removed", + _linear((1.2, U), (0.6, V)), + _linear((-0.9, U), (0.8, W)), + (U,), + ), + _continuous_scenario( + "residual-association", + _linear((1.1, U), (1.0, V)), + _linear((-0.7, U), (0.7, V), (0.3, W)), + (U,), + ), + _continuous_scenario( + "two-conditioning-columns", + _linear((0.9, U), (-0.4, V), (0.8, W), (0.2, T)), + _linear((-0.5, U), (0.7, V), (-0.3, W), (0.9, T)), + (U, V), + ), + _continuous_scenario( + "scale-offset-invariance", + _linear((7.0, U), offset=10.0), + _linear((2.0, V), offset=-3.0), + ), + ] + + +def _equivalence_scenarios() -> list[tuple[dict[str, Any], float]]: + return [ + (_continuous_scenario("inside-orthogonal", U, V), 0.1), + ( + _continuous_scenario( + "inside-weak-positive", + U, + _linear((0.08, U), (math.sqrt(1.0 - 0.08**2), V)), + ), + 0.2, + ), + ( + _continuous_scenario( + "outside-positive", + U, + _linear((0.65, U), (math.sqrt(1.0 - 0.65**2), V)), + ), + 0.1, + ), + ( + _continuous_scenario( + "outside-negative", + U, + _linear((-0.55, U), (math.sqrt(1.0 - 0.55**2), V)), + ), + 0.15, + ), + ( + _continuous_scenario( + "inside-conditioned", + _linear((1.2, U), (1.0, V)), + _linear((-0.8, U), (0.05, V), (1.0, W)), + (U,), + ), + 0.15, + ), + ( + _continuous_scenario( + "outside-two-conditioning-columns", + _linear((0.3, U), (-0.4, V), (0.8, W), (0.2, T)), + _linear((-0.6, U), (0.5, V), (0.1, W), (0.9, T)), + (U, V), + ), + 0.2, + ), + ] + + +def _fisher_scenarios() -> list[dict[str, Any]]: + scenarios = _pearson_scenarios()[:6] + scenarios.append(_continuous_scenario("perfect-correlation", U, _linear((3.0, U), offset=2.0))) + return scenarios + + +def _partial_correlation(case: dict[str, Any]) -> tuple[float, int]: + x = np.asarray(case["columns"][case["x"]]["values"], dtype=np.float64) + y = np.asarray(case["columns"][case["y"]]["values"], dtype=np.float64) + if not case["z"]: + correlation = float(stats.pearsonr(x, y).statistic) + else: + z_columns = [ + np.asarray(case["columns"][name]["values"], dtype=np.float64) + for name in case["z"] + ] + design = np.column_stack([np.ones(len(x)), *z_columns]) + x_beta = np.linalg.lstsq(design, x, rcond=None)[0] + y_beta = np.linalg.lstsq(design, y, rcond=None)[0] + x_residual = x - design @ x_beta + y_residual = y - design @ y_beta + correlation = float(np.corrcoef(x_residual, y_residual)[0, 1]) + return correlation, len(x) - len(case["z"]) - 2 + + +def _pearson_expected(case: dict[str, Any]) -> dict[str, Any]: + correlation, dof = _partial_correlation(case) + t_statistic = correlation * math.sqrt(dof / (1.0 - correlation**2)) + return { + "statistic": correlation, + "p_value": float(2.0 * stats.t.sf(abs(t_statistic), df=dof)), + "dof": dof, + "effect_size": abs(correlation), + } + + +def _equivalence_expected(case: dict[str, Any], delta: float) -> dict[str, Any]: + correlation, _ = _partial_correlation(case) + rho = float(np.clip(correlation, -0.999999, 0.999999)) + coefficient = math.atanh(rho) + z_delta = math.atanh(delta) + scale = math.sqrt(len(case["columns"][case["x"]]["values"]) - len(case["z"]) - 3) + p_lower = 1.0 - float(stats.norm.cdf(scale * (coefficient + z_delta))) + p_upper = float(stats.norm.cdf(scale * (coefficient - z_delta))) + return { + "statistic": coefficient, + "p_value": max(p_lower, p_upper), + "dof": None, + "effect_size": abs(correlation), + } + + +def _fisher_expected(case: dict[str, Any]) -> dict[str, Any]: + correlation, _ = _partial_correlation(case) + rho = float(np.clip(correlation, -0.999999, 0.999999)) + scale = math.sqrt(len(case["columns"][case["x"]]["values"]) - len(case["z"]) - 3) + statistic = scale * math.atanh(rho) + return { + "statistic": statistic, + "p_value": float(2.0 * stats.norm.sf(abs(statistic))), + "dof": None, + "effect_size": abs(correlation), + } + + +def _case( + case_id: str, + test: str, + params: dict[str, Any], + scenario: dict[str, Any], + expected: dict[str, Any], +) -> dict[str, Any]: + return { + "id": case_id, + "test": test, + "params": params, + "columns": scenario["columns"], + "x": scenario["x"], + "y": scenario["y"], + "z": scenario["z"], + "expected": expected, + } + + +def build_cases() -> list[dict[str, Any]]: + cases: list[dict[str, Any]] = [] + for scenario in _discrete_scenarios(): + for test, lambda_ in DISCRETE_LAMBDAS.items(): + cases.append( + _case( + f"{scenario['scenario_id']}-{test.replace('_', '-')}", + test, + {"yates": scenario["yates"]}, + scenario, + _discrete_expected(scenario, lambda_), + ) + ) + + for scenario in _pearson_scenarios(): + cases.append( + _case( + f"pearson-correlation-{scenario['scenario_id']}", + "pearson_correlation", + {}, + scenario, + _pearson_expected(scenario), + ) + ) + + for scenario, delta in _equivalence_scenarios(): + cases.append( + _case( + f"pearson-equivalence-{scenario['scenario_id']}", + "pearson_equivalence", + {"delta_threshold": delta}, + scenario, + _equivalence_expected(scenario, delta), + ) + ) + + for scenario in _fisher_scenarios(): + cases.append( + _case( + f"fisher-z-{scenario['scenario_id']}", + "fisher_z", + {}, + scenario, + _fisher_expected(scenario), + ) + ) + return cases + + +def _finite_number(value: Any) -> bool: + return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value) + + +def validate_cases(cases: list[dict[str, Any]]) -> None: + counts = Counter(case.get("test") for case in cases) + if len(cases) != 80 or counts != Counter(EXPECTED_COUNTS): + raise ValueError(f"expected the approved 80-case distribution, got {dict(counts)}") + + ids: set[str] = set() + for case in cases: + case_id = case.get("id") + if not isinstance(case_id, str) or CASE_ID.fullmatch(case_id) is None: + raise ValueError(f"invalid case id: {case_id!r}") + if case_id in ids: + raise ValueError(f"duplicate case id: {case_id}") + ids.add(case_id) + + test = case["test"] + params = case["params"] + if test in DISCRETE_LAMBDAS: + if set(params) != {"yates"} or not isinstance(params["yates"], bool): + raise ValueError(f"{case_id}: invalid discrete params") + elif test == "pearson_equivalence": + if set(params) != {"delta_threshold"} or not _finite_number(params["delta_threshold"]): + raise ValueError(f"{case_id}: invalid equivalence params") + if not 0.0 < params["delta_threshold"] < 1.0: + raise ValueError(f"{case_id}: delta_threshold must be in (0, 1)") + elif test in {"pearson_correlation", "fisher_z"}: + if params: + raise ValueError(f"{case_id}: unexpected params") + else: + raise ValueError(f"{case_id}: unknown test {test!r}") + + columns = case["columns"] + if not isinstance(columns, dict) or not columns: + raise ValueError(f"{case_id}: columns must be a non-empty object") + lengths: set[int] = set() + for name, column in columns.items(): + if not isinstance(name, str) or column.get("kind") not in {"discrete", "continuous"}: + raise ValueError(f"{case_id}: invalid column {name!r}") + values = column.get("values") + if not isinstance(values, list) or not values or not all(_finite_number(v) for v in values): + raise ValueError(f"{case_id}: invalid values for column {name}") + lengths.add(len(values)) + if len(lengths) != 1: + raise ValueError(f"{case_id}: column lengths differ") + + query = [case["x"], case["y"], *case["z"]] + if len(query) != len(set(query)) or any(name not in columns for name in query): + raise ValueError(f"{case_id}: invalid query columns {query}") + + expected = case["expected"] + if set(expected) != EXPECTED_KEYS: + raise ValueError(f"{case_id}: result keys must be {sorted(EXPECTED_KEYS)}") + for field in ("statistic", "p_value", "effect_size"): + if not _finite_number(expected[field]): + raise ValueError(f"{case_id}: {field} must be finite") + dof = expected["dof"] + if dof is not None and (isinstance(dof, bool) or not isinstance(dof, int) or dof < 0): + raise ValueError(f"{case_id}: invalid dof {dof!r}") + if not 0.0 <= expected["p_value"] <= 1.0 or expected["effect_size"] < 0.0: + raise ValueError(f"{case_id}: invalid probability or effect size") +``` + +- [ ] **Step 5: Format, lint, and run the catalog tests** + +Run: + +```bash +ruff format tests/fixtures +ruff check tests/fixtures +pytest tests/fixtures/test_generate_golden.py -v +``` + +Expected: Ruff succeeds; four pytest tests pass with no SciPy warnings and no import of a project binding. + +- [ ] **Step 6: Commit the independent oracle** + +```bash +git add tests/fixtures/requirements.txt tests/fixtures/test_generate_golden.py tests/fixtures/generate_golden.py +git commit -m "test: add independent golden reference catalog" +``` + +--- + +### Task 2: Add canonical serialization, drift checking, and the committed fixture + +**Files:** + +- Modify: `tests/fixtures/test_generate_golden.py` +- Modify: `tests/fixtures/generate_golden.py` +- Create: `tests/fixtures/golden.json` (generated, never hand-edited) + +**Interfaces:** + +- Consumes: `build_cases()` and `validate_cases()` from Task 1. +- Produces: `render_cases(cases) -> str`, `write_fixture(path, rendered) -> None`, `check_fixture(path, rendered) -> bool`, and `main(argv=None) -> int`. + +- [ ] **Step 1: Write failing tests for canonical output and non-mutating check mode** + +Add `import json` to `tests/fixtures/test_generate_golden.py`, then append: + +```python +def test_render_cases_is_strict_and_deterministic() -> None: + generator = load_generator() + cases = generator.build_cases() + + first = generator.render_cases(cases) + second = generator.render_cases(cases) + + assert first == second + assert first.endswith("\n") + assert "NaN" not in first + assert "Infinity" not in first + decoded = json.loads(first) + assert len(decoded) == 80 + positive = by_id(decoded, "pearson-correlation-positive-unconditional") + assert positive["expected"]["statistic"] == 0.8 + + +def test_write_and_check_fixture_are_separate_operations(tmp_path: Path) -> None: + generator = load_generator() + rendered = generator.render_cases(generator.build_cases()) + output = tmp_path / "nested" / "golden.json" + + generator.write_fixture(output, rendered) + assert output.read_text(encoding="utf-8") == rendered + assert generator.check_fixture(output, rendered) is True + + changed = rendered.replace('"test": "chi_squared"', '"test": "changed"', 1) + output.write_text(changed, encoding="utf-8") + assert generator.check_fixture(output, rendered) is False + assert output.read_text(encoding="utf-8") == changed + + +def test_cli_check_mode_never_creates_or_rewrites_output(tmp_path: Path) -> None: + generator = load_generator() + output = tmp_path / "golden.json" + + assert generator.main(["--check", "--output", str(output)]) == 1 + assert not output.exists() + + assert generator.main(["--output", str(output)]) == 0 + original = output.read_text(encoding="utf-8") + assert generator.main(["--check", "--output", str(output)]) == 0 + assert output.read_text(encoding="utf-8") == original + + output.write_text(f"{original}\n", encoding="utf-8") + changed = output.read_text(encoding="utf-8") + assert generator.main(["--check", "--output", str(output)]) == 1 + assert output.read_text(encoding="utf-8") == changed +``` + +- [ ] **Step 2: Run the new tests and verify the expected missing-interface failure** + +Run: + +```bash +pytest tests/fixtures/test_generate_golden.py -k "render or write or cli" -v +``` + +Expected: FAIL with `AttributeError: module 'generate_golden' has no attribute 'render_cases'`. + +- [ ] **Step 3: Implement canonical rendering, atomic writes, and the CLI** + +Replace the standard-library import block near the top of +`tests/fixtures/generate_golden.py` with the complete final block: + +```python +import argparse +import json +import math +import os +import re +import sys +import tempfile +from collections import Counter +from collections.abc import Sequence +from pathlib import Path +from typing import Any +``` + +Add the default output constant after `EXPECTED_KEYS`: + +```python +DEFAULT_OUTPUT = Path(__file__).with_name("golden.json") +``` + +Append these complete functions: + +```python +def _canonicalize(value: Any) -> Any: + if isinstance(value, float): + rounded = float(f"{value:.12g}") + return 0.0 if rounded == 0.0 else rounded + if isinstance(value, list): + return [_canonicalize(item) for item in value] + if isinstance(value, dict): + return {key: _canonicalize(item) for key, item in value.items()} + return value + + +def render_cases(cases: list[dict[str, Any]]) -> str: + validate_cases(cases) + canonical = _canonicalize(cases) + return json.dumps(canonical, indent=2, sort_keys=True, allow_nan=False) + "\n" + + +def write_fixture(path: Path, rendered: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + text=True, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(rendered) + os.chmod(temporary_name, 0o644) + os.replace(temporary_name, path) + except BaseException: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + + +def check_fixture(path: Path, rendered: str) -> bool: + return path.exists() and path.read_text(encoding="utf-8") == rendered + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="verify the committed fixture without writing it", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=argparse.SUPPRESS, + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + rendered = render_cases(build_cases()) + if args.check: + if check_fixture(args.output, rendered): + print(f"golden fixture is current: {args.output}") + return 0 + print( + f"golden fixture is missing or stale: {args.output}\n" + "regenerate it with: python tests/fixtures/generate_golden.py", + file=sys.stderr, + ) + return 1 + + write_fixture(args.output, rendered) + print(f"wrote 80 golden cases: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run the focused serialization tests and verify they pass** + +Run: + +```bash +pytest tests/fixtures/test_generate_golden.py -k "render or write or cli" -v +``` + +Expected: PASS for all three selected tests. The `--check` calls leave the temporary file absent or byte-for-byte unchanged. + +- [ ] **Step 5: Add the failing committed-artifact drift test** + +Append to `tests/fixtures/test_generate_golden.py`: + +```python +def test_committed_fixture_matches_generator() -> None: + generator = load_generator() + rendered = generator.render_cases(generator.build_cases()) + assert GOLDEN_PATH.exists(), "golden.json must be committed" + assert generator.check_fixture(GOLDEN_PATH, rendered), ( + "golden.json is stale; run python tests/fixtures/generate_golden.py" + ) +``` + +- [ ] **Step 6: Run the drift test and verify the artifact is still missing** + +Run: + +```bash +pytest tests/fixtures/test_generate_golden.py::test_committed_fixture_matches_generator -v +``` + +Expected: FAIL with `golden.json must be committed`. + +- [ ] **Step 7: Generate the fixture and rerun the complete generator suite** + +Run: + +```bash +python tests/fixtures/generate_golden.py +pytest tests/fixtures/test_generate_golden.py -v +python tests/fixtures/generate_golden.py --check +``` + +Expected: the first command reports `wrote 80 golden cases`; all eight generator tests pass; check mode reports `golden fixture is current`. + +- [ ] **Step 8: Prove regeneration is byte-stable** + +Run: + +```bash +sha256sum tests/fixtures/golden.json +python tests/fixtures/generate_golden.py +sha256sum tests/fixtures/golden.json +``` + +Expected: both SHA-256 values are identical. + +- [ ] **Step 9: Re-run formatting, linting, tests, and drift checking** + +Run: + +```bash +ruff format tests/fixtures +ruff check tests/fixtures +pytest tests/fixtures/test_generate_golden.py -v +python tests/fixtures/generate_golden.py --check +``` + +Expected: Ruff succeeds, all eight tests pass, and check mode still reports byte-identical output. + +- [ ] **Step 10: Commit the generated contract** + +```bash +git add tests/fixtures/generate_golden.py tests/fixtures/test_generate_golden.py tests/fixtures/golden.json +git commit -m "test: add reproducible golden parity fixture" +``` + +--- + +### Task 3: Make the Rust acceptance gate consume stable case IDs + +**Files:** + +- Modify: `crates/ci-core/tests/golden.rs:48-62,139-163,165-210` + +**Interfaces:** + +- Consumes: the `id` and `expected` fields in `tests/fixtures/golden.json`. +- Produces: the existing `golden_fixture_matches` integration test with stable case IDs in every numeric mismatch. + +- [ ] **Step 1: Add a compile-failing assertion that requires the fixture ID** + +At the start of the `for case in &cases` loop in `golden_fixture_matches`, before `run_case`, add: + +```rust +assert!(!case.id.is_empty(), "fixture cases must have a stable id"); +``` + +- [ ] **Step 2: Run the Rust golden target and verify the new contract fails to compile** + +Run: + +```bash +cargo test -p ci_core --test golden +``` + +Expected: FAIL with `no field 'id' on type '&Case'`. This proves the consumer does not yet deserialize the new contract field. + +- [ ] **Step 3: Deserialize the ID and use it in field failures** + +Add `id` to `Case`: + +```rust +#[derive(Debug, Deserialize)] +struct Case { + id: String, + test: String, + #[serde(default)] + params: Params, + columns: BTreeMap, + x: String, + y: String, + z: Vec, + expected: Expected, +} +``` + +Replace `assert_field` with: + +```rust +fn assert_field(name: &str, case: &Case, expected: Option, actual: Option) { + let Some(exp) = expected else { return }; + let act = actual.unwrap_or_else(|| { + panic!( + "[{}:{}] expected {name}={exp} but result had None", + case.id, case.test + ) + }); + + if exp.is_infinite() || act.is_infinite() { + assert!( + exp.total_cmp(&act).is_eq(), + "[{}:{}] {name} mismatch: expected {exp}, got {act} (x={}, y={}, z={:?})", + case.id, + case.test, + case.x, + case.y, + case.z, + ); + return; + } + + assert!( + (act - exp).abs() < TOL, + "[{}:{}] {name} mismatch: expected {exp}, got {act} (x={}, y={}, z={:?})", + case.id, + case.test, + case.x, + case.y, + case.z, + ); +} +``` + +- [ ] **Step 4: Run the target and complete core suite** + +Run: + +```bash +cargo test -p ci_core --test golden -- --nocapture +cargo test -p ci_core +``` + +Expected: the target reports all 80 cases across eight tests and passes; the complete core suite passes with no missing-file error. + +- [ ] **Step 5: Commit the Rust consumer update** + +```bash +git add crates/ci-core/tests/golden.rs +git commit -m "test(core): report golden fixture case ids" +``` + +--- + +### Task 4: Complete the Python four-field parity assertion + +**Files:** + +- Modify: `crates/ci-python/test/test_golden.py:1-10,96-130` + +**Interfaces:** + +- Consumes: each fixture case's `id` and `expected.effect_size`. +- Produces: 80 parameterized Python binding checks for statistic, p-value, dof, and effect size. + +- [ ] **Step 1: Tighten the parameterized acceptance test** + +Update the module documentation to name all four fields: + +```python +"""Golden parity test for the data-bound Python bindings. + +Loads the shared cross-language fixture ``tests/fixtures/golden.json`` and, for +each of the eight tests, builds a :class:`Dataset` from the case's columns, +constructs the test with the case's parameters, runs it, and asserts that +``statistic`` / ``p_value`` / ``dof`` / ``effect_size`` match the recorded +``expected`` values. This is the binding's numeric parity gate against the +SciPy/pgmpy reference. +""" +``` + +Replace the parameterization and function signature with stable IDs: + +```python +@pytest.mark.parametrize( + "case", + GOLDEN_CASES, + ids=[case["id"] for case in GOLDEN_CASES], +) +def test_golden_case(case: dict[str, Any]) -> None: + """Each fixture case reproduces every defined result field.""" + case_id = case["id"] +``` + +Keep the existing dataset construction and statistic/p-value/dof assertions, then add after the dof assertion: + +```python + _assert_close( + result.effect_size, + expected.get("effect_size"), + "effect_size", + case_id, + ) +``` + +- [ ] **Step 2: Build the extension and run the golden module** + +Run: + +```bash +python -m pip install maturin pytest numpy mypy +python -m pip install -e crates/ci-python --no-build-isolation +pytest crates/ci-python/test/test_golden.py -v +``` + +Expected: 81 tests pass: 80 named fixture cases plus the coverage/count test. + +- [ ] **Step 3: Run the complete Python suite and type checker** + +Run: + +```bash +pytest crates/ci-python/test/ +cd crates/ci-python +mypy test/ +``` + +Expected: all Python tests and mypy checks pass. + +- [ ] **Step 4: Commit the Python contract check** + +```bash +git add crates/ci-python/test/test_golden.py +git commit -m "test(python): assert golden effect sizes" +``` + +--- + +### Task 5: Complete the JavaScript four-field parity assertion + +**Files:** + +- Modify: `crates/ci-js/tests/golden.test.js:1-10,109-154` + +**Interfaces:** + +- Consumes: each fixture case's `id` and `expected.effect_size`. +- Produces: 80 Vitest binding checks for statistic, p-value, dof, and effect size. + +- [ ] **Step 1: Add stable IDs and the missing effect-size assertion** + +Change the top-level description to: + +```javascript +// Loads the shared cross-language fixture `tests/fixtures/golden.json` (at the +// repository root) and, for each of the eight tests, builds a `Dataset` from the +// case's columns, constructs the test with the case's parameters, runs it, and +// asserts that `statistic` / `pValue` / `dof` / `effectSize` match the recorded +// `expected` values within 1e-7. This is the binding's numeric parity gate +// against the SciPy/pgmpy reference. +``` + +Inside the parameterized test, replace the index-derived label with: + +```javascript + const caseId = testCase.id; +``` + +After the existing dof assertion, add: + +```javascript + assertClose(result.effectSize, expected.effect_size, "effectSize", caseId); +``` + +- [ ] **Step 2: Build the WASM package and run Vitest** + +Run: + +```bash +wasm-pack build crates/ci-js --target nodejs +cd crates/ci-js/tests +npm ci +npm test +``` + +Expected: Vitest passes all API tests plus 80 golden cases and the golden coverage/count test. + +- [ ] **Step 3: Run JavaScript lint and formatting checks** + +Run: + +```bash +cd crates/ci-js +npm ci +npx prettier --check . +npx eslint . +``` + +Expected: all checks pass without rewriting files. + +- [ ] **Step 4: Commit the JavaScript contract check** + +```bash +git add crates/ci-js/tests/golden.test.js +git commit -m "test(js): assert golden effect sizes" +``` + +--- + +### Task 6: Complete the R four-field parity assertion and declare jsonlite + +**Files:** + +- Modify: `crates/ci-r/tests/testthat/test-golden.R:1-10,115-151` +- Modify: `crates/ci-r/DESCRIPTION:15-19` + +**Interfaces:** + +- Consumes: each fixture case's `id` and `expected.effect_size` through `jsonlite`. +- Produces: 80 testthat binding checks for statistic, p-value, dof, and effect size; a declared package test dependency. + +- [ ] **Step 1: Declare the JSON fixture reader** + +Change `Suggests` in `crates/ci-r/DESCRIPTION` to: + +```text +Suggests: + devtools, + jsonlite, + rextendr, + testthat (>= 3.0.0) +``` + +- [ ] **Step 2: Add stable IDs and the missing effect-size assertion** + +Update the file header and test description: + +```r +# Loads the shared cross-language fixture `tests/fixtures/golden.json` and, for +# each of the eight tests, builds a `dataset()` from the case's columns, +# constructs the test with the case's parameters, runs it, and asserts that +# `statistic` / `p_value` / `dof` / `effect_size` match the recorded `expected` +# values within a tight tolerance. This is the binding's numeric parity gate +# against the SciPy/pgmpy reference. +``` + +```r +test_that("each golden case reproduces every defined result field", { +``` + +Inside the loop, replace the index-based case label with: + +```r + case_id <- case$id +``` + +After the existing dof assertion, add: + +```r + assert_close(result$effect_size, expected$effect_size, "effect_size", case_id) +``` + +- [ ] **Step 3: Rebuild and run the R package suite** + +Run from the repository root: + +```bash +Rscript -e 'rextendr::document("crates/ci-r")' +Rscript -e 'devtools::test("crates/ci-r", reporter = "summary")' +``` + +Expected: testthat passes the package suite, including the count assertion and all 80 four-field cases. + +- [ ] **Step 4: Run R package metadata and style checks** + +Run: + +```bash +Rscript -e 'devtools::check("crates/ci-r", error_on = "warning")' +Rscript -e 'lintr::lint_package("crates/ci-r")' +``` + +Expected: `R CMD check` recognizes `jsonlite` as declared and lintr returns no lints. + +- [ ] **Step 5: Commit the R contract check** + +```bash +git add crates/ci-r/DESCRIPTION crates/ci-r/tests/testthat/test-golden.R +git commit -m "test(r): assert golden effect sizes" +``` + +--- + +### Task 7: Gate fixture drift in CI and correct the documentation + +**Files:** + +- Modify: `.github/workflows/python.yml:1-110` +- Modify: `.github/workflows/rust.yml:1-105` +- Modify: `.github/workflows/js.yml:1-112` +- Modify: `.github/workflows/r.yml:1-116` +- Modify: `README.md:198-218` +- Modify: `CONTRIBUTING.md:70-80,242-262` + +**Interfaces:** + +- Consumes: `tests/fixtures/requirements.txt`, `test_generate_golden.py`, and `generate_golden.py --check`. +- Produces: a Linux/Python 3.12 CI drift gate and accurate contributor instructions for the 80-case fixture. + +- [ ] **Step 1: Add a dedicated fixture job to Python CI** + +Insert this job before `python-lint` in `.github/workflows/python.yml`: + +```yaml + golden-fixture: + name: Golden Fixture Drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install reference dependencies + run: python -m pip install pytest -r tests/fixtures/requirements.txt + + - name: Test fixture generator + run: pytest tests/fixtures/test_generate_golden.py + + - name: Check committed fixture + run: python tests/fixtures/generate_golden.py --check +``` + +Change the Python binding job dependency to: + +```yaml + needs: [golden-fixture, python-lint] +``` + +Update both Python workflow comments from 73 to 80 cases: + +```yaml + # (crates/ci-python/test/test_golden.py) reproduces all 80 fixture cases. +``` + +```yaml + # Verified locally: `pytest crates/ci-python/test/` -> golden = 80/80. +``` + +- [ ] **Step 2: Correct Rust and JavaScript workflow counts** + +In `.github/workflows/rust.yml`, replace both 73-case comments with 80-case wording: + +```yaml +# include the 80-case shared golden parity fixture. +``` + +```yaml + # Verified locally: `cargo test -p ci_core` (includes the 80-case golden +``` + +In `.github/workflows/js.yml`, use: + +```yaml +# golden parity suite (golden.test.js) covers all 80 fixture cases (81 vitest +# test cases: 80 parametrized + 1 coverage assertion). +``` + +and: + +```yaml + # the golden suite covers all 80 fixture cases. +``` + +- [ ] **Step 3: Correct R workflow counts and dependency wording** + +Replace the opening R workflow description with: + +```yaml +# Toolchain: R + rextendr (which compiles the embedded Rust crate), devtools, +# testthat, and jsonlite (the golden suite reads tests/fixtures/golden.json via +# jsonlite::fromJSON). rextendr::document() regenerates the extendr wrappers, +# then devtools::test() runs the package suite, including the 80-case golden +# parity gate. +``` + +Replace the dependency comment with: + +```yaml + # jsonlite is declared in Suggests and used by the golden suite; keep it + # explicit here alongside the package development tools. +``` + +Replace the final 73-case comment with: + +```yaml + # 80-case golden parity gate (tests/fixtures/golden.json). +``` + +- [ ] **Step 4: Document regeneration and drift checking in README** + +After the paragraph that introduces `tests/fixtures/golden.json` in `README.md`, insert: + +````markdown +The fixture is generated independently with pinned NumPy/SciPy references and is committed so +normal binding tests do not need those Python dependencies. Regenerate or verify it from the +repository root: + +```bash +python -m pip install -r tests/fixtures/requirements.txt +python tests/fixtures/generate_golden.py +python tests/fixtures/generate_golden.py --check +``` +```` + +- [ ] **Step 5: Correct the contributor tree and fixture instructions** + +Remove this nonexistent entry from the repository tree in `CONTRIBUTING.md`: + +```text +├── benchmarks/ # Value-parity and runtime benchmarks +``` + +Replace the fixture regeneration paragraph and command with: + +````markdown +If you change a statistic or add a test, install the pinned reference dependencies, regenerate +the fixture, and run its drift check before the binding suites: + +```bash +python -m pip install -r tests/fixtures/requirements.txt +python tests/fixtures/generate_golden.py +python tests/fixtures/generate_golden.py --check +``` + +The generator uses deterministic inputs and independent NumPy/SciPy implementations of the +pgmpy formulas. It never imports the Rust core or a binding. The committed fixture contains 80 +cases, and every consumer compares `statistic`, `p_value`, `dof`, and `effect_size` within an +absolute tolerance of `1e-7`. +```` + +- [ ] **Step 6: Verify workflow syntax and stale-text removal** + +Run: + +```bash +python -c "import pathlib, yaml; [yaml.safe_load(pathlib.Path(p).read_text()) for p in ['.github/workflows/python.yml', '.github/workflows/rust.yml', '.github/workflows/js.yml', '.github/workflows/r.yml']]; print('yaml-ok')" +rg -n "73-case|73 fixture|all 73|golden = 73" .github README.md CONTRIBUTING.md +rg -n "^├── benchmarks/" CONTRIBUTING.md +git diff --check +``` + +Expected: `yaml-ok`; both `rg` commands exit with no matches; `git diff --check` has no output. + +- [ ] **Step 7: Commit the CI and documentation contract** + +```bash +git add .github/workflows/python.yml .github/workflows/rust.yml .github/workflows/js.yml .github/workflows/r.yml README.md CONTRIBUTING.md +git commit -m "ci: verify golden fixture drift" +``` + +--- + +### Task 8: Run final cross-language verification + +**Files:** + +- Verify only; do not change generated expectations to match an implementation failure. + +**Interfaces:** + +- Consumes: every deliverable from Tasks 1-7. +- Produces: evidence that the independent artifact is current and every available consumer passes. + +- [ ] **Step 1: Verify the generator and strict JSON artifact from a clean calculation** + +Run: + +```bash +pytest tests/fixtures/test_generate_golden.py -v +python tests/fixtures/generate_golden.py --check +python -c "import json, pathlib; cases=json.loads(pathlib.Path('tests/fixtures/golden.json').read_text()); assert len(cases)==80; assert len({c['id'] for c in cases})==80; print('golden-json-ok')" +``` + +Expected: eight pytest tests pass, check mode reports the fixture is current, and the final command prints `golden-json-ok`. + +- [ ] **Step 2: Run Rust formatting, linting, and all core tests** + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy -p ci_core --all-targets -- -D warnings +cargo test -p ci_core +``` + +Expected: all three commands exit zero; the golden integration target exercises 80 cases. + +- [ ] **Step 3: Run Python formatting, linting, typing, and tests** + +Run: + +```bash +ruff format --check tests/fixtures crates/ci-python +ruff check tests/fixtures crates/ci-python +mypy crates/ci-python/test/ +pytest crates/ci-python/test/ +``` + +Expected: all commands exit zero and the Python golden module exercises 80 cases plus its count check. + +- [ ] **Step 4: Run JavaScript and R verification when their toolchains are installed** + +Run: + +```bash +wasm-pack build crates/ci-js --target nodejs +npm --prefix crates/ci-js/tests ci +npm --prefix crates/ci-js/tests test +Rscript -e 'rextendr::document("crates/ci-r")' +Rscript -e 'devtools::test("crates/ci-r", reporter = "summary")' +``` + +Expected: both binding suites pass their API tests and all 80 fixture cases. If `wasm-pack` or R is unavailable, record the exact `command not found` evidence and rely on the corresponding CI workflow; do not claim that suite was run locally. + +- [ ] **Step 5: Inspect the final repository state** + +Run: + +```bash +git status --short +git log --oneline -8 +git diff HEAD~7..HEAD --check +``` + +Expected: no uncommitted implementation changes; the recent history contains the focused oracle, fixture, consumer, and CI/documentation commits; the aggregate diff has no whitespace errors. diff --git a/docs/superpowers/specs/2026-07-18-golden-fixture-parity-design.md b/docs/superpowers/specs/2026-07-18-golden-fixture-parity-design.md new file mode 100644 index 0000000..f7cead2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-golden-fixture-parity-design.md @@ -0,0 +1,308 @@ +# Golden Fixture Parity Gate Design + +**Date:** 2026-07-18 + +**Status:** Approved for implementation + +## Context + +The refactor introduced fixture consumers for Rust, Python, R, and JavaScript, +but did not commit the fixture they all load or the generator documented in +`CONTRIBUTING.md`. Commit history shows that the original consumers expected 73 +cases. A later change added Fisher-Z to every consumer and increased the +contract to 80 cases, again without adding the seven Fisher-Z rows or the +original 73-row artifact. + +Consequently, `cargo test -p ci_core` runs all core unit tests successfully and +then fails when `crates/ci-core/tests/golden.rs` tries to open +`tests/fixtures/golden.json`. The other three binding suites fail at the same +boundary. This is a missing source-of-truth pipeline, rather than a statistical +failure in one binding. + +## Goals + +- Restore a deterministic, committed 80-case numeric parity fixture. +- Generate expected values independently of the Rust implementation. +- Match the statistical semantics of the corresponding pgmpy CI tests, + including this project's explicit Yates configuration and uniform result + fields. +- Make fixture drift detectable in CI. +- Assert every defined result field in Rust, Python, R, and JavaScript. +- Make failures identify a stable scenario instead of only an array index. +- Correct stale documentation and workflow comments that still say 73 cases. + +## Non-goals + +- Reintroducing runtime benchmarks; that is a separate design task. +- Adding new CI-test algorithms or changing their public APIs. +- Using the golden fixture to test rejected inputs or expected errors. Those + behaviors remain focused unit/API tests because a cross-language numeric + fixture represents successful result values. +- Adding pgmpy as a build, runtime, or test dependency of any binding. + +## Considered Approaches + +### 1. NumPy/SciPy reference generator (selected) + +A Python generator owns deterministic input scenarios and evaluates them with +NumPy/SciPy implementations of the documented pgmpy formulas. It never imports +or invokes `ci_core` or a language binding. The generated JSON is committed so +normal Rust, R, and JavaScript tests do not require Python or SciPy. + +This keeps the oracle independent, lightweight, inspectable, and usable by all +four suites. + +### 2. Generate directly through pgmpy + +This is close to the upstream surface, but pgmpy is a comparatively heavy and +evolving dependency. Its names and result metadata do not map one-to-one to +this project's unified contract, and this project additionally exposes Yates +as an option. Direct generation would therefore still require adapter formulas +while making regeneration more fragile. + +### 3. Generate through `ci_core` + +This is the simplest implementation, but it is circular: the expected values +would be produced by the implementation being tested. It could detect binding +marshalling bugs but not statistical regressions in the Rust core, so it is +rejected. + +## Architecture + +The pipeline has three layers: + +1. `tests/fixtures/generate_golden.py` defines the case catalog, evaluates the + independent reference formulas, validates the resulting cases, and either + writes or checks the canonical artifact. +2. `tests/fixtures/golden.json` is the generated, language-neutral acceptance + contract committed to Git. +3. The four existing fixture consumers construct their idiomatic `Dataset`, run + the named test, and compare all four result fields with the fixture. + +No consumer generates expectations. No reference calculation calls into Rust. + +## Fixture Contract + +The top-level representation remains a JSON array to avoid unnecessary schema +churn. Each case contains: + +```json +{ + "id": "discrete-associated-2x2-yates-chi-squared", + "test": "chi_squared", + "params": { "yates": true }, + "columns": { + "X": { "kind": "discrete", "values": [0, 0, 1, 1] }, + "Y": { "kind": "discrete", "values": [0, 1, 0, 1] } + }, + "x": "X", + "y": "Y", + "z": [], + "expected": { + "statistic": 0.0, + "p_value": 1.0, + "dof": 1, + "effect_size": 0.0 + } +} +``` + +Contract rules: + +- `id` is unique, stable, lowercase kebab-case, and used in failure messages. +- `test` is one of the eight stable registry names already used by the + consumers. +- `params` is always present. It contains `yates` for a discrete test, + `delta_threshold` for Pearson equivalence, and is empty for Pearson + correlation and Fisher-Z. +- Every column has one supported `kind` and a non-empty numeric `values` array. + All columns in a case have equal length. +- `x`, `y`, and every member of `z` name distinct columns present in `columns`. +- `expected` always contains `statistic`, `p_value`, `dof`, and `effect_size`. + `dof` is `null` only for algorithms that do not report it. +- The generated numeric fixture contains only finite JSON numbers. Degenerate + cases that yield errors, NaN, or infinity belong in algorithm unit tests. + +## Case Catalog + +The generator emits exactly 80 cases with this fixed distribution: + +| Test family | Scenario count | Expanded cases | +| --- | ---: | ---: | +| Five discrete power-divergence tests | 12 shared scenarios | 60 | +| Pearson correlation | 7 | 7 | +| Pearson equivalence | 6 | 6 | +| Fisher-Z | 7 | 7 | +| **Total** | | **80** | + +The 12 discrete scenarios cover: + +1. balanced independent 2x2 with Yates enabled; +2. associated 2x2 with Yates enabled; +3. the same associated 2x2 with Yates disabled; +4. unbalanced 2x2 with Yates enabled; +5. the same unbalanced 2x2 with Yates disabled; +6. an unconditional 2x3 table; +7. an unconditional 3x2 table; +8. an unconditional 3x3 table; +9. conditioning on one binary variable; +10. conditioning on one three-level variable; +11. conditioning on two variables; and +12. sparse strata in which globally known levels are inactive in some strata. + +Every discrete scenario is expanded across `chi_squared`, `log_likelihood`, +`cressie_read`, `freeman_tukey`, and `modified_likelihood`. Tables are chosen so +every cell inside an active sub-table has a positive observed count and all five +statistics are finite. Globally known rows or columns may still be inactive in +an individual sparse stratum and are removed before the SciPy call. + +The seven Pearson scenarios cover near-zero, positive, and negative +unconditional relationships; confounding removed by one conditioning variable; +residual association after one conditioning variable; two conditioning +variables; and scale/offset invariance. The six equivalence scenarios exercise +correlations inside and outside multiple positive margins, both unconditional +and conditional. Fisher-Z reuses six finite Pearson scenarios and adds one +perfect-correlation case to pin its pgmpy-compatible clipping behavior. + +All generated inputs are fixed arrays. Random input generation is avoided, even +with a seed, so the fixture does not depend on a random-number implementation. + +## Reference Calculations + +### Discrete power-divergence family + +The generator partitions rows by each observed combination of `z`. Within each +stratum it removes rows and columns with zero marginal counts and calls +`scipy.stats.chi2_contingency` with the case's `correction` value and the +numeric lambda: + +- `chi_squared`: `1.0` +- `log_likelihood`: `0.0` +- `cressie_read`: `2.0 / 3.0` +- `freeman_tukey`: `-0.5` +- `modified_likelihood`: `-1.0` + +Stratum statistics and degrees of freedom are summed. The aggregate p-value is +`scipy.stats.chi2.sf(statistic, dof)`. Effect size follows pgmpy's Cramer's V +contract: + +`sqrt(statistic / (n * max(min(k_x, k_y) - 1, 1)))`. + +Here `k_x` and `k_y` are global observed cardinalities, matching `Dataset` +factorization. + +### Pearson correlation + +Without `z`, the reference obtains the raw correlation with +`scipy.stats.pearsonr`. With `z`, it regresses both variables on `[1, Z]` with +`numpy.linalg.lstsq` and correlates the residuals. In both paths, the uniform +result contract records `dof = n - |Z| - 2` and computes the two-sided p-value +with Student's t survival function using that dof. The effect size is the +absolute raw correlation. + +### Pearson equivalence + +The reference obtains the same partial correlation, clips it to +`[-0.999999, 0.999999]`, and applies pgmpy's Fisher-transform TOST formulas. +The statistic is the clipped correlation's `atanh`, the p-value is the maximum +of the two one-sided normal probabilities, `dof` is null, and effect size is the +absolute un-clipped correlation. + +### Fisher-Z + +The reference obtains the same partial correlation, clips it to +`[-0.999999, 0.999999]`, and computes +`sqrt(n - |Z| - 3) * atanh(rho)`. Its p-value is the two-sided standard-normal +tail, `dof` is null, and effect size is the absolute un-clipped correlation. + +## Deterministic Generation and Drift Checking + +The generator supports two modes: + +```bash +python tests/fixtures/generate_golden.py +python tests/fixtures/generate_golden.py --check +``` + +Normal mode validates and atomically replaces `golden.json`. Check mode builds +the same canonical document in memory, performs no writes, and exits nonzero +with a concise regeneration instruction if the committed bytes differ or the +file is missing. + +To reduce harmless BLAS/platform last-bit variation, computed floats are +canonicalized to 12 significant decimal digits before serialization. JSON is +written with sorted keys, two-space indentation, a trailing newline, and +`allow_nan=False`. Twelve significant digits are substantially tighter than +the consumers' absolute `1e-7` acceptance tolerance. + +`tests/fixtures/requirements.txt` pins `numpy==2.4.2` and `scipy==1.17.0`, the +reference versions used to regenerate and check the oracle. Normal binding +tests consume only the JSON and do not install these dependencies. + +Before output, validation rejects: + +- a case count or per-test distribution different from the table above; +- duplicate or malformed IDs; +- unknown tests, params, or column kinds; +- mismatched or empty column lengths; +- missing, repeated, or invalid query columns; +- missing result keys, non-integral dof, or non-finite numbers. + +## Consumer Changes + +The Rust consumer already asserts all four fields. It will deserialize `id` and +include it in assertion failures. + +Python, R, and JavaScript will add the currently omitted `effect_size` / +`effectSize` assertion using the same `1e-7` absolute tolerance. Their test +descriptions will say all four fields. Every consumer continues to assert the +80-case count and the complete eight-test name set. + +The R package will add `jsonlite` to `Suggests`, because the checked-in test +suite imports it. CI no longer needs to describe it as an undeclared test +dependency. + +## CI and Documentation + +Python CI gets one platform-independent fixture job on Python 3.12. It installs +pytest plus `tests/fixtures/requirements.txt`, runs +`pytest tests/fixtures/test_generate_golden.py`, and runs +`generate_golden.py --check`. Binding matrices continue to read the committed +fixture and do not regenerate it. + +Workflow comments in Rust, Python, R, and JavaScript are updated from 73 to 80 +cases. `README.md` and `CONTRIBUTING.md` document the write/check commands, the +reference dependencies, and the complete four-field parity contract. The +repository tree stops claiming a benchmark directory exists. + +## Testing Strategy + +Implementation follows red-green-refactor: + +1. Add focused generator tests that initially fail because the generator and + artifact do not exist. They pin the schema, exact 80-case distribution, + uniqueness, determinism, selected known reference values, and `--check` + behavior. +2. Implement the generator and produce the fixture until those tests pass. +3. Run the existing Rust golden integration test, which currently reproduces + the missing-file failure, and make it pass against the independent fixture. +4. Add effect-size assertions to each binding suite and run each suite where + its toolchain is available. +5. Run formatting, linting, generator drift, and the complete core suite. + +Unavailable local language toolchains are reported explicitly; their CI +commands and fixture consumers are still updated and statically inspected. + +## Acceptance Criteria + +- `tests/fixtures/golden.json` and its generator are tracked in Git. +- Running the generator twice produces byte-identical output. +- `generate_golden.py --check` succeeds on the committed fixture and fails on a + missing or modified fixture without changing it. +- The fixture contains exactly 80 valid, uniquely identified cases with the + specified per-test distribution. +- `cargo test -p ci_core` passes, including all 80 golden cases. +- Every available binding suite passes and compares all four result fields. +- CI detects generator/fixture drift independently from binding builds. +- No workflow or contributor documentation still claims 73 cases or a present + benchmark directory.