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,153 @@
1
+ # SFI/statefunc/nodes/ops/linear.py
2
+ """Linear algebra operator nodes: Dense and Coeff."""
3
+
4
+ import equinox as eqx
5
+ import jax.numpy as jnp
6
+
7
+ from ...params import ParamSpec, ParamSuite
8
+ from ..base import BaseNode, BaseOpNode
9
+ from ..contract import _ContractMixin
10
+
11
+
12
+ # ──────────────────────────────────────────────────────────────────────────────
13
+ # DenseNode – linear map on the feature axis of one child
14
+ # ──────────────────────────────────────────────────────────────────────────────
15
+ class DenseNode(BaseOpNode):
16
+ """
17
+ Affine map along the feature axis of a single child.
18
+
19
+ Parameters
20
+ ----------
21
+ n_out : int - output feature dimension
22
+ weight : str = "W" - parameter, shape ``(n_in, n_out)``
23
+ bias : str | None = "b" - parameter, shape ``(n_out,)`` or None
24
+ """
25
+
26
+ weight_name: str = eqx.field(static=True)
27
+ bias_name: str | None = eqx.field(static=True)
28
+ _n_out: int = eqx.field(static=True)
29
+
30
+ # ------------------------------------------------------------------
31
+ def __init__(self, child: BaseNode, *, n_out: int, weight: str = "W", bias: str | None = "b"):
32
+ self.weight_name = weight
33
+ self.bias_name = bias
34
+ self._n_out = int(n_out)
35
+ super().__init__(child)
36
+
37
+ # own parameter template
38
+ W_spec = ParamSpec(weight, (child.n_features, n_out))
39
+ specs = [W_spec]
40
+ if bias is not None:
41
+ specs.append(ParamSpec(bias, (n_out,)))
42
+ own_suite = ParamSuite.from_specs(*specs)
43
+
44
+ merged = child.param_suite.merge(own_suite) if child.param_suite else own_suite
45
+ object.__setattr__(self, "param_suite", merged)
46
+
47
+ def with_children(self, new_children):
48
+ return DenseNode(
49
+ new_children[0],
50
+ n_out=self._n_out,
51
+ weight=self.weight_name,
52
+ bias=self.bias_name,
53
+ )
54
+
55
+ # ------------------------------------------------------------------
56
+ def _merge_static(self, children):
57
+ ch = children[0]
58
+ return _ContractMixin.inherit_contract(ch, n_features=self._n_out)
59
+
60
+ # ------------------------------------------------------------------
61
+ def _op(self, outs, *, params):
62
+ y_in = outs[0]
63
+ W = params[self.weight_name]
64
+ b = params[self.bias_name] if self.bias_name else None
65
+ y = jnp.tensordot(y_in, W, axes=[-1, 0]) # "...i,ij->...j"
66
+ return y + b if b is not None else y
67
+
68
+ # ------------------------------------------------------------------
69
+ # Feature-wise flatten
70
+ # ------------------------------------------------------------------
71
+ def flatten(self):
72
+ """
73
+ Return (funcs, labels, descs) where
74
+
75
+ * ``funcs[j](...)`` calls the *DenseNode* itself and picks the
76
+ j-th feature slice → keeps maintenance trivial.
77
+ * Label is ``"dense{j}"`` as there's no practical way to propagate
78
+ previous labels through a Dense node.
79
+ * Descriptors are inherited from the **child** – the linear map
80
+ does not alter the mathematical identity, only its coefficients.
81
+ """
82
+
83
+ child_funcs, child_labels, child_descs = self.children[0].flatten()
84
+ n_out = self.n_features
85
+
86
+ # --- per-feature callables -----------------------------------
87
+ def _slice_feature(j):
88
+ # j is captured as default arg to avoid late binding
89
+ return lambda x, *, v=None, mask=None, extras=None, params=None, _j=j: (
90
+ self(x, v=v, mask=mask, extras=extras, params=params)[..., _j]
91
+ )
92
+
93
+ funcs = tuple(_slice_feature(j) for j in range(n_out))
94
+
95
+ # --- labels ---------------------------------------------------
96
+ labels = tuple(f"dense{j}" for j in range(n_out))
97
+
98
+ # --- descriptors (inherit) ------------------------------------
99
+ descs = tuple({"dense_of": tuple(child_descs), "index": j} for j in range(n_out))
100
+
101
+ return funcs, labels, descs
102
+
103
+
104
+ # ──────────────────────────────────────────────────────────────────────────────
105
+ # CoeffNode – “Dense-with-θ-vector” (n_out = 1, bias = None)
106
+ # ──────────────────────────────────────────────────────────────────────────────
107
+ class CoeffNode(DenseNode):
108
+ """
109
+ Special-case of :class:`DenseNode` that stores its weights as a **vector**
110
+ θ ∈ ℝⁿ rather than a (n_in × 1) column matrix:
111
+
112
+ F(x; θ) = θ · f(x) # dot on feature axis, returns scalar
113
+
114
+ The output therefore has a **singleton feature axis** (n_features = 1).
115
+
116
+ Parameters
117
+ ----------
118
+ child : BaseNode
119
+ Basis/dictionary to be linearly combined.
120
+ coeff_key : str, default ``"coeff"``
121
+ Parameter-dict name under which θ is stored.
122
+ """
123
+
124
+ _basis_labels: tuple[str, ...] = eqx.field(static=True, default=())
125
+
126
+ # ------------------------------------------------------------------
127
+ def __init__(self, child: BaseNode, *, coeff_key: str = "coeff"):
128
+ # call DenseNode constructor with n_out = 1, bias = None
129
+ super().__init__(child, n_out=1, weight=coeff_key, bias=None)
130
+
131
+ # Override the weight spec to a **vector** (n_in,) instead of matrix,
132
+ # with a default of ones so bare `Basis → .to_psf()` simulates out of
133
+ # the box (equivalent to "sum all features").
134
+ theta = ParamSpec(coeff_key, (child.n_features,), default=jnp.ones((child.n_features,)))
135
+ own = ParamSuite.from_specs(theta)
136
+ merged = child.param_suite.merge(own) if child.param_suite else own
137
+ object.__setattr__(self, "param_suite", merged)
138
+
139
+ # Preserve the original basis labels for downstream reporting
140
+ _, child_labels, _ = child.flatten()
141
+ object.__setattr__(self, "_basis_labels", tuple(child_labels))
142
+
143
+ def with_children(self, new_children):
144
+ return CoeffNode(new_children[0], coeff_key=self.weight_name)
145
+
146
+ # ------------------------------------------------------------------
147
+ # DenseNode._op expects W shaped (n_in, 1); we override to use a vector.
148
+ # ------------------------------------------------------------------
149
+ def _op(self, outs, *, params):
150
+ y_in = outs[0] # (..., n_feat)
151
+ theta = params[self.weight_name] # (n_feat,)
152
+ y = jnp.einsum("...b,b->...", y_in, theta)[..., None] # (..., 1)
153
+ return y
@@ -0,0 +1,138 @@
1
+ # SFI/statefunc/nodes/ops/mapn.py
2
+ """MapN: broadcast and apply N-ary function across children."""
3
+
4
+ from typing import Callable
5
+
6
+ import equinox as eqx
7
+
8
+ from ...params import ParamSuite
9
+ from ..base import BaseNode, BaseOpNode
10
+
11
+
12
+ # ──────────────────────────────────────────────────────────────────────────────
13
+ # MapNNode – elementwise map of N children (with optional parameters)
14
+ # ──────────────────────────────────────────────────────────────────────────────
15
+ class MapNNode(BaseOpNode):
16
+ """
17
+ Apply an arbitrary **element-wise** callable to *N* child bases.
18
+
19
+ Parameters
20
+ ----------
21
+ mapper : Callable
22
+ A function that will be called as::
23
+
24
+ mapper(*arrays[, theta]) -> array
25
+
26
+ The *arrays* (one per child) have **identical shapes**, including feature axis.
27
+ If a ``mapper_param_suite`` is supplied, a θ-dict with **only** those names
28
+ is appended as the *last positional argument*; otherwise no parameter payload
29
+ is passed to the mapper.
30
+
31
+ mapper_param_suite : ParamSuite | None
32
+ Parameter template **owned by the mapper itself** (not the children).
33
+
34
+ Contract rules
35
+ --------------
36
+ - Children must match in (rank, n_features). ``dim`` is unified if concrete.
37
+ - Particle depth follows broadcast rules: you may mix (pdepth=0, particles_input=False)
38
+ with (pdepth=1, True). The output uses the latter, and non-P children treat P as part
39
+ of the batch prefix.
40
+ - The mapper **must** return a tensor with the same last-axis size (``n_features``),
41
+ otherwise ``_assert_outputs`` will trigger.
42
+
43
+ Notes
44
+ -----
45
+ This node's full ``param_suite`` is the **shared-by-name** merge of
46
+ ``(children's suites) ∪ (mapper_param_suite)``.
47
+
48
+ Reusing the same name across children/mapper **ties** that parameter provided
49
+ the shape/dtype match exactly; incompatible duplicates raise an error.
50
+ """
51
+
52
+ CONTRACT_MODE = "map"
53
+
54
+ mapper: Callable = eqx.field(static=True)
55
+ mapper_param_suite: ParamSuite | None = eqx.field(static=True)
56
+ label_fn: Callable[[str], str] | None = eqx.field(static=True, default=None, repr=False)
57
+
58
+ # ------------------------------------------------------------------
59
+ def __init__(
60
+ self,
61
+ mapper: Callable,
62
+ *children: BaseNode,
63
+ mapper_param_suite: ParamSuite | None = None,
64
+ label_fn: Callable[[str], str] | None = None,
65
+ ):
66
+ object.__setattr__(self, "mapper", mapper)
67
+ object.__setattr__(self, "mapper_param_suite", mapper_param_suite)
68
+ object.__setattr__(self, "label_fn", label_fn)
69
+
70
+ # Build base composite first: this sets `param_suite` from children
71
+ super().__init__(*children)
72
+
73
+ # Now incorporate the mapper's own params using shared-by-name merge
74
+ # (allows parameter tying across children and mapper when specs match).
75
+ merged = ParamSuite.merge_many(self.param_suite, self.mapper_param_suite)
76
+ object.__setattr__(self, "param_suite", merged)
77
+
78
+ def with_children(self, new_children):
79
+ return MapNNode(
80
+ self.mapper,
81
+ *new_children,
82
+ mapper_param_suite=self.mapper_param_suite,
83
+ label_fn=self.label_fn,
84
+ )
85
+
86
+ # ------------------------------------------------------------------
87
+ def _op(self, outs, *, params):
88
+ """
89
+ Combine child outputs element-wise via `mapper`.
90
+
91
+ The `params` dict passed here is the full suite; we slice out only the
92
+ mapper's subset (if any) and append it as the final positional argument.
93
+ """
94
+ if self.mapper_param_suite:
95
+ theta = {n: params[n] for n in self.mapper_param_suite._lookup}
96
+ return self.mapper(*outs, theta)
97
+ return self.mapper(*outs)
98
+
99
+ # ------------------------------------------------------------------
100
+ # Feature-aware flatten (N children, element-wise map)
101
+ # ------------------------------------------------------------------
102
+ def flatten(self):
103
+ """
104
+ Build (funcs, labels, descs) where *n_features* matches children exactly.
105
+
106
+ - funcs[i](x, ...) := mapper( child0_i(x), child1_i(x), …, θ? )
107
+ - labels are produced by `label_fn` if given, otherwise
108
+ `"{mapper_name}(" + ",".join(child_labels) + ")"`.
109
+ - descs = tuple of children’s desc[i] (one per child) for traceability.
110
+ """
111
+ # ---- gather children ----
112
+ child_flat = [ch.flatten() for ch in self.children]
113
+ funcs_lists, labels_lists, descs_lists = zip(*child_flat)
114
+ n_feat = len(labels_lists[0]) # already validated in _merge_static
115
+
116
+ # --- per-feature callables -----------------------------------
117
+ def _slice_feature(j):
118
+ # j is captured as default arg to avoid late binding
119
+ return lambda x, *, v=None, mask=None, extras=None, params=None, _j=j: (
120
+ self(x, v=v, mask=mask, extras=extras, params=params)[..., _j]
121
+ )
122
+
123
+ funcs = tuple(_slice_feature(j) for j in range(n_feat))
124
+
125
+ # ---- labels ----
126
+ if self.label_fn is not None:
127
+ labels = tuple(self.label_fn(*(lablist[i] for lablist in labels_lists)) for i in range(n_feat))
128
+ else:
129
+ mname = self.mapper.__name__ if getattr(self.mapper, "__name__", "") else "map"
130
+ labels = tuple(f"{mname}(" + ",".join(lab_tuple) + ")" for lab_tuple in zip(*labels_lists))
131
+
132
+ # ---- descriptors ----
133
+ descs = [
134
+ tuple(dl[i] for dl in descs_lists) # one tuple per child
135
+ for i in range(n_feat)
136
+ ]
137
+
138
+ return funcs, labels, descs
@@ -0,0 +1,158 @@
1
+ # SFI/statefunc/nodes/ops/reshape_rank.py
2
+ """Lossless reshape between spatial (rank) axes and the feature axis."""
3
+
4
+ from functools import reduce
5
+ from operator import mul
6
+
7
+ import equinox as eqx
8
+ import jax.numpy as jnp
9
+
10
+ from ..base import BaseNode, BaseOpNode
11
+ from ..contract import Rank, _ContractMixin
12
+
13
+
14
+ def _rank_axis_sizes(node: _ContractMixin) -> tuple[int, ...]:
15
+ """Return per-axis sizes for rank axes: sdims if present, else (dim,)^rank."""
16
+ if node.sdims is not None:
17
+ return node.sdims
18
+ if node.dim is None:
19
+ raise ValueError("ReshapeRankNode requires known axis sizes (dim or sdims); got dim=None, sdims=None.")
20
+ return (node.dim,) * int(node.rank)
21
+
22
+
23
+ class ReshapeRankNode(BaseOpNode):
24
+ """Lossless reshape that moves data between rank (spatial) axes and features.
25
+
26
+ Given a child with output layout::
27
+
28
+ batch · (dim,)^source_rank · n_features
29
+
30
+ this node reshapes to::
31
+
32
+ batch · (dim,)^target_rank · n_features'
33
+
34
+ where ``n_features' = n_features x dim^(source_rank - target_rank)``
35
+ (folding when *target_rank < source_rank*) or
36
+ ``n_features' = n_features / dim^(target_rank - source_rank)``
37
+ (unfolding when *target_rank > source_rank*).
38
+
39
+ The reshape uses C-order so that folding followed by unfolding back
40
+ to the original rank is the identity — the pair is invertible.
41
+
42
+ Parameters
43
+ ----------
44
+ child : BaseNode
45
+ Source node (must have known ``dim``).
46
+ target_rank : int or Rank
47
+ Desired output rank.
48
+
49
+ Notes
50
+ -----
51
+ * Folding merges the *innermost* spatial axes into the feature axis.
52
+ * Unfolding splits the feature axis to create new *innermost* spatial axes.
53
+ * The child's ``dim`` must be known (not ``None``).
54
+ * For unfolding, ``n_features`` must be divisible by ``dim^Δrank``.
55
+ """
56
+
57
+ _target_rank: Rank = eqx.field(static=True)
58
+
59
+ def __init__(self, child: BaseNode, *, target_rank: int | Rank):
60
+ object.__setattr__(self, "_target_rank", Rank(int(target_rank)))
61
+
62
+ if child.dim is None and child.sdims is None:
63
+ raise ValueError("ReshapeRankNode requires a child with known dim or sdims; got dim=None, sdims=None.")
64
+ src = int(child.rank)
65
+ tgt = int(self._target_rank)
66
+
67
+ if tgt > src:
68
+ # Unfolding: need to split the feature axis. Determine how many
69
+ # elements need to be carved out. For uniform dim this is dim^(tgt-src).
70
+ # For sdims, the target rank axes beyond the source's sdims are unknown
71
+ # at this point — we require uniform dim for unfolding (sdims=None or
72
+ # all dims equal).
73
+ if child.sdims is not None:
74
+ raise ValueError(
75
+ "ReshapeRankNode: unfolding with non-uniform sdims is not "
76
+ "supported. Use explicit reshape or einsum instead."
77
+ )
78
+ assert child.dim is not None # guaranteed: sdims is None and guard on L62 ensures one is non-None
79
+ factor = child.dim ** (tgt - src)
80
+ if child.n_features % factor != 0:
81
+ raise ValueError(
82
+ f"Cannot unfold {child.n_features} features into rank-{tgt} "
83
+ f"with dim={child.dim}: {child.n_features} is not divisible "
84
+ f"by dim^{tgt - src} = {factor}."
85
+ )
86
+
87
+ super().__init__(child)
88
+
89
+ # ------------------------------------------------------------------
90
+ def _merge_static(self, children):
91
+ ch = children[0]
92
+ src = int(ch.rank)
93
+ tgt = int(self._target_rank)
94
+ axis_sizes = _rank_axis_sizes(ch)
95
+
96
+ if tgt < src:
97
+ # Folding: innermost (src − tgt) rank axes → features
98
+ factor = reduce(mul, axis_sizes[tgt:], 1)
99
+ n_out = ch.n_features * factor
100
+ out_sdims = axis_sizes[:tgt] if ch.sdims is not None else None
101
+ elif tgt > src:
102
+ # Unfolding: features → new innermost rank axes (uniform dim only)
103
+ dim = ch.dim
104
+ factor = dim ** (tgt - src)
105
+ n_out = ch.n_features // factor
106
+ # Uniform unfolding: new axes are all dim-sized
107
+ out_sdims = axis_sizes + (dim,) * (tgt - src) if ch.sdims is not None else None
108
+ else:
109
+ n_out = ch.n_features
110
+ out_sdims = ch.sdims
111
+
112
+ return _ContractMixin.inherit_contract(ch, rank=self._target_rank, n_features=n_out, sdims=out_sdims)
113
+
114
+ # ------------------------------------------------------------------
115
+ def _op(self, outs, *, params):
116
+ y = outs[0]
117
+ src = int(self.children[0].rank)
118
+ tgt = int(self._target_rank)
119
+
120
+ if tgt == src:
121
+ return y
122
+
123
+ if tgt < src:
124
+ # Fold innermost (src − tgt) spatial axes + feature axis into one.
125
+ n_fold = src - tgt
126
+ new_shape = y.shape[: -(n_fold + 1)] + (-1,)
127
+ return jnp.reshape(y, new_shape)
128
+ else:
129
+ # Unfold feature axis into (tgt − src) new spatial axes + features.
130
+ n_unfold = tgt - src
131
+ dim = self.children[0].dim
132
+ n_feat_out = self.n_features
133
+ new_shape = y.shape[:-1] + (dim,) * n_unfold + (n_feat_out,)
134
+ return jnp.reshape(y, new_shape)
135
+
136
+ # ------------------------------------------------------------------
137
+ def flatten(self):
138
+ child_funcs, child_labels, child_descs = self.children[0].flatten()
139
+
140
+ n_out = self.n_features
141
+
142
+ def _slice_feature(j):
143
+ return lambda x, *, v=None, mask=None, extras=None, params=None, _j=j: (
144
+ self(x, v=v, mask=mask, extras=extras, params=params)[..., _j]
145
+ )
146
+
147
+ src = int(self.children[0].rank)
148
+ tgt = int(self._target_rank)
149
+ tag = "fold" if tgt < src else "unfold"
150
+
151
+ funcs = [_slice_feature(j) for j in range(n_out)]
152
+ labels = [f"{tag}_{j}" for j in range(n_out)]
153
+ descs = [{f"{tag}_of": [d for d in child_descs], "index": j} for j in range(n_out)]
154
+ return funcs, labels, descs
155
+
156
+ # ------------------------------------------------------------------
157
+ def _tree_id(self):
158
+ return hash(("ReshapeRankNode", int(self._target_rank), self.children[0]._tree_id()))
@@ -0,0 +1,83 @@
1
+ # SFI/statefunc/nodes/ops/slice.py
2
+ """Slice features along the feature axis."""
3
+
4
+ from typing import Sequence
5
+
6
+ import equinox as eqx
7
+
8
+ from ..base import BaseNode, BaseOpNode
9
+ from ..contract import _ContractMixin
10
+
11
+
12
+ # ---------------------------------------------------------------------
13
+ # Helper node – slice the **feature axis**
14
+ # ---------------------------------------------------------------------
15
+ class SliceFeaturesNode(BaseOpNode):
16
+ """Select or reorder *feature* entries of a child basis.
17
+
18
+ The node simply indexes the last axis (feature axis) of its child
19
+ while preserving every spatial axis. Supported *idx* forms are
20
+
21
+ * ``int`` - keep a single feature → ``n_features = 1``
22
+ * ``slice`` - standard Python slice
23
+ * ``Sequence[int]`` - fancy indexing/ordering
24
+ * ``Sequence[bool]`` - boolean mask (length must equal ``n_features``)
25
+
26
+ Notes
27
+ -----
28
+ - Rank, dim, pdepth & needs_v are *identical* to the child - only
29
+ ``n_features`` changes.
30
+ - No parameters or extras are consumed, so the node is deterministic.
31
+ """
32
+
33
+ idx: int | slice | Sequence[int] | Sequence[bool] = eqx.field(static=True)
34
+
35
+ # ---------------- constructor ----------------
36
+ def __init__(self, child: BaseNode, idx):
37
+ object.__setattr__(self, "idx", idx)
38
+ super().__init__(child)
39
+ object.__setattr__(self, "param_suite", child.param_suite)
40
+
41
+ # ------------- static contract merge ----------
42
+ def _merge_static(self, children):
43
+ ch = children[0]
44
+ n_out: int
45
+ if isinstance(self.idx, int):
46
+ n_out = 1
47
+ elif isinstance(self.idx, slice):
48
+ n_out = len(range(*self.idx.indices(ch.n_features)))
49
+ else: # fancy or boolean indexing
50
+ n_out = len(self.idx)
51
+ return _ContractMixin.inherit_contract(ch, n_features=n_out)
52
+
53
+ # ---------------- combine ---------------------
54
+ def _op(self, outs, *, params):
55
+ y = outs[0]
56
+ if isinstance(self.idx, int):
57
+ # Keep the feature axis (size 1), including for negative indices.
58
+ return y[..., [self.idx]]
59
+ return y[..., self.idx]
60
+
61
+ # ---------------- flatten (label-aware) --------
62
+ def flatten(self):
63
+ """Select the labels/funcs/descriptors matching the sliced features."""
64
+ child_funcs, child_labels, child_descs = self.children[0].flatten()
65
+
66
+ # Resolve idx to a concrete list of integer indices
67
+ n_child = len(child_funcs)
68
+ if isinstance(self.idx, int):
69
+ indices = [self.idx % n_child]
70
+ elif isinstance(self.idx, slice):
71
+ indices = list(range(*self.idx.indices(n_child)))
72
+ else:
73
+ seq = list(self.idx)
74
+ if seq and isinstance(seq[0], bool):
75
+ # boolean mask
76
+ indices = [i for i, b in enumerate(seq) if b]
77
+ else:
78
+ indices = [int(i) % n_child for i in seq]
79
+
80
+ funcs = [child_funcs[i] for i in indices]
81
+ labels = [child_labels[i] for i in indices]
82
+ descs = [child_descs[i] for i in indices]
83
+ return funcs, labels, descs