API reference#
Core#
Core Benettin/QR Lyapunov-spectrum engine.
Scope: single-node (or otherwise uncoupled) systems given as a one-step
map state -> new_state. Networked/coupled systems (lyapax.network)
and delayed systems (lyapax.dde) reuse the same renormalization idea
but need extra tangent bookkeeping (a delay ring buffer’s tangent, in the
DDE case).
Method: propagate a (d, k) matrix of tangent vectors alongside the
trajectory. Every renorm_every steps, QR-decompose the tangent matrix,
accumulate log|diag(R)|, and replace the tangent matrix with the
orthonormal factor Q (Benettin’s method).
Tangent propagation is jax.jvp-based, not jax.jacfwd-based: one
jax.jvp call per tracked column, batched via jax.vmap - cost is O(k)
forward-mode passes per raw step, not O(d) for a dense Jacobian, which
matters whenever k < d (the partial-spectrum case jacfwd can’t
exploit, since it always computes all d columns regardless of how many
are actually tracked). See Matrix-free tangent propagation for the design
rationale.
Differentiating an exponent w.r.t. a system parameter:
lyapunov_spectrum is built entirely from
jax.lax.scan (static trip count) and jnp.linalg.qr, both of which
support reverse-mode AD, so jax.grad/jax.jacrev do not raise here
(unlike lyapax.adaptive’s diffrax-backed integrator, whose internal
dynamic-trip-count while_loop blocks reverse-mode outright). But not
raising is not the same as useful: for a genuinely chaotic trajectory,
step_fn closes over the parameter being differentiated, so
jax.grad/jax.jacfwd differentiate through the entire unrolled state
trajectory, and the result inherits that trajectory’s own exponential
sensitivity to perturbation - the returned “gradient” grows roughly like
exp(lambda_max * horizon) and is numerically meaningless well before it
overflows (confirmed empirically: unusable already within a few hundred
steps for the Lorenz system, worse for longer runs; see
tests/test_differentiability.py). This is a known phenomenon in
chaotic sensitivity analysis, not a lyapax-specific bug - naive
trajectory-unrolling gradients of long-time-averaged chaotic quantities are
fundamentally unreliable, which is why shadowing-based methods (e.g.
least-squares shadowing) exist in that literature. Practical guidance:
jax.grad/jax.jacfwd through this engine are reliable for
non-chaotic or short-horizon systems (e.g. tuning a parameter that keeps
the trajectory on a stable fixed point/limit cycle, or a genuinely short
run) - do not trust a gradient computed through a long chaotic trajectory
without independently checking it (e.g. against a finite-difference
estimate) first.
- class lyapax.core.ConvergenceDrift(absolute, relative, converged)[source]#
- absolute: Array#
(k,)
|history[-1] - history[-1 - window_rows]|per exponent – the raw change in the running estimate over the tail window.
- converged: Array | None#
(k,) bool,
relative <= tolper exponent –Noneiftolwas not given toconvergence_drift.
- relative: Array#
(k,)
absolute / |history[-1]|per exponent. Blows up for exponents near zero (e.g. a conservative system’s zero exponent, or any near-degenerate pair) – a near-zero reference makes “relative to it” not meaningful; useabsolutefor those columns instead.
- class lyapax.core.LyapunovCheckpoint(state, Y, cum_log_growth, elapsed_time, dt)[source]#
- Y: Array#
(d, k) QR-orthonormalized tangent basis at the end of the checkpointed run, in its raw (not display-sorted) column order – see
cum_log_growth.
- cum_log_growth: Array#
(k,) total accumulated log-growth (sum of
log|diag(R)|over every renormalization block since accumulation started) in the same raw column order asY– notLyapunovResult.history’s column order, which is re-sorted by the final row on every call and can reorder differently between two otherwise-identical resumed runs if exponents are near-degenerate. Keeping this inY’s raw order is what makes resuming exact: it is the same “never reordered until the very end” bookkeepinglyapunov_spectrumalready does internally within a single call, just carried across two calls instead of within one.
- dt: float#
The
dtof the run that produced this checkpoint (a plain Python float, not a traced array – kept out of the tangent computation). Onresume=,lyapunov_spectrumchecks this against thedtof the resuming call and raises if they differ, since silently resuming under a differentdtwould misinterpretelapsed_time/historywithout any structural (shape-based) sign of the mismatch. This does not validatestep_fn/system parameters/integrator – those aren’t identifiable from an opaque JAX closure – so a same-dtcheckpoint can still be resumed against a different system silently;dtis only the one stable field cheap enough to check automatically.
- elapsed_time: Array#
total elapsed accumulation time since the end of the transient, across every resumed call so far – same units as
LyapunovResult.times.- Type:
Scalar
- state: Array#
(d,) trajectory state at the end of the checkpointed run.
- class lyapax.core.LyapunovResult(exponents, history, times, checkpoint)[source]#
- checkpoint: LyapunovCheckpoint | DDECheckpoint | None#
Enough state to continue this run with another
lyapunov_spectrum(..., resume=result.checkpoint)/lyapunov_spectrum_dde(..., resume=result.checkpoint)call, picking up exactly where this one left off (no re-transient, no discontinuity inhistory/times) – the “run a fixed n_steps, eyeballhistory/convergence_drift, resume if not converged yet” workflow. Always set by bothlyapunov_spectrum(the ODE engine,LyapunovCheckpoint) andlyapax.dde.lyapunov_spectrum_dde(the DDE engine,lyapax.dde.DDECheckpoint– also carries the delay ring buffer’s state, since a DDE’s Markovian state is(state, buf)together, notstatealone).
- exponents: jnp.ndarray#
(k,) final Lyapunov-exponent estimates, sorted descending.
- history: jnp.ndarray#
(n_renorm, k) running estimate at each renormalization point, in the same column order as
exponents- use to check convergence.Columns are ordered once, by the final row (
history[-1]==exponents), then that column order is applied to every row. Near- degenerate exponents can cross over during the run, so an early row’s per-column values are not guaranteed to be individually sorted or to track the same Oseledets direction throughout - only the last row is.
- times: jnp.ndarray#
(n_renorm,) elapsed time (in units of
dt) at each row ofhistory, measured from the end of the transient.
- class lyapax.core.ODEProblem(step_fn: Callable[[Array], Array], state0: Array, dt: float)[source]#
Owns the
(step_fn, state0, dt)triple thatlyapunov_spectrumotherwise asks the caller to keep in sync by hand – in particular, thedtbaked intostep_fn(e.g. viaode_step) and thedtpassed separately tolyapunov_spectrummust agree, and nothing checks that when they’re two independent arguments. Build one withode_problem(plain ODE) ornetwork_problem(coupled network), or construct directly if you already have a step_fn.- Parameters:
step_fn – one fixed-time-step update,
state (d,) -> new_state (d,).state0 –
(d,)initial state (pre-transient).dt – time represented by one call to
step_fn.
- lyapax.core.convergence_drift(result: LyapunovResult, window: float = 0.1, tol: float | None = None) ConvergenceDrift[source]#
Summarize how much each exponent’s running estimate has moved over the tail of the run, by comparing
result.history[-1](the final estimate) to the estimatewindowfraction of the run’s renormalization points earlier.lyapunov_spectrum/lyapunov_spectrum_ddealways run a fixedn_steps– there is no built-in stopping criterion, adaptive or otherwise. This is a diagnostic to help a caller judge, after the fact, whether that fixed-length run was long enough: a large drift means the estimate was probably still moving andn_stepsshould be increased; a small, stable drift is evidence (not proof) of convergence. Pairs withresult.checkpoint/resume=: if not converged, continue the same run withlyapunov_spectrum(..., resume=result.checkpoint)instead of restarting from scratch.- Parameters:
result – output of
lyapunov_spectrum/lyapunov_spectrum_dde.window – fraction, in
(0, 1], ofresult.history’s rows making up the tail comparison window. Default0.1compares the final estimate against the estimate from 10% of the run ago.tol – if given,
convergedisrelative <= tolper exponent; otherwiseconvergedisNone.
- Returns:
ConvergenceDrift(absolute, relative, converged).
A single
relative <= tolpass is evidence of settling, not a statistical stopping rule: picktolas a mix of absolute and relative tolerance (relativealone blows up for exponents near zero – seeConvergenceDrift.relative’s docstring, and useabsolutedirectly for those columns), and prefer requiring several consecutive resumed chunks to reportconvergedbefore trusting it, rather than stopping at the first pass. CPU/GPU QR and reduction differences are not bitwise-identical, so the exact chunk where a tighttolfirst passes can differ by backend even for the same system and seed.Usage#
>>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> from lyapax import lyapunov_spectrum, ode_problem, systems >>> from lyapax.core import convergence_drift >>> rhs = systems.lorenz(sigma=10.0, rho=28.0, beta=8.0 / 3.0) >>> problem = ode_problem(rhs, state0=jnp.array([1.0, 1.0, 1.0]), dt=1e-2) >>> result = lyapunov_spectrum( ... problem, n_steps=50_000, renorm_every=10, t_transient=100.0, ... ) >>> drift = convergence_drift(result, window=0.1, tol=1e-2) >>> bool(drift.converged[0]) True
- lyapax.core.kaplan_yorke_dimension(exponents: Array, d_total: int | None = None) float[source]#
Kaplan-Yorke (Lyapunov) dimension: an estimate of an attractor’s fractal dimension from its Lyapunov spectrum (Kaplan & Yorke, 1979),
j + sum(exponents[:j]) / |exponents[j]|wherejis the largest prefix length with a non-negative partial sum. Pure post-processing ofLyapunovResult.exponents– no tangent-propagation or QR involved.Eager, host-side post-processing: the crossing index
jis found by branching on Pythonfloat``s in a Python loop, not ``jnp.where/lax.cond, so this is notjax.jit-,jax.vmap-, orjax.grad-compatible. Call it after (not inside) any transformed code, on a concreteexponentsarray.- Parameters:
exponents –
(k,)exponents, sorted descending – exactlyLyapunovResult.exponents’s own order, sokaplan_yorke_dimension(result.exponents)is the usual call.d_total – the full system dimension, if
exponentsis only a partial spectrum (k < d_total). If given, and the partial sum never goes negative within the trackedkexponents, raisesValueErrorinstead of silently returningk– the true crossing point lies beyond what was tracked, sokwould understate the answer; track more exponents (increasek) instead. Omit (the default) whenexponentsis already the full spectrum – the “sum stays non-negative” case then correctly returnslen(exponents)(the attractor fills the whole tracked phase space, e.g. a conservative system).
- Returns:
the Kaplan-Yorke dimension, in
[0, len(exponents)].
Usage#
>>> import jax.numpy as jnp >>> from lyapax.core import kaplan_yorke_dimension >>> float(kaplan_yorke_dimension(jnp.array([0.906, 0.0, -14.57]))) # Lorenz 2.0621...
- lyapax.core.lyapunov_spectrum(step_fn_or_problem: Callable[[Array], Array] | ODEProblem, state0: Array | int | 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: LyapunovCheckpoint | None = None) LyapunovResult[source]#
Compute the (partial or full) Lyapunov spectrum of
step_fnalong the trajectory started fromstate0, via the Benettin/QR method.- Parameters:
step_fn_or_problem (either an
ODEProblem(fromode_problem/) –network_problem) – in which casestate0anddtare read off it and the second positional argument isn_steps(lyapunov_spectrum(problem, n_steps)orlyapunov_spectrum(problem, n_steps=...)) – or a plainstep_fn,state (d,) -> new_state (d,), in which casestate0,dt,n_stepsare all given explicitly (the original, lower-level call form). Must be a pure, differentiable JAX function ofstatealone - close over any parameters.state0 ((d,) initial state (pre-transient). Ignored (and may be) – omitted) when an
ODEProblemis passed.dt (time represented by one call to
step_fn. For discrete maps,) – passdt=1.0and interpret the exponents as per-iterate. When anODEProblemis passed, this may be omitted; if provided, it must match thedtstored on the problem.n_steps (number of steps to run after the transient. Must be a) – multiple of
renorm_every.k (number of leading exponents to track (
k <= d). Defaults to the) – full spectrum (k = d). Cost scales withk, notd- this is the “only the first few largest exponents” case.renorm_every (QR-renormalize every this many steps. Larger values) – reduce QR overhead but risk tangent-vector overflow/underflow for fast-growing/shrinking directions (see Choosing renorm_every) - keep small enough that
exp(|lambda_max| * renorm_every * dt)stays well within float64 range.t_transient (time to integrate (discarding tangent tracking) before) – starting the Lyapunov accumulation. Mutually exclusive with
resume(raisesValueErrorif both are given and nonzero) – a resumed run is already transient-aligned.seed (PRNG seed for the initial random tangent-vector basis. Unused) – when
resumeis given (the tangent basis comes from the checkpoint instead).check_finite (if True, raise
FloatingPointErrorwhen any running) – estimate inhistoryis non-finite (a QR diagonal underflowed to 0 or overflowed to inf, e.g. fromrenorm_everytoo large). Off by default and only usable when this function is called eagerly (not wrapped injax.jit).resume (a previous call’s
result.checkpoint, to continue that run) – instead of starting a fresh one – skips the random tangent-basis init and the transient, andhistory/timesin the returnedLyapunovResultcontinue the cumulative running estimate (not reset to zero), so concatenating two calls’historyreads as one continuous convergence curve.kmust match the checkpoint’s tracked dimension if given explicitly (defaults to it otherwise). See 16_convergence_drift.py for the run-inspect-resume workflow this enables.
- Return type:
Examples
>>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> from lyapax import lyapunov_spectrum, ode_problem, systems >>> rhs = systems.lorenz(sigma=10.0, rho=28.0, beta=8.0 / 3.0) >>> problem = ode_problem(rhs, state0=jnp.array([1.0, 1.0, 1.0]), dt=1e-2) >>> result = lyapunov_spectrum( ... problem, n_steps=50_000, renorm_every=10, t_transient=100.0, ... ) >>> result.exponents Array([ 0.906, 0. , -14.57], dtype=float64)
- lyapax.core.ode_problem(rhs, state0: Array, dt: float, integrator: str | Callable = 'rk4') ODEProblem[source]#
Build an
ODEProblemfor a plain (uncoupled) ODE – bundlesode_step’sstep_fntogether withstate0anddtsolyapunov_spectrum(problem, n_steps=...)never needsdt(orstate0) passed a second time.- Parameters:
rhs – right-hand side,
state (d,) -> dstate (d,).state0 –
(d,)initial state (pre-transient).dt – fixed step size.
integrator –
"euler","heun","rk4","rk6", or a callable(rhs, dt) -> step_fn– seelyapax.integrators.ode_step.
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 Grid-snapped vs. interpolated history reads for the
design and tradeoffs.
Tangent propagation is jax.jvp-based (the same matrix-free pattern as
lyapax.core, see Matrix-free tangent propagation): 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.
- lyapax.dde.CarryStepFn#
step(carry, _) -> (new_carry, new_state), carry = (state, buf, t, params) – exactly what lyapax.simulator.make_step_fn(…) returns.
alias of
Callable
- class lyapax.dde.DDECheckpoint(state: jnp.ndarray, buf: jnp.ndarray, t: jnp.ndarray, Y_state: jnp.ndarray, Y_buf: jnp.ndarray, cum_log_growth: jnp.ndarray, elapsed_time: jnp.ndarray, dt: float = None)[source]#
The DDE counterpart of
lyapax.core.LyapunovCheckpoint: enough state to continue alyapunov_spectrum_dderun with anotherlyapunov_spectrum_dde(..., resume=result.checkpoint)call. A DDE’s Markovian state is(state, buf)together (see the module docstring), notstatealone, so unlike the ODE checkpoint this also carriesbuf, the ring-buffer tangentY_buf, and the ring-buffer step countert.- Y_buf: Array#
(horizon, n_cvar, n_nodes, k) (or the
interpolate=Trueshape) tangent basis for the ring buffer, same raw column order asY_state– together,(Y_state, Y_buf)is the full augmented tangent basis, mirroring how(state, buf)is the full augmented primal state.
- Y_state: Array#
(n_sv, n_nodes, k) QR-orthonormalized tangent basis for
state, in raw (not display-sorted) column order – seecum_log_growth.
- buf: Array#
(horizon, n_cvar, n_nodes) (or the
interpolate=Truevalue/ derivative shape) ring buffer at the end of the checkpointed run.
- cum_log_growth: Array#
(k,) total accumulated log-growth since accumulation started, in the same raw column order as
Y_state/Y_buf– seelyapax.core.LyapunovCheckpoint.cum_log_growth.
- dt: float#
The
dtof the run that produced this checkpoint (a plain Python float, not a traced array).dtalso determines the grid-snapped delaytau_steps/horizonfor a DDE, so adtmismatch on resume would be even more likely to silently corrupt a DDE run than an ODE one – seelyapax.core.LyapunovCheckpoint.dt, which this mirrors.lyapunov_spectrum_ddechecks this onresume=and raises if it differs from the resuming call’sdt.
- elapsed_time: Array#
total elapsed accumulation time since the end of the transient, across every resumed call so far – same units as
LyapunovResult.times.- Type:
Scalar
- state: Array#
(n_sv, n_nodes) trajectory state at the end of the checkpointed run.
- t: Array#
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
tlives in the scanned carry).
- class lyapax.dde.DDEProblem(step_fn: Callable, state0: Array, buf0: Array, params: dict, dt: float, tau_steps: int | float)[source]#
Owns the carry-style plumbing (
step_fn, ring buffer, delay length) thatlyapunov_spectrum_ddeotherwise 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 withdde_problem(single/scalar DDE) ornetwork_dde_problem(coupled, uniformly-delayed network), or construct directly if you already have a carrystep_fn.- Parameters:
step_fn – see
lyapunov_spectrum_dde.state0 –
(n_sv, n_nodes)initial state (pre-transient).buf0 –
(horizon, n_cvar, n_nodes)initial ring buffer.params – closed over for tangent propagation.
dt – fixed step size.
tau_steps – delay in units of
dt(seeresolve_tau_steps) – carried for reference/inspection (problem.tau_steps,tau_eff(problem.tau_steps, problem.dt));lyapunov_spectrum_ddeitself only needsbuf0’s shape. An integer for the default (grid-snapped) delay; a non-integer float when built withinterpolate=True.
- lyapax.dde.DelayedRHS#
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.
alias of
Callable[[Array,Array],Array]
- lyapax.dde.constant_history_buf0(cvar_state0: Array, horizon: int, interpolate: bool = False) Array[source]#
Initial ring buffer under the constant-history DDE convention: the coupling-variable state is assumed equal to
cvar_state0for allt <= 0.- Parameters:
cvar_state0 –
(n_cvar, n_nodes)coupling-variable state at t=0.interpolate – build the
(value, derivative)buffer shapelyapax.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)wheninterpolate=True), ready forcarry0 = (state0, buf0, jnp.int32(0), params).
- lyapax.dde.dde_problem(rhs_delayed: Callable[[Array, Array], Array], state0: Array, tau: float, dt: float, history: Array | None = None, integrator: str | Callable = 'heun', interpolate: bool = False) DDEProblem[source]#
Build a
DDEProblemfor a simple, non-networked scalar/vector fixed-delay DDE directly fromrhs_delayed– the parallel-to-ODE front door forlyapunov_spectrum_dde, hiding the ring-buffer/carry construction thatmake_scalar_delayed_step_fn+scalar_delayed_history0otherwise ask the caller to wire up by hand.- Parameters:
rhs_delayed –
(state_now, state_delayed) -> dstate, both shape(m,)– seeDelayedRHS.state0 –
(m,)initial state (pre-transient, pre-history).tau – physical delay. Rounded to an integer number of
dtsteps (seeresolve_tau_steps) unlessinterpolate=True, in which casetauis used exactly (via Hermite-interpolated history reads, seelyapax.simulator.make_step_fn) – no rounding, no warning.dt – fixed step size.
history – optional initial ring buffer (
(tau_steps + 1, m), or theinterpolate=Truevalue/derivative shape – seescalar_delayed_history0), for a non-constant history. Defaults to the constant-history convention (state0held for allt <= 0).integrator –
"euler","heun","rk4","rk6", or a callable – seelyapax.simulator.make_step_fn.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 Array([-0.5985], dtype=float64) # dominant root of the Lambert-W characteristic equation
- lyapax.dde.lyapunov_spectrum_dde(step_fn_or_problem: Callable | DDEProblem, state0: Array | int | None = None, buf0: Array | 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[source]#
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(fromdde_problem/) –network_dde_problem) – in which casestate0,buf0,params, anddtare read off it and the second positional argument isn_steps(lyapunov_spectrum_dde(problem, n_steps)orlyapunov_spectrum_dde(problem, n_steps=...)) – or a plain carrystep_fn,step(carry, _) -> (new_carry, new_state), carry =(state, buf, t, params), in which casestate0,buf0,params,dt,n_stepsare 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
DDEProblemis passed.buf0 ((horizon, n_cvar, n_nodes) initial ring buffer (see) –
constant_history_buf0). Ignored when aDDEProblemis passed.params (closed over for tangent propagation (not differentiated --) – only
state/bufare). Ignored when aDDEProblemis passed.dt (see
lyapax.core.lyapunov_spectrum) – (same meaning). When aDDEProblemis passed,dtmay be omitted; if provided, it must match thedtstored on the problem.n_steps (see
lyapax.core.lyapunov_spectrum) – (same meaning). When aDDEProblemis passed,dtmay be omitted; if provided, it must match thedtstored on the problem.renorm_every (see
lyapax.core.lyapunov_spectrum) – (same meaning). When aDDEProblemis passed,dtmay be omitted; if provided, it must match thedtstored on the problem.seed (see
lyapax.core.lyapunov_spectrum) – (same meaning). When aDDEProblemis passed,dtmay be omitted; if provided, it must match thedtstored 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 withhorizon(delay length). Per-step cost and tangent-matrix memory areO(k), and QR isO(d_total * k^2), so for largehorizon(long delays or many network nodes) pass an explicit smallkrather 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 largehorizon) 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 largehorizon, so this is worth enabling while tuningrenorm_everyfor a new DDE system.resume (a previous call’s
result.checkpoint(aDDECheckpoint),) – 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’sbufwas already fully populated by the checkpointed run), andhistory/timesin the returnedLyapunovResultcontinue the cumulative running estimate, so concatenating two calls’historyreads as one continuous convergence curve – same contract aslyapax.core.lyapunov_spectrum’sresume. Mutually exclusive with a nonzerot_transient.kmust match the checkpoint’s tracked dimension if given explicitly (defaults to it otherwise).
- Return type:
Examples
See
dde_problemfor a full worked example (lyapunov_spectrum_dde(dde_problem(...), n_steps=...)); this function is the DDE analogue oflyapax.core.lyapunov_spectrum, generalized to the ring-buffer-augmented(state, buf)tangent pair.
- lyapax.dde.make_scalar_delayed_step_fn(rhs_delayed: Callable[[Array, Array], Array], m: int, tau_steps: int | float, dt: float, integrator: str | Callable = 'heun', interpolate: bool = False) Callable[source]#
Build a carry-based step function for a simple, non-networked fixed-delay DDE directly from a plain
rhs_delayed– noModelSpec/coupling_fnceremony needed. Mirrorslyapax.integrators.rk4_step’s role on the ODE side: a lightweight front door for the “just one system, no network” case, sitting next tolyapax.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_delayeditself, not a coupling formula – there’s no real network here).- Parameters:
rhs_delayed –
(state_now, state_delayed) -> dstate, both shape(m,)– seeDelayedRHS.m – state dimension (e.g. 1 for a scalar DDE like Mackey-Glass).
tau_steps – delay in units of
dt(seeresolve_tau_steps). An integer wheninterpolate=False; may be a non-integer float wheninterpolate=True.integrator –
"euler","heun","rk4","rk6", or a callable – seelyapax.simulator.make_step_fn.interpolate – see
lyapax.simulator.make_step_fn.
- lyapax.dde.network_dde_problem(dfun: Callable, network: Network, coupling: Callable, params: dict, state0: Array, dt: float, tau: float | None = None, history: Array | None = None, integrator: str | Callable = 'heun', interpolate: bool = False) DDEProblem[source]#
Build a
DDEProblemfor a coupled, uniformly-delayed network – the DDE counterpart oflyapax.network.network_step(see that function andNetworkfor 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_stepsread via one ring-buffer lookup per node) – per-edge heterogeneous delays combined with a customcouplingcallable are not yet supported, seelyapax.simulator.make_step_fn’s docstring.- Parameters:
dfun –
(state, coupling, params) -> dstate.network – topology; exactly one of
tauornetwork.delay_stepsmust give the uniform delay.coupling –
(cvar_state, weights, params) -> coupling.params – closed over for tangent propagation.
state0 –
(n_sv, n_nodes)initial state.dt – fixed step size.
tau – physical delay. Rounded via
resolve_tau_stepsunlessinterpolate=True(used exactly, via Hermite-interpolated history reads). Mutually exclusive withnetwork.delay_steps.history – optional initial ring buffer (
(tau_steps + 1, n_cvar, n_nodes), or theinterpolate=Truevalue/derivative shape – seeconstant_history_buf0). Defaults to the constant-history convention.integrator –
"euler","heun","rk4","rk6", or a callable – seelyapax.simulator.make_step_fn.interpolate – see
lyapax.simulator.make_step_fn. When True,tau(ornetwork.delay_steps) need not be an integer number ofdtsteps.
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 Array([-0.6578, -1.3967], dtype=float64) # symmetric, antisymmetric mode
- lyapax.dde.resolve_tau_steps(tau: float, dt: float, warn_tol: float = 1e-06) int[source]#
Round a physical delay
tauto an integer number ofdtsteps.Integer-step only, no sub-step interpolation – see Grid-snapped vs. interpolated history reads for the accuracy tradeoff this implies. Every downstream Lyapunov spectrum is computed for the rounded delay
tau_eff(tau_steps, dt), not the exacttaupassed in – usetau_effto see what delay was actually used, and decreasedtto shrink the gap.- warn_tolrelative tolerance (of
tau) above which a mismatch between
tauand the roundedtau_efftriggers a warning.
- warn_tolrelative tolerance (of
- lyapax.dde.scalar_delayed_history0(state0_now: Array, tau_steps: int | float, interpolate: bool = False)[source]#
(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.- Parameters:
interpolate – see
lyapax.simulator.make_step_fn.
Simulator#
Trimmed model-specification dataclasses.
Adapted from vbi/simulator/spec/model.py - see NOTICE.md in this directory for provenance and what was dropped.
- class lyapax.simulator.model_spec.ModelSpec(name: str, state_variables: tuple[StateVar, ...], parameters: tuple[Parameter, ...], cvar: tuple[str, ...], dfun_str: dict[str, str])[source]#
Backend-agnostic description of a dynamical system’s right-hand side.
dfun_str maps each state variable name to a bare math expression string. Only these symbols may appear: state variable names, parameter names, ‘c’ (coupling input, scalar per node - zero for uncoupled/single-node systems), and the math functions: exp, log, sin, cos, tanh, sqrt, abs, pi. No ‘np.’ / ‘jnp.’ prefix - the code generator injects the namespace.
Warning
dfun_stris compiled and run via Python’sexec()(build_jax_dfun) with no sanitization - it can execute arbitrary code. Only build aModelSpecfrom strings you (or your own code) wrote; never from untrusted input such as user uploads, network responses, or config files from unknown sources.
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.
- lyapax.simulator.step.build_jax_dfun(spec: ModelSpec) Callable[source]#
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_strexpressions are code-generated and executed viaexec()with no sanitization. This is fine for specs a caller writes themselves, but never build aModelSpecfrom untrusted input (user uploads, network data, config files from unknown sources) - the strings can execute arbitrary code.
- lyapax.simulator.step.make_step_fn(dfun: Callable, weights: Array, has_delays: bool, horizon: int, n_nodes: int, cvar_indices: tuple[int, ...], dt: float, delay_steps: Array | 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[source]#
Returns step(carry, _) -> (new_carry, new_state).
carry = (state, buf, step_int32, params)
paramstravels 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.bufis a no-op array whenhas_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, wherecoupling_at(y_stage, c) -> couplingis 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 Delays, coupling, and Runge–Kutta 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 fromy_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 timestep + cvia 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 ignorescand reads once per step – without an interpolant there is no sub-step history to read – and remains O(dt); passinterpolate=Truefor higher-order DDE stepping.- coupling_fnoptional
lyapax.coupling-style plain callable, (cvar_state, weights, params) -> coupling(seelyapax.coupling.CouplingFn). When given, replaces the hardcodedG_default/coup_a/coup_blinear formula for both the zero-delay and (uniform-delay-only, seetau_steps) delayed branches, unifying this step withlyapax.coupling’s plain-callable design.G_default/coup_a/coup_bare ignored whencoupling_fnis given. DefaultNonepreserves the exact original vendored behavior.- tau_stepsrequired alongside
coupling_fnwhenhas_delays=True – a single global delay (in steps), read via
_read_uniform_delayed_cvar(O(1) ring-buffer read) or, wheninterpolate=True,_read_uniform_delayed_cvar_interp(in which casetau_stepsneed not be an integer – seeinterpolate). Per-edge heterogeneous delays (the generaldelay_stepsmatrix) are not supported together with a customcoupling_fnyet – that combination needs an edge-aware coupling_fn signature, which does not exist yet; use the legacycoupling_fn=Nonepath (which does support per-edgedelay_steps, just with the hardcoded linear formula) until then.- interpolatereconstruct 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 Grid-snapped vs. interpolated history reads. Requires
has_delays=True,coupling_fngiven, andtau_stepsgiven (the uniform-delay path only; not supported with the legacy per-edgedelay_stepspath).tau_stepsmay then be a non-integer float: the physical delay no longer needs rounding to a whole number ofdtsteps. Requirestau_steps >= 1(tau >= dt): the per-stage delayed reads at intra-step times up tostep + 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 extradfunevaluation per step (to compute the derivative to store) and doublesbuf’s per-slot size (value and derivative, instead of just value).
- integrator
Trimmed connectivity dataclass.
Adapted from vbi/simulator/spec/connectivity.py - see NOTICE.md in this
directory for provenance and what was dropped. vbi’s CouplingSpec
(a fixed-kind enum) is not vendored here - lyapax’s coupling is a
plain callable instead (see lyapax.coupling).
- class lyapax.simulator.coupling.Connectivity(weights: ndarray, tract_lengths: ndarray | None = None, speed: float = 1.0)[source]#
Structural connectivity: weights + optional per-edge delay (tract length / speed).
tract_lengths is None(or all-zero) means a pure ODE (zero-delay) network; any non-zero entry makes it a DDE network.- delay_steps(dt: float) ndarray[source]#
(n, n) int32 array of delay expressed in integration steps.
Integer-step only (no sub-step interpolation) - see Grid-snapped vs. interpolated history reads for the accuracy tradeoff this implies.
Integrators#
General-purpose fixed-step ODE integrators.
Independent of the vendored (Euler/Heun) integrators in
lyapax.simulator.step - those are tied to the coupling/ring-buffer
plumbing for network models (M3+). These are plain state -> new_state
maps for standalone benchmark systems (Lorenz, Rössler, …), where RK4’s
better accuracy at a given dt matters for getting a clean Lyapunov-exponent
estimate (see Tier 2 of the validation guide).
- lyapax.integrators.RK6_STAGE_C = (0.0, 0.06, 0.09593333333333333, 0.1439, 0.4973, 0.9725, 0.9995, 1.0)#
Butcher
c_i(fractional position within the step) for each ofrk6_combine’s 8 stages – verified via each stage’s row-sum consistency condition (sum_j a_ij == c_i) against the source tableau, seerk6_combine’s docstring. Exposed so a coupled/delayed caller can recompute a state- or time-dependent input (e.g. network coupling, or a DDE’s delayed history lookup) at the correct intra-step point for each stage, instead of freezing it at the step’s start – see Delays, coupling, and Runge–Kutta stage order.
- lyapax.integrators.get_integrator(name: str) Callable[[Callable[[Array], Array], float], Callable[[Array], Array]][source]#
Look up one of the built-in fixed-step ODE integrator builders by name (
"euler","heun","rk4", or"rk6"), each mapping(rhs, dt) -> step_fn.
- lyapax.integrators.heun_step(rhs: Callable[[Array], Array], dt: float) Callable[[Array], Array][source]#
Fixed-step 2nd-order Heun (explicit trapezoidal).
- lyapax.integrators.ode_step(rhs: Callable[[Array], Array], dt: float, integrator: str | Callable[[Callable[[Array], Array], float], Callable[[Array], Array]] = 'rk4') Callable[[Array], Array][source]#
Build a single-step
state -> new_statemap for a plain (uncoupled) ODE, with the integration method as an explicit, swappable argument.- Parameters:
rhs – right-hand side,
state (d,) -> dstate (d,).dt – fixed step size.
integrator – one of
"euler","heun","rk4","rk6", or a callable(rhs, dt) -> step_fn(e.g.rk4_stepitself, or a user-supplied builder with the same signature).
- lyapax.integrators.rk4_step(rhs: Callable[[Array], Array], dt: float) Callable[[Array], Array][source]#
Classic fixed-step 4th-order Runge-Kutta.
- lyapax.integrators.rk6_combine(f: Callable[[Array, float], Array], state: Array, dt: float) Array[source]#
One fixed-step 6th-order Runge-Kutta update,
state -> new_state, against a derivative evaluatorf(y, c) -> dy, wherecis that stage’s Butcher node (RK6_STAGE_C) – the fractional position within the step,0at the first stage,1at the last. Purely autonomous callers (no coupling, no explicit time-dependence) can ignorec;lyapax.simulator.step’s coupled-network RK6 uses it to recompute coupling (or a delayed history lookup) fresh at each stage’s own intra-step state/time, rather than freezing it once per step (see Delays, coupling, and Runge–Kutta stage order) – this andrk6_stepshare the same tableau instead of duplicating ~30 lines of coefficients twice.The 8-stage tableau is the
a_ijcoefficients (stages 1-8) of Verner’s “efficient order 6(5)” embedded pair – the coefficients underlyingVern6in Julia’s OrdinaryDiffEq.jl – with the 6th-order solution taken as the FSAL point these stages build towards (c=1, i.e. what would be stage 9’s argument), rather than a separately weighted sum over 9 evaluated stages: an earlier draft of this function used theb1..b9weights given as comments in the Julia source directly, which turned out to be a red herring – symbolically expanding that combination’s stability function againste^z(exact rational arithmetic,state' = z*state) showed it matches only throughz^5, i.e. order 5, not 6. The 8-stage FSAL combination below was verified the same way and matchese^zthroughz^6(mismatching first atz^7, the expected order-6 signature), and separately confirmed with an empirical convergence-order check against a genuinely nonlinear system with a known exact solution – seetests/test_integrators.py.
- lyapax.integrators.rk6_step(rhs: Callable[[Array], Array], dt: float) Callable[[Array], Array][source]#
Fixed-step 6th-order Runge-Kutta, for when RK4’s O(dt^4) local error is the accuracy bottleneck (e.g. wanting a much larger dt at the same error, or a much smaller error at the same dt, than RK4 gives). See
rk6_combinefor the tableau, provenance, and correctness checks.
Adaptive#
Optional adaptive-step ODE integration via the adaptive extra
(pip install lyapax[adaptive]); not imported by lyapax’s
top-level __init__.
Adaptive-step ODE integration via diffrax.
ODE-only: diffrax has no DDE support
(patrick-kidger/diffrax#406,
open since April 2024, re-checked 2026-07-07) – lyapax.dde stays
fixed-step, see that module.
Not imported by lyapax’s top-level __init__, since diffrax is an
optional dependency (the adaptive extra) – import this module directly:
from lyapax.adaptive import diffrax_adaptive_step.
This is not a speed optimization – measured slower, not faster, than
fixed-step integration. At matched accuracy (not matched dt/rtol,
which isn’t a fair comparison), this integrator was 2-4x slower than
rk4_step/rk6_step on Lorenz, and up to ~4.5x slower on GPU for that
same small system, across every system tested including large (d=1500-3000)
ones – the gap narrows at scale but a fixed-step win was never overturned.
Root cause: the internal accept/reject jax.lax.while_loop pays real
per-step overhead (PID controller update, solver.step bookkeeping) that
a fixed-step raw step doesn’t. Checked and still true even on a relaxation
oscillator (Van der Pol, mu=30, sharply time-varying stiffness unlike
Lorenz’s roughly uniform one) where fixed-step “wastes” steps during slow
phases: RK4 at matched accuracy was still ~4x faster (0.31s vs 1.28s),
because avoiding those extra steps only pays off if each one was expensive
– for a small system they’re nearly free regardless of stiffness, so
adaptive’s fixed control-flow overhead per step is never amortized. A win
would need both sharply time-varying stiffness and an expensive per-step
cost (large state dimension) together; not tested, and not assumed here.
Reach for this integrator for tolerance-driven accuracy control (no
more manual dt-convergence sweeps) and for its being the only
forward-mode-differentiable adaptive option here, not for throughput. See
Adaptive integration is not a speed optimization for a summary of these findings.
Design: diffrax_adaptive_step(...) returns a builder with the same
(rhs, dt) -> step_fn signature as the fixed-step builders in
lyapax.integrators (rk4_step, etc.), so it drops straight into
lyapax.ode_problem(rhs, state0, dt, integrator=diffrax_adaptive_step(...))
with no changes to lyapax.core – core.py’s _advance already
treats step_fn as an opaque state -> new_state map called once per
raw step and differentiated with jax.jvp; whether that one call does a
single fixed-size update or an internal accept/reject/step-size-adaptation
loop is invisible to it. dt keeps its usual lyapax meaning here too –
the fixed sampling/renormalization interval lyapunov_spectrum advances by
per raw step – and is not the adaptive integrator’s own internal step
size, which varies within [t, t + dt] under rtol/atol control.
The internal accept/reject loop is a jax.lax.while_loop over diffrax’s
low-level solver.step primitive (the same primitive diffrax.diffeqsolve
is built on), mirroring diffrax._integrate.loop’s own step/clip-to-boundary
structure closely enough to inherit its correctness, but scoped to a single
dt advance with no SaveAt/dense-output machinery. See
tests/test_adaptive_diffrax.py for the isolated primitive check this is
built on, and two findings that shaped this implementation:
``scan_kind=”lax”`` is required. A diffrax Runge-Kutta solver’s default construction (
scan_kind=None, internally"checkpointed") iterates its stages via anequinox-checkpointedwhile_loopwrapped in acustom_vjpthat rejects forward-modejax.jvpoutright (TypeError).scan_kind="lax"uses a plainjax.lax.scaninstead, which is jvp-compatible. Enforced here, not left to the caller.Reverse-mode differentiation (``jax.grad``/``jax.jacrev``) does not work end-to-end through this integrator, even though the Lyapunov engine’s own tangent propagation is forward-mode (
jax.jvp, seelyapax.core’s module docstring): the outer while_loop here has a data-dependent trip count (however many accept/reject iterations a givenrtol/atol/system needs), and JAX’s reverse-mode AD cannot replay alax.while_loopwith a dynamic trip count backward at all – a fundamental JAX limitation, not a diffrax quirk or a precision issue. Differentiating a Lyapunov exponent w.r.t. a handful of system parameters still works, but requiresjax.jacfwd/jax.jvp(forward-mode over the parameter), notjax.grad. Confirmed empirically:jax.gradraisesValueError: Reverse-mode differentiation does not work for lax.while_loop ... with dynamic start/stop values;jax.jacfwdof the same function matches a central finite-difference reference to ~1e-8.
diffrax’s own step-size controller (PIDController) already applies
jax.lax.stop_gradient internally to its step-size-adjustment factor and
initial-step guess (see its source) – the “stop_gradient on the step-size
decision” concern from the design note is therefore already handled by using
PIDController, not something this module adds on top.
- lyapax.adaptive.diffrax_adaptive_step(solver=None, rtol: float = 1e-06, atol: float = 1e-09, dt0: float | None = None, max_steps: int = 4096) Callable[[Callable[[Array], Array], float], Callable[[Array], Array]][source]#
Build a
lyapax.ode_step-compatible adaptive integrator:(rhs, dt) -> step_fnwherestep_fn(state) -> new_stateadvances by exactlydtusing an adaptive-step diffrax solver internally.- Parameters:
solver – a diffrax solver instance, e.g.
diffrax.Dopri5(), constructed withscan_kind="lax". Defaults todiffrax.Dopri5(scan_kind="lax"). RaisesValueErrorif given a solver withoutscan_kind="lax"– see the module docstring.atol (rtol,) –
diffrax.PIDControllerstep-size tolerances.dt0 – initial internal step-size guess. Defaults to diffrax’s own heuristic initial-step selection (
PIDController.init).max_steps – safety cap on internal accept+reject iterations per
dtadvance, so a misbehavingrhs/tolerance combination loops a bounded number of times insidejax.lax.while_looprather than running away. Unlikediffrax.diffeqsolve(which returns whatever was reached whenmax_stepsis hit unlessthrow=True), exhaustingmax_stepshere always raises viaequinox.error_if– returning a state from an earlier time than the requestedt1whilelyapunov_spectrumadvances its elapsed-time denominator by the fulldtregardless would silently mis-associate exponent with elapsed time, whichcheck_finite=Truecannot detect (a truncated step is still finite).equinox.error_ifraises immediately in eager mode and underjax.jit/jax.vmap; see its docstring for theEQX_ON_ERRORenvironment variable if you need to disable this check or substitute NaN instead. Raisemax_stepsor loosenrtol/atolif this triggers on a well-posed system.
Usage#
>>> import lyapax >>> from lyapax.adaptive import diffrax_adaptive_step >>> problem = lyapax.ode_problem( ... rhs, state0, dt=0.1, integrator=diffrax_adaptive_step(rtol=1e-8, atol=1e-10), ... ) >>> result = lyapax.lyapunov_spectrum(problem, n_steps=...)
Coupling#
Network coupling as plain callables.
Design note: coupling here is deliberately not
a closed enum dispatched through hardcoded if/elif branches, unlike vbi’s
CouplingSpec.kind + build_coupling (duplicated across its numpy and
JAX backends, with jr_sigmoidal even special-cased to skip the G
multiply). Each builder below just returns a plain function:
coupling_fn(cvar_state, weights, params) -> coupling
that lyapax.network.make_network_step_fn calls directly. A user’s own
function with that exact signature is a first-class coupling - no
registry, no “kind” string to add to a dispatch table, no library changes.
- param cvar_state:
(n_cvar, n_nodes)coupling-variable state – the instantaneous state for zero-delay networks, a delayed array of the same shape for DDE networks.- param weights:
(n_nodes, n_nodes)weights[tgt, src].- param params:
dict - the global coupling strength
Gis read from here (params.get("G", G_default)), the same convention the vendored step function uses, soGcan be swept or differentiated without being baked into a closure constant.- returns:
(n_cvar, n_nodes)
- lyapax.coupling.kuramoto_coupling(alpha: float = 0.0, G_default: float = 1.0) Callable[[Array, Array, dict], Array][source]#
c[0, tgt] = (G/N) * sum_src w[tgt, src] * sin(theta_src - theta_tgt + alpha).
Requires exactly one coupling variable (the phase);
cvar_state[0]is taken astheta.
- lyapax.coupling.linear_coupling(a: float = 1.0, b: float = 0.0, G_default: float = 1.0) Callable[[Array, Array, dict], Array][source]#
c[cvar, tgt] = G * a * sum_src(w[tgt, src] * x[cvar, src]) + b.
- lyapax.coupling.sigmoidal_coupling(a: float = 1.0, b: float = 0.0, midpoint: float = 0.0, sigma: float = 1.0, G_default: float = 1.0) Callable[[Array, Array, dict], Array][source]#
c[cvar, tgt] = G * a * sum_src(w[tgt, src] * sigm(x[cvar, src])) + b, sigm(x) = 1 / (1 + exp(-(x - midpoint) / sigma)).
Network#
Coupled, zero-delay networks - wires a dfun + a coupling callable
into the flat state -> new_state shape lyapax.core.lyapunov_spectrum
expects.
Built on the vendored ring-buffer step (lyapax.simulator.make_step_fn,
has_delays=False) via a thin carry-to-flat adapter, not a second,
independent Euler/Heun implementation – the adapter is the single place
that bridges the carry-based step to the flat map
lyapax.core.lyapunov_spectrum wants, so there is only one copy of the
integrators to maintain.
Why this still doesn’t need lyapax.dde’s carry-aware tangent engine:
for has_delays=False the vendored step’s buf never changes (it’s a
structural no-op) and t is never read, so closing over a fixed
placeholder buf/t and only threading state through
lyapunov_spectrum’s jacfwd is exactly equivalent to differentiating
the whole carry – there is no delay-buffer tangent information to lose.
- class lyapax.network.Network(weights: Array, cvar_indices: tuple[int, ...], delay_steps: int | float | Array | None = None)[source]#
Topology for a coupled network: what M3+ call
weights/cvar_indices(and, for delayed networks,delay_steps), gathered into one named object instead of separate positional arguments repeated acrossnetwork_step/network_dde_problemcall sites.- Parameters:
weights –
(n_nodes, n_nodes),weights[tgt, src].cvar_indices – indices into the state-variable axis selecting which rows of
statefeed the coupling.delay_steps –
Nonefor a zero-delay network, a number (int, orfloatwhen interpolating – seelyapax.simulator.make_step_fn) for a single global delay (in units ofdt), or an(n_nodes, n_nodes)integer matrix for per-edge delays. Only relevant to the DDE path (network_dde_problem); ignored bynetwork_step.
- lyapax.network.make_network_step_fn(dfun: Callable, weights: Array, cvar_indices: tuple[int, ...], params: dict, dt: float, coupling_fn: Callable[[Array, Array, dict], Array], integrator: str | Callable = 'heun') Callable[[Array], Array][source]#
Build a flat
state_flat (n_sv * n_nodes,) -> new_state_flatstep function for use withlyapax.core.lyapunov_spectrum.- Parameters:
dfun –
(state, coupling, params) -> dstate, e.g. fromlyapax.simulator.build_jax_dfun(model_spec).stateandcouplingare(n_sv, n_nodes)/(n_cvar, n_nodes).weights –
(n_nodes, n_nodes),weights[tgt, src].cvar_indices – indices into the state-variable axis selecting which rows of
statefeed the coupling (ModelSpec.cvar_indices).params – closed over for this step function. To sweep over params via
jax.vmapinstead, usemake_parametrized_network_step_fn+lyapax.sweep.sweep_lyapunov_spectrum(M6).coupling_fn – see
lyapax.coupling- any callable with signature(cvar_state, weights, params) -> couplingworks, including a user-defined one.integrator –
"euler","heun","rk4","rk6", or a callable(state, dfun, coupling, dt, params) -> new_state– seelyapax.simulator.make_step_fn.
- lyapax.network.make_parametrized_network_step_fn(dfun: Callable, weights: Array, cvar_indices: tuple[int, ...], dt: float, coupling_fn: Callable[[Array, Array, dict], Array], integrator: str | Callable = 'heun') Callable[[Array, dict], Array][source]#
Same wiring as
make_network_step_fn, exceptparamsis a plain call-time argument,(state_flat, params) -> new_state_flat, instead of being closed over at construction time – for M6’sjax.vmapparameter sweeps (lyapax.sweep.sweep_lyapunov_spectrum), whereparamsneeds to be data the vmapped function can batch over, not a baked-in Python constant. Works becauselyapax.simulator.make_step_fnalready threadsparamsthrough the scanned carry rather than closing over it (see that function’s docstring) – this adapter was the only piece still closing params in early, so it’s the only piece that needed to change.- dfun, weights, cvar_indices, coupling_fn, integratorsee
make_network_step_fn.
- lyapax.network.network_problem(dfun: Callable, network: Network, coupling: Callable[[Array, Array, dict], Array], params: dict, state0: Array, dt: float, integrator: str | Callable = 'heun') ODEProblem[source]#
Build an
ODEProblemfor a zero-delay coupled network – bundlesnetwork_step’sstep_fntogether withstate0anddt, the samelyapunov_spectrum(problem, n_steps=...)recipeode_problemgives the plain-ODE case (seeODEProblem).- Parameters:
dfun –
(state, coupling, params) -> dstate.network – topology, see
Network.coupling – any
lyapax.coupling-style callable,(cvar_state, weights, params) -> coupling.params – closed over for this step function.
state0 – initial state, either already flat (
n_sv * n_nodes,) or in the network’s natural(n_sv, n_nodes)shape – flattened here either way, sincenetwork_step’s step function operates on flat state.dt – fixed step size.
integrator –
"euler","heun","rk4","rk6", or a callable – seelyapax.simulator.make_step_fn.
Examples
>>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> from lyapax.core import lyapunov_spectrum >>> from lyapax.coupling import linear_coupling >>> from lyapax.network import Network, network_problem >>> from lyapax.simulator import ModelSpec, StateVar, Parameter, build_jax_dfun >>> weights = jnp.array([[0., 1., 0., 1.], ... [1., 0., 1., 0.], ... [0., 1., 0., 1.], ... [1., 0., 1., 0.]]) # 4-cycle graph >>> model = ModelSpec( ... name="linear_node", ... state_variables=(StateVar("x", default_init=0.0),), ... parameters=(Parameter("gamma", -2.0),), ... cvar=("x",), ... dfun_str={"x": "gamma * x + c"}, ... ) >>> dfun = build_jax_dfun(model) >>> network = Network(weights=weights, cvar_indices=model.cvar_indices) >>> problem = network_problem( ... dfun, network, linear_coupling(a=1.0, b=0.0), ... params={"gamma": -2.0, "G": 0.5}, ... state0=jnp.array([0.3, -0.1, 0.2, -0.4]), dt=1e-3, ... ) >>> result = lyapunov_spectrum(problem, n_steps=20_000, renorm_every=10) >>> result.exponents Array([-1., -2., -2., -3.], dtype=float64)
- lyapax.network.network_step(dfun: Callable, network: Network, coupling: Callable[[Array, Array, dict], Array], params: dict, dt: float, integrator: str | Callable = 'heun') Callable[[Array], Array][source]#
Build a flat
state_flat -> new_state_flatstep for a zero-delay coupled network, naming the four conceptsmake_network_step_fnotherwise mixes into one long argument list separately: model dynamics (dfun), topology (network), the coupling rule (coupling), and the numerical method (integrator).A thin wrapper – it calls straight through to
make_network_step_fn, readingweights/cvar_indicesoffnetwork. Prefer this overmake_network_step_fnin new code; the latter remains for direct, lower-level use.- Parameters:
dfun –
(state, coupling, params) -> dstate, e.g. fromlyapax.simulator.build_jax_dfun(model_spec).network – topology, see
Network.coupling – any
lyapax.coupling-style callable,(cvar_state, weights, params) -> coupling.params – closed over for this step function.
dt – fixed step size.
integrator –
"euler","heun","rk4","rk6", or a callable – seelyapax.simulator.make_step_fn.
- lyapax.network.network_step_parametrized(dfun: Callable, network: Network, coupling: Callable[[Array, Array, dict], Array], dt: float, integrator: str | Callable = 'heun') Callable[[Array, dict], Array][source]#
network_step’s counterpart forjax.vmapparameter sweeps – seemake_parametrized_network_step_fn, which this wraps.
Systems#
Benchmark systems for validating the Lyapunov engine.
Plain flat-vector JAX functions, deliberately independent of the vendored
ModelSpec/coupling machinery in lyapax.simulator - these are single-node,
uncoupled textbook systems, so routing them through the string-expression
codegen built for networked models would only add indirection. Each entry
here corresponds to a tier of the validation suite; see
Validation: how lyapax’s results are checked for the reference Lyapunov values and
citations.
Tier 4 (DDE) entries have a different shape than the rest: rhs_delayed
(state_now, state_delayed) -> dstate instead of rhs(state) -> dstate,
since they need both the current and the delayed state (see
lyapax.dde.DelayedRHS). Pair with
lyapax.dde.make_scalar_delayed_step_fn, not the plain integrators in
lyapax.integrators – mirrors this module’s relationship to
lyapax.integrators.rk4_step for the ODE case: plain math here, a
step-function builder elsewhere.
- lyapax.systems.henon_map(a: float = 1.4, b: float = 0.3) Callable[[Array], Array][source]#
Constant Jacobian determinant -b => sum(LE) = ln|b| exactly (Tier 0.3).
- lyapax.systems.linear_scalar_dde(a: float = 1.0)[source]#
x’(t) = -a*x(t-tau). Dominant Lyapunov exponent solves the transcendental characteristic equation lambda = -a*exp(-lambda*tau) (Lambert W: lambda = W(-a*tau)/tau) – a closer-to-analytic reference for isolating DDE tangent-propagation bugs from Mackey-Glass’s nonlinear chaos (Tier 4.2).
- lyapax.systems.linear_system(A: Array) Callable[[Array], Array][source]#
rhs(x) = A @ x. Exact Lyapunov spectrum = Re(eigvals(A)) (Tier 0.1).
- lyapax.systems.logistic_map(r: float) Callable[[Array], Array][source]#
x_{n+1} = r x (1-x). At r=4, exact LE = ln(2) (Tier 0.2).
- lyapax.systems.lorenz(sigma: float = 10.0, rho: float = 28.0, beta: float = 2.6666666666666665) Callable[[Array], Array][source]#
Constant trace(J) = -(sigma+1+beta) => sum(LE) exactly known (Tier 1.1); lambda1 has a well-documented published value (Tier 2).
- lyapax.systems.mackey_glass(beta: float = 0.2, gamma: float = 0.1, n: float = 10.0)[source]#
x’(t) = beta*x(t-tau)/(1+x(t-tau)^n) - gamma*x(t). The classic beta=0.2, gamma=0.1, n=10, tau=17 parameter set is chaotic – the system Farmer (1982) used to introduce the discretized-map method for DDE Lyapunov spectra (Tier 4.1).
Sweep#
Batched parameter/initial-condition sweeps via jax.vmap.
Mirrors vbi’s JaxSweeper pattern (batch a simulation over a grid of
parameter values) without reusing its code, per the vendoring decision.
The surface needed here is much smaller than a
general sweeper: lyapax.simulator.make_step_fn’s carry already threads
params through as data rather than closing over it (see that function’s
docstring), so a parameter
sweep is just jax.vmap applied to a function that calls
lyapax.core.lyapunov_spectrum once, with params (and optionally
state0) as the vmapped argument. No new tangent-propagation or
QR machinery – this is a batching wrapper around the existing engine, not
a third one.
The one piece of the network path that still closed over params at
construction time was lyapax.network.make_network_step_fn (a thin
adapter, not the tangent engine); make_parametrized_network_step_fn
(in that module) is the call-time-params sibling this needs.
- lyapax.sweep.sweep_lyapunov_spectrum(step_fn: Callable[[Array, dict], Array], state0: Array, params_batch: dict, dt: float, n_steps: int, k: int | None = None, renorm_every: int = 1, t_transient: float = 0.0, seed: int = 0, state0_batch: Array | None = None) LyapunovResult[source]#
Batched
lyapunov_spectrumover a grid of parameter values (and, optionally, a matching grid of initial conditions) via onejax.vmapcall, rather than a Python loop making one call per grid point (as in e.g.examples/05_kuramoto_sync.py/09_kuramoto_delayed_network.py).- step_fn
(state, params) -> new_state–paramsmust be a call-time argument, not closed over. See
lyapax.network.make_parametrized_network_step_fnfor the network-coupling case; a hand-written function with this signature works too (same “plain callable, no registry” extension point aslyapax.coupling).- state0(d,) initial state, shared across the whole sweep, unless
state0_batchis given.- params_batchpytree matching one
paramsdict’s structure, but every leaf carries an extra leading batch axis of size
n_sweep– e.g.{"G": jnp.linspace(0, 4, 13), "omega": jnp.broadcast_to(omega, (13, n_nodes))}to sweepGat fixedomega.- dt, n_steps, k, renorm_every, t_transient, seedsee
lyapax.core.lyapunov_spectrum- shared (not swept) across the whole grid.- state0_batchoptional
(n_sweep, d)initial conditions, one row per grid point, if the initial condition should vary alongside
params_batchrather than staying fixed atstate0.
- Returns:
LyapunovResult, every field with an extra leading batch axis of size
n_sweep– e.g.result.exponents.shape == (n_sweep, k).
Examples
Sweep the coupling strength
Gof a Kuramoto network in onejax.vmapcall instead of a Python loop overlyapunov_spectrum:>>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> from lyapax.coupling import kuramoto_coupling >>> from lyapax.network import Network, network_step_parametrized >>> from lyapax.simulator import ModelSpec, StateVar, Parameter, build_jax_dfun >>> from lyapax.sweep import sweep_lyapunov_spectrum >>> n_nodes = 6 >>> omega = jnp.linspace(-1.0, 1.0, n_nodes) >>> weights = jnp.ones((n_nodes, n_nodes)) - jnp.eye(n_nodes) >>> model = ModelSpec( ... name="kuramoto", ... state_variables=(StateVar("theta", default_init=0.0),), ... parameters=(Parameter("omega", 0.0),), ... cvar=("theta",), ... dfun_str={"theta": "omega + c"}, ... ) >>> dfun = build_jax_dfun(model) >>> network = Network(weights=weights, cvar_indices=model.cvar_indices) >>> dt = 1e-2 >>> state0 = jnp.linspace(0.0, 2 * jnp.pi, n_nodes, endpoint=False) >>> G_values = jnp.linspace(0.0, 4.0, 13) >>> step_p = network_step_parametrized(dfun, network, kuramoto_coupling(alpha=0.0), dt) >>> params_batch = {"omega": jnp.broadcast_to(omega, (13, n_nodes)), "G": G_values} >>> result = sweep_lyapunov_spectrum( ... step_p, state0, params_batch, dt=dt, n_steps=5_000, ... renorm_every=10, k=2, t_transient=50.0, ... ) >>> result.exponents.shape (13, 2)
- step_fn
Utils#
Small helpers for demos/examples – not part of the core LE engine.
- lyapax.utils.simulate_trajectory(step_fn: Callable[[Array], Array], state0: Array, n_steps: int, dt: float | None = None) Array | tuple[Array, Array][source]#
Run
step_fnforn_stepsiterations vialax.scan(fast, compiled – unlike a Python for-loop calling a JAX function repeatedly).Returns the trajectory including the initial state: shape
(n_steps + 1, d). Ifdtis given, also returns the matching time axis as(t, traj),t = arange(n_steps + 1) * dt–dtis otherwise opaque tostep_fn(baked into its closure), so this is the only place that can hand it back to the caller.