tinydiffeq 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tinydiffeq/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Tiny differentiable ODE/SDE solvers for JAX.
2
+
3
+ solve_ode integrates dx/dt = f(x, t, args, p) with fixed-step (Euler, RK4)
4
+ or adaptive (Tsit5 + IController) explicit Runge-Kutta methods inside one
5
+ bounded lax.scan of exactly max_steps iterations, so shapes are static and
6
+ solves are differentiable in BOTH forward and reverse mode (including
7
+ reverse-over-forward) with O(max_steps) memory. SaveAt picks the output:
8
+ the endpoint, cubic-Hermite interpolation onto a fixed grid, or the raw
9
+ padded step rows. solve_sde is fixed-step Euler-Maruyama with presampled
10
+ diagonal noise. States are arrays (scalar or vector); pytree states,
11
+ implicit/stiff solvers, PID control, events, dense output, and adjoint
12
+ methods are non-goals — use diffrax for those.
13
+ """
14
+
15
+ from tinydiffeq.controllers import ConstantStepSize, IController
16
+ from tinydiffeq.interpolation import hermite_interpolate
17
+ from tinydiffeq.ode import solve_ode
18
+ from tinydiffeq.quadrature import cumulative_trapezoid
19
+ from tinydiffeq.saveat import SaveAt
20
+ from tinydiffeq.sde import solve_sde
21
+ from tinydiffeq.solution import Solution
22
+ from tinydiffeq.solvers import RK4, Euler, EulerMaruyama, Tsit5
23
+
24
+ __all__ = [
25
+ "solve_ode",
26
+ "solve_sde",
27
+ "Euler",
28
+ "RK4",
29
+ "Tsit5",
30
+ "EulerMaruyama",
31
+ "ConstantStepSize",
32
+ "IController",
33
+ "SaveAt",
34
+ "Solution",
35
+ "hermite_interpolate",
36
+ "cumulative_trapezoid",
37
+ ]
@@ -0,0 +1,72 @@
1
+ from dataclasses import dataclass
2
+
3
+ import jax
4
+ import jax.numpy as jnp
5
+
6
+ # Controllers decide accept/reject and the next step size from the embedded
7
+ # error estimate. Contract: adapt(x0, x1, err, dt_used, dt_prev, order) ->
8
+ # (accept, dt_next), where dt_used is the horizon-clipped step the solver
9
+ # actually took and dt_prev is the unclipped carried step size. All numeric
10
+ # fields are pytree data leaves, so changing tolerances never recompiles.
11
+
12
+ SAFETY, MIN_FACTOR, MAX_FACTOR = 0.9, 0.2, 5.0
13
+
14
+
15
+ @jax.tree_util.register_dataclass
16
+ @dataclass(frozen=True)
17
+ class ConstantStepSize:
18
+ """Accept every step and keep the carried step size unchanged."""
19
+
20
+ uses_error_estimate = False
21
+
22
+ def adapt(self, x0, x1, err, dt_used, dt_prev, order):
23
+ return jnp.asarray(True), dt_prev
24
+
25
+
26
+ @jax.tree_util.register_dataclass
27
+ @dataclass(frozen=True)
28
+ class IController:
29
+ """Integral step-size controller with max-norm error.
30
+
31
+ Accept iff ``E = max(|err| / (atol + rtol * max(|x0|, |x1|))) <= 1``
32
+ (forced accept once the step reaches ``dtmin``), and propose
33
+ ``dt_next = dt_used * clip(safety * E**(-1/order), factormin, factormax)``
34
+ clipped to ``[dtmin, dtmax]``. This is the classic integral controller —
35
+ equal to diffrax's ``PIDController`` at its default coefficients
36
+ (pcoeff=0, dcoeff=0); there is no proportional term, hence the name.
37
+ """
38
+
39
+ rtol: float
40
+ atol: float
41
+ dtmin: float = 1e-10
42
+ dtmax: float = float("inf")
43
+ safety: float = SAFETY
44
+ factormin: float = MIN_FACTOR
45
+ factormax: float = MAX_FACTOR
46
+
47
+ uses_error_estimate = True
48
+
49
+ def adapt(self, x0, x1, err, dt_used, dt_prev, order):
50
+ dtype = jnp.result_type(x0, float)
51
+ rtol = jnp.asarray(self.rtol, dtype)
52
+ atol = jnp.asarray(self.atol, dtype)
53
+ dtmin = jnp.asarray(self.dtmin, jnp.result_type(dt_used))
54
+ dtmax = jnp.asarray(self.dtmax, jnp.result_type(dt_used))
55
+ safety = jnp.asarray(self.safety, dtype)
56
+ factormin = jnp.asarray(self.factormin, dtype)
57
+ factormax = jnp.asarray(self.factormax, dtype)
58
+ scale = atol + rtol * jnp.maximum(jnp.abs(x0), jnp.abs(x1))
59
+ # The controller is wrapped in stop_gradient: accept/reject is a
60
+ # non-differentiable branch either way, the gradient of E**(-1/order)
61
+ # blows up at the exact-zero error of a flat-start policy, and the
62
+ # d(dt)/dtheta term only slides sample points along the visited
63
+ # trajectory -- irrelevant to a residual that must vanish at every
64
+ # state. The states themselves remain fully differentiable through
65
+ # the RK stages.
66
+ E = jax.lax.stop_gradient(jnp.max(jnp.abs(err) / scale))
67
+ accept = (E <= 1.0) | (dt_used <= dtmin)
68
+ factor = jnp.clip(
69
+ safety * jnp.maximum(E, 1e-12) ** (-1.0 / order), factormin, factormax
70
+ )
71
+ dt_next = jnp.clip(jax.lax.stop_gradient(dt_used) * factor, dtmin, dtmax)
72
+ return accept, dt_next
@@ -0,0 +1,47 @@
1
+ import jax.numpy as jnp
2
+
3
+ # Cubic Hermite interpolation over the raw attempt rows of the bounded scan.
4
+ # The knot times are nondecreasing with exact duplicates (rejected attempts
5
+ # and the post-horizon frozen tail repeat the previous row), so no compaction
6
+ # is needed: searchsorted(side="right") - 1 lands on the LAST duplicate at or
7
+ # before each query, giving a positive-width bracket everywhere except the
8
+ # frozen tail. Degenerate (zero-width) brackets return the left knot -- flat
9
+ # extrapolation beyond the reached time when the budget was exhausted. The
10
+ # bracketing indices are integer and non-differentiable, consistent with the
11
+ # stop-gradiented step controller (the sliding-knot d(dt)/dtheta term is
12
+ # deliberately excluded); values differentiate fully through xL, xR, fL, fR.
13
+
14
+
15
+ def hermite_interpolate(ts_query, knot_ts, knot_xs, knot_fs):
16
+ """Cubic Hermite interpolation of ``(knot_ts, knot_xs, knot_fs)`` at
17
+ ``ts_query``, where ``knot_fs`` holds the time derivatives at the knots.
18
+
19
+ ``knot_ts`` must be nondecreasing; duplicate knots are allowed and
20
+ queries falling on a zero-width bracket return the left knot value.
21
+ Queries outside the knot span clamp to the boundary knot values (flat
22
+ extrapolation) rather than evaluating the cubic outside its bracket.
23
+ """
24
+ n = knot_ts.shape[0]
25
+ idx = jnp.clip(jnp.searchsorted(knot_ts, ts_query, side="right") - 1, 0, n - 2)
26
+ t_left, t_right = knot_ts[idx], knot_ts[idx + 1]
27
+ x_left, x_right = knot_xs[idx], knot_xs[idx + 1]
28
+ f_left, f_right = knot_fs[idx], knot_fs[idx + 1]
29
+ width = t_right - t_left
30
+ degenerate = width <= 0.0
31
+ # double-where: divide by the safe width BEFORE branching so neither the
32
+ # primal nor its jvp/vjp ever sees a 0/0.
33
+ width_safe = jnp.where(degenerate, 1.0, width)
34
+ s = jnp.clip((ts_query - t_left) / width_safe, 0.0, 1.0)
35
+
36
+ extra = knot_xs.ndim - 1
37
+
38
+ def bc(a):
39
+ return a.reshape(a.shape + (1,) * extra)
40
+
41
+ s_, w_, deg_ = bc(s), bc(width_safe), bc(degenerate)
42
+ h00 = (1.0 + 2.0 * s_) * (1.0 - s_) ** 2
43
+ h10 = s_ * (1.0 - s_) ** 2
44
+ h01 = s_**2 * (3.0 - 2.0 * s_)
45
+ h11 = s_**2 * (s_ - 1.0)
46
+ value = h00 * x_left + h10 * w_ * f_left + h01 * x_right + h11 * w_ * f_right
47
+ return jnp.where(deg_, x_left, value)
tinydiffeq/ode.py ADDED
@@ -0,0 +1,176 @@
1
+ import inspect
2
+
3
+ import jax
4
+ import jax.numpy as jnp
5
+
6
+ from tinydiffeq.controllers import ConstantStepSize
7
+ from tinydiffeq.interpolation import hermite_interpolate
8
+ from tinydiffeq.saveat import SaveAt
9
+ from tinydiffeq.solution import Solution
10
+
11
+
12
+ def identity_project(x):
13
+ return x
14
+
15
+
16
+ def canonicalize_field(f, name="f"):
17
+ # The vector field may take (x), (x, t), (x, t, args), or (x, t, args, p),
18
+ # always in that order; it is wrapped into the canonical 4-arg form here
19
+ # so the compiled code is identical for all four. Uninspectable
20
+ # signatures (or *args) are assumed 4-arg.
21
+ try:
22
+ signature = inspect.signature(f)
23
+ except (TypeError, ValueError):
24
+ arity = 4
25
+ else:
26
+ arity = 0
27
+ for parameter in signature.parameters.values():
28
+ if parameter.kind in (
29
+ inspect.Parameter.POSITIONAL_ONLY,
30
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
31
+ ):
32
+ arity += 1
33
+ elif parameter.kind == inspect.Parameter.VAR_POSITIONAL:
34
+ arity = 4
35
+ break
36
+ if arity < 1 or arity > 4:
37
+ raise ValueError(
38
+ f"{name} must take 1 to 4 positional arguments: "
39
+ "(x), (x, t), (x, t, args), or (x, t, args, p)"
40
+ )
41
+ if arity == 1:
42
+ return lambda x, t, args, p: f(x)
43
+ if arity == 2:
44
+ return lambda x, t, args, p: f(x, t)
45
+ if arity == 3:
46
+ return lambda x, t, args, p: f(x, t, args)
47
+ return f
48
+
49
+
50
+ def solve_ode(
51
+ f,
52
+ solver,
53
+ t0,
54
+ t1,
55
+ x0,
56
+ *,
57
+ p=None,
58
+ args=None,
59
+ dt0=None,
60
+ saveat=None,
61
+ controller=None,
62
+ max_steps=4096,
63
+ project=None,
64
+ ):
65
+ """Integrate ``dx/dt = f(x, t, args, p)`` from ``t0`` to ``t1 > t0``.
66
+
67
+ The vector field may be declared ``f(x)``, ``f(x, t)``, ``f(x, t, args)``,
68
+ or ``f(x, t, args, p)`` — always in that order. ``x`` is an array state
69
+ (scalar or vector), ``args`` is pass-through data that is by convention
70
+ not an AD target, and ``p`` holds differentiable parameters (any pytree);
71
+ jvp/vjp with respect to ``p`` and ``x0`` are first-class.
72
+
73
+ Fixed and adaptive stepping share one bounded ``lax.scan`` of exactly
74
+ ``max_steps`` iterations, so shapes are static and curvature-dependent
75
+ step counts never retrace. ``dt0`` is required (no auto-initial-step
76
+ heuristic). Each attempt is clipped to the remaining horizon; the clipped
77
+ step also feeds the controller's next-step proposal, which doubles as the
78
+ growth guard — near-flat fields otherwise grow steps into quarter-horizon
79
+ leaps. ``project`` (e.g. a positivity clamp, assumed idempotent) is
80
+ applied at every point where ``f`` is evaluated and to every accepted
81
+ state. Returns a :class:`Solution`; ``sol.ok`` is False if the budget ran
82
+ out before ``t1`` (outputs are then the reached prefix, never poisoned).
83
+
84
+ The time dtype follows ``jnp.result_type(x0, float)``; the library never
85
+ sets ``jax_enable_x64`` — do that in your application.
86
+ """
87
+ if dt0 is None:
88
+ raise ValueError("dt0 is required (tinydiffeq has no initial-step heuristic)")
89
+ if saveat is None:
90
+ saveat = SaveAt(t1=True)
91
+ if controller is None:
92
+ controller = ConstantStepSize()
93
+ if project is None:
94
+ project = identity_project
95
+ if controller.uses_error_estimate and not solver.has_error_estimate:
96
+ raise ValueError(
97
+ f"{type(controller).__name__} needs an embedded error estimate, "
98
+ f"which {type(solver).__name__} does not provide"
99
+ )
100
+ f = canonicalize_field(f)
101
+
102
+ x0 = jnp.asarray(x0)
103
+ tdt = jnp.result_type(x0, float)
104
+ t0 = jnp.asarray(t0, tdt)
105
+ t1 = jnp.asarray(t1, tdt)
106
+ dt0 = jnp.asarray(dt0, tdt)
107
+ t_eps = 4.0 * jnp.finfo(tdt).eps * jnp.maximum(1.0, jnp.abs(t1))
108
+ # Summing ~max_steps rounded steps can leave t short of t1 by up to
109
+ # ~max_steps * eps, so a step whose remaining horizon is within that
110
+ # slack of the desired dt is stretched to land on t1 exactly; otherwise
111
+ # dt0 = (t1 - t0)/n with max_steps = n would strand a one-ulp sliver.
112
+ t_slack = max_steps * t_eps
113
+
114
+ def g(x, t):
115
+ return f(project(x), t, args, p)
116
+
117
+ need_f = solver.fsal or (saveat.ts is not None)
118
+ f_init = g(x0, t0) if need_f else jnp.zeros_like(x0)
119
+
120
+ def body(carry, _):
121
+ t, x, dt, f_cur, done, num_accepted = carry
122
+ remaining = t1 - t
123
+ h = jnp.where(remaining <= dt + t_slack, jnp.maximum(remaining, 1e-12), dt)
124
+ x1, f1, err = solver.step(g, t, x, h, f_cur if need_f else None, project)
125
+ if need_f and f1 is None:
126
+ f1 = g(x1, t + h)
127
+ accept, dt_next = controller.adapt(x, x1, err, h, dt, solver.order)
128
+ advance = accept & ~done
129
+ x_new = jnp.where(advance, x1, x)
130
+ t_new = jnp.where(advance, t + h, t)
131
+ f_new = jnp.where(advance, f1, f_cur) if need_f else f_cur
132
+ dt_new = jnp.where(done, dt, dt_next)
133
+ done_new = done | (t_new >= t1 - t_eps)
134
+ num_new = num_accepted + advance.astype(jnp.int32)
135
+ carry_new = (t_new, x_new, dt_new, f_new, done_new, num_new)
136
+ if saveat.t1:
137
+ out = None
138
+ elif saveat.steps:
139
+ out = (t_new, x_new, advance)
140
+ else:
141
+ out = (t_new, x_new, f_new, advance)
142
+ return carry_new, out
143
+
144
+ carry0 = (t0, x0, dt0, f_init, jnp.asarray(False), jnp.asarray(0, jnp.int32))
145
+ (t_final, x_final, _, _, done, num_accepted), rows = jax.lax.scan(
146
+ body, carry0, None, length=max_steps
147
+ )
148
+
149
+ if saveat.t1:
150
+ return Solution(ts=t_final, xs=x_final, ok=done, num_accepted=num_accepted)
151
+
152
+ if saveat.steps:
153
+ ts_s, xs_s, adv_s = rows
154
+ else:
155
+ ts_s, xs_s, fs_s, adv_s = rows
156
+ ts_all = jnp.concatenate([t0[None], ts_s])
157
+ xs_all = jnp.concatenate([x0[None], xs_s])
158
+ accepted = jnp.concatenate([jnp.ones((1,), bool), adv_s])
159
+
160
+ if saveat.steps:
161
+ if saveat.fill == "inf":
162
+ ts_all = jnp.where(accepted, ts_all, jnp.inf)
163
+ keep = accepted.reshape(accepted.shape + (1,) * (xs_all.ndim - 1))
164
+ xs_all = jnp.where(keep, xs_all, jnp.inf)
165
+ return Solution(
166
+ ts=ts_all,
167
+ xs=xs_all,
168
+ ok=done,
169
+ num_accepted=num_accepted,
170
+ accepted=accepted,
171
+ )
172
+
173
+ fs_all = jnp.concatenate([f_init[None], fs_s])
174
+ ts_query = jnp.asarray(saveat.ts, tdt)
175
+ xs_query = hermite_interpolate(ts_query, ts_all, xs_all, fs_all)
176
+ return Solution(ts=ts_query, xs=xs_query, ok=done, num_accepted=num_accepted)
@@ -0,0 +1,31 @@
1
+ import jax
2
+ import jax.numpy as jnp
3
+
4
+
5
+ def cumulative_trapezoid(g, ts, *, substeps=1):
6
+ """Cumulative composite-trapezoid integral of a time-only ``g(t)`` on the
7
+ (possibly nonuniform) sorted grid ``ts``.
8
+
9
+ Each grid interval is subdivided into ``substeps`` uniform panels, so the
10
+ quadrature error shrinks with ``substeps`` without changing the output
11
+ grid. Returns ``(integral, values)`` where ``integral[k]`` approximates
12
+ the integral of ``g`` from ``ts[0]`` to ``ts[k]`` (``integral[0] = 0``)
13
+ and ``values = g(ts)``; ``g`` may return any array shape, which is
14
+ appended to the leading grid axis.
15
+ """
16
+ if substeps < 1:
17
+ raise ValueError("substeps must be at least 1")
18
+ values = jax.vmap(g)(ts)
19
+
20
+ def interval_increment(left, right):
21
+ nodes = jnp.linspace(left, right, substeps + 1)
22
+ node_values = jax.vmap(g)(nodes)
23
+ widths = nodes[1:] - nodes[:-1]
24
+ widths = widths.reshape(widths.shape + (1,) * (node_values.ndim - 1))
25
+ return jnp.sum(0.5 * widths * (node_values[:-1] + node_values[1:]), axis=0)
26
+
27
+ increments = jax.vmap(interval_increment)(ts[:-1], ts[1:])
28
+ integral = jnp.concatenate(
29
+ [jnp.zeros_like(increments[:1]), jnp.cumsum(increments, axis=0)]
30
+ )
31
+ return integral, values
tinydiffeq/saveat.py ADDED
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ import jax
4
+
5
+
6
+ @jax.tree_util.register_dataclass
7
+ @dataclass(frozen=True)
8
+ class SaveAt:
9
+ """What ``solve_ode``/``solve_sde`` return. Exactly one mode must be set.
10
+
11
+ - ``t1=True``: the endpoint only (the default in the solve functions).
12
+ - ``ts=grid``: cubic-Hermite interpolation of the internal steps onto a
13
+ fixed, sorted query grid in ``[t0, t1]``. Output shape is
14
+ ``(len(ts), ...)`` regardless of how many internal steps the controller
15
+ takes, so changing curvature never changes shapes or recompiles.
16
+ ``ts`` is a data leaf; a different grid of the same length retraces
17
+ nothing. ODE only.
18
+ - ``steps=True``: the raw padded attempt rows, ``max_steps + 1`` of them
19
+ including the initial state. Rejected attempts and post-horizon frozen
20
+ iterations duplicate the previous row. ``fill="last"`` (default) keeps
21
+ those duplicates; ``fill="inf"`` overwrites every non-accepted row
22
+ (including mid-trajectory rejection rows) with ``inf``, diffrax-style.
23
+
24
+ ``fill`` only applies to ``steps=True``.
25
+ """
26
+
27
+ t1: bool = field(default=False, metadata=dict(static=True))
28
+ ts: jax.Array | None = None
29
+ steps: bool = field(default=False, metadata=dict(static=True))
30
+ fill: str = field(default="last", metadata=dict(static=True))
31
+
32
+ def __post_init__(self):
33
+ modes = int(bool(self.t1)) + int(self.ts is not None) + int(bool(self.steps))
34
+ if modes != 1:
35
+ raise ValueError(
36
+ "SaveAt requires exactly one of t1=True, ts=..., steps=True"
37
+ )
38
+ if self.fill not in ("last", "inf"):
39
+ raise ValueError('SaveAt fill must be "last" or "inf"')
tinydiffeq/sde.py ADDED
@@ -0,0 +1,86 @@
1
+ import jax
2
+ import jax.numpy as jnp
3
+
4
+ from tinydiffeq.ode import canonicalize_field, identity_project
5
+ from tinydiffeq.saveat import SaveAt
6
+ from tinydiffeq.solution import Solution
7
+
8
+
9
+ def solve_sde(
10
+ drift,
11
+ diffusion,
12
+ solver,
13
+ t0,
14
+ t1,
15
+ x0,
16
+ *,
17
+ key,
18
+ n_steps,
19
+ p=None,
20
+ args=None,
21
+ saveat=None,
22
+ project=None,
23
+ ):
24
+ """Integrate the Ito SDE ``dx = drift dt + diffusion dW`` (diagonal noise)
25
+ on the fixed grid of ``n_steps`` uniform steps from ``t0`` to ``t1 > t0``.
26
+
27
+ ``drift`` and ``diffusion`` follow the same signature convention as
28
+ ``solve_ode`` — ``(x)``, ``(x, t)``, ``(x, t, args)``, or
29
+ ``(x, t, args, p)``. ``n_steps`` must be a static Python int (the honest
30
+ static-shape contract; there is no adaptive SDE stepping in v1). The
31
+ Brownian increments are presampled from ``key`` as
32
+ ``sqrt(dt) * normal(key, (n_steps,) + x0.shape)``, so a fixed key gives a
33
+ fixed noise process: reproducible across calls and differentiable with
34
+ respect to ``x0`` and ``p`` (not ``key``).
35
+
36
+ ``SaveAt(ts=...)`` raises — cubic Hermite interpolation is wrong for
37
+ rough paths; use ``t1`` (default) or ``steps`` (here ``n_steps + 1``
38
+ rows, all accepted).
39
+ """
40
+ if not isinstance(n_steps, int):
41
+ raise TypeError("n_steps must be a static Python int")
42
+ if n_steps < 1:
43
+ raise ValueError("n_steps must be at least 1")
44
+ if saveat is None:
45
+ saveat = SaveAt(t1=True)
46
+ if saveat.ts is not None:
47
+ raise ValueError(
48
+ "SaveAt(ts=...) is not supported for SDEs: Hermite interpolation "
49
+ "is wrong for rough paths; use SaveAt(t1=True) or SaveAt(steps=True)"
50
+ )
51
+ if project is None:
52
+ project = identity_project
53
+ drift = canonicalize_field(drift, name="drift")
54
+ diffusion = canonicalize_field(diffusion, name="diffusion")
55
+
56
+ x0 = jnp.asarray(x0)
57
+ tdt = jnp.result_type(x0, float)
58
+ t0 = jnp.asarray(t0, tdt)
59
+ t1 = jnp.asarray(t1, tdt)
60
+ dt = (t1 - t0) / n_steps
61
+ dW = jnp.sqrt(dt) * jax.random.normal(key, (n_steps,) + x0.shape, dtype=tdt)
62
+ ts_grid = jnp.linspace(t0, t1, n_steps + 1)
63
+
64
+ def g_drift(x, t):
65
+ return drift(project(x), t, args, p)
66
+
67
+ def g_diffusion(x, t):
68
+ return diffusion(project(x), t, args, p)
69
+
70
+ def body(x, inputs):
71
+ t, dW_step = inputs
72
+ x1 = solver.step(g_drift, g_diffusion, t, x, dt, dW_step, project)
73
+ return x1, x1 if saveat.steps else None
74
+
75
+ x_final, xs_s = jax.lax.scan(body, x0, (ts_grid[:-1], dW))
76
+ num_accepted = jnp.asarray(n_steps, jnp.int32)
77
+ ok = jnp.asarray(True)
78
+
79
+ if saveat.t1:
80
+ return Solution(ts=t1, xs=x_final, ok=ok, num_accepted=num_accepted)
81
+
82
+ xs_all = jnp.concatenate([x0[None], xs_s])
83
+ accepted = jnp.ones((n_steps + 1,), bool)
84
+ return Solution(
85
+ ts=ts_grid, xs=xs_all, ok=ok, num_accepted=num_accepted, accepted=accepted
86
+ )
tinydiffeq/solution.py ADDED
@@ -0,0 +1,28 @@
1
+ from dataclasses import dataclass
2
+
3
+ import jax
4
+
5
+
6
+ @jax.tree_util.register_dataclass
7
+ @dataclass(frozen=True)
8
+ class Solution:
9
+ """Result of ``solve_ode``/``solve_sde``.
10
+
11
+ - ``ts``/``xs``: times and states in the shape dictated by ``SaveAt`` —
12
+ scalar/endpoint for ``t1``, ``(len(ts), ...)`` for ``ts``,
13
+ ``(max_steps + 1, ...)`` for ``steps``.
14
+ - ``ok``: scalar bool, True iff the integration reached ``t1`` within the
15
+ attempt budget. The package never poisons outputs; callers that want
16
+ diverging residuals do ``jnp.where(sol.ok, sol.xs, jnp.inf)``.
17
+ - ``num_accepted``: number of accepted steps (excluding the initial
18
+ state).
19
+ - ``accepted``: ``steps`` mode only (otherwise None): per-row bool mask,
20
+ True for rows holding a newly computed state. Row 0 (the initial state)
21
+ is always True, so ``accepted.sum() == num_accepted + 1``.
22
+ """
23
+
24
+ ts: jax.Array
25
+ xs: jax.Array
26
+ ok: jax.Array
27
+ num_accepted: jax.Array
28
+ accepted: jax.Array | None = None
tinydiffeq/solvers.py ADDED
@@ -0,0 +1,129 @@
1
+ from dataclasses import dataclass
2
+
3
+ import jax
4
+
5
+ # Solvers are stateless frozen dataclasses registered as pytrees so they pass
6
+ # through jit/vmap as ordinary arguments. `step` receives `g(x, t)` -- the
7
+ # user vector field already wrapped so every evaluation goes through
8
+ # `project` -- plus `f0`, the loop-carried value of `g` at (x, t) when the
9
+ # loop guarantees it is current (FSAL, or interpolation output requested);
10
+ # otherwise `f0` is None and the solver evaluates its own first stage.
11
+ # The step contract is `step(g, t, x, dt, f0, project) -> (x1, f1, err)`:
12
+ # `x1` is the projected accepted candidate, `f1` is `g(x1, t + dt)` when the
13
+ # solver produces it for free (FSAL) and None otherwise, and `err` is the
14
+ # embedded error estimate or None. `project` is assumed idempotent (a clamp).
15
+
16
+ # Tsitouras (2011) 5(4) coefficients (FSAL: k7 = f(x1) is the next step's k1).
17
+ A21 = 0.161
18
+ A31, A32 = -0.008480655492356989, 0.335480655492357
19
+ A41, A42, A43 = 2.8971530571054935, -6.359448489975075, 4.3622954328695815
20
+ A51, A52, A53, A54 = (
21
+ 5.325864828439257,
22
+ -11.748883564062828,
23
+ 7.4955393428898365,
24
+ -0.09249506636175525,
25
+ )
26
+ A61, A62, A63, A64, A65 = (
27
+ 5.86145544294642,
28
+ -12.92096931784711,
29
+ 8.159367898576159,
30
+ -0.071584973281401,
31
+ -0.028269050394068383,
32
+ )
33
+ B1, B2, B3, B4, B5, B6 = (
34
+ 0.09646076681806523,
35
+ 0.01,
36
+ 0.4798896504144996,
37
+ 1.379008574103742,
38
+ -3.290069515436081,
39
+ 2.324710524099774,
40
+ )
41
+ # embedded 4th-order error coefficients (b - bhat)
42
+ E1, E2, E3, E4, E5, E6, E7 = (
43
+ -0.00178001105222577714,
44
+ -0.0008164344596567469,
45
+ 0.007880878010261995,
46
+ -0.1447110071732629,
47
+ 0.5823571654525552,
48
+ -0.45808210592918697,
49
+ 1.0 / 66.0,
50
+ )
51
+ # stage times c_i (needed for non-autonomous fields)
52
+ C2, C3, C4, C5, C6, C7 = 0.161, 0.327, 0.9, 0.9800255409045097, 1.0, 1.0
53
+
54
+
55
+ @jax.tree_util.register_dataclass
56
+ @dataclass(frozen=True)
57
+ class Euler:
58
+ """Explicit Euler. Fixed-step only: no embedded error estimate."""
59
+
60
+ order = 1
61
+ fsal = False
62
+ has_error_estimate = False
63
+
64
+ def step(self, g, t, x, dt, f0, project):
65
+ k1 = g(x, t) if f0 is None else f0
66
+ x1 = project(x + dt * k1)
67
+ return x1, None, None
68
+
69
+
70
+ @jax.tree_util.register_dataclass
71
+ @dataclass(frozen=True)
72
+ class RK4:
73
+ """Classic fourth-order Runge-Kutta. Fixed-step only: no error estimate."""
74
+
75
+ order = 4
76
+ fsal = False
77
+ has_error_estimate = False
78
+
79
+ def step(self, g, t, x, dt, f0, project):
80
+ k1 = g(x, t) if f0 is None else f0
81
+ k2 = g(x + 0.5 * dt * k1, t + 0.5 * dt)
82
+ k3 = g(x + 0.5 * dt * k2, t + 0.5 * dt)
83
+ k4 = g(x + dt * k3, t + dt)
84
+ x1 = project(x + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4))
85
+ return x1, None, None
86
+
87
+
88
+ @jax.tree_util.register_dataclass
89
+ @dataclass(frozen=True)
90
+ class Tsit5:
91
+ """Tsitouras 5(4) explicit Runge-Kutta with embedded error estimate.
92
+
93
+ FSAL: the last stage k7 = g(x1, t + dt) is the next step's first stage,
94
+ so an accepted adaptive step costs six fresh evaluations. Note k7 is
95
+ evaluated at the *projected* accepted state, so the FSAL cache stays
96
+ consistent with the state actually carried forward when `project` binds.
97
+ """
98
+
99
+ order = 5
100
+ fsal = True
101
+ has_error_estimate = True
102
+
103
+ def step(self, g, t, x, dt, f0, project):
104
+ k1 = g(x, t) if f0 is None else f0
105
+ k2 = g(x + dt * (A21 * k1), t + C2 * dt)
106
+ k3 = g(x + dt * (A31 * k1 + A32 * k2), t + C3 * dt)
107
+ k4 = g(x + dt * (A41 * k1 + A42 * k2 + A43 * k3), t + C4 * dt)
108
+ k5 = g(x + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4), t + C5 * dt)
109
+ k6 = g(
110
+ x + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5),
111
+ t + C6 * dt,
112
+ )
113
+ x1 = project(
114
+ x + dt * (B1 * k1 + B2 * k2 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6)
115
+ )
116
+ k7 = g(x1, t + C7 * dt)
117
+ err = dt * (E1 * k1 + E2 * k2 + E3 * k3 + E4 * k4 + E5 * k5 + E6 * k6 + E7 * k7)
118
+ return x1, k7, err
119
+
120
+
121
+ @jax.tree_util.register_dataclass
122
+ @dataclass(frozen=True)
123
+ class EulerMaruyama:
124
+ """Euler-Maruyama for Ito SDEs with diagonal noise. Fixed-step only."""
125
+
126
+ order = 1
127
+
128
+ def step(self, g_drift, g_diffusion, t, x, dt, dW, project):
129
+ return project(x + dt * g_drift(x, t) + g_diffusion(x, t) * dW)
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinydiffeq
3
+ Version: 0.1.0
4
+ Summary: Tiny differentiable ODE/SDE solvers for JAX — bounded-scan adaptive stepping, static shapes, jvp/vjp-safe
5
+ Project-URL: Homepage, https://github.com/HighDimensionalEconLab/tinydiffeq
6
+ Project-URL: Repository, https://github.com/HighDimensionalEconLab/tinydiffeq
7
+ Project-URL: Documentation, https://highdimensionaleconlab.github.io/tinydiffeq/
8
+ Project-URL: Issues, https://github.com/HighDimensionalEconLab/tinydiffeq/issues
9
+ Author-email: Jesse Perla <jesseperla@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: autodiff,differential-equations,jax,ode,runge-kutta,sde
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: jax>=0.7.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # tinydiffeq
24
+
25
+ [![CI](https://github.com/HighDimensionalEconLab/tinydiffeq/actions/workflows/ci.yml/badge.svg)](https://github.com/HighDimensionalEconLab/tinydiffeq/actions/workflows/ci.yml)
26
+ [![Docs](https://github.com/HighDimensionalEconLab/tinydiffeq/actions/workflows/docs.yml/badge.svg)](https://highdimensionaleconlab.github.io/tinydiffeq/)
27
+ [![PyPI](https://img.shields.io/pypi/v/tinydiffeq.svg)](https://pypi.org/project/tinydiffeq/)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/tinydiffeq.svg)](https://pypi.org/project/tinydiffeq/)
29
+ [![License: MIT](https://img.shields.io/github/license/HighDimensionalEconLab/tinydiffeq)](https://github.com/HighDimensionalEconLab/tinydiffeq/blob/main/LICENSE)
30
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
31
+
32
+ Tiny differentiable ODE/SDE solvers for JAX: fixed-step Euler/RK4, adaptive
33
+ Tsit5 with an integral step-size controller, and Euler–Maruyama for Itô SDEs.
34
+ One bounded `lax.scan` of exactly `max_steps` iterations serves fixed and
35
+ adaptive stepping, so shapes are static, nothing recompiles as tolerances or
36
+ curvature change, and every solve is differentiable in **both** forward and
37
+ reverse mode — including reverse-over-forward, the pattern a
38
+ Levenberg–Marquardt optimizer with geodesic acceleration needs when it
39
+ differentiates through a rollout.
40
+
41
+ This is a deliberately small, jvp/vjp-friendly subset of
42
+ [diffrax](https://docs.kidger.site/diffrax/). Use diffrax if you need pytree
43
+ states, stiff/implicit solvers, PID control, events, dense output, or
44
+ checkpointed/backsolve adjoints. tinydiffeq's single runtime dependency is
45
+ `jax`.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ uv add tinydiffeq
51
+ ```
52
+
53
+ For GPU use, install the JAX accelerator build that matches your hardware,
54
+ for example:
55
+
56
+ ```bash
57
+ uv add tinydiffeq "jax[cuda13]"
58
+ ```
59
+
60
+ ## Minimal example
61
+
62
+ The vector field may take `(x)`, `(x, t)`, `(x, t, args)`, or
63
+ `(x, t, args, p)` — always in that order. `args` is pass-through data (not an
64
+ AD target by convention); `p` holds differentiable parameters (any pytree).
65
+
66
+ ```python
67
+ import jax
68
+ import jax.numpy as jnp
69
+ from tinydiffeq import solve_ode, Tsit5, IController, SaveAt
70
+
71
+ jax.config.update("jax_enable_x64", True) # your call — the library never sets it
72
+
73
+
74
+ def f(x, t, args, p):
75
+ return -p * x
76
+
77
+
78
+ sol = solve_ode(
79
+ f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
80
+ p=jnp.asarray(1.3),
81
+ dt0=0.1,
82
+ controller=IController(rtol=1e-8, atol=1e-10),
83
+ max_steps=512,
84
+ saveat=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
85
+ ) # however many steps adapt
86
+ print(sol.xs) # states on the grid
87
+ print(sol.ok) # reached t1 within the max_steps budget?
88
+ ```
89
+
90
+ ## Gradients through the solve
91
+
92
+ ```python
93
+ def endpoint(p):
94
+ return solve_ode(
95
+ f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
96
+ dt0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
97
+ max_steps=512,
98
+ ).xs
99
+
100
+ jax.grad(endpoint)(jnp.asarray(1.3)) # reverse mode
101
+ jax.jvp(endpoint, (jnp.asarray(1.3),), (jnp.asarray(1.0),)) # forward mode
102
+ jax.grad(lambda p: jax.jvp(endpoint, (p,), (jnp.asarray(1.0),))[1])(
103
+ jnp.asarray(1.3)
104
+ ) # reverse-over-forward
105
+ ```
106
+
107
+ The step-size controller is wrapped in `stop_gradient` (accept/reject is
108
+ non-differentiable either way, and the error-ratio power blows up at exactly
109
+ zero error); the states differentiate fully through the RK stages. See the
110
+ [docs](https://highdimensionaleconlab.github.io/tinydiffeq/) for the design
111
+ contracts: static shapes and `SaveAt`, AD through adaptive stepping, SDE key
112
+ semantics, and migration recipes from hand-rolled RK4/Tsit5 loops.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,13 @@
1
+ tinydiffeq/__init__.py,sha256=OhMHS5DJX26rPrnTBIM4p6vCPYg8rrlB9aGXyw-e_7w,1383
2
+ tinydiffeq/controllers.py,sha256=Umq0d3hku0TeKhqsOgxMgdAa_bDstx-o5E-fb8jx10k,2956
3
+ tinydiffeq/interpolation.py,sha256=uYHO58eAIfw32ueG6SYGtPqv3p9LvWibzpTtvOhBpgk,2280
4
+ tinydiffeq/ode.py,sha256=ruG2EGs-8HjPZQn5ogGAvvTWr-Kp_2l0BDPNuJT68X0,6584
5
+ tinydiffeq/quadrature.py,sha256=0YglgE1bjhMfeXsIAGUzQAQKsOsHbHoBxtxu_bm42Vw,1283
6
+ tinydiffeq/saveat.py,sha256=iFh04a8J9qJy8FY8Dvu4iJfcO8Q-e52l9Uekxo2siUs,1707
7
+ tinydiffeq/sde.py,sha256=-pkSvcte1VxagUhoyfh1sJb_Gx1Lf2z3U3Qs_D7Z6ck,2912
8
+ tinydiffeq/solution.py,sha256=9i2hV_s4Mhc4hk7K2pEWACMvAXxsIoQlFjcScY4wPFw,1015
9
+ tinydiffeq/solvers.py,sha256=a8BJymEoHAcihl6J9KtB1Du2PjzpGq75GCVwRwU_zZQ,4291
10
+ tinydiffeq-0.1.0.dist-info/METADATA,sha256=DoxN0K77HZyTDMVWEffde585ev9cc12IHaQkCgGGz_0,4851
11
+ tinydiffeq-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ tinydiffeq-0.1.0.dist-info/licenses/LICENSE,sha256=6ecMv6jswIubE8SkuH00m6qcbn45ZDuvG-fzsAYNFek,1079
13
+ tinydiffeq-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HighDimensionalEconLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.