gam 0.1.17

Generalized penalized likelihood engine
Documentation
name: Rust CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: rust-ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  actions: read
  contents: read

env:
  CARGO_TERM_COLOR: always

jobs:
  fmt:
    name: Rustfmt
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 1

      - name: Install Rust 1.93.0
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.93.0
          components: rustfmt

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@v2

      - name: Check formatting
        run: cargo fmt --all -- --check

  clippy:
    name: Clippy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 1

      - name: Install Rust 1.93.0
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.93.0
          components: clippy

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@v2

      - name: Lint
        run: cargo clippy --all-targets --all-features -- -A warnings -D clippy::correctness -D clippy::suspicious

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 1

      - name: Install Rust 1.93.0
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.93.0

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@v2

      - name: Run tests
        shell: bash
        run: |
          set -uo pipefail
          # Capture cargo test's exit status without aborting, so the
          # forbidden-substring grep below can act as a diagnostic
          # classifier on failure rather than a separate gate that trips
          # on log output from passing tests that deliberately exercise
          # these error paths.
          cargo test --all-features -- --nocapture 2>&1 | tee /tmp/cargo-test.log
          test_status=${PIPESTATUS[0]}

          # The patterns here are literal/ERE substrings that match the
          # exact log::warn!/log::error!/Display strings emitted at runtime
          # when the solver hits specific degenerate states. Many library
          # tests deliberately drive the solver into those states — e.g.
          # perfect-separation coverage, seed-exhaustion coverage,
          # PIRLS-non-convergence coverage. On a passing run those
          # emissions are *expected* test output, not regressions. The
          # grep only fires when cargo test itself returned non-zero,
          # where the pattern match helps classify the failure mode.
          #
          # Patterns verified against src/ to match non-test runtime log
          # output:
          #   - "P-IRLS INNER LOOP FAILED" — solver/reml/runtime.rs
          #     log::warn! emitted by the outer REML cost path when PIRLS
          #     returns an error.
          #   - "P-IRLS failed convergence check" — runtime.rs log::error!
          #     when the inner solve hits max iterations with gradient
          #     norm still above the acceptable KKT band.
          #   - "P-IRLS separation/non-convergence at current rho" —
          #     runtime.rs log::warn! used when PerfectSeparationDetected
          #     or PirlsDidNotConverge gets converted into +inf/infeasible
          #     outer evaluations.
          #   - "Perfect or quasi-perfect separation detected" — Display
          #     for EstimationError::PerfectSeparationDetected.
          #   - "seed candidates failed" — aggregate seed-loop error from
          #     solver/outer_strategy.rs.
          #   - "no candidate seeds passed outer startup validation" —
          #     aggregate seed-loop error when started_seeds == 0.
          forbidden='P-IRLS INNER LOOP FAILED|P-IRLS failed convergence check|P-IRLS separation/non-convergence at current rho|Perfect or quasi-perfect separation detected|seed candidates failed|no candidate seeds passed outer startup validation'
          if [ "$test_status" -ne 0 ]; then
            if grep -nE "$forbidden" /tmp/cargo-test.log; then
              echo "::error::Forbidden runtime error signatures were found alongside a cargo test failure."
            fi
            exit "$test_status"
          fi

  python-tests:
    name: Python API tests
    runs-on: ubuntu-latest
    # Runs after cargo-tests pass so we don't burn minutes building the
    # extension when the Rust side is already broken.
    needs: test
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 1

      - name: Install Rust 1.93.0
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.93.0

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@v2

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip

      - name: Install maturin
        run: python -m pip install --upgrade pip "maturin>=1.7,<2"

      - name: Build and install gam extension (maturin build + wheel install)
        shell: bash
        run: |
          set -euo pipefail
          rm -rf dist
          maturin build --release -i python3 -o dist
          python -m pip install --force-reinstall dist/*.whl

      - name: Install Python test extras
        # We only need the test-extra dependencies — the gam package itself
        # was already installed from the maturin-built wheel above. Using
        # `pip install -e .` would rebuild via maturin and replace the wheel
        # install, so we pull the extras directly instead.
        run: |
          python -m pip install \
            'pytest>=8' 'numpy>=1.26' 'pandas>=2.0' 'pyarrow>=14' \
            'matplotlib>=3.8' 'scikit-learn>=1.4'

      - name: Run Python API + pipeline smoke tests
        run: python -m pytest tests/test_python_api.py -v --tb=short

      - name: Run Python tables tests
        # In-tree pure-Python tests against the gam._tables helpers; no
        # benchmark binaries or external services are required.
        run: python -m pytest tests/test_tables.py -v --tb=short

      - name: Run benchmark configuration / mapping tests
        # These are pure-Python contract tests over the bench/ orchestration
        # scripts (run_suite.py, fuzz_vs_mgcv.py, biobank_scale/runner.py).
        # They mock subprocess calls and never invoke the Rust binary, so
        # they are safe for the standard CI runner.
        run: |
          python -m pytest \
            tests/bench_run_suite_mapping_test.py \
            tests/bench_run_suite_optional_imports_test.py \
            tests/bench_fuzz_vs_mgcv_test.py \
            tests/bench_biobank_scale_runner_test.py \
            -v --tb=short

      - name: Build gam release binary
        # The integration scripts below shell out to target/release/gam.
        # The maturin wheel build above produces the Python extension, not
        # the CLI binary; we need an explicit cargo build.
        run: cargo build --release --bin gam

      - name: Run CLI integration scripts (numeric contract checks)
        # Both scripts now perform numeric contract assertions on top of
        # exit-code checks: PIT pipeline asserts |z̄| < 0.5 and 0.5 < sd(z) < 1.8;
        # marginal-slope asserts each prediction column is in [0,1] and not
        # collapsed to a constant value. A mutation that emits constant or
        # biased predictions will fail here, not silently exit 0.
        run: |
          python tests/integration_marginal_slope.py
          python tests/integration_pit_pipeline.py