physim-gate 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 (43) hide show
  1. physim_gate/__init__.py +62 -0
  2. physim_gate/__main__.py +3 -0
  3. physim_gate/adapters/__init__.py +23 -0
  4. physim_gate/adapters/base.py +142 -0
  5. physim_gate/adapters/gymnasium.py +224 -0
  6. physim_gate/adapters/json_adapter.py +18 -0
  7. physim_gate/adapters/lerobot.py +241 -0
  8. physim_gate/adapters/synthetic.py +211 -0
  9. physim_gate/cli.py +671 -0
  10. physim_gate/comparison.py +219 -0
  11. physim_gate/config.py +158 -0
  12. physim_gate/demo.py +114 -0
  13. physim_gate/engine.py +526 -0
  14. physim_gate/fixtures/__init__.py +0 -0
  15. physim_gate/fixtures/insufficient_evidence/baseline.yaml +28 -0
  16. physim_gate/fixtures/insufficient_evidence/candidate.yaml +28 -0
  17. physim_gate/fixtures/insufficient_evidence/gate.yaml +9 -0
  18. physim_gate/fixtures/invalid_comparison/baseline.yaml +26 -0
  19. physim_gate/fixtures/invalid_comparison/candidate.yaml +19 -0
  20. physim_gate/fixtures/invalid_comparison/gate.yaml +9 -0
  21. physim_gate/fixtures/no_regression/baseline.yaml +37 -0
  22. physim_gate/fixtures/no_regression/candidate.yaml +37 -0
  23. physim_gate/fixtures/no_regression/gate.yaml +20 -0
  24. physim_gate/fixtures/regression/baseline.yaml +42 -0
  25. physim_gate/fixtures/regression/candidate.yaml +42 -0
  26. physim_gate/fixtures/regression/gate.yaml +15 -0
  27. physim_gate/planning.py +297 -0
  28. physim_gate/provenance.py +83 -0
  29. physim_gate/py.typed +0 -0
  30. physim_gate/reporting/__init__.py +8 -0
  31. physim_gate/reporting/format.py +67 -0
  32. physim_gate/reporting/html.py +577 -0
  33. physim_gate/reporting/junit.py +104 -0
  34. physim_gate/reporting/markdown.py +176 -0
  35. physim_gate/reporting/terminal.py +203 -0
  36. physim_gate/schema.py +320 -0
  37. physim_gate/stats.py +200 -0
  38. physim_gate/validation.py +95 -0
  39. physim_gate-0.2.0.dist-info/METADATA +465 -0
  40. physim_gate-0.2.0.dist-info/RECORD +43 -0
  41. physim_gate-0.2.0.dist-info/WHEEL +4 -0
  42. physim_gate-0.2.0.dist-info/entry_points.txt +8 -0
  43. physim_gate-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,62 @@
1
+ """PhySim Gate: simulator-agnostic regression gates for physical AI.
2
+
3
+ Typical library use::
4
+
5
+ from physim_gate import compare_runs, load_config, load_run
6
+
7
+ result = compare_runs(load_run(baseline), load_run(candidate), load_config(gate))
8
+ print(result.verdict, result.summary)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.2.0"
14
+
15
+ from physim_gate.comparison import (
16
+ ComparisonResult,
17
+ Evidence,
18
+ ExitCode,
19
+ MetricComparison,
20
+ Verdict,
21
+ load_comparison,
22
+ )
23
+ from physim_gate.config import GateConfig, MetricGate, load_config
24
+ from physim_gate.engine import compare_runs
25
+ from physim_gate.schema import (
26
+ FAILURE_RATE_METRIC,
27
+ Direction,
28
+ Domain,
29
+ MetricSpec,
30
+ RunResult,
31
+ SchemaError,
32
+ Trial,
33
+ TrialStatus,
34
+ dump_json,
35
+ load_run,
36
+ write_run,
37
+ )
38
+
39
+ __all__ = [
40
+ "FAILURE_RATE_METRIC",
41
+ "ComparisonResult",
42
+ "Direction",
43
+ "Domain",
44
+ "Evidence",
45
+ "ExitCode",
46
+ "GateConfig",
47
+ "MetricComparison",
48
+ "MetricGate",
49
+ "MetricSpec",
50
+ "RunResult",
51
+ "SchemaError",
52
+ "Trial",
53
+ "TrialStatus",
54
+ "Verdict",
55
+ "__version__",
56
+ "compare_runs",
57
+ "dump_json",
58
+ "load_comparison",
59
+ "load_config",
60
+ "load_run",
61
+ "write_run",
62
+ ]
@@ -0,0 +1,3 @@
1
+ from physim_gate.cli import main
2
+
3
+ main()
@@ -0,0 +1,23 @@
1
+ """Adapters convert simulator output into PhySim Gate run results."""
2
+
3
+ from physim_gate.adapters.base import (
4
+ BUILTIN_ADAPTERS,
5
+ ENTRY_POINT_GROUP,
6
+ Adapter,
7
+ AdapterError,
8
+ AdapterInfo,
9
+ discover,
10
+ get_adapter,
11
+ parse_options,
12
+ )
13
+
14
+ __all__ = [
15
+ "BUILTIN_ADAPTERS",
16
+ "ENTRY_POINT_GROUP",
17
+ "Adapter",
18
+ "AdapterError",
19
+ "AdapterInfo",
20
+ "discover",
21
+ "get_adapter",
22
+ "parse_options",
23
+ ]
@@ -0,0 +1,142 @@
1
+ """The adapter plugin contract and discovery.
2
+
3
+ An adapter turns a simulator's native output into a :class:`~physim_gate.schema.RunResult`.
4
+ Third-party packages register adapters under the ``physim_gate.adapters`` entry-point group::
5
+
6
+ [project.entry-points."physim_gate.adapters"]
7
+ my-sim = "my_package.adapter:MySimAdapter"
8
+
9
+ The entry point must resolve to a zero-argument callable (usually a class) that returns an
10
+ object satisfying :class:`Adapter`. No change to PhySim Gate itself is needed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from importlib import import_module
18
+ from importlib.metadata import EntryPoint, entry_points
19
+ from pathlib import Path
20
+ from typing import Protocol, runtime_checkable
21
+
22
+ from physim_gate.schema import RunResult
23
+
24
+ ENTRY_POINT_GROUP = "physim_gate.adapters"
25
+
26
+ #: Built-in adapters, available even when package metadata is missing (e.g. a source checkout).
27
+ BUILTIN_ADAPTERS: dict[str, str] = {
28
+ "json": "physim_gate.adapters.json_adapter:JsonAdapter",
29
+ "synthetic": "physim_gate.adapters.synthetic:SyntheticAdapter",
30
+ "gymnasium": "physim_gate.adapters.gymnasium:GymnasiumAdapter",
31
+ "lerobot": "physim_gate.adapters.lerobot:LeRobotAdapter",
32
+ }
33
+
34
+
35
+ class AdapterError(Exception):
36
+ """An adapter could not be found, loaded, or could not convert its input."""
37
+
38
+
39
+ @runtime_checkable
40
+ class Adapter(Protocol):
41
+ """What every adapter provides.
42
+
43
+ ``load`` receives the path given on the command line and any ``--adapter-option key=value``
44
+ pairs. It must return a validated :class:`RunResult` or raise :class:`AdapterError` (or
45
+ :class:`~physim_gate.schema.SchemaError`) with a message that tells the user what to fix.
46
+ """
47
+
48
+ name: str
49
+ description: str
50
+
51
+ def load(self, source: Path, options: Mapping[str, str]) -> RunResult: ...
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class AdapterInfo:
56
+ name: str
57
+ target: str
58
+ origin: str
59
+ error: str | None = None
60
+
61
+
62
+ def _entry_points() -> list[EntryPoint]:
63
+ return list(entry_points(group=ENTRY_POINT_GROUP))
64
+
65
+
66
+ def discover() -> dict[str, AdapterInfo]:
67
+ """All known adapters by name. Conflicting registrations are reported as errors."""
68
+ found = {
69
+ name: AdapterInfo(name=name, target=target, origin="built-in")
70
+ for name, target in BUILTIN_ADAPTERS.items()
71
+ }
72
+ for ep in _entry_points():
73
+ origin = ep.dist.name if ep.dist is not None else "unknown distribution"
74
+ existing = found.get(ep.name)
75
+ if existing is None:
76
+ found[ep.name] = AdapterInfo(name=ep.name, target=ep.value, origin=origin)
77
+ elif existing.target != ep.value:
78
+ found[f"{ep.name} ({origin})"] = AdapterInfo(
79
+ name=ep.name,
80
+ target=ep.value,
81
+ origin=origin,
82
+ error=f"name conflicts with the adapter from {existing.origin}; ignored",
83
+ )
84
+ return dict(sorted(found.items()))
85
+
86
+
87
+ def _resolve(target: str) -> object:
88
+ module_name, _, attr = target.partition(":")
89
+ obj: object = import_module(module_name)
90
+ for part in attr.split(".") if attr else []:
91
+ obj = getattr(obj, part)
92
+ return obj
93
+
94
+
95
+ def get_adapter(name: str) -> Adapter:
96
+ """Instantiate the adapter registered as ``name``."""
97
+ info = discover().get(name)
98
+ if info is None or info.error is not None:
99
+ available = ", ".join(n for n, i in discover().items() if i.error is None)
100
+ raise AdapterError(f"unknown adapter {name!r}; available: {available}")
101
+ try:
102
+ factory = _resolve(info.target)
103
+ except Exception as exc:
104
+ raise AdapterError(
105
+ f"adapter {name!r} ({info.target}, from {info.origin}) failed to import: "
106
+ f"{type(exc).__name__}: {exc}"
107
+ ) from exc
108
+ if not callable(factory):
109
+ raise AdapterError(f"adapter {name!r} ({info.target}) is not callable")
110
+ try:
111
+ adapter = factory()
112
+ except Exception as exc:
113
+ raise AdapterError(
114
+ f"adapter {name!r} ({info.target}) failed to initialize: {type(exc).__name__}: {exc}"
115
+ ) from exc
116
+ if not isinstance(adapter, Adapter):
117
+ raise AdapterError(
118
+ f"adapter {name!r} ({info.target}) does not implement the Adapter protocol "
119
+ "(needs 'name', 'description' and 'load(source, options)')"
120
+ )
121
+ return adapter
122
+
123
+
124
+ def parse_options(pairs: list[str]) -> dict[str, str]:
125
+ """Parse repeated ``key=value`` strings."""
126
+ options: dict[str, str] = {}
127
+ for pair in pairs:
128
+ key, sep, value = pair.partition("=")
129
+ if not sep or not key:
130
+ raise AdapterError(f"adapter option {pair!r} must look like key=value")
131
+ options[key.strip()] = value.strip()
132
+ return options
133
+
134
+
135
+ def reject_unknown_options(adapter: str, options: Mapping[str, str], known: set[str]) -> None:
136
+ unknown = sorted(set(options) - known)
137
+ if unknown:
138
+ accepted = ", ".join(sorted(known)) or "none"
139
+ raise AdapterError(
140
+ f"adapter {adapter!r} does not accept option(s) {', '.join(unknown)}; "
141
+ f"accepted: {accepted}"
142
+ )
@@ -0,0 +1,224 @@
1
+ """Optional Gymnasium integration: roll out a policy over seeds and record a run result.
2
+
3
+ Requires the ``gymnasium`` extra (``pip install "physim-gate[gymnasium]"``). Only CPU
4
+ environments that need no extra system packages (for example the classic-control suite) are
5
+ tested. Gymnasium's own reproducibility caveats apply: seeding ``reset`` fixes the initial
6
+ state, not every source of non-determinism in every environment.
7
+
8
+ From Python::
9
+
10
+ from physim_gate.adapters.gymnasium import run_gymnasium
11
+ run = run_gymnasium("CartPole-v1", my_policy, seeds=range(30), run_id="cand-42")
12
+
13
+ From the CLI, point the ``gymnasium`` adapter at a YAML spec::
14
+
15
+ env_id: CartPole-v1
16
+ policy: my_package.policies:balance # callable(observation) -> action, or "random"
17
+ episodes: 30
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import platform
24
+ from collections.abc import Callable, Iterable, Mapping
25
+ from pathlib import Path
26
+ from typing import Annotated, Any
27
+
28
+ import yaml
29
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
30
+
31
+ from physim_gate.adapters.base import AdapterError, reject_unknown_options
32
+ from physim_gate.provenance import capture_environment, capture_git_source, utc_now
33
+ from physim_gate.schema import (
34
+ Direction,
35
+ Domain,
36
+ Failure,
37
+ Identifier,
38
+ InputError,
39
+ MetricSpec,
40
+ RunResult,
41
+ RunStatus,
42
+ SchemaError,
43
+ Simulator,
44
+ Trial,
45
+ TrialStatus,
46
+ format_validation_error,
47
+ )
48
+
49
+ Policy = Callable[[Any], Any]
50
+
51
+
52
+ def _require_gymnasium() -> Any:
53
+ try:
54
+ return importlib.import_module("gymnasium")
55
+ except ImportError as exc:
56
+ raise AdapterError(
57
+ "the gymnasium adapter needs the optional dependency: "
58
+ 'pip install "physim-gate[gymnasium]"'
59
+ ) from exc
60
+
61
+
62
+ def run_gymnasium(
63
+ env_id: str,
64
+ policy: Policy | None,
65
+ *,
66
+ seeds: Iterable[int],
67
+ run_id: str,
68
+ policy_id: str | None = None,
69
+ max_steps: int | None = None,
70
+ env_kwargs: Mapping[str, Any] | None = None,
71
+ domain: Domain = Domain.GENERIC,
72
+ ) -> RunResult:
73
+ """Roll out ``policy`` once per seed and return a run result.
74
+
75
+ ``policy`` maps an observation to an action. ``None`` samples uniformly from the action space,
76
+ seeded per episode. Exceptions raised by the policy or the environment mark that trial as
77
+ ``error`` instead of aborting the run.
78
+ """
79
+ gym = _require_gymnasium()
80
+ env = gym.make(env_id, **dict(env_kwargs or {}))
81
+ trials: list[Trial] = []
82
+ seed_list = list(seeds)
83
+ try:
84
+ for seed in seed_list:
85
+ trials.append(_episode(env, policy, seed, max_steps))
86
+ finally:
87
+ env.close()
88
+
89
+ errors = sum(1 for t in trials if t.status is not TrialStatus.OK)
90
+ return RunResult(
91
+ run_id=run_id,
92
+ created_at=utc_now(),
93
+ domain=domain,
94
+ simulator=Simulator(name="gymnasium", version=str(gym.__version__)),
95
+ scenario=Identifier(id=env_id),
96
+ policy=Identifier(id=policy_id or _policy_name(policy)),
97
+ seeds=seed_list,
98
+ source=capture_git_source(),
99
+ environment=capture_environment(implementation=platform.python_implementation()),
100
+ status=RunStatus.PARTIAL if errors else RunStatus.COMPLETED,
101
+ metrics={
102
+ "episode_return": MetricSpec(
103
+ direction=Direction.HIGHER_IS_BETTER,
104
+ description="Sum of rewards over the episode.",
105
+ ),
106
+ "episode_length": MetricSpec(
107
+ direction=Direction.HIGHER_IS_BETTER,
108
+ unit="steps",
109
+ description="Steps until termination or truncation.",
110
+ ),
111
+ "terminated": MetricSpec(
112
+ direction=Direction.LOWER_IS_BETTER,
113
+ description="1 if the episode ended in a terminal state, 0 if truncated.",
114
+ ),
115
+ },
116
+ trials=trials,
117
+ )
118
+
119
+
120
+ def _episode(env: Any, policy: Policy | None, seed: int, max_steps: int | None) -> Trial:
121
+ total = 0.0
122
+ steps = 0
123
+ terminated = truncated = False
124
+ try:
125
+ observation, _ = env.reset(seed=seed)
126
+ env.action_space.seed(seed)
127
+ while not (terminated or truncated):
128
+ action = env.action_space.sample() if policy is None else policy(observation)
129
+ observation, reward, terminated, truncated, _ = env.step(action)
130
+ total += float(reward)
131
+ steps += 1
132
+ if max_steps is not None and steps >= max_steps:
133
+ truncated = True
134
+ except Exception as exc:
135
+ return Trial(
136
+ trial_id=f"seed-{seed}",
137
+ seed=seed,
138
+ status=TrialStatus.ERROR,
139
+ failure=Failure(kind=type(exc).__name__, message=str(exc)),
140
+ metrics={"episode_return": None, "episode_length": None, "terminated": None},
141
+ )
142
+ return Trial(
143
+ trial_id=f"seed-{seed}",
144
+ seed=seed,
145
+ metrics={
146
+ "episode_return": total,
147
+ "episode_length": float(steps),
148
+ "terminated": 1.0 if terminated else 0.0,
149
+ },
150
+ )
151
+
152
+
153
+ def _policy_name(policy: Policy | None) -> str:
154
+ if policy is None:
155
+ return "random"
156
+ module = getattr(policy, "__module__", None) or "unknown"
157
+ name = getattr(policy, "__qualname__", None) or type(policy).__name__
158
+ return f"{module}:{name}"
159
+
160
+
161
+ class GymnasiumSpec(BaseModel):
162
+ model_config = ConfigDict(extra="forbid", frozen=True)
163
+
164
+ env_id: str
165
+ policy: str = "random"
166
+ run_id: str
167
+ policy_id: str | None = None
168
+ episodes: Annotated[int, Field(ge=1, le=100_000)] = 30
169
+ first_seed: int = 0
170
+ max_steps: Annotated[int, Field(ge=1)] | None = None
171
+ env_kwargs: dict[str, Any] = Field(default_factory=dict)
172
+ domain: Domain = Domain.GENERIC
173
+
174
+
175
+ def load_policy(reference: str) -> Policy | None:
176
+ """Resolve ``"random"`` or ``"module:attribute"`` to a policy callable."""
177
+ if reference == "random":
178
+ return None
179
+ module_name, sep, attr = reference.partition(":")
180
+ if not sep or not attr:
181
+ raise AdapterError(f"policy {reference!r} must be 'random' or 'module:attribute'")
182
+ try:
183
+ obj: Any = importlib.import_module(module_name)
184
+ for part in attr.split("."):
185
+ obj = getattr(obj, part)
186
+ except (ImportError, AttributeError) as exc:
187
+ raise AdapterError(f"cannot load policy {reference!r}: {exc}") from exc
188
+ if not callable(obj):
189
+ raise AdapterError(f"policy {reference!r} is not callable")
190
+ policy: Policy = obj
191
+ return policy
192
+
193
+
194
+ class GymnasiumAdapter:
195
+ name = "gymnasium"
196
+ description = "Runs a policy in a Gymnasium environment over seeds (optional extra, CPU)."
197
+
198
+ def load(self, source: Path, options: Mapping[str, str]) -> RunResult:
199
+ reject_unknown_options(self.name, options, {"run_id", "policy", "episodes"})
200
+ try:
201
+ data = yaml.safe_load(source.read_text(encoding="utf-8"))
202
+ except OSError as exc:
203
+ raise InputError(f"{source}: cannot read file ({exc.strerror or exc})") from exc
204
+ except yaml.YAMLError as exc:
205
+ raise SchemaError(f"{source}: invalid YAML: {exc}") from exc
206
+ if not isinstance(data, dict):
207
+ raise SchemaError(f"{source}: expected a mapping at the top level")
208
+ data = {**data, **options}
209
+ try:
210
+ spec = GymnasiumSpec.model_validate(data)
211
+ except ValidationError as exc:
212
+ raise SchemaError(
213
+ f"{source}: not a valid gymnasium spec", format_validation_error(exc)
214
+ ) from exc
215
+ return run_gymnasium(
216
+ spec.env_id,
217
+ load_policy(spec.policy),
218
+ seeds=range(spec.first_seed, spec.first_seed + spec.episodes),
219
+ run_id=spec.run_id,
220
+ policy_id=spec.policy_id or spec.policy,
221
+ max_steps=spec.max_steps,
222
+ env_kwargs=spec.env_kwargs,
223
+ domain=spec.domain,
224
+ )
@@ -0,0 +1,18 @@
1
+ """Reads results that are already in the PhySim Gate run schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ from physim_gate.adapters.base import reject_unknown_options
9
+ from physim_gate.schema import RunResult, load_run
10
+
11
+
12
+ class JsonAdapter:
13
+ name = "json"
14
+ description = "PhySim Gate run-result JSON (schema 1.x). The default."
15
+
16
+ def load(self, source: Path, options: Mapping[str, str]) -> RunResult:
17
+ reject_unknown_options(self.name, options, set())
18
+ return load_run(source)