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.
- physim_gate/__init__.py +62 -0
- physim_gate/__main__.py +3 -0
- physim_gate/adapters/__init__.py +23 -0
- physim_gate/adapters/base.py +142 -0
- physim_gate/adapters/gymnasium.py +224 -0
- physim_gate/adapters/json_adapter.py +18 -0
- physim_gate/adapters/lerobot.py +241 -0
- physim_gate/adapters/synthetic.py +211 -0
- physim_gate/cli.py +671 -0
- physim_gate/comparison.py +219 -0
- physim_gate/config.py +158 -0
- physim_gate/demo.py +114 -0
- physim_gate/engine.py +526 -0
- physim_gate/fixtures/__init__.py +0 -0
- physim_gate/fixtures/insufficient_evidence/baseline.yaml +28 -0
- physim_gate/fixtures/insufficient_evidence/candidate.yaml +28 -0
- physim_gate/fixtures/insufficient_evidence/gate.yaml +9 -0
- physim_gate/fixtures/invalid_comparison/baseline.yaml +26 -0
- physim_gate/fixtures/invalid_comparison/candidate.yaml +19 -0
- physim_gate/fixtures/invalid_comparison/gate.yaml +9 -0
- physim_gate/fixtures/no_regression/baseline.yaml +37 -0
- physim_gate/fixtures/no_regression/candidate.yaml +37 -0
- physim_gate/fixtures/no_regression/gate.yaml +20 -0
- physim_gate/fixtures/regression/baseline.yaml +42 -0
- physim_gate/fixtures/regression/candidate.yaml +42 -0
- physim_gate/fixtures/regression/gate.yaml +15 -0
- physim_gate/planning.py +297 -0
- physim_gate/provenance.py +83 -0
- physim_gate/py.typed +0 -0
- physim_gate/reporting/__init__.py +8 -0
- physim_gate/reporting/format.py +67 -0
- physim_gate/reporting/html.py +577 -0
- physim_gate/reporting/junit.py +104 -0
- physim_gate/reporting/markdown.py +176 -0
- physim_gate/reporting/terminal.py +203 -0
- physim_gate/schema.py +320 -0
- physim_gate/stats.py +200 -0
- physim_gate/validation.py +95 -0
- physim_gate-0.2.0.dist-info/METADATA +465 -0
- physim_gate-0.2.0.dist-info/RECORD +43 -0
- physim_gate-0.2.0.dist-info/WHEEL +4 -0
- physim_gate-0.2.0.dist-info/entry_points.txt +8 -0
- physim_gate-0.2.0.dist-info/licenses/LICENSE +201 -0
physim_gate/__init__.py
ADDED
|
@@ -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
|
+
]
|
physim_gate/__main__.py
ADDED
|
@@ -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)
|