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/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
+ )