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.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|