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,447 @@
1
+ # SFI/statefunc/nodes/ops/derivative.py
2
+ """Derivative operator node."""
3
+
4
+ import equinox as eqx
5
+ import jax
6
+ import jax.numpy as jnp
7
+ from jax import lax
8
+
9
+ from ...memhint import SampleMeta, inflate_for_grad
10
+ from ..base import BaseNode, BaseOpNode
11
+ from ..contract import _ContractMixin
12
+
13
+
14
+ # ──────────────────────────────────────────────────────────────────────────────
15
+ # DerivativeNode – first-order Jacobian with optional cross-particle block
16
+ # ──────────────────────────────────────────────────────────────────────────────
17
+ class DerivativeNode(BaseOpNode):
18
+ """First-order derivative wrapper around *one* child node.
19
+
20
+ Parameters
21
+ ----------
22
+ child : BaseNode
23
+ var : {'x', 'v', 'theta'}
24
+ Choose which variable to differentiate with respect to.
25
+ same_particle : bool, default False
26
+ Only meaningful for ``var in {'x','v'}``.
27
+ Requires ``child.particles_input is True`` and ``child.pdepth == 1``.
28
+
29
+ - ``True`` -> same-particle Jacobian df[i]/dx[i] (work/memory ``O(P)``).
30
+ - ``False`` -> full cross Jacobian df[i]/dx[j] (work/memory ``O(P**2)``).
31
+
32
+ mode : {'auto','fwd','rev'}
33
+ JAX autodiff flavour. ``'auto'`` maps to:
34
+ - x, v → ``jacfwd`` (forward mode)
35
+ - theta → ``jacrev`` (reverse mode)
36
+
37
+ Shapes produced
38
+ ---------------
39
+ *Suffix; batch prefix unchanged.*
40
+
41
+ Let ``P_out`` denote the child's **output** particle axis count (pdepth),
42
+ and ``P_in`` the **input** particle axis (present **only when** the model
43
+ has particle inputs). Let ``dim`` be spatial dimension, ``rank`` the existing
44
+ spatial rank axes, and ``F`` the feature axis.
45
+
46
+ **Noninteracting case** (pdepth=0, particles_input=False):
47
+
48
+ For noninteracting nodes, inputs have shape ``batch · dim`` and outputs
49
+ ``batch · rank … · F``. First-order derivatives w.r.t. ``x``/``v`` are computed
50
+ **per sample** by temporarily un-batching the input to shape ``(dim,)`` and
51
+ then vmapping the per-sample Jacobian back over the batch prefix. The result
52
+ shape is::
53
+
54
+ d_x(), d_v(): batch · dim · rank … · F
55
+
56
+ No cross-batch Jacobian axes are formed.
57
+
58
+ **Interacting case** (particles_input=True):
59
+
60
+ ================ ===========================================================
61
+ call output suffix (after derivative)
62
+ ================ ===========================================================
63
+ d_x(same=True) ``P_out^1 · dim · rank … · F`` (pdepth unchanged)
64
+ d_x(same=False) ``P_in · P_out^1 · dim · rank … · F`` (**pdepth → pdepth+1**; only if particles_input)
65
+ d_v(same=True) same as d_x(same=True)
66
+ d_v(same=False) same as d_x(same=False)
67
+ d_theta() ``rank … · (F x n_param)``
68
+ ================ ===========================================================
69
+
70
+ Notes
71
+ -----
72
+ - Derivatives w.r.t. x/v *increase spatial rank by 1* (insert new derivative-dim
73
+ immediately **before** the existing rank block).
74
+ - For not ``same_particle`` with particle inputs, the derivative introduces a new
75
+ **input particle axis** positioned **before** the child's pdepth block, and
76
+ the node's **pdepth is incremented by 1**. If there is **no** particle input,
77
+ no extra axis is added and pdepth is unchanged.
78
+ - Theta-Jacobian is formed leafwise and concatenated along the final axis, fusing
79
+ (features x total_param_elems).
80
+ """
81
+
82
+ var: str = eqx.field(static=True)
83
+ same_particle: bool = eqx.field(static=True)
84
+ mode: str = eqx.field(static=True)
85
+
86
+ def __init__(
87
+ self,
88
+ child: "BaseNode",
89
+ *,
90
+ var: str, # 'x' | 'v' | 'theta'
91
+ same_particle: bool = False,
92
+ mode: str = "auto",
93
+ ):
94
+ object.__setattr__(self, "child", child)
95
+ object.__setattr__(self, "var", var)
96
+ object.__setattr__(self, "same_particle", bool(same_particle))
97
+ object.__setattr__(self, "mode", mode)
98
+
99
+ if var not in {"x", "v", "theta"}:
100
+ raise ValueError("var must be 'x', 'v', or 'theta'")
101
+ if mode not in {"auto", "fwd", "rev"}:
102
+ raise ValueError("mode must be 'auto', 'fwd', or 'rev'")
103
+ if var == "theta" and (child.param_suite is None or child.param_suite.size == 0):
104
+ raise ValueError("θ-gradient requested but child has no parameters")
105
+
106
+ if var in {"x", "v"} and self.same_particle:
107
+ if not child.particles_input or child.pdepth != 1:
108
+ raise ValueError(
109
+ "same-particle derivative requires particles_input=True and pdepth==1 "
110
+ "(differentiate after slicing/selecting a single particle output)."
111
+ )
112
+ if var == "v" and not child.needs_v:
113
+ raise ValueError("v-gradient requested but child does not depend on v")
114
+
115
+ super().__init__(child)
116
+ object.__setattr__(self, "param_suite", child.param_suite)
117
+
118
+ def _merge_static(self, children):
119
+ ch = children[0]
120
+ new_rank = ch.rank + (1 if self.var in {"x", "v"} else 0)
121
+
122
+ add_p = (self.var in {"x", "v"}) and ch.particles_input and (not self.same_particle)
123
+ new_pdepth = ch.pdepth + (1 if add_p else 0)
124
+
125
+ new_nfeat = ch.n_features
126
+ if self.var == "theta":
127
+ assert ch.param_suite is not None
128
+ new_nfeat *= max(1, ch.param_suite.size)
129
+
130
+ return _ContractMixin.inherit_contract(ch, rank=new_rank, pdepth=new_pdepth, n_features=new_nfeat)
131
+
132
+ # ------------------------------------------------------------------
133
+ # Feature-aware flatten (auto-labelling for derivatives)
134
+ # ------------------------------------------------------------------
135
+ def flatten(self):
136
+ child = self.children[0]
137
+ funcs_ch, labels_ch, descs_ch = child.flatten()
138
+
139
+ prefix = {"x": "∂ₓ", "v": "∂ᵥ", "theta": "∂θ"}[self.var]
140
+
141
+ def _wrap(lab):
142
+ return lab if len(lab) == 1 else f"({lab})"
143
+
144
+ if self.var in {"x", "v"}:
145
+ # Feature count unchanged; just decorate labels.
146
+ labels = [f"{prefix}{_wrap(lab)}" for lab in labels_ch]
147
+ else:
148
+ # theta: fused axis = n_features × n_params_total
149
+ assert child.param_suite is not None
150
+ n_params = max(1, child.param_suite.size)
151
+ labels = [f"{prefix}({lab},{k})" for lab in labels_ch for k in range(n_params)]
152
+
153
+ n_feat = len(labels)
154
+
155
+ def _slice_feature(j):
156
+ return lambda x, *, v=None, mask=None, extras=None, params=None, _j=j: (
157
+ self(x, v=v, mask=mask, extras=extras, params=params)[..., _j]
158
+ )
159
+
160
+ funcs = [_slice_feature(j) for j in range(n_feat)]
161
+ descs = [
162
+ {"derivative": self.var, "child": d} for d in (descs_ch * (n_feat // len(descs_ch) if descs_ch else 1))
163
+ ]
164
+
165
+ return funcs, labels, descs
166
+
167
+ def __call__(self, x, *, v=None, params=None, mask=None, extras=None):
168
+ """Compute the requested first-order Jacobian with proper un-batching."""
169
+ self._assert_inputs(x, v, mask, extras)
170
+ child = self.children[0]
171
+
172
+ # pick AD flavour
173
+ mode = (
174
+ "fwd"
175
+ if (self.mode == "auto" and self.var in {"x", "v"})
176
+ else "rev"
177
+ if (self.mode == "auto" and self.var == "theta")
178
+ else self.mode
179
+ )
180
+ diff = jax.jacfwd if mode == "fwd" else jax.jacrev
181
+
182
+ # ----- θ derivative ------------------------------------------
183
+ if self.var == "theta":
184
+
185
+ def gθ(_x, _v, _θ):
186
+ return child(_x, v=_v, params=_θ, mask=mask, extras=extras)
187
+
188
+ Jtree = diff(gθ, argnums=2)(x, v, params)
189
+ J_leaves, treedefJ = jax.tree_util.tree_flatten(Jtree)
190
+ P_leaves, treedefP = jax.tree_util.tree_flatten(params)
191
+ if treedefJ != treedefP:
192
+ raise ValueError("[DerivativeNode] Jacobian tree does not match params tree structure")
193
+ folded = []
194
+ for J_leaf, p_leaf in zip(J_leaves, P_leaves):
195
+ pr = int(getattr(p_leaf, "ndim", 0))
196
+ A = (
197
+ jnp.reshape(J_leaf, J_leaf.shape + (1,))
198
+ if pr == 0
199
+ else jnp.reshape(J_leaf, J_leaf.shape[:-pr] + (-1,))
200
+ )
201
+ # fuse the last two axes (ranked output axis × param axis)
202
+ A = jnp.reshape(A, A.shape[:-2] + (A.shape[-2] * A.shape[-1],))
203
+ folded.append(A)
204
+ if not folded:
205
+ return child(x, v=v, params=params, mask=mask, extras=extras)
206
+ return jnp.concatenate(folded, axis=-1)
207
+
208
+ # ----- x/v derivative paths: un-batch then vmap back ----------------------
209
+ # core dims for the diff'ed argument
210
+ expect_particles = bool(child.particles_input)
211
+ core_nd = 2 if expect_particles else 1 # (P,dim) vs (dim,)
212
+ b_nd = max(0, x.ndim - core_nd) # how many leading batch axes in x
213
+
214
+ # Helpers to broadcast/bucket mask/extras to the batch prefix and flatten it
215
+ def _broadcast_prefix_to(a, bshape):
216
+ if a is None:
217
+ return None
218
+ if not (hasattr(a, "shape") and hasattr(a, "dtype")):
219
+ return a # non-array leaf stays scalar/constant
220
+ # prepend ones so we can broadcast to the full prefix
221
+ pad = len(bshape) - a.ndim if a.ndim < len(bshape) else 0
222
+ if pad > 0:
223
+ a = jnp.reshape(a, (1,) * pad + a.shape)
224
+ # target shape = bshape + trailing
225
+ trailing = a.shape[len(bshape) :]
226
+ target = tuple(bshape) + tuple(trailing)
227
+ a = jnp.broadcast_to(a, target)
228
+ return a
229
+
230
+ def _prep_tree_for_flat_map(tree, bshape):
231
+ return jax.tree_util.tree_map(lambda leaf: _broadcast_prefix_to(leaf, bshape), tree)
232
+
233
+ def _flatten_tree_prefix(tree, bshape):
234
+ """Flatten the batch prefix of every leaf so it matches x_flat."""
235
+
236
+ def _flat(a):
237
+ if a is None:
238
+ return None
239
+ if not (hasattr(a, "shape") and hasattr(a, "dtype")):
240
+ return a
241
+ trailing = a.shape[len(bshape) :]
242
+ return jnp.reshape(a, (-1,) + trailing)
243
+
244
+ return jax.tree_util.tree_map(_flat, tree)
245
+
246
+ # same-particle case: only valid for interacting (already validated in __init__)
247
+ if self.same_particle:
248
+ if b_nd == 0:
249
+ # Direct call — _same_particle_jacobian returns contract layout
250
+ return child._same_particle_jacobian(x, var=self.var, v=v, params=params, mask=mask, extras=extras)
251
+
252
+ # vmap per-sample across all leading batch axes
253
+ bshape = x.shape[:b_nd]
254
+
255
+ # 1) Prepare/broadcast and flatten mask to batch prefix
256
+ mask_flat = _prep_tree_for_flat_map(mask, bshape)
257
+ mask_flat = _flatten_tree_prefix(mask_flat, bshape)
258
+
259
+ # 2) Flatten x (and v); extras are **closed over** unchanged.
260
+ x_flat = jnp.reshape(x, (-1,) + x.shape[-core_nd:])
261
+ v_flat = None if v is None else jnp.reshape(v, (-1,) + v.shape[-core_nd:])
262
+
263
+ in_axes_mask_sp = jax.tree_util.tree_map(lambda a: 0 if (hasattr(a, "shape")) else None, mask_flat)
264
+
265
+ def map_one_sp(xi, vi, mi):
266
+ return child._same_particle_jacobian(xi, var=self.var, v=vi, params=params, mask=mi, extras=extras)
267
+
268
+ vm = jax.vmap(
269
+ map_one_sp,
270
+ in_axes=(0, 0 if v_flat is not None else None, in_axes_mask_sp),
271
+ )
272
+ J_flat = vm(x_flat, v_flat, mask_flat)
273
+ return jnp.reshape(J_flat, bshape + J_flat.shape[1:])
274
+
275
+ # cross-particle (or noninteracting) derivative → jacobian wrt x or v
276
+ argnum = 0 if self.var == "x" else 1
277
+
278
+ if b_nd == 0:
279
+ # simple per-sample diff on (dim,) or (P,dim)
280
+ J = diff(
281
+ lambda _x, _v: child(_x, v=_v, params=params, mask=mask, extras=extras),
282
+ argnums=argnum,
283
+ )(x, v)
284
+ return _move_deriv_axis(
285
+ J,
286
+ rank=child.rank,
287
+ pdepth_old=child.pdepth,
288
+ particles_input=child.particles_input,
289
+ same_particle=False,
290
+ )
291
+
292
+ # batched case: vmap over the entire batch prefix (flattened), then restore shape
293
+ bshape = x.shape[:b_nd]
294
+ mask_b = _prep_tree_for_flat_map(mask, bshape)
295
+ mask_b = _flatten_tree_prefix(mask_b, bshape) # (K,N,...) → (K*N,...)
296
+ if mask_b is not None and hasattr(mask_b, "shape"):
297
+ mask_b = jnp.reshape(mask_b, (-1,) + mask_b.shape[len(bshape) :])
298
+ x_flat = jnp.reshape(x, (-1,) + x.shape[-core_nd:])
299
+ v_flat = None if v is None else jnp.reshape(v, (-1,) + v.shape[-core_nd:])
300
+
301
+ def map_one(xi, vi, mi):
302
+ return diff(
303
+ lambda _x, _v: child(_x, v=_v, params=params, mask=mi, extras=extras),
304
+ argnums=argnum,
305
+ )(xi, vi)
306
+
307
+ in_axes_mask = jax.tree_util.tree_map(lambda a: 0 if (hasattr(a, "shape")) else None, mask_b)
308
+ vm = jax.vmap(map_one, in_axes=(0, 0 if v_flat is not None else None, in_axes_mask))
309
+ J_flat = vm(x_flat, v_flat, mask_b)
310
+ J = jnp.reshape(J_flat, bshape + J_flat.shape[1:])
311
+ return _move_deriv_axis(
312
+ J,
313
+ rank=child.rank,
314
+ pdepth_old=child.pdepth,
315
+ particles_input=child.particles_input,
316
+ same_particle=False,
317
+ )
318
+
319
+ # Derivatives tend to keep tangents/tapes alive. Inflate conservatively in grad mode.
320
+ def memory_hint(
321
+ self,
322
+ *,
323
+ dtype=None,
324
+ particle_size: int | None = None,
325
+ sample: SampleMeta | None = None,
326
+ mode: str = "forward",
327
+ ):
328
+ base = super().memory_hint(dtype=dtype, particle_size=particle_size, sample=sample, mode=mode)
329
+ return inflate_for_grad(base, factor=2.0) if mode == "grad" else base
330
+
331
+
332
+ def _same_particle_grad(g, diff, x, v, params, *, var: str = "x", **g_kwargs):
333
+ """
334
+ Compute same-particle blocks without forming the full PxP.
335
+
336
+ The callable `g` must have signature:
337
+ g(x, v, params, **g_kwargs) -> y
338
+ and should already close over any global/edge-local context (e.g. `extras`, `mask`)
339
+ if not provided via `g_kwargs`.
340
+
341
+ For var == 'x': returns ∂f_i / ∂x_i
342
+ For var == 'v': returns ∂f_i / ∂v_i
343
+
344
+ Let x.shape == B₁ · B₂ · … · Bk · P · dim (k ≥ 0 batch axes).
345
+ Result shape:
346
+ B₁ · B₂ · … · Bk · P · dim · (rank …) · (feature?)
347
+
348
+ Implementation detail:
349
+ - vmap across ALL batch axes (k nested vmaps), then over the particle index.
350
+ - “Added particle axis” elsewhere refers to input particles and only matters
351
+ when `particles_input=True`.
352
+ """
353
+ *batch_shape, P, dim = x.shape
354
+ particles_axis = -2 # P is last-but-one
355
+
356
+ def per_batch(_x, _v):
357
+ def inner(i):
358
+ if var == "x":
359
+ yi = lax.dynamic_index_in_dim(_x, i, axis=particles_axis, keepdims=False) # (dim,)
360
+
361
+ def repl(y_local):
362
+ x_mod = lax.dynamic_update_index_in_dim(_x, y_local, i, axis=particles_axis)
363
+ # NOTE: extras/mask are expected to be captured by `g` or provided via **g_kwargs
364
+ y_full = g(x_mod, v=_v, params=params, **g_kwargs) # shape: P_out(=1) · rank … · F
365
+ y_i = lax.dynamic_index_in_dim(y_full, i, axis=0, keepdims=False)
366
+ return y_i
367
+
368
+ return diff(repl)(yi) # dim × rank … × F
369
+ elif var == "v":
370
+ if _v is None:
371
+ raise ValueError("same-particle v-derivative requires v")
372
+ yi = lax.dynamic_index_in_dim(_v, i, axis=particles_axis, keepdims=False) # (dim,)
373
+
374
+ def repl(y_local):
375
+ v_mod = lax.dynamic_update_index_in_dim(_v, y_local, i, axis=particles_axis)
376
+ y_full = g(_x, v=v_mod, params=params, **g_kwargs)
377
+ y_i = lax.dynamic_index_in_dim(y_full, i, axis=0, keepdims=False)
378
+ return y_i
379
+
380
+ return diff(repl)(yi) # dim × rank … × F
381
+ else:
382
+ raise ValueError("var must be 'x' or 'v' for same-particle")
383
+
384
+ # use int32 indices for JAX gather niceness
385
+ return jax.vmap(inner)(jnp.arange(P, dtype=jnp.int32)) # P × dim × rank … × F
386
+
387
+ # vmap over ALL batch axes (k nested vmaps)
388
+ def vmap_n(f, n, has_v):
389
+ gfun = f
390
+ for _ in range(n):
391
+ gfun = jax.vmap(gfun, in_axes=(0, 0 if has_v else None))
392
+ return gfun
393
+
394
+ vmapped = vmap_n(per_batch, len(batch_shape), v is not None)
395
+ return vmapped(x, v)
396
+
397
+
398
+ def _move_deriv_axis(
399
+ arr,
400
+ *,
401
+ rank: int,
402
+ pdepth_old: int,
403
+ particles_input: bool,
404
+ same_particle: bool,
405
+ ):
406
+ """
407
+ Re-arrange the raw Jacobian from JAX to the contract:
408
+
409
+ output (full cross, only if particles_input=True):
410
+ batch · [P_in]^1 · [P_out]^{pdepth_old} · dim · rank · F
411
+ and the node's new pdepth is (pdepth_old + 1)
412
+
413
+ output (same-particle):
414
+ batch · [P_out]^{pdepth_old} · dim · rank · F
415
+ (no new particle axis; pdepth unchanged)
416
+
417
+ Parameters
418
+ ----------
419
+ arr : jnp.ndarray # output of jacfwd / jacrev
420
+ rank : int # number of spatial rank axes in the child
421
+ pdepth_old : int # child's output particle-depth
422
+ particles_input : bool # whether inputs have a P axis
423
+ same_particle : bool # if True, produce the same-particle layout
424
+ """
425
+ nd = arr.ndim
426
+ has_pin = particles_input and (not same_particle)
427
+
428
+ # Raw jacfwd layout assumption:
429
+ # B · P_out^{pdepth_old} · rank · F · [P_in?] · dim
430
+ n_tail = rank + 1 + (1 if has_pin else 0) + 1 # rank + F + P_in? + dim
431
+ B = nd - (pdepth_old + n_tail)
432
+
433
+ idx_batch = list(range(0, B))
434
+ idx_pout = list(range(B, B + pdepth_old))
435
+ idx_rank = list(range(B + pdepth_old, B + pdepth_old + rank))
436
+ idx_feat = [B + pdepth_old + rank]
437
+ idx_pin = [B + pdepth_old + rank + 1] if has_pin else []
438
+ idx_dim = [nd - 1]
439
+
440
+ if has_pin:
441
+ # Target: B · P_in · P_out^{pdepth_old} · dim · rank · F
442
+ order = idx_batch + idx_pin + idx_pout + idx_dim + idx_rank + idx_feat
443
+ else:
444
+ # Target: B · P_out^{pdepth_old} · dim · rank · F
445
+ order = idx_batch + idx_pout + idx_dim + idx_rank + idx_feat
446
+
447
+ return jnp.transpose(arr, order)
@@ -0,0 +1,120 @@
1
+ # SFI/statefunc/nodes/ops/einsum.py
2
+ """Einsum contraction operator node."""
3
+
4
+ import itertools
5
+
6
+ import equinox as eqx
7
+ import jax.numpy as jnp
8
+
9
+ from ..base import BaseNode, BaseOpNode
10
+
11
+ # helper letters for generating spatial tokens (implementation detail)
12
+ _LETTERS = "mnopqrstuvwxyz" # 14 letters ⇒ rank ≤ 14 each is fine
13
+
14
+
15
+ def _rank_letters(rank: int, offset: int = 0) -> str:
16
+ """Return `rank` distinct lower-case letters starting at `offset`."""
17
+ if rank + offset > len(_LETTERS):
18
+ raise ValueError("Rank exceeds available einsum letters")
19
+ return _LETTERS[offset : offset + rank]
20
+
21
+
22
+ # ──────────────────────────────────────────────────────────────────────────────
23
+ # EinsumNode – generic contraction over spatial (rank) axes
24
+ # ──────────────────────────────────────────────────────────────────────────────
25
+ class EinsumNode(BaseOpNode):
26
+ """
27
+ Generic einsum contraction of *N* sub-bases along their **rank axes**.
28
+
29
+ Notes
30
+ -----
31
+ * Specs are spatial-only (no ellipses); batch is implicit.
32
+ * Validation happens in `contract.merge_contract`. This node only implements
33
+ the execution and internal axis wiring.
34
+
35
+ Parameters
36
+ ----------
37
+ *children : BaseNode
38
+ spec : str
39
+ Einstein notation **with LOWERCASE letters only** - one comma-separated
40
+ operand per child, followed by '->' and the RHS letters. **No ellipses**:
41
+ batch/particle axes are implicit and auto-injected.
42
+
43
+ Examples (spatial-only specs):
44
+ "m,n->mn" vector x vector outer
45
+ "m,m->" dot / trace
46
+ ",n->n" scalar x vector
47
+ "mn,n->m" tensor x vector
48
+ "m,n,p->mnp" 3-way outer product
49
+ "mn,mp->np" contraction on first index
50
+ """
51
+
52
+ CONTRACT_MODE = "einsum"
53
+ _BASIS_LETTERS_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
54
+
55
+ spec: str = eqx.field(static=True)
56
+ _einsum: str = eqx.field(static=True, repr=False)
57
+
58
+ # ------------------------------------------------------------------
59
+ def __init__(self, *children: BaseNode, spec: str):
60
+ # spec is assumed validated by contract
61
+ object.__setattr__(self, "spec", spec.replace(" ", ""))
62
+ super().__init__(*children)
63
+
64
+ lhs, rhs = self.spec.split("->")
65
+ lhs_ops = lhs.split(",")
66
+
67
+ # Build internal einsum by:
68
+ # - prefixing '...' on every operand and RHS for batch
69
+ # - appending unique per-child feature letters to operands
70
+ # - appending the concatenated feature letters on RHS
71
+ if len(children) > len(self._BASIS_LETTERS_UPPER):
72
+ raise ValueError("Too many children for auto basis letters (limit 26)")
73
+
74
+ embl_ops = [f"...{op}{self._BASIS_LETTERS_UPPER[i]}" for i, op in enumerate(lhs_ops)]
75
+ rhs_with_batch = f"...{rhs}{self._BASIS_LETTERS_UPPER[: len(children)]}"
76
+ einsum_full = ",".join(embl_ops) + "->" + rhs_with_batch
77
+ object.__setattr__(self, "_einsum", einsum_full)
78
+
79
+ def with_children(self, new_children):
80
+ return EinsumNode(*new_children, spec=self.spec)
81
+
82
+ # ------------------------------------------------------------------
83
+ def _op(self, outs, *, params):
84
+ y = jnp.einsum(self._einsum, *outs)
85
+ # collapse the per-child feature/basis axes into one
86
+ if len(self.children) > 1:
87
+ y = y.reshape(y.shape[: -len(self.children)] + (-1,))
88
+ return y
89
+
90
+ # ------------------------------------------------------------------
91
+ # flatten = Cartesian product of leaf callables --------------------
92
+ def flatten(self):
93
+ funcs_lists, label_lists, desc_lists = zip(*(ch.flatten() for ch in self.children))
94
+
95
+ funcs, labels, descs = [], [], []
96
+ for idx_combo in itertools.product(*[range(len(lst)) for lst in funcs_lists]):
97
+
98
+ def _prod(X, *, v=None, mask=None, extras=None, params=None, _idx=idx_combo):
99
+ parts = [
100
+ funcs_lists[k][_idx[k]](X, v=v, mask=mask, extras=extras, params=params)[..., None]
101
+ for k in range(len(_idx))
102
+ ]
103
+ res = jnp.einsum(self._einsum, *parts)
104
+ return res.squeeze(tuple(range(-len(_idx), 0))) # drop len-1 axes
105
+
106
+ label = "·".join(
107
+ (f"({label_lists[k][idx]})" if any(c in label_lists[k][idx] for c in "+-·") else label_lists[k][idx])
108
+ for k, idx in enumerate(idx_combo)
109
+ )
110
+ desc = tuple(desc_lists[k][idx] for k, idx in enumerate(idx_combo))
111
+
112
+ funcs.append(_prod)
113
+ labels.append(label)
114
+ descs.append(desc)
115
+
116
+ return funcs, labels, descs
117
+
118
+ # ------------------------------------------------------------------
119
+ def _tree_id(self):
120
+ return hash((self.spec, tuple(ch._tree_id() for ch in self.children)))