Skip to content

Browser app for JAXSR at /jaxsr/app - #12

Merged
jkitchin merged 1 commit into
mainfrom
claude/wasm-browser-app
Aug 4, 2026
Merged

Browser app for JAXSR at /jaxsr/app#12
jkitchin merged 1 commit into
mainfrom
claude/wasm-browser-app

Conversation

@jkitchin

@jkitchin jkitchin commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Adds a zero-install front end published alongside the docs on GitHub Pages, at
https://kitchingroup.cheme.cmu.edu/jaxsr/app/: upload a spreadsheet, assign column
roles, pick which function families the search may draw from, and get a ranked table of
candidate equations with diagnostics and exports. Everything runs client-side through
Pyodide, so no data leaves the browser and there is no server to operate.

How it runs without JAX

jaxlib is a compiled XLA wheel with no Emscripten build, so JAX cannot run under
Pyodide. JAXSR barely needs it: roughly 1300 jnp.* calls that NumPy also provides,
plus a short tail of genuinely JAX-specific API (jit, grad, lax.erf, .at[]
index updates, random).

webapp/py/jax_shim.py supplies that tail on top of NumPy and registers itself in
sys.modules before import jaxsr, so the library itself is unmodified. That
substitution is only trustworthy if JAXSR behaves the same on top of it, so the whole
test suite runs through the shim as a new CI job (numpy-backend). A library change
that only breaks the browser path is caught at PR time.

Measured in real Pyodide: ~6 s to boot, 9-26 ms per fit, SymPy lazy-loaded in
377 ms and only when someone asks for LaTeX.

Three pre-existing bugs this surfaced

None of these are shim artifacts. All three reproduce on real JAX with
jax_enable_x64, which docs/guides/performance.md actively recommends.

  • basis.py_safe_exp clipped to a hardcoded ±500. At float32 that overflows
    to inf and the non-finite column filter drops the term; at float64 it yields a
    finite ~1e217 that survives selection and overflows Phi.T @ Phi. On real JAX in x64
    this silently returned a garbage 2-term model instead of recovering exp(x0*x1). The
    bound is now derived from np.finfo(dtype), and only the upper side is clipped —
    large negative arguments underflowing to zero is a legitimate result, not an
    out-of-domain one.

  • metrics.py — every information criterion returned +inf for mse <= 0, scoring
    a perfect fit as the worst possible model. Exact zeros are routine in float64
    because selection.py computes MSE by a closed form that cancels, so the correct
    model was being rejected outright. MSE is now floored so the log-likelihood stays
    finite and fewer parameters still wins ties.

  • metrics.py — the IC functions did scalar math with jnp, so every AIC/BIC in
    jaxsr was computed in float32. Switched to math: exact, and dtype-independent.

One API addition

SymbolicRegressor.selection_path_, a public accessor for the candidates the search
evaluated, so the app does not have to reach into _selection_path. Its docstring
records a subtlety worth knowing independently of this PR: the path predates the
pruning and constraint refitting in fit(), so path.best can carry terms the
returned model does not.

Verification

  • 656 tests pass on both backends — real JAX and the NumPy shim.
  • New tests/test_metrics.py closes a gap listed in CLAUDE.md; new
    tests/test_numpy_shim.py covers the JAX contract and checks a fit through the shim
    agrees with the same fit on real JAX.
  • Driven end-to-end in headless Chromium: uploads a real multi-sheet .xlsx with an ID
    column, a text column and failed runs, fits, and checks the rendered output. No
    console errors.
  • The bundled example workbook is generated from equations recorded on its own answer
    key. It includes stir_rpm, a variable drawn independently of the response — the
    winning model correctly excludes it, all four true coefficients land inside their 95%
    intervals, and the intercept is selected but flagged not significant, which is right
    since the data has no constant term.

Worth knowing

  • Deployment only takes effect on merge. docs.yml uploads the Pages artifact on
    pushes to main only; this PR exercises the doc build and both test backends but does
    not publish.
  • Browser verification used headless Chromium via Playwright, not a real interactive
    browser session. Worth a manual look before merging.
  • jupyter and nbconvert are declared runtime dependencies of jaxsr but are imported
    nowhere in src/. They are why micropip tried to pull in tornado and pyzmq. Harmless
    here since the app installs with deps=False, but they make every pip install jaxsr
    heavier than it needs to be. Not addressed in this PR.
  • The CLAUDE.md change also carries a pre-existing Crucible block that was already
    uncommitted in the working tree.

🤖 Generated with Claude Code

Adds a zero-install front end published alongside the docs on GitHub Pages:
upload a spreadsheet, assign column roles, pick which function families the
search may draw from, and get a ranked table of candidate equations with
diagnostics and exports. Everything runs client-side through Pyodide, so no
data leaves the browser and there is no server to operate.

jaxlib has no WebAssembly build, but jaxsr barely uses JAX: ~1300 jnp.* calls
that NumPy also provides, plus a short tail (jit, grad, lax.erf, .at[],
random). webapp/py/jax_shim.py supplies that tail on NumPy and registers
itself before `import jaxsr`, so the library needs no changes. The whole test
suite runs through the shim (scripts/test_under_numpy.py) as a CI job, so a
change that only breaks the browser path is caught at PR time. Measured in
Pyodide: ~6 s to boot, 9-26 ms per fit.

Validating the shim surfaced three pre-existing float64 bugs. They are not
shim artifacts -- all three reproduce on real JAX with jax_enable_x64, which
docs/guides/performance.md recommends:

- basis.py: _safe_exp clipped to a hardcoded +/-500. At float32 that overflows
  to inf and the non-finite column filter drops the term; at float64 it gives
  a finite ~1e217 that survives selection and overflows Phi.T @ Phi. On real
  JAX in x64 this silently returned a garbage 2-term model instead of
  recovering exp(x0*x1). The bound is now derived from np.finfo(dtype), and
  only the upper side is clipped.

- metrics.py: every information criterion returned +inf for mse <= 0, scoring
  a perfect fit as the worst possible model. Exact zeros are routine in
  float64 because selection.py computes MSE by a closed form that cancels, so
  the correct model was being rejected outright. MSE is now floored so the
  log-likelihood stays finite and fewer parameters still wins ties.

- metrics.py: the IC functions did scalar math with jnp, so every AIC/BIC in
  jaxsr was computed in float32. Switched to `math`, which is both exact and
  dtype-independent.

Also adds SymbolicRegressor.selection_path_, a public accessor for the
candidates the search evaluated, so the app does not reach into a private
attribute. Its docstring records a subtlety worth knowing: the path predates
the pruning and constraint refitting in fit(), so path.best can carry terms
the returned model does not.

New tests: tests/test_metrics.py (closes a gap listed in CLAUDE.md),
tests/test_numpy_shim.py, plus coverage for _safe_exp and selection_path_.
656 tests pass on both backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jkitchin
jkitchin merged commit aaeb0b4 into main Aug 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant