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/api.py ADDED
@@ -0,0 +1,64 @@
1
+ """Practical Python API for supplied step-level or trajectory-level adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from phaseprobe.config import CONFIG_SCHEMA_VERSION, ProbeConfig, canonical_json, parse_config
8
+ from phaseprobe.engine import (
9
+ Adapter,
10
+ ProbeOutcome,
11
+ SimulationResult,
12
+ run_check,
13
+ run_perturb,
14
+ run_scan,
15
+ simulate,
16
+ )
17
+
18
+
19
+ def _coerce_config(adapter: Adapter, config: ProbeConfig | Mapping[str, object]) -> ProbeConfig:
20
+ if isinstance(config, ProbeConfig):
21
+ return config
22
+ data = dict(config)
23
+ data.setdefault("schema_version", CONFIG_SCHEMA_VERSION)
24
+ data.setdefault("model", adapter.name)
25
+ data.setdefault("seed", 0)
26
+ return parse_config(canonical_json(data), "Python API configuration")
27
+
28
+
29
+ def run_simulation(
30
+ adapter: Adapter, config: ProbeConfig | Mapping[str, object]
31
+ ) -> SimulationResult:
32
+ """Run one supplied adapter without registering it globally."""
33
+
34
+ return simulate(_coerce_config(adapter, config), adapter=adapter)
35
+
36
+
37
+ def run_parameter_scan(
38
+ adapter: Adapter, config: ProbeConfig | Mapping[str, object]
39
+ ) -> ProbeOutcome:
40
+ """Run the shared bounded one-dimensional scan with a supplied adapter."""
41
+
42
+ return run_scan(_coerce_config(adapter, config), adapter=adapter)
43
+
44
+
45
+ def run_perturbation(adapter: Adapter, config: ProbeConfig | Mapping[str, object]) -> ProbeOutcome:
46
+ """Run the shared bounded initial-state perturbation search."""
47
+
48
+ return run_perturb(_coerce_config(adapter, config), adapter=adapter)
49
+
50
+
51
+ def run_invariant_check(
52
+ adapter: Adapter, config: ProbeConfig | Mapping[str, object]
53
+ ) -> ProbeOutcome:
54
+ """Run the shared declared CI policy and invariant evaluation."""
55
+
56
+ return run_check(_coerce_config(adapter, config), adapter=adapter)
57
+
58
+
59
+ __all__ = [
60
+ "run_invariant_check",
61
+ "run_parameter_scan",
62
+ "run_perturbation",
63
+ "run_simulation",
64
+ ]
@@ -0,0 +1,124 @@
1
+ """Bounded run-directory persistence and artifact manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from phaseprobe.config import canonical_json
12
+ from phaseprobe.engine import ProbeOutcome, SimulationResult
13
+ from phaseprobe.replay import fixture_payload
14
+ from phaseprobe.reporting import html_report, json_report
15
+
16
+
17
+ def _sha256(path: Path) -> str:
18
+ digest = hashlib.sha256()
19
+ with path.open("rb") as handle:
20
+ for chunk in iter(lambda: handle.read(65_536), b""):
21
+ digest.update(chunk)
22
+ return digest.hexdigest()
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ArtifactBundle:
27
+ """Paths written for one bounded PhaseProbe execution."""
28
+
29
+ run_directory: Path
30
+ run_json: Path
31
+ findings_json: Path
32
+ replay_json: Path
33
+ trace_jsonl: Path
34
+ report_html: Path
35
+ manifest_json: Path
36
+
37
+
38
+ def _trace_lines(label: str, result: SimulationResult) -> list[str]:
39
+ lines: list[str] = []
40
+ for point in result.trace:
41
+ payload = {
42
+ "series": label,
43
+ "step": point.step,
44
+ "time": point.time,
45
+ "state": list(point.state),
46
+ "observations": dict(point.observations),
47
+ }
48
+ lines.append(canonical_json(payload))
49
+ return lines
50
+
51
+
52
+ def write_artifacts(outcome: ProbeOutcome, output_root: Path) -> ArtifactBundle:
53
+ """Write finite evidence files and a hash manifest under one run directory."""
54
+
55
+ output_root.mkdir(parents=True, exist_ok=True)
56
+ signature = hashlib.sha256(canonical_json(outcome.as_dict()).encode("utf-8")).hexdigest()[:8]
57
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
58
+ run_directory = output_root / f"{timestamp}-{signature}"
59
+ suffix = 1
60
+ while run_directory.exists():
61
+ run_directory = output_root / f"{timestamp}-{signature}-{suffix}"
62
+ suffix += 1
63
+ run_directory.mkdir()
64
+
65
+ run_json = run_directory / "run.json"
66
+ findings_json = run_directory / "findings.json"
67
+ replay_json = run_directory / "replay.json"
68
+ trace_jsonl = run_directory / "trace.jsonl"
69
+ report_html = run_directory / "report.html"
70
+ manifest_json = run_directory / "manifest.json"
71
+
72
+ data = outcome.as_dict()
73
+ run_json.write_text(json_report(data), encoding="utf-8")
74
+ findings_json.write_text(
75
+ json.dumps(
76
+ {
77
+ "schema_version": "2.0",
78
+ "status": outcome.status,
79
+ "finding": dict(outcome.finding) if outcome.finding is not None else None,
80
+ "reproducible": outcome.reproducible,
81
+ },
82
+ allow_nan=False,
83
+ indent=2,
84
+ sort_keys=True,
85
+ )
86
+ + "\n",
87
+ encoding="utf-8",
88
+ )
89
+ replay_json.write_text(
90
+ json.dumps(fixture_payload(outcome), allow_nan=False, indent=2, sort_keys=True) + "\n",
91
+ encoding="utf-8",
92
+ )
93
+ trace_lines = _trace_lines("baseline", outcome.baseline)
94
+ if outcome.changed is not None:
95
+ trace_lines.extend(_trace_lines("changed", outcome.changed))
96
+ trace_jsonl.write_text("\n".join(trace_lines) + "\n", encoding="utf-8")
97
+ report_html.write_text(html_report(data), encoding="utf-8")
98
+
99
+ files = [run_json, findings_json, replay_json, trace_jsonl, report_html]
100
+ manifest = {
101
+ "schema_version": "2.0",
102
+ "run_id": run_directory.name,
103
+ "bounded_trace": {
104
+ "baseline_points": len(outcome.baseline.trace),
105
+ "changed_points": len(outcome.changed.trace) if outcome.changed is not None else 0,
106
+ "configured_cap_per_series": outcome.baseline.settings.trace_cap,
107
+ },
108
+ "files": {
109
+ path.name: {"bytes": path.stat().st_size, "sha256": _sha256(path)} for path in files
110
+ },
111
+ }
112
+ manifest_json.write_text(
113
+ json.dumps(manifest, allow_nan=False, indent=2, sort_keys=True) + "\n",
114
+ encoding="utf-8",
115
+ )
116
+ return ArtifactBundle(
117
+ run_directory=run_directory,
118
+ run_json=run_json,
119
+ findings_json=findings_json,
120
+ replay_json=replay_json,
121
+ trace_jsonl=trace_jsonl,
122
+ report_html=report_html,
123
+ manifest_json=manifest_json,
124
+ )
phaseprobe/cli.py ADDED
@@ -0,0 +1,242 @@
1
+ """PhaseProbe command-line interface and stable exit semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Callable, Sequence
9
+ from pathlib import Path
10
+
11
+ from phaseprobe import __version__
12
+ from phaseprobe.artifacts import ArtifactBundle, write_artifacts
13
+ from phaseprobe.config import EXAMPLE_FILES, ProbeConfig, load_config, load_example
14
+ from phaseprobe.engine import ProbeOutcome, run_check, run_perturb, run_scan
15
+ from phaseprobe.errors import (
16
+ ConfigurationError,
17
+ ExitCode,
18
+ IntegrityError,
19
+ NumericalFailure,
20
+ PhaseProbeError,
21
+ )
22
+ from phaseprobe.generate import generate_regression_test
23
+ from phaseprobe.replay import verify_replay
24
+ from phaseprobe.reporting import json_report, regenerate_reports, terminal_report
25
+
26
+
27
+ def _add_config_source(parser: argparse.ArgumentParser) -> None:
28
+ source = parser.add_mutually_exclusive_group(required=True)
29
+ source.add_argument("--config", type=Path, help="versioned JSON configuration")
30
+ source.add_argument(
31
+ "--example", choices=sorted(EXAMPLE_FILES), help="built-in deterministic example"
32
+ )
33
+ parser.add_argument(
34
+ "--output-root",
35
+ type=Path,
36
+ default=Path(".phaseprobe") / "runs",
37
+ help="bounded run directory root (default: .phaseprobe/runs)",
38
+ )
39
+ parser.add_argument("--json", action="store_true", help="emit versioned JSON to stdout")
40
+
41
+
42
+ def build_parser() -> argparse.ArgumentParser:
43
+ """Build the public CLI parser."""
44
+
45
+ parser = argparse.ArgumentParser(
46
+ prog="phaseprobe",
47
+ description="Find bounded simulation transitions and turn them into regression tests.",
48
+ )
49
+ parser.add_argument("--version", action="version", version=f"phaseprobe {__version__}")
50
+ commands = parser.add_subparsers(dest="command", required=True)
51
+
52
+ scan = commands.add_parser("scan", help="scan one parameter for adjacent qualitative changes")
53
+ _add_config_source(scan)
54
+ scan.add_argument(
55
+ "--fail-on-finding",
56
+ action="store_true",
57
+ help="return exit 1 when a transition is found",
58
+ )
59
+
60
+ perturb = commands.add_parser(
61
+ "perturb", help="search bounded initial-state perturbations with twin trajectories"
62
+ )
63
+ _add_config_source(perturb)
64
+ perturb.add_argument(
65
+ "--fail-on-finding",
66
+ action="store_true",
67
+ help="return exit 1 when a sensitive perturbation is found",
68
+ )
69
+
70
+ check = commands.add_parser("check", help="run a declared policy for CI enforcement")
71
+ _add_config_source(check)
72
+
73
+ replay = commands.add_parser("replay", help="re-execute an integrity-protected replay fixture")
74
+ replay.add_argument("fixture", type=Path)
75
+ replay.add_argument("--json", action="store_true")
76
+
77
+ generate = commands.add_parser(
78
+ "generate-test", help="generate fixed-template pytest from validated evidence"
79
+ )
80
+ generate.add_argument("fixture", type=Path)
81
+ generate.add_argument("--output-directory", type=Path, default=Path("tests") / "generated")
82
+ generate.add_argument("--json", action="store_true")
83
+
84
+ report = commands.add_parser(
85
+ "report", help="regenerate terminal, JSON, and offline HTML evidence"
86
+ )
87
+ report.add_argument("run_directory", type=Path)
88
+ report.add_argument("--format", choices=("terminal", "json", "html", "all"), default="all")
89
+ return parser
90
+
91
+
92
+ def _config_from_args(args: argparse.Namespace) -> ProbeConfig:
93
+ config_path = getattr(args, "config", None)
94
+ example = getattr(args, "example", None)
95
+ if isinstance(config_path, Path):
96
+ return load_config(config_path)
97
+ if isinstance(example, str):
98
+ return load_example(example)
99
+ raise ConfigurationError("a configuration or built-in example is required")
100
+
101
+
102
+ def _with_artifacts(outcome: ProbeOutcome, bundle: ArtifactBundle) -> dict[str, object]:
103
+ data = outcome.as_dict()
104
+ data["artifacts"] = {
105
+ "run_directory": str(bundle.run_directory),
106
+ "replay": str(bundle.replay_json),
107
+ "html_report": str(bundle.report_html),
108
+ "manifest": str(bundle.manifest_json),
109
+ }
110
+ return data
111
+
112
+
113
+ def _run_evidence_command(
114
+ args: argparse.Namespace, execute: Callable[[ProbeConfig], ProbeOutcome]
115
+ ) -> int:
116
+ config = _config_from_args(args)
117
+ outcome = execute(config)
118
+ output_root = args.output_root
119
+ if not isinstance(output_root, Path):
120
+ raise ConfigurationError("output root must be a path")
121
+ bundle = write_artifacts(outcome, output_root)
122
+ data = _with_artifacts(outcome, bundle)
123
+ if bool(getattr(args, "json", False)):
124
+ sys.stdout.write(json_report(data))
125
+ else:
126
+ print(terminal_report(data, replay=str(bundle.replay_json)))
127
+ print(f"HTML report: {bundle.report_html}")
128
+ if outcome.policy_failed:
129
+ return int(ExitCode.POLICY_FAILED)
130
+ if bool(getattr(args, "fail_on_finding", False)) and outcome.finding is not None:
131
+ return int(ExitCode.POLICY_FAILED)
132
+ return int(ExitCode.OK)
133
+
134
+
135
+ def _replay_command(args: argparse.Namespace) -> int:
136
+ fixture = args.fixture
137
+ if not isinstance(fixture, Path):
138
+ raise ConfigurationError("fixture must be a path")
139
+ verification = verify_replay(fixture)
140
+ data = verification.as_dict()
141
+ if bool(getattr(args, "json", False)):
142
+ sys.stdout.write(json_report(data))
143
+ else:
144
+ print(str(data["status"]))
145
+ print()
146
+ print(f"Model: {verification.model}")
147
+ print(f"Comparison mode: {verification.mode}")
148
+ for comparison in verification.comparisons:
149
+ if verification.mode == "exact":
150
+ print(
151
+ f"{comparison['series']}: classification="
152
+ f"{comparison['classification_match']}, "
153
+ f"trace-hash={comparison['trace_hash_match']}, "
154
+ f"model-identity={comparison['model_identity_match']}"
155
+ )
156
+ else:
157
+ matched = all(
158
+ value is True for key, value in comparison.items() if key.endswith("_match")
159
+ )
160
+ print(f"{comparison['series']}: declared tolerances={matched}")
161
+ return int(ExitCode.OK if verification.ok else ExitCode.POLICY_FAILED)
162
+
163
+
164
+ def _generate_command(args: argparse.Namespace) -> int:
165
+ fixture = args.fixture
166
+ output_directory = args.output_directory
167
+ if not isinstance(fixture, Path) or not isinstance(output_directory, Path):
168
+ raise ConfigurationError("fixture and output directory must be paths")
169
+ generated = generate_regression_test(fixture, output_directory)
170
+ data: dict[str, object] = {
171
+ "schema_version": "2.0",
172
+ "status": "PYTEST REGRESSION GENERATED",
173
+ "test": str(generated.test_path),
174
+ "fixture": str(generated.fixture_path),
175
+ }
176
+ if bool(getattr(args, "json", False)):
177
+ sys.stdout.write(json_report(data))
178
+ else:
179
+ print("PYTEST REGRESSION GENERATED")
180
+ print()
181
+ print(f"Test: {generated.test_path}")
182
+ print(f"Replay fixture: {generated.fixture_path}")
183
+ return int(ExitCode.OK)
184
+
185
+
186
+ def _report_command(args: argparse.Namespace) -> int:
187
+ run_directory = args.run_directory
188
+ report_format = args.format
189
+ if not isinstance(run_directory, Path) or not isinstance(report_format, str):
190
+ raise ConfigurationError("invalid report arguments")
191
+ json_path, html_path = regenerate_reports(run_directory)
192
+ parsed = json.loads((run_directory / "run.json").read_text(encoding="utf-8"))
193
+ if not isinstance(parsed, dict):
194
+ raise ConfigurationError("run.json must contain an object")
195
+ data: dict[str, object] = parsed
196
+ if report_format in {"terminal", "all"}:
197
+ print(terminal_report(data, replay=str(run_directory / "replay.json")))
198
+ if report_format in {"json", "all"}:
199
+ if report_format == "json":
200
+ sys.stdout.write(json_report(data))
201
+ else:
202
+ print(f"JSON report: {json_path}")
203
+ if report_format in {"html", "all"}:
204
+ print(f"HTML report: {html_path}")
205
+ return int(ExitCode.OK)
206
+
207
+
208
+ def main(argv: Sequence[str] | None = None) -> int:
209
+ """Run the CLI and translate expected defects into the documented exit contract."""
210
+
211
+ parser = build_parser()
212
+ args = parser.parse_args(argv)
213
+ try:
214
+ if args.command == "scan":
215
+ return _run_evidence_command(args, run_scan)
216
+ if args.command == "perturb":
217
+ return _run_evidence_command(args, run_perturb)
218
+ if args.command == "check":
219
+ return _run_evidence_command(args, run_check)
220
+ if args.command == "replay":
221
+ return _replay_command(args)
222
+ if args.command == "generate-test":
223
+ return _generate_command(args)
224
+ if args.command == "report":
225
+ return _report_command(args)
226
+ raise ConfigurationError(f"unknown command {args.command!r}")
227
+ except NumericalFailure as exc:
228
+ print(f"NUMERICAL FAILURE: {exc}", file=sys.stderr)
229
+ return int(ExitCode.NUMERICAL_FAILURE)
230
+ except (ConfigurationError, IntegrityError, ValueError, OSError) as exc:
231
+ print(f"INVALID INPUT: {exc}", file=sys.stderr)
232
+ return int(ExitCode.INVALID_INPUT)
233
+ except PhaseProbeError as exc:
234
+ print(f"PHASEPROBE ERROR: {exc}", file=sys.stderr)
235
+ return int(ExitCode.INTERNAL_ERROR)
236
+ except Exception as exc: # pragma: no cover - final CLI containment boundary
237
+ print(f"INTERNAL PHASEPROBE DEFECT: {type(exc).__name__}: {exc}", file=sys.stderr)
238
+ return int(ExitCode.INTERNAL_ERROR)
239
+
240
+
241
+ if __name__ == "__main__": # pragma: no cover
242
+ raise SystemExit(main())
phaseprobe/config.py ADDED
@@ -0,0 +1,132 @@
1
+ """Versioned JSON configuration loading and deterministic serialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ from phaseprobe.errors import ConfigurationError
14
+
15
+ CONFIG_SCHEMA_VERSION = "2.0"
16
+ SUPPORTED_CONFIG_SCHEMA_VERSIONS = frozenset({"1.0", CONFIG_SCHEMA_VERSION})
17
+ _DOTTED_MODULE = re.compile(r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$")
18
+ _IDENTIFIER = re.compile(r"^[A-Za-z_]\w*$")
19
+
20
+
21
+ def canonical_json(value: object) -> str:
22
+ """Serialize configuration data deterministically for hashing and replay."""
23
+
24
+ return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True)
25
+
26
+
27
+ def _as_object(value: Any, context: str) -> dict[str, object]:
28
+ if not isinstance(value, dict):
29
+ raise ConfigurationError(f"{context} must be a JSON object")
30
+ return cast(dict[str, object], value)
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ProbeConfig:
35
+ """Validated top-level configuration with typed access helpers."""
36
+
37
+ data: Mapping[str, object]
38
+ source: str
39
+
40
+ @property
41
+ def model(self) -> str:
42
+ value = self.data.get("model")
43
+ if not isinstance(value, str) or not value:
44
+ raise ConfigurationError("model must be a non-empty string")
45
+ return value
46
+
47
+ @property
48
+ def seed(self) -> int:
49
+ value = self.data.get("seed", 0)
50
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
51
+ raise ConfigurationError("seed must be a non-negative integer")
52
+ return value
53
+
54
+ def section(self, name: str, *, required: bool = True) -> Mapping[str, object]:
55
+ value = self.data.get(name)
56
+ if value is None and not required:
57
+ return {}
58
+ if not isinstance(value, dict):
59
+ raise ConfigurationError(f"{name} must be a JSON object")
60
+ return cast(Mapping[str, object], value)
61
+
62
+ def string(self, name: str, default: str | None = None) -> str:
63
+ value = self.data.get(name, default)
64
+ if not isinstance(value, str):
65
+ raise ConfigurationError(f"{name} must be a string")
66
+ return value
67
+
68
+
69
+ def parse_config(text: str, source: str) -> ProbeConfig:
70
+ """Parse and validate a versioned JSON configuration."""
71
+
72
+ try:
73
+ parsed: Any = json.loads(text)
74
+ except json.JSONDecodeError as exc:
75
+ raise ConfigurationError(f"invalid JSON in {source}: {exc}") from exc
76
+ data = _as_object(parsed, source)
77
+ version = data.get("schema_version")
78
+ if version not in SUPPORTED_CONFIG_SCHEMA_VERSIONS:
79
+ raise ConfigurationError(
80
+ f"unsupported configuration schema {version!r}; supported versions are "
81
+ f"{sorted(SUPPORTED_CONFIG_SCHEMA_VERSIONS)!r}"
82
+ )
83
+ config = ProbeConfig(data=data, source=source)
84
+ _ = config.model
85
+ _ = config.seed
86
+ adapter = data.get("adapter")
87
+ if adapter is not None:
88
+ values = _as_object(adapter, "adapter")
89
+ kind = values.get("kind")
90
+ module = values.get("module")
91
+ factory = values.get("factory")
92
+ if kind != "python":
93
+ raise ConfigurationError("adapter.kind must be 'python'")
94
+ if not isinstance(module, str) or _DOTTED_MODULE.fullmatch(module) is None:
95
+ raise ConfigurationError("adapter.module must be an absolute dotted Python module name")
96
+ if not isinstance(factory, str) or _IDENTIFIER.fullmatch(factory) is None:
97
+ raise ConfigurationError("adapter.factory must be a Python identifier")
98
+ return config
99
+
100
+
101
+ def load_config(path: Path) -> ProbeConfig:
102
+ """Load a UTF-8 JSON configuration from a user-selected path."""
103
+
104
+ try:
105
+ text = path.read_text(encoding="utf-8")
106
+ except OSError as exc:
107
+ raise ConfigurationError(f"cannot read configuration {path}: {exc}") from exc
108
+ return parse_config(text, str(path))
109
+
110
+
111
+ EXAMPLE_FILES: Mapping[str, str] = {
112
+ "logistic": "logistic-scan.json",
113
+ "logistic-negative": "logistic-negative.json",
114
+ "lorenz": "lorenz-perturb.json",
115
+ "lorenz-negative": "lorenz-negative.json",
116
+ "predator-prey": "predator-prey-check.json",
117
+ "predator-prey-negative": "predator-prey-negative.json",
118
+ "toggle": "toggle-perturb.json",
119
+ "toggle-negative": "toggle-negative.json",
120
+ }
121
+
122
+
123
+ def load_example(name: str) -> ProbeConfig:
124
+ """Load one of the immutable examples embedded in the installed wheel."""
125
+
126
+ filename = EXAMPLE_FILES.get(name)
127
+ if filename is None:
128
+ choices = ", ".join(sorted(EXAMPLE_FILES))
129
+ raise ConfigurationError(f"unknown example {name!r}; choose one of: {choices}")
130
+ package = resources.files("phaseprobe.data.examples")
131
+ text = package.joinpath(filename).read_text(encoding="utf-8")
132
+ return parse_config(text, f"built-in example {name}")
@@ -0,0 +1 @@
1
+ """Packaged PhaseProbe data."""
@@ -0,0 +1 @@
1
+ """Deterministic built-in example configurations."""
@@ -0,0 +1,27 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "model": "logistic-map",
4
+ "seed": 17,
5
+ "parameters": {"r": 3.2},
6
+ "model_config": {"initial_state": {"x": 0.2}},
7
+ "simulation": {
8
+ "steps": 128,
9
+ "burn_in": 1500,
10
+ "dt": 1.0,
11
+ "sample_every": 1,
12
+ "trace_cap": 128,
13
+ "hard_state_limit": 10.0
14
+ },
15
+ "tolerances": {"period": 1e-9},
16
+ "scan": {
17
+ "parameter": "r",
18
+ "start": 3.2,
19
+ "stop": 3.4,
20
+ "points": 5,
21
+ "refine_iterations": 6,
22
+ "repeatability": 2
23
+ },
24
+ "classification_rule": "Finite-period tail classifier used by the positive logistic example.",
25
+ "refinement_rule": "Refine only if an adjacent qualitative class change is observed.",
26
+ "invalid_state_policy": "Abort on invalid integration."
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "model": "logistic-map",
4
+ "seed": 17,
5
+ "parameters": {"r": 3.448},
6
+ "model_config": {"initial_state": {"x": 0.2}},
7
+ "simulation": {
8
+ "steps": 256,
9
+ "burn_in": 500000,
10
+ "dt": 1.0,
11
+ "sample_every": 1,
12
+ "trace_cap": 256,
13
+ "hard_state_limit": 10.0
14
+ },
15
+ "tolerances": {"period": 1e-9},
16
+ "scan": {
17
+ "parameter": "r",
18
+ "start": 3.448,
19
+ "stop": 3.452,
20
+ "points": 3,
21
+ "refine_iterations": 12,
22
+ "repeatability": 2
23
+ },
24
+ "classification_rule": "Smallest detected period in {1,2,4,8,16} whose retained tail repeats within absolute tolerance; otherwise unresolved/aperiodic.",
25
+ "refinement_rule": "First adjacent coarse class change, followed by up to twelve deterministic binary probes. Refinement stops rather than relabeling a midpoint that reproduces neither stable endpoint class.",
26
+ "invalid_state_policy": "Abort as invalid integration on NaN, infinity, overflow, dimension mismatch, or hard-state-limit breach."
27
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "model": "lorenz",
4
+ "seed": 23,
5
+ "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665},
6
+ "model_config": {"initial_state": {"x": 1.0, "y": 1.0, "z": 1.0}},
7
+ "simulation": {
8
+ "steps": 1000,
9
+ "burn_in": 0,
10
+ "dt": 0.005,
11
+ "sample_every": 2,
12
+ "trace_cap": 500,
13
+ "hard_state_limit": 1000.0
14
+ },
15
+ "tolerances": {"state_bound": 100.0},
16
+ "perturb": {
17
+ "dimension": "x",
18
+ "start": 1e-8,
19
+ "stop": 0.0001,
20
+ "points": 5,
21
+ "scale": "log",
22
+ "predicate": "finite-time-divergence",
23
+ "divergence_threshold": 1000.0,
24
+ "refine_iterations": 4,
25
+ "repeatability": 2
26
+ },
27
+ "classification_rule": "Finite-time lobe visitation only.",
28
+ "refinement_rule": "No refinement when the declared separation threshold is not reached.",
29
+ "invalid_state_policy": "Abort on invalid integration."
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "model": "lorenz",
4
+ "seed": 23,
5
+ "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665},
6
+ "model_config": {"initial_state": {"x": 1.0, "y": 1.0, "z": 1.0}},
7
+ "simulation": {
8
+ "steps": 8000,
9
+ "burn_in": 0,
10
+ "dt": 0.005,
11
+ "sample_every": 2,
12
+ "trace_cap": 4000,
13
+ "hard_state_limit": 1000.0
14
+ },
15
+ "tolerances": {"state_bound": 100.0},
16
+ "perturb": {
17
+ "dimension": "x",
18
+ "start": 1e-8,
19
+ "stop": 0.0001,
20
+ "points": 5,
21
+ "scale": "log",
22
+ "predicate": "finite-time-divergence",
23
+ "divergence_threshold": 1.0,
24
+ "refine_iterations": 8,
25
+ "repeatability": 2
26
+ },
27
+ "classification_rule": "Report retained finite-time lobe visitation; the finding predicate is Euclidean twin-trajectory separation, not a qualitative-class change.",
28
+ "refinement_rule": "Evaluate ascending logarithmic perturbations; refine a non-trigger/trigger bracket when one exists and repeat the smallest trigger.",
29
+ "invalid_state_policy": "Abort on NaN, infinity, overflow, or declared hard-state-limit breach."
30
+ }