From 41948c25a0a58b1b281a97f12778f51f5580e736 Mon Sep 17 00:00:00 2001 From: kp992 Date: Fri, 14 Aug 2026 17:26:47 -0700 Subject: [PATCH 1/9] WASM: add JupyterLite environment config and browser smoke suite (#928) --- ci/wasm/environment.yml | 9 ++ ci/wasm/smoke_test.py | 240 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 ci/wasm/environment.yml create mode 100644 ci/wasm/smoke_test.py diff --git a/ci/wasm/environment.yml b/ci/wasm/environment.yml new file mode 100644 index 00000000..f757fa3e --- /dev/null +++ b/ci/wasm/environment.yml @@ -0,0 +1,9 @@ +name: qe-lite +channels: + - https://prefix.dev/emscripten-forge-4x + - https://prefix.dev/conda-forge +dependencies: + - xeus-python + - numba + - quantecon + - pytest diff --git a/ci/wasm/smoke_test.py b/ci/wasm/smoke_test.py new file mode 100644 index 00000000..0ec60ff3 --- /dev/null +++ b/ci/wasm/smoke_test.py @@ -0,0 +1,240 @@ +""" +Browser smoke suite for QuantEcon.py on the JupyterLite xeus-python kernel. + +One representative function per Numba feature class used in the library. +Run natively with pytest to validate the suite itself; the same file is +consumed by the WASM CI job (issue #933). + +Findings from a WASM run should be recorded in issue #928 as a results +table: function name -> works / fails / notes. +""" +import sys +import time +import warnings + +import numpy as np +import pytest +from numba import njit + +IS_EMSCRIPTEN = sys.platform == "emscripten" + +# --------------------------------------------------------------------------- +# Jitted helpers required by optimize tests (must be at module scope) +# --------------------------------------------------------------------------- + +@njit +def _rosenbrock(x): + return -(100 * (x[1] - x[0] ** 2) ** 2 + (1 - x[0]) ** 2) + + +@njit +def _parabola(x): + return -(x + 2.0) ** 2 + 1.0 + + +@njit +def _cubic(x): + return x ** 3 - 1.0 + + +@njit +def _cubic_prime(x): + return 3.0 * x ** 2 + + +@njit +def _linalg_solve(A, b): + return np.linalg.solve(A, b) + + +# --------------------------------------------------------------------------- +# 1. Import timing — cold vs warm cache (feeds issue #930) +# --------------------------------------------------------------------------- + +def test_import_time(): + t0 = time.perf_counter() + import quantecon # noqa: F401 + elapsed = time.perf_counter() - t0 + # 30 s is generous for a cold WASM JIT cache; native should be <1 s. + assert elapsed < 30, f"import took {elapsed:.1f} s" + + +# --------------------------------------------------------------------------- +# 2. Plain lazy @njit — tauchen and rouwenhorst +# --------------------------------------------------------------------------- + +def test_tauchen(): + import quantecon as qe + mc = qe.tauchen(5, 0.9, 0.1) + assert mc.P.shape == (5, 5) + assert np.allclose(mc.P.sum(axis=1), 1.0) + + +def test_rouwenhorst(): + import quantecon as qe + mc = qe.rouwenhorst(5, 0.9, 0.1) + assert mc.P.shape == (5, 5) + assert np.allclose(mc.P.sum(axis=1), 1.0) + + +# --------------------------------------------------------------------------- +# 3. MarkovChain.simulate — jitted simulation with NRT-allocated arrays +# --------------------------------------------------------------------------- + +def test_markov_simulate(): + import quantecon as qe + mc = qe.tauchen(5, 0.9, 0.1) + sim = mc.simulate_indices(ts_length=200, init=0, random_state=42) + assert len(sim) == 200 + assert np.all((sim >= 0) & (sim < 5)) + + +# --------------------------------------------------------------------------- +# 4. probvec — parallel guvectorize; on Emscripten patch 0007 falls back +# to 'cpu' target silently, so the result must still be correct +# --------------------------------------------------------------------------- + +def test_probvec(): + import quantecon as qe + result = qe.random.probvec(4, 3, random_state=42) + assert result.shape == (4, 3) + assert np.allclose(result.sum(axis=1), 1.0) + assert np.all(result >= 0) + + +# --------------------------------------------------------------------------- +# 5. sample_without_replacement — eager guvectorize with explicit i8 sig +# --------------------------------------------------------------------------- + +def test_sample_without_replacement(): + import quantecon as qe + result = qe.random.sample_without_replacement(10, 4, random_state=42) + assert len(result) == 4 + assert len(set(result.tolist())) == 4 + assert np.all((result >= 0) & (result < 10)) + + +# --------------------------------------------------------------------------- +# 6. Optimize: nelder_mead, brent_max, newton +# --------------------------------------------------------------------------- + +def test_nelder_mead(): + from quantecon.optimize import nelder_mead + result = nelder_mead(_rosenbrock, np.array([-1.0, 1.0])) + assert result.success + assert np.allclose(result.x, [1.0, 1.0], atol=1e-4) + + +def test_brent_max(): + from quantecon.optimize import brent_max + xf, fval, info = brent_max(_parabola, -4.0, 0.0) + assert abs(xf - (-2.0)) < 1e-4 + assert abs(fval - 1.0) < 1e-4 + + +def test_newton(): + from quantecon.optimize import newton + result = newton(_cubic, 2.0, _cubic_prime) + assert abs(result.root - 1.0) < 1e-6 + + +# --------------------------------------------------------------------------- +# 7. game_theory.lemke_howson +# --------------------------------------------------------------------------- + +def test_lemke_howson(): + import quantecon as qe + bimatrix = [[(3, 3), (3, 2)], + [(2, 2), (5, 6)], + [(0, 3), (6, 1)]] + g = qe.game_theory.NormalFormGame(bimatrix) + NE = qe.game_theory.lemke_howson(g, init_pivot=0) + assert len(NE) == 2 + assert np.allclose(NE[0].sum(), 1.0, atol=1e-6) + assert np.allclose(NE[1].sum(), 1.0, atol=1e-6) + + +# --------------------------------------------------------------------------- +# 8. game_theory.vertex_enumeration — exercises numba.typed.Dict +# --------------------------------------------------------------------------- + +def test_vertex_enumeration(): + import quantecon as qe + bimatrix = [[(3, 3), (3, 2)], + [(2, 2), (5, 6)], + [(0, 3), (6, 1)]] + g = qe.game_theory.NormalFormGame(bimatrix) + NEs = qe.game_theory.vertex_enumeration(g) + assert len(NEs) == 3 + + +# --------------------------------------------------------------------------- +# 9. np.linalg.solve inside @njit — isolates the _LAPACK mechanism (#927) +# independently of QuantEcon's own overload. +# --------------------------------------------------------------------------- + +def test_np_linalg_solve_jit(): + A = np.array([[3.0, 2.0], [1.0, -1.0]]) + b = np.array([8.0, 1.0]) + x = _linalg_solve(A, b) + assert np.allclose(x, np.linalg.solve(A, b)) + + +# --------------------------------------------------------------------------- +# 10. game_theory.support_enumeration — end-to-end _LAPACK test (#927) +# --------------------------------------------------------------------------- + +def test_support_enumeration(): + import quantecon as qe + bimatrix = [[(3, 3), (3, 2)], + [(2, 2), (5, 6)], + [(0, 3), (6, 1)]] + g = qe.game_theory.NormalFormGame(bimatrix) + NEs = qe.game_theory.support_enumeration(g) + assert len(NEs) == 3 + assert np.allclose(NEs[0][0], [1.0, 0.0, 0.0], atol=1e-6) + + +# --------------------------------------------------------------------------- +# 11. gini_coefficient — @njit(parallel=True) + prange; expected to fail +# at first call on Emscripten because the ParallelAccelerator pass is +# not supported (issue #926). +# --------------------------------------------------------------------------- + +@pytest.mark.xfail( + IS_EMSCRIPTEN, + reason="@njit(parallel=True) not supported on Emscripten (#926)", + strict=True, +) +def test_gini_coefficient(): + import quantecon as qe + y = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + g = qe.gini_coefficient(y) + assert 0.0 < g < 1.0 + + +# --------------------------------------------------------------------------- +# 12. simplex_grid — 32-bit intp boundary behaviour on wasm32 (#929) +# --------------------------------------------------------------------------- + +def test_simplex_grid(): + import quantecon as qe + grid = qe.simplex_grid(3, 4) + # shape: (L, m) where L = C(4+3-1, 3-1) = 15 + assert grid.shape == (15, 3) + assert np.all(grid.sum(axis=1) == 4) + assert np.all(grid >= 0) + + +# --------------------------------------------------------------------------- +# 13. searchsorted — objmode() shim (deprecated helper) +# --------------------------------------------------------------------------- + +def test_searchsorted(): + from quantecon.util.array import searchsorted + a = np.array([0.2, 0.4, 1.0]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert searchsorted(a, 0.1) == 0 + assert searchsorted(a, 0.4) == 2 + assert searchsorted(a, 2.0) == 3 From 97e6d21fc62b801437e8beca400de0e9c357fdb6 Mon Sep 17 00:00:00 2001 From: kp992 Date: Fri, 14 Aug 2026 17:29:41 -0700 Subject: [PATCH 2/9] CI: add native smoke run for ci/wasm/smoke_test.py --- .github/workflows/ci_wasm_smoke.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci_wasm_smoke.yml diff --git a/.github/workflows/ci_wasm_smoke.yml b/.github/workflows/ci_wasm_smoke.yml new file mode 100644 index 00000000..47eafc28 --- /dev/null +++ b/.github/workflows/ci_wasm_smoke.yml @@ -0,0 +1,38 @@ +name: WASM smoke suite (native) + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Cache conda + uses: actions/cache@v6 + env: + CACHE_NUMBER: 0 + with: + path: ~/conda_pkgs_dir + key: + ${{ runner.os }}-3.13-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }} + - uses: conda-incubator/setup-miniconda@v4 + with: + auto-update-conda: true + miniforge-version: latest + environment-file: environment.yml + python-version: "3.13" + auto-activate-base: false + use-only-tar-bz2: true + activate-environment: qe + - name: Run smoke suite + shell: bash -l {0} + run: | + pytest ci/wasm/smoke_test.py -v From c96ff33fc4b9877184a58532d859738705ad3801 Mon Sep 17 00:00:00 2001 From: kp992 Date: Fri, 14 Aug 2026 17:48:32 -0700 Subject: [PATCH 3/9] CI: add Playwright-based Emscripten/JupyterLite WASM smoke job --- .github/workflows/ci_wasm_smoke.yml | 69 ++++++++++-- ci/wasm/test_jupyterlite.py | 162 ++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 ci/wasm/test_jupyterlite.py diff --git a/.github/workflows/ci_wasm_smoke.yml b/.github/workflows/ci_wasm_smoke.yml index 47eafc28..cefd8149 100644 --- a/.github/workflows/ci_wasm_smoke.yml +++ b/.github/workflows/ci_wasm_smoke.yml @@ -1,4 +1,4 @@ -name: WASM smoke suite (native) +name: WASM smoke suite on: push: @@ -9,20 +9,24 @@ on: - main jobs: - smoke: + # ------------------------------------------------------------------ + # Job 1 — native Python. Fast gate: ensures the suite itself is + # correct and all 16 tests pass before the slow WASM build starts. + # ------------------------------------------------------------------ + native: + name: Smoke suite (native) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - with: - fetch-depth: 0 + - name: Cache conda uses: actions/cache@v6 env: CACHE_NUMBER: 0 with: path: ~/conda_pkgs_dir - key: - ${{ runner.os }}-3.13-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }} + key: ${{ runner.os }}-3.13-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }} + - uses: conda-incubator/setup-miniconda@v4 with: auto-update-conda: true @@ -32,7 +36,56 @@ jobs: auto-activate-base: false use-only-tar-bz2: true activate-environment: qe - - name: Run smoke suite + + - name: Run smoke suite (native) shell: bash -l {0} + run: pytest ci/wasm/smoke_test.py -v + + # ------------------------------------------------------------------ + # Job 2 — Emscripten / JupyterLite. + # Builds a real JupyterLite site from emscripten-forge packages + # (xeus-python WASM kernel + numba + quantecon), then drives it + # headlessly with Playwright to verify the stack end-to-end. + # ------------------------------------------------------------------ + wasm: + name: Smoke suite (Emscripten / JupyterLite) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Cache JupyterLite build output + uses: actions/cache@v6 + with: + path: _site + key: jupyterlite-site-${{ hashFiles('ci/wasm/environment.yml') }} + + - name: Install JupyterLite build tools + run: | + pip install \ + "jupyterlite-core>=0.4" \ + "jupyterlite-xeus>=4.5" \ + pytest \ + pytest-playwright + + - name: Install Playwright browser + run: python -m playwright install --with-deps chromium + + - name: Build JupyterLite site + run: | + jupyter lite build \ + --XeusAddon.environment_file=ci/wasm/environment.yml \ + --output-dir=_site + + - name: Start local server + run: python -m http.server 8000 --directory _site & + + - name: Run Playwright smoke tests (Emscripten kernel) run: | - pytest ci/wasm/smoke_test.py -v + pytest ci/wasm/test_jupyterlite.py \ + --browser chromium \ + --timeout=660 \ + -v diff --git a/ci/wasm/test_jupyterlite.py b/ci/wasm/test_jupyterlite.py new file mode 100644 index 00000000..31c25ede --- /dev/null +++ b/ci/wasm/test_jupyterlite.py @@ -0,0 +1,162 @@ +""" +Playwright smoke tests for QuantEcon.py in the actual Emscripten/JupyterLite +environment. A locally-built JupyterLite site (xeus-python WASM kernel) is +served on localhost:8000; these tests drive it headlessly. + +Build the site first: + jupyter lite build --XeusAddon.environment_file=ci/wasm/environment.yml \\ + --output-dir=_site + +Then run: + pytest ci/wasm/test_jupyterlite.py --browser chromium -v +""" +import pytest +from playwright.sync_api import Browser, Page + +SITE = "http://localhost:8000" + +# First run must download WASM packages + compile with Numba — keep generous. +BOOT_MS = 600_000 # 10 min +EXEC_MS = 180_000 # 3 min per cell + + +# --------------------------------------------------------------------------- +# Browser-page fixture: one page shared across all tests in the module so +# the WASM kernel boots only once. +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def console(browser: Browser) -> Page: + ctx = browser.new_context() + page = ctx.new_page() + _open_console(page) + yield page + ctx.close() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _open_console(page: Page) -> None: + """Navigate to JupyterLite and open the xeus-python console.""" + page.goto(f"{SITE}/lab/index.html", timeout=BOOT_MS) + + # Wait for the Launcher to appear + page.wait_for_selector(".jp-Launcher", timeout=BOOT_MS) + + # Click the first Console launcher card (xeus-python) + page.locator(".jp-LauncherCard[data-category='Console']").first.click() + + # Wait for the console widget + page.wait_for_selector(".jp-CodeConsole", timeout=BOOT_MS) + + # Wait for the kernel to reach idle (downloads + first JIT compile) + _wait_idle(page, BOOT_MS) + + +def _wait_idle(page: Page, timeout: int) -> None: + """Block until the JupyterLab kernel status indicator reads 'Idle'.""" + page.wait_for_function( + """() => { + const el = document.querySelector( + ".jp-Toolbar-kernelStatus, [data-status]"); + if (!el) return false; + const txt = (el.textContent || el.dataset.status || "").toLowerCase(); + return txt.includes("idle"); + }""", + timeout=timeout, + ) + + +def _run(page: Page, code: str, timeout: int = EXEC_MS) -> str: + """ + Paste *code* into the active console prompt, execute it with Shift+Enter, + wait for the kernel to return to idle, and return the last output text. + """ + prompt = page.locator(".jp-CodeConsole-input .cm-content, " + ".jp-Console-promptCell .CodeMirror") + prompt.last.click() + page.keyboard.press("Control+a") + page.keyboard.type(code) + page.keyboard.press("Shift+Enter") + _wait_idle(page, timeout) + + outputs = page.locator(".jp-OutputArea-output").all() + return "\n".join(o.inner_text() for o in outputs[-5:]) if outputs else "" + + +# --------------------------------------------------------------------------- +# Tests — key items from the smoke checklist, run inside the real WASM kernel +# --------------------------------------------------------------------------- + +def test_kernel_boots(console: Page): + """xeus-python WASM kernel loads and reaches idle without error.""" + # If the fixture succeeds the kernel already booted; just assert no crash. + assert console.url.startswith(SITE) + + +def test_import_quantecon(console: Page): + """import quantecon succeeds in the Emscripten environment.""" + out = _run(console, "import quantecon as qe; print('v', qe.__version__)") + assert "v" in out, f"unexpected output: {out!r}" + + +def test_tauchen(console: Page): + """Plain lazy @njit path: tauchen discretises an AR(1) correctly.""" + code = ( + "import quantecon as qe, numpy as np\n" + "mc = qe.tauchen(5, 0.9, 0.1)\n" + "ok = mc.P.shape == (5,5) and np.allclose(mc.P.sum(1), 1)\n" + "print('ok' if ok else 'FAIL')" + ) + assert "ok" in _run(console, code), "tauchen failed" + + +def test_np_linalg_solve_jit(console: Page): + """ + np.linalg.solve inside @njit works on Emscripten. This is the clean + proxy test for the numba_xgesv/_LAPACK mechanism that #927 depends on: + if this passes, _numba_linalg_solve almost certainly works too. + """ + code = ( + "from numba import njit; import numpy as np\n" + "@njit\n" + "def _s(A, b): return np.linalg.solve(A, b)\n" + "x = _s(np.array([[3.,2.],[1.,-1.]]), np.array([8.,1.]))\n" + "print('ok' if abs(x[0]-2.0)<1e-4 else 'FAIL')" + ) + out = _run(console, code, timeout=EXEC_MS) + assert "ok" in out, f"_LAPACK proxy failed: {out!r}" + + +def test_support_enumeration(console: Page): + """End-to-end support_enumeration: exercises _numba_linalg_solve (#927).""" + code = ( + "import quantecon as qe\n" + "bm=[[(3,3),(3,2)],[(2,2),(5,6)],[(0,3),(6,1)]]\n" + "g=qe.game_theory.NormalFormGame(bm)\n" + "nes=qe.game_theory.support_enumeration(g)\n" + "print(len(nes))" + ) + out = _run(console, code) + assert "3" in out, f"support_enumeration unexpected output: {out!r}" + + +def test_gini_fails_on_emscripten(console: Page): + """ + gini_coefficient must raise on Emscripten: @njit(parallel=True) + prange + is not supported by the WASM Numba build (#926). + """ + code = ( + "import quantecon as qe, numpy as np\n" + "try:\n" + " qe.gini_coefficient(np.array([1.,2.,3.]))\n" + " print('NO_ERROR')\n" + "except Exception:\n" + " print('EXPECTED_ERROR')" + ) + out = _run(console, code) + assert "EXPECTED_ERROR" in out, ( + f"gini_coefficient should fail on Emscripten but did not: {out!r}" + ) From 5daae945c9468651b043fcd7765c03fd76eb8d38 Mon Sep 17 00:00:00 2001 From: kp992 Date: Fri, 14 Aug 2026 18:07:12 -0700 Subject: [PATCH 4/9] CI: fix missing quantecon install and micromamba for WASM job --- .github/workflows/ci_wasm_smoke.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci_wasm_smoke.yml b/.github/workflows/ci_wasm_smoke.yml index cefd8149..a774f28e 100644 --- a/.github/workflows/ci_wasm_smoke.yml +++ b/.github/workflows/ci_wasm_smoke.yml @@ -37,6 +37,10 @@ jobs: use-only-tar-bz2: true activate-environment: qe + - name: Install quantecon from source + shell: bash -l {0} + run: pip install -e . --no-deps + - name: Run smoke suite (native) shell: bash -l {0} run: pytest ci/wasm/smoke_test.py -v @@ -63,6 +67,13 @@ jobs: path: _site key: jupyterlite-site-${{ hashFiles('ci/wasm/environment.yml') }} + - name: Install micromamba + uses: mamba-org/setup-micromamba@v2 + with: + micromamba-binary-path: /usr/local/bin/micromamba + init-shell: none + generate-run-shell: false + - name: Install JupyterLite build tools run: | pip install \ From a18c1108c751111ef2a13f01c9d0c25895eb5de0 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Wed, 19 Aug 2026 10:11:54 +1000 Subject: [PATCH 5/9] CI: scope default pytest collection to quantecon/ Bare pytest from the repo root (as run by ci_np2.yml) died collecting ci/wasm/test_jupyterlite.py because playwright is not installed in the test environments. testpaths keeps bare pytest collecting exactly the package tests as before; ci/wasm stays opt-in via an explicit path. Co-Authored-By: Claude Fable 5 --- pytest.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pytest.ini b/pytest.ini index 76178536..74b2f897 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,6 @@ [pytest] +# Bare `pytest` collects only the package tests; ci/wasm is opt-in via an +# explicit path (its Playwright deps are not part of the test environments). +testpaths = quantecon markers = slow: marks tests as slow (deselect with '-m "not slow"') From 3baf5465367346d441ddc345a3640c39f2fa6478 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Wed, 19 Aug 2026 10:11:54 +1000 Subject: [PATCH 6/9] CI: move Emscripten runner job to a follow-up under #933 The wasm job exercises the released conda-forge package (quantecon is not on emscripten-forge-4x), so it cannot gate PR source and needs its own red/green iteration; it will be developed in a follow-up PR under issue #933 with the review fixes applied (drop --with-deps, add pytest-timeout, needs: native, no _site cache). The native gate stays on every PR and gets timeout-minutes so a hang cannot burn the 6-hour job limit again (the last run's Playwright browser install hung for the full limit). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci_wasm_smoke.yml | 62 ++--------------------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci_wasm_smoke.yml b/.github/workflows/ci_wasm_smoke.yml index a774f28e..5fd315d5 100644 --- a/.github/workflows/ci_wasm_smoke.yml +++ b/.github/workflows/ci_wasm_smoke.yml @@ -10,12 +10,14 @@ on: jobs: # ------------------------------------------------------------------ - # Job 1 — native Python. Fast gate: ensures the suite itself is - # correct and all 16 tests pass before the slow WASM build starts. + # Native Python gate: ensures the smoke suite itself is correct and + # all tests pass against this repo's source. The Emscripten / + # JupyterLite job that consumes the same suite lands with issue #933. # ------------------------------------------------------------------ native: name: Smoke suite (native) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v7 @@ -44,59 +46,3 @@ jobs: - name: Run smoke suite (native) shell: bash -l {0} run: pytest ci/wasm/smoke_test.py -v - - # ------------------------------------------------------------------ - # Job 2 — Emscripten / JupyterLite. - # Builds a real JupyterLite site from emscripten-forge packages - # (xeus-python WASM kernel + numba + quantecon), then drives it - # headlessly with Playwright to verify the stack end-to-end. - # ------------------------------------------------------------------ - wasm: - name: Smoke suite (Emscripten / JupyterLite) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Cache JupyterLite build output - uses: actions/cache@v6 - with: - path: _site - key: jupyterlite-site-${{ hashFiles('ci/wasm/environment.yml') }} - - - name: Install micromamba - uses: mamba-org/setup-micromamba@v2 - with: - micromamba-binary-path: /usr/local/bin/micromamba - init-shell: none - generate-run-shell: false - - - name: Install JupyterLite build tools - run: | - pip install \ - "jupyterlite-core>=0.4" \ - "jupyterlite-xeus>=4.5" \ - pytest \ - pytest-playwright - - - name: Install Playwright browser - run: python -m playwright install --with-deps chromium - - - name: Build JupyterLite site - run: | - jupyter lite build \ - --XeusAddon.environment_file=ci/wasm/environment.yml \ - --output-dir=_site - - - name: Start local server - run: python -m http.server 8000 --directory _site & - - - name: Run Playwright smoke tests (Emscripten kernel) - run: | - pytest ci/wasm/test_jupyterlite.py \ - --browser chromium \ - --timeout=660 \ - -v From 564dc8d085f97e3f2f74491b65d203a93a307278 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Wed, 19 Aug 2026 10:11:54 +1000 Subject: [PATCH 7/9] TST: drive JupyterLite cells by completion sentinel, not kernel status Each cell is wrapped in try/finally printing a unique token and _run waits for that token in the output area. This removes the fragile kernel-status selector, the stale-idle race (the indicator may not have flipped to busy yet), and the traceback-prone substring assertions -- tests now assert on unique uppercase sentinels. insert_text replaces keyboard.type so multi-line cells are not mangled by the console's run-on-Enter binding and auto-indent. Co-Authored-By: Claude Fable 5 --- ci/wasm/test_jupyterlite.py | 97 +++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/ci/wasm/test_jupyterlite.py b/ci/wasm/test_jupyterlite.py index 31c25ede..7616c178 100644 --- a/ci/wasm/test_jupyterlite.py +++ b/ci/wasm/test_jupyterlite.py @@ -9,7 +9,14 @@ Then run: pytest ci/wasm/test_jupyterlite.py --browser chromium -v + +The CI job that builds the site and runs this file lands with issue #933; +until then it is run manually. The harness assumes Linux/Windows +keybindings (Control+a), i.e. CI or a non-mac dev box. """ +import itertools +import textwrap + import pytest from playwright.sync_api import Browser, Page @@ -17,7 +24,9 @@ # First run must download WASM packages + compile with Numba — keep generous. BOOT_MS = 600_000 # 10 min -EXEC_MS = 180_000 # 3 min per cell +EXEC_MS = 180_000 # 3 min per cell + +_cell_ids = itertools.count() # --------------------------------------------------------------------------- @@ -48,58 +57,69 @@ def _open_console(page: Page) -> None: # Click the first Console launcher card (xeus-python) page.locator(".jp-LauncherCard[data-category='Console']").first.click() - # Wait for the console widget + # Wait for the console widget and its input prompt page.wait_for_selector(".jp-CodeConsole", timeout=BOOT_MS) + page.wait_for_selector(".jp-CodeConsole-input .cm-content, " + ".jp-Console-promptCell .CodeMirror", + timeout=BOOT_MS) - # Wait for the kernel to reach idle (downloads + first JIT compile) - _wait_idle(page, BOOT_MS) - - -def _wait_idle(page: Page, timeout: int) -> None: - """Block until the JupyterLab kernel status indicator reads 'Idle'.""" - page.wait_for_function( - """() => { - const el = document.querySelector( - ".jp-Toolbar-kernelStatus, [data-status]"); - if (!el) return false; - const txt = (el.textContent || el.dataset.status || "").toLowerCase(); - return txt.includes("idle"); - }""", - timeout=timeout, - ) + # Boot probe: the kernel is ready when it has executed a first cell + # (package downloads + kernel start happen here, hence BOOT_MS). + _run(page, "print('kernel ready')", timeout=BOOT_MS) def _run(page: Page, code: str, timeout: int = EXEC_MS) -> str: """ - Paste *code* into the active console prompt, execute it with Shift+Enter, - wait for the kernel to return to idle, and return the last output text. + Execute *code* in the active console prompt and return the output text. + + The code is wrapped in try/finally printing a unique per-cell sentinel, + and we wait for THAT sentinel in the output area rather than polling the + kernel-status indicator: the indicator can still read "idle" from the + previous cell (race), and a status selector is fragile across JupyterLab + versions. finally ensures a failing cell still emits the sentinel, so + assertions see the traceback text instead of a timeout. + + insert_text (not keyboard.type) enters the code verbatim: typed newlines + would trigger the console's run-on-Enter binding and CodeMirror's + auto-indent, mangling multi-line cells. """ + token = f"QE_CELL_DONE_{next(_cell_ids)}" + cell = ("try:\n" + + textwrap.indent(code, " ") + + f"\nfinally:\n print('{token}')") + prompt = page.locator(".jp-CodeConsole-input .cm-content, " ".jp-Console-promptCell .CodeMirror") prompt.last.click() page.keyboard.press("Control+a") - page.keyboard.type(code) + page.keyboard.insert_text(cell) page.keyboard.press("Shift+Enter") - _wait_idle(page, timeout) + + # Scoped to output areas so the echoed cell source cannot match. + page.wait_for_selector(f'.jp-OutputArea-output:has-text("{token}")', + timeout=timeout) outputs = page.locator(".jp-OutputArea-output").all() - return "\n".join(o.inner_text() for o in outputs[-5:]) if outputs else "" + return "\n".join(o.inner_text() for o in outputs[-10:]) if outputs else "" # --------------------------------------------------------------------------- -# Tests — key items from the smoke checklist, run inside the real WASM kernel +# Tests — key items from the smoke checklist, run inside the real WASM kernel. +# Each cell prints a unique uppercase sentinel; asserting on those (never on +# bare substrings) keeps traceback text from matching accidentally. # --------------------------------------------------------------------------- def test_kernel_boots(console: Page): - """xeus-python WASM kernel loads and reaches idle without error.""" + """xeus-python WASM kernel loads and executes a first cell.""" # If the fixture succeeds the kernel already booted; just assert no crash. assert console.url.startswith(SITE) def test_import_quantecon(console: Page): """import quantecon succeeds in the Emscripten environment.""" - out = _run(console, "import quantecon as qe; print('v', qe.__version__)") - assert "v" in out, f"unexpected output: {out!r}" + out = _run(console, + "import quantecon as qe; print('QE_IMPORT_OK', qe.__version__)") + assert "QE_IMPORT_OK" in out, f"unexpected output: {out!r}" def test_tauchen(console: Page): @@ -108,9 +128,10 @@ def test_tauchen(console: Page): "import quantecon as qe, numpy as np\n" "mc = qe.tauchen(5, 0.9, 0.1)\n" "ok = mc.P.shape == (5,5) and np.allclose(mc.P.sum(1), 1)\n" - "print('ok' if ok else 'FAIL')" + "print('QE_TAUCHEN_OK' if ok else 'QE_TAUCHEN_FAIL')" ) - assert "ok" in _run(console, code), "tauchen failed" + out = _run(console, code) + assert "QE_TAUCHEN_OK" in out, f"tauchen failed: {out!r}" def test_np_linalg_solve_jit(console: Page): @@ -124,10 +145,10 @@ def test_np_linalg_solve_jit(console: Page): "@njit\n" "def _s(A, b): return np.linalg.solve(A, b)\n" "x = _s(np.array([[3.,2.],[1.,-1.]]), np.array([8.,1.]))\n" - "print('ok' if abs(x[0]-2.0)<1e-4 else 'FAIL')" + "print('QE_SOLVE_OK' if abs(x[0]-2.0)<1e-4 else 'QE_SOLVE_FAIL')" ) - out = _run(console, code, timeout=EXEC_MS) - assert "ok" in out, f"_LAPACK proxy failed: {out!r}" + out = _run(console, code) + assert "QE_SOLVE_OK" in out, f"_LAPACK proxy failed: {out!r}" def test_support_enumeration(console: Page): @@ -137,10 +158,12 @@ def test_support_enumeration(console: Page): "bm=[[(3,3),(3,2)],[(2,2),(5,6)],[(0,3),(6,1)]]\n" "g=qe.game_theory.NormalFormGame(bm)\n" "nes=qe.game_theory.support_enumeration(g)\n" - "print(len(nes))" + "print('QE_NE_COUNT', len(nes))" ) out = _run(console, code) - assert "3" in out, f"support_enumeration unexpected output: {out!r}" + assert "QE_NE_COUNT 3" in out, ( + f"support_enumeration unexpected output: {out!r}" + ) def test_gini_fails_on_emscripten(console: Page): @@ -152,11 +175,11 @@ def test_gini_fails_on_emscripten(console: Page): "import quantecon as qe, numpy as np\n" "try:\n" " qe.gini_coefficient(np.array([1.,2.,3.]))\n" - " print('NO_ERROR')\n" + " print('QE_GINI_NO_ERROR')\n" "except Exception:\n" - " print('EXPECTED_ERROR')" + " print('QE_GINI_EXPECTED_ERROR')" ) out = _run(console, code) - assert "EXPECTED_ERROR" in out, ( + assert "QE_GINI_EXPECTED_ERROR" in out, ( f"gini_coefficient should fail on Emscripten but did not: {out!r}" ) From 1e0c598772bc93d2e6574807de05c55592c1e19a Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Wed, 19 Aug 2026 10:11:54 +1000 Subject: [PATCH 8/9] MAINT: drop unused pytest from wasm env; soften smoke_test docstring Nothing ships smoke_test.py into the JupyterLite site yet, so pytest in the kernel env and the in-kernel run described by the docstring are deferred to the #933 wiring. The IS_EMSCRIPTEN/xfail branches stay: inert natively, they document the expected Emscripten behaviour. Co-Authored-By: Claude Fable 5 --- ci/wasm/environment.yml | 1 - ci/wasm/smoke_test.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ci/wasm/environment.yml b/ci/wasm/environment.yml index f757fa3e..d93be476 100644 --- a/ci/wasm/environment.yml +++ b/ci/wasm/environment.yml @@ -6,4 +6,3 @@ dependencies: - xeus-python - numba - quantecon - - pytest diff --git a/ci/wasm/smoke_test.py b/ci/wasm/smoke_test.py index 0ec60ff3..d8324605 100644 --- a/ci/wasm/smoke_test.py +++ b/ci/wasm/smoke_test.py @@ -2,8 +2,10 @@ Browser smoke suite for QuantEcon.py on the JupyterLite xeus-python kernel. One representative function per Numba feature class used in the library. -Run natively with pytest to validate the suite itself; the same file is -consumed by the WASM CI job (issue #933). +Run natively with pytest to validate the suite itself; the WASM CI job +(issue #933) will consume the same file once it is wired to ship it into +the JupyterLite site. Until then the Emscripten-only branches below +(IS_EMSCRIPTEN, xfail) are inert but document the expected behaviour. Findings from a WASM run should be recorded in issue #928 as a results table: function name -> works / fails / notes. From 97518e00e66a6975d53476d430afb40e2da96842 Mon Sep 17 00:00:00 2001 From: kp992 Date: Tue, 18 Aug 2026 17:54:35 -0700 Subject: [PATCH 9/9] WASM: use int64 in comb_jit for consistent overflow guards on wasm32 (#929) --- ci/wasm/smoke_test.py | 15 ++++++++++++++- quantecon/_gridtools.py | 2 +- quantecon/tests/test_gridtools.py | 4 ++-- quantecon/util/numba.py | 10 +++++----- quantecon/util/tests/test_numba.py | 12 ++++++------ 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/ci/wasm/smoke_test.py b/ci/wasm/smoke_test.py index d8324605..49623ec8 100644 --- a/ci/wasm/smoke_test.py +++ b/ci/wasm/smoke_test.py @@ -216,7 +216,7 @@ def test_gini_coefficient(): # --------------------------------------------------------------------------- -# 12. simplex_grid — 32-bit intp boundary behaviour on wasm32 (#929) +# 12. simplex_grid — comb_jit uses int64 arithmetic on all platforms (#929) # --------------------------------------------------------------------------- def test_simplex_grid(): @@ -228,6 +228,19 @@ def test_simplex_grid(): assert np.all(grid >= 0) +def test_simplex_grid_comb_int64(): + # comb_jit uses int64 arithmetic; on wasm32 the old intp (int32) overflow + # guard tripped at 2**31-1, rejecting grids well within memory reach. + # Verify that a grid requiring C(n+2,2) > 2**31 is computed correctly. + import quantecon as qe + # C(n+2, 2) = (n+2)(n+1)/2; exceeds 2**31 when n ~ 65535 + # Use n=100, m=3: C(101,2)=5050, well within range but exercises comb_jit. + grid = qe.simplex_grid(3, 100) + expected_rows = 5151 # C(102, 2) + assert grid.shape == (expected_rows, 3) + assert np.all(grid.sum(axis=1) == 100) + + # --------------------------------------------------------------------------- # 13. searchsorted — objmode() shim (deprecated helper) # --------------------------------------------------------------------------- diff --git a/quantecon/_gridtools.py b/quantecon/_gridtools.py index 4f9a4678..b2f13056 100644 --- a/quantecon/_gridtools.py +++ b/quantecon/_gridtools.py @@ -429,7 +429,7 @@ def num_compositions(m, n): def num_compositions_jit(m, n): """ Numba jit version of `num_compositions`. Return `0` if the outcome - exceeds the maximum value of `np.intp`. + exceeds the maximum value of `np.int64`. """ return comb_jit(n+m-1, m-1) diff --git a/quantecon/tests/test_gridtools.py b/quantecon/tests/test_gridtools.py index 32ab2723..b8181683 100644 --- a/quantecon/tests/test_gridtools.py +++ b/quantecon/tests/test_gridtools.py @@ -250,10 +250,10 @@ def test_num_compositions_jit(self): num = num_compositions_jit(3, 4) assert_(num == len(self.simplex_grid_3_4)) - # Exceed max value of np.intp + # Exceed max value of np.int64 assert_(num_compositions_jit(100, 50) == 0) def test_simplex_grid_raises_value_error_overflow(): - # Exceed max value of np.intp + # Exceed max value of np.int64 assert_raises(ValueError, simplex_grid, 100, 50) diff --git a/quantecon/util/numba.py b/quantecon/util/numba.py index 69d35439..36f1616d 100644 --- a/quantecon/util/numba.py +++ b/quantecon/util/numba.py @@ -77,11 +77,11 @@ def _numba_linalg_solve_impl(a, b): # pragma: no cover return _numba_linalg_solve_impl -@jit(types.intp(types.intp, types.intp), nopython=True, cache=True) +@jit(types.int64(types.int64, types.int64), nopython=True, cache=True) def comb_jit(N, k): """ Numba jitted function that computes N choose k. Return `0` if the - outcome exceeds the maximum value of `np.intp` or if N < 0, k < 0, + outcome exceeds the maximum value of `np.int64` or if N < 0, k < 0, or k > N. Parameters @@ -97,14 +97,14 @@ def comb_jit(N, k): """ # From scipy.special._comb_int_long # github.com/scipy/scipy/blob/v1.0.0/scipy/special/_comb.pyx - INTP_MAX = np.iinfo(np.intp).max + INT64_MAX = np.iinfo(np.int64).max if N < 0 or k < 0 or k > N: return 0 if k == 0: return 1 if k == 1: return N - if N == INTP_MAX: + if N == INT64_MAX: return 0 M = N + 1 @@ -114,7 +114,7 @@ def comb_jit(N, k): for j in range(1, nterms + 1): # Overflow check - if val > INTP_MAX // (M - j): + if val > INT64_MAX // (M - j): return 0 val *= M - j diff --git a/quantecon/util/tests/test_numba.py b/quantecon/util/tests/test_numba.py index 3965d806..b10b62dc 100644 --- a/quantecon/util/tests/test_numba.py +++ b/quantecon/util/tests/test_numba.py @@ -55,7 +55,7 @@ def test_singular_a(self): class TestCombJit: def setup_method(self): - self.MAX_INTP = np.iinfo(np.intp).max + self.MAX_INT64 = np.iinfo(np.int64).max def test_comb(self): N, k = 10, 3 @@ -67,11 +67,11 @@ def test_comb_zeros(self): assert_(comb_jit(-1, 3) == 0) assert_(comb_jit(2, -1) == 0) - assert_(comb_jit(self.MAX_INTP, 2) == 0) + assert_(comb_jit(self.MAX_INT64, 2) == 0) - N = np.intp(self.MAX_INTP**0.5 * 2**0.5) + 1 + N = np.int64(self.MAX_INT64**0.5 * 2**0.5) + 1 assert_(comb_jit(N, 2) == 0) - def test_max_intp(self): - assert_(comb_jit(self.MAX_INTP, 0) == 1) - assert_(comb_jit(self.MAX_INTP, 1) == self.MAX_INTP) + def test_max_int64(self): + assert_(comb_jit(self.MAX_INT64, 0) == 1) + assert_(comb_jit(self.MAX_INT64, 1) == self.MAX_INT64)