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,1243 @@
1
+ """High-level immutable façade over backend node trees.
2
+
3
+ This module exposes three public classes:
4
+
5
+ - ``Basis`` -- deterministic dictionary of features (no parameters),
6
+ - ``PSF`` -- parametric state-function family ``F(x; theta)``,
7
+ - ``SF`` -- bound state function with ``theta`` fixed.
8
+
9
+ They all share the **StateExpr** algebra (broadcasting, linear ops, feature
10
+ product/concatenation rules, differentiation builders).
11
+
12
+ ---------------------------------------------------------------------------
13
+ Shape conventions (runtime evaluation)
14
+ ---------------------------------------------------------------------------
15
+ Users call `Basis/PSF/SF` on single inputs or batched arrays; the library
16
+ handles batching and vectorization internally. Let `x` be the runtime input:
17
+
18
+ - If `particles_input=False`: `x.shape == batch · dim`
19
+ - If `particles_input=True`: `x.shape == batch · P · dim`
20
+
21
+ Single inputs have batch=(,). Outputs always end with the **feature axis**
22
+ (length `n_features`):
23
+
24
+ y.shape == batch · [P]^pdepth · (dim)^rank · n_features
25
+
26
+ `pdepth` is strict: outputs have *exactly* `pdepth` particle axes.
27
+ If `particles_input=False`, then `pdepth` must be 0 (no particle axes can be
28
+ created without a particle input axis). Any particle axis is simply treated as
29
+ batch in that case.
30
+
31
+ ---------------------------------------------------------------------------
32
+ User function contract (single–sample)
33
+ ---------------------------------------------------------------------------
34
+ Factories (`make_basis`, `make_psf`) accept *single-sample* callables. Your
35
+ function never sees batch axes; it receives:
36
+
37
+ - `x`: `(dim,)` or `(P, dim)` if `particles_input=True`
38
+ - Optional keywords: any subset of `{v, mask, extras, params}` that you declare
39
+ in the signature. We only pass those you declare.
40
+
41
+ Return shape (no batch axis):
42
+
43
+ (P,)*pdepth + (dim,)*rank + (n_features?,)
44
+
45
+ If `n_features==1` you may omit the last axis; we insert a singleton feature
46
+ axis automatically.
47
+
48
+ ``mask`` semantics: must broadcast to the prefix of ``x`` **including** the particle
49
+ axis (i.e. ``batch * P`` when present). Numeric or boolean masks are accepted.
50
+
51
+ ``extras`` semantics: Extras are pass-through data for user functions. The expression
52
+ enforces **presence only**:
53
+
54
+ - If a leaf declares ``extras_keys=("a","b",...)``, those keys must be present
55
+ in the ``extras`` mapping at call time.
56
+ - If a leaf declares ``extras`` but no keys, only the *presence* of a mapping
57
+ is required; no keys are enforced.
58
+
59
+ No shape/broadcasting of extras is performed by the expression. Three kinds
60
+ exist by *declaration* (never by shape):
61
+
62
+ - global -- default; forwarded unchanged to user functions;
63
+ - particle -- declared only by interaction leaves; gathered by the
64
+ **dispatcher** per edge and then forwarded downstream as globals;
65
+ - structural -- rule/dispatcher-owned (e.g., CSR arrays); never forwarded.
66
+
67
+ ---------------------------------------------------------------------------
68
+ JAX use & autodiff
69
+ ---------------------------------------------------------------------------
70
+ All computations are JAX-friendly. Write user functions with ``jax.numpy``.
71
+ Expressions compose under ``jit``/``vmap``, and support automatic
72
+ differentiation:
73
+
74
+ - ``.d_x()`` adds a spatial derivative axis to the rank block, and adds a
75
+ particle axis when ``particles_input=True`` (coming from JAX; we only
76
+ permute/reshape), **unless pdepth=1 and same_particle is True**.
77
+ - ``.d_v()`` similarly (if ``needs_v=True``).
78
+ - ``.d_theta()`` (PSF only) returns a Jacobian with the feature axis fused
79
+ with parameters.
80
+
81
+ Internally, derivative axis ordering is canonicalized by permutation only;
82
+ we **never create particle axes** ourselves—if `particles_input=True`, extra
83
+ particle axes in a Jacobian come from JAX itself.
84
+
85
+ """
86
+
87
+ import string
88
+ from typing import Any, Callable, Sequence
89
+
90
+ import equinox as eqx
91
+ import jax.numpy as jnp
92
+
93
+ from .core.runtime import _JIT_ENABLED, _eager_eval, _jitted_eval
94
+ from .memhint import SampleMeta
95
+ from .nodes import (
96
+ BaseNode,
97
+ ConcatNode,
98
+ DenseNode,
99
+ DerivativeNode,
100
+ EinsumNode,
101
+ MapNNode,
102
+ Rank,
103
+ ReshapeRankNode,
104
+ SimpleLeaf,
105
+ SliceFeaturesNode,
106
+ )
107
+
108
+ # 26 unique letters for einsum spatial indices.
109
+ _EINSUM_LETTERS: str = string.ascii_lowercase
110
+
111
+
112
+ # ---------------------------------------------------------------------
113
+ # StateExpr – public façade class
114
+ # ---------------------------------------------------------------------
115
+ class StateExpr(eqx.Module):
116
+ """Immutable *state expression* backed by a static node tree.
117
+
118
+ Think: a read-only NumPy array whose **last axis is features**. Every algebraic
119
+ operation returns a **new** ``StateExpr`` (functional style), and static contract
120
+ metadata (``rank``, ``dim``, ``pdepth``, ``n_features``, ``needs_v``, ``particles_input``)
121
+ is validated at graph-construction time.
122
+
123
+ Runtime shapes
124
+ --------------
125
+ Inputs are **batched** at call time; the library handles batching.
126
+
127
+ * If ``particles_input=False``: ``x.shape == batch · dim``
128
+ * If ``particles_input=True``: ``x.shape == batch · P · dim``
129
+
130
+ Outputs always end with the **feature axis** (length ``n_features``)::
131
+
132
+ y.shape == batch · [P]^pdepth · (dim)^rank · n_features
133
+
134
+ If ``particles_input=False``, ``pdepth`` must be ``0``.
135
+
136
+ User function contract (single-sample)
137
+ ---------------------------------------
138
+ Factories accept *single-sample* callables; user code never sees batch axes.
139
+ Your function gets ``x`` of shape ``(dim,)`` (or ``(P, dim)`` if ``particles_input``) and
140
+ any subset of keyword-only args it declares: ``{v, mask, extras, params}``.
141
+ Return shape (no batch axis): ``(P,)*pdepth + (dim,)*rank + (n_features?,)``.
142
+ If ``n_features==1``, you may omit the last axis; a singleton is inserted.
143
+
144
+ * ``mask`` must broadcast to the prefix of ``x`` **including** the particle axis.
145
+ * ``extras`` presence: if a leaf declares ``extras`` with no explicit keys, presence
146
+ is required (any dict). If ``extras_keys`` is given, those keys are required.
147
+ Values may be scalars or arrays that broadcast over **batch only**.
148
+
149
+ Operators
150
+ ---------
151
+ **Element-wise arithmetic**
152
+
153
+ * ``+ - * /`` -- element-wise on spatial axes; **features must match**.
154
+ Scalars and 1-D vectors (length ``n_features``) broadcast along features.
155
+ * Unary: ``+expr``, ``-expr``.
156
+ * NumPy/JAX ufuncs: ``sin``, ``exp``, etc. forward to element-wise maps with the
157
+ same broadcasting rules; binary ufuncs accept ``StateExpr ∘ const`` and
158
+ ``StateExpr ∘ StateExpr`` (features must match for the latter).
159
+
160
+ **Linear-algebra-like**
161
+
162
+ * ``@`` (matmul): true matrix multiplication on spatial axes,
163
+ ``(..., m, k) @ (..., k, n) -> (..., m, n)``; **features form a Cartesian
164
+ product** between operands (result features = ``F_left × F_right``).
165
+ * ``.einsum(*others, spec=...)``: generic spatial contraction; **features take a
166
+ Cartesian product across all operands** (no implicit feature reduction).
167
+ * ``.dot(other)``: Spatial inner product between last rank axis of self and first
168
+ rank axis of other. Cartesian product over features.
169
+ * ``.sqrtm()``: matrix square root per-feature; requires ``rank==2``.
170
+
171
+ **Feature-axis manipulation**
172
+
173
+ * ``expr1 & expr2`` / ``StateExpr.stack([...])``: **concatenate features**. Static
174
+ spatial contracts must match; labels (if present) are concatenated.
175
+ * ``expr[idx]``: feature selection (slice/list/bool/int). Spatial contract is
176
+ unchanged; labels are subset when available.
177
+ * ``.elementwisemap(func, label_fn=None)``: apply a scalar-to-scalar map to each
178
+ feature independently (spatial axes untouched). Optional ``label_fn`` updates
179
+ labels for ``Basis``.
180
+
181
+ Differentiation builders
182
+ ------------------------
183
+ All builders return **new expressions** (no evaluation).
184
+
185
+ * ``.d_x(same_particle=False, mode='auto')`` -- spatial Jacobian dF/dx.
186
+
187
+ * Adds **one derivative-dim axis** immediately **before** the rank block.
188
+ * If ``particles_input=True``:
189
+
190
+ - when ``same_particle=False`` (default), builds the full cross-particle
191
+ Jacobian df_i/dx_j and a second particle axis appears (from JAX);
192
+ - when ``same_particle=True`` and ``pdepth=1``, computes the same-particle
193
+ Jacobian df_i/dx_i without adding a new particle axis; otherwise an
194
+ error is raised.
195
+
196
+ * ``.d_v(same_particle=False, mode='auto')`` -- velocity Jacobian dF/dv
197
+ (requires ``needs_v=True``). Same axis rules as ``.d_x()``.
198
+ * ``.d_theta(mode='auto')`` -- Jacobian w.r.t. parameters (PSF only); the final axis becomes
199
+ ``features × n_params_total``. Batch/particle/rank prefixes are preserved.
200
+
201
+ Type mixing and broadcasting
202
+ ----------------------------
203
+ * Scalars and ndarrays are treated as **purely spatial constants**: they must
204
+ be broadcastable to the spatial rank block ``(dim,)*rank`` and are then
205
+ broadcast uniformly across the feature axis. Bare arrays cannot target the
206
+ feature axis directly.
207
+ * Combining two ``StateExpr`` requires matching static contracts for ``rank``,
208
+ ``dim``, and ``pdepth``.
209
+ * For element-wise ops such as ``+``, ``-`` and most binary ufuncs, ``n_features``
210
+ must match (per-feature operations).
211
+ * For multiplicative ops (``*``, ``/`` and their ufuncs), as well as ``@`` and
212
+ ``.einsum``, feature axes take a **Cartesian product** between operands:
213
+ ``F_out = F_left × F_right``. When both operands have more than one feature a
214
+ one-off warning is emitted, as this can grow ``n_features`` quickly.
215
+ * ``needs_v`` is **OR-combined**: if any operand needs ``v``, the result does.
216
+ * ``particles_input`` is **OR-combined**: if any operand uses particle
217
+ input, the result does too. An operand without particle input is
218
+ broadcast uniformly across the particle axis.
219
+
220
+ Array interop
221
+ -------------
222
+ Plain JAX/NumPy arrays are accepted in binary ops with StateExpr.
223
+ They are treated as **spatial constants** with a single feature.
224
+ Arrays broadcast over spatial axes and batch/particles only.
225
+ Features never arise from arrays and are never contracted unless
226
+ requested by explicit feature-aware APIs.
227
+
228
+ Supported operations with arrays:
229
+
230
+ - Elementwise: ``+``, ``-``, ``*``, ``/``, ``**``, and their reflected forms.
231
+ - Linear algebra: ``A @ B``, ``B @ A``.
232
+ - Tensor algebra: ``einsum(eq, ...)``, ``dot(...)``, ``tensordot(...)``.
233
+
234
+ JAX compatibility and autodiff
235
+ ------------------------------
236
+ Write user functions with ``jax.numpy as jnp``. Expressions compose under ``jit``
237
+ / ``vmap``, and support automatic differentiation:
238
+
239
+ * ``.d_x()``, ``.d_v()`` add a derivative-dim axis (and a particle axis when
240
+ ``particles_input=True``).
241
+ * ``.d_theta()`` fuses ``features × n_params`` on the last axis.
242
+ Derivative axis ordering is canonicalized by permutation only.
243
+
244
+ """
245
+
246
+ # tell NumPy we take precedence when mixing with ndarrays
247
+ __array_priority__ = 1_000_000
248
+
249
+ root: "BaseNode"
250
+
251
+ def __init__(self, root):
252
+ object.__setattr__(self, "root", root)
253
+ # Tree-wide static sanity: catches invalid nodes even if built manually
254
+ for _n in _walk_nodes(root):
255
+ _static_contract_sanity_node(_n)
256
+
257
+ def _validate_extras_presence(self, extras):
258
+ _validate_extras_presence(self.required_extras, extras)
259
+
260
+ # -----------------------------------------------------------------
261
+ # Static-contract passthrough
262
+ # -----------------------------------------------------------------
263
+ @property
264
+ def rank(self):
265
+ return self.root.rank
266
+
267
+ @property
268
+ def dim(self):
269
+ return self.root.dim
270
+
271
+ @property
272
+ def pdepth(self):
273
+ return self.root.pdepth
274
+
275
+ @property
276
+ def n_features(self):
277
+ return self.root.n_features
278
+
279
+ @property
280
+ def needs_v(self):
281
+ return self.root.needs_v
282
+
283
+ @property
284
+ def particles_input(self):
285
+ return self.root.particles_input
286
+
287
+ @property
288
+ def sdims(self):
289
+ return self.root.sdims
290
+
291
+ # -------- Memory hints surfaced at the expression level --------
292
+ def memory_hint(
293
+ self,
294
+ *,
295
+ dtype=None,
296
+ particle_size: int | None = None,
297
+ sample: SampleMeta | None = None,
298
+ mode: str = "forward",
299
+ ):
300
+ """
301
+ Conservative per-sample memory footprint for the WHOLE expression tree.
302
+ Delegates to the root node, which sums children + own output along the way.
303
+ """
304
+ return self.root.memory_hint(dtype=dtype, particle_size=particle_size, sample=sample, mode=mode)
305
+
306
+ def estimate_bytes_per_sample(
307
+ self,
308
+ *,
309
+ dtype=None,
310
+ particle_size: int | None = None,
311
+ sample: SampleMeta | None = None,
312
+ mode: str = "forward",
313
+ ) -> int:
314
+ """Small convenience wrapper returning only the transient bytes/sample."""
315
+ return self.root.estimate_bytes_per_sample(dtype=dtype, particle_size=particle_size, sample=sample, mode=mode)
316
+
317
+ # =================================================================
318
+ # INTERNAL HELPERS
319
+ # =================================================================
320
+ def _with_node(self, new_root: BaseNode): # pragma: no cover
321
+ """Dispatch to the concrete subclass constructor."""
322
+ return type(self)(new_root) # Basis/PSF/SF override if needed
323
+
324
+ def specialize(self, *, dataset: int) -> "StateExpr":
325
+ """Collapse a pooled model to its single-condition specialization.
326
+
327
+ Returns a new expression in which every ``dataset_index``-reading
328
+ primitive (e.g. :func:`~SFI.bases.per_dataset_scalar`,
329
+ :func:`~SFI.bases.dataset_indicator`) is folded at condition
330
+ ``dataset``: per-condition parameter arrays collapse to that condition's
331
+ slice and the reserved ``dataset_index`` extra drops out of
332
+ :attr:`required_extras`. The pooled-ness is an inference-time concern;
333
+ once a condition is chosen the model stands alone (no dataset concept).
334
+
335
+ On a bound :class:`~SFI.statefunc.SF` the stored parameter values are
336
+ projected to match the shrunken template; on an unbound ``PSF`` the
337
+ template's per-condition specs become scalars.
338
+ """
339
+ new_root = _specialize_node(self.root, int(dataset))
340
+ return self._with_node(new_root)
341
+
342
+ def _caller(self, x, v, mask, extras, params):
343
+ if _JIT_ENABLED:
344
+ y = _jitted_eval(self.root, x, v, mask, extras, params)
345
+ else:
346
+ y = _eager_eval(self.root, x, v, mask, extras, params)
347
+ return y
348
+
349
+ def _binary(self, other, fn: Callable[[Any, Any], Any], *, swap=False, label_fn=None):
350
+ """Generic binary op; *other* may be StateExpr or scalar/ndarray.
351
+
352
+ Broadcasting policy:
353
+ - If `other` is StateExpr → element-wise MapN over both nodes (features must match).
354
+ - If `other` is array-like (incl. scalars):
355
+ - Treat it as **purely spatial**: it must be broadcastable to the rank block
356
+ `(dim,)*rank`. No feature axis is allowed/assumed.
357
+ - We then broadcast it uniformly across the **feature axis**.
358
+ """
359
+ if isinstance(other, StateExpr):
360
+ left, right = (other.root, self.root) if swap else (self.root, other.root)
361
+ node = MapNNode(lambda a, b, _fn=fn: _fn(a, b), left, right, label_fn=label_fn)
362
+ return self._with_node(node)
363
+
364
+ # constant path – strictly spatial
365
+ const = jnp.asarray(other)
366
+ if const.ndim == 0:
367
+ const_spatial = const[..., None] # broadcast across features uniformly
368
+ else:
369
+ rank = int(self.rank)
370
+ dim = int(self.dim)
371
+ if not _is_broadcastable_to_rank(tuple(const.shape), rank=rank, dim=dim):
372
+ raise TypeError(
373
+ "Constant has incompatible shape. Allowed: scalar or a tensor "
374
+ f"broadcastable to the spatial rank block (dim={dim}, rank={rank}). "
375
+ f"Got shape={tuple(const.shape)}."
376
+ )
377
+ const_spatial = const[..., None] # add feature singleton
378
+
379
+ if swap:
380
+ node = MapNNode(lambda a, c=const_spatial, _fn=fn: _fn(c, a), self.root)
381
+ else:
382
+ node = MapNNode(lambda a, c=const_spatial, _fn=fn: _fn(a, c), self.root)
383
+ return self._with_node(node)
384
+
385
+ def _scalar(self, fn: Callable[[Any], Any]):
386
+ node = MapNNode(lambda a, _fn=fn: _fn(a), self.root)
387
+ return self._with_node(node)
388
+
389
+ # -----------------------------------------------------------------
390
+ def __repr__(self):
391
+ # Compact summary showing the static contract; useful in notebooks/tracebacks.
392
+ return (
393
+ f"{self.__class__.__name__}(rank={self.rank}, dim={self.dim}, "
394
+ f"pdepth={self.pdepth}, n_features={self.n_features}, "
395
+ f"needs_v={self.needs_v}, particles_input={self.particles_input})"
396
+ )
397
+
398
+ # -----------------------------------------------------------------
399
+ # Gradient builders
400
+ # -----------------------------------------------------------------
401
+ def d_x(self, *, same_particle: bool = False, mode: str = "auto"):
402
+ """Build an expression for the spatial Jacobian dF/dx.
403
+
404
+ Axis effects
405
+ ------------
406
+ - Adds **one derivative-dim** immediately **before** the rank block.
407
+ - If ``particles_input=True``:
408
+
409
+ * when ``same_particle=True``:
410
+ if pdepth=1, compute df_i/dx_i (no extra P axis); the particle
411
+ dimension behaves like a broadcasted index.
412
+ Otherwise, raises an error.
413
+ * when ``same_particle=False`` (default): compute the full
414
+ cross-particle Jacobian df_i/dx_j; **an extra particle axis
415
+ appears** (from JAX). We never create P axes ourselves; we
416
+ only permute to canonical order.
417
+
418
+ Parameters
419
+ ----------
420
+ same_particle : bool
421
+ See axis effects above.
422
+ mode : {'auto', ...}
423
+ Backend differentiation mode; 'auto' selects a sane default.
424
+
425
+ Returns
426
+ -------
427
+ StateExpr
428
+ A new expression representing the Jacobian.
429
+
430
+ Notes
431
+ -----
432
+ This method triggers no evaluation; it returns a *new graph*.
433
+ """
434
+ node = DerivativeNode(self.root, var="x", same_particle=same_particle, mode=mode)
435
+ return self._with_node(node)
436
+
437
+ def d_v(self, *, same_particle: bool = False, mode: str = "auto"):
438
+ """Build an expression for the velocity Jacobian ∂F/∂v.
439
+
440
+ Same rules as `.d_x()`. Requires `needs_v=True` on the underlying expression.
441
+ """
442
+ if not self.needs_v:
443
+ raise AttributeError("Underlying expression does not depend on v")
444
+ node = DerivativeNode(self.root, var="v", same_particle=same_particle, mode=mode)
445
+ return self._with_node(node)
446
+
447
+ # =================================================================
448
+ # BASIC ARITHMETIC & ELEMENT-WISE OPS
449
+ # =================================================================
450
+ def __add__(self, o):
451
+ return self._binary(o, lambda a, b: a + b, label_fn=lambda la, lb: f"{la}+{lb}")
452
+
453
+ __radd__ = __add__
454
+
455
+ def __sub__(self, o):
456
+ return self._binary(
457
+ o,
458
+ lambda a, b: a - b,
459
+ label_fn=lambda la, lb: f"{la}-({lb})" if any(c in lb for c in "+-") else f"{la}-{lb}",
460
+ )
461
+
462
+ def __rsub__(self, o):
463
+ return self._binary(
464
+ o,
465
+ lambda a, b: a - b,
466
+ swap=True,
467
+ label_fn=lambda la, lb: f"{la}-({lb})" if any(c in lb for c in "+-") else f"{la}-{lb}",
468
+ )
469
+
470
+ def __mul__(self, o):
471
+ """
472
+ Multiplication.
473
+
474
+ - If `o` is a StateExpr:
475
+ * same-rank: spatially elementwise, features combine as a Cartesian
476
+ product F_out = F_self × F_other (via `einsum`);
477
+ * scalar × any-rank (either side): spatially elementwise scaling,
478
+ still with feature Cartesian product.
479
+ - If `o` is array-like: fall back to `_binary`, i.e. aligned features
480
+ and spatial broadcasting.
481
+ """
482
+ if isinstance(o, StateExpr):
483
+ r_self = int(self.rank)
484
+ r_other = int(o.rank)
485
+
486
+ def _check_rank(r: int) -> None:
487
+ if r > len(_EINSUM_LETTERS):
488
+ raise ValueError(
489
+ f"rank={r} too large for implicit '*' spec; use .einsum() "
490
+ "with an explicit spatial contraction string."
491
+ )
492
+
493
+ # Same-rank case
494
+ if r_self == r_other:
495
+ _check_rank(r_self)
496
+ if self.n_features > 1 and o.n_features > 1:
497
+ _warn_cartesian_multi_feature("*")
498
+ tok = _EINSUM_LETTERS[:r_self]
499
+ # spatially elementwise, feature Cartesian product
500
+ return type(self).einsum(f"{tok},{tok}->{tok}", self, o)
501
+
502
+ # Allow scalar * any-rank when either side is scalar (rank 0)
503
+ if r_self == 0 and r_other > 0:
504
+ _check_rank(r_other)
505
+ if self.n_features > 1 and o.n_features > 1:
506
+ _warn_cartesian_multi_feature("*")
507
+ tok = _EINSUM_LETTERS[:r_other]
508
+ # scalar (no spatial letters) times higher-rank
509
+ return type(self).einsum(f",{tok}->{tok}", self, o)
510
+
511
+ if r_other == 0 and r_self > 0:
512
+ _check_rank(r_self)
513
+ if self.n_features > 1 and o.n_features > 1:
514
+ _warn_cartesian_multi_feature("*")
515
+ tok = _EINSUM_LETTERS[:r_self]
516
+ # higher-rank times scalar (no spatial letters on rhs)
517
+ return type(self).einsum(f"{tok},->{tok}", self, o)
518
+
519
+ # Remaining mismatched-rank cases are ambiguous
520
+ raise TypeError(
521
+ "Multiplication between StateExprs requires matching spatial rank "
522
+ "or one scalar rank; use .einsum() for general contractions."
523
+ )
524
+
525
+ # scalar / array path: old behaviour, aligned features + spatial broadcasting
526
+ return self._binary(o, lambda a, b: a * b)
527
+
528
+ __rmul__ = __mul__
529
+
530
+ def __truediv__(self, o):
531
+ """
532
+ Division.
533
+
534
+ - If `o` is a StateExpr: divide via multiplication by its inverse,
535
+ so feature axes combine as in `*`.
536
+ - If `o` is array-like: aligned features via `_binary`.
537
+ """
538
+ if isinstance(o, StateExpr):
539
+ inv = o._scalar(lambda x: 1 / x)
540
+ return self * inv
541
+ return self._binary(o, lambda a, b: a / b)
542
+
543
+ def __rtruediv__(self, o):
544
+ if isinstance(o, StateExpr):
545
+ inv_self = self._scalar(lambda x: 1 / x)
546
+ return o * inv_self
547
+ # scalar / array on the left: swap arguments in `_binary`
548
+ return self._binary(o, lambda a, b: a / b, swap=True)
549
+
550
+ def __neg__(self):
551
+ return self._scalar(lambda a: -a)
552
+
553
+ def __pos__(self):
554
+ return self
555
+
556
+ def __pow__(self, exponent):
557
+ """Element-wise power: ``expr ** n``.
558
+
559
+ *exponent* must be a scalar or array-like constant (not a StateExpr).
560
+ For StateExpr exponents, use ``jnp.power(base, exp)`` via
561
+ ``__array_ufunc__``.
562
+ """
563
+ if isinstance(exponent, StateExpr):
564
+ return self._binary(exponent, lambda a, b: a**b)
565
+ e = jnp.asarray(exponent)
566
+ return self._scalar(lambda a, _e=e: a**_e)
567
+
568
+ def __rpow__(self, base):
569
+ """Element-wise power with constant base: ``n ** expr``."""
570
+ b = jnp.asarray(base)
571
+ return self._scalar(lambda a, _b=b: _b**a)
572
+
573
+ # =================================================================
574
+ # LINEAR-ALGEBRA-LIKE
575
+ # =================================================================
576
+ @classmethod
577
+ def einsum(cls, spec: str, *operands):
578
+ """
579
+ General contraction on **spatial** axes (like `jnp.einsum`).
580
+
581
+ Important
582
+ ---------
583
+ * Use only lowercase letters.
584
+ * `spec` refers **only to spatial axes** (not the feature axis).
585
+ * **Features take a Cartesian product** across operands (no implicit
586
+ feature reduction or alignment). If you need feature concatenation,
587
+ use `&`/`stack`. For per-feature ops, use element-wise maps or binary
588
+ ops where features must match.
589
+
590
+ Arrays in `operands` are accepted and coerced to spatial-constant
591
+ expressions with a **single feature**. Only spatial letters in
592
+ `spec` are interpreted. If no StateExpr is present, a TypeError
593
+ is raised because `dim` cannot be inferred.
594
+
595
+ Examples
596
+ --------
597
+ Vector inner product (per-feature), two rank-1 inputs:
598
+ >>> # a, b: i × F
599
+ >>> c = StateExpr.einsum("i,i->", a, b) # result: × F
600
+
601
+ Matrix–vector product (per-feature), rank-2 with rank-1:
602
+ >>> # M: ij × F1, v: j × F2 → i × (F1×F2)
603
+ >>> y = StateExpr.einsum("ij,j->i", M, v)
604
+
605
+ Outer product (per-feature Cartesian product):
606
+ >>> # u: i × F1, v: j × F2 → ij × (F1×F2)
607
+ >>> O = StateExpr.einsum("i,j->ij", u, v)
608
+
609
+
610
+ Parameters
611
+ ----------
612
+ spec : str
613
+ An einsum string over spatial indices, e.g. "ij,j->i".
614
+ operands : mix[StateExpr, array-like]
615
+ Any mix of StateExpr and arrays.
616
+ """
617
+ if "..." in spec:
618
+ raise ValueError("Ellipsis '...' is not supported in einsum specs.")
619
+
620
+ ref = next((op for op in operands if isinstance(op, StateExpr)), None)
621
+ if ref is None:
622
+ raise TypeError("einsum needs at least one StateExpr to infer spatial dim.")
623
+
624
+ lhs, sep, rhs = spec.partition("->")
625
+ terms = [t.strip() for t in lhs.split(",")]
626
+ if len(terms) != len(operands):
627
+ raise ValueError("einsum: number of terms and operands mismatch.")
628
+
629
+ # Coerce arrays → spatial-constant exprs
630
+ coerced = []
631
+ for term, op in zip(terms, operands):
632
+ if "..." in term:
633
+ raise ValueError("Ellipsis is not supported in operand terms.")
634
+ if isinstance(op, StateExpr):
635
+ coerced.append(op)
636
+ else:
637
+ rank = len(term) if term else 0
638
+ coerced.append(ref._const_expr_from_array(op, rank_override=rank))
639
+
640
+ # Canonicalize RHS: put letters owned by a StateExpr **first** (stable), then others.
641
+ # This matches tests that expect B.einsum("j,i->ji", A, B) == np.einsum("i,j->ij", x, A).
642
+ if sep:
643
+ rhs_letters = [c for c in rhs if c.isalpha()]
644
+ se_idxs = {i for i, op in enumerate(operands) if isinstance(op, StateExpr)}
645
+
646
+ def owner(letter: str) -> int | None:
647
+ for k, term in enumerate(terms):
648
+ if letter in term:
649
+ return k
650
+ return None
651
+
652
+ rhs_se = [c for c in rhs_letters if owner(c) in se_idxs]
653
+ rhs_cons = [c for c in rhs_letters if owner(c) not in se_idxs]
654
+ rhs_new = "".join(rhs_se + rhs_cons)
655
+ if rhs_new != rhs:
656
+ spec = f"{lhs}->{rhs_new}"
657
+
658
+ return cls._einsum_impl(spec, *coerced)
659
+
660
+ @classmethod
661
+ def _einsum_impl(cls, spec: str, *exprs: "StateExpr") -> "StateExpr":
662
+ """
663
+ Internal: build an EinsumNode from operands and the spatial einsum string.
664
+ Features are handled in the node (Cartesian-product rule).
665
+ """
666
+ if not exprs:
667
+ raise ValueError("einsum needs at least one operand")
668
+ node = EinsumNode(*(e.root for e in exprs), spec=spec)
669
+ return exprs[0]._with_node(node)
670
+
671
+ def __matmul__(self, other):
672
+ # True matrix multiplication: (..., m, k) @ (..., k, n) -> (..., m, n)
673
+ if not isinstance(other, StateExpr):
674
+ other = self._const_expr_from_array(other)
675
+ ra, rb = self.rank, other.rank
676
+ if ra < 1 or rb < 1:
677
+ raise TypeError("matmul requires rank >= 1 for both operands.")
678
+
679
+ aL = list(_EINSUM_LETTERS[:ra])
680
+ bL = list(_EINSUM_LETTERS[ra : ra + rb])
681
+
682
+ # contract A's last with B's first
683
+ bL[0] = aL[-1]
684
+ out = "".join(aL[:-1] + bL[1:])
685
+ eq = f"{''.join(aL)},{''.join(bL)}->{out}"
686
+ return type(self).einsum(eq, self, other)
687
+
688
+ def __rmatmul__(self, other):
689
+ # True matrix multiplication for array/expr on the left
690
+ if not isinstance(other, StateExpr):
691
+ other = self._const_expr_from_array(other)
692
+ ra, rb = other.rank, self.rank
693
+ if ra < 1 or rb < 1:
694
+ raise TypeError("matmul requires rank >= 1 for both operands.")
695
+
696
+ aL = list(_EINSUM_LETTERS[:ra])
697
+ bL = list(_EINSUM_LETTERS[ra : ra + rb])
698
+
699
+ # contract A's last with B's first
700
+ bL[0] = aL[-1]
701
+ out = "".join(aL[:-1] + bL[1:])
702
+ eq = f"{''.join(aL)},{''.join(bL)}->{out}"
703
+ return type(self).einsum(eq, other, self)
704
+
705
+ def dot(self, other, axes=None):
706
+ """
707
+ Spatial tensordot via einsum.
708
+
709
+ Semantics:
710
+ - axes=None: contract last axis of self with first axis of other.
711
+ - axes=int:
712
+ * if self.rank == other.rank: contract **all** axes (Frobenius/trace for rank-2).
713
+ * else: contract `axes` trailing axes of self with `axes` leading axes of other.
714
+ - axes=(a_axes, b_axes): NumPy-style explicit lists.
715
+
716
+ Arrays are accepted and coerced to spatial constants.
717
+ """
718
+ if not isinstance(other, StateExpr):
719
+ other = self._const_expr_from_array(other)
720
+
721
+ ra, rb = self.rank, other.rank
722
+
723
+ # Normalize axes
724
+ if axes is None:
725
+ a_axes, b_axes = (ra - 1,), (0,)
726
+ elif isinstance(axes, int):
727
+ if ra == rb:
728
+ # full Frobenius contraction when ranks match (matches tests)
729
+ a_axes = tuple(range(ra))
730
+ b_axes = tuple(range(rb))
731
+ else:
732
+ if axes < 0 or axes > min(ra, rb):
733
+ raise ValueError("axes out of range")
734
+ a_axes = tuple(range(ra - axes, ra))
735
+ b_axes = tuple(range(axes))
736
+ else:
737
+ a_axes = tuple(axes[0])
738
+ b_axes = tuple(axes[1])
739
+ if len(a_axes) != len(b_axes):
740
+ raise ValueError("axes lengths must match")
741
+
742
+ aL = list(_EINSUM_LETTERS[:ra])
743
+ bL = list(_EINSUM_LETTERS[ra : ra + rb])
744
+
745
+ # Share letters on contracted axes
746
+ for ai, bi in zip(a_axes, b_axes):
747
+ bL[bi] = aL[ai]
748
+
749
+ # Output keeps non-contracted axes in order: self then other
750
+ outA = [c for i, c in enumerate(aL) if i not in a_axes]
751
+ outB = [c for j, c in enumerate(bL) if j not in b_axes]
752
+
753
+ eq = f"{''.join(aL)},{''.join(bL)}->{''.join(outA + outB)}"
754
+ return type(self).einsum(eq, self, other)
755
+
756
+ def tensordot(self, other, axes=1):
757
+ """Alias of .dot with NumPy-compatible `axes`."""
758
+ return self.dot(other, axes=axes)
759
+
760
+ def _const_expr_from_array(self, arr, *, rank_override: int | None = None):
761
+ """
762
+ Wrap a JAX/NumPy array as a spatial-constant StateExpr with one feature.
763
+ Broadcasts over spatial axes and batch/particles. Feature count = 1.
764
+ If `rank_override` is given, ignore `arr.ndim` and use that rank
765
+ for spatial semantics (useful in einsum parsing).
766
+ """
767
+ const = jnp.asarray(arr)
768
+ rank = int(rank_override) if rank_override is not None else int(const.ndim)
769
+ dim = int(self.dim)
770
+ if not _is_broadcastable_to_rank(tuple(const.shape), rank=rank, dim=dim):
771
+ raise TypeError(
772
+ "Array has incompatible shape for matmul with this expression. "
773
+ f"Expected a tensor broadcastable to (dim={dim},)*rank; got {tuple(const.shape)}."
774
+ )
775
+ node = SimpleLeaf(
776
+ func=lambda x, **kw: const,
777
+ rank=rank,
778
+ dim=dim,
779
+ n_features=1,
780
+ needs_v=False,
781
+ )
782
+ return self._with_node(node)
783
+
784
+ def sqrtm(self):
785
+ from ..utils.maths import sqrtm_psd
786
+
787
+ if self.rank != Rank.MATRIX:
788
+ raise ValueError("sqrtm only valid for rank-2 tensors")
789
+ node = MapNNode(sqrtm_psd, self.root, label_fn=lambda s: f"sqrtm({s})")
790
+ return self._with_node(node)
791
+
792
+ # =================================================================
793
+ # FEATURE AXIS MANIPULATION
794
+ # =================================================================
795
+ def __and__(self, other: "StateExpr"):
796
+ """Concatenate along the **feature axis**.
797
+
798
+ Static contracts must match (rank/dim, compatible pdepth).
799
+ """
800
+ node = ConcatNode(self.root, other.root)
801
+ return self._with_node(node)
802
+
803
+ # stack helper
804
+ @classmethod
805
+ def stack(cls, exprs: Sequence["StateExpr"]):
806
+ """Concatenate along the **feature axis**.
807
+
808
+ Static contracts must match (rank/dim, compatible pdepth).
809
+ """
810
+ if not exprs:
811
+ raise ValueError("stack() received empty sequence")
812
+ node = ConcatNode(*(e.root for e in exprs))
813
+ return exprs[0]._with_node(node)
814
+
815
+ # feature slicing / fancy indexing
816
+ def __getitem__(self, idx):
817
+ """Feature selection via slices, lists, boolean masks, or integers.
818
+
819
+ Returns a new expression with the same spatial contract; labels are
820
+ subsetted when applicable.
821
+ """
822
+ node = SliceFeaturesNode(self.root, idx)
823
+ return self._with_node(node)
824
+
825
+ # -----------------------------------------------------------------
826
+ # RANK LIFTING: scalar → vector / matrix
827
+ # -----------------------------------------------------------------
828
+ def vectorize(self, dim: int | None = None, axes=None):
829
+ """Lift a **scalar** expression to **rank-1 (vector)**.
830
+
831
+ Equivalent to ``self * unit_vector_basis(dim, axes=axes)``, i.e. a
832
+ Cartesian product of the feature axis with unit vectors.
833
+
834
+ Parameters
835
+ ----------
836
+ dim : int, optional
837
+ Spatial dimension. Inferred from the expression's contract when
838
+ possible.
839
+ axes : sequence of int, optional
840
+ Subset of spatial axes to include (default: all ``dim`` axes).
841
+
842
+ Returns
843
+ -------
844
+ StateExpr
845
+ Vector expression with ``n_features = self.n_features × len(axes)``.
846
+ """
847
+ from ..bases.constants import unit_vector_basis
848
+
849
+ if self.rank != 0:
850
+ raise TypeError(f"vectorize() requires a scalar (rank-0) expression; got rank={self.rank}")
851
+ if getattr(self.root, "particles_input", False):
852
+ raise TypeError(
853
+ "vectorize() cannot be used on an undispatched Interactor. "
854
+ "Either dispatch it first (e.g. inter.dispatch_pairs().vectorize()), "
855
+ "or use a vector pair basis directly (e.g. radial_pair_basis)."
856
+ )
857
+ if dim is None:
858
+ dim = getattr(self.root, "dim", None)
859
+ if dim is None:
860
+ raise ValueError("Cannot infer dim; pass it explicitly.")
861
+ return self * unit_vector_basis(dim, axes=axes)
862
+
863
+ def tensorize(self, dim: int | None = None, mode: str = "symmetric"):
864
+ """Lift a **scalar** expression to **rank-2 (matrix)**.
865
+
866
+ Parameters
867
+ ----------
868
+ dim : int, optional
869
+ Spatial dimension. Inferred when possible.
870
+ mode : str
871
+ ``'symmetric'`` (default) uses :func:`symmetric_matrix_basis`
872
+ (d(d+1)/2 features per scalar feature, spans all symmetric
873
+ matrices). ``'identity'`` uses :func:`identity_matrix_basis`
874
+ (1 feature per scalar feature, isotropic).
875
+
876
+ Returns
877
+ -------
878
+ StateExpr
879
+ Matrix expression.
880
+ """
881
+ from ..bases.constants import identity_matrix_basis, symmetric_matrix_basis
882
+
883
+ if self.rank != 0:
884
+ raise TypeError(f"tensorize() requires a scalar (rank-0) expression; got rank={self.rank}")
885
+ if getattr(self.root, "particles_input", False):
886
+ raise TypeError(
887
+ "tensorize() cannot be used on an undispatched Interactor. "
888
+ "Either dispatch it first (e.g. inter.dispatch_pairs().tensorize()), "
889
+ "or use a tensor pair basis directly (e.g. dyadic_pair_basis)."
890
+ )
891
+ if dim is None:
892
+ dim = getattr(self.root, "dim", None)
893
+ if dim is None:
894
+ raise ValueError("Cannot infer dim; pass it explicitly.")
895
+ if mode == "symmetric":
896
+ return self * symmetric_matrix_basis(dim)
897
+ if mode == "identity":
898
+ return self * identity_matrix_basis(dim)
899
+ raise ValueError(f"Unknown mode={mode!r}; choose 'symmetric' or 'identity'.")
900
+
901
+ # -----------------------------------------------------------------
902
+ # RANK ↔ FEATURE RESHAPING (lossless, invertible)
903
+ # -----------------------------------------------------------------
904
+ def rank_to_features(self):
905
+ """Fold **all** spatial (rank) axes into the feature axis → rank-0.
906
+
907
+ The output layout changes from::
908
+
909
+ batch · (dim,)^rank · n_features
910
+
911
+ to::
912
+
913
+ batch · (n_features × dim^rank,)
914
+
915
+ with rank = 0. This is a pure reshape (no copy, no learnable
916
+ parameters) and is the exact **inverse** of
917
+ ``features_to_rank(original_rank)``.
918
+
919
+ Returns
920
+ -------
921
+ StateExpr (same subclass)
922
+ Scalar expression whose feature count is
923
+ ``self.n_features × self.dim ** self.rank``.
924
+
925
+ Raises
926
+ ------
927
+ TypeError
928
+ If the expression is already rank‑0 (no-op would be confusing).
929
+
930
+ Examples
931
+ --------
932
+ Prepare a rank-1 position vector for dense layers::
933
+
934
+ >>> X(dim=2).rank_to_features() # rank-0, 2 features
935
+
936
+ The round-trip is the identity::
937
+
938
+ >>> expr.rank_to_features().features_to_rank(expr.rank) # same as expr
939
+ """
940
+ if self.rank == 0:
941
+ raise TypeError("rank_to_features() on a rank-0 expression is a no-op; the features are already scalar.")
942
+ node = ReshapeRankNode(self.root, target_rank=0)
943
+ return self._with_node(node)
944
+
945
+ def features_to_rank(self, rank: int):
946
+ """Unfold features into spatial axes → given *rank*.
947
+
948
+ The output layout changes from the current::
949
+
950
+ batch · (dim,)^self.rank · n_features
951
+
952
+ to::
953
+
954
+ batch · (dim,)^rank · (n_features / dim^(rank − self.rank),)
955
+
956
+ where the new innermost spatial axes are carved out of the
957
+ feature axis. This is a pure reshape and is the exact
958
+ **inverse** of ``rank_to_features()`` when restoring the
959
+ original rank.
960
+
961
+ Parameters
962
+ ----------
963
+ rank : int
964
+ Target tensor rank (must be greater than the current rank).
965
+
966
+ Returns
967
+ -------
968
+ StateExpr (same subclass)
969
+ Expression at the requested rank with fewer features.
970
+
971
+ Raises
972
+ ------
973
+ ValueError
974
+ If ``n_features`` is not divisible by ``dim^Δrank``.
975
+ TypeError
976
+ If ``rank ≤ self.rank`` (use ``rank_to_features`` to go down).
977
+
978
+ Examples
979
+ --------
980
+ Turn a dense layer's output back into a vector field::
981
+
982
+ >>> scalar_expr.features_to_rank(1) # rank-1, F/dim features
983
+
984
+ Build a 2→H→H→2 MLP force field::
985
+
986
+ >>> mlp = (
987
+ ... X(dim=2)
988
+ ... .rank_to_features() # rank-0, 2 features
989
+ ... .dense(32, weight="W1", bias="b1")
990
+ ... .elementwisemap(jnp.tanh)
991
+ ... .dense(2, weight="W2", bias="b2") # rank-0, 2 features
992
+ ... .features_to_rank(1) # rank-1, 1 feature
993
+ ... )
994
+ """
995
+ if rank <= self.rank:
996
+ raise TypeError(
997
+ f"features_to_rank({rank}) requires rank > current rank "
998
+ f"({self.rank}); use rank_to_features() to decrease rank."
999
+ )
1000
+ node = ReshapeRankNode(self.root, target_rank=rank)
1001
+ return self._with_node(node)
1002
+
1003
+ # -----------------------------------------------------------------
1004
+ # ELEMENT-WISE TRANSFORM OF FEATURE AXIS
1005
+ # -----------------------------------------------------------------
1006
+ def elementwisemap(
1007
+ self,
1008
+ func: Callable[[jnp.ndarray], jnp.ndarray],
1009
+ *,
1010
+ label_fn: Callable[[str], str] | None = None,
1011
+ ):
1012
+ """
1013
+ Apply *func* element-wise to every **feature** (spatial axes untouched).
1014
+
1015
+ `func` must be a pure JAX function from scalar→scalar (rank-0 arrays OK).
1016
+ If the expression carries feature labels (e.g., a `Basis` or an `SF` bound
1017
+ from a `Basis`), `label_fn` (if provided) is applied to each feature label.
1018
+
1019
+ Example
1020
+ -------
1021
+ >>> B = ... # Basis with 4 features
1022
+ >>> C = B.elementwisemap(jnp.tanh, label_fn=lambda s: f"tanh({s})")
1023
+ """
1024
+ node = MapNNode(lambda a, _f=func: _f(a), self.root, label_fn=label_fn)
1025
+ return self._with_node(node)
1026
+
1027
+ # -----------------------------------------------------------------
1028
+ # DENSE (AFFINE) LAYER ON FEATURE AXIS
1029
+ # -----------------------------------------------------------------
1030
+ def dense(
1031
+ self,
1032
+ n_out: int,
1033
+ *,
1034
+ weight: str = "W",
1035
+ bias: str | None = "b",
1036
+ ):
1037
+ """Apply a learnable affine map on the **feature axis**.
1038
+
1039
+ ``y[..., j] = sum_i x[..., i] * W[i, j] + b[j]``
1040
+
1041
+ Spatial (rank) axes are untouched: the same ``W, b`` are shared
1042
+ across every spatial component. The result is always a `PSF`
1043
+ (since the dense layer introduces learnable parameters).
1044
+
1045
+ Parameters
1046
+ ----------
1047
+ n_out : int
1048
+ Number of output features.
1049
+ weight : str
1050
+ Name for the weight parameter (default ``"W"``). Use distinct
1051
+ names (``"W1"``, ``"W2"``, …) when stacking multiple layers.
1052
+ bias : str | None
1053
+ Name for the bias parameter (default ``"b"``; ``None`` to omit).
1054
+ Use distinct names (``"b1"``, ``"b2"``, …) when stacking layers.
1055
+
1056
+ Returns
1057
+ -------
1058
+ PSF
1059
+ A parametric state function wrapping the dense layer.
1060
+
1061
+ Examples
1062
+ --------
1063
+ Build the hidden layers of an MLP force field::
1064
+
1065
+ >>> from SFI.bases import X
1066
+ >>> import jax.numpy as jnp
1067
+ >>> mlp = (
1068
+ ... X(dim=2).vectorize(2)
1069
+ ... .dense(32, weight="W1", bias="b1")
1070
+ ... .elementwisemap(jnp.tanh)
1071
+ ... .dense(1, weight="W2", bias="b2")
1072
+ ... )
1073
+ """
1074
+ from .psf import PSF # deferred to avoid circular import
1075
+
1076
+ node = DenseNode(self.root, n_out=n_out, weight=weight, bias=bias)
1077
+ return PSF(node)
1078
+
1079
+ # =================================================================
1080
+ # NUMPY / JAX UNARY UFUNC FORWARDING
1081
+ # =================================================================
1082
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
1083
+ # We only implement the standard call "ufunc(expr [, const])"
1084
+ if method != "__call__":
1085
+ return NotImplemented
1086
+
1087
+ # Unary: sin(expr)
1088
+ if ufunc.nin == 1 and inputs == (self,):
1089
+ node = MapNNode(lambda a, _uf=ufunc, _kw=kwargs: _uf(a, **_kw), self.root)
1090
+ return self._with_node(node)
1091
+
1092
+ # Binary: expr + const OR const + expr
1093
+ if ufunc.nin == 2 and len(inputs) == 2:
1094
+ a, b = inputs
1095
+ if a is self and not isinstance(b, StateExpr):
1096
+ return self._binary(b, lambda x, y: ufunc(x, y, **kwargs))
1097
+ if b is self and not isinstance(a, StateExpr):
1098
+ return self._binary(a, lambda x, y: ufunc(x, y, **kwargs), swap=True)
1099
+ if isinstance(a, StateExpr) and isinstance(b, StateExpr):
1100
+ return a._binary(b, lambda x, y: ufunc(x, y, **kwargs))
1101
+
1102
+ return NotImplemented
1103
+
1104
+ @property
1105
+ def required_extras(self) -> tuple[str, ...]:
1106
+ """
1107
+ Presence-only extras required by the expression, forwarded from the root node.
1108
+ No shape/broadcast semantics here.
1109
+ """
1110
+ return tuple(getattr(self.root, "extras_required", ()) or ())
1111
+
1112
+ @property
1113
+ def particle_extras(self) -> tuple[str, ...]:
1114
+ """
1115
+ Pure metadata, forwarded from the root node.
1116
+
1117
+ Names of extras declared as per-particle somewhere in the underlying node tree
1118
+ (typically by interaction leaves). The dispatcher reads this to know which keys
1119
+ to gather from `(P, ...)` into `(E, K, ...)` per edge before calling locals.
1120
+ """
1121
+ return tuple(getattr(self.root, "particle_extras", ()) or ())
1122
+
1123
+
1124
+ ### Helpers ###
1125
+
1126
+
1127
+ def _validate_extras_presence(required, extras):
1128
+ """
1129
+ Presence rules (new policy):
1130
+
1131
+ - If ``required`` is empty, no check is performed.
1132
+ - Otherwise: all keys in ``required`` must be present in ``extras``.
1133
+
1134
+ Legacy ``'*'`` (presence-only) is ignored here; leaves should not emit it.
1135
+ """
1136
+ required = tuple(k for k in (required or ()) if k != "*")
1137
+ if not required:
1138
+ return
1139
+ if extras is None:
1140
+ raise KeyError(f"Missing extras: {required}")
1141
+ missing = [k for k in required if k not in extras]
1142
+ if missing:
1143
+ raise KeyError(f"Missing extras keys: {missing}")
1144
+
1145
+
1146
+ def _walk_nodes(node):
1147
+ yield node
1148
+ if hasattr(node, "children") and isinstance(node.children, (tuple, list)):
1149
+ for ch in node.children:
1150
+ yield from _walk_nodes(ch)
1151
+ for attr in ("child", "inner"):
1152
+ if hasattr(node, attr):
1153
+ yield from _walk_nodes(getattr(node, attr))
1154
+
1155
+
1156
+ def _specialize_node(node: BaseNode, dataset: int) -> BaseNode:
1157
+ """Recursively fold ``dataset_index``-reading leaves at condition ``dataset``.
1158
+
1159
+ A leaf that carries a ``specialize_at`` recipe is replaced by its
1160
+ condition-``dataset`` form; composite nodes are rebuilt from specialized
1161
+ children via ``with_children`` (which recomputes the static contract, extras
1162
+ union, and parameter suite); all other leaves pass through unchanged.
1163
+ """
1164
+ hook = getattr(node, "specialize_at", None)
1165
+ if hook is not None:
1166
+ return hook(dataset).root
1167
+ children = getattr(node, "children", None)
1168
+ if children:
1169
+ new_children = [_specialize_node(c, dataset) for c in children]
1170
+ # Only rebuild when a leaf was actually specialized. If every child is
1171
+ # unchanged (the common case: no ``dataset_index``-reading leaves in this
1172
+ # subtree) the node is returned as-is, so node types that legitimately
1173
+ # do not implement ``with_children`` (e.g. ``InteractionDispatcher``)
1174
+ # pass through specialize() untouched instead of raising.
1175
+ if all(nc is oc for nc, oc in zip(new_children, children)):
1176
+ return node
1177
+ return node.with_children(new_children)
1178
+ return node
1179
+
1180
+
1181
+ def _static_contract_sanity_node(n):
1182
+ # Only validate fields that exist on this node
1183
+ pdepth = getattr(n, "pdepth", None)
1184
+ rank = getattr(n, "rank", None)
1185
+ n_features = getattr(n, "n_features", None)
1186
+ dim = getattr(n, "dim", None)
1187
+ particles_input = getattr(n, "particles_input", None)
1188
+
1189
+ if pdepth is not None:
1190
+ if not isinstance(pdepth, int) or pdepth < 0:
1191
+ raise ValueError(f"[Contract] {type(n).__name__}: pdepth must be a non-negative int, got {pdepth!r}")
1192
+ if rank is not None:
1193
+ if not isinstance(rank, int) or rank < 0:
1194
+ raise ValueError(f"[Contract] {type(n).__name__}: rank must be a non-negative int, got {rank!r}")
1195
+ if n_features is not None:
1196
+ if not isinstance(n_features, int) or n_features < 1:
1197
+ raise ValueError(f"[Contract] {type(n).__name__}: n_features must be a positive int, got {n_features!r}")
1198
+ if dim is not None:
1199
+ if not isinstance(dim, int) or dim < 1:
1200
+ raise ValueError(f"[Contract] {type(n).__name__}: dim must be None or a positive int, got {dim!r}")
1201
+ if (particles_input is not None) and (pdepth is not None):
1202
+ if (not particles_input) and (pdepth > 0):
1203
+ raise ValueError(
1204
+ f"[Contract] {type(n).__name__}: pdepth>0 requires particles_input=True "
1205
+ "(cannot create particle axes without a particle input axis)."
1206
+ )
1207
+
1208
+
1209
+ def _is_broadcastable_to_rank(shape: tuple[int, ...], *, rank: int, dim: int) -> bool:
1210
+ """True iff `shape` numpy-broadcasts to the spatial rank block `(dim,)*rank`.
1211
+ Right-align against the rank block; each aligned axis must be 1 or dim.
1212
+ """
1213
+ if len(shape) > rank:
1214
+ return False
1215
+ for s in shape[::-1]:
1216
+ if s != 1 and s != dim:
1217
+ return False
1218
+ return True
1219
+
1220
+
1221
+ # One-off warning for rare multi-feature feature-Cartesian binary ops
1222
+ _CARTESIAN_FEATURE_WARNED = False
1223
+
1224
+
1225
+ def _warn_cartesian_multi_feature(op: str) -> None:
1226
+ """
1227
+ Emit a single warning the first time we apply a Cartesian product over
1228
+ feature axes in a binary op between two multi-feature expressions.
1229
+ """
1230
+ import warnings
1231
+
1232
+ global _CARTESIAN_FEATURE_WARNED
1233
+ if _CARTESIAN_FEATURE_WARNED:
1234
+ return
1235
+ warnings.warn(
1236
+ f"[StateExpr] Binary op {op!r} between multi-feature expressions uses a "
1237
+ "Cartesian product over feature axes (F_out = F_left × F_right). "
1238
+ "This can grow n_features quickly; use '&' (stack) or aligned maps if "
1239
+ "you intended per-feature operations.",
1240
+ RuntimeWarning,
1241
+ stacklevel=3,
1242
+ )
1243
+ _CARTESIAN_FEATURE_WARNED = True