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
SFI/statefunc/factory.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Callable, Iterable, Optional, Sequence
|
|
3
|
+
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
|
|
6
|
+
from .basis import Basis
|
|
7
|
+
from .interactor import Interactor
|
|
8
|
+
from .nodes import SimpleLeaf
|
|
9
|
+
from .nodes.contract import Rank
|
|
10
|
+
from .nodes.leaf import InteractionLeaf
|
|
11
|
+
from .params import ParamSpec, ParamSuite
|
|
12
|
+
from .psf import PSF
|
|
13
|
+
from .sf import SF
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _probe_call(obj, *, dim: int, rank: int, n_features: int, label: str):
|
|
19
|
+
"""Run a zero-valued test call to catch shape/signature errors early.
|
|
20
|
+
|
|
21
|
+
Called when ``dim`` is known at factory construction time.
|
|
22
|
+
Logs a warning on failure instead of raising, so construction still
|
|
23
|
+
succeeds (the user may intend a shape that only works with real data).
|
|
24
|
+
"""
|
|
25
|
+
x_probe = jnp.zeros((1, dim)) # (N=1, dim)
|
|
26
|
+
try:
|
|
27
|
+
out = obj(x_probe)
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
logger.warning(
|
|
30
|
+
"%s: probe call with x of shape %s failed — the function may error at runtime. Original error: %s",
|
|
31
|
+
label,
|
|
32
|
+
x_probe.shape,
|
|
33
|
+
exc,
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
out = jnp.asarray(out)
|
|
38
|
+
if out.shape[0] != 1:
|
|
39
|
+
logger.warning(
|
|
40
|
+
"%s: probe call returned shape %s but expected leading axis 1 (matching N=1 input). Check output shape.",
|
|
41
|
+
label,
|
|
42
|
+
out.shape,
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
logger.debug("%s: probe call OK, output shape %s", label, out.shape)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_basis(
|
|
49
|
+
func: Callable,
|
|
50
|
+
*,
|
|
51
|
+
dim: int | None = None,
|
|
52
|
+
rank: int,
|
|
53
|
+
n_features: int = 1,
|
|
54
|
+
needs_v: bool = False,
|
|
55
|
+
labels: Optional[Sequence[str]] = None,
|
|
56
|
+
descriptor: Any = "custom",
|
|
57
|
+
extras_keys: Optional[Sequence[str]] = None,
|
|
58
|
+
particle_extras: Optional[Sequence[str]] = None,
|
|
59
|
+
specialize_at: Optional[Callable] = None,
|
|
60
|
+
) -> Basis:
|
|
61
|
+
"""
|
|
62
|
+
Construct a **deterministic Basis** from a *single-sample* user function,
|
|
63
|
+
with **no particle semantics**. Particle axes (if present in ``x`` at call
|
|
64
|
+
time) are treated purely as batch axes and vmapped over.
|
|
65
|
+
|
|
66
|
+
``particle_extras`` names extras keys whose values are **per-sample**
|
|
67
|
+
arrays aligned with the batch/particle axes (e.g. an ``extras_local``
|
|
68
|
+
entry of shape ``(N, ...)``): they are vmapped alongside ``x``, so the
|
|
69
|
+
single-sample function sees *its own* particle's value instead of the
|
|
70
|
+
whole array — the route to per-particle terms in single-particle
|
|
71
|
+
bases (home-range centres, individual labels, ...).
|
|
72
|
+
|
|
73
|
+
User function signature — declare **only** the kwargs you need:
|
|
74
|
+
|
|
75
|
+
- Simplest: ``f(x) -> array``
|
|
76
|
+
- With velocity: ``f(x, *, v) -> array``
|
|
77
|
+
- With extras: ``f(x, *, extras) -> array``
|
|
78
|
+
|
|
79
|
+
The full signature is ``f(x, *, v=None, mask=None, extras=None)``;
|
|
80
|
+
we introspect and pass only the kwargs you declare.
|
|
81
|
+
|
|
82
|
+
Shapes (single sample)::
|
|
83
|
+
|
|
84
|
+
x: (dim,)
|
|
85
|
+
return: (*rank_axes, m) # feature last; m == n_features
|
|
86
|
+
|
|
87
|
+
If ``n_features == 1`` you may omit the last axis; a singleton feature
|
|
88
|
+
axis is auto-inserted.
|
|
89
|
+
|
|
90
|
+
Extras
|
|
91
|
+
~~~~~~
|
|
92
|
+
If ``extras`` is declared, you may provide ``extras_keys=(...)`` to
|
|
93
|
+
enforce keys. Extras arrays must broadcast over the **batch prefix**
|
|
94
|
+
(never over rank/feature).
|
|
95
|
+
|
|
96
|
+
Examples
|
|
97
|
+
--------
|
|
98
|
+
>>> import jax.numpy as jnp
|
|
99
|
+
>>> from SFI.statefunc import make_basis
|
|
100
|
+
>>> B = make_basis(lambda x: x, dim=2, rank=1, n_features=1) # (equivalent to the built-in X(dim=2))
|
|
101
|
+
|
|
102
|
+
JAX
|
|
103
|
+
~~~
|
|
104
|
+
Write ``f`` with ``jax.numpy`` and keep it pure; works with
|
|
105
|
+
jit/vmap/autodiff.
|
|
106
|
+
"""
|
|
107
|
+
leaf = SimpleLeaf(
|
|
108
|
+
func=func,
|
|
109
|
+
n_features=int(n_features),
|
|
110
|
+
labels=tuple(labels) if labels is not None else tuple(f"f{j}" for j in range(n_features)),
|
|
111
|
+
descriptor=descriptor,
|
|
112
|
+
dim=dim,
|
|
113
|
+
rank=Rank(rank),
|
|
114
|
+
needs_v=bool(needs_v),
|
|
115
|
+
# SimpleLeaf forbids particles & pdepth by construction (pdepth=0, particles_input=False).
|
|
116
|
+
extras_keys=tuple(extras_keys) if extras_keys is not None else (),
|
|
117
|
+
particle_extras=tuple(particle_extras) if particle_extras is not None else (),
|
|
118
|
+
specialize_at=specialize_at,
|
|
119
|
+
)
|
|
120
|
+
result = Basis(leaf)
|
|
121
|
+
if dim is not None and not extras_keys and not needs_v:
|
|
122
|
+
_probe_call(result, dim=dim, rank=rank, n_features=n_features, label="make_basis")
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def make_psf(
|
|
127
|
+
func: Callable,
|
|
128
|
+
*,
|
|
129
|
+
dim: int | None = None,
|
|
130
|
+
rank: int,
|
|
131
|
+
n_features: int = 1,
|
|
132
|
+
drop_features: bool = True,
|
|
133
|
+
needs_v: bool = False,
|
|
134
|
+
labels: Optional[Sequence[str]] = None,
|
|
135
|
+
descriptor: Any = "parametric",
|
|
136
|
+
params: ParamSuite | Iterable[ParamSpec] | dict[str, Any],
|
|
137
|
+
extras_keys: Optional[Sequence[str]] = None,
|
|
138
|
+
specialize_at: Optional[Callable] = None,
|
|
139
|
+
) -> PSF:
|
|
140
|
+
"""
|
|
141
|
+
Construct a **parametric state-function family (PSF)** from a *single-sample*
|
|
142
|
+
user function, **without particle semantics**.
|
|
143
|
+
|
|
144
|
+
User function signature — declare **only** the kwargs you need:
|
|
145
|
+
|
|
146
|
+
- Simplest: ``f(x, *, params) -> array``
|
|
147
|
+
- With velocity: ``f(x, *, v, params) -> array``
|
|
148
|
+
- With extras: ``f(x, *, params, extras) -> array``
|
|
149
|
+
|
|
150
|
+
The full signature is ``f(x, *, params, v=None, mask=None, extras=None)``;
|
|
151
|
+
we introspect and pass only the kwargs you declare.
|
|
152
|
+
|
|
153
|
+
Shapes (single sample)::
|
|
154
|
+
|
|
155
|
+
x: (dim,)
|
|
156
|
+
return: (*rank_axes, m) # feature last; m == n_features
|
|
157
|
+
|
|
158
|
+
If ``n_features == 1`` you may omit the last axis; we auto-insert a
|
|
159
|
+
singleton feature axis.
|
|
160
|
+
|
|
161
|
+
Parameters (``params``) may be described as:
|
|
162
|
+
|
|
163
|
+
- a ``ParamSuite``,
|
|
164
|
+
- an iterable of ``ParamSpec``,
|
|
165
|
+
- a dict of shapes, e.g. ``{'W': (d,d), 'b': ()}``,
|
|
166
|
+
- or a dict of **sample arrays** from which (shape, dtype) are inferred.
|
|
167
|
+
|
|
168
|
+
Extras
|
|
169
|
+
~~~~~~
|
|
170
|
+
Same rules as ``make_basis`` (``extras_keys`` optional; broadcast over
|
|
171
|
+
batch prefix).
|
|
172
|
+
|
|
173
|
+
JAX
|
|
174
|
+
~~~
|
|
175
|
+
Works with jit/vmap/autodiff w.r.t. inputs and parameters.
|
|
176
|
+
"""
|
|
177
|
+
suite = ParamSuite.parse(params)
|
|
178
|
+
|
|
179
|
+
leaf = SimpleLeaf(
|
|
180
|
+
func=func,
|
|
181
|
+
n_features=int(n_features),
|
|
182
|
+
labels=tuple(labels) if labels is not None else tuple(f"f{j}" for j in range(n_features)),
|
|
183
|
+
descriptor=descriptor,
|
|
184
|
+
dim=dim,
|
|
185
|
+
rank=Rank(rank),
|
|
186
|
+
needs_v=bool(needs_v),
|
|
187
|
+
param_suite=suite, # Parametric path is enabled by providing a ParamSuite.
|
|
188
|
+
extras_keys=tuple(extras_keys) if extras_keys is not None else (),
|
|
189
|
+
specialize_at=specialize_at,
|
|
190
|
+
)
|
|
191
|
+
return PSF(leaf, drop_features=bool(drop_features))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def make_sf(
|
|
195
|
+
func: Callable,
|
|
196
|
+
*,
|
|
197
|
+
dim: int | None = None,
|
|
198
|
+
rank: int,
|
|
199
|
+
n_features: int = 1,
|
|
200
|
+
drop_features: bool = True,
|
|
201
|
+
needs_v: bool = False,
|
|
202
|
+
labels: Optional[Sequence[str]] = None,
|
|
203
|
+
descriptor: Any = "custom_sf",
|
|
204
|
+
extras_keys: Optional[Sequence[str]] = None,
|
|
205
|
+
) -> SF:
|
|
206
|
+
"""
|
|
207
|
+
Construct an **SF** (bound state function) directly from a parameter-free
|
|
208
|
+
user function — no ``Basis`` or ``PSF`` intermediate needed.
|
|
209
|
+
|
|
210
|
+
This is the simplest entry point when you have a known, fixed function
|
|
211
|
+
(e.g. an exact model for comparison, or a hand-coded feature) and just
|
|
212
|
+
want a callable that participates in the SFI expression-tree ecosystem.
|
|
213
|
+
|
|
214
|
+
The resulting ``SF`` supports ``.d_x()``, ``.d_v()``, and can be passed
|
|
215
|
+
to ``compare_to_exact``, ``integrate``, or any other API that accepts
|
|
216
|
+
an ``SF`` / ``StateExpr``.
|
|
217
|
+
|
|
218
|
+
User function signature — declare **only** the kwargs you need:
|
|
219
|
+
|
|
220
|
+
- Simplest: ``f(x) -> array``
|
|
221
|
+
- With velocity: ``f(x, *, v) -> array``
|
|
222
|
+
- With extras: ``f(x, *, extras) -> array``
|
|
223
|
+
|
|
224
|
+
Shapes (single sample)::
|
|
225
|
+
|
|
226
|
+
x: (dim,)
|
|
227
|
+
return: (*rank_axes, n_features)
|
|
228
|
+
|
|
229
|
+
If ``n_features == 1`` you may omit the trailing feature axis; it is
|
|
230
|
+
auto-inserted. The resulting SF squeezes it back when
|
|
231
|
+
``drop_features=True`` (default).
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
func : callable
|
|
236
|
+
Pure JAX function, compatible with jit/vmap/autodiff.
|
|
237
|
+
dim : int or None
|
|
238
|
+
Spatial dimensionality (None = infer at first call).
|
|
239
|
+
rank : int
|
|
240
|
+
Tensor rank of the output (0=scalar, 1=vector, 2=matrix).
|
|
241
|
+
n_features : int
|
|
242
|
+
Number of output features (default 1).
|
|
243
|
+
drop_features : bool
|
|
244
|
+
Remove trailing size-1 feature axis (default True).
|
|
245
|
+
needs_v : bool
|
|
246
|
+
Whether ``func`` requires velocity ``v``.
|
|
247
|
+
labels : sequence of str or None
|
|
248
|
+
Human-readable feature labels (auto-generated if None).
|
|
249
|
+
descriptor : any
|
|
250
|
+
Metadata tag stored on the leaf node.
|
|
251
|
+
extras_keys : sequence of str or None
|
|
252
|
+
Required keys in the ``extras`` mapping.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
SF
|
|
257
|
+
A bound, callable state function with no free parameters.
|
|
258
|
+
|
|
259
|
+
Examples
|
|
260
|
+
--------
|
|
261
|
+
>>> import jax.numpy as jnp
|
|
262
|
+
>>> from SFI.statefunc import make_sf
|
|
263
|
+
>>> harmonic = make_sf(lambda x: -x, rank=1, dim=2)
|
|
264
|
+
>>> harmonic(jnp.array([1.0, 2.0]))
|
|
265
|
+
Array([-1., -2.], dtype=float32)
|
|
266
|
+
"""
|
|
267
|
+
# Build a SimpleLeaf with an *empty* (but non-None) ParamSuite so the
|
|
268
|
+
# node is accepted by PSF. The empty suite carries zero parameters,
|
|
269
|
+
# so PSF.__call__ auto-supplies params={} and SF.bind freezes nothing.
|
|
270
|
+
empty_suite = ParamSuite([])
|
|
271
|
+
|
|
272
|
+
leaf = SimpleLeaf(
|
|
273
|
+
func=func,
|
|
274
|
+
n_features=int(n_features),
|
|
275
|
+
labels=tuple(labels) if labels is not None else tuple(f"f{j}" for j in range(n_features)),
|
|
276
|
+
descriptor=descriptor,
|
|
277
|
+
dim=dim,
|
|
278
|
+
rank=Rank(rank),
|
|
279
|
+
needs_v=bool(needs_v),
|
|
280
|
+
param_suite=empty_suite,
|
|
281
|
+
extras_keys=tuple(extras_keys) if extras_keys is not None else (),
|
|
282
|
+
)
|
|
283
|
+
psf = PSF(leaf, drop_features=bool(drop_features))
|
|
284
|
+
result = psf.bind({})
|
|
285
|
+
if dim is not None and not extras_keys and not needs_v:
|
|
286
|
+
_probe_call(result, dim=dim, rank=rank, n_features=n_features, label="make_sf")
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def make_interactor(
|
|
291
|
+
func: Callable,
|
|
292
|
+
*,
|
|
293
|
+
dim: int,
|
|
294
|
+
rank: Rank,
|
|
295
|
+
# arity:
|
|
296
|
+
K: int | None = None,
|
|
297
|
+
Kmax: int | None = None,
|
|
298
|
+
# features & plumbing:
|
|
299
|
+
n_features: int = 1,
|
|
300
|
+
needs_v: bool = False,
|
|
301
|
+
labels: Iterable[str] = (),
|
|
302
|
+
descriptor=None,
|
|
303
|
+
params: ParamSuite | None = None,
|
|
304
|
+
extras_keys: Iterable[str] = (),
|
|
305
|
+
particle_extras: Iterable[str] = (),
|
|
306
|
+
):
|
|
307
|
+
"""
|
|
308
|
+
Build a local interaction dictionary (Interactor) from a single-sample user
|
|
309
|
+
function that consumes (K, dim) and returns feature-last.
|
|
310
|
+
|
|
311
|
+
Pass exactly one of:
|
|
312
|
+
- K=int → fixed arity
|
|
313
|
+
- Kmax=int → variable arity (ragged via mask)
|
|
314
|
+
|
|
315
|
+
``particle_extras`` names the extras keys whose values are
|
|
316
|
+
**per-particle** arrays (shape ``(P, ...)``): the dispatcher gathers
|
|
317
|
+
them per edge member, so inside ``func`` they arrive with shape
|
|
318
|
+
``(K, ...)`` — one entry per member of the local tuple. The
|
|
319
|
+
reserved ``"particle_index"`` extra (injected by
|
|
320
|
+
:class:`~SFI.trajectory.TrajectoryCollection`) combined with a
|
|
321
|
+
``(P,)``-shaped parameter gives per-particle inferred parameters::
|
|
322
|
+
|
|
323
|
+
def local(Xk, *, params, extras):
|
|
324
|
+
mob = params["mob"][extras["particle_index"]] # (K,)
|
|
325
|
+
...
|
|
326
|
+
"""
|
|
327
|
+
suite = ParamSuite.parse(params)
|
|
328
|
+
if (K is None) == (Kmax is None):
|
|
329
|
+
raise ValueError("Provide exactly one of K or Kmax.")
|
|
330
|
+
|
|
331
|
+
leaf = InteractionLeaf(
|
|
332
|
+
mode="fixed" if K is not None else "variable",
|
|
333
|
+
K=K,
|
|
334
|
+
Kmax=Kmax,
|
|
335
|
+
func=func,
|
|
336
|
+
dim=dim,
|
|
337
|
+
rank=rank,
|
|
338
|
+
n_features=n_features,
|
|
339
|
+
needs_v=needs_v,
|
|
340
|
+
param_suite=suite,
|
|
341
|
+
labels=tuple(labels),
|
|
342
|
+
descriptor=descriptor,
|
|
343
|
+
extras_keys=tuple(extras_keys),
|
|
344
|
+
particle_extras=tuple(particle_extras),
|
|
345
|
+
)
|
|
346
|
+
return Interactor(leaf)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import Any, Literal, Optional
|
|
2
|
+
|
|
3
|
+
from .basis import Basis
|
|
4
|
+
from .nodes.base import BaseNode
|
|
5
|
+
from .nodes.interactions.dispatcher import InteractionDispatcher
|
|
6
|
+
from .nodes.interactions.specs import (
|
|
7
|
+
AutoPairs,
|
|
8
|
+
FromExtrasPairsCSR,
|
|
9
|
+
HyperCSR,
|
|
10
|
+
HyperFixed,
|
|
11
|
+
PairsCSR,
|
|
12
|
+
SpecRule,
|
|
13
|
+
)
|
|
14
|
+
from .psf import PSF
|
|
15
|
+
from .stateexpr import StateExpr
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Helper: does the subtree contain any parameters?
|
|
19
|
+
def _tree_is_parametric(root: BaseNode) -> bool:
|
|
20
|
+
def _walk(n: Any) -> bool:
|
|
21
|
+
if getattr(n, "param_suite", None) is not None:
|
|
22
|
+
return True
|
|
23
|
+
for ch in getattr(n, "children", ()):
|
|
24
|
+
if _walk(ch):
|
|
25
|
+
return True
|
|
26
|
+
for attr in ("child", "inner"):
|
|
27
|
+
ch = getattr(n, attr, None)
|
|
28
|
+
if ch is not None and _walk(ch):
|
|
29
|
+
return True
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
return _walk(root)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Interactor(StateExpr):
|
|
36
|
+
"""
|
|
37
|
+
Local interaction expression (pre-dispatch).
|
|
38
|
+
|
|
39
|
+
- `root` must be a local graph built from InteractionLeaf(s):
|
|
40
|
+
particles_input=True, pdepth=0.
|
|
41
|
+
|
|
42
|
+
Compose as usual: `inter = make_interactor(...); inter2 = (inter & inter)...`
|
|
43
|
+
Then call `.dispatch(...)` exactly once to obtain a **Basis** or a **PSF**.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# sugar: keep the same fluent ops API as StateExpr (inherited)
|
|
47
|
+
|
|
48
|
+
# ---- Dispatch API ---------------------------------------------------------
|
|
49
|
+
def dispatch(
|
|
50
|
+
self,
|
|
51
|
+
spec: PairsCSR | HyperFixed | HyperCSR | SpecRule,
|
|
52
|
+
*,
|
|
53
|
+
owners: Literal["focal", "all", "custom", "global"] = "focal",
|
|
54
|
+
focal_index: int = 0,
|
|
55
|
+
owner_weights=None,
|
|
56
|
+
reducer: Literal["sum", "mean", "max"] = "sum",
|
|
57
|
+
normalize_by_degree: bool = False,
|
|
58
|
+
exclude_self: bool = True,
|
|
59
|
+
chunk_size: Optional[int] = None,
|
|
60
|
+
return_as: Literal["auto", "basis", "psf"] = "auto",
|
|
61
|
+
drop_features: Optional[bool] = None,
|
|
62
|
+
):
|
|
63
|
+
disp = InteractionDispatcher(
|
|
64
|
+
self.root,
|
|
65
|
+
spec=spec,
|
|
66
|
+
owners=owners,
|
|
67
|
+
focal_index=focal_index,
|
|
68
|
+
owner_weights=owner_weights,
|
|
69
|
+
reducer=reducer,
|
|
70
|
+
normalize_by_degree=normalize_by_degree,
|
|
71
|
+
exclude_self=exclude_self,
|
|
72
|
+
chunk_size=chunk_size,
|
|
73
|
+
)
|
|
74
|
+
# Decide wrapper kind
|
|
75
|
+
kind = return_as
|
|
76
|
+
if kind == "auto":
|
|
77
|
+
kind = "psf" if _tree_is_parametric(self.root) else "basis"
|
|
78
|
+
if kind == "basis":
|
|
79
|
+
return Basis(disp)
|
|
80
|
+
elif kind == "psf":
|
|
81
|
+
return PSF(disp, drop_features=drop_features)
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f"Unknown return_as={return_as!r}")
|
|
84
|
+
|
|
85
|
+
# common sugars for pairs
|
|
86
|
+
def dispatch_pairs(self, *, symmetric=True, exclude_self=True, **kwargs):
|
|
87
|
+
return self.dispatch(AutoPairs(symmetric=symmetric, exclude_self=exclude_self), **kwargs)
|
|
88
|
+
|
|
89
|
+
def dispatch_pairs_from_extras(self, *, indptr_key: str, indices_key: str, **kwargs):
|
|
90
|
+
return self.dispatch(FromExtrasPairsCSR(indptr_key, indices_key), **kwargs)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Structured-dimensions layout sub-package.
|
|
2
|
+
|
|
3
|
+
Re-exports for backward compatibility with ``from SFI.statefunc.layout import ...``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._base import (
|
|
7
|
+
IdentityLayout,
|
|
8
|
+
StateLayout,
|
|
9
|
+
_BaseLayout,
|
|
10
|
+
)
|
|
11
|
+
from ._grid import GridLayout
|
|
12
|
+
from ._sectors import (
|
|
13
|
+
ScalarSector,
|
|
14
|
+
Sector,
|
|
15
|
+
SymTensorSector,
|
|
16
|
+
TensorSector,
|
|
17
|
+
VectorSector,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ScalarSector",
|
|
22
|
+
"VectorSector",
|
|
23
|
+
"SymTensorSector",
|
|
24
|
+
"TensorSector",
|
|
25
|
+
"Sector",
|
|
26
|
+
"StateLayout",
|
|
27
|
+
"IdentityLayout",
|
|
28
|
+
"GridLayout",
|
|
29
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Layout protocol, base class, and IdentityLayout.
|
|
2
|
+
|
|
3
|
+
Defines :class:`StateLayout` (protocol), :class:`_BaseLayout` (implementation
|
|
4
|
+
base), and :class:`IdentityLayout` (trivial single-sector layout).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from ..structexpr import StructuredExpr, _ConstNode, _SectorLeaf
|
|
13
|
+
from ._sectors import Sector, VectorSector
|
|
14
|
+
|
|
15
|
+
# =====================================================================
|
|
16
|
+
# Layout instance counter (unique IDs for layout-compatibility checks)
|
|
17
|
+
# =====================================================================
|
|
18
|
+
|
|
19
|
+
_layout_counter = itertools.count()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _next_layout_id() -> int:
|
|
23
|
+
return next(_layout_counter)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =====================================================================
|
|
27
|
+
# Layout protocol
|
|
28
|
+
# =====================================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class StateLayout(Protocol):
|
|
33
|
+
"""Protocol for all layouts (Grid, Particle, Identity, …)."""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def dim(self) -> int:
|
|
37
|
+
"""Total data width (``x.shape[-1]``)."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def unpack(self) -> dict[str, StructuredExpr]:
|
|
41
|
+
"""Return named symbolic field leaves."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def embed(self, rank: int = 1, **named_fields: StructuredExpr) -> Any:
|
|
45
|
+
"""Compile inner expressions into outer ``StateExpr``."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# =====================================================================
|
|
50
|
+
# _BaseLayout (shared logic for all concrete layouts)
|
|
51
|
+
# =====================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _BaseLayout:
|
|
55
|
+
"""Common base for Layout implementations.
|
|
56
|
+
|
|
57
|
+
Handles sector storage, index validation, and field-expression
|
|
58
|
+
creation. Subclasses add engine-specific operators and ``embed()``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, *, dim: int, **sectors: Sector) -> None:
|
|
62
|
+
self._dim = dim
|
|
63
|
+
self._layout_id = _next_layout_id()
|
|
64
|
+
self._sectors: dict[str, Sector] = dict(sectors)
|
|
65
|
+
self._validate_indices()
|
|
66
|
+
self._fields = self._build_fields()
|
|
67
|
+
|
|
68
|
+
# --- public interface ---------------------------------------------
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def dim(self) -> int:
|
|
72
|
+
return self._dim
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def sectors(self) -> dict[str, Sector]:
|
|
76
|
+
"""Read-only mapping ``name → Sector``."""
|
|
77
|
+
return dict(self._sectors)
|
|
78
|
+
|
|
79
|
+
def unpack(self) -> dict[str, StructuredExpr]:
|
|
80
|
+
"""Return a dict of named symbolic field leaves."""
|
|
81
|
+
return dict(self._fields)
|
|
82
|
+
|
|
83
|
+
# --- attribute access for field names -----------------------------
|
|
84
|
+
|
|
85
|
+
def __getattr__(self, name: str) -> StructuredExpr:
|
|
86
|
+
# Guard against recursion during __init__
|
|
87
|
+
if name.startswith("_"):
|
|
88
|
+
raise AttributeError(name)
|
|
89
|
+
try:
|
|
90
|
+
fields = object.__getattribute__(self, "_fields")
|
|
91
|
+
except AttributeError:
|
|
92
|
+
raise AttributeError(name) from None
|
|
93
|
+
if name in fields:
|
|
94
|
+
return fields[name]
|
|
95
|
+
raise AttributeError(f"'{type(self).__name__}' has no field '{name}'. Available fields: {', '.join(fields)}")
|
|
96
|
+
|
|
97
|
+
# --- validation ---------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _validate_indices(self) -> None:
|
|
100
|
+
"""No overlap; all indices in ``range(dim)``."""
|
|
101
|
+
seen: dict[int, str] = {}
|
|
102
|
+
for name, sector in self._sectors.items():
|
|
103
|
+
for idx in sector.indices:
|
|
104
|
+
if not (0 <= idx < self._dim):
|
|
105
|
+
raise ValueError(f"Sector '{name}': index {idx} out of range for dim={self._dim}")
|
|
106
|
+
if idx in seen:
|
|
107
|
+
raise ValueError(f"Index {idx} appears in both sector '{seen[idx]}' and sector '{name}'")
|
|
108
|
+
seen[idx] = name
|
|
109
|
+
|
|
110
|
+
# --- internal -----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _build_fields(self) -> dict[str, StructuredExpr]:
|
|
113
|
+
fields: dict[str, StructuredExpr] = {}
|
|
114
|
+
for name, sector in self._sectors.items():
|
|
115
|
+
fields[name] = StructuredExpr(
|
|
116
|
+
sdims=sector.sdims,
|
|
117
|
+
n_features=1,
|
|
118
|
+
param_suite=None,
|
|
119
|
+
labels=(name,),
|
|
120
|
+
_layout_id=self._layout_id,
|
|
121
|
+
_node=_SectorLeaf(
|
|
122
|
+
sector_name=name,
|
|
123
|
+
indices=sector.indices,
|
|
124
|
+
sdims=sector.sdims,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
return fields
|
|
128
|
+
|
|
129
|
+
def const(
|
|
130
|
+
self,
|
|
131
|
+
value: float | int = 1,
|
|
132
|
+
label: str | None = None,
|
|
133
|
+
) -> StructuredExpr:
|
|
134
|
+
"""Scalar constant compatible with this layout.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
value : float or int
|
|
139
|
+
The constant value (default ``1``).
|
|
140
|
+
label : str, optional
|
|
141
|
+
Human-readable label. Defaults to ``str(int(value))`` for
|
|
142
|
+
integer-valued numbers, ``str(value)`` otherwise.
|
|
143
|
+
"""
|
|
144
|
+
if label is None:
|
|
145
|
+
if isinstance(value, int) or (isinstance(value, float) and value == int(value)):
|
|
146
|
+
label = str(int(value))
|
|
147
|
+
else:
|
|
148
|
+
label = f"{value:g}"
|
|
149
|
+
return StructuredExpr(
|
|
150
|
+
sdims=(),
|
|
151
|
+
n_features=1,
|
|
152
|
+
param_suite=None,
|
|
153
|
+
labels=(label,),
|
|
154
|
+
_layout_id=self._layout_id,
|
|
155
|
+
_node=_ConstNode(value=value, sdims=()),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def __repr__(self) -> str:
|
|
159
|
+
parts = [f"dim={self._dim}"]
|
|
160
|
+
for name, sector in self._sectors.items():
|
|
161
|
+
parts.append(f"{name}={sector!r}")
|
|
162
|
+
return f"{type(self).__name__}({', '.join(parts)})"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# =====================================================================
|
|
166
|
+
# IdentityLayout (trivial: one vector sector spanning all of dim)
|
|
167
|
+
# =====================================================================
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class IdentityLayout(_BaseLayout):
|
|
171
|
+
"""Trivial layout with a single ``state`` field spanning all of *dim*.
|
|
172
|
+
|
|
173
|
+
Example::
|
|
174
|
+
|
|
175
|
+
layout = IdentityLayout(dim=3)
|
|
176
|
+
x = layout.state # StructuredExpr(sdims=(3,), n_features=1)
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, dim: int) -> None:
|
|
180
|
+
super().__init__(
|
|
181
|
+
dim=dim,
|
|
182
|
+
state=VectorSector(indices=tuple(range(dim)), sdim=dim),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def embed(self, rank: int = 1, **named_fields: StructuredExpr) -> Any:
|
|
186
|
+
raise NotImplementedError("IdentityLayout.embed() is not yet implemented.")
|