op-engine 0.3.0__tar.gz → 0.4.0__tar.gz

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 (110) hide show
  1. {op_engine-0.3.0 → op_engine-0.4.0}/CHANGELOG.md +59 -0
  2. {op_engine-0.3.0 → op_engine-0.4.0}/PKG-INFO +30 -8
  3. {op_engine-0.3.0 → op_engine-0.4.0}/README.md +29 -7
  4. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/backends.md +3 -0
  5. op_engine-0.4.0/docs/guides/bounded-thinning.md +112 -0
  6. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/solver-methods.md +106 -6
  7. op_engine-0.4.0/flepimop2-op_engine/README.md +745 -0
  8. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/benchmarks/README.md +4 -2
  9. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/benchmarks/prepared_execution.py +78 -19
  10. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/pyproject.toml +8 -8
  11. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py +330 -31
  12. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/config.py +48 -1
  13. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/reactions.py +185 -34
  14. op_engine-0.4.0/flepimop2-op_engine/tests/test_adaptive_tau_generated.py +155 -0
  15. op_engine-0.4.0/flepimop2-op_engine/tests/test_axis_less_reactions.py +210 -0
  16. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_config.py +123 -0
  17. op_engine-0.4.0/flepimop2-op_engine/tests/test_coord_shift.py +335 -0
  18. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_engine.py +10 -2
  19. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_op_system_integration.py +488 -20
  20. op_engine-0.4.0/flepimop2-op_engine/tests/test_renewal.py +463 -0
  21. op_engine-0.4.0/flepimop2-op_engine/tests/test_routed_reactions.py +257 -0
  22. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_solver_matrix_benchmark.py +36 -1
  23. op_engine-0.4.0/flepimop2-op_engine/tests/test_stochastic_coverage.py +198 -0
  24. op_engine-0.4.0/flepimop2-op_engine/tests/test_thinning.py +515 -0
  25. {op_engine-0.3.0 → op_engine-0.4.0}/mkdocs.yml +2 -0
  26. {op_engine-0.3.0 → op_engine-0.4.0}/pyproject.toml +1 -1
  27. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/__init__.py +16 -0
  28. op_engine-0.4.0/src/op_engine/_forcing.py +53 -0
  29. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/adaptive_tau.py +29 -4
  30. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/stochastic_solver.py +84 -26
  31. op_engine-0.4.0/src/op_engine/thinning_ssa.py +443 -0
  32. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_adaptive_tau.py +207 -0
  33. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_direct_ssa.py +262 -3
  34. op_engine-0.4.0/tests/op_engine/test_tau_forcing.py +247 -0
  35. op_engine-0.4.0/tests/op_engine/test_thinning_ssa.py +509 -0
  36. op_engine-0.3.0/flepimop2-op_engine/README.md +0 -381
  37. {op_engine-0.3.0 → op_engine-0.4.0}/.github/workflows/ci.yaml +0 -0
  38. {op_engine-0.3.0 → op_engine-0.4.0}/.github/workflows/gh-pages.yaml +0 -0
  39. {op_engine-0.3.0 → op_engine-0.4.0}/.github/workflows/release.yaml +0 -0
  40. {op_engine-0.3.0 → op_engine-0.4.0}/.gitignore +0 -0
  41. {op_engine-0.3.0 → op_engine-0.4.0}/LICENSE +0 -0
  42. {op_engine-0.3.0 → op_engine-0.4.0}/docs/api-reference/.gitignore +0 -0
  43. {op_engine-0.3.0 → op_engine-0.4.0}/docs/assets/chemostat-split.svg +0 -0
  44. {op_engine-0.3.0 → op_engine-0.4.0}/docs/assets/chemostat-summary.png +0 -0
  45. {op_engine-0.3.0 → op_engine-0.4.0}/docs/development/creating-a-release.md +0 -0
  46. {op_engine-0.3.0 → op_engine-0.4.0}/docs/development/fem-assembly-boundary.md +0 -0
  47. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/biogeochemical-network.md +0 -0
  48. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/chemostat-pde.md +0 -0
  49. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/getting-started.md +0 -0
  50. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/linear-multistep-methods.md +0 -0
  51. {op_engine-0.3.0 → op_engine-0.4.0}/docs/guides/nonlinear-solvers.md +0 -0
  52. {op_engine-0.3.0 → op_engine-0.4.0}/docs/index.md +0 -0
  53. {op_engine-0.3.0 → op_engine-0.4.0}/examples/__init__.py +0 -0
  54. {op_engine-0.3.0 → op_engine-0.4.0}/examples/biogeochemical_network.py +0 -0
  55. {op_engine-0.3.0 → op_engine-0.4.0}/examples/chemostat_pde.py +0 -0
  56. {op_engine-0.3.0 → op_engine-0.4.0}/examples/fem_diffusion.py +0 -0
  57. {op_engine-0.3.0 → op_engine-0.4.0}/examples/simple_sir.py +0 -0
  58. {op_engine-0.3.0 → op_engine-0.4.0}/examples/test_biogeochemical_network.py +0 -0
  59. {op_engine-0.3.0 → op_engine-0.4.0}/examples/test_chemostat_pde.py +0 -0
  60. {op_engine-0.3.0 → op_engine-0.4.0}/examples/test_fem_diffusion.py +0 -0
  61. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/LICENSE +0 -0
  62. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/benchmarks/__init__.py +0 -0
  63. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/benchmarks/adaptive_replay.py +0 -0
  64. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/benchmarks/solver_matrix.py +0 -0
  65. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/benchmarks/structured_blocks.py +0 -0
  66. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/explicit_operators.py +0 -0
  67. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/operators.py +0 -0
  68. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/py.typed +0 -0
  69. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/conftest.py +0 -0
  70. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/data/config.yaml +0 -0
  71. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/data/sir.py +0 -0
  72. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_integration_cli.py +0 -0
  73. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_operators.py +0 -0
  74. {op_engine-0.3.0 → op_engine-0.4.0}/flepimop2-op_engine/tests/test_reactions.py +0 -0
  75. {op_engine-0.3.0 → op_engine-0.4.0}/justfile +0 -0
  76. {op_engine-0.3.0 → op_engine-0.4.0}/overrides/main.html +0 -0
  77. {op_engine-0.3.0 → op_engine-0.4.0}/scripts/__init__.py +0 -0
  78. {op_engine-0.3.0 → op_engine-0.4.0}/scripts/api-reference.py +0 -0
  79. {op_engine-0.3.0 → op_engine-0.4.0}/scripts/release_validate.py +0 -0
  80. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_additive_runge_kutta.py +0 -0
  81. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_array.py +0 -0
  82. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_multistep.py +0 -0
  83. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_rosenbrock.py +0 -0
  84. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_runge_kutta.py +0 -0
  85. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_sdirk.py +0 -0
  86. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/_typing.py +0 -0
  87. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/core_solver.py +0 -0
  88. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/matrix_ops.py +0 -0
  89. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/model_core.py +0 -0
  90. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/nonlinear_solver.py +0 -0
  91. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/py.typed +0 -0
  92. {op_engine-0.3.0 → op_engine-0.4.0}/src/op_engine/spatial_grid.py +0 -0
  93. {op_engine-0.3.0 → op_engine-0.4.0}/tests/conftest.py +0 -0
  94. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test__version__.py +0 -0
  95. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_additive_runge_kutta.py +0 -0
  96. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_array_namespace.py +0 -0
  97. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_configuration.py +0 -0
  98. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_core_solver_accuracy.py +0 -0
  99. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_core_solver_ark.py +0 -0
  100. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_core_solver_plumbing.py +0 -0
  101. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_core_solver_sdirk.py +0 -0
  102. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_matrix_ops.py +0 -0
  103. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_model_core.py +0 -0
  104. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_multistep.py +0 -0
  105. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_nonlinear_solver.py +0 -0
  106. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_rosenbrock.py +0 -0
  107. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_runge_kutta.py +0 -0
  108. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_sdirk.py +0 -0
  109. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_spatial_grid.py +0 -0
  110. {op_engine-0.3.0 → op_engine-0.4.0}/tests/op_engine/test_stochastic_solver.py +0 -0
@@ -5,6 +5,65 @@ versioning while the project remains pre-1.0.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-10-02
9
+
10
+ ### Added
11
+
12
+ - Prepared Flepimop2 execution plans now support explicit PyTree and JAX block
13
+ layouts for fixed steps and frozen adaptive replay, including structured typed
14
+ operators, while preserving dynamic-value cache contracts (#168).
15
+ - Direct SSA accepts explicit forcing breakpoints for exact piecewise-constant
16
+ rates, including dormant zero-rate intervals, while retaining pending events
17
+ across observation times. The provider exposes this for pure direct SSA (#171).
18
+ - Fixed and adaptive tau-leaping stop at declared forcing changes, so each
19
+ leap segment uses its own starting rate; forcing boundaries take precedence
20
+ over tied critical or exact-fallback events (#174).
21
+ - Pure stochastic runs consume the producer's declared `forcing_breakpoints`
22
+ (op_system `time_interpolation: previous`), combined with explicit engine
23
+ boundaries. Hybrid mode reports producer forcing as unsupported (#175).
24
+ - `stochastic_method: thinning-ssa` gives exact bounded thinning for smoothly
25
+ time-varying rates. Supply a total-rate bound as `thinning_rate_bound` or a
26
+ `RateBoundFunction` through `run(..., rate_bound=...)`; `ThinningSSASolver`
27
+ is available in the core package (#173, #176, #177).
28
+ - Stochastic execution consumes axis-wide `coord_shift` reactions from
29
+ op_system: each source bin of an `offsets` axis becomes a channel that moves
30
+ one unit by the published step, and off-axis destinations only deplete the
31
+ donor. Direct SSA reproduces Poisson bin occupancy and Erlang exit times for
32
+ pure aging chains (#180).
33
+ - Pure stochastic validation and execution reject models with dynamics that
34
+ have no reaction artifact: transitions listed in the producer's
35
+ `reaction_gaps` coverage records, and typed operators. Each uncovered
36
+ transition is named by origin, selectors, and reason. Hybrid mode and
37
+ producers without coverage records are unchanged (#182).
38
+ - Stochastic execution supports reactions between templates with different
39
+ axes, including axis-less states: scalar S→I→R models, axis-less donors
40
+ depositing into a pinned cell, and templated sources collapsing into an
41
+ axis-less state. Targets are indexed with op_system's `to_full_axes`,
42
+ falling back to `full_axes` for older producers (#184).
43
+ - Stochastic execution runs routing and target-only fan-out reactions: each
44
+ source cell and routed target coordinate (op_system's `routed_axes`) is one
45
+ channel, so routing generators move discrete units between bins (#186).
46
+ - Adaptive tau-leaping validation reports incomplete reactant metadata before
47
+ running, and both validation and run name each incomplete reaction and the
48
+ remedy: `reactants` on ordinary op_system transitions, or `catalysts` on
49
+ `chain:` and `coord_shift` entries (#188).
50
+
51
+ ### Changed
52
+
53
+ - The provider's optional `op_system` integration requires
54
+ `flepimop2-op-system>=0.6.0` and `op-system>=0.6.0`.
55
+ - Pure stochastic runs that previously dropped uncovered transitions or typed
56
+ operators silently now fail validation; use `mode: hybrid` or give each
57
+ transition a reaction artifact (#182).
58
+
59
+ ### Fixed
60
+
61
+ - `PreparedExecution` can be passed directly to JAX transformations, including
62
+ mixed NumPy/JAX samples whose effective namespaces and dtypes are canonicalized
63
+ by JAX during tracing (#168).
64
+ - Pinned source-only renewal births are accepted: donor-axis coverage is
65
+ validated only for reactions with a donor (#178, #179).
66
+
8
67
  ## [0.3.0] - 2026-09-27
9
68
 
10
69
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: op_engine
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Multiphysics integration engine for ODEs and PDEs using implicit-explicit operator splitting
5
5
  Project-URL: Repository, https://github.com/ACCIDDA/op_engine
6
6
  Project-URL: Documentation, https://accidda.github.io/op_engine/
@@ -88,6 +88,18 @@ With JAX as the array namespace:
88
88
  pip install "op_engine[jax]"
89
89
  ```
90
90
 
91
+ For the other packaged Array-API backends:
92
+
93
+ ```bash
94
+ pip install "op_engine[torch]"
95
+ pip install "op_engine[cupy]"
96
+ ```
97
+
98
+ NumPy is installed with the core package. Backend support is method-specific;
99
+ consult the [backend guide](docs/guides/backends.md) before assuming that JIT,
100
+ automatic differentiation, adaptivity, or stochastic sampling have identical
101
+ semantics across namespaces.
102
+
91
103
  With flepimop2 adapter:
92
104
 
93
105
  ```bash
@@ -139,7 +151,8 @@ adaptive controller is eager Python control flow. See the
139
151
 
140
152
  ```python
141
153
  import numpy as np
142
- from op_engine import CoreSolver, ModelCore, OperatorSpecs
154
+ from op_engine import CoreSolver, ModelCore
155
+ from op_engine.core_solver import OperatorSpecs, RunConfig
143
156
 
144
157
  n = 4
145
158
  times = np.linspace(0.0, 1.0, 11)
@@ -156,13 +169,20 @@ def rhs(t, y):
156
169
  return -0.1 * y
157
170
 
158
171
 
159
- solver = CoreSolver(core, operators=ops.default, operator_axis="state")
160
- solver.run(rhs, config=None) # defaults: method="heun" (explicit)
161
-
162
- # For IMEX methods set method and operators via RunConfig:
163
- # from op_engine.core_solver import RunConfig, AdaptiveConfig, DtControllerConfig
172
+ solver = CoreSolver(core, operator_axis="state")
173
+ solver.run(
174
+ rhs,
175
+ config=RunConfig(
176
+ method="imex-heun-tr",
177
+ operators=ops,
178
+ ),
179
+ )
164
180
  ```
165
181
 
182
+ This compact example uses identity left/right operators to show the API. For a
183
+ nontrivial implicit term, construct timestep-aware operator factories as in the
184
+ [solver guide](docs/guides/solver-methods.md#imex-methods-and-operator-factories).
185
+
166
186
  ## Public API
167
187
  - `ModelCore`: state tensor + time grid manager; supports extra axes and optional history.
168
188
  - `CoreSolver`: portable explicit and dense IMEX/implicit stepping selected by the state array namespace.
@@ -170,7 +190,9 @@ solver.run(rhs, config=None) # defaults: method="heun" (explicit)
170
190
  - `AdaptiveTauLeapingSolver`: adaptive non-negative stochastic leaps using explicit reactant stoichiometry and injected Poisson plus exact-event sampling.
171
191
  - `TauLeapingSolver`: portable stoichiometric updates with NumPy, JAX, or another Array-API namespace supplying Poisson samples.
172
192
  - Operator utilities (`matrix_ops`): portable upwind advection, Laplacian, Crank–Nicolson/implicit Euler/trapezoidal operators, predictor-corrector builders, implicit solve cache, Kronecker helpers, and grouped aggregation utilities.
173
- - Configuration helpers: `RunConfig`, `OperatorSpecs`, `AdaptiveConfig`, `DtControllerConfig` for method/IMEX/adaptive control.
193
+ - Configuration helpers: `RunConfig`, `OperatorSpecs`, `AdaptiveConfig`, and
194
+ `DtControllerConfig` from `op_engine.core_solver` for method, IMEX, and
195
+ adaptive control.
174
196
  - Adapters: optional flepimop2 integration (extra dependency) via entrypoints in the adapter package. The adapter merges any `mixing_kernels` already computed by op_system (no automatic generation) and consumes config-supplied IMEX operator specs (dict or `OperatorSpecs`), forwarding the chosen `operator_axis` to `CoreSolver`.
175
197
 
176
198
  ## Development
@@ -36,6 +36,18 @@ With JAX as the array namespace:
36
36
  pip install "op_engine[jax]"
37
37
  ```
38
38
 
39
+ For the other packaged Array-API backends:
40
+
41
+ ```bash
42
+ pip install "op_engine[torch]"
43
+ pip install "op_engine[cupy]"
44
+ ```
45
+
46
+ NumPy is installed with the core package. Backend support is method-specific;
47
+ consult the [backend guide](docs/guides/backends.md) before assuming that JIT,
48
+ automatic differentiation, adaptivity, or stochastic sampling have identical
49
+ semantics across namespaces.
50
+
39
51
  With flepimop2 adapter:
40
52
 
41
53
  ```bash
@@ -87,7 +99,8 @@ adaptive controller is eager Python control flow. See the
87
99
 
88
100
  ```python
89
101
  import numpy as np
90
- from op_engine import CoreSolver, ModelCore, OperatorSpecs
102
+ from op_engine import CoreSolver, ModelCore
103
+ from op_engine.core_solver import OperatorSpecs, RunConfig
91
104
 
92
105
  n = 4
93
106
  times = np.linspace(0.0, 1.0, 11)
@@ -104,13 +117,20 @@ def rhs(t, y):
104
117
  return -0.1 * y
105
118
 
106
119
 
107
- solver = CoreSolver(core, operators=ops.default, operator_axis="state")
108
- solver.run(rhs, config=None) # defaults: method="heun" (explicit)
109
-
110
- # For IMEX methods set method and operators via RunConfig:
111
- # from op_engine.core_solver import RunConfig, AdaptiveConfig, DtControllerConfig
120
+ solver = CoreSolver(core, operator_axis="state")
121
+ solver.run(
122
+ rhs,
123
+ config=RunConfig(
124
+ method="imex-heun-tr",
125
+ operators=ops,
126
+ ),
127
+ )
112
128
  ```
113
129
 
130
+ This compact example uses identity left/right operators to show the API. For a
131
+ nontrivial implicit term, construct timestep-aware operator factories as in the
132
+ [solver guide](docs/guides/solver-methods.md#imex-methods-and-operator-factories).
133
+
114
134
  ## Public API
115
135
  - `ModelCore`: state tensor + time grid manager; supports extra axes and optional history.
116
136
  - `CoreSolver`: portable explicit and dense IMEX/implicit stepping selected by the state array namespace.
@@ -118,7 +138,9 @@ solver.run(rhs, config=None) # defaults: method="heun" (explicit)
118
138
  - `AdaptiveTauLeapingSolver`: adaptive non-negative stochastic leaps using explicit reactant stoichiometry and injected Poisson plus exact-event sampling.
119
139
  - `TauLeapingSolver`: portable stoichiometric updates with NumPy, JAX, or another Array-API namespace supplying Poisson samples.
120
140
  - Operator utilities (`matrix_ops`): portable upwind advection, Laplacian, Crank–Nicolson/implicit Euler/trapezoidal operators, predictor-corrector builders, implicit solve cache, Kronecker helpers, and grouped aggregation utilities.
121
- - Configuration helpers: `RunConfig`, `OperatorSpecs`, `AdaptiveConfig`, `DtControllerConfig` for method/IMEX/adaptive control.
141
+ - Configuration helpers: `RunConfig`, `OperatorSpecs`, `AdaptiveConfig`, and
142
+ `DtControllerConfig` from `op_engine.core_solver` for method, IMEX, and
143
+ adaptive control.
122
144
  - Adapters: optional flepimop2 integration (extra dependency) via entrypoints in the adapter package. The adapter merges any `mixing_kernels` already computed by op_system (no automatic generation) and consumes config-supplied IMEX operator specs (dict or `OperatorSpecs`), forwarding the chosen `operator_axis` to `CoreSolver`.
123
145
 
124
146
  ## Development
@@ -82,6 +82,9 @@ semantics.
82
82
  `NumpySSASampler` and `NumpyPoissonSampler` are provided as conveniences.
83
83
  JAX users can construct fresh keys from the stable direct-SSA `draw_index` or
84
84
  tau-leaping `step_index`.
85
+ Direct SSA's optional forcing schedule is static Python configuration. It
86
+ preserves the state namespace and uses fresh draw indices when a forcing
87
+ boundary invalidates a pending event; observation times retain that event.
85
88
 
86
89
  The current safety checks and stochastic event loops are eager, so these
87
90
  stochastic solvers are not JIT-compatible paths. Adaptive selection also reads
@@ -0,0 +1,112 @@
1
+ # Bounded thinning SSA
2
+
3
+ `ThinningSSASolver` provides an exact waiting-time method for smoothly varying
4
+ propensities with user-certified bounds. This design addresses
5
+ [#173](https://github.com/ACCIDDA/op_engine/issues/173), including the flepimop2
6
+ provider's explicit `thinning-ssa` method.
7
+
8
+ ## Bound contract
9
+
10
+ `ThinningSSASolver.run(propensity_func, thinning_sampler, *, rate_bound, config=None)`
11
+ uses the same stoichiometry, reaction axis, and batched propensity contract as
12
+ direct SSA. It permits arbitrary time dependence between events when a valid
13
+ upper bound is supplied.
14
+
15
+ `rate_bound` is either a finite non-negative real constant, or a
16
+ `RateBoundFunction(t, state, limit)` returning `TotalRateBound(rate, valid_until)`.
17
+ The bound covers the sum of **all** reaction and batch-cell propensities with
18
+ the supplied state held fixed, over `[t, valid_until)`. The callback chooses a
19
+ finite endpoint satisfying `t < valid_until <= limit`. `limit` is the earlier
20
+ of the next declared forcing change and the solve's final time; observations
21
+ do not shorten it. A constant bound applies to every state reached during the
22
+ solve, until the next forcing change or final time.
23
+
24
+ After an accepted event, the state changes and the callback is queried again.
25
+ A rejected candidate leaves the state and current bound unchanged. At expiry,
26
+ the solver discards a tied or later candidate, advances without firing, and
27
+ requests a new bound. The same rule applies at forcing changes, with
28
+ right-continuous propensities. No candidate fires exactly at an interval's
29
+ right endpoint, including the solve's final endpoint.
30
+
31
+ A zero bound declares the entire interval inactive. The solver checks rates
32
+ at the interval's start, advances to its endpoint without sampling, and can
33
+ resume with a positive bound. Zero instantaneous propensity with a positive
34
+ bound still produces candidates, allowing smooth rates to become active.
35
+ The caller must prove the bound over the whole interval. Checks at interval
36
+ starts and candidate times detect encountered violations but cannot certify
37
+ unsampled times. A violation fails visibly rather than clipping acceptance.
38
+ Bounds and cumulative rates use the state's floating dtype. Bounds that
39
+ overflow or become zero on conversion fail; tight bounds should allow for
40
+ floating-point rounding when summing many channels. Callback inputs must be
41
+ treated as read-only, and callbacks must not depend on observation storage.
42
+
43
+ ## Sampling contract
44
+
45
+ `ThinningSampler(bound_rate, draw_index)` returns
46
+ `ThinningSample(waiting_time, uniform)`, both scalar arrays in the state array
47
+ namespace. The wait is finite and strictly positive, sampled exponentially at
48
+ `bound_rate`. The independent uniform lies in `[0, 1)`. Draw indices start at
49
+ zero and increase globally, including rejected and expired candidates.
50
+ `NumpyThinningSampler(seed)` supplies the NumPy implementation; other backends
51
+ inject their own sampler.
52
+
53
+ At a candidate time, the solver evaluates the actual channel rates. If
54
+ `uniform * bound_rate` is at least the actual total rate, it rejects the
55
+ candidate. Otherwise it selects the flattened channel/batch event whose
56
+ cumulative propensity contains that threshold. This uses one uniform for
57
+ acceptance and channel selection: each channel has probability
58
+ `channel_rate / bound_rate`, and the remaining probability rejects.
59
+
60
+ Candidates beyond an observation are retained with their sampled uniform;
61
+ their propensities are evaluated only when the solver reaches them. Inserting
62
+ observations with the same solve endpoints therefore preserves the seeded path
63
+ and callback/sampler history. `ThinningSSAConfig.max_candidates` bounds all
64
+ candidate draws during one run, including rejection and expiry. Invalid
65
+ sampler values, non-advancing clocks, invalid bounds, and impossible population
66
+ updates fail explicitly.
67
+
68
+ The construction follows
69
+ [Lewis and Shedler's thinning method](https://doi.org/10.1002/nav.3800260304).
70
+ The bound and expiry contract extends it to state-dependent reaction networks;
71
+ exactness remains conditional on the caller's bound and random sampling laws.
72
+ Existing direct SSA and tau-leaping behavior and samplers are unchanged.
73
+
74
+ ## Example
75
+
76
+ For an independent birth channel in each batch cell with rate `2 * time`, the
77
+ integrated intensity over `[0, 1]` is one per cell. The total upper bound must
78
+ include every batch cell:
79
+
80
+ ```python
81
+ import numpy as np
82
+
83
+ from op_engine import ModelCore, NumpyThinningSampler, ThinningSSASolver
84
+
85
+ core = ModelCore(1, 100, np.linspace(0, 1, 11))
86
+ core.set_initial_state(np.zeros((1, 100)))
87
+
88
+
89
+ def smooth_birth(time, state):
90
+ return np.full(state.shape, 2 * time, dtype=state.dtype)
91
+
92
+
93
+ ThinningSSASolver(core, np.asarray([[1]])).run(
94
+ smooth_birth, NumpyThinningSampler(seed=173), rate_bound=200,
95
+ )
96
+ ```
97
+
98
+ A callback can instead return `TotalRateBound(2 * limit * state.shape[1], limit)`
99
+ for this example. For consuming reactions, a bound that depends on the current
100
+ state can be refreshed after each accepted event. A loose bound remains valid
101
+ but generates more rejected candidates.
102
+
103
+ The flepimop2 provider selects this method through
104
+ `mode: stochastic` and `stochastic_method: thinning-ssa`. Supply a constant
105
+ `thinning_rate_bound` in configuration, or leave it unset and pass a scalar or
106
+ `RateBoundFunction` through `run(..., rate_bound=...)`. Both interfaces cannot
107
+ be supplied together. `thinning_max_candidates` sets the per-run candidate
108
+ guard. NumPy has seeded sampling through `random_seed`; other backends inject
109
+ `thinning_sampler=`. Hybrid thinning is unsupported. See the
110
+ [provider examples](https://github.com/ACCIDDA/op_engine/tree/main/flepimop2-op_engine#bounded-thinning-ssa)
111
+ for configuration and callback usage. Existing direct SSA and tau-leaping
112
+ defaults remain unchanged.
@@ -2,8 +2,9 @@
2
2
 
3
3
  `ModelCore` owns output times and state storage. Deterministic systems use
4
4
  `CoreSolver` and a method selected by `RunConfig`. Stochastic reaction networks
5
- use `DirectSSASolver` or `TauLeapingSolver`, because propensities and
6
- stoichiometry have different semantics from an ODE right-hand side. In every
5
+ use `DirectSSASolver`, `ThinningSSASolver`, `TauLeapingSolver`, or
6
+ `AdaptiveTauLeapingSolver`, because
7
+ propensities and stoichiometry have different semantics from an ODE right-hand side. In every
7
8
  case the state array selects the Array-API namespace; choosing JAX instead of
8
9
  NumPy does not select a different numerical method.
9
10
 
@@ -48,7 +49,8 @@ reaction axis.
48
49
  exponential waiting time and one categorical reaction/batch event, applies
49
50
  exactly one stoichiometric update, and repeats. A draw beyond an output boundary
50
51
  is retained rather than discarded, so adding observation times does not alter
51
- the simulated path. Zero total propensity is absorbing.
52
+ the simulated path. Zero total propensity is absorbing when no declared future
53
+ forcing boundary remains.
52
54
 
53
55
  Use direct SSA as the reference method for low-copy-number networks, rare-event
54
56
  questions, or checking a tau-leaping approximation. Its cost is proportional to
@@ -78,13 +80,111 @@ DirectSSASolver(core, stoichiometry).run(
78
80
  )
79
81
  ```
80
82
 
81
- The direct method assumes propensities remain constant between reaction events,
82
- as in a time-homogeneous continuous-time Markov chain. Across batch cells it
83
- samples from the superposed event process; the result is equivalent to
83
+ The direct method assumes propensities remain constant in time while the state
84
+ is unchanged, except at explicitly declared forcing boundaries. Across batch
85
+ cells it samples from the superposed event process; the result is equivalent to
84
86
  independent direct-method trajectories for independent batch cells.
85
87
 
88
+ #### Piecewise-constant forcing
89
+
90
+ Declare every forcing change through `DirectSSAConfig.forcing_breakpoints`:
91
+
92
+ ```python
93
+ from op_engine import DirectSSAConfig, array_namespace
94
+
95
+ core = ModelCore(n_states=1, n_subgroups=1, time_grid=np.linspace(0.0, 3.0, 13))
96
+ core.set_initial_state(np.zeros((1, 1)))
97
+
98
+
99
+ def forced_birth_rate(time, state):
100
+ xp = array_namespace(state)
101
+ rate = 0.0 if time < 1.0 else (3.0 if time < 2.0 else 1.0)
102
+ return xp.full(state.shape, rate, dtype=state.dtype)
103
+
104
+
105
+ DirectSSASolver(core, np.asarray([[1]])).run(
106
+ forced_birth_rate,
107
+ NumpySSASampler(seed=2026),
108
+ config=DirectSSAConfig(forcing_breakpoints=(1.0, 2.0)),
109
+ )
110
+ ```
111
+
112
+ If an event would reach or cross a forcing boundary, the solver advances to
113
+ that boundary without firing, reevaluates the rates, and redraws. Exponential
114
+ memorylessness makes this exact for piecewise-constant forcing. Zero-rate
115
+ intervals advance to the next forcing boundary without drawing randomness.
116
+
117
+ Forcing is right-continuous: the callback must return the new rates at a
118
+ breakpoint. In an exact event/breakpoint tie, the forcing boundary wins and the
119
+ old event is discarded. Observation times remain independent of the forcing
120
+ schedule, even when an observation coincides with a breakpoint.
121
+
122
+ Breakpoints must be finite real scalars in strictly increasing order. They are
123
+ snapshotted as an immutable tuple; a global schedule may include times outside
124
+ the solve interval. Boundaries at the initial time are already reflected in
125
+ the first evaluation, and the final boundary causes no extra random draw.
126
+ An omitted or empty schedule preserves ordinary time-homogeneous execution.
127
+
128
+ The schedule does not freeze or interpolate callback values. All time
129
+ dependence between boundaries must be constant for this exactness claim;
130
+ smoothly varying rates require a different waiting-time method. In particular,
131
+ `op_system` defaults to linear interpolation of time-indexed parameters, so
132
+ listing their grid points as breakpoints does not make direct SSA exact for
133
+ those interpolated rates. Producers containing
134
+ [op_system PR #241](https://github.com/ACCIDDA/op_system/pull/241) can select
135
+ `time_interpolation: previous` to hold table values until the next coordinate.
136
+ The flepimop2 engine provider consumes their published `forcing_breakpoints`
137
+ automatically in pure stochastic mode and combines them with any extra
138
+ boundaries in the engine configuration. This also supports fixed and adaptive
139
+ tau-leaping. Any additional direct time dependence must still satisfy the
140
+ chosen solver's exactness conditions.
141
+
142
+ ### Exact SSA for smooth forcing
143
+
144
+ `ThinningSSASolver` accepts time-dependent propensities and a user-certified
145
+ total-rate bound. It samples candidates at the bound rate, then evaluates
146
+ channel rates at the candidate time to reject or apply one event. See the
147
+ [bounded thinning guide](bounded-thinning.md) for constant and callback bounds,
148
+ sampling injection, interval expiry, and a runnable smooth-birth example.
149
+
86
150
  ### Bounded adaptive tau-leaping
87
151
 
152
+ Both tau-leaping configurations accept the same `forcing_breakpoints` schedule
153
+ as direct SSA. Each leap is capped at the next observation or forcing time,
154
+ as well as the method's usual step-size limit. The next leap evaluates the
155
+ right-continuous rates at that boundary. Boundaries count toward the fixed or
156
+ adaptive method's `max_steps` guard for the entire output interval; they do
157
+ not reset it.
158
+
159
+ For example, the piecewise birth callback above can also use fixed tau-leaping:
160
+
161
+ ```python
162
+ from op_engine import NumpyPoissonSampler, TauLeapingConfig, TauLeapingSolver
163
+
164
+ core = ModelCore(n_states=1, n_subgroups=1, time_grid=np.linspace(0.0, 3.0, 13))
165
+ core.set_initial_state(np.zeros((1, 1)))
166
+
167
+ TauLeapingSolver(core, np.asarray([[1]])).run(
168
+ forced_birth_rate,
169
+ NumpyPoissonSampler(seed=2026),
170
+ config=TauLeapingConfig(max_step=0.1, forcing_breakpoints=(1.0, 2.0)),
171
+ )
172
+ ```
173
+
174
+ Adaptive tau-leaping uses these caps for both accepted leaps and rejected
175
+ proposals. A critical event tied with a forcing boundary is discarded, while
176
+ noncritical Poisson counts cover the segment ending there. During exact
177
+ fallback, a pending event survives observation times but is discarded at or
178
+ beyond the next forcing change. Zero total propensity waits for that change
179
+ without drawing randomness; without a future boundary it remains absorbing.
180
+
181
+ Boundary caps keep a leap from using the old rate across a known change. The
182
+ usual tau-leaping approximation still freezes state-dependent propensities
183
+ within each leap. Smooth time variation between boundaries also remains an
184
+ approximation for leaps and is unsupported by adaptive exact fallback. For
185
+ that fallback, use rates constant in time between declared forcing changes
186
+ while the state is unchanged.
187
+
88
188
  `AdaptiveTauLeapingSolver` implements the species-based pre-leap selector of
89
189
  [Cao, Gillespie, and Petzold](https://doi.org/10.1063/1.2159468) together with
90
190
  their [non-negative modified Poisson