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,422 @@
|
|
|
1
|
+
# SFI/statefunc/contract.py
|
|
2
|
+
# --------------------------
|
|
3
|
+
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from typing import Any, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
import equinox as eqx
|
|
8
|
+
from jaxtyping import Array
|
|
9
|
+
|
|
10
|
+
from ..params import ParamSuite
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Enums - ease of reading & cheap comparisons
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
class Rank(IntEnum):
|
|
17
|
+
SCALAR = 0
|
|
18
|
+
VECTOR = 1
|
|
19
|
+
MATRIX = 2
|
|
20
|
+
TENSOR3 = 3
|
|
21
|
+
TENSOR4 = 4 # extend if/when needed
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------
|
|
25
|
+
# Contract mix-in (rank | dim | pdepth | … + runtime guards)
|
|
26
|
+
# ---------------------------------------------------------------------
|
|
27
|
+
class _ContractMixin(eqx.Module):
|
|
28
|
+
"""
|
|
29
|
+
Static contract shared by every StateExpr node.
|
|
30
|
+
|
|
31
|
+
Parameters are stored as *eqx.field(static=True)* so they never flow into AD.
|
|
32
|
+
|
|
33
|
+
Attributes
|
|
34
|
+
----------
|
|
35
|
+
rank : Rank enum
|
|
36
|
+
Tensor rank of each basis element (0=scalar, 1=vector, ...).
|
|
37
|
+
dim : int | None
|
|
38
|
+
Spatial dimension (None = agnostic).
|
|
39
|
+
needs_v : bool
|
|
40
|
+
Dictionary requires velocity input?
|
|
41
|
+
pdepth : int
|
|
42
|
+
Particle depth: how many particle axes the node's **output** carries.
|
|
43
|
+
Inputs with `particles_input=True` *consume* one particle axis from `x`.
|
|
44
|
+
n_features : int
|
|
45
|
+
Number of basis functions/features carried by this node (feature-last).
|
|
46
|
+
param_suite : ParamSuite | None
|
|
47
|
+
- None → deterministic node
|
|
48
|
+
- ParamSuite → parametric node (no broadcasting of params).
|
|
49
|
+
extras_required : tuple[str, ...]
|
|
50
|
+
Presence-only requirements for the `extras` mapping. **No broadcasting is
|
|
51
|
+
validated here.** Dispatcher/leaves decide how extras are consumed.
|
|
52
|
+
particles_input : bool
|
|
53
|
+
If True, `x` is expected to have a particle axis: layout `batch · P · dim`.
|
|
54
|
+
sdims : tuple[int, ...] | None
|
|
55
|
+
Spatial dimensions of the node's output. None = agnostic. Useful for structured
|
|
56
|
+
data like grids, where each rank axis may have a different size. If provided, it
|
|
57
|
+
is used for validating outputs and merging contracts of composite nodes (e.g.
|
|
58
|
+
concatenation requires matching sdims on the concatenated axis). For leaf nodes,
|
|
59
|
+
if sdims is None but dim is provided, we treat all rank axes as having size `dim`.
|
|
60
|
+
For composite nodes, sdims must be explicitly provided if any child has sdims; we
|
|
61
|
+
do not attempt to infer or broadcast sdims from dim in that case.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# ---------- static metadata --------------------------------------
|
|
65
|
+
rank: "Rank" = eqx.field(static=True)
|
|
66
|
+
dim: Optional[int] = eqx.field(static=True, default=None)
|
|
67
|
+
needs_v: bool = eqx.field(static=True, default=False)
|
|
68
|
+
pdepth: int = eqx.field(static=True, default=0)
|
|
69
|
+
n_features: int = eqx.field(static=True, default=1)
|
|
70
|
+
param_suite: ParamSuite | None = eqx.field(static=True, default=None)
|
|
71
|
+
extras_required: tuple[str, ...] = eqx.field(static=True, default=())
|
|
72
|
+
particles_input: bool = eqx.field(static=True, default=False)
|
|
73
|
+
sdims: tuple[int, ...] | None = eqx.field(static=True, default=None)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def inherit_contract(child: "_ContractMixin", **overrides) -> dict:
|
|
77
|
+
"""
|
|
78
|
+
Start from child's static contract and override only what changes.
|
|
79
|
+
Keys: rank, dim, pdepth, n_features, needs_v, particles_input, sdims
|
|
80
|
+
"""
|
|
81
|
+
out = dict(
|
|
82
|
+
rank=child.rank,
|
|
83
|
+
dim=child.dim,
|
|
84
|
+
pdepth=child.pdepth,
|
|
85
|
+
n_features=child.n_features,
|
|
86
|
+
needs_v=child.needs_v,
|
|
87
|
+
particles_input=child.particles_input,
|
|
88
|
+
sdims=child.sdims,
|
|
89
|
+
)
|
|
90
|
+
out.update(overrides)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
# -----------------------------------------------------------------
|
|
94
|
+
# merge helper used by composite nodes
|
|
95
|
+
# -----------------------------------------------------------------
|
|
96
|
+
@classmethod
|
|
97
|
+
def merge_contract(
|
|
98
|
+
cls,
|
|
99
|
+
children: Sequence["_ContractMixin"],
|
|
100
|
+
*,
|
|
101
|
+
mode: str,
|
|
102
|
+
spec: str | None = None,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""
|
|
105
|
+
Single source of truth for merging static contracts of composite nodes.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
children : sequence of nodes
|
|
110
|
+
The operands to merge.
|
|
111
|
+
mode : {'concat','map','einsum'}
|
|
112
|
+
Semantic of the operation.
|
|
113
|
+
``'concat'``: feature-axis concatenation.
|
|
114
|
+
``'map'``: elementwise Hadamard-like map (identical shapes/features).
|
|
115
|
+
``'einsum'``: spatial contraction; requires ``spec``.
|
|
116
|
+
spec : str | None
|
|
117
|
+
Einstein notation for 'einsum' mode, lowercase letters only
|
|
118
|
+
on spatial axes. Example: "m,n->mn", "m,m->", ",n->n".
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
dict with keys: rank, dim, pdepth, n_features, needs_v, particles_input
|
|
123
|
+
|
|
124
|
+
Notes
|
|
125
|
+
-----
|
|
126
|
+
- Particle-depth and particles_input are merged with the broadcast rule:
|
|
127
|
+
Allowed mixes:
|
|
128
|
+
- identical (pdepth, particles_input) for all children
|
|
129
|
+
- OR (pdepth=0, particles_input=False) combined with (pdepth=1, particles_input=True)
|
|
130
|
+
nodes. In that case the input particle axis is treated as broadcast by the False node,
|
|
131
|
+
which aligns and conceptually matches the particle axis in the True node.
|
|
132
|
+
The merged depth is the **maximum**; particles_input is True if any child has it.
|
|
133
|
+
- This function is purposely strict on error messages to make debugging easy.
|
|
134
|
+
- Extras policy: composite nodes **union** children’s `extras_required` at construction
|
|
135
|
+
(presence-only). This function does not validate extras shapes or broadcasting.
|
|
136
|
+
"""
|
|
137
|
+
if not children:
|
|
138
|
+
raise ValueError("merge_contract(mode=%r): needs at least one child" % mode)
|
|
139
|
+
|
|
140
|
+
# ---------------- helpers ----------------
|
|
141
|
+
def _merge_pdepth(nodes: Sequence["_ContractMixin"]) -> tuple[int, bool]:
|
|
142
|
+
pd, flag = nodes[0].pdepth, nodes[0].particles_input
|
|
143
|
+
for ch in nodes[1:]:
|
|
144
|
+
if (ch.pdepth, ch.particles_input) == (pd, flag):
|
|
145
|
+
continue
|
|
146
|
+
# benign broadcast case
|
|
147
|
+
if (pd, flag) == (0, False) and ch.particles_input and ch.pdepth == 1:
|
|
148
|
+
pd, flag = ch.pdepth, True
|
|
149
|
+
continue
|
|
150
|
+
if (ch.pdepth, ch.particles_input) == (0, False) and flag and pd == 1:
|
|
151
|
+
# keep current pd/flag
|
|
152
|
+
continue
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"merge_contract(%s): incompatible particle-depth mix: "
|
|
155
|
+
"A(pdepth=%d, particles_input=%s) vs B(pdepth=%d, particles_input=%s)"
|
|
156
|
+
% (mode, pd, flag, ch.pdepth, ch.particles_input)
|
|
157
|
+
)
|
|
158
|
+
return int(pd), bool(flag)
|
|
159
|
+
|
|
160
|
+
def _unify_dim(nodes: Sequence["_ContractMixin"]) -> int | None:
|
|
161
|
+
dims = [n.dim for n in nodes]
|
|
162
|
+
concrete = [d for d in dims if d is not None]
|
|
163
|
+
if not concrete:
|
|
164
|
+
return None
|
|
165
|
+
d0 = concrete[0]
|
|
166
|
+
if any(d != d0 for d in concrete[1:]):
|
|
167
|
+
raise ValueError("merge_contract(%s): children disagree on `dim`: %s" % (mode, tuple(dims)))
|
|
168
|
+
return d0
|
|
169
|
+
|
|
170
|
+
def _all_equal(attrs: tuple[str, ...]) -> tuple[bool, dict]:
|
|
171
|
+
"""Check equality across children for the given attributes."""
|
|
172
|
+
vals = {a: tuple(getattr(ch, a) for ch in children) for a in attrs}
|
|
173
|
+
ok = all(len(set(vals[a])) == 1 for a in attrs)
|
|
174
|
+
return ok, vals
|
|
175
|
+
|
|
176
|
+
def _unify_sdims(nodes: Sequence["_ContractMixin"]) -> tuple[int, ...] | None:
|
|
177
|
+
"""Merge sdims: all must agree, or all be None."""
|
|
178
|
+
all_sd = [n.sdims for n in nodes]
|
|
179
|
+
if all(s is None for s in all_sd):
|
|
180
|
+
return None
|
|
181
|
+
if any(s is None for s in all_sd):
|
|
182
|
+
raise ValueError(
|
|
183
|
+
"merge_contract(%s): some children have sdims=None while "
|
|
184
|
+
"others have explicit sdims: %s" % (mode, all_sd)
|
|
185
|
+
)
|
|
186
|
+
s0 = all_sd[0]
|
|
187
|
+
if any(s != s0 for s in all_sd[1:]):
|
|
188
|
+
raise ValueError("merge_contract(%s): children disagree on sdims: %s" % (mode, all_sd))
|
|
189
|
+
return s0
|
|
190
|
+
|
|
191
|
+
# -------------- fields independent of mode --------------
|
|
192
|
+
needs_v = any(ch.needs_v for ch in children)
|
|
193
|
+
pdepth, pflag = _merge_pdepth(children)
|
|
194
|
+
dim = _unify_dim(children)
|
|
195
|
+
|
|
196
|
+
# -------------- mode-specific logic ---------------------
|
|
197
|
+
mode = mode.lower()
|
|
198
|
+
if mode == "concat":
|
|
199
|
+
ok, vals = _all_equal(("rank",))
|
|
200
|
+
if not ok:
|
|
201
|
+
raise ValueError(f"merge_contract(concat): children must share rank; got rank={vals['rank']}")
|
|
202
|
+
n_features = sum(int(ch.n_features) for ch in children)
|
|
203
|
+
rank = int(children[0].rank)
|
|
204
|
+
sdims = _unify_sdims(children)
|
|
205
|
+
|
|
206
|
+
elif mode in {"map"}:
|
|
207
|
+
ok, vals = _all_equal(("rank", "n_features"))
|
|
208
|
+
if not ok:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"merge_contract({mode}): children must share rank and n_features; "
|
|
211
|
+
f"got rank={vals['rank']}, n_features={vals['n_features']}"
|
|
212
|
+
)
|
|
213
|
+
n_features = int(children[0].n_features)
|
|
214
|
+
rank = int(children[0].rank)
|
|
215
|
+
sdims = _unify_sdims(children)
|
|
216
|
+
|
|
217
|
+
elif mode == "einsum":
|
|
218
|
+
if not spec or "->" not in spec:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"merge_contract(einsum): a valid `spec` like 'i,j->ij' is required"
|
|
221
|
+
" (with RHS; no ellipses; batch is implicit)"
|
|
222
|
+
)
|
|
223
|
+
if "..." in spec:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"merge_contract(einsum): ellipsis '...' is reserved for batch and must NOT appear in specs"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
lhs, rhs = spec.replace(" ", "").split("->")
|
|
229
|
+
lhs_ops = lhs.split(",")
|
|
230
|
+
|
|
231
|
+
if len(lhs_ops) != len(children):
|
|
232
|
+
raise ValueError(
|
|
233
|
+
"merge_contract(einsum): spec lists %d operands but %d children were provided"
|
|
234
|
+
% (len(lhs_ops), len(children))
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _letters_only(op: str) -> str:
|
|
238
|
+
# Only lowercase letters; no dots allowed at all.
|
|
239
|
+
if "." in op:
|
|
240
|
+
raise ValueError(f"merge_contract(einsum): malformed operand {op!r} (dots/ellipsis not allowed)")
|
|
241
|
+
bad = [c for c in op if not c.islower()]
|
|
242
|
+
if bad:
|
|
243
|
+
raise ValueError(f"merge_contract(einsum): operand {op!r} contains non-lowercase symbols: {bad}")
|
|
244
|
+
return op
|
|
245
|
+
|
|
246
|
+
# LHS validation and rank compatibility (spatial rank == len(token))
|
|
247
|
+
lhs_clean = []
|
|
248
|
+
for idx, (ch, op) in enumerate(zip(children, lhs_ops)):
|
|
249
|
+
core = _letters_only(op)
|
|
250
|
+
if len(core) != int(ch.rank):
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"merge_contract(einsum): rank mismatch for child %d: child.rank=%d, "
|
|
253
|
+
"operand %r has %d spatial letters" % (idx, int(ch.rank), op, len(core))
|
|
254
|
+
)
|
|
255
|
+
lhs_clean.append(core)
|
|
256
|
+
|
|
257
|
+
# RHS validation: subset of union of LHS letters
|
|
258
|
+
rhs_core = _letters_only(rhs)
|
|
259
|
+
if set(rhs_core) - set("".join(lhs_clean)):
|
|
260
|
+
raise ValueError("merge_contract(einsum): RHS contains letters not present on LHS")
|
|
261
|
+
|
|
262
|
+
rank = len(rhs_core)
|
|
263
|
+
|
|
264
|
+
# Feature axes: Cartesian product
|
|
265
|
+
from functools import reduce
|
|
266
|
+
from operator import mul
|
|
267
|
+
|
|
268
|
+
n_features = reduce(mul, (int(ch.n_features) for ch in children), 1)
|
|
269
|
+
|
|
270
|
+
# --- sdims for einsum: build per-letter size map ---
|
|
271
|
+
any_has_sdims = any(ch.sdims is not None for ch in children)
|
|
272
|
+
if any_has_sdims:
|
|
273
|
+
letter_size: dict[str, int] = {}
|
|
274
|
+
for ch, op in zip(children, lhs_clean):
|
|
275
|
+
ch_sdims = ch.sdims
|
|
276
|
+
if ch_sdims is None:
|
|
277
|
+
# Uniform dim: all rank axes are dim-sized
|
|
278
|
+
if ch.dim is None:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
"merge_contract(einsum): child has sdims=None and "
|
|
281
|
+
"dim=None but other children have sdims; cannot "
|
|
282
|
+
"determine letter sizes"
|
|
283
|
+
)
|
|
284
|
+
ch_sdims = (ch.dim,) * int(ch.rank)
|
|
285
|
+
for letter, size in zip(op, ch_sdims):
|
|
286
|
+
if letter in letter_size:
|
|
287
|
+
if letter_size[letter] != size:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"merge_contract(einsum): letter {letter!r} has "
|
|
290
|
+
f"inconsistent sizes: {letter_size[letter]} vs {size}"
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
letter_size[letter] = size
|
|
294
|
+
sdims = tuple(letter_size[c] for c in rhs_core) if rhs_core else ()
|
|
295
|
+
else:
|
|
296
|
+
sdims = None
|
|
297
|
+
|
|
298
|
+
else:
|
|
299
|
+
raise ValueError("merge_contract: unknown mode %r" % mode)
|
|
300
|
+
|
|
301
|
+
return dict(
|
|
302
|
+
rank=Rank(rank),
|
|
303
|
+
dim=dim,
|
|
304
|
+
sdims=sdims,
|
|
305
|
+
pdepth=int(pdepth),
|
|
306
|
+
n_features=int(n_features),
|
|
307
|
+
needs_v=bool(needs_v),
|
|
308
|
+
particles_input=bool(pflag),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _assert_inputs(
|
|
312
|
+
self,
|
|
313
|
+
x: Array,
|
|
314
|
+
v: Optional[Array],
|
|
315
|
+
mask: Optional[Array],
|
|
316
|
+
extras: Any | None = None,
|
|
317
|
+
):
|
|
318
|
+
# ---------- spatial dimension ---------------------------------
|
|
319
|
+
if (self.dim is not None) and (x.shape[-1] != self.dim):
|
|
320
|
+
raise ValueError(f"[Node] expected x.shape[-1]=={self.dim}, got {x.shape[-1]}")
|
|
321
|
+
|
|
322
|
+
# ---------- minimal rank of x (batch · [P?] · dim) ------------
|
|
323
|
+
min_ndim = 2 if self.particles_input else 1
|
|
324
|
+
if x.ndim < min_ndim:
|
|
325
|
+
need = "batch·P·dim" if self.particles_input else "batch·dim"
|
|
326
|
+
raise ValueError(f"[Node] x.ndim={x.ndim} < required for {need}")
|
|
327
|
+
|
|
328
|
+
# Prefixes used for later checks
|
|
329
|
+
prefix_shape = x.shape[:-1] # everything except spatial dim (includes P if present)
|
|
330
|
+
|
|
331
|
+
# ---------- velocity ------------------------------------------
|
|
332
|
+
if self.needs_v:
|
|
333
|
+
if v is None:
|
|
334
|
+
raise ValueError("[Node] velocity `v` required but not provided")
|
|
335
|
+
if v.shape != x.shape:
|
|
336
|
+
raise ValueError("[Node] `v` must have same shape as `x`")
|
|
337
|
+
|
|
338
|
+
# ---------- helpers -------------------------------------------
|
|
339
|
+
def _strip_trailing_ones(shp: tuple[int, ...]) -> tuple[int, ...]:
|
|
340
|
+
k = len(shp)
|
|
341
|
+
while k > 0 and shp[k - 1] == 1:
|
|
342
|
+
k -= 1
|
|
343
|
+
return shp[:k]
|
|
344
|
+
|
|
345
|
+
def _broadcastable(dst: tuple[int, ...], src: tuple[int, ...]) -> bool:
|
|
346
|
+
# NumPy broadcasting: align from the right
|
|
347
|
+
for a, b in zip(dst[::-1], src[::-1]):
|
|
348
|
+
if b not in (1, a):
|
|
349
|
+
return False
|
|
350
|
+
if len(src) > len(dst):
|
|
351
|
+
lead = src[: len(src) - len(dst)]
|
|
352
|
+
return all(d == 1 for d in lead)
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
# ---------- mask ----------------------------------------------
|
|
356
|
+
if mask is not None:
|
|
357
|
+
# Allow boolean OR numeric masks. Require broadcast to prefix_shape (batch·[P]),
|
|
358
|
+
# without stripping trailing ones (so (B,1) can broadcast to (B,P)).
|
|
359
|
+
if hasattr(mask, "shape"):
|
|
360
|
+
mshape = tuple(mask.shape)
|
|
361
|
+
|
|
362
|
+
if not _broadcastable(prefix_shape, mshape):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"[Node] mask must broadcast to x's batch/particle prefix: "
|
|
365
|
+
f"expected {prefix_shape}, got {mask.shape}"
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
# Non-array (e.g. Python float) is treated as scalar broadcast.
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
# ----- extras: presence-only contract (no broadcasting checks here) -----
|
|
372
|
+
req = tuple(getattr(self, "extras_required", ()) or ())
|
|
373
|
+
|
|
374
|
+
if req:
|
|
375
|
+
if extras is None:
|
|
376
|
+
# Named keys were declared but no mapping was provided at all.
|
|
377
|
+
raise KeyError(f"[Node] missing extras keys: {req}")
|
|
378
|
+
missing = [k for k in req if k not in extras]
|
|
379
|
+
if missing:
|
|
380
|
+
raise KeyError(f"[Node] missing extras keys: {tuple(missing)}")
|
|
381
|
+
# (If extras is provided, we do not validate shapes here—dispatcher/leaf runtime handles that.)
|
|
382
|
+
|
|
383
|
+
# -----------------------------------------------------------------
|
|
384
|
+
def _assert_outputs(self, x: Array, y: Array):
|
|
385
|
+
# -------- feature axis size -----------------------------------------
|
|
386
|
+
if y.shape[-1] != self.n_features:
|
|
387
|
+
raise ValueError(f"[Node] expected last axis length {self.n_features}, got {y.shape[-1]}")
|
|
388
|
+
|
|
389
|
+
# -------- rank axes size ---------------------------------------------
|
|
390
|
+
r_axes = int(self.rank) # 0 = scalar, 1 = vector, etc.
|
|
391
|
+
rank_shape = () if r_axes == 0 else y.shape[-(r_axes + 1) : -1]
|
|
392
|
+
if r_axes > 0:
|
|
393
|
+
if self.sdims is not None:
|
|
394
|
+
# Structured dimensions: per-axis sizes from sdims
|
|
395
|
+
expected_rank = self.sdims
|
|
396
|
+
elif self.dim is not None:
|
|
397
|
+
# Uniform dimensions: all rank axes are dim-sized
|
|
398
|
+
expected_rank = (self.dim,) * r_axes
|
|
399
|
+
else:
|
|
400
|
+
expected_rank = None
|
|
401
|
+
if expected_rank is not None and rank_shape != expected_rank:
|
|
402
|
+
raise ValueError(f"[Node] rank axes shape {rank_shape} ≠ expected {expected_rank}")
|
|
403
|
+
|
|
404
|
+
# -------- batch / particle prefix --------------------
|
|
405
|
+
# Layouts:
|
|
406
|
+
# x : batch · [P_in?] · dim
|
|
407
|
+
# y : batch · [P_out^pdepth] · (rank) · feature
|
|
408
|
+
y_prefix = y.shape[: -(r_axes + 1)]
|
|
409
|
+
x_batch = x.shape[:-2] if self.particles_input else x.shape[:-1]
|
|
410
|
+
|
|
411
|
+
if self.particles_input:
|
|
412
|
+
P_in = x.shape[-2]
|
|
413
|
+
expected = x_batch + ((P_in,) * int(self.pdepth))
|
|
414
|
+
else:
|
|
415
|
+
expected = x_batch if int(self.pdepth) == 0 else None
|
|
416
|
+
|
|
417
|
+
if expected is None or y_prefix != expected:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
"[Node] output prefix incompatible: "
|
|
420
|
+
f"y_prefix={y_prefix}, expected={expected}, "
|
|
421
|
+
f"particles_input={self.particles_input}, pdepth={self.pdepth}"
|
|
422
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .dispatcher import InteractionDispatcher
|
|
2
|
+
from .specs import (
|
|
3
|
+
AutoPairs,
|
|
4
|
+
CachedRule,
|
|
5
|
+
FromExtrasHyperFixed,
|
|
6
|
+
FromExtrasPairsCSR,
|
|
7
|
+
HyperCSR,
|
|
8
|
+
HyperFixed,
|
|
9
|
+
PairsCSR,
|
|
10
|
+
SpecRule,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PairsCSR",
|
|
15
|
+
"HyperFixed",
|
|
16
|
+
"HyperCSR",
|
|
17
|
+
"SpecRule",
|
|
18
|
+
"CachedRule",
|
|
19
|
+
"FromExtrasHyperFixed",
|
|
20
|
+
"AutoPairs",
|
|
21
|
+
"FromExtrasPairsCSR",
|
|
22
|
+
"InteractionDispatcher",
|
|
23
|
+
]
|