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/errors.py ADDED
@@ -0,0 +1,31 @@
1
+ """PhaseProbe exception hierarchy and documented process exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class ExitCode(IntEnum):
9
+ """Stable CLI exit-code contract."""
10
+
11
+ OK = 0
12
+ POLICY_FAILED = 1
13
+ INVALID_INPUT = 2
14
+ NUMERICAL_FAILURE = 3
15
+ INTERNAL_ERROR = 4
16
+
17
+
18
+ class PhaseProbeError(Exception):
19
+ """Base class for expected PhaseProbe failures."""
20
+
21
+
22
+ class ConfigurationError(PhaseProbeError):
23
+ """Raised when a configuration or user input is invalid."""
24
+
25
+
26
+ class NumericalFailure(PhaseProbeError):
27
+ """Raised when integration produces an invalid state or solver failure."""
28
+
29
+
30
+ class IntegrityError(PhaseProbeError):
31
+ """Raised when a replay fixture fails integrity validation."""
@@ -0,0 +1 @@
1
+ """Reference model definitions used by reproducible optional-dependency examples."""
@@ -0,0 +1,151 @@
1
+ """Genuine SciPy-backed Lorenz and Lotka-Volterra reference adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+
11
+ from phaseprobe.adapters.scipy import SolveIVPAdapter
12
+ from phaseprobe.types import (
13
+ InvariantResult,
14
+ Parameters,
15
+ Scalar,
16
+ SimulationTrace,
17
+ State,
18
+ Tolerances,
19
+ )
20
+
21
+ FloatArray = npt.NDArray[np.float64]
22
+
23
+
24
+ def lorenz_rhs(time: float, state: FloatArray, parameters: Parameters) -> tuple[float, ...]:
25
+ """Classic Lorenz equations; time is accepted by the solve_ivp contract."""
26
+
27
+ x, y, z = state
28
+ sigma = parameters["sigma"]
29
+ rho = parameters["rho"]
30
+ beta = parameters["beta"]
31
+ return (sigma * (y - x), x * (rho - z) - y, x * y - beta * z)
32
+
33
+
34
+ def lorenz_observable(time: float, state: State, parameters: Parameters) -> Mapping[str, Scalar]:
35
+ x, y, z = state
36
+ return {"x": x, "y": y, "z": z, "radius": math.sqrt(x * x + y * y + z * z)}
37
+
38
+
39
+ def lorenz_classifier(trace: SimulationTrace, tolerances: Tolerances) -> str:
40
+ bound = tolerances.get("state_bound", 100.0)
41
+ maximum = max(abs(value) for point in trace.points for value in point.state)
42
+ return "bounded-finite-window" if maximum <= bound else "declared-bound-exceeded"
43
+
44
+
45
+ def lorenz_invariants(
46
+ trace: SimulationTrace, parameters: Parameters, tolerances: Tolerances
47
+ ) -> list[InvariantResult]:
48
+ bound = tolerances.get("state_bound", 100.0)
49
+ maximum = max(abs(value) for point in trace.points for value in point.state)
50
+ return [
51
+ InvariantResult(
52
+ name="declared-state-bound",
53
+ passed=maximum <= bound,
54
+ measured=maximum,
55
+ tolerance=bound,
56
+ detail="Maximum absolute state over the declared finite observation window.",
57
+ )
58
+ ]
59
+
60
+
61
+ def lorenz_adapter(values: Mapping[str, object]) -> SolveIVPAdapter:
62
+ """Factory named by the explicit CLI configuration."""
63
+
64
+ return SolveIVPAdapter.from_config(
65
+ name="lorenz-scipy",
66
+ rhs=lorenz_rhs,
67
+ values=values,
68
+ observable=lorenz_observable,
69
+ classifier=lorenz_classifier,
70
+ invariant=lorenz_invariants,
71
+ )
72
+
73
+
74
+ def predator_prey_rhs(time: float, state: FloatArray, parameters: Parameters) -> tuple[float, ...]:
75
+ prey, predator = state
76
+ alpha = parameters["alpha"]
77
+ beta = parameters["beta"]
78
+ delta = parameters["delta"]
79
+ gamma = parameters["gamma"]
80
+ return (
81
+ alpha * prey - beta * prey * predator,
82
+ delta * prey * predator - gamma * predator,
83
+ )
84
+
85
+
86
+ def _first_integral(state: State, parameters: Parameters) -> float:
87
+ prey, predator = state
88
+ if prey <= 0.0 or predator <= 0.0:
89
+ return math.inf
90
+ return (
91
+ parameters["delta"] * prey
92
+ - parameters["gamma"] * math.log(prey)
93
+ + parameters["beta"] * predator
94
+ - parameters["alpha"] * math.log(predator)
95
+ )
96
+
97
+
98
+ def predator_prey_observable(
99
+ time: float, state: State, parameters: Parameters
100
+ ) -> Mapping[str, Scalar]:
101
+ return {
102
+ "prey": state[0],
103
+ "predator": state[1],
104
+ "first_integral": _first_integral(state, parameters),
105
+ }
106
+
107
+
108
+ def predator_prey_classifier(trace: SimulationTrace, tolerances: Tolerances) -> str:
109
+ positive = all(point.state[0] > 0.0 and point.state[1] > 0.0 for point in trace.points)
110
+ return "positive-oscillation" if positive else "non-positive-population"
111
+
112
+
113
+ def predator_prey_invariants(
114
+ trace: SimulationTrace, parameters: Parameters, tolerances: Tolerances
115
+ ) -> list[InvariantResult]:
116
+ threshold = tolerances.get("invariant_drift", 1e-8)
117
+ reference = _first_integral(trace.points[0].state, parameters)
118
+ drift = max(abs(_first_integral(point.state, parameters) - reference) for point in trace.points)
119
+ minimum_population = min(value for point in trace.points for value in point.state)
120
+ return [
121
+ InvariantResult(
122
+ name="lotka-volterra-first-integral-drift",
123
+ passed=math.isfinite(drift) and drift <= threshold,
124
+ measured=drift,
125
+ tolerance=threshold,
126
+ detail="Maximum absolute drift of the declared Lotka-Volterra first integral.",
127
+ ),
128
+ InvariantResult(
129
+ name="positive-populations",
130
+ passed=minimum_population > 0.0,
131
+ measured=minimum_population,
132
+ tolerance=0.0,
133
+ detail="Minimum retained population; the policy requires a value strictly above zero.",
134
+ ),
135
+ ]
136
+
137
+
138
+ def predator_prey_adapter(values: Mapping[str, object]) -> SolveIVPAdapter:
139
+ """Factory named by the explicit CLI configuration."""
140
+
141
+ return SolveIVPAdapter.from_config(
142
+ name="predator-prey-scipy",
143
+ rhs=predator_prey_rhs,
144
+ values=values,
145
+ observable=predator_prey_observable,
146
+ classifier=predator_prey_classifier,
147
+ invariant=predator_prey_invariants,
148
+ )
149
+
150
+
151
+ __all__ = ["lorenz_adapter", "predator_prey_adapter"]
phaseprobe/generate.py ADDED
@@ -0,0 +1,72 @@
1
+ """Safe deterministic pytest generation from validated replay fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from phaseprobe.replay import validate_fixture, verify_replay
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class GeneratedTest:
15
+ """Generated fixed-template test and copied integrity-protected fixture."""
16
+
17
+ test_path: Path
18
+ fixture_path: Path
19
+
20
+
21
+ def _safe_name(value: object) -> str:
22
+ candidate = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_")
23
+ return candidate[:64] or "model"
24
+
25
+
26
+ def generate_regression_test(fixture: Path, output_directory: Path) -> GeneratedTest:
27
+ """Validate evidence, copy its fixture, and emit a non-extensible pytest template."""
28
+
29
+ payload = validate_fixture(fixture)
30
+ verification = verify_replay(fixture)
31
+ if not verification.ok:
32
+ raise ValueError(
33
+ "replay fixture does not reproduce; refusing to generate a regression test"
34
+ )
35
+ model_name = _safe_name(payload.get("model"))
36
+ output_directory.mkdir(parents=True, exist_ok=True)
37
+ fixture_directory = output_directory / "fixtures"
38
+ fixture_directory.mkdir(exist_ok=True)
39
+ copied_fixture = fixture_directory / f"{model_name}-replay.json"
40
+ shutil.copyfile(fixture, copied_fixture)
41
+ test_path = output_directory / f"test_{model_name}_transition.py"
42
+ baseline = payload.get("baseline")
43
+ metadata = baseline.get("execution_metadata") if isinstance(baseline, dict) else None
44
+ adapter_configuration = (
45
+ metadata.get("adapter_configuration") if isinstance(metadata, dict) else None
46
+ )
47
+ scipy_backed = (
48
+ isinstance(adapter_configuration, dict)
49
+ and adapter_configuration.get("adapter") == "scipy.solve_ivp"
50
+ )
51
+ scipy_import = "import pytest\n" if scipy_backed else ""
52
+ scipy_guard = (
53
+ '\npytest.importorskip("scipy")\npytestmark = pytest.mark.scipy\n' if scipy_backed else ""
54
+ )
55
+ source = f'''"""Generated by PhaseProbe from a validated replay fixture."""
56
+
57
+ from pathlib import Path
58
+
59
+ {scipy_import}
60
+ from phaseprobe.replay import verify_replay
61
+ {scipy_guard}
62
+
63
+ FIXTURE = Path(__file__).parent / "fixtures" / "{model_name}-replay.json"
64
+
65
+
66
+ def test_{model_name}_transition_replays() -> None:
67
+ """Re-execute model/config/seed and verify the fixture's declared replay policy."""
68
+ result = verify_replay(FIXTURE)
69
+ assert result.ok, result.as_dict()
70
+ '''
71
+ test_path.write_text(source, encoding="utf-8", newline="\n")
72
+ return GeneratedTest(test_path=test_path, fixture_path=copied_fixture)
@@ -0,0 +1,36 @@
1
+ """Built-in deterministic model adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import cast
7
+
8
+ from phaseprobe.errors import ConfigurationError
9
+ from phaseprobe.models.logistic import LogisticMapAdapter
10
+ from phaseprobe.models.lorenz import LorenzAdapter
11
+ from phaseprobe.models.predator_prey import PredatorPreyAdapter
12
+ from phaseprobe.models.toggle import GeneticToggleAdapter
13
+ from phaseprobe.types import ModelAdapter
14
+
15
+ _MODELS: Mapping[str, ModelAdapter] = {
16
+ "logistic-map": cast(ModelAdapter, LogisticMapAdapter()),
17
+ "lorenz": cast(ModelAdapter, LorenzAdapter()),
18
+ "predator-prey": cast(ModelAdapter, PredatorPreyAdapter()),
19
+ "genetic-toggle": cast(ModelAdapter, GeneticToggleAdapter()),
20
+ }
21
+
22
+
23
+ def get_model(name: str) -> ModelAdapter:
24
+ """Return a built-in model adapter by stable identity."""
25
+
26
+ model = _MODELS.get(name)
27
+ if model is None:
28
+ choices = ", ".join(sorted(_MODELS))
29
+ raise ConfigurationError(f"unknown model {name!r}; choose one of: {choices}")
30
+ return model
31
+
32
+
33
+ def model_names() -> tuple[str, ...]:
34
+ """Return stable built-in model names."""
35
+
36
+ return tuple(sorted(_MODELS))
@@ -0,0 +1,56 @@
1
+ """Shared validation and fixed-step integration helpers for built-in adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+
7
+ from phaseprobe.errors import ConfigurationError
8
+ from phaseprobe.types import State
9
+
10
+ Derivative = Callable[[State], State]
11
+
12
+
13
+ def float_value(values: Mapping[str, object], name: str, default: float | None = None) -> float:
14
+ """Read a finite-number-shaped value; runtime finite checks happen in the engine."""
15
+
16
+ value = values.get(name, default)
17
+ if not isinstance(value, int | float) or isinstance(value, bool):
18
+ raise ConfigurationError(f"{name} must be a number")
19
+ return float(value)
20
+
21
+
22
+ def initial_state(
23
+ config: Mapping[str, object], dimensions: Sequence[str], defaults: State
24
+ ) -> State:
25
+ """Read an optional named initial-state object."""
26
+
27
+ raw = config.get("initial_state")
28
+ if raw is None:
29
+ return defaults
30
+ if not isinstance(raw, dict):
31
+ raise ConfigurationError("model.initial_state must be an object")
32
+ values = raw
33
+ return tuple(float_value(values, dimension) for dimension in dimensions)
34
+
35
+
36
+ def parameter(parameters: Mapping[str, float], name: str, default: float) -> float:
37
+ """Read a model parameter with a documented default."""
38
+
39
+ return float(parameters.get(name, default))
40
+
41
+
42
+ def rk4_step(state: State, dt: float, derivative: Derivative) -> State:
43
+ """Advance an autonomous ODE with a deterministic classical RK4 step."""
44
+
45
+ k1 = derivative(state)
46
+ k2 = derivative(
47
+ tuple(value + 0.5 * dt * slope for value, slope in zip(state, k1, strict=False))
48
+ )
49
+ k3 = derivative(
50
+ tuple(value + 0.5 * dt * slope for value, slope in zip(state, k2, strict=False))
51
+ )
52
+ k4 = derivative(tuple(value + dt * slope for value, slope in zip(state, k3, strict=False)))
53
+ return tuple(
54
+ value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
55
+ for value, a, b, c, d in zip(state, k1, k2, k3, k4, strict=False)
56
+ )
@@ -0,0 +1,63 @@
1
+ """Logistic-map adapter and finite-period classifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from phaseprobe.models._common import initial_state, parameter
9
+ from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint
10
+
11
+
12
+ class LogisticMapAdapter:
13
+ """The discrete logistic map ``x[n+1] = r*x[n]*(1-x[n])``."""
14
+
15
+ name = "logistic-map"
16
+ identity = "phaseprobe.builtin.logistic-map:v1"
17
+ dimensions = ("x",)
18
+
19
+ def initial_state(self, config: Mapping[str, object], seed: int) -> State:
20
+ del seed
21
+ return initial_state(config, self.dimensions, (0.2,))
22
+
23
+ def step(self, state: State, parameters: Parameters, dt: float) -> State:
24
+ del dt
25
+ r = parameter(parameters, "r", 3.44)
26
+ x = state[0]
27
+ return (r * x * (1.0 - x),)
28
+
29
+ def observe(self, state: State) -> Mapping[str, float]:
30
+ return {"x": state[0]}
31
+
32
+ def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str:
33
+ values = [point.state[0] for point in trace]
34
+ tolerance = tolerances.get("period", 1e-8)
35
+ for period in (1, 2, 4, 8, 16):
36
+ if len(values) < 4 * period:
37
+ continue
38
+ tail = values[-4 * period :]
39
+ error = max(
40
+ abs(tail[index] - tail[index - period]) for index in range(period, len(tail))
41
+ )
42
+ if error <= tolerance:
43
+ return f"period-{period}"
44
+ return "aperiodic-or-unresolved"
45
+
46
+ def invariants(
47
+ self,
48
+ trace: Sequence[TracePoint],
49
+ parameters: Parameters,
50
+ tolerances: Tolerances,
51
+ ) -> list[InvariantResult]:
52
+ del parameters, tolerances
53
+ values = [point.state[0] for point in trace]
54
+ violation = max((max(-value, value - 1.0, 0.0) for value in values), default=0.0)
55
+ return [
56
+ InvariantResult(
57
+ name="unit-interval-boundedness",
58
+ passed=math.isfinite(violation) and violation == 0.0,
59
+ measured=violation,
60
+ tolerance=0.0,
61
+ detail="For 0 <= r <= 4 and 0 <= x <= 1, retained states should remain in [0, 1].",
62
+ )
63
+ ]
@@ -0,0 +1,64 @@
1
+ """Lorenz-system adapter for finite-time trajectory-divergence evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from phaseprobe.models._common import initial_state, parameter, rk4_step
9
+ from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint
10
+
11
+
12
+ class LorenzAdapter:
13
+ """Classical three-variable Lorenz equations integrated with fixed-step RK4."""
14
+
15
+ name = "lorenz"
16
+ identity = "phaseprobe.builtin.lorenz:v1"
17
+ dimensions = ("x", "y", "z")
18
+
19
+ def initial_state(self, config: Mapping[str, object], seed: int) -> State:
20
+ del seed
21
+ return initial_state(config, self.dimensions, (1.0, 1.0, 1.0))
22
+
23
+ def step(self, state: State, parameters: Parameters, dt: float) -> State:
24
+ sigma = parameter(parameters, "sigma", 10.0)
25
+ rho = parameter(parameters, "rho", 28.0)
26
+ beta = parameter(parameters, "beta", 8.0 / 3.0)
27
+
28
+ def derivative(current: State) -> State:
29
+ x, y, z = current
30
+ return (sigma * (y - x), x * (rho - z) - y, x * y - beta * z)
31
+
32
+ return rk4_step(state, dt, derivative)
33
+
34
+ def observe(self, state: State) -> Mapping[str, float]:
35
+ x, y, z = state
36
+ return {"x": x, "y": y, "z": z, "radius": math.sqrt(x * x + y * y + z * z)}
37
+
38
+ def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str:
39
+ del tolerances
40
+ signs = {point.state[0] >= 0.0 for point in trace}
41
+ if len(signs) > 1:
42
+ return "two-lobe-finite-time-trajectory"
43
+ if trace and trace[-1].state[0] >= 0.0:
44
+ return "positive-lobe-finite-time-trajectory"
45
+ return "negative-lobe-finite-time-trajectory"
46
+
47
+ def invariants(
48
+ self,
49
+ trace: Sequence[TracePoint],
50
+ parameters: Parameters,
51
+ tolerances: Tolerances,
52
+ ) -> list[InvariantResult]:
53
+ del parameters
54
+ bound = tolerances.get("state_bound", 100.0)
55
+ measured = max((abs(value) for point in trace for value in point.state), default=0.0)
56
+ return [
57
+ InvariantResult(
58
+ name="declared-finite-state-bound",
59
+ passed=math.isfinite(measured) and measured <= bound,
60
+ measured=measured,
61
+ tolerance=bound,
62
+ detail="A diagnostic finite-time bound, not a mathematical invariant of the Lorenz system.",
63
+ )
64
+ ]
@@ -0,0 +1,86 @@
1
+ """Lotka-Volterra predator-prey adapter with a conserved-quantity diagnostic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from phaseprobe.models._common import initial_state, parameter, rk4_step
9
+ from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint
10
+
11
+
12
+ class PredatorPreyAdapter:
13
+ """Conservative two-species Lotka-Volterra equations."""
14
+
15
+ name = "predator-prey"
16
+ identity = "phaseprobe.builtin.predator-prey:v1"
17
+ dimensions = ("prey", "predator")
18
+
19
+ def initial_state(self, config: Mapping[str, object], seed: int) -> State:
20
+ del seed
21
+ return initial_state(config, self.dimensions, (10.0, 5.0))
22
+
23
+ def step(self, state: State, parameters: Parameters, dt: float) -> State:
24
+ alpha = parameter(parameters, "alpha", 1.1)
25
+ beta = parameter(parameters, "beta", 0.4)
26
+ delta = parameter(parameters, "delta", 0.1)
27
+ gamma = parameter(parameters, "gamma", 0.4)
28
+
29
+ def derivative(current: State) -> State:
30
+ prey, predator = current
31
+ return (
32
+ alpha * prey - beta * prey * predator,
33
+ delta * prey * predator - gamma * predator,
34
+ )
35
+
36
+ return rk4_step(state, dt, derivative)
37
+
38
+ def observe(self, state: State) -> Mapping[str, float]:
39
+ return {"prey": state[0], "predator": state[1]}
40
+
41
+ def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str:
42
+ del tolerances
43
+ if not trace:
44
+ return "unresolved"
45
+ minima = [min(point.state[index] for point in trace) for index in (0, 1)]
46
+ return "bounded-positive-oscillation" if min(minima) > 0.0 else "invalid-population"
47
+
48
+ def invariants(
49
+ self,
50
+ trace: Sequence[TracePoint],
51
+ parameters: Parameters,
52
+ tolerances: Tolerances,
53
+ ) -> list[InvariantResult]:
54
+ alpha = parameter(parameters, "alpha", 1.1)
55
+ beta = parameter(parameters, "beta", 0.4)
56
+ delta = parameter(parameters, "delta", 0.1)
57
+ gamma = parameter(parameters, "gamma", 0.4)
58
+ tolerance = tolerances.get("invariant_drift", 1e-5)
59
+
60
+ def conserved(state: State) -> float:
61
+ prey, predator = state
62
+ if prey <= 0.0 or predator <= 0.0:
63
+ return math.inf
64
+ return (
65
+ delta * prey - gamma * math.log(prey) + beta * predator - alpha * math.log(predator)
66
+ )
67
+
68
+ values = [conserved(point.state) for point in trace]
69
+ drift = max((abs(value - values[0]) for value in values), default=0.0)
70
+ min_population = min((value for point in trace for value in point.state), default=0.0)
71
+ return [
72
+ InvariantResult(
73
+ name="lotka-volterra-conserved-quantity",
74
+ passed=math.isfinite(drift) and drift <= tolerance,
75
+ measured=drift,
76
+ tolerance=tolerance,
77
+ detail="Maximum retained drift from the initial value of the analytic first integral.",
78
+ ),
79
+ InvariantResult(
80
+ name="positive-populations",
81
+ passed=min_population > 0.0,
82
+ measured=min_population,
83
+ tolerance=0.0,
84
+ detail="Both populations must remain strictly positive over the retained trajectory.",
85
+ ),
86
+ ]
@@ -0,0 +1,73 @@
1
+ """Small mutually repressing genetic-toggle adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from phaseprobe.models._common import initial_state, parameter, rk4_step
9
+ from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint
10
+
11
+
12
+ class GeneticToggleAdapter:
13
+ """Dimensionless two-gene mutual-repression model."""
14
+
15
+ name = "genetic-toggle"
16
+ identity = "phaseprobe.builtin.genetic-toggle:v1"
17
+ dimensions = ("u", "v")
18
+
19
+ def initial_state(self, config: Mapping[str, object], seed: int) -> State:
20
+ del seed
21
+ return initial_state(config, self.dimensions, (3.0, 0.2))
22
+
23
+ def step(self, state: State, parameters: Parameters, dt: float) -> State:
24
+ alpha_u = parameter(parameters, "alpha_u", 3.0)
25
+ alpha_v = parameter(parameters, "alpha_v", 3.0)
26
+ hill_u = parameter(parameters, "hill_u", 2.0)
27
+ hill_v = parameter(parameters, "hill_v", 2.0)
28
+
29
+ def derivative(current: State) -> State:
30
+ u, v = current
31
+ return (
32
+ alpha_u / (1.0 + v**hill_v) - u,
33
+ alpha_v / (1.0 + u**hill_u) - v,
34
+ )
35
+
36
+ return rk4_step(state, dt, derivative)
37
+
38
+ def observe(self, state: State) -> Mapping[str, float]:
39
+ u, v = state
40
+ return {"u": u, "v": v, "difference": u - v}
41
+
42
+ def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str:
43
+ if not trace:
44
+ return "unresolved"
45
+ threshold = tolerances.get("dominance", 0.05)
46
+ difference = trace[-1].state[0] - trace[-1].state[1]
47
+ if difference > threshold:
48
+ return "u-dominant"
49
+ if difference < -threshold:
50
+ return "v-dominant"
51
+ return "balanced"
52
+
53
+ def invariants(
54
+ self,
55
+ trace: Sequence[TracePoint],
56
+ parameters: Parameters,
57
+ tolerances: Tolerances,
58
+ ) -> list[InvariantResult]:
59
+ del parameters
60
+ bound = tolerances.get("state_bound", 10.0)
61
+ values = [value for point in trace for value in point.state]
62
+ low = min(values, default=0.0)
63
+ high = max(values, default=0.0)
64
+ violation = max(-low, high - bound, 0.0)
65
+ return [
66
+ InvariantResult(
67
+ name="nonnegative-declared-bound",
68
+ passed=math.isfinite(violation) and violation == 0.0,
69
+ measured=violation,
70
+ tolerance=0.0,
71
+ detail=f"Concentrations must remain in the declared interval [0, {bound:g}].",
72
+ )
73
+ ]