StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|