phaseprobe 0.2.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 (38) hide show
  1. phaseprobe/__init__.py +30 -0
  2. phaseprobe/__main__.py +5 -0
  3. phaseprobe/adapters/__init__.py +4 -0
  4. phaseprobe/adapters/loader.py +51 -0
  5. phaseprobe/adapters/scipy.py +539 -0
  6. phaseprobe/api.py +64 -0
  7. phaseprobe/artifacts.py +124 -0
  8. phaseprobe/cli.py +242 -0
  9. phaseprobe/config.py +132 -0
  10. phaseprobe/data/__init__.py +1 -0
  11. phaseprobe/data/examples/__init__.py +1 -0
  12. phaseprobe/data/examples/logistic-negative.json +27 -0
  13. phaseprobe/data/examples/logistic-scan.json +27 -0
  14. phaseprobe/data/examples/lorenz-negative.json +30 -0
  15. phaseprobe/data/examples/lorenz-perturb.json +30 -0
  16. phaseprobe/data/examples/predator-prey-check.json +25 -0
  17. phaseprobe/data/examples/predator-prey-negative.json +25 -0
  18. phaseprobe/data/examples/toggle-negative.json +30 -0
  19. phaseprobe/data/examples/toggle-perturb.json +31 -0
  20. phaseprobe/engine.py +810 -0
  21. phaseprobe/errors.py +31 -0
  22. phaseprobe/examples/__init__.py +1 -0
  23. phaseprobe/examples/scipy_models.py +151 -0
  24. phaseprobe/generate.py +72 -0
  25. phaseprobe/models/__init__.py +36 -0
  26. phaseprobe/models/_common.py +56 -0
  27. phaseprobe/models/logistic.py +63 -0
  28. phaseprobe/models/lorenz.py +64 -0
  29. phaseprobe/models/predator_prey.py +86 -0
  30. phaseprobe/models/toggle.py +73 -0
  31. phaseprobe/replay.py +496 -0
  32. phaseprobe/reporting.py +173 -0
  33. phaseprobe/types.py +131 -0
  34. phaseprobe-0.2.0.dist-info/METADATA +275 -0
  35. phaseprobe-0.2.0.dist-info/RECORD +38 -0
  36. phaseprobe-0.2.0.dist-info/WHEEL +4 -0
  37. phaseprobe-0.2.0.dist-info/entry_points.txt +2 -0
  38. phaseprobe-0.2.0.dist-info/licenses/LICENSE +201 -0
phaseprobe/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """PhaseProbe: qualitative simulation evidence and executable regressions."""
2
+
3
+ from phaseprobe.api import (
4
+ run_invariant_check,
5
+ run_parameter_scan,
6
+ run_perturbation,
7
+ run_simulation,
8
+ )
9
+ from phaseprobe.types import (
10
+ InvariantResult,
11
+ ModelAdapter,
12
+ SimulationTrace,
13
+ TracePoint,
14
+ TrajectoryAdapter,
15
+ )
16
+
17
+ __all__ = [
18
+ "InvariantResult",
19
+ "ModelAdapter",
20
+ "SimulationTrace",
21
+ "TracePoint",
22
+ "TrajectoryAdapter",
23
+ "__version__",
24
+ "run_invariant_check",
25
+ "run_parameter_scan",
26
+ "run_perturbation",
27
+ "run_simulation",
28
+ ]
29
+
30
+ __version__ = "0.2.0"
phaseprobe/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m phaseprobe``."""
2
+
3
+ from phaseprobe.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ """Optional and external adapter loading support.
2
+
3
+ SciPy is deliberately not imported here so the base package stays dependency-free.
4
+ """
@@ -0,0 +1,51 @@
1
+ """Explicit loading of user-selected Python adapter factories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+
7
+ from phaseprobe.config import ProbeConfig
8
+ from phaseprobe.errors import ConfigurationError
9
+ from phaseprobe.types import ModelAdapter, TrajectoryAdapter
10
+
11
+
12
+ def load_configured_adapter(config: ProbeConfig) -> ModelAdapter | TrajectoryAdapter:
13
+ """Import and call the explicitly configured adapter factory.
14
+
15
+ Configuration parsing validates names but does not import anything. Calling this function
16
+ executes the selected module and factory, so callers must trust that Python code.
17
+ """
18
+
19
+ reference = config.section("adapter")
20
+ module_name = reference.get("module")
21
+ factory_name = reference.get("factory")
22
+ if not isinstance(module_name, str) or not isinstance(factory_name, str):
23
+ raise ConfigurationError("adapter.module and adapter.factory must be strings")
24
+ try:
25
+ module = importlib.import_module(module_name)
26
+ except (ImportError, ValueError) as exc:
27
+ raise ConfigurationError(
28
+ f"cannot import configured adapter module {module_name!r}: {exc}"
29
+ ) from exc
30
+ factory = getattr(module, factory_name, None)
31
+ if not callable(factory):
32
+ raise ConfigurationError(
33
+ f"configured adapter factory {module_name}.{factory_name} is not callable"
34
+ )
35
+ try:
36
+ candidate = factory(reference)
37
+ except ConfigurationError:
38
+ raise
39
+ except Exception as exc:
40
+ raise ConfigurationError(
41
+ f"configured adapter factory {module_name}.{factory_name} failed: {exc}"
42
+ ) from exc
43
+ if not isinstance(candidate, ModelAdapter | TrajectoryAdapter):
44
+ raise ConfigurationError(
45
+ f"configured factory {module_name}.{factory_name} did not return a PhaseProbe adapter"
46
+ )
47
+ if candidate.name != config.model:
48
+ raise ConfigurationError(
49
+ f"configured adapter name {candidate.name!r} does not match model {config.model!r}"
50
+ )
51
+ return candidate
@@ -0,0 +1,539 @@
1
+ """Trajectory-level adapter for the public :func:`scipy.integrate.solve_ivp` API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import platform
8
+ import sys
9
+ from collections.abc import Callable, Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from itertools import pairwise
12
+ from typing import Any, Literal, TypeAlias, cast
13
+
14
+ try:
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+ import scipy
18
+ from scipy.integrate import solve_ivp
19
+ except ModuleNotFoundError as exc: # pragma: no cover - exercised in an isolated interpreter
20
+ raise ImportError(
21
+ "PhaseProbe SciPy support is optional; install it with "
22
+ '`python -m pip install "phaseprobe[scipy]"`.'
23
+ ) from exc
24
+
25
+ from phaseprobe.config import canonical_json
26
+ from phaseprobe.errors import ConfigurationError, NumericalFailure
27
+ from phaseprobe.types import (
28
+ InvariantResult,
29
+ ModelConfig,
30
+ Parameters,
31
+ Scalar,
32
+ SimulationTrace,
33
+ State,
34
+ Tolerances,
35
+ TracePoint,
36
+ )
37
+
38
+ FloatArray: TypeAlias = npt.NDArray[np.float64]
39
+ RHSCallback: TypeAlias = Callable[[float, FloatArray, Parameters], object]
40
+ EventCallback: TypeAlias = Callable[[float, FloatArray, Parameters], float]
41
+ ObservableCallback: TypeAlias = Callable[[float, State, Parameters], Mapping[str, Scalar]]
42
+ ClassifierCallback: TypeAlias = Callable[[SimulationTrace, Tolerances], str]
43
+ InvariantCallback: TypeAlias = Callable[
44
+ [SimulationTrace, Parameters, Tolerances], list[InvariantResult]
45
+ ]
46
+
47
+ SUPPORTED_METHODS = ("RK23", "RK45", "DOP853", "Radau", "BDF", "LSODA")
48
+ MAX_EVALUATION_POINTS = 100_000
49
+ MAX_EVENT_POINTS = 10_000
50
+
51
+
52
+ def _finite_number(value: object, context: str) -> float:
53
+ if not isinstance(value, int | float) or isinstance(value, bool):
54
+ raise ConfigurationError(f"{context} must be a real number")
55
+ result = float(value)
56
+ if not math.isfinite(result):
57
+ raise ConfigurationError(f"{context} must be finite")
58
+ return result
59
+
60
+
61
+ def _finite_sequence(value: object, context: str) -> tuple[float, ...]:
62
+ if not isinstance(value, list | tuple):
63
+ raise ConfigurationError(f"{context} must be an array of real numbers")
64
+ return tuple(_finite_number(item, f"{context}[{index}]") for index, item in enumerate(value))
65
+
66
+
67
+ def _string(value: object, context: str) -> str:
68
+ if not isinstance(value, str) or not value:
69
+ raise ConfigurationError(f"{context} must be a non-empty string")
70
+ return value
71
+
72
+
73
+ def _mapping(value: object, context: str) -> Mapping[str, object]:
74
+ if not isinstance(value, dict):
75
+ raise ConfigurationError(f"{context} must be an object")
76
+ return cast(Mapping[str, object], value)
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class EventSpec:
81
+ """Named, explicitly configured solve_ivp event callback."""
82
+
83
+ name: str
84
+ function: EventCallback
85
+ terminal: bool | int = False
86
+ direction: float = 0.0
87
+
88
+ def __post_init__(self) -> None:
89
+ if not self.name:
90
+ raise ConfigurationError("event name must be non-empty")
91
+ terminal = self.terminal
92
+ if not isinstance(terminal, bool) and (not isinstance(terminal, int) or terminal <= 0):
93
+ raise ConfigurationError("event terminal must be a boolean or positive integer")
94
+ if not math.isfinite(float(self.direction)):
95
+ raise ConfigurationError("event direction must be finite")
96
+
97
+ def as_dict(self) -> dict[str, object]:
98
+ """Serialize event policy without serializing executable code."""
99
+
100
+ return {
101
+ "name": self.name,
102
+ "terminal": self.terminal,
103
+ "direction": float(self.direction),
104
+ }
105
+
106
+
107
+ class _EventWrapper:
108
+ def __init__(self, spec: EventSpec, parameters: Parameters) -> None:
109
+ self._spec = spec
110
+ self._parameters = parameters
111
+ self.terminal = spec.terminal
112
+ self.direction = float(spec.direction)
113
+
114
+ def __call__(self, time: float, state: FloatArray) -> float:
115
+ value = float(self._spec.function(time, state, self._parameters))
116
+ if not math.isfinite(value):
117
+ raise NumericalFailure(
118
+ f"invalid event value from {self._spec.name!r}: expected a finite scalar"
119
+ )
120
+ return value
121
+
122
+
123
+ class SolveIVPAdapter:
124
+ """Typed real-valued trajectory adapter backed by SciPy ``solve_ivp``.
125
+
126
+ ``identity`` is an explicit user-controlled identifier. PhaseProbe combines it with a digest
127
+ of serialized numerical configuration; callable source is never inspected or stored.
128
+ """
129
+
130
+ replay_mode: Literal["tolerance"] = "tolerance"
131
+
132
+ def __init__(
133
+ self,
134
+ *,
135
+ name: str,
136
+ identity: str,
137
+ rhs: RHSCallback,
138
+ state_names: Sequence[str],
139
+ initial_state: Sequence[float],
140
+ t_span: tuple[float, float],
141
+ t_eval: Sequence[float] | int = 501,
142
+ method: str = "RK45",
143
+ rtol: float = 1e-3,
144
+ atol: float | Sequence[float] = 1e-6,
145
+ max_step: float | None = None,
146
+ events: Sequence[EventSpec] = (),
147
+ observable: ObservableCallback | None = None,
148
+ classifier: ClassifierCallback | None = None,
149
+ invariant: InvariantCallback | None = None,
150
+ vectorized: bool = False,
151
+ dense_output: bool = False,
152
+ ) -> None:
153
+ self.name = _string(name, "name")
154
+ self.explicit_identity = _string(identity, "identity")
155
+ self.dimensions = tuple(_string(item, "state_names item") for item in state_names)
156
+ if not self.dimensions or len(set(self.dimensions)) != len(self.dimensions):
157
+ raise ConfigurationError("state_names must be non-empty and unique")
158
+ self._initial_state = _finite_sequence(tuple(initial_state), "initial_state")
159
+ if len(self._initial_state) != len(self.dimensions):
160
+ raise ConfigurationError("initial_state length must match state_names")
161
+ self._t_span = (
162
+ _finite_number(t_span[0], "t_span[0]"),
163
+ _finite_number(t_span[1], "t_span[1]"),
164
+ )
165
+ if self._t_span[0] == self._t_span[1]:
166
+ raise ConfigurationError("t_span endpoints must differ")
167
+ self._t_eval, self._grid_configuration = self._evaluation_grid(t_eval)
168
+ if method not in SUPPORTED_METHODS:
169
+ raise ConfigurationError(f"method must be one of {', '.join(SUPPORTED_METHODS)}")
170
+ self.method = method
171
+ self.rtol = _finite_number(rtol, "rtol")
172
+ if self.rtol <= 0.0:
173
+ raise ConfigurationError("rtol must be positive")
174
+ if isinstance(atol, int | float) and not isinstance(atol, bool):
175
+ scalar_atol = _finite_number(atol, "atol")
176
+ if scalar_atol <= 0.0:
177
+ raise ConfigurationError("atol must be positive")
178
+ self.atol: float | tuple[float, ...] = scalar_atol
179
+ else:
180
+ vector_atol = _finite_sequence(atol, "atol")
181
+ if len(vector_atol) != len(self.dimensions) or any(x <= 0.0 for x in vector_atol):
182
+ raise ConfigurationError("vector atol must contain one positive value per state")
183
+ self.atol = vector_atol
184
+ if max_step is None:
185
+ self.max_step = None
186
+ else:
187
+ parsed_max_step = _finite_number(max_step, "max_step")
188
+ if parsed_max_step <= 0.0:
189
+ raise ConfigurationError("max_step must be positive")
190
+ self.max_step = parsed_max_step
191
+ self.events = tuple(events)
192
+ if len({event.name for event in self.events}) != len(self.events):
193
+ raise ConfigurationError("event names must be unique")
194
+ if not isinstance(vectorized, bool) or not isinstance(dense_output, bool):
195
+ raise ConfigurationError("vectorized and dense_output must be booleans")
196
+ self.vectorized = vectorized
197
+ self.dense_output = dense_output
198
+ self._rhs = rhs
199
+ self._observable = observable or self._default_observable
200
+ self._classifier = classifier or self._default_classifier
201
+ self._invariant = invariant or self._default_invariants
202
+ configuration = self.configuration()
203
+ digest = hashlib.sha256(canonical_json(configuration).encode("utf-8")).hexdigest()
204
+ self.identity = f"{self.explicit_identity}:sha256:{digest[:16]}"
205
+
206
+ @classmethod
207
+ def from_config(
208
+ cls,
209
+ *,
210
+ name: str,
211
+ rhs: RHSCallback,
212
+ values: Mapping[str, object],
213
+ observable: ObservableCallback | None = None,
214
+ classifier: ClassifierCallback | None = None,
215
+ invariant: InvariantCallback | None = None,
216
+ events: Sequence[EventSpec] = (),
217
+ ) -> SolveIVPAdapter:
218
+ """Construct from a JSON-compatible ``adapter.options`` mapping."""
219
+
220
+ options = _mapping(values.get("options"), "adapter.options")
221
+ state_names_raw = options.get("state_names")
222
+ if not isinstance(state_names_raw, list) or not all(
223
+ isinstance(item, str) for item in state_names_raw
224
+ ):
225
+ raise ConfigurationError("adapter.options.state_names must be an array of strings")
226
+ t_span_values = _finite_sequence(options.get("t_span"), "adapter.options.t_span")
227
+ if len(t_span_values) != 2:
228
+ raise ConfigurationError("adapter.options.t_span must contain two values")
229
+ atol_raw = options.get("atol", 1e-6)
230
+ if isinstance(atol_raw, list):
231
+ atol: float | Sequence[float] = _finite_sequence(atol_raw, "adapter.options.atol")
232
+ else:
233
+ atol = _finite_number(atol_raw, "adapter.options.atol")
234
+ t_eval_raw = options.get("t_eval", 501)
235
+ if isinstance(t_eval_raw, dict):
236
+ grid = _mapping(t_eval_raw, "adapter.options.t_eval")
237
+ if grid.get("kind") != "linspace":
238
+ raise ConfigurationError("adapter.options.t_eval.kind must be 'linspace'")
239
+ points_raw = grid.get("points")
240
+ if not isinstance(points_raw, int) or isinstance(points_raw, bool):
241
+ raise ConfigurationError("adapter.options.t_eval.points must be an integer")
242
+ t_eval: Sequence[float] | int = points_raw
243
+ elif isinstance(t_eval_raw, list):
244
+ t_eval = _finite_sequence(t_eval_raw, "adapter.options.t_eval")
245
+ else:
246
+ raise ConfigurationError("adapter.options.t_eval must be an array or linspace object")
247
+ max_step_raw = options.get("max_step")
248
+ max_step = (
249
+ None
250
+ if max_step_raw is None
251
+ else _finite_number(max_step_raw, "adapter.options.max_step")
252
+ )
253
+ vectorized = options.get("vectorized", False)
254
+ dense_output = options.get("dense_output", False)
255
+ if not isinstance(vectorized, bool) or not isinstance(dense_output, bool):
256
+ raise ConfigurationError("adapter.options.vectorized and dense_output must be booleans")
257
+ return cls(
258
+ name=name,
259
+ identity=_string(options.get("identity"), "adapter.options.identity"),
260
+ rhs=rhs,
261
+ state_names=cast(list[str], state_names_raw),
262
+ initial_state=_finite_sequence(
263
+ options.get("initial_state"), "adapter.options.initial_state"
264
+ ),
265
+ t_span=(t_span_values[0], t_span_values[1]),
266
+ t_eval=t_eval,
267
+ method=_string(options.get("method", "RK45"), "adapter.options.method"),
268
+ rtol=_finite_number(options.get("rtol", 1e-3), "adapter.options.rtol"),
269
+ atol=atol,
270
+ max_step=max_step,
271
+ events=events,
272
+ observable=observable,
273
+ classifier=classifier,
274
+ invariant=invariant,
275
+ vectorized=vectorized,
276
+ dense_output=dense_output,
277
+ )
278
+
279
+ def _evaluation_grid(
280
+ self, requested: Sequence[float] | int
281
+ ) -> tuple[tuple[float, ...], dict[str, object]]:
282
+ start, stop = self._t_span
283
+ if isinstance(requested, bool):
284
+ raise ConfigurationError("t_eval must be an integer point count or numeric sequence")
285
+ if isinstance(requested, int):
286
+ if requested < 2 or requested > MAX_EVALUATION_POINTS:
287
+ raise ConfigurationError(
288
+ f"t_eval point count must be between 2 and {MAX_EVALUATION_POINTS}"
289
+ )
290
+ values = tuple(
291
+ start + (stop - start) * index / (requested - 1) for index in range(requested)
292
+ )
293
+ configuration: dict[str, object] = {
294
+ "kind": "linspace",
295
+ "start": start,
296
+ "stop": stop,
297
+ "points": requested,
298
+ }
299
+ else:
300
+ values = _finite_sequence(tuple(requested), "t_eval")
301
+ if len(values) < 2 or len(values) > MAX_EVALUATION_POINTS:
302
+ raise ConfigurationError(
303
+ f"t_eval must contain between 2 and {MAX_EVALUATION_POINTS} values"
304
+ )
305
+ configuration = {"kind": "explicit", "values": list(values)}
306
+ direction = 1.0 if stop > start else -1.0
307
+ if values[0] != start or values[-1] != stop:
308
+ raise ConfigurationError("t_eval must include both t_span endpoints")
309
+ if any(direction * (right - left) <= 0.0 for left, right in pairwise(values)):
310
+ raise ConfigurationError("t_eval must be strictly ordered in the t_span direction")
311
+ return values, configuration
312
+
313
+ def configuration(self) -> Mapping[str, object]:
314
+ """Return stable JSON metadata; callables are represented only by declared labels."""
315
+
316
+ return {
317
+ "adapter": "scipy.solve_ivp",
318
+ "identity": self.explicit_identity,
319
+ "state_names": list(self.dimensions),
320
+ "initial_state": list(self._initial_state),
321
+ "t_span": list(self._t_span),
322
+ "t_eval": self._grid_configuration,
323
+ "method": self.method,
324
+ "rtol": self.rtol,
325
+ "atol": list(self.atol) if isinstance(self.atol, tuple) else self.atol,
326
+ "max_step": self.max_step,
327
+ "events": [event.as_dict() for event in self.events],
328
+ "vectorized": self.vectorized,
329
+ "dense_output": self.dense_output,
330
+ }
331
+
332
+ def initial_state(self, config: ModelConfig, seed: int) -> State:
333
+ """Return the serialized initial state; config and seed remain replay evidence."""
334
+
335
+ return self._initial_state
336
+
337
+ def _default_observable(
338
+ self, time: float, state: State, parameters: Parameters
339
+ ) -> Mapping[str, Scalar]:
340
+ return dict(zip(self.dimensions, state, strict=True))
341
+
342
+ @staticmethod
343
+ def _default_classifier(trace: SimulationTrace, tolerances: Tolerances) -> str:
344
+ return "completed"
345
+
346
+ @staticmethod
347
+ def _default_invariants(
348
+ trace: SimulationTrace, parameters: Parameters, tolerances: Tolerances
349
+ ) -> list[InvariantResult]:
350
+ return []
351
+
352
+ def _checked_rhs(self, parameters: Parameters) -> Callable[[float, FloatArray], FloatArray]:
353
+ def evaluate(time: float, state: FloatArray) -> FloatArray:
354
+ raw: Any = self._rhs(time, state, parameters)
355
+ if np.iscomplexobj(raw):
356
+ raise NumericalFailure(
357
+ "SolveIVPAdapter supports real-valued states only; split complex states into "
358
+ "real and imaginary components"
359
+ )
360
+ result = np.asarray(raw, dtype=float)
361
+ if result.shape != state.shape:
362
+ raise NumericalFailure(f"RHS returned shape {result.shape}; expected {state.shape}")
363
+ if not np.isfinite(result).all():
364
+ raise NumericalFailure("RHS returned NaN or infinite derivative values")
365
+ return cast(FloatArray, result)
366
+
367
+ return evaluate
368
+
369
+ def _point(self, index: int, time: float, state: object, parameters: Parameters) -> TracePoint:
370
+ values_array = np.asarray(state)
371
+ if np.iscomplexobj(values_array):
372
+ raise NumericalFailure("solve_ivp returned a complex state to a real-valued adapter")
373
+ values = tuple(float(value) for value in values_array)
374
+ if len(values) != len(self.dimensions) or not all(math.isfinite(x) for x in values):
375
+ raise NumericalFailure("solve_ivp returned an invalid state shape or NaN/Inf value")
376
+ observations = dict(self._observable(time, values, parameters))
377
+ for key, value in observations.items():
378
+ if not isinstance(key, str) or not key:
379
+ raise NumericalFailure("observable names must be non-empty strings")
380
+ if isinstance(value, int | float) and not isinstance(value, bool):
381
+ if not math.isfinite(float(value)):
382
+ raise NumericalFailure(f"observable {key!r} is NaN or infinite")
383
+ elif not isinstance(value, str):
384
+ raise NumericalFailure(f"observable {key!r} must be a finite number or string")
385
+ return TracePoint(step=index, time=float(time), state=values, observations=observations)
386
+
387
+ def simulate(
388
+ self,
389
+ initial_state: State,
390
+ parameters: Parameters,
391
+ config: ModelConfig,
392
+ seed: int,
393
+ ) -> SimulationTrace:
394
+ """Run one public ``solve_ivp`` call and retain its declared evaluation grid."""
395
+
396
+ if len(initial_state) != len(self.dimensions) or not all(
397
+ math.isfinite(value) for value in initial_state
398
+ ):
399
+ raise NumericalFailure("initial state must match state_names and contain finite values")
400
+ parameter_snapshot = dict(parameters)
401
+ wrappers = tuple(_EventWrapper(event, parameter_snapshot) for event in self.events)
402
+ try:
403
+ solution: Any = solve_ivp(
404
+ self._checked_rhs(parameter_snapshot),
405
+ self._t_span,
406
+ np.asarray(initial_state, dtype=float),
407
+ method=self.method,
408
+ t_eval=np.asarray(self._t_eval, dtype=float),
409
+ dense_output=self.dense_output,
410
+ events=wrappers or None,
411
+ vectorized=self.vectorized,
412
+ rtol=self.rtol,
413
+ atol=np.asarray(self.atol, dtype=float)
414
+ if isinstance(self.atol, tuple)
415
+ else self.atol,
416
+ max_step=np.inf if self.max_step is None else self.max_step,
417
+ )
418
+ except NumericalFailure:
419
+ raise
420
+ except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
421
+ raise NumericalFailure(f"solve_ivp failed before returning a result: {exc}") from exc
422
+
423
+ times = np.asarray(solution.t)
424
+ states = np.asarray(solution.y)
425
+ if times.ndim != 1 or states.shape != (len(self.dimensions), len(times)):
426
+ raise NumericalFailure("solve_ivp returned an invalid trajectory shape")
427
+ if not np.isfinite(times).all() or not np.isfinite(states).all():
428
+ raise NumericalFailure("solve_ivp returned NaN or infinite trajectory values")
429
+ points = [
430
+ self._point(index, float(time), states[:, index], parameter_snapshot)
431
+ for index, time in enumerate(times)
432
+ ]
433
+ event_evidence: list[dict[str, object]] = []
434
+ terminal_candidates: list[tuple[float, State]] = []
435
+ t_events = solution.t_events or []
436
+ y_events = solution.y_events or []
437
+ for index, spec in enumerate(self.events):
438
+ event_times = np.asarray(t_events[index])
439
+ event_states = np.asarray(y_events[index])
440
+ if len(event_times) > MAX_EVENT_POINTS:
441
+ raise NumericalFailure(
442
+ f"event {spec.name!r} exceeded the bounded event retention limit"
443
+ )
444
+ serialized_states: list[list[float]] = []
445
+ serialized_times: list[float] = []
446
+ for event_time, event_state in zip(event_times, event_states, strict=True):
447
+ point = self._point(len(points), float(event_time), event_state, parameter_snapshot)
448
+ serialized_times.append(point.time)
449
+ serialized_states.append(list(point.state))
450
+ terminal_candidates.append((point.time, point.state))
451
+ event_evidence.append(
452
+ {
453
+ "name": spec.name,
454
+ "terminal": spec.terminal,
455
+ "direction": float(spec.direction),
456
+ "times": serialized_times,
457
+ "states": serialized_states,
458
+ }
459
+ )
460
+ if not points:
461
+ raise NumericalFailure("solve_ivp returned no retained evaluation points")
462
+ final_state = points[-1].state
463
+ termination_time = points[-1].time
464
+ if int(solution.status) == 1 and terminal_candidates:
465
+ direction = 1.0 if self._t_span[1] > self._t_span[0] else -1.0
466
+ termination_time, final_state = max(
467
+ terminal_candidates, key=lambda item: direction * item[0]
468
+ )
469
+ if not math.isclose(points[-1].time, termination_time, rel_tol=0.0, abs_tol=1e-15):
470
+ points.append(
471
+ self._point(len(points), termination_time, final_state, parameter_snapshot)
472
+ )
473
+ metadata: dict[str, object] = {
474
+ "python_version": platform.python_version(),
475
+ "python_implementation": platform.python_implementation(),
476
+ "numpy_version": np.__version__,
477
+ "scipy_version": scipy.__version__,
478
+ "platform": {
479
+ "system": platform.system(),
480
+ "release": platform.release(),
481
+ "machine": platform.machine(),
482
+ "architecture_bits": 64 if sys.maxsize > 2**32 else 32,
483
+ "byteorder": sys.byteorder,
484
+ },
485
+ "solver_method": self.method,
486
+ "rtol": self.rtol,
487
+ "atol": list(self.atol) if isinstance(self.atol, tuple) else self.atol,
488
+ "evaluation_grid": self._grid_configuration,
489
+ "maximum_step": self.max_step,
490
+ "t_span": list(self._t_span),
491
+ "initial_state": list(initial_state),
492
+ "parameters": parameter_snapshot,
493
+ "event_configuration": [event.as_dict() for event in self.events],
494
+ "events": event_evidence,
495
+ "solver_success": bool(solution.success),
496
+ "solver_status": int(solution.status),
497
+ "solver_message": str(solution.message),
498
+ "termination_time": termination_time,
499
+ "nfev": int(solution.nfev),
500
+ "njev": int(solution.njev),
501
+ "nlu": int(solution.nlu),
502
+ "vectorized": self.vectorized,
503
+ "dense_output": self.dense_output,
504
+ "seed": seed,
505
+ }
506
+ return SimulationTrace(
507
+ points=tuple(points),
508
+ final_state=final_state,
509
+ success=bool(solution.success),
510
+ status=int(solution.status),
511
+ message=str(solution.message),
512
+ metadata=metadata,
513
+ )
514
+
515
+ def observe(self, trace: SimulationTrace) -> Mapping[str, Scalar]:
516
+ """Return the final retained point's observables."""
517
+
518
+ return dict(trace.points[-1].observations)
519
+
520
+ def classify(self, trace: SimulationTrace, tolerances: Tolerances) -> str:
521
+ """Delegate qualitative interpretation to the declared callback."""
522
+
523
+ result = self._classifier(trace, tolerances)
524
+ if not isinstance(result, str) or not result:
525
+ raise NumericalFailure("classifier must return a non-empty string")
526
+ return result
527
+
528
+ def invariants(
529
+ self,
530
+ trace: SimulationTrace,
531
+ parameters: Parameters,
532
+ tolerances: Tolerances,
533
+ ) -> list[InvariantResult]:
534
+ """Delegate invariant evaluation to the declared callback."""
535
+
536
+ return list(self._invariant(trace, parameters, tolerances))
537
+
538
+
539
+ __all__ = ["SUPPORTED_METHODS", "EventSpec", "SolveIVPAdapter"]