Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Explore the Model in Your Browser

Authors
Affiliations
Hunter College and The Graduate Center, City University of New York
Hunter College and The Graduate Center, City University of New York

Every other page on this site shows figures that were rendered ahead of time. This page allows you to run the cells below with python in your browser — no server, no account, no install. They call the same enclose package that generates the paper’s figures.

The source of each cell is collapsed. Use the toggle on a cell to read or edit it.

How this works

The site is static HTML on GitHub Pages. It gains a Python kernel through JupyterLite, which runs CPython compiled to WebAssembly (Pyodide) inside the browser tab.

The one thing that is not automatic is enclose itself. Pyodide only knows about packages in its own distribution. So the build ships a wheel as a static asset and the first cell installs it from this site.

Setup

Source
import micropip
import js
from pyodide.http import pyfetch

# Resolved against wherever this page is served from, so the same cell works on the deployed
# site and under a local `myst start`. `/pyodide/` is a `static_files` entry in myst.yml,
# copied to the site root at build time.
#
# `js.location`, not `js.window.location`: the JupyterLite kernel runs in a Web Worker, where
# there is no `window` and the global is a WorkerGlobalScope. Same origin either way.
BASE = js.location.origin
WHEEL_NAME = "enclose-0.1.0-py3-none-any.whl"
WHEEL_URL = f"{BASE}/pyodide/{WHEEL_NAME}"

# numpy and matplotlib ship with Pyodide, so micropip resolves them from its own lockfile
# rather than PyPI. Two of `enclose`'s four dependencies are deliberately left out, because
# nothing on this page reaches the modules that need them:
#   sympy -- `enclose.symbolic`, which re-derives every locus from its objective and checks
#            the numeric layer against it. That check belongs in the test suite.
#   scipy -- `enclose.manufacturing`, whose three-sector equilibrium is transcendental and
#            needs `brentq`. Add "scipy" below to explore that extension here.
#
# ipywidgets is NOT preinstalled and the major version is not free to choose. thebe bundles
# the browser half of the widget protocol, and its `@jupyter-widgets/controls` is 2.0.0 --
# the ipywidgets 8 protocol. Pair it with ipywidgets 7 and the sliders render as blank space
# with no error, because the front end cannot resolve the model. Check `CONTROLS_VERSION` in
# `thebe-core.min.js` before changing this pin.
await micropip.install(["numpy", "matplotlib", "ipywidgets>=8,<9"])

# Fetch the wheel ourselves rather than handing micropip the URL. Given a URL together with
# deps=False, micropip can reach its install step with nothing downloaded and raise
# "Micropip internal error: attempted to install wheel before downloading it?". Fetching
# explicitly also means a bad path fails here, with a status code you can read, instead of
# surfacing as an internal error several frames deep in micropip.
#
# cache="no-store" is load-bearing. The wheel's filename never changes, so every deploy
# replaces a URL the browser may already hold, and GitHub Pages serves it with
# `Cache-Control: max-age=600`. Without this a reader gets up to ten minutes of the previous
# build's model code while the surrounding page is current -- which is not a hypothetical:
# it shipped a heatmap with white gridlines and no axis labels after both had been fixed,
# deployed and verified on the server. There is no build step here to hash the filename
# with, so the fetch declines the cache instead.
resp = await pyfetch(WHEEL_URL, cache="no-store")
if resp.status != 200:
    raise RuntimeError(f"HTTP {resp.status} fetching {WHEEL_URL}")
wheel_bytes = await resp.bytes()
with open(WHEEL_NAME, "wb") as fh:
    fh.write(wheel_bytes)

# `emfs:` means "a wheel already sitting in the Emscripten virtual filesystem".
await micropip.install(f"emfs:{WHEEL_NAME}", deps=False)

# Fingerprint what actually got installed, reported by the next cell (this one's output is
# hidden, and micropip is noisy). If a figure here ever disagrees with the source on GitHub,
# this is the first thing to check.
import hashlib

WHEEL_SHA = hashlib.sha256(wheel_bytes).hexdigest()[:12]
WHEEL_SIZE = len(wheel_bytes)
print("installed from", WHEEL_URL)
Output
Source
import numpy as np
import matplotlib.pyplot as plt

from enclose import figures, loci, model, welfare

print("enclose", __import__("enclose").__version__, "loaded in the browser")
print(f"wheel {WHEEL_SIZE:,} bytes, sha256 {WHEEL_SHA}")

A figure, rendered here rather than shipped

The same call that produces social_opt_cond.png in the paper. If this renders, the whole path works: wheel delivery, Pyodide, matplotlib, and the package’s own figure code.

Source
fig, ax = figures.social_optimum(cond_opt=True)

Closing the wedge: μ\mu and τ\tau on one panel

This is the figure the site exists to make interactive.

Figure 6 of the paper shows four panels at the corners of the (μ,τ)(\mu, \tau) square — (0,0)(0,0), (1,0)(1,0), (0,1)(0,1), (1,1)(1,1) — and asks you to interpolate between them in your head. Here both parameters are continuous on a single panel, so you can watch the interpolation instead.

Push μ\mu and τ\tau to 1 and the red band lands exactly on the black one. That coincidence is the paper’s Key Result (§5.3) — the wedge closes only when commons governance and compensation are both complete. Neither alone suffices, which you can check by pushing one slider to 1 while leaving the other at 0.

Source
from ipywidgets import interact, FloatSlider

def wedge(mu=0.0, tau=0.0, alp=2/3):
    fig, ax = figures.wedge_panel(mu=mu, tau=tau, alp=alp)
    plt.show()

interact(
    wedge,
    mu=FloatSlider(value=0.0, min=0.0, max=1.0, step=0.05,
                   description=r"$\mu$ (governance)", style={"description_width": "initial"}),
    tau=FloatSlider(value=0.0, min=0.0, max=1.0, step=0.05,
                    description=r"$\tau$ (compensation)", style={"description_width": "initial"}),
    # alpha rescales the axes, so unlike mu and tau it moves the canvas as well as the
    # curves -- see `figures._wedge_window`.
    alp=FloatSlider(value=2/3, min=0.45, max=0.9, step=0.01,
                    description=r"$\alpha$ (labor share)", readout_format=".2f",
                    style={"description_width": "initial"}),
);

The four corners, side by side, are the paper’s Figure 6:

Source
fig, ax = figures.combined_4x4()

How much output is actually lost

The loci say where private and social enclosure decisions diverge. They do not say by how much. enclose.welfare answers that by comparing three economies at every point of the same (θ,lnlˉ)(\theta, \ln\bar l) plane — each a pair of a labor rule and an enclosure rate:

regimelabor allocated byenclosure rate tet_e
First-best WFBW^{FB}Λo\Lambda_o — marginal products equatedmaximizes zz
Second-best WSBW^{SB}Λμ\Lambda_\mu — the decentralized rule, taken as givenmaximizes z0μz_0^\mu
Private WPW^{P}Λμ\Lambda_\mutedt_e^d where r(te)=cr(t_e)=c, global-games selected

The second-best planner is what makes the exercise work: it inherits the labor distortion it cannot fix but chooses enclosure freely. That separates the two failures, so net output Wk=YkctekW^k = Y^k - c\,t_e^k splits exactly:

WFBWPtotal foregone=WFBWSBlabor misallocation+WSBWPenclosure error\underbrace{W^{FB} - W^{P}}_{\text{total foregone}} = \underbrace{W^{FB} - W^{SB}}_{\text{labor misallocation}} + \underbrace{W^{SB} - W^{P}}_{\text{enclosure error}}

Both terms are non-negative by construction, not by luck, and build_grid asserts the orderings and the identity at every grid point rather than taking them on trust.

Source
from ipywidgets import Dropdown

def losses(mu=0.0, tau=0.0, component="total", normalize="ratio", n=121):
    figures.loss_panel(mu=mu, tau=tau, component=component, normalize=normalize, n=n)
    plt.show()

# continuous_update=False: each redraw is a full grid solve, so recompute on release rather
# than on every pixel of the drag. Drop `grid` to 61 if that still feels slow -- cost is
# quadratic in it, and no conclusion on this page depends on the resolution.
interact(
    losses,
    mu=FloatSlider(value=0.0, min=0.0, max=1.0, step=0.05, continuous_update=False,
                   description=r"$\mu$ (governance)", style={"description_width": "initial"}),
    tau=FloatSlider(value=0.0, min=0.0, max=1.0, step=0.05, continuous_update=False,
                    description=r"$\tau$ (compensation)", style={"description_width": "initial"}),
    component=Dropdown(options=list(welfare.COMP_LABEL), value="total",
                       description="component", style={"description_width": "initial"}),
    normalize=Dropdown(options=list(welfare.NORM_LABEL), value="ratio",
                       description="normalize", style={"description_width": "initial"}),
    n=Dropdown(options=[61, 91, 121, 161], value=121,
               description="grid", style={"description_width": "initial"}),
);

Three things are worth looking for.

The losses live in a band, and its edges are curves the paper already draws. Everything outside is flat zero. At low density nobody encloses; at high density everybody does; both are right. Inefficiency is a transitional phenomenon, confined to where the decision is genuinely close — which is why the overlaid loci are not decoration. The paper’s diagrams turn out to be the boundaries of this surface rather than separate objects.

The enclosure error dominates the misallocation. Switch component between the two. The open-access labor distortion — the classic tragedy-of-the-commons story — tops out near 5% of first-best. Getting the enclosure decision wrong costs several times that. The misallocation wedge also vanishes at both corners by construction, since with all land in one state there is nothing to misallocate between, so it is intrinsically an interior, second-order effect.

Below θ=1\theta = 1, enclosure has no productivity rationale and happens anyway. Both planners enclose nothing there. Private agents enclose fully once density passes a threshold, because enclosure captures rents that open access was dissipating — with a misallocation component of exactly zero. Redistribution dressed as improvement, triggered by density, not by any change in technology.

Does either reform work alone?

The Key Result is stated in locus terms — at μ=τ=1\mu=\tau=1 the decentralized loci become identical to the planner’s. In output terms it becomes quantitative, and stronger:

Source
grid = [0.0, 0.25, 0.5, 0.75, 1.0]

print("mean total loss, % of first-best")
print("        " + "".join(f"  tau={t:<5.2f}" for t in grid))
for mu in grid:
    row = ""
    for tau in grid:
        _, _, S = welfare.build_grid(n_th=31, n_l=31, mu=mu, tau=tau)
        # Clamped: non-negative by theorem, but at mu=tau=1 the two objectives coincide
        # exactly and the mean lands a few times 1e-16 below zero. Printing "-0.00" there
        # would read as a bug rather than as the exact-zero result it is.
        row += f"{max(0.0, 100 * S['total'].mean()):9.2f}"
    print(f"  mu={mu:4.2f}" + row)

The diagonal falls monotonically to zero: joint reform works, and works smoothly. But both axes are U-shaped, and both end worse than doing nothing — unilateral reform is not merely insufficient, it is counterproductive at the limit. Raising μ\mu alone fixes the smaller wedge while widening the gap between private and social enclosure incentives, because a well-run commons has value that enclosers still do not pay for. Raising τ\tau alone converts over-enclosure into under-enclosure; set component to over_enclosure and then under_enclosure and sweep τ\tau to watch the trade.

Read the means as shape, not level: they are unweighted averages over an arbitrary rectangle of (θ,lnlˉ)(\theta, \ln\bar l), not a welfare criterion, and the minimizing τ\tau moves with the window.

Going further

Every locus is a plain function of θ\theta, so anything on this page can be rebuilt from parts. Edit the cell below and re-run it.

Source
theta = np.linspace(1.01, 2.5, 300)

fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(theta, loci.ln_l01(theta), label=r"first-best, no enclosure  (eq. 6)")
ax.plot(theta, loci.ln_ld0(theta), label=r"decentralized, $\tau=0$")
ax.plot(theta, loci.ln_ld0(theta, tau=1.0), label=r"decentralized, $\tau=1$")
ax.set_xlabel(r"$\theta$ (rel. TFP)")
ax.set_ylabel(r"$\ln(\overline{l})$ (log population density)")
ax.legend()
plt.show()

If something here is broken

Three things can fail independently:

The wheel is committed at pyodide/enclose-0.1.0-py3-none-any.whl, rebuilt by CI ahead of the test suite, and byte-compared against the source by tests/test_wheel.py — so what the site serves cannot lag what the tests pass against. Caching is a separate matter, handled above. If a result here still disagrees with the figures page once the fingerprints match, open an issue.