Semi-Explicit Index-1 SDAEs
solve_semi_explicit_sdae integrates the Itô system
with fixed steps and diagonal noise in the differential state. The algebraic
Jacobian \(g_z\) must be square and nonsingular along the chosen root branch.
Arrays and pytrees are supported for both y and z.
import jax
import jax.numpy as jnp
from tinydiffeq import EulerMaruyama, SaveAt, solve_semi_explicit_sdae
def drift(y, z, t, args, p, algebraic_aux):
value = p["mu"] * z
saved_aux = {
"variance_scale": algebraic_aux["variance_scale"],
"flow": value,
}
return value, saved_aux
def diffusion(y, z, t, args, p, algebraic_aux):
return p["sigma"] * z
def constraint(y, z, t, args, p):
context = {"variance_scale": p["sigma"] ** 2 * z**2}
return z - y, context
sol = solve_semi_explicit_sdae(
drift,
diffusion,
constraint,
EulerMaruyama(),
0.0,
1.0,
jnp.asarray(1.0),
jnp.asarray(0.8),
key=jax.random.key(0),
n_steps=256,
p={"mu": jnp.asarray(0.4), "sigma": jnp.asarray(0.3)},
save_at=SaveAt(steps=True),
)
Algorithm and convergence
EulerMaruyama and SRA1 are supported. At a consistent node,
Euler–Maruyama updates
followed by a root solve for \(g(y_{n+1},z_{n+1},t_{n+1})=0\). Locally writing
the unique root as \(z=Z(y,t)\) shows this is exactly Euler–Maruyama on the
reduced SDE — no Itô correction is missing, since z is reconstructed from
the constraint rather than advanced by a separate SDE. It has strong order
0.5 and weak order 1 under the usual regularity assumptions on the reduced
system (strong order 1 for additive noise). A per-node root error
\(\epsilon\) should be \(O(\sqrt h)\) or smaller to preserve strong order 0.5;
normal root tolerances are far tighter.
SRA1 applies the Rößler additive-noise stochastic Runge–Kutta scheme to
the same reduced SDE: each step evaluates the drift at the node and at one
internal stage, with a root solve restoring consistency at the stage time
\(t_n + \tfrac34 h\) and at the endpoint — two root solves per step. Its
strong order 1.5 requires the diffusion to be independent of the state; in
the SDAE setting, z depends on y through the constraint, so the
requirement is that diffusion(y, z, t) depends only on t. As in
solve_sde, the contract is documented, not runtime-checked, and the
endpoint diffusion evaluation reuses the current node's (y, z, context)
with the endpoint time. Milstein is deliberately not wired in: its
commutativity condition and derivative correction do not translate cleanly
through the implicit reduction \(z = Z(y, t)\).
Randomness, aux, and AD
Noise follows the solve_sde sampling contract via
solver.sample_noise(y_0, key, n_steps, dt, dtype): Brownian increments
for EulerMaruyama, an independent \((\Delta W, \Delta Z)\) pair for SRA1.
Arrays use the draw shape (n_steps,) + y_0.shape; pytrees use one flat
draw partitioned in deterministic leaf order. A fixed key defines a fixed
path, so JVP/VJP with respect to y_0 and p are pathwise derivatives
under common random numbers. The key is not differentiable.
z_0 is a root guess, receives zero tangent, and selects a local root
branch. Algebraic solves use the same LMRootSolver configuration and
implicit-AD contract as deterministic DAEs — see
Nonlinear-solve and AD contract:
residual-only stopping, CONVERGED with residual norm strictly below the
root atol, and nlls-gram's direct square LU() implicit derivative.
The algebraic function may return (residual, algebraic_aux); that internal
context is passed to both drift and diffusion but is not stored. The drift
may return (drift_value, saved_aux), and only that saved aux becomes
sol.aux: steps mode stores it at every consistent node, endpoint mode
evaluates it only at the final node. Its derivatives include both direct
parameter dependence and dependence through the implicit root. Invalid
algebraic context at initialization sets ok=False before any stochastic
step; invalid saved aux terminates at the previous consistent node (steps
mode) or zero-fills aux at the retained endpoint (endpoint mode).
sol.num_root_solves counts logical active root calls — for SRA1, a
stage and an endpoint root per step plus the initial consistency solve —
and sol.num_root_steps sums their LM updates; both have exact-zero
tangents.
Only SaveAt(t_1=True) and SaveAt(steps=True) are supported: stochastic
paths are rough, so deterministic dense interpolation between nodes would be
mathematically wrong. A failed root freezes the last consistent prefix, sets
ok=False, and pads the remaining static buffer with zero implicit
tangents, so masked lanes preserve successful JVPs/VJPs under vmap. For
that contract, pass failure_ad_reference=(y_ref, z_ref, t_ref, p_ref) at a
point where the residual, context, and saved-aux maps are finite and
differentiable — the same semantics as the
DAE reference point.