inspect-robots 0.3.1__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.
- inspect_robots/__init__.py +108 -0
- inspect_robots/_builtins.py +53 -0
- inspect_robots/_defaults.py +153 -0
- inspect_robots/approver.py +70 -0
- inspect_robots/cli.py +393 -0
- inspect_robots/compat.py +203 -0
- inspect_robots/controller.py +174 -0
- inspect_robots/embodiment.py +78 -0
- inspect_robots/errors.py +55 -0
- inspect_robots/eval.py +458 -0
- inspect_robots/frames.py +60 -0
- inspect_robots/log.py +111 -0
- inspect_robots/logging/__init__.py +15 -0
- inspect_robots/logging/json_log.py +88 -0
- inspect_robots/logging/rerun_sink.py +120 -0
- inspect_robots/logging/sink.py +53 -0
- inspect_robots/mock/__init__.py +14 -0
- inspect_robots/mock/cubepick.py +109 -0
- inspect_robots/mock/policies.py +103 -0
- inspect_robots/policy.py +72 -0
- inspect_robots/py.typed +0 -0
- inspect_robots/registry.py +116 -0
- inspect_robots/rollout.py +255 -0
- inspect_robots/scene.py +54 -0
- inspect_robots/scorer.py +260 -0
- inspect_robots/spaces.py +151 -0
- inspect_robots/task.py +76 -0
- inspect_robots/transcript.py +53 -0
- inspect_robots/types.py +95 -0
- inspect_robots-0.3.1.dist-info/METADATA +209 -0
- inspect_robots-0.3.1.dist-info/RECORD +34 -0
- inspect_robots-0.3.1.dist-info/WHEEL +4 -0
- inspect_robots-0.3.1.dist-info/entry_points.txt +2 -0
- inspect_robots-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Inspect Robots — the Inspect AI for robotics.
|
|
2
|
+
|
|
3
|
+
An evaluation framework for VLA (vision-language-action) models. A benchmark is
|
|
4
|
+
defined once as a [`Task`][inspect_robots.task.Task] and run against any compatible
|
|
5
|
+
pairing of a [`Policy`][inspect_robots.policy.Policy] (the VLA) and an
|
|
6
|
+
[`Embodiment`][inspect_robots.embodiment.Embodiment] (a real robot or simulator).
|
|
7
|
+
|
|
8
|
+
The public API is everything exported here via ``__all__``. Anything not listed
|
|
9
|
+
(or prefixed with ``_``) is private and carries no stability guarantee.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = version("inspect_robots")
|
|
18
|
+
except PackageNotFoundError: # pragma: no cover - only hit in a non-installed tree
|
|
19
|
+
__version__ = "0.0.0+unknown"
|
|
20
|
+
|
|
21
|
+
from inspect_robots.embodiment import (
|
|
22
|
+
Embodiment,
|
|
23
|
+
EmbodimentBase,
|
|
24
|
+
EmbodimentInfo,
|
|
25
|
+
)
|
|
26
|
+
from inspect_robots.eval import eval, eval_set
|
|
27
|
+
from inspect_robots.log import (
|
|
28
|
+
EvalLog,
|
|
29
|
+
EvalResults,
|
|
30
|
+
EvalSpec,
|
|
31
|
+
EvalStats,
|
|
32
|
+
SceneResult,
|
|
33
|
+
read_eval_log,
|
|
34
|
+
)
|
|
35
|
+
from inspect_robots.policy import Policy, PolicyBase, PolicyConfig, PolicyInfo
|
|
36
|
+
from inspect_robots.registry import embodiment, policy, registered, resolve, scorer, sink, task
|
|
37
|
+
from inspect_robots.rollout import TrialRecord
|
|
38
|
+
from inspect_robots.scene import Scene, Target
|
|
39
|
+
from inspect_robots.scorer import (
|
|
40
|
+
Score,
|
|
41
|
+
Scorer,
|
|
42
|
+
episode_length,
|
|
43
|
+
min_distance_to_goal,
|
|
44
|
+
operator_scorer,
|
|
45
|
+
reached_goal_state,
|
|
46
|
+
success_at_end,
|
|
47
|
+
)
|
|
48
|
+
from inspect_robots.spaces import (
|
|
49
|
+
ActionSemantics,
|
|
50
|
+
Box,
|
|
51
|
+
CameraSpec,
|
|
52
|
+
ObservationSpace,
|
|
53
|
+
StateField,
|
|
54
|
+
StateSpec,
|
|
55
|
+
)
|
|
56
|
+
from inspect_robots.task import Epochs, Task
|
|
57
|
+
from inspect_robots.types import Action, ActionChunk, Observation, StepResult
|
|
58
|
+
|
|
59
|
+
# The public, stability-guaranteed API. Anything not listed here (or prefixed
|
|
60
|
+
# with ``_``) is private. Authoring a benchmark, policy, or embodiment should only
|
|
61
|
+
# need these names.
|
|
62
|
+
__all__ = [
|
|
63
|
+
"Action",
|
|
64
|
+
"ActionChunk",
|
|
65
|
+
"ActionSemantics",
|
|
66
|
+
"Box",
|
|
67
|
+
"CameraSpec",
|
|
68
|
+
"Embodiment",
|
|
69
|
+
"EmbodimentBase",
|
|
70
|
+
"EmbodimentInfo",
|
|
71
|
+
"Epochs",
|
|
72
|
+
"EvalLog",
|
|
73
|
+
"EvalResults",
|
|
74
|
+
"EvalSpec",
|
|
75
|
+
"EvalStats",
|
|
76
|
+
"Observation",
|
|
77
|
+
"ObservationSpace",
|
|
78
|
+
"Policy",
|
|
79
|
+
"PolicyBase",
|
|
80
|
+
"PolicyConfig",
|
|
81
|
+
"PolicyInfo",
|
|
82
|
+
"Scene",
|
|
83
|
+
"SceneResult",
|
|
84
|
+
"Score",
|
|
85
|
+
"Scorer",
|
|
86
|
+
"StateField",
|
|
87
|
+
"StateSpec",
|
|
88
|
+
"StepResult",
|
|
89
|
+
"Target",
|
|
90
|
+
"Task",
|
|
91
|
+
"TrialRecord",
|
|
92
|
+
"__version__",
|
|
93
|
+
"embodiment",
|
|
94
|
+
"episode_length",
|
|
95
|
+
"eval",
|
|
96
|
+
"eval_set",
|
|
97
|
+
"min_distance_to_goal",
|
|
98
|
+
"operator_scorer",
|
|
99
|
+
"policy",
|
|
100
|
+
"reached_goal_state",
|
|
101
|
+
"read_eval_log",
|
|
102
|
+
"registered",
|
|
103
|
+
"resolve",
|
|
104
|
+
"scorer",
|
|
105
|
+
"sink",
|
|
106
|
+
"success_at_end",
|
|
107
|
+
"task",
|
|
108
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Register the in-tree builtin components with the registry.
|
|
2
|
+
|
|
3
|
+
Imported lazily by [`inspect_robots.registry`][inspect_robots.registry] on first
|
|
4
|
+
lookup, so importing ``inspect_robots`` stays cheap and free of import cycles.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from inspect_robots.logging import JsonLogSink, RerunSink
|
|
10
|
+
from inspect_robots.mock import CubePickEmbodiment, NoopPolicy, RandomPolicy, ScriptedPolicy
|
|
11
|
+
from inspect_robots.registry import embodiment, policy, scorer, sink, task
|
|
12
|
+
from inspect_robots.scene import Scene
|
|
13
|
+
from inspect_robots.scorer import (
|
|
14
|
+
episode_length,
|
|
15
|
+
min_distance_to_goal,
|
|
16
|
+
operator_scorer,
|
|
17
|
+
reached_goal_state,
|
|
18
|
+
success_at_end,
|
|
19
|
+
)
|
|
20
|
+
from inspect_robots.task import Task
|
|
21
|
+
|
|
22
|
+
# Embodiments
|
|
23
|
+
embodiment("cubepick")(CubePickEmbodiment)
|
|
24
|
+
|
|
25
|
+
# Policies
|
|
26
|
+
policy("scripted")(ScriptedPolicy)
|
|
27
|
+
policy("random")(RandomPolicy)
|
|
28
|
+
policy("noop")(NoopPolicy)
|
|
29
|
+
|
|
30
|
+
# Scorers
|
|
31
|
+
scorer("success_at_end")(success_at_end)
|
|
32
|
+
scorer("episode_length")(episode_length)
|
|
33
|
+
scorer("min_distance_to_goal")(min_distance_to_goal)
|
|
34
|
+
scorer("reached_goal_state")(reached_goal_state)
|
|
35
|
+
scorer("operator")(operator_scorer)
|
|
36
|
+
|
|
37
|
+
# Sinks
|
|
38
|
+
sink("json")(JsonLogSink)
|
|
39
|
+
sink("rerun")(RerunSink)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@task("cubepick-reach")
|
|
43
|
+
def _cubepick_reach(num_scenes: int = 4, max_steps: int = 80) -> Task:
|
|
44
|
+
"""A simple reach benchmark over a handful of seeded cube layouts."""
|
|
45
|
+
return Task(
|
|
46
|
+
name="cubepick-reach",
|
|
47
|
+
scenes=[
|
|
48
|
+
Scene(id=f"scene-{i}", instruction="reach the cube", init_seed=i)
|
|
49
|
+
for i in range(num_scenes)
|
|
50
|
+
],
|
|
51
|
+
scorer=success_at_end(),
|
|
52
|
+
max_steps=max_steps,
|
|
53
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""User-level default components for the zero-config CLI (plan 0005).
|
|
2
|
+
|
|
3
|
+
``inspect-robots "place the spoon on the plate"`` needs a policy and an
|
|
4
|
+
embodiment without flags. They come from, in order: explicit CLI flags
|
|
5
|
+
(handled in ``cli.py``), the ``INSPECT_ROBOTS_POLICY`` /
|
|
6
|
+
``INSPECT_ROBOTS_EMBODIMENT`` environment variables, then the user config
|
|
7
|
+
file ``<config-home>/inspect-robots/config.ini`` (INI via stdlib
|
|
8
|
+
``configparser`` — the core supports py3.10, which has no ``tomllib``).
|
|
9
|
+
|
|
10
|
+
There is deliberately **no project-local config file**: a checked-in
|
|
11
|
+
``./inspect-robots.ini`` choosing which policy runs on the user's hardware
|
|
12
|
+
would be a trust footgun for a tool that moves physical robots.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import configparser
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
ENV_POLICY = "INSPECT_ROBOTS_POLICY"
|
|
25
|
+
ENV_EMBODIMENT = "INSPECT_ROBOTS_EMBODIMENT"
|
|
26
|
+
ENV_SIM_EMBODIMENT = "INSPECT_ROBOTS_SIM_EMBODIMENT"
|
|
27
|
+
|
|
28
|
+
# Fallbacks for ad-hoc (instruction) runs when neither flag nor config decides.
|
|
29
|
+
ADHOC_SCORER_FALLBACK = "operator"
|
|
30
|
+
ADHOC_MAX_STEPS_FALLBACK = 300
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_value(text: str) -> Any:
|
|
34
|
+
"""Best-effort scalar parse for ``k=v`` args (bool/int/float/None/str)."""
|
|
35
|
+
low = text.lower()
|
|
36
|
+
if low in ("true", "false"):
|
|
37
|
+
return low == "true"
|
|
38
|
+
if low in ("none", "null"):
|
|
39
|
+
return None
|
|
40
|
+
for caster in (int, float):
|
|
41
|
+
try:
|
|
42
|
+
return caster(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
continue
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Defaults:
|
|
50
|
+
"""Resolved user defaults, each with a human-readable source for the run header."""
|
|
51
|
+
|
|
52
|
+
policy: str | None = None
|
|
53
|
+
policy_source: str | None = None
|
|
54
|
+
embodiment: str | None = None
|
|
55
|
+
embodiment_source: str | None = None
|
|
56
|
+
# The sim counterpart --sim swaps in; real hardware is just the default
|
|
57
|
+
# `embodiment`. Args are kept separate: real-rig args (ports, camera
|
|
58
|
+
# serials) are wrong for a sim and vice versa.
|
|
59
|
+
sim_embodiment: str | None = None
|
|
60
|
+
sim_embodiment_source: str | None = None
|
|
61
|
+
scorer: str | None = None
|
|
62
|
+
max_steps: int | None = None
|
|
63
|
+
policy_args: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
embodiment_args: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
sim_embodiment_args: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _config_path(env: Mapping[str, str]) -> Path | None:
|
|
69
|
+
"""The user config file location, derived from ``env`` only (testable)."""
|
|
70
|
+
if xdg := env.get("XDG_CONFIG_HOME"):
|
|
71
|
+
home = Path(xdg)
|
|
72
|
+
elif user_home := env.get("HOME"):
|
|
73
|
+
home = Path(user_home) / ".config"
|
|
74
|
+
else:
|
|
75
|
+
return None
|
|
76
|
+
return home / "inspect-robots" / "config.ini"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _die(path: Path, problem: str) -> SystemExit:
|
|
80
|
+
return SystemExit(f"error in {path}: {problem}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_args_section(parser: configparser.ConfigParser, section: str) -> dict[str, Any]:
|
|
84
|
+
"""An ``[<kind>.args]`` section as parsed kwargs, with ``~`` paths expanded."""
|
|
85
|
+
if not parser.has_section(section):
|
|
86
|
+
return {}
|
|
87
|
+
out: dict[str, Any] = {}
|
|
88
|
+
for key, raw in parser.items(section):
|
|
89
|
+
value = parse_value(raw)
|
|
90
|
+
if isinstance(value, str) and value.startswith("~"):
|
|
91
|
+
# Checkpoint paths are the flagship use; a literal "~/..." string
|
|
92
|
+
# would fail silently deep inside a plugin.
|
|
93
|
+
value = os.path.expanduser(value)
|
|
94
|
+
out[key] = value
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_config(path: Path) -> Defaults:
|
|
99
|
+
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"))
|
|
100
|
+
try:
|
|
101
|
+
with path.open(encoding="utf-8") as fh:
|
|
102
|
+
parser.read_file(fh)
|
|
103
|
+
except (configparser.Error, UnicodeDecodeError) as exc:
|
|
104
|
+
raise _die(path, f"malformed config: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
source = str(path)
|
|
107
|
+
max_steps: int | None = None
|
|
108
|
+
if raw_steps := parser.get("defaults", "max_steps", fallback=None):
|
|
109
|
+
parsed = parse_value(raw_steps)
|
|
110
|
+
if not isinstance(parsed, int) or isinstance(parsed, bool) or parsed < 1:
|
|
111
|
+
raise _die(path, f"[defaults] max_steps must be an integer >= 1, got {raw_steps!r}")
|
|
112
|
+
max_steps = parsed
|
|
113
|
+
|
|
114
|
+
policy = parser.get("defaults", "policy", fallback=None)
|
|
115
|
+
embodiment = parser.get("defaults", "embodiment", fallback=None)
|
|
116
|
+
sim_embodiment = parser.get("defaults", "sim_embodiment", fallback=None)
|
|
117
|
+
return Defaults(
|
|
118
|
+
policy=policy,
|
|
119
|
+
policy_source=source if policy else None,
|
|
120
|
+
embodiment=embodiment,
|
|
121
|
+
embodiment_source=source if embodiment else None,
|
|
122
|
+
sim_embodiment=sim_embodiment,
|
|
123
|
+
sim_embodiment_source=source if sim_embodiment else None,
|
|
124
|
+
scorer=parser.get("defaults", "scorer", fallback=None),
|
|
125
|
+
max_steps=max_steps,
|
|
126
|
+
policy_args=_parse_args_section(parser, "policy.args"),
|
|
127
|
+
embodiment_args=_parse_args_section(parser, "embodiment.args"),
|
|
128
|
+
sim_embodiment_args=_parse_args_section(parser, "sim_embodiment.args"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_defaults(env: Mapping[str, str]) -> Defaults:
|
|
133
|
+
"""Load user defaults: environment variables override the config file.
|
|
134
|
+
|
|
135
|
+
``env`` is injected (pass ``os.environ``) so tests never touch the real
|
|
136
|
+
home directory. A missing config file yields empty defaults; a malformed
|
|
137
|
+
or type-invalid one raises ``SystemExit`` naming the file — the
|
|
138
|
+
zero-config path must never print a traceback at the user.
|
|
139
|
+
"""
|
|
140
|
+
from dataclasses import replace
|
|
141
|
+
|
|
142
|
+
path = _config_path(env)
|
|
143
|
+
defaults = _read_config(path) if path is not None and path.is_file() else Defaults()
|
|
144
|
+
|
|
145
|
+
if policy := env.get(ENV_POLICY):
|
|
146
|
+
defaults = replace(defaults, policy=policy, policy_source=f"${ENV_POLICY}")
|
|
147
|
+
if embodiment := env.get(ENV_EMBODIMENT):
|
|
148
|
+
defaults = replace(defaults, embodiment=embodiment, embodiment_source=f"${ENV_EMBODIMENT}")
|
|
149
|
+
if sim := env.get(ENV_SIM_EMBODIMENT):
|
|
150
|
+
defaults = replace(
|
|
151
|
+
defaults, sim_embodiment=sim, sim_embodiment_source=f"${ENV_SIM_EMBODIMENT}"
|
|
152
|
+
)
|
|
153
|
+
return defaults
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The Approver — a safety gate between policy output and the embodiment.
|
|
2
|
+
|
|
3
|
+
Every action passes through ``Approver.review`` before ``embodiment.step``. This
|
|
4
|
+
is the robotics analog of Inspect AI's ``ApprovalPolicy`` and is more
|
|
5
|
+
safety-critical: an approver may pass, clamp, or veto an action (a veto raises
|
|
6
|
+
[`SafetyAbort`][inspect_robots.errors.SafetyAbort]). In the tracer slice the default approver
|
|
7
|
+
passes everything through; clamping/operator approval land in rollout hardening.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from inspect_robots.errors import SafetyAbort
|
|
18
|
+
from inspect_robots.spaces import Box
|
|
19
|
+
from inspect_robots.types import Action
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Approver(Protocol):
|
|
24
|
+
"""Reviews an action before it reaches the embodiment.
|
|
25
|
+
|
|
26
|
+
May return the action unchanged, return a modified (e.g. clamped) action, or
|
|
27
|
+
raise [`SafetyAbort`][inspect_robots.errors.SafetyAbort] to halt the eval.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def review(self, action: Action, store: dict[str, Any]) -> Action: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AutoApprover:
|
|
34
|
+
"""Approve every action unchanged (the permissive default)."""
|
|
35
|
+
|
|
36
|
+
def review(self, action: Action, store: dict[str, Any]) -> Action:
|
|
37
|
+
return action
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClampApprover:
|
|
41
|
+
"""Clamp actions to a box's ``low``/``high`` bounds before they reach hardware.
|
|
42
|
+
|
|
43
|
+
One-sided boxes are honored: a ``low``-only box clamps from below, a
|
|
44
|
+
``high``-only box from above. A modified action is flagged via
|
|
45
|
+
``action.meta["clamped"]`` so the rollout can record an approval event;
|
|
46
|
+
when nothing clamps, the *same* action object is returned (the rollout
|
|
47
|
+
detects modification by identity).
|
|
48
|
+
|
|
49
|
+
Non-finite values are the safety cases: ``NaN`` anywhere in the action
|
|
50
|
+
raises [`SafetyAbort`][inspect_robots.errors.SafetyAbort] — a NaN is a
|
|
51
|
+
poisonous value with no meaningful clamp, and it must never reach hardware.
|
|
52
|
+
``±inf`` is *not* an abort: it clamps to the finite bound on that side like
|
|
53
|
+
any other out-of-range value (and passes through if that side is
|
|
54
|
+
unbounded).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, action_space: Box):
|
|
58
|
+
self._space = action_space
|
|
59
|
+
|
|
60
|
+
def review(self, action: Action, store: dict[str, Any]) -> Action:
|
|
61
|
+
data = np.asarray(action.data, dtype=np.float64)
|
|
62
|
+
if bool(np.isnan(data).any()):
|
|
63
|
+
raise SafetyAbort("ClampApprover: action contains NaN; refusing to pass it on")
|
|
64
|
+
low, high = self._space.low, self._space.high
|
|
65
|
+
if low is None and high is None:
|
|
66
|
+
return action
|
|
67
|
+
clamped = np.clip(data, low, high)
|
|
68
|
+
if np.array_equal(clamped, data):
|
|
69
|
+
return action
|
|
70
|
+
return replace(action, data=clamped, meta={**dict(action.meta), "clamped": True})
|