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,286 @@
1
+ # SFI/inference/parametric_core/objective.py
2
+ """
3
+ Integrate-engine programs for the overdamped parametric core.
4
+
5
+ Each program declares the sliding-window stream ``X_window:{W}`` and
6
+ returns one value **per (window, particle)** ``(K, N)``; the engine owns
7
+ chunking, masking (``mask_out``), JIT, and the reduction over both window
8
+ centers and particles (``reduce_over_particles=True``). Single particle
9
+ is just ``N=1`` — there is no separate multiparticle path (cf.
10
+ ``infer_force_linear``).
11
+
12
+ Per particle the residuals/Jacobians come from :func:`flow_multi.
13
+ multi_step_residuals` (per-particle for non-interacting forces, full-flow
14
+ + frozen-background Jacobian for interacting ones); the covariance and
15
+ precision kernels are applied per particle.
16
+
17
+ * :class:`ODLossProgram` — frozen-precision quadratic (minimised over θ).
18
+ * :class:`ODCondNLLProgram` — windowed conditional NLL (profiling / joint).
19
+ * :class:`ODDiffNLLProgram` — conditional NLL for state-dependent D(x; θ_D).
20
+ * :class:`ODGramProgram` — GN Gram/score/NLL for the parameter covariance.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+
28
+ from .covariance import build_od_blocks
29
+ from .flow_multi import multi_od_instrument, multi_step_residuals, multi_step_residuals_with_psi
30
+ from .precision import (
31
+ center_conditional_nll_contribution,
32
+ center_gram_contribution,
33
+ center_loss_contribution,
34
+ )
35
+
36
+ __all__ = ["ODLossProgram", "ODGramProgram", "ODCondNLLProgram", "ODDiffNLLProgram", "unpack_gram"]
37
+
38
+ _BANDWIDTH = 1
39
+
40
+
41
+ def unpack_gram(packed, n_params):
42
+ """Unpack a flat ``(G, f, H, nll)`` vector from :class:`ODGramProgram`.
43
+
44
+ ``H = ψ_leftᵀPψ_left`` is the estimating-function variance (the sandwich
45
+ meat for the IV path; ``H = G`` on the symmetric path).
46
+ """
47
+ n2 = n_params * n_params
48
+ G = packed[:n2].reshape(n_params, n_params)
49
+ f = packed[n2:n2 + n_params]
50
+ H = packed[n2 + n_params:2 * n2 + n_params].reshape(n_params, n_params)
51
+ return G, f, H, packed[-1]
52
+
53
+
54
+ def _center_index(W_res):
55
+ return (W_res - 1) // 2
56
+
57
+
58
+ def _per_particle_loss(r, J, D, Lambda, dt, jitter, jitter_chol):
59
+ """``½ rᵀ P r`` (center decomposition) per particle → ``(N,)``."""
60
+ c = _center_index(r.shape[0])
61
+
62
+ def _p(rp, Jp):
63
+ blocks = build_od_blocks(Jp, D, Lambda, dt, jitter=jitter)
64
+ return center_loss_contribution(blocks.A, blocks.offdiag, rp, c, jitter_chol, _BANDWIDTH)
65
+
66
+ return jax.vmap(_p, in_axes=(1, 1))(r, J)
67
+
68
+
69
+ def _per_particle_condnll(r, J, D, Lambda, dt, jitter, jitter_chol, n_cond):
70
+ """Windowed conditional NLL per particle (last residual | n_cond past) → ``(N,)``."""
71
+ def _p(rp, Jp):
72
+ blocks = build_od_blocks(Jp, D, Lambda, dt, jitter=jitter)
73
+ return center_conditional_nll_contribution(blocks.A, blocks.offdiag, rp, n_cond, n_cond, jitter_chol)
74
+
75
+ return jax.vmap(_p, in_axes=(1, 1))(r, J)
76
+
77
+
78
+ class _ODProgramBase:
79
+ def __init__(self, F_psf, *, dt, n_substeps, jitter=1e-7, jitter_chol=1e-10,
80
+ extra_radius=1, integrator="rk4"):
81
+ self._F = F_psf
82
+ self._dt = dt
83
+ self._n_sub = n_substeps
84
+ self._jitter = jitter
85
+ self._jitter_chol = jitter_chol
86
+ self._integrator = integrator
87
+ self._n_params = int(F_psf.template.size)
88
+ W_pos = 2 * _BANDWIDTH + 2 * extra_radius + 2
89
+ self._stream_key = f"X_window:{W_pos}"
90
+
91
+ def require(self):
92
+ req = {self._stream_key}
93
+ if getattr(self._F, "required_extras", None):
94
+ req.add("extras")
95
+ return req
96
+
97
+ def _resid(self, theta, X_w, extras):
98
+ return multi_step_residuals(
99
+ self._F, self._F.unflatten_params(theta), X_w, extras,
100
+ self._dt, self._n_sub, self._integrator)
101
+
102
+
103
+ class ODLossProgram(_ODProgramBase):
104
+ """Per-(window, particle) frozen-precision quadratic ``½ rᵀ P r``."""
105
+
106
+ def estimate_bytes_per_sample(self, sample_row):
107
+ return int(sample_row[self._stream_key].shape[-2]) * 8
108
+
109
+ def batch_call(self, *, params, **streams):
110
+ theta_live, theta_frozen, D, Lambda = params
111
+ X_w = streams[self._stream_key] # (K, W, N, d)
112
+ extras = streams.get("extras")
113
+
114
+ def _window(xw): # (W, N, d)
115
+ r, _ = self._resid(theta_live, xw, extras)
116
+ _, J = self._resid(jax.lax.stop_gradient(theta_frozen), xw, extras)
117
+ J = jax.lax.stop_gradient(J)
118
+ return _per_particle_loss(r, J, D, Lambda, self._dt, self._jitter, self._jitter_chol)
119
+
120
+ return jax.vmap(_window)(X_w) # (K, N)
121
+
122
+
123
+ class ODCondNLLProgram(_ODProgramBase):
124
+ """Per-(window, particle) conditional NLL with (θ, D, Λ) live."""
125
+
126
+ def __init__(self, F_psf, *, n_cond=3, **kw):
127
+ super().__init__(F_psf, **kw)
128
+ self._n_cond = int(n_cond)
129
+ self._stream_key = f"X_window:{n_cond + 2}"
130
+
131
+ def estimate_bytes_per_sample(self, sample_row):
132
+ return int(sample_row[self._stream_key].shape[-2]) * 8
133
+
134
+ def batch_call(self, *, params, **streams):
135
+ theta, D, Lambda = params
136
+ X_w = streams[self._stream_key]
137
+ extras = streams.get("extras")
138
+
139
+ def _window(xw):
140
+ r, J = self._resid(theta, xw, extras)
141
+ return _per_particle_condnll(
142
+ r, J, D, Lambda, self._dt, self._jitter, self._jitter_chol, self._n_cond)
143
+
144
+ return jax.vmap(_window)(X_w)
145
+
146
+
147
+ class ODDiffNLLProgram(_ODProgramBase):
148
+ """Conditional NLL for state-dependent diffusion ``D(x; θ_D)`` (force fixed)."""
149
+
150
+ def __init__(self, F_psf, theta_F, D_psf, *, n_cond=3, **kw):
151
+ super().__init__(F_psf, **kw)
152
+ self._theta_F = theta_F
153
+ self._D_psf = D_psf
154
+ self._n_cond = int(n_cond)
155
+ self._stream_key = f"X_window:{n_cond + 2}"
156
+
157
+ def require(self):
158
+ req = {self._stream_key}
159
+ if getattr(self._F, "required_extras", None) or getattr(self._D_psf, "required_extras", None):
160
+ req.add("extras")
161
+ return req
162
+
163
+ def estimate_bytes_per_sample(self, sample_row):
164
+ return int(sample_row[self._stream_key].shape[-2]) * 8
165
+
166
+ def batch_call(self, *, params, **streams):
167
+ theta_D, Lambda = params
168
+ X_w = streams[self._stream_key]
169
+ extras = streams.get("extras")
170
+ D_struct = self._D_psf.unflatten_params(theta_D)
171
+
172
+ def _D(x):
173
+ return self._D_psf(x[None], params=D_struct, extras=extras)[0]
174
+
175
+ def _window(xw): # (W, N, d)
176
+ r, J = self._resid(self._theta_F, xw, extras) # (W-1,N,d),(W-1,N,d,d)
177
+ n_cond = self._n_cond
178
+
179
+ def _p(rp, Jp, Xp): # (W-1,d),(W-1,d,d),(W,d)
180
+ Dk = jax.vmap(_D)(Xp[:-1]) # (W-1, d, d) state-dependent
181
+ blocks = build_od_blocks(Jp, Dk, Lambda, self._dt, jitter=self._jitter)
182
+ return center_conditional_nll_contribution(
183
+ blocks.A, blocks.offdiag, rp, n_cond, n_cond, self._jitter_chol)
184
+
185
+ return jax.vmap(_p, in_axes=(1, 1, 1))(r, J, xw) # (N,)
186
+
187
+ return jax.vmap(_window)(X_w)
188
+
189
+
190
+ class ODGramProgram(_ODProgramBase):
191
+ """Per-(window, particle) GN Gram/score/NLL packed as ``(G, f, nll)``.
192
+
193
+ The estimating equation is ``⟨ψ_left P r⟩ = 0`` with regressor
194
+ ``ψ_right = ∂r/∂θ``. With ``w = 0`` (default) the left factor is the
195
+ regressor — the symmetric MLE Gram, byte-identical to before. With
196
+ ``w > 0`` the *center* left factor is blended toward an η-clean instrument,
197
+
198
+ ψ_left,c = (1 − w) ψ_right,c + w ψ_inst,c,
199
+
200
+ which removes the measurement-noise errors-in-variables bias (``w → 1`` is
201
+ the pure instrument). ``w`` is a host-side scalar set from the EIV ratio.
202
+ """
203
+
204
+ def __init__(self, F_psf, *, w=0.0, extra_radius=1, **kw):
205
+ super().__init__(F_psf, extra_radius=extra_radius, **kw)
206
+ self._w = float(w)
207
+ if self._w > 0.0:
208
+ # The η-clean instrument reserves 1 front position OUTSIDE the
209
+ # residual block the tridiagonal precision couples in (paired against
210
+ # the first block residual); widen the window to hold it. The w=0
211
+ # (MLE) pass keeps the efficient center window.
212
+ self._stream_key = f"X_window:{2 * _BANDWIDTH + 2 * extra_radius + 2 + 1}"
213
+
214
+ def estimate_bytes_per_sample(self, sample_row):
215
+ n = self._n_params
216
+ shape = sample_row[self._stream_key].shape # (W, N, d)
217
+ N = int(shape[-2])
218
+ out = N * (2 * n * n + n + 1) * 8
219
+ if getattr(self._F, "particles_input", False):
220
+ # Interacting working set: per-stage dF/dθ jacfwd transient over
221
+ # the pair graph (~N² pair terms × n tangents), 4 RK4 stages ×
222
+ # n_substeps, per window residual. Without this the output-only
223
+ # estimate lets the whole trajectory into one chunk and the
224
+ # working set blows up (FINDINGS #7).
225
+ W = int(shape[-3])
226
+ out += W * 4 * self._n_sub * N * N * max(n, 8) * 8
227
+ return int(out)
228
+
229
+ def batch_call(self, *, params, **streams):
230
+ theta_flat, D, Lambda = params
231
+ X_w = streams[self._stream_key]
232
+ extras = streams.get("extras")
233
+ w = self._w
234
+ interacting = getattr(self._F, "particles_input", False)
235
+
236
+ # When the instrument is active (w>0) the base is the reserved front
237
+ # position, OUTSIDE the tridiagonal-coupled block (η-clean of the whole
238
+ # block), paired against the first block residual.
239
+ skip = w > 0.0
240
+
241
+ def _window(xw):
242
+ xw_res = xw[1:] if skip else xw # block (skip the front position)
243
+
244
+ if interacting:
245
+ # Frozen-background ψ_right (same-particle θ-recursion): the
246
+ # full-flow jacfwd holds n_params tangents alive across the
247
+ # whole window graph — O(N²·n_params) per window — and is the
248
+ # interacting-path memory blow-up (FINDINGS #7).
249
+ r, J, psi = multi_step_residuals_with_psi(
250
+ self._F, theta_flat, xw_res, extras,
251
+ self._dt, self._n_sub, self._integrator)
252
+ else:
253
+ def resid(th):
254
+ r, _ = self._resid(th, xw_res, extras)
255
+ return r # (R, N, d)
256
+
257
+ r = resid(theta_flat)
258
+ psi = jax.jacfwd(resid)(theta_flat) # (R, N, d, n_params) = ψ_right
259
+ _, J = self._resid(theta_flat, xw_res, extras)
260
+ c = 0 if skip else _center_index(r.shape[0])
261
+
262
+ if w > 0.0:
263
+ # instrument base: the reserved front X[0], η-clean of the whole
264
+ # block. Flow-propagated trapeze to the first block residual;
265
+ # interacting models use the same N-body flow as the residual.
266
+ psi_inst = multi_od_instrument(
267
+ self._F, theta_flat, xw[0], extras,
268
+ self._dt, self._n_sub, self._integrator) # (N, d, n_params)
269
+
270
+ def _p(rp, psip, Jp, inst):
271
+ blocks = build_od_blocks(Jp, D, Lambda, self._dt, jitter=self._jitter)
272
+ psi_left = psip.at[c].set((1.0 - w) * psip[c] + w * inst)
273
+ G, f, H, nll = center_gram_contribution(
274
+ blocks.A, blocks.offdiag, rp, psi_left, c, self._jitter_chol, psi_right_w=psip)
275
+ return jnp.concatenate([G.ravel(), f, H.ravel(), nll[None]])
276
+
277
+ def _p_sym(rp, psip, Jp):
278
+ blocks = build_od_blocks(Jp, D, Lambda, self._dt, jitter=self._jitter)
279
+ G, f, H, nll = center_gram_contribution(blocks.A, blocks.offdiag, rp, psip, c, self._jitter_chol)
280
+ return jnp.concatenate([G.ravel(), f, H.ravel(), nll[None]])
281
+
282
+ if w > 0.0:
283
+ return jax.vmap(_p, in_axes=(1, 1, 1, 0))(r, psi, J, psi_inst)
284
+ return jax.vmap(_p_sym, in_axes=(1, 1, 1))(r, psi, J) # (N, n_params²+n_params+1)
285
+
286
+ return jax.vmap(_window)(X_w) # (K, N, packed)
@@ -0,0 +1,253 @@
1
+ # SFI/inference/parametric_core/objective_ud.py
2
+ """
3
+ Integrate-engine programs for the underdamped parametric core (bandwidth 2).
4
+
5
+ Mirror of :mod:`objective` (overdamped): each program returns one value
6
+ **per (window, particle)** ``(K, N)`` and runs with
7
+ ``reduce_over_particles=True`` (single particle = N=1). Per particle the
8
+ 3-point shooting residual + α-propagators come from
9
+ :func:`flow_multi.ud_multi_step_residuals`; the pentadiagonal covariance
10
+ (:func:`covariance.build_ud_blocks`) and the bandwidth-generic precision
11
+ kernels are applied per particle.
12
+
13
+ The force is a velocity-dependent PSF (``needs_v=True``); the unobserved
14
+ velocity is resolved internally by shooting. Interacting underdamped
15
+ multiparticle (multi-particle phase-space shooting) is a follow-up.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import jax
21
+ import jax.numpy as jnp
22
+
23
+ from .covariance import build_ud_blocks
24
+ from .flow_multi import multi_ud_instrument, ud_multi_step_residuals, ud_multi_step_residuals_with_psi
25
+ from .objective import unpack_gram # shared packing
26
+ from .precision import (
27
+ center_conditional_nll_contribution,
28
+ center_gram_contribution,
29
+ center_loss_contribution,
30
+ )
31
+
32
+ __all__ = ["UDLossProgram", "UDCondNLLProgram", "UDGramProgram", "UDDiffNLLProgram", "unpack_gram"]
33
+
34
+ _BANDWIDTH = 2
35
+
36
+
37
+ def _ud_force_struct(F_psf, theta):
38
+ return F_psf.unflatten_params(theta)
39
+
40
+
41
+ class _UDProgramBase:
42
+ def __init__(self, F_psf, *, dt, n_substeps, jitter=1e-7, jitter_chol=1e-10, integrator="rk4"):
43
+ self._F = F_psf
44
+ self._dt = dt
45
+ self._n_sub = n_substeps
46
+ self._jitter = jitter
47
+ self._jitter_chol = jitter_chol
48
+ self._integrator = integrator
49
+ self._n_params = int(F_psf.template.size)
50
+
51
+ def require(self):
52
+ req = {self._stream_key}
53
+ if getattr(self._F, "required_extras", None):
54
+ req.add("extras")
55
+ return req
56
+
57
+ def _resid(self, theta, Y_w, extras):
58
+ return ud_multi_step_residuals(
59
+ self._F, self._F.unflatten_params(theta), Y_w, extras,
60
+ self._dt, self._n_sub, self._integrator)
61
+
62
+
63
+ class UDLossProgram(_UDProgramBase):
64
+ """Per-(window, particle) frozen-precision quadratic ``½ rᵀ P r`` (bandwidth 2)."""
65
+
66
+ def __init__(self, F_psf, *, w_res=7, **kw):
67
+ super().__init__(F_psf, **kw)
68
+ self._center = w_res // 2
69
+ self._stream_key = f"X_window:{w_res + 2}"
70
+
71
+ def estimate_bytes_per_sample(self, sample_row):
72
+ return int(sample_row[self._stream_key].shape[-2]) * 8
73
+
74
+ def batch_call(self, *, params, **streams):
75
+ theta_live, theta_frozen, D, Lambda = params
76
+ Y_w = streams[self._stream_key]
77
+ extras = streams.get("extras")
78
+ c = self._center
79
+
80
+ def _window(yw):
81
+ r, _, _, _, _ = self._resid(theta_live, yw, extras)
82
+ _, ap, a0, am, _ = self._resid(jax.lax.stop_gradient(theta_frozen), yw, extras)
83
+ ap, a0, am = (jax.lax.stop_gradient(x) for x in (ap, a0, am))
84
+
85
+ def _p(rp, app, a0p, amp):
86
+ blocks = build_ud_blocks(app, a0p, amp, D, Lambda, self._dt, jitter=self._jitter)
87
+ return center_loss_contribution(blocks.A, blocks.offdiag, rp, c, self._jitter_chol, _BANDWIDTH)
88
+
89
+ return jax.vmap(_p, in_axes=(1, 1, 1, 1))(r, ap, a0, am)
90
+
91
+ return jax.vmap(_window)(Y_w)
92
+
93
+
94
+ class UDCondNLLProgram(_UDProgramBase):
95
+ """Per-(window, particle) conditional NLL — profiling / joint."""
96
+
97
+ def __init__(self, F_psf, *, n_cond=4, **kw):
98
+ super().__init__(F_psf, **kw)
99
+ self._n_cond = int(n_cond)
100
+ self._stream_key = f"X_window:{n_cond + 3}"
101
+
102
+ def estimate_bytes_per_sample(self, sample_row):
103
+ return int(sample_row[self._stream_key].shape[-2]) * 8
104
+
105
+ def batch_call(self, *, params, **streams):
106
+ theta, D, Lambda = params
107
+ Y_w = streams[self._stream_key]
108
+ extras = streams.get("extras")
109
+ n_cond = self._n_cond
110
+
111
+ def _window(yw):
112
+ r, ap, a0, am, _ = self._resid(theta, yw, extras)
113
+
114
+ def _p(rp, app, a0p, amp):
115
+ blocks = build_ud_blocks(app, a0p, amp, D, Lambda, self._dt, jitter=self._jitter)
116
+ return center_conditional_nll_contribution(
117
+ blocks.A, blocks.offdiag, rp, n_cond, n_cond, self._jitter_chol)
118
+
119
+ return jax.vmap(_p, in_axes=(1, 1, 1, 1))(r, ap, a0, am)
120
+
121
+ return jax.vmap(_window)(Y_w)
122
+
123
+
124
+ class UDDiffNLLProgram(_UDProgramBase):
125
+ """Conditional NLL for state/velocity-dependent diffusion ``D(x, v; θ_D)`` (force fixed)."""
126
+
127
+ def __init__(self, F_psf, theta_F, D_psf, *, n_cond=4, **kw):
128
+ super().__init__(F_psf, **kw)
129
+ self._theta_F = theta_F
130
+ self._D_psf = D_psf
131
+ self._n_cond = int(n_cond)
132
+ self._stream_key = f"X_window:{n_cond + 3}"
133
+
134
+ def estimate_bytes_per_sample(self, sample_row):
135
+ return int(sample_row[self._stream_key].shape[-2]) * 8
136
+
137
+ def batch_call(self, *, params, **streams):
138
+ theta_D, Lambda = params
139
+ Y_w = streams[self._stream_key]
140
+ extras = streams.get("extras")
141
+ n_cond = self._n_cond
142
+ D_struct = self._D_psf.unflatten_params(theta_D)
143
+
144
+ def _D(x, v):
145
+ return self._D_psf(x[None], v=v[None], params=D_struct, extras=extras)[0]
146
+
147
+ def _window(yw): # (W, N, d)
148
+ r, ap, a0, am, vhat = self._resid(self._theta_F, yw, extras)
149
+ Y_base = yw[1:-1] # (W-2, N, d)
150
+
151
+ def _p(rp, app, a0p, amp, vhatp, Ybp):
152
+ Dk = jax.vmap(_D)(Ybp, vhatp) # (W-2, d, d)
153
+ blocks = build_ud_blocks(app, a0p, amp, Dk, Lambda, self._dt, jitter=self._jitter)
154
+ return center_conditional_nll_contribution(
155
+ blocks.A, blocks.offdiag, rp, n_cond, n_cond, self._jitter_chol)
156
+
157
+ return jax.vmap(_p, in_axes=(1, 1, 1, 1, 1, 1))(r, ap, a0, am, vhat, Y_base)
158
+
159
+ return jax.vmap(_window)(Y_w)
160
+
161
+
162
+ class UDGramProgram(_UDProgramBase):
163
+ """Per-(window, particle) GN Gram/score/NLL packed as ``(G, f, nll)``.
164
+
165
+ ``w`` is the EIV instrument blend weight (0 = symmetric MLE). The
166
+ underdamped η-clean instrument is wired in below when ``w > 0``.
167
+ """
168
+
169
+ def __init__(self, F_psf, *, w_res=7, w=0.0, **kw):
170
+ super().__init__(F_psf, **kw)
171
+ self._center = w_res // 2
172
+ self._w = float(w)
173
+ # The η-clean instrument (w>0) reserves 2 extra front positions for its
174
+ # base (outside the residual block the pentadiagonal precision couples
175
+ # in), so widen the window to keep the block — and hence the precision
176
+ # context / variance — unchanged. The w=0 (MLE) pass uses the narrow window.
177
+ extra = 2 if self._w > 0.0 else 0
178
+ self._stream_key = f"X_window:{w_res + 2 + extra}"
179
+
180
+ def estimate_bytes_per_sample(self, sample_row):
181
+ n = self._n_params
182
+ shape = sample_row[self._stream_key].shape # (W, N, d)
183
+ N = int(shape[-2])
184
+ out = N * (2 * n * n + n + 1) * 8
185
+ if getattr(self._F, "particles_input", False):
186
+ # Interacting working set (see ODGramProgram): per-stage dF/dθ
187
+ # transient over the pair graph, two phase flows per residual.
188
+ W = int(shape[-3])
189
+ out += W * 8 * self._n_sub * N * N * max(n, 8) * 8
190
+ return int(out)
191
+
192
+ def batch_call(self, *, params, **streams):
193
+ theta_flat, D, Lambda = params
194
+ Y_w = streams[self._stream_key]
195
+ extras = streams.get("extras")
196
+ w = self._w
197
+ interacting = getattr(self._F, "particles_input", False)
198
+ # When the instrument is active (w>0) the window is [2 front positions |
199
+ # residual block]: the instrument base pair (the front positions) is then
200
+ # OUTSIDE the whole block's measurement-noise support, so it cannot overlap
201
+ # any residual the pentadiagonal precision couples into the moment (the
202
+ # legacy first-row IV), and is paired against the first block residual.
203
+ # The w=0 (MLE) pass uses the narrow center window.
204
+ skip = w > 0.0
205
+ # center index: first block residual when the instrument is active; middle
206
+ # residual otherwise (derived from the actual window width so it is correct
207
+ # for both the narrow MLE window and the wider instrument one).
208
+ W_pos = Y_w.shape[1]
209
+ c = 0 if skip else (W_pos - 3) // 2
210
+
211
+ def _window(yw):
212
+ yw_res = yw[2:] if skip else yw # block positions (skip the front pair)
213
+
214
+ if interacting:
215
+ # Frozen-background ψ_right (per-particle phase θ-recursion) —
216
+ # see ODGramProgram; the full-flow jacfwd is the interacting
217
+ # memory blow-up.
218
+ r, ap, a0, am, _, psi = ud_multi_step_residuals_with_psi(
219
+ self._F, theta_flat, yw_res, extras,
220
+ self._dt, self._n_sub, self._integrator)
221
+ else:
222
+ def resid(th):
223
+ r, *_ = self._resid(th, yw_res, extras)
224
+ return r # (R, N, d) = ψ_right base
225
+ r = resid(theta_flat)
226
+ psi = jax.jacfwd(resid)(theta_flat) # (R, N, d, n_params) = ψ_right
227
+ _, ap, a0, am, _ = self._resid(theta_flat, yw_res, extras)
228
+
229
+ if w > 0.0:
230
+ # Instrument base pair: the reserved front pair (yw[0], yw[1]),
231
+ # η-clean of the whole block. Flow-propagated to the c-th residual;
232
+ # interacting models use the same N-body flow as the residual.
233
+ psi_inst = multi_ud_instrument(
234
+ self._F, theta_flat, yw[0], yw[1], extras,
235
+ self._dt, self._n_sub, self._integrator) # (N, d, n_params)
236
+
237
+ def _p(rp, psip, app, a0p, amp, inst):
238
+ blocks = build_ud_blocks(app, a0p, amp, D, Lambda, self._dt, jitter=self._jitter)
239
+ psi_left = psip.at[c].set((1.0 - w) * psip[c] + w * inst)
240
+ G, f, H, nll = center_gram_contribution(
241
+ blocks.A, blocks.offdiag, rp, psi_left, c, self._jitter_chol, psi_right_w=psip)
242
+ return jnp.concatenate([G.ravel(), f, H.ravel(), nll[None]])
243
+
244
+ def _p_sym(rp, psip, app, a0p, amp):
245
+ blocks = build_ud_blocks(app, a0p, amp, D, Lambda, self._dt, jitter=self._jitter)
246
+ G, f, H, nll = center_gram_contribution(blocks.A, blocks.offdiag, rp, psip, c, self._jitter_chol)
247
+ return jnp.concatenate([G.ravel(), f, H.ravel(), nll[None]])
248
+
249
+ if w > 0.0:
250
+ return jax.vmap(_p, in_axes=(1, 1, 1, 1, 1, 0))(r, psi, ap, a0, am, psi_inst)
251
+ return jax.vmap(_p_sym, in_axes=(1, 1, 1, 1, 1))(r, psi, ap, a0, am)
252
+
253
+ return jax.vmap(_window)(Y_w)