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_equationsreturns. 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