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,448 @@
1
+ # underdamped.py
2
+ """
3
+ Underdamped Langevin simulator (velocity-Verlet-like, generic F(x,v) and D(x[,v])).
4
+
5
+ This mirrors :mod:`overdamped` as closely as possible, but simulates the
6
+ phase-space SDE
7
+
8
+ dx = v dt
9
+ dv = F(x, v) dt + sqrt(2 D(x, v)) dW
10
+
11
+ where diffusion acts on *velocity* increments. The returned
12
+ :class:`~SFI.trajectory.collection.TrajectoryCollection` stores **positions
13
+ only** by design.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any, Dict, Optional
20
+
21
+ import jax
22
+ import jax.numpy as jnp
23
+ from jax import jit, lax, random
24
+
25
+ from SFI.statefunc import PSF, SF
26
+ from SFI.utils.maths import as_default_float
27
+
28
+ from .base import Array, DiffusionKind, LangevinBase
29
+
30
+
31
+ @dataclass
32
+ class UnderdampedProcess(LangevinBase):
33
+ """Underdamped Langevin simulator.
34
+
35
+ Parameters
36
+ ----------
37
+ F : PSF | SF
38
+ Force model with `rank=vector`, `needs_v=True`, and `pdepth∈{0,1}`.
39
+ If a PSF is provided, bind parameters via :meth:`set_params` prior to
40
+ simulation.
41
+ D : float | Array | PSF | SF
42
+ Diffusion model acting on velocities: scalar σ (interpreted as σ·I),
43
+ constant (d×d) matrix, or a PSF/SF with `rank=matrix`.
44
+ If provided as PSF/SF, it may depend on (x) or (x, v), controlled by
45
+ its `needs_v` flag.
46
+
47
+ Notes
48
+ -----
49
+ This class does **not** insert particle axes; it follows the `pdepth`
50
+ convention of the statefunc objects, similarly to :class:`OverdampedProcess`.
51
+ """
52
+
53
+ # Whether the (state-dependent) diffusion SF requires v.
54
+ _D_needs_v: bool = False
55
+ # Bound diffusion callable (only used for eager structural-extras preparation).
56
+ _D_sf: Optional[SF] = None
57
+
58
+ # ------------------------------- API --------------------------------
59
+ def initialize(self, x0: Array, v0: Optional[Array] = None) -> None:
60
+ """Initialize the process state.
61
+
62
+ Parameters
63
+ ----------
64
+ x0 : Array
65
+ Initial position. Must satisfy:
66
+ - If `F.pdepth == 0`: shape (d,)
67
+ - If `F.pdepth == 1`: shape (P, d)
68
+ v0 : Array, optional
69
+ Initial velocity. Must have the same shape as `x0`. Defaults to 0.
70
+ """
71
+ self._normalize_basis_to_psf()
72
+ self._assert_force_contract(self.F)
73
+
74
+ # Canonicalize dtype (single normalization point; see
75
+ # OverdampedProcess.initialize for rationale).
76
+ x0 = as_default_float(x0)
77
+ v0 = as_default_float(v0)
78
+
79
+ # Deduce (P, d) from x0
80
+ if x0.ndim == 1:
81
+ d = int(x0.shape[0])
82
+ P = None
83
+ elif x0.ndim == 2:
84
+ P = int(x0.shape[0])
85
+ d = int(x0.shape[1])
86
+ else:
87
+ raise ValueError("x0 must have shape (d,) or (P, d).")
88
+
89
+ f_pdepth = getattr(self.F, "pdepth", None)
90
+ if f_pdepth not in (0, 1):
91
+ raise ValueError("F.pdepth must be 0 or 1 for underdamped simulations.")
92
+ if f_pdepth == 0 and x0.ndim != 1:
93
+ if x0.shape[0] == 1 and x0.ndim == 2:
94
+ x0 = x0[0]
95
+ if v0 is not None and v0.ndim == 2 and v0.shape[0] == 1:
96
+ v0 = v0[0]
97
+ else:
98
+ raise ValueError("F expects no particle axis (pdepth=0): x0 must be (d,).")
99
+ if f_pdepth == 1 and x0.ndim != 2:
100
+ raise ValueError("F expects a particle axis (pdepth=1): x0 must be (P, d).")
101
+
102
+ if v0 is None:
103
+ v0 = jnp.zeros_like(x0)
104
+ if v0.shape != x0.shape:
105
+ raise ValueError(f"v0.shape must match x0.shape; got v0={v0.shape}, x0={x0.shape}.")
106
+
107
+ # Bind models
108
+ self._F_sf = self._bind_force()
109
+ if isinstance(self.D, (PSF, SF)):
110
+ if isinstance(self.D, SF):
111
+ self._D_sf = self.D
112
+ else:
113
+ if self.theta_D is None:
114
+ raise ValueError("Diffusion PSF not bound: call set_params(theta_D=...).")
115
+ self._D_sf = self.D.bind(self.theta_D) # type: ignore[attr-defined]
116
+ self._D_needs_v = bool(getattr(self.D, "needs_v", False))
117
+ else:
118
+ self._D_sf = None
119
+ self._D_needs_v = False
120
+
121
+ # Prepare structural extras once for all bound expressions.
122
+ # (The base helper caches preparation globally; pass the full list here.)
123
+ self._invalidate_prepared_extras()
124
+ exprs = [self._F_sf] + ([self._D_sf] if self._D_sf is not None else [])
125
+ extras = self._prepare_model_extras(x_probe=x0, v_probe=v0, exprs=exprs)
126
+
127
+ # Validate force output shape
128
+ f_out = self._F_sf(x0, v=v0, extras=extras)
129
+ if f_out.shape != x0.shape:
130
+ raise ValueError(f"Force output shape {f_out.shape} does not match input shape {x0.shape}.")
131
+
132
+ # Diffusion (constant vs state-dependent)
133
+ self._check_diffusion_contract(self.D, d=d, f_pdepth=int(f_pdepth))
134
+ self._setup_diffusion(d=d, with_v=self._D_needs_v)
135
+
136
+ # Persist runtime state/metadata basics
137
+ self._x = x0
138
+ self._v = v0
139
+ num_particles = int(P) if P is not None else 1
140
+ self.metadata.clear()
141
+ self.metadata.update(
142
+ dict(
143
+ kind="underdamped",
144
+ dimension=d,
145
+ pdepth=int(f_pdepth),
146
+ num_particles=num_particles,
147
+ x0=x0,
148
+ v0=v0,
149
+ )
150
+ )
151
+
152
+ def simulate(
153
+ self,
154
+ dt: float,
155
+ Nsteps: int,
156
+ key: Array,
157
+ *,
158
+ oversampling: int = 4,
159
+ prerun: int = 0,
160
+ jit_compile: bool = True,
161
+ compute_observables: bool = False,
162
+ ):
163
+ """Run the integrator and return a :class:`TrajectoryCollection` of positions.
164
+
165
+ Parameters
166
+ ----------
167
+ dt
168
+ Time step between recorded frames.
169
+ Nsteps
170
+ Number of recorded time steps.
171
+ key
172
+ PRNG key for the simulation.
173
+ oversampling
174
+ Number of velocity-Verlet substeps between recorded frames.
175
+ The effective substep size is ``dt / oversampling``.
176
+ Although all integrators have a consistent continuous limit, they
177
+ introduce short-range, algorithm-specific temporal correlations at
178
+ the scale of a single step. Downsampling by recording only every
179
+ ``oversampling``-th substep ensures these artefacts never reach
180
+ the inference layer. The default of 4 is a safe minimum for
181
+ typical use; increase it when ``dt`` is large or the process
182
+ varies rapidly.
183
+ prerun
184
+ Number of recorded steps to discard as burn-in.
185
+ jit_compile
186
+ If True, JIT-compile the single-step integrator before scanning.
187
+ compute_observables
188
+ Not yet implemented for the underdamped case.
189
+
190
+ Returns
191
+ -------
192
+ TrajectoryCollection
193
+ A collection with a single dataset containing the positions only
194
+ (velocities are not stored by design). The underlying dataset has:
195
+
196
+ - ``X`` of shape ``(Nsteps, d)`` or ``(Nsteps, P, d)``,
197
+ - metadata combining model info (kind, dimension, pdepth, etc.)
198
+ and run info (dt, Nsteps, oversampling, prerun).
199
+ """
200
+ if self._F_sf is None:
201
+ raise RuntimeError("Call initialize(x0, v0=...) before simulate().")
202
+ if compute_observables:
203
+ raise NotImplementedError(
204
+ "Underdamped observables are not implemented in this variant, "
205
+ "because velocities are intentionally not stored."
206
+ )
207
+
208
+ # Split extras into static values and per-frame schedules.
209
+ static_extras, schedules, eg_out, el_out = self._materialize_step_extras(dt=dt, Nsteps=Nsteps)
210
+
211
+ step = self._make_step(static_extras)
212
+ if jit_compile:
213
+ step = jit(step)
214
+
215
+ # Carry is (x, v), but we record positions only.
216
+ traj_x, final_state, info = self._scan_positions(
217
+ (self._x, self._v),
218
+ step_fn=step,
219
+ dt=dt,
220
+ Nsteps=Nsteps,
221
+ oversampling=oversampling,
222
+ prerun=prerun,
223
+ key=key,
224
+ schedules=schedules or None,
225
+ )
226
+
227
+ self._x, self._v = final_state
228
+
229
+ run_meta: Dict[str, Any] = dict(self.metadata)
230
+ run_meta.update(dict(dt=float(dt), **info))
231
+ if schedules:
232
+ run_meta["time_dependent_extras"] = sorted(schedules)
233
+
234
+ return self._traj_to_collection(
235
+ traj_x, dt=dt, meta=run_meta, extras_global_out=eg_out, extras_local_out=el_out
236
+ )
237
+
238
+ # ----------------------------- Internals -----------------------------
239
+ def _make_step(self, extras=None):
240
+ r"""Create the substep function (kick–drift–kick, velocity-Verlet-like).
241
+
242
+ .. physics:: Velocity-Verlet integrator (underdamped)
243
+ :label: velocity-verlet-underdamped
244
+ :category: Simulation
245
+
246
+ Stochastic splitting (kick–drift–kick):
247
+
248
+ .. math::
249
+
250
+ v_{1/2} &= v + \tfrac{1}{2}\mathrm{d}t\, F(x,v)
251
+ + \sqrt{\mathrm{d}t/2}\; B(x,v)\,\xi_1 \\
252
+ x' &= x + \mathrm{d}t\, v_{1/2} \\
253
+ v' &= v_{1/2} + \tfrac{1}{2}\mathrm{d}t\, F(x', v_{1/2})
254
+ + \sqrt{\mathrm{d}t/2}\; B(x', v_{1/2})\,\xi_2
255
+
256
+ Preserves the symplectic structure of the deterministic part.
257
+ """
258
+ F = self._F_sf
259
+ if extras is None:
260
+ extras = self._model_extras()
261
+ static_extras = extras
262
+ kind = self._D_kind
263
+ Bc = self._B_const
264
+ Bf = self._B_fn
265
+ D_needs_v = self._D_needs_v
266
+
267
+ if F is None:
268
+ raise RuntimeError("Force not bound. Did you call initialize() after set_params()?")
269
+
270
+ def _eval_B(x: Array, v: Array, ex) -> Array:
271
+ if kind is DiffusionKind.STATE_FUNC:
272
+ if Bf is None:
273
+ raise RuntimeError("State-dependent diffusion not initialized.")
274
+ return Bf(x, v, extras=ex) if D_needs_v else Bf(x, extras=ex)
275
+ if Bc is None:
276
+ raise RuntimeError("Constant diffusion not initialized.")
277
+ return Bc
278
+
279
+ def _apply(B: Array, xi: Array) -> Array:
280
+ # Be permissive: if B happens to be (d,d), treat it as constant.
281
+ return self._apply_B(B, xi, state_dependent=(B.ndim > 2))
282
+
283
+ def step(state, ddt: float, key: Array, sched=None):
284
+ x, v = state
285
+ k1, k2 = random.split(key)
286
+ # Both half-kicks use the frame's scheduled value (zeroth-order
287
+ # hold: the protocol is piecewise constant by definition).
288
+ ex = static_extras if sched is None else {**(static_extras or {}), **sched}
289
+
290
+ # --- First half-kick
291
+ a0 = F(x, v=v, extras=ex)
292
+ B0 = _eval_B(x, v, ex)
293
+ xi1 = self._noise(k1, v)
294
+ dv1 = _apply(B0, xi1)
295
+ v_half = v + 0.5 * ddt * a0 + jnp.sqrt(ddt / 2.0) * dv1
296
+
297
+ # --- Drift
298
+ x_new = x + ddt * v_half
299
+
300
+ # --- Second half-kick
301
+ a1 = F(x_new, v=v_half, extras=ex)
302
+ B1 = _eval_B(x_new, v_half, ex)
303
+ xi2 = self._noise(k2, v)
304
+ dv2 = _apply(B1, xi2)
305
+ v_new = v_half + 0.5 * ddt * a1 + jnp.sqrt(ddt / 2.0) * dv2
306
+
307
+ return (x_new, v_new)
308
+
309
+ return step
310
+
311
+ @staticmethod
312
+ def _scan_positions(
313
+ initial_state,
314
+ *,
315
+ step_fn,
316
+ dt: float,
317
+ Nsteps: int,
318
+ oversampling: int,
319
+ prerun: int,
320
+ key: Array,
321
+ schedules=None,
322
+ ):
323
+ """Scan loop that records positions only (carry contains (x,v)).
324
+
325
+ ``schedules`` follows the same per-frame contract as
326
+ :meth:`LangevinBase._scan`.
327
+ """
328
+ if oversampling < 1:
329
+ raise ValueError("oversampling must be >= 1")
330
+
331
+ ddt = dt / float(oversampling)
332
+ scheduled = bool(schedules)
333
+
334
+ if scheduled:
335
+ sched0 = {k: v[0] for k, v in schedules.items()}
336
+
337
+ def one_substep_at(sched):
338
+ def one_substep(carry, _):
339
+ st, k = carry
340
+ k, sub = random.split(k)
341
+ st = step_fn(st, ddt, sub, sched)
342
+ return (st, k), None
343
+
344
+ return one_substep
345
+
346
+ def one_recorded_step(carry, sched_t):
347
+ st, k = carry
348
+ (st, k), _ = lax.scan(one_substep_at(sched_t), (st, k), None, length=oversampling)
349
+ return (st, k), st[0]
350
+
351
+ if prerun > 0:
352
+ (state, key), _ = lax.scan(
353
+ lambda c, _: one_recorded_step(c, sched0), (initial_state, key), None, length=prerun
354
+ )
355
+ else:
356
+ state = initial_state
357
+
358
+ if Nsteps > 0:
359
+ from SFI.langevin.base import LangevinBase
360
+
361
+ xs = LangevinBase._shift_schedules(schedules)
362
+ (state, key), traj_x = lax.scan(one_recorded_step, (state, key), xs, length=Nsteps)
363
+ else:
364
+ x_example = initial_state[0]
365
+ traj_x = jax.tree_util.tree_map(lambda a: a[None, ...][:0], x_example)
366
+
367
+ else:
368
+
369
+ def one_substep(carry, _):
370
+ st, k = carry
371
+ k, sub = random.split(k)
372
+ st = step_fn(st, ddt, sub)
373
+ return (st, k), None
374
+
375
+ def one_recorded_step_plain(carry, _):
376
+ st, k = carry
377
+ (st, k), _ = lax.scan(one_substep, (st, k), None, length=oversampling)
378
+ # record positions only
379
+ return (st, k), st[0]
380
+
381
+ if prerun > 0:
382
+ (state, key), _ = lax.scan(one_recorded_step_plain, (initial_state, key), None, length=prerun)
383
+ else:
384
+ state = initial_state
385
+
386
+ if Nsteps > 0:
387
+ (state, key), traj_x = lax.scan(one_recorded_step_plain, (state, key), None, length=Nsteps)
388
+ else:
389
+ x_example = initial_state[0]
390
+ traj_x = jax.tree_util.tree_map(lambda a: a[None, ...][:0], x_example)
391
+
392
+ info = {
393
+ "Nsteps": int(Nsteps),
394
+ "oversampling": int(oversampling),
395
+ "prerun": int(prerun),
396
+ }
397
+ return traj_x, state, info
398
+
399
+ # --------------------------- Validations -----------------------------
400
+ @staticmethod
401
+ def _assert_force_contract(F: PSF | SF) -> None:
402
+ """Validate force has the right rank/dim flags for underdamped use."""
403
+ needs_v = getattr(F, "needs_v", False)
404
+ if not needs_v:
405
+ raise ValueError("Underdamped force must require velocity (needs_v=True).")
406
+ rank = getattr(F, "rank", None)
407
+ if rank != 1:
408
+ raise ValueError("Force must have rank=vector.")
409
+ dim = getattr(F, "dim", None)
410
+ if dim is None or not isinstance(dim, int):
411
+ raise ValueError("Force must declare an integer `dim` attribute.")
412
+ pdepth = getattr(F, "pdepth", None)
413
+ if pdepth not in (0, 1):
414
+ raise ValueError("Force `pdepth` must be 0 or 1 for underdamped simulations.")
415
+
416
+ @staticmethod
417
+ def _check_diffusion_contract(D, *, d: int, f_pdepth: int) -> None:
418
+ """Validate diffusion against the force contract, allowing benign broadcast."""
419
+ from SFI.langevin.noise import NoiseModel
420
+
421
+ if isinstance(D, NoiseModel):
422
+ if D.dim != d:
423
+ raise ValueError(f"NoiseModel n_fields={D.dim} must match force dim={d}.")
424
+ return
425
+ if isinstance(D, (int, float)):
426
+ return
427
+ if isinstance(D, jnp.ndarray) and D.ndim == 2:
428
+ if D.shape != (d, d):
429
+ raise ValueError(f"Constant diffusion must be shape (d,d)={(d, d)}, got {D.shape}.")
430
+ return
431
+
432
+ if not isinstance(D, (PSF, SF)):
433
+ raise TypeError("Diffusion must be a float, (d×d) array, PSF/SF, or NoiseModel.")
434
+
435
+ rank = getattr(D, "rank", None)
436
+ if rank != 2:
437
+ raise ValueError("Diffusion PSF/SF must have rank=matrix.")
438
+ dim = getattr(D, "dim", None)
439
+ if dim != d:
440
+ raise ValueError(f"Diffusion dim={dim} must match force dim={d}.")
441
+ d_pdepth = getattr(D, "pdepth", None)
442
+ if d_pdepth not in (0, 1):
443
+ raise ValueError("Diffusion `pdepth` must be 0 or 1.")
444
+ if not (d_pdepth == f_pdepth or d_pdepth == 0):
445
+ raise ValueError(
446
+ f"Incompatible pdepth: force pdepth={f_pdepth}, diffusion pdepth={d_pdepth}. "
447
+ "Only equal depths or diffusion depth=0 (broadcast) are allowed."
448
+ )
@@ -0,0 +1,49 @@
1
+ # SFI/statefunc/__init__.py
2
+ """
3
+ High-level API for state functions.
4
+
5
+ Public classes:
6
+ - Basis: deterministic dictionary façade
7
+ - PSF: parametric family F(x; θ)
8
+ - SF: state-function with fixed θ
9
+ - Interactor: local interaction expression (pre-dispatch)
10
+ - StateExpr: immutable expression tree base class
11
+
12
+ Factory helpers:
13
+ - make_basis, make_psf, make_sf, make_interactor
14
+
15
+ Control:
16
+ - set_jit: enable/disable JIT on __call__
17
+
18
+ Power-user primitives:
19
+ - Rank, ParamSpec, ParamSuite
20
+ """
21
+
22
+ from .basis import Basis
23
+ from .core.runtime import set_jit
24
+ from .factory import make_basis, make_interactor, make_psf, make_sf
25
+ from .interactor import Interactor
26
+ from .nodes import Rank
27
+ from .params import ParamSpec, ParamSuite
28
+ from .psf import PSF
29
+ from .sf import SF
30
+ from .stateexpr import StateExpr
31
+
32
+ __all__ = [
33
+ # façades
34
+ "Basis",
35
+ "PSF",
36
+ "SF",
37
+ "Interactor",
38
+ "StateExpr",
39
+ "set_jit",
40
+ # factory
41
+ "make_basis",
42
+ "make_psf",
43
+ "make_sf",
44
+ "make_interactor",
45
+ # power-user primitives
46
+ "Rank",
47
+ "ParamSpec",
48
+ "ParamSuite",
49
+ ]
SFI/statefunc/basis.py ADDED
@@ -0,0 +1,87 @@
1
+ """Basis façade: dictionary of deterministic functions."""
2
+
3
+ from .psf import PSF
4
+ from .stateexpr import StateExpr
5
+
6
+
7
+ # ============================================================================
8
+ # BASIS – deterministic dictionary
9
+ # ============================================================================
10
+ class Basis(StateExpr):
11
+ """Deterministic dictionary façade (no parameters)."""
12
+
13
+ def __init__(self, root):
14
+ if root.param_suite is not None:
15
+ raise ValueError("Basis root must be parameter-free")
16
+ super().__init__(root)
17
+
18
+ def __call__(self, x, *, v=None, mask=None, extras=None):
19
+ """Evaluate the basis on a single or batched input.
20
+
21
+ Parameters
22
+ ----------
23
+ x : array
24
+ If `particles_input=False`: shape `batch · dim`.
25
+ If `particles_input=True`: shape `batch · P · dim`.
26
+ `batch` can be empty (single input).
27
+ v : array | None
28
+ Optional velocity matching `x.shape` (required if `needs_v=True`).
29
+ mask : array | scalar | None
30
+ Broadcasts to the prefix of `x` **including** the particle axis.
31
+ Boolean or numeric masks are accepted.
32
+ extras : dict | None
33
+ Optional per-batch metadata. Presence is enforced according to the
34
+ aggregated node requirements; values must broadcast over **batch only**.
35
+
36
+ Returns
37
+ -------
38
+ array
39
+ Shape `batch · [P]^pdepth · (dim)^rank · n_features`.
40
+ """
41
+ self._validate_extras_presence(extras)
42
+ return self._caller(x, v, mask, extras, None)
43
+
44
+ # convenience
45
+ @property
46
+ def labels(self):
47
+ _, labs, _ = self.root.flatten()
48
+ return labs
49
+
50
+ def __len__(self):
51
+ return self.n_features
52
+
53
+ # Upgrade-aware dispatch: if a derived node carries parameters,
54
+ # the result is a PSF (e.g. Basis * PSF, PSF - Basis, …).
55
+ def _with_node(self, new_root):
56
+ if new_root.param_suite is not None:
57
+ return PSF(new_root)
58
+ return Basis(new_root)
59
+
60
+ # factory → linear-coeff PSF
61
+ def to_psf(self, coeff_key: str = "coeff", drop_features=True):
62
+ """
63
+ Return a **parametric state function** whose value is a linear combination
64
+ of this Basis' features:
65
+
66
+ F(x; θ) = Σ_j θ_j · f_j(x)
67
+
68
+ Note that use cases are rare within SFI, since the PSF's features axis is typically
69
+ used for nonlinearities and/or vector/tensor components. But this can be useful for
70
+ quick prototyping of linear models, benchmark comparisons of linear vs nonlinear
71
+ solvers, or as a building block for more complex PSFs.
72
+
73
+ Parameters
74
+ ----------
75
+ coeff_key : str
76
+ Key name for the coefficient vector in the parameter dict.
77
+ drop_features: bool
78
+ Whether to remove the trailing size-1 feature axis (default True).
79
+
80
+ Notes
81
+ -----
82
+ The resulting `PSF` shares the same spatial contract (rank/dim/pdepth,
83
+ particles_input) as this `Basis`, and does not have a features axis.
84
+ """
85
+ from .nodes import CoeffNode
86
+
87
+ return PSF(CoeffNode(self.root, coeff_key=coeff_key), drop_features=drop_features)
@@ -0,0 +1,47 @@
1
+ from typing import Any, Callable, Dict
2
+
3
+ import equinox as eqx
4
+
5
+ # === JIT control & harness ====================================================
6
+ _JIT_ENABLED = True
7
+
8
+ # Per-root compiled function cache. Keyed by object identity of `root`.
9
+ # We intentionally use identity (id(root)) rather than a structural hash to
10
+ # keep lookups O(1) and avoid expensive tree-walk hashing or serialisation.
11
+ _COMPILED_CACHE: Dict[int, Callable[..., Any]] = {}
12
+
13
+
14
+ def set_jit(enabled: bool = True):
15
+ """Globally enable/disable JIT for Basis/PSF/SF __call__."""
16
+ global _JIT_ENABLED
17
+ _JIT_ENABLED = bool(enabled)
18
+
19
+
20
+ def _eager_eval(root, x, v, mask, extras, params):
21
+ # Plain Python call; used when JIT is disabled (set_jit(False)).
22
+ return root(x, params=params, v=v, mask=mask, extras=extras)
23
+
24
+
25
+ def _compiled_for_root(root) -> Callable[..., Any]:
26
+ """
27
+ Return a compiled callable specialised to the given `root`.
28
+ The compiled function *closes over* `root`, so `root` is static
29
+ without being an argument (reduces tracing/dispatch overhead).
30
+ """
31
+ key = id(root)
32
+ fn = _COMPILED_CACHE.get(key)
33
+ if fn is not None:
34
+ return fn
35
+
36
+ # Compile once for this root: dynamic args are (x, v, mask, extras, params)
37
+ @eqx.filter_jit
38
+ def _call(x, v, mask, extras, params):
39
+ return root(x, params=params, v=v, mask=mask, extras=extras)
40
+
41
+ _COMPILED_CACHE[key] = _call
42
+ return _call
43
+
44
+
45
+ def _jitted_eval(root, x, v, mask, extras, params):
46
+ """Dispatch to the per-root compiled callable (JIT-enabled path)."""
47
+ return _compiled_for_root(root)(x, v, mask, extras, params)