# Model code — copied verbatim from guides/ch03-the-neoclassical-growth-model/_model.py
"""Canonical model for Chapter 3 — The neoclassical growth model (Ramsey–Cass–Koopmans).
THE single source of the chapter's model equations for this guide: key-graphs.qmd imports it, _verify.py
checks against it, write-notebook copies it verbatim into the model notebook, and _model.js (the OJS
sliders) is checked against it through PARITY_CASES (`uv run m4pp check-parity ch03`).
Notation follows the chapter (book/ch03-the-neoclassical-growth-model/main.md): per-capita capital k,
consumption c, population growth n, rate of time preference rho (> n), elasticity of intertemporal
substitution sigma, NO depreciation (the chapter sets delta = 0, §3.1 "The resource constraint").
Technology is the chapter's Cobb–Douglas, eq. 3.19, Y = A L^alpha K^(1-alpha), where ALPHA IS THE
LABOUR SHARE (eq. 3.21) — so per capita f(k) = A k^(1-alpha).
The chapter gives no calibration: the values below are ILLUSTRATIVE (chosen so rho > n), and every page
that uses them says so.
"""
import numpy as np
from scipy.integrate import solve_ivp
# --- Illustrative parameters (the chapter gives none; rho > n is required, footnote 5) ----------
A = 1.0 # total factor productivity
ALPHA = 2 / 3 # labour share (eq. 3.21); the capital share is 1 - ALPHA
RHO = 0.04 # rate of time preference
N = 0.01 # population growth
SIGMA = 1.0 # elasticity of intertemporal substitution (eq. 3.13); 1 = log utility
def f(k, A=A, alpha=ALPHA):
"""Output per capita, f(k) = A k^(1-alpha)."""
return A * np.power(k, 1 - alpha)
def f_prime(k, A=A, alpha=ALPHA):
"""Marginal product of capital, f'(k) = (1-alpha) A k^(-alpha)."""
return (1 - alpha) * A * np.power(k, -alpha)
def f_second(k, A=A, alpha=ALPHA):
"""f''(k) = -alpha (1-alpha) A k^(-alpha-1) < 0 (diminishing returns)."""
return -alpha * (1 - alpha) * A * np.power(k, -alpha - 1)
def k_star(rho=RHO, A=A, alpha=ALPHA):
"""Steady-state capital from the modified golden rule f'(k*) = rho (eq. 3.17)."""
return ((1 - alpha) * A / rho) ** (1 / alpha)
def k_golden(n=N, A=A, alpha=ALPHA):
"""Golden-rule capital from f'(k_G) = n (eq. 3.18): maximises steady-state consumption."""
return ((1 - alpha) * A / n) ** (1 / alpha)
def c_kdot0(k, n=N, A=A, alpha=ALPHA):
"""The kdot = 0 locus, c = f(k) - n k (eq. 3.16): hump-shaped, peaking at k_G."""
return f(k, A, alpha) - n * k
def c_star(rho=RHO, n=N, A=A, alpha=ALPHA):
"""Steady-state consumption: the kdot = 0 locus evaluated at k* (eqs. 3.16–3.17)."""
return c_kdot0(k_star(rho, A, alpha), n, A, alpha)
def euler_growth(k, rho=RHO, sigma=SIGMA, A=A, alpha=ALPHA):
"""Consumption growth from the Euler equation, cdot/c = sigma [f'(k) - rho] (eq. 3.15)."""
return sigma * (f_prime(k, A, alpha) - rho)
def eigenvalues(rho=RHO, n=N, sigma=SIGMA, A=A, alpha=ALPHA):
"""Eigenvalues (stable, unstable) of the linearised system, Omega in eq. 3.33.
Omega = [[rho - n, -1], [sigma c* f''(k*), 0]]; det = sigma c* f''(k*) < 0 ⇒ opposite signs.
"""
ks, cs = k_star(rho, A, alpha), c_star(rho, n, A, alpha)
tr, det = rho - n, sigma * cs * f_second(ks, A, alpha)
disc = np.sqrt(tr ** 2 - 4 * det)
return (tr - disc) / 2, (tr + disc) / 2
def eig_stable(rho=RHO, n=N, sigma=SIGMA, A=A, alpha=ALPHA):
"""The negative (stable) eigenvalue: the speed at which the economy converges along the saddle path."""
return eigenvalues(rho, n, sigma, A, alpha)[0]
def saddle_slope(rho=RHO, n=N, sigma=SIGMA, A=A, alpha=ALPHA):
"""Slope dc/dk of the saddle path at the steady state (linear approximation): rho - n - lambda_s > 0."""
return rho - n - eig_stable(rho, n, sigma, A, alpha)
def dynamics(t, y, rho=RHO, n=N, sigma=SIGMA, A=A, alpha=ALPHA):
"""The system eqs. 3.5 and 3.15: kdot = f(k) - n k - c, cdot = sigma c [f'(k) - rho]."""
k, c = y
return [f(k, A, alpha) - n * k - c, sigma * c * (f_prime(k, A, alpha) - rho)]
def saddle_path(k_min, k_max, rho=RHO, n=N, sigma=SIGMA, A=A, alpha=ALPHA, eps=1e-4):
"""Global saddle path c(k) on [k_min, k_max], traced by integrating the system BACKWARD in time
from points just off the steady state along the stable eigenvector (backward, the stable arm
becomes the attracting one). Returns (k, c) sorted by k."""
ks, cs = k_star(rho, A, alpha), c_star(rho, n, A, alpha)
slope = saddle_slope(rho, n, sigma, A, alpha)
pieces = []
for direction in (-1, 1):
k0, c0 = ks + direction * eps * ks, cs + direction * eps * ks * slope
stop = (lambda t, y: y[0] - k_min) if direction < 0 else (lambda t, y: y[0] - k_max)
stop.terminal = True
sol = solve_ivp(lambda t, y: [-v for v in dynamics(t, y, rho, n, sigma, A, alpha)],
(0, 2000), [k0, c0], events=stop, max_step=1.0, rtol=1e-9, atol=1e-11)
pieces.append(sol.y)
k = np.concatenate([pieces[0][0][::-1], [ks], pieces[1][0]])
c = np.concatenate([pieces[0][1][::-1], [cs], pieces[1][1]])
keep = (k > 0) & (c > 0)
order = np.argsort(k[keep])
return k[keep][order], c[keep][order]
def transition(k0, T=200, rho=RHO, n=N, sigma=SIGMA, A=A, alpha=ALPHA, num=401):
"""Optimal paths (t, k_t, c_t) from k0: consumption jumps onto the saddle path, then both move
together to the steady state (policy function c(k) from the saddle path)."""
ks = k_star(rho, A, alpha)
kp, cp = saddle_path(min(k0, ks) * 0.5, max(k0, ks) * 1.5, rho, n, sigma, A, alpha)
policy = lambda k: np.interp(k, kp, cp)
sol = solve_ivp(lambda t, y: [f(y[0], A, alpha) - n * y[0] - policy(y[0])], (0, T), [k0],
t_eval=np.linspace(0, T, num), rtol=1e-9, atol=1e-11)
k = sol.y[0]
return sol.t, k, policy(k)