🇬🇧 English · 🇪🇸 Español
Local and global equilibrium policies
Use one nonlinear model specification for several calculations, while keeping their economic questions and accuracy checks distinct:
| Calculation | Object being computed | Treatment of the future | Evidence to examine |
|---|---|---|---|
model.solve(order=1/2/3) |
A local Taylor approximation to decision rules | Shock covariance enters the perturbation calculation; realized innovations enter policy evaluation | Determinacy, conditioning, and nonlinear residuals near and away from the expansion point |
solve_perfect_foresight / solve_transition |
A finite trajectory for specified initial conditions and a terminal closure | The announced shock path is known within that experiment | Original path equations, feasibility, information timing, and sensitivity to the terminal closure/horizon |
solve_time_iteration |
An interpolated recursive policy over a declared state domain | A conditional integration rule evaluates future policies at possible successor states | Node convergence, off-grid Euler/KKT residuals, domain coverage, and sensitivity to approximation and integration |
A perfect-foresight path is not a stochastic policy: replacing unknown future shocks by zero generally changes a nonlinear expectation. Likewise, a global policy does not mean a solution on every possible state or a proof of global uniqueness. Here, “global” describes approximation across the chosen domain. See nonlinear model loading and nonlinear transitions for the other two workflows, and the DSGE workflow guide for method selection and teaching.
The global interface does not infer a planner's objective from arbitrary equilibrium equations. It requires explicit states, controls, transitions, conditional probabilities, domains and admissibility. There is no general existence, uniqueness or convergence guarantee.
All Python blocks below run in order in one session, using the public API. The examples are identical in both language editions.
The recursive equilibrium contract
Write the problem in terms of states known when decisions are made, controls, and future innovations:
EquilibriumProblem describes this system. It is not a Bellman objective and
does not infer a welfare-maximizing planner. States must contain enough
information to make the supplied transition and conditional shock rule valid.
An omitted lag, regime, or persistent shock cannot be recovered by increasing
the polynomial degree.
Let ns be the number of states, nx the number of controls, ne the number of
innovation coordinates, and q the number of integration nodes.
| Argument or callback | Required shape and meaning |
|---|---|
state_names, control_names |
Ordered names, of lengths ns and nx; names are unique within each group |
domain |
(ns, 2) finite, increasing bounds; the approximation rectangle |
transition(state, controls, innovation) |
Receives (ns,), (nx,), (ne,); returns a finite (ns,) successor state |
residual(state, controls, next_state, next_controls, innovation) |
Returns a finite (nx,) vector before conditional expectation; one equation per control |
shock_nodes, shock_weights |
(q, ne) future innovations and (q,) nonnegative probabilities summing to one |
shock_rule(state) |
Optional conditional (nodes, weights) pair; overrides the fixed rule and is checked at each evaluation |
control_bounds |
(nx, 2) ordered bounds, or a callback returning them from (ns,); infinite endpoints are allowed |
admissible(state, controls) |
Optional Boolean economic-feasibility check, such as positive consumption |
residual_scale |
(nx,) positive, finite equation scales; defaults to ones |
Callbacks receive one point at a time. Returned policies also support
batches: result(state) returns (nx,), while result(states) for
states.shape == (n, ns) returns (n, nx). A deterministic problem can omit the
shock rule: the default has one innovation of shape (0,) with weight one.
With EquilibriumProblem.from_model, the additional mappings are:
| Mapping | Contract |
|---|---|
current(state, controls) |
Full current endogenous vector in model.variables order; the same mapping at successor inputs supplies the lead vector |
lagged(state) |
Full lagged endogenous vector in model.variables order |
exogenous(state) |
Current innovations in model.shocks order; defaults to zeros |
equations |
One distinct source equation per control, selected by equation name or zero-based integer index |
steady_state |
Optional full endogenous vector or mapping used by source steady_state(...) expressions |
Use model.equation_names to inspect available names. The bridge copies the
model and its calibration. Callback closures remain the caller's responsibility:
changing a parameter referenced by transition or current changes that
callback, even though the source residual calibration was copied. The bridge
does not verify that every unselected source equation is identically satisfied
by the mappings; establish those identities when constructing the problem.
One model, explicit recursive timing
Consider log utility, full depreciation, Cobb–Douglas production, and independent Gaussian log-productivity innovations:
This tractable specialization of stochastic growth has the exact policy \(k_t=\alpha\beta y_t\), with \(c_t=(1-\alpha\beta)y_t\). Substitution into \(1/c_t=\beta E_t[\alpha y_{t+1}/(k_t c_{t+1})]\) verifies the Euler equation: future output cancels, leaving the same current marginal utility. This provides an economic oracle separate from the numerical solver. The stochastic growth framework originates in Brock and Mirman (1972).
The recursive state is (inherited_capital, observed_log_productivity) and the
control is savings, which is also the end-of-current-period capital k in the
.mod file. read_mod compiles the model without requiring a local solution.
from dataclasses import replace
import numpy as np
from puremacro.dsge import (
read_mod, EquilibriumProblem, gaussian_quadrature,
solve_time_iteration, audit_policy, compare_policies,
solve_adaptive_time_iteration, refine_time_iteration,
evaluate_perturbation, diagnose_perturbation,
)
model = read_mod("""
var y c k a; varexo e;
parameters alpha beta;
alpha=0.33; beta=0.95;
model;
[name='resources'] c+k=y;
[name='production'] y=exp(a)*k(-1)^alpha;
[name='Euler'] 1/c=beta*alpha*y(+1)/(k*c(+1));
[name='productivity'] a=e;
end;
steady_state_model;
k=(alpha*beta)^(1/(1-alpha));
y=k^alpha; c=(1-alpha*beta)*y; a=0;
end;
shocks; var e; stderr 0.02; end;
""")
alpha, beta = (model.parameter_values[key] for key in ("alpha", "beta"))
names = tuple(model.variables)
def output(state):
return np.exp(state[1]) * state[0] ** alpha
def current(state, control):
y = output(state)
values = dict(y=y, c=y-control[0], k=control[0], a=state[1])
return np.array([values[name] for name in names])
def lagged(state):
return np.array([state[0] if name == "k" else 0.0 for name in names])
def transition(state, control, innovation):
return np.array([control[0], innovation[0]])
nodes, weights = gaussian_quadrature([[0.02**2]], order=3)
problem = EquilibriumProblem.from_model(
model, state_names=("inherited_capital", "observed_log_productivity"),
control_names=("savings",), domain=((0.1, 0.5), (-0.05, 0.05)),
current=current, lagged=lagged, transition=transition,
equations=("Euler",), exogenous=lambda state: state[1:2],
shock_nodes=nodes, shock_weights=weights,
control_bounds=lambda state: [(1e-6, output(state)-1e-6)],
admissible=lambda state, control: 0 < control[0] < output(state),
)
initial = lambda state: np.array([0.26 * output(state)])
result = solve_time_iteration(problem, initial, orders=(7, 2), tol=1e-8)
assert result.converged
print(result.summary())
grid_values = result.to_frame()
assert len(grid_values) == (7 + 1) * (2 + 1)
The state contains inherited capital and observed current productivity.
The transition consumes a future innovation. current constructs current
and next endogenous vectors and imposes the resource/production identities;
lagged supplies the historical capital needed by the model. Only the remaining
Euler equation is selected. Conditional expectation is applied to the selected
residual after evaluating the future policy. Future marginal utility must stay
inside this expectation; multiplying the equation by random future consumption
would change the stochastic model.
Multiplication by a factor known at time t preserves an equality conditional
on that information when the factor is nonzero. Multiplication by a random
future variable does not generally do so. Keep expressions such as
return_next / consumption_next together inside the residual being integrated.
For a growth model with endogenous labor, a useful alternative is
controls=(consumption, labor): impose production and resource accounting in
current, let transition carry the resulting savings, and select the
intratemporal and Euler equations. The MAV T02_A/T02_B notebooks demonstrate
that construction. A different choice of controls requires a corresponding
change in the residuals, bounds, and transition; adding controls without
additional independent equations does not define a solvable system.
What time iteration solves
Each iteration holds the previous interpolated future policy fixed. At each state node it solves the conditional equilibrium equations for current controls using bounded nonlinear least squares. It then optionally dampens the nodal update, interpolates the new controls, and recomputes the equations using the updated policy. This is an Euler-equation policy iteration, not Howard improvement for a supplied Bellman value function. Projection methods represent functions in a finite basis and assess residual errors; see Judd (1992) for the broader numerical framework.
| Option | Meaning |
|---|---|
tol=1e-7 |
Maximum accepted scaled KKT/equation residual and absolute nodal control update |
max_iter=200 |
Outer policy-update budget |
inner_max_iter=100 |
Per-node nonlinear function-evaluation budget |
damping=1.0 |
Fraction of the proposed nodal update, in (0, 1] |
max_nodes=10000 |
State-grid construction guard; not an accuracy target |
strict=False |
Keep a failed result with a warning; True raises on numerical nonconvergence |
inner_max_iter bounds the damped Newton iterations per collocation node. The
reported evaluation count tracks residual evaluations; numerical-Jacobian probes
can add residual calls when transition_jacobian or residual_jacobian
callbacks are omitted (with EquilibriumProblem.from_model, compiled equation
derivatives are reused automatically via TrajectoryJacobian).
result.callback_evaluations separately tracks "transition_evaluations",
"residual_evaluations", "transition_jacobian_evaluations",
"residual_jacobian_evaluations", and "trial_rejections". Neither the
iteration budget nor max_nodes is a wall-clock or total-memory guarantee.
The initial policy can be a callable, one constant (nx,) vector, or a nodal
array matching the constructed grid. It must be finite and satisfy control
bounds and admissible at the nodes. The inner solve enforces box bounds and
uses domain-safe feasible backtracking (trial_rejections in history and
callback_evaluations) if a full nodal or outer policy update would violate
bounds or admissible at collocation or quadrature successor states;
admissible is a feasibility check, not a general nonlinear-constraint
optimizer. Tensor Chebyshev fitting uses separable 1D LU factorizations along
each state axis rather than assembling a dense N-dimensional Vandermonde
inverse (policy.collocation_matrix_allocated is False). Keep the residual
finite on relevant trial controls through the economic parameterization and
bounds.
result.converged requires the updated node audit, a small control update, and
no failed node solve. A small least-squares gradient or a small update alone is
insufficient. The history exposes policy_change, max_kkt_residual,
inner_failures, evaluation counts, and domain-exit mass. These are numerical
conditions on this discretization; inspect the economic accuracy separately.
Check points that were not used to solve
rng = np.random.default_rng(824)
points = rng.uniform([0.12, -0.04], [0.47, 0.04], size=(64, 2))
exact = lambda state: np.array([alpha * beta * output(state)])
comparison = compare_policies(
problem, {"time iteration": result, "analytic": exact}, points, tol=1e-3,
)
print(comparison)
audit = audit_policy(problem, result, points, tol=1e-3)
assert audit.passed
print(audit.diagnostics)
reference = np.array([exact(state) for state in points])
policy_error = float(np.max(np.abs(result(points) - reference)))
assert policy_error < 3e-5
assert audit_policy(problem, exact, points, tol=1e-12).passed
def direct_euler(state, policy):
savings = policy(state)[0]
consumption = output(state) - savings
expected_return = 0.0
for innovation, weight in zip(nodes, weights):
next_state = np.array([savings, innovation[0]])
next_output = output(next_state)
next_consumption = next_output - policy(next_state)[0]
expected_return += weight * alpha * next_output / (savings * next_consumption)
return 1 / consumption - beta * expected_return
np.testing.assert_allclose(
[direct_euler(state, result) for state in points],
audit.residuals["Euler"].to_numpy(), atol=1e-12,
)
print("maximum savings error against exact policy:", policy_error)
audit_policy evaluates the supplied policy; it never solves for a replacement.
The random points above are independent of the collocation nodes, and the
direct Euler calculation independently checks the .mod bridge. Random points
are useful coverage, but a finite sample is not a supremum-error bound. Add
points near economically relevant boundaries and constraint switches.
| Audit field | Interpretation |
|---|---|
residuals |
Conditional expected residuals in original equation units |
kkt_residuals |
Residuals divided by residual_scale, with natural-map residuals replacing declared complementarity rows |
diagnostics.feasible, future_feasible |
Current and quadrature-successor controls satisfy their bounds and economic predicate |
state_inside, next_state_outside_mass |
Domain membership and summed integration weights of successor states outside the declared rectangle |
failure |
Numerical/domain failure recorded at that audit point |
passed |
Every supplied point passes the requested tolerance, feasibility, and default domain checks |
Failed evaluations can leave infinite sentinels in residuals or domain-exit
mass. Inspect failure before treating an exit-mass entry as a probability.
max_residual and max_kkt_residual summarize the first two tables. A binding
constraint can have a nonzero raw residual and a valid KKT residual. Across
models, residuals in consumption, marginal utility, or resource units cannot
be compared as if they were the same error. residual_scale is explicit, and
the package does not label a generic residual as a consumption-equivalent or
welfare error. compare_policies applies the same problem and points to every
method and uses the strict domain audit by default.
Integrate the conditional equation
For each current state, the solver computes
\(\sum_{j=1}^{q}w_j F(s,x,g(s,x,\varepsilon_j),\pi(g(s,x,\varepsilon_j)),\varepsilon_j)\).
It does not plug mean shocks or mean future controls into a nonlinear equation.
gaussian_quadrature(covariance, order=3) supplies a zero-mean Gauss–Hermite
rule with covariance already incorporated in its nodes. Do not multiply those
nodes by the innovation standard deviation a second time.
Correlated and singular positive-semidefinite covariance matrices are supported.
Integration is performed in the covariance's positive-eigenvalue subspace;
the work grows as order ** rank, and max_nodes can reject an oversized rule.
A shock_rule(state) can instead supply state-dependent innovation nodes and
weights, including conditional Markov transition probabilities. Those weights
must be conditional on the current state, not unconditional regime frequencies.
Invalid probabilities are rejected, never silently normalized.
Gaussian integration is a finite quadrature rule, not a claim that the shock distribution has bounded support. Even zero reported exit mass concerns only the supplied nodes. Increase quadrature order and examine the economic tails separately.
The following checks Gaussian moments, then solves on a wider productivity domain so a seven-node rule can be audited without extrapolation. Both audits evaluate the same final policy with different integration rules.
covariance = np.array([[0.0004, 0.00015], [0.00015, 0.0009]])
joint_nodes, joint_weights = gaussian_quadrature(covariance, order=3)
np.testing.assert_allclose(joint_weights @ joint_nodes, 0, atol=1e-15)
np.testing.assert_allclose(
joint_nodes.T @ (joint_nodes * joint_weights[:, None]), covariance, atol=1e-14,
)
fine_nodes, fine_weights = gaussian_quadrature([[0.02**2]], order=7)
wide_problem = replace(problem, domain=((0.1, 0.5), (-0.09, 0.09)),
shock_nodes=fine_nodes, shock_weights=fine_weights)
warm_start = replace(result.policy, extrapolation="allow")
wide_result = solve_time_iteration(wide_problem, warm_start, orders=(11, 4), tol=1e-8)
assert wide_result.converged
same_domain_rule3 = replace(wide_problem, shock_nodes=nodes, shock_weights=weights)
audit3 = audit_policy(same_domain_rule3, wide_result, points, tol=1e-4)
audit7 = audit_policy(wide_problem, wide_result, points, tol=1e-4)
assert audit3.passed and audit7.passed
print("three-node / seven-node residuals:", audit3.max_residual, audit7.max_residual)
Explicit extrapolation is used only to form the wider-domain initial guess;
wide_result itself uses default strict domain convergence and evaluation.
The Brock–Mirman exact Euler equation cancels future output, making this
benchmark unusually insensitive to the integration rule. It checks the wiring
and interpolation, not the adequacy of three nodes for other nonlinear models.
Choose the basis and state domain
basis |
Interpretation of orders |
Useful properties and limitations |
|---|---|---|
"chebyshev" |
Scalar degree in every state, or one degree per state | Tensor Chebyshev–Lobatto nodes; (degree + 1) nodes per dimension. Smooth approximation; node count is the product across dimensions |
"smolyak" |
A single positive sparse-grid level | Uses the existing Smolyak polynomial basis; reduces tensor-grid growth but still has construction, interpolation, and node-solve costs |
"linear" |
One degree per state, defining the same tensor node counts | Piecewise multilinear interpolation on those nodes; useful around kinks, without polynomial oscillations between feasible nodal values for constant box bounds |
Smolyak's level is not a per-dimension polynomial degree, so changing basis
while keeping the same integer orders does not hold cost or accuracy fixed.
The current wrapper guards sparse construction at 12 state dimensions and
level 8, in addition to max_nodes; these are work limits, not a claim of
economic accuracy at those sizes. Existing VFI/EGM and interpolation tools
remain useful when their objective and model assumptions match the problem;
this interface is specifically for explicitly supplied equilibrium residuals.
Domain selection and anisotropic approximation matter as well as the total number of nodes; Judd, Maliar, Maliar and Valero (2014) study these choices for dynamic models. The implementation here uses the existing sparse basis and a separate Chebyshev refinement heuristic; it does not claim to reproduce every algorithm in that paper.
Choose a domain that includes relevant states and inspect transitions from
both its interior and edges. A wider rectangle with unchanged degree can
reduce resolution where the policy is most curved. Higher degree can still
produce infeasible controls between nodes. Evaluate bounds and admissible
off-grid; plotting a smooth line is not a feasibility certificate.
Two options govern different decisions:
extrapolation="raise"controls evaluation of the returned policy and is the default."allow"explicitly extends its polynomial or linear interpolant; it does not clip a state to the nearest boundary.require_domain=Truecontrols node-convergence acceptance. Setting it toFalseallows convergence conditional on that extension, while retaining domain-exit diagnostics. It does not disable economic feasibility checks.
Trial controls internally use polynomial/linear extension so a nonlinear trial is not silently changed by clipping. Default final acceptance still requires domain closure under the supplied rule. For a Gaussian process, closure under finite quadrature is not closure under the complete shock distribution.
Refine the approximation within a work budget
adaptive = solve_adaptive_time_iteration(
problem, initial, points, orders=(3, 2), tol=1e-4, max_solves=6,
)
print(adaptive.history)
assert adaptive.accepted
ladder = refine_time_iteration(
problem, initial,
levels=[{"orders": (7, 2)}, {"orders": (11, 4)}],
points=points, tol=1e-4, max_solves=2,
)
print(ladder.history)
limited = refine_time_iteration(
problem, initial, levels=[{"orders": (7, 2)}],
points=points, tol=1e-4, max_solves=1,
)
assert not limited.accepted
Adaptive refinement increases the Chebyshev degree in the state direction with
the largest high-degree coefficient tail. The tail is a heuristic, not an error
bound. Acceptance also requires independent Euler/KKT checks and agreement
between adjacent policies on the same supplied points. Exhausting max_solves
does not create a pass.
The explicit refinement ladder can additionally change domain, shock_nodes
and shock_weights. This permits separate domain and integration checks, with
the same audit points. basis="smolyak" uses the existing sparse-grid basis,
with scalar orders interpreted as its level; the automatic anisotropic driver
currently uses Chebyshev only. basis="linear" uses piecewise-linear
interpolation on the tensor nodes and is useful near constraint kinks. All
methods still need independent off-grid checks and adequate state coverage.
PolicyRefinementResult.accepted is stronger than its final inner
result.converged: the final off-grid audit must pass, the inner solve must
converge, and the largest absolute control change from the previous level at
the same points must be at most the refinement tolerance. The first level
therefore cannot establish acceptance. max_solves truncates the ladder;
max_nodes guards construction and raises if a requested grid is oversized.
Neither setting is an automatic certificate at the budget boundary.
For interpretation, vary one numerical choice at a time: grid resolution, then quadrature on a fixed policy/domain, then domain size with comparable resolution. A ladder may change all three, but a changed answer then has several possible causes. Store the history, final policy, and audit points together. Fixed evaluation points facilitate reproducibility; a fresh held-out set is useful after selecting the refinement level.
Compare local approximations using the same expectation
local_policies, local_solutions = {}, {}
for order in (1, 2, 3):
solution = model.solve(order=order)
local_solutions[order] = solution
rules = solution.decision_rules()
assert tuple(rules.state_variables) == ("k",)
assert tuple(rules.shock_names) == ("e",)
capital_index = list(rules.variable_names).index("k")
def local_policy(state, solution=solution, capital_index=capital_index):
values = evaluate_perturbation(solution, [state[0]], [state[1]])
return values[[capital_index]]
local_policies[f"order {order}"] = local_policy
local_policies["global"] = adaptive.result
local_comparison = compare_policies(problem, local_policies, points, tol=1e-4)
print(local_comparison)
local_diagnostics = diagnose_perturbation(local_solutions[3])
print(local_diagnostics.summary())
local_seeded = solve_time_iteration(problem, local_policies["order 2"], orders=(7, 2), tol=1e-8)
assert local_seeded.converged
np.testing.assert_allclose(local_seeded(points), result(points), atol=1e-7)
batch = evaluate_perturbation(local_solutions[3], points[:, :1], points[:, 1:2])
assert batch.shape == (len(points), len(model.variables))
The global state includes current observed productivity, whereas the local decision rule's predetermined state in this i.i.d. benchmark is only inherited capital. The code explicitly supplies current productivity as the current innovation. In an AR(1) model, supply the lagged productivity and current innovation consistently with its law of motion; do not confuse observed current productivity with its lag.
All policies in the comparison use the same exact static accounting identities and integration rule. They are compared over the declared points, not over different simulation draws or different shock variances. An increased local order may improve a neighborhood and still fail farther away or violate a bound. Do not require a preferred ordering of errors before observing them.
The second-order rule also provides an alternative feasible initialization of time iteration. Agreement from two initializations is useful numerical evidence, not a proof of equilibrium uniqueness. In other models or wider domains, a Taylor initial policy may violate bounds and must be reconsidered.
Levels, coefficient order, and risk scaling
evaluate_perturbation evaluates coefficients already present in the solution.
Its optional order can truncate the available approximation; it cannot create
third-order coefficients from a first-order result. The inputs and output are:
| Quantity | Convention |
|---|---|
lagged_states |
Previous-period state levels in decision_rules().state_variables order; one vector or a row batch |
shocks |
Current realized innovations in shock_names order; omitted means zero, and one vector broadcasts across rows |
deviations=True |
Changes only the state input to deviations; output remains endogenous levels |
| Returned array | Current levels in variable_names order; (n_variables,) or (n_points, n_variables) |
sigma |
Nonnegative multiplier for anticipated risk terms; it does not rescale the supplied innovations |
For example, hold the realized innovation fixed when isolating the retained risk terms:
third_order = local_solutions[3]
ss = third_order.decision_rules().ys
lagged_capital = np.array([float(ss["k"])])
realized_innovation = np.array([0.01])
with_risk = evaluate_perturbation(third_order, lagged_capital, realized_innovation, sigma=1)
without_risk = evaluate_perturbation(third_order, lagged_capital, realized_innovation, sigma=0)
print("retained risk-term contribution:", with_risk - without_risk)
In coefficient notation, the evaluator includes the second-order constant
ghs2 * sigma**2 / 2 and the third-order mixed terms
(ghxss @ state_deviation + ghuss @ innovation) * sigma**2 / 2, together with
the ordinary linear, quadratic, and cubic terms and their Taylor factorials.
The covariance used to solve those coefficients is already embedded in their
construction. sigma=0 is an evaluation of the retained expansion with those
risk terms removed, not a fresh solution of a recalibrated stochastic model.
Relative covariance changes require re-solving the approximation.
This log/full-depreciation benchmark has no precautionary savings response;
small or zero risk contributions here should not be generalized to RBC models.
Orders two and three use direct tensor contractions without building Kronecker
vectors. These are raw Taylor policy evaluations for initialization and residual
checks. A pruned simulation retains separate order components of its state
history and is a different calculation. diagnose_perturbation reports the
selected transition roots near the unit circle and original dynamic Jacobian
rank/conditioning when retained. It does not replace a Blanchard–Kahn
classification or certify nonlinear uniqueness. The original Jacobian rank
uses the concatenated lag/current/lead blocks when retained, and its condition
number depends on equation and variable units. Missing Jacobians are reported
as not checked. A near-unit transition root is evidence of persistence and
sensitivity, not by itself a proof of an invalid equilibrium.
Higher-order Sylvester conditioning and exact factorization reuse. The
recursive Real Schur (Bartels–Stewart) Sylvester solver keys its LU cache by
exact IEEE-754 floating-point values (float for \(1\times 1\) blocks and four
floats for \(2\times 2\) blocks), eliminating the 12-decimal rounding collision
reported in 5.7.1 where distinct near-unit Schur products could reuse a
factorization. diagnose_perturbation(solution, max_curvature_order=3) reports
schur_block_condition (the fast diagonal Schur-block lower bound on the
higher-order operator condition number), curvature_operator_condition and
curvature_coupling_amplification (the full Kronecker operator condition number
and off-diagonal Schur coupling ratio, summarized in curvature_conditioning),
sylvester_residuals (ghxx, ghs2, ghxxx, ghxss, ghuss), and
sylvester_work (SylvesterWorkCounts) so higher-order operator
ill-conditioning is distinguished from the first-order dynamic Jacobian
condition number.
Pruning avoids recursively generating terms beyond the chosen approximation
order and carries separate state components. The distinction between a raw
Taylor rule and a pruned state-space recursion is developed by
Andreasen, Fernández-Villaverde and Rubio-Ramírez.
An audit of the raw rule is therefore not an audit of an arbitrary pruned
simulation history. Use the solution's pruned simulation methods for those
paths; do not reconstruct pruning by repeatedly feeding total simulated states
into evaluate_perturbation.
Complementarity and a binding-bound example
Bounds alone restrict admissible controls; they do not turn equations into
inequalities. With complementarity={equation_index: control_index}, let x
denote that control and F its conditional expected residual. The sign
convention is:
The reported natural-map residual is
\(x-\operatorname{clip}(x-F/d,\ell,u)\), where d is the positive equation
scale. Its zero set expresses those KKT conditions. This projection is part of
the complementarity equation; it does not clip the returned policy or the
state transition. Feasibility is checked separately. The mapping is explicit
because the economic control need not have the same index as its equation.
Here a nonnegative adjustment x minimizes (x-s)**2 / 2. Future profitability
is 0.5*s and is unaffected by the decision, so the exact policy is
max(s, 0). This small constrained economic problem isolates the sign and
binding-bound contract without a hidden reference solve.
constrained_problem = EquilibriumProblem(
state_names=("profitability",), control_names=("adjustment",),
domain=((-1, 1),), transition=lambda s, x, e: 0.5 * s,
residual=lambda s, x, sp, xp, e: x - s,
control_bounds=[(0, 2)], complementarity={0: 0},
)
constrained = solve_time_iteration(
constrained_problem, [0.5], basis="linear", orders=2, tol=1e-7,
)
constraint_points = np.linspace(-0.98, 0.98, 41)[:, None]
constraint_audit = audit_policy(constrained_problem, constrained, constraint_points, tol=2e-6)
assert constrained.converged and constraint_audit.passed
np.testing.assert_allclose(
constrained(constraint_points)[:, 0], np.maximum(constraint_points[:, 0], 0), atol=2e-6,
)
assert constraint_audit.max_residual > 0.9
assert constraint_audit.max_kkt_residual < 2e-6
The nonzero raw residual at negative profitability is the correct lower-bound inequality. Forcing it to zero would reject the economically valid corner. The kink is represented exactly by the linear basis in this special example; arbitrary nonlinear constraint boundaries still require refinement and off-grid checks. General equilibrium KKT conditions are necessary conditions supplied by the modeler, not a guarantee of a unique global welfare optimum.
Export results and plot an explicit policy slice
Policy audits, time-iteration results, refinement results and local diagnostics
expose to_frame() / to_dataframe(), to_markdown(), to_latex() and
to_typst() through the package's shared table formatters. Refinement tables
contain their solve history; local diagnostic tables contain transition roots.
print(audit.to_markdown(index=False))
slice_points = np.column_stack((np.linspace(0.12, 0.47, 40), np.zeros(40)))
figure = result.plot(slice_points, state="inherited_capital", controls=["savings"], show=False)
axis = figure.axes[0]
assert adaptive.plot(slice_points, state=0, ax=axis) is axis
latex_table = local_diagnostics.to_latex(index=False)
typst_table = adaptive.to_typst(index=False)
import matplotlib.pyplot as plt
plt.close(figure)
Plots require full state coordinates. Here productivity is explicitly held at
zero. state selects the horizontal coordinate and controls selects plotted
controls; point order is preserved. Passing ax returns that axis; otherwise
the method returns a new figure. Matplotlib is loaded only when plotting, and
no display is requested unless show=True. Refinement results delegate to the
final policy with the same explicit points.
result.to_frame() contains state nodes and nodal controls;
audit.to_frame() contains per-point diagnostics. Detailed residual tables
remain available separately. Exporters return strings and do not write files
or publish output.
Domain exits, failures, and limits
Default convergence requires node residuals, policy-change tolerance, feasibility
and closure under the supplied integration rule. Policies raise when evaluated
outside their domain. For a deliberately truncated persistent-shock domain,
extrapolation="allow", require_domain=False permits a solve conditional on
the interpolation extension, retaining its exit-mass diagnostics. This is
not validation of the omitted region. In particular, the MAV AR(1) example's
endpoint quadrature exits remain visible even when a strict interior audit
passes. No shocks or policies are silently clipped.
For an AR(1), even a stationary process can exit a finite domain. Increasing
the persistence or quadrature order can increase reported edge exits without
indicating an algebraic error. Expand the domain, examine a justified interior
region, or explicitly accept conditional extrapolation and report that choice.
Simply clipping shocks changes the integration law and can manufacture a
misleading residual pass. The default audit_policy remains strict even if
the solver was run with require_domain=False; conditional auditing requires
its own explicit require_domain=False.
On numerical failure or an iteration limit, the result remains
converged=False and a warning is emitted; strict=True raises instead.
The last iterate and iteration history remain available in the ordinary
non-strict result. Invalid shapes, probabilities, calibration mappings, and
oversized grids are input errors and may raise immediately instead.
When a solve fails, use the history to distinguish a node-solve failure from stagnation, domain exits, and infeasible future controls. Verify timing and equation signs first; then examine scales, a feasible initialization, damping, and a small well-resolved domain. More iterations do not repair a missing state, wrong conditional expectation, or an incompatible constraint mapping.
The method is intended for manageable continuous-state equilibria. Multiple equilibria, discontinuous regimes, highly nonlinear constraints, unbounded tails, and large DSGE/HANK state spaces need model-specific treatment. A finite set of residual and refinement checks supplies evidence about the reported approximation; it does not establish existence, uniqueness, transversality, or accuracy everywhere.