Lyapunov Spectrum — Tangent-Space Stability Probe¶
The monitor.lyapunov module carries two public surfaces. The first,
LyapunovGuard, is a lightweight observer that tracks the
Lyapunov function V(θ) of a Kuramoto network, its time derivative,
and the basin-of-attraction flag — a per-step cost that is cheap
enough to call inside a control loop. The second, lyapunov_spectrum,
is the heavy kernel: the full Benettin 1980 / Shimada-Nagashima
1979 Lyapunov spectrum reconstructed from the variational equation
dQ/dt = J(θ)·Q with periodic QR reorthogonalisation. Both live in
the same file so the observer can lean on the same Jacobian helper
without a second import.
This is the sixth module migrated to the AttnRes-level standard:
five-language backend chain, bit-exact parity across Rust / Julia / Go
against the NumPy reference, Mojo parity within the text-stream
round-trip, multi-backend benchmark, and pytest.mark.slow stability
tests that verify long-run invariants (Σ λ_i ≈ ⟨tr J⟩, Kaplan-Yorke
bounds, contracting-network regime).
1. Mathematical formalism¶
1.1 Kuramoto variational equation¶
The Sakaguchi-Kuramoto network with optional external driver reads
Linearising around a trajectory θ(t) gives the variational
equation for a small perturbation δθ:
with the Jacobian
The driver contributes only to the diagonal, because
∂/∂θ_i [ζ sin(Ψ − θ_i)] = −ζ cos(Ψ − θ_i) and the off-diagonal
cross-derivatives vanish.
The public API and direct backend bridge wrappers reject non-zero K_ii before backend dispatch or optional runtime loading. A
diagonal coupling entry is a self-interaction, not a physical
oscillator pair. Leaving it in the variational path would add a
spurious sin(-α_ii) self-torque and would make the observer's
Lyapunov potential/basin check depend on an interaction that the
Kuramoto graph interpretation does not permit.
1.2 Benettin 1980 QR algorithm¶
The Lyapunov spectrum {λ_1, …, λ_N} quantifies the exponential
growth rates of N orthogonal perturbation vectors evolved alongside
the reference trajectory. The Benettin 1980 / Shimada-Nagashima 1979
algorithm works on all exponents simultaneously:
- Initialise
Q(0) = I ∈ ℝ^{N×N}— an identity matrix whoseNrows are seed perturbation vectors. - Evolve the pair
(θ, Q)viadθ/dt = f(θ),dQ/dt = J(θ) Qforqr_intervalintegration steps. The columns ofQare then the image of the unit vectors under the tangent-space flow but are no longer orthonormal. - Reorthogonalise
Qvia Modified Gram-Schmidt (MGS), keeping the diagonal ofR(the column-norm factors) in a running log-sum. - Repeat until total integrated time
T = n_steps · dt. Thei-th exponent is
Step 3 is what prevents the integrated Q from collapsing onto the
single most expanding direction: without periodic reorthogonalisation,
finite-precision arithmetic would align all N vectors within a few
Lyapunov times and only λ_1 would survive.
1.3 Row-oriented MGS convention¶
All five SPO backends orthonormalise the rows of Q, not the
columns. This matches the Rust kernel (spo_kernel::lyapunov_spectrum_rust)
which is the performance reference, and it means the Python fallback
computes np.linalg.qr(Q.T) and transposes back. The R-diagonal is
identical to column-MGS on Q.T, so the exponents are
direction-independent.
1.4 Integration: RK4 throughout¶
The ODE for (θ, Q) is integrated with classic RK4:
θ is wrapped to [0, 2π) after each full step to keep the phases
in a deterministic range; the Jacobian is periodic in θ, so the
wrap does not affect J(θ) nor the spectrum.
1.5 Invariants the spectrum must respect¶
- Sum rule.
Σ_i λ_i = ⟨tr J(θ(t))⟩_T(time-average of the Jacobian trace). A broken RK4 or QR implementation shows up as a drift in this sum long before it shows up as visibly wrong exponents. - Kaplan-Yorke bound.
0 ≤ d_KY ≤ Nalways, withd_KY = k + (Σ_{i≤k} λ_i)/|λ_{k+1}|wherekis the largest index such that the partial sum is non-negative. - Ordering.
λ_1 ≥ λ_2 ≥ … ≥ λ_Nby convention. - Finiteness. Under bounded coupling / bounded driver, every
λ_iis finite; anyinfornanmeans a numerical blow-up, almost always fromlog(|R_ii|)with|R_ii| < 1e-300.
1.6 Regime interpretation¶
| Pattern | Meaning |
|---|---|
All λ_i < 0 |
Fully contracting — strong coupling dominates. |
λ_1 = 0, rest < 0 |
Neutral on the synchronisation manifold. |
λ_1 > 0 |
Chaotic attractor. |
λ_i ≈ 0 for several i |
Quasi-periodic torus of matching dimension. |
All λ_i ≈ 0 |
Uncoupled or near-neutral. |
2. API¶
The module exports:
from scpn_phase_orchestrator.monitor.lyapunov import (
ACTIVE_BACKEND, # str — currently serving backend
AVAILABLE_BACKENDS, # list — all resolved backends, order = preference
LyapunovGuard, # class — cheap V(θ), dV/dt, in_basin observer
LyapunovState, # dataclass — return type of LyapunovGuard.evaluate
lyapunov_spectrum, # fn — full Lyapunov spectrum
)
2.1 LyapunovGuard¶
class LyapunovGuard:
def __init__(self, basin_threshold: float = math.pi / 2): ...
def evaluate(self, phases: NDArray, knm: NDArray) -> LyapunovState: ...
def reset(self) -> None: ...
evaluate(phases, knm)computesV(θ) = −(1/2N) Σ_{ij} K_ij cos(θ_i − θ_j)together withdV/dt ≈ (V_n − V_{n−1})(finite-difference derivative from the previous call), the booleanin_basinflag, and the largest geodesic phase gap on theS¹coupling graph.reset()clears the cached previousVso the nextevaluatereportsdV/dt = 0.- The guard is stateful but not thread-safe — use one guard per simulation thread.
2.2 lyapunov_spectrum¶
def lyapunov_spectrum(
phases_init: NDArray,
omegas: NDArray,
knm: NDArray,
alpha: NDArray,
dt: float = 0.01,
n_steps: int = 1000,
qr_interval: int = 10,
zeta: float = 0.0,
psi: float = 0.0,
) -> NDArray: ...
Returns (N,) exponents sorted descending. knm and alpha are
row-major (N, N) matrices; flat versions are also accepted (the
dispatcher reshapes them for each backend).
Public validation runs before float coercion. Phase/frequency vectors and
coupling/lag matrices must contain finite real non-boolean numeric values;
numeric strings such as "1.0", complex dtypes, object-complex aliases, and
boolean aliases are rejected before backend dispatch.
Guidelines on the integrator parameters:
| Parameter | Typical range | Effect |
|---|---|---|
dt |
1e-3 … 2e-2 |
RK4 stable; smaller dt tightens per-step accuracy. |
n_steps |
500 … 20 000 |
Longer T = n_steps · dt tightens exponent averaging. |
qr_interval |
5 … 50 |
Shorter intervals stabilise QR but cost more mat-mul. |
zeta, psi |
application-specific | Driver adds −ζ cos(Ψ − θ) to the Jacobian diagonal. |
2.3 LyapunovState¶
@dataclass
class LyapunovState:
V: float # Lyapunov function at current phases
dV_dt: float # V_current − V_previous; 0 after reset()
in_basin: bool # max phase gap < basin_threshold on coupled graph
max_phase_diff: float
3. Backend fallback chain¶
The module resolves backends in the order
Rust → Mojo → Julia → Go → Python at import time.
The first backend that loads without raising ImportError,
RuntimeError, or OSError becomes ACTIVE_BACKEND. Python is
always appended as the guaranteed fallback.
3.1 Loader probes¶
| Backend | Probe | Artefact |
|---|---|---|
| Rust | from spo_kernel import lyapunov_spectrum_rust |
spo_kernel wheel via maturin. |
| Mojo | _ensure_exe() → mojo/lyapunov_mojo |
Stand-alone executable from mojo build. |
| Julia | import juliacall; then Main.include("julia/lyapunov.jl") |
juliacall Python binding + Julia 1.11. |
| Go | ctypes.CDLL("go/liblyapunov.so") |
C-shared library from go build. |
| Python | Pure NumPy — no external dependencies. | Built in. |
3.2 Dispatcher surface¶
from scpn_phase_orchestrator.monitor import lyapunov as ly_mod
ly_mod.ACTIVE_BACKEND # e.g. "rust"
ly_mod.AVAILABLE_BACKENDS # e.g. ["rust", "mojo", "julia", "go", "python"]
ly_mod.ACTIVE_BACKEND = "python" # force a specific backend (tests)
3.3 Semantic equivalence¶
All five backends produce the same spectrum to within the tolerances listed in the parity tests:
| Backend vs NumPy reference | Tolerance (atol) | Reason |
|---|---|---|
| Rust | 1e-12 |
Shared f64 arithmetic; no text serialisation. |
| Julia | 1e-12 |
juliacall passes Float64 arrays directly. |
| Go | 1e-12 |
ctypes passes double* by pointer; no round-trip. |
| Mojo | 1e-6 |
Subprocess stdin ↔ stdout text; atof round-trips f64. |
| Python | exact | Python is the reference. |
Direct Go, Julia, and Mojo wrappers revalidate backend spectra before returning
them. Accepted spectra must contain exactly N finite real exponents and must
already be sorted in descending Lyapunov order. Malformed length, complex,
non-finite, boolean-alias, numeric-string, or unsorted backend payloads are
rejected at the adapter boundary rather than being passed to downstream
stability logic.
The Mojo subprocess bridge additionally requires the SPEC executable to emit
exactly N scalar stdout lines before numeric parsing; missing, extra, blank,
or non-scalar lines fail closed before spectrum validation.
4. Per-backend build notes¶
4.1 Rust (spo-kernel/crates/spo-engine/src/lyapunov.rs)¶
The Rust kernel is the performance reference:
- Rayon parallelism.
compute_rhsandcompute_jqiterate over theNrows of the output withpar_iter_mut/par_chunks_mut. On a 16-core host the per-row work splits well aboveN = 16. - Two-pass MGS.
modified_gram_schmidtiteratesfor _pass in 0..2to suppress catastrophic cancellation when the perturbation vectors collapse (Daniel et al. 1976). - Alpha fast path.
alpha_zerois computed once; when true the RHS uses pre-computedsin θ_i,cos θ_iand aK_ij · (sin θ_j · cos θ_i − cos θ_j · sin θ_i)expansion, avoiding asinper pair. - Driver fast path.
zs_psi,zc_psi = ζ·sin Ψ, ζ·cos Ψare pre-computed so the driver term collapses to two scalar multiplies. - RK4 buffer reuse. All eight stage buffers (
k1_p..k4_p,k1_q..k4_q) plustmp_p,tmp_q,sin_theta,cos_theta,qare allocated once and reused across the step loop. - FFI boundary.
spo-ffi/src/lib.rswraps the kernel aslyapunov_spectrum_rust(phases, omegas, knm_flat, alpha_flat, dt, n_steps, qr_interval, zeta, psi) -> PyArray1<f64>. The Python dispatcher passes flat(N·N,)matrices row-major.
4.2 Mojo (mojo/lyapunov.mojo)¶
Mojo 0.26 lacks both a QR decomposition and a matrix multiplication in its standard library. The port therefore re-implements
kuramoto_rhs— O(N²) evaluation withsin/cosfromstd.math.kuramoto_jacobian— includes the driver diagonal term.mat_mul— naive triple loop, row-major (sinceN ≤ 64in practice the cost is acceptable and stays out of Mojo's alias analyser).column_mgs— two-pass MGS on tangent-vector columns matching the Rust convention.sort_descending— in-place insertion sort.
The executable reads a single whitespace-separated line from stdin
tagged SPEC followed by the integer / float scalar parameters and
the four flat arrays. It prints N lines of f64 to stdout.
_lyapunov_mojo.py serialises arguments with repr(float(x)), so
the only precision loss is the ASCII round-trip (captured by the
1e-6 tolerance in the parity tests). The Python bridge rejects stdout
unless it contains exactly one scalar line per Lyapunov exponent.
The direct Mojo, Julia, and Go bridge functions validate all array and scalar
inputs before loading optional runtimes: phase/frequency vectors and
coupling/lag matrices must be finite real float64 arrays with boolean aliases
rejected, complex/object-complex aliases rejected, numeric-string aliases
rejected before float coercion, dt must be positive, n_steps and
qr_interval must be integer counts, zeta must be non-negative, psi must
be finite, and the coupling diagonal must remain zero.
Build:
4.3 Julia (julia/lyapunov.jl)¶
Julia's LinearAlgebra.qr is first-class, so the port applies
F = qr(Q); Q = Matrix(F.Q); R = F.R directly to the tangent-vector
columns. The rest of the code mirrors Python: kuramoto_rhs,
kuramoto_jacobian (with the driver diagonal) and a standard RK4 step.
The Julia package is loaded once per Python process by juliacall and
cached in _JULIA_MODULE, so amortised call overhead is a single
dispatch plus a Float64 array round-trip.
4.4 Go (go/lyapunov.go)¶
Compiled with go build -buildmode=c-shared -o liblyapunov.so
lyapunov.go. Allocations are per-call because a single
libgolang.so runtime cannot hand out Go-owned memory safely over
the FFI boundary — instead, the caller pre-allocates the output
buffer and passes the pointer. Internals:
kuramotoJacobian— matches Python line-for-line including the driver diagonal term.matMul— naiveO(N³)row-major; nogonumdependency so the shared object stays under 2 MB.rowMGS— two-pass MGS on rows, with1e-300floor.sort.Sort(sort.Reverse(sort.Float64Slice(...)))— stdlib sort.- FFI export:
LyapunovSpectrum(phasesInit, omegas, knmFlat, alphaFlat, n, dt, nSteps, qrInterval, zeta, psi, outPtr) int.
4.5 Python (src/.../monitor/lyapunov.py)¶
The reference fallback is pure NumPy / np.linalg.qr:
k1_q = _kuramoto_jacobian(phases, knm, alpha, zeta, psi) @ Q
...
Q_t, R = np.linalg.qr(Q.T)
Q = Q_t.T
The only NumPy call outside the Jacobian is np.linalg.qr, which is
LAPACK under the hood. At N = 32, n_steps = 500, qr_interval = 10
it takes ~217 ms per call — fine for diagnostic runs, slow for
online control.
5. Benchmarks¶
Measured on an Ubuntu 24.04 host with 16-thread x86_64 CPU, NumPy
2.3.4 / MKL, Julia 1.11.2, Go 1.23.4, Mojo 0.26.2, spo_kernel
built in release mode with maturin build --release. Every entry
is ms per call for a fresh (N × N) Kuramoto problem with
dt = 0.01, qr_interval = 10, calls = 2 (warm-up plus measured
pair). Re-run with
python benchmarks/lyapunov_benchmark.py --sizes 4 8 16 32 --n-steps 500.
| N | n_steps | rust (ms) | mojo (ms) | julia (ms) | go (ms) | python (ms) |
|---|---|---|---|---|---|---|
| 4 | 500 | 49.87 | 89.22 | 1.91 | 2.53 | 67.30 |
| 8 | 500 | 65.45 | 123.80 | 6.57 | 8.28 | 69.51 |
| 16 | 500 | 72.63 | 122.40 | 22.12 | 28.89 | 77.41 |
| 32 | 500 | 157.81 | 185.12 | 166.53 | 165.61 | 216.84 |
Observations:
- Go and Julia lead at small N. For
N ≤ 16they dominate because the per-call setup cost is minimal (no process spawn, no rayon thread-pool priming). The naive triple-loopmatMulin Go is faster than the LAPACK dispatch overhead in Python / Rust. - Rust dominates at large N. At
N = 32rayon amortises across all 16 threads and the rust backend matches Julia / Go while bringing a lot more headroom (tests atN = 64, n_steps = 2000clock rust at ~900 ms, go at ~2.6 s). - Mojo subprocess overhead is the floor. Each call forks a
process, parses a text stream, and prints
Nlines of ASCII — this dominates belowN = 32. Mojo is retained in the chain for parity coverage rather than raw throughput. - Python is competitive at small N. The RK4 loop is only a few
lines of NumPy, so dispatch overhead is small; the
N³scaling catches up byN = 32.
Raw benchmark JSON is saved by
--output /tmp/ly_bench.json; SPO CI publishes this file as a build
artifact so regressions across backends are visible in release notes.
The release reference suite also runs a deterministic parity gate:
That gate records every declared language slot in a single JSON matrix:
Rust, Mojo, Julia, Go, and Python. Available backends are timed and compared
against the forced Python reference on the same Kuramoto/Sakaguchi problem.
Unavailable auxiliary toolchains remain explicit records with a reason, instead
of disappearing from the report. Acceptance requires the Python reference to be
present and every available backend to satisfy its tolerance (1e-12 for
Rust/Julia/Go, 1e-6 for Mojo's text round-trip, exact equality for Python
against itself). The stored reference-suite snapshot exposes this as
lyapunov_polyglot.
6. Usage examples¶
6.1 Quickstart¶
import numpy as np
from scpn_phase_orchestrator.monitor.lyapunov import lyapunov_spectrum
rng = np.random.default_rng(42)
N = 6
phases = rng.uniform(0, 2 * np.pi, N)
omegas = rng.normal(0.0, 0.2, N)
knm = rng.uniform(0.5, 1.5, (N, N))
np.fill_diagonal(knm, 0.0)
alpha = np.zeros((N, N))
spectrum = lyapunov_spectrum(
phases, omegas, knm, alpha,
dt=0.01, n_steps=1000, qr_interval=10,
)
print(spectrum) # (N,) array, descending
print("λ_max =", spectrum[0])
print("dim_KY ≤", N)
6.2 Stability guard inside a control loop¶
from scpn_phase_orchestrator.monitor.lyapunov import LyapunovGuard
guard = LyapunovGuard(basin_threshold=np.pi / 3)
for step in range(n_steps):
state = guard.evaluate(phases, knm)
if not state.in_basin:
# Trigger a stronger control pulse — phases drifted out of
# the Lyapunov basin.
zeta = min(zeta * 1.2, ZETA_MAX)
phases = integrator.step(phases, omegas, knm, zeta, psi, alpha)
dV/dt is available as a soft-indicator of local contraction — a
sustained positive sign signals the system is climbing the Lyapunov
landscape and will diverge unless the driver clamps it.
6.3 Driver-response sweep¶
zetas = np.linspace(0.0, 5.0, 21)
lmax = []
for zeta in zetas:
spec = lyapunov_spectrum(
phases, omegas, knm, alpha,
zeta=zeta, psi=0.0,
n_steps=2000,
)
lmax.append(spec[0])
critical_zeta = zetas[np.argmin(np.abs(np.asarray(lmax)))]
critical_zeta is the driver strength at which λ_1 crosses zero
— the phase transition between synchronised and chaotic regimes for
this particular (ω, K, α) tuple.
6.4 Forcing a specific backend¶
from scpn_phase_orchestrator.monitor import lyapunov as ly_mod
saved = ly_mod.ACTIVE_BACKEND
try:
ly_mod.ACTIVE_BACKEND = "julia"
spec = lyapunov_spectrum(phases, omegas, knm, alpha)
finally:
ly_mod.ACTIVE_BACKEND = saved
This pattern is how the parity tests pin a specific backend for comparison against the Python reference.
7. Tests¶
Three dedicated test files cover the module:
7.1 tests/test_lyapunov_algorithm.py — algorithmic properties¶
Pins down the analytic behaviour that must hold regardless of backend. Classes:
TestShapeAndOrdering— output shape, descending sort, finiteness.TestAnalyticLimits— zero-coupling / strong-coupling limits, single oscillator neutrality.TestDriverResponse— adding a contracting driver must not increaseλ_max.TestSakaguchiPhaseLag— non-zeroαmust perturb the spectrum; this test was added after a pre-fix bug where the Euler reference silently ignoredαin the Jacobian.TestKuramotoJacobianInternals—_kuramoto_jacobianmust include−ζ cos(Ψ − θ_i)on the diagonal whenζ ≠ 0and must useK_ij cos(θ_j − θ_i − α_ij)for off-diagonals.TestRK4Convergence— halvingdtshould shift the spectrum by less than0.1on a random problem (RK4 is 4th-order indt).TestRandomProperty— Hypothesis property test acrossN ∈ [2, 6]and random seeds; sorting and finiteness must hold for every draw.TestInputValidation— empty phases → empty spectrum;qr_interval = 0must raise through the Rust FFI; non-zeroK_iiis rejected before backend dispatch.
7.2 tests/test_lyapunov_backends.py — cross-backend parity¶
Runs the same problem through each available backend and compares against the forced Python reference. Classes:
TestRustParity— three seeds plus a driver-on case at1e-12.TestJuliaParity— two seeds at1e-12.TestGoParity— three seeds plus a driver + phase-lag case at1e-12.TestMojoParity— two seeds at1e-6(text round-trip).TestCrossBackendConsistency— iterates everyAVAILABLE_BACKENDSentry and asserts the tolerance-matched diff vs Python.TestDispatcherResolution—ACTIVE_BACKENDis the first available;"python"is always in the chain.TestDirectBackendBoundaryContracts— direct adapter inputs, backend output spectra, and Mojo stdout cardinality fail closed at the bridge boundary.
7.3 tests/test_lyapunov_benchmark.py — benchmark parity gate¶
Pins the release benchmark contract for the polyglot Lyapunov spectrum matrix. The test requires records for all declared language slots, verifies Python as the reference, and checks that every available backend reports bounded numerical error before the reference-suite gate can pass.
7.4 tests/test_lyapunov_stability.py — long-run invariants¶
@pytest.mark.slow. Three scenarios:
test_long_run_all_attracting— 10 000 steps on a strongly contracting network; all exponents must be< −0.5.test_sum_tracks_trace_of_jacobian—Σ λ_i ≈ ⟨tr J⟩up to a loose tolerance; any drift implies a bug in RK4 or QR.test_kaplan_yorke_dimension_bounded—0 ≤ d_KY ≤ N.
Run with:
pytest tests/test_lyapunov_algorithm.py tests/test_lyapunov_backends.py
pytest tests/test_lyapunov_stability.py -m slow
Pipeline wiring is validated by the pre-existing
tests/test_lyapunov_spectrum.py::TestLyapunovSpectrumPipelineWiring
which threads UPDEEngine.step → lyapunov_spectrum → spectrum.
8. Failure modes and caveats¶
8.1 Numerical fragility¶
|R_ii| < 1e-300. Floored to1e-300beforelogto keep the exponents finite. Hitting this floor repeatedly (visible as a constant−log(10^300) ≈ −690in the running log) means the perturbation vectors have collapsed — eitherqr_intervalis too large or the dynamics are pathologically contracting.- Forward-Euler in older
LyapunovGuard.evaluate. The observer uses consecutive-call finite differences fordV/dt. Between calls the caller owns the simulation step, sodV/dtreflects the caller's integration cadence, not RK4. - Mojo text round-trip.
atofdecodesrepr(float(x))which preservesf64to its 17 significant-digit representation; the resulting1e-6parity bound is an empirical observation, not a theoretical guarantee.
8.2 Ordering at degeneracy¶
When two exponents are exactly equal (λ_i = λ_{i+1}), the QR
factorisation's column order is sign-ambiguous. All five backends
sort descending before returning, which breaks the tie
deterministically; the underlying subspace is the correct observable,
not the individual vectors.
8.3 Large N scaling¶
matMul and kuramotoJacobian are both O(N²) per stage with 4
RK4 stages per step and O(N²) per QR. Total cost scales as
O(N³ · n_steps / qr_interval + N² · n_steps · 4). For N > 64 the
Rust backend's rayon parallelism is essential; Python / Go / Julia
/ Mojo all serialise and become 10× slower past that threshold.
8.4 Rust kernel uses RK4 everywhere¶
Unlike earlier versions of the Python reference, all backends now
use RK4 + driver-diagonal Jacobian. A comment in the docstring of
_lyapunov_spectrum_python spells this out — otherwise an innocent
"simplification" back to Euler would silently reintroduce the
pre-fix bug where the Euler reference disagreed with Rust by several
units.
8.5 Backend acquisition order¶
If a higher-priority backend is broken (e.g., Mojo executable
missing its runtime .so), the dispatcher still advances to the
next backend. The noisy exception is caught inside _resolve_backends
and the offending backend is simply omitted from AVAILABLE_BACKENDS
— there is no hard failure at import time.
9. Complexity¶
| Routine | Time | Space |
|---|---|---|
kuramoto_rhs |
O(N²) |
O(N) |
kuramoto_jacobian |
O(N²) |
O(N²) |
mat_mul (J · Q) |
O(N³) |
O(N²) (scratch) |
| RK4 step | 4 · (kuramoto_rhs + mat_mul) |
O(N²) per stage |
Row-MGS (qr_interval⁻¹) |
O(N³) every qr_interval steps |
O(N) (diagR) |
| Total for one spectrum | O(n_steps · (N³ + 4·N² + N³/qrI)) |
O(N²) peak residency |
At the default n_steps = 1000, qr_interval = 10, N = 16, each call
does roughly 4 × 10⁵ FLOPs — sub-millisecond on Rust, tens of ms
on Python.
10. References¶
- Benettin, G., Galgani, L., Giorgilli, A., Strelcyn, J.-M. (1980). Lyapunov characteristic exponents for smooth dynamical systems and for Hamiltonian systems; a method for computing all of them. Meccanica 15, 9–30.
- Shimada, I., Nagashima, T. (1979). A numerical approach to ergodic problem of dissipative dynamical systems. Progress of Theoretical Physics 61 (6), 1605–1616.
- Pikovsky, A., Politi, A. (2016). Lyapunov Exponents: A Tool to Explore Complex Dynamics. Cambridge University Press.
- van Hemmen, J. L., Wreszinski, W. F. (1993). Lyapunov function for the Kuramoto model of nonlinearly coupled oscillators. Journal of Statistical Physics 72 (1–2), 145–166.
- Daniel, J. W., Gragg, W. B., Kaufman, L., Stewart, G. W. (1976). Reorthogonalisation and stable algorithms for updating the Gram-Schmidt QR factorisation. Mathematics of Computation 30 (136), 772–795.
- Kuramoto, Y. (1984). Chemical Oscillations, Waves, and Turbulence. Springer Series in Synergetics 19.
- Sakaguchi, H., Kuramoto, Y. (1986). A soluble active rotator model showing phase transitions via mutual entrainment. Progress of Theoretical Physics 76 (3), 576–581.
- Schreiber, T. (2000). Measuring information transfer. Physical Review Letters 85 (2), 461.
11. Changelog¶
-
2026-05-31 — Corrected the QR convention across Python, Rust, Go, Julia, and Mojo to orthonormalise tangent-vector columns for
dQ/dt = J(θ)Q. Autonomous systems now seed the first tangent column with the normalized global phase-shift direction so finite Lyapunov horizons preserve the exact neutral mode while transverse modes contract. -
2026-04-18 — Migrated to the AttnRes-level standard. The Python reference now uses RK4 + driver-diagonal Jacobian + periodic Gram-Schmidt QR. The Julia, Go, and Mojo ports were rewritten to match Rust bit-for-bit; the Mojo port re-implements the matrix and QR kernels in pure Mojo. Three new test files (
test_lyapunov_algorithm.py,test_lyapunov_backends.py,test_lyapunov_stability.py) plus the multi-backend benchmark.