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
SFI/integrate/api.py ADDED
@@ -0,0 +1,920 @@
1
+ """
2
+ Integration runtime: vmapped over time indices with dataset-owned producers.
3
+
4
+ Contract
5
+ --------
6
+ - `collection.peek_row(require=...)` returns a single-row sample mapping for
7
+ memory sizing.
8
+ - `collection.iter_slices(require=..., bytes_hint=..., chunk_target_bytes=..., subsampling=..., context=...)`
9
+ yields dictionaries with:
10
+
11
+ - "producer": Callable[[t], row] — JAX-traceable single-t builder,
12
+ - "t_idx": jax.Array[int32] — indices for this chunk,
13
+ - "weight": float — dataset-level weight in [0,1],
14
+ - "dataset_index": int — for bookkeeping.
15
+ - `program` implements:
16
+ - `require() -> set[str]` of streams (plus "extras" if needed),
17
+ - `estimate_bytes_per_sample(sample_row) -> Optional[int]`,
18
+ - `__call__(**streams)` for one time slice; for the parametric route
19
+ it additionally supports a keyword-only argument `params`.
20
+
21
+ This module provides:
22
+
23
+ - `integrate(...)`: one-off integration using an `Integrand` `program`
24
+ (backwards compatible front-end).
25
+ - `make_parametric_integrator(...)`: build a reusable, jittable integrator
26
+ for a parameterised `Integrand`, with a clear separation between host-side
27
+ planning and JAX-side runtime.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
34
+
35
+ import jax
36
+ import jax.numpy as jnp
37
+
38
+ Row = Mapping[str, Any]
39
+ Producer = Callable[[int], Row]
40
+ BatchProducer = Callable[[jnp.ndarray], Row]
41
+ RowEval = Callable[[Row, Any], jnp.ndarray]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ChunkSpec:
46
+ """One time-chunk (possibly padded) for a given dataset."""
47
+
48
+ dataset_index: int
49
+ weight: float
50
+ t_block: jnp.ndarray # shape (K_chunk,)
51
+ valid_block: jnp.ndarray # shape (K_chunk,), bool
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class IntegrationPlan:
56
+ """
57
+ Host-side integration plan.
58
+
59
+ Contains:
60
+ - producers: per-dataset single-t row builders,
61
+ - batch_producers: per-dataset batch-t row builders,
62
+ - chunks: padded time blocks with validity masks and weights,
63
+ - reduction semantics and memory hints.
64
+ """
65
+
66
+ producers: Dict[int, Producer]
67
+ batch_producers: Dict[int, BatchProducer]
68
+ chunks: Tuple[ChunkSpec, ...]
69
+ reduce: str
70
+ reduce_over_particles: bool
71
+ weight_by_dt: bool
72
+ bytes_hint: Optional[int]
73
+ K_fixed: Optional[int]
74
+ context: Optional[str]
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Planning
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def _build_plan(
83
+ collection,
84
+ program,
85
+ *,
86
+ reduce: str,
87
+ reduce_over_particles: bool,
88
+ weight_by_dt: bool = True,
89
+ subsampling: int,
90
+ chunk_target_bytes: int,
91
+ context: Optional[str],
92
+ bytes_per_sample: Optional[int] = None,
93
+ ) -> IntegrationPlan:
94
+ """
95
+ Plan an integration once on the host side.
96
+
97
+ Computes:
98
+ - required streams from `program.require()`,
99
+ - one real sample to estimate bytes per sample,
100
+ - a fixed chunk size K for stable vmapped kernels,
101
+ - the list of ChunkSpec and per-dataset producers.
102
+ """
103
+ # Required streams; include sentinel for dt window if datasets support it.
104
+ require = set(program.require())
105
+ require.add("__dt__") # dataset.valid_indices should treat as offsets (0,+1)
106
+
107
+ # Size hint from a real row
108
+ try:
109
+ sample_row = collection.peek_row(require=require, context=context)
110
+ except ValueError:
111
+ # ``peek_row`` raises ValueError both when no dataset has a usable time
112
+ # window (the legitimate empty-plan case) and when materialising a row
113
+ # genuinely fails (e.g. a malformed extra). Only the former should be
114
+ # swallowed into an empty plan — masking the latter turns an informative
115
+ # error into a cryptic downstream crash (a scalar Gram fed to swapaxes).
116
+ if any(ds.valid_indices(require).size > 0 for ds in collection.datasets):
117
+ raise
118
+ # No dataset has valid rows for these requirements
119
+ return IntegrationPlan(
120
+ producers={},
121
+ batch_producers={},
122
+ chunks=tuple(),
123
+ reduce=reduce,
124
+ reduce_over_particles=reduce_over_particles,
125
+ weight_by_dt=weight_by_dt,
126
+ bytes_hint=None,
127
+ K_fixed=None,
128
+ context=context,
129
+ )
130
+
131
+ if bytes_per_sample is not None:
132
+ bytes_hint = int(bytes_per_sample)
133
+ else:
134
+ estimator = getattr(program, "estimate_bytes_per_sample", None)
135
+ if callable(estimator):
136
+ bytes_hint = estimator(sample_row)
137
+ else:
138
+ bytes_hint = None
139
+
140
+ # Derive fixed K per chunk from hint
141
+ if not bytes_hint or bytes_hint <= 0:
142
+ K_fixed: Optional[int] = None
143
+ else:
144
+ # Conservative: at least one row
145
+ K_fixed = max(1, int(chunk_target_bytes // int(bytes_hint)))
146
+
147
+ producers: Dict[int, Producer] = {}
148
+ batch_producers: Dict[int, BatchProducer] = {}
149
+ chunks: list[ChunkSpec] = []
150
+
151
+ # Collect all payloads first so we can cap K_fixed at the actual data size.
152
+ # Without this cap, tiny datasets get absurdly padded (e.g. 19 valid indices
153
+ # padded to 6.4M when bytes_hint is small and chunk_target_bytes is large).
154
+ payloads = list(
155
+ collection.iter_slices(
156
+ require=require,
157
+ bytes_hint=bytes_hint,
158
+ chunk_target_bytes=chunk_target_bytes,
159
+ subsampling=subsampling,
160
+ context=context,
161
+ )
162
+ )
163
+
164
+ if K_fixed is not None and payloads:
165
+ max_payload = max(int(p["t_idx"].shape[0]) for p in payloads)
166
+ K_fixed = min(K_fixed, max_payload)
167
+
168
+ for payload in payloads:
169
+ ds_idx: int = int(payload["dataset_index"])
170
+ producer = payload["producer"]
171
+ t_idx = payload["t_idx"]
172
+ weight = float(payload.get("weight", 1.0))
173
+
174
+ if ds_idx not in producers:
175
+ producers[ds_idx] = producer
176
+ # Build the matching batch producer from the underlying dataset
177
+ ds = collection.datasets[ds_idx]
178
+ batch_producers[ds_idx] = ds.make_batch_producer(
179
+ require,
180
+ include_mask=True,
181
+ include_dt=True,
182
+ context=context,
183
+ force_dt_keys={"dt"},
184
+ dataset_index=collection.dataset_index(ds_idx),
185
+ )
186
+
187
+ if K_fixed is None:
188
+ # No padding, one chunk per payload
189
+ t_block = t_idx
190
+ valid_block = jnp.ones_like(t_idx, dtype=bool)
191
+ chunks.append(
192
+ ChunkSpec(
193
+ dataset_index=ds_idx,
194
+ weight=weight,
195
+ t_block=t_block,
196
+ valid_block=valid_block,
197
+ )
198
+ )
199
+ else:
200
+ # Split t_idx into blocks of size K_fixed and pad the last one
201
+ K_total = int(t_idx.shape[0])
202
+ for start in range(0, K_total, K_fixed):
203
+ stop = min(start + K_fixed, K_total)
204
+ cur = t_idx[start:stop]
205
+ K = int(cur.shape[0])
206
+ pad = K_fixed - K
207
+
208
+ if pad > 0:
209
+ pad_idx = jnp.pad(cur, (0, pad), mode="edge")
210
+ valid = jnp.concatenate([jnp.ones((K,), dtype=bool), jnp.zeros((pad,), dtype=bool)])
211
+ else:
212
+ pad_idx = cur
213
+ valid = jnp.ones((K_fixed,), dtype=bool)
214
+
215
+ chunks.append(
216
+ ChunkSpec(
217
+ dataset_index=ds_idx,
218
+ weight=weight,
219
+ t_block=pad_idx,
220
+ valid_block=valid,
221
+ )
222
+ )
223
+
224
+ return IntegrationPlan(
225
+ producers=producers,
226
+ batch_producers=batch_producers,
227
+ chunks=tuple(chunks),
228
+ reduce=reduce,
229
+ reduce_over_particles=reduce_over_particles,
230
+ weight_by_dt=weight_by_dt,
231
+ bytes_hint=bytes_hint,
232
+ K_fixed=K_fixed,
233
+ context=context,
234
+ )
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Core row kernel and runner (shared)
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ def _row_kernel(
243
+ row_eval: RowEval,
244
+ row: Row,
245
+ theta: Any,
246
+ *,
247
+ reduce_over_particles: bool,
248
+ weight_by_dt: bool = True,
249
+ ) -> Tuple[jnp.ndarray, jnp.ndarray]:
250
+ """
251
+ Single-row kernel.
252
+
253
+ ``row_eval(row, theta) -> y``, same semantics as
254
+ ``program(**row)`` in the non-parametric case.
255
+ Returns ``(weighted_value, dt_eff)`` where ``weighted_value``
256
+ already includes dt when *weight_by_dt* is True.
257
+ """
258
+ y = row_eval(row, theta)
259
+
260
+ # Optional particle reduction
261
+ if reduce_over_particles:
262
+ if y.ndim == 0:
263
+ raise ValueError("reduce_over_particles=True but row_eval returned a scalar.")
264
+ m = row.get("mask_out", None)
265
+ if m is not None:
266
+ if y.ndim == 0 or y.shape[0] != m.shape[0]:
267
+ raise ValueError(
268
+ "mask_out mismatch: row_eval must return an array with "
269
+ f"leading particle axis of size {m.shape[0]} when mask_out is present."
270
+ )
271
+ mexp = m.reshape((m.shape[0],) + (1,) * (y.ndim - 1))
272
+ y = jnp.where(mexp, y, 0.0)
273
+ y = jnp.sum(y, axis=0)
274
+
275
+ dt = row["dt"]
276
+ if weight_by_dt:
277
+ dteff = dt * row["N_active"]
278
+ y_w = y * dt
279
+ else:
280
+ dteff = row["N_active"]
281
+ y_w = y
282
+ return y_w, dteff
283
+
284
+
285
+ def _run_plan_core(
286
+ plan: IntegrationPlan,
287
+ row_eval: RowEval,
288
+ theta: Any,
289
+ ) -> Tuple[jnp.ndarray, jnp.ndarray]:
290
+ """
291
+ Core runtime: integrates according to a pre-built IntegrationPlan and a given row_eval.
292
+
293
+ Returns ``(acc, Teff_total)`` where:
294
+
295
+ - ``acc`` is the sum over chunks of ``y_w``,
296
+ - ``Teff_total`` is the sum over chunks of effective exposure.
297
+
298
+ Suitable for jitting when ``row_eval`` and ``theta`` are JAX-traceable.
299
+ """
300
+ if not plan.chunks:
301
+ zero = jnp.asarray(0.0, dtype=jnp.float32)
302
+ return zero, zero
303
+
304
+ reduce_over_particles = plan.reduce_over_particles
305
+ weight_by_dt = plan.weight_by_dt
306
+ producer_by_idx: Dict[int, Producer] = plan.producers
307
+
308
+ # Per-dataset weights are applied in every reduction (sum and mean) so the
309
+ # force Gram, diffusion average, and parametric Gram pool datasets the same
310
+ # way. Within-dataset weighting (per-dt vs per-point) is the caller's
311
+ # ``weight_by_dt``; the per-dataset multiplier is orthogonal.
312
+ use_weight = True # per-dataset weights applied in all reductions (unit weights => no-op)
313
+
314
+ acc = None
315
+ Teff_total = jnp.asarray(0.0, dtype=jnp.float32)
316
+
317
+ for chunk in plan.chunks:
318
+ producer = producer_by_idx[chunk.dataset_index]
319
+ t_block = chunk.t_block # (K,)
320
+ valid_block = chunk.valid_block # (K,)
321
+ weight = chunk.weight
322
+
323
+ def row_masked(t, is_valid, theta_):
324
+ row = producer(t)
325
+ y_w, dteff = _row_kernel(
326
+ row_eval,
327
+ row,
328
+ theta_,
329
+ reduce_over_particles=reduce_over_particles,
330
+ weight_by_dt=weight_by_dt,
331
+ )
332
+ assert y_w is not None
333
+ maskf = is_valid.astype(y_w.dtype)
334
+ if use_weight:
335
+ return (
336
+ y_w * maskf * weight,
337
+ dteff * is_valid.astype(dteff.dtype) * weight,
338
+ )
339
+ else:
340
+ return (
341
+ y_w * maskf,
342
+ dteff * is_valid.astype(dteff.dtype),
343
+ )
344
+
345
+ Ys, Dteffs = jax.vmap(row_masked, in_axes=(0, 0, None))(t_block, valid_block, theta)
346
+ y_sum = jnp.sum(Ys, axis=0)
347
+ dteff_sum = jnp.sum(Dteffs, axis=0)
348
+
349
+ Teff_total = Teff_total + dteff_sum
350
+ acc = y_sum if acc is None else (acc + y_sum)
351
+
352
+ return acc, Teff_total
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Batched runner: batch gather + batch statefunc + vmapped einsum
357
+ # ---------------------------------------------------------------------------
358
+
359
+ BatchRowEval = Callable[[Row, Any], jnp.ndarray]
360
+
361
+
362
+ def _run_plan_batched(
363
+ plan: IntegrationPlan,
364
+ batch_row_eval: BatchRowEval,
365
+ theta: Any,
366
+ ) -> Tuple[jnp.ndarray, jnp.ndarray]:
367
+ """
368
+ Batched runtime: gathers K rows at once and evaluates in batch.
369
+
370
+ Instead of vmapping over individual time indices (which nests vmaps
371
+ inside the state-expression leaves), this path:
372
+
373
+ 1. Gathers K rows with a single batch producer (one XLA gather),
374
+ 2. Passes ``(K, N, d)`` tensors to state expressions, which handle
375
+ the leading batch dimensions in a single fused vmap,
376
+ 3. vmaps the einsum contractions over the K axis.
377
+
378
+ The result shapes are ``(K, ...)`` where ``...`` is the per-row result
379
+ shape. Particle and time reductions happen outside the batch evaluator.
380
+
381
+ Returns ``(acc, Teff_total)`` with the same semantics as
382
+ :func:`_run_plan_core`.
383
+ """
384
+ if not plan.chunks:
385
+ zero = jnp.asarray(0.0, dtype=jnp.float32)
386
+ return zero, zero
387
+
388
+ reduce_over_particles = plan.reduce_over_particles
389
+ batch_producer_by_idx: Dict[int, BatchProducer] = plan.batch_producers
390
+
391
+ use_weight = True # per-dataset weights applied in all reductions (unit weights => no-op)
392
+
393
+ acc = None
394
+ Teff_total = jnp.asarray(0.0, dtype=jnp.float32)
395
+
396
+ for chunk in plan.chunks:
397
+ batch_producer = batch_producer_by_idx[chunk.dataset_index]
398
+ t_block = chunk.t_block # (K,)
399
+ valid_block = chunk.valid_block # (K,), bool
400
+ weight = chunk.weight
401
+
402
+ # 1) Batch gather — one XLA gather per stream
403
+ batch_row = batch_producer(t_block)
404
+ # {X: (K,N,d), dX: (K,N,d), dt: (K,), mask_out: (K,N), ...}
405
+
406
+ # 2) Batch evaluate — statefunc sees (K,N,d), einsum vmapped over K
407
+ y = batch_row_eval(batch_row, theta)
408
+ # y shape: (K, N, ..., F) if particle axis is present,
409
+ # (K, ..., F) if the einsum already contracted particles.
410
+
411
+ # 3) Particle reduction over axis 1
412
+ if reduce_over_particles:
413
+ if y.ndim < 2:
414
+ raise ValueError(
415
+ "reduce_over_particles=True but batch_row_eval returned an array with fewer than 2 dimensions."
416
+ )
417
+ m = batch_row.get("mask_out", None) # (K, N)
418
+ if m is not None:
419
+ # Expand mask to broadcast: (K, N, 1, ..., 1)
420
+ n_trail = y.ndim - 2 # dims after particle axis
421
+ mexp = m.reshape(m.shape + (1,) * n_trail)
422
+ y = jnp.where(mexp, y, 0.0)
423
+ y = jnp.sum(y, axis=1) # (K, ..., F)
424
+
425
+ # 4) dt weighting
426
+ dt = batch_row["dt"] # (K,)
427
+ if plan.weight_by_dt:
428
+ dteff = dt * batch_row["N_active"] # (K,)
429
+ dt_exp = dt.reshape((dt.shape[0],) + (1,) * (y.ndim - 1))
430
+ y_w = y * dt_exp # (K, ..., F)
431
+ else:
432
+ dteff = batch_row["N_active"] # (K,)
433
+ y_w = y
434
+
435
+ # 5) Valid masking
436
+ valid_f = valid_block.astype(y_w.dtype)
437
+ valid_exp = valid_f.reshape((valid_f.shape[0],) + (1,) * (y_w.ndim - 1))
438
+ y_w = y_w * valid_exp
439
+ dteff = dteff * valid_block.astype(dteff.dtype)
440
+
441
+ if use_weight:
442
+ y_w = y_w * weight
443
+ dteff = dteff * weight
444
+
445
+ # 6) Sum over K
446
+ y_sum = jnp.sum(y_w, axis=0)
447
+ dteff_sum = jnp.sum(dteff, axis=0)
448
+
449
+ Teff_total = Teff_total + dteff_sum
450
+ acc = y_sum if acc is None else (acc + y_sum)
451
+
452
+ return acc, Teff_total
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Public API: integrate (non-parametric)
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ def _has_time_varying_required_extras(collection, program) -> bool:
461
+ """True when the program reads an extras key that is time-varying.
462
+
463
+ The batched runtime gathers extras once per chunk
464
+ (``make_batch_producer`` collects them at the chunk's first index),
465
+ which is only correct for static extras. Programs that read
466
+ :class:`TimeSeriesExtra` values or plain time-generator callables must
467
+ run on the per-``t`` core runtime, where ``build_extras(t)`` slices
468
+ them per frame.
469
+ """
470
+ req = getattr(program, "required_extras", None)
471
+ keys: tuple = tuple(req() or ()) if callable(req) else ()
472
+ if not keys:
473
+ return False
474
+ # The reserved ``time`` extra (auto-injected per frame by
475
+ # ``build_extras``) is inherently time-varying, so any program that
476
+ # reads it — e.g. a :func:`~SFI.bases.time_fourier` dictionary — must
477
+ # run on the per-``t`` core runtime even though it is not stored as a
478
+ # TimeSeriesExtra on the dataset.
479
+ if "time" in keys:
480
+ return True
481
+ from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra
482
+
483
+ for ds in collection.datasets:
484
+ for src in (ds.extras_global, ds.extras_local):
485
+ for k in keys:
486
+ v = (src or {}).get(k)
487
+ if isinstance(v, TimeSeriesExtra) or (callable(v) and not isinstance(v, FunctionExtra)):
488
+ return True
489
+ return False
490
+
491
+
492
+ def integrate(
493
+ collection,
494
+ program,
495
+ *,
496
+ reduce: str = "sum", # {'sum','mean'}
497
+ reduce_over_particles: bool = True, # sum over leading i if present
498
+ weight_by_dt: bool = True,
499
+ subsampling: int = 1,
500
+ chunk_target_bytes: int = 512 * 1024**2,
501
+ context: Optional[str] = None,
502
+ batch: bool = True,
503
+ ) -> jnp.ndarray:
504
+ """
505
+ Integrate an instantaneous program over time and datasets.
506
+
507
+ Parameters
508
+ ----------
509
+ collection
510
+ TrajectoryCollection exposing producers and time-index chunks.
511
+ program
512
+ Integrand object with `require`, `estimate_bytes_per_sample`, and `__call__`.
513
+ reduce : {'sum','mean'}
514
+ Dataset-and-time reduction. `'mean'` divides by the accumulated
515
+ effective exposure computed from the same `dt` used in the numerator.
516
+ reduce_over_particles : bool
517
+ If the program output has a leading particle axis, apply `mask_out`,
518
+ then sum that axis before the time reduction.
519
+ weight_by_dt : bool
520
+ If True (default), multiply each program output by ``dt`` before
521
+ accumulation. Set to False for programs whose output should be
522
+ summed without dt weighting (e.g. parametric Gram matrices).
523
+ subsampling : int
524
+ Keep indices with `t % subsampling == 0`.
525
+ chunk_target_bytes : int
526
+ Target working-set size for the vmapped kernel.
527
+ context : str, optional
528
+ Forwarded to dataset extras via producers.
529
+
530
+ Returns
531
+ -------
532
+ jax.Array
533
+ Reduced value with particle axis removed if requested. Shapes match the
534
+ program’s output after optional particle reduction.
535
+ """
536
+ if reduce not in {"sum", "mean"}:
537
+ raise ValueError("reduce must be 'sum' or 'mean'")
538
+
539
+ # Time-varying extras must be sliced per frame: only the per-t core
540
+ # runtime does that (the batch producer gathers extras once per chunk).
541
+ if batch and _has_time_varying_required_extras(collection, program):
542
+ batch = False
543
+
544
+ plan = _build_plan(
545
+ collection,
546
+ program,
547
+ reduce=reduce,
548
+ reduce_over_particles=reduce_over_particles,
549
+ weight_by_dt=weight_by_dt,
550
+ subsampling=subsampling,
551
+ chunk_target_bytes=chunk_target_bytes,
552
+ context=context,
553
+ )
554
+
555
+ if batch:
556
+ batch_row_eval = _build_batch_row_eval(program, context=context, parametric=False)
557
+ acc, Teff_total = _run_plan_batched(plan, batch_row_eval, theta=None)
558
+ else:
559
+ # Original vmap-over-t path
560
+ def row_eval(row: Row, _theta: Any) -> jnp.ndarray:
561
+ return program(**row)
562
+
563
+ acc, Teff_total = _run_plan_core(plan, row_eval, theta=None)
564
+
565
+ if reduce == "sum":
566
+ return acc
567
+
568
+ # mean: check Teff_total on host for backwards-compatible error behaviour
569
+ Teff_val = float(Teff_total)
570
+ if Teff_val <= 0.0:
571
+ raise ValueError("Mean reduction requested but total exposure is non-positive.")
572
+ return acc / jnp.asarray(Teff_total, dtype=acc.dtype)
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Public API: parametric integrator
577
+ # ---------------------------------------------------------------------------
578
+
579
+
580
+ def make_parametric_integrator(
581
+ collection,
582
+ program,
583
+ *,
584
+ reduce: str = "sum",
585
+ reduce_over_particles: bool = True,
586
+ weight_by_dt: bool = True,
587
+ subsampling: int = 1,
588
+ chunk_target_bytes: int = 512 * 1024**2,
589
+ context: Optional[str] = None,
590
+ bytes_per_sample: Optional[int] = None,
591
+ batch: bool = True,
592
+ ) -> Tuple[IntegrationPlan, Callable[[Any], jnp.ndarray]]:
593
+ """
594
+ Build a reusable, jittable integrator for a parametric Integrand.
595
+
596
+ Parameters
597
+ ----------
598
+ collection
599
+ TrajectoryCollection exposing producers and time-index chunks.
600
+ program
601
+ Integrand object with `require`, `estimate_bytes_per_sample`, and
602
+ a call signature ``program(**streams, params=theta)`` where `theta`
603
+ is a PyTree of parameters.
604
+ reduce, reduce_over_particles, weight_by_dt, subsampling, chunk_target_bytes, context
605
+ Same meaning as in :func:`integrate`.
606
+ bytes_per_sample : int, optional
607
+ Optional override for the per-sample memory estimate. If None, the
608
+ program's `estimate_bytes_per_sample` is used.
609
+ batch : bool
610
+ If True, use the batched integration path (see :func:`integrate`).
611
+
612
+ Returns
613
+ -------
614
+ plan : IntegrationPlan
615
+ Host-side plan describing the chunks and producers.
616
+ run : callable
617
+ JAX-jitted function ``run(theta) -> value`` that evaluates the
618
+ integration for a given set of parameters.
619
+ """
620
+ if reduce not in {"sum", "mean"}:
621
+ raise ValueError("reduce must be 'sum' or 'mean'")
622
+
623
+ # Same correctness rule as `integrate`: time-varying extras require the
624
+ # per-t core runtime.
625
+ if batch and _has_time_varying_required_extras(collection, program):
626
+ batch = False
627
+
628
+ plan = _build_plan(
629
+ collection,
630
+ program,
631
+ reduce=reduce,
632
+ reduce_over_particles=reduce_over_particles,
633
+ weight_by_dt=weight_by_dt,
634
+ subsampling=subsampling,
635
+ chunk_target_bytes=chunk_target_bytes,
636
+ context=context,
637
+ bytes_per_sample=bytes_per_sample,
638
+ )
639
+
640
+ if not plan.chunks:
641
+ # Empty-plan edge case: always return 0.0
642
+ @jax.jit
643
+ def run_empty(theta):
644
+ del theta
645
+ return jnp.asarray(0.0, dtype=jnp.float32)
646
+
647
+ return plan, run_empty
648
+
649
+ if batch:
650
+ batch_row_eval = _build_batch_row_eval(program, context=context)
651
+
652
+ def run(theta):
653
+ acc, Teff_total = _run_plan_batched(plan, batch_row_eval, theta)
654
+ if reduce == "sum":
655
+ return acc
656
+ Teff_safe = jnp.where(Teff_total > 0, Teff_total, jnp.ones_like(Teff_total))
657
+ return acc / Teff_safe.astype(acc.dtype)
658
+ else:
659
+ # Original vmap-over-t path
660
+ def row_eval(row: Row, theta: Any) -> jnp.ndarray:
661
+ return program(params=theta, **row)
662
+
663
+ def run(theta):
664
+ acc, Teff_total = _run_plan_core(plan, row_eval, theta)
665
+ if reduce == "sum":
666
+ return acc
667
+ Teff_safe = jnp.where(Teff_total > 0, Teff_total, jnp.ones_like(Teff_total))
668
+ return acc / Teff_safe.astype(acc.dtype)
669
+
670
+ return plan, run
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # Mini-batch parametric integrator
675
+ # ---------------------------------------------------------------------------
676
+
677
+
678
+ def _build_batch_row_eval(program, context=None, *, parametric: bool = True):
679
+ """Build a JIT-compiled batch row evaluator.
680
+
681
+ Parameters
682
+ ----------
683
+ program
684
+ Integrand or duck-typed program with ``require`` / ``__call__``.
685
+ context : str, optional
686
+ Forwarded to extras as a static constant.
687
+ parametric : bool
688
+ If True (default), forward ``theta`` as ``params=theta`` on every
689
+ call. Set False for non-parametric programs whose ``__call__``
690
+ does not accept a ``params`` keyword.
691
+
692
+ Returns
693
+ -------
694
+ batch_row_eval : callable
695
+ ``batch_row_eval(row, theta) -> jnp.ndarray``.
696
+ """
697
+ _base_static: Dict[str, Any] = {}
698
+ if context is not None:
699
+ _base_static["context"] = context
700
+
701
+ def _split_extras(row: Row):
702
+ ext = row.get("extras")
703
+ if ext is None or not isinstance(ext, dict):
704
+ return row, {}, ()
705
+ arr_ext: Dict[str, Any] = {}
706
+ fn_ext: Dict[str, Any] = {}
707
+ for k, v in ext.items():
708
+ if hasattr(v, "shape") and hasattr(v, "dtype"):
709
+ arr_ext[k] = v
710
+ elif callable(v):
711
+ fn_ext[k] = v
712
+ elif isinstance(v, (str, type(None), bool, int, float)):
713
+ fn_ext[k] = v
714
+ else:
715
+ arr_ext[k] = v
716
+ if not fn_ext:
717
+ return row, {}, ()
718
+ row = dict(row)
719
+ row["extras"] = arr_ext if arr_ext else {}
720
+ cache_key = tuple(sorted((k, id(v)) for k, v in fn_ext.items()))
721
+ return row, fn_ext, cache_key
722
+
723
+ _jit_cache: Dict[tuple, Any] = {}
724
+
725
+ def _make_jit_fn(fn_extras: Dict[str, Any]):
726
+ static = {**_base_static, **fn_extras}
727
+ if hasattr(program, "batch_call"):
728
+
729
+ @jax.jit
730
+ def jit_fn(row: Row, theta: Any) -> jnp.ndarray:
731
+ if static:
732
+ ext = dict(row.get("extras", {}))
733
+ ext.update(static)
734
+ row = {**row, "extras": ext}
735
+ if parametric:
736
+ return program.batch_call(params=theta, **row)
737
+ return program.batch_call(**row)
738
+ else:
739
+
740
+ @jax.jit
741
+ def jit_fn(row: Row, theta: Any) -> jnp.ndarray:
742
+ if static:
743
+ ext = dict(row.get("extras", {}))
744
+ ext.update(static)
745
+ row = {**row, "extras": ext}
746
+ batched = {k: v for k, v in row.items() if hasattr(v, "ndim") and v.ndim >= 1}
747
+ scalars = {k: v for k, v in row.items() if not hasattr(v, "ndim") or v.ndim < 1}
748
+
749
+ def _call(b):
750
+ if parametric:
751
+ return program(params=theta, **{**b, **scalars})
752
+ return program(**{**b, **scalars})
753
+
754
+ return jax.vmap(_call)(batched)
755
+
756
+ return jit_fn
757
+
758
+ _default_jit = _make_jit_fn({})
759
+
760
+ def batch_row_eval(row: Row, theta: Any) -> jnp.ndarray:
761
+ stripped, fn_extras, cache_key = _split_extras(row)
762
+ if not cache_key:
763
+ return _default_jit(stripped, theta)
764
+ if cache_key not in _jit_cache:
765
+ _jit_cache[cache_key] = _make_jit_fn(fn_extras)
766
+ return _jit_cache[cache_key](stripped, theta)
767
+
768
+ return batch_row_eval
769
+
770
+
771
+ # [new code — parametric update] minibatch infrastructure below
772
+ def _build_minibatch_runner(
773
+ plan: IntegrationPlan,
774
+ program,
775
+ *,
776
+ batch_size: int,
777
+ context: Optional[str] = None,
778
+ ):
779
+ """Build a stochastic mini-batch evaluator from an existing plan.
780
+
781
+ Parameters
782
+ ----------
783
+ plan : IntegrationPlan
784
+ A plan already built by ``_build_plan`` / ``make_parametric_integrator``.
785
+ program : Integrand
786
+ The same program used to build *plan*.
787
+ batch_size : int
788
+ Number of time indices to sample per evaluation.
789
+ context : str, optional
790
+ Forwarded to extras.
791
+
792
+ Returns
793
+ -------
794
+ run_batch : callable
795
+ ``run_batch(theta, rng_key) -> scalar``. An unbiased estimator
796
+ of the full-data loss (with ``reduce="sum"`` semantics).
797
+ """
798
+ if not plan.chunks:
799
+
800
+ def run_batch_empty(theta, rng_key):
801
+ del theta, rng_key
802
+ return jnp.asarray(0.0, dtype=jnp.float32)
803
+
804
+ return run_batch_empty
805
+
806
+ reduce_over_particles = plan.reduce_over_particles
807
+ batch_row_eval = _build_batch_row_eval(program, context=context)
808
+
809
+ # Pool valid indices per dataset from the plan.
810
+ ds_indices: Dict[int, jnp.ndarray] = {}
811
+ for chunk in plan.chunks:
812
+ ds_idx = chunk.dataset_index
813
+ valid = chunk.t_block[chunk.valid_block]
814
+ if ds_idx in ds_indices:
815
+ ds_indices[ds_idx] = jnp.concatenate([ds_indices[ds_idx], valid])
816
+ else:
817
+ ds_indices[ds_idx] = valid
818
+
819
+ # Pre-compute per-dataset batch sizes (proportional allocation).
820
+ total_valid = sum(int(idx.shape[0]) for idx in ds_indices.values())
821
+ ds_batch_info = [] # list of (ds_idx, all_idx, n_batch)
822
+ for ds_idx, all_idx in ds_indices.items():
823
+ n_ds = int(all_idx.shape[0])
824
+ n_batch = max(1, min(n_ds, round(batch_size * n_ds / total_valid)))
825
+ ds_batch_info.append((ds_idx, all_idx, n_ds, n_batch))
826
+
827
+ def run_batch(theta, rng_key):
828
+ acc = jnp.asarray(0.0, dtype=jnp.float32)
829
+
830
+ for ds_idx, all_idx, n_ds, n_batch in ds_batch_info:
831
+ rng_key, subkey = jax.random.split(rng_key)
832
+ # Sample without replacement
833
+ perm = jax.random.permutation(subkey, n_ds)[:n_batch]
834
+ sampled = all_idx[perm] # (n_batch,)
835
+
836
+ batch_producer = plan.batch_producers[ds_idx]
837
+ batch_row = batch_producer(sampled)
838
+
839
+ y = batch_row_eval(batch_row, theta)
840
+ # y shape: (n_batch, N, ...) or (n_batch, ...)
841
+
842
+ if reduce_over_particles:
843
+ if y.ndim < 2:
844
+ raise ValueError("reduce_over_particles=True but batch_row_eval returned < 2 dimensions.")
845
+ m = batch_row.get("mask_out", None)
846
+ if m is not None:
847
+ n_trail = y.ndim - 2
848
+ mexp = m.reshape(m.shape + (1,) * n_trail)
849
+ y = jnp.where(mexp, y, 0.0)
850
+ y = jnp.sum(y, axis=1)
851
+
852
+ # dt weighting
853
+ if plan.weight_by_dt:
854
+ dt = batch_row["dt"]
855
+ dt_exp = dt.reshape((dt.shape[0],) + (1,) * (y.ndim - 1))
856
+ y_w = y * dt_exp
857
+ else:
858
+ y_w = y
859
+
860
+ # Sum over batch and scale to be unbiased
861
+ y_sum = jnp.sum(y_w, axis=0)
862
+ scale = jnp.asarray(n_ds / n_batch, dtype=y_sum.dtype)
863
+ acc = acc + y_sum * scale
864
+
865
+ return acc
866
+
867
+ return run_batch
868
+
869
+
870
+ def make_minibatch_parametric_integrator(
871
+ collection,
872
+ program,
873
+ *,
874
+ batch_size: int,
875
+ reduce: str = "sum",
876
+ reduce_over_particles: bool = True,
877
+ weight_by_dt: bool = True,
878
+ subsampling: int = 1,
879
+ chunk_target_bytes: int = 512 * 1024**2,
880
+ context: Optional[str] = None,
881
+ bytes_per_sample: Optional[int] = None,
882
+ batch: bool = True,
883
+ ) -> Tuple[IntegrationPlan, Callable, Callable]:
884
+ """Build a parametric integrator with both full and mini-batch runners.
885
+
886
+ Parameters
887
+ ----------
888
+ collection, program, reduce, reduce_over_particles, weight_by_dt, subsampling, chunk_target_bytes, context, bytes_per_sample, batch
889
+ Same as :func:`make_parametric_integrator`.
890
+ batch_size : int
891
+ Number of time indices to sample per mini-batch evaluation.
892
+
893
+ Returns
894
+ -------
895
+ plan : IntegrationPlan
896
+ run_full : callable
897
+ ``run_full(theta) -> scalar`` — full-data evaluator.
898
+ run_batch : callable
899
+ ``run_batch(theta, rng_key) -> scalar`` — stochastic mini-batch
900
+ evaluator. Unbiased estimator of the full-data value.
901
+ """
902
+ plan, run_full = make_parametric_integrator(
903
+ collection,
904
+ program,
905
+ reduce=reduce,
906
+ reduce_over_particles=reduce_over_particles,
907
+ weight_by_dt=weight_by_dt,
908
+ subsampling=subsampling,
909
+ chunk_target_bytes=chunk_target_bytes,
910
+ context=context,
911
+ bytes_per_sample=bytes_per_sample,
912
+ batch=batch,
913
+ )
914
+ run_batch = _build_minibatch_runner(
915
+ plan,
916
+ program,
917
+ batch_size=batch_size,
918
+ context=context,
919
+ )
920
+ return plan, run_full, run_batch