diff --git a/.github/workflows/ci_wasm_smoke.yml b/.github/workflows/ci_wasm_smoke.yml new file mode 100644 index 00000000..5fd315d5 --- /dev/null +++ b/.github/workflows/ci_wasm_smoke.yml @@ -0,0 +1,48 @@ +name: WASM smoke suite + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + # ------------------------------------------------------------------ + # 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 + + - 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: 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 diff --git a/ci/wasm/environment.yml b/ci/wasm/environment.yml new file mode 100644 index 00000000..d93be476 --- /dev/null +++ b/ci/wasm/environment.yml @@ -0,0 +1,8 @@ +name: qe-lite +channels: + - https://prefix.dev/emscripten-forge-4x + - https://prefix.dev/conda-forge +dependencies: + - xeus-python + - numba + - quantecon diff --git a/ci/wasm/smoke_test.py b/ci/wasm/smoke_test.py new file mode 100644 index 00000000..d8324605 --- /dev/null +++ b/ci/wasm/smoke_test.py @@ -0,0 +1,242 @@ +""" +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 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. +""" +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 diff --git a/ci/wasm/test_jupyterlite.py b/ci/wasm/test_jupyterlite.py new file mode 100644 index 00000000..7616c178 --- /dev/null +++ b/ci/wasm/test_jupyterlite.py @@ -0,0 +1,185 @@ +""" +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 + +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 + +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 + +_cell_ids = itertools.count() + + +# --------------------------------------------------------------------------- +# 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 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) + + # 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: + """ + 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.insert_text(cell) + page.keyboard.press("Shift+Enter") + + # 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[-10:]) if outputs else "" + + +# --------------------------------------------------------------------------- +# 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 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('QE_IMPORT_OK', qe.__version__)") + assert "QE_IMPORT_OK" 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('QE_TAUCHEN_OK' if ok else 'QE_TAUCHEN_FAIL')" + ) + out = _run(console, code) + assert "QE_TAUCHEN_OK" in out, f"tauchen failed: {out!r}" + + +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('QE_SOLVE_OK' if abs(x[0]-2.0)<1e-4 else 'QE_SOLVE_FAIL')" + ) + out = _run(console, code) + assert "QE_SOLVE_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('QE_NE_COUNT', len(nes))" + ) + out = _run(console, code) + assert "QE_NE_COUNT 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('QE_GINI_NO_ERROR')\n" + "except Exception:\n" + " print('QE_GINI_EXPECTED_ERROR')" + ) + out = _run(console, code) + assert "QE_GINI_EXPECTED_ERROR" in out, ( + f"gini_coefficient should fail on Emscripten but did not: {out!r}" + ) 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"')