Skip to content

Theory · 01

Lowering pipeline

Symbolic → IR tape (no toolchain, no on-disk cache)

When you first integrate or iterate a system, its one symbolic method is lowered to an instruction-tape the Rust engine evaluates directly:

_equations(y, t, **params)          your one symbolic method
        │  evaluated with SymEngine symbols
        ▼
SymEngine expression list           dim expressions, params as symbols
        │  tsdynamics._engine.compile.lower_ode (SSA lowering)
        ▼
IR tape                             a flat list of SSA instructions (+ the
        │                           analytic Jacobian when a stiff method needs it)
        ▼
the engine                          Cranelift-JITed (jit, default) or interpreted (interp)

There is no C/LLVM compilation step and no on-disk cache — lowering happens in-process and is effectively instantaneous, and the JIT compile that follows it is memoised per system (median well under a millisecond for a typical flow), so cold start is flat (sub-second) and editing a system's _equations simply takes effect on the next run. The maps path (_step) and the DDE / SDE paths lower the same way (lower_map / lower_dde / lower_sde).

Structural vs. control parameters

Ordinary parameters are not baked into the tape. The engine reads their values live from the system on every run, so a parameter sweep re-lowers nothing:

lor = ts.systems.Lorenz()
for rho in np.linspace(0, 50, 200):
    lor.with_params(rho=rho).run(final_time=50.0)   # one tape, 200 runs

Two kinds of parameter do change the tape's shape and therefore re-lower:

  • Structural parameters — integer loop bounds like Lorenz-96's N, which determine how many expressions _equations returns. Declare them in _structural_params:
class Lorenz96(ContinuousSystem):
    params = {"f": 8.0, "N": 20}
    _structural_params = frozenset({"N"})
  • DDE delay values — a delay shapes the engine's history buffer, so it is baked into the tape rather than read live. This is why DDE parameter sweeps re-lower per value.

Jacobians for free

Because the right-hand side exists symbolically, the Jacobian does too — no hand-derivation, no finite differences:

lor = ts.systems.Lorenz()
lor.rhs([1.0, 1.0, 1.0])        # ndarray (3,), the field itself: du/dt
lor.jacobian_sym()              # dim × dim SymEngine expressions, ∂f_i/∂y_j
lor.jacobian([1.0, 1.0, 1.0])   # ndarray (3, 3), evaluated at a state

jacobian_sym differentiates _equations with SymEngine; jacobian evaluates a cached numeric form. The same symbolic differentiation lowers the Jacobian into the same tape for the implicit (stiff) solver kernels and for the extended variational system of Lyapunov runs (abs/sign derivatives resolved a.e.). Hand-written _jacobian methods on ODE system classes are never used at runtime — the autogenerated form is the single source of truth, and the test suite cross-checks any hand-written ones against it.

rhs(u, t=0.0) is the function jacobian is the derivative of — the vector field, evaluated with the system's current parameters. It is what the vector_field / flow_speed / streamlines plots draw, so it is also how you check one of those pictures against the equations. (The internal _rhs_numeric() returns a pre-bound callable instead of a value, which is what the Poincaré crossing refinement and the reference-backend cross-validation want in a tight loop.)

See also

  • Backends — how the lowered tape is evaluated (interp / jit / reference)
  • Conventions — what else is keyed on parameter hashes