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,362 @@
1
+ # SFI/inference/parametric_core/flow_multi.py
2
+ """
3
+ Multiparticle residuals — one function for both the non-interacting and
4
+ interacting cases, dispatched on the force model's particle contract.
5
+
6
+ The covariance/precision machinery is **per particle**, so all this layer
7
+ does is produce per-particle residuals ``r`` and flow Jacobians ``J`` from
8
+ a window of multiparticle positions:
9
+
10
+ * **non-interacting** (``particles_input=False``): the same force is
11
+ applied to each particle independently — ``vmap`` the single-particle
12
+ :func:`flow.od_window_residuals` over the particle axis.
13
+ * **interacting** (``particles_input=True``): the displacement uses the
14
+ *full* multiparticle force (couplings matter), while the per-particle
15
+ flow Jacobian uses the frozen-background same-particle derivative
16
+ ``F_psf.d_x(same_particle=True)`` — via
17
+ :func:`jacobians.rk4_composed_jacobian` (O(N) per window).
18
+
19
+ Single particle is just ``N=1``; the integrate engine reduces + masks
20
+ over the particle axis (``reduce_over_particles``), so there is no
21
+ separate multiparticle code path in the solver — exactly like
22
+ ``infer_force_linear``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import jax
28
+ import jax.numpy as jnp
29
+
30
+ from .flow import flow_displacement, od_instrument, od_window_residuals
31
+ from .flow_ud import _phase_field, ud_instrument, ud_window_residuals
32
+
33
+ __all__ = ["multi_step_residuals", "multi_step_residuals_with_psi",
34
+ "ud_multi_step_residuals", "ud_multi_step_residuals_with_psi",
35
+ "multi_od_instrument", "multi_ud_instrument"]
36
+
37
+
38
+ def _interacting_jpb(F_psf, theta_flat, extras, dt, n_substeps, integrator):
39
+ r"""Frame → ``(J, Φ, B)`` closure for an interacting force (frozen background).
40
+
41
+ ``B = ∂Φ/∂θ`` per particle via :func:`jacobians.rk4_composed_jacobian_theta`
42
+ — the legacy same-particle trick: O(N·d·n_params) carry instead of
43
+ forward-mode tangents through the full N-body window flow. ``∂F/∂θ`` at a
44
+ fixed frame is exact and per-particle (jacfwd of one force evaluation);
45
+ only the O(h²) cross-particle substep feedback is dropped, consistently
46
+ with the frozen-background ``J``.
47
+ """
48
+ from .jacobians import rk4_composed_jacobian_theta
49
+
50
+ struct = F_psf.unflatten_params(theta_flat)
51
+ dFdx = F_psf.d_x(same_particle=True)
52
+ n_params = int(theta_flat.shape[-1])
53
+
54
+ def dFdth(X):
55
+ return jax.jacfwd(
56
+ lambda th: F_psf(X, params=F_psf.unflatten_params(th), extras=extras)
57
+ )(theta_flat) # (N, d, n_params)
58
+
59
+ def jpb(X_start):
60
+ return rk4_composed_jacobian_theta(
61
+ F_psf, dFdx, dFdth, n_params, X_start, struct, None, extras,
62
+ dt, n_substeps, integrator)
63
+
64
+ return jpb
65
+
66
+
67
+ def multi_step_residuals_with_psi(F_psf, theta_flat, X_w, extras, dt, n_substeps,
68
+ integrator="rk4"):
69
+ r"""Interacting residuals + flow Jacobians + frozen-background ``ψ_right``.
70
+
71
+ Same residuals/Jacobians as the interacting branch of
72
+ :func:`multi_step_residuals`, additionally returning the per-particle
73
+ regressor ``ψ_right = ∂r/∂θ = −B`` from the same-particle θ-recursion —
74
+ the memory-scalable replacement for ``jax.jacfwd`` through the full
75
+ N-body flow (which holds n_params tangents alive across the whole
76
+ window graph and scales O(K·N²·n_params) per chunk).
77
+
78
+ Returns
79
+ -------
80
+ r : ``(W-1, N, d)``, J : ``(W-1, N, d, d)``, psi : ``(W-1, N, d, n_params)``
81
+ """
82
+ jpb = _interacting_jpb(F_psf, theta_flat, extras, dt, n_substeps, integrator)
83
+
84
+ def _step(X_start, X_end):
85
+ J, Phi, B = jpb(X_start)
86
+ return X_end - X_start - Phi, J, -B
87
+
88
+ return jax.vmap(_step)(X_w[:-1], X_w[1:])
89
+
90
+
91
+ def multi_step_residuals(F_psf, theta_struct, X_w, extras, dt, n_substeps, integrator="rk4"):
92
+ r"""Per-particle residuals and flow Jacobians over a multiparticle window.
93
+
94
+ Parameters
95
+ ----------
96
+ F_psf : PSF
97
+ theta_struct : structured parameters (from ``F_psf.unflatten_params``).
98
+ X_w : ``(W, N, d)`` window of multiparticle positions.
99
+ extras : dict or None
100
+ dt, n_substeps, integrator : flow settings.
101
+
102
+ Returns
103
+ -------
104
+ r : ``(W-1, N, d)``
105
+ J : ``(W-1, N, d, d)``
106
+ """
107
+ if getattr(F_psf, "particles_input", False):
108
+ from .jacobians import rk4_composed_jacobian
109
+
110
+ dFdx = F_psf.d_x(same_particle=True)
111
+
112
+ def _step(X_start, X_end): # (N, d), (N, d)
113
+ J, Phi = rk4_composed_jacobian(
114
+ F_psf, dFdx, X_start, theta_struct, None, extras, dt, n_substeps, integrator)
115
+ return X_end - X_start - Phi, J
116
+
117
+ return jax.vmap(_step)(X_w[:-1], X_w[1:]) # (W-1, N, d), (W-1, N, d, d)
118
+
119
+ def _drift(x):
120
+ return F_psf(x[None], params=theta_struct, extras=extras)[0]
121
+
122
+ def _per_particle(Xp): # (W, d)
123
+ return od_window_residuals(_drift, Xp, dt, n_substeps, integrator)
124
+
125
+ r, J = jax.vmap(_per_particle, in_axes=1)(X_w) # (N, W-1, d), (N, W-1, d, d)
126
+ return jnp.swapaxes(r, 0, 1), jnp.swapaxes(J, 0, 1)
127
+
128
+
129
+ def ud_multi_step_residuals(F_psf, theta_struct, Y_w, extras, dt, n_substeps, integrator="rk4"):
130
+ r"""Per-particle underdamped 3-point residuals + α-propagators + ``v̂``.
131
+
132
+ * *Non-interacting* (``particles_input=False``): ``vmap`` the
133
+ single-particle :func:`flow_ud.ud_window_residuals` over particles.
134
+ * *Interacting* (``particles_input=True``): multi-particle phase-space
135
+ shooting (full force drives the flow) + frozen-background per-particle
136
+ phase Jacobian via ``rk4_composed_jacobian_phase`` — the underdamped
137
+ analogue of the overdamped interacting path.
138
+
139
+ Returns ``r, α₊, α₀, α₋, v̂`` each shaped ``(W-2, N, ...)``.
140
+ """
141
+ if getattr(F_psf, "particles_input", False):
142
+ return _ud_multi_interacting(F_psf, theta_struct, Y_w, extras, dt, n_substeps, integrator)
143
+
144
+ def _force(x, v):
145
+ return F_psf(x[None], v=v[None], params=theta_struct, extras=extras)[0]
146
+
147
+ def _per_particle(Yp): # (W, d)
148
+ r, ap, a0, am, _, vhat = ud_window_residuals(_force, Yp, dt, n_substeps, integrator)
149
+ return r, ap, a0, am, vhat
150
+
151
+ r, ap, a0, am, vhat = jax.vmap(_per_particle, in_axes=1)(Y_w) # each (N, W-2, ...)
152
+
153
+ def sw(x):
154
+ return jnp.swapaxes(x, 0, 1)
155
+
156
+ return sw(r), sw(ap), sw(a0), sw(am), sw(vhat)
157
+
158
+
159
+ def multi_od_instrument(F_psf, theta, X_base, extras, dt, n_substeps, integrator="rk4"):
160
+ r"""η-clean overdamped instrument per particle — ``(N, d, n_params)``.
161
+
162
+ Multiparticle dispatcher for :func:`flow.od_instrument`, mirroring
163
+ :func:`multi_step_residuals`:
164
+
165
+ * **non-interacting**: the single-particle instrument, ``vmap``-ed over
166
+ the particle axis of the base frame.
167
+ * **interacting** (``particles_input=True``): the instrument flow must be
168
+ the *same N-body flow* the residual uses — the sensitivity is taken of
169
+ the full-frame displacement ``Φ(X;θ)``, seeded at the η-clean base frame.
170
+ Evaluating the force on isolated single-particle frames instead zeroes
171
+ (or crashes) every pair-feature column, which makes the IV Gram
172
+ structurally singular on the interaction parameters — the ABP-port
173
+ plateau. ``od_instrument`` is shape-agnostic, so the frame-level call
174
+ only needs a frame-level drift closure.
175
+
176
+ Parameters
177
+ ----------
178
+ F_psf : PSF
179
+ theta : ``(n_params,)`` flat parameters.
180
+ X_base : ``(N, d)`` η-clean base frame (the reserved front position).
181
+ extras : dict or None
182
+ dt, n_substeps, integrator : flow settings.
183
+ """
184
+ if getattr(F_psf, "particles_input", False):
185
+ # Frozen-background trapeze: ψ_inst = −½(B(X_base) + B(X_base + Φ)),
186
+ # the same-particle θ-sensitivity at the η-clean frame and its forward
187
+ # image — O(N·d·n_params) memory, vs jacfwd through the N-body flow.
188
+ jpb = _interacting_jpb(F_psf, theta, extras, dt, n_substeps, integrator)
189
+ _, Phi0, B0 = jpb(X_base)
190
+ _, _, B1 = jpb(jax.lax.stop_gradient(X_base + Phi0))
191
+ return -0.5 * (B0 + B1)
192
+
193
+ def drift_of_theta(th):
194
+ struct = F_psf.unflatten_params(th)
195
+ return lambda y: F_psf(y[None], params=struct, extras=extras)[0]
196
+
197
+ return jax.vmap(
198
+ lambda xb: od_instrument(drift_of_theta, theta, xb, dt, n_substeps, integrator)
199
+ )(X_base)
200
+
201
+
202
+ def multi_ud_instrument(F_psf, theta, Y_a, Y_b, extras, dt, n_substeps,
203
+ integrator="rk4", n_predict=2):
204
+ r"""η-clean underdamped instrument per particle — ``(N, d, n_params)``.
205
+
206
+ Multiparticle dispatcher for :func:`flow_ud.ud_instrument`:
207
+
208
+ * **non-interacting**: the single-particle instrument, ``vmap``-ed over
209
+ the particle axis of the lagged pair ``(Y_a, Y_b)``.
210
+ * **interacting** (``particles_input=True``): frame-level analogue built
211
+ from the same protocols as the interacting residual path — shooting at
212
+ ``(Y_a → Y_b)`` with the frozen-background per-particle ``J^{xv}``
213
+ (:func:`jacobians.rk4_composed_jacobian_phase`, as in the residual's
214
+ ``v̂``), phase-flowing the full frame forward ``n_predict`` intervals,
215
+ then differentiating the full-force one-step position flow in θ at the
216
+ clean (stop-gradient) phase point. Everything is a function of the two
217
+ front frames only, so the instrument stays η-clean of the whole
218
+ residual block.
219
+ """
220
+ if getattr(F_psf, "particles_input", False):
221
+ from .jacobians import rk4_composed_jacobian_phase
222
+
223
+ struct0 = F_psf.unflatten_params(theta)
224
+ dFdx = F_psf.d_x(same_particle=True)
225
+ dFdv = F_psf.d_v(same_particle=True)
226
+ N = Y_a.shape[0]
227
+
228
+ def frame_phase_field(th_struct):
229
+ def force(X, V):
230
+ return F_psf(X, v=V, params=th_struct, extras=extras)
231
+ return _phase_field(force, N)
232
+
233
+ # 1. shooting at (Y_a → Y_b): arrival velocity at Y_b (residual protocol).
234
+ V0 = (Y_b - Y_a) / dt
235
+ Phi_x0, Phi_v0, _Jxx, Jxv, _Jvx, Jvv = rk4_composed_jacobian_phase(
236
+ F_psf, dFdx, dFdv, Y_a, V0, struct0, None, extras, dt, n_substeps, integrator)
237
+ dV = jnp.linalg.solve(Jxv, (Y_b - Phi_x0)[..., None]).squeeze(-1)
238
+ v_b = Phi_v0 + jnp.einsum("nij,nj->ni", Jvv, dV)
239
+
240
+ # 2. full-force phase flow to the center base (θ fixed, then "data").
241
+ z = jnp.concatenate([Y_b, v_b], axis=0) # (2N, d) lifted frame
242
+ g0 = frame_phase_field(struct0)
243
+ for _ in range(n_predict):
244
+ z = z + flow_displacement(g0, z, dt, n_substeps, integrator)
245
+ z_c = jax.lax.stop_gradient(z)
246
+
247
+ # 3. ψ_inst = −∂Φˣ/∂θ at the clean phase point — frozen-background
248
+ # per-particle phase θ-sensitivity (Bx), O(N·d·n_params) memory.
249
+ jpb = _interacting_phase_jpb(F_psf, theta, extras, dt, n_substeps, integrator)
250
+ x_c, v_c = z_c[:N], z_c[N:]
251
+ Bx = jpb(x_c, v_c)[6]
252
+ return -Bx
253
+
254
+ def force_of_theta(th):
255
+ struct = F_psf.unflatten_params(th)
256
+ return lambda x, v: F_psf(x[None], v=v[None], params=struct, extras=extras)[0]
257
+
258
+ return jax.vmap(
259
+ lambda a, b: ud_instrument(force_of_theta, theta, a, b, dt, n_substeps,
260
+ integrator, n_predict=n_predict)
261
+ )(Y_a, Y_b)
262
+
263
+
264
+ def _interacting_phase_jpb(F_psf, theta_flat, extras, dt, n_substeps, integrator):
265
+ """Phase frame → (Φx, Φv, Jxx, Jxv, Jvx, Jvv, Bx, Bv) closure (frozen background)."""
266
+ from .jacobians import rk4_composed_jacobian_phase_theta
267
+
268
+ struct = F_psf.unflatten_params(theta_flat)
269
+ dFdx = F_psf.d_x(same_particle=True)
270
+ dFdv = F_psf.d_v(same_particle=True)
271
+ n_params = int(theta_flat.shape[-1])
272
+
273
+ def dFdth(X, V):
274
+ return jax.jacfwd(
275
+ lambda th: F_psf(X, v=V, params=F_psf.unflatten_params(th), extras=extras)
276
+ )(theta_flat) # (N, d, n_params)
277
+
278
+ def jpb(X, V):
279
+ return rk4_composed_jacobian_phase_theta(
280
+ F_psf, dFdx, dFdv, dFdth, n_params, X, V, struct, None, extras,
281
+ dt, n_substeps, integrator)
282
+
283
+ return jpb
284
+
285
+
286
+ def ud_multi_step_residuals_with_psi(F_psf, theta_flat, Y_w, extras, dt, n_substeps,
287
+ integrator="rk4"):
288
+ r"""Interacting UD residuals + α-propagators + frozen-background ``ψ_right``.
289
+
290
+ Same shooting protocol as :func:`_ud_multi_interacting`, additionally
291
+ returning ``ψ_right = ∂r/∂θ`` from the per-particle phase θ-recursion:
292
+
293
+ .. math::
294
+
295
+ \partial\hat v/\partial\theta = B^v_{\rm in}
296
+ - J^{vv} (J^{xv})^{-1} B^x_{\rm in},\qquad
297
+ ψ = -\bigl(B^x_{\rm out} + J^{xv}_{\rm out}\,
298
+ \partial\hat v/\partial\theta\bigr),
299
+
300
+ treating the (O(h)) Jacobian blocks as θ-independent — the same
301
+ second-order cross terms the frozen-background blocks already drop.
302
+
303
+ Returns ``r, α₊, α₀, α₋, v̂, ψ`` with ``ψ : (W-2, N, d, n_params)``.
304
+ """
305
+ jpb = _interacting_phase_jpb(F_psf, theta_flat, extras, dt, n_substeps, integrator)
306
+ d = Y_w.shape[-1]
307
+ I_d = jnp.eye(d, dtype=Y_w.dtype)
308
+
309
+ def _step(X_prev, X_cur, X_next): # each (N, d)
310
+ V0 = (X_cur - X_prev) / dt
311
+ Phi_x0, Phi_v0, Jxx_in, Jxv_in, Jvx_in, Jvv_in, Bx_in, Bv_in = jpb(X_prev, V0)
312
+ Jxv_in_inv = jnp.linalg.inv(Jxv_in)
313
+ dV = jnp.einsum("nij,nj->ni", Jxv_in_inv, X_cur - Phi_x0)
314
+ v_hat = Phi_v0 + jnp.einsum("nij,nj->ni", Jvv_in, dV)
315
+ dvhat_dth = Bv_in - jnp.einsum("nij,njk->nik", Jvv_in,
316
+ jnp.einsum("nij,njk->nik", Jxv_in_inv, Bx_in))
317
+
318
+ Phi_x_out, _, Jxx_out, Jxv_out, _, _, Bx_out, _ = jpb(X_cur, v_hat)
319
+ r = X_next - Phi_x_out
320
+ psi = -(Bx_out + jnp.einsum("nij,njk->nik", Jxv_out, dvhat_dth))
321
+
322
+ M = Jvx_in - Jvv_in @ Jxv_in_inv @ Jxx_in
323
+ Nmat = Jvv_in @ Jxv_in_inv
324
+ alpha_p1 = jnp.broadcast_to(I_d, Jxx_out.shape)
325
+ alpha_0 = -Jxx_out - Jxv_out @ Nmat
326
+ alpha_m1 = -Jxv_out @ M
327
+ return r, alpha_p1, alpha_0, alpha_m1, v_hat, psi
328
+
329
+ return jax.vmap(_step)(Y_w[:-2], Y_w[1:-1], Y_w[2:])
330
+
331
+
332
+ def _ud_multi_interacting(F_psf, theta_struct, Y_w, extras, dt, n_substeps, integrator):
333
+ """Interacting underdamped: per-particle shooting + frozen-background phase Jacobian."""
334
+ from .jacobians import rk4_composed_jacobian_phase
335
+
336
+ dFdx = F_psf.d_x(same_particle=True)
337
+ dFdv = F_psf.d_v(same_particle=True)
338
+ d = Y_w.shape[-1]
339
+ I_d = jnp.eye(d, dtype=Y_w.dtype)
340
+
341
+ def _phase(X, V):
342
+ return rk4_composed_jacobian_phase(
343
+ F_psf, dFdx, dFdv, X, V, theta_struct, None, extras, dt, n_substeps, integrator)
344
+
345
+ def _step(X_prev, X_cur, X_next): # each (N, d)
346
+ V0 = (X_cur - X_prev) / dt
347
+ Phi_x0, Phi_v0, Jxx_in, Jxv_in, Jvx_in, Jvv_in = _phase(X_prev, V0)
348
+ dV = jnp.linalg.solve(Jxv_in, (X_cur - Phi_x0)[..., None]).squeeze(-1) # (N, d)
349
+ v_hat = Phi_v0 + jnp.einsum("nij,nj->ni", Jvv_in, dV)
350
+
351
+ Phi_x_out, _, Jxx_out, Jxv_out, _, _ = _phase(X_cur, v_hat)
352
+ r = X_next - Phi_x_out
353
+
354
+ Jxv_in_inv = jnp.linalg.inv(Jxv_in)
355
+ M = Jvx_in - Jvv_in @ Jxv_in_inv @ Jxx_in
356
+ Nmat = Jvv_in @ Jxv_in_inv
357
+ alpha_p1 = jnp.broadcast_to(I_d, Jxx_out.shape)
358
+ alpha_0 = -Jxx_out - Jxv_out @ Nmat
359
+ alpha_m1 = -Jxv_out @ M
360
+ return r, alpha_p1, alpha_0, alpha_m1, v_hat
361
+
362
+ return jax.vmap(_step)(Y_w[:-2], Y_w[1:-1], Y_w[2:]) # each (W-2, N, ...)
@@ -0,0 +1,168 @@
1
+ # SFI/inference/parametric_core/flow_ud.py
2
+ """
3
+ Underdamped (2nd-order) flow: phase-space propagation, shooting velocity,
4
+ and the 3-point position residual with its measurement-noise propagators.
5
+
6
+ The velocity is unobserved, so the residual is built from three
7
+ consecutive positions:
8
+
9
+ 1. **Shooting** — resolve the velocity ``v̂_n`` that carries ``Y_{n-1}``
10
+ to ``Y_n`` by one Newton step from the secant velocity (the position
11
+ block of the phase-space flow is inverted via ``J^{xv}``).
12
+ 2. **Residual** — propagate ``(Y_n, v̂_n)`` one step and compare to the
13
+ next observed position: ``r_n = Y_{n+1} − Φˣ(Y_n, v̂_n; θ)``.
14
+ 3. **α-propagators** — how the measurement noise at the three samples
15
+ enters ``r_n`` (``α₊ = I``, ``α₀``, ``α₋``); these set the bandwidth-2
16
+ (pentadiagonal) covariance structure.
17
+
18
+ The phase-space flow is just :func:`flow.flow_step` applied to the lifted
19
+ field ``ż = (v, F(x, v))`` on ``ℝ^{2d}`` — RK4/Euler and the Jacobian come
20
+ for free.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+
28
+ from .flow import flow_step
29
+
30
+ __all__ = ["phase_space_flow_jac", "shooting_velocity", "ud_step_quantities",
31
+ "ud_window_residuals", "ud_instrument"]
32
+
33
+
34
+ def _phase_field(force_fn, d):
35
+ def g(z):
36
+ return jnp.concatenate([z[d:], force_fn(z[:d], z[d:])])
37
+ return g
38
+
39
+
40
+ def phase_space_flow_jac(force_fn, x, v, dt, n_substeps, integrator="rk4"):
41
+ r"""Phase-space arrival state and ``2d×2d`` flow Jacobian blocks.
42
+
43
+ Returns ``Φ_x, Φ_v`` (arrival position, velocity) and the four
44
+ ``d×d`` blocks ``J^{xx}, J^{xv}, J^{vx}, J^{vv}`` of ``∂(x',v')/∂(x,v)``.
45
+ """
46
+ d = x.shape[0]
47
+ z0 = jnp.concatenate([x, v])
48
+ Phi_z, J = flow_step(_phase_field(force_fn, d), z0, dt, n_substeps, integrator)
49
+ z_final = z0 + Phi_z
50
+ return (
51
+ z_final[:d], z_final[d:],
52
+ J[:d, :d], J[:d, d:], J[d:, :d], J[d:, d:],
53
+ )
54
+
55
+
56
+ def shooting_velocity(force_fn, Y_prev, Y_cur, dt, n_substeps, integrator="rk4"):
57
+ r"""Velocity at ``Y_prev`` that flows to ``Y_cur`` (one Newton step).
58
+
59
+ Returns ``v̂`` (the *arrival* velocity at ``Y_cur``) and the incoming
60
+ phase-space Jacobian blocks evaluated at ``(Y_prev, v₀)``.
61
+ """
62
+ v0 = (Y_cur - Y_prev) / dt
63
+ Phi_x0, Phi_v0, Jxx, Jxv, Jvx, Jvv = phase_space_flow_jac(force_fn, Y_prev, v0, dt, n_substeps, integrator)
64
+ dv = jnp.linalg.solve(Jxv, Y_cur - Phi_x0)
65
+ v_hat = Phi_v0 + Jvv @ dv
66
+ return v_hat, Jxx, Jxv, Jvx, Jvv
67
+
68
+
69
+ def ud_step_quantities(force_fn, Y_prev, Y_cur, Y_next, dt, n_substeps, integrator="rk4"):
70
+ r"""Residual and α-propagators for one interior position ``Y_cur``.
71
+
72
+ Returns ``r, α₊, α₀, α₋, J^{vv}_out, v̂`` where ``r = Y_next − Φˣ(Y_cur,
73
+ v̂)``, the α's propagate the measurement noise at ``Y_{n-1}, Y_n,
74
+ Y_{n+1}`` into ``r``, and ``v̂`` is the shooting velocity at ``Y_cur``
75
+ (used to evaluate a velocity-dependent diffusion ``D(x, v)``).
76
+ """
77
+ d = Y_cur.shape[0]
78
+ I_d = jnp.eye(d, dtype=Y_cur.dtype)
79
+
80
+ v_hat, Jxx_in, Jxv_in, Jvx_in, Jvv_in = shooting_velocity(force_fn, Y_prev, Y_cur, dt, n_substeps, integrator)
81
+ Phi_x_out, _, Jxx_out, Jxv_out, _, Jvv_out = phase_space_flow_jac(
82
+ force_fn, Y_cur, v_hat, dt, n_substeps, integrator
83
+ )
84
+
85
+ r = Y_next - Phi_x_out
86
+
87
+ Jxv_in_inv = jnp.linalg.inv(Jxv_in)
88
+ M = Jvx_in - Jvv_in @ Jxv_in_inv @ Jxx_in
89
+ N = Jvv_in @ Jxv_in_inv
90
+
91
+ alpha_p1 = I_d
92
+ alpha_0 = -Jxx_out - Jxv_out @ N
93
+ alpha_m1 = -Jxv_out @ M
94
+ return r, alpha_p1, alpha_0, alpha_m1, Jvv_out, v_hat
95
+
96
+
97
+ def ud_window_residuals(force_fn, Y_w, dt, n_substeps, integrator="rk4"):
98
+ r"""3-point residuals and α-propagators over a position window.
99
+
100
+ For ``W`` consecutive positions returns the ``W-2`` interior residuals
101
+ and their propagators.
102
+
103
+ Returns
104
+ -------
105
+ r : ``(W-2, d)``
106
+ alpha_plus, alpha_zero, alpha_minus : ``(W-2, d, d)``
107
+ Jvv_out : ``(W-2, d, d)``
108
+ v_hat : ``(W-2, d)`` shooting velocities (for velocity-dependent D)
109
+ """
110
+ def _step(Yp, Yc, Yn):
111
+ return ud_step_quantities(force_fn, Yp, Yc, Yn, dt, n_substeps, integrator)
112
+
113
+ return jax.vmap(_step)(Y_w[:-2], Y_w[1:-1], Y_w[2:])
114
+
115
+
116
+ def ud_instrument(force_of_theta, theta, Y_a, Y_b, dt, n_substeps, integrator="rk4", n_predict=2):
117
+ r"""η-clean instrument (left test function) for one underdamped residual.
118
+
119
+ The center 3-point residual ``r_c = Y_{c+2} − Φˣ(Y_{c+1}, v̂)`` is
120
+ contaminated by measurement noise at ``Y_c, Y_{c+1}, Y_{c+2}`` through the
121
+ shooting velocity ``v̂`` (reconstructed from ``Y_c, Y_{c+1}``) — the
122
+ errors-in-variables source that is *amplified* in the underdamped case
123
+ because ``v̂ ∼ ΔY/Δt`` divides the position noise by ``Δt``.
124
+
125
+ The instrument is the regressor ``∂r/∂θ = −∂Φˣ(Y_{c+1}, v̂)/∂θ`` evaluated
126
+ at a **clean phase point** instead of the noisy ``(Y_{c+1}, v̂)``. The clean
127
+ phase point is reconstructed from the lagged pair ``(Y_a, Y_b) =
128
+ (Y_{c-2}, Y_{c-1})`` — whose measurement noise is independent of the
129
+ residual's — by shooting the velocity at ``Y_b`` and phase-flowing forward
130
+ ``n_predict`` intervals to the center base; that point is held fixed
131
+ (stop-gradient, "data"), and only the final one-step position displacement
132
+ is differentiated in θ:
133
+
134
+ .. math:: ψ_{\mathrm{inst}} = -\left.\frac{∂Φˣ(x,v;θ)}{∂θ}\right|_{(\tilde x_{c+1},\,\tilde v_{c+1})}.
135
+
136
+ Because it is the *same* one-step displacement sensitivity as the regressor
137
+ (only the evaluation phase point is clean), every parameter column — in
138
+ particular the velocity-damping (``γ``) column, which depends on the
139
+ reconstructed velocity — keeps the regressor's scale, so the asymmetric
140
+ Gram stays well-conditioned. It is the underdamped analogue of the
141
+ overdamped lag-1 shift instrument :func:`flow.od_instrument`.
142
+
143
+ Parameters
144
+ ----------
145
+ force_of_theta : callable ``θ_flat → (force_fn : (x, v) → accel)``
146
+ theta : array ``(n_params,)``
147
+ Y_a, Y_b : arrays ``(d,)`` clean lagged pair ``(Y_{c-2}, Y_{c-1})``.
148
+ n_predict : int phase-flow steps from ``Y_b`` to the center base
149
+ (``= 2`` for the centered 7-residual window: ``Y_{c-1} → Y_{c+1}``).
150
+
151
+ Returns
152
+ -------
153
+ psi_inst : array ``(d, n_params)``
154
+ """
155
+ force0 = force_of_theta(theta)
156
+ v_b, *_ = shooting_velocity(force0, Y_a, Y_b, dt, n_substeps, integrator)
157
+ x, v = Y_b, v_b
158
+ for _ in range(n_predict):
159
+ x, v, *_ = phase_space_flow_jac(force0, x, v, dt, n_substeps, integrator)
160
+ x_c = jax.lax.stop_gradient(x)
161
+ v_c = jax.lax.stop_gradient(v)
162
+
163
+ def disp_x(th):
164
+ x_next, _, *_ = phase_space_flow_jac(
165
+ force_of_theta(th), x_c, v_c, dt, n_substeps, integrator)
166
+ return x_next
167
+
168
+ return -jax.jacfwd(disp_x)(theta)