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,161 @@
1
+ # SFI/statefunc/nodes/interactions/prepare.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ from typing import Any, Dict, Iterable, Mapping, MutableMapping, Optional
6
+
7
+ from SFI.trajectory.collection import TrajectoryCollection
8
+ from SFI.trajectory.dataset import TrajectoryDataset
9
+
10
+ # -----------------------------------------------------------------------------
11
+ # Cache-only extras convention
12
+ # -----------------------------------------------------------------------------
13
+ CACHE_PREFIX = "_cache/"
14
+ """
15
+ Prefix reserved for **auto-generated** extras.
16
+
17
+ Any key under ``_cache/``:
18
+ - may be large (CSR tables, hyper tables, slot masks, slot geometry...),
19
+ - is **derivable** from smaller context (box geometry, offsets, bc, etc.),
20
+ - must be safe to drop when the dataset/collection context changes
21
+ (degradation, regridding, cropping, etc.).
22
+
23
+ Rule of thumb
24
+ -------------
25
+ If an extra can be regenerated without losing information, it belongs under
26
+ ``_cache/``.
27
+ """
28
+
29
+
30
+ def is_cache_key(k: str, *, prefix: str = CACHE_PREFIX) -> bool:
31
+ """Return True iff `k` is a cache-only key."""
32
+ return str(k).startswith(prefix)
33
+
34
+
35
+ def purge_cache_extras(
36
+ extras: Optional[Mapping[str, Any]],
37
+ *,
38
+ prefix: str = CACHE_PREFIX,
39
+ ) -> Optional[Dict[str, Any]]:
40
+ """
41
+ Drop all cache-only keys from an extras mapping.
42
+
43
+ This is the universal "invalidate derived structural extras" operation.
44
+ """
45
+ if extras is None:
46
+ return None
47
+ if not any(is_cache_key(k, prefix=prefix) for k in extras.keys()):
48
+ return dict(extras)
49
+ return {k: v for k, v in extras.items() if not is_cache_key(k, prefix=prefix)}
50
+
51
+
52
+ # -----------------------------------------------------------------------------
53
+ # Host-side structural preparation hooks
54
+ # -----------------------------------------------------------------------------
55
+ def _iter_nodes(root: Any) -> Iterable[Any]:
56
+ """DFS over node trees using a conventional `children` attribute."""
57
+ stack = [root]
58
+ seen: set[int] = set()
59
+ while stack:
60
+ n = stack.pop()
61
+ if id(n) in seen:
62
+ continue
63
+ seen.add(id(n))
64
+ yield n
65
+ for ch in getattr(n, "children", ()) or ():
66
+ stack.append(ch)
67
+
68
+
69
+ def prepare_structural_extras_for_expr(expr: Any, extras: MutableMapping[str, Any]) -> Dict[str, Any]:
70
+ """
71
+ Host-side: call `spec.prepare_extras(extras)` for any dispatcher-owned spec in `expr`.
72
+
73
+ Contract
74
+ --------
75
+ - This is **host-only** (must run outside JIT tracing).
76
+ - Any returned keys **must** start with ``_cache/``.
77
+ This makes "what is purgable" explicit and prevents accidentally
78
+ smuggling structural arrays into user-facing extras.
79
+
80
+ Returns
81
+ -------
82
+ dict
83
+ Newly created cache extras (also inserted into `extras` in-place).
84
+ """
85
+ root = expr.root
86
+ created: Dict[str, Any] = {}
87
+
88
+ for node in _iter_nodes(root):
89
+ spec = getattr(node, "spec", None)
90
+ if spec is None:
91
+ continue
92
+
93
+ fn = getattr(spec, "prepare_extras", None)
94
+ if fn is None:
95
+ continue
96
+
97
+ out = fn(extras)
98
+ if not out:
99
+ continue
100
+
101
+ bad = [k for k in out.keys() if not is_cache_key(k)]
102
+ if bad:
103
+ raise ValueError(f"prepare_extras() must only return keys under {CACHE_PREFIX!r}. Offending keys: {bad}")
104
+
105
+ extras.update(out)
106
+ created.update(out)
107
+
108
+ return created
109
+
110
+
111
+ def build_structural_overlay(expr: Any, dataset: TrajectoryDataset) -> Dict[str, Any]:
112
+ """
113
+ Build an expression's dispatcher-owned structural arrays into a *throwaway* overlay.
114
+
115
+ Returns just the ``_cache/`` arrays the specs in `expr` need (CSR / stencil
116
+ tables), built host-side from the dataset's **descriptors** (box geometry,
117
+ user-supplied CSR keys, ...) — **never written onto the dataset**. The build
118
+ always starts from the descriptors, dropping any pre-existing ``_cache/`` keys,
119
+ so the overlay cannot reuse a stale structural table: a changed descriptor
120
+ yields a freshly-built array.
121
+
122
+ This is the persistence-free counterpart to
123
+ :func:`prepare_collection_for_expr`. It runs host-side (outside JIT) and is
124
+ meant to be merged into the extras seen by a single evaluation, then discarded.
125
+ """
126
+ descriptors: Dict[str, Any] = {
127
+ k: v
128
+ for src in (dataset.extras_global or {}, dataset.extras_local or {})
129
+ for k, v in src.items()
130
+ if not is_cache_key(k)
131
+ }
132
+ return prepare_structural_extras_for_expr(expr, descriptors)
133
+
134
+
135
+ def prepare_collection_for_expr(coll: TrajectoryCollection, *exprs: Any) -> TrajectoryCollection:
136
+ """
137
+ Return a *transient* collection whose datasets carry the structural arrays
138
+ required by `exprs`, freshly built from each dataset's descriptors.
139
+
140
+ The structural arrays live under ``_cache/`` in ``extras_global``. Any
141
+ pre-existing ``_cache/`` keys are dropped and the union of every expression's
142
+ overlay is rebuilt, so a stale table can never survive. The input `coll` is
143
+ never mutated; the returned collection is meant to be used for a single
144
+ evaluation and then discarded (see
145
+ :meth:`~SFI.inference.base.BaseLangevinInference._structural_scope`).
146
+ """
147
+ new_datasets: list[TrajectoryDataset] = []
148
+ for ds in coll.datasets:
149
+ overlay: Dict[str, Any] = {}
150
+ for expr in exprs:
151
+ if expr is not None:
152
+ overlay.update(build_structural_overlay(expr, ds))
153
+ if overlay:
154
+ eg = {k: v for k, v in (ds.extras_global or {}).items() if not is_cache_key(k)}
155
+ eg.update(overlay)
156
+ ds2 = replace(ds, extras_global=eg)
157
+ else:
158
+ ds2 = ds
159
+ new_datasets.append(ds2)
160
+
161
+ return TrajectoryCollection(datasets=new_datasets, weights=coll.weights)
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, Mapping, Optional, Tuple
5
+
6
+ import equinox as eqx
7
+ import jax.numpy as jnp
8
+ from jaxtyping import Array
9
+
10
+ # ──────────────────────────────────────────────────────────────────────────────
11
+ # Specs = “what to dispatch”
12
+ # These are plain holders (or builders via rules) that describe the set of
13
+ # interactions. They do not compute anything by themselves.
14
+ # ──────────────────────────────────────────────────────────────────────────────
15
+
16
+ # -----------------------------------------------------------------------------
17
+ # Convention: cache-only extras keys
18
+ # -----------------------------------------------------------------------------
19
+ CACHE_PREFIX = "_cache/"
20
+ """
21
+ Keys starting with ``_cache/`` are reserved for auto-generated structural extras.
22
+
23
+ These objects should:
24
+ - be produced host-side (outside JIT),
25
+ - be purgeable whenever dataset/collection context changes,
26
+ - typically be marked as structural (dispatcher-owned) and not forwarded to
27
+ children operators.
28
+ """
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PairsCSR:
33
+ """
34
+ K=2 pairs encoded in CSR form.
35
+
36
+ Optional metadata (forwarded to interactor):
37
+ - edge_extras: per-edge arrays aligned with `indices` order (shape (..., nnz, ...))
38
+ - slot_extras: per-edge-per-slot arrays (shape (..., nnz, 2, ...))
39
+ """
40
+
41
+ indptr: Array
42
+ indices: Array
43
+ edge_extras: Optional[Mapping[str, Array]] = None
44
+ slot_extras: Optional[Mapping[str, Array]] = None
45
+
46
+ def __repr__(self) -> str:
47
+ try:
48
+ p = int(self.indptr.shape[-1]) - 1
49
+ m = int(self.indices.shape[-1])
50
+ return f"PairsCSR(P={p}, nnz={m})"
51
+ except Exception:
52
+ return "PairsCSR(?)"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class HyperFixed:
57
+ """
58
+ Fixed-K hyperedges: each row lists K participants in one interaction.
59
+
60
+ This is the natural container for regular-grid stencils:
61
+ - M = number of hyperedges (often M == P, one per focal site),
62
+ - K = number of participants in each hyperedge (center + neighbors).
63
+
64
+ Attributes
65
+ ----------
66
+ hyper
67
+ Integer array of shape (..., M, K) with indices in [0, P).
68
+ For grid stencils built by :func:`~SFI.statefunc.nodes.interactions.stencils.hyperfixed_square_stencil`,
69
+ the shape is typically (P, K).
70
+ slot_mask
71
+ Optional boolean array of shape (..., M, K). If present, marks which
72
+ slots are *in-bounds* (or otherwise active). This is useful for "drop"
73
+ boundary conditions and for operators that want to treat boundary slots
74
+ differently. For "noflux", you may still keep it for diagnostics.
75
+ edge_extras
76
+ Optional mapping of per-hyperedge arrays, each shaped (..., M, ...).
77
+ Forwarded to the interactor as-is.
78
+ slot_extras
79
+ Optional mapping of per-slot arrays, each shaped (..., M, K, ...).
80
+ Typical entry: ``{"offset": (M,K,ndim)}`` storing integer offset vectors
81
+ for each slot (directionality).
82
+ """
83
+
84
+ hyper: Array
85
+ slot_mask: Optional[Array] = None
86
+ edge_extras: Optional[Mapping[str, Any]] = None
87
+ slot_extras: Optional[Mapping[str, Any]] = None
88
+
89
+ def __repr__(self) -> str:
90
+ try:
91
+ m, k = int(self.hyper.shape[-2]), int(self.hyper.shape[-1])
92
+ return f"HyperFixed(M={m}, K={k})"
93
+ except Exception:
94
+ return "HyperFixed(?)"
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class HyperCSR:
99
+ """
100
+ Variable-K hyperedges (CSR-like).
101
+
102
+ - he_indptr: shape (..., M+1)
103
+ - he_indices: shape (..., total_K)
104
+
105
+ Optional metadata:
106
+ - slot_ids: integer codes aligned with he_indices (shape (..., total_K,))
107
+ - edge_extras: per-hyperedge arrays (shape (..., M, ...))
108
+ - slot_extras: per-slot arrays aligned with he_indices (shape (..., total_K, ...))
109
+ """
110
+
111
+ he_indptr: Array
112
+ he_indices: Array
113
+ slot_ids: Optional[Array] = None
114
+ edge_extras: Optional[Mapping[str, Array]] = None
115
+ slot_extras: Optional[Mapping[str, Array]] = None
116
+
117
+ def __repr__(self) -> str:
118
+ try:
119
+ m = int(self.he_indptr.shape[-1]) - 1
120
+ nz = int(self.he_indices.shape[-1])
121
+ return f"HyperCSR(M={m}, total_K={nz})"
122
+ except Exception:
123
+ return "HyperCSR(?)"
124
+
125
+
126
+ # ──────────────────────────────────────────────────────────────────────────────
127
+ # Rules = “how to build a spec from (x, …) at call-time”
128
+ # A rule advertises arity for constructor-time K-compat checks and builds the
129
+ # concrete spec in __call__/build.
130
+ # ──────────────────────────────────────────────────────────────────────────────
131
+
132
+
133
+ class SpecRule(eqx.Module):
134
+ """Base class for spec-building rules.
135
+
136
+ A rule advertises its arity for constructor-time K-compat checks and can
137
+ declare two kinds of extras:
138
+
139
+ - structural_extras(): keys owned by the rule/dispatcher (CSR arrays,
140
+ neighbor lists, etc.). They are required to build the schedule and are
141
+ **never forwarded** to children.
142
+
143
+ - required_extras(): presence-only keys that must be present at call time
144
+ for this rule to build, and that will be forwarded downstream (globals).
145
+ No broadcasting is enforced here; the runtime handles presence.
146
+
147
+ Cache-only extras convention
148
+ ----------------------------
149
+ Any auto-generated structural arrays should live under the ``_cache/`` prefix.
150
+ Degradation and other context-changing transforms are expected to drop them.
151
+
152
+ """
153
+
154
+ def arity(self) -> Tuple[Literal["fixed", "variable"], Optional[int]]:
155
+ """
156
+ Return ("fixed", K) or ("variable", None).
157
+ Used by the dispatcher constructor for K-compat checks.
158
+ """
159
+ raise NotImplementedError(f"{type(self).__name__} must implement arity")
160
+
161
+ def required_extras(self) -> tuple[str, ...]:
162
+ """
163
+ Presence-only extras the rule expects at build time and that will be
164
+ forwarded downstream (globals). Use this sparingly—most rules either
165
+ use no globals or only structural extras.
166
+ """
167
+ return ()
168
+
169
+ def structural_extras(self) -> tuple[str, ...]:
170
+ """
171
+ Structural arrays used by the rule/dispatcher (e.g., CSR 'indptr/indices').
172
+ They are *not* forwarded to children and must not be broadcast-checked.
173
+ """
174
+ return ()
175
+
176
+ def build(
177
+ self,
178
+ x: Array,
179
+ *,
180
+ v: Array | None = None,
181
+ mask: Array | None = None,
182
+ extras: Mapping[str, Any] | None = None,
183
+ ) -> PairsCSR | HyperFixed | HyperCSR:
184
+ raise NotImplementedError(f"{type(self).__name__} must implement build")
185
+
186
+
187
+ class AutoPairs(SpecRule):
188
+ """
189
+ Build an all-pairs CSR on the fly.
190
+
191
+ symmetric=True → directed i→j for all i≠j (each unordered pair counted twice).
192
+ symmetric=False → keep only i<j rows (each unordered pair counted once).
193
+ """
194
+
195
+ symmetric: bool = eqx.field(static=True, default=True)
196
+ exclude_self: bool = eqx.field(static=True, default=True)
197
+
198
+ def arity(self) -> Tuple[Literal["fixed", "variable"], Optional[int]]:
199
+ return ("fixed", 2)
200
+
201
+ def build(self, x, *, v=None, mask=None, extras=None) -> PairsCSR:
202
+ P = x.shape[-2]
203
+ i = jnp.arange(P, dtype=jnp.int32)
204
+
205
+ if self.symmetric:
206
+ if self.exclude_self:
207
+ # Each row has degree P-1; per-row columns are [0..P-1] \ {i}.
208
+ deg = jnp.full((P,), P - 1, dtype=jnp.int32)
209
+ indptr = jnp.concatenate([jnp.array([0], jnp.int32), jnp.cumsum(deg)])
210
+ base = jnp.arange(P - 1, dtype=jnp.int32) # (P-1,)
211
+ ii = i[:, None] # (P,1)
212
+ # For each row i, map base -> [0..i-1, i+1..P-1] via +1 shift after i
213
+ cols = base + (base >= ii).astype(jnp.int32) # (P, P-1)
214
+ indices = cols.reshape(-1) # grouped by row
215
+ return PairsCSR(indptr=indptr, indices=indices)
216
+ else:
217
+ # Degree P per row; columns are 0..P-1 for every row
218
+ deg = jnp.full((P,), P, dtype=jnp.int32)
219
+ indptr = jnp.concatenate([jnp.array([0], jnp.int32), jnp.cumsum(deg)])
220
+ cols = jnp.tile(i, (P, 1)) # (P, P)
221
+ indices = cols.reshape(-1)
222
+ return PairsCSR(indptr=indptr, indices=indices)
223
+
224
+ # Asymmetric: keep only j > i (strict upper triangle).
225
+ # Per-row degree is P-1-i; indptr follows directly.
226
+ deg = (P - 1 - i).astype(jnp.int32)
227
+ indptr = jnp.concatenate([jnp.array([0], jnp.int32), jnp.cumsum(deg)])
228
+ # jnp.triu_indices avoids boolean gathers and returns rows grouped, then cols
229
+ _, cols = jnp.triu_indices(P, k=1)
230
+ indices = cols.astype(jnp.int32)
231
+ return PairsCSR(indptr=indptr, indices=indices)
232
+
233
+
234
+ class FromExtrasPairsCSR(SpecRule):
235
+ """
236
+ Build a pairs spec by reading CSR arrays from `extras`.
237
+ These are *structural* arrays (dispatcher-owned, never forwarded).
238
+ """
239
+
240
+ key_indptr: str = eqx.field(static=True)
241
+ key_indices: str = eqx.field(static=True)
242
+
243
+ def arity(self) -> Tuple[Literal["fixed", "variable"], Optional[int]]:
244
+ return ("fixed", 2)
245
+
246
+ def required_extras(self) -> tuple[str, ...]:
247
+ # Presence is checked in build(); we don't list CSR here to avoid forwarding.
248
+ return ()
249
+
250
+ def structural_extras(self) -> tuple[str, ...]:
251
+ # Dispatcher will consume these; children never see them.
252
+ return (self.key_indptr, self.key_indices)
253
+
254
+ def build(
255
+ self,
256
+ x: Array,
257
+ *,
258
+ v: Array | None = None,
259
+ mask: Array | None = None,
260
+ extras=None,
261
+ ) -> PairsCSR:
262
+ if extras is None:
263
+ raise KeyError("FromExtrasPairsCSR: extras is required")
264
+ return PairsCSR(indptr=extras[self.key_indptr], indices=extras[self.key_indices])
265
+
266
+
267
+ class CachedRule(SpecRule):
268
+ """Cache the concrete spec built by an underlying rule.
269
+
270
+ This is a *Python-side* cache keyed by:
271
+ (P, rule.cache_key(extras))
272
+
273
+ It is designed to avoid rebuilding large structural objects (CSR, hyper tables)
274
+ every time the dispatcher is evaluated.
275
+
276
+ Notes / caveats
277
+ ---------------
278
+ - The cache lives inside the rule instance (static field), so it is shared
279
+ across all uses of that exact node object.
280
+ - `cache_key(extras)` must return a **hashable** object (typically tuples of
281
+ Python ints/floats/strings).
282
+ - The rule is expected to be called in contexts where `extras` contains
283
+ **concrete** values (not abstract tracers). If you call a function that
284
+ triggers `build()` under JIT tracing with tracer-valued extras, any attempt
285
+ to convert to Python scalars inside `cache_key` will fail. In that case,
286
+ either:
287
+ (i) keep `cache_key` shape-only, or
288
+ (ii) ensure spec building happens outside the traced region.
289
+ """
290
+
291
+ rule: SpecRule = eqx.field(static=True)
292
+ _cache: dict = eqx.field(static=True, default_factory=dict)
293
+
294
+ def arity(self):
295
+ return self.rule.arity()
296
+
297
+ def required_extras(self):
298
+ return self.rule.required_extras()
299
+
300
+ def structural_extras(self):
301
+ return self.rule.structural_extras()
302
+
303
+ def _key(self, x, extras):
304
+ # P disambiguates different particle counts. For grids, P = prod(grid_shape).
305
+ P = int(x.shape[-2])
306
+
307
+ # Optional secondary key advertised by the wrapped rule.
308
+ # Must be hashable, typically a tuple of Python scalars.
309
+ k2 = getattr(self.rule, "cache_key", lambda e: ())(extras)
310
+ return (P, k2)
311
+
312
+ def build(self, x, *, v=None, mask=None, extras=None):
313
+ key = self._key(x, extras)
314
+ if key in self._cache:
315
+ return self._cache[key]
316
+ spec = self.rule.build(x, v=v, mask=mask, extras=extras)
317
+ self._cache[key] = spec
318
+ return spec
319
+
320
+
321
+ class FromExtrasHyperFixed(SpecRule):
322
+ """
323
+ Build a HyperFixed spec by *reading* arrays from `extras`.
324
+
325
+ This rule is deliberately JIT-safe:
326
+ - no numpy conversions,
327
+ - no Python hashing on tracer values,
328
+ - no caching logic.
329
+
330
+ It is meant to be paired with a host-side preparation step that inserts
331
+ the structural arrays (hyper tables, slot masks, slot offsets, ...) into
332
+ `extras_global` before any jitted StateExpr evaluation.
333
+ """
334
+
335
+ key_hyper: str = eqx.field(static=True)
336
+ key_slot_mask: Optional[str] = eqx.field(static=True, default=None)
337
+ key_slot_offset: Optional[str] = eqx.field(static=True, default=None)
338
+
339
+ def arity(self) -> Tuple[Literal["fixed", "variable"], Optional[int]]:
340
+ # fixed-K, but K is not known at constructor time here
341
+ return ("fixed", None)
342
+
343
+ def structural_extras(self) -> tuple[str, ...]:
344
+ keys = [self.key_hyper]
345
+ if self.key_slot_mask is not None:
346
+ keys.append(self.key_slot_mask)
347
+ if self.key_slot_offset is not None:
348
+ keys.append(self.key_slot_offset)
349
+ return tuple(keys)
350
+
351
+ def build(self, x, *, v=None, mask=None, extras: Mapping[str, Any] | None = None) -> HyperFixed:
352
+ if extras is None:
353
+ raise KeyError("FromExtrasHyperFixed: extras is required")
354
+
355
+ hyper = extras[self.key_hyper]
356
+ slot_mask = extras[self.key_slot_mask] if (self.key_slot_mask is not None) else None
357
+
358
+ slot_extras = None
359
+ if self.key_slot_offset is not None:
360
+ slot_extras = {"offset": extras[self.key_slot_offset]}
361
+
362
+ return HyperFixed(hyper=hyper, slot_mask=slot_mask, slot_extras=slot_extras)