Source code for lyapax.dde

"""Fixed-delay DDE Lyapunov engine, on top of the vendored ring buffer.

Reuses the vendored ring-buffer simulator (``lyapax.simulator.step``) as-is
rather than a second, parallel history mechanism: the genuinely missing
piece was never "how do we store delayed history" (the vendored
``_write_ring``/``_read_delayed_coupling`` already do that correctly), it
was that ``lyapax.core.lyapunov_spectrum`` only differentiates a flat
``state``, not the ``(state, buf)`` carry a DDE actually needs -- a
sensitivity to the delayed value (``d f/d x(t-tau)``) is just as real as
the sensitivity to the current value, and dropping it silently gives wrong
exponents, not merely imprecise ones.

Method, following Farmer's (1982) approach to DDE Lyapunov spectra (the
same method the established ``jitcdde_lyap`` package implements for
adaptive-step DDEs via Hermite-interpolated history): augment the system
with explicit tangent dynamics for both ``state`` and ``buf``, propagate
them alongside the primal trajectory, periodically re-orthonormalize via
QR. Where this differs from ``jitcdde_lyap``: our scope is fixed-step,
with a finite-size ring buffer and a finite-dimensional tangent state (a
plain ``jnp.linalg.qr``, not jitcdde's continuous-function inner product
over a Hermite interpolant). By default a delayed value snaps to the
nearest stored grid point (integer-step delay, no interpolation --
``resolve_tau_steps`` rounds ``tau`` to the nearest ``dt`` multiple);
passing ``interpolate=True`` (see ``lyapax.simulator.make_step_fn``,
``dde_problem``, ``network_dde_problem``) switches to cubic Hermite
interpolation over the ring buffer's stored (value, derivative) pairs,
removing that rounding at the cost of storing/differentiating twice as
much per buffer slot -- see :ref:`dde-history-interpolation` for the
design and tradeoffs.

Tangent propagation is ``jax.jvp``-based (the same matrix-free pattern as
``lyapax.core``, see :ref:`matrix-free-tangent`): cost is O(k) forward
passes per raw step (k = tracked exponents), not O(d_total) for the full
augmented ``(state, buf)`` dimension -- a dense-Jacobian approach does not
scale to real coupled networks, since d_total grows with both network
size and ring-buffer depth.

Scope note: ``lyapunov_spectrum_dde`` itself has no opinion on delay
structure -- it differentiates through whatever carry ``step_fn`` produces,
so it already works correctly with a genuine per-edge (heterogeneous)
delay matrix via the vendored step's legacy, hardcoded-linear coupling
path (``coupling_fn=None``, ``delay_steps=<(n_nodes,n_nodes) matrix>``,
verified directly against an asymmetric 2-node case). The real scope limit
lives in ``lyapax.simulator.make_step_fn``, not here: a *custom*
``coupling_fn`` (``lyapax.coupling``'s plain-callable style, e.g. for a
delayed sigmoidal/Kuramoto network) is currently only wired up for
zero-delay and *uniform*-delay (single global ``tau_steps``) branches --
combining a custom ``coupling_fn`` with per-edge delays needs an
edge-aware ``coupling_fn`` signature, which does not exist yet.
"""
from __future__ import annotations

import math
import warnings
from dataclasses import dataclass
from typing import Callable, NamedTuple

import jax
import jax.numpy as jnp

from .core import LyapunovResult, _check_finite, _run_renorm_scan, _warn_if_not_x64
from .network import Network
from .simulator import make_step_fn

CarryStepFn = Callable
"""step(carry, _) -> (new_carry, new_state), carry = (state, buf, t, params)
-- exactly what lyapax.simulator.make_step_fn(...) returns."""

DelayedRHS = Callable[[jnp.ndarray, jnp.ndarray], jnp.ndarray]
"""rhs_delayed(state_now, state_delayed) -> dstate, both shape (m,) --
m=1 for a scalar DDE (e.g. Mackey-Glass), m>1 for a small non-networked
vector DDE. See make_scalar_delayed_step_fn."""


[docs] def resolve_tau_steps(tau: float, dt: float, warn_tol: float = 1e-6) -> int: """Round a physical delay ``tau`` to an integer number of ``dt`` steps. Integer-step only, no sub-step interpolation -- see :ref:`dde-history-interpolation` for the accuracy tradeoff this implies. Every downstream Lyapunov spectrum is computed for the *rounded* delay ``tau_eff(tau_steps, dt)``, not the exact ``tau`` passed in -- use ``tau_eff`` to see what delay was actually used, and decrease ``dt`` to shrink the gap. warn_tol : relative tolerance (of ``tau``) above which a mismatch between ``tau`` and the rounded ``tau_eff`` triggers a warning. """ tau_steps = max(1, round(tau / dt)) if tau > 0 and abs(tau_steps * dt - tau) / tau > warn_tol: warnings.warn( f"resolve_tau_steps: requested tau={tau} rounds to " f"tau_steps={tau_steps} (tau_eff={tau_steps * dt}) at dt={dt} " "-- the Lyapunov spectrum will be computed for tau_eff, not " "tau. Decrease dt to shrink this gap.", stacklevel=2, ) return tau_steps
[docs] def tau_eff(tau_steps: int, dt: float) -> float: """The effective (rounded-to-grid) delay actually used by a DDE run built with integer ``tau_steps``, in the same units as ``dt``. See ``resolve_tau_steps``.""" return tau_steps * dt
[docs] def constant_history_buf0( cvar_state0: jnp.ndarray, horizon: int, interpolate: bool = False, ) -> jnp.ndarray: """Initial ring buffer under the constant-history DDE convention: the coupling-variable state is assumed equal to ``cvar_state0`` for all ``t <= 0``. :param cvar_state0: ``(n_cvar, n_nodes)`` coupling-variable state at t=0. :param interpolate: build the ``(value, derivative)`` buffer shape ``lyapax.simulator.make_step_fn(..., interpolate=True)`` expects. The constant-history derivative is exactly 0 (a constant function has zero derivative), not an approximation. :returns: ``(horizon, n_cvar, n_nodes)`` buf0 (or ``(horizon, 2, n_cvar, n_nodes)`` when ``interpolate=True``), ready for ``carry0 = (state0, buf0, jnp.int32(0), params)``. """ cvar_state0 = jnp.asarray(cvar_state0) value = jnp.tile(cvar_state0[None, :, :], (horizon, 1, 1)) if not interpolate: return value return jnp.stack([value, jnp.zeros_like(value)], axis=1)
[docs] def make_scalar_delayed_step_fn( rhs_delayed: DelayedRHS, m: int, tau_steps: int | float, dt: float, integrator: str | Callable = "heun", interpolate: bool = False, ) -> CarryStepFn: """ Build a carry-based step function for a simple, non-networked fixed-delay DDE directly from a plain ``rhs_delayed`` -- no ``ModelSpec``/``coupling_fn`` ceremony needed. Mirrors ``lyapax.integrators.rk4_step``'s role on the ODE side: a lightweight front door for the "just one system, no network" case, sitting next to ``lyapax.simulator.make_step_fn`` (the general, ``ModelSpec``-based front door used for real delayed networks, e.g. ``tests/test_dde.py``'s benchmark test) -- both build a step for the *same* underlying vendored ring-buffer simulator, so there is still only one DDE mechanism, just two ways to construct a step for it. Internally: a trivial "coupled to your own delayed history" 1-node self-loop, with an identity coupling_fn (the delayed state passes through unchanged; any scaling/nonlinearity belongs in ``rhs_delayed`` itself, not a coupling formula -- there's no real network here). :param rhs_delayed: ``(state_now, state_delayed) -> dstate``, both shape ``(m,)`` -- see ``DelayedRHS``. :param m: state dimension (e.g. 1 for a scalar DDE like Mackey-Glass). :param tau_steps: delay in units of ``dt`` (see ``resolve_tau_steps``). An integer when ``interpolate=False``; may be a non-integer float when ``interpolate=True``. :param integrator: ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable -- see ``lyapax.simulator.make_step_fn``. :param interpolate: see ``lyapax.simulator.make_step_fn``. """ def dfun(state, coupling, params): return rhs_delayed(state[:, 0], coupling[:, 0])[:, None] def _identity_coupling(cvar_state, weights, params): return cvar_state weights = jnp.ones((1, 1)) horizon = _resolve_horizon(tau_steps, interpolate) return make_step_fn( dfun=dfun, weights=weights, has_delays=True, horizon=horizon, n_nodes=1, cvar_indices=tuple(range(m)), dt=dt, coupling_fn=_identity_coupling, tau_steps=tau_steps, integrator=integrator, interpolate=interpolate, )
def _resolve_horizon(tau_steps: int | float, interpolate: bool) -> int: """Ring-buffer depth needed for a given delay: ``tau_steps + 1`` for an exact integer delay, or enough slots to always have both Hermite interpolation anchors (``floor``/``ceil`` of a non-integer delay) in-buffer when ``interpolate=True``.""" if interpolate: return math.ceil(tau_steps) + 2 return tau_steps + 1
[docs] def scalar_delayed_history0( state0_now: jnp.ndarray, tau_steps: int | float, interpolate: bool = False, ): """(state0, buf0) for make_scalar_delayed_step_fn, from a flat ``(m,)`` initial condition, under the constant-history DDE convention -- the scalar-case counterpart to constant_history_buf0, hiding the ``(n_sv, n_nodes)``/``(horizon, n_cvar, n_nodes)`` reshaping the general (networked) path needs. :param interpolate: see ``lyapax.simulator.make_step_fn``. """ state0_now = jnp.asarray(state0_now) state0 = state0_now[:, None] # (m, 1) horizon = _resolve_horizon(tau_steps, interpolate) buf0 = constant_history_buf0(state0, horizon, interpolate=interpolate) return state0, buf0
[docs] @dataclass(frozen=True) class DDEProblem: """Owns the carry-style plumbing (``step_fn``, ring buffer, delay length) that ``lyapunov_spectrum_dde`` otherwise asks the caller to assemble and pass by hand -- ``buf0``, ``tau_steps``, and the carry layout become properties of one object instead of four separate positional arguments. Build one with ``dde_problem`` (single/scalar DDE) or ``network_dde_problem`` (coupled, uniformly-delayed network), or construct directly if you already have a carry ``step_fn``. :param step_fn: see ``lyapunov_spectrum_dde``. :param state0: ``(n_sv, n_nodes)`` initial state (pre-transient). :param buf0: ``(horizon, n_cvar, n_nodes)`` initial ring buffer. :param params: closed over for tangent propagation. :param dt: fixed step size. :param tau_steps: delay in units of ``dt`` (see ``resolve_tau_steps``) -- carried for reference/inspection (``problem.tau_steps``, ``tau_eff(problem.tau_steps, problem.dt)``); ``lyapunov_spectrum_dde`` itself only needs ``buf0``'s shape. An integer for the default (grid-snapped) delay; a non-integer float when built with ``interpolate=True``. """ step_fn: CarryStepFn state0: jnp.ndarray buf0: jnp.ndarray params: dict dt: float tau_steps: int | float
[docs] def dde_problem( rhs_delayed: DelayedRHS, state0: jnp.ndarray, tau: float, dt: float, history: jnp.ndarray | None = None, integrator: str | Callable = "heun", interpolate: bool = False, ) -> DDEProblem: """ Build a ``DDEProblem`` for a simple, non-networked scalar/vector fixed-delay DDE directly from ``rhs_delayed`` -- the parallel-to-ODE front door for ``lyapunov_spectrum_dde``, hiding the ring-buffer/carry construction that ``make_scalar_delayed_step_fn`` + ``scalar_delayed_history0`` otherwise ask the caller to wire up by hand. :param rhs_delayed: ``(state_now, state_delayed) -> dstate``, both shape ``(m,)`` -- see ``DelayedRHS``. :param state0: ``(m,)`` initial state (pre-transient, pre-history). :param tau: physical delay. Rounded to an integer number of ``dt`` steps (see ``resolve_tau_steps``) unless ``interpolate=True``, in which case ``tau`` is used exactly (via Hermite-interpolated history reads, see ``lyapax.simulator.make_step_fn``) -- no rounding, no warning. :param dt: fixed step size. :param history: optional initial ring buffer (``(tau_steps + 1, m)``, or the ``interpolate=True`` value/derivative shape -- see ``scalar_delayed_history0``), for a non-constant history. Defaults to the constant-history convention (``state0`` held for all ``t <= 0``). :param integrator: ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable -- see ``lyapax.simulator.make_step_fn``. :param interpolate: see ``lyapax.simulator.make_step_fn``. Examples -------- >>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> from lyapax import systems >>> from lyapax.dde import dde_problem, lyapunov_spectrum_dde >>> a, tau, dt = 0.5, 0.3, 1e-2 >>> problem = dde_problem( ... systems.linear_scalar_dde(a=a), state0=jnp.array([1.0]), ... tau=tau, dt=dt, ... ) >>> result = lyapunov_spectrum_dde( ... problem, n_steps=20_000, k=1, renorm_every=5, t_transient=10.0, ... ) >>> result.exponents # doctest: +SKIP Array([-0.5985], dtype=float64) # dominant root of the Lambert-W characteristic equation """ state0 = jnp.asarray(state0) m = state0.shape[0] tau_steps = (tau / dt) if interpolate else resolve_tau_steps(tau, dt) step_fn = make_scalar_delayed_step_fn( rhs_delayed, m, tau_steps, dt, integrator=integrator, interpolate=interpolate) state0_2d, default_buf0 = scalar_delayed_history0(state0, tau_steps, interpolate=interpolate) buf0 = jnp.asarray(history) if history is not None else default_buf0 return DDEProblem( step_fn=step_fn, state0=state0_2d, buf0=buf0, params={}, dt=dt, tau_steps=tau_steps, )
[docs] def network_dde_problem( dfun: Callable, network: Network, coupling: Callable, params: dict, state0: jnp.ndarray, dt: float, tau: float | None = None, history: jnp.ndarray | None = None, integrator: str | Callable = "heun", interpolate: bool = False, ) -> DDEProblem: """ Build a ``DDEProblem`` for a coupled, uniformly-delayed network -- the DDE counterpart of ``lyapax.network.network_step`` (see that function and ``Network`` for the topology/coupling/integrator split this keeps parallel between the ODE and DDE construction paths). Only a single global delay is supported (a uniform ``tau_steps`` read via one ring-buffer lookup per node) -- per-edge heterogeneous delays combined with a custom ``coupling`` callable are not yet supported, see ``lyapax.simulator.make_step_fn``'s docstring. :param dfun: ``(state, coupling, params) -> dstate``. :param network: topology; exactly one of ``tau`` or ``network.delay_steps`` must give the uniform delay. :param coupling: ``(cvar_state, weights, params) -> coupling``. :param params: closed over for tangent propagation. :param state0: ``(n_sv, n_nodes)`` initial state. :param dt: fixed step size. :param tau: physical delay. Rounded via ``resolve_tau_steps`` unless ``interpolate=True`` (used exactly, via Hermite-interpolated history reads). Mutually exclusive with ``network.delay_steps``. :param history: optional initial ring buffer (``(tau_steps + 1, n_cvar, n_nodes)``, or the ``interpolate=True`` value/derivative shape -- see ``constant_history_buf0``). Defaults to the constant-history convention. :param integrator: ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable -- see ``lyapax.simulator.make_step_fn``. :param interpolate: see ``lyapax.simulator.make_step_fn``. When True, ``tau`` (or ``network.delay_steps``) need not be an integer number of ``dt`` steps. Examples -------- Two linearly delay-coupled nodes, ``x_i' = gamma*x_i + G*x_j(t-tau)``, whose symmetric/antisymmetric modes have closed-form Lambert-W exponents: >>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> import lyapax >>> from lyapax.coupling import linear_coupling >>> from lyapax.simulator import ModelSpec, StateVar, Parameter, build_jax_dfun >>> gamma, G, tau, dt = -1.0, 0.3, 0.2, 1e-3 >>> model = ModelSpec( ... name="linear_node", ... state_variables=(StateVar("x", default_init=0.0),), ... parameters=(Parameter("gamma", gamma),), ... cvar=("x",), ... dfun_str={"x": "gamma * x + c"}, ... ) >>> dfun = build_jax_dfun(model) >>> network = lyapax.Network( ... weights=jnp.array([[0., 1.], [1., 0.]]), cvar_indices=model.cvar_indices, ... ) >>> problem = lyapax.network_dde_problem( ... dfun, network, linear_coupling(a=1.0, b=0.0), ... params={"gamma": gamma, "G": G}, state0=jnp.array([[0.3, -0.2]]), ... dt=dt, tau=tau, ... ) >>> result = lyapax.lyapunov_spectrum_dde( ... problem, n_steps=20_000, k=2, renorm_every=10, t_transient=5.0, ... ) >>> result.exponents # doctest: +SKIP Array([-0.6578, -1.3967], dtype=float64) # symmetric, antisymmetric mode """ if tau is not None: if network.delay_steps is not None: raise ValueError( "network_dde_problem: pass either tau or network.delay_steps, " "not both." ) tau_steps = (tau / dt) if interpolate else resolve_tau_steps(tau, dt) elif isinstance(network.delay_steps, (int, float)): tau_steps = network.delay_steps else: raise ValueError( "network_dde_problem needs a single global delay: pass tau=... " "or set network.delay_steps to a number (uniform tau_steps); a " "per-edge delay matrix is not supported with a custom coupling." ) state0 = jnp.asarray(state0) horizon = _resolve_horizon(tau_steps, interpolate) step_fn = make_step_fn( dfun=dfun, weights=network.weights, has_delays=True, horizon=horizon, n_nodes=network.n_nodes, cvar_indices=network.cvar_indices, dt=dt, coupling_fn=coupling, tau_steps=tau_steps, integrator=integrator, interpolate=interpolate, ) if history is not None: buf0 = jnp.asarray(history) else: cvar_idx = jnp.array(network.cvar_indices, dtype=jnp.int32) buf0 = constant_history_buf0(state0[cvar_idx], horizon, interpolate=interpolate) return DDEProblem( step_fn=step_fn, state0=state0, buf0=buf0, params=params, dt=dt, tau_steps=tau_steps, )
[docs] class DDECheckpoint(NamedTuple): """The DDE counterpart of ``lyapax.core.LyapunovCheckpoint``: enough state to continue a ``lyapunov_spectrum_dde`` run with another ``lyapunov_spectrum_dde(..., resume=result.checkpoint)`` call. A DDE's Markovian state is ``(state, buf)`` together (see the module docstring), not ``state`` alone, so unlike the ODE checkpoint this also carries ``buf``, the ring-buffer tangent ``Y_buf``, and the ring-buffer step counter ``t``.""" state: jnp.ndarray """(n_sv, n_nodes) trajectory state at the end of the checkpointed run.""" buf: jnp.ndarray """(horizon, n_cvar, n_nodes) (or the ``interpolate=True`` value/ derivative shape) ring buffer at the end of the checkpointed run.""" t: jnp.ndarray """Scalar int32 ring-buffer step counter at the end of the checkpointed run -- must be threaded through unchanged (not reset to 0), since it drives the buffer's modular read/write indices (see the module docstring's note on why ``t`` lives in the scanned carry).""" Y_state: jnp.ndarray """(n_sv, n_nodes, k) QR-orthonormalized tangent basis for ``state``, in raw (not display-sorted) column order -- see ``cum_log_growth``.""" Y_buf: jnp.ndarray """(horizon, n_cvar, n_nodes, k) (or the ``interpolate=True`` shape) tangent basis for the ring buffer, same raw column order as ``Y_state`` -- together, ``(Y_state, Y_buf)`` is the full augmented tangent basis, mirroring how ``(state, buf)`` is the full augmented primal state.""" cum_log_growth: jnp.ndarray """(k,) total accumulated log-growth since accumulation started, in the same raw column order as ``Y_state``/``Y_buf`` -- see ``lyapax.core.LyapunovCheckpoint.cum_log_growth``.""" elapsed_time: jnp.ndarray """Scalar: total elapsed accumulation time since the end of the transient, across every resumed call so far -- same units as ``LyapunovResult.times``.""" dt: float = None """The ``dt`` of the run that produced this checkpoint (a plain Python float, not a traced array). ``dt`` also determines the grid-snapped delay ``tau_steps``/``horizon`` for a DDE, so a ``dt`` mismatch on resume would be even more likely to silently corrupt a DDE run than an ODE one -- see ``lyapax.core.LyapunovCheckpoint.dt``, which this mirrors. ``lyapunov_spectrum_dde`` checks this on ``resume=`` and raises if it differs from the resuming call's ``dt``."""
[docs] def lyapunov_spectrum_dde( step_fn_or_problem: CarryStepFn | DDEProblem, state0: jnp.ndarray | int | None = None, buf0: jnp.ndarray | None = None, params: dict | None = None, dt: float | None = None, n_steps: int | None = None, k: int | None = None, renorm_every: int = 1, t_transient: float = 0.0, seed: int = 0, check_finite: bool = False, resume: DDECheckpoint | None = None, ) -> LyapunovResult: """ Compute the (partial or full) Lyapunov spectrum of a fixed-delay DDE, via the Benettin/QR method generalized to a ``(state, buf)`` tangent pair (see module docstring). Parameters ---------- step_fn_or_problem : either a ``DDEProblem`` (from ``dde_problem`` / ``network_dde_problem``) -- in which case ``state0``, ``buf0``, ``params``, and ``dt`` are read off it and the second positional argument is ``n_steps`` (``lyapunov_spectrum_dde(problem, n_steps)`` or ``lyapunov_spectrum_dde(problem, n_steps=...)``) -- or a plain carry ``step_fn``, ``step(carry, _) -> (new_carry, new_state)``, carry = ``(state, buf, t, params)``, in which case ``state0``, ``buf0``, ``params``, ``dt``, ``n_steps`` are all given explicitly (the original, lower-level call form). state0 : (n_sv, n_nodes) initial state (pre-transient). Ignored (and may be omitted) when a ``DDEProblem`` is passed. buf0 : (horizon, n_cvar, n_nodes) initial ring buffer (see ``constant_history_buf0``). Ignored when a ``DDEProblem`` is passed. params : closed over for tangent propagation (not differentiated -- only ``state``/``buf`` are). Ignored when a ``DDEProblem`` is passed. dt, n_steps, renorm_every, seed : see ``lyapax.core.lyapunov_spectrum`` (same meaning). When a ``DDEProblem`` is passed, ``dt`` may be omitted; if provided, it must match the ``dt`` stored on the problem. k : number of leading exponents to track. Defaults to the *full* augmented spectrum, ``k = d_total = state0.size + buf0.size`` -- note this is the ring-buffer-augmented dimension, not just the physical state dimension, and grows with ``horizon`` (delay length). Per-step cost and tangent-matrix memory are ``O(k)``, and QR is ``O(d_total * k^2)``, so for large ``horizon`` (long delays or many network nodes) pass an explicit small ``k`` rather than relying on the full-spectrum default. t_transient : time to integrate (discarding tangent tracking) before starting the Lyapunov accumulation. Internally rounded up to cover at least one full ring cycle (``horizon * dt``): until every buffer slot has been written at least once from real dynamics, delayed- direction tangent information is incomplete, and (for large ``horizon``) a random initial tangent basis is disproportionately concentrated in the buffer's trivial "just shifted forward unchanged" directions -- the same class of silent-bias risk M1's transient fix addresses for the plain ODE case, amplified here. check_finite : see ``lyapax.core.lyapunov_spectrum`` (same meaning); the augmented ``(state, buf)`` tangent dimension makes underflow more likely for large ``horizon``, so this is worth enabling while tuning ``renorm_every`` for a new DDE system. resume : a previous call's ``result.checkpoint`` (a ``DDECheckpoint``), to continue that run instead of starting a fresh one -- skips the random tangent-basis init *and* the mandatory ring-cycle transient (a resumed run's ``buf`` was already fully populated by the checkpointed run), and ``history``/``times`` in the returned ``LyapunovResult`` continue the cumulative running estimate, so concatenating two calls' ``history`` reads as one continuous convergence curve -- same contract as ``lyapax.core.lyapunov_spectrum``'s ``resume``. Mutually exclusive with a nonzero ``t_transient``. ``k`` must match the checkpoint's tracked dimension if given explicitly (defaults to it otherwise). Returns ------- LyapunovResult Examples -------- See ``dde_problem`` for a full worked example (``lyapunov_spectrum_dde(dde_problem(...), n_steps=...)``); this function is the DDE analogue of ``lyapax.core.lyapunov_spectrum``, generalized to the ring-buffer-augmented ``(state, buf)`` tangent pair. """ if isinstance(step_fn_or_problem, DDEProblem): problem = step_fn_or_problem if dt is not None and float(dt) != float(problem.dt): raise ValueError( "dt was passed both directly and via DDEProblem with " f"different values: dt={dt!r}, problem.dt={problem.dt!r}." ) if n_steps is None: if state0 is None: raise TypeError( "lyapunov_spectrum_dde(problem, ...) requires n_steps." ) n_steps = state0 # lyapunov_spectrum_dde(problem, n_steps) form step_fn = problem.step_fn state0, buf0, params, dt = problem.state0, problem.buf0, problem.params, problem.dt else: step_fn = step_fn_or_problem if n_steps is None or state0 is None or buf0 is None or params is None or dt is None: raise TypeError( "lyapunov_spectrum_dde(step_fn, state0, buf0, params, dt, " "n_steps, ...) requires all of state0, buf0, params, dt, " "n_steps when step_fn is a plain carry step function." ) state0 = jnp.asarray(state0) buf0 = jnp.asarray(buf0) _warn_if_not_x64(state0) horizon = buf0.shape[0] state_shape = state0.shape buf_shape = buf0.shape d_state = state0.size d_buf = buf0.size d_total = d_state + d_buf if resume is not None: if t_transient > 0.0: raise ValueError( "t_transient and resume are mutually exclusive -- a " "resumed run continues from an already-transient-aligned " "checkpoint (its ring buffer is already fully populated); " "pass t_transient=0.0 (the default) when resume is given." ) if resume.state.shape != state_shape or resume.buf.shape != buf_shape: raise ValueError( f"resume.state/buf have shapes {resume.state.shape}/" f"{resume.buf.shape}, but state0/buf0 have shapes " f"{state_shape}/{buf_shape} -- resume must come from a " "checkpoint of a run on the same system." ) if resume.dt is not None and float(resume.dt) != float(dt): raise ValueError( f"resume.dt ({resume.dt!r}) does not match this call's dt " f"({dt!r}) -- resuming under a different dt would " "misinterpret the checkpointed elapsed_time/history (and, " "for a DDE, the grid-snapped delay/horizon) without any " "shape-based sign of the mismatch. Use the same dt as the " "checkpointed run, or start a fresh run instead of " "resuming." ) resume_k = resume.Y_state.shape[-1] if k is None: k = resume_k elif k != resume_k: raise ValueError( f"k={k} does not match resume.Y_state's tracked dimension " f"({resume_k}) -- resuming must track the same number of " "exponents as the checkpointed run." ) elif k is None: k = d_total if not (1 <= k <= d_total): raise ValueError(f"k must be in [1, {d_total}]; got {k}.") if n_steps <= 0: raise ValueError(f"n_steps must be > 0; got {n_steps}.") if renorm_every < 1: raise ValueError(f"renorm_every must be >= 1; got {renorm_every}.") if n_steps % renorm_every != 0: raise ValueError( f"n_steps ({n_steps}) must be a multiple of renorm_every " f"({renorm_every})." ) if resume is not None: state0, buf0, t0 = resume.state, resume.buf, resume.t Y_state0, Y_buf0 = resume.Y_state, resume.Y_buf else: key = jax.random.PRNGKey(seed) Y0_flat, _ = jnp.linalg.qr(jax.random.normal(key, (d_total, k), dtype=state0.dtype)) Y_state0 = Y0_flat[:d_state].reshape(state_shape + (k,)) Y_buf0 = Y0_flat[d_state:].reshape(buf_shape + (k,)) t0 = jnp.int32(0) def _flatten(Y_state, Y_buf): return jnp.concatenate( [Y_state.reshape(d_state, k), Y_buf.reshape(d_buf, k)], axis=0) def _unflatten(Y_flat): return (Y_flat[:d_state].reshape(state_shape + (k,)), Y_flat[d_state:].reshape(buf_shape + (k,))) def _advance(state, buf, t, Y_state, Y_buf, n_substeps): """Propagate (state, buf) and their tangent pair jointly for n_substeps raw steps, then QR once -- t lives in the scanned carry and is re-read/incremented every raw step (not closed over once per block): closing over a stale t was verified to silently diverge from the correct answer during design review (no crash/NaN, just a wrong number), since t drives the ring-buffer's modular read/write indices and must match what step_fn itself sees at each raw step.""" def _raw_step(carry_inner, _): state_i, buf_i, t_i, Y_state_i, Y_buf_i = carry_inner def f(state, buf): (new_state, new_buf, _t2, _params2), _ = step_fn( (state, buf, t_i, params), None) return new_state, new_buf def _single_column(Y_state_col, Y_buf_col): return jax.jvp(f, (state_i, buf_i), (Y_state_col, Y_buf_col)) (new_state_rep, new_buf_rep), (dY_state, dY_buf) = jax.vmap( _single_column, in_axes=(-1, -1), out_axes=((0, 0), (-1, -1)) )(Y_state_i, Y_buf_i) new_state, new_buf = new_state_rep[0], new_buf_rep[0] return (new_state, new_buf, t_i + 1, dY_state, dY_buf), None (state, buf, t, Y_state, Y_buf), _ = jax.lax.scan( _raw_step, (state, buf, t, Y_state, Y_buf), None, length=n_substeps) Q_flat, R = jnp.linalg.qr(_flatten(Y_state, Y_buf)) Q_state, Q_buf = _unflatten(Q_flat) return state, buf, t, Q_state, Q_buf, R # Unconditional (unlike core.py's ODE transient, which is skippable via # t_transient=0.0): a DDE always needs at least one full ring cycle # (horizon * dt) before every buffer slot has been written from real # dynamics -- see the t_transient docstring above. Floored, not # skipped, even when the caller passes t_transient=0.0 exactly. Skipped # entirely on resume: the checkpointed run already covered at least one # full ring cycle, so buf0 (== resume.buf) is already fully populated. if resume is None: min_transient = horizon * dt n_transient = renorm_every * max( 1, round(max(t_transient, min_transient) / dt / renorm_every)) def _transient_block(carry, _): state, buf, t, Y_state, Y_buf = carry state, buf, t, Y_state, Y_buf, _R = _advance( state, buf, t, Y_state, Y_buf, renorm_every) return (state, buf, t, Y_state, Y_buf), None (state0, buf0, t0, Y_state0, Y_buf0), _ = jax.lax.scan( _transient_block, (state0, buf0, t0, Y_state0, Y_buf0), None, length=n_transient // renorm_every) def _renorm_block(carry, _): state, buf, t, Y_state, Y_buf = carry state, buf, t, Y_state, Y_buf, R = _advance( state, buf, t, Y_state, Y_buf, renorm_every) log_growth = jnp.log(jnp.abs(jnp.diag(R))) return (state, buf, t, Y_state, Y_buf), log_growth n_renorm = n_steps // renorm_every cum_log_growth0 = resume.cum_log_growth if resume is not None else 0.0 elapsed_time0 = resume.elapsed_time if resume is not None else 0.0 final_carry, final_cum_log_growth, result = _run_renorm_scan( _renorm_block, (state0, buf0, t0, Y_state0, Y_buf0), n_renorm, renorm_every, dt, cum_log_growth0=cum_log_growth0, elapsed_time0=elapsed_time0, ) final_state, final_buf, final_t, final_Y_state, final_Y_buf = final_carry checkpoint = DDECheckpoint( state=final_state, buf=final_buf, t=final_t, Y_state=final_Y_state, Y_buf=final_Y_buf, cum_log_growth=final_cum_log_growth, elapsed_time=result.times[-1], dt=float(dt), ) result = result._replace(checkpoint=checkpoint) if check_finite: _check_finite(result) return result