StochasticForceInference 2.0.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.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,540 @@
1
+ # SFI/inference/parametric_core/jacobians.py
2
+ """
3
+ RK4-composed per-particle Jacobians for interacting (multi-particle) systems.
4
+
5
+ Moved verbatim from the legacy parametric package (``_od_precompute`` /
6
+ ``_underdamped``) — the frozen-background approximation: the state update
7
+ uses the full multi-particle force, while the tangent is propagated per
8
+ particle from the same-particle derivatives, keeping the flow Jacobian
9
+ block-diagonal in the particle axis and the cost O(N) per window.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import jax.numpy as jnp
15
+ from jax import lax
16
+
17
+ __all__ = ["rk4_composed_jacobian", "rk4_composed_jacobian_theta",
18
+ "rk4_composed_jacobian_phase", "rk4_composed_jacobian_phase_theta"]
19
+
20
+
21
+ def rk4_composed_jacobian_theta(F_psf, dFdx_expr, dFdth_fn, n_params, X_start, theta_struct,
22
+ mask, extras, dt, n_substeps, integrator="rk4"):
23
+ r"""Per-particle flow Jacobian, displacement, **and θ-sensitivity** ``B``.
24
+
25
+ Same frozen-background RK4 composition as :func:`rk4_composed_jacobian`,
26
+ additionally carrying the per-particle parameter sensitivity
27
+
28
+ .. math:: B_p = \partial \Phi_p / \partial \theta \in (N, d, n_\theta)
29
+
30
+ through the classic variation-of-constants recursion (the legacy
31
+ ``_compute_jb_single_particle`` trick, RK4-composed): per stage
32
+ ``dk_s/dθ = Θ_s + c_s h A_s dk_{s-1}/dθ`` with ``Θ = ∂F/∂θ`` at the
33
+ *fixed* stage frame (exact — the force's direct θ-dependence is
34
+ per-particle), and per substep ``B ← J_step B + B_step``. The dropped
35
+ cross-particle paths (θ wiggling a *neighbour's* substep motion, which
36
+ feeds back within one Δt) are the same O(h²·coupling) terms the
37
+ frozen-background ``J`` already drops. Peak memory is
38
+ O(N·d·n_θ) instead of forward-mode AD's n_θ tangents through the full
39
+ N-body window flow.
40
+
41
+ Parameters
42
+ ----------
43
+ dFdth_fn : callable ``(N, d) frame → (N, d, n_params)``
44
+ Direct parameter derivative of the force at a fixed frame
45
+ (e.g. ``jax.jacfwd`` over θ of the frame-level force).
46
+ n_params : int
47
+ Number of flat parameters (sets the ``B`` carry width).
48
+ Other parameters as in :func:`rk4_composed_jacobian`.
49
+
50
+ Returns
51
+ -------
52
+ J : ``(N, d, d)``, Phi : ``(N, d)``, B : ``(N, d, n_params)``
53
+ """
54
+ if integrator != "rk4":
55
+ raise ValueError(
56
+ f"rk4_composed_jacobian_theta only supports integrator='rk4', got {integrator!r}"
57
+ )
58
+ h = dt / n_substeps
59
+ d = X_start.shape[-1]
60
+ I_d = jnp.eye(d)
61
+
62
+ def _force(X):
63
+ return F_psf(X, params=theta_struct, mask=mask, extras=extras)
64
+
65
+ def _dFdx(X):
66
+ raw = dFdx_expr(X, params=theta_struct, mask=mask, extras=extras)
67
+ return jnp.swapaxes(raw, -1, -2)
68
+
69
+ def _mm(A, B):
70
+ return jnp.einsum("...ij,...jk->...ik", A, B)
71
+
72
+ def _rk4_step(carry, _):
73
+ X, J_acc, B_acc = carry
74
+
75
+ k1 = _force(X)
76
+ A1 = _dFdx(X)
77
+ T1 = dFdth_fn(X)
78
+ X2 = X + (h / 2) * k1
79
+ k2 = _force(X2)
80
+ A2 = _dFdx(X2)
81
+ T2 = dFdth_fn(X2)
82
+ X3 = X + (h / 2) * k2
83
+ k3 = _force(X3)
84
+ A3 = _dFdx(X3)
85
+ T3 = dFdth_fn(X3)
86
+ X4 = X + h * k3
87
+ k4 = _force(X4)
88
+ A4 = _dFdx(X4)
89
+ T4 = dFdth_fn(X4)
90
+
91
+ X_new = X + (h / 6) * (k1 + 2 * k2 + 2 * k3 + k4)
92
+
93
+ # x-tangent chain (as in rk4_composed_jacobian)
94
+ S1 = I_d + (h / 2) * A1
95
+ A2S1 = _mm(A2, S1)
96
+ S2 = I_d + (h / 2) * A2S1
97
+ A3S2 = _mm(A3, S2)
98
+ S3 = I_d + h * A3S2
99
+ J_step = I_d + (h / 6) * (A1 + 2 * A2S1 + 2 * A3S2 + _mm(A4, S3))
100
+
101
+ # θ-tangent chain: dk_s = Θ_s + c_s·h·A_s·dk_{s-1}
102
+ dk1 = T1
103
+ dk2 = T2 + (h / 2) * _mm(A2, dk1)
104
+ dk3 = T3 + (h / 2) * _mm(A3, dk2)
105
+ dk4 = T4 + h * _mm(A4, dk3)
106
+ B_step = (h / 6) * (dk1 + 2 * dk2 + 2 * dk3 + dk4)
107
+
108
+ J_acc_new = _mm(J_step, J_acc)
109
+ B_acc_new = _mm(J_step, B_acc) + B_step
110
+ return (X_new, J_acc_new, B_acc_new), None
111
+
112
+ J0 = jnp.broadcast_to(I_d, X_start.shape[:-1] + (d, d))
113
+ B0 = jnp.zeros(X_start.shape[:-1] + (d, n_params), dtype=X_start.dtype)
114
+ (X_final, J_composed, B_composed), _ = lax.scan(
115
+ _rk4_step, (X_start, J0, B0), None, length=n_substeps)
116
+ return J_composed, X_final - X_start, B_composed
117
+
118
+
119
+ def rk4_composed_jacobian(F_psf, dFdx_expr, X_start, theta_struct, mask, extras, dt, n_substeps, integrator="rk4"):
120
+ r"""Exact RK4-composed per-particle diagonal Jacobian and displacement.
121
+
122
+ Computes the per-particle flow Jacobian :math:`\partial x_{end}^p /
123
+ \partial x_{start}^p` and displacement :math:`\Phi^p = x_{end}^p -
124
+ x_{start}^p` by composing exact RK4 Jacobians across substeps using
125
+ ``dFdx_expr = F_psf.d_x(same_particle=True)``.
126
+
127
+ The frozen-background approximation is used: cross-particle
128
+ derivatives are ignored (other particles held fixed at their
129
+ observed positions within each substep).
130
+
131
+ Parameters
132
+ ----------
133
+ F_psf : PSF object (``particles_input=True``)
134
+ dFdx_expr : DerivativeNode
135
+ From ``F_psf.d_x(same_particle=True)``. Evaluated as
136
+ ``dFdx_expr(X, params=..., mask=..., extras=...)``
137
+ returning ``(N, d, d)`` in the SFI convention (derivative-dim
138
+ first: ``[p, j, i] = dF_p^i / dx_p^j``).
139
+ X_start : ``(N, d)``
140
+ theta_struct : structured parameters (from ``F_psf.unflatten_params``)
141
+ mask : ``(N,)`` boolean or None
142
+ extras : dict or None
143
+ dt : float
144
+ n_substeps : int
145
+ integrator : {"rk4"}, optional
146
+ Accepted for API symmetry with the single-particle
147
+ :func:`._underdamped._phase_space_jacobian`. Only ``"rk4"`` is
148
+ supported here — the substep Jacobian is the hand-rolled RK4
149
+ chain rule below. Passing anything else raises ``ValueError`` so
150
+ callers don't silently get RK4 when they asked for Euler.
151
+
152
+ Returns
153
+ -------
154
+ J : ``(N, d, d)``
155
+ Flow Jacobian in standard convention: ``J[p, i, j] =
156
+ dx_{end,p}^i / dx_{start,p}^j``.
157
+ Phi : ``(N, d)``
158
+ Displacement ``x_{end} - x_{start}``.
159
+ """
160
+ if integrator != "rk4":
161
+ raise ValueError(
162
+ f"rk4_composed_jacobian only supports integrator='rk4', got {integrator!r}"
163
+ )
164
+ h = dt / n_substeps
165
+ d = X_start.shape[-1]
166
+ I_d = jnp.eye(d)
167
+
168
+ def _force(X):
169
+ return F_psf(X, params=theta_struct, mask=mask, extras=extras)
170
+
171
+ def _dFdx(X):
172
+ # SFI convention → standard: swap last two axes
173
+ raw = dFdx_expr(X, params=theta_struct, mask=mask, extras=extras)
174
+ return jnp.swapaxes(raw, -1, -2)
175
+
176
+ def _mm(A, B):
177
+ """Batched (N, d, d) matmul."""
178
+ return jnp.einsum("...ij,...jk->...ik", A, B)
179
+
180
+ def _rk4_step(carry, _):
181
+ X, J_acc = carry
182
+
183
+ k1 = _force(X)
184
+ A1 = _dFdx(X)
185
+
186
+ k2 = _force(X + (h / 2) * k1)
187
+ A2 = _dFdx(X + (h / 2) * k1)
188
+
189
+ k3 = _force(X + (h / 2) * k2)
190
+ A3 = _dFdx(X + (h / 2) * k2)
191
+
192
+ k4 = _force(X + h * k3)
193
+ A4 = _dFdx(X + h * k3)
194
+
195
+ # State update
196
+ X_new = X + (h / 6) * (k1 + 2 * k2 + 2 * k3 + k4)
197
+
198
+ # RK4 Jacobian chain rule (all per-particle, (N, d, d))
199
+ # S_i = sensitivity of stage i input w.r.t. X_start
200
+ S1 = I_d + (h / 2) * A1
201
+ A2S1 = _mm(A2, S1)
202
+ S2 = I_d + (h / 2) * A2S1
203
+ A3S2 = _mm(A3, S2)
204
+ S3 = I_d + h * A3S2
205
+
206
+ J_step = I_d + (h / 6) * (A1 + 2 * A2S1 + 2 * A3S2 + _mm(A4, S3))
207
+ J_acc_new = _mm(J_step, J_acc)
208
+
209
+ return (X_new, J_acc_new), None
210
+
211
+ J0 = jnp.broadcast_to(I_d, X_start.shape[:-1] + (d, d))
212
+ (X_final, J_composed), _ = lax.scan(
213
+ _rk4_step,
214
+ (X_start, J0),
215
+ None,
216
+ length=n_substeps,
217
+ )
218
+
219
+ Phi = X_final - X_start
220
+ return J_composed, Phi
221
+
222
+
223
+ def rk4_composed_jacobian_phase(
224
+ F_psf,
225
+ dFdx_expr,
226
+ dFdv_expr,
227
+ X_start,
228
+ V_start,
229
+ theta_struct,
230
+ mask,
231
+ extras,
232
+ dt,
233
+ n_substeps,
234
+ integrator="rk4",
235
+ ):
236
+ r"""RK4-composed per-particle phase-space Jacobian and flow.
237
+
238
+ Multi-particle analogue of :func:`_phase_space_jacobian` for the
239
+ flocking class: the state update uses the full multi-particle
240
+ force :math:`F(X, V)` (so inter-particle couplings drive the
241
+ dynamics), while the tangent is propagated **per particle** using
242
+ the per-edge derivatives
243
+ :math:`\partial F_p/\partial x_p` and :math:`\partial F_p/\partial v_p`
244
+ obtained from ``F_psf.d_x(same_particle=True)`` and
245
+ ``F_psf.d_v(same_particle=True)``. This is the frozen-background
246
+ approximation lifted to phase space: cross-particle Jacobian blocks
247
+ are dropped, keeping the per-particle flow Jacobian block-diagonal
248
+ in the particle axis and the cost O(N) per window.
249
+
250
+ Parameters
251
+ ----------
252
+ F_psf : PSF (``particles_input=True``, ``needs_v=True``)
253
+ dFdx_expr : ``F_psf.d_x(same_particle=True)``
254
+ dFdv_expr : ``F_psf.d_v(same_particle=True)``
255
+ X_start, V_start : ``(N, d)``
256
+ theta_struct : structured parameters (from ``F_psf.unflatten_params``)
257
+ mask : ``(N,)`` boolean or None
258
+ extras : dict or None
259
+ dt : float
260
+ n_substeps : int
261
+
262
+ integrator : {"rk4"}, optional
263
+ Accepted for API symmetry with the single-particle
264
+ :func:`_phase_space_jacobian`. Only ``"rk4"`` is supported here —
265
+ the substep Jacobian is the hand-rolled RK4 chain rule below.
266
+ Passing anything else raises ``ValueError`` so callers don't
267
+ silently get RK4 when they asked for Euler.
268
+
269
+ Returns
270
+ -------
271
+ Phi_x, Phi_v : ``(N, d)``
272
+ End-state position and velocity after time ``dt`` (matches
273
+ single-particle :func:`_phase_space_jacobian` convention).
274
+ Jxx, Jxv, Jvx, Jvv : ``(N, d, d)``
275
+ Per-particle phase-space Jacobian blocks in standard
276
+ convention: ``Jxx[p, i, j] = ∂x_{end,p}^i/∂x_{start,p}^j``, etc.
277
+ """
278
+ if integrator != "rk4":
279
+ raise ValueError(
280
+ f"rk4_composed_jacobian_phase only supports integrator='rk4', got {integrator!r}"
281
+ )
282
+ import jax.lax as lax
283
+
284
+ h = dt / n_substeps
285
+ d = X_start.shape[-1]
286
+ N = X_start.shape[-2]
287
+ I_d = jnp.eye(d)
288
+ I_2d = jnp.eye(2 * d)
289
+
290
+ def _force(X, V):
291
+ return F_psf(
292
+ X,
293
+ v=V,
294
+ params=theta_struct,
295
+ mask=mask,
296
+ extras=extras,
297
+ )
298
+
299
+ def _dFdx(X, V):
300
+ # SFI convention ``[p, j, i] = dF_p^i / dx_p^j`` →
301
+ # standard ``[p, i, j] = dF_p^i / dx_p^j`` via swap.
302
+ raw = dFdx_expr(
303
+ X,
304
+ v=V,
305
+ params=theta_struct,
306
+ mask=mask,
307
+ extras=extras,
308
+ )
309
+ return jnp.swapaxes(raw, -1, -2)
310
+
311
+ def _dFdv(X, V):
312
+ raw = dFdv_expr(
313
+ X,
314
+ v=V,
315
+ params=theta_struct,
316
+ mask=mask,
317
+ extras=extras,
318
+ )
319
+ return jnp.swapaxes(raw, -1, -2)
320
+
321
+ def _mm(A, B):
322
+ """Batched ``(N, a, b) @ (N, b, c) → (N, a, c)`` matmul."""
323
+ return jnp.einsum("...ij,...jk->...ik", A, B)
324
+
325
+ def _M_stack(Ax, Av):
326
+ """Build per-particle stage Jacobian
327
+
328
+ M = [[0, I_d], [A_x, A_v]] shape (N, 2d, 2d)
329
+ """
330
+ zero = jnp.zeros_like(Ax)
331
+ top = jnp.concatenate([zero, jnp.broadcast_to(I_d, Ax.shape)], axis=-1)
332
+ bot = jnp.concatenate([Ax, Av], axis=-1)
333
+ return jnp.concatenate([top, bot], axis=-2)
334
+
335
+ def _rk4_step(carry, _):
336
+ X, V, J_acc = carry
337
+
338
+ # Stage 1
339
+ A1_x = _dFdx(X, V)
340
+ A1_v = _dFdv(X, V)
341
+ k1_x = V
342
+ k1_v = _force(X, V)
343
+ M1 = _M_stack(A1_x, A1_v) # (N, 2d, 2d)
344
+ T1 = M1 # = M1 @ I = M1
345
+
346
+ # Stage 2
347
+ X2 = X + (h / 2) * k1_x
348
+ V2 = V + (h / 2) * k1_v
349
+ A2_x = _dFdx(X2, V2)
350
+ A2_v = _dFdv(X2, V2)
351
+ k2_x = V2
352
+ k2_v = _force(X2, V2)
353
+ M2 = _M_stack(A2_x, A2_v)
354
+ S2 = I_2d + (h / 2) * T1
355
+ T2 = _mm(M2, S2)
356
+
357
+ # Stage 3
358
+ X3 = X + (h / 2) * k2_x
359
+ V3 = V + (h / 2) * k2_v
360
+ A3_x = _dFdx(X3, V3)
361
+ A3_v = _dFdv(X3, V3)
362
+ k3_x = V3
363
+ k3_v = _force(X3, V3)
364
+ M3 = _M_stack(A3_x, A3_v)
365
+ S3 = I_2d + (h / 2) * T2
366
+ T3 = _mm(M3, S3)
367
+
368
+ # Stage 4
369
+ X4 = X + h * k3_x
370
+ V4 = V + h * k3_v
371
+ A4_x = _dFdx(X4, V4)
372
+ A4_v = _dFdv(X4, V4)
373
+ k4_x = V4
374
+ k4_v = _force(X4, V4)
375
+ M4 = _M_stack(A4_x, A4_v)
376
+ S4 = I_2d + h * T3
377
+ T4 = _mm(M4, S4)
378
+
379
+ # State update (full multi-particle force)
380
+ X_new = X + (h / 6) * (k1_x + 2 * k2_x + 2 * k3_x + k4_x)
381
+ V_new = V + (h / 6) * (k1_v + 2 * k2_v + 2 * k3_v + k4_v)
382
+
383
+ # Per-particle 2d×2d substep Jacobian + chain compose
384
+ J_step = I_2d + (h / 6) * (T1 + 2 * T2 + 2 * T3 + T4)
385
+ J_acc_new = _mm(J_step, J_acc)
386
+
387
+ return (X_new, V_new, J_acc_new), None
388
+
389
+ J0 = jnp.broadcast_to(I_2d, (N, 2 * d, 2 * d))
390
+ (X_final, V_final, J_composed), _ = lax.scan(
391
+ _rk4_step,
392
+ (X_start, V_start, J0),
393
+ None,
394
+ length=n_substeps,
395
+ )
396
+
397
+ Phi_x = X_final
398
+ Phi_v = V_final
399
+ Jxx = J_composed[:, :d, :d]
400
+ Jxv = J_composed[:, :d, d:]
401
+ Jvx = J_composed[:, d:, :d]
402
+ Jvv = J_composed[:, d:, d:]
403
+ return Phi_x, Phi_v, Jxx, Jxv, Jvx, Jvv
404
+
405
+
406
+ def rk4_composed_jacobian_phase_theta(
407
+ F_psf,
408
+ dFdx_expr,
409
+ dFdv_expr,
410
+ dFdth_fn,
411
+ n_params,
412
+ X_start,
413
+ V_start,
414
+ theta_struct,
415
+ mask,
416
+ extras,
417
+ dt,
418
+ n_substeps,
419
+ integrator="rk4",
420
+ ):
421
+ r"""Phase-space per-particle Jacobian blocks, flow, **and θ-sensitivity**.
422
+
423
+ Phase-space analogue of :func:`rk4_composed_jacobian_theta`: alongside
424
+ the frozen-background blocks of :func:`rk4_composed_jacobian_phase`,
425
+ carries the per-particle lifted parameter sensitivity
426
+
427
+ .. math:: B_p = \partial (x', v')_p / \partial\theta \in (N, 2d, n_\theta)
428
+
429
+ through the RK4 chain with lifted stage tangents
430
+ ``Θ̃ = [0; ∂F/∂θ]`` (the position field has no direct θ-dependence) and
431
+ ``dk_s/dθ = Θ̃_s + c_s h M_s dk_{s-1}/dθ``; per substep
432
+ ``B ← J_step B + B_step``. Memory O(N·2d·n_θ).
433
+
434
+ Parameters
435
+ ----------
436
+ dFdth_fn : callable ``((N, d), (N, d)) → (N, d, n_params)``
437
+ Direct parameter derivative of the force at a fixed phase frame.
438
+ n_params : int
439
+ Other parameters as in :func:`rk4_composed_jacobian_phase`.
440
+
441
+ Returns
442
+ -------
443
+ Phi_x, Phi_v : ``(N, d)`` end-state position / velocity.
444
+ Jxx, Jxv, Jvx, Jvv : ``(N, d, d)`` frozen-background blocks.
445
+ Bx, Bv : ``(N, d, n_params)`` θ-sensitivity of the end state.
446
+ """
447
+ if integrator != "rk4":
448
+ raise ValueError(
449
+ f"rk4_composed_jacobian_phase_theta only supports integrator='rk4', got {integrator!r}"
450
+ )
451
+ import jax.lax as lax
452
+
453
+ h = dt / n_substeps
454
+ d = X_start.shape[-1]
455
+ N = X_start.shape[-2]
456
+ I_d = jnp.eye(d)
457
+ I_2d = jnp.eye(2 * d)
458
+
459
+ def _force(X, V):
460
+ return F_psf(X, v=V, params=theta_struct, mask=mask, extras=extras)
461
+
462
+ def _dFdx(X, V):
463
+ raw = dFdx_expr(X, v=V, params=theta_struct, mask=mask, extras=extras)
464
+ return jnp.swapaxes(raw, -1, -2)
465
+
466
+ def _dFdv(X, V):
467
+ raw = dFdv_expr(X, v=V, params=theta_struct, mask=mask, extras=extras)
468
+ return jnp.swapaxes(raw, -1, -2)
469
+
470
+ def _mm(A, B):
471
+ return jnp.einsum("...ij,...jk->...ik", A, B)
472
+
473
+ def _M_stack(Ax, Av):
474
+ zero = jnp.zeros_like(Ax)
475
+ top = jnp.concatenate([zero, jnp.broadcast_to(I_d, Ax.shape)], axis=-1)
476
+ bot = jnp.concatenate([Ax, Av], axis=-1)
477
+ return jnp.concatenate([top, bot], axis=-2)
478
+
479
+ def _lift(T):
480
+ # (N, d, n) → (N, 2d, n): zero position block on top
481
+ return jnp.concatenate([jnp.zeros_like(T), T], axis=-2)
482
+
483
+ def _rk4_step(carry, _):
484
+ X, V, J_acc, B_acc = carry
485
+
486
+ A1x, A1v = _dFdx(X, V), _dFdv(X, V)
487
+ k1_x, k1_v = V, _force(X, V)
488
+ M1 = _M_stack(A1x, A1v)
489
+ T1 = _lift(dFdth_fn(X, V))
490
+
491
+ X2, V2 = X + (h / 2) * k1_x, V + (h / 2) * k1_v
492
+ A2x, A2v = _dFdx(X2, V2), _dFdv(X2, V2)
493
+ k2_x, k2_v = V2, _force(X2, V2)
494
+ M2 = _M_stack(A2x, A2v)
495
+ T2 = _lift(dFdth_fn(X2, V2))
496
+
497
+ X3, V3 = X + (h / 2) * k2_x, V + (h / 2) * k2_v
498
+ A3x, A3v = _dFdx(X3, V3), _dFdv(X3, V3)
499
+ k3_x, k3_v = V3, _force(X3, V3)
500
+ M3 = _M_stack(A3x, A3v)
501
+ T3 = _lift(dFdth_fn(X3, V3))
502
+
503
+ X4, V4 = X + h * k3_x, V + h * k3_v
504
+ A4x, A4v = _dFdx(X4, V4), _dFdv(X4, V4)
505
+ k4_x, k4_v = V4, _force(X4, V4)
506
+ M4 = _M_stack(A4x, A4v)
507
+ T4 = _lift(dFdth_fn(X4, V4))
508
+
509
+ # x-tangent chain (identical to rk4_composed_jacobian_phase)
510
+ S2 = I_2d + (h / 2) * M1
511
+ T2_ = _mm(M2, S2)
512
+ S3 = I_2d + (h / 2) * T2_
513
+ T3_ = _mm(M3, S3)
514
+ S4 = I_2d + h * T3_
515
+ T4_ = _mm(M4, S4)
516
+ J_step = I_2d + (h / 6) * (M1 + 2 * T2_ + 2 * T3_ + T4_)
517
+
518
+ # θ-tangent chain on the lifted system
519
+ dk1 = T1
520
+ dk2 = T2 + (h / 2) * _mm(M2, dk1)
521
+ dk3 = T3 + (h / 2) * _mm(M3, dk2)
522
+ dk4 = T4 + h * _mm(M4, dk3)
523
+ B_step = (h / 6) * (dk1 + 2 * dk2 + 2 * dk3 + dk4)
524
+
525
+ X_new = X + (h / 6) * (k1_x + 2 * k2_x + 2 * k3_x + k4_x)
526
+ V_new = V + (h / 6) * (k1_v + 2 * k2_v + 2 * k3_v + k4_v)
527
+ J_acc_new = _mm(J_step, J_acc)
528
+ B_acc_new = _mm(J_step, B_acc) + B_step
529
+ return (X_new, V_new, J_acc_new, B_acc_new), None
530
+
531
+ J0 = jnp.broadcast_to(I_2d, (N, 2 * d, 2 * d))
532
+ B0 = jnp.zeros((N, 2 * d, n_params), dtype=X_start.dtype)
533
+ (X_final, V_final, J_composed, B_composed), _ = lax.scan(
534
+ _rk4_step, (X_start, V_start, J0, B0), None, length=n_substeps)
535
+ return (
536
+ X_final, V_final,
537
+ J_composed[:, :d, :d], J_composed[:, :d, d:],
538
+ J_composed[:, d:, :d], J_composed[:, d:, d:],
539
+ B_composed[:, :d, :], B_composed[:, d:, :],
540
+ )