glmm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. glmm-0.1.0/.github/workflows/ci.yml +160 -0
  2. glmm-0.1.0/.github/workflows/publish-r-universe.yml +61 -0
  3. glmm-0.1.0/.github/workflows/release.yml +234 -0
  4. glmm-0.1.0/.gitignore +22 -0
  5. glmm-0.1.0/CHANGELOG.md +191 -0
  6. glmm-0.1.0/Cargo.lock +1623 -0
  7. glmm-0.1.0/Cargo.toml +122 -0
  8. glmm-0.1.0/LICENSE +674 -0
  9. glmm-0.1.0/PKG-INFO +119 -0
  10. glmm-0.1.0/README.md +137 -0
  11. glmm-0.1.0/documentation/TUTORIAL-PYTHON.md +191 -0
  12. glmm-0.1.0/documentation/TUTORIAL-R.md +217 -0
  13. glmm-0.1.0/documentation/TUTORIAL-RUST.md +181 -0
  14. glmm-0.1.0/documentation/algorithms-glmm.md +399 -0
  15. glmm-0.1.0/documentation/algorithms-lmm.md +304 -0
  16. glmm-0.1.0/documentation/algorithms.md +241 -0
  17. glmm-0.1.0/documentation/supported_families.md +53 -0
  18. glmm-0.1.0/faststats/__init__.py +1 -0
  19. glmm-0.1.0/faststats/glmm/__init__.py +8 -0
  20. glmm-0.1.0/glmm/__init__.py +408 -0
  21. glmm-0.1.0/glmm-python/Cargo.toml +19 -0
  22. glmm-0.1.0/glmm-python/src/convert.rs +134 -0
  23. glmm-0.1.0/glmm-python/src/lib.rs +84 -0
  24. glmm-0.1.0/glmm-python/src/orchestrate.rs +348 -0
  25. glmm-0.1.0/pyproject.toml +51 -0
  26. glmm-0.1.0/python/LICENSE +674 -0
  27. glmm-0.1.0/python/README.md +96 -0
  28. glmm-0.1.0/python/faststats/__init__.py +1 -0
  29. glmm-0.1.0/python/faststats/glmm/__init__.py +8 -0
  30. glmm-0.1.0/python/glmm/__init__.py +408 -0
  31. glmm-0.1.0/python/pyproject.toml +51 -0
  32. glmm-0.1.0/python/tests/test_api.py +74 -0
  33. glmm-0.1.0/python/tests/test_factor_levels.py +92 -0
  34. glmm-0.1.0/python/tests/test_fit_wiring.py +86 -0
  35. glmm-0.1.0/python/tests/test_packaging.py +11 -0
  36. glmm-0.1.0/python/tests/test_summary.py +97 -0
  37. glmm-0.1.0/python/tests/test_validation.py +213 -0
  38. glmm-0.1.0/r/.Rbuildignore +6 -0
  39. glmm-0.1.0/r/.gitignore +11 -0
  40. glmm-0.1.0/r/DESCRIPTION +31 -0
  41. glmm-0.1.0/r/NAMESPACE +30 -0
  42. glmm-0.1.0/r/R/extendr-wrappers.R +25 -0
  43. glmm-0.1.0/r/R/fastglmm-methods.R +392 -0
  44. glmm-0.1.0/r/R/fastglmm-package.R +3 -0
  45. glmm-0.1.0/r/R/fastglmm.R +484 -0
  46. glmm-0.1.0/r/README.md +79 -0
  47. glmm-0.1.0/r/cleanup +1 -0
  48. glmm-0.1.0/r/cleanup.win +1 -0
  49. glmm-0.1.0/r/configure +3 -0
  50. glmm-0.1.0/r/configure.win +2 -0
  51. glmm-0.1.0/r/man/VarCorr.Rd +32 -0
  52. glmm-0.1.0/r/man/confint.fastglmm.Rd +28 -0
  53. glmm-0.1.0/r/man/fastglmm-package.Rd +22 -0
  54. glmm-0.1.0/r/man/fastglmm.Rd +135 -0
  55. glmm-0.1.0/r/man/fastglmm_fit.Rd +17 -0
  56. glmm-0.1.0/r/man/fixef.Rd +24 -0
  57. glmm-0.1.0/r/man/formula.fastglmm.Rd +25 -0
  58. glmm-0.1.0/r/man/isSingular.Rd +21 -0
  59. glmm-0.1.0/r/man/ranef.Rd +21 -0
  60. glmm-0.1.0/r/man/sigma.fastglmm.Rd +20 -0
  61. glmm-0.1.0/r/man/summary.fastglmm.Rd +24 -0
  62. glmm-0.1.0/r/src/.gitignore +8 -0
  63. glmm-0.1.0/r/src/Makevars.in +52 -0
  64. glmm-0.1.0/r/src/Makevars.win.in +51 -0
  65. glmm-0.1.0/r/src/entrypoint.c +10 -0
  66. glmm-0.1.0/r/src/fastglmm-win.def +2 -0
  67. glmm-0.1.0/r/tests/testthat/helper-benchmark.R +34 -0
  68. glmm-0.1.0/r/tests/testthat/test-acceptance-benchmark.R +84 -0
  69. glmm-0.1.0/r/tests/testthat/test-errors.R +123 -0
  70. glmm-0.1.0/r/tests/testthat/test-marshalling.R +96 -0
  71. glmm-0.1.0/r/tests/testthat/test-methods.R +140 -0
  72. glmm-0.1.0/r/tests/testthat.R +4 -0
  73. glmm-0.1.0/r/tools/config.R +113 -0
  74. glmm-0.1.0/r/tools/cran-tarball.sh +90 -0
  75. glmm-0.1.0/r/tools/msrv.R +117 -0
  76. glmm-0.1.0/src/consts.rs +444 -0
  77. glmm-0.1.0/src/family.rs +367 -0
  78. glmm-0.1.0/src/fit/common.rs +482 -0
  79. glmm-0.1.0/src/fit/common_tests.rs +1203 -0
  80. glmm-0.1.0/src/fit/glm.rs +386 -0
  81. glmm-0.1.0/src/fit/glm_tests.rs +888 -0
  82. glmm-0.1.0/src/fit/glmm.rs +472 -0
  83. glmm-0.1.0/src/fit/glmm_tests.rs +2394 -0
  84. glmm-0.1.0/src/fit/lmm.rs +198 -0
  85. glmm-0.1.0/src/fit/lmm_tests.rs +1449 -0
  86. glmm-0.1.0/src/fit/loop_advanced_seam.rs +431 -0
  87. glmm-0.1.0/src/fit/mod.rs +682 -0
  88. glmm-0.1.0/src/fit/ols.rs +130 -0
  89. glmm-0.1.0/src/fit/ols_tests.rs +124 -0
  90. glmm-0.1.0/src/formula/error.rs +113 -0
  91. glmm-0.1.0/src/formula/materialize.rs +748 -0
  92. glmm-0.1.0/src/formula/mod.rs +31 -0
  93. glmm-0.1.0/src/formula/parse.rs +448 -0
  94. glmm-0.1.0/src/glm.rs +1013 -0
  95. glmm-0.1.0/src/glmm/agq.rs +631 -0
  96. glmm-0.1.0/src/glmm/deviance.rs +552 -0
  97. glmm-0.1.0/src/glmm/mod.rs +949 -0
  98. glmm-0.1.0/src/glmm/pirls.rs +1829 -0
  99. glmm-0.1.0/src/glmm/se.rs +641 -0
  100. glmm-0.1.0/src/glmm/tests.rs +4594 -0
  101. glmm-0.1.0/src/glmm/workspace.rs +1021 -0
  102. glmm-0.1.0/src/ids.rs +117 -0
  103. glmm-0.1.0/src/lib.rs +72 -0
  104. glmm-0.1.0/src/linalg.rs +29 -0
  105. glmm-0.1.0/src/lme.rs +3130 -0
  106. glmm-0.1.0/src/lmm.rs +4754 -0
  107. glmm-0.1.0/src/loop_advanced.rs +107 -0
  108. glmm-0.1.0/src/ols.rs +1182 -0
  109. glmm-0.1.0/src/simd_transcendental.rs +950 -0
  110. glmm-0.1.0/src/sparse/glmm.rs +1299 -0
  111. glmm-0.1.0/src/sparse/mod.rs +1931 -0
  112. glmm-0.1.0/src/sparse/tests.rs +4544 -0
  113. glmm-0.1.0/src/spec.rs +274 -0
  114. glmm-0.1.0/src/start.rs +27 -0
  115. glmm-0.1.0/src/test_support.rs +277 -0
  116. glmm-0.1.0/tests/contrasts_oracle.rs +101 -0
  117. glmm-0.1.0/tests/fixtures/contrasts_fixtures.rs +69 -0
  118. glmm-0.1.0/tests/fixtures/gen_glmm_hessian_vcov.R +40 -0
  119. glmm-0.1.0/tests/fixtures/glmm_hessian_vcov.json +114 -0
  120. glmm-0.1.0/tests/formula_fit.rs +675 -0
  121. glmm-0.1.0/tests/gen_contrasts_fixtures.R +82 -0
  122. glmm-0.1.0/tests/parse_suite.rs +416 -0
@@ -0,0 +1,160 @@
1
+ name: CI
2
+
3
+ # Single-crate mirror of the MCPower engine's `rust` job, plus the three gates a
4
+ # standalone publishable crate needs: an MSRV floor check, a wasm compile (the
5
+ # crate ships a cfg(wasm32) faer split), and a publish dry-run so `main` is always
6
+ # in a releasable state.
7
+ on:
8
+ push:
9
+ branches: [main, dev]
10
+ pull_request:
11
+ branches: [main]
12
+ # Let release.yml reuse these exact gates before publishing (single source of truth).
13
+ workflow_call:
14
+
15
+ # Newer pushes supersede in-flight runs on the same ref.
16
+ concurrency:
17
+ group: ci-${{ github.ref }}
18
+ cancel-in-progress: true
19
+
20
+ env:
21
+ CARGO_TERM_COLOR: always
22
+
23
+ jobs:
24
+ # ── fmt + clippy + the regex-free-kernel guard, on stable ─────────────────
25
+ # Split out from the #[test] suite so the four feature-variant test runs can
26
+ # go wide in a matrix (below) instead of recompiling serially behind clippy.
27
+ lint:
28
+ name: lint
29
+ runs-on: ubuntu-latest
30
+ timeout-minutes: 15
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: dtolnay/rust-toolchain@stable
34
+ with:
35
+ components: rustfmt, clippy
36
+ - uses: Swatinem/rust-cache@v2
37
+ - name: fmt
38
+ run: cargo fmt --all -- --check
39
+ - name: clippy
40
+ # The crate's workspace lints set clippy::all = deny, so a plain clippy
41
+ # run is the correctness gate — no extra `-- -D warnings` needed.
42
+ run: cargo clippy --all-targets
43
+ - name: guard — the default-off kernel links no regex
44
+ # `formula` is on by default (semver-covered surface), so the kernel's
45
+ # regex-free property is no longer structural — this assertion IS the
46
+ # guarantee. See GLMM.md's dependency-direction decision.
47
+ run: |
48
+ if cargo tree -e normal -p glmm --no-default-features | grep -q regex; then
49
+ echo "::error::default-off kernel pulls in regex — the formula feature is leaking"
50
+ cargo tree -e normal -p glmm --no-default-features | grep -B5 regex
51
+ exit 1
52
+ fi
53
+ echo "Kernel is regex-free with default features off."
54
+
55
+ # ── the full #[test] suite across all four feature configs, in parallel ───
56
+ test:
57
+ name: test
58
+ runs-on: ubuntu-latest
59
+ timeout-minutes: 15
60
+ env:
61
+ # The dhat bounded-allocation gates assert exact alloc counts; faer/rayon
62
+ # multi-thread jitter perturbs them, so pin the pool to one thread.
63
+ RAYON_NUM_THREADS: 1
64
+ strategy:
65
+ # A failing feature config must not cancel the others — we want every
66
+ # leg's result on one push, not just the first to break.
67
+ fail-fast: false
68
+ matrix:
69
+ # Each entry is one recompile-and-test on its own runner:
70
+ # default — the semver-covered surface.
71
+ # loop_advanced — unstable scratch-explicit surface, off by default;
72
+ # must still compile+pass so the hot-path consumer
73
+ # never breaks silently.
74
+ # parallel — rayon in-fit parallelism (AGQ cluster loop), off by
75
+ # default; includes the bit-identity test pinning it
76
+ # to the serial path's exact output.
77
+ # no-default — the parse-once/fit-many formula-free kernel; the
78
+ # formula tests are #![cfg(feature = "formula")] and
79
+ # skip themselves.
80
+ include:
81
+ - { name: default, flags: "" }
82
+ - { name: loop_advanced, flags: "--features loop_advanced" }
83
+ - { name: parallel, flags: "--features parallel" }
84
+ - { name: no-default, flags: "--no-default-features" }
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+ - uses: dtolnay/rust-toolchain@stable
88
+ - uses: Swatinem/rust-cache@v2
89
+ - name: test (${{ matrix.name }})
90
+ run: cargo test ${{ matrix.flags }}
91
+
92
+ # ── MSRV floor: the bobyqa dep pins rust-version = 1.85 ───────────────────
93
+ msrv:
94
+ name: msrv (1.85)
95
+ runs-on: ubuntu-latest
96
+ timeout-minutes: 15
97
+ steps:
98
+ - uses: actions/checkout@v4
99
+ - uses: dtolnay/rust-toolchain@1.85
100
+ - uses: Swatinem/rust-cache@v2
101
+ - run: cargo check --all-targets
102
+
103
+ # ── wasm: keep the cfg(wasm32) faer config honest ─────────────────────────
104
+ wasm:
105
+ name: wasm
106
+ runs-on: ubuntu-latest
107
+ timeout-minutes: 15
108
+ steps:
109
+ - uses: actions/checkout@v4
110
+ - uses: dtolnay/rust-toolchain@stable
111
+ with:
112
+ targets: wasm32-unknown-unknown
113
+ - uses: Swatinem/rust-cache@v2
114
+ - run: cargo check --target wasm32-unknown-unknown
115
+ - name: check (parallel feature, must be inert on wasm)
116
+ # rayon is target-dep-excluded on wasm32, so the feature must compile to
117
+ # a no-op here, never pull rayon in or break the build.
118
+ run: cargo check --target wasm32-unknown-unknown --features parallel
119
+
120
+ # ── package: gate the publishable state on every push ─────────────────────
121
+ package:
122
+ name: publish dry-run
123
+ runs-on: ubuntu-latest
124
+ timeout-minutes: 15
125
+ steps:
126
+ - uses: actions/checkout@v4
127
+ - uses: dtolnay/rust-toolchain@stable
128
+ - uses: Swatinem/rust-cache@v2
129
+ - run: cargo publish --dry-run
130
+
131
+ # ── python: build the glmm wheel, install it, test + lint (spec §7) ───────
132
+ python:
133
+ name: python (glmm wheel)
134
+ runs-on: ubuntu-latest
135
+ timeout-minutes: 20
136
+ steps:
137
+ - uses: actions/checkout@v4
138
+ - uses: dtolnay/rust-toolchain@stable
139
+ - uses: Swatinem/rust-cache@v2
140
+ - uses: actions/setup-python@v5
141
+ with:
142
+ python-version: "3.12"
143
+ - name: Build wheel (abi3, Linux x86_64)
144
+ uses: PyO3/maturin-action@v1
145
+ with:
146
+ working-directory: python
147
+ command: build
148
+ args: --release --out dist
149
+ - name: Install the built wheel (non-editable, catches packaging mistakes)
150
+ run: pip install --find-links python/dist glmm
151
+ - name: Install test + lint deps
152
+ run: pip install pytest ruff
153
+ - name: pytest against the installed wheel, run outside the source tree
154
+ run: |
155
+ cd "$RUNNER_TEMP"
156
+ python -m pytest "$GITHUB_WORKSPACE/python/tests" -v
157
+ - name: ruff check
158
+ run: ruff check python/
159
+ - name: ruff format --check
160
+ run: ruff format --check python/
@@ -0,0 +1,61 @@
1
+ name: Publish (r-universe)
2
+
3
+ # Publishes fastglmm to r-universe. r-universe builds straight from a git ref
4
+ # via `R CMD build`, which strips out-of-package path deps (r/ depends on
5
+ # ../glmm-r, which depends on ../ — the glmm kernel), so it cannot build the
6
+ # repo tree directly. Instead this workflow runs cran-tarball.sh --vendor to
7
+ # stage the self-contained, offline-vendored package and force-pushes that
8
+ # staged tree as the *root* of an orphan `r-universe` branch. r-universe's
9
+ # registry (a separate repo the user controls) points at that branch; it
10
+ # notices the ref change and rebuilds. main never holds the vendored tree —
11
+ # the branch is a generated artifact (docs/GLMM/plans/2026-07-16-r-repo-publishing-spec.md).
12
+ on:
13
+ push:
14
+ tags: ["r-*"]
15
+
16
+ jobs:
17
+ verify:
18
+ uses: ./.github/workflows/ci.yml
19
+
20
+ version-check:
21
+ name: Verify tag matches r/DESCRIPTION
22
+ runs-on: ubuntu-latest
23
+ needs: [verify]
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - name: Check tag matches DESCRIPTION version
27
+ shell: bash
28
+ run: |
29
+ TAG="${GITHUB_REF#refs/tags/r-}"
30
+ VERSION=$(grep -m1 '^Version: ' r/DESCRIPTION | cut -d' ' -f2)
31
+ if [ "$VERSION" != "$TAG" ]; then
32
+ echo "::error::r/DESCRIPTION has Version: $VERSION, tag is r-$TAG"
33
+ exit 1
34
+ fi
35
+ echo "r/DESCRIPTION matches tag r-$TAG."
36
+
37
+ publish-r-universe:
38
+ name: Stage + push r-universe branch
39
+ needs: [version-check]
40
+ runs-on: ubuntu-latest
41
+ timeout-minutes: 20
42
+ permissions:
43
+ contents: write # force-push the generated r-universe branch with GITHUB_TOKEN
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: dtolnay/rust-toolchain@stable
47
+ - uses: Swatinem/rust-cache@v2
48
+ - uses: r-lib/actions/setup-r@v2
49
+ - name: Stage self-contained, vendored package
50
+ run: r/tools/cran-tarball.sh --vendor
51
+ - name: Force-push staged tree as the r-universe branch root
52
+ working-directory: target/cran-stage/fastglmm
53
+ run: |
54
+ git init -q
55
+ git config user.name "github-actions[bot]"
56
+ git config user.email "github-actions[bot]@users.noreply.github.com"
57
+ git add -A
58
+ git commit -q -m "r-universe: fastglmm ${GITHUB_REF#refs/tags/r-}"
59
+ git push --force "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:r-universe
60
+ env:
61
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,234 @@
1
+ name: Publish
2
+
3
+ # One tag, one release: `v*` ships the crate to crates.io AND the Python package
4
+ # to PyPI from the same run. The Python package is a thin wrapper over the crate
5
+ # with no independent version or changelog, so it has no separate cadence.
6
+ #
7
+ # Both indexes use Trusted Publishing (OIDC) — no stored tokens.
8
+ #
9
+ # PREREQUISITES:
10
+ # - crates.io: crate exists and has a Trusted Publisher pointing at THIS repo +
11
+ # release.yml + the `release` environment. (No pending-publisher flow there,
12
+ # so 0.0.1 was published manually; this covers 0.0.2+.)
13
+ # - PyPI: a pending publisher for project `glmm` bound to this repo +
14
+ # release.yml + the same `release` environment (shared with crates.io —
15
+ # protection rules on it gate both uploads).
16
+ on:
17
+ push:
18
+ tags: ["v*"]
19
+
20
+ env:
21
+ CARGO_TERM_COLOR: always
22
+
23
+ jobs:
24
+ # Run the full CI suite (fmt + clippy + test + msrv + wasm + publish dry-run)
25
+ # against the tagged commit. Everything downstream is gated on this — a tag that
26
+ # fails any gate never reaches an index.
27
+ verify:
28
+ uses: ./.github/workflows/ci.yml
29
+
30
+ # The three versions are lockstep-coupled but otherwise enforced only by a
31
+ # comment in pyproject.toml. This makes drift a release-time failure.
32
+ version-check:
33
+ name: Verify tag matches all three versions
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - name: Check 3-way version lockstep
38
+ shell: bash
39
+ run: |
40
+ TAG="${GITHUB_REF#refs/tags/v}"
41
+ fail=0
42
+ check() { # $1 = found version, $2 = file it came from
43
+ if [ "$1" != "$TAG" ]; then
44
+ echo "::error::$2 has version '$1', tag is '$TAG'"; fail=1
45
+ fi
46
+ }
47
+ CRATE=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
48
+ PYCRATE=$(grep -m1 '^version = ' glmm-python/Cargo.toml | cut -d'"' -f2)
49
+ PYPROJ=$(grep -m1 '^version = ' python/pyproject.toml | cut -d'"' -f2)
50
+ check "$CRATE" Cargo.toml
51
+ check "$PYCRATE" glmm-python/Cargo.toml
52
+ check "$PYPROJ" python/pyproject.toml
53
+ [ "$fail" = 0 ] || exit 1
54
+ echo "All three at $TAG."
55
+
56
+ build_wheels:
57
+ name: Build & test wheel (${{ matrix.target }} on ${{ matrix.os }})
58
+ needs: [verify, version-check]
59
+ runs-on: ${{ matrix.os }}
60
+ strategy:
61
+ fail-fast: false
62
+ matrix:
63
+ # One abi3 wheel per platform (abi3-py310 → no per-Python matrix), built
64
+ # AND tested on its own hardware so no wheel ships untested. macOS is
65
+ # Apple Silicon only: the Intel macos-13 runner was retired Dec 2025 —
66
+ # Intel Macs fall back to the sdist.
67
+ # Linux is also built+tested by ci.yml's python job (via verify) —
68
+ # deliberate: that job gates every push, this one produces the release
69
+ # artifact with the manylinux tag. Don't deduplicate.
70
+ include:
71
+ - { os: ubuntu-latest, target: x86_64, manylinux: '2_28' }
72
+ - { os: macos-latest, target: aarch64 }
73
+ - { os: windows-latest, target: x64 }
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ - uses: dtolnay/rust-toolchain@stable
77
+ - uses: actions/setup-python@v5
78
+ with:
79
+ python-version: '3.12'
80
+ # maturin-action, not cibuildwheel: pyproject's manifest-path reaches out to
81
+ # ../glmm-python, which depends on ... cibuildwheel mounts
82
+ # only the package dir, so those paths would not resolve; this mounts the
83
+ # whole workspace.
84
+ - name: Build wheel
85
+ uses: PyO3/maturin-action@v1
86
+ with:
87
+ target: ${{ matrix.target }}
88
+ # manylinux applies to the Linux entry only; 'auto' is a no-op on the
89
+ # macOS/Windows entries that omit it.
90
+ manylinux: ${{ matrix.manylinux || 'auto' }}
91
+ working-directory: python
92
+ args: --release --out dist
93
+ - name: Verify native extension + license are in the wheel
94
+ shell: bash
95
+ run: |
96
+ python - <<'PY'
97
+ import glob, sys, zipfile
98
+ wheels = glob.glob("python/dist/*.whl")
99
+ assert wheels, "no wheel built"
100
+ for whl in wheels:
101
+ names = zipfile.ZipFile(whl).namelist()
102
+ if not any("_native" in n for n in names):
103
+ sys.exit(f"::error::native extension (_native) missing from {whl}")
104
+ lic = [n for n in names if n.endswith("dist-info/licenses/LICENSE")]
105
+ if not lic:
106
+ sys.exit(f"::error::LICENSE missing from {whl}")
107
+ body = zipfile.ZipFile(whl).read(lic[0]).decode()
108
+ # python/LICENSE must stay a real copy, never a symlink to ../LICENSE:
109
+ # Git for Windows does not materialize symlinks without core.symlinks,
110
+ # which actions/checkout does not set, so the runner would embed the
111
+ # 10-byte string "../LICENSE" as the GPL text. This catches that.
112
+ if "GNU GENERAL PUBLIC LICENSE" not in body:
113
+ sys.exit(f"::error::LICENSE in {whl} is not the GPL text")
114
+ print(f" ok: {whl}")
115
+ PY
116
+ - name: Install the built wheel and run the suite
117
+ shell: bash
118
+ run: |
119
+ python -m pip install --upgrade pip
120
+ python -m pip install python/dist/*.whl
121
+ python -m pip install pytest
122
+ # Scratch dir + absolute tests path: `import glmm` must resolve to the
123
+ # installed wheel, never python/glmm/ in the source tree.
124
+ cd "$RUNNER_TEMP"
125
+ python -m pytest "$GITHUB_WORKSPACE/python/tests" -v
126
+ - uses: actions/upload-artifact@v4
127
+ with:
128
+ name: wheels-${{ matrix.os }}-${{ matrix.target }}
129
+ path: python/dist/*.whl
130
+
131
+ build_sdist:
132
+ name: Build & test sdist
133
+ needs: [verify, version-check]
134
+ runs-on: ubuntu-latest
135
+ steps:
136
+ - uses: actions/checkout@v4
137
+ - uses: dtolnay/rust-toolchain@stable
138
+ - uses: actions/setup-python@v5
139
+ with:
140
+ python-version: '3.12'
141
+ # Plain maturin, not maturin-action: an sdist needs no manylinux container,
142
+ # so the action's container machinery buys nothing here. (The build only
143
+ # archives sources; the Rust toolchain above is for the install step below.)
144
+ - name: Build sdist
145
+ working-directory: python
146
+ run: |
147
+ python -m pip install "maturin>=1.9,<2.0"
148
+ maturin sdist --out dist
149
+ # The sdist is the only install path for Intel Macs and Linux aarch64, so it
150
+ # gets the same gate as the wheels: install it (a real source build — hence
151
+ # the Rust toolchain above) and run the suite.
152
+ - name: Install from the sdist and run the suite
153
+ shell: bash
154
+ run: |
155
+ python -m pip install python/dist/*.tar.gz
156
+ python -m pip install pytest
157
+ cd "$RUNNER_TEMP"
158
+ python -m pytest "$GITHUB_WORKSPACE/python/tests" -v
159
+ - uses: actions/upload-artifact@v4
160
+ with:
161
+ name: sdist
162
+ path: python/dist/*.tar.gz
163
+
164
+ publish-pypi:
165
+ name: Publish to PyPI
166
+ needs: [build_wheels, build_sdist]
167
+ runs-on: ubuntu-latest
168
+ environment: release # must match the PyPI trusted-publisher config
169
+ permissions:
170
+ id-token: write # mints the OIDC token PyPI verifies
171
+ steps:
172
+ - uses: actions/download-artifact@v4
173
+ with:
174
+ path: dist/
175
+ - name: Flatten artifacts
176
+ run: |
177
+ mkdir -p dist-flat
178
+ find dist/ \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist-flat/ \;
179
+ ls -la dist-flat/
180
+ - name: Publish to PyPI
181
+ uses: pypa/gh-action-pypi-publish@release/v1
182
+ with:
183
+ packages-dir: dist-flat/
184
+ # No skip-existing, unlike the crates.io guard below: on the real index
185
+ # "file already exists" means the version was not bumped, and that must
186
+ # fail loudly.
187
+
188
+ # Ordered last, after PyPI: both uploads are irreversible, and this way a failing
189
+ # wheel never leaves a crate release stranded without its Python counterpart. If
190
+ # this job fails after PyPI succeeded, re-run THIS JOB alone — a full workflow
191
+ # re-run dies in publish-pypi on the already-uploaded files.
192
+ publish-crates:
193
+ name: cargo publish
194
+ needs: [publish-pypi]
195
+ runs-on: ubuntu-latest
196
+ timeout-minutes: 20
197
+ # Naming an environment lets you attach required-reviewer / branch protection
198
+ # to releases. It MUST match the environment set in the crates.io Trusted
199
+ # Publisher config (or remove it in both places).
200
+ environment: release
201
+ permissions:
202
+ # Required: lets the job request the OIDC identity token crates.io verifies.
203
+ id-token: write
204
+ steps:
205
+ - uses: actions/checkout@v4
206
+ - uses: dtolnay/rust-toolchain@stable
207
+ - uses: Swatinem/rust-cache@v2
208
+ # cargo publish has no --skip-existing and hard-fails on an existing version,
209
+ # so re-running a tag after a transient failure would error instead of
210
+ # converging. This guard makes it a no-op instead.
211
+ - name: Skip if this version is already on crates.io
212
+ id: guard
213
+ shell: bash
214
+ run: |
215
+ TAG="${GITHUB_REF#refs/tags/v}"
216
+ # Sparse-index path for a 4-char crate name: /gl/mm/glmm. One JSON object
217
+ # per line, one per published version. `curl -f` so a 404 (crate absent)
218
+ # yields empty rather than an HTML body.
219
+ PUBLISHED=$(curl -sf https://index.crates.io/gl/mm/glmm | jq -r '.vers' || true)
220
+ if grep -qx "$TAG" <<<"$PUBLISHED"; then
221
+ echo "glmm $TAG already on crates.io — skipping publish."
222
+ echo "skip=true" >> "$GITHUB_OUTPUT"
223
+ else
224
+ echo "skip=false" >> "$GITHUB_OUTPUT"
225
+ fi
226
+ - name: Authenticate to crates.io (Trusted Publishing)
227
+ if: steps.guard.outputs.skip == 'false'
228
+ id: auth
229
+ uses: rust-lang/crates-io-auth-action@v1
230
+ - name: Publish
231
+ if: steps.guard.outputs.skip == 'false'
232
+ run: cargo publish
233
+ env:
234
+ CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
glmm-0.1.0/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ /target
2
+ /Cargo.lock
3
+ dhat-heap.json
4
+ python/venv/
5
+ __pycache__/
6
+ *.egg-info/
7
+ python/build/
8
+ python/.pytest_cache/
9
+ python/glmm/_native*.so
10
+ python/glmm/_native*.pyd
11
+
12
+ # parity: fixed-seed simulated inputs (regenerated by run.sh --prep; the freeze is
13
+ # the committed seed + prep script, not the bytes) and per-engine result JSONs
14
+ # (regenerated every run). Committed inputs: data_empirical/*.csv (lme4/nlme). Durable
15
+ # summaries live in parity/reports/ and parity/weights/ (not under results/).
16
+ # The flat *.csv fixtures under data_simulated/ are small and committed (cargo
17
+ # test's include_str! depends on them, incl. in CI); only the 429MB grid/
18
+ # campaign dataset is gitignored.
19
+ /parity/data_simulated/grid/
20
+ /parity/weights/data_simulated/
21
+ /parity/results/
22
+ /parity/weights/results/
@@ -0,0 +1,191 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `glmm` crate are recorded here. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ The Python package (`glmm` on PyPI) is versioned in lockstep with the crate and
7
+ shares these entries; Python-specific notes are called out where they differ.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.0] — 2026-07-16
12
+
13
+ First release of the Python package (`glmm` on PyPI), and the first crate
14
+ release since `0.0.2`. The breaking changes below are real breaks against the
15
+ published `0.0.2` — `ModelSpec` is now structure-only and the `mcpower` feature
16
+ is renamed `loop_advanced`. `0.0.3` was never published, so it is not a
17
+ migration source.
18
+
19
+ All four estimators are wired into the stable `fit` dispatch: OLS; GLM
20
+ (Gaussian, binomial logit/probit, Poisson, Gamma, negative binomial); LMM
21
+ (closed-form single-intercept + BOBYQA general); GLMM (dense and sparse-Z, all
22
+ families including NB), with AGQ (nAGQ > 1) for single scalar random effects.
23
+ Validated against R/lme4 and Julia/MixedModels.jl across a 23-rung dataset
24
+ parity manifest plus a 15-rung prior-weights harness.
25
+
26
+ ### Fixed
27
+
28
+ - **A factor's level order is no longer silently discarded.** `glmm::formula`
29
+ sorted every factor's levels lexicographically, so the treatment-contrast base
30
+ was whichever label sorted first, regardless of what the caller asked for. A
31
+ deliberately ordered categorical — `pd.Categorical(x, categories=["low",
32
+ "med", "high"])` — was refactored to base `"high"`, returning a different
33
+ coefficient for a different question with nothing in the output to reveal it.
34
+ `Column::Factor` now takes `{ levels, codes }`, so the caller states the order
35
+ and level 0 is the base. Python passes a `Categorical`'s
36
+ `categories`/`codes` through; a plain string column has no declared order and
37
+ is sorted by `Column::factor_from_labels` — the same lexicographic default as
38
+ R's `factor()`, now a default rather than an imposition.
39
+ - Python: a categorical of non-strings (`pd.Categorical([1, 2, 3])`) was
40
+ classified numeric and fit as one continuous slope instead of expanding to
41
+ dummies. Column classification now checks the dtype before sniffing values.
42
+ - Python: `summary()` printed `group 0` instead of the grouping's name, and its
43
+ per-term rows carried no labels — `Lowered::re_groups` was never carried
44
+ across the PyO3 shim. It now is, and `summary()` prints e.g. `Subject:` with
45
+ `(Intercept)`/`Days` rows.
46
+
47
+ ### Added
48
+
49
+ - `glmm::formula` — the R-style formula frontend is now part of the crate,
50
+ behind the `formula` feature (on by default). `lower("y ~ x + (1|g)", &table,
51
+ family)` builds the kernel's inputs from a formula string and a data table.
52
+ Previously an unpublished companion crate, so it was unreachable for anyone
53
+ installing from crates.io.
54
+ - `default-features = false` gives the formula-free kernel, which links no
55
+ `regex` — the configuration for parse-once/fit-many hot paths.
56
+ - **`Fit::vcov`** — the full `p×p` fixed-effect covariance `Cov(β̂)`, on every
57
+ path. `Fit::se` is its diagonal and cannot answer anything about two
58
+ coefficients jointly, so a contrast, a confidence interval, or anything of
59
+ `vcov()`/`confint`/`glht`/`emmeans`'s shape needed off-diagonals that were
60
+ being computed and thrown away (GLMM) or never formed (OLS/GLM/LMM). It is
61
+ finite exactly where `se` is. Also on the Python `Fit`, as a `(p, p)` array.
62
+ - Python `Fit` gained `n_eval` (optimizer evaluation count), `deviance` (the
63
+ minimized criterion — **not** comparable across models, see the docs), and
64
+ `re_groups`. All three were already on the Rust `Fit`; none crossed the shim.
65
+
66
+ ### Changed
67
+
68
+ - **Python: `theta=` is renamed `init_theta=`.** One call had two unrelated
69
+ parameters named `theta`: the negative-binomial shape seed and, inside
70
+ `warm_start={"theta": …}`, the random-effect Cholesky vector. The seed takes
71
+ the name R already uses for it (`MASS::glm.nb(init.theta=)`);
72
+ `warm_start["theta"]` is unchanged, matching lme4's `start=list(theta=)`.
73
+ - **Python: `targets=` is removed.** It exposed `FitOptions::target_indices`, a
74
+ performance knob for MCPower's hot path that leaves non-target SEs `NaN`. That
75
+ hot path drives the Rust surface directly, where the option is unchanged; no
76
+ Python caller wants `summary()` printing `NA` for standard errors it could
77
+ have computed.
78
+ - Python: the native call returns a dict keyed by field name rather than a
79
+ positional tuple. Internal, but it is why `re_groups`/`n_eval`/`deviance`
80
+ could go missing unnoticed.
81
+
82
+ ### LMM cold start
83
+
84
+ #### Changed
85
+
86
+ - **LMM cold starts now use the unit-diagonal blind seed** (diagonal θ at 1,
87
+ off-diagonal vech entries at 0 — the lme4/MixedModels convention), on both
88
+ the dense (`fit_lmm`) and sparse (`fit_mle_sparse`) Gaussian paths. The
89
+ former start set *every* component to 1; on wide-slope designs (q ≥ 4 with
90
+ correlated slopes) that start funneled BOBYQA into a second-best local
91
+ optimum on 8 of 9 adjudicated grid cells (deviance gaps +0.23 to +57.4 vs
92
+ the best-known optima, now frozen as goldens under `parity/goldens/optima/`).
93
+ With the new seed the fitted optimum matches or beats MixedModels on all 9.
94
+ Intercept-only and uncorrelated-slope models have no off-diagonal
95
+ components and are bit-identical. Full-grid effect vs MixedModels on the
96
+ gaussian slope stratum: worse-than-MM cells drop 8 → 2 — the two
97
+ remaining are *new* coin-flips where the old start happened to hold the
98
+ best-known basin (`lmm_q6_g300p5_bal_base` +0.008,
99
+ `lmm_q8_g3000p5_bal_lowsnr` +2.03; goldens frozen for both). It also fixes
100
+ the dense-vs-sparse basin disagreement behind the `noz_sparse_grid_agrees`
101
+ cell-20 failure. Eval counts on affected wide-slope fits move both ways
102
+ (grid-wide gaussian-slope total −10%). The sparse non-Gaussian GLMM joint
103
+ seed already used this shape; the Gaussian paths now match it.
104
+
105
+ ### Prior weights
106
+
107
+ #### Added
108
+
109
+ - **`FitOptions::weights`** — per-row prior (case) weights, lme4's `weights=`.
110
+ An aggregated binomial (y = success proportion, weight = trial count) now
111
+ fits directly — lme4's `cbind(s, m−s)` objective, which shares its argmin
112
+ (and so β/SE/varcomp) with the expanded-Bernoulli fit — letting the
113
+ `sim_sparse_binomial` parity rung fit its 240 aggregated rows instead of the
114
+ 3,059-row Bernoulli expansion. Parity holds at unchanged tolerances; the
115
+ per-solve O(n·width²) cost collapses accordingly.
116
+
117
+ #### Changed
118
+
119
+ - `FitOptions.weights` now supported on all paths (was: sparse binomial GLMM
120
+ only); nAGQ>1 with weights rejected.
121
+
122
+ ### Two-stage GLMM optimizer
123
+
124
+ #### Changed
125
+
126
+ - **GLMM fits now use a two-stage optimizer** (lme4's structure, Bates et al. 2015
127
+ §3): a fast θ-only search profiles the fixed effects β out per PIRLS iteration,
128
+ then a short joint (θ, β) polish on the exact Laplace objective warm-started from
129
+ it. The converged (θ̂, β̂) and all standard errors are unchanged up to optimizer
130
+ tolerance — the parity goldens hold at their existing tolerances — but the outer
131
+ evaluation count drops materially (roughly 2× fewer BOBYQA evaluations on the
132
+ grouseticks 3-crossed Poisson fixture). The prior single-stage joint solve remains
133
+ available as an internal A/B toggle. `Fit::n_eval` now includes stage-1
134
+ evaluations, so eval counts are not directly comparable to versions before this
135
+ change.
136
+
137
+ #### Added
138
+
139
+ - **PIRLS step-halving.** The inner penalized-IRLS loop now backtracks (halves the
140
+ step, up to 10 times) when a full Fisher-scoring step raises the penalized
141
+ deviance, hardening convergence on ill-scaled joint (u, β) steps; an exhausted
142
+ backtrack surfaces as the existing non-converged/NaN failure state.
143
+
144
+ ### M3.5 — warm-start entry-split
145
+
146
+ The fit surface now separates model *structure*, optimizer *warm-start state*, and
147
+ method *knobs* into three distinct places (`docs/GLMM/api.md`, Layers A–C). The
148
+ stable `fit`/`fit_grouped` signatures are unchanged; the breakage is in the shapes
149
+ they consume.
150
+
151
+ #### Changed (breaking)
152
+
153
+ - **`ModelSpec` is structure-only.** Removed the method knobs `wald_se` and `nagq`
154
+ and every magnitude payload — a `ModelSpec` can no longer carry a start estimate.
155
+ - `ReStructure` and `Grouping` lost `tau_squared` and now hold
156
+ `slopes: Vec<ColumnId>` (the `SlopeTerm` struct, which bundled a column with its
157
+ variance/correlation magnitudes, is deleted along with the `re_correlation_*`
158
+ helpers).
159
+ - `Family::Gamma` lost its `dispersion: Option<f64>` payload;
160
+ `Family::NegativeBinomial` lost its `theta: Option<f64>` payload.
161
+ - **`FitOptions` gained the relocated knobs:** `wald_se`, `nagq`, and `dispersion`
162
+ (the Gamma fix-vs-estimate directive). All are defaulted — construct with
163
+ `..FitOptions::default()` (Wald SE `Hessian`, `nagq` 1 = Laplace, `dispersion`
164
+ `None` = estimate φ post-fit). `FitOptions` now implements `Default`.
165
+ - **The stable `fit`/`fit_grouped` cold-start the optimizer** — they no longer derive
166
+ a warm start from spec magnitudes; the kernels use their `THETA0` blind start. The
167
+ converged MLE is unchanged up to optimizer tolerance (start-independent), so the
168
+ oracle goldens stay green at their existing tolerances.
169
+ - **Cargo feature `mcpower` renamed to `loop_advanced`.** Capability-named rather
170
+ than consumer-named; still off by default, still the unstable scratch-explicit
171
+ loop-tier surface with no semver guarantees. The `cluster_theta_truth` re-export is
172
+ removed (truth-start magnitudes no longer live in `ModelSpec`).
173
+
174
+ #### Added
175
+
176
+ - **`StartValues { beta, theta }`** — the warm-start primitive (api.md Layer B): raw
177
+ optimizer state (`beta` = fixed-effect start, `theta` = RE Cholesky parameters),
178
+ not high-level variances. Exported `pub` only behind the `loop_advanced` feature;
179
+ the stable tier never takes it. Carries no `phi`/`nb_theta`: Gamma φ is profiled and
180
+ the GLMM neg-binomial θ search is a global bracket, so neither warm-starts anything
181
+ reachable through the loop surface.
182
+
183
+ #### Migration — MCPower pin-bump action
184
+
185
+ MCPower consumes a pinned published `glmm`, so this rename is not a live break. When
186
+ MCPower next bumps its pinned `glmm`:
187
+
188
+ - switch its feature selection `mcpower` → `loop_advanced`;
189
+ - build any spec-derived start as a raw `StartValues.theta` (column-major vech of the
190
+ RE Cholesky parameters) instead of relying on the removed `cluster_theta_truth` /
191
+ `ModelSpec` magnitude fields.