Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,23 @@ jobs:
- name: Install dependencies
run: |
pip install -e .
pip install "jupyter-book>=0.15,<2" sphinx-copybutton
# `build` produces the wheel the app installs into Pyodide;
# xlsxwriter writes the example workbook the app offers.
pip install "jupyter-book>=0.15,<2" sphinx-copybutton build "xlsxwriter>=3.1"

- name: Build the book
run: jupyter-book build docs/

# The browser app is served as plain static files alongside the docs, at
# /jaxsr/app/. It lives outside docs/ so jupyter-book never tries to
# process it; the build step produces the wheel the page installs into
# Pyodide, plus the manifest that names it.
- name: Build the browser app
run: python scripts/build_webapp.py

- name: Stage the browser app into the site
run: cp -r webapp docs/_build/html/app

- name: Upload artifact
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: actions/upload-pages-artifact@v3
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ jobs:
file: ./coverage.xml
fail_ci_if_error: false

# The browser app runs jaxsr on a NumPy stand-in for JAX, because jaxlib has
# no WebAssembly build. This runs the whole suite through that stand-in, so a
# library change that only breaks the browser path is caught here.
numpy-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,excel,reports,cli]"

- name: Run the test suite on the NumPy backend
run: |
python scripts/test_under_numpy.py tests/ -v --tb=short --timeout=60

lint:
runs-on: ubuntu-latest
steps:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ manuscript/*.bbl
manuscript/*.blg
manuscript/*.toc


# Web app build artifacts (regenerated by scripts/build_webapp.py)
webapp/wheels/
webapp/manifest.json

# Generated by scripts/make_example_workbook.py
webapp/example/
40 changes: 39 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,33 @@ ruff check --fix src/ tests/ # Auto-fix lint issues
# Coverage
pytest tests/ --cov=jaxsr --cov-report=term-missing # Coverage to terminal
pytest tests/ --cov=jaxsr --cov-report=html # Coverage HTML report → htmlcov/

# Browser app (webapp/) — see webapp/README.md
python scripts/test_under_numpy.py # run the suite on the NumPy backend
python scripts/build_webapp.py # build the wheel, manifest and example workbook
python -m http.server -d webapp 8000 # serve it locally
```

## The library must keep working without JAX

`webapp/` publishes JAXSR to the browser through Pyodide, where `jaxlib` cannot exist.
`webapp/py/jax_shim.py` supplies the small JAX-specific surface (`jit`, `grad`,
`lax.erf`, `.at[]`, `random`) on top of NumPy, and CI runs the whole test suite through
it (`numpy-backend` job in `.github/workflows/tests.yml`). Two consequences for library
code:

- **Everything runs in float64 there, not JAX's float32.** Do not rely on float32
overflow to signal an out-of-domain result — that was a real bug in `_safe_exp`, where
a fixed `clip(x, -500, 500)` produced `inf` at float32 (caught by the non-finite column
filter) but a finite ~1e217 at float64 that silently overflowed `Phi.T @ Phi`. Derive
such thresholds from `np.finfo(dtype)`.
- **Scalar math in the information criteria uses `math`, not `jnp`.** `jnp.log` on a
Python float produces a float32 scalar, which was silently costing precision in every
AIC/BIC comparison. Keep `metrics.py`'s IC functions on plain Python floats.

If you add a new JAX API call to `src/jaxsr/`, add it to the shim too, or the
`numpy-backend` CI job will fail.

## CI Requirements

All PRs must pass before merge:
Expand All @@ -39,7 +64,6 @@ Always run `black` and `ruff check` locally before committing.
- At least one test in the corresponding `tests/test_<module>.py` file

### Modules that still need dedicated test files:
- `metrics.py` → needs `tests/test_metrics.py`
- `simplify.py` → needs `tests/test_simplify.py`
- `sampling.py` → needs `tests/test_sampling.py`
- `plotting.py` → needs `tests/test_plotting.py`
Expand Down Expand Up @@ -301,3 +325,17 @@ The `fail_under` threshold is **60%**. Coverage reports exclude `pragma: no cove
3. **metrics.py** (28%) — Test all metric functions with known inputs/outputs
4. **utils.py** (40%) — Test utility functions
5. **basis.py** (58%) — Test SISSO, power laws, rational forms builders

<!-- crucible-project -->
## Crucible Knowledge Base

This project has a Crucible knowledge base in `.crucible/`.
Use the `crucible` CLI to ingest sources, search, and maintain the wiki.

Layout: `.crucible/sources/` (primary sources), `.crucible/wiki/` (distilled articles),
`.crucible/crucible.db` (graph database).

Conventions: org-mode with scimax, org-ref citations, narrative prose.
The LLM maintains the wiki; manual edits are the exception.
Run `crucible help all` for the full CLI reference.
<!-- crucible-project -->
148 changes: 148 additions & 0 deletions scripts/build_webapp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python
"""
Build the assets the JAXSR browser app needs.

The app installs JAXSR into Pyodide from a wheel served alongside the page, so
the wheel has to exist before the page can boot. This script builds it and
writes ``webapp/manifest.json`` describing what the page should load.

Usage
-----
python scripts/build_webapp.py [--skip-build]

``--skip-build`` reuses an existing wheel in ``dist/``, which is handy when
iterating on the front end.

The wheel and manifest are generated artifacts and are gitignored; CI rebuilds
them before publishing.
"""

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

REPO_ROOT = Path(__file__).resolve().parent.parent
WEBAPP = REPO_ROOT / "webapp"
WHEELS = WEBAPP / "wheels"
DIST = REPO_ROOT / "dist"

# Pinned so the page always boots against a known NumPy/SciPy. Bumping this is
# a deliberate act: check that numpy, scipy and sympy are still available in the
# new release before changing it.
#
# 0.29.3 ships CPython 3.13 / numpy 2.2 / scipy 1.14 / sympy 1.13, which is the
# closest match to the environment the NumPy shim is validated against by
# scripts/test_under_numpy.py. Newer Pyodide releases run CPython 3.14, which
# is outside the Python versions jaxsr's CI covers.
PYODIDE_VERSION = "0.29.3"
PYODIDE_CDN = f"https://cdn.jsdelivr.net/pyodide/v{PYODIDE_VERSION}/full/"

# Loaded before jaxsr. sympy is deliberately absent: it costs several MB and is
# only needed for LaTeX output, so the page fetches it on demand.
CORE_PACKAGES = ["numpy", "scipy", "micropip"]


def build_wheel() -> Path:
"""
Build the jaxsr wheel into ``dist/``.

Returns
-------
Path
The freshly built wheel.

Raises
------
SystemExit
If the build fails or produces no wheel.
"""
print("Building jaxsr wheel...")
result = subprocess.run(
[sys.executable, "-m", "build", "--wheel", "--outdir", str(DIST)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(result.stdout)
print(result.stderr, file=sys.stderr)
raise SystemExit("Wheel build failed. Is `build` installed? pip install build")
return newest_wheel()


def newest_wheel() -> Path:
"""
Return the most recently modified jaxsr wheel in ``dist/``.

Returns
-------
Path
Path to the wheel.

Raises
------
SystemExit
If no wheel is present.
"""
wheels = sorted(DIST.glob("jaxsr-*.whl"), key=lambda p: p.stat().st_mtime)
if not wheels:
raise SystemExit(f"No jaxsr wheel found in {DIST}. Run without --skip-build.")
return wheels[-1]


def main() -> int:
"""
Stage the wheel and write the manifest.

Returns
-------
int
Process exit code.
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--skip-build", action="store_true", help="reuse an existing wheel in dist/"
)
args = parser.parse_args()

wheel = newest_wheel() if args.skip_build else build_wheel()

import make_example_workbook

make_example_workbook.main([])

WHEELS.mkdir(parents=True, exist_ok=True)
for stale in WHEELS.glob("jaxsr-*.whl"):
stale.unlink()
staged = WHEELS / wheel.name
shutil.copy2(wheel, staged)

version = wheel.name.split("-")[1]
manifest = {
"jaxsrVersion": version,
"wheel": f"wheels/{wheel.name}",
"pyodideVersion": PYODIDE_VERSION,
"pyodideIndexURL": PYODIDE_CDN,
"corePackages": CORE_PACKAGES,
"pythonModules": ["py/jax_shim.py", "py/kernel.py"],
}
manifest_path = WEBAPP / "manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")

size_mb = staged.stat().st_size / 1e6
print(f" wheel {staged.relative_to(REPO_ROOT)} ({size_mb:.2f} MB)")
print(f" manifest {manifest_path.relative_to(REPO_ROOT)}")
print(f" jaxsr {version} on pyodide {PYODIDE_VERSION}")
print(f"\nServe locally with:\n python -m http.server -d {WEBAPP.relative_to(REPO_ROOT)} 8000")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading