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,346 @@
1
+ import logging
2
+ from typing import Any, Callable, Iterable, Optional, Sequence
3
+
4
+ import jax.numpy as jnp
5
+
6
+ from .basis import Basis
7
+ from .interactor import Interactor
8
+ from .nodes import SimpleLeaf
9
+ from .nodes.contract import Rank
10
+ from .nodes.leaf import InteractionLeaf
11
+ from .params import ParamSpec, ParamSuite
12
+ from .psf import PSF
13
+ from .sf import SF
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _probe_call(obj, *, dim: int, rank: int, n_features: int, label: str):
19
+ """Run a zero-valued test call to catch shape/signature errors early.
20
+
21
+ Called when ``dim`` is known at factory construction time.
22
+ Logs a warning on failure instead of raising, so construction still
23
+ succeeds (the user may intend a shape that only works with real data).
24
+ """
25
+ x_probe = jnp.zeros((1, dim)) # (N=1, dim)
26
+ try:
27
+ out = obj(x_probe)
28
+ except Exception as exc:
29
+ logger.warning(
30
+ "%s: probe call with x of shape %s failed — the function may error at runtime. Original error: %s",
31
+ label,
32
+ x_probe.shape,
33
+ exc,
34
+ )
35
+ return
36
+
37
+ out = jnp.asarray(out)
38
+ if out.shape[0] != 1:
39
+ logger.warning(
40
+ "%s: probe call returned shape %s but expected leading axis 1 (matching N=1 input). Check output shape.",
41
+ label,
42
+ out.shape,
43
+ )
44
+ return
45
+ logger.debug("%s: probe call OK, output shape %s", label, out.shape)
46
+
47
+
48
+ def make_basis(
49
+ func: Callable,
50
+ *,
51
+ dim: int | None = None,
52
+ rank: int,
53
+ n_features: int = 1,
54
+ needs_v: bool = False,
55
+ labels: Optional[Sequence[str]] = None,
56
+ descriptor: Any = "custom",
57
+ extras_keys: Optional[Sequence[str]] = None,
58
+ particle_extras: Optional[Sequence[str]] = None,
59
+ specialize_at: Optional[Callable] = None,
60
+ ) -> Basis:
61
+ """
62
+ Construct a **deterministic Basis** from a *single-sample* user function,
63
+ with **no particle semantics**. Particle axes (if present in ``x`` at call
64
+ time) are treated purely as batch axes and vmapped over.
65
+
66
+ ``particle_extras`` names extras keys whose values are **per-sample**
67
+ arrays aligned with the batch/particle axes (e.g. an ``extras_local``
68
+ entry of shape ``(N, ...)``): they are vmapped alongside ``x``, so the
69
+ single-sample function sees *its own* particle's value instead of the
70
+ whole array — the route to per-particle terms in single-particle
71
+ bases (home-range centres, individual labels, ...).
72
+
73
+ User function signature — declare **only** the kwargs you need:
74
+
75
+ - Simplest: ``f(x) -> array``
76
+ - With velocity: ``f(x, *, v) -> array``
77
+ - With extras: ``f(x, *, extras) -> array``
78
+
79
+ The full signature is ``f(x, *, v=None, mask=None, extras=None)``;
80
+ we introspect and pass only the kwargs you declare.
81
+
82
+ Shapes (single sample)::
83
+
84
+ x: (dim,)
85
+ return: (*rank_axes, m) # feature last; m == n_features
86
+
87
+ If ``n_features == 1`` you may omit the last axis; a singleton feature
88
+ axis is auto-inserted.
89
+
90
+ Extras
91
+ ~~~~~~
92
+ If ``extras`` is declared, you may provide ``extras_keys=(...)`` to
93
+ enforce keys. Extras arrays must broadcast over the **batch prefix**
94
+ (never over rank/feature).
95
+
96
+ Examples
97
+ --------
98
+ >>> import jax.numpy as jnp
99
+ >>> from SFI.statefunc import make_basis
100
+ >>> B = make_basis(lambda x: x, dim=2, rank=1, n_features=1) # (equivalent to the built-in X(dim=2))
101
+
102
+ JAX
103
+ ~~~
104
+ Write ``f`` with ``jax.numpy`` and keep it pure; works with
105
+ jit/vmap/autodiff.
106
+ """
107
+ leaf = SimpleLeaf(
108
+ func=func,
109
+ n_features=int(n_features),
110
+ labels=tuple(labels) if labels is not None else tuple(f"f{j}" for j in range(n_features)),
111
+ descriptor=descriptor,
112
+ dim=dim,
113
+ rank=Rank(rank),
114
+ needs_v=bool(needs_v),
115
+ # SimpleLeaf forbids particles & pdepth by construction (pdepth=0, particles_input=False).
116
+ extras_keys=tuple(extras_keys) if extras_keys is not None else (),
117
+ particle_extras=tuple(particle_extras) if particle_extras is not None else (),
118
+ specialize_at=specialize_at,
119
+ )
120
+ result = Basis(leaf)
121
+ if dim is not None and not extras_keys and not needs_v:
122
+ _probe_call(result, dim=dim, rank=rank, n_features=n_features, label="make_basis")
123
+ return result
124
+
125
+
126
+ def make_psf(
127
+ func: Callable,
128
+ *,
129
+ dim: int | None = None,
130
+ rank: int,
131
+ n_features: int = 1,
132
+ drop_features: bool = True,
133
+ needs_v: bool = False,
134
+ labels: Optional[Sequence[str]] = None,
135
+ descriptor: Any = "parametric",
136
+ params: ParamSuite | Iterable[ParamSpec] | dict[str, Any],
137
+ extras_keys: Optional[Sequence[str]] = None,
138
+ specialize_at: Optional[Callable] = None,
139
+ ) -> PSF:
140
+ """
141
+ Construct a **parametric state-function family (PSF)** from a *single-sample*
142
+ user function, **without particle semantics**.
143
+
144
+ User function signature — declare **only** the kwargs you need:
145
+
146
+ - Simplest: ``f(x, *, params) -> array``
147
+ - With velocity: ``f(x, *, v, params) -> array``
148
+ - With extras: ``f(x, *, params, extras) -> array``
149
+
150
+ The full signature is ``f(x, *, params, v=None, mask=None, extras=None)``;
151
+ we introspect and pass only the kwargs you declare.
152
+
153
+ Shapes (single sample)::
154
+
155
+ x: (dim,)
156
+ return: (*rank_axes, m) # feature last; m == n_features
157
+
158
+ If ``n_features == 1`` you may omit the last axis; we auto-insert a
159
+ singleton feature axis.
160
+
161
+ Parameters (``params``) may be described as:
162
+
163
+ - a ``ParamSuite``,
164
+ - an iterable of ``ParamSpec``,
165
+ - a dict of shapes, e.g. ``{'W': (d,d), 'b': ()}``,
166
+ - or a dict of **sample arrays** from which (shape, dtype) are inferred.
167
+
168
+ Extras
169
+ ~~~~~~
170
+ Same rules as ``make_basis`` (``extras_keys`` optional; broadcast over
171
+ batch prefix).
172
+
173
+ JAX
174
+ ~~~
175
+ Works with jit/vmap/autodiff w.r.t. inputs and parameters.
176
+ """
177
+ suite = ParamSuite.parse(params)
178
+
179
+ leaf = SimpleLeaf(
180
+ func=func,
181
+ n_features=int(n_features),
182
+ labels=tuple(labels) if labels is not None else tuple(f"f{j}" for j in range(n_features)),
183
+ descriptor=descriptor,
184
+ dim=dim,
185
+ rank=Rank(rank),
186
+ needs_v=bool(needs_v),
187
+ param_suite=suite, # Parametric path is enabled by providing a ParamSuite.
188
+ extras_keys=tuple(extras_keys) if extras_keys is not None else (),
189
+ specialize_at=specialize_at,
190
+ )
191
+ return PSF(leaf, drop_features=bool(drop_features))
192
+
193
+
194
+ def make_sf(
195
+ func: Callable,
196
+ *,
197
+ dim: int | None = None,
198
+ rank: int,
199
+ n_features: int = 1,
200
+ drop_features: bool = True,
201
+ needs_v: bool = False,
202
+ labels: Optional[Sequence[str]] = None,
203
+ descriptor: Any = "custom_sf",
204
+ extras_keys: Optional[Sequence[str]] = None,
205
+ ) -> SF:
206
+ """
207
+ Construct an **SF** (bound state function) directly from a parameter-free
208
+ user function — no ``Basis`` or ``PSF`` intermediate needed.
209
+
210
+ This is the simplest entry point when you have a known, fixed function
211
+ (e.g. an exact model for comparison, or a hand-coded feature) and just
212
+ want a callable that participates in the SFI expression-tree ecosystem.
213
+
214
+ The resulting ``SF`` supports ``.d_x()``, ``.d_v()``, and can be passed
215
+ to ``compare_to_exact``, ``integrate``, or any other API that accepts
216
+ an ``SF`` / ``StateExpr``.
217
+
218
+ User function signature — declare **only** the kwargs you need:
219
+
220
+ - Simplest: ``f(x) -> array``
221
+ - With velocity: ``f(x, *, v) -> array``
222
+ - With extras: ``f(x, *, extras) -> array``
223
+
224
+ Shapes (single sample)::
225
+
226
+ x: (dim,)
227
+ return: (*rank_axes, n_features)
228
+
229
+ If ``n_features == 1`` you may omit the trailing feature axis; it is
230
+ auto-inserted. The resulting SF squeezes it back when
231
+ ``drop_features=True`` (default).
232
+
233
+ Parameters
234
+ ----------
235
+ func : callable
236
+ Pure JAX function, compatible with jit/vmap/autodiff.
237
+ dim : int or None
238
+ Spatial dimensionality (None = infer at first call).
239
+ rank : int
240
+ Tensor rank of the output (0=scalar, 1=vector, 2=matrix).
241
+ n_features : int
242
+ Number of output features (default 1).
243
+ drop_features : bool
244
+ Remove trailing size-1 feature axis (default True).
245
+ needs_v : bool
246
+ Whether ``func`` requires velocity ``v``.
247
+ labels : sequence of str or None
248
+ Human-readable feature labels (auto-generated if None).
249
+ descriptor : any
250
+ Metadata tag stored on the leaf node.
251
+ extras_keys : sequence of str or None
252
+ Required keys in the ``extras`` mapping.
253
+
254
+ Returns
255
+ -------
256
+ SF
257
+ A bound, callable state function with no free parameters.
258
+
259
+ Examples
260
+ --------
261
+ >>> import jax.numpy as jnp
262
+ >>> from SFI.statefunc import make_sf
263
+ >>> harmonic = make_sf(lambda x: -x, rank=1, dim=2)
264
+ >>> harmonic(jnp.array([1.0, 2.0]))
265
+ Array([-1., -2.], dtype=float32)
266
+ """
267
+ # Build a SimpleLeaf with an *empty* (but non-None) ParamSuite so the
268
+ # node is accepted by PSF. The empty suite carries zero parameters,
269
+ # so PSF.__call__ auto-supplies params={} and SF.bind freezes nothing.
270
+ empty_suite = ParamSuite([])
271
+
272
+ leaf = SimpleLeaf(
273
+ func=func,
274
+ n_features=int(n_features),
275
+ labels=tuple(labels) if labels is not None else tuple(f"f{j}" for j in range(n_features)),
276
+ descriptor=descriptor,
277
+ dim=dim,
278
+ rank=Rank(rank),
279
+ needs_v=bool(needs_v),
280
+ param_suite=empty_suite,
281
+ extras_keys=tuple(extras_keys) if extras_keys is not None else (),
282
+ )
283
+ psf = PSF(leaf, drop_features=bool(drop_features))
284
+ result = psf.bind({})
285
+ if dim is not None and not extras_keys and not needs_v:
286
+ _probe_call(result, dim=dim, rank=rank, n_features=n_features, label="make_sf")
287
+ return result
288
+
289
+
290
+ def make_interactor(
291
+ func: Callable,
292
+ *,
293
+ dim: int,
294
+ rank: Rank,
295
+ # arity:
296
+ K: int | None = None,
297
+ Kmax: int | None = None,
298
+ # features & plumbing:
299
+ n_features: int = 1,
300
+ needs_v: bool = False,
301
+ labels: Iterable[str] = (),
302
+ descriptor=None,
303
+ params: ParamSuite | None = None,
304
+ extras_keys: Iterable[str] = (),
305
+ particle_extras: Iterable[str] = (),
306
+ ):
307
+ """
308
+ Build a local interaction dictionary (Interactor) from a single-sample user
309
+ function that consumes (K, dim) and returns feature-last.
310
+
311
+ Pass exactly one of:
312
+ - K=int → fixed arity
313
+ - Kmax=int → variable arity (ragged via mask)
314
+
315
+ ``particle_extras`` names the extras keys whose values are
316
+ **per-particle** arrays (shape ``(P, ...)``): the dispatcher gathers
317
+ them per edge member, so inside ``func`` they arrive with shape
318
+ ``(K, ...)`` — one entry per member of the local tuple. The
319
+ reserved ``"particle_index"`` extra (injected by
320
+ :class:`~SFI.trajectory.TrajectoryCollection`) combined with a
321
+ ``(P,)``-shaped parameter gives per-particle inferred parameters::
322
+
323
+ def local(Xk, *, params, extras):
324
+ mob = params["mob"][extras["particle_index"]] # (K,)
325
+ ...
326
+ """
327
+ suite = ParamSuite.parse(params)
328
+ if (K is None) == (Kmax is None):
329
+ raise ValueError("Provide exactly one of K or Kmax.")
330
+
331
+ leaf = InteractionLeaf(
332
+ mode="fixed" if K is not None else "variable",
333
+ K=K,
334
+ Kmax=Kmax,
335
+ func=func,
336
+ dim=dim,
337
+ rank=rank,
338
+ n_features=n_features,
339
+ needs_v=needs_v,
340
+ param_suite=suite,
341
+ labels=tuple(labels),
342
+ descriptor=descriptor,
343
+ extras_keys=tuple(extras_keys),
344
+ particle_extras=tuple(particle_extras),
345
+ )
346
+ return Interactor(leaf)
@@ -0,0 +1,90 @@
1
+ from typing import Any, Literal, Optional
2
+
3
+ from .basis import Basis
4
+ from .nodes.base import BaseNode
5
+ from .nodes.interactions.dispatcher import InteractionDispatcher
6
+ from .nodes.interactions.specs import (
7
+ AutoPairs,
8
+ FromExtrasPairsCSR,
9
+ HyperCSR,
10
+ HyperFixed,
11
+ PairsCSR,
12
+ SpecRule,
13
+ )
14
+ from .psf import PSF
15
+ from .stateexpr import StateExpr
16
+
17
+
18
+ # Helper: does the subtree contain any parameters?
19
+ def _tree_is_parametric(root: BaseNode) -> bool:
20
+ def _walk(n: Any) -> bool:
21
+ if getattr(n, "param_suite", None) is not None:
22
+ return True
23
+ for ch in getattr(n, "children", ()):
24
+ if _walk(ch):
25
+ return True
26
+ for attr in ("child", "inner"):
27
+ ch = getattr(n, attr, None)
28
+ if ch is not None and _walk(ch):
29
+ return True
30
+ return False
31
+
32
+ return _walk(root)
33
+
34
+
35
+ class Interactor(StateExpr):
36
+ """
37
+ Local interaction expression (pre-dispatch).
38
+
39
+ - `root` must be a local graph built from InteractionLeaf(s):
40
+ particles_input=True, pdepth=0.
41
+
42
+ Compose as usual: `inter = make_interactor(...); inter2 = (inter & inter)...`
43
+ Then call `.dispatch(...)` exactly once to obtain a **Basis** or a **PSF**.
44
+ """
45
+
46
+ # sugar: keep the same fluent ops API as StateExpr (inherited)
47
+
48
+ # ---- Dispatch API ---------------------------------------------------------
49
+ def dispatch(
50
+ self,
51
+ spec: PairsCSR | HyperFixed | HyperCSR | SpecRule,
52
+ *,
53
+ owners: Literal["focal", "all", "custom", "global"] = "focal",
54
+ focal_index: int = 0,
55
+ owner_weights=None,
56
+ reducer: Literal["sum", "mean", "max"] = "sum",
57
+ normalize_by_degree: bool = False,
58
+ exclude_self: bool = True,
59
+ chunk_size: Optional[int] = None,
60
+ return_as: Literal["auto", "basis", "psf"] = "auto",
61
+ drop_features: Optional[bool] = None,
62
+ ):
63
+ disp = InteractionDispatcher(
64
+ self.root,
65
+ spec=spec,
66
+ owners=owners,
67
+ focal_index=focal_index,
68
+ owner_weights=owner_weights,
69
+ reducer=reducer,
70
+ normalize_by_degree=normalize_by_degree,
71
+ exclude_self=exclude_self,
72
+ chunk_size=chunk_size,
73
+ )
74
+ # Decide wrapper kind
75
+ kind = return_as
76
+ if kind == "auto":
77
+ kind = "psf" if _tree_is_parametric(self.root) else "basis"
78
+ if kind == "basis":
79
+ return Basis(disp)
80
+ elif kind == "psf":
81
+ return PSF(disp, drop_features=drop_features)
82
+ else:
83
+ raise ValueError(f"Unknown return_as={return_as!r}")
84
+
85
+ # common sugars for pairs
86
+ def dispatch_pairs(self, *, symmetric=True, exclude_self=True, **kwargs):
87
+ return self.dispatch(AutoPairs(symmetric=symmetric, exclude_self=exclude_self), **kwargs)
88
+
89
+ def dispatch_pairs_from_extras(self, *, indptr_key: str, indices_key: str, **kwargs):
90
+ return self.dispatch(FromExtrasPairsCSR(indptr_key, indices_key), **kwargs)
@@ -0,0 +1,29 @@
1
+ """Structured-dimensions layout sub-package.
2
+
3
+ Re-exports for backward compatibility with ``from SFI.statefunc.layout import ...``.
4
+ """
5
+
6
+ from ._base import (
7
+ IdentityLayout,
8
+ StateLayout,
9
+ _BaseLayout,
10
+ )
11
+ from ._grid import GridLayout
12
+ from ._sectors import (
13
+ ScalarSector,
14
+ Sector,
15
+ SymTensorSector,
16
+ TensorSector,
17
+ VectorSector,
18
+ )
19
+
20
+ __all__ = [
21
+ "ScalarSector",
22
+ "VectorSector",
23
+ "SymTensorSector",
24
+ "TensorSector",
25
+ "Sector",
26
+ "StateLayout",
27
+ "IdentityLayout",
28
+ "GridLayout",
29
+ ]
@@ -0,0 +1,186 @@
1
+ """Layout protocol, base class, and IdentityLayout.
2
+
3
+ Defines :class:`StateLayout` (protocol), :class:`_BaseLayout` (implementation
4
+ base), and :class:`IdentityLayout` (trivial single-sector layout).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ from typing import Any, Protocol, runtime_checkable
11
+
12
+ from ..structexpr import StructuredExpr, _ConstNode, _SectorLeaf
13
+ from ._sectors import Sector, VectorSector
14
+
15
+ # =====================================================================
16
+ # Layout instance counter (unique IDs for layout-compatibility checks)
17
+ # =====================================================================
18
+
19
+ _layout_counter = itertools.count()
20
+
21
+
22
+ def _next_layout_id() -> int:
23
+ return next(_layout_counter)
24
+
25
+
26
+ # =====================================================================
27
+ # Layout protocol
28
+ # =====================================================================
29
+
30
+
31
+ @runtime_checkable
32
+ class StateLayout(Protocol):
33
+ """Protocol for all layouts (Grid, Particle, Identity, …)."""
34
+
35
+ @property
36
+ def dim(self) -> int:
37
+ """Total data width (``x.shape[-1]``)."""
38
+ ...
39
+
40
+ def unpack(self) -> dict[str, StructuredExpr]:
41
+ """Return named symbolic field leaves."""
42
+ ...
43
+
44
+ def embed(self, rank: int = 1, **named_fields: StructuredExpr) -> Any:
45
+ """Compile inner expressions into outer ``StateExpr``."""
46
+ ...
47
+
48
+
49
+ # =====================================================================
50
+ # _BaseLayout (shared logic for all concrete layouts)
51
+ # =====================================================================
52
+
53
+
54
+ class _BaseLayout:
55
+ """Common base for Layout implementations.
56
+
57
+ Handles sector storage, index validation, and field-expression
58
+ creation. Subclasses add engine-specific operators and ``embed()``.
59
+ """
60
+
61
+ def __init__(self, *, dim: int, **sectors: Sector) -> None:
62
+ self._dim = dim
63
+ self._layout_id = _next_layout_id()
64
+ self._sectors: dict[str, Sector] = dict(sectors)
65
+ self._validate_indices()
66
+ self._fields = self._build_fields()
67
+
68
+ # --- public interface ---------------------------------------------
69
+
70
+ @property
71
+ def dim(self) -> int:
72
+ return self._dim
73
+
74
+ @property
75
+ def sectors(self) -> dict[str, Sector]:
76
+ """Read-only mapping ``name → Sector``."""
77
+ return dict(self._sectors)
78
+
79
+ def unpack(self) -> dict[str, StructuredExpr]:
80
+ """Return a dict of named symbolic field leaves."""
81
+ return dict(self._fields)
82
+
83
+ # --- attribute access for field names -----------------------------
84
+
85
+ def __getattr__(self, name: str) -> StructuredExpr:
86
+ # Guard against recursion during __init__
87
+ if name.startswith("_"):
88
+ raise AttributeError(name)
89
+ try:
90
+ fields = object.__getattribute__(self, "_fields")
91
+ except AttributeError:
92
+ raise AttributeError(name) from None
93
+ if name in fields:
94
+ return fields[name]
95
+ raise AttributeError(f"'{type(self).__name__}' has no field '{name}'. Available fields: {', '.join(fields)}")
96
+
97
+ # --- validation ---------------------------------------------------
98
+
99
+ def _validate_indices(self) -> None:
100
+ """No overlap; all indices in ``range(dim)``."""
101
+ seen: dict[int, str] = {}
102
+ for name, sector in self._sectors.items():
103
+ for idx in sector.indices:
104
+ if not (0 <= idx < self._dim):
105
+ raise ValueError(f"Sector '{name}': index {idx} out of range for dim={self._dim}")
106
+ if idx in seen:
107
+ raise ValueError(f"Index {idx} appears in both sector '{seen[idx]}' and sector '{name}'")
108
+ seen[idx] = name
109
+
110
+ # --- internal -----------------------------------------------------
111
+
112
+ def _build_fields(self) -> dict[str, StructuredExpr]:
113
+ fields: dict[str, StructuredExpr] = {}
114
+ for name, sector in self._sectors.items():
115
+ fields[name] = StructuredExpr(
116
+ sdims=sector.sdims,
117
+ n_features=1,
118
+ param_suite=None,
119
+ labels=(name,),
120
+ _layout_id=self._layout_id,
121
+ _node=_SectorLeaf(
122
+ sector_name=name,
123
+ indices=sector.indices,
124
+ sdims=sector.sdims,
125
+ ),
126
+ )
127
+ return fields
128
+
129
+ def const(
130
+ self,
131
+ value: float | int = 1,
132
+ label: str | None = None,
133
+ ) -> StructuredExpr:
134
+ """Scalar constant compatible with this layout.
135
+
136
+ Parameters
137
+ ----------
138
+ value : float or int
139
+ The constant value (default ``1``).
140
+ label : str, optional
141
+ Human-readable label. Defaults to ``str(int(value))`` for
142
+ integer-valued numbers, ``str(value)`` otherwise.
143
+ """
144
+ if label is None:
145
+ if isinstance(value, int) or (isinstance(value, float) and value == int(value)):
146
+ label = str(int(value))
147
+ else:
148
+ label = f"{value:g}"
149
+ return StructuredExpr(
150
+ sdims=(),
151
+ n_features=1,
152
+ param_suite=None,
153
+ labels=(label,),
154
+ _layout_id=self._layout_id,
155
+ _node=_ConstNode(value=value, sdims=()),
156
+ )
157
+
158
+ def __repr__(self) -> str:
159
+ parts = [f"dim={self._dim}"]
160
+ for name, sector in self._sectors.items():
161
+ parts.append(f"{name}={sector!r}")
162
+ return f"{type(self).__name__}({', '.join(parts)})"
163
+
164
+
165
+ # =====================================================================
166
+ # IdentityLayout (trivial: one vector sector spanning all of dim)
167
+ # =====================================================================
168
+
169
+
170
+ class IdentityLayout(_BaseLayout):
171
+ """Trivial layout with a single ``state`` field spanning all of *dim*.
172
+
173
+ Example::
174
+
175
+ layout = IdentityLayout(dim=3)
176
+ x = layout.state # StructuredExpr(sdims=(3,), n_features=1)
177
+ """
178
+
179
+ def __init__(self, dim: int) -> None:
180
+ super().__init__(
181
+ dim=dim,
182
+ state=VectorSector(indices=tuple(range(dim)), sdim=dim),
183
+ )
184
+
185
+ def embed(self, rank: int = 1, **named_fields: StructuredExpr) -> Any:
186
+ raise NotImplementedError("IdentityLayout.embed() is not yet implemented.")