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,309 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Iterable, Tuple
5
+
6
+ import equinox as eqx
7
+ import jax.numpy as jnp
8
+
9
+
10
+ # ---------------------------------------------------------------------
11
+ # ParamSpec - static description of one parameter block
12
+ # ---------------------------------------------------------------------
13
+ @dataclass(frozen=True, slots=True)
14
+ class ParamSpec:
15
+ name: str # <-- KEY for sharing
16
+ shape: Tuple[int, ...]
17
+ dtype: Any = jnp.float32
18
+ init: Callable | str = "zeros" # PRNG->array or keyword
19
+ default: Any = None # optional concrete value (scalar or array-like)
20
+
21
+ @property
22
+ def size(self) -> int:
23
+ from functools import reduce
24
+ from operator import mul
25
+
26
+ return reduce(mul, self.shape, 1)
27
+
28
+ def compatible_with(self, other: "ParamSpec") -> bool:
29
+ """Shareable iff shape and dtype match exactly."""
30
+ return (self.shape == other.shape) and (self.dtype == other.dtype)
31
+
32
+ def merged_with(self, other: "ParamSpec") -> "ParamSpec":
33
+ """
34
+ Return a single spec representing the shared parameter.
35
+ Requires compatibility; keeps `self.init` by default.
36
+ """
37
+ if not self.compatible_with(other):
38
+ raise ValueError(
39
+ f"ParamSpec mismatch for '{self.name}': {self.shape}/{self.dtype} vs {other.shape}/{other.dtype}"
40
+ )
41
+ # deterministically keep `self`, but salvage a default from `other`
42
+ if self.default is None and other.default is not None:
43
+ from dataclasses import replace
44
+
45
+ return replace(self, default=other.default)
46
+ return self
47
+
48
+
49
+ class ParamSuite(eqx.Module):
50
+ """Immutable container holding a set of ``ParamSpec`` objects."""
51
+
52
+ specs: tuple[ParamSpec, ...] = eqx.field(static=True)
53
+ _lookup: dict[str, int] = eqx.field(static=True) # name → idx
54
+
55
+ # ---------- construction ---------- #
56
+ def __init__(self, specs: Iterable[ParamSpec]):
57
+ specs = tuple(specs)
58
+ names = [ps.name for ps in specs]
59
+ if len(set(names)) != len(names):
60
+ dup = {n for n in names if names.count(n) > 1}
61
+ raise ValueError(f"Duplicate parameter names: {dup}")
62
+ object.__setattr__(self, "specs", specs)
63
+ object.__setattr__(self, "_lookup", {ps.name: i for i, ps in enumerate(specs)})
64
+
65
+ @classmethod
66
+ def from_specs(cls, *specs: ParamSpec) -> "ParamSuite":
67
+ return cls(specs)
68
+
69
+ # ---------- basic info ---------- #
70
+ def __iter__(self):
71
+ return iter(self.specs)
72
+
73
+ def __len__(self):
74
+ return len(self.specs)
75
+
76
+ def __getitem__(self, name: str) -> ParamSpec:
77
+ return self.specs[self._lookup[name]]
78
+
79
+ @property
80
+ def size(self) -> int:
81
+ return sum(ps.size for ps in self.specs)
82
+
83
+ # ---------- convenience constructors ---------- #
84
+ def zeros(self) -> dict[str, jnp.ndarray]:
85
+ """Return a parameter dict with all values initialized to zero."""
86
+ return {ps.name: jnp.zeros(ps.shape, dtype=ps.dtype) for ps in self.specs}
87
+
88
+ @property
89
+ def has_defaults(self) -> bool:
90
+ """True iff every spec in this suite carries a concrete ``default``."""
91
+ return len(self.specs) > 0 and all(ps.default is not None for ps in self.specs)
92
+
93
+ def defaults(self) -> dict[str, jnp.ndarray] | None:
94
+ """Return a parameter dict from spec ``default`` values, or None if any
95
+ spec has no default. Values are broadcast to the declared shape."""
96
+ if not self.has_defaults:
97
+ return None
98
+ out: dict[str, jnp.ndarray] = {}
99
+ for ps in self.specs:
100
+ arr = jnp.asarray(ps.default, dtype=ps.dtype)
101
+ if arr.shape != ps.shape:
102
+ arr = jnp.broadcast_to(arr, ps.shape)
103
+ out[ps.name] = arr
104
+ return out
105
+
106
+ # ---------- param ↔ vector helpers ---------- #
107
+ def materialize(
108
+ self,
109
+ vector: jnp.ndarray,
110
+ *,
111
+ dtype_overrides: dict[str, jnp.dtype] | None = None,
112
+ ):
113
+ if vector.ndim != 1 or vector.size != self.size:
114
+ raise ValueError("Flat vector has wrong length")
115
+ out, i = {}, 0
116
+ for ps in self.specs:
117
+ n = ps.size
118
+ arr = (
119
+ vector[i : i + n]
120
+ .reshape(ps.shape)
121
+ .astype(dtype_overrides.get(ps.name, ps.dtype) if dtype_overrides else ps.dtype)
122
+ )
123
+ out[ps.name] = arr
124
+ i += n
125
+ return out
126
+
127
+ def vectorize(self, tree: dict[str, jnp.ndarray]) -> jnp.ndarray:
128
+ parts = [jnp.ravel(tree[ps.name]).astype(ps.dtype) for ps in self.specs]
129
+ return jnp.concatenate(parts, axis=0)
130
+
131
+ # ---------- merging (parameter sharing) ---------- #
132
+ def merge(self, other: "ParamSuite | None") -> "ParamSuite":
133
+ """
134
+ Union with sharing-by-name:
135
+ - If a name appears in both suites and specs are compatible (shape/dtype),
136
+ they are **tied** (kept once).
137
+ - If incompatible → error.
138
+ """
139
+ if other is None:
140
+ return self
141
+ if not isinstance(other, ParamSuite):
142
+ raise TypeError(f"merge expects ParamSuite or None, got {type(other).__name__}")
143
+ if not self.specs:
144
+ return other
145
+ if not other.specs:
146
+ return self
147
+
148
+ left = {ps.name: ps for ps in self.specs}
149
+ right = {ps.name: ps for ps in other.specs}
150
+
151
+ out: dict[str, ParamSpec] = dict(left) # copy
152
+ for name, ps_r in right.items():
153
+ if name in out:
154
+ ps_l = out[name]
155
+ out[name] = ps_l.merged_with(ps_r) # validates compatibility
156
+ else:
157
+ out[name] = ps_r
158
+ return ParamSuite(out.values())
159
+
160
+ @classmethod
161
+ def merge_many(cls, *suites: "ParamSuite | None") -> "ParamSuite | None":
162
+ """Merge any number of suites, sharing parameters by name (shape/dtype must match)."""
163
+ merged: ParamSuite | None = None
164
+ for s in suites:
165
+ if s is None:
166
+ continue
167
+ merged = s if merged is None else merged.merge(s)
168
+ return merged
169
+
170
+ # ---------- PyTree protocol ---------- #
171
+ def tree_flatten(self):
172
+ children = () # no array leaves
173
+ aux = self.specs # static
174
+ return children, aux
175
+
176
+ # ---------- universal parser ---------- #
177
+ @classmethod
178
+ def parse(cls, params) -> "ParamSuite | None":
179
+ """
180
+ Normalize various user-facing descriptions into a ParamSuite.
181
+
182
+ Accepts:
183
+
184
+ - ``None`` -- returns ``None``
185
+ - ``ParamSuite`` -- returned as-is
186
+ - ``dict[name -> array | shape]`` -- infer shape/dtype
187
+ - ``iterable[ParamSpec]`` -- from_specs
188
+ - ``iterable[str]`` -- scalar specs for each name
189
+
190
+ Shapes may be ``()``, ``(k,)``, ``(m, n, ...)`` or an integer k
191
+ (interpreted as ``(k,)``).
192
+ """
193
+ from collections.abc import Iterable as _Iterable
194
+
195
+ if params is None:
196
+ return None
197
+ if isinstance(params, ParamSuite):
198
+ return params
199
+ # dict path: values can be arrays (sample), shapes, ints, ParamSpec, or None→scalar
200
+ if isinstance(params, dict):
201
+ specs: list[ParamSpec] = []
202
+ for name, val in params.items():
203
+ if isinstance(val, ParamSpec):
204
+ specs.append(val)
205
+ continue
206
+ if hasattr(val, "shape"):
207
+ shape = tuple(val.shape)
208
+ dtype = getattr(val, "dtype", jnp.float32)
209
+ specs.append(ParamSpec(name, shape, dtype=dtype))
210
+ continue
211
+ if val is None:
212
+ specs.append(ParamSpec(name, ()))
213
+ continue
214
+ if isinstance(val, int):
215
+ specs.append(ParamSpec(name, (int(val),)))
216
+ continue
217
+ if isinstance(val, tuple) and all(isinstance(n, int) for n in val):
218
+ specs.append(ParamSpec(name, tuple(int(n) for n in val)))
219
+ continue
220
+ raise TypeError(f"ParamSuite.parse: unsupported dict value for '{name}': {type(val).__name__}")
221
+ return cls.from_specs(*specs)
222
+ # iterable path: ParamSpec or str
223
+ if isinstance(params, _Iterable):
224
+ items = list(params)
225
+ specs: list[ParamSpec] = []
226
+ for it in items:
227
+ if isinstance(it, ParamSpec):
228
+ specs.append(it)
229
+ elif isinstance(it, str):
230
+ specs.append(ParamSpec(it, ()))
231
+ else:
232
+ raise TypeError(
233
+ f"ParamSuite.parse: iterable must contain ParamSpec or str (got {type(it).__name__})"
234
+ )
235
+ return cls.from_specs(*specs)
236
+ raise TypeError(f"ParamSuite.parse: unsupported params of type {type(params).__name__}")
237
+
238
+ @classmethod
239
+ def tree_unflatten(cls, aux, children):
240
+ return cls(aux)
241
+
242
+ def coerce(
243
+ self,
244
+ params: dict,
245
+ *,
246
+ allow_scalar_for_scalar: bool = True,
247
+ allow_scalar_to_len1: bool = True,
248
+ cast_dtype: bool = True,
249
+ ) -> dict[str, jnp.ndarray]:
250
+ """
251
+ Normalize a user param dict into JAX arrays matching this suite.
252
+
253
+ Rules:
254
+ - If spec.shape == (), accept Python scalars / 0-d arrays (if allowed).
255
+ - If spec.shape == (1,), optionally accept a scalar and expand to (1,).
256
+ - Otherwise, require exact shape; dtype is cast if `cast_dtype` is True.
257
+ Returns a NEW dict with normalized arrays.
258
+ """
259
+ out = {}
260
+ look = self._lookup
261
+ missing = [k for k in look if k not in params]
262
+ if missing:
263
+ raise KeyError(f"Missing params: {missing}")
264
+
265
+ for spec in self:
266
+ name, shape, dtype = spec.name, spec.shape, spec.dtype
267
+ val = params[name]
268
+
269
+ arr = jnp.asarray(val, dtype=dtype if cast_dtype else None)
270
+
271
+ # Handle scalar vs length-1 convenience
272
+ if shape == (): # true scalar
273
+ # 0-d OK; (1,) OK if allow_scalar_for_scalar and allow squeezing
274
+ if arr.shape == ():
275
+ pass
276
+ elif arr.shape == (1,) and allow_scalar_for_scalar:
277
+ arr = jnp.reshape(arr, ())
278
+ else:
279
+ raise TypeError(f"Param '{name}': expected scalar (), got {arr.shape}")
280
+ elif shape == (1,):
281
+ # accept scalar and expand to (1,) if asked
282
+ if arr.shape == ():
283
+ if allow_scalar_to_len1:
284
+ arr = jnp.reshape(arr, (1,))
285
+ else:
286
+ raise TypeError(f"Param '{name}': expected (1,), got scalar ()")
287
+ elif arr.shape == (1,):
288
+ pass
289
+ else:
290
+ raise TypeError(f"Param '{name}': expected {shape}, got {arr.shape}")
291
+ else:
292
+ if arr.shape != shape:
293
+ raise TypeError(f"Param '{name}': expected {shape}, got {arr.shape}")
294
+
295
+ # Final dtype enforcement
296
+ if cast_dtype:
297
+ arr = arr.astype(dtype)
298
+ elif arr.dtype != dtype:
299
+ raise TypeError(f"Param '{name}': expected dtype {dtype}, got {arr.dtype}")
300
+
301
+ out[name] = arr
302
+ return out
303
+
304
+ def stamp(self, params_dict: dict) -> tuple:
305
+ """
306
+ Build a stable stamp for (shape, dtype) of each param in template order.
307
+ Caller is expected to pass an already-coerced dict.
308
+ """
309
+ return tuple((spec.name, params_dict[spec.name].shape, params_dict[spec.name].dtype) for spec in self)
SFI/statefunc/psf.py ADDED
@@ -0,0 +1,112 @@
1
+ """PSF façade: parametric family of state functions."""
2
+
3
+ import equinox as eqx
4
+ import jax
5
+ import jax.numpy as jnp
6
+
7
+ from .nodes import BaseNode, DerivativeNode
8
+ from .params import ParamSuite
9
+ from .stateexpr import StateExpr
10
+
11
+
12
+ # ============================================================================
13
+ # PSF – parametric state-function family
14
+ # ============================================================================
15
+ class PSF(StateExpr):
16
+ """Parametric State-Function family `F(x; θ)`.
17
+
18
+ By default `drop_features=True`: when `n_features==1`, outputs **do not**
19
+ carry a trailing feature axis. `.d_theta()` forces `drop_features=False`.
20
+
21
+ Holds a `ParamSuite` template describing names, shapes, and dtypes of θ.
22
+ `__call__` evaluates `F` given a parameter dict matching the template.
23
+ Supports `.d_theta()` in addition to `.d_x()`/`.d_v()`.
24
+ """
25
+
26
+ template: ParamSuite = eqx.field(static=True)
27
+ drop_features: bool = eqx.field(static=True, default=True)
28
+ _validated_stamp: tuple | None = eqx.field(static=True, default=None, repr=False)
29
+
30
+ def __init__(self, root: BaseNode, *, drop_features: bool = True):
31
+ if root.param_suite is None:
32
+ raise ValueError("PSF root must carry parameters")
33
+ super().__init__(root)
34
+ object.__setattr__(self, "template", root.param_suite)
35
+ object.__setattr__(self, "drop_features", bool(drop_features))
36
+
37
+ def _coerce_and_check(self, params, extras):
38
+ # Normalize/validate via the suite
39
+ pnorm = self.template.coerce(params)
40
+ stamp = tuple(sorted((k, pnorm[k].shape, pnorm[k].dtype) for k in self.template._lookup))
41
+ if stamp != self._validated_stamp:
42
+ object.__setattr__(self, "_validated_stamp", stamp)
43
+ # Only presence for extras here; shape/broadcast is handled in node contracts
44
+ self._validate_extras_presence(extras)
45
+ return pnorm
46
+
47
+ def __call__(self, x, *, v=None, mask=None, extras=None, params=None):
48
+ if params is None:
49
+ if self.template.size == 0:
50
+ params = {} # parameter-free PSF: auto-supply
51
+ else:
52
+ defaults = self.template.defaults()
53
+ if defaults is None:
54
+ raise ValueError("PSF.__call__: params are required (template has parameters without defaults).")
55
+ params = defaults
56
+ params = self._coerce_and_check(params, extras)
57
+ y = self._caller(x, v, mask, extras, params)
58
+ if self.drop_features and self.n_features == 1:
59
+ return jnp.squeeze(y, axis=-1)
60
+ return y
61
+
62
+ def bind(self, params: dict[str, jax.Array] | None = None):
63
+ """Freeze parameter dict into an SF with normalized arrays.
64
+
65
+ If ``params is None``, fall back to spec defaults (``ParamSuite.defaults()``).
66
+ Raises if the template has parameters without defaults.
67
+ """
68
+ from .sf import SF
69
+
70
+ if params is None:
71
+ defaults = self.template.defaults()
72
+ if defaults is None:
73
+ raise ValueError("PSF.bind(): params are required (template has parameters without defaults).")
74
+ params = defaults
75
+ return SF(self, params, drop_features=self.drop_features)
76
+
77
+ def d_theta(self, *, mode: str = "auto"):
78
+ """Build an expression for the Jacobian w.r.t. parameters θ.
79
+
80
+ Shape effect
81
+ ------------
82
+ The final axis becomes `features × n_params_total`. Batch/pdepth/rank
83
+ prefixes are preserved exactly.
84
+
85
+ Notes
86
+ -----
87
+ The parameter PyTree is handled leafwise; each grad leaf is flattened over
88
+ its param part, then all leaves are concatenated along the final axis.
89
+ """
90
+ if self.root.param_suite is None: # unreachable: enforced by __init__
91
+ raise AttributeError("Expression has no parameters to differentiate")
92
+ node = DerivativeNode(self.root, var="theta", mode=mode)
93
+ return PSF(node, drop_features=False)
94
+
95
+ # ------------- SciPy helpers ---------------------------------
96
+ @property
97
+ def labels(self):
98
+ """Basis labels from the underlying CoeffNode (if present)."""
99
+ from .nodes.ops.linear import CoeffNode
100
+
101
+ if isinstance(self.root, CoeffNode):
102
+ return self.root._basis_labels
103
+ _, labs, _ = self.root.flatten()
104
+ return labs
105
+
106
+ def flatten_params(self, params: dict[str, jax.Array]):
107
+ """Vectorize a parameter dict according to the template order."""
108
+ return self.template.vectorize(params)
109
+
110
+ def unflatten_params(self, vec: jax.Array):
111
+ """Materialize a parameter dict from a flat vector (inverse of `flatten_params`)."""
112
+ return self.template.materialize(vec)
SFI/statefunc/sf.py ADDED
@@ -0,0 +1,104 @@
1
+ """SF façade: state function with fixed parameters."""
2
+
3
+ import equinox as eqx
4
+ import jax
5
+ import jax.numpy as jnp
6
+
7
+ from .nodes import BaseNode
8
+ from .psf import PSF
9
+ from .stateexpr import StateExpr, _specialize_node
10
+
11
+
12
+ # ============================================================================
13
+ # SF – bound (θ-fixed) state-function
14
+ # ============================================================================
15
+ class SF(StateExpr):
16
+ """State-Function with θ fixed (a thin wrapper over the PSF’s root).
17
+
18
+ Behaves like a `Basis` for evaluation purposes (no `.d_theta()`), but you
19
+ can still build `.d_x()` / `.d_v()` expressions.
20
+
21
+ Feature axis handling mirrors the parent `PSF`:
22
+ if `drop_features=True` and `n_features==1`, the final axis is removed.
23
+ """
24
+
25
+ params: dict[str, jax.Array] = eqx.field(static=False, repr=False)
26
+ _psf: PSF = eqx.field(static=True, repr=False)
27
+ drop_features: bool = eqx.field(static=True, default=True)
28
+
29
+ def __init__(
30
+ self,
31
+ psf: PSF,
32
+ params: dict[str, jax.Array],
33
+ *,
34
+ drop_features: bool | None = None,
35
+ ):
36
+ super().__init__(psf.root)
37
+ params = psf.template.coerce(params)
38
+ object.__setattr__(self, "params", params)
39
+ object.__setattr__(self, "_psf", psf)
40
+ object.__setattr__(
41
+ self,
42
+ "drop_features",
43
+ psf.drop_features if drop_features is None else bool(drop_features),
44
+ )
45
+
46
+ def __call__(self, x, *, v=None, mask=None, extras=None):
47
+ """Evaluate the bound function on a **batched** input."""
48
+ self._validate_extras_presence(extras)
49
+
50
+ y = self._caller(x, v, mask, extras, self.params)
51
+
52
+ if self.drop_features and self.n_features == 1:
53
+ return jnp.squeeze(y, axis=-1)
54
+ return y
55
+
56
+ @property
57
+ def labels(self):
58
+ """Basis labels propagated from the parent PSF."""
59
+ return self._psf.labels
60
+
61
+ def _with_node(self, new_root: BaseNode):
62
+ new_psf = PSF(new_root, drop_features=self.drop_features)
63
+ return SF(new_psf, self.params, drop_features=self.drop_features)
64
+
65
+ def specialize(self, *, dataset: int) -> "SF":
66
+ """Specialize a *bound* function at condition ``dataset``.
67
+
68
+ Rewrites the graph (folding ``dataset_index``-reading leaves) and
69
+ projects the bound parameter values onto the shrunken template: a
70
+ per-condition spec whose shape loses a leading axis is sliced at
71
+ ``dataset``; shared specs are kept verbatim.
72
+ """
73
+ k = int(dataset)
74
+ new_root = _specialize_node(self.root, k)
75
+ new_psf = PSF(new_root, drop_features=self.drop_features)
76
+ new_params = _project_params(self.params, self._psf.template, new_psf.template, k)
77
+ return SF(new_psf, new_params, drop_features=self.drop_features)
78
+
79
+
80
+ def _project_params(old_params, old_template, new_template, k: int) -> dict:
81
+ """Map a bound parameter dict onto a specialized template.
82
+
83
+ For each spec in ``new_template``: keep the old value when the shape is
84
+ unchanged; slice the leading axis at ``k`` when specialization dropped it
85
+ (the per-condition case, e.g. ``(K,) -> ()``).
86
+ """
87
+ out: dict = {}
88
+ for spec in new_template:
89
+ name = spec.name
90
+ if name not in old_params:
91
+ continue # genuinely new param (none today); leave to template defaults
92
+ old_val = old_params[name]
93
+ old_shape = tuple(old_template[name].shape)
94
+ new_shape = tuple(spec.shape)
95
+ if old_shape == new_shape:
96
+ out[name] = old_val
97
+ elif len(old_shape) == len(new_shape) + 1 and old_shape[1:] == new_shape:
98
+ out[name] = old_val[k] # per-condition slice
99
+ else:
100
+ raise ValueError(
101
+ f"Cannot project param {name!r} from shape {old_shape} to "
102
+ f"{new_shape} during specialize(dataset={k})."
103
+ )
104
+ return out