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,1003 @@
1
+ """StructuredExpr — declarative structured tensor expressions (inner world).
2
+
3
+ Defines :class:`StructuredExpr`, the user-facing expression type for the
4
+ inner physics world, and the lightweight ``_StructNode`` IR that encodes
5
+ the computation graph. Expressions are symbolic and cannot be evaluated
6
+ directly; use ``Layout.embed()`` to compile them into outer-world
7
+ ``StateExpr`` objects.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ import math
14
+ from dataclasses import dataclass
15
+ from typing import Any, Callable, Sequence
16
+
17
+ import jax.numpy as jnp
18
+
19
+ from .params import ParamSpec, ParamSuite
20
+
21
+ # =====================================================================
22
+ # Constants
23
+ # =====================================================================
24
+
25
+ _FREE_LAYOUT: int = -1 # layout-agnostic sentinel (compatible with any layout)
26
+ _EINSUM_LETTERS: str = "ijklmnopqrstuvwxyz"
27
+
28
+ # Sentinel for "not provided" (distinct from None)
29
+ _MISSING: object = object()
30
+
31
+
32
+ # =====================================================================
33
+ # _StructNode: lightweight IR (frozen dataclasses, no eval logic)
34
+ # =====================================================================
35
+ # Users never see these directly. The embed compiler (Phase 3) walks
36
+ # them to produce outer-world StateExpr nodes.
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class _StructNode:
41
+ """Base marker for all IR nodes."""
42
+
43
+
44
+ # --- Leaves -----------------------------------------------------------
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class _SectorLeaf(_StructNode):
49
+ """Field extracted from a Layout sector."""
50
+
51
+ sector_name: str
52
+ indices: tuple[int, ...]
53
+ sdims: tuple[int, ...]
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class _ConstNode(_StructNode):
58
+ """Compile-time constant (scalar, eye, etc.)."""
59
+
60
+ value: Any # int | float | jax.Array
61
+ sdims: tuple[int, ...]
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class _ParamLeaf(_StructNode):
66
+ """Learnable parameter block."""
67
+
68
+ param_spec: ParamSpec
69
+ sdims: tuple[int, ...]
70
+
71
+
72
+ # --- Layout-engine operators ------------------------------------------
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class _DiffOpNode(_StructNode):
77
+ """Differential operator (grad, lap, div, …). Engine-specific."""
78
+
79
+ op_name: str
80
+ child: _StructNode
81
+ engine_meta: Any # opaque to algebra
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class _InteractionNode(_StructNode):
86
+ """K-body interaction. Engine-specific."""
87
+
88
+ fn: Callable
89
+ reads: tuple
90
+ writes: Any
91
+ arity: int
92
+ spec_factory: Any
93
+ engine_meta: Any
94
+
95
+
96
+ # --- Pure algebra (engine-agnostic) -----------------------------------
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class _BinaryOp(_StructNode):
101
+ """Binary arithmetic: ``+ - * / **``."""
102
+
103
+ op: str
104
+ left: _StructNode
105
+ right: _StructNode
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class _EinsumOp(_StructNode):
110
+ """Einstein summation over rank axes."""
111
+
112
+ spec: str # e.g. 'ij,jk->ik'
113
+ children: tuple[_StructNode, ...]
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class _ConcatOp(_StructNode):
118
+ """Feature concatenation (``&``)."""
119
+
120
+ children: tuple[_StructNode, ...]
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class _StackOp(_StructNode):
125
+ """Stack expressions along a new leading rank axis."""
126
+
127
+ children: tuple[_StructNode, ...]
128
+ sdim: int
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class _SliceOp(_StructNode):
133
+ """Feature selection (``expr[idx]``)."""
134
+
135
+ child: _StructNode
136
+ idx: Any # int | slice | tuple[int, ...]
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class _UnaryOp(_StructNode):
141
+ """Unary: neg, transpose, elementwisemap."""
142
+
143
+ op: str # "neg" | "T" | "ew"
144
+ child: _StructNode
145
+ fn: Callable | None = None # only for "ew"
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class _ReshapeOp(_StructNode):
150
+ """Reshape between rank axes and features."""
151
+
152
+ child: _StructNode
153
+ target_sdims: tuple[int, ...]
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class _DenseOp(_StructNode):
158
+ """Learnable affine layer on features."""
159
+
160
+ child: _StructNode
161
+ n_out: int
162
+ param_spec: ParamSpec
163
+
164
+
165
+ # =====================================================================
166
+ # Helpers
167
+ # =====================================================================
168
+
169
+
170
+ def _merge_params(
171
+ a: ParamSuite | None,
172
+ b: ParamSuite | None,
173
+ ) -> ParamSuite | None:
174
+ """Merge two optional ParamSuites (sharing-by-name)."""
175
+ if a is None:
176
+ return b
177
+ if b is None:
178
+ return a
179
+ return a.merge(b)
180
+
181
+
182
+ def _resolve_layout(a: int, b: int) -> int:
183
+ """Resolve layout IDs. ``_FREE_LAYOUT`` is compatible with anything."""
184
+ if a == _FREE_LAYOUT:
185
+ return b
186
+ if b == _FREE_LAYOUT:
187
+ return a
188
+ if a != b:
189
+ raise TypeError(f"Cannot combine expressions from different Layouts (layout ids {a} vs {b})")
190
+ return a
191
+
192
+
193
+ def _parse_einsum(spec: str) -> tuple[list[str], str]:
194
+ """Parse ``'ij,jk->ik'`` → ``(['ij', 'jk'], 'ik')``."""
195
+ if "->" not in spec:
196
+ raise ValueError(f"Einsum spec must contain '->': {spec!r}")
197
+ lhs, rhs = spec.split("->", 1)
198
+ return lhs.split(","), rhs
199
+
200
+
201
+ def _validate_einsum(
202
+ operand_specs: list[str],
203
+ rhs: str,
204
+ operands: Sequence[StructuredExpr],
205
+ ) -> tuple[int, ...]:
206
+ """Validate einsum letter→size mapping; return output sdims."""
207
+ if len(operand_specs) != len(operands):
208
+ raise ValueError(f"Einsum spec has {len(operand_specs)} operands, got {len(operands)} expressions")
209
+ letter_size: dict[str, int] = {}
210
+ for spec_str, expr in zip(operand_specs, operands):
211
+ if len(spec_str) != expr.srank:
212
+ raise ValueError(
213
+ f"Einsum operand spec '{spec_str}' has {len(spec_str)} axes, "
214
+ f"but expression has srank={expr.srank} (sdims={expr.sdims})"
215
+ )
216
+ for ch, sz in zip(spec_str, expr.sdims):
217
+ if ch in letter_size:
218
+ if letter_size[ch] != sz:
219
+ raise ValueError(f"Einsum letter '{ch}' has conflicting sizes: {letter_size[ch]} vs {sz}")
220
+ else:
221
+ letter_size[ch] = sz
222
+ for ch in rhs:
223
+ if ch not in letter_size:
224
+ raise ValueError(f"Einsum output letter '{ch}' not found in any input operand")
225
+ return tuple(letter_size[ch] for ch in rhs)
226
+
227
+
228
+ def _coerce_scalar(value: Any) -> StructuredExpr | None:
229
+ """Wrap a Python/JAX scalar as a constant ``StructuredExpr``, or None."""
230
+ if isinstance(value, (int, float)):
231
+ return StructuredExpr(
232
+ sdims=(),
233
+ n_features=1,
234
+ param_suite=None,
235
+ labels=(),
236
+ _layout_id=_FREE_LAYOUT,
237
+ _node=_ConstNode(value=value, sdims=()),
238
+ )
239
+ if hasattr(value, "shape") and getattr(value, "shape") == ():
240
+ return StructuredExpr(
241
+ sdims=(),
242
+ n_features=1,
243
+ param_suite=None,
244
+ labels=(),
245
+ _layout_id=_FREE_LAYOUT,
246
+ _node=_ConstNode(value=value, sdims=()),
247
+ )
248
+ return None
249
+
250
+
251
+ # =====================================================================
252
+ # Auto-label helpers
253
+ # =====================================================================
254
+
255
+ _SUPERSCRIPT_DIGITS = str.maketrans("0123456789-", "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079\u207b")
256
+
257
+ # Characters in a label that signal it is "compound" and needs
258
+ # parenthesisation when used as a factor in a product.
259
+ _MUL_PAREN_CHARS = frozenset("+-/\u00b7")
260
+
261
+
262
+ def _int_superscript(n: int) -> str:
263
+ """Convert an integer to a unicode superscript string."""
264
+ return str(n).translate(_SUPERSCRIPT_DIGITS)
265
+
266
+
267
+ def _complete_labels(labels: tuple[str, ...], n_features: int) -> bool:
268
+ """True when *labels* has exactly one non-empty entry per feature."""
269
+ return len(labels) == n_features > 0 and all(labels)
270
+
271
+
272
+ def _is_const_value(node: _StructNode, value: float | int) -> bool:
273
+ """True when *node* is a ``_ConstNode`` with the given Python scalar."""
274
+ if not isinstance(node, _ConstNode):
275
+ return False
276
+ v = node.value
277
+ return isinstance(v, (int, float)) and v == value
278
+
279
+
280
+ def _const_int(node: _StructNode) -> int | None:
281
+ """Return the integer value of a ``_ConstNode``, or ``None``."""
282
+ if not isinstance(node, _ConstNode):
283
+ return None
284
+ v = node.value
285
+ if isinstance(v, int):
286
+ return v
287
+ if isinstance(v, float) and v == int(v) and abs(v) < 1000:
288
+ return int(v)
289
+ return None
290
+
291
+
292
+ def _paren_for_pow(label: str) -> str:
293
+ """Wrap in parentheses when raising to a power (multi-char labels)."""
294
+ return f"({label})" if len(label) > 1 else label
295
+
296
+
297
+ def _paren_for_mul(label: str) -> str:
298
+ """Wrap in parentheses when used as a factor in a product."""
299
+ check = label[1:] if label.startswith("-") else label
300
+ return f"({label})" if any(c in _MUL_PAREN_CHARS for c in check) else label
301
+
302
+
303
+ def _auto_mul_labels(
304
+ a_labels: tuple[str, ...],
305
+ a_nf: int,
306
+ a_node: _StructNode,
307
+ b_labels: tuple[str, ...],
308
+ b_nf: int,
309
+ b_node: _StructNode,
310
+ ) -> tuple[str, ...]:
311
+ """Auto-generate labels for element-wise multiplication ``a * b``."""
312
+ # multiply by 1 -> identity
313
+ if _is_const_value(b_node, 1) and _complete_labels(a_labels, a_nf):
314
+ return a_labels
315
+ if _is_const_value(a_node, 1) and _complete_labels(b_labels, b_nf):
316
+ return b_labels
317
+ # both fully labelled -> Cartesian product (juxtaposition)
318
+ if _complete_labels(a_labels, a_nf) and _complete_labels(b_labels, b_nf):
319
+ return tuple(_paren_for_mul(la) + _paren_for_mul(lb) for la in a_labels for lb in b_labels)
320
+ return ()
321
+
322
+
323
+ def _auto_pow_labels(
324
+ base_labels: tuple[str, ...],
325
+ base_nf: int,
326
+ exp_node: _StructNode,
327
+ ) -> tuple[str, ...]:
328
+ """Auto-generate labels for ``base ** exp``."""
329
+ n = _const_int(exp_node)
330
+ if n is None or not _complete_labels(base_labels, base_nf):
331
+ return ()
332
+ if n == 0:
333
+ return tuple("1" for _ in base_labels)
334
+ if n == 1:
335
+ return base_labels
336
+ sup = _int_superscript(n)
337
+ return tuple(f"{_paren_for_pow(lab)}{sup}" for lab in base_labels)
338
+
339
+
340
+ def _auto_sub_labels(
341
+ a_labels: tuple[str, ...],
342
+ a_nf: int,
343
+ b_labels: tuple[str, ...],
344
+ b_nf: int,
345
+ ) -> tuple[str, ...]:
346
+ """Auto-generate labels for ``a - b``."""
347
+ if not _complete_labels(a_labels, a_nf) or not _complete_labels(b_labels, b_nf):
348
+ return ()
349
+ if a_nf != b_nf:
350
+ return ()
351
+ return tuple(f"{la}-{_paren_for_mul(lb)}" for la, lb in zip(a_labels, b_labels))
352
+
353
+
354
+ def _auto_div_labels(
355
+ a_labels: tuple[str, ...],
356
+ a_nf: int,
357
+ b_labels: tuple[str, ...],
358
+ b_nf: int,
359
+ ) -> tuple[str, ...]:
360
+ """Auto-generate labels for ``a / b``."""
361
+ if not _complete_labels(a_labels, a_nf) or not _complete_labels(b_labels, b_nf):
362
+ return ()
363
+ if b_nf == 1:
364
+ d = _paren_for_mul(b_labels[0])
365
+ return tuple(f"{_paren_for_mul(la)}/{d}" for la in a_labels)
366
+ if a_nf == b_nf:
367
+ return tuple(f"{_paren_for_mul(la)}/{_paren_for_mul(lb)}" for la, lb in zip(a_labels, b_labels))
368
+ return ()
369
+
370
+
371
+ def _auto_add_labels(
372
+ a_labels: tuple[str, ...],
373
+ a_nf: int,
374
+ b_labels: tuple[str, ...],
375
+ b_nf: int,
376
+ ) -> tuple[str, ...]:
377
+ """Auto-generate labels for ``a + b``.
378
+
379
+ When both sides are fully labelled, produces ``"a+b"`` per feature.
380
+ Otherwise falls back to first-non-empty-wins (the previous default).
381
+ """
382
+ if _complete_labels(a_labels, a_nf) and _complete_labels(b_labels, b_nf) and a_nf == b_nf:
383
+ return tuple(f"{la}+{lb}" for la, lb in zip(a_labels, b_labels))
384
+ return a_labels or b_labels
385
+
386
+
387
+ def _auto_einsum_labels(
388
+ operand_info: Sequence[tuple[tuple[str, ...], int]],
389
+ ) -> tuple[str, ...]:
390
+ """Auto-generate labels for ``einsum`` (Cartesian product with ``\u00b7``)."""
391
+ for labels, nf in operand_info:
392
+ if not _complete_labels(labels, nf):
393
+ return ()
394
+ label_lists = [labels for labels, _nf in operand_info]
395
+ return tuple("\u00b7".join(combo) for combo in itertools.product(*label_lists))
396
+
397
+
398
+ def _auto_ew_labels(
399
+ fn: Callable,
400
+ labels: tuple[str, ...],
401
+ n_features: int,
402
+ name: str | None = None,
403
+ ) -> tuple[str, ...]:
404
+ """Auto-generate labels for ``elementwisemap(fn, \u2026)``."""
405
+ if not _complete_labels(labels, n_features):
406
+ return ()
407
+ nm = name or getattr(fn, "__name__", "")
408
+ if not nm or nm == "<lambda>":
409
+ return ()
410
+ return tuple(f"{nm}({lab})" for lab in labels)
411
+
412
+
413
+ def _feature_count(idx: Any, n_features: int) -> int:
414
+ """Compute output feature count for a ``__getitem__`` index."""
415
+ if isinstance(idx, int):
416
+ if idx < -n_features or idx >= n_features:
417
+ raise IndexError(f"Feature index {idx} out of range for n_features={n_features}")
418
+ return 1
419
+ if isinstance(idx, slice):
420
+ return len(range(*idx.indices(n_features)))
421
+ if isinstance(idx, (list, tuple)):
422
+ for i in idx:
423
+ if not isinstance(i, int):
424
+ raise TypeError(f"Feature indices must be ints, got {type(i).__name__}")
425
+ if i < -n_features or i >= n_features:
426
+ raise IndexError(f"Feature index {i} out of range for n_features={n_features}")
427
+ return len(idx)
428
+ raise TypeError(f"Unsupported index type: {type(idx).__name__}")
429
+
430
+
431
+ # =====================================================================
432
+ # StructuredExpr
433
+ # =====================================================================
434
+
435
+
436
+ @dataclass(frozen=True, slots=True, eq=False)
437
+ class StructuredExpr:
438
+ """Declarative structured tensor expression (inner world).
439
+
440
+ Users build these via Layout methods and algebra. They are symbolic
441
+ and cannot be evaluated directly — use ``Layout.embed()`` to compile
442
+ to an outer-world ``StateExpr``.
443
+
444
+ Attributes
445
+ ----------
446
+ sdims : tuple[int, ...]
447
+ Per-axis sizes. ``()`` = scalar, ``(2,)`` = 2-vector, etc.
448
+ n_features : int
449
+ Number of independent regression channels (last axis).
450
+ param_suite : ParamSuite | None
451
+ Learnable parameters (``None`` = pure basis).
452
+ labels : tuple[str, ...]
453
+ Human-readable feature labels.
454
+ """
455
+
456
+ sdims: tuple[int, ...]
457
+ n_features: int
458
+ param_suite: ParamSuite | None
459
+ labels: tuple[str, ...]
460
+ _layout_id: int
461
+ _node: _StructNode
462
+
463
+ # --- properties ---------------------------------------------------
464
+
465
+ @property
466
+ def srank(self) -> int:
467
+ """Number of structured dimensions (rank axes)."""
468
+ return len(self.sdims)
469
+
470
+ # --- private helpers ----------------------------------------------
471
+
472
+ def _new(
473
+ self,
474
+ *,
475
+ sdims: tuple[int, ...] | None = None,
476
+ n_features: int | None = None,
477
+ param_suite: Any = _MISSING,
478
+ labels: tuple[str, ...] = (),
479
+ layout_id: int | None = None,
480
+ node: _StructNode,
481
+ ) -> StructuredExpr:
482
+ """Shortcut: create a new expr, inheriting metadata from *self*."""
483
+ return StructuredExpr(
484
+ sdims=sdims if sdims is not None else self.sdims,
485
+ n_features=(n_features if n_features is not None else self.n_features),
486
+ param_suite=(param_suite if param_suite is not _MISSING else self.param_suite),
487
+ labels=labels,
488
+ _layout_id=(layout_id if layout_id is not None else self._layout_id),
489
+ _node=node,
490
+ )
491
+
492
+ def _check_layout(self, other: StructuredExpr) -> None:
493
+ _resolve_layout(self._layout_id, other._layout_id)
494
+
495
+ # =================================================================
496
+ # Arithmetic: + - * / ** (unary -)
497
+ # =================================================================
498
+
499
+ def __add__(self, other: Any) -> StructuredExpr:
500
+ if not isinstance(other, StructuredExpr):
501
+ other = _coerce_scalar(other)
502
+ if other is None:
503
+ return NotImplemented # type: ignore[return-value]
504
+ self._check_layout(other)
505
+ if self.sdims != other.sdims:
506
+ raise ValueError(f"Cannot add: sdims mismatch {self.sdims} vs {other.sdims}")
507
+ if self.n_features != other.n_features:
508
+ raise ValueError(f"Cannot add: n_features mismatch {self.n_features} vs {other.n_features}")
509
+ return StructuredExpr(
510
+ sdims=self.sdims,
511
+ n_features=self.n_features,
512
+ param_suite=_merge_params(self.param_suite, other.param_suite),
513
+ labels=_auto_add_labels(
514
+ self.labels,
515
+ self.n_features,
516
+ other.labels,
517
+ other.n_features,
518
+ ),
519
+ _layout_id=_resolve_layout(self._layout_id, other._layout_id),
520
+ _node=_BinaryOp("+", self._node, other._node),
521
+ )
522
+
523
+ def __radd__(self, other: Any) -> StructuredExpr:
524
+ return self.__add__(other) # addition is commutative
525
+
526
+ def __sub__(self, other: Any) -> StructuredExpr:
527
+ if not isinstance(other, StructuredExpr):
528
+ other = _coerce_scalar(other)
529
+ if other is None:
530
+ return NotImplemented # type: ignore[return-value]
531
+ self._check_layout(other)
532
+ if self.sdims != other.sdims:
533
+ raise ValueError(f"Cannot subtract: sdims mismatch {self.sdims} vs {other.sdims}")
534
+ if self.n_features != other.n_features:
535
+ raise ValueError(f"Cannot subtract: n_features mismatch {self.n_features} vs {other.n_features}")
536
+ return StructuredExpr(
537
+ sdims=self.sdims,
538
+ n_features=self.n_features,
539
+ param_suite=_merge_params(self.param_suite, other.param_suite),
540
+ labels=_auto_sub_labels(
541
+ self.labels,
542
+ self.n_features,
543
+ other.labels,
544
+ other.n_features,
545
+ ),
546
+ _layout_id=_resolve_layout(self._layout_id, other._layout_id),
547
+ _node=_BinaryOp("-", self._node, other._node),
548
+ )
549
+
550
+ def __rsub__(self, other: Any) -> StructuredExpr:
551
+ other_expr = _coerce_scalar(other)
552
+ if other_expr is None:
553
+ return NotImplemented # type: ignore[return-value]
554
+ return other_expr.__sub__(self)
555
+
556
+ def __mul__(self, other: Any) -> StructuredExpr:
557
+ if not isinstance(other, StructuredExpr):
558
+ other = _coerce_scalar(other)
559
+ if other is None:
560
+ return NotImplemented # type: ignore[return-value]
561
+ self._check_layout(other)
562
+ a, b = self, other
563
+ if a.srank == 0:
564
+ sdims = b.sdims
565
+ elif b.srank == 0:
566
+ sdims = a.sdims
567
+ elif a.sdims == b.sdims:
568
+ sdims = a.sdims
569
+ else:
570
+ raise TypeError(
571
+ f"Cannot multiply expressions with incompatible sdims "
572
+ f"{a.sdims} and {b.sdims}. Use einsum() for mixed-rank "
573
+ f"contractions."
574
+ )
575
+ return StructuredExpr(
576
+ sdims=sdims,
577
+ n_features=a.n_features * b.n_features,
578
+ param_suite=_merge_params(a.param_suite, b.param_suite),
579
+ labels=_auto_mul_labels(
580
+ a.labels,
581
+ a.n_features,
582
+ a._node,
583
+ b.labels,
584
+ b.n_features,
585
+ b._node,
586
+ ),
587
+ _layout_id=_resolve_layout(a._layout_id, b._layout_id),
588
+ _node=_BinaryOp("*", a._node, b._node),
589
+ )
590
+
591
+ def __rmul__(self, other: Any) -> StructuredExpr:
592
+ return self.__mul__(other) # multiplication is commutative
593
+
594
+ def __truediv__(self, other: Any) -> StructuredExpr:
595
+ if not isinstance(other, StructuredExpr):
596
+ other = _coerce_scalar(other)
597
+ if other is None:
598
+ return NotImplemented # type: ignore[return-value]
599
+ self._check_layout(other)
600
+ if other.n_features not in (1, self.n_features):
601
+ raise ValueError(
602
+ f"Division requires divisor n_features=1 or matching "
603
+ f"n_features={self.n_features}, got {other.n_features}"
604
+ )
605
+ if other.srank > 0 and other.sdims != self.sdims:
606
+ raise ValueError(f"Cannot divide: sdims mismatch {self.sdims} vs {other.sdims}")
607
+ return StructuredExpr(
608
+ sdims=self.sdims,
609
+ n_features=self.n_features,
610
+ param_suite=_merge_params(self.param_suite, other.param_suite),
611
+ labels=_auto_div_labels(
612
+ self.labels,
613
+ self.n_features,
614
+ other.labels,
615
+ other.n_features,
616
+ ),
617
+ _layout_id=_resolve_layout(self._layout_id, other._layout_id),
618
+ _node=_BinaryOp("/", self._node, other._node),
619
+ )
620
+
621
+ def __rtruediv__(self, other: Any) -> StructuredExpr:
622
+ other_expr = _coerce_scalar(other)
623
+ if other_expr is None:
624
+ return NotImplemented # type: ignore[return-value]
625
+ return other_expr.__truediv__(self)
626
+
627
+ def __pow__(self, other: Any) -> StructuredExpr:
628
+ if not isinstance(other, StructuredExpr):
629
+ other = _coerce_scalar(other)
630
+ if other is None:
631
+ return NotImplemented # type: ignore[return-value]
632
+ self._check_layout(other)
633
+ if other.srank != 0 or other.n_features != 1:
634
+ raise TypeError(
635
+ f"Exponent must be a scalar with n_features=1, got sdims={other.sdims}, n_features={other.n_features}"
636
+ )
637
+ return StructuredExpr(
638
+ sdims=self.sdims,
639
+ n_features=self.n_features,
640
+ param_suite=_merge_params(self.param_suite, other.param_suite),
641
+ labels=_auto_pow_labels(
642
+ self.labels,
643
+ self.n_features,
644
+ other._node,
645
+ ),
646
+ _layout_id=_resolve_layout(self._layout_id, other._layout_id),
647
+ _node=_BinaryOp("**", self._node, other._node),
648
+ )
649
+
650
+ def __neg__(self) -> StructuredExpr:
651
+ return self._new(labels=self.labels, node=_UnaryOp("neg", self._node))
652
+
653
+ def __pos__(self) -> StructuredExpr:
654
+ return self
655
+
656
+ # =================================================================
657
+ # Human-readable label
658
+ # =================================================================
659
+
660
+ def with_label(self, label: str) -> StructuredExpr:
661
+ """Return a copy of this expression with a single human-readable label.
662
+
663
+ Useful for annotating derived quantities (arithmetic, einsum, …)
664
+ so that ``print_report`` shows a meaningful term name instead of
665
+ a generic fallback.
666
+
667
+ Parameters
668
+ ----------
669
+ label : str
670
+ Human-readable name for this term, e.g. ``"|Q|²Q"``.
671
+
672
+ Returns
673
+ -------
674
+ StructuredExpr
675
+ Identical expression with ``labels=(label,)``.
676
+ """
677
+ if self.n_features != 1:
678
+ raise ValueError(
679
+ f"with_label() requires n_features=1, "
680
+ f"got n_features={self.n_features}. "
681
+ "Use the & operator to concatenate labelled single-feature "
682
+ "terms instead of labelling a multi-feature block."
683
+ )
684
+ return StructuredExpr(
685
+ sdims=self.sdims,
686
+ n_features=self.n_features,
687
+ param_suite=self.param_suite,
688
+ labels=(label,),
689
+ _layout_id=self._layout_id,
690
+ _node=self._node,
691
+ )
692
+
693
+ # =================================================================
694
+ # Feature concatenation (&)
695
+ # =================================================================
696
+
697
+ def __and__(self, other: Any) -> StructuredExpr:
698
+ if not isinstance(other, StructuredExpr):
699
+ return NotImplemented # type: ignore[return-value]
700
+ self._check_layout(other)
701
+ if self.sdims != other.sdims:
702
+ raise ValueError(f"Cannot concatenate (&): sdims mismatch {self.sdims} vs {other.sdims}")
703
+ # Flatten left-side concats so labels[i] aligns with children[i]
704
+ # (avoids misalignment when _compile_sector iterates node.children)
705
+ if isinstance(self._node, _ConcatOp):
706
+ new_children = self._node.children + (other._node,)
707
+ else:
708
+ new_children = (self._node, other._node)
709
+ return StructuredExpr(
710
+ sdims=self.sdims,
711
+ n_features=self.n_features + other.n_features,
712
+ param_suite=_merge_params(self.param_suite, other.param_suite),
713
+ labels=self.labels + other.labels,
714
+ _layout_id=_resolve_layout(self._layout_id, other._layout_id),
715
+ _node=_ConcatOp(new_children),
716
+ )
717
+
718
+ # =================================================================
719
+ # Feature selection (expr[idx])
720
+ # =================================================================
721
+
722
+ def __getitem__(self, idx: Any) -> StructuredExpr:
723
+ n = _feature_count(idx, self.n_features)
724
+ if isinstance(idx, int):
725
+ lbls = (self.labels[idx],) if idx < len(self.labels) else ()
726
+ elif isinstance(idx, slice):
727
+ lbls = tuple(self.labels[idx]) if self.labels else ()
728
+ else:
729
+ lbls = ()
730
+ return self._new(
731
+ n_features=n,
732
+ labels=lbls,
733
+ node=_SliceOp(self._node, idx),
734
+ )
735
+
736
+ # =================================================================
737
+ # Transpose
738
+ # =================================================================
739
+
740
+ @property
741
+ def T(self) -> StructuredExpr:
742
+ """Swap last two rank axes. Requires ``srank >= 2``."""
743
+ if self.srank < 2:
744
+ raise TypeError(f"Transpose requires srank >= 2, got srank={self.srank} (sdims={self.sdims})")
745
+ new_sdims = self.sdims[:-2] + (self.sdims[-1], self.sdims[-2])
746
+ return self._new(
747
+ sdims=new_sdims,
748
+ node=_UnaryOp("T", self._node),
749
+ )
750
+
751
+ # =================================================================
752
+ # Matmul (@)
753
+ # =================================================================
754
+
755
+ def __matmul__(self, other: Any) -> StructuredExpr:
756
+ """Contract last axis of *self* with first axis of *other*."""
757
+ if not isinstance(other, StructuredExpr):
758
+ return NotImplemented # type: ignore[return-value]
759
+ if self.srank < 1 or other.srank < 1:
760
+ raise TypeError(f"Matmul requires srank >= 1 on both sides, got {self.srank} and {other.srank}")
761
+ if self.sdims[-1] != other.sdims[0]:
762
+ raise ValueError(
763
+ f"Matmul contraction mismatch: self.sdims[-1]={self.sdims[-1]} vs other.sdims[0]={other.sdims[0]}"
764
+ )
765
+ pool = iter(_EINSUM_LETTERS)
766
+ a_letters = [next(pool) for _ in range(self.srank)]
767
+ shared = a_letters[-1]
768
+ b_letters = [shared] + [next(pool) for _ in range(other.srank - 1)]
769
+ spec = "".join(a_letters) + "," + "".join(b_letters) + "->" + "".join(a_letters[:-1]) + "".join(b_letters[1:])
770
+ return type(self).einsum(spec, self, other)
771
+
772
+ # =================================================================
773
+ # Dot (contract last axes of both)
774
+ # =================================================================
775
+
776
+ def dot(self, other: StructuredExpr) -> StructuredExpr:
777
+ """Contract last axis of *self* with last axis of *other*."""
778
+ if not isinstance(other, StructuredExpr):
779
+ raise TypeError(f"Expected StructuredExpr, got {type(other).__name__}")
780
+ if self.srank < 1 or other.srank < 1:
781
+ raise TypeError(f"dot requires srank >= 1 on both sides, got {self.srank} and {other.srank}")
782
+ if self.sdims[-1] != other.sdims[-1]:
783
+ raise ValueError(
784
+ f"dot contraction mismatch: self.sdims[-1]={self.sdims[-1]} vs other.sdims[-1]={other.sdims[-1]}"
785
+ )
786
+ pool = iter(_EINSUM_LETTERS)
787
+ a_rest = [next(pool) for _ in range(self.srank - 1)]
788
+ b_rest = [next(pool) for _ in range(other.srank - 1)]
789
+ shared = next(pool)
790
+ spec = "".join(a_rest + [shared]) + "," + "".join(b_rest + [shared]) + "->" + "".join(a_rest + b_rest)
791
+ return type(self).einsum(spec, self, other)
792
+
793
+ # =================================================================
794
+ # Einsum (static method)
795
+ # =================================================================
796
+
797
+ @staticmethod
798
+ def einsum(spec: str, *operands: StructuredExpr) -> StructuredExpr:
799
+ """Einstein summation over rank axes.
800
+
801
+ Example::
802
+
803
+ Q = StructuredExpr.einsum('i,j->ij', n, n)
804
+ """
805
+ operand_specs, rhs = _parse_einsum(spec)
806
+ output_sdims = _validate_einsum(operand_specs, rhs, operands)
807
+
808
+ layout_id = _FREE_LAYOUT
809
+ params: ParamSuite | None = None
810
+ for op in operands:
811
+ layout_id = _resolve_layout(layout_id, op._layout_id)
812
+ params = _merge_params(params, op.param_suite)
813
+
814
+ n_features = math.prod(op.n_features for op in operands)
815
+ return StructuredExpr(
816
+ sdims=output_sdims,
817
+ n_features=n_features,
818
+ param_suite=params,
819
+ labels=_auto_einsum_labels([(op.labels, op.n_features) for op in operands]),
820
+ _layout_id=layout_id,
821
+ _node=_EinsumOp(spec, tuple(op._node for op in operands)),
822
+ )
823
+
824
+ # =================================================================
825
+ # Stack (classmethod — build vector from scalars)
826
+ # =================================================================
827
+
828
+ @classmethod
829
+ def stack(
830
+ cls,
831
+ exprs: Sequence[StructuredExpr],
832
+ *,
833
+ sdim: int | None = None,
834
+ ) -> StructuredExpr:
835
+ """Stack expressions along a new leading rank axis.
836
+
837
+ All inputs must share the same ``sdims`` and ``n_features``.
838
+ ``sdim`` defaults to ``len(exprs)``.
839
+ """
840
+ exprs = list(exprs)
841
+ if not exprs:
842
+ raise ValueError("stack requires at least one expression")
843
+ if sdim is None:
844
+ sdim = len(exprs)
845
+ if sdim != len(exprs):
846
+ raise ValueError(f"sdim={sdim} does not match number of expressions ({len(exprs)})")
847
+ ref = exprs[0]
848
+ layout_id = ref._layout_id
849
+ params = ref.param_suite
850
+ for e in exprs[1:]:
851
+ layout_id = _resolve_layout(layout_id, e._layout_id)
852
+ if e.sdims != ref.sdims:
853
+ raise ValueError(f"stack: all expressions must have same sdims, got {ref.sdims} and {e.sdims}")
854
+ if e.n_features != ref.n_features:
855
+ raise ValueError(
856
+ f"stack: all expressions must have same n_features, got {ref.n_features} and {e.n_features}"
857
+ )
858
+ params = _merge_params(params, e.param_suite)
859
+ return StructuredExpr(
860
+ sdims=(sdim,) + ref.sdims,
861
+ n_features=ref.n_features,
862
+ param_suite=params,
863
+ labels=(),
864
+ _layout_id=layout_id,
865
+ _node=_StackOp(tuple(e._node for e in exprs), sdim),
866
+ )
867
+
868
+ # =================================================================
869
+ # Eye (classmethod — identity matrix)
870
+ # =================================================================
871
+
872
+ @classmethod
873
+ def eye(
874
+ cls,
875
+ sdim: int,
876
+ *,
877
+ layout_id: int = _FREE_LAYOUT,
878
+ ) -> StructuredExpr:
879
+ """Identity matrix with ``sdims=(sdim, sdim)``, ``n_features=1``."""
880
+ return StructuredExpr(
881
+ sdims=(sdim, sdim),
882
+ n_features=1,
883
+ param_suite=None,
884
+ labels=("I",),
885
+ _layout_id=layout_id,
886
+ _node=_ConstNode(value=jnp.eye(sdim), sdims=(sdim, sdim)),
887
+ )
888
+
889
+ # =================================================================
890
+ # Math convenience methods
891
+ # =================================================================
892
+
893
+ def sin(self) -> StructuredExpr:
894
+ return self.elementwisemap(jnp.sin, name="sin")
895
+
896
+ def cos(self) -> StructuredExpr:
897
+ return self.elementwisemap(jnp.cos, name="cos")
898
+
899
+ def exp(self) -> StructuredExpr:
900
+ return self.elementwisemap(jnp.exp, name="exp")
901
+
902
+ def log(self) -> StructuredExpr:
903
+ return self.elementwisemap(jnp.log, name="log")
904
+
905
+ def tanh(self) -> StructuredExpr:
906
+ return self.elementwisemap(jnp.tanh, name="tanh")
907
+
908
+ def abs(self) -> StructuredExpr:
909
+ return self.elementwisemap(jnp.abs, name="abs")
910
+
911
+ def sqrt(self) -> StructuredExpr:
912
+ return self.elementwisemap(jnp.sqrt, name="sqrt")
913
+
914
+ def elementwisemap(
915
+ self,
916
+ fn: Callable,
917
+ *,
918
+ name: str | None = None,
919
+ ) -> StructuredExpr:
920
+ """Apply a JAX-traceable function elementwise.
921
+
922
+ Records a ``_UnaryOp("ew", \u2026)`` node in the IR tree. At embed
923
+ time this compiles to ``StateExpr.elementwisemap(fn)``.
924
+
925
+ Parameters
926
+ ----------
927
+ name : str, optional
928
+ Override the function name used in auto-generated labels.
929
+ Defaults to ``fn.__name__``.
930
+ """
931
+ return self._new(
932
+ labels=_auto_ew_labels(fn, self.labels, self.n_features, name),
933
+ node=_UnaryOp("ew", self._node, fn),
934
+ )
935
+
936
+ # =================================================================
937
+ # Reshape operations
938
+ # =================================================================
939
+
940
+ def rank_to_features(self) -> StructuredExpr:
941
+ """Flatten all rank axes into the features axis.
942
+
943
+ ``sdims=(2,3), n_features=5`` → ``sdims=(), n_features=30``.
944
+ """
945
+ new_nf = self.n_features * math.prod(self.sdims) if self.sdims else self.n_features
946
+ return self._new(
947
+ sdims=(),
948
+ n_features=new_nf,
949
+ node=_ReshapeOp(self._node, target_sdims=()),
950
+ )
951
+
952
+ def features_to_rank(self, target_sdims: tuple[int, ...]) -> StructuredExpr:
953
+ """Promote features into rank axes. Requires ``srank == 0``.
954
+
955
+ ``sdims=(), n_features=12`` → ``features_to_rank((3,4))``
956
+ → ``sdims=(3,4), n_features=1``.
957
+ """
958
+ if self.srank != 0:
959
+ raise TypeError(
960
+ f"features_to_rank requires srank == 0 (scalar input), "
961
+ f"got srank={self.srank}. Use rank_to_features() first."
962
+ )
963
+ p = math.prod(target_sdims)
964
+ if p == 0:
965
+ raise ValueError("target_sdims must have all positive sizes")
966
+ if self.n_features % p != 0:
967
+ raise ValueError(f"n_features={self.n_features} is not divisible by prod(target_sdims)={p}")
968
+ return self._new(
969
+ sdims=target_sdims,
970
+ n_features=self.n_features // p,
971
+ node=_ReshapeOp(self._node, target_sdims=target_sdims),
972
+ )
973
+
974
+ # =================================================================
975
+ # Dense (learnable affine layer on features)
976
+ # =================================================================
977
+
978
+ def dense(self, n_out: int, *, name: str = "dense") -> StructuredExpr:
979
+ """Affine projection of features. Adds a learnable weight matrix."""
980
+ ps = ParamSpec(name=name, shape=(n_out, self.n_features))
981
+ return StructuredExpr(
982
+ sdims=self.sdims,
983
+ n_features=n_out,
984
+ param_suite=_merge_params(self.param_suite, ParamSuite([ps])),
985
+ labels=(),
986
+ _layout_id=self._layout_id,
987
+ _node=_DenseOp(self._node, n_out, ps),
988
+ )
989
+
990
+ # =================================================================
991
+ # repr
992
+ # =================================================================
993
+
994
+ def __repr__(self) -> str:
995
+ parts = [
996
+ f"sdims={self.sdims}",
997
+ f"n_features={self.n_features}",
998
+ ]
999
+ if self.labels:
1000
+ parts.append(f"labels={self.labels}")
1001
+ if self.param_suite is not None:
1002
+ parts.append("has_params=True")
1003
+ return f"StructuredExpr({', '.join(parts)})"