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/engine.py
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
"""Deterministic simulation, bounded search, refinement, and policy evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from phaseprobe.adapters.loader import load_configured_adapter
|
|
12
|
+
from phaseprobe.config import ProbeConfig, canonical_json
|
|
13
|
+
from phaseprobe.errors import ConfigurationError, NumericalFailure
|
|
14
|
+
from phaseprobe.models import get_model
|
|
15
|
+
from phaseprobe.types import (
|
|
16
|
+
InvariantResult,
|
|
17
|
+
ModelAdapter,
|
|
18
|
+
SimulationTrace,
|
|
19
|
+
State,
|
|
20
|
+
TracePoint,
|
|
21
|
+
TrajectoryAdapter,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Adapter = ModelAdapter | TrajectoryAdapter
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _number(values: Mapping[str, object], name: str, default: float | None = None) -> float:
|
|
28
|
+
value = values.get(name, default)
|
|
29
|
+
if not isinstance(value, int | float) or isinstance(value, bool):
|
|
30
|
+
raise ConfigurationError(f"{name} must be a number")
|
|
31
|
+
result = float(value)
|
|
32
|
+
if not math.isfinite(result):
|
|
33
|
+
raise ConfigurationError(f"{name} must be finite")
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _integer(values: Mapping[str, object], name: str, default: int | None = None) -> int:
|
|
38
|
+
value = values.get(name, default)
|
|
39
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
40
|
+
raise ConfigurationError(f"{name} must be an integer")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _boolean(values: Mapping[str, object], name: str, default: bool) -> bool:
|
|
45
|
+
value = values.get(name, default)
|
|
46
|
+
if not isinstance(value, bool):
|
|
47
|
+
raise ConfigurationError(f"{name} must be a boolean")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _string(values: Mapping[str, object], name: str, default: str | None = None) -> str:
|
|
52
|
+
value = values.get(name, default)
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise ConfigurationError(f"{name} must be a string")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _numeric_mapping(values: Mapping[str, object], context: str) -> dict[str, float]:
|
|
59
|
+
result: dict[str, float] = {}
|
|
60
|
+
for name, raw in values.items():
|
|
61
|
+
if not isinstance(raw, int | float) or isinstance(raw, bool):
|
|
62
|
+
raise ConfigurationError(f"{context}.{name} must be a number")
|
|
63
|
+
number = float(raw)
|
|
64
|
+
if not math.isfinite(number):
|
|
65
|
+
raise ConfigurationError(f"{context}.{name} must be finite")
|
|
66
|
+
result[name] = number
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class SimulationSettings:
|
|
72
|
+
"""Bounded execution settings shared by every adapter."""
|
|
73
|
+
|
|
74
|
+
steps: int
|
|
75
|
+
burn_in: int
|
|
76
|
+
dt: float
|
|
77
|
+
sample_every: int
|
|
78
|
+
trace_cap: int
|
|
79
|
+
hard_state_limit: float
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_config(cls, values: Mapping[str, object]) -> SimulationSettings:
|
|
83
|
+
settings = cls(
|
|
84
|
+
steps=_integer(values, "steps"),
|
|
85
|
+
burn_in=_integer(values, "burn_in", 0),
|
|
86
|
+
dt=_number(values, "dt", 1.0),
|
|
87
|
+
sample_every=_integer(values, "sample_every", 1),
|
|
88
|
+
trace_cap=_integer(values, "trace_cap", 2048),
|
|
89
|
+
hard_state_limit=_number(values, "hard_state_limit", 1e100),
|
|
90
|
+
)
|
|
91
|
+
if settings.steps <= 0 or settings.burn_in < 0:
|
|
92
|
+
raise ConfigurationError("simulation steps must be positive and burn_in non-negative")
|
|
93
|
+
if settings.dt <= 0.0 or settings.sample_every <= 0:
|
|
94
|
+
raise ConfigurationError("simulation dt and sample_every must be positive")
|
|
95
|
+
if settings.trace_cap < 16 or settings.trace_cap > 100_000:
|
|
96
|
+
raise ConfigurationError("simulation trace_cap must be between 16 and 100000")
|
|
97
|
+
if settings.hard_state_limit <= 0.0:
|
|
98
|
+
raise ConfigurationError("simulation hard_state_limit must be positive")
|
|
99
|
+
return settings
|
|
100
|
+
|
|
101
|
+
def as_dict(self) -> dict[str, object]:
|
|
102
|
+
return {
|
|
103
|
+
"steps": self.steps,
|
|
104
|
+
"burn_in": self.burn_in,
|
|
105
|
+
"dt": self.dt,
|
|
106
|
+
"sample_every": self.sample_every,
|
|
107
|
+
"trace_cap": self.trace_cap,
|
|
108
|
+
"hard_state_limit": self.hard_state_limit,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class TrajectorySettings:
|
|
114
|
+
"""Engine-owned bounds for one trajectory-level adapter execution."""
|
|
115
|
+
|
|
116
|
+
trace_cap: int
|
|
117
|
+
hard_state_limit: float
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_config(cls, values: Mapping[str, object]) -> TrajectorySettings:
|
|
121
|
+
settings = cls(
|
|
122
|
+
trace_cap=_integer(values, "trace_cap", 2048),
|
|
123
|
+
hard_state_limit=_number(values, "hard_state_limit", 1e100),
|
|
124
|
+
)
|
|
125
|
+
if settings.trace_cap < 16 or settings.trace_cap > 100_000:
|
|
126
|
+
raise ConfigurationError("simulation trace_cap must be between 16 and 100000")
|
|
127
|
+
if settings.hard_state_limit <= 0.0:
|
|
128
|
+
raise ConfigurationError("simulation hard_state_limit must be positive")
|
|
129
|
+
return settings
|
|
130
|
+
|
|
131
|
+
def as_dict(self) -> dict[str, object]:
|
|
132
|
+
return {
|
|
133
|
+
"mode": "trajectory",
|
|
134
|
+
"trace_cap": self.trace_cap,
|
|
135
|
+
"hard_state_limit": self.hard_state_limit,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _invariant_dict(result: InvariantResult) -> dict[str, object]:
|
|
140
|
+
return {
|
|
141
|
+
"name": result.name,
|
|
142
|
+
"passed": result.passed,
|
|
143
|
+
"measured": result.measured,
|
|
144
|
+
"tolerance": result.tolerance,
|
|
145
|
+
"detail": result.detail,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _point_dict(point: TracePoint) -> dict[str, object]:
|
|
150
|
+
return {
|
|
151
|
+
"step": point.step,
|
|
152
|
+
"time": point.time,
|
|
153
|
+
"state": list(point.state),
|
|
154
|
+
"observations": dict(point.observations),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def trace_hash(trace: Sequence[TracePoint]) -> str:
|
|
159
|
+
"""Hash the retained canonical trace exactly for deterministic replay."""
|
|
160
|
+
|
|
161
|
+
payload = [_point_dict(point) for point in trace]
|
|
162
|
+
return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class SimulationResult:
|
|
167
|
+
"""One bounded step-level or trajectory-level execution."""
|
|
168
|
+
|
|
169
|
+
model: str
|
|
170
|
+
model_identity: str
|
|
171
|
+
seed: int
|
|
172
|
+
parameters: Mapping[str, float]
|
|
173
|
+
initial_state: State
|
|
174
|
+
final_state: State
|
|
175
|
+
settings: SimulationSettings | TrajectorySettings
|
|
176
|
+
tolerances: Mapping[str, float]
|
|
177
|
+
classification: str
|
|
178
|
+
invariants: tuple[InvariantResult, ...]
|
|
179
|
+
observations: Mapping[str, object]
|
|
180
|
+
trace: tuple[TracePoint, ...]
|
|
181
|
+
trace_sha256: str
|
|
182
|
+
replay_mode: str
|
|
183
|
+
execution_metadata: Mapping[str, object]
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def invariant_violations(self) -> int:
|
|
187
|
+
return sum(not result.passed for result in self.invariants)
|
|
188
|
+
|
|
189
|
+
def as_dict(self, *, include_trace: bool = False) -> dict[str, object]:
|
|
190
|
+
payload: dict[str, object] = {
|
|
191
|
+
"model": self.model,
|
|
192
|
+
"model_identity": self.model_identity,
|
|
193
|
+
"seed": self.seed,
|
|
194
|
+
"parameters": dict(self.parameters),
|
|
195
|
+
"initial_state": list(self.initial_state),
|
|
196
|
+
"final_state": list(self.final_state),
|
|
197
|
+
"simulation": self.settings.as_dict(),
|
|
198
|
+
"tolerances": dict(self.tolerances),
|
|
199
|
+
"classification": self.classification,
|
|
200
|
+
"invariants": [_invariant_dict(result) for result in self.invariants],
|
|
201
|
+
"invariant_violations": self.invariant_violations,
|
|
202
|
+
"observations": dict(self.observations),
|
|
203
|
+
"trace_points_retained": len(self.trace),
|
|
204
|
+
"trace_sha256": self.trace_sha256,
|
|
205
|
+
"replay_mode": self.replay_mode,
|
|
206
|
+
"execution_metadata": dict(self.execution_metadata),
|
|
207
|
+
}
|
|
208
|
+
if include_trace:
|
|
209
|
+
payload["trace"] = [_point_dict(point) for point in self.trace]
|
|
210
|
+
return payload
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class ProbeOutcome:
|
|
215
|
+
"""Serializable result of a scan, perturbation search, or declared check."""
|
|
216
|
+
|
|
217
|
+
command: str
|
|
218
|
+
status: str
|
|
219
|
+
config: ProbeConfig
|
|
220
|
+
baseline: SimulationResult
|
|
221
|
+
changed: SimulationResult | None
|
|
222
|
+
finding: Mapping[str, object] | None
|
|
223
|
+
history: tuple[Mapping[str, object], ...]
|
|
224
|
+
reproducible: bool
|
|
225
|
+
policy_failed: bool = False
|
|
226
|
+
|
|
227
|
+
def as_dict(self) -> dict[str, object]:
|
|
228
|
+
return {
|
|
229
|
+
"schema_version": "2.0",
|
|
230
|
+
"command": self.command,
|
|
231
|
+
"status": self.status,
|
|
232
|
+
"model": self.baseline.model,
|
|
233
|
+
"source": self.config.source,
|
|
234
|
+
"configuration": dict(self.config.data),
|
|
235
|
+
"baseline": self.baseline.as_dict(),
|
|
236
|
+
"changed": self.changed.as_dict() if self.changed is not None else None,
|
|
237
|
+
"finding": dict(self.finding) if self.finding is not None else None,
|
|
238
|
+
"history": [dict(item) for item in self.history],
|
|
239
|
+
"reproducible": self.reproducible,
|
|
240
|
+
"policy_failed": self.policy_failed,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _validate_state(
|
|
245
|
+
model: Adapter,
|
|
246
|
+
state: State,
|
|
247
|
+
settings: SimulationSettings | TrajectorySettings,
|
|
248
|
+
step: int,
|
|
249
|
+
) -> None:
|
|
250
|
+
if len(state) != len(model.dimensions):
|
|
251
|
+
raise NumericalFailure(
|
|
252
|
+
f"solver failure at step {step}: adapter returned {len(state)} dimensions; "
|
|
253
|
+
f"expected {len(model.dimensions)}"
|
|
254
|
+
)
|
|
255
|
+
for dimension, value in zip(model.dimensions, state, strict=False):
|
|
256
|
+
if not math.isfinite(value):
|
|
257
|
+
raise NumericalFailure(
|
|
258
|
+
f"invalid integration at step {step}: {dimension} is NaN or infinite"
|
|
259
|
+
)
|
|
260
|
+
if abs(value) > settings.hard_state_limit:
|
|
261
|
+
raise NumericalFailure(
|
|
262
|
+
f"invalid integration at step {step}: {dimension} exceeded hard_state_limit"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resolve_adapter(config: ProbeConfig, adapter: Adapter | None) -> Adapter:
|
|
267
|
+
if adapter is not None:
|
|
268
|
+
if not isinstance(adapter, ModelAdapter | TrajectoryAdapter):
|
|
269
|
+
raise ConfigurationError("adapter does not implement a PhaseProbe protocol")
|
|
270
|
+
if adapter.name != config.model:
|
|
271
|
+
raise ConfigurationError(
|
|
272
|
+
f"adapter name {adapter.name!r} does not match model {config.model!r}"
|
|
273
|
+
)
|
|
274
|
+
return adapter
|
|
275
|
+
try:
|
|
276
|
+
return get_model(config.model)
|
|
277
|
+
except ConfigurationError:
|
|
278
|
+
if "adapter" not in config.data:
|
|
279
|
+
raise
|
|
280
|
+
return load_configured_adapter(config)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _validate_trace_points(
|
|
284
|
+
model: Adapter,
|
|
285
|
+
trace: SimulationTrace,
|
|
286
|
+
settings: TrajectorySettings,
|
|
287
|
+
) -> None:
|
|
288
|
+
if not trace.points:
|
|
289
|
+
raise NumericalFailure("trajectory adapter returned an empty trace")
|
|
290
|
+
direction = 0
|
|
291
|
+
previous_time: float | None = None
|
|
292
|
+
for point in trace.points:
|
|
293
|
+
if not math.isfinite(point.time):
|
|
294
|
+
raise NumericalFailure("trajectory adapter returned a NaN or infinite time")
|
|
295
|
+
_validate_state(model, point.state, settings, point.step)
|
|
296
|
+
for name, value in point.observations.items():
|
|
297
|
+
if not isinstance(name, str) or not name:
|
|
298
|
+
raise NumericalFailure("trajectory observable names must be non-empty strings")
|
|
299
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
300
|
+
if not math.isfinite(float(value)):
|
|
301
|
+
raise NumericalFailure(f"trajectory observable {name!r} is NaN or infinite")
|
|
302
|
+
elif not isinstance(value, str):
|
|
303
|
+
raise NumericalFailure(
|
|
304
|
+
f"trajectory observable {name!r} must be a finite number or string"
|
|
305
|
+
)
|
|
306
|
+
if previous_time is not None:
|
|
307
|
+
delta = point.time - previous_time
|
|
308
|
+
if delta == 0.0:
|
|
309
|
+
raise NumericalFailure("trajectory retained duplicate time points")
|
|
310
|
+
this_direction = 1 if delta > 0.0 else -1
|
|
311
|
+
if direction == 0:
|
|
312
|
+
direction = this_direction
|
|
313
|
+
elif direction != this_direction:
|
|
314
|
+
raise NumericalFailure("trajectory retained times are not monotonic")
|
|
315
|
+
previous_time = point.time
|
|
316
|
+
_validate_state(model, trace.final_state, settings, trace.points[-1].step)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _bounded_trace(trace: SimulationTrace, cap: int) -> SimulationTrace:
|
|
320
|
+
if len(trace.points) <= cap:
|
|
321
|
+
return trace
|
|
322
|
+
last = len(trace.points) - 1
|
|
323
|
+
indices = tuple(round(index * last / (cap - 1)) for index in range(cap))
|
|
324
|
+
points = tuple(trace.points[index] for index in indices)
|
|
325
|
+
metadata = dict(trace.metadata)
|
|
326
|
+
metadata["retention"] = {
|
|
327
|
+
"points_produced": len(trace.points),
|
|
328
|
+
"points_retained": len(points),
|
|
329
|
+
"strategy": "uniform-in-index-with-endpoints",
|
|
330
|
+
}
|
|
331
|
+
return SimulationTrace(
|
|
332
|
+
points=points,
|
|
333
|
+
final_state=trace.final_state,
|
|
334
|
+
success=trace.success,
|
|
335
|
+
status=trace.status,
|
|
336
|
+
message=trace.message,
|
|
337
|
+
metadata=metadata,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def simulate(
|
|
342
|
+
config: ProbeConfig,
|
|
343
|
+
*,
|
|
344
|
+
parameters_override: Mapping[str, float] | None = None,
|
|
345
|
+
initial_override: State | None = None,
|
|
346
|
+
adapter: Adapter | None = None,
|
|
347
|
+
) -> SimulationResult:
|
|
348
|
+
"""Execute one model with explicit seed, parameters, retention, and tolerances."""
|
|
349
|
+
|
|
350
|
+
model = _resolve_adapter(config, adapter)
|
|
351
|
+
parameters = _numeric_mapping(config.section("parameters"), "parameters")
|
|
352
|
+
if parameters_override is not None:
|
|
353
|
+
parameters.update(parameters_override)
|
|
354
|
+
tolerances = _numeric_mapping(config.section("tolerances", required=False), "tolerances")
|
|
355
|
+
model_config = config.section("model_config", required=False)
|
|
356
|
+
state = (
|
|
357
|
+
initial_override
|
|
358
|
+
if initial_override is not None
|
|
359
|
+
else model.initial_state(model_config, config.seed)
|
|
360
|
+
)
|
|
361
|
+
initial = tuple(state)
|
|
362
|
+
if isinstance(model, TrajectoryAdapter):
|
|
363
|
+
trajectory_settings = TrajectorySettings.from_config(config.section("simulation"))
|
|
364
|
+
_validate_state(model, initial, trajectory_settings, 0)
|
|
365
|
+
try:
|
|
366
|
+
raw_trace = model.simulate(initial, parameters, model_config, config.seed)
|
|
367
|
+
except NumericalFailure:
|
|
368
|
+
raise
|
|
369
|
+
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
|
|
370
|
+
raise NumericalFailure(f"trajectory solver failure: {exc}") from exc
|
|
371
|
+
_validate_trace_points(model, raw_trace, trajectory_settings)
|
|
372
|
+
if not raw_trace.success:
|
|
373
|
+
raise NumericalFailure(
|
|
374
|
+
f"solver failure with status {raw_trace.status}: {raw_trace.message}"
|
|
375
|
+
)
|
|
376
|
+
bounded = _bounded_trace(raw_trace, trajectory_settings.trace_cap)
|
|
377
|
+
classification = model.classify(bounded, tolerances)
|
|
378
|
+
invariants = tuple(model.invariants(bounded, parameters, tolerances))
|
|
379
|
+
observations = dict(model.observe(bounded))
|
|
380
|
+
execution_metadata = dict(bounded.metadata)
|
|
381
|
+
execution_metadata["adapter_configuration"] = dict(model.configuration())
|
|
382
|
+
return SimulationResult(
|
|
383
|
+
model=model.name,
|
|
384
|
+
model_identity=model.identity,
|
|
385
|
+
seed=config.seed,
|
|
386
|
+
parameters=parameters,
|
|
387
|
+
initial_state=initial,
|
|
388
|
+
final_state=bounded.final_state,
|
|
389
|
+
settings=trajectory_settings,
|
|
390
|
+
tolerances=tolerances,
|
|
391
|
+
classification=classification,
|
|
392
|
+
invariants=invariants,
|
|
393
|
+
observations=observations,
|
|
394
|
+
trace=bounded.points,
|
|
395
|
+
trace_sha256=trace_hash(bounded.points),
|
|
396
|
+
replay_mode=model.replay_mode,
|
|
397
|
+
execution_metadata=execution_metadata,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
settings = SimulationSettings.from_config(config.section("simulation"))
|
|
401
|
+
_validate_state(model, initial, settings, 0)
|
|
402
|
+
retained: deque[TracePoint] = deque(maxlen=settings.trace_cap)
|
|
403
|
+
total_steps = settings.burn_in + settings.steps
|
|
404
|
+
for step in range(1, total_steps + 1):
|
|
405
|
+
try:
|
|
406
|
+
state = model.step(state, parameters, settings.dt)
|
|
407
|
+
except (ArithmeticError, ValueError) as exc:
|
|
408
|
+
raise NumericalFailure(f"solver failure at step {step}: {exc}") from exc
|
|
409
|
+
_validate_state(model, state, settings, step)
|
|
410
|
+
if step > settings.burn_in and (step - settings.burn_in) % settings.sample_every == 0:
|
|
411
|
+
retained.append(
|
|
412
|
+
TracePoint(
|
|
413
|
+
step=step,
|
|
414
|
+
time=step * settings.dt,
|
|
415
|
+
state=tuple(state),
|
|
416
|
+
observations=dict(model.observe(state)),
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
trace = tuple(retained)
|
|
420
|
+
if not trace:
|
|
421
|
+
raise ConfigurationError("simulation retention settings produced an empty trace")
|
|
422
|
+
classification = model.classify(trace, tolerances)
|
|
423
|
+
invariants = tuple(model.invariants(trace, parameters, tolerances))
|
|
424
|
+
return SimulationResult(
|
|
425
|
+
model=model.name,
|
|
426
|
+
model_identity=model.identity,
|
|
427
|
+
seed=config.seed,
|
|
428
|
+
parameters=parameters,
|
|
429
|
+
initial_state=initial,
|
|
430
|
+
final_state=tuple(state),
|
|
431
|
+
settings=settings,
|
|
432
|
+
tolerances=tolerances,
|
|
433
|
+
classification=classification,
|
|
434
|
+
invariants=invariants,
|
|
435
|
+
observations=dict(trace[-1].observations),
|
|
436
|
+
trace=trace,
|
|
437
|
+
trace_sha256=trace_hash(trace),
|
|
438
|
+
replay_mode="exact",
|
|
439
|
+
execution_metadata={"execution_kind": "step", "solver_success": True},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _linspace(start: float, stop: float, points: int) -> list[float]:
|
|
444
|
+
if points < 2:
|
|
445
|
+
raise ConfigurationError("search points must be at least 2")
|
|
446
|
+
return [start + (stop - start) * index / (points - 1) for index in range(points)]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _logspace(start: float, stop: float, points: int) -> list[float]:
|
|
450
|
+
if start <= 0.0 or stop <= 0.0:
|
|
451
|
+
raise ConfigurationError("logarithmic search bounds must be positive")
|
|
452
|
+
log_start = math.log(start)
|
|
453
|
+
log_stop = math.log(stop)
|
|
454
|
+
return [math.exp(value) for value in _linspace(log_start, log_stop, points)]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def run_scan(config: ProbeConfig, *, adapter: Adapter | None = None) -> ProbeOutcome:
|
|
458
|
+
"""Scan a one-dimensional parameter and refine the first adjacent class change."""
|
|
459
|
+
|
|
460
|
+
scan = config.section("scan")
|
|
461
|
+
parameter_name = _string(scan, "parameter")
|
|
462
|
+
start = _number(scan, "start")
|
|
463
|
+
stop = _number(scan, "stop")
|
|
464
|
+
points = _integer(scan, "points")
|
|
465
|
+
refinements = _integer(scan, "refine_iterations", 12)
|
|
466
|
+
repeatability = _integer(scan, "repeatability", 2)
|
|
467
|
+
if stop <= start or refinements < 0 or repeatability < 1:
|
|
468
|
+
raise ConfigurationError("scan requires stop > start and non-negative refinement")
|
|
469
|
+
|
|
470
|
+
history: list[Mapping[str, object]] = []
|
|
471
|
+
runs: list[SimulationResult] = []
|
|
472
|
+
values = _linspace(start, stop, points)
|
|
473
|
+
for value in values:
|
|
474
|
+
run = simulate(config, parameters_override={parameter_name: value}, adapter=adapter)
|
|
475
|
+
runs.append(run)
|
|
476
|
+
history.append({"phase": "coarse", "value": value, "classification": run.classification})
|
|
477
|
+
|
|
478
|
+
transition_index: int | None = None
|
|
479
|
+
for index in range(len(runs) - 1):
|
|
480
|
+
if runs[index].classification != runs[index + 1].classification:
|
|
481
|
+
transition_index = index
|
|
482
|
+
break
|
|
483
|
+
if transition_index is None:
|
|
484
|
+
return ProbeOutcome(
|
|
485
|
+
command="scan",
|
|
486
|
+
status="NO QUALITATIVE TRANSITION FOUND",
|
|
487
|
+
config=config,
|
|
488
|
+
baseline=runs[0],
|
|
489
|
+
changed=None,
|
|
490
|
+
finding=None,
|
|
491
|
+
history=tuple(history),
|
|
492
|
+
reproducible=True,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
coarse_left = values[transition_index]
|
|
496
|
+
coarse_right = values[transition_index + 1]
|
|
497
|
+
low = coarse_left
|
|
498
|
+
high = coarse_right
|
|
499
|
+
left_run = runs[transition_index]
|
|
500
|
+
right_run = runs[transition_index + 1]
|
|
501
|
+
left_class = left_run.classification
|
|
502
|
+
right_class = right_run.classification
|
|
503
|
+
unresolved_midpoint: float | None = None
|
|
504
|
+
for _ in range(refinements):
|
|
505
|
+
midpoint = (low + high) / 2.0
|
|
506
|
+
midpoint_run = simulate(
|
|
507
|
+
config, parameters_override={parameter_name: midpoint}, adapter=adapter
|
|
508
|
+
)
|
|
509
|
+
history.append(
|
|
510
|
+
{
|
|
511
|
+
"phase": "refine",
|
|
512
|
+
"low": low,
|
|
513
|
+
"high": high,
|
|
514
|
+
"value": midpoint,
|
|
515
|
+
"classification": midpoint_run.classification,
|
|
516
|
+
}
|
|
517
|
+
)
|
|
518
|
+
if midpoint_run.classification == left_class:
|
|
519
|
+
low = midpoint
|
|
520
|
+
left_run = midpoint_run
|
|
521
|
+
elif midpoint_run.classification == right_class:
|
|
522
|
+
high = midpoint
|
|
523
|
+
right_run = midpoint_run
|
|
524
|
+
else:
|
|
525
|
+
unresolved_midpoint = midpoint
|
|
526
|
+
history.append(
|
|
527
|
+
{
|
|
528
|
+
"phase": "refine-stopped",
|
|
529
|
+
"value": midpoint,
|
|
530
|
+
"classification": midpoint_run.classification,
|
|
531
|
+
"reason": "midpoint did not reproduce either stable endpoint class",
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
reproducible = True
|
|
537
|
+
confirmations: list[dict[str, object]] = []
|
|
538
|
+
for side, _expected in (("baseline", left_run), ("changed", right_run)):
|
|
539
|
+
hashes: list[str] = []
|
|
540
|
+
classes: list[str] = []
|
|
541
|
+
for _ in range(repeatability):
|
|
542
|
+
value = low if side == "baseline" else high
|
|
543
|
+
confirmed = simulate(
|
|
544
|
+
config, parameters_override={parameter_name: value}, adapter=adapter
|
|
545
|
+
)
|
|
546
|
+
hashes.append(confirmed.trace_sha256)
|
|
547
|
+
classes.append(confirmed.classification)
|
|
548
|
+
stable = len(set(hashes)) == 1 and len(set(classes)) == 1
|
|
549
|
+
reproducible = reproducible and stable
|
|
550
|
+
confirmations.append(
|
|
551
|
+
{"side": side, "runs": repeatability, "stable": stable, "trace_sha256": hashes[0]}
|
|
552
|
+
)
|
|
553
|
+
finding: dict[str, object] = {
|
|
554
|
+
"kind": "qualitative-regime-change",
|
|
555
|
+
"parameter": parameter_name,
|
|
556
|
+
"coarse_bracket": [coarse_left, coarse_right],
|
|
557
|
+
"stable_bracket": [low, high],
|
|
558
|
+
"bracket_width": high - low,
|
|
559
|
+
"smallest_reproducible_change_found": high - low,
|
|
560
|
+
"baseline_regime": left_run.classification,
|
|
561
|
+
"changed_regime": right_run.classification,
|
|
562
|
+
"classification_rule": config.string("classification_rule"),
|
|
563
|
+
"refinement_rule": config.string("refinement_rule"),
|
|
564
|
+
"repeatability": confirmations,
|
|
565
|
+
"unresolved_midpoint": unresolved_midpoint,
|
|
566
|
+
"minimality_statement": "Smallest reproducible separation found by the declared bounded scan and binary refinement; not a proof of a globally minimal perturbation or exact bifurcation point.",
|
|
567
|
+
}
|
|
568
|
+
return ProbeOutcome(
|
|
569
|
+
command="scan",
|
|
570
|
+
status="QUALITATIVE TRANSITION FOUND",
|
|
571
|
+
config=config,
|
|
572
|
+
baseline=left_run,
|
|
573
|
+
changed=right_run,
|
|
574
|
+
finding=finding,
|
|
575
|
+
history=tuple(history),
|
|
576
|
+
reproducible=reproducible,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _distance(left: State, right: State) -> float:
|
|
581
|
+
return math.sqrt(sum((a - b) ** 2 for a, b in zip(left, right, strict=False)))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _pair_metrics(
|
|
585
|
+
baseline: SimulationResult, changed: SimulationResult, delta: float
|
|
586
|
+
) -> dict[str, object]:
|
|
587
|
+
count = min(len(baseline.trace), len(changed.trace))
|
|
588
|
+
if count == 0:
|
|
589
|
+
raise NumericalFailure("cannot compare empty twin traces")
|
|
590
|
+
left = baseline.trace[-count:]
|
|
591
|
+
right = changed.trace[-count:]
|
|
592
|
+
distances = [_distance(a.state, b.state) for a, b in zip(left, right, strict=False)]
|
|
593
|
+
max_index = max(range(count), key=distances.__getitem__)
|
|
594
|
+
max_distance = distances[max_index]
|
|
595
|
+
elapsed = abs(right[max_index].time - right[0].time)
|
|
596
|
+
if elapsed == 0.0:
|
|
597
|
+
elapsed = 1.0
|
|
598
|
+
initial_separation = abs(delta)
|
|
599
|
+
rate: float | None = None
|
|
600
|
+
if initial_separation > 0.0 and max_distance > 0.0:
|
|
601
|
+
rate = math.log(max_distance / initial_separation) / elapsed
|
|
602
|
+
return {
|
|
603
|
+
"initial_separation": initial_separation,
|
|
604
|
+
"max_trajectory_distance": max_distance,
|
|
605
|
+
"terminal_trajectory_distance": distances[-1],
|
|
606
|
+
"finite_time_divergence_rate": rate,
|
|
607
|
+
"rate_observation_window": elapsed,
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def run_perturb(config: ProbeConfig, *, adapter: Adapter | None = None) -> ProbeOutcome:
|
|
612
|
+
"""Search bounded initial-state perturbations using a deterministic predicate."""
|
|
613
|
+
|
|
614
|
+
search = config.section("perturb")
|
|
615
|
+
model = _resolve_adapter(config, adapter)
|
|
616
|
+
dimension = _string(search, "dimension")
|
|
617
|
+
try:
|
|
618
|
+
dimension_index = model.dimensions.index(dimension)
|
|
619
|
+
except ValueError as exc:
|
|
620
|
+
raise ConfigurationError(
|
|
621
|
+
f"unknown perturbation dimension {dimension!r}; choose one of {model.dimensions}"
|
|
622
|
+
) from exc
|
|
623
|
+
start = _number(search, "start")
|
|
624
|
+
stop = _number(search, "stop")
|
|
625
|
+
points = _integer(search, "points")
|
|
626
|
+
scale = _string(search, "scale", "linear")
|
|
627
|
+
predicate = _string(search, "predicate", "classification-change")
|
|
628
|
+
target_classification_raw = search.get("target_classification")
|
|
629
|
+
target_classification = (
|
|
630
|
+
_string(search, "target_classification") if target_classification_raw is not None else None
|
|
631
|
+
)
|
|
632
|
+
threshold = _number(search, "divergence_threshold", 1.0)
|
|
633
|
+
refinements = _integer(search, "refine_iterations", 10)
|
|
634
|
+
repeatability = _integer(search, "repeatability", 2)
|
|
635
|
+
if stop <= start or start < 0.0 or refinements < 0 or repeatability < 1:
|
|
636
|
+
raise ConfigurationError("perturb requires stop > start >= 0 and valid refinement counts")
|
|
637
|
+
if predicate not in {"classification-change", "finite-time-divergence"}:
|
|
638
|
+
raise ConfigurationError(
|
|
639
|
+
"perturb predicate must be classification-change or finite-time-divergence"
|
|
640
|
+
)
|
|
641
|
+
deltas = _logspace(start, stop, points) if scale == "log" else _linspace(start, stop, points)
|
|
642
|
+
if scale not in {"linear", "log"}:
|
|
643
|
+
raise ConfigurationError("perturb scale must be linear or log")
|
|
644
|
+
|
|
645
|
+
baseline = simulate(config, adapter=model)
|
|
646
|
+
initial = baseline.initial_state
|
|
647
|
+
history: list[Mapping[str, object]] = []
|
|
648
|
+
previous_delta = 0.0
|
|
649
|
+
previous_triggered = False
|
|
650
|
+
found_delta: float | None = None
|
|
651
|
+
found_run: SimulationResult | None = None
|
|
652
|
+
found_metrics: dict[str, object] | None = None
|
|
653
|
+
|
|
654
|
+
def evaluate(delta: float, phase: str) -> tuple[bool, SimulationResult, dict[str, object]]:
|
|
655
|
+
candidate_state = list(initial)
|
|
656
|
+
candidate_state[dimension_index] += delta
|
|
657
|
+
changed = simulate(config, initial_override=tuple(candidate_state), adapter=model)
|
|
658
|
+
metrics = _pair_metrics(baseline, changed, delta)
|
|
659
|
+
if predicate == "classification-change":
|
|
660
|
+
triggered = (
|
|
661
|
+
changed.classification == target_classification
|
|
662
|
+
if target_classification is not None
|
|
663
|
+
else changed.classification != baseline.classification
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
distance = metrics["max_trajectory_distance"]
|
|
667
|
+
if not isinstance(distance, float):
|
|
668
|
+
raise NumericalFailure("internal distance metric type failure")
|
|
669
|
+
triggered = distance >= threshold
|
|
670
|
+
history.append(
|
|
671
|
+
{
|
|
672
|
+
"phase": phase,
|
|
673
|
+
"delta": delta,
|
|
674
|
+
"classification": changed.classification,
|
|
675
|
+
"triggered": triggered,
|
|
676
|
+
**metrics,
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
return triggered, changed, metrics
|
|
680
|
+
|
|
681
|
+
for delta in deltas:
|
|
682
|
+
triggered, changed, metrics = evaluate(delta, "coarse")
|
|
683
|
+
if triggered:
|
|
684
|
+
found_delta, found_run, found_metrics = delta, changed, metrics
|
|
685
|
+
break
|
|
686
|
+
previous_delta = delta
|
|
687
|
+
previous_triggered = triggered
|
|
688
|
+
|
|
689
|
+
if found_delta is None or found_run is None or found_metrics is None:
|
|
690
|
+
last_delta = deltas[-1]
|
|
691
|
+
_, last_run, _ = evaluate(last_delta, "negative-control-confirmation")
|
|
692
|
+
return ProbeOutcome(
|
|
693
|
+
command="perturb",
|
|
694
|
+
status="NO SENSITIVE PERTURBATION FOUND",
|
|
695
|
+
config=config,
|
|
696
|
+
baseline=baseline,
|
|
697
|
+
changed=last_run,
|
|
698
|
+
finding=None,
|
|
699
|
+
history=tuple(history),
|
|
700
|
+
reproducible=True,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
low = previous_delta
|
|
704
|
+
high = found_delta
|
|
705
|
+
if low > 0.0 and not previous_triggered:
|
|
706
|
+
for _ in range(refinements):
|
|
707
|
+
midpoint = (low + high) / 2.0
|
|
708
|
+
triggered, changed, metrics = evaluate(midpoint, "refine")
|
|
709
|
+
if triggered:
|
|
710
|
+
high, found_run, found_metrics = midpoint, changed, metrics
|
|
711
|
+
else:
|
|
712
|
+
low = midpoint
|
|
713
|
+
|
|
714
|
+
hashes: list[str] = []
|
|
715
|
+
classes: list[str] = []
|
|
716
|
+
triggered_confirmations: list[bool] = []
|
|
717
|
+
for _ in range(repeatability):
|
|
718
|
+
triggered, confirmed, _ = evaluate(high, "repeatability")
|
|
719
|
+
hashes.append(confirmed.trace_sha256)
|
|
720
|
+
classes.append(confirmed.classification)
|
|
721
|
+
triggered_confirmations.append(triggered)
|
|
722
|
+
reproducible = len(set(hashes)) == 1 and len(set(classes)) == 1 and all(triggered_confirmations)
|
|
723
|
+
finding = {
|
|
724
|
+
"kind": predicate,
|
|
725
|
+
"dimension": dimension,
|
|
726
|
+
"search_bounds": [start, stop],
|
|
727
|
+
"stable_bracket": [low, high],
|
|
728
|
+
"smallest_reproducible_change_found": high,
|
|
729
|
+
"baseline_regime": baseline.classification,
|
|
730
|
+
"changed_regime": found_run.classification,
|
|
731
|
+
"target_classification": target_classification,
|
|
732
|
+
"metrics": found_metrics,
|
|
733
|
+
"classification_rule": config.string("classification_rule"),
|
|
734
|
+
"refinement_rule": config.string("refinement_rule"),
|
|
735
|
+
"repeatability": {
|
|
736
|
+
"runs": repeatability,
|
|
737
|
+
"stable": reproducible,
|
|
738
|
+
"trace_sha256": hashes[0],
|
|
739
|
+
},
|
|
740
|
+
"minimality_statement": "Smallest reproducible perturbation found within the declared finite search; not a proof of global minimality.",
|
|
741
|
+
}
|
|
742
|
+
status = (
|
|
743
|
+
"FINITE-TIME TRAJECTORY DIVERGENCE FOUND"
|
|
744
|
+
if predicate == "finite-time-divergence"
|
|
745
|
+
else "QUALITATIVE STATE SWITCH FOUND"
|
|
746
|
+
)
|
|
747
|
+
return ProbeOutcome(
|
|
748
|
+
command="perturb",
|
|
749
|
+
status=status,
|
|
750
|
+
config=config,
|
|
751
|
+
baseline=baseline,
|
|
752
|
+
changed=found_run,
|
|
753
|
+
finding=finding,
|
|
754
|
+
history=tuple(history),
|
|
755
|
+
reproducible=reproducible,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def run_check(config: ProbeConfig, *, adapter: Adapter | None = None) -> ProbeOutcome:
|
|
760
|
+
"""Execute a declared CI policy and mark only policy violations as failure."""
|
|
761
|
+
|
|
762
|
+
check = config.section("check")
|
|
763
|
+
analysis = _string(check, "analysis", "invariants")
|
|
764
|
+
policy = config.section("policy")
|
|
765
|
+
forbid_findings = _boolean(policy, "forbid_findings", False)
|
|
766
|
+
require_finding = _boolean(policy, "require_finding", False)
|
|
767
|
+
require_invariants = _boolean(policy, "require_invariants", True)
|
|
768
|
+
|
|
769
|
+
if analysis == "scan":
|
|
770
|
+
outcome = run_scan(config, adapter=adapter)
|
|
771
|
+
elif analysis == "perturb":
|
|
772
|
+
outcome = run_perturb(config, adapter=adapter)
|
|
773
|
+
elif analysis == "invariants":
|
|
774
|
+
baseline = simulate(config, adapter=adapter)
|
|
775
|
+
violations = [_invariant_dict(item) for item in baseline.invariants if not item.passed]
|
|
776
|
+
finding: Mapping[str, object] | None = None
|
|
777
|
+
if violations:
|
|
778
|
+
finding = {"kind": "invariant-violation", "violations": violations}
|
|
779
|
+
outcome = ProbeOutcome(
|
|
780
|
+
command="check",
|
|
781
|
+
status="CHECK EVIDENCE COLLECTED",
|
|
782
|
+
config=config,
|
|
783
|
+
baseline=baseline,
|
|
784
|
+
changed=None,
|
|
785
|
+
finding=finding,
|
|
786
|
+
history=(),
|
|
787
|
+
reproducible=True,
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
raise ConfigurationError("check.analysis must be invariants, scan, or perturb")
|
|
791
|
+
|
|
792
|
+
finding_present = outcome.finding is not None
|
|
793
|
+
policy_failed = (forbid_findings and finding_present) or (
|
|
794
|
+
require_finding and not finding_present
|
|
795
|
+
)
|
|
796
|
+
if require_invariants:
|
|
797
|
+
policy_failed = policy_failed or outcome.baseline.invariant_violations > 0
|
|
798
|
+
if outcome.changed is not None:
|
|
799
|
+
policy_failed = policy_failed or outcome.changed.invariant_violations > 0
|
|
800
|
+
return ProbeOutcome(
|
|
801
|
+
command="check",
|
|
802
|
+
status="CHECK POLICY FAILED" if policy_failed else "CHECK POLICY PASSED",
|
|
803
|
+
config=config,
|
|
804
|
+
baseline=outcome.baseline,
|
|
805
|
+
changed=outcome.changed,
|
|
806
|
+
finding=outcome.finding,
|
|
807
|
+
history=outcome.history,
|
|
808
|
+
reproducible=outcome.reproducible,
|
|
809
|
+
policy_failed=policy_failed,
|
|
810
|
+
)
|