🇬🇧 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?
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 raisesTransportErrornaming CORS as the likely cause. Passproxy=to route through a CORS proxy you control. - No timeouts, no custom User-Agent. A synchronous
XMLHttpRequeston the main thread cannot set either, so both are accepted and ignored rather than raising — which also means the WAF-bypass user-agent trick innarrative/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.mappreserves 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, orPUREMACRO_THREADS=0— it runs the loop and builds no executor at all. Yourn_jobsis then simply ignored; nothing raises. - If the pool is attempted and cannot start (
RuntimeError,OSErrororValueErrorout ofThreadPoolExecutor), aRuntimeWarningnames the engine and says the replications are being evaluated serially instead. n_jobs=0raisesValueErrorbefore anything runs; a negativen_jobsmeans every CPU core;n_jobs=1is 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_payloadsembeds 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
.pmzcartridges 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
cachemode 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.