tinydiffeq 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (49) hide show
  1. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/PKG-INFO +31 -11
  2. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/README.md +30 -10
  3. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/adaptive_ad.md +48 -13
  4. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/api.md +2 -0
  5. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/index.md +26 -15
  6. tinydiffeq-0.2.0/docs/llms.txt +11 -0
  7. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/docs/sde.md +6 -6
  8. tinydiffeq-0.2.0/docs/static_shapes.md +124 -0
  9. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/mkdocs.yml +0 -1
  10. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/pyproject.toml +1 -1
  11. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/__init__.py +12 -10
  12. tinydiffeq-0.2.0/src/tinydiffeq/controllers.py +182 -0
  13. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/interpolation.py +7 -6
  14. tinydiffeq-0.2.0/src/tinydiffeq/ode.py +230 -0
  15. tinydiffeq-0.1.0/src/tinydiffeq/saveat.py → tinydiffeq-0.2.0/src/tinydiffeq/save_at.py +12 -11
  16. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/sde.py +35 -29
  17. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/solution.py +5 -5
  18. tinydiffeq-0.2.0/src/tinydiffeq/solvers.py +141 -0
  19. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/kernels_reference.py +56 -45
  20. tinydiffeq-0.2.0/tests/test_ad.py +238 -0
  21. tinydiffeq-0.2.0/tests/test_adaptive.py +371 -0
  22. tinydiffeq-0.2.0/tests/test_float64_subprocess.py +244 -0
  23. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_gpu.py +7 -7
  24. tinydiffeq-0.2.0/tests/test_recompile.py +118 -0
  25. tinydiffeq-0.2.0/tests/test_save_at.py +139 -0
  26. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_sde.py +25 -25
  27. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_solvers_fixed.py +58 -30
  28. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/uv.lock +1 -1
  29. tinydiffeq-0.1.0/docs/llms.txt +0 -12
  30. tinydiffeq-0.1.0/docs/migration.md +0 -95
  31. tinydiffeq-0.1.0/docs/static_shapes.md +0 -96
  32. tinydiffeq-0.1.0/src/tinydiffeq/controllers.py +0 -72
  33. tinydiffeq-0.1.0/src/tinydiffeq/ode.py +0 -176
  34. tinydiffeq-0.1.0/src/tinydiffeq/solvers.py +0 -129
  35. tinydiffeq-0.1.0/tests/test_ad.py +0 -205
  36. tinydiffeq-0.1.0/tests/test_adaptive.py +0 -233
  37. tinydiffeq-0.1.0/tests/test_float64_subprocess.py +0 -158
  38. tinydiffeq-0.1.0/tests/test_recompile.py +0 -73
  39. tinydiffeq-0.1.0/tests/test_saveat.py +0 -86
  40. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.githooks/pre-commit +0 -0
  41. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.github/workflows/ci.yml +0 -0
  42. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.github/workflows/docs.yml +0 -0
  43. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.github/workflows/publish.yml +0 -0
  44. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.gitignore +0 -0
  45. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/.python-version +0 -0
  46. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/LICENSE +0 -0
  47. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/src/tinydiffeq/quadrature.py +0 -0
  48. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/conftest.py +0 -0
  49. {tinydiffeq-0.1.0 → tinydiffeq-0.2.0}/tests/test_quadrature.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinydiffeq
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Tiny differentiable ODE/SDE solvers for JAX — bounded-scan adaptive stepping, static shapes, jvp/vjp-safe
5
5
  Project-URL: Homepage, https://github.com/HighDimensionalEconLab/tinydiffeq
6
6
  Project-URL: Repository, https://github.com/HighDimensionalEconLab/tinydiffeq
@@ -30,19 +30,21 @@ Description-Content-Type: text/markdown
30
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
31
 
32
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.
33
+ Tsit5 with integral or proportional-integral step-size control, and
34
+ Euler–Maruyama for Itô SDEs.
34
35
  One bounded `lax.scan` of exactly `max_steps` iterations serves fixed and
35
36
  adaptive stepping, so shapes are static, nothing recompiles as tolerances or
36
37
  curvature change, and every solve is differentiable in **both** forward and
37
38
  reverse mode — including reverse-over-forward, the pattern a
38
39
  Levenberg–Marquardt optimizer with geodesic acceleration needs when it
39
- differentiates through a rollout.
40
+ differentiates through a rollout. After a solve reaches its horizon, a
41
+ `lax.cond` skips solver and controller work during the padded scan tail.
40
42
 
41
43
  This is a deliberately small, jvp/vjp-friendly subset of
42
44
  [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`.
45
+ states, stiff/implicit solvers, full derivative-term PID control, events,
46
+ dense output, or checkpointed/backsolve adjoints. tinydiffeq's single runtime
47
+ dependency is `jax`.
46
48
 
47
49
  ## Install
48
50
 
@@ -78,22 +80,40 @@ def f(x, t, args, p):
78
80
  sol = solve_ode(
79
81
  f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
80
82
  p=jnp.asarray(1.3),
81
- dt0=0.1,
83
+ dt_0=0.1,
82
84
  controller=IController(rtol=1e-8, atol=1e-10),
83
85
  max_steps=512,
84
- saveat=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
86
+ save_at=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
85
87
  ) # however many steps adapt
86
88
  print(sol.xs) # states on the grid
87
- print(sol.ok) # reached t1 within the max_steps budget?
89
+ print(sol.ok) # reached t_1 within the max_steps budget?
88
90
  ```
89
91
 
92
+ `IController()` and `PIController()` choose tolerances from `x_0.dtype`:
93
+ `rtol=1e-4, atol=1e-6` for float32 and `rtol=1e-7, atol=1e-9` for
94
+ float64. Pass explicit values when tolerances are part of your model's
95
+ scientific specification. The default `dt_min` is
96
+ `10 * finfo(dtype).eps * max(1, abs(t_1))`.
97
+
98
+ `max_steps` is the total internal **attempt budget**: accepted steps plus
99
+ rejections. It is not normally the number of returned times. Endpoint mode
100
+ returns one time/state, `SaveAt(ts=...)` returns the requested grid, and
101
+ `SaveAt(steps=True)` returns the initial state and accepted internal steps as
102
+ a contiguous prefix of `max_steps + 1` rows. The remaining rows repeat the
103
+ last accepted state by default; `sol.accepted` distinguishes data from
104
+ padding. Rejected attempts never appear in the returned trajectory.
105
+
106
+ `SaveAt(ts=...)` also accepts a Python sequence. These are observation times:
107
+ the adaptive controller still chooses its own internal mesh, and cubic
108
+ Hermite interpolation evaluates the solution at every requested point.
109
+
90
110
  ## Gradients through the solve
91
111
 
92
112
  ```python
93
113
  def endpoint(p):
94
114
  return solve_ode(
95
115
  f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
96
- dt0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
116
+ dt_0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
97
117
  max_steps=512,
98
118
  ).xs
99
119
 
@@ -109,7 +129,7 @@ non-differentiable either way, and the error-ratio power blows up at exactly
109
129
  zero error); the states differentiate fully through the RK stages. See the
110
130
  [docs](https://highdimensionaleconlab.github.io/tinydiffeq/) for the design
111
131
  contracts: static shapes and `SaveAt`, AD through adaptive stepping, SDE key
112
- semantics, and migration recipes from hand-rolled RK4/Tsit5 loops.
132
+ semantics, and the package API.
113
133
 
114
134
  ## License
115
135
 
@@ -8,19 +8,21 @@
8
8
  [![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)
9
9
 
10
10
  Tiny differentiable ODE/SDE solvers for JAX: fixed-step Euler/RK4, adaptive
11
- Tsit5 with an integral step-size controller, and Euler–Maruyama for Itô SDEs.
11
+ Tsit5 with integral or proportional-integral step-size control, and
12
+ Euler–Maruyama for Itô SDEs.
12
13
  One bounded `lax.scan` of exactly `max_steps` iterations serves fixed and
13
14
  adaptive stepping, so shapes are static, nothing recompiles as tolerances or
14
15
  curvature change, and every solve is differentiable in **both** forward and
15
16
  reverse mode — including reverse-over-forward, the pattern a
16
17
  Levenberg–Marquardt optimizer with geodesic acceleration needs when it
17
- differentiates through a rollout.
18
+ differentiates through a rollout. After a solve reaches its horizon, a
19
+ `lax.cond` skips solver and controller work during the padded scan tail.
18
20
 
19
21
  This is a deliberately small, jvp/vjp-friendly subset of
20
22
  [diffrax](https://docs.kidger.site/diffrax/). Use diffrax if you need pytree
21
- states, stiff/implicit solvers, PID control, events, dense output, or
22
- checkpointed/backsolve adjoints. tinydiffeq's single runtime dependency is
23
- `jax`.
23
+ states, stiff/implicit solvers, full derivative-term PID control, events,
24
+ dense output, or checkpointed/backsolve adjoints. tinydiffeq's single runtime
25
+ dependency is `jax`.
24
26
 
25
27
  ## Install
26
28
 
@@ -56,22 +58,40 @@ def f(x, t, args, p):
56
58
  sol = solve_ode(
57
59
  f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
58
60
  p=jnp.asarray(1.3),
59
- dt0=0.1,
61
+ dt_0=0.1,
60
62
  controller=IController(rtol=1e-8, atol=1e-10),
61
63
  max_steps=512,
62
- saveat=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
64
+ save_at=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)), # fixed output shape,
63
65
  ) # however many steps adapt
64
66
  print(sol.xs) # states on the grid
65
- print(sol.ok) # reached t1 within the max_steps budget?
67
+ print(sol.ok) # reached t_1 within the max_steps budget?
66
68
  ```
67
69
 
70
+ `IController()` and `PIController()` choose tolerances from `x_0.dtype`:
71
+ `rtol=1e-4, atol=1e-6` for float32 and `rtol=1e-7, atol=1e-9` for
72
+ float64. Pass explicit values when tolerances are part of your model's
73
+ scientific specification. The default `dt_min` is
74
+ `10 * finfo(dtype).eps * max(1, abs(t_1))`.
75
+
76
+ `max_steps` is the total internal **attempt budget**: accepted steps plus
77
+ rejections. It is not normally the number of returned times. Endpoint mode
78
+ returns one time/state, `SaveAt(ts=...)` returns the requested grid, and
79
+ `SaveAt(steps=True)` returns the initial state and accepted internal steps as
80
+ a contiguous prefix of `max_steps + 1` rows. The remaining rows repeat the
81
+ last accepted state by default; `sol.accepted` distinguishes data from
82
+ padding. Rejected attempts never appear in the returned trajectory.
83
+
84
+ `SaveAt(ts=...)` also accepts a Python sequence. These are observation times:
85
+ the adaptive controller still chooses its own internal mesh, and cubic
86
+ Hermite interpolation evaluates the solution at every requested point.
87
+
68
88
  ## Gradients through the solve
69
89
 
70
90
  ```python
71
91
  def endpoint(p):
72
92
  return solve_ode(
73
93
  f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
74
- dt0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
94
+ dt_0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
75
95
  max_steps=512,
76
96
  ).xs
77
97
 
@@ -87,7 +107,7 @@ non-differentiable either way, and the error-ratio power blows up at exactly
87
107
  zero error); the states differentiate fully through the RK stages. See the
88
108
  [docs](https://highdimensionaleconlab.github.io/tinydiffeq/) for the design
89
109
  contracts: static shapes and `SaveAt`, AD through adaptive stepping, SDE key
90
- semantics, and migration recipes from hand-rolled RK4/Tsit5 loops.
110
+ semantics, and the package API.
91
111
 
92
112
  ## License
93
113
 
@@ -5,10 +5,31 @@ differentiable in forward mode, reverse mode, and reverse-over-forward. That
5
5
  holds because of three deliberate choices about **what is not
6
6
  differentiated**.
7
7
 
8
+ ## Default tolerances follow the state precision
9
+
10
+ `IController()` and `PIController()` resolve omitted tolerances from the
11
+ state dtype at trace time:
12
+
13
+ | State/time dtype | `rtol` | `atol` | unit-scale `dt_min` |
14
+ |---|---:|---:|---:|
15
+ | float32 | `1e-4` | `1e-6` | `10 * eps` ≈ `1.19e-6` |
16
+ | float64 | `1e-7` | `1e-9` | `10 * eps` ≈ `2.22e-15` |
17
+
18
+ The policy never changes `jax_enable_x64`: a float32 `x_0` stays float32 even
19
+ when x64 is enabled globally. Explicit tolerances override the defaults and
20
+ are cast to the state dtype. Use explicit values whenever tolerances are part
21
+ of a reproducibility or accuracy contract.
22
+
23
+ Automatic `dt_min` is `10 * eps * max(1, abs(t_1))`; set it explicitly when
24
+ the relevant time scale differs materially from the absolute horizon. The
25
+ controller also floors exact-zero error ratios at machine epsilon before
26
+ applying a negative power; the growth-factor clip then selects maximal step
27
+ growth without introducing infinities.
28
+
8
29
  ## The controller is stop-gradiented
9
30
 
10
- The step-size controller (`IController`) computes its scaled error norm and
11
- next-step factor inside `stop_gradient`:
31
+ The adaptive step-size controllers (`IController` and `PIController`) compute
32
+ their scaled error norms and next-step factors inside `stop_gradient`:
12
33
 
13
34
  > accept/reject is a non-differentiable branch either way, the gradient of
14
35
  > `E**(-1/order)` blows up at the exact-zero error of a flat-start policy,
@@ -21,13 +42,27 @@ pattern actually taken*. This is the same convention diffrax uses.
21
42
 
22
43
  The `E**(-1/5)` blow-up is not hypothetical: a policy initialized flat gives
23
44
  an exactly-zero error estimate on the first step, and without the
24
- `stop_gradient` (plus the `max(E, 1e-12)` floor) the backward pass is NaN
45
+ `stop_gradient` (plus the machine-epsilon error floor) the backward pass is NaN
25
46
  from iteration one. `tests/test_ad.py::test_grad_finite_on_flat_field` pins
26
47
  this.
27
48
 
49
+ `PIController` additionally carries the previous accepted error ratio. Its
50
+ step-size factor is
51
+
52
+ ```text
53
+ safety * E_n**(-(p_coeff + i_coeff) / order)
54
+ * E_prev**(p_coeff / order)
55
+ ```
56
+
57
+ and `E_prev` changes only after acceptance. The whole recurrence is
58
+ controller-internal and stop-gradiented. Setting `p_coeff=0, i_coeff=1`
59
+ reproduces `IController` bit for bit; the default `p_coeff=0.4, i_coeff=0.3`
60
+ is less sensitive to oscillatory error estimates and typically rejects fewer
61
+ attempts on harder problems.
62
+
28
63
  ## The horizon clip is the growth guard
29
64
 
30
- Every attempt is clipped so it cannot step past `t1`, and — deliberately
65
+ Every attempt is clipped so it cannot step past `t_1`, and — deliberately
31
66
  unlike diffrax — the controller's next-step proposal is computed from the
32
67
  **clipped** step:
33
68
 
@@ -36,15 +71,15 @@ unlike diffrax — the controller's next-step proposal is computed from the
36
71
  > whose Gauss–Newton linearization stalls a trust-region optimizer
37
72
  > differentiating through the rollout.
38
73
 
39
- With `factormax = 5`, a flat field would otherwise reach `dt ≈ t1/4` within
74
+ With `factor_max = 5`, a flat field would otherwise reach `dt ≈ t_1/4` within
40
75
  a few accepted steps; residuals sampled from three or four giant steps make
41
76
  the optimizer's linear model useless. Clipping first means the proposal can
42
- never exceed `factormax × remaining horizon`.
77
+ never exceed `factor_max × remaining horizon`.
43
78
 
44
79
  One refinement over a bare `min(dt, remaining)`: when the remaining horizon
45
80
  is within `max_steps × eps` of the desired step, the step is stretched to
46
- land on `t1` exactly. Summing `n` rounded steps of `(t1 - t0)/n` can leave
47
- `t` one accumulated ulp short of `t1`, and without the stretch that sliver
81
+ land on `t_1` exactly. Summing `n` rounded steps of `(t_1 - t_0)/n` can leave
82
+ `t` one accumulated ulp short of `t_1`, and without the stretch that sliver
48
83
  would cost an extra iteration a `max_steps = n` budget doesn't have.
49
84
 
50
85
  ## Interpolation knots are non-differentiable
@@ -74,15 +109,15 @@ on the output is not enough — reverse mode differentiates both branches, and
74
109
  Levenberg–Marquardt geodesic-acceleration pattern) all work through
75
110
  adaptive solves and interpolated output, verified against closed forms in
76
111
  `tests/test_ad.py`.
77
- - `jax.vmap` over `x0` or `p` gives genuinely per-lane adaptivity: each lane
112
+ - `jax.vmap` over `x_0` or `p` gives genuinely per-lane adaptivity: each lane
78
113
  accepts/rejects independently through the masked scan, and batched results
79
114
  equal the individual solves exactly.
80
115
 
81
116
  ## What to watch
82
117
 
83
- - Reductions over `SaveAt(steps=True)` rows are **discontinuous** in the
84
- inputs: the number of duplicate rows changes when an accept flips to a
85
- reject. Consume steps mode with residuals that vanish at every state (so
86
- duplicates are harmless), or reduce to `xs[-1]`.
118
+ - Reductions over the valid prefix of `SaveAt(steps=True)` are
119
+ **discontinuous** in the inputs: its length changes when an accept flips to
120
+ a reject. Use `sol.accepted` when padding must not contribute. Because the
121
+ default tail repeats the endpoint, `xs[-1]` remains the reached final state.
87
122
  - Finite-difference checks of adaptive solves are noisy for the same reason;
88
123
  compare AD against closed forms or use fixed-step solvers for FD tests.
@@ -22,6 +22,8 @@
22
22
 
23
23
  ::: tinydiffeq.IController
24
24
 
25
+ ::: tinydiffeq.PIController
26
+
25
27
  ## Output selection and results
26
28
 
27
29
  ::: tinydiffeq.SaveAt
@@ -1,8 +1,9 @@
1
1
  # tinydiffeq
2
2
 
3
3
  `tinydiffeq` is a deliberately tiny set of differentiable ODE/SDE integrators
4
- for JAX: fixed-step Euler and RK4, adaptive Tsit5 with an integral step-size
5
- controller, and fixed-step Euler–Maruyama for Itô SDEs. Everything runs
4
+ for JAX: fixed-step Euler and RK4, adaptive Tsit5 with integral or
5
+ proportional-integral step-size control, and fixed-step Euler–Maruyama for
6
+ Itô SDEs. Everything runs
6
7
  inside one bounded `lax.scan` with static shapes, and every solve is
7
8
  differentiable in **both** forward and reverse mode — including
8
9
  reverse-over-forward, the pattern a Levenberg–Marquardt optimizer with
@@ -14,7 +15,7 @@ need any of:**
14
15
 
15
16
  - pytree states (tinydiffeq states are single arrays, scalar or vector)
16
17
  - stiff or implicit solvers
17
- - PID step-size control
18
+ - full derivative-term PID step-size control
18
19
  - events, root-finding, or backward-time integration
19
20
  - dense output / continuous interpolation objects
20
21
  - checkpointed or backsolve adjoints for long horizons
@@ -54,7 +55,7 @@ f(x, t, args, p)
54
55
  nothing stops you differentiating with respect to it, but the library's
55
56
  contracts and tests treat it as constants.
56
57
  - `p` holds differentiable parameters — any pytree, e.g. neural-network
57
- weights. jvp/vjp with respect to `p` and `x0` are first-class and tested.
58
+ weights. jvp/vjp with respect to `p` and `x_0` are first-class and tested.
58
59
 
59
60
  The arity is inspected once and the function is wrapped into the canonical
60
61
  four-argument form, so the compiled code is identical for all four. There is
@@ -78,22 +79,28 @@ def f(x, t, args, p):
78
79
  sol = solve_ode(
79
80
  f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0),
80
81
  p=jnp.asarray(1.3),
81
- dt0=0.1,
82
+ dt_0=0.1,
82
83
  controller=IController(rtol=1e-8, atol=1e-10),
83
84
  max_steps=512,
84
- saveat=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)),
85
+ save_at=SaveAt(ts=jnp.linspace(0.0, 2.0, 21)),
85
86
  )
86
87
  sol.xs # (21,) states on the grid, however many internal steps were taken
87
- sol.ok # False if max_steps ran out before t1
88
+ sol.ok # False if max_steps ran out before t_1
88
89
  ```
89
90
 
91
+ With no arguments, `IController()` and `PIController()` use precision-aware
92
+ tolerances: `rtol=1e-4, atol=1e-6` for float32 states and
93
+ `rtol=1e-7, atol=1e-9` for float64 states. Explicit values override the
94
+ policy and are cast to the state dtype. Automatic `dt_min` is
95
+ `10 * eps * max(1, abs(t_1))` in the time dtype.
96
+
90
97
  Gradients go straight through the solve:
91
98
 
92
99
  ```python
93
100
  def endpoint(p):
94
101
  return solve_ode(
95
102
  f, Tsit5(), 0.0, 2.0, jnp.asarray(1.0), p=p,
96
- dt0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
103
+ dt_0=0.1, controller=IController(rtol=1e-10, atol=1e-12),
97
104
  max_steps=512,
98
105
  ).xs
99
106
 
@@ -106,20 +113,24 @@ jax.grad(lambda p: jax.jvp(endpoint, (p,), (jnp.asarray(1.0),))[1])(
106
113
 
107
114
  ## Design contracts at a glance
108
115
 
109
- - **`dt0` is required.** There is no initial-step heuristic.
110
- - **Forward time only**: `t1 > t0`.
111
- - **Never poisons.** `sol.ok` reports whether `t1` was reached; callers that
116
+ - **`dt_0` is required.** There is no initial-step heuristic.
117
+ - **`max_steps` counts attempted internal steps**, including rejections. It
118
+ controls the bounded scan and only becomes an output-row count in
119
+ `SaveAt(steps=True)`, which returns `max_steps + 1` padded rows including
120
+ the initial state. Accepted steps form a contiguous prefix; rejected
121
+ attempts are omitted and the tail repeats the last accepted state.
122
+ - **Forward time only**: `t_1 > t_0`.
123
+ - **Never poisons.** `sol.ok` reports whether `t_1` was reached; callers that
112
124
  want diverging residuals do `jnp.where(sol.ok, sol.xs, jnp.inf)`.
113
125
  - **`project`** (an idempotent clamp, e.g. positivity) is applied at every
114
126
  point where the vector field is evaluated and to every accepted state.
115
127
  - **Never sets `jax_enable_x64`.** The time dtype follows
116
- `jnp.result_type(x0, float)`; float32 problems stay float32 even under
128
+ `jnp.result_type(x_0, float)`; float32 problems stay float32 even under
117
129
  x64.
118
130
  - Solvers, controllers, `SaveAt`, and `Solution` are frozen dataclasses
119
- registered as pytrees: numeric fields (tolerances, grids, `dt0`, `x0`) are
131
+ registered as pytrees: numeric fields (tolerances, grids, `dt_0`, `x_0`) are
120
132
  data leaves, so changing them never recompiles.
121
133
 
122
134
  Read next: [Static Shapes](static_shapes.md) for the bounded-scan design and
123
135
  `SaveAt`, [Adaptive Stepping and AD](adaptive_ad.md) for what is and is not
124
- differentiated, [SDEs](sde.md), and
125
- [Migration](migration.md) if you are replacing hand-rolled RK4/Tsit5 loops.
136
+ differentiated, [SDEs](sde.md), and the [API Reference](api.md).
@@ -0,0 +1,11 @@
1
+ # tinydiffeq
2
+
3
+ > Tiny differentiable ODE/SDE solvers for JAX. Fixed-step (Euler, RK4) and adaptive (Tsit5 + integral or proportional-integral controller) explicit Runge-Kutta inside one bounded lax.scan of exactly max_steps attempted steps: static shapes, one compilation across explicit controller values/curvature/initial conditions, and solves differentiable in both forward and reverse mode (including reverse-over-forward) with O(max_steps) memory. Adaptive-controller defaults are precision-aware: rtol=1e-4, atol=1e-6 for float32 and rtol=1e-7, atol=1e-9 for float64; dt_min is 10*eps*max(1, abs(t_1)) in the time dtype. Expensive solver work is skipped after the horizon via lax.cond while the scan emits its padded tail. Array states only (scalar or vector). SaveAt selects endpoint, cubic-Hermite interpolation onto a requested grid, or a compact accepted-step prefix padded to max_steps + 1. solve_sde is fixed-step Euler-Maruyama with presampled diagonal noise keyed on a PRNG key. A deliberately small subset of diffrax: use diffrax for pytree states, stiff/implicit solvers, full derivative-term PID control, events, dense output, and adjoint methods.
4
+
5
+ ## Docs
6
+
7
+ - [Home — positioning, vector-field signature convention f(x, t, args, p), minimal examples](https://highdimensionaleconlab.github.io/tinydiffeq/)
8
+ - [Static shapes — the bounded-scan design, SaveAt observation grids, compact accepted steps and padding, why nothing recompiles](https://highdimensionaleconlab.github.io/tinydiffeq/static_shapes/)
9
+ - [Adaptive stepping and AD — stop-gradiented controller rationale, horizon-clip growth guard, non-differentiable interpolation knots, double-where NaN safety](https://highdimensionaleconlab.github.io/tinydiffeq/adaptive_ad/)
10
+ - [SDEs — Euler-Maruyama orders, fixed-noise key semantics, shared-path strong-convergence testing, why SaveAt(ts) raises](https://highdimensionaleconlab.github.io/tinydiffeq/sde/)
11
+ - [API reference — solve_ode, solve_sde, solvers, controllers, SaveAt, Solution, hermite_interpolate, cumulative_trapezoid](https://highdimensionaleconlab.github.io/tinydiffeq/api/)
@@ -14,11 +14,11 @@ increments.
14
14
  from tinydiffeq import solve_sde, EulerMaruyama, SaveAt
15
15
 
16
16
  sol = solve_sde(
17
- drift, diffusion, EulerMaruyama(), 0.0, 1.0, x0,
17
+ drift, diffusion, EulerMaruyama(), 0.0, 1.0, x_0,
18
18
  key=jax.random.PRNGKey(0),
19
19
  n_steps=256,
20
20
  p=(mu, sigma),
21
- saveat=SaveAt(steps=True),
21
+ save_at=SaveAt(steps=True),
22
22
  )
23
23
  ```
24
24
 
@@ -38,13 +38,13 @@ for now; a roadmap issue sketches what it would take here.
38
38
  The Brownian increments are presampled once,
39
39
 
40
40
  ```python
41
- dW = jnp.sqrt(dt) * jax.random.normal(key, (n_steps,) + x0.shape)
41
+ d_w = jnp.sqrt(dt) * jax.random.normal(key, (n_steps,) + x_0.shape)
42
42
  ```
43
43
 
44
44
  so a fixed `key` pins the entire noise path:
45
45
 
46
46
  - **Reproducible** — the same key gives the same path, every call.
47
- - **Differentiable with respect to `x0` and `p`** (not `key`): with the path
47
+ - **Differentiable with respect to `x_0` and `p`** (not `key`): with the path
48
48
  held fixed, the solution map is smooth, and jvp/vjp against finite
49
49
  differences are tested in `tests/test_sde.py`. This is exactly the "common
50
50
  random numbers" setup simulation-based estimators want.
@@ -61,7 +61,7 @@ X_T = X_0 \exp\left((\mu - \tfrac{\sigma^2}{2})T + \sigma W_T\right),
61
61
  \qquad W_T = \textstyle\sum_k \Delta W_k .
62
62
  $$
63
63
 
64
- Because the increments `dW` are exactly reproducible from the key, the test
64
+ Because the increments `d_w` are exactly reproducible from the key, the test
65
65
  regenerates them, computes the exact endpoint on the same path, and checks
66
66
  the mean absolute error slope across `dt` levels lands in `[0.35, 0.65]`.
67
67
  That shared-path construction is the right way to measure strong error —
@@ -69,7 +69,7 @@ comparing against an independently sampled exact solution measures nothing.
69
69
 
70
70
  ## SaveAt for SDEs
71
71
 
72
- `SaveAt(t1=True)` (default) and `SaveAt(steps=True)` (`n_steps + 1` rows,
72
+ `SaveAt(t_1=True)` (default) and `SaveAt(steps=True)` (`n_steps + 1` rows,
73
73
  all accepted) are supported. `SaveAt(ts=...)` **raises**: cubic Hermite
74
74
  interpolation assumes smooth trajectories and is simply wrong between the
75
75
  points of a rough path. Land your grid on the step boundaries instead by
@@ -0,0 +1,124 @@
1
+ # Static Shapes
2
+
3
+ JAX jits fixed-shape programs. An adaptive integrator is naturally
4
+ dynamic — the number of steps depends on the data — so something must give.
5
+ tinydiffeq's answer is a **bounded scan**: `solve_ode` always runs one
6
+ `lax.scan` of exactly `max_steps` iterations, whatever the controller does.
7
+
8
+ Each iteration attempts one step:
9
+
10
+ - an **accepted** attempt advances `(t, x)` and (for FSAL solvers) reuses the
11
+ last stage as the next first stage;
12
+ - a **rejected** attempt leaves the state in place and retries with a smaller
13
+ step;
14
+ - once `t_1` is reached, the remaining iterations **freeze**.
15
+
16
+ The raw internal scan buffer contains repeated rows for rejected and frozen
17
+ iterations. That buffer is an implementation detail used by interpolation;
18
+ step output compacts it into accepted rows plus tail padding.
19
+
20
+ The scan loop itself always has `max_steps` iterations, preserving static
21
+ shapes and reverse-mode AD. A scalar `lax.cond` nevertheless keeps the
22
+ expensive vector-field, solver-stage, and controller computations out of the
23
+ frozen tail, so a scalar solve's compute cost follows its attempted steps plus
24
+ cheap loop overhead. Under `vmap`, JAX may turn each lane's conditional into
25
+ selection; batched work can therefore continue until the slowest lane
26
+ finishes.
27
+
28
+ Fixed-step and adaptive integration share this single code path:
29
+ `ConstantStepSize` accepts every attempt, so `dt_0 = (t_1 - t_0)/n` with
30
+ `max_steps = n` reproduces a fixed grid exactly. The conditional has modest
31
+ overhead for very cheap solves whose budget is already exact; it pays off
32
+ when the budget has a padded tail or the vector field is expensive.
33
+
34
+ If the budget runs out before `t_1`, `sol.ok` is `False` and the outputs hold
35
+ the reached prefix. The package never poisons values; the caller decides:
36
+
37
+ ```python
38
+ xs = jnp.where(sol.ok, sol.xs, jnp.inf) # kernels-style rejection
39
+ ```
40
+
41
+ ## SaveAt is the shape contract
42
+
43
+ Exactly one of three modes:
44
+
45
+ ### `SaveAt(t_1=True)` — endpoint only (default)
46
+
47
+ `sol.ts` is the reached time (equals `t_1` when `ok`), `sol.xs` the final
48
+ state.
49
+
50
+ ### `SaveAt(ts=grid)` — interpolation onto a fixed grid
51
+
52
+ This is the answer to "adaptive steps vs static shapes". Internal steps
53
+ adapt freely; the output is cubic-Hermite interpolation onto your fixed
54
+ query grid, so `sol.xs.shape == (len(grid),) + x_0.shape` **regardless of how
55
+ many steps the controller took**. Changing tolerances, initial conditions,
56
+ or curvature changes the internal knots but never the output shape — no
57
+ recompilation. A one-dimensional JAX/NumPy array or Python sequence is
58
+ accepted; times must be nondecreasing and within `[t_0, t_1]`, while repeated
59
+ times and omitted endpoints are allowed. Changing values without changing
60
+ the grid length does not recompile.
61
+
62
+ These are observation times, not mandatory internal stops. The adaptive
63
+ controller chooses exactly the same mesh regardless of the requested grid,
64
+ then the solver evaluates every requested point through dense interpolation.
65
+ Forcing exact internal landing times is a distinct feature and is not part of
66
+ this API.
67
+
68
+ The interpolation runs directly over the raw padded rows: duplicate knots
69
+ from rejections or the frozen tail form zero-width brackets, and the
70
+ bracketing `searchsorted` lands on the last duplicate at-or-before each
71
+ query, so no compaction pass is needed. Queries outside the knot span clamp
72
+ to the boundary values — in particular, when `ok` is `False`, queries beyond
73
+ the reached time return the last state (flat extrapolation) rather than
74
+ evaluating a cubic outside its bracket.
75
+
76
+ The interpolant is 4th-order accurate between 5th-order-accurate knots:
77
+ expect grid values slightly less accurate than the knots themselves, which
78
+ is the standard dense-output trade-off.
79
+
80
+ ### `SaveAt(steps=True)` — accepted steps with padding
81
+
82
+ `max_steps + 1` rows including the initial state. Accepted internal steps are
83
+ gathered chronologically into a contiguous prefix; rejected attempts are not
84
+ returned. `sol.accepted` is the validity mask (`accepted[0]` is always
85
+ `True`, so `accepted.sum() == num_accepted + 1`). On a successful solve, the
86
+ endpoint is at index `num_accepted`.
87
+
88
+ - `fill="last"` (default) repeats the last valid time and state through the
89
+ padded tail.
90
+ - `fill="inf"` fills only the invalid tail with `inf`.
91
+
92
+ If the attempt budget is exhausted, the same contract holds for the reached
93
+ accepted prefix, the tail repeats or masks its last state, and `sol.ok` is
94
+ `False`. No fake endpoint is inserted. Compaction is a stable fixed-size
95
+ gather; it performs no sorting.
96
+
97
+ ## Why one compilation, precisely
98
+
99
+ - The scan length `max_steps` is static; nothing else about the loop depends
100
+ on data shapes.
101
+ - Tolerances and PI coefficients (`IController(...)` / `PIController(...)`),
102
+ `dt_0`, `t_0`, `t_1`, `x_0`,
103
+ `args`, `p`, and `SaveAt.ts` are pytree **data leaves**. Only genuine
104
+ structure — the solver type, `SaveAt` mode, `fill`, `max_steps`, the
105
+ functions themselves — is static.
106
+
107
+ An omitted tolerance or `dt_min` is represented by `None`, so switching a
108
+ jitted call between automatic and explicit values changes the controller
109
+ pytree structure and compiles once for each policy. Changing the numeric
110
+ values of already-explicit controller fields does not recompile.
111
+
112
+ So this compiles once:
113
+
114
+ ```python
115
+ @jax.jit
116
+ def run(x_0, dt_0, controller, args):
117
+ return solve_ode(f, Tsit5(), 0.0, 1.0, x_0, args=args, dt_0=dt_0,
118
+ controller=controller, max_steps=128,
119
+ save_at=SaveAt(steps=True))
120
+ ```
121
+
122
+ across different curvatures (different accepted counts), tolerances, initial
123
+ steps, and initial conditions — pinned by `tests/test_recompile.py` with
124
+ `_cache_size() == 1` assertions.
@@ -46,5 +46,4 @@ nav:
46
46
  - Static Shapes: static_shapes.md
47
47
  - Adaptive Stepping and AD: adaptive_ad.md
48
48
  - SDEs: sde.md
49
- - Migration from Hand-Rolled Integrators: migration.md
50
49
  - API Reference: api.md
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tinydiffeq"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Tiny differentiable ODE/SDE solvers for JAX — bounded-scan adaptive stepping, static shapes, jvp/vjp-safe"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,22 +1,23 @@
1
1
  """Tiny differentiable ODE/SDE solvers for JAX.
2
2
 
3
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
4
+ or adaptive (Tsit5 + IController/PIController) explicit Runge-Kutta methods
5
+ inside one bounded lax.scan of exactly max_steps iterations, so shapes are
6
+ static and solves are differentiable in BOTH forward and reverse mode
7
+ (including reverse-over-forward) with O(max_steps) memory. SaveAt picks the
8
+ output: the endpoint, cubic-Hermite interpolation onto a fixed grid, or
9
+ accepted internal steps with fixed-shape padding. solve_sde is fixed-step
10
+ Euler-Maruyama with presampled
10
11
  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.
12
+ implicit/stiff solvers, full derivative-term PID control, events, dense
13
+ output, and adjoint methods are non-goals — use diffrax for those.
13
14
  """
14
15
 
15
- from tinydiffeq.controllers import ConstantStepSize, IController
16
+ from tinydiffeq.controllers import ConstantStepSize, IController, PIController
16
17
  from tinydiffeq.interpolation import hermite_interpolate
17
18
  from tinydiffeq.ode import solve_ode
18
19
  from tinydiffeq.quadrature import cumulative_trapezoid
19
- from tinydiffeq.saveat import SaveAt
20
+ from tinydiffeq.save_at import SaveAt
20
21
  from tinydiffeq.sde import solve_sde
21
22
  from tinydiffeq.solution import Solution
22
23
  from tinydiffeq.solvers import RK4, Euler, EulerMaruyama, Tsit5
@@ -30,6 +31,7 @@ __all__ = [
30
31
  "EulerMaruyama",
31
32
  "ConstantStepSize",
32
33
  "IController",
34
+ "PIController",
33
35
  "SaveAt",
34
36
  "Solution",
35
37
  "hermite_interpolate",