Tutorial: LowGrow SSE — Climate-Economy SFC with TIR Green Transition Routing¶
LowGrow is a Stock-Flow Consistent climate-economy model in which firms allocate investment between brown capital (fossil-fuel intensive, high short-run return) and green capital (renewable/low-carbon, lower short-run return). EconIAC replaces the standard fixed-share investment rule with TIR routing at rationality β:
$$w = \text{gibbs\_weights}([U_{\text{brown}},\, U_{\text{green}}],\, \beta)$$ $$I_{\text{brown}} = w_0 \cdot I \qquad I_{\text{green}} = w_1 \cdot I$$
where $U_{\text{brown}} = r_{\text{brown}} - \tau$ (after-tax return on brown capital) and $U_{\text{green}} = r_{\text{green}}$ (green return, untaxed). The carbon tax $\tau$ is the key policy lever.
What this tutorial demonstrates:
- The carbon lock-in paradox: without a carbon tax, "rational" firms (high β) invest more in brown capital because $U_{\text{brown}} > U_{\text{green}}$. Maximum-entropy (β=0) accidentally produces better climate outcomes.
- The carbon tax as a policy lever: $\tau > \tau^*$ flips $U_{\text{brown}} < U_{\text{green}}$, so rationality now drives the green transition. The phase boundary $\tau^*$ is where brown and green utilities are equal.
- β-calibration to IEA data: recovering implied corporate rationality β* from observed green investment shares — and whether β* has structural meaning depends on which side of the $\tau^*$ phase boundary we are on.
- CurvedBalanceSheet: climate damage as field-strength curvature — a non-zero column sum that no sector can claim as an asset.
| β | τ | Portfolio | Interpretation |
|---|---|---|---|
| 0 | 0 | 50% brown, 50% green | Maximum entropy — accidentally green-ish |
| high | 0 | ≈100% brown | Fully rational lock-in |
| high | > τ* | ≈100% green | Rationality drives green transition |
| 0 | any | 50/50 | β=0 is τ-insensitive |
Reference: Victor (2008) Managing Without Growth; Godley & Lavoie (2007) Monetary Economics. EconIAC TIR implementation: Buckley (2026) doi:10.5281/zenodo.20237288
# !pip install econiac "jax[cpu]"
import jax.numpy as jnp
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np
from econiac.core.ensemble import gibbs_weights
from econiac.economics.lowgrow import (
LGParameters, ModelLG, calibrate_green_beta,
green_transition_curve, carbon_tax_phase_diagram,
IEA_GREEN_SHARES, IEA_SCENARIOS, BREAKEVEN_CARBON_TAX,
)
plt.rcParams.update({"figure.dpi": 120, "font.size": 11})
# Colour palette used throughout
C_BROWN = "#8B4513"
C_GREEN = "#228B22"
C_BLUE = "#1f77b4"
C_RED = "#d62728"
1. The Carbon Lock-In Paradox: β-sweep without a carbon tax¶
Without a carbon tax ($\tau = 0$), brown capital offers a higher utility: $U_{\text{brown}} = r_{\text{brown}} = 0.08 > 0.06 = r_{\text{green}} = U_{\text{green}}$.
The TIR routing rule responds rationally: higher β concentrates more investment in the dominant option. So the more rational firms are, the more brown capital they build — a structural lock-in driven by the return differential.
The paradox: maximum-entropy firms (β=0, indifferent) split investment 50/50 and accidentally produce lower cumulative emissions and lower climate damage than fully-rational firms (high β) who correctly identify brown capital as the better short-run bet.
Below we simulate ModelLG for five values of β with no carbon tax and track:
- Output Y(t): the LowGrow SSE has $Y_{ss} \approx 52.6$, not 100. Output peaks around year 20 at ≈ 150 then declines as climate damage erodes productive capacity.
- Cumulative emissions $E_{\text{cum}}(t)$
- Climate damage fraction damage(t)
betas_sweep = [0, 2, 6, 20, 50]
palette = ["#aaaaaa", C_BLUE, C_GREEN, C_BROWN, C_RED]
trajs = {}
for b in betas_sweep:
model = ModelLG(LGParameters(beta=float(b)))
trajs[b] = model.simulate(T=50)
ts = range(51)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for b, color in zip(betas_sweep, palette):
traj = trajs[b]
Y_t = [s.Y for s in traj]
E_cum_t = [s.E_cum for s in traj]
damage_t = [s.damage for s in traj]
axes[0].plot(ts, Y_t, color=color, lw=2, label=f"β={b}")
axes[1].plot(ts, E_cum_t, color=color, lw=2, label=f"β={b}")
axes[2].plot(ts, damage_t, color=color, lw=2, label=f"β={b}")
axes[0].set_xlabel("Year"); axes[0].set_ylabel("Output Y")
axes[0].set_title("GDP Y(t) — peak ~year 20, then\nclimate drag pulls it down")
axes[0].legend(fontsize=8)
axes[1].set_xlabel("Year"); axes[1].set_ylabel("Cumulative emissions")
axes[1].set_title("Cumulative emissions E_cum(t)\nHigher β = more brown = more emissions")
axes[1].legend(fontsize=8)
axes[2].set_xlabel("Year"); axes[2].set_ylabel("Damage fraction")
axes[2].set_title("Climate damage(t)\nParadox: β=0 has lowest damage (gray)")
axes[2].legend(fontsize=8)
fig.suptitle(
"Carbon lock-in paradox: τ=0, β-sweep\n"
"Higher rationality → more brown investment → worse climate outcomes",
fontsize=12, y=1.03,
)
plt.tight_layout()
plt.show()
2. Green Transition Curve: s_green(β) at three carbon tax levels¶
The green investment share is:
$$s_{\text{green}}(\beta, \tau) = \text{gibbs\_weights}\bigl([r_{\text{brown}} - \tau,\; r_{\text{green}}],\, \beta\bigr)[1]$$
At $\tau = 0$: $U_{\text{brown}} > U_{\text{green}}$, so the curve decreases with β — more rational firms invest less in green. This is the lock-in regime.
At the breakeven tax $\tau^* = r_{\text{brown}} - r_{\text{green}}$: both utilities are equal. The curve is flat at $s = 0.5$ for all β — rationality has no directional effect.
At $\tau > \tau^*$: $U_{\text{green}} > U_{\text{brown}}$, so the curve increases with β — rationality now drives the green transition. The policy has flipped the phase.
The shaded regions mark the brown-dominant zone ($s < 0.5$, gray) and the green-dominant zone ($s > 0.5$, light green).
tau_values = [0.0, BREAKEVEN_CARBON_TAX, 0.3]
tau_labels = ["τ=0 (no tax, lock-in)", f"τ=τ*={BREAKEVEN_CARBON_TAX:.4f} (breakeven)", "τ=0.3 (above threshold)"]
tau_colors = [C_BROWN, "#888888", C_GREEN]
tau_styles = ["-", "--", "-"]
fig, ax = plt.subplots(figsize=(10, 4))
# Shade regions
ax.axhspan(0.0, 0.5, alpha=0.06, color="gray", label="Brown-dominant (s < 0.5)")
ax.axhspan(0.5, 1.0, alpha=0.06, color=C_GREEN, label="Green-dominant (s > 0.5)")
ax.axhline(0.5, color="gray", lw=1, ls=":")
for tau, label, color, ls in zip(tau_values, tau_labels, tau_colors, tau_styles):
beta_range, shares = green_transition_curve(r_brown=0.08, r_green=0.06, tau=tau)
ax.plot(beta_range, shares, color=color, lw=2.5, ls=ls, label=label)
# Mark breakeven tax phase boundary
ax.axvline(x=0, color="gray", lw=0.8, ls=":", alpha=0.5)
ax.annotate(
f"Phase boundary τ*={BREAKEVEN_CARBON_TAX:.4f}",
xy=(60, 0.5), xytext=(65, 0.42),
fontsize=9, color="#555555",
arrowprops=dict(arrowstyle="->", color="#555555", lw=1.0),
)
ax.set_xlabel("β (corporate rationality)")
ax.set_ylabel("Green investment share s_green")
ax.set_title(
"Green transition curve s_green(β) at three tax levels\n"
"τ < τ*: rationality locks in brown. τ > τ*: rationality drives green."
)
ax.set_ylim(0.0, 1.0)
ax.legend(fontsize=9, loc="center right")
plt.tight_layout()
plt.show()
3. Carbon Tax Phase Diagram: s_green(τ) at fixed β¶
Holding β fixed and varying the carbon tax $\tau$ reveals the phase transition at $\tau^*$. For more rational firms (higher β), the transition is sharper:
- β=2 (weakly rational): a broad, gradual sigmoid crossing 0.5 around τ*
- β=6 (moderately rational): steeper sigmoid
- β=20 (highly rational): a near-discontinuous jump — the system snaps from almost fully brown to almost fully green as τ crosses τ*
This sharpening is a direct consequence of the Gibbs softmax: larger β amplifies small utility differences into large allocation differences. The implication for policy is that more rational corporate sectors require smaller tax increases to trigger the transition, but the window of ambiguity is also narrower — getting the policy wrong on the wrong side of τ* causes a sharper lock-in.
betas_phase = [2, 6, 20]
phase_colors = [C_BLUE, C_GREEN, C_RED]
fig, ax = plt.subplots(figsize=(10, 4))
# Shade brown/green zones
ax.axhspan(0.0, 0.5, alpha=0.05, color="gray")
ax.axhspan(0.5, 1.0, alpha=0.05, color=C_GREEN)
# Phase boundary lines
ax.axvline(BREAKEVEN_CARBON_TAX, color="black", lw=1.5, ls="--", label=f"τ* = {BREAKEVEN_CARBON_TAX:.4f} (policy threshold)")
ax.axhline(0.5, color="gray", lw=1, ls=":")
for b, color in zip(betas_phase, phase_colors):
tau_range, shares = carbon_tax_phase_diagram(beta=b, r_brown=0.08, r_green=0.06)
ax.plot(tau_range, shares, color=color, lw=2.5, label=f"β={b}")
ax.annotate(
"Policy threshold τ*\nbelow: brown lock-in\nabove: green transition",
xy=(BREAKEVEN_CARBON_TAX, 0.75),
xytext=(BREAKEVEN_CARBON_TAX + 0.04, 0.72),
fontsize=9, color="black",
arrowprops=dict(arrowstyle="->", color="black", lw=1.0),
)
ax.set_xlabel("Carbon tax τ")
ax.set_ylabel("Green investment share s_green")
ax.set_title(
"Carbon tax phase diagram s_green(τ) at fixed β\n"
"Higher β = sharper phase transition at τ*"
)
ax.set_ylim(0.0, 1.0)
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
4. β-Calibration to IEA Investment Data¶
The IEA World Energy Investment reports publish observed green investment shares across scenarios. We can recover the implied corporate rationality β* for each scenario by minimising:
$$\mathcal{L}(\beta) = \bigl(\text{gibbs\_weights}([U_{\text{brown}}, U_{\text{green}}],\, \beta)[1] - s_{\text{obs}}\bigr)^2$$
via JAX gradient descent.
Crucially, the structural meaning of β* depends on which side of τ* we are on:
- If $s_{\text{obs}} < 0.5$ and $\tau = 0$: β* is the rationality that produces that brown-dominant share. Higher β* = stronger lock-in. β* is identified.
- If $s_{\text{obs}} > 0.5$ and $\tau = 0$: no positive β can produce this — it requires either $\tau > \tau^*$ (carbon pricing is active) or β < 0 (irrational green preference / regulatory mandate). The calibration hits the β=0 floor. β* is not identified from rationality alone.
This parallels the UK anomaly in the GL PC tutorial: the TIR model makes the regime boundary explicit and computable.
calibration_results = {}
print(f" {'Scenario':28s} {'s_obs':>7s} {'β*':>8s} {'s_pred':>8s} {'Error':>8s} {'Regime'}")
print(f" {'-'*80}")
for name, (s_obs, r_brown, r_green) in IEA_SCENARIOS.items():
b_star, losses = calibrate_green_beta(
s_obs, r_brown=r_brown, r_green=r_green, n_steps=2000, lr=2.0
)
U = jnp.array([r_brown, r_green])
w_pred = gibbs_weights(U, beta=b_star)
s_pred = float(w_pred[1])
err = abs(s_pred - s_obs) * 100
# Determine regime
tau_star_scen = r_brown - r_green
if s_obs > 0.5 and 0.0 < tau_star_scen:
regime = "floor (needs τ > τ* or mandate)"
else:
regime = "identified (β* meaningful)"
calibration_results[name] = (s_obs, b_star, s_pred, err, regime)
print(f" {name:28s} {s_obs:7.3f} {b_star:8.2f} {s_pred:8.4f} {err:7.4f}% {regime}")
names = list(calibration_results.keys())
s_obs_v = [calibration_results[n][0] for n in names]
b_stars = [calibration_results[n][1] for n in names]
regimes = [calibration_results[n][4] for n in names]
bar_colors = [C_GREEN if "identified" in r else C_RED for r in regimes]
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].bar(range(len(names)), s_obs_v, color=bar_colors, alpha=0.85)
axes[0].axhline(0.5, ls="--", color="gray", lw=1.5, label="β=0 boundary (s=0.5)")
axes[0].set_xticks(range(len(names)))
axes[0].set_xticklabels(names, rotation=20, ha="right", fontsize=9)
axes[0].set_ylabel("Observed green share s_obs")
axes[0].set_title("IEA observed green investment shares")
axes[0].legend(fontsize=9)
axes[1].bar(range(len(names)), b_stars, color=bar_colors, alpha=0.85)
axes[1].set_xticks(range(len(names)))
axes[1].set_xticklabels(names, rotation=20, ha="right", fontsize=9)
axes[1].set_ylabel("Implied rationality β*")
axes[1].set_title("Calibrated β* by IEA scenario\nGreen=identified, Red=floor (needs τ>τ*)")
fig.suptitle(
"TIR β-calibration to IEA World Energy Investment data",
fontsize=12,
)
plt.tight_layout()
plt.show()
5. Climate Damage Scenarios: BAU → NZE¶
We compare five scenarios spanning the policy space:
| Scenario | β | τ | Description |
|---|---|---|---|
| BAU | 0 | 0 | Business as usual — no tax, max-entropy |
| Carbon lock-in | 2 | 0 | Mildly rational, no tax — slight lock-in |
| LowGrow SSE | 6 | 0 | Steady-state economy, no carbon price |
| LowGrow+tax | 6 | 0.2 | Steady-state with moderate carbon tax |
| IEA NZE | 35 | 0.3 | Net-Zero Emissions — high rationality + strong tax |
The key outcome: by year 50, the IEA NZE scenario achieves roughly 12% climate damage compared to 26% for BAU. The LowGrow+tax scenario sits between them, illustrating that carbon pricing alone (without a broader post-growth economic transition) delivers partial but incomplete protection.
Note the GDP trajectories: all scenarios peak around year 20 as the economy approaches the LowGrow SSE target ($Y_{ss} \approx 52.6$). The NZE scenario sustains higher output in later years because lower damage reduces the drag on productive capacity.
scenarios = [
("BAU", 0, 0.0, "#555555", "-"),
("Carbon lock-in",2, 0.0, C_BROWN, "--"),
("LowGrow SSE", 6, 0.0, C_BLUE, "-"),
("LowGrow+tax", 6, 0.2, C_GREEN, "--"),
("IEA NZE", 35, 0.3, C_RED, "-"),
]
sim_results = {}
for name, b, tau, color, ls in scenarios:
model = ModelLG(LGParameters(beta=float(b), tau=float(tau)))
sim_results[name] = (model.simulate(T=50), color, ls)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for name, (traj, color, ls) in sim_results.items():
Y_t = [s.Y for s in traj]
damage_t = [s.damage for s in traj]
ts = range(len(traj))
axes[0].plot(ts, Y_t, color=color, lw=2, ls=ls, label=name)
axes[1].plot(ts, damage_t, color=color, lw=2, ls=ls, label=name)
axes[0].set_xlabel("Year"); axes[0].set_ylabel("Output Y")
axes[0].set_title("GDP trajectories Y(t)\nNZE sustains higher output via lower damage")
axes[0].legend(fontsize=8)
axes[1].set_xlabel("Year"); axes[1].set_ylabel("Damage fraction")
axes[1].set_title("Climate damage(t)\nNZE ≈ 12%, BAU ≈ 26% at year 50")
axes[1].legend(fontsize=8)
fig.suptitle(
"Climate damage scenarios: BAU through IEA Net-Zero Emissions",
fontsize=12,
)
plt.tight_layout()
plt.show()
# Print year-50 summary
print("Year-50 outcomes:")
print(f" {'Scenario':20s} {'Y(50)':>8s} {'damage(50)':>12s}")
print(f" {'-'*44}")
for name, (traj, color, ls) in sim_results.items():
s50 = traj[50]
print(f" {name:20s} {s50.Y:8.2f} {s50.damage:12.4f}")
6. SFC Instability: Diagnosing and Fixing the Bond Spiral¶
SFC models are notorious for instability — not bugs, but genuine consequences of non-linear demand-led feedback loops. LowGrow SSE's oscillatory behaviour in Stella Architect originates in exactly this structure.
The bond-spiral mechanism in our model:
- Government runs a structural primary deficit: $G > T$ at equilibrium
- Deficit is financed by issuing bonds to households: $\Delta B_h = G - T + r \cdot B_h$
- Bond interest enters household income $YD$, boosting consumption $C$
- Rising $C$ raises $Y$ slightly, but taxes only recoup fraction $\theta$
- Net: deficit persists → $B_h \to \infty$, then $C > Y$ permanently
The EconIAC diagnostic uses three tools from the library:
| Tool | What it measures |
|---|---|
model.laplacian_spectrum() |
Algebraic connectivity of flow network — small λ₂ = weakly-damped bond cycle |
model.cycle_basis() |
Identifies which H₁ cycle is unstable (bond_issuance + bond_interest) |
model.consistency_residual(s, s_prev) |
Quantifies ∂²=0 violation — zero = SFC holds; growing = spiral active |
The EconIAC fix uses sfc_discipline=True, which enforces the Godley constraint
at each step: household bond absorption is capped at net saving, with any uncovered
deficit implicitly monetised (central bank residual absorber).
This is the same discipline that Godley tables impose in Stella — but here it is explicit, auditable code, and the violation is measurable as a topological residual.
# --- Pacioli manifold: topology of the LowGrow flow network ---
model_topo = ModelLG(LGParameters(beta=6.0), G_bar=20.0)
eigvals = model_topo.laplacian_spectrum()
print('Laplacian spectrum:', eigvals.round(4))
print(f' λ₁ = {eigvals[0]:.4f} (zero = connected; H₀=1)')
print(f' λ₂ = {eigvals[1]:.4f} (algebraic connectivity — bond cycle coupling)')
print(f' λ₃ = {eigvals[2]:.4f}')
print(f' λ₄ = {eigvals[3]:.4f}')
print()
edges, cycles = model_topo.cycle_basis()
h1_rank = cycles.shape[1]
print(f'H₁ = {h1_rank} independent financial cycles')
print('Cycle basis (dominant edge participations):')
for i, e in enumerate(edges):
row = cycles[i]
dominant = [f'{cycles[j][i]:.2f}·{edges[j]}' for j in range(len(edges))
if abs(cycles[j][i]) > 0.5]
print()
# Print the two bond-dominated cycles (columns 1 and 2)
print('Bond cycle 1 (dominated by bond_issuance):')
for i, e in enumerate(edges):
if abs(cycles[i, 1]) > 0.1:
print(f' {e:15s}: {cycles[i,1]:+.3f}')
print('Bond cycle 2 (dominated by bond_interest):')
for i, e in enumerate(edges):
if abs(cycles[i, 2]) > 0.1:
print(f' {e:15s}: {cycles[i,2]:+.3f}')
# --- Simulate: undisciplined vs disciplined ---
m_bad = ModelLG(LGParameters(beta=6.0), G_bar=20.0, sfc_discipline=False)
m_good = ModelLG(LGParameters(beta=6.0), G_bar=20.0, sfc_discipline=True)
traj_bad = m_bad.simulate(T=100)
traj_good = m_good.simulate(T=100)
years = [s.t for s in traj_bad]
Y_bad = [s.Y for s in traj_bad]
Bh_bad = [s.Bh for s in traj_bad]
C_bad = [s.C for s in traj_bad]
Y_good = [s.Y for s in traj_good]
Bh_good = [s.Bh for s in traj_good]
C_good = [s.C for s in traj_good]
# SFC residuals: ||ΔV - (YD-C)||
resid_bad = [0.0] + [m_bad.consistency_residual(traj_bad[i], traj_bad[i-1]) for i in range(1,101)]
resid_good = [0.0] + [m_good.consistency_residual(traj_good[i], traj_good[i-1]) for i in range(1,101)]
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
# Panel 1: GDP
axes[0].plot(years, Y_bad, color=C_RED, lw=2, label='Undisciplined (SFC✗)')
axes[0].plot(years, C_bad, color=C_RED, lw=1.2, ls='--', alpha=0.6, label='C (undisciplined)')
axes[0].plot(years, Y_good, color=C_GREEN, lw=2, label='Disciplined (SFC✓)')
axes[0].plot(years, C_good, color=C_GREEN, lw=1.2, ls='--', alpha=0.6, label='C (disciplined)')
axes[0].axhline(0, color='k', lw=0.5, ls=':')
axes[0].set_title('GDP and Consumption')
axes[0].set_xlabel('Year')
axes[0].set_ylabel('$bn')
axes[0].legend(fontsize=8)
# Panel 2: Bond holdings Bh
axes[1].plot(years, Bh_bad, color=C_RED, lw=2, label='Undisciplined')
axes[1].plot(years, Bh_good, color=C_GREEN, lw=2, label='Disciplined')
axes[1].set_title('Household Bond Holdings $B_h$')
axes[1].set_xlabel('Year')
axes[1].set_ylabel('$bn')
axes[1].legend()
# Panel 3: SFC residual ||ΔV - (YD-C)||
axes[2].plot(years, resid_bad, color=C_RED, lw=2, label='Undisciplined — ∂²≠0 signal')
axes[2].plot(years, resid_good, color=C_GREEN, lw=2, label='Disciplined — ∂²=0 enforced')
axes[2].set_title('SFC Residual ||dV - (YD-C)||')
axes[2].set_xlabel('Year')
axes[2].set_ylabel('Residual ($bn)')
axes[2].legend()
plt.suptitle('SFC Instability: Undisciplined vs Disciplined LowGrow Model', fontsize=12)
plt.tight_layout()
plt.show()
print(f'Undisciplined — Year 100: Y={Y_bad[-1]:.1f}, Bh={Bh_bad[-1]:.1f}, C={C_bad[-1]:.1f}')
print(f'Disciplined — Year 100: Y={Y_good[-1]:.1f}, Bh={Bh_good[-1]:.1f}, C={C_good[-1]:.1f}')
print(f'SFC residual at year 100: {resid_bad[-1]:.2f} (undisciplined) vs {resid_good[-1]:.5f} (disciplined)')
What we just saw¶
Undisciplined model (sfc_discipline=False): the bond spiral activates around year 65
when household deposits are exhausted. The max(..., 0) floor on Hh severs the saving
identity — from that point ΔV > YD - C, i.e. bonds accumulate faster than households
are saving. The SFC residual ||ΔV - (YD-C)|| climbs from zero to 82 by year 100.
Disciplined model (sfc_discipline=True): the Godley constraint caps bond absorption
at net household saving. The SFC residual stays near zero. But the fiscal closure reveals
a deeper tension: with a permanent primary deficit, long-run Y is lower — the economy
is sustainable but not growing, which is exactly the LowGrow SSE scenario.
The topological fingerprint: the bond spiral corresponds to the H₁ cycle with edges
bond_issuance and bond_interest. It has no natural restoring force (no negative
feedback) in the undisciplined model. The Laplacian's algebraic connectivity λ₂ ≈ 2.95
measures how loosely this cycle is coupled to the real economy — small λ₂ means the
bond cycle can run away before the real-economy variables respond.
EconIAC principle: instability in SFC models is not a bug to be tuned away. It is a topological signal — a non-zero
consistency_residualfingerprints exactly which financial cycle is unstable. Thesfc_discipline=Trueflag enforces ∂²=0 as a hard constraint at every step, which is what Godley tables do in Stella — but here it is explicit, auditable, and differentiable.
7. CurvedBalanceSheet: Climate Damage as Field-Strength Curvature¶
In a standard SFC balance sheet, every row sums to zero (every liability is someone else's asset) and every column sum is the net worth of that sector. The sum of all column sums is zero — the global accounting identity.
Climate damage breaks this. It destroys real value — productive capacity, ecosystem services, physical capital — without creating a corresponding liability on any sector's balance sheet. The damage is external to the monetary circuit.
EconIAC represents this as a field-strength curvature $F$: a term whose norm $\|F\|$ measures the deviation from the flat (zero-curvature) SFC identity. Formally:
$$F = \text{damage} \times Y_{\text{pot}}$$
where $Y_{\text{pot}}$ is the undamaged potential output. This is analogous to the field-strength tensor $F_{\mu\nu}$ in gauge theory: it is the curvature of the balance-sheet manifold induced by the external shock. When $F = 0$, the economy's balance sheet is flat and the standard SFC identities hold exactly.
Below we show the CurvedBalanceSheet at four time steps — years 0, 10, 25, 50 —
and track the growth of $\|F\|$ over time in the LowGrow SSE (β=6, τ=0) scenario.
model_lg = ModelLG(LGParameters(beta=6.0), G_bar=20.0)
traj_lg = model_lg.simulate(T=50)
check_years = [0, 10, 25, 50]
print("CurvedBalanceSheet snapshots — LowGrow SSE (β=6, τ=0)")
print(f" {'Year':>5s} {'Y':>8s} {'damage':>8s} {'||F||':>10s} {'consistent?':>12s}")
print(f" {'-'*55}")
curvature_norms = []
for yr in check_years:
state = traj_lg[yr]
bs = model_lg.balance_sheet(state)
F_norm = float(bs.curvature_norm()) if hasattr(bs, "curvature_norm") else abs(state.damage * state.Y)
curvature_norms.append((yr, F_norm))
consistent = bs.is_consistent() if hasattr(bs, "is_consistent") else (state.damage < 1e-8)
print(f" {yr:5d} {state.Y:8.3f} {state.damage:8.4f} {F_norm:10.4f} {str(consistent):>12s}")
print()
print("Interpretation:")
print(" ||F|| = 0 at year 0: no damage yet, balance sheet is flat.")
print(" ||F|| grows monotonically: climate damage accumulates as a curvature term")
print(" that cannot be netted out by any sector's net worth.")
print(" In standard SFC accounting this value is simply missing — it is invisible.")
print(" CurvedBalanceSheet makes it explicit and measurable.")
# Plot ||F|| over the full 50-year trajectory
all_years = range(51)
F_norms_full = []
for yr in all_years:
state = traj_lg[yr]
bs = model_lg.balance_sheet(state)
F_norm = float(bs.curvature_norm()) if hasattr(bs, "curvature_norm") else abs(state.damage * state.Y)
F_norms_full.append(F_norm)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
damage_full = [s.damage for s in traj_lg]
axes[0].plot(all_years, damage_full, color=C_BROWN, lw=2.5)
for yr in check_years:
axes[0].axvline(yr, color="gray", lw=0.8, ls=":", alpha=0.6)
axes[0].set_xlabel("Year"); axes[0].set_ylabel("Damage fraction")
axes[0].set_title("Climate damage(t) — LowGrow SSE (β=6, τ=0)")
axes[1].plot(all_years, F_norms_full, color=C_RED, lw=2.5)
for yr, fn in curvature_norms:
axes[1].scatter([yr], [fn], color="black", s=60, zorder=5)
axes[1].annotate(f"yr {yr}\n||F||={fn:.2f}", (yr, fn),
textcoords="offset points", xytext=(5, 5), fontsize=8)
axes[1].set_xlabel("Year"); axes[1].set_ylabel("||F|| (curvature norm)")
axes[1].set_title(
"Balance-sheet curvature ||F||(t)\n"
"Value destroyed by climate — belongs to no sector"
)
fig.suptitle(
"CurvedBalanceSheet: climate damage as field-strength curvature",
fontsize=12,
)
plt.tight_layout()
plt.show()
Summary¶
| Step | What it showed | | --- | --- | | Carbon lock-in paradox | Higher β → more brown investment → more emissions → more damage. β=0 is accidental best | | Green transition curve | τ < τ: s(β) decreasing (lock-in). τ = τ: flat. τ > τ: s(β) increasing (green) | | Phase diagram | Higher β sharpens the τ phase transition; NZE-scale rationality snaps from brown to green | | β* calibration | IEA scenarios imply a range of β*; scenarios with s_obs > 0.5 at τ=0 hit the β=0 floor | | Damage scenarios | NZE achieves ~12% damage vs ~26% for BAU at year 50; LowGrow+tax sits between | | CurvedBalanceSheet | ||F|| grows monotonically; climate damage is curvature invisible to standard SFC accounting |
The key novel results:
Carbon lock-in is endogenous to rationality: TIR routing makes the mechanism explicit — it is not a market failure in the usual sense, but a phase in which rational behaviour and climate objectives are misaligned. The misalignment is resolved by crossing the τ* phase boundary.
τ* is a structural policy target: the breakeven carbon tax $\tau^* = r_{\text{brown}} - r_{\text{green}}$ is directly computable from observed investment return differentials. It is the minimum tax required to make rationality work for the green transition rather than against it.
CurvedBalanceSheet identifies hidden value destruction: the curvature norm $\|F\|$ is a quantitative measure of the gap between the monetary economy and the real economy — the value destroyed by climate impacts that standard double-entry accounting cannot see.
EconIAC is open source: pip install econiac
Theory: Buckley (2026) TIR. doi:10.5281/zenodo.20237288