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.
- phaseprobe/__init__.py +30 -0
- phaseprobe/__main__.py +5 -0
- phaseprobe/adapters/__init__.py +4 -0
- phaseprobe/adapters/loader.py +51 -0
- phaseprobe/adapters/scipy.py +539 -0
- phaseprobe/api.py +64 -0
- phaseprobe/artifacts.py +124 -0
- phaseprobe/cli.py +242 -0
- phaseprobe/config.py +132 -0
- phaseprobe/data/__init__.py +1 -0
- phaseprobe/data/examples/__init__.py +1 -0
- phaseprobe/data/examples/logistic-negative.json +27 -0
- phaseprobe/data/examples/logistic-scan.json +27 -0
- phaseprobe/data/examples/lorenz-negative.json +30 -0
- phaseprobe/data/examples/lorenz-perturb.json +30 -0
- phaseprobe/data/examples/predator-prey-check.json +25 -0
- phaseprobe/data/examples/predator-prey-negative.json +25 -0
- phaseprobe/data/examples/toggle-negative.json +30 -0
- phaseprobe/data/examples/toggle-perturb.json +31 -0
- phaseprobe/engine.py +810 -0
- phaseprobe/errors.py +31 -0
- phaseprobe/examples/__init__.py +1 -0
- phaseprobe/examples/scipy_models.py +151 -0
- phaseprobe/generate.py +72 -0
- phaseprobe/models/__init__.py +36 -0
- phaseprobe/models/_common.py +56 -0
- phaseprobe/models/logistic.py +63 -0
- phaseprobe/models/lorenz.py +64 -0
- phaseprobe/models/predator_prey.py +86 -0
- phaseprobe/models/toggle.py +73 -0
- phaseprobe/replay.py +496 -0
- phaseprobe/reporting.py +173 -0
- phaseprobe/types.py +131 -0
- phaseprobe-0.2.0.dist-info/METADATA +275 -0
- phaseprobe-0.2.0.dist-info/RECORD +38 -0
- phaseprobe-0.2.0.dist-info/WHEEL +4 -0
- phaseprobe-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|