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,1365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Literal, Mapping, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
import equinox as eqx
|
|
6
|
+
import jax
|
|
7
|
+
import jax.numpy as jnp
|
|
8
|
+
from jaxtyping import Array
|
|
9
|
+
|
|
10
|
+
from ...memhint import MemHint, SampleMeta, itemsize_of, resolve_P
|
|
11
|
+
from ..base import BaseNode, BaseOpNode
|
|
12
|
+
from ..contract import Rank, _ContractMixin
|
|
13
|
+
from ..leaf import InteractionLeaf
|
|
14
|
+
from .specs import HyperCSR, HyperFixed, PairsCSR, SpecRule
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Small helper for rank axis sizes (vector/matrix → dim per axis).
|
|
18
|
+
def _rank_shape(rank: Rank, d: int) -> Tuple[int, ...]:
|
|
19
|
+
r = int(rank)
|
|
20
|
+
return () if r == 0 else (d,) * r
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Walk the interactor subtree and infer K mode; “fixed wins”.
|
|
24
|
+
def _infer_interactor_K(interactor: BaseNode):
|
|
25
|
+
fixed_Ks, var_seen = set(), False
|
|
26
|
+
|
|
27
|
+
def _walk(n):
|
|
28
|
+
nonlocal var_seen
|
|
29
|
+
if isinstance(n, InteractionLeaf):
|
|
30
|
+
if n.mode == "fixed":
|
|
31
|
+
fixed_Ks.add(int(n.K))
|
|
32
|
+
else:
|
|
33
|
+
var_seen = True
|
|
34
|
+
for ch in getattr(n, "children", ()):
|
|
35
|
+
_walk(ch)
|
|
36
|
+
|
|
37
|
+
_walk(interactor)
|
|
38
|
+
|
|
39
|
+
if fixed_Ks:
|
|
40
|
+
if len(fixed_Ks) != 1:
|
|
41
|
+
raise ValueError(f"Interactor mixes fixed arities {sorted(fixed_Ks)}; split across dispatchers.")
|
|
42
|
+
return "fixed", int(next(iter(fixed_Ks)))
|
|
43
|
+
if var_seen:
|
|
44
|
+
return "variable", None
|
|
45
|
+
|
|
46
|
+
# No InteractionLeaves found → likely a SimpleLeaf graph. Not allowed as interactor.
|
|
47
|
+
raise ValueError("Interactor must be built from InteractionLeaves (particles_input=True, pdepth=0).")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InteractionDispatcher(BaseOpNode):
|
|
51
|
+
"""
|
|
52
|
+
Stream K-body interactions defined by `spec` (or a rule), evaluate a local
|
|
53
|
+
interactor that consumes (K, d), and scatter-reduce to per-particle or global
|
|
54
|
+
outputs.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
owners='global' → (..., ``*rank``, m) # pdepth = 0
|
|
58
|
+
else → (..., P, ``*rank``, m) # pdepth = 1
|
|
59
|
+
|
|
60
|
+
Contract invariants:
|
|
61
|
+
- This node always *consumes* a particle axis (…, P, d) → particles_input=True.
|
|
62
|
+
- Output pdepth depends only on `owners`.
|
|
63
|
+
- Interactor must be local: particles_input=True, pdepth=0.
|
|
64
|
+
|
|
65
|
+
structural_extras : tuple[str, ...]
|
|
66
|
+
Keys used by the rule/dispatcher (e.g., CSR arrays). They are never forwarded
|
|
67
|
+
to the interactor subtree.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
CONTRACT_MODE = None # we use unary inherit
|
|
71
|
+
|
|
72
|
+
# Child
|
|
73
|
+
interactor: BaseNode = eqx.field(static=True)
|
|
74
|
+
# Spec or rule
|
|
75
|
+
spec: PairsCSR | HyperFixed | HyperCSR | SpecRule = eqx.field(static=True)
|
|
76
|
+
|
|
77
|
+
# Reduction / ownership
|
|
78
|
+
owners: Literal["focal", "all", "custom", "global"] = eqx.field(static=True, default="focal")
|
|
79
|
+
focal_index: int = eqx.field(static=True, default=0)
|
|
80
|
+
owner_weights: Optional[Array] = eqx.field(static=False, default=None)
|
|
81
|
+
reducer: Literal["sum", "mean", "max"] = eqx.field(static=True, default="sum")
|
|
82
|
+
normalize_by_degree: bool = eqx.field(static=True, default=False)
|
|
83
|
+
exclude_self: bool = eqx.field(static=True, default=True)
|
|
84
|
+
chunk_size: Optional[int] = eqx.field(static=True, default=None)
|
|
85
|
+
|
|
86
|
+
# K-mode info (constructor-time check; “fixed wins”)
|
|
87
|
+
_kmode: Literal["fixed", "variable"] = eqx.field(static=True, repr=False)
|
|
88
|
+
_K: Optional[int] = eqx.field(static=True, repr=False, default=None)
|
|
89
|
+
_enforce_fixedK_on_hypercsr: bool = eqx.field(static=True, repr=False, default=False)
|
|
90
|
+
|
|
91
|
+
# ───────────────────────── constructor ────────────────────────────
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
interactor: BaseNode,
|
|
95
|
+
*,
|
|
96
|
+
spec: PairsCSR | HyperFixed | HyperCSR | SpecRule,
|
|
97
|
+
owners: Literal["focal", "all", "custom", "global"] = "focal",
|
|
98
|
+
focal_index: int = 0,
|
|
99
|
+
owner_weights: Optional[Array] = None,
|
|
100
|
+
reducer: Literal["sum", "mean", "max"] = "sum",
|
|
101
|
+
normalize_by_degree: bool = False,
|
|
102
|
+
exclude_self: bool = True,
|
|
103
|
+
chunk_size: Optional[int] = None,
|
|
104
|
+
):
|
|
105
|
+
# Interactor must be local (no global P leaking through)
|
|
106
|
+
if not interactor.particles_input or interactor.pdepth != 0:
|
|
107
|
+
raise ValueError("Interactor must have particles_input=True and pdepth=0.")
|
|
108
|
+
|
|
109
|
+
object.__setattr__(self, "interactor", interactor)
|
|
110
|
+
object.__setattr__(self, "spec", spec)
|
|
111
|
+
|
|
112
|
+
object.__setattr__(self, "owners", owners)
|
|
113
|
+
object.__setattr__(self, "focal_index", int(focal_index))
|
|
114
|
+
object.__setattr__(self, "owner_weights", owner_weights)
|
|
115
|
+
object.__setattr__(self, "reducer", reducer)
|
|
116
|
+
object.__setattr__(self, "normalize_by_degree", bool(normalize_by_degree))
|
|
117
|
+
object.__setattr__(self, "exclude_self", bool(exclude_self))
|
|
118
|
+
object.__setattr__(self, "chunk_size", chunk_size)
|
|
119
|
+
|
|
120
|
+
# Interactor K-mode
|
|
121
|
+
kmode, K = _infer_interactor_K(interactor)
|
|
122
|
+
spec_mode, spec_K = self._spec_arity(spec)
|
|
123
|
+
|
|
124
|
+
# “Fixed wins” across interactor+spec
|
|
125
|
+
if kmode == "fixed" or spec_mode == "fixed":
|
|
126
|
+
K_eff = spec_K if spec_mode == "fixed" else K
|
|
127
|
+
if kmode == "fixed" and K is not None and K_eff is not None and K != K_eff:
|
|
128
|
+
raise ValueError(f"Fixed-K mismatch between interactor (K={K}) and spec (K={K_eff}).")
|
|
129
|
+
kmode, K = "fixed", (K or K_eff)
|
|
130
|
+
if isinstance(spec, HyperCSR):
|
|
131
|
+
object.__setattr__(self, "_enforce_fixedK_on_hypercsr", True)
|
|
132
|
+
|
|
133
|
+
object.__setattr__(self, "_kmode", kmode)
|
|
134
|
+
object.__setattr__(self, "_K", K)
|
|
135
|
+
|
|
136
|
+
# Build BaseOpNode with one child (unifies params/extras contracts)
|
|
137
|
+
super().__init__(interactor)
|
|
138
|
+
|
|
139
|
+
# If rule advertises broadcast-checked extras, union them (currently none).
|
|
140
|
+
# Structural extras are owned by the rule/dispatcher and are never forwarded downstream.
|
|
141
|
+
if isinstance(spec, SpecRule):
|
|
142
|
+
req = set(self.extras_required).union(spec.required_extras())
|
|
143
|
+
object.__setattr__(self, "extras_required", tuple(sorted(req)))
|
|
144
|
+
object.__setattr__(self, "structural_extras", tuple(spec.structural_extras()))
|
|
145
|
+
else:
|
|
146
|
+
object.__setattr__(self, "structural_extras", ())
|
|
147
|
+
|
|
148
|
+
# Dispatcher *always* consumes a particle axis
|
|
149
|
+
object.__setattr__(self, "particles_input", True)
|
|
150
|
+
|
|
151
|
+
# ───────────────────────── arity helpers ──────────────────────────
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _spec_arity(spec) -> tuple[Literal["fixed", "variable"], Optional[int]]:
|
|
154
|
+
if isinstance(spec, PairsCSR):
|
|
155
|
+
return "fixed", 2
|
|
156
|
+
if isinstance(spec, HyperFixed):
|
|
157
|
+
return "fixed", int(spec.hyper.shape[-1])
|
|
158
|
+
if isinstance(spec, HyperCSR):
|
|
159
|
+
return "variable", None
|
|
160
|
+
if isinstance(spec, SpecRule):
|
|
161
|
+
return spec.arity()
|
|
162
|
+
raise TypeError(f"Unknown spec type {type(spec)}")
|
|
163
|
+
|
|
164
|
+
# ───────────────────────── contract merge ─────────────────────────
|
|
165
|
+
# Unary inherit: take child's contract and override pdepth/particles_input.
|
|
166
|
+
def _merge_static(self, children):
|
|
167
|
+
ch = children[0]
|
|
168
|
+
new_pdepth = 0 if (self.owners == "global") else 1
|
|
169
|
+
return _ContractMixin.inherit_contract(
|
|
170
|
+
ch,
|
|
171
|
+
pdepth=new_pdepth,
|
|
172
|
+
particles_input=True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# ───────────────────────── runtime ────────────────────────────────
|
|
176
|
+
def __call__(self, x, *, params=None, v=None, mask=None, extras=None):
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# 1) Determine which extras are "structural" for THIS spec/rule.
|
|
179
|
+
# Structural extras are consumed by the dispatcher/spec builder and
|
|
180
|
+
# must not be forwarded to child local-ops.
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
if isinstance(self.spec, SpecRule):
|
|
183
|
+
structural = set(self.spec.structural_extras() or ())
|
|
184
|
+
else:
|
|
185
|
+
structural = set()
|
|
186
|
+
|
|
187
|
+
# Extras forwarded to child local-ops (and used for broadcast validation).
|
|
188
|
+
extras_child = extras
|
|
189
|
+
if extras is not None and structural:
|
|
190
|
+
extras_child = {k: v for k, v in extras.items() if k not in structural}
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# 2) Centralized input checks should be done on *forwarded* extras only:
|
|
194
|
+
# - structural arrays like (P,K) must not be required/broadcast-checked
|
|
195
|
+
# as they are not part of the child API.
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
self._assert_inputs(x, v, mask, extras_child)
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# 3) Build concrete spec. This may require structural extras, so build
|
|
201
|
+
# from the original `extras`, not the filtered version.
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
spec = self.spec.build(x, v=v, mask=mask, extras=extras) if isinstance(self.spec, SpecRule) else self.spec
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# 4) Run dispatch. Child should only see `extras_child`.
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
if isinstance(spec, PairsCSR):
|
|
209
|
+
y = self._pairs_csr_vectorized(x, v=v, mask=mask, extras=extras_child, params=params, spec=spec)
|
|
210
|
+
elif isinstance(spec, HyperFixed):
|
|
211
|
+
y = self._hyper_fixed_vectorized(x, v=v, mask=mask, extras=extras_child, params=params, spec=spec)
|
|
212
|
+
elif isinstance(spec, HyperCSR):
|
|
213
|
+
y = self._hyper_csr_vectorized(x, v=v, mask=mask, extras=extras_child, params=params, spec=spec)
|
|
214
|
+
else:
|
|
215
|
+
raise TypeError(f"Unknown spec type {type(spec)}")
|
|
216
|
+
|
|
217
|
+
self._assert_outputs(x, y)
|
|
218
|
+
return y
|
|
219
|
+
|
|
220
|
+
# ───────────────────────── PAIRS / CSR path ───────────────────────
|
|
221
|
+
def _pairs_csr_vectorized(self, x, *, v, mask, extras, params, spec: PairsCSR):
|
|
222
|
+
*batch, P, d = x.shape
|
|
223
|
+
globals_extras, particle_arrays = self._split_extras(extras or {}, P=P)
|
|
224
|
+
|
|
225
|
+
def _per_sample(xb, vb, mb, iptr, idx):
|
|
226
|
+
Pb = xb.shape[0]
|
|
227
|
+
|
|
228
|
+
# degrees per row (for degree-normalized 'mean')
|
|
229
|
+
deg = (iptr[1:] - iptr[:-1]).astype(jnp.int32) # (P,)
|
|
230
|
+
|
|
231
|
+
# Flat (row, col) without jnp.repeat
|
|
232
|
+
M = idx.shape[0]
|
|
233
|
+
k = jnp.arange(M, dtype=jnp.int32)
|
|
234
|
+
row = jnp.searchsorted(iptr[1:], k, side="right") # (M,)
|
|
235
|
+
col = idx # (M,)
|
|
236
|
+
|
|
237
|
+
# Keep mask for self-edges; used only arithmetically
|
|
238
|
+
keep = jnp.logical_not(row == col) if self.exclude_self else jnp.ones((M,), dtype=bool)
|
|
239
|
+
|
|
240
|
+
# ---------------------------- UNCHUNKED ----------------------------
|
|
241
|
+
if self.chunk_size is None:
|
|
242
|
+
Xi, Xj = xb[row], xb[col] # (M,d)
|
|
243
|
+
Xk = jnp.stack([Xi, Xj], axis=1) # (M,2,d)
|
|
244
|
+
|
|
245
|
+
Vk = None
|
|
246
|
+
if vb is not None:
|
|
247
|
+
Vi, Vj = vb[row], vb[col]
|
|
248
|
+
Vk = jnp.stack([Vi, Vj], axis=1)
|
|
249
|
+
|
|
250
|
+
Mk = None
|
|
251
|
+
if mb is not None:
|
|
252
|
+
mi, mj = mb[row], mb[col]
|
|
253
|
+
Mk = jnp.stack([mi, mj], axis=1)
|
|
254
|
+
|
|
255
|
+
ex_edge = self._gather_particle_extras(particle_arrays, row, col) # (M, 2, ...)
|
|
256
|
+
ex_local = globals_extras if not ex_edge else ({**globals_extras, **ex_edge})
|
|
257
|
+
yloc = self.interactor(Xk, v=Vk, mask=Mk, params=params, extras=ex_local)
|
|
258
|
+
|
|
259
|
+
km = keep.reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
260
|
+
yloc = yloc * km
|
|
261
|
+
|
|
262
|
+
if self.owners == "global":
|
|
263
|
+
if self.reducer == "sum":
|
|
264
|
+
return yloc.sum(axis=0)
|
|
265
|
+
elif self.reducer == "mean":
|
|
266
|
+
return yloc.sum(axis=0) / jnp.maximum(1, keep.sum())
|
|
267
|
+
elif self.reducer == "max":
|
|
268
|
+
return yloc.max(axis=0)
|
|
269
|
+
else:
|
|
270
|
+
raise ValueError(f"Unknown reducer {self.reducer!r}")
|
|
271
|
+
|
|
272
|
+
# per-particle
|
|
273
|
+
if self.reducer == "mean" and self.normalize_by_degree:
|
|
274
|
+
y_r = yloc / jnp.maximum(1, deg[row]).reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
275
|
+
y_c = yloc / jnp.maximum(1, deg[col]).reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
276
|
+
else:
|
|
277
|
+
y_r = yloc
|
|
278
|
+
y_c = yloc
|
|
279
|
+
|
|
280
|
+
outP = jnp.zeros((Pb,) + yloc.shape[1:], dtype=xb.dtype)
|
|
281
|
+
if self.owners == "focal":
|
|
282
|
+
outP = outP.at[row].add(y_r)
|
|
283
|
+
elif self.owners == "all":
|
|
284
|
+
outP = outP.at[row].add(y_r).at[col].add(y_c)
|
|
285
|
+
elif self.owners == "custom":
|
|
286
|
+
w = self.owner_weights
|
|
287
|
+
if w is None or w.shape[0] != 2:
|
|
288
|
+
raise ValueError("owners='custom' requires owner_weights of shape (2,)")
|
|
289
|
+
outP = outP.at[row].add(w[0] * y_r).at[col].add(w[1] * y_c)
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"Unknown owners {self.owners!r}")
|
|
292
|
+
return outP
|
|
293
|
+
|
|
294
|
+
# ----------------------------- CHUNKED -----------------------------
|
|
295
|
+
CS = int(self.chunk_size)
|
|
296
|
+
pad = (CS - (M % CS)) % CS
|
|
297
|
+
Mpad = M + pad
|
|
298
|
+
|
|
299
|
+
# Pad arrays and build validity masks
|
|
300
|
+
row_pad = jnp.pad(row, (0, pad))
|
|
301
|
+
col_pad = jnp.pad(col, (0, pad))
|
|
302
|
+
keep_pad = jnp.pad(keep, (0, pad))
|
|
303
|
+
# marks true data vs. padded tail
|
|
304
|
+
data_mask = jnp.concatenate([jnp.ones((M,), dtype=bool), jnp.zeros((pad,), dtype=bool)], axis=0)
|
|
305
|
+
|
|
306
|
+
# Accumulators
|
|
307
|
+
rb = _rank_shape(self.interactor.rank, d)
|
|
308
|
+
if self.owners == "global":
|
|
309
|
+
if self.reducer == "max":
|
|
310
|
+
acc = jnp.full(rb + (int(self.n_features),), -jnp.inf, dtype=xb.dtype)
|
|
311
|
+
else:
|
|
312
|
+
acc = jnp.zeros(rb + (int(self.n_features),), dtype=xb.dtype)
|
|
313
|
+
cnt = jnp.zeros((), dtype=jnp.int32)
|
|
314
|
+
else:
|
|
315
|
+
acc = jnp.zeros((Pb,) + rb + (int(self.n_features),), dtype=xb.dtype)
|
|
316
|
+
cnt = jnp.zeros((), dtype=jnp.int32) # only used for global-mean
|
|
317
|
+
|
|
318
|
+
steps = Mpad // CS
|
|
319
|
+
|
|
320
|
+
def body(i, carry):
|
|
321
|
+
acc, cnt = carry
|
|
322
|
+
start = i * CS
|
|
323
|
+
r_blk = jax.lax.dynamic_slice_in_dim(row_pad, start, CS, axis=0)
|
|
324
|
+
c_blk = jax.lax.dynamic_slice_in_dim(col_pad, start, CS, axis=0)
|
|
325
|
+
k_blk = jax.lax.dynamic_slice_in_dim(keep_pad, start, CS, axis=0)
|
|
326
|
+
d_blk = jax.lax.dynamic_slice_in_dim(data_mask, start, CS, axis=0)
|
|
327
|
+
valid = jnp.logical_and(k_blk, d_blk)
|
|
328
|
+
|
|
329
|
+
Xi, Xj = xb[r_blk], xb[c_blk]
|
|
330
|
+
Xk = jnp.stack([Xi, Xj], axis=1)
|
|
331
|
+
|
|
332
|
+
Vk = None
|
|
333
|
+
if vb is not None:
|
|
334
|
+
Vi, Vj = vb[r_blk], vb[c_blk]
|
|
335
|
+
Vk = jnp.stack([Vi, Vj], axis=1)
|
|
336
|
+
|
|
337
|
+
Mk = None
|
|
338
|
+
if mb is not None:
|
|
339
|
+
mi, mj = mb[r_blk], mb[c_blk]
|
|
340
|
+
Mk = jnp.stack([mi, mj], axis=1)
|
|
341
|
+
|
|
342
|
+
ex_edge_blk = self._gather_particle_extras(particle_arrays, r_blk, c_blk) # (CS, 2, ...)
|
|
343
|
+
ex_local_blk = globals_extras if not ex_edge_blk else ({**globals_extras, **ex_edge_blk})
|
|
344
|
+
yloc = self.interactor(Xk, v=Vk, mask=Mk, params=params, extras=ex_local_blk)
|
|
345
|
+
vm = valid.reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
346
|
+
yloc = yloc * vm
|
|
347
|
+
|
|
348
|
+
if self.owners == "global":
|
|
349
|
+
if self.reducer in ("sum", "mean"):
|
|
350
|
+
acc = acc + yloc.sum(axis=0)
|
|
351
|
+
cnt = cnt + valid.sum().astype(cnt.dtype)
|
|
352
|
+
elif self.reducer == "max":
|
|
353
|
+
acc = jnp.maximum(acc, yloc.max(axis=0))
|
|
354
|
+
else:
|
|
355
|
+
raise ValueError(f"Unknown reducer {self.reducer!r}")
|
|
356
|
+
return acc, cnt
|
|
357
|
+
|
|
358
|
+
# per-particle owners
|
|
359
|
+
if self.reducer == "mean" and self.normalize_by_degree:
|
|
360
|
+
y_r = yloc / jnp.maximum(1, deg[r_blk]).reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
361
|
+
y_c = yloc / jnp.maximum(1, deg[c_blk]).reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
362
|
+
else:
|
|
363
|
+
y_r = yloc
|
|
364
|
+
y_c = yloc
|
|
365
|
+
|
|
366
|
+
if self.owners == "focal":
|
|
367
|
+
acc = acc.at[r_blk].add(y_r)
|
|
368
|
+
elif self.owners == "all":
|
|
369
|
+
acc = acc.at[r_blk].add(y_r).at[c_blk].add(y_c)
|
|
370
|
+
elif self.owners == "custom":
|
|
371
|
+
w = self.owner_weights
|
|
372
|
+
if w is None or w.shape[0] != 2:
|
|
373
|
+
raise ValueError("owners='custom' requires owner_weights of shape (2,)")
|
|
374
|
+
acc = acc.at[r_blk].add(w[0] * y_r).at[c_blk].add(w[1] * y_c)
|
|
375
|
+
else:
|
|
376
|
+
raise ValueError(f"Unknown owners {self.owners!r}")
|
|
377
|
+
return acc, cnt
|
|
378
|
+
|
|
379
|
+
accN, cntN = jax.lax.fori_loop(0, steps, body, (acc, cnt))
|
|
380
|
+
if self.owners == "global" and self.reducer == "mean":
|
|
381
|
+
accN = accN / jnp.maximum(1, cntN)
|
|
382
|
+
return accN
|
|
383
|
+
|
|
384
|
+
# vmap over batch prefix (or call once)
|
|
385
|
+
if batch:
|
|
386
|
+
# CSR arrays may or may not carry a leading batch axis.
|
|
387
|
+
# Static graphs: indptr (N+1,), indices (nnz,) → in_axes=None
|
|
388
|
+
# Per-step graphs: indptr (K,N+1), indices (K,nnz) → in_axes=0
|
|
389
|
+
iptr_ax = 0 if spec.indptr.ndim >= 2 else None
|
|
390
|
+
idx_ax = 0 if spec.indices.ndim >= 2 else None
|
|
391
|
+
vm = jax.vmap(
|
|
392
|
+
_per_sample,
|
|
393
|
+
in_axes=(
|
|
394
|
+
0,
|
|
395
|
+
0 if v is not None else None,
|
|
396
|
+
0 if mask is not None else None,
|
|
397
|
+
iptr_ax,
|
|
398
|
+
idx_ax,
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
return vm(x, v, mask, spec.indptr, spec.indices)
|
|
402
|
+
else:
|
|
403
|
+
return _per_sample(x, v, mask, spec.indptr, spec.indices)
|
|
404
|
+
|
|
405
|
+
### Hyper branch:
|
|
406
|
+
|
|
407
|
+
def _scatter_hyper(self, yloc: Array, hyper: Array, P: int, K: int, fi: int) -> Array:
|
|
408
|
+
"""
|
|
409
|
+
Scatter hyperedge-local outputs back onto the particle axis.
|
|
410
|
+
|
|
411
|
+
This method turns *local* contributions produced by the interactor on each
|
|
412
|
+
hyperedge into a *global* array indexed by particle id.
|
|
413
|
+
|
|
414
|
+
The interactor is called on gathered neighborhoods::
|
|
415
|
+
|
|
416
|
+
Xk = x[hyper] # (M, K, d) (plus optional batch axes)
|
|
417
|
+
yloc = interactor(Xk, ...) # (M, ...)
|
|
418
|
+
|
|
419
|
+
Two output conventions are supported:
|
|
420
|
+
|
|
421
|
+
1. **Per-hyperedge outputs** (typical for PDE stencils):
|
|
422
|
+
``yloc.shape == (M, ...)``
|
|
423
|
+
|
|
424
|
+
The same hyperedge contribution is scattered to one or more owners
|
|
425
|
+
depending on ``self.owners``.
|
|
426
|
+
|
|
427
|
+
2. **Per-slot outputs** (slot-resolved):
|
|
428
|
+
``yloc.shape == (M, K, ...)``
|
|
429
|
+
|
|
430
|
+
Each slot has its own contribution; this is useful when the kernel
|
|
431
|
+
produces distinct terms for focal vs neighbors and wants to scatter
|
|
432
|
+
them differently.
|
|
433
|
+
|
|
434
|
+
Parameters
|
|
435
|
+
----------
|
|
436
|
+
yloc
|
|
437
|
+
Local outputs for each hyperedge. Shape is either ``(M, ...)`` or
|
|
438
|
+
``(M, K, ...)``.
|
|
439
|
+
hyper
|
|
440
|
+
Integer participant list of shape ``(M, K)``. ``hyper[m, s]`` is the
|
|
441
|
+
particle id at slot ``s`` of hyperedge ``m``.
|
|
442
|
+
P
|
|
443
|
+
Number of particles (grid sites) in the global state.
|
|
444
|
+
K
|
|
445
|
+
Number of participants per hyperedge (stencil size).
|
|
446
|
+
fi
|
|
447
|
+
Slot index of the "focal" participant (typically 0 in PDE stencils).
|
|
448
|
+
|
|
449
|
+
Returns
|
|
450
|
+
-------
|
|
451
|
+
out
|
|
452
|
+
Scattered global array.
|
|
453
|
+
- If ``owners == "global"``: shape matches ``yloc`` reduced over hyperedges.
|
|
454
|
+
- Else: shape is ``(P, ...)`` where ``...`` matches the non-hyperedge axes
|
|
455
|
+
of ``yloc``.
|
|
456
|
+
|
|
457
|
+
Notes
|
|
458
|
+
-----
|
|
459
|
+
- Scattering uses ``.at[idx].add(...)``, so repeated indices are summed
|
|
460
|
+
(as expected for additive contributions).
|
|
461
|
+
- No degree normalization is applied here; if you want mean-per-particle
|
|
462
|
+
behavior, implement it at the reduction stage (or add a separate
|
|
463
|
+
normalization pass) to keep this primitive simple and predictable.
|
|
464
|
+
"""
|
|
465
|
+
# Detect whether the kernel returned per-slot contributions.
|
|
466
|
+
# Per-hyperedge yloc has shape (M, *rank_axes, n_features) → ndim = 2 + rank
|
|
467
|
+
# Per-slot yloc has shape (M, K, *rank_axes, n_features) → ndim = 3 + rank
|
|
468
|
+
# The shape-only heuristic ``yloc.shape[1] == K`` is ambiguous when
|
|
469
|
+
# ``dim == K`` (e.g. dim=5 state with a 5-point 2D Laplacian stencil); use
|
|
470
|
+
# rank+ndim as the unambiguous discriminator.
|
|
471
|
+
r_axes = int(self.interactor.rank)
|
|
472
|
+
expected_per_hyper_ndim = 2 + r_axes
|
|
473
|
+
per_slot = yloc.ndim > expected_per_hyper_ndim and yloc.shape[1] == K
|
|
474
|
+
|
|
475
|
+
# "global" ownership means: do *not* scatter onto particle ids at all.
|
|
476
|
+
# Instead reduce across hyperedges and return a single tensor.
|
|
477
|
+
if self.owners == "global":
|
|
478
|
+
if self.reducer == "sum":
|
|
479
|
+
return yloc.sum(axis=0)
|
|
480
|
+
if self.reducer == "mean":
|
|
481
|
+
return yloc.mean(axis=0)
|
|
482
|
+
if self.reducer == "max":
|
|
483
|
+
return yloc.max(axis=0)
|
|
484
|
+
raise ValueError(f"Unknown reducer {self.reducer!r}")
|
|
485
|
+
|
|
486
|
+
# Allocate the global destination array.
|
|
487
|
+
# For per-slot yloc: yloc is (M, K, ...), so drop axes (M, K) -> keep "..."
|
|
488
|
+
# For per-hyperedge yloc: yloc is (M, ...), so drop axis (M) -> keep "..."
|
|
489
|
+
out_shape = (P,) + (yloc.shape[2:] if per_slot else yloc.shape[1:])
|
|
490
|
+
out = jnp.zeros(out_shape, dtype=yloc.dtype)
|
|
491
|
+
|
|
492
|
+
# Owners="focal": each hyperedge contributes only to its focal participant.
|
|
493
|
+
if self.owners == "focal":
|
|
494
|
+
idx = hyper[:, fi] # (M,)
|
|
495
|
+
contrib = yloc[:, fi] if per_slot else yloc # (M, ...) in both cases
|
|
496
|
+
return out.at[idx].add(contrib)
|
|
497
|
+
|
|
498
|
+
# Owners="all": scatter the same contribution to all K participants (or per-slot values if provided).
|
|
499
|
+
# Owners="custom": same as "all" but multiply each slot by a slot weight vector.
|
|
500
|
+
if self.owners in ("all", "custom"):
|
|
501
|
+
if self.owners == "custom":
|
|
502
|
+
w = self.owner_weights
|
|
503
|
+
if w is None or w.shape[0] != K:
|
|
504
|
+
raise ValueError(f"owners='custom' requires owner_weights of shape (K,) with K={K}")
|
|
505
|
+
else:
|
|
506
|
+
w = None
|
|
507
|
+
|
|
508
|
+
# Flatten all target indices for scattering: (M*K,)
|
|
509
|
+
idx = hyper.reshape((-1,))
|
|
510
|
+
|
|
511
|
+
# Build a per-slot contribution array of shape (M, K, ...).
|
|
512
|
+
if per_slot:
|
|
513
|
+
# Kernel already returned per-slot values.
|
|
514
|
+
contrib = yloc
|
|
515
|
+
else:
|
|
516
|
+
# Kernel returned a single value per hyperedge; broadcast across slots.
|
|
517
|
+
contrib = jnp.broadcast_to(yloc[:, None, ...], (yloc.shape[0], K) + yloc.shape[1:])
|
|
518
|
+
|
|
519
|
+
# Optional per-slot weighting (useful e.g. to send +/- fluxes to different slots).
|
|
520
|
+
if w is not None:
|
|
521
|
+
# Reshape weights for broadcasting over the trailing axes of contrib.
|
|
522
|
+
wm = w.reshape((1, K) + (1,) * (contrib.ndim - 2))
|
|
523
|
+
contrib = contrib * wm
|
|
524
|
+
|
|
525
|
+
# Flatten to align with flattened idx: (M*K, ...)
|
|
526
|
+
contrib = contrib.reshape((yloc.shape[0] * K,) + contrib.shape[2:])
|
|
527
|
+
return out.at[idx].add(contrib)
|
|
528
|
+
|
|
529
|
+
raise ValueError(f"Unknown owners {self.owners!r}")
|
|
530
|
+
|
|
531
|
+
def _hyper_fixed_vectorized(self, x, *, v, mask, extras, params, spec: HyperFixed):
|
|
532
|
+
"""
|
|
533
|
+
Dispatch a fixed-K hyperedge program (HyperFixed) on a state array.
|
|
534
|
+
|
|
535
|
+
This is the HyperFixed analogue of the PairsCSR path. It gathers the K
|
|
536
|
+
participants for each hyperedge, calls the local interactor, and scatters
|
|
537
|
+
the result back onto the particle axis according to ``owners``.
|
|
538
|
+
|
|
539
|
+
Shape conventions
|
|
540
|
+
-----------------
|
|
541
|
+
- State: ``x`` has shape ``(..., P, d)`` (leading batch axes optional).
|
|
542
|
+
- Spec: ``hyper`` has shape ``(M, K)`` or ``(..., M, K)`` (optionally batched).
|
|
543
|
+
- Gathered neighborhood: ``Xk = x[hyper]`` has shape ``(..., M, K, d)``.
|
|
544
|
+
- Mask: ``mask`` (if provided) has shape ``(..., P)`` and is gathered to
|
|
545
|
+
``Mk = mask[hyper]`` of shape ``(..., M, K)``.
|
|
546
|
+
- Slot mask: ``spec.slot_mask`` (optional) has shape matching ``hyper`` and
|
|
547
|
+
is ANDed with ``Mk``. This is how boundary/invalid slots can be disabled
|
|
548
|
+
while preserving fixed K.
|
|
549
|
+
|
|
550
|
+
Chunking
|
|
551
|
+
--------
|
|
552
|
+
If ``self.chunk_size`` is not None, hyperedges are processed in blocks of
|
|
553
|
+
size ``chunk_size`` to control peak memory use.
|
|
554
|
+
|
|
555
|
+
Notes on masking / no-flux semantics
|
|
556
|
+
-----------------------------------
|
|
557
|
+
This dispatcher *only* combines and forwards masks; it does not encode a
|
|
558
|
+
specific boundary condition for holes/masked pixels. For “no flux through
|
|
559
|
+
masked pixels”, implement it in the local operator by replacing a masked
|
|
560
|
+
neighbor by the focal value (or any other consistent scheme). The mask
|
|
561
|
+
information is provided as ``mask[..., slot]`` to the kernel.
|
|
562
|
+
|
|
563
|
+
Parameters
|
|
564
|
+
----------
|
|
565
|
+
x
|
|
566
|
+
State array of shape ``(..., P, d)``.
|
|
567
|
+
v
|
|
568
|
+
Optional velocity-like array (same shape as x); forwarded to interactor.
|
|
569
|
+
mask
|
|
570
|
+
Optional per-particle boolean mask of shape ``(..., P)``; gathered to slots.
|
|
571
|
+
extras
|
|
572
|
+
Extra arrays; split into global vs particle-aligned by ``_split_extras``.
|
|
573
|
+
Particle-aligned extras are gathered to hyperedge slots and passed to the kernel.
|
|
574
|
+
params
|
|
575
|
+
Parameters forwarded to the interactor.
|
|
576
|
+
spec
|
|
577
|
+
A :class:`~SFI.statefunc.nodes.interactions.specs.HyperFixed` schedule.
|
|
578
|
+
|
|
579
|
+
Returns
|
|
580
|
+
-------
|
|
581
|
+
y
|
|
582
|
+
Dispatched result.
|
|
583
|
+
- For owners != "global": shape ``(..., P, <rank-shape>, n_features)``.
|
|
584
|
+
- For owners == "global": shape ``(..., <rank-shape>, n_features)``.
|
|
585
|
+
"""
|
|
586
|
+
*batch, P, d = x.shape
|
|
587
|
+
|
|
588
|
+
# Separate extras into:
|
|
589
|
+
# - globals_extras: arrays not aligned with particle axis (passed through unchanged)
|
|
590
|
+
# - particle_arrays: arrays aligned with particle axis P (to be gathered on hyperedges)
|
|
591
|
+
globals_extras, particle_arrays = self._split_extras(extras or {}, P=P)
|
|
592
|
+
|
|
593
|
+
hyper = spec.hyper
|
|
594
|
+
slot_mask = getattr(spec, "slot_mask", None)
|
|
595
|
+
|
|
596
|
+
# Optional, spec-aligned metadata.
|
|
597
|
+
#
|
|
598
|
+
# These are already aligned with hyperedges/slots, so unlike particle_extras
|
|
599
|
+
# they do not require indexing by `hyper`.
|
|
600
|
+
#
|
|
601
|
+
# Note: we treat dicts of arrays as pytrees, which lets us vmap over them
|
|
602
|
+
# when they carry a batch prefix.
|
|
603
|
+
edge_extras = getattr(spec, "edge_extras", None) or {}
|
|
604
|
+
slot_extras = getattr(spec, "slot_extras", None) or {}
|
|
605
|
+
|
|
606
|
+
K = int(hyper.shape[-1])
|
|
607
|
+
fi = int(self.focal_index)
|
|
608
|
+
if fi < 0 or fi >= K:
|
|
609
|
+
raise ValueError(f"focal_index={fi} out of bounds for K={K}")
|
|
610
|
+
|
|
611
|
+
def _per_sample(xb, vb, mb, hyper_b, slot_mask_b, edge_extras_b, slot_extras_b):
|
|
612
|
+
"""
|
|
613
|
+
Per-sample worker used both for non-batched and vmapped calls.
|
|
614
|
+
|
|
615
|
+
xb: (P, d)
|
|
616
|
+
hyper_b: (M, K)
|
|
617
|
+
"""
|
|
618
|
+
Pb = xb.shape[0]
|
|
619
|
+
M = int(hyper_b.shape[0])
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------
|
|
622
|
+
# Optional exclusion of self-interactions.
|
|
623
|
+
#
|
|
624
|
+
# In grid stencils with "noflux" boundaries, out-of-bounds neighbors are
|
|
625
|
+
# often encoded as "neighbor index = focal index". Treating those slots
|
|
626
|
+
# as invalid (mask=False) is a convenient way to express boundary handling
|
|
627
|
+
# without special cases in the kernel.
|
|
628
|
+
#
|
|
629
|
+
# We therefore compute `slot_keep` that is True for:
|
|
630
|
+
# - the focal slot itself
|
|
631
|
+
# - any slot whose participant index differs from the focal index
|
|
632
|
+
# and AND it into Mk.
|
|
633
|
+
# ---------------------------------------------------------------------
|
|
634
|
+
if self.exclude_self:
|
|
635
|
+
focal = hyper_b[:, fi] # (M,)
|
|
636
|
+
slots = jnp.arange(K, dtype=jnp.int32)[None, :] # (1, K)
|
|
637
|
+
slot_keep = jnp.logical_or(slots == fi, hyper_b != focal[:, None]) # (M, K)
|
|
638
|
+
else:
|
|
639
|
+
slot_keep = None
|
|
640
|
+
|
|
641
|
+
# -------------------------- non-chunked path -------------------------- #
|
|
642
|
+
if self.chunk_size is None:
|
|
643
|
+
# Gather neighborhood state: (M, K, d)
|
|
644
|
+
Xk = xb[hyper_b]
|
|
645
|
+
|
|
646
|
+
# Gather optional v and mask to neighborhood slots.
|
|
647
|
+
Vk = vb[hyper_b] if vb is not None else None # (M, K, d) or None
|
|
648
|
+
Mk = mb[hyper_b] if mb is not None else None # (M, K) or None
|
|
649
|
+
|
|
650
|
+
# Combine all slot-wise masks:
|
|
651
|
+
# - `Mk` from particle mask (dynamic, per sample)
|
|
652
|
+
# - `slot_mask_b` from the spec (static boundary/invalid slots)
|
|
653
|
+
# - `slot_keep` from exclude_self (static, computed from hyper table)
|
|
654
|
+
if slot_mask_b is not None:
|
|
655
|
+
Mk = slot_mask_b if Mk is None else jnp.logical_and(Mk, slot_mask_b)
|
|
656
|
+
if slot_keep is not None:
|
|
657
|
+
Mk = slot_keep if Mk is None else jnp.logical_and(Mk, slot_keep)
|
|
658
|
+
|
|
659
|
+
# Gather particle-aligned extras to (M, K, ...).
|
|
660
|
+
ex_slots = self._gather_particle_extras_hyper(particle_arrays, hyper_b)
|
|
661
|
+
|
|
662
|
+
# Build extras dict passed to the interactor:
|
|
663
|
+
# - global extras (unchanged)
|
|
664
|
+
# - gathered particle extras (slot-shaped)
|
|
665
|
+
# - optional spec-provided extras
|
|
666
|
+
ex_local = globals_extras if not ex_slots else ({**globals_extras, **ex_slots})
|
|
667
|
+
if edge_extras_b:
|
|
668
|
+
ex_local = {**ex_local, **edge_extras_b} # per-hyperedge arrays
|
|
669
|
+
if slot_extras_b:
|
|
670
|
+
ex_local = {**ex_local, **slot_extras_b} # per-slot arrays
|
|
671
|
+
|
|
672
|
+
# Evaluate kernel on gathered neighborhoods.
|
|
673
|
+
yloc = self.interactor(Xk, v=Vk, mask=Mk, params=params, extras=ex_local)
|
|
674
|
+
|
|
675
|
+
# Scatter to particle axis.
|
|
676
|
+
return self._scatter_hyper(yloc, hyper_b, Pb, K, fi)
|
|
677
|
+
|
|
678
|
+
# ---------------------------- chunked path ---------------------------- #
|
|
679
|
+
CS = int(self.chunk_size)
|
|
680
|
+
|
|
681
|
+
# Pad M to a multiple of chunk size so that we can use a simple fori_loop.
|
|
682
|
+
pad = (CS - (M % CS)) % CS
|
|
683
|
+
Mpad = M + pad
|
|
684
|
+
steps = Mpad // CS
|
|
685
|
+
|
|
686
|
+
# Pad the hyper list; padded rows are arbitrary but will be masked out via data_mask.
|
|
687
|
+
hyper_pad = jnp.pad(hyper_b, ((0, pad), (0, 0)))
|
|
688
|
+
|
|
689
|
+
# Mask marking which hyperedges are real vs padded.
|
|
690
|
+
data_mask = jnp.concatenate([jnp.ones((M,), dtype=bool), jnp.zeros((pad,), dtype=bool)], axis=0) # (Mpad,)
|
|
691
|
+
|
|
692
|
+
# Pad slot_mask and slot_keep to match padded hyper list.
|
|
693
|
+
slot_mask_pad = jnp.pad(slot_mask_b, ((0, pad), (0, 0))) if slot_mask_b is not None else None
|
|
694
|
+
slot_keep_pad = jnp.pad(slot_keep, ((0, pad), (0, 0))) if slot_keep is not None else None
|
|
695
|
+
|
|
696
|
+
# Output shape template: rank_shape + (n_features,)
|
|
697
|
+
rb = _rank_shape(self.interactor.rank, d)
|
|
698
|
+
|
|
699
|
+
# Accumulator differs for global vs per-particle output.
|
|
700
|
+
if self.owners == "global":
|
|
701
|
+
# For global reductions, we keep one tensor (no particle axis).
|
|
702
|
+
if self.reducer == "max":
|
|
703
|
+
acc = jnp.full(rb + (int(self.n_features),), -jnp.inf, dtype=xb.dtype)
|
|
704
|
+
else:
|
|
705
|
+
acc = jnp.zeros(rb + (int(self.n_features),), dtype=xb.dtype)
|
|
706
|
+
cnt = jnp.zeros((), dtype=jnp.int32) # used only for "mean"
|
|
707
|
+
else:
|
|
708
|
+
# For scattered outputs, keep a full particle-axis accumulator.
|
|
709
|
+
acc = jnp.zeros((Pb,) + rb + (int(self.n_features),), dtype=xb.dtype)
|
|
710
|
+
cnt = jnp.zeros((), dtype=jnp.int32)
|
|
711
|
+
|
|
712
|
+
def body(i, carry):
|
|
713
|
+
"""
|
|
714
|
+
Process one chunk of hyperedges.
|
|
715
|
+
|
|
716
|
+
The chunk is masked by `valid` so that padded hyperedges contribute 0.
|
|
717
|
+
"""
|
|
718
|
+
acc, cnt = carry
|
|
719
|
+
start = i * CS
|
|
720
|
+
|
|
721
|
+
# Slice the current chunk of hyperedges and validity mask.
|
|
722
|
+
hyper_blk = jax.lax.dynamic_slice_in_dim(hyper_pad, start, CS, axis=0) # (CS, K)
|
|
723
|
+
valid = jax.lax.dynamic_slice_in_dim(data_mask, start, CS, axis=0) # (CS,)
|
|
724
|
+
|
|
725
|
+
# Gather state / v / mask on this chunk.
|
|
726
|
+
Xk = xb[hyper_blk] # (CS, K, d)
|
|
727
|
+
Vk = vb[hyper_blk] if vb is not None else None # (CS, K, d) or None
|
|
728
|
+
Mk = mb[hyper_blk] if mb is not None else None # (CS, K) or None
|
|
729
|
+
|
|
730
|
+
# Combine slot masks (schedule boundary mask, exclude_self, …).
|
|
731
|
+
if slot_mask_pad is not None:
|
|
732
|
+
sm = jax.lax.dynamic_slice_in_dim(slot_mask_pad, start, CS, axis=0) # (CS, K)
|
|
733
|
+
Mk = sm if Mk is None else jnp.logical_and(Mk, sm)
|
|
734
|
+
if slot_keep_pad is not None:
|
|
735
|
+
sk = jax.lax.dynamic_slice_in_dim(slot_keep_pad, start, CS, axis=0) # (CS, K)
|
|
736
|
+
Mk = sk if Mk is None else jnp.logical_and(Mk, sk)
|
|
737
|
+
|
|
738
|
+
# Gather particle extras for this chunk.
|
|
739
|
+
ex_slots_blk = self._gather_particle_extras_hyper(particle_arrays, hyper_blk)
|
|
740
|
+
ex_local_blk = globals_extras if not ex_slots_blk else ({**globals_extras, **ex_slots_blk})
|
|
741
|
+
|
|
742
|
+
# Slice spec-provided extras if present.
|
|
743
|
+
# Edge extras are (M, ...); slot extras are (M, K, ...) typically.
|
|
744
|
+
if edge_extras_b:
|
|
745
|
+
ex_edge_blk = self._slice_extras_1d(edge_extras_b, start, CS)
|
|
746
|
+
ex_local_blk = {**ex_local_blk, **ex_edge_blk}
|
|
747
|
+
if slot_extras_b:
|
|
748
|
+
ex_slot_blk = self._slice_extras_1d(slot_extras_b, start, CS)
|
|
749
|
+
ex_local_blk = {**ex_local_blk, **ex_slot_blk}
|
|
750
|
+
|
|
751
|
+
# Kernel evaluation on this chunk.
|
|
752
|
+
yloc = self.interactor(Xk, v=Vk, mask=Mk, params=params, extras=ex_local_blk)
|
|
753
|
+
|
|
754
|
+
# Mask out padded hyperedges: multiply by valid[..., None, ...] broadcast.
|
|
755
|
+
yloc = yloc * valid.reshape((-1,) + (1,) * (yloc.ndim - 1))
|
|
756
|
+
|
|
757
|
+
if self.owners == "global":
|
|
758
|
+
# Reduce on-the-fly for global ownership.
|
|
759
|
+
if self.reducer in ("sum", "mean"):
|
|
760
|
+
acc = acc + yloc.sum(axis=0)
|
|
761
|
+
cnt = cnt + valid.sum().astype(cnt.dtype)
|
|
762
|
+
elif self.reducer == "max":
|
|
763
|
+
acc = jnp.maximum(acc, yloc.max(axis=0))
|
|
764
|
+
else:
|
|
765
|
+
raise ValueError(f"Unknown reducer {self.reducer!r}")
|
|
766
|
+
return acc, cnt
|
|
767
|
+
|
|
768
|
+
# Scatter chunk contributions onto particles and accumulate.
|
|
769
|
+
acc = acc + self._scatter_hyper(yloc, hyper_blk, Pb, K, fi)
|
|
770
|
+
return acc, cnt
|
|
771
|
+
|
|
772
|
+
accN, cntN = jax.lax.fori_loop(0, steps, body, (acc, cnt))
|
|
773
|
+
|
|
774
|
+
# Finalize mean for global reduction (avoid division by 0 when M=0).
|
|
775
|
+
if self.owners == "global" and self.reducer == "mean":
|
|
776
|
+
accN = accN / jnp.maximum(1, cntN)
|
|
777
|
+
return accN
|
|
778
|
+
|
|
779
|
+
# ----------------------------- batch handling ----------------------------- #
|
|
780
|
+
# If x has leading batch axes, we vmap over them. The schedule (hyper) can be:
|
|
781
|
+
# - shared across the batch: hyper has shape (M, K) -> in_axes=None
|
|
782
|
+
# - provided per sample: hyper has shape (..., M, K) -> in_axes=0
|
|
783
|
+
if batch:
|
|
784
|
+
hyper_in = 0 if hyper.ndim == (len(batch) + 2) else None
|
|
785
|
+
sm_in = 0 if (slot_mask is not None and slot_mask.ndim == (len(batch) + 2)) else None
|
|
786
|
+
edge_in = hyper_in if edge_extras else None
|
|
787
|
+
slot_in = hyper_in if slot_extras else None
|
|
788
|
+
vm = jax.vmap(
|
|
789
|
+
_per_sample,
|
|
790
|
+
in_axes=(
|
|
791
|
+
0,
|
|
792
|
+
0 if v is not None else None,
|
|
793
|
+
0 if mask is not None else None,
|
|
794
|
+
hyper_in,
|
|
795
|
+
sm_in,
|
|
796
|
+
edge_in,
|
|
797
|
+
slot_in,
|
|
798
|
+
),
|
|
799
|
+
)
|
|
800
|
+
return vm(x, v, mask, hyper, slot_mask, edge_extras or None, slot_extras or None)
|
|
801
|
+
|
|
802
|
+
return _per_sample(x, v, mask, hyper, slot_mask, edge_extras or None, slot_extras or None)
|
|
803
|
+
|
|
804
|
+
def _hyper_csr_vectorized(self, x, *, v, mask, extras, params, spec: HyperCSR):
|
|
805
|
+
"""Dispatch a CSR hyperedge schedule.
|
|
806
|
+
|
|
807
|
+
:class:`~SFI.statefunc.nodes.interactions.specs.HyperCSR` stores a ragged list
|
|
808
|
+
of hyperedges:
|
|
809
|
+
|
|
810
|
+
- ``he_indptr``: shape ``(..., M+1)``
|
|
811
|
+
- ``he_indices``: shape ``(..., total_slots)``
|
|
812
|
+
|
|
813
|
+
where hyperedge ``e`` uses participants ``he_indices[indptr[e]:indptr[e+1]]``.
|
|
814
|
+
|
|
815
|
+
For JAX-friendly execution, the dispatcher converts this ragged representation
|
|
816
|
+
into a *dense* fixed-K representation by padding each hyperedge to
|
|
817
|
+
``K = self._K`` and providing a ``slot_mask`` that marks which slots are real.
|
|
818
|
+
|
|
819
|
+
This conversion is fully traceable as long as ``K`` is static (fixed-K mode).
|
|
820
|
+
"""
|
|
821
|
+
if self._K is None:
|
|
822
|
+
raise NotImplementedError(
|
|
823
|
+
"HyperCSR dispatch requires a static K. Use a fixed-K interactor (or pass a fixed K override)."
|
|
824
|
+
)
|
|
825
|
+
K = int(self._K)
|
|
826
|
+
|
|
827
|
+
he_indptr = spec.he_indptr
|
|
828
|
+
he_indices = spec.he_indices
|
|
829
|
+
slot_ids = getattr(spec, "slot_ids", None)
|
|
830
|
+
edge_extras = getattr(spec, "edge_extras", None) or {}
|
|
831
|
+
slot_extras = getattr(spec, "slot_extras", None) or {}
|
|
832
|
+
|
|
833
|
+
def _csr_to_hyperfixed(indptr, indices, slot_ids_b, edge_extras_b, slot_extras_b):
|
|
834
|
+
"""Convert one CSR schedule (no batch axis) into :class:`HyperFixed`.
|
|
835
|
+
|
|
836
|
+
The conversion keeps:
|
|
837
|
+
- ``edge_extras``: aligned with hyperedges (axis 0 = M)
|
|
838
|
+
- ``slot_extras``: aligned with flattened slots (axis 0 = total_slots)
|
|
839
|
+
and scattered into a dense (M, K, ...) representation.
|
|
840
|
+
"""
|
|
841
|
+
total = indices.shape[0]
|
|
842
|
+
M = indptr.shape[0] - 1
|
|
843
|
+
|
|
844
|
+
# Each flattened slot knows which hyperedge it belongs to.
|
|
845
|
+
pos = jnp.arange(total, dtype=jnp.int32)
|
|
846
|
+
edge_id = jnp.searchsorted(indptr[1:], pos, side="right") # (total,)
|
|
847
|
+
in_edge = pos - indptr[edge_id] # (total,)
|
|
848
|
+
|
|
849
|
+
in_range = in_edge < K
|
|
850
|
+
|
|
851
|
+
# Initialize dense participant table by repeating the first participant
|
|
852
|
+
# of each hyperedge (convenient padding value).
|
|
853
|
+
first = indices[indptr[:-1]] # (M,)
|
|
854
|
+
hyper = jnp.repeat(first[:, None], K, axis=1)
|
|
855
|
+
slot_mask = jnp.zeros((M, K), dtype=jnp.bool_)
|
|
856
|
+
|
|
857
|
+
# Scatter the actual participants into their slot positions.
|
|
858
|
+
hyper = hyper.at[edge_id[in_range], in_edge[in_range]].set(indices[in_range])
|
|
859
|
+
slot_mask = slot_mask.at[edge_id[in_range], in_edge[in_range]].set(True)
|
|
860
|
+
|
|
861
|
+
# Convert slot_ids into a dense per-slot extra (if present).
|
|
862
|
+
slot_extras_fixed: Dict[str, Array] = {}
|
|
863
|
+
if slot_ids_b is not None:
|
|
864
|
+
sid = jnp.full((M, K), -1, dtype=slot_ids_b.dtype)
|
|
865
|
+
sid = sid.at[edge_id[in_range], in_edge[in_range]].set(slot_ids_b[in_range])
|
|
866
|
+
slot_extras_fixed["slot_id"] = sid
|
|
867
|
+
|
|
868
|
+
# Scatter any explicit slot extras (aligned with `indices`) to (M, K, ...).
|
|
869
|
+
for kname, arr in (slot_extras_b or {}).items():
|
|
870
|
+
z = jnp.zeros((M, K) + arr.shape[1:], dtype=arr.dtype)
|
|
871
|
+
z = z.at[edge_id[in_range], in_edge[in_range]].set(arr[in_range])
|
|
872
|
+
slot_extras_fixed[kname] = z
|
|
873
|
+
|
|
874
|
+
return HyperFixed(
|
|
875
|
+
hyper=hyper,
|
|
876
|
+
slot_mask=slot_mask,
|
|
877
|
+
edge_extras=edge_extras_b or None,
|
|
878
|
+
slot_extras=slot_extras_fixed or None,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
# Support shared schedule (no batch) vs per-sample schedule (vmapped).
|
|
882
|
+
*batch, _P, _d = x.shape
|
|
883
|
+
if batch:
|
|
884
|
+
# Assume schedule arrays (indptr, indices, extras) follow the same
|
|
885
|
+
# batching pattern as the state x.
|
|
886
|
+
vm = jax.vmap(
|
|
887
|
+
_csr_to_hyperfixed,
|
|
888
|
+
in_axes=(
|
|
889
|
+
0,
|
|
890
|
+
0,
|
|
891
|
+
0 if slot_ids is not None else None,
|
|
892
|
+
0 if edge_extras else None,
|
|
893
|
+
0 if slot_extras else None,
|
|
894
|
+
),
|
|
895
|
+
)
|
|
896
|
+
hf = vm(
|
|
897
|
+
he_indptr,
|
|
898
|
+
he_indices,
|
|
899
|
+
slot_ids,
|
|
900
|
+
edge_extras or None,
|
|
901
|
+
slot_extras or None,
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
hf = _csr_to_hyperfixed(
|
|
905
|
+
he_indptr,
|
|
906
|
+
he_indices,
|
|
907
|
+
slot_ids,
|
|
908
|
+
edge_extras or None,
|
|
909
|
+
slot_extras or None,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
return self._hyper_fixed_vectorized(x, v=v, mask=mask, extras=extras, params=params, spec=hf)
|
|
913
|
+
|
|
914
|
+
def _split_extras(self, extras: dict | None, *, P: int):
|
|
915
|
+
"""
|
|
916
|
+
Return (globals_extras, particle_arrays) where:
|
|
917
|
+
- globals_extras : dict of non-structural, non-particle keys (pass-through)
|
|
918
|
+
- particle_arrays: dict[key -> (P, ...)] gathered per edge by the dispatcher
|
|
919
|
+
"""
|
|
920
|
+
if not extras:
|
|
921
|
+
return {}, {}
|
|
922
|
+
particle_keys = set(getattr(self.interactor, "particle_extras", ()) or ())
|
|
923
|
+
structural = set(getattr(self, "structural_extras", ()) or ())
|
|
924
|
+
globals_extras, particle_arrays = {}, {}
|
|
925
|
+
for k, v in extras.items():
|
|
926
|
+
if k in structural:
|
|
927
|
+
continue # dispatcher-owned; never forward
|
|
928
|
+
if k in particle_keys:
|
|
929
|
+
if not (hasattr(v, "shape") and v.shape and v.shape[0] == P):
|
|
930
|
+
raise ValueError(f"Particle extra '{k}' must have shape (P, ...); got {getattr(v, 'shape', None)}")
|
|
931
|
+
particle_arrays[k] = v
|
|
932
|
+
else:
|
|
933
|
+
globals_extras[k] = v
|
|
934
|
+
return globals_extras, particle_arrays
|
|
935
|
+
|
|
936
|
+
def _gather_particle_extras(self, particle_arrays: dict, row: Array, col: Array) -> dict:
|
|
937
|
+
"""For pairs (K=2), gather per-edge slices into shape (E, 2, ...)."""
|
|
938
|
+
if not particle_arrays:
|
|
939
|
+
return {}
|
|
940
|
+
gathered = {}
|
|
941
|
+
for k, arr in particle_arrays.items():
|
|
942
|
+
ei = arr[row] # (E, ...)
|
|
943
|
+
ej = arr[col] # (E, ...)
|
|
944
|
+
gathered[k] = jnp.stack([ei, ej], axis=1) # (E, 2, ...)
|
|
945
|
+
return gathered
|
|
946
|
+
|
|
947
|
+
def _gather_particle_extras_hyper(self, particle_arrays: dict, hyper: Array) -> dict:
|
|
948
|
+
"""For hyperedges (fixed K), gather per-slot slices into shape (M, K, ...)."""
|
|
949
|
+
if not particle_arrays:
|
|
950
|
+
return {}
|
|
951
|
+
return {k: arr[hyper] for k, arr in particle_arrays.items()}
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def _slice_extras_1d(extras: Mapping[str, Array], start: int, length: int) -> Dict[str, Array]:
|
|
955
|
+
"""Dynamic-slice a dict of arrays along axis=0."""
|
|
956
|
+
# This helper is used in chunked execution to take a consistent slice
|
|
957
|
+
# of spec-aligned metadata (edge_extras or slot_extras) for the current
|
|
958
|
+
# chunk. It assumes those arrays are aligned with the hyperedge axis
|
|
959
|
+
# (axis 0 = hyperedges), which is the convention used by HyperFixed.
|
|
960
|
+
if not extras:
|
|
961
|
+
return {}
|
|
962
|
+
return {k: jax.lax.dynamic_slice_in_dim(arr, start, length, axis=0) for k, arr in extras.items()}
|
|
963
|
+
|
|
964
|
+
# ──────────── same-particle diagonal Jacobian (efficient) ─────────────────
|
|
965
|
+
def _same_particle_jacobian(self, x, *, var="x", v=None, params=None, mask=None, extras=None):
|
|
966
|
+
"""Per-edge ``jacfwd`` + scatter: O(M·d²) instead of generic O(P·M·d).
|
|
967
|
+
|
|
968
|
+
Returns ``(P, d_jac, rank..., F)`` in contract layout, matching the
|
|
969
|
+
convention of :meth:`BaseNode._same_particle_jacobian`.
|
|
970
|
+
"""
|
|
971
|
+
if self.owners == "global":
|
|
972
|
+
raise ValueError("same-particle Jacobian is not defined for owners='global'")
|
|
973
|
+
|
|
974
|
+
# ── resolve spec (same logic as __call__) ──
|
|
975
|
+
if isinstance(self.spec, SpecRule):
|
|
976
|
+
structural = set(self.spec.structural_extras() or ())
|
|
977
|
+
else:
|
|
978
|
+
structural = set()
|
|
979
|
+
extras_child = extras
|
|
980
|
+
if extras is not None and structural:
|
|
981
|
+
extras_child = {k: val for k, val in extras.items() if k not in structural}
|
|
982
|
+
spec = self.spec.build(x, v=v, mask=mask, extras=extras) if isinstance(self.spec, SpecRule) else self.spec
|
|
983
|
+
|
|
984
|
+
# ── dispatch by spec type ──
|
|
985
|
+
if isinstance(spec, PairsCSR):
|
|
986
|
+
return self._pairs_csr_spj(
|
|
987
|
+
x,
|
|
988
|
+
v=v,
|
|
989
|
+
mask=mask,
|
|
990
|
+
extras=extras_child,
|
|
991
|
+
params=params,
|
|
992
|
+
spec=spec,
|
|
993
|
+
var=var,
|
|
994
|
+
)
|
|
995
|
+
if isinstance(spec, HyperFixed):
|
|
996
|
+
return self._hyper_fixed_spj(
|
|
997
|
+
x,
|
|
998
|
+
v=v,
|
|
999
|
+
mask=mask,
|
|
1000
|
+
extras=extras_child,
|
|
1001
|
+
params=params,
|
|
1002
|
+
spec=spec,
|
|
1003
|
+
var=var,
|
|
1004
|
+
)
|
|
1005
|
+
# HyperCSR / unknown: fall back to generic AD path
|
|
1006
|
+
return super()._same_particle_jacobian(x, var=var, v=v, params=params, mask=mask, extras=extras)
|
|
1007
|
+
|
|
1008
|
+
# ·············· PairsCSR per-edge Jacobian ··············
|
|
1009
|
+
def _pairs_csr_spj(self, x, *, v, mask, extras, params, spec, var):
|
|
1010
|
+
P, d = x.shape
|
|
1011
|
+
globals_ex, particle_arrays = self._split_extras(extras or {}, P=P)
|
|
1012
|
+
|
|
1013
|
+
indptr, indices = spec.indptr, spec.indices
|
|
1014
|
+
M = indices.shape[0]
|
|
1015
|
+
k = jnp.arange(M, dtype=jnp.int32)
|
|
1016
|
+
row = jnp.searchsorted(indptr[1:], k, side="right")
|
|
1017
|
+
col = indices
|
|
1018
|
+
|
|
1019
|
+
keep = jnp.logical_not(row == col) if self.exclude_self else jnp.ones(M, dtype=bool)
|
|
1020
|
+
|
|
1021
|
+
# Gather stencils (same as forward path)
|
|
1022
|
+
Xk = jnp.stack([x[row], x[col]], axis=1) # (M, 2, d)
|
|
1023
|
+
Vk = jnp.stack([v[row], v[col]], axis=1) if v is not None else None
|
|
1024
|
+
Mk = jnp.stack([mask[row], mask[col]], axis=1) if mask is not None else None
|
|
1025
|
+
ex_edge = self._gather_particle_extras(particle_arrays, row, col)
|
|
1026
|
+
|
|
1027
|
+
# — per-edge jacfwd —
|
|
1028
|
+
J_all = self._vmap_jacfwd_edges(
|
|
1029
|
+
Xk,
|
|
1030
|
+
Vk,
|
|
1031
|
+
Mk,
|
|
1032
|
+
ex_edge,
|
|
1033
|
+
globals_ex,
|
|
1034
|
+
params,
|
|
1035
|
+
var,
|
|
1036
|
+
) # (M, rank..., F, K=2, d)
|
|
1037
|
+
|
|
1038
|
+
# Apply keep mask
|
|
1039
|
+
km = keep.reshape((-1,) + (1,) * (J_all.ndim - 1))
|
|
1040
|
+
J_all = J_all * km
|
|
1041
|
+
|
|
1042
|
+
# Degree normalization (if applicable)
|
|
1043
|
+
deg = None
|
|
1044
|
+
if self.reducer == "mean" and self.normalize_by_degree:
|
|
1045
|
+
deg = (indptr[1:] - indptr[:-1]).astype(jnp.int32)
|
|
1046
|
+
|
|
1047
|
+
# Scatter based on owners
|
|
1048
|
+
fi = self.focal_index
|
|
1049
|
+
out_shape = (P,) + J_all.shape[1:-2] + (J_all.shape[-1],)
|
|
1050
|
+
out = jnp.zeros(out_shape, dtype=x.dtype)
|
|
1051
|
+
|
|
1052
|
+
if self.owners == "focal":
|
|
1053
|
+
J_focal = J_all[..., fi, :] # (M, rank..., F, d)
|
|
1054
|
+
if deg is not None:
|
|
1055
|
+
J_focal = J_focal / jnp.maximum(1, deg[row]).reshape((-1,) + (1,) * (J_focal.ndim - 1))
|
|
1056
|
+
out = out.at[row].add(J_focal)
|
|
1057
|
+
elif self.owners in ("all", "custom"):
|
|
1058
|
+
w = self.owner_weights
|
|
1059
|
+
for s in range(2):
|
|
1060
|
+
J_s = J_all[..., s, :]
|
|
1061
|
+
idx = row if s == fi else col
|
|
1062
|
+
if deg is not None:
|
|
1063
|
+
J_s = J_s / jnp.maximum(1, deg[idx]).reshape((-1,) + (1,) * (J_s.ndim - 1))
|
|
1064
|
+
if w is not None:
|
|
1065
|
+
J_s = w[s] * J_s
|
|
1066
|
+
out = out.at[idx].add(J_s)
|
|
1067
|
+
else:
|
|
1068
|
+
raise ValueError(f"Unsupported owners='{self.owners}' for same-particle Jacobian")
|
|
1069
|
+
|
|
1070
|
+
# (P, rank..., F, d) → (P, d, rank..., F)
|
|
1071
|
+
return jnp.moveaxis(out, -1, 1)
|
|
1072
|
+
|
|
1073
|
+
# ·············· HyperFixed per-edge Jacobian ··············
|
|
1074
|
+
def _hyper_fixed_spj(self, x, *, v, mask, extras, params, spec, var):
|
|
1075
|
+
P, d = x.shape
|
|
1076
|
+
globals_ex, particle_arrays = self._split_extras(extras or {}, P=P)
|
|
1077
|
+
|
|
1078
|
+
hyper = spec.hyper # (M, K)
|
|
1079
|
+
slot_mask_spec = getattr(spec, "slot_mask", None)
|
|
1080
|
+
edge_extras_spec = getattr(spec, "edge_extras", None) or {}
|
|
1081
|
+
slot_extras_spec = getattr(spec, "slot_extras", None) or {}
|
|
1082
|
+
|
|
1083
|
+
K = int(hyper.shape[-1])
|
|
1084
|
+
fi = self.focal_index
|
|
1085
|
+
|
|
1086
|
+
# Gather stencils
|
|
1087
|
+
Xk = x[hyper] # (M, K, d)
|
|
1088
|
+
Vk = v[hyper] if v is not None else None
|
|
1089
|
+
Mk = mask[hyper] if mask is not None else None
|
|
1090
|
+
|
|
1091
|
+
# Combine slot masks
|
|
1092
|
+
if slot_mask_spec is not None:
|
|
1093
|
+
Mk = slot_mask_spec if Mk is None else jnp.logical_and(Mk, slot_mask_spec)
|
|
1094
|
+
if self.exclude_self:
|
|
1095
|
+
focal = hyper[:, fi]
|
|
1096
|
+
slots = jnp.arange(K, dtype=jnp.int32)[None, :]
|
|
1097
|
+
slot_keep = jnp.logical_or(slots == fi, hyper != focal[:, None])
|
|
1098
|
+
Mk = slot_keep if Mk is None else jnp.logical_and(Mk, slot_keep)
|
|
1099
|
+
|
|
1100
|
+
# Collect all per-edge varying extras
|
|
1101
|
+
edge_varying = {}
|
|
1102
|
+
ex_slots = self._gather_particle_extras_hyper(particle_arrays, hyper)
|
|
1103
|
+
if ex_slots:
|
|
1104
|
+
edge_varying.update(ex_slots)
|
|
1105
|
+
if edge_extras_spec:
|
|
1106
|
+
edge_varying.update(edge_extras_spec)
|
|
1107
|
+
if slot_extras_spec:
|
|
1108
|
+
edge_varying.update(slot_extras_spec)
|
|
1109
|
+
|
|
1110
|
+
# — per-edge jacfwd —
|
|
1111
|
+
J_all = self._vmap_jacfwd_edges(
|
|
1112
|
+
Xk,
|
|
1113
|
+
Vk,
|
|
1114
|
+
Mk,
|
|
1115
|
+
edge_varying,
|
|
1116
|
+
globals_ex,
|
|
1117
|
+
params,
|
|
1118
|
+
var,
|
|
1119
|
+
) # (M, rank..., F, K, d)
|
|
1120
|
+
|
|
1121
|
+
# Scatter based on owners
|
|
1122
|
+
out_shape = (P,) + J_all.shape[1:-2] + (J_all.shape[-1],)
|
|
1123
|
+
out = jnp.zeros(out_shape, dtype=x.dtype)
|
|
1124
|
+
|
|
1125
|
+
if self.owners == "focal":
|
|
1126
|
+
J_focal = J_all[..., fi, :]
|
|
1127
|
+
out = out.at[hyper[:, fi]].add(J_focal)
|
|
1128
|
+
elif self.owners in ("all", "custom"):
|
|
1129
|
+
w = self.owner_weights
|
|
1130
|
+
for s in range(K):
|
|
1131
|
+
J_s = J_all[..., s, :]
|
|
1132
|
+
if w is not None:
|
|
1133
|
+
J_s = w[s] * J_s
|
|
1134
|
+
out = out.at[hyper[:, s]].add(J_s)
|
|
1135
|
+
else:
|
|
1136
|
+
raise ValueError(f"Unsupported owners='{self.owners}' for same-particle Jacobian")
|
|
1137
|
+
|
|
1138
|
+
return jnp.moveaxis(out, -1, 1)
|
|
1139
|
+
|
|
1140
|
+
# ·············· shared: vmap(jacfwd) over edges ··············
|
|
1141
|
+
def _vmap_jacfwd_edges(
|
|
1142
|
+
self,
|
|
1143
|
+
Xk,
|
|
1144
|
+
Vk,
|
|
1145
|
+
Mk,
|
|
1146
|
+
edge_varying,
|
|
1147
|
+
globals_ex,
|
|
1148
|
+
params,
|
|
1149
|
+
var,
|
|
1150
|
+
):
|
|
1151
|
+
"""Compute per-edge Jacobian w.r.t. position or velocity.
|
|
1152
|
+
|
|
1153
|
+
Parameters
|
|
1154
|
+
----------
|
|
1155
|
+
Xk : (M, K, d) positions
|
|
1156
|
+
Vk : (M, K, d) or None
|
|
1157
|
+
Mk : (M, K) or None
|
|
1158
|
+
edge_varying : dict of (M, ...) arrays (per-edge extras)
|
|
1159
|
+
globals_ex : dict of non-batched extras (closed over)
|
|
1160
|
+
params : parameter dict
|
|
1161
|
+
var : 'x' or 'v'
|
|
1162
|
+
|
|
1163
|
+
Returns
|
|
1164
|
+
-------
|
|
1165
|
+
J : (M, rank..., F, K, d)
|
|
1166
|
+
"""
|
|
1167
|
+
interactor = self.interactor
|
|
1168
|
+
|
|
1169
|
+
def _jac_one(xk_one, vk_one, mk_one, ev_one):
|
|
1170
|
+
# Merge global (closed over) + per-edge extras
|
|
1171
|
+
ex = dict(globals_ex) if globals_ex else {}
|
|
1172
|
+
if ev_one:
|
|
1173
|
+
ex.update(ev_one)
|
|
1174
|
+
extras_arg = ex if ex else None
|
|
1175
|
+
|
|
1176
|
+
if var == "x":
|
|
1177
|
+
return jax.jacfwd(lambda xk: interactor(xk, v=vk_one, mask=mk_one, params=params, extras=extras_arg))(
|
|
1178
|
+
xk_one
|
|
1179
|
+
)
|
|
1180
|
+
else:
|
|
1181
|
+
return jax.jacfwd(lambda vk: interactor(xk_one, v=vk, mask=mk_one, params=params, extras=extras_arg))(
|
|
1182
|
+
vk_one
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
in_vk = 0 if Vk is not None else None
|
|
1186
|
+
in_mk = 0 if Mk is not None else None
|
|
1187
|
+
in_ev = jax.tree_util.tree_map(lambda _: 0, edge_varying) if edge_varying else {}
|
|
1188
|
+
|
|
1189
|
+
return jax.vmap(_jac_one, in_axes=(0, in_vk, in_mk, in_ev))(
|
|
1190
|
+
Xk,
|
|
1191
|
+
Vk,
|
|
1192
|
+
Mk,
|
|
1193
|
+
edge_varying if edge_varying else {},
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
# ─────────────────────── memory hint (custom) ─────────────────────────────
|
|
1197
|
+
def memory_hint(
|
|
1198
|
+
self,
|
|
1199
|
+
*,
|
|
1200
|
+
dtype=None, # default float32 when None
|
|
1201
|
+
particle_size: int | None = None,
|
|
1202
|
+
sample: SampleMeta | None = None,
|
|
1203
|
+
mode: str = "forward",
|
|
1204
|
+
) -> MemHint:
|
|
1205
|
+
"""
|
|
1206
|
+
Conservative per-sample footprint for interaction streaming.
|
|
1207
|
+
|
|
1208
|
+
Counts:
|
|
1209
|
+
- gathered edge chunk (Xk and optionally Vk/Mask) ~ (chunk_E, K, d)
|
|
1210
|
+
- per-edge temporary outputs: (chunk_E, ``*rank``, n_features)
|
|
1211
|
+
- accumulator:
|
|
1212
|
+
owners='global' → (``*rank``, n_features)
|
|
1213
|
+
else → (P, ``*rank``, n_features)
|
|
1214
|
+
- small integer/boolean work arrays for row/col/valid/deg
|
|
1215
|
+
"""
|
|
1216
|
+
P = resolve_P(particle_size, sample) or 1
|
|
1217
|
+
d = int(getattr(self, "dim", 0) or 0)
|
|
1218
|
+
mfeat = int(getattr(self, "n_features", 0) or 0)
|
|
1219
|
+
r = int(getattr(self.interactor, "rank", 0) or 0)
|
|
1220
|
+
K = self._effective_K() or (sample.K if sample and sample.K else 2)
|
|
1221
|
+
|
|
1222
|
+
isz = itemsize_of(dtype)
|
|
1223
|
+
isz_i32 = jnp.dtype(jnp.int32).itemsize
|
|
1224
|
+
isz_bool = jnp.dtype(jnp.bool_).itemsize
|
|
1225
|
+
|
|
1226
|
+
rb = (d or 1) ** r
|
|
1227
|
+
out_per_edge_bytes = rb * mfeat * isz
|
|
1228
|
+
|
|
1229
|
+
# persistent (weights etc.) only from child; transient will be re-counted here
|
|
1230
|
+
child_hint = self.interactor.memory_hint(dtype=dtype, particle_size=None, sample=None, mode=mode)
|
|
1231
|
+
persistent = int(child_hint.persistent_bytes)
|
|
1232
|
+
|
|
1233
|
+
M_est = self._estimate_edges(P)
|
|
1234
|
+
chunk_E = int(M_est if self.chunk_size is None else int(self.chunk_size))
|
|
1235
|
+
|
|
1236
|
+
gather_bytes_per_edge = K * d * isz
|
|
1237
|
+
if bool(getattr(self.interactor, "needs_v", False)) or (sample and sample.has_v):
|
|
1238
|
+
gather_bytes_per_edge += K * d * isz
|
|
1239
|
+
# cheap mask slab
|
|
1240
|
+
if sample and sample.has_mask:
|
|
1241
|
+
gather_bytes_per_edge += K * isz_bool
|
|
1242
|
+
# row/col/valid bookkeeping
|
|
1243
|
+
gather_bytes_per_edge += 2 * isz_i32 + 2 * isz_bool
|
|
1244
|
+
|
|
1245
|
+
gather_bytes = chunk_E * gather_bytes_per_edge
|
|
1246
|
+
working_outputs = chunk_E * out_per_edge_bytes
|
|
1247
|
+
|
|
1248
|
+
# Final accumulator that persists while chunks stream
|
|
1249
|
+
if self.owners == "global":
|
|
1250
|
+
acc_bytes = rb * mfeat * isz
|
|
1251
|
+
else:
|
|
1252
|
+
acc_bytes = P * rb * mfeat * isz
|
|
1253
|
+
acc_bytes += P * isz_i32 # degrees for normalized means
|
|
1254
|
+
|
|
1255
|
+
per_sample = int(gather_bytes + working_outputs + acc_bytes)
|
|
1256
|
+
return MemHint(per_sample_bytes=per_sample, persistent_bytes=persistent)
|
|
1257
|
+
|
|
1258
|
+
# ───────────────────── self-tuning chunk size from budget ─────────────────
|
|
1259
|
+
def suggest_chunk_size(
|
|
1260
|
+
self,
|
|
1261
|
+
*,
|
|
1262
|
+
max_per_sample_bytes: int,
|
|
1263
|
+
dtype=None,
|
|
1264
|
+
particle_size: int | None = None,
|
|
1265
|
+
sample: SampleMeta | None = None,
|
|
1266
|
+
mode: str = "forward",
|
|
1267
|
+
clamp_to_total: bool = True,
|
|
1268
|
+
) -> int:
|
|
1269
|
+
"""
|
|
1270
|
+
Choose the largest chunk size (edges per chunk) that keeps the
|
|
1271
|
+
*per-sample* memory under `max_per_sample_bytes`.
|
|
1272
|
+
|
|
1273
|
+
Returns a positive integer. If `clamp_to_total`, the result is capped
|
|
1274
|
+
by the estimated total number of edges for one sample.
|
|
1275
|
+
"""
|
|
1276
|
+
P = resolve_P(particle_size, sample) or 1
|
|
1277
|
+
d = int(getattr(self, "dim", 0) or 0)
|
|
1278
|
+
mfeat = int(getattr(self, "n_features", 0) or 0)
|
|
1279
|
+
r = int(getattr(self.interactor, "rank", 0) or 0)
|
|
1280
|
+
K = self._effective_K() or (sample.K if sample and sample.K else 2)
|
|
1281
|
+
|
|
1282
|
+
isz = itemsize_of(dtype)
|
|
1283
|
+
isz_i32 = jnp.dtype(jnp.int32).itemsize
|
|
1284
|
+
isz_bool = jnp.dtype(jnp.bool_).itemsize
|
|
1285
|
+
|
|
1286
|
+
rb = (d or 1) ** r
|
|
1287
|
+
out_per_edge_bytes = rb * mfeat * isz
|
|
1288
|
+
|
|
1289
|
+
gather_bytes_per_edge = K * d * isz
|
|
1290
|
+
if bool(getattr(self.interactor, "needs_v", False)) or (sample and sample.has_v):
|
|
1291
|
+
gather_bytes_per_edge += K * d * isz
|
|
1292
|
+
if sample and sample.has_mask:
|
|
1293
|
+
gather_bytes_per_edge += K * isz_bool
|
|
1294
|
+
gather_bytes_per_edge += 2 * isz_i32 + 2 * isz_bool
|
|
1295
|
+
|
|
1296
|
+
# accumulator is independent of chunk size
|
|
1297
|
+
if self.owners == "global":
|
|
1298
|
+
acc_bytes = rb * mfeat * isz
|
|
1299
|
+
else:
|
|
1300
|
+
acc_bytes = P * rb * mfeat * isz
|
|
1301
|
+
acc_bytes += P * isz_i32
|
|
1302
|
+
|
|
1303
|
+
# budget inequality:
|
|
1304
|
+
# chunk_E * (gather_bytes_per_edge + out_per_edge_bytes) + acc_bytes <= max_bytes
|
|
1305
|
+
per_edge = gather_bytes_per_edge + out_per_edge_bytes
|
|
1306
|
+
budget = int(max_per_sample_bytes) - int(acc_bytes)
|
|
1307
|
+
if budget <= 0:
|
|
1308
|
+
return 1
|
|
1309
|
+
chunk_max = max(1, budget // int(per_edge))
|
|
1310
|
+
|
|
1311
|
+
if clamp_to_total:
|
|
1312
|
+
M_est = self._estimate_edges(P)
|
|
1313
|
+
chunk_max = min(chunk_max, int(max(1, M_est)))
|
|
1314
|
+
|
|
1315
|
+
return int(chunk_max)
|
|
1316
|
+
|
|
1317
|
+
# ───────────────────────── helpers used by memory_hint ─────────────────────
|
|
1318
|
+
def _effective_K(self) -> int | None:
|
|
1319
|
+
"""Return interaction arity if fixed; otherwise None."""
|
|
1320
|
+
if getattr(self, "_kmode", None) == "fixed" and getattr(self, "_K", None) is not None:
|
|
1321
|
+
return int(self._K)
|
|
1322
|
+
try:
|
|
1323
|
+
mode, K = self._spec_arity(self.spec) # existing helper
|
|
1324
|
+
if mode == "fixed" and K is not None:
|
|
1325
|
+
return int(K)
|
|
1326
|
+
except Exception:
|
|
1327
|
+
pass
|
|
1328
|
+
return None
|
|
1329
|
+
|
|
1330
|
+
def _estimate_edges(self, P: int) -> int:
|
|
1331
|
+
"""
|
|
1332
|
+
Best-effort conservative estimate of edge count for ONE sample.
|
|
1333
|
+
Uses concrete spec shapes when available, else worst-case.
|
|
1334
|
+
"""
|
|
1335
|
+
spec = self.spec
|
|
1336
|
+
# exact rows/nnz when available
|
|
1337
|
+
if isinstance(spec, PairsCSR):
|
|
1338
|
+
try:
|
|
1339
|
+
return int(spec.indices.shape[-1])
|
|
1340
|
+
except Exception:
|
|
1341
|
+
return int(P * (P - 1)) if getattr(self, "exclude_self", False) else int(P * P)
|
|
1342
|
+
|
|
1343
|
+
if isinstance(spec, HyperFixed):
|
|
1344
|
+
try:
|
|
1345
|
+
return int(spec.hyper.shape[-2]) # M rows
|
|
1346
|
+
except Exception:
|
|
1347
|
+
return int(P * max(0, P - 1))
|
|
1348
|
+
|
|
1349
|
+
if isinstance(spec, HyperCSR):
|
|
1350
|
+
try:
|
|
1351
|
+
return int(spec.he_indptr.shape[-1]) - 1
|
|
1352
|
+
except Exception:
|
|
1353
|
+
return int(P * max(0, P - 1))
|
|
1354
|
+
|
|
1355
|
+
if isinstance(spec, SpecRule):
|
|
1356
|
+
try:
|
|
1357
|
+
mode, K = spec.arity()
|
|
1358
|
+
if mode == "fixed" and K == 2:
|
|
1359
|
+
return int(P * (P - 1)) if getattr(self, "exclude_self", False) else int(P * P)
|
|
1360
|
+
except Exception:
|
|
1361
|
+
pass
|
|
1362
|
+
return int(P * max(0, P - 1))
|
|
1363
|
+
|
|
1364
|
+
# unknown spec → pessimistic bound
|
|
1365
|
+
return int(P * max(0, P - 1))
|