Source code for lyapax.simulator.step

"""dfun codegen + the ring-buffer step(carry, _) factory.

Adapted from ``vbi/simulator/backend/jax_/codegen.py`` and
``vbi/simulator/backend/jax_/simulator.py`` - see NOTICE.md in this directory
for provenance and what was dropped (stochastic noise, state clipping,
sigmoidal/kuramoto coupling, monitors/stimuli).

This is the piece that unifies ODE and DDE: ``has_delays=False`` reads
coupling from the instantaneous state; ``has_delays=True`` reads it from a
ring buffer of past coupling-variable states via a flat-index gather. Both
paths produce the same ``step(carry, _) -> (new_carry, new_state)`` shape
consumed by ``jax.lax.scan``, and - critically for the Lyapunov engines - both
are plain, differentiable JAX functions of ``carry``.
"""
from __future__ import annotations

from typing import Callable

import jax.numpy as jnp

from ..integrators import rk6_combine
from .model_spec import ModelSpec


[docs] def build_jax_dfun(spec: ModelSpec) -> Callable: """ Compile dfun_str expressions into a JAX-traceable function. Generated signature: fn(state, coupling, params) -> jnp.ndarray shape (n_sv, n_nodes) where: state : (n_sv, n_nodes) coupling : (n_cvar, n_nodes) - one row per coupling variable params : dict[str, scalar | jnp.ndarray] Compiled once via exec() on the spec's own strings, same as the vbi original - nothing about the generated function blocks jax.jacfwd / jax.jvp. .. warning:: The ``dfun_str`` expressions are code-generated and executed via ``exec()`` with **no sanitization**. This is fine for specs a caller writes themselves, but never build a ``ModelSpec`` from untrusted input (user uploads, network data, config files from unknown sources) - the strings can execute arbitrary code. """ sv = spec.sv_names param_names = spec.param_names cvar_names = spec.cvar lines = [ "import jax.numpy as _jnp", "from jax.numpy import pi, exp, log, sin, cos, tanh, sqrt", "def _dfun_jax(state, coupling, params):", ] for i, name in enumerate(sv): lines.append(f" {name} = state[{i}]") for name in param_names: lines.append(f" {name} = params['{name}']") for i, cname in enumerate(cvar_names): lines.append(f" c_{cname} = coupling[{i}]") lines.append(" c = coupling[0] if coupling.shape[0] > 0 else 0.0") sv_exprs = ", ".join(f"({spec.dfun_str[name]})" for name in sv) lines.append(f" return _jnp.stack([{sv_exprs}])") src = "\n".join(lines) globs: dict = {} exec(compile(src, "<dfun_jax>", "exec"), globs) return globs["_dfun_jax"]
# --------------------------------------------------------------------------- # Coupling primitives # --------------------------------------------------------------------------- def _instant_coupling(cvar_state: jnp.ndarray, weights: jnp.ndarray, G: float, a: float, b: float) -> jnp.ndarray: """No-delay path. cvar_state: (n_cvar, N) -> coupling: (n_cvar, N).""" return G * a * jnp.einsum("ts,cs->ct", weights, cvar_state) + b def _write_ring(buf: jnp.ndarray, step: int, cvar_state: jnp.ndarray, horizon: int) -> jnp.ndarray: """buf: (horizon, n_cvar, n_nodes) -> updated buf. Time convention: slot ``k % horizon`` holds the coupling-variable value at physical time ``k * dt``. ``step()``'s caller must pass the physical time index of ``cvar_state`` itself as ``step`` -- e.g. the newly integrated state advances the carry's own step counter ``t`` to ``t + 1``, so it is written at ``step=t + 1``, not ``t``. Writing under the wrong index by one silently shifts every delayed read by one step (previously the case here -- fixed).""" return buf.at[step % horizon].set(cvar_state) def _read_delayed_coupling( buf: jnp.ndarray, step: int, delay_steps: jnp.ndarray, weights: jnp.ndarray, G: float, a: float, b: float, horizon: int, n_nodes: int) -> jnp.ndarray: """ Returns coupling (n_cvar, n_nodes), reading each source node's delayed coupling-variable state out of the ring buffer via a flat-index gather (avoids a Python loop / dynamic 2-D gather inside traced code). Per-edge delay (delay_steps is a full (N, N) matrix): the coupling formula (G*a*sum+b) is baked in here rather than exposed as a separate "gather" step, because different edges read different time offsets -- there's no single (n_cvar, n_nodes) "the delayed cvar state" independent of which edge is asking, so a plain coupling_fn(cvar_state, weights, params) signature (one reading per node) can't express this in general. Left untouched for M5 (per-edge delay + coupling_fn needs an edge-aware signature, a separate design fork) -- see _read_uniform_delayed_cvar below for the M4 case where this collapses back to one reading per node. """ idx_time = (step - delay_steps) % horizon # (N, N) src_idx = jnp.arange(n_nodes, dtype=jnp.int32) flat_idx = idx_time * n_nodes + src_idx[None, :] # (N, N) n_cvar = buf.shape[1] cvars = [] for cv in range(n_cvar): buf_cv = buf[:, cv, :] # (horizon, N) delayed = buf_cv.reshape(-1)[flat_idx] # (N, N) cvars.append(G * a * jnp.sum(weights * delayed, axis=1) + b) return jnp.stack(cvars) # (n_cvar, N) def _read_uniform_delayed_cvar( buf: jnp.ndarray, step: int, tau_steps: int, horizon: int) -> jnp.ndarray: """M4: a single global delay (not per-edge), so the delayed reading is the same for every node -- one O(1) modular ring-buffer read, no flat-index gather needed. Returns cvar_state (n_cvar, n_nodes), the same shape lyapax.coupling's plain-callable coupling_fn expects for the zero-delay case, so any existing coupling_fn works unmodified against either instantaneous or uniformly-delayed cvar_state. ``step`` is the physical time index of *this* read (i.e. the caller's current time in units of dt) -- see ``_write_ring``'s docstring for the matching write convention this read relies on.""" return buf[(step - tau_steps) % horizon] def _write_ring_interp( buf: jnp.ndarray, step: int, cvar_value: jnp.ndarray, cvar_deriv: jnp.ndarray, horizon: int) -> jnp.ndarray: """Interpolated-history counterpart of ``_write_ring``: ``buf`` gains a length-2 axis (value, derivative) so later reads can reconstruct a delayed value *between* grid points via Hermite interpolation instead of only ever reading an exact stored sample -- see ``_read_uniform_delayed_cvar_interp``. ``buf``: ``(horizon, 2, n_cvar, n_nodes) -> updated buf``. Same ``step`` time convention as ``_write_ring``.""" idx = step % horizon buf = buf.at[idx, 0].set(cvar_value) return buf.at[idx, 1].set(cvar_deriv) def _read_uniform_delayed_cvar_interp( buf: jnp.ndarray, step, tau_steps: float, horizon: int, dt: float) -> jnp.ndarray: """Cubic Hermite-interpolated counterpart of ``_read_uniform_delayed_cvar``: reconstructs the delayed coupling-variable value at the exact (possibly non-integer) time ``step*dt - tau_steps*dt``, from the stored value *and derivative* at the two grid points bracketing it, rather than snapping ``tau_steps`` to the nearest integer. ``tau_steps`` is a plain (generally non-integer) float here. ``buf``: ``(horizon, 2, n_cvar, n_nodes)``, axis 1 = (value, derivative), as written by ``_write_ring_interp``. """ t_delayed = step - tau_steps i0 = jnp.floor(t_delayed).astype(jnp.int32) theta = t_delayed - i0 idx0 = i0 % horizon idx1 = (i0 + 1) % horizon y0, dy0 = buf[idx0, 0], buf[idx0, 1] y1, dy1 = buf[idx1, 0], buf[idx1, 1] # Standard cubic Hermite basis on theta in [0, 1); dy0/dy1 are d(y)/dt, # so they're scaled by dt (the spacing between grid points i0 and i1). h00 = 2 * theta ** 3 - 3 * theta ** 2 + 1 h10 = theta ** 3 - 2 * theta ** 2 + theta h01 = -2 * theta ** 3 + 3 * theta ** 2 h11 = theta ** 3 - theta ** 2 return h00 * y0 + h10 * dt * dy0 + h01 * y1 + h11 * dt * dy1 # --------------------------------------------------------------------------- # Integrators (pure JAX, traceable) # --------------------------------------------------------------------------- # # Each takes ``coupling_at(y_stage, c) -> coupling`` -- a callable, not a # precomputed array -- and calls it fresh at every stage, passing that # stage's own intra-step state estimate ``y_stage`` and Butcher node ``c`` # (0 at the first stage, 1 at the last), instead of freezing coupling at # the value read once at the step's start. Freezing it, the earlier # design, silently capped every one of these methods at O(dt) whenever # coupling was actually nonzero, regardless of the base method's nominal # order (rk4 and rk6 gave *identical* errors on a coupled network). # ``make_step_fn``'s ``coupling_at`` # exploits this in two places: for the zero-delay case, coupling depends # on ``y_stage`` (the evolving state), evaluated fresh at each stage; for # ``has_delays=True`` with ``interpolate=True``, the delayed lookup # depends on *time*, so it is re-read via the Hermite interpolant at each # stage's own intra-step time ``step + c``. (An earlier attempt at that # per-stage delayed read appeared not to help -- but it was made while # the ring-buffer write index was off by one full step, itself an O(dt) # bias that masked the improvement; with the write convention fixed, the # per-stage read restores the integrator's real order, capped at ~4 by # the cubic Hermite history.) # The grid-snapped delayed path (``interpolate=False``) still ignores # ``c``: without an interpolant there is no sub-step history to read, so # it stays O(dt) by construction. A caller that has no coupling at all # (``coupling_at`` returns a constant, or is never dependent on ``y``/``c``) # pays a few extra, cheap function calls for no behavior change. _EULER_STAGE_C = (0.0,) _HEUN_STAGE_C = (0.0, 1.0) _RK4_STAGE_C = (0.0, 0.5, 0.5, 1.0) def _euler(state, dfun, coupling_at, dt, params): c = _EULER_STAGE_C k1 = dfun(state, coupling_at(state, c[0]), params) return state + dt * k1 def _heun(state, dfun, coupling_at, dt, params): c = _HEUN_STAGE_C k1 = dfun(state, coupling_at(state, c[0]), params) y2 = state + dt * k1 k2 = dfun(y2, coupling_at(y2, c[1]), params) return state + 0.5 * dt * (k1 + k2) def _rk4(state, dfun, coupling_at, dt, params): c = _RK4_STAGE_C k1 = dfun(state, coupling_at(state, c[0]), params) y2 = state + 0.5 * dt * k1 k2 = dfun(y2, coupling_at(y2, c[1]), params) y3 = state + 0.5 * dt * k2 k3 = dfun(y3, coupling_at(y3, c[2]), params) y4 = state + dt * k3 k4 = dfun(y4, coupling_at(y4, c[3]), params) return state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4) def _rk6(state, dfun, coupling_at, dt, params): """See ``lyapax.integrators.rk6_combine`` for the tableau, provenance, and correctness checks -- this just plugs the coupled-network derivative ``dfun(y, coupling_at(y, c), params)``, recomputed fresh at each of the 8 stages, into that shared combination.""" return rk6_combine(lambda y, c: dfun(y, coupling_at(y, c), params), state, dt) _STEP_INTEGRATORS: dict[str, Callable] = { "euler": _euler, "heun": _heun, "rk4": _rk4, "rk6": _rk6, } # --------------------------------------------------------------------------- # step(carry, _) factory # ---------------------------------------------------------------------------
[docs] def make_step_fn( dfun: Callable, weights: jnp.ndarray, has_delays: bool, horizon: int, n_nodes: int, cvar_indices: tuple[int, ...], dt: float, delay_steps: jnp.ndarray | None = None, G_default: float = 1.0, coup_a: float = 1.0, coup_b: float = 0.0, integrator: str | Callable = "heun", coupling_fn: Callable | None = None, tau_steps: int | float | None = None, interpolate: bool = False, ) -> Callable: """ Returns step(carry, _) -> (new_carry, new_state). carry = (state, buf, step_int32, params) ``params`` travels inside the carry (not closed over) so that, later, jax.vmap over swept parameters and jax.jacfwd/jvp w.r.t. state both see it as data rather than a baked-in constant. ``buf`` is a no-op array when ``has_delays=False`` - the ODE and DDE cases share this exact function; only the coupling branch differs. integrator : ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable ``(state, dfun, coupling_at, dt, params) -> new_state``, where ``coupling_at(y_stage, c) -> coupling`` is called fresh at each internal stage (``y_stage``: that stage's intra-step state estimate; ``c``: its Butcher node, 0 at the first stage, 1 at the last) rather than being frozen once per step -- see the "Integrators" section's module comment, and :ref:`dde-rk-stage-order` for why freezing it instead silently caps the whole scheme at O(dt) whenever coupling is nonzero. The zero-delay case reads coupling from ``y_stage`` (the evolving state) fresh at each stage; the interpolated-delay case (``interpolate=True``) reads the delayed history at each stage's own intra-step time ``step + c`` via the Hermite interpolant, so both realize the integrator's nominal order (the delayed case up to the cubic Hermite history's own ~4th-order ceiling). Only the grid-snapped delayed default (``interpolate=False``) still ignores ``c`` and reads once per step -- without an interpolant there is no sub-step history to read -- and remains O(dt); pass ``interpolate=True`` for higher-order DDE stepping. coupling_fn : optional ``lyapax.coupling``-style plain callable, ``(cvar_state, weights, params) -> coupling`` (see ``lyapax.coupling.CouplingFn``). When given, replaces the hardcoded ``G_default``/``coup_a``/``coup_b`` linear formula for *both* the zero-delay and (uniform-delay-only, see ``tau_steps``) delayed branches, unifying this step with ``lyapax.coupling``'s plain-callable design. ``G_default``/``coup_a``/``coup_b`` are ignored when ``coupling_fn`` is given. Default ``None`` preserves the exact original vendored behavior. tau_steps : required alongside ``coupling_fn`` when ``has_delays=True`` -- a single global delay (in steps), read via ``_read_uniform_delayed_cvar`` (O(1) ring-buffer read) or, when ``interpolate=True``, ``_read_uniform_delayed_cvar_interp`` (in which case ``tau_steps`` need not be an integer -- see ``interpolate``). Per-edge heterogeneous delays (the general ``delay_steps`` matrix) are not supported together with a custom ``coupling_fn`` yet -- that combination needs an edge-aware coupling_fn signature, which does not exist yet; use the legacy ``coupling_fn=None`` path (which does support per-edge ``delay_steps``, just with the hardcoded linear formula) until then. interpolate : reconstruct the delayed coupling-variable value via cubic Hermite interpolation (using the stored value *and* derivative at the two grid points bracketing the delayed time) instead of snapping to the nearest stored grid sample -- see :ref:`dde-history-interpolation`. Requires ``has_delays=True``, ``coupling_fn`` given, and ``tau_steps`` given (the uniform-delay path only; not supported with the legacy per-edge ``delay_steps`` path). ``tau_steps`` may then be a non-integer float: the physical delay no longer needs rounding to a whole number of ``dt`` steps. Requires ``tau_steps >= 1`` (``tau >= dt``): the per-stage delayed reads at intra-step times up to ``step + 1`` (and the derivative stored at write time) must only ever land on ring-buffer slots already finalized by earlier steps, which fails once the delay is shorter than one step. Costs one extra ``dfun`` evaluation per step (to compute the derivative to store) and doubles ``buf``'s per-slot size (value and derivative, instead of just value). """ if interpolate and not (has_delays and coupling_fn is not None and tau_steps is not None): raise ValueError( "interpolate=True requires has_delays=True, a coupling_fn, and " "tau_steps (the uniform-delay path) -- interpolated reads are " "not supported with has_delays=False or the legacy per-edge " "delay_steps path." ) if interpolate and tau_steps < 1: raise ValueError( f"interpolate=True requires tau_steps >= 1 (tau >= dt); got " f"tau_steps={tau_steps}. A sub-step delay would make the " "per-stage Hermite history reads (and the stored-derivative " "computation) depend on ring-buffer slots not yet written at " "read time -- decrease dt below tau instead." ) if getattr(integrator, "_lyapax_adaptive_ode_only", False): raise ValueError( "adaptive ODE integrators (lyapax.adaptive.diffrax_adaptive_step) " "are only supported for a single, uncoupled ode_problem -- not " "for network_problem (even without delays) or DDEs. Networks " "and DDEs go through this shared step-function machinery, whose " "per-stage coupling callback (coupling_at) has no adaptive-" "integrator equivalent; DDEs additionally can't use diffrax at " "all (diffrax has no DDE support, patrick-kidger/diffrax#406). " "Both stay fixed-step only." ) cvar_idx = jnp.array(list(cvar_indices), dtype=jnp.int32) integrate = _STEP_INTEGRATORS[integrator] if isinstance(integrator, str) else integrator # coupling_at(y_stage, c, buf, step, params) -> coupling, called fresh # at each integrator stage (see the "Integrators" section above): for # has_delays=False, at that stage's own intra-step state y_stage (c is # unused -- coupling only depends on the current state, not on time). # This measurably fixes the zero-delay case: a coupled linear network's error # dropped roughly five orders of magnitude at the same dt, and rk4/rk6 # stopped giving identical (order-1) errors. # # For has_delays=True with interpolate=True, the delayed lookup is # likewise re-read at each stage's own intra-step time (step + c) via # the Hermite interpolant -- a delayed lookup depends on *time*, not # y_stage. Reading it once per step and freezing it across stages is # an O(dt) error in the delayed argument, which caps the whole scheme # at first order exactly like frozen zero-delay coupling did. (This # same change was once tried and looked ineffective -- but only # because the ring-buffer write was then off by one slot, a second, # independent O(dt) bias hiding the gain. With both fixed, the # scalar linear DDE's measured order is ~2.0 under heun and ~4 under # rk4/rk6, the cubic Hermite history's ceiling.) The stage times # step + c never exceed step + 1, so with tau_steps >= 1 (validated # above) every read lands on slots finalized by earlier steps. # # The grid-snapped default (interpolate=False) still ignores c: there # is no sub-step history to read without an interpolant, so it stays # O(dt) by construction. if coupling_fn is not None: def _coupling_at(y_stage, c, buf, step, params): if has_delays and interpolate: cvar_state = _read_uniform_delayed_cvar_interp( buf, step + c, tau_steps, horizon, dt) elif has_delays: cvar_state = _read_uniform_delayed_cvar(buf, step, tau_steps, horizon) else: cvar_state = y_stage[cvar_idx] return coupling_fn(cvar_state, weights, params) else: def _coupling_at(y_stage, c, buf, step, params): G = params.get("G", G_default) if has_delays: return _read_delayed_coupling( buf, step, delay_steps, weights, G, coup_a, coup_b, horizon, n_nodes) cvar_state = y_stage[cvar_idx] return _instant_coupling(cvar_state, weights, G, coup_a, coup_b) def step(carry, _): state, buf, t, params = carry coupling_at = lambda y_stage, c: _coupling_at(y_stage, c, buf, t, params) # noqa: E731 new_state = integrate(state, dfun, coupling_at, dt, params) if has_delays and interpolate: # The derivative to store is the true d(cvar)/dt at new_state, # which needs the coupling *at the new time* -- re-read it # (interpolated) at t+1 rather than reusing this step's coupling: # since tau_steps >= 1, this only touches ring-buffer slots # already finalized by earlier steps, not the slot this step # is about to write. coup_new = _coupling_at(new_state, 0.0, buf, t + 1, params) new_deriv = dfun(new_state, coup_new, params)[cvar_idx] new_buf = _write_ring_interp(buf, t + 1, new_state[cvar_idx], new_deriv, horizon) elif has_delays: new_buf = _write_ring(buf, t + 1, new_state[cvar_idx], horizon) else: new_buf = buf return (new_state, new_buf, t + 1, params), new_state return step