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/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
|
+
]
|
phaseprobe/artifacts.py
ADDED
|
@@ -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
|
+
}
|