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,530 @@
1
+ import math
2
+ from typing import Any, Callable, Literal, Optional
3
+
4
+ import equinox as eqx
5
+ import jax
6
+ import jax.numpy as jnp
7
+ from jaxtyping import Array
8
+
9
+ from ..memhint import MemHint, SampleMeta, default_leaf_hint, itemsize_of, resolve_P
10
+ from ..params import ParamSuite
11
+ from .base import BaseNode
12
+ from .contract import Rank
13
+
14
+
15
+ # -----------------------------------------------------------------------
16
+ # helper: leaf‑local fill policy (root already did a "zero" premask) -----
17
+ # -----------------------------------------------------------------------
18
+ def _apply_fill(arr: Array, mask: Optional[Array], policy: str) -> Array:
19
+ """Optionally replace masked entries before dangerous maths.
20
+
21
+ Gradient behaviour
22
+ ------------------
23
+ ``jnp.where(mask, arr, fill)`` naturally provides zero tangents for the
24
+ masked branch in both forward and reverse mode (the fill value is a
25
+ constant with zero tangent). We therefore do **not** wrap the filled
26
+ array in ``stop_gradient`` — doing so would kill the Jacobian for
27
+ *all* entries (including active, non-masked ones), breaking
28
+ ``jacfwd``-based derivative nodes such as ``basis.d_x()``.
29
+
30
+ If the user-function has a singularity at the fill value (e.g. ``1/x``
31
+ filled with 0), use ``fill_policy='eps'`` instead.
32
+ """
33
+ if mask is None or policy == "zero":
34
+ return arr
35
+
36
+ # Guard: squeeze trailing singleton dims from mask so that it matches
37
+ # arr's non-spatial prefix. This prevents `mask[..., None]` from
38
+ # broadcasting *into* arr and inflating its shape (e.g. mask (1,) with
39
+ # arr (d,) would produce (1, d) without this squeeze).
40
+ if hasattr(mask, "ndim"):
41
+ prefix_ndim = arr.ndim - 1 # everything except the spatial dim
42
+ while mask.ndim > prefix_ndim and mask.shape[-1] == 1:
43
+ mask = mask[..., 0]
44
+
45
+ if policy == "eps":
46
+ eps = jnp.finfo(arr.dtype).eps
47
+ return jnp.where(mask[..., None], arr, eps)
48
+ if policy == "zerostop":
49
+ return jnp.where(mask[..., None], arr, 0)
50
+ if policy == "nanstop":
51
+ return jnp.where(mask[..., None], arr, jnp.nan)
52
+ if callable(policy):
53
+ return policy(arr, mask)
54
+ raise ValueError(f"Unknown fill policy {policy!r}")
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Generic primitive leaf – deterministic *or* parametric
59
+ # ---------------------------------------------------------------------------
60
+ class BaseLeaf(BaseNode):
61
+ """
62
+ One callable that returns **n_features** stacked on the last axis.
63
+
64
+ Parameters
65
+ ----------
66
+ func : Callable
67
+ Signature may include any subset of
68
+
69
+ (x, v=None, mask=None, extras=None, params=None)
70
+
71
+ - in any capitalisation. ``params`` is omitted if
72
+ ``param_suite is None`` (deterministic leaf).
73
+ param_suite : ParamSuite | None
74
+ * ``None`` → deterministic dictionary element
75
+ * ParamSuite → parameters are required at call time
76
+ rank, dim, pdepth, n_features, needs_v
77
+ Static contract.
78
+ labels : tuple[str, ...] - human-readable titles, length == n_features
79
+ descriptor : Any - optional JSON-serialisable metadata
80
+ fill_policy : "zero" | "eps" | "zerostop" | "nanstop" | Callable
81
+ extras_keys : tuple[str, ...] - required keys in the `extras` mapping
82
+
83
+ Mask handling
84
+ -------------
85
+ ``fill_policy`` controls how masked entries are preprocessed **before**
86
+ calling your function:
87
+
88
+ * ``"zero"`` : no-op (pass inputs through unchanged).
89
+ * ``"eps"`` : replace masked entries by machine epsilon of the dtype (safe for logs/div).
90
+ * ``"zerostop"`` : replace masked entries by 0 (fill value is a constant, so
91
+ ``jnp.where`` gives zero tangent for masked entries).
92
+ * ``"nanstop"`` : replace masked entries by NaN (fail-fast in JITed code; same
93
+ zero-tangent property as ``"zerostop"``).
94
+ * ``callable`` : custom policy ``fn(arr, mask) -> arr`` applied elementwise.
95
+
96
+ ``mask`` must broadcast to the prefix of ``x`` **including** the particle axis, i.e.
97
+ ``batch·P`` if ``particles_input=True`` or ``batch`` otherwise.
98
+
99
+ Extras policy
100
+ -------------
101
+ If a leaf declares ``extras``, values are read from the ``extras`` mapping at call time.
102
+ All extras are treated as **global constants**: they are closed over inside the vmap and
103
+ passed verbatim to every per-sample call. No shape-broadcasting is attempted.
104
+
105
+ Named requirements can be declared via ``extras_keys=("alpha","beta",...)``. If the function
106
+ declares an ``extras`` argument but no keys are specified, we only enforce **presence** of the
107
+ mapping (no specific keys).
108
+
109
+ """
110
+
111
+ # ---------------- static fields ----------------
112
+ func: Callable = eqx.field(static=True)
113
+ param_suite: ParamSuite | None = eqx.field(static=True, default=None)
114
+ labels: tuple[str, ...] = eqx.field(static=True, default=())
115
+ descriptor: Any = eqx.field(static=True, default=None)
116
+ fill_policy: str | Callable = eqx.field(static=True, default="zerostop")
117
+ extras_keys: tuple[str, ...] = eqx.field(static=True, default=())
118
+ particle_extras: tuple[str, ...] = eqx.field(static=True, default=())
119
+ particles_input: bool = eqx.field(static=True, default=False)
120
+ # Optional ``k -> StateExpr`` recipe folding this leaf at condition ``k``
121
+ # (used by ``StateExpr.specialize``; see per_dataset_scalar/dataset_indicator).
122
+ specialize_at: Any = eqx.field(static=True, repr=False, default=None)
123
+ _signature: Any = eqx.field(static=True, repr=False, default=None)
124
+ _sig_params: set[str] = eqx.field(static=True, repr=False, default_factory=set)
125
+ _sig_param_map: dict[str, str] = eqx.field(static=True, repr=False, default_factory=dict)
126
+
127
+ # -------------- constructor --------------------
128
+ def __init__(
129
+ self,
130
+ *,
131
+ func: Callable,
132
+ rank: Rank,
133
+ dim: int | None,
134
+ n_features: int,
135
+ pdepth: int = 0,
136
+ particles_input: bool = False,
137
+ needs_v: bool = False,
138
+ param_suite=None,
139
+ labels: tuple[str, ...] = (),
140
+ descriptor: Any = "custom",
141
+ fill_policy: str | Callable = "zerostop",
142
+ extras_keys: tuple[str, ...] = (),
143
+ particle_extras: tuple[str, ...] = (),
144
+ specialize_at: Callable | None = None,
145
+ ):
146
+ # static contract
147
+ object.__setattr__(self, "specialize_at", specialize_at)
148
+ object.__setattr__(self, "rank", rank)
149
+ object.__setattr__(self, "dim", dim)
150
+ object.__setattr__(self, "pdepth", pdepth)
151
+ object.__setattr__(self, "needs_v", needs_v)
152
+ object.__setattr__(self, "particles_input", particles_input)
153
+ object.__setattr__(self, "n_features", int(n_features))
154
+
155
+ # static metadata
156
+ object.__setattr__(self, "func", func)
157
+ object.__setattr__(self, "param_suite", param_suite)
158
+ object.__setattr__(self, "descriptor", descriptor)
159
+ object.__setattr__(self, "fill_policy", fill_policy)
160
+ # normalize and store extras_keys
161
+ if extras_keys is None:
162
+ extras_keys = ()
163
+ elif isinstance(extras_keys, str):
164
+ extras_keys = (extras_keys,)
165
+ extras_keys = tuple(k for k in extras_keys if k != "*") # no wildcard sentinel
166
+ object.__setattr__(self, "extras_keys", extras_keys)
167
+ object.__setattr__(self, "particle_extras", tuple(particle_extras or ()))
168
+
169
+ if labels and len(labels) != n_features:
170
+ raise ValueError("labels length must equal n_features")
171
+ object.__setattr__(self, "labels", tuple(labels or [f"f{j}" for j in range(n_features)]))
172
+
173
+ # --- signature analysis (single pass) --------------------------------
174
+ import inspect as _inspect
175
+
176
+ sig = _inspect.signature(func)
177
+ object.__setattr__(self, "_signature", sig)
178
+
179
+ # canonical-name → original-name (case-insensitive lookup)
180
+ lower2orig = {p.name.lower(): p.name for p in sig.parameters.values()}
181
+ object.__setattr__(self, "_sig_params", set(lower2orig))
182
+ object.__setattr__(self, "_sig_param_map", lower2orig)
183
+
184
+ params_by = {p.name: p for p in sig.parameters.values()}
185
+
186
+ # needs_v → function must actually accept 'v'
187
+ if self.needs_v and ("v" not in params_by):
188
+ raise ValueError(f"{type(self).__name__}: needs_v=True but the function does not declare 'v'.")
189
+
190
+ # extras_keys → function must actually accept 'extras'
191
+ if self.extras_keys and ("extras" not in params_by):
192
+ raise ValueError(f"{type(self).__name__}: extras_keys provided but the function has no 'extras' parameter.")
193
+
194
+ # ParamLeaf must accept 'params' if it actually has parameters
195
+ has_params_kw = "params" in params_by
196
+ has_nonempty_suite = (self.param_suite is not None) and (getattr(self.param_suite, "size", 0) > 0)
197
+ if has_nonempty_suite and (not has_params_kw):
198
+ raise ValueError(
199
+ f"{type(self).__name__}: function must declare a 'params' keyword to build a non-empty ParamLeaf."
200
+ )
201
+
202
+ # extras presence policy
203
+ has_extras_param = "extras" in params_by
204
+ if has_extras_param:
205
+ required = tuple(self.extras_keys) if self.extras_keys else ()
206
+ else:
207
+ required = ()
208
+ object.__setattr__(self, "extras_required", required)
209
+
210
+ # ---- static contract sanity ----
211
+ if not isinstance(self.pdepth, int) or self.pdepth < 0:
212
+ raise ValueError(f"{type(self).__name__}: pdepth must be a non-negative int, got {self.pdepth!r}")
213
+ if not isinstance(self.rank, int) or self.rank < 0:
214
+ raise ValueError(f"{type(self).__name__}: rank must be a non-negative int, got {self.rank!r}")
215
+ if not isinstance(self.n_features, int) or self.n_features < 1:
216
+ raise ValueError(f"{type(self).__name__}: n_features must be a positive int, got {self.n_features!r}")
217
+ if (self.dim is not None) and (not isinstance(self.dim, int) or self.dim < 1):
218
+ raise ValueError(f"{type(self).__name__}: dim must be None or a positive int, got {self.dim!r}")
219
+ if (not self.particles_input) and (self.pdepth > 0):
220
+ raise ValueError(
221
+ f"{type(self).__name__}: pdepth>0 requires particles_input=True "
222
+ "(cannot create particle axes without a particle input axis)."
223
+ )
224
+
225
+ # -------------- call ---------------------------
226
+ def __call__(self, x, *, params=None, v=None, mask=None, extras=None):
227
+ self._assert_inputs(x, v, mask, extras)
228
+ x_safe = _apply_fill(x, mask, self.fill_policy)
229
+
230
+ y = self._apply_user_func(x_safe, v=v, mask=mask, extras=extras, params=params)
231
+
232
+ self._assert_outputs(x, y)
233
+ return y
234
+
235
+ # -----------------------------------------------------------------
236
+ # Canonicalise shapes, batch-vmap once, call user func
237
+ # -----------------------------------------------------------------
238
+ def _apply_user_func(
239
+ self,
240
+ x,
241
+ *,
242
+ v=None,
243
+ mask=None,
244
+ extras=None,
245
+ params=None,
246
+ ):
247
+ """
248
+ Ensure the user-provided function sees a single sample:
249
+ - x: (dim,) or (P, dim) if particles_input=True
250
+ - v: same shape as x (only if the function declares 'v')
251
+ - mask: scalar or (P,) (only if the function declares 'mask')
252
+ - extras: per-sample dict (only if the function declares 'extras')
253
+ - params: dict (if the function declares 'params', even for BasisLeaf)
254
+
255
+ Batching over any leading axes is handled via a single vmap.
256
+ """
257
+ import jax.numpy as jnp
258
+
259
+ # ---------- 1. Expected inner shapes ----------
260
+ inner_ndim = 1 + int(self.particles_input) # dim (+P)
261
+ if x.ndim < inner_ndim:
262
+ if self.dim == 1 and x.ndim == inner_ndim - 1:
263
+ x = jnp.expand_dims(x, axis=-1)
264
+ if v is not None:
265
+ v = jnp.expand_dims(v, axis=-1)
266
+ else:
267
+ raise ValueError(
268
+ f"{type(self).__name__}: input x.ndim={x.ndim} too small for particles_input={self.particles_input}"
269
+ )
270
+
271
+ batch_shape = x.shape[:-inner_ndim] # tuple of batch dims
272
+ inner_shape = x.shape[-inner_ndim:]
273
+ flat_B = int(math.prod(batch_shape)) if batch_shape else 1
274
+
275
+ # ---------- 2. Flatten batch ----------
276
+ def _flatten(arr):
277
+ return None if arr is None else arr.reshape((flat_B,) + inner_shape)
278
+
279
+ x_f = _flatten(x)
280
+ v_f = _flatten(v)
281
+ m_f = None
282
+ if mask is not None:
283
+ # Let mask carry only its own dims; library already checked broadcast.
284
+ # We broadcast mask over the full batch prefix and then flatten.
285
+ mask_arr = jnp.asarray(mask)
286
+ # Pad to at least len(batch_shape) dims
287
+ pad = max(0, len(batch_shape) - mask_arr.ndim)
288
+ mask_arr = jnp.reshape(mask_arr, (1,) * pad + mask_arr.shape)
289
+ # Broadcast the first len(batch_shape) dims to batch_shape (remaining dims unchanged)
290
+ target = tuple(batch_shape) + mask_arr.shape[len(batch_shape) :]
291
+ mask_arr = jnp.broadcast_to(mask_arr, target)
292
+ m_f = mask_arr.reshape((flat_B,) + mask_arr.shape[len(batch_shape) :])
293
+
294
+ # ---------- 3. Prepare extras ------------
295
+ # Policy:
296
+ # - extras are GLOBAL constants (closed over), not vmapped — except
297
+ # keys declared in ``particle_extras`` whose leading axes match
298
+ # the batch (the dispatcher's per-edge gathered arrays): those
299
+ # are vmapped alongside x so the user function sees its own
300
+ # ``(K, ...)`` slice per sample.
301
+ e_f = None
302
+ e_mapped = None
303
+ if "extras" in self._sig_params:
304
+ if extras is None:
305
+ e_f = None
306
+ else:
307
+ # If keys were declared, keep those; otherwise pass the whole mapping.
308
+ keys = self.extras_keys if getattr(self, "extras_keys", ()) else tuple(extras.keys())
309
+ e_all = {k: extras[k] for k in keys}
310
+ pkeys = set(getattr(self, "particle_extras", ()) or ())
311
+ nb = len(batch_shape)
312
+
313
+ def _as_mapped(val):
314
+ """Reshape a declared per-sample extra to (flat_B, ...).
315
+
316
+ Accepts a full batch-prefix match, or a trailing-axes
317
+ match (e.g. a per-particle ``(N, ...)`` array under a
318
+ batched ``(K, N)`` evaluation), broadcast over the
319
+ leading batch dims. Returns None when the value does
320
+ not align (treated as a constant).
321
+ """
322
+ if not hasattr(val, "shape") or nb == 0:
323
+ return None
324
+ shape = tuple(val.shape)
325
+ if shape[:nb] == tuple(batch_shape):
326
+ return val.reshape((flat_B,) + shape[nb:])
327
+ for k_off in range(1, nb):
328
+ lead = nb - k_off
329
+ if shape[:lead] == tuple(batch_shape[k_off:]):
330
+ rest = shape[lead:]
331
+ tgt = tuple(batch_shape) + rest
332
+ return jnp.broadcast_to(val, tgt).reshape((flat_B,) + rest)
333
+ return None
334
+
335
+ e_mapped = {}
336
+ for k in pkeys & set(e_all):
337
+ mapped = _as_mapped(e_all[k])
338
+ if mapped is not None:
339
+ e_mapped[k] = mapped
340
+ e_f = {k: v for k, v in e_all.items() if k not in e_mapped}
341
+ if not e_mapped:
342
+ e_mapped = None
343
+
344
+ # ---------- 4. Batched caller ----------
345
+
346
+ def _caller(x_, v_, mask_, e_m):
347
+ kwargs = {}
348
+ if "v" in self._sig_params:
349
+ kwargs[self._sig_param_map["v"]] = v_
350
+ if "mask" in self._sig_params:
351
+ kwargs[self._sig_param_map["mask"]] = mask_
352
+ if "extras" in self._sig_params:
353
+ ex = e_f if e_m is None else {**(e_f or {}), **e_m}
354
+ kwargs[self._sig_param_map["extras"]] = ex
355
+ if "params" in self._sig_params:
356
+ kwargs[self._sig_param_map["params"]] = params
357
+ return self.func(x_, **kwargs)
358
+
359
+ in_axes = (
360
+ 0,
361
+ 0 if v_f is not None else None,
362
+ 0 if m_f is not None else None,
363
+ 0 if e_mapped is not None else None,
364
+ )
365
+ y_f = jax.vmap(_caller, in_axes=in_axes)(x_f, v_f, m_f, e_mapped)
366
+
367
+ # ---------- 5. Restore batch shape ----------
368
+ y = y_f.reshape(batch_shape + y_f.shape[1:])
369
+
370
+ # ---------- 6. Contract check (strict pdepth) + auto feature axis ----------
371
+ trail = y_f.shape[1:] # excluding the vmapped axis
372
+ P = inner_shape[0] if (self.particles_input and x.ndim >= 2) else None
373
+
374
+ particle_block = (P,) * int(self.pdepth) if self.pdepth else ()
375
+ rank_block = (self.dim,) * int(self.rank) if int(self.rank) else ()
376
+ base_suffix = particle_block + rank_block
377
+
378
+ if trail == base_suffix + (self.n_features,):
379
+ return y
380
+ if self.n_features == 1 and trail == base_suffix:
381
+ return y[..., None] # auto-insert singleton feature axis
382
+
383
+ raise ValueError(
384
+ f"[{type(self).__name__}] output shape mismatch.\n"
385
+ f" expected suffix : {base_suffix + (self.n_features,)}\n"
386
+ f" got : {trail}"
387
+ )
388
+
389
+ # -------------- flatten ------------------------
390
+ def flatten(self):
391
+ funcs = [
392
+ lambda _x, *, v=None, mask=None, extras=None, params=None, _j=j: # noqa: E731
393
+ self(_x, v=v, mask=mask, extras=extras, params=params)[..., _j]
394
+ for j in range(self.n_features)
395
+ ]
396
+ descs = [self.descriptor] * self.n_features
397
+ return funcs, list(self.labels), descs
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Leaf aliases for intelligible construction
402
+ # ---------------------------------------------------------------------------
403
+ class SimpleLeaf(BaseLeaf):
404
+ """
405
+ Standard leaf: never reads or produces a particle axis.
406
+ Treats any particle dimension in `x` as batch and vmaps over it.
407
+ Output suffix is (``*rank``, n_features) with feature last.
408
+ """
409
+
410
+ def __init__(self, **kwargs):
411
+ if kwargs.get("particles_input", False):
412
+ raise ValueError("SimpleLeaf forbids particles_input=True (it never sees P).")
413
+ if kwargs.get("pdepth", 0) != 0:
414
+ raise ValueError("SimpleLeaf requires pdepth=0 (it never produces a particle axis).")
415
+ kwargs["particles_input"] = False
416
+ kwargs["pdepth"] = 0
417
+ super().__init__(**kwargs)
418
+
419
+
420
+ class InteractionLeaf(BaseLeaf):
421
+ """
422
+ Local interaction leaf: **consumes** a `(K, dim)` particle slice and returns
423
+ feature-last output with **no** particle axis (pdepth=0).
424
+
425
+ Modes
426
+ -----
427
+ - mode="fixed", K=int → assert `x.shape[-2] == K`
428
+ - mode="variable", Kmax=int → assert `x.shape[-2] == Kmax`, pass (optional)
429
+ 1D mask of length Kmax to the user func.
430
+
431
+ Extras semantics
432
+ ----------------
433
+ This leaf supports explicit separation of extras kinds (declared at construction time):
434
+ - ``global_extras`` : keys whose values are **global constants** for the call
435
+ (e.g., PBC box). They are passed through verbatim.
436
+ - ``particle_extras`` : keys whose values are **per-particle** arrays (shape ``(P, ...)``).
437
+ The dispatcher is responsible for **gathering** these into the
438
+ local K-order before calling the leaf. The leaf sees per-sample
439
+ payload only (no batch axes).
440
+ """
441
+
442
+ mode: Literal["fixed", "variable"] = eqx.field(static=True)
443
+ K: int | None = eqx.field(static=True, default=None)
444
+ Kmax: int | None = eqx.field(static=True, default=None)
445
+
446
+ def __init__(
447
+ self,
448
+ *,
449
+ mode: Literal["fixed", "variable"],
450
+ K: int | None = None,
451
+ Kmax: int | None = None,
452
+ func,
453
+ dim: int,
454
+ rank: "Rank",
455
+ n_features: int = 1,
456
+ needs_v: bool = False,
457
+ param_suite=None,
458
+ labels: tuple[str, ...] = (),
459
+ descriptor=None,
460
+ fill_policy: str | Callable = "zero",
461
+ extras_keys: tuple[str, ...] = (),
462
+ particle_extras: tuple[str, ...] = (),
463
+ ):
464
+ if mode == "fixed":
465
+ if K is None:
466
+ raise ValueError("InteractionLeaf(mode='fixed') requires K.")
467
+ elif mode == "variable":
468
+ if Kmax is None:
469
+ raise ValueError("InteractionLeaf(mode='variable') requires Kmax.")
470
+ # A safer default for ragged padding
471
+ if fill_policy == "zero":
472
+ fill_policy = "zerostop"
473
+ else:
474
+ raise ValueError(f"Unknown mode {mode!r}")
475
+
476
+ super().__init__(
477
+ func=func,
478
+ rank=rank,
479
+ dim=dim,
480
+ n_features=n_features,
481
+ pdepth=0, # never *produces* a particle axis
482
+ particles_input=True, # but it *consumes* (K?, dim)
483
+ needs_v=needs_v,
484
+ param_suite=param_suite,
485
+ labels=labels,
486
+ descriptor=descriptor,
487
+ fill_policy=fill_policy,
488
+ extras_keys=extras_keys,
489
+ )
490
+ object.__setattr__(self, "mode", mode)
491
+ object.__setattr__(self, "K", None if mode != "fixed" else int(K))
492
+ object.__setattr__(self, "Kmax", None if mode != "variable" else int(Kmax))
493
+ # NOTE: must be set *after* super().__init__, which resets the
494
+ # base-class default to ().
495
+ if particle_extras is None:
496
+ particle_extras = ()
497
+ object.__setattr__(self, "particle_extras", tuple(particle_extras))
498
+
499
+ def __call__(self, x: Array, *, params=None, v=None, mask=None, extras=None) -> Array:
500
+ if x.ndim < 2:
501
+ raise ValueError(f"{type(self).__name__} expects (..., K, dim); got {x.shape}")
502
+ Kin = x.shape[-2]
503
+ if self.mode == "fixed":
504
+ if Kin != self.K:
505
+ raise ValueError(f"{type(self).__name__} expected K={self.K}, got {Kin}")
506
+ else: # variable
507
+ if Kin != self.Kmax:
508
+ raise ValueError(f"{type(self).__name__} expected Kmax={self.Kmax}, got {Kin}")
509
+ # If the user func declared `mask`, BaseLeaf will route it; fill_policy applies.
510
+ return super().__call__(x, params=params, v=v, mask=mask, extras=extras)
511
+
512
+ # Interaction leaves often need a small live working set of the gathered neighborhood.
513
+ # Count it conservatively as K*dim elements in addition to the output buffer.
514
+ def memory_hint(
515
+ self,
516
+ *,
517
+ dtype=None,
518
+ particle_size: int | None = None,
519
+ sample: SampleMeta | None = None,
520
+ mode: str = "forward",
521
+ ) -> MemHint:
522
+ P = resolve_P(particle_size, sample)
523
+ base = default_leaf_hint(self, dtype=dtype, particle_size=P, mode=mode)
524
+ dim = int(getattr(self, "dim", 0) or 0)
525
+ Kwork = self.K if self.mode == "fixed" else self.Kmax
526
+ wset = (int(Kwork) * dim * itemsize_of(dtype)) if (Kwork is not None and dim) else 0
527
+ return MemHint(
528
+ per_sample_bytes=base.per_sample_bytes + wset,
529
+ persistent_bytes=base.persistent_bytes,
530
+ )
@@ -0,0 +1,27 @@
1
+ # SFI/statefunc/nodes/ops/__init__.py
2
+ """
3
+ Operator node classes.
4
+
5
+ These are the building blocks for composite state expressions.
6
+ Most users will never need them directly — they’re primarily
7
+ for developers extending `SFI.statefunc`.
8
+ """
9
+
10
+ from .concat import ConcatNode
11
+ from .derivative import DerivativeNode
12
+ from .einsum import EinsumNode
13
+ from .linear import CoeffNode, DenseNode
14
+ from .mapn import MapNNode
15
+ from .reshape_rank import ReshapeRankNode
16
+ from .slice import SliceFeaturesNode
17
+
18
+ __all__ = [
19
+ "ConcatNode",
20
+ "MapNNode",
21
+ "EinsumNode",
22
+ "DenseNode",
23
+ "CoeffNode",
24
+ "SliceFeaturesNode",
25
+ "DerivativeNode",
26
+ "ReshapeRankNode",
27
+ ]
@@ -0,0 +1,28 @@
1
+ # SFI/statefunc/nodes/ops/concat.py
2
+ """Concatenation operator node."""
3
+
4
+ import jax.numpy as jnp
5
+
6
+ from ..base import BaseOpNode
7
+
8
+
9
+ # ──────────────────────────────────────────────────────────────────────────────
10
+ # ConcatNode – feature-axis concatenation
11
+ # ──────────────────────────────────────────────────────────────────────────────
12
+ class ConcatNode(BaseOpNode):
13
+ """
14
+ Concatenate the **feature axes** of several sub-bases.
15
+
16
+ All children must share the same *spatial* contract
17
+ ``(rank, dim, pdepth)``; the resulting node inherits that contract
18
+ while its ``n_features`` is the sum of the children's features.
19
+ """
20
+
21
+ CONTRACT_MODE = "concat"
22
+
23
+ # combine tensors
24
+ def _op(self, outs, *, params):
25
+ return jnp.concatenate(outs, axis=-1)
26
+
27
+ def with_children(self, new_children):
28
+ return ConcatNode(*new_children)