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.

Adding a Solver

A regime’s solver field selects the algorithm that computes its value function during backward induction. pylcm ships five solvers (GridSearch, DCEGM, NEGM, OneAssetEGM, TwoDimEGM; see lcm.solvers), and the engine is designed so that new ones can be added without touching the backward-induction loop. This page specifies the contract a solver implements, the lifecycle the engine drives it through, and the invariants that keep the generic layer solver-agnostic.

The single normative source is src/_lcm/solution/contract.py. Everything the backward-induction loop knows about a solver flows through the types defined there; this page explains how they fit together.

The design rule

The backward-induction layer understands generic solver outputs; each solver implementation understands how those outputs are produced.

Concretely:

The contract objects

Solver (abstract base class). The user-facing configuration object, attached as Regime(solver=...). A frozen dataclass carrying the solver’s settings (grid names, batch sizes, thresholds). Two methods and one property matter:

SolverBuildContext. Everything a solver may read at build time, bundled so the method signature stays stable as solvers with different needs are added: the regime’s state-action space, grids, processed functions, constraints, transitions, the per-period Q-and-F closures, interpolation info for every regime’s value function, flat parameter names (own and per-regime), JIT and taste-shock flags, the optional certainty equivalent, and the distributed co-map axes. Each solver reads only the fields it uses.

SolutionKernels. What build_period_kernels hands back: an immutable mapping of period to PeriodKernel, plus an optional continuation_template — an all-finite payload with the regime’s static shapes. The template initializes the rolling continuation mapping and serves as the lowering argument when a parent’s kernel is AOT-compiled, so it must be shaped exactly like every real payload the kernels will publish.

PeriodKernel (protocol). The loop’s uniform call target — one non-jitted adapter per regime-period. Plain closures or small frozen dataclasses satisfy it structurally. It separates three concerns:

with_fixed_params(fixed_flat_params=...) returns a copy with the regime’s fixed params bound into the core(s); the adapter owns its solver’s binding rule so the engine never switches on solver type to bind params.

KernelResult. One regime-period output, assembled outside JIT:

BackwardInductionResult. The loop’s return value: value_functions (period → regime → V array) and simulation_policies (period → regime → published policy, sparse over regimes). Internal — Model.solve unpacks it into the public return shape.

The lifecycle

  1. Build. process_regimes builds a SolverBuildContext per regime and calls solver.validate(context), then solver.build_period_kernels(context) (src/_lcm/regime_building/processing.py). Terminal regimes produce their closed-form continuation payloads only when some regime’s solver reports requires_continuation — the build reads the capability, not the type.

  2. AOT compilation. The loop collects every kernel’s cores(), dedupes them by identity, and lowers each with the kwargs from build_lower_args. Continuation templates stand in for real payloads during lowering.

  3. The solve loop (src/_lcm/solution/backward_induction.py). For each period, for each active regime, the loop calls the period’s adapter and accumulates the result:

    • V_arr always enters period_solution (and the NaN/Inf diagnostics — automatic for every solver, no kernel involvement).

    • continuation, if present, enters period_continuations.

    • simulation_policy, if present, enters period_simulation_policies and is evicted to host memory (policies are solve outputs; no backward step reads them, and leaving them on device would pin one continuation-sized buffer per period).

    After the period, the loop rolls next_regime_to_V_arr and next_regime_to_continuation forward. Both mappings keep their full template key sets and update only the entries solved this period, so the pytree structure the compiled cores were lowered against never changes. Superseded payload buffers are deleted eagerly once rolled.

Invariants a new solver must respect

Where the code goes

The minimal reference: GridSearch

src/_lcm/solution/grid_search.py is the whole contract in ~160 lines: a configuration-free Solver whose build_period_kernels wraps each period’s Q-and-F closure in a jitted max-Q-over-a core (identity-deduped across periods), and a _GridSearchPeriodKernel dataclass implementing the four protocol methods around a single "main" core, returning KernelResult(V_arr=...) with no optional outputs. Read it first; the EGM solvers are the same shape with more machinery inside the cores.

Testing a new solver

Follow the repository’s red-first discipline (see the testing section in AGENTS.md), and cover at minimum:

What deliberately does not exist