StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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))