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,718 @@
1
+ """
2
+ Stencil helpers for grid-based SPDEs.
3
+
4
+ This module provides *spec constructors* for fixed-K regular-grid stencils.
5
+
6
+ Why a separate module?
7
+ ---------------------
8
+ The :mod:`~SFI.statefunc.nodes.interactions.specs` module defines generic
9
+ containers (:class:`~SFI.statefunc.nodes.interactions.specs.HyperFixed`,
10
+ :class:`~SFI.statefunc.nodes.interactions.specs.HyperCSR`, ...). Stencils are
11
+ domain-specific *ways of producing* such specs (e.g. finite differences on a
12
+ Cartesian lattice), so keeping them here avoids mixing generic API with
13
+ application-level utilities.
14
+
15
+ Conventions
16
+ -----------
17
+ We assume that a field discretized on a regular grid is represented as a
18
+ particle system with:
19
+
20
+ * particle axis = flattened grid index ``p = 0..P-1``;
21
+ * state dimension ``dim`` = number of field components per grid site.
22
+
23
+ This matches the StateExpr/dispatcher convention used across SFI.
24
+
25
+ Cache-only extras
26
+ ----------------
27
+ When stencil hyper tables / slot masks / slot geometry are materialized as extras
28
+ (e.g. via a `prepare_extras()` hook), they must be stored under the ``_cache/``
29
+ prefix so they can be purged safely after any context-changing operation.
30
+
31
+ Box polymorphism strategy
32
+ -------------------------
33
+ A stencil-based operator (e.g. Laplacian) is created **once** with boundary
34
+ conditions and offset layout fixed. The dataset provides only:
35
+
36
+ * ``{box}/grid_shape`` : integer array-like, shape (ndim,)
37
+ * ``{box}/dx`` : float array-like, shape (ndim,) or scalar
38
+
39
+ The neighbor lists (HyperFixed spec) are then built *on demand* from grid_shape
40
+ (and cached by :class:`~SFI.statefunc.nodes.interactions.specs.CachedRule`).
41
+
42
+ This avoids having to precompute and store large structural arrays in dataset
43
+ extras, while keeping the basis / PSF object reusable across grid sizes.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from typing import Any, Dict, Literal, Mapping, Optional, Sequence
49
+
50
+ import equinox as eqx
51
+ import jax.numpy as jnp
52
+ import numpy as np
53
+ from jaxtyping import Array
54
+
55
+ from .specs import FromExtrasHyperFixed, HyperFixed, SpecRule
56
+
57
+ # =============================================================================
58
+ # Small utilities (grid <-> flat index)
59
+ # =============================================================================
60
+
61
+
62
+ def _as_int_tuple(x) -> tuple[int, ...]:
63
+ """Convert a grid shape-like object to a Python tuple of ints.
64
+
65
+ This is used for cache keys and NumPy-side construction of stencil specs.
66
+ """
67
+ if isinstance(x, tuple):
68
+ return tuple(int(v) for v in x)
69
+ arr = np.asarray(x)
70
+ if arr.ndim != 1:
71
+ raise ValueError(f"grid_shape must be 1D (got shape {arr.shape})")
72
+ return tuple(int(v) for v in arr.tolist())
73
+
74
+
75
+ def _as_float_array(dx: float | Sequence[float], *, ndim: int) -> np.ndarray:
76
+ """Return dx as a NumPy array of shape (ndim,)."""
77
+ if np.isscalar(dx):
78
+ return np.full((ndim,), float(dx), dtype=np.float64)
79
+ arr = np.asarray(dx, dtype=np.float64)
80
+ if arr.shape != (ndim,):
81
+ raise ValueError(f"dx must be scalar or shape ({ndim},) (got {arr.shape})")
82
+ return arr
83
+
84
+
85
+ def _coords_from_flat(p: np.ndarray, grid_shape: tuple[int, ...], *, order: str) -> np.ndarray:
86
+ """Map flat indices to coordinates.
87
+
88
+ Parameters
89
+ ----------
90
+ p
91
+ Flat indices, shape (...,).
92
+ grid_shape
93
+ Grid shape (n0, n1, ..., n_{ndim-1}).
94
+ order
95
+ Flattening order, either "C" (row-major) or "F" (column-major).
96
+
97
+ Returns
98
+ -------
99
+ coords
100
+ Integer coordinates, shape (..., ndim).
101
+ """
102
+ if order not in ("C", "F"):
103
+ raise ValueError(f"order must be 'C' or 'F' (got {order!r})")
104
+ coords = np.stack(np.unravel_index(p, grid_shape, order=order), axis=-1)
105
+ return coords.astype(np.int32)
106
+
107
+
108
+ def _flat_from_coords(coords: np.ndarray, grid_shape: tuple[int, ...], *, order: str) -> np.ndarray:
109
+ """Map integer coordinates to flat indices.
110
+
111
+ coords must be shape (..., ndim), with 0 <= coords[...,ax] < grid_shape[ax].
112
+ """
113
+ if order == "C":
114
+ strides = np.cumprod((1,) + grid_shape[::-1])[:-1][::-1]
115
+ elif order == "F":
116
+ strides = np.cumprod((1,) + grid_shape)[:-1]
117
+ else:
118
+ raise ValueError(f"order must be 'C' or 'F' (got {order!r})")
119
+ strides = np.asarray(strides, dtype=np.int64) # (ndim,)
120
+ return (coords.astype(np.int64) * strides).sum(axis=-1).astype(np.int32)
121
+
122
+
123
+ # =============================================================================
124
+ # Offset constructors and weights (Cartesian square grid)
125
+ # =============================================================================
126
+
127
+
128
+ def square_axis_offsets(
129
+ ndim: int,
130
+ axis: int,
131
+ *,
132
+ scheme: Literal["central", "forward", "backward"] = "central",
133
+ include_center: bool = True,
134
+ ) -> Array:
135
+ """Offsets for a 1D finite-difference stencil along one grid axis.
136
+
137
+ Parameters
138
+ ----------
139
+ ndim
140
+ Grid dimension.
141
+ axis
142
+ Which grid axis to differentiate along (0 ≤ axis < ndim).
143
+ scheme
144
+ One of: "central" (±e_axis), "forward" (+e_axis), "backward" (-e_axis).
145
+ include_center
146
+ Whether to include the zero offset as the first slot.
147
+
148
+ Returns
149
+ -------
150
+ offsets
151
+ Integer offsets shaped (K, ndim).
152
+ """
153
+ ndim = int(ndim)
154
+ axis = int(axis)
155
+ if not (0 <= axis < ndim):
156
+ raise ValueError(f"axis must be in [0, {ndim}) (got {axis})")
157
+
158
+ offs: list[tuple[int, ...]] = []
159
+ if include_center:
160
+ offs.append((0,) * ndim)
161
+
162
+ e = [0] * ndim
163
+ if scheme == "central":
164
+ e[axis] = 1
165
+ offs.append(tuple(e))
166
+ e[axis] = -1
167
+ offs.append(tuple(e))
168
+ elif scheme == "forward":
169
+ e[axis] = 1
170
+ offs.append(tuple(e))
171
+ elif scheme == "backward":
172
+ e[axis] = -1
173
+ offs.append(tuple(e))
174
+ else:
175
+ raise ValueError(f"Unknown scheme {scheme!r}")
176
+
177
+ return jnp.asarray(offs, dtype=jnp.int32)
178
+
179
+
180
+ def square_cross_offsets(ndim: int, *, include_center: bool = True) -> Array:
181
+ """Offsets for the standard cross stencil (center + nearest neighbors).
182
+
183
+ Ordering convention (important!)
184
+ -------------------------------
185
+ Returned offsets are:
186
+
187
+ [0, +e0, -e0, +e1, -e1, ..., +e_{ndim-1}, -e_{ndim-1}]
188
+
189
+ This ordering is assumed by :func:`square_weights_laplacian_cross` and by
190
+ the SPDE Laplacian helper in :mod:`SFI.bases.spde`.
191
+
192
+ Parameters
193
+ ----------
194
+ ndim
195
+ Grid dimension.
196
+ include_center
197
+ Whether to include the center offset as the first slot.
198
+
199
+ Returns
200
+ -------
201
+ offsets
202
+ Integer offsets shaped (K, ndim), with K = 1 + 2*ndim if include_center.
203
+ """
204
+ ndim = int(ndim)
205
+ offs: list[tuple[int, ...]] = []
206
+ if include_center:
207
+ offs.append((0,) * ndim)
208
+ for ax in range(ndim):
209
+ e = [0] * ndim
210
+ e[ax] = 1
211
+ offs.append(tuple(e))
212
+ e[ax] = -1
213
+ offs.append(tuple(e))
214
+ return jnp.asarray(offs, dtype=jnp.int32)
215
+
216
+
217
+ def square_weights_laplacian_cross(dx: float | Sequence[float], *, ndim: int) -> Array:
218
+ """Weights for the cross Laplacian stencil.
219
+
220
+ This matches the ordering returned by :func:`square_cross_offsets`.
221
+
222
+ For anisotropic dx:
223
+ Δu ≈ Σ_ax (u_{+ax} - 2 u_0 + u_{-ax}) / dx_ax^2
224
+
225
+ Parameters
226
+ ----------
227
+ dx
228
+ Grid spacing (scalar or per-axis sequence).
229
+ ndim
230
+ Grid dimension.
231
+
232
+ Returns
233
+ -------
234
+ w
235
+ Float weights shaped (K,), with K = 1 + 2*ndim.
236
+ """
237
+ dxv = _as_float_array(dx, ndim=int(ndim))
238
+ inv = 1.0 / (dxv * dxv) # (ndim,)
239
+
240
+ # Slot 0: center coefficient = -2 * sum(inv_dx2)
241
+ w0 = -2.0 * inv.sum()
242
+
243
+ # Slots: (+e0, -e0, +e1, -e1, ...)
244
+ w = [w0]
245
+ for ax in range(int(ndim)):
246
+ w.append(inv[ax])
247
+ w.append(inv[ax])
248
+ return jnp.asarray(w, dtype=jnp.float32)
249
+
250
+
251
+ def square_biharmonic_offsets(ndim: int, *, include_center: bool = True) -> Array:
252
+ r"""Offsets for the biharmonic :math:`\nabla^4` stencil on a Cartesian grid.
253
+
254
+ In 2D, this is the standard 13-point stencil
255
+ (extended cross radius-2 **plus** diagonal corners ``(±1, ±1)``).
256
+
257
+ Ordering convention
258
+ -------------------
259
+ Returned offsets are:
260
+
261
+ [center,
262
+ +2e0, -2e0, +2e1, -2e1, ..., # extended along axes (radius 2)
263
+ +e0, -e0, +e1, -e1, ..., # cross (radius 1)
264
+ +e0+e1, +e0-e1, -e0+e1, -e0-e1, ...] # diagonal corners
265
+
266
+ Parameters
267
+ ----------
268
+ ndim
269
+ Grid dimension (2 or 3).
270
+ include_center
271
+ Whether to include the center offset as the first slot.
272
+
273
+ Returns
274
+ -------
275
+ offsets
276
+ Integer offsets shaped ``(K, ndim)``.
277
+ """
278
+ ndim = int(ndim)
279
+ offs: list[tuple[int, ...]] = []
280
+
281
+ if include_center:
282
+ offs.append((0,) * ndim)
283
+
284
+ # --- radius-2 along each axis ---
285
+ for ax in range(ndim):
286
+ e = [0] * ndim
287
+ e[ax] = 2
288
+ offs.append(tuple(e))
289
+ e[ax] = -2
290
+ offs.append(tuple(e))
291
+
292
+ # --- radius-1 cross ---
293
+ for ax in range(ndim):
294
+ e = [0] * ndim
295
+ e[ax] = 1
296
+ offs.append(tuple(e))
297
+ e[ax] = -1
298
+ offs.append(tuple(e))
299
+
300
+ # --- diagonal corners: all combinations of ±1 in pairs of axes ---
301
+ import itertools
302
+
303
+ for ax_pair in itertools.combinations(range(ndim), 2):
304
+ for signs in itertools.product([1, -1], repeat=2):
305
+ e = [0] * ndim
306
+ for a, s in zip(ax_pair, signs):
307
+ e[a] = s
308
+ offs.append(tuple(e))
309
+
310
+ return jnp.asarray(offs, dtype=jnp.int32)
311
+
312
+
313
+ # =============================================================================
314
+ # HyperFixed construction for square stencils
315
+ # =============================================================================
316
+
317
+
318
+ def hyperfixed_square_stencil(
319
+ *,
320
+ grid_shape: Sequence[int],
321
+ offsets: Array,
322
+ bc: Literal["pbc", "noflux", "drop"] = "noflux",
323
+ order: Literal["C", "F"] = "C",
324
+ include_slot_extras_offset: bool = True,
325
+ ) -> tuple[Array, Array, Optional[Array]]:
326
+ """Build a fixed-K stencil as HyperFixed tables for a Cartesian grid.
327
+
328
+ Each grid site becomes one hyperedge of arity K, whose participants are
329
+ the focal site and its neighbors at the provided integer `offsets`.
330
+
331
+ Parameters
332
+ ----------
333
+ grid_shape
334
+ Spatial grid shape, length ndim.
335
+ offsets
336
+ Integer offsets of shape (K, ndim).
337
+ bc
338
+ Boundary condition handling:
339
+
340
+ - ``"pbc"``: periodic wrap
341
+ - ``"noflux"``: out-of-bounds neighbors are replaced by focal index
342
+ (Neumann-like for cross stencils)
343
+ - ``"drop"``: out-of-bounds slots are deactivated via slot_mask
344
+ (neighbors replaced by focal index but masked out)
345
+ order
346
+ Flattening order used to map coords <-> flat indices.
347
+ include_slot_extras_offset
348
+ If True, also returns `slot_extras_offset` = integer offsets broadcast to
349
+ shape (P, K, ndim). This is useful for direction-aware operators
350
+ (grad/div/elastic contractions), and for non-square lattices you can
351
+ later replace this with real edge vectors as extras.
352
+
353
+ Returns
354
+ -------
355
+ hyper
356
+ Int array, shape (P, K), neighbor indices for each focal site.
357
+ slot_mask
358
+ Bool array, shape (P, K), True for active slots.
359
+ slot_extras_offset
360
+ Optional int array, shape (P, K, ndim), the integer offset vectors.
361
+ """
362
+ gshape = _as_int_tuple(grid_shape)
363
+ ndim = len(gshape)
364
+ offsets_np = np.asarray(offsets, dtype=np.int32)
365
+ if offsets_np.ndim != 2 or offsets_np.shape[1] != ndim:
366
+ raise ValueError(f"offsets must have shape (K,{ndim}) (got {offsets_np.shape})")
367
+ K = int(offsets_np.shape[0])
368
+
369
+ P = int(np.prod(gshape))
370
+ p = np.arange(P, dtype=np.int32)
371
+ coords = _coords_from_flat(p, gshape, order=order) # (P, ndim)
372
+
373
+ # Neighbor coordinates for each slot: (P, K, ndim)
374
+ nbr = coords[:, None, :] + offsets_np[None, :, :]
375
+
376
+ # In-bounds mask per slot: (P, K)
377
+ inb = np.ones((P, K), dtype=bool)
378
+ for ax, n in enumerate(gshape):
379
+ inb &= (nbr[..., ax] >= 0) & (nbr[..., ax] < n)
380
+
381
+ if bc == "pbc":
382
+ for ax, n in enumerate(gshape):
383
+ nbr[..., ax] %= n
384
+ slot_mask = np.ones((P, K), dtype=bool)
385
+ elif bc in ("noflux", "drop"):
386
+ # Replace any out-of-bounds slot by focal coordinate.
387
+ #
388
+ # - For "noflux": keeping focal indices is the *actual* boundary behavior.
389
+ # - For "drop": focal indices are placeholders; the slot will be deactivated
390
+ # by slot_mask.
391
+ #
392
+ # IMPORTANT: boolean indexing must match shapes; use np.where to avoid
393
+ # shape pitfalls on (P,K,ndim) arrays.
394
+ nbr = np.where(inb[..., None], nbr, coords[:, None, :])
395
+ if bc == "noflux":
396
+ slot_mask = np.ones((P, K), dtype=bool)
397
+ else:
398
+ slot_mask = inb
399
+ else:
400
+ raise ValueError(f"Unknown bc={bc!r}")
401
+
402
+ hyper = _flat_from_coords(nbr, gshape, order=order).astype(np.int32) # (P, K)
403
+
404
+ if include_slot_extras_offset:
405
+ slot_extras_offset = np.broadcast_to(offsets_np[None, :, :], (P, K, ndim)).astype(np.int32)
406
+ else:
407
+ slot_extras_offset = None
408
+
409
+ return (
410
+ jnp.asarray(hyper, dtype=jnp.int32),
411
+ jnp.asarray(slot_mask),
412
+ (None if slot_extras_offset is None else jnp.asarray(slot_extras_offset, dtype=jnp.int32)),
413
+ )
414
+
415
+
416
+ class HyperFixedSquareStencilFromBox(SpecRule):
417
+ """Build a HyperFixed stencil from *box* extras: `grid_shape` (and optionally `dx`).
418
+
419
+ This is the preferred entry point for *box-polymorphic* SPDE operators.
420
+
421
+ Only the *small* box descriptors are required as dataset extras, while the
422
+ large hyper tables are built on demand and typically cached by wrapping
423
+ this rule in :class:`~SFI.statefunc.nodes.interactions.specs.CachedRule`.
424
+
425
+ Parameters
426
+ ----------
427
+ offsets
428
+ Integer offsets, shape (K, ndim). The slot order must match the operator.
429
+ bc, order
430
+ Boundary condition and flattening order.
431
+ key_grid_shape
432
+ Extras key for the grid shape (length ndim).
433
+ include_slot_extras_offset
434
+ Whether to store integer offset vectors in `spec.slot_extras["offset"]`.
435
+ This is useful for future direction-aware operators (grad/div, elasticity),
436
+ and for non-square lattices you can later substitute real edge vectors.
437
+ """
438
+
439
+ offsets: tuple = eqx.field(static=True) # tuple-of-tuples of ints
440
+ bc: Literal["pbc", "noflux", "drop"] = eqx.field(static=True)
441
+ order: Literal["C", "F"] = eqx.field(static=True)
442
+ key_grid_shape: str = eqx.field(static=True)
443
+ include_slot_extras_offset: bool = eqx.field(static=True)
444
+
445
+ def __init__(
446
+ self,
447
+ *,
448
+ offsets: Array,
449
+ bc: Literal["pbc", "noflux", "drop"] = "noflux",
450
+ order: Literal["C", "F"] = "C",
451
+ key_grid_shape: str,
452
+ include_slot_extras_offset: bool = True,
453
+ ):
454
+ _arr = np.asarray(offsets, dtype=np.int32)
455
+ object.__setattr__(self, "offsets", tuple(tuple(int(x) for x in row) for row in _arr))
456
+ object.__setattr__(self, "bc", bc)
457
+ object.__setattr__(self, "order", order)
458
+ object.__setattr__(self, "key_grid_shape", str(key_grid_shape))
459
+ object.__setattr__(self, "include_slot_extras_offset", bool(include_slot_extras_offset))
460
+
461
+ def arity(self):
462
+ K = len(self.offsets)
463
+ return ("fixed", K)
464
+
465
+ def required_extras(self) -> tuple[str, ...]:
466
+ # These are small globals and should be forwarded.
467
+ return (self.key_grid_shape,)
468
+
469
+ def structural_extras(self) -> tuple[str, ...]:
470
+ # We do not consume large structural arrays from extras here.
471
+ return ()
472
+
473
+ def cache_key(self, extras) -> tuple:
474
+ # CachedRule will combine (P, cache_key). We include full grid_shape to
475
+ # avoid collisions for different factorizations with same P.
476
+ gshape = _as_int_tuple(extras[self.key_grid_shape])
477
+ return (gshape, self.bc, self.order, len(self.offsets))
478
+
479
+ def build(self, x, *, v=None, mask=None, extras=None) -> HyperFixed:
480
+ if extras is None:
481
+ raise KeyError("HyperFixedSquareStencilFromBox: extras is required")
482
+ gshape = _as_int_tuple(extras[self.key_grid_shape])
483
+ hyper, slot_mask, slot_offs = hyperfixed_square_stencil(
484
+ grid_shape=gshape,
485
+ offsets=np.array(self.offsets, dtype=np.int32),
486
+ bc=self.bc,
487
+ order=self.order,
488
+ include_slot_extras_offset=self.include_slot_extras_offset,
489
+ )
490
+
491
+ slot_extras = None
492
+ if slot_offs is not None:
493
+ slot_extras = {"offset": slot_offs}
494
+
495
+ return HyperFixed(hyper=hyper, slot_mask=slot_mask, slot_extras=slot_extras)
496
+
497
+
498
+ # =============================================================================
499
+ # Convenience: add box extras
500
+ # =============================================================================
501
+
502
+
503
+ def box_extras(
504
+ *,
505
+ grid_shape: Sequence[int],
506
+ dx: float | Sequence[float] = 1.0,
507
+ prefix: str = "box",
508
+ ) -> Dict[str, Array]:
509
+ """Return the minimal extras dictionary required by box-polymorphic stencils.
510
+
511
+ This is intended for `TrajectoryCollection.extras_global.update(...)`.
512
+
513
+ Keys
514
+ ----
515
+ - ``{prefix}/grid_shape``: int32 array, shape (ndim,)
516
+ - ``{prefix}/dx`` : float32 array, shape (ndim,)
517
+
518
+ Notes
519
+ -----
520
+ - We store dx as a length-ndim vector even when input is scalar, so that
521
+ downstream operators can assume a stable shape.
522
+ - These are *globals* (not structural) and should flow through the dispatcher.
523
+ """
524
+ gshape = _as_int_tuple(grid_shape)
525
+ if any(n <= 0 for n in gshape):
526
+ raise ValueError(f"grid_shape entries must be positive; got {gshape}")
527
+ dxv = _as_float_array(dx, ndim=len(gshape))
528
+ if np.any(dxv <= 0.0):
529
+ raise ValueError(f"dx must be positive; got {dxv}")
530
+ return {
531
+ f"{prefix}/grid_shape": jnp.asarray(gshape, dtype=jnp.int32),
532
+ f"{prefix}/dx": jnp.asarray(dxv, dtype=jnp.float32),
533
+ }
534
+
535
+
536
+ # =============================================================================
537
+ # Host-prepared stencil rules (box-polymorphic, JIT-safe at runtime)
538
+ # =============================================================================
539
+
540
+
541
+ class PreparedSquareStencilFromBox(SpecRule):
542
+ """
543
+ Regular-grid fixed-K stencil whose *structural* arrays are prepared on the host.
544
+
545
+ This rule is the “JIT-friendly” way to use grid-based differential operators:
546
+
547
+ - At **prepare time** (Python side, not under JIT), we:
548
+ * read small box descriptors from `extras` (e.g. grid shape, maybe dx later),
549
+ * build large structural arrays (hyper table, slot mask, slot offsets),
550
+ * store them back into `extras` under a cache prefix.
551
+
552
+ - At **build/eval time** (can be under JIT), we:
553
+ * only *read* those already-prepared arrays from `extras`,
554
+ * never call NumPy, never hash tracer values, never do Python caching.
555
+
556
+ Structural extras policy
557
+ ------------------------
558
+ The keys written under the cache prefix are *structural*: they are consumed by the
559
+ dispatcher/spec builder and must not be forwarded to child local-ops.
560
+
561
+ Therefore, these keys are returned by `structural_extras()` and **not** by
562
+ `required_extras()`.
563
+
564
+ Cache naming
565
+ ------------
566
+ This class supports two equivalent APIs:
567
+
568
+ - `key_prefix="..."`
569
+ Explicit prefix where arrays are stored/read.
570
+
571
+ Parameters
572
+ ----------
573
+ offsets
574
+ Integer offsets, shape (K, ndim). Slot order must match the operator.
575
+ bc, order
576
+ Boundary condition and flattening order passed to `hyperfixed_square_stencil`.
577
+ key_grid_shape
578
+ Extras key holding the grid shape (length ndim). Must be *concrete* at prepare time.
579
+ key_prefix
580
+ Prefix used to store structural arrays in extras.
581
+ Keys written/read:
582
+ - f"{key_prefix}/hyper"
583
+ - f"{key_prefix}/slot_mask"
584
+ - optionally f"{key_prefix}/slot_offset"
585
+ include_slot_extras_offset
586
+ If True, store per-slot offset vectors under f"{key_prefix}/slot_offset".
587
+ This is what you want for direction-aware operators later.
588
+ """
589
+
590
+ offsets: tuple = eqx.field(static=True) # tuple-of-tuples of ints
591
+ bc: Literal["pbc", "noflux", "drop"] = eqx.field(static=True)
592
+ order: Literal["C", "F"] = eqx.field(static=True)
593
+ key_grid_shape: str = eqx.field(static=True)
594
+ key_prefix: str = eqx.field(static=True)
595
+ include_slot_extras_offset: bool = eqx.field(static=True)
596
+
597
+ # Internal reader: JIT-safe, just reads arrays from extras.
598
+ _reader: "FromExtrasHyperFixed" = eqx.field(static=True)
599
+
600
+ def __init__(
601
+ self,
602
+ *,
603
+ offsets: Array,
604
+ bc: Literal["pbc", "noflux", "drop"] = "noflux",
605
+ order: Literal["C", "F"] = "C",
606
+ key_grid_shape: str,
607
+ key_prefix: str,
608
+ include_slot_extras_offset: bool = True,
609
+ ):
610
+ object.__setattr__(self, "key_prefix", str(key_prefix))
611
+ object.__setattr__(self, "key_grid_shape", str(key_grid_shape))
612
+
613
+ _arr = np.asarray(offsets, dtype=np.int32)
614
+ object.__setattr__(self, "offsets", tuple(tuple(int(x) for x in row) for row in _arr))
615
+ object.__setattr__(self, "bc", bc)
616
+ object.__setattr__(self, "order", order)
617
+ object.__setattr__(self, "include_slot_extras_offset", bool(include_slot_extras_offset))
618
+
619
+ # --- structural keys written/read under key_prefix ----------------
620
+ key_h = f"{self.key_prefix}/hyper"
621
+ key_m = f"{self.key_prefix}/slot_mask"
622
+ key_o = f"{self.key_prefix}/slot_offset" if self.include_slot_extras_offset else None
623
+
624
+ # Reader rule is purely a “read from extras” spec builder: JIT-safe.
625
+ object.__setattr__(
626
+ self,
627
+ "_reader",
628
+ FromExtrasHyperFixed(
629
+ key_hyper=key_h,
630
+ key_slot_mask=key_m,
631
+ key_slot_offset=key_o,
632
+ ),
633
+ )
634
+
635
+ def arity(self):
636
+ # Fixed-K stencil: K is the number of offsets (slots).
637
+ return ("fixed", len(self.offsets))
638
+
639
+ def required_extras(self) -> tuple[str, ...]:
640
+ # Small “box descriptors” should remain forwardable and available to children.
641
+ # They are also what `prepare_extras` needs.
642
+ return (self.key_grid_shape,)
643
+
644
+ def structural_extras(self) -> tuple[str, ...]:
645
+ # Structural arrays live under the cache prefix; these must not be forwarded.
646
+ return self._reader.structural_extras()
647
+
648
+ # ------------------------------------------------------------------
649
+ # Host-side preparation hook
650
+ # ------------------------------------------------------------------
651
+ def prepare_extras(self, extras: Mapping[str, Any]) -> dict[str, Any]:
652
+ """
653
+ Host-side hook: build and insert structural stencil arrays into `extras`.
654
+
655
+ Contract
656
+ --------
657
+ - Input `extras` must provide `key_grid_shape` as a *concrete* value.
658
+ - Returns a dict of additions/updates to be merged into extras_global.
659
+ - If keys already exist in extras, does nothing (returns {}).
660
+
661
+ Notes
662
+ -----
663
+ This function is intentionally *not JIT-safe* (uses Python/NumPy),
664
+ and must be called outside traced code (e.g. in process.initialize(),
665
+ inference entry points, etc.).
666
+ """
667
+ if extras is None:
668
+ raise KeyError("PreparedSquareStencilFromBox.prepare_extras: extras is required")
669
+
670
+ # If the structural arrays are already present, do nothing.
671
+ key_h = f"{self.key_prefix}/hyper"
672
+ key_m = f"{self.key_prefix}/slot_mask"
673
+ key_o = f"{self.key_prefix}/slot_offset"
674
+ have_h = key_h in extras
675
+ have_m = key_m in extras
676
+ have_o = (key_o in extras) if self.include_slot_extras_offset else True
677
+ if have_h and have_m and have_o:
678
+ return {}
679
+
680
+ # Read grid shape (must be concrete here).
681
+ gshape = _as_int_tuple(extras[self.key_grid_shape])
682
+
683
+ # Build heavy structural arrays (NumPy-heavy helper).
684
+ hyper, slot_mask, slot_offs = hyperfixed_square_stencil(
685
+ grid_shape=gshape,
686
+ offsets=np.array(self.offsets, dtype=np.int32),
687
+ bc=self.bc,
688
+ order=self.order,
689
+ include_slot_extras_offset=self.include_slot_extras_offset,
690
+ )
691
+
692
+ # Normalize outputs.
693
+ hyper = jnp.asarray(hyper, dtype=jnp.int32)
694
+ if slot_mask is None:
695
+ slot_mask = jnp.ones_like(hyper, dtype=jnp.bool_)
696
+ else:
697
+ slot_mask = jnp.asarray(slot_mask, dtype=jnp.bool_)
698
+
699
+ built: dict[str, Any] = {
700
+ key_h: hyper,
701
+ key_m: slot_mask,
702
+ }
703
+ if self.include_slot_extras_offset:
704
+ # slot_offs has shape (M, K, ndim) with integer offsets.
705
+ built[key_o] = jnp.asarray(slot_offs, dtype=jnp.int32)
706
+
707
+ return built
708
+
709
+ # ------------------------------------------------------------------
710
+ # JIT-safe spec builder: just read arrays prepared above
711
+ # ------------------------------------------------------------------
712
+ def build(self, x, *, v=None, mask=None, extras: Mapping[str, Any] | None = None) -> HyperFixed:
713
+ """
714
+ Build a `HyperFixed` spec by reading arrays from `extras`.
715
+
716
+ This is JIT-safe provided `extras` already contains the prepared keys.
717
+ """
718
+ return self._reader.build(x, v=v, mask=mask, extras=extras)