Skip to content

🇬🇧 English · 🇪🇸 Español

Running anywhere: iPad, Juno, and the browser

puremacro's numerical core is pure numpy + scipy + pandas + matplotlib, which is what makes it importable on an iPad. Importable is not the same as usable. Four things a workstation provides silently are missing on a tablet, and this page is about closing that gap rather than restating the promise.

Start here: what can this machine do?

from puremacro import runtime
print(runtime.report())
puremacro runtime
  host       : pyodide 3.12.7 (wasm32)
  device     : tablet
  network    : js-fetch (call runtime.enable_browser_network())
  parquet    : unavailable -> use puremacro.runtime.store / pocket
  threads    : no (1 cpu, unknown)
  writable fs: yes
  backends   : numpy

Detection is heuristic by necessity — no Python API answers "am I inside Juno?" — so it reads sys.platform, the iOS sandbox path (/var/mobile/), and the browser user agent, and every field can be pinned when it guesses wrong:

variable effect
PUREMACRO_HOST cpython / pyodide / unknown
PUREMACRO_DEVICE workstation / tablet / browser / unknown
PUREMACRO_SOCKETS force the socket verdict
PUREMACRO_PARQUET force the parquet verdict
PUREMACRO_THREADS force the threads verdict

runtime.capabilities() returns the same information as a frozen dataclass, and records which fields came from an override in .overridden.

1. There are no sockets

Under Pyodide there is no TCP stack, so requests and urllib both fail and every puremacro.fetch.* call dies — even though the estimator core imported perfectly. The browser can still make requests; it just does them in JavaScript. One call reroutes the whole existing fetch layer over that:

# requires: a Pyodide / browser kernel with network access
from puremacro import runtime
from puremacro.fetch import fetch_xrate_monthly

runtime.enable_browser_network()
fx = fetch_xrate_monthly(["MEX"])

Nothing in fetch or narrative.sources is modified. The switch replaces the two chokepoints those modules already funnel through: the urllib call in puremacro._http and the requests module object in puremacro.fetch._http. runtime.disable_browser_network() puts both back.

Two browser limits are worth knowing before you depend on this:

  • CORS. The browser refuses cross-origin responses without Access-Control-Allow-Origin. Some public statistical endpoints send it; many WAF-fronted government sites do not. A blocked request raises TransportError naming CORS as the likely cause. Pass proxy= to route through a CORS proxy you control.
  • No timeouts, no custom User-Agent. A synchronous XMLHttpRequest on the main thread cannot set either, so both are accepted and ignored rather than raising — which also means the WAF-bypass user-agent trick in narrative/sources/RETRY_POLICY.md §7 does not work in a browser.

2. There may be no pyarrow

pyarrow is an optional extra (puremacro[io]), and only recent Pyodide distributions (314.x) include it, so on older kernels such as Pyodide 0.28 every parquet path (cache, fetch.labor*, shock_atlas, build_panel) is unreachable. numpy's own .npz container has no such problem — it is zlib plus a header, implemented in numpy itself.

runtime.store is a DataFrame ⇄ npz codec built on that: one array per column plus a JSON schema recording dtypes, index structure and column labels, so a frame survives the round trip with its index intact. It handles PeriodIndex, tz-aware datetimes, Categorical, pandas nullable extension dtypes and MultiIndex, and refuses to pickle arbitrary objects rather than writing an archive that will not load elsewhere. On a 5,000×8 quarterly panel it is also smaller than parquet (310 KB vs 409 KB).

Above it sits puremacro.pocket: pack data where the network and pyarrow are, open it where they are not.

import numpy as np
import pandas as pd
from puremacro import pocket

# A frame to ship (in practice: the panel you just fetched on the workstation)
panel = pd.DataFrame(
    {"USA": [1.0, 1.2, 1.1], "DEU": [0.8, 0.9, 1.0]},
    index=pd.period_range("2025Q1", periods=3, freq="Q"),
)

# workstation, online
pocket.pack(panel, "g7.pmz", source="OECD QNA", vintage="2026-08-19")

# iPad, airplane mode
cart = pocket.load("g7.pmz")
panel = cart.frame()          # sha256-checked on read
cart.provenance.vintage       # '2026-08-19'
print(cart.summary())

A .pmz is a plain zip: a JSON manifest plus one npz per frame, both stdlib or numpy, so it opens without puremacro installed at all. pocket.snapshot(fn, *args, path=...) runs a call and records the reproducing expression in the manifest. And because getting a file onto an iPad is often more friction than the analysis, a cartridge also travels as text:

blob = pocket.to_base64("g7.pmz")     # paste into a message, a note, an email
pocket.from_base64(blob, "g7.pmz")    # on the other machine

Cartridges are a transport format, not a trust boundary: the checksums detect corruption in transit, and nothing more.

3. The app gets suspended

iPadOS stops a backgrounded app. A four-minute bootstrap does not survive someone answering a message, and neither does a Metropolis-Hastings chain or a Krusell-Smith solve. The tablet is not so much slow as interruptible, and an ordinary estimator call is one opaque block that either finishes or is lost.

import numpy as np
from puremacro import longrun

rng = np.random.default_rng(0)
Y = rng.standard_normal((120, 2))          # your VAR data

def one_draw(i):
    """One bootstrap replication: returns whatever you want the bands of."""
    idx = np.random.default_rng(i).integers(0, len(Y), len(Y))
    return Y[idx].mean(axis=0)

job = longrun.bootstrap(one_draw, 2000, checkpoint="irf.ckpt")
job.run(seconds=30)     # 240/2000 · 12% · ~220s of compute left
job.run(seconds=30)     # ... and again, in a later session
bands = np.percentile(job.result(), [5, 95], axis=0)

Draw i always uses default_rng([seed, i]), so results are invariant to chunk size and to how many sessions the job took — a run resumed across five sittings is bit-identical to one that went straight through. That is what makes a resumed run publishable rather than merely finished. job.result() raises unless the job is complete, so a half-run bootstrap cannot be mistaken for a full one, and a checkpoint carries a fingerprint of the job that wrote it and refuses to be resumed by a different one.

Checkpoints are plain npz loaded with allow_pickle=False, so a job started on the iPad can be finished on the workstation.

4. Compute is smaller

from puremacro import runtime
from puremacro.var.identify import cholesky_svar

runtime.fit(n_boot=2000)              # -> {'n_boot': 400} on a tablet
svar = runtime.budgeted(cholesky_svar)  # clamps cost arguments of a call

Both are opt-in. No estimator consults the budget, so every default is exactly what it always was and a script that runs on your laptop produces the same numbers after this feature landed. Only parameters that change cost are clamped (n_boot, n_draws, n_grid, n_sim); horizon changes what is being estimated, so it is deliberately left alone.

n_jobs falls back to a plain loop

Under Pyodide there are no working OS threads, and ThreadPoolExecutor there either refuses to start or silently runs nothing. Five bootstrap engines take n_jobs — wild_bootstrap, wild_bootstrap_var, block_bootstrap, cum_irf_block_bootstrap and bootstrap_bands — and all five route through one place that decides between the pool and the loop:

  • Each engine draws all of its random material up front with your generator and then maps a pure per-replication function over it. Executor.map preserves input order, so the pool and the loop return identical output; only the wall clock differs.
  • Before an executor is constructed, the engine asks runtime.capabilities().threads. When the answer is no — a Pyodide/WASM kernel, or PUREMACRO_THREADS=0 — it runs the loop and builds no executor at all. Your n_jobs is then simply ignored; nothing raises.
  • If the pool is attempted and cannot start (RuntimeError, OSError or ValueError out of ThreadPoolExecutor), a RuntimeWarning names the engine and says the replications are being evaluated serially instead.
  • n_jobs=0 raises ValueError before anything runs; a negative n_jobs means every CPU core; n_jobs=1 is the loop.
  • An exception raised inside your per-replication function is never swallowed. It propagates unchanged from either path, so a failing refit is reported rather than quietly retried.

PUREMACRO_THREADS is a boolean override, not a thread count. Like the other capability overrides it is true only for 1, true, yes or on; anything else — including PUREMACRO_THREADS=8 — reads as no threads and turns every n_jobs into a serial loop. To pick a worker count, pass n_jobs.

from puremacro import runtime
runtime.capabilities().threads      # the verdict all five engines gate on
runtime.capabilities().overridden   # ('threads',) when the env var set it

5. Offloading Heavy Compute to Google Colab

When a simulation, bootstrap, or MCMC chain exceeds the memory or thermal envelope of an iPad, puremacro.runtime provides a bridge to export self-contained jobs to Google Colab:

import numpy as np
import pandas as pd
from puremacro.runtime import generate_colab_notebook, colab_auth_guide

# Print step-by-step authentication instructions for mobile Safari / iPad
print(colab_auth_guide())

# The data the cloud run needs, embedded losslessly (index and dtypes preserved)
panel_data = pd.DataFrame(np.random.default_rng(0).standard_normal((200, 6)),
                          columns=[f"x{i}" for i in range(5)] + ["policy_rate"])

# Heavy computation script
heavy_code = """
import numpy as np
from puremacro.var import favar

# Runs 2,000 bootstrap draws on cloud CPUs
result = favar(my_df, my_df["policy_rate"], n_factors=4, p=2, horizon=24, n_boot=2000)
print(result.summary())
"""

# Export a 1-click self-contained Colab notebook with embedded data
generate_colab_notebook(
    heavy_code,
    data_payloads={"my_df": panel_data},
    title="FAVAR Heavy Bootstrap Run",
    save_path="favar_colab_run.ipynb",
    mount_drive=True,
)

Why this works seamlessly on iPad:

  • Zero File Uploads: data_payloads embeds your DataFrames directly as base64 strings into the notebook cells, so Colab runs without requiring manual CSV uploads.
  • Guided Authentication: Automatically generates cells prompting for Google account login (auth.authenticate_user()) and Drive mounting (drive.mount('/content/drive')).
  • Cloud Acceleration: Colab provides free cloud GPUs, multi-core CPUs, and expansive RAM, returning .pmz cartridges or LaTeX tables back to your Drive.

Local models on the iPad

The Juno app's own Python (not the Pyodide kernel of juno.sh) ships MLX and mlx-lm. MLX is selected on the iPad under Juno, where mlx and mlx-lm import; a completion there is not yet verified on a device. Engines are chosen by what can be imported, never by the operating system, which is why MLX is the one picked there, and the narrative LLM features (score_llm, llm_prob_kernel) go through the same puremacro.llm entry point. Three things to know:

  • Keep Juno in front while the model downloads and while a corpus is scored; a suspended app stops both. The completion cache keeps every answer already computed.
  • Models persist in the app's container. The first completion downloads the reference model, qwen3-4b (about 2.3 GB); later sessions find it there.
  • Compute is smaller: 117 GFLOPS for a float32 matrix product on an iPad Pro M4, against 430 on an M3 Pro Mac. A cache built on a Mac and shipped to the iPad answers in cache mode with no model at all, and that is also what the browser can do.

A completion through Juno's mlx-lm build has not yet been verified on a device. Installation, the model ladder, the modes, shipping a cache and troubleshooting are in Local language models.

The micropip steps under Installing on Juno are for the browser kernel of juno.sh (Pyodide). The Juno app's own Python has no pip or micropip: install puremacro there through Juno's package manager.

Installing on Juno

Every base dependency ships with Pyodide, so a dependency-resolving install works without reaching PyPI:

# requires: Pyodide (JupyterLite / juno.sh); `await` is only valid there
import micropip
await micropip.install("puremacro")

Add await micropip.install("pyarrow") for the parquet paths if your Pyodide distribution includes it (314.x does; 0.28 does not).

What is actually verified

python tools/release_check.py --pyodide (gate 6) boots a real Pyodide kernel, installs the wheel the way the playground does (resolving dependencies, none from PyPI) and runs the pyodide_smoke-marked suite: 31 tests green under Pyodide 0.28.3, including all ten runtime.store frame round-trips, cartridge pack/verify/base64 transport, the longrun invariance property, and dsge.build solving a model to its closed form. test_detection_matches_the_interpreter_it_is_running_on cross-examines the interpreter and confirms on the target that host == "pyodide", sockets is False, js_fetch is True, parquet is False and backends == ("numpy",).

One gap, stated plainly: the synchronous-XMLHttpRequest body of runtime.transport has never executed. Node-hosted Pyodide has no XMLHttpRequest — it is a browser API — so gate 6 cannot reach it either. Its callers, error paths and module patching are tested; the XHR call itself awaits confirmation from a real browser or Juno session.