From 9401b6693f377de822a7f904590048f22294692b Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Wed, 2 Sep 2026 21:05:33 +0200 Subject: [PATCH] Add the MolmoSpaces env-server integration Squash of the molmo-env-server branch (PR #504), rebased onto main after the tasks(spec) and keys restructuring: - `MolmoSpacesEnv` answers `tasks(spec)` with the benchmark's episode records; the config no longer reads benchmark.json itself - `MolmoAdapter.task_params` maps records to trial params under the new `molmo_spaces/keys.py` leaf (`eval.episode_index`, `eval.task_horizon`) - The env-server wire vocabulary lives in `protocol` constants on both sides, the `tasks` verb included - `serve_subprocess` waits for the bind and reports a server that dies before it --- .basedpyright/baseline.json | 72 --- ARCHITECTURE.md | 22 + CLAUDE.md | 2 + docs/evaluation.md | 1 + positronic/cfg/eval/sim/molmo.py | 99 ++++ positronic/drivers/roboarm/models.py | 19 +- positronic/drivers/roboarm/tests/test_ik.py | 19 + positronic/eval/__init__.py | 4 +- positronic/offboard/README.md | 29 ++ positronic/policy/observation.py | 32 +- positronic/policy/tests/test_policy_io.py | 9 + positronic/simulator/env_server/adapter.py | 18 +- positronic/simulator/env_server/client.py | 14 +- positronic/simulator/env_server/launcher.py | 41 +- positronic/simulator/env_server/protocol.py | 70 ++- positronic/simulator/env_server/proxy.py | 13 +- positronic/simulator/env_server/server.py | 28 +- .../simulator/env_server/tests/mujoco_env.py | 45 +- .../env_server/tests/test_remote_env.py | 124 +++-- positronic/simulator/libero/adapter.py | 3 +- positronic/simulator/molmo_spaces/__init__.py | 0 positronic/simulator/molmo_spaces/adapter.py | 77 +++ positronic/simulator/molmo_spaces/env.py | 449 ++++++++++++++++++ positronic/simulator/molmo_spaces/keys.py | 7 + positronic/simulator/molmo_spaces/launcher.py | 128 +++++ positronic/simulator/molmo_spaces/mapping.py | 266 +++++++++++ .../molmo_spaces/molmo_constraints.txt | 177 +++++++ .../simulator/molmo_spaces/tests/__init__.py | 0 .../molmo_spaces/tests/droid_obs.npz | Bin 0 -> 1528 bytes .../simulator/molmo_spaces/tests/e2e.py | 92 ++++ .../molmo_spaces/tests/make_fixture.py | 56 +++ .../molmo_spaces/tests/make_replay_fixture.py | 214 +++++++++ .../simulator/molmo_spaces/tests/parity.py | 178 +++++++ .../molmo_spaces/tests/parity_native.py | 144 ++++++ .../molmo_spaces/tests/parity_record.py | 23 + .../molmo_spaces/tests/replay_ep03.npz | Bin 0 -> 83328 bytes .../molmo_spaces/tests/replay_ep06.npz | Bin 0 -> 64232 bytes .../molmo_spaces/tests/test_adapter.py | 97 ++++ .../molmo_spaces/tests/test_mapping.py | 258 ++++++++++ .../molmo_spaces/tests/test_replay.py | 91 ++++ .../molmo_spaces/tests/test_tasks.py | 95 ++++ .../simulator/molmo_spaces/tests/validate.py | 160 +++++++ positronic/simulator/robolab/adapter.py | 3 +- positronic/tests/test_keys.py | 3 + positronic/vendors/openpi/codecs.py | 6 +- pyproject.toml | 11 +- 46 files changed, 3025 insertions(+), 174 deletions(-) create mode 100644 positronic/cfg/eval/sim/molmo.py create mode 100644 positronic/simulator/molmo_spaces/__init__.py create mode 100644 positronic/simulator/molmo_spaces/adapter.py create mode 100644 positronic/simulator/molmo_spaces/env.py create mode 100644 positronic/simulator/molmo_spaces/keys.py create mode 100644 positronic/simulator/molmo_spaces/launcher.py create mode 100644 positronic/simulator/molmo_spaces/mapping.py create mode 100644 positronic/simulator/molmo_spaces/molmo_constraints.txt create mode 100644 positronic/simulator/molmo_spaces/tests/__init__.py create mode 100644 positronic/simulator/molmo_spaces/tests/droid_obs.npz create mode 100644 positronic/simulator/molmo_spaces/tests/e2e.py create mode 100644 positronic/simulator/molmo_spaces/tests/make_fixture.py create mode 100644 positronic/simulator/molmo_spaces/tests/make_replay_fixture.py create mode 100644 positronic/simulator/molmo_spaces/tests/parity.py create mode 100644 positronic/simulator/molmo_spaces/tests/parity_native.py create mode 100644 positronic/simulator/molmo_spaces/tests/parity_record.py create mode 100644 positronic/simulator/molmo_spaces/tests/replay_ep03.npz create mode 100644 positronic/simulator/molmo_spaces/tests/replay_ep06.npz create mode 100644 positronic/simulator/molmo_spaces/tests/test_adapter.py create mode 100644 positronic/simulator/molmo_spaces/tests/test_mapping.py create mode 100644 positronic/simulator/molmo_spaces/tests/test_replay.py create mode 100644 positronic/simulator/molmo_spaces/tests/test_tasks.py create mode 100644 positronic/simulator/molmo_spaces/tests/validate.py diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index c351c8d9f..5d1902973 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -5724,52 +5724,6 @@ } } ], - "./positronic/simulator/env_server/client.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 22, - "endColumn": 33, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 26, - "endColumn": 50, - "lineCount": 1 - } - } - ], - "./positronic/simulator/env_server/proxy.py": [ - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 28, - "endColumn": 32, - "lineCount": 1 - } - } - ], - "./positronic/simulator/env_server/server.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 40, - "endColumn": 60, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 28, - "endColumn": 42, - "lineCount": 1 - } - } - ], "./positronic/simulator/env_server/tests/mujoco_env.py": [ { "code": "reportArgumentType", @@ -5819,14 +5773,6 @@ "lineCount": 1 } }, - { - "code": "reportOptionalMemberAccess", - "range": { - "startColumn": 60, - "endColumn": 64, - "lineCount": 1 - } - }, { "code": "reportAttributeAccessIssue", "range": { @@ -5837,14 +5783,6 @@ } ], "./positronic/simulator/env_server/tests/test_remote_env.py": [ - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 22, - "endColumn": 32, - "lineCount": 1 - } - }, { "code": "reportOptionalMemberAccess", "range": { @@ -6460,16 +6398,6 @@ } } ], - "./positronic/simulator/robolab/launcher.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 24, - "endColumn": 42, - "lineCount": 1 - } - } - ], "./positronic/simulator/robolab/make_fixture.py": [ { "code": "reportMissingImports", diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 621a40dab..af211f2c8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -165,3 +165,25 @@ leaks a timing vocabulary into the dataset core. Hence telemetry is a set of sid process, next to the dataset but never inside it: nested spans for the phase split and a free-running machine-load sampler, wall-clock native, owned by `positronic/telemetry.py`. The pass report is an offline reduce over those raw files, so nothing is stored twice and the dataset stays clock-agnostic. + +**An adoption loses nothing.** A task is defined in real or in one simulator, and carries that +simulator with it — object poses, success criteria and horizons included. What a customer buys is +one API across all of them: they implement a single Positronic policy interface and their model +runs on every supported env, giving up nothing the env offers natively. Two requirements hold that +up, one on each side of the interface: + +- Given a policy that already drives an env directly, it must be possible to construct a Positronic + `Policy` equivalent to it. This binds policy construction as much as it binds the adoption. +- An adoption's capabilities match what its env provides natively, so a Positronic run reproduces + the env's own run: a deterministic env to byte-identical outcomes (modulo wire format), a + non-deterministic env to an identical sim/inference call sequence (same count, same order). + +Every sim-env adoption ships a native-vs-Positronic parity test that drives one pinned episode +through both stacks and asserts this, re-run on every bump of the sim's pinned version. + +The episode horizon is one case of that reproduction rather than a rule of its own: a task that +defines a horizon has it enforced by the env, which reports expiry through the same terminal `done` +a success uses; a task that defines none leaves nothing to reproduce. The harness `Task.timeout` is +only a runaway-cost safety net, so the config that knows the benchmark derives the timeout from the +horizon it declares rather than taking one on faith — a budget below the horizon would silently +truncate valid episodes and score them as failures. diff --git a/CLAUDE.md b/CLAUDE.md index 508d4934f..f06ff815f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,4 +115,6 @@ # Infrastructure - Machines, Docker contexts and images: `docker/CONTEXTS.md` - Model-specific workflows: `positronic/vendors/{lerobot,gr00t,openpi}/README.md` +- Inference serving, and the adapter/codec/wire-client separation of responsibilities (read BEFORE + writing a sim/rig adapter): `positronic/offboard/README.md` - Reconstructing previous runs: read `run_metadata_*.yaml` and episode `static.json` from output directory diff --git a/docs/evaluation.md b/docs/evaluation.md index 080096c4b..cee843bfe 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -9,6 +9,7 @@ You ship a new checkpoint and want a clean answer to one question: is it actuall ## What you get - **One checkpoint, every target.** Sim: LIBERO, RoboLab (NVIDIA Isaac Lab), MolmoSpaces. Real hardware: the DROID setup (Franka FR3 + Robotiq 2F-85), bimanual next. Serve a DROID policy once and it runs across all of them, and on the rig, with nothing to port. Sim for cheap, broad iteration; real hardware as ground truth. +- **Native benchmarks, comparable scores.** Each benchmark runs with its own task definitions and horizons — the run reproduces the native benchmark rather than a re-interpretation of it — so your score is comparable with the benchmark's own published numbers. - **Blinded A/B.** Your checkpoint against your own previous checkpoints, or against our maintained baselines (π0.5, GR00T, SmolVLA, ACT) — randomized and blinded, so lighting and setup drift don't bias the result. - **Every run returned.** Multi-view video, full telemetry, and the complete run dataset — not just a success rate. Yours to analyze. - **Latency-honest execution.** On real hardware, inference and network delay are real — a slow model is scored as slow. In sim the world pauses during inference by default (as in other harnesses), but you can charge the model's measured inference time with `--charge_inference_time=True`, so sim scores reflect the delay the robot would actually feel — something sim-only harnesses can't model. diff --git a/positronic/cfg/eval/sim/molmo.py b/positronic/cfg/eval/sim/molmo.py new file mode 100644 index 000000000..c1540a107 --- /dev/null +++ b/positronic/cfg/eval/sim/molmo.py @@ -0,0 +1,99 @@ +import logging +from pathlib import Path + +import configuronic as cfn + +from positronic.cfg.eval import number_trials, spec +from positronic.drivers.roboarm.models import GRASP_SITE_LINK, bundled_franka_model +from positronic.eval import Eval, Observation, Task +from positronic.eval import keys as eval_keys +from positronic.simulator.env_server.proxy import RemoteEnvControlSystem, remote_franka_embodiment +from positronic.simulator.molmo_spaces import keys as molmo_keys +from positronic.simulator.molmo_spaces import mapping +from positronic.simulator.molmo_spaces.adapter import DEFAULT_CAMERA_DICT, MolmoAdapter +from positronic.simulator.molmo_spaces.launcher import serve_molmo_spaces + +# How far the harness deadline sits above the benchmark horizon. Being sim-time, the spare budget costs +# nothing unless the sim stops terminating, which is the only thing the deadline is there to catch. +_TIMEOUT_MARGIN_SEC = 10.0 + + +@cfn.config(camera_dict=DEFAULT_CAMERA_DICT, episodes=None, trial_count=1, timeout=None, seed=None) +def _molmo_eval( + benchmark_dir: str, + episodes: int | list[int] | None, + trial_count: int, + timeout: float | None, + camera_dict: dict[str, str], + seed: int | None, +) -> Eval: + """A MolmoSpaces eval: the embodiment proxies a remote MolmoSpaces env, the task carries the scenario. + + MolmoSpaces (https://github.com/allenai/molmospaces) is AllenAI's MuJoCo manipulation benchmark on the DROID + rig (Franka arm + Robotiq 2F-85) across ProcTHOR scenes; a benchmark is a directory holding a ``benchmark.json`` + (a JSON list of episode specs — house, task, exact object poses, cameras, language goal), so + ``--eval.benchmark_dir`` names that directory and ``--eval.episodes`` optionally pins a subset of episode + indices (default: the whole benchmark). The asset packs live under ``MLSPACES_ASSETS_DIR``. + + positronic launches a single task-agnostic env server in MolmoSpaces' own interpreter; the proxy drives it + over the socket, the env answers which episodes the sweep runs, and the episode index rides each trial's reset + token. The instruction is never pinned: the task reads its language live from the env, which reports the + episode's resolved goal in every reset's meta. Episodes are exact-pose deterministic, so ``trial_count`` + defaults to 1. + + ``timeout`` is not the benchmark horizon — the sim owns that (the benchmark's ``task_horizon_sec``, enforced + env-side and delivered as a terminal ``done``). It is only a runaway-cost safety net for a sim that never + terminates, so its default is the benchmark's own horizon plus a margin. An explicit value can only lower the + deadline, never raise it, and one at or below the horizon truncates valid episodes — so any value that + differs from the default is warned about. + """ + # A non-positive count yields no trials at all, and an empty plan reads to the self-driving harness as a + # finished run — the command would exit 0 having evaluated nothing. + if trial_count < 1: + raise ValueError(f'--eval.trial_count must be at least 1, got {trial_count}') + proxy = RemoteEnvControlSystem(MolmoAdapter(camera_dict), serve_molmo_spaces(Path(benchmark_dir))) + # MolmoSpaces drives a Franka DROID rig; recordings carry the same model (URDF + meshes + joint names + + # control frame) for the 3D viewer and offline IK, supplied here since the molmo server can't import + # positronic to emit it via ``robot_meta``. ``DEFAULT_FRAME`` is declared on the gripper's grasp site, + # which is where ``env.py`` reports ``robot_state.ee_pose`` and resolves Cartesian targets, so a policy + # frame reached from it via ``ChangeEEFrame`` and offline IK over a recording both anchor correctly. + embodiment = remote_franka_embodiment( + proxy, camera_dict, descriptor='remote.molmo_spaces.droid', static_meta=bundled_franka_model(GRASP_SITE_LINK) + ) + # The env's full MuJoCo state is recorded as privileged ground truth, never fed to the policy. + privileged = {mapping.OBS_SIM_STATE: Observation(proxy.privileged[mapping.OBS_SIM_STATE], None)} + + def tasks() -> list[Task]: + params = proxy.tasks(spec(episodes=episodes)) + # The benchmark declares one horizon over all its episodes (the env refuses an inconsistent one), so one + # backstop deadline covers the run. + backstop = params[0][molmo_keys.TASK_HORIZON] + _TIMEOUT_MARGIN_SEC + if timeout is not None and timeout != backstop: + logging.warning( + '--eval.timeout %ss overrides the benchmark backstop of %ss (the %ss horizon plus a margin); ' + 'running with %ss. The deadline only catches a sim that stopped terminating, and a deadline at ' + 'or below the horizon cuts valid episodes short and scores them as failures.', + timeout, + backstop, + params[0][molmo_keys.TASK_HORIZON], + min(timeout, backstop), + ) + deadline = backstop if timeout is None else min(timeout, backstop) + task = Task(instruction_source=lambda: proxy.meta[mapping.META_TASK], timeout_sec=deadline) + # Benchmark episodes are exact-pose deterministic and carry their own seed. An unset ``seed`` leaves + # ``eval.seed`` off the trial, so the env falls back to the episode's spec seed (reproducing the + # benchmark); an explicit ``seed`` overrides it, sweeping ``seed .. seed + trial_count - 1``. + return number_trials([ + (task, {**p, **({eval_keys.SEED: seed + t} if seed is not None else {})}) + for p in params + for t in range(trial_count) + ]) + + return Eval(embodiment, tasks, privileged=privileged, done=proxy.done) + + +# The whole benchmark in one run (every episode in ``--eval.benchmark_dir``'s benchmark.json). +benchmark = _molmo_eval + +# A single-episode smoke target: the first episode of the benchmark. +first_episode = _molmo_eval.override(episodes=0) diff --git a/positronic/drivers/roboarm/models.py b/positronic/drivers/roboarm/models.py index 55fc0a7d0..163411d25 100644 --- a/positronic/drivers/roboarm/models.py +++ b/positronic/drivers/roboarm/models.py @@ -3,7 +3,7 @@ real arm and the live franka driver.""" import xml.etree.ElementTree as ET -from functools import lru_cache +from functools import cache, lru_cache from pathlib import Path import numpy as np @@ -81,6 +81,12 @@ def _2f85_finger(side: str, sign: int, base_rpy: str) -> list[tuple]: ] +GRASP_SITE_LINK = 'gripper_grasp_site' +# The 2F-85's grasp point — where the closed pads meet — 155mm along the flange approach axis, sharing the +# flange's orientation. MuJoCo Menagerie's own 2F-85 places its ``grasp_site`` here, and a MuJoCo rig driving +# this gripper measures and accepts poses at that site rather than at the arm's flange. +_2F85_GRASP_XYZ = '0 0 0.155' + # Rows: (link, parent, joint | None, origin xyz, origin rpy, axis | None, mesh | None, visual xyz | None). # A row with an axis is a revolute joint whose axis sign sets its closing direction, so one positive # ``grip`` drives the whole 4-bar: driver/spring_link swing the finger in (+X), coupler/follower @@ -89,6 +95,7 @@ def _2f85_finger(side: str, sign: int, base_rpy: str) -> list[tuple]: _ROBOTIQ_2F85 = [ ('gripper_base_mount', FLANGE_LINK, None, '0 0 0.007', _2F85_MOUNT_RPY, None, 'base_mount.stl', None), ('gripper_base', 'gripper_base_mount', None, '0 0 0.0038', '0 0 -1.5707963268', None, 'base.stl', None), + (GRASP_SITE_LINK, FLANGE_LINK, None, _2F85_GRASP_XYZ, '0 0 0', None, None, None), *_2f85_finger('right', 1, '0 0 0'), *_2f85_finger('left', -1, '0 0 3.1415926536'), # RoboLab's ``eef_frame`` (``Robotiq_2F_85/base_link`` ∘ ``EEF_OFFSET_ROT``), measured off its DROID USD @@ -156,19 +163,21 @@ def attach_robotiq_2f85(arm_root: ET.Element, meshes: dict[str, bytes]) -> dict: return gripper[roboarm_keys.GRIPPER] -@lru_cache(maxsize=1) -def bundled_franka_model() -> dict: +@cache +def bundled_franka_model(default_frame_at: str = EE_LINK) -> dict: """The bundled real franka arm + Robotiq 2F-85 for the 3D viewer: the FR3 URDF and its collision meshes with the 2F-85 grafted onto the flange, plus the canonical joint names and control frame. - Backfills real-robot datasets recorded before they stored their own model. + Backfills real-robot datasets recorded before they stored their own model. ``default_frame_at`` names the + link ``DEFAULT_FRAME`` is declared on: a rig that measures and drives at the gripper's grasp point passes + ``GRASP_SITE_LINK``, so it publishes poses in the frame it drives rather than at the franka EE. """ here = Path(__file__).resolve() arm_root = ET.fromstring((here.parent / 'fr3.urdf').read_text()) mesh_dir = here.parents[2] / 'assets' / 'fr3_collision' meshes = {f.name: f.read_bytes() for f in sorted(mesh_dir.glob('*.stl'))} gripper = attach_robotiq_2f85(arm_root, meshes) - add_default_frame(arm_root, EE_LINK) + add_default_frame(arm_root, default_frame_at) return { roboarm_keys.URDF: ET.tostring(arm_root, encoding='unicode'), 'meshes': meshes, diff --git a/positronic/drivers/roboarm/tests/test_ik.py b/positronic/drivers/roboarm/tests/test_ik.py index 6ec1eeab5..3e0c04c74 100644 --- a/positronic/drivers/roboarm/tests/test_ik.py +++ b/positronic/drivers/roboarm/tests/test_ik.py @@ -23,6 +23,8 @@ DROID_EE_FRAME, DROID_EEF_LINK, EE_LINK, + FLANGE_LINK, + GRASP_SITE_LINK, bundled_franka_model, bundled_panda_model, ) @@ -245,3 +247,20 @@ def test_pickle_roundtrip(solver_cls): q_result = restored.solve(q_start, target_pose) result_pose = _fk(PANDA_URDF, q_result) np.testing.assert_allclose(result_pose[:3], target_pose[:3], atol=1e-3) + + +def test_grasp_site_sits_at_the_2f85_grasp_point(): + """The 2F-85's grasp point is 155mm along the flange approach axis, in the flange's own orientation — + where MolmoSpaces' franka_droid model places its ``gripper/grasp_site``.""" + transform = frame_transform(bundled_franka_model()[roboarm_keys.URDF], FLANGE_LINK, GRASP_SITE_LINK) + np.testing.assert_allclose(transform.translation, [0.0, 0.0, 0.155], atol=1e-9) + np.testing.assert_allclose(transform.rotation.as_rotation_matrix, np.eye(3), atol=1e-9) + + +def test_grasp_site_model_declares_the_frame_it_reports_in(): + """A rig measuring at the grasp point declares ``DEFAULT_FRAME`` there, so the frame it publishes poses + in is the frame it drives.""" + model = bundled_franka_model(GRASP_SITE_LINK) + assert model[roboarm_keys.CONTROL_FRAME] == DEFAULT_FRAME + transform = frame_transform(model[roboarm_keys.URDF], DEFAULT_FRAME, GRASP_SITE_LINK) + np.testing.assert_allclose(transform.as_matrix, np.eye(4), atol=1e-9) diff --git a/positronic/eval/__init__.py b/positronic/eval/__init__.py index 4e8f2872a..2ec83c276 100644 --- a/positronic/eval/__init__.py +++ b/positronic/eval/__init__.py @@ -67,7 +67,9 @@ class Task: """One trial: the goal the policy conditions on, the time budget it runs under, and what sets it up.""" instruction_source: str | Callable[[], str] - # Time budget for a rollout; ``None`` ends on ``Eval.done`` alone. + # Time budget for a rollout; ``None`` ends on ``Eval.done`` alone. A benchmark sim enforces the task's own + # horizon and reports expiry as a terminal, so there the budget is a runaway-cost net set well beyond any + # healthy horizon; a real or attended eval has no such terminal and the budget is the trial's actual bound. timeout_sec: float | None # What to ask for, keyed as ``Embodiment.prepare_handlers`` is; a handler this does not name goes unasked prepare_args: dict[str, Any] = field(default_factory=dict) diff --git a/positronic/offboard/README.md b/positronic/offboard/README.md index ddf7140e1..f1c481951 100644 --- a/positronic/offboard/README.md +++ b/positronic/offboard/README.md @@ -2,6 +2,35 @@ This package implements the protocol and utilities for offboard policy inference, allowing robots or simulators to stream observations to a remote server and receive actions. +## Separation of responsibilities: adapter vs codec vs wire client + +Three layers touch an observation on its way to a model, and each owns exactly one concern. +When writing a new sim/rig adapter, check this table before adding any transform to it: + +| Layer | Owns | Examples | +|---|---|---| +| **Adapter** (per sim/rig, e.g. `simulator/molmo_spaces/adapter.py`) | Rig semantics ONLY: mapping the rig's observation/action vocabulary onto positronic's raw keys | Camera-key mapping, gripper qpos → `[0, 1]` closure, decoded commands → the rig's action format | +| **Codec** (per model family, `policy/codec.py` subclasses) | Model preprocessing: everything the checkpoint's input distribution requires | Resize-with-pad to model resolution, prompt normalization (e.g. DROID lowercasing), state assembly | +| **Wire client** (`InferenceClient` / `RemotePolicy`) | Transport optimization, negotiated — never semantics | Downscaling frames to the server-advertised `image_sizes` (aspect-preserving, never upscaling), optional JPEG compression | + +Consequences: + +- **An adapter never resizes, pads, normalizes prompts, or otherwise preprocesses for the model.** + It passes frames and text through at native fidelity. If the same transform appears in an adapter + and a codec, the adapter's copy is the bug: a drifted duplicate silently changes eval inputs. +- **Bandwidth is not the adapter's problem.** The client already downsizes to what the server says + it needs: every `Codec` advertises its expected input sizes via the reserved `image_sizes` meta + key (see `Codec.meta`), the server returns it in the session handshake, and the client fits + frames to it before sending. This is default-on — an adapter that resizes "to keep the wire + payload small" is duplicating it. +- **Codecs run on either side of the wire** — the client being the process driving the robot or sim, + the server being the process holding the model. positronic-native evals compose the codec around + `RemotePolicy` on the client (`cfg/policy.py` — the wire then carries model-sized encoded inputs, + and the client-side resize is disabled since `codec.meta` already reports `image_sizes`). + Thin-client deployments (a sim adapter in a foreign venv talking to a serverless endpoint) host + the codec on the server — the wire carries raw positronic keys, downsized by the negotiation + above. Both placements are supported; pick by where the dependencies can live. + ## Protocol v1 The unified WebSocket protocol is built to enable ANY hardware to connect to ANY model. All Positronic inference servers (LeRobot, GR00T, OpenPI) implement this protocol, allowing a single `.remote` policy client to work across all vendors. diff --git a/positronic/policy/observation.py b/positronic/policy/observation.py index ff6aa9b12..16a37a046 100644 --- a/positronic/policy/observation.py +++ b/positronic/policy/observation.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from functools import partial from typing import Any @@ -8,7 +9,7 @@ from positronic.dataset import Signal, transforms from positronic.dataset.episode import Episode from positronic.dataset.transforms import image -from positronic.dataset.transforms.episode import Derive, Get +from positronic.dataset.transforms.episode import Derive from positronic.policy.codec import Codec, lerobot_image, lerobot_state # The encoded observation's language prompt, under the name LeRobot training and its policies both use. It @@ -22,7 +23,9 @@ class ObservationCodec(Codec): Args: state: mapping from output state key to an ordered dict of {episode_key: dim} to concatenate. images: mapping from output image name to tuple (input_key, (width, height)). - task_field: output key carrying the language prompt at inference. + task_field: output key carrying the language prompt at inference; training always emits ``TASK_FIELD``. + lowercase_task: lowercase the task text at inference, for checkpoints trained on lowercased language + (the pretrained DROID models; MolmoSpaces' Pi baseline applies the same normalization). """ WIRE_NAME = 'observation_codec' @@ -32,14 +35,20 @@ def __init__( state: dict[str, dict[str, int]], images: dict[str, tuple[str, tuple[int, int]]], task_field: str = TASK_FIELD, + lowercase_task: bool = False, ): self._state = state self._image_configs = images self._task_field = task_field + self._lowercase_task = lowercase_task - self._derive_transforms: dict[str, Any] = {k: partial(self._derive_state, k) for k in state.keys()} + self._derive_transforms: dict[str, Callable[[Episode], Any]] = { + k: partial(self._derive_state, k) for k in state.keys() + } self._derive_transforms.update({k: partial(self._derive_image, k) for k in images.keys()}) - self._derive_transforms[TASK_FIELD] = Get(keys.TASK, '') + # Lowercase the training task the same way ``encode`` lowercases the served prompt, so a codec with + # ``lowercase_task`` trains and infers on one text distribution (the ``Codec`` same-keys contract). + self._derive_transforms[TASK_FIELD] = self._derive_task lerobot_features: dict[str, Any] = {} for name, features in state.items(): @@ -57,6 +66,12 @@ def _derive_image(self, out_name: str, episode: Episode) -> Signal[Any]: input_key, (width, height) = self._image_configs[out_name] return image.resize_with_pad(width, height, signal=episode[input_key]) + def _normalize_task(self, task: str) -> str: + return task.lower() if self._lowercase_task else task + + def _derive_task(self, episode: Episode) -> Any: + return self._normalize_task(episode[keys.TASK] if keys.TASK in episode else '') + def _decode_single(self, data: dict) -> dict: return {} @@ -64,7 +79,7 @@ def encode(self, inputs: dict[str, Any]) -> dict[str, Any]: obs: dict[str, Any] = {} if keys.TASK in inputs: - obs[self._task_field] = inputs[keys.TASK] + obs[self._task_field] = self._normalize_task(inputs[keys.TASK]) for out_name, (input_key, (width, height)) in self._image_configs.items(): if input_key not in inputs: @@ -101,5 +116,10 @@ def to_spec(self): images = {name: [key, list(size)] for name, (key, size) in self._image_configs.items()} return { 'name': self.WIRE_NAME, - 'args': {'state': self._state, 'images': images, 'task_field': self._task_field}, + 'args': { + 'state': self._state, + 'images': images, + 'task_field': self._task_field, + 'lowercase_task': self._lowercase_task, + }, } diff --git a/positronic/policy/tests/test_policy_io.py b/positronic/policy/tests/test_policy_io.py index c6f9f1569..e3f50a8d0 100644 --- a/positronic/policy/tests/test_policy_io.py +++ b/positronic/policy/tests/test_policy_io.py @@ -19,6 +19,7 @@ FlipGrip, ) from positronic.policy.observation import ObservationCodec +from positronic.policy.spec import from_spec def test_observation_encode_images_and_state_shapes(): @@ -67,6 +68,14 @@ def test_observation_encode_task(): assert obs_keys.TASK not in obs_no_task +def test_observation_codec_spec_preserves_lowercase_task(): + enc = ObservationCodec(state={}, images={}, lowercase_task=True) + rebuilt = from_spec(enc.to_spec()) + assert isinstance(rebuilt, ObservationCodec) + obs = rebuilt.encode({obs_keys.TASK: 'MixedCase'}) + assert obs[obs_keys.TASK] == 'mixedcase' + + def test_absolute_position_action_encode_decode_quat(): # Identity rotation, known translation/grip ts = [1000, 2000] diff --git a/positronic/simulator/env_server/adapter.py b/positronic/simulator/env_server/adapter.py index 08be1cde8..d5c49c97f 100644 --- a/positronic/simulator/env_server/adapter.py +++ b/positronic/simulator/env_server/adapter.py @@ -15,6 +15,7 @@ import pimm from positronic import geom, keys from positronic.drivers.roboarm import command as roboarm_command +from positronic.simulator.env_server import protocol class EnvAdapter(ABC): @@ -83,23 +84,23 @@ def _wire_command(cmd: Any) -> dict[str, Any]: rep = geom.Rotation.Representation.ROTATION_MATRIX match cmd: case roboarm_command.CartesianPosition(pose): - wire = {'type': 'cartesian', 'pose': pose.as_vector(rep)} + wire = {protocol.COMMAND_TYPE: protocol.CARTESIAN, protocol.COMMAND_POSE: pose.as_vector(rep)} case roboarm_command.JointPosition(positions): - wire = {'type': 'joint_pos', 'q': positions} + wire = {protocol.COMMAND_TYPE: protocol.JOINT_POS, protocol.COMMAND_JOINT_POS: positions} case roboarm_command.JointDelta(velocities): - wire = {'type': 'joint_vel', 'dq': velocities} + wire = {protocol.COMMAND_TYPE: protocol.JOINT_VEL, protocol.COMMAND_JOINT_VEL: velocities} case roboarm_command.CartesianDelta(delta, frame): # The env anchors a delta on the pose it measures, which is its control frame and nowhere else, so # a delta still expressed somewhere else has no faithful wire form. if not np.allclose(frame.as_matrix, np.eye(4)): raise ValueError('CartesianDelta outside the env control frame cannot be sent to a remote env') - wire = {'type': 'cartesian_delta', 'delta': delta.as_vector(rep)} + wire = {protocol.COMMAND_TYPE: protocol.CARTESIAN_DELTA, protocol.COMMAND_DELTA: delta.as_vector(rep)} case None: - return {'type': 'hold'} + return {protocol.COMMAND_TYPE: protocol.HOLD} case other: raise ValueError(f'no wire encoding for robot_command {type(other).__name__}') if cmd.mode is not None: - wire['mode'] = roboarm_command.to_wire(cmd.mode) + wire[protocol.COMMAND_MODE] = roboarm_command.to_wire(cmd.mode) return wire @@ -144,4 +145,7 @@ def action(self, commands: dict[str, pimm.Message]) -> dict[str, Any]: if isinstance(cmd, roboarm_command.CartesianDelta | roboarm_command.JointDelta): self._held.pop(keys.ROBOT_COMMAND) grip = float(self._held.get(keys.TARGET_GRIP, 0.0)) - return {'command': _wire_command(_in_env_control_frame(cmd, self.env_control_frame)), 'grip': grip} + return { + protocol.ACTION_COMMAND: _wire_command(_in_env_control_frame(cmd, self.env_control_frame)), + protocol.ACTION_GRIP: grip, + } diff --git a/positronic/simulator/env_server/client.py b/positronic/simulator/env_server/client.py index f89963e4f..17c16d33f 100644 --- a/positronic/simulator/env_server/client.py +++ b/positronic/simulator/env_server/client.py @@ -11,6 +11,7 @@ from websockets.exceptions import ConnectionClosed from websockets.sync.client import connect +from . import protocol from .protocol import decode, encode logger = logging.getLogger(__name__) @@ -47,24 +48,25 @@ def __init__(self, host: str, port: int, *, open_timeout: float = 10.0, connect_ backoff = min(backoff * 2, 5.0) def tasks(self, spec: Any) -> list[dict[str, Any]]: - return self._request({'cmd': 'tasks', 'spec': spec})['tasks'] + req = {protocol.REQUEST_CMD: protocol.CMD_TASKS, protocol.REQUEST_SPEC: spec} + return self._request(req)[protocol.RESPONSE_TASKS] def reset(self, token: Any) -> dict[str, Any]: - return self._request({'cmd': 'reset', 'token': token}) + return self._request({protocol.REQUEST_CMD: protocol.CMD_RESET, protocol.REQUEST_TOKEN: token}) def step(self, action: dict[str, Any]) -> dict[str, Any]: - return self._request({'cmd': 'step', 'action': action}) + return self._request({protocol.REQUEST_CMD: protocol.CMD_STEP, protocol.REQUEST_ACTION: action}) def _request(self, msg: dict[str, Any]) -> dict[str, Any]: self._ws.send(encode(msg)) result = decode(self._ws.recv()) - if 'error' in result: - raise RuntimeError(f'env server: {result["error"]}') + if protocol.RESPONSE_ERROR in result: + raise RuntimeError(f'env server: {result[protocol.RESPONSE_ERROR]}') return result def close(self) -> None: try: - self._ws.send(encode({'cmd': 'close'})) + self._ws.send(encode({protocol.REQUEST_CMD: protocol.CMD_CLOSE})) self._ws.recv(timeout=_CLOSE_ACK_TIMEOUT) except ConnectionClosed: pass # a peer already gone has released whatever the acknowledgement would have reported diff --git a/positronic/simulator/env_server/launcher.py b/positronic/simulator/env_server/launcher.py index e2708d7ff..0993ddf7f 100644 --- a/positronic/simulator/env_server/launcher.py +++ b/positronic/simulator/env_server/launcher.py @@ -8,6 +8,7 @@ import shutil import socket import subprocess +import time from collections.abc import Callable, Iterator from contextlib import contextmanager from pathlib import Path @@ -57,22 +58,50 @@ def terminate(proc: subprocess.Popen) -> None: proc.kill() +# How long a server may take to bind its port, and how often the wait re-checks. The deadline must cover a +# cold first boot, where a simulator compiles shaders and loads asset packs before serving. +_BIND_DEADLINE = 1800.0 +_BIND_POLL_INTERVAL = 0.2 + + +def _await_bind(proc: subprocess.Popen, host: str, port: int, deadline: float) -> None: + """Block until *proc* accepts connections on *port*, raising if it exits or the deadline passes. + + The server's accept loop drops a connection that never handshakes, so this probe costs it nothing. + """ + end = time.monotonic() + deadline + while True: + try: + with socket.create_connection((host, port), timeout=1.0): + return + except OSError: + pass + status = proc.poll() + if status is not None: + raise RuntimeError(f'env server exited with status {status} before binding {host}:{port}') + if time.monotonic() >= end: + raise TimeoutError(f'env server did not bind {host}:{port} within {deadline:.0f}s') + time.sleep(_BIND_POLL_INTERVAL) + + @contextmanager -def serve_subprocess(spawn: Callable[[str, int], subprocess.Popen], host: str) -> Iterator[tuple[str, int]]: - """Run an env-server subprocess for the body's lifetime, yielding its ``(host, port)``. +def serve_subprocess( + spawn: Callable[[str, int], subprocess.Popen], host: str, bind_deadline: float = _BIND_DEADLINE +) -> Iterator[tuple[str, int]]: + """Run an env-server subprocess for the body's lifetime, yielding its ``(host, port)`` once it is bound. The single owner of the subprocess: ``RemoteEnvControlSystem`` enters it to tie the subprocess to the World run, and a plain client (e.g. an e2e demo replay) enters it directly to talk over the socket without a World. The task spec rides the reset token, so the subprocess needs only its address — it serves - whatever task the first reset asks for. The port is picked before the spawn; the client's connect retry - covers the gap until the server binds it. + whatever task the first reset asks for. - TODO: a subprocess that dies at startup goes unnoticed until the client's connect deadline — nothing - surfaces its exit during the retry wait. + ``bind_deadline`` must cover a cold first boot: a heavy simulator spends minutes compiling shaders and + loading assets before it binds. """ port = free_port() proc = spawn(host, port) try: + _await_bind(proc, host, port, bind_deadline) yield host, port finally: terminate(proc) diff --git a/positronic/simulator/env_server/protocol.py b/positronic/simulator/env_server/protocol.py index f24362d37..888ccc70f 100644 --- a/positronic/simulator/env_server/protocol.py +++ b/positronic/simulator/env_server/protocol.py @@ -1,4 +1,4 @@ -"""Wire codec for the remote env-server boundary: msgpack with a numpy envelope. +"""The wire contract for the remote env-server boundary: the command vocabulary and the msgpack codec. This module is **positronic-free** — it imports only ``msgpack`` and ``numpy`` — so it can be imported (or copied) into a benchmark's isolated interpreter alongside the dumb server without @@ -10,10 +10,68 @@ """ import functools +from typing import Any import msgpack import numpy as np +# The canonical command contract: the tag on every arm command a client puts on the wire. It is total — one +# contract carries every policy onto every embodiment — so an env adoption converts each of these into +# whatever its own controller natively takes. Owned here because both interpreters spell the tags: +# positronic's ``EnvAdapter`` writes them, an env venv's own decoder reads them, and this is the module both +# sides import. +CARTESIAN = 'cartesian' +CARTESIAN_DELTA = 'cartesian_delta' +JOINT_POS = 'joint_pos' +JOINT_VEL = 'joint_vel' +HOLD = 'hold' +CANONICAL_COMMAND_TYPES = (CARTESIAN, CARTESIAN_DELTA, JOINT_POS, JOINT_VEL, HOLD) + +# The action a client puts on the wire: the tagged arm command, and the gripper closure alongside it. +ACTION_COMMAND = 'command' +ACTION_GRIP = 'grip' + +# The tagged command's own fields: the tag, the one value each tag carries (``hold`` carries none), and the +# control law the command pins. +COMMAND_TYPE = 'type' +COMMAND_POSE = 'pose' # CARTESIAN — an absolute pose, [t(3), R(9)] +COMMAND_DELTA = 'delta' # CARTESIAN_DELTA — a relative pose, same encoding +COMMAND_JOINT_POS = 'q' # JOINT_POS — absolute joint targets +COMMAND_JOINT_VEL = 'dq' # JOINT_VEL — per-step joint deltas +COMMAND_MODE = 'mode' # any tag — the pinned control mode, absent when the command pins none + +# The address every env-server script is spawned with: its launcher builds the command in positronic's +# interpreter, its ``env.py`` parser declares it in the adoption's own, so a rename that misses one side +# fails at spawn rather than at import. +OPT_HOST = '--host' +OPT_PORT = '--port' + +# The request envelope: the verb the client sends, the arguments each verb carries, and the replies the +# server writes itself (every other reply is an env's own frame). +REQUEST_CMD = 'cmd' +REQUEST_SPEC = 'spec' # CMD_TASKS — the eval config's task selection +REQUEST_TOKEN = 'token' # CMD_RESET — the adapter's reset token +REQUEST_ACTION = 'action' # CMD_STEP — the tagged command plus grip +CMD_TASKS = 'tasks' +CMD_RESET = 'reset' +CMD_STEP = 'step' +CMD_CLOSE = 'close' +RESPONSE_TASKS = 'tasks' # CMD_TASKS — the env's own task records +RESPONSE_OK = 'ok' # CMD_CLOSE's acknowledgement +RESPONSE_ERROR = 'error' # any verb: the server caught an exception and the client re-raises it + +# The frames an env reports back. ``reset`` carries the observation, the scene meta, the robot model identity +# and the control period; ``step`` carries the observation, the terminal, the control period, and — where the +# env judges one — its success. ``horizon`` is the episode limit the env enforces itself, in sim-seconds, +# absent when the env enforces none. +FRAME_OBS = 'obs' +FRAME_META = 'meta' +FRAME_ROBOT_META = 'robot_meta' +FRAME_CONTROL_DT = 'control_dt' +FRAME_HORIZON = 'horizon' +FRAME_DONE = 'done' +FRAME_SUCCESS = 'success' + def _pack(obj): if isinstance(obj, np.ndarray): @@ -35,5 +93,13 @@ def _unpack(obj): return obj -encode = functools.partial(msgpack.packb, default=_pack) +def encode(obj: Any) -> bytes: + """*obj* as a msgpack frame, with the numpy envelope applied to arrays and scalars inside it.""" + # ``packb`` is annotated ``bytes | None``, for a streaming mode this call does not use; every caller here + # hands the result straight to a socket, so the frame is materialized. + packed = msgpack.packb(obj, default=_pack) + assert packed is not None + return packed + + decode = functools.partial(msgpack.unpackb, object_hook=_unpack) diff --git a/positronic/simulator/env_server/proxy.py b/positronic/simulator/env_server/proxy.py index 440d9915e..962c8ff11 100644 --- a/positronic/simulator/env_server/proxy.py +++ b/positronic/simulator/env_server/proxy.py @@ -18,6 +18,7 @@ from positronic.dataset.serializers import Serializers from positronic.eval import ROBOT_STATIC_META, Command, Embodiment, Observation from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol from positronic.simulator.env_server.adapter import EnvAdapter from positronic.simulator.env_server.client import EnvConnection @@ -94,10 +95,10 @@ def reset(self, params: dict[str, Any]) -> None: for _, receiver in self.commands.items(): receiver.read() self._frame = conn.reset(self._adapter.reset_token(params)) - self._meta = self._frame['meta'] + self._meta = self._frame[protocol.FRAME_META] self._active = True - self.robot_meta.emit(self._frame['robot_meta']) - self._emit_payload(self._frame['obs']) + self.robot_meta.emit(self._frame[protocol.FRAME_ROBOT_META]) + self._emit_payload(self._frame[protocol.FRAME_OBS]) # An empty payload clears the wire: a terminal the previous trial reached would end this one at once. self.done.emit({}) @@ -111,7 +112,7 @@ def run(self, should_stop: pimm.SignalReceiver, clock: pimm.Clock) -> Iterator[p try: while not should_stop.value: # The proxy paces every turn; ``control_dt`` is known only once a reset ran. - yield pimm.Sleep(self._frame['control_dt'] if self._frame is not None else _IDLE_DT) + yield pimm.Sleep(self._frame[protocol.FRAME_CONTROL_DT] if self._frame is not None else _IDLE_DT) if (call := next(self.env_reset.incoming(), None)) is not None: with pimm.calls.raise_to(call): self.reset(dict(call.request or {})) @@ -121,11 +122,13 @@ def run(self, should_stop: pimm.SignalReceiver, clock: pimm.Clock) -> Iterator[p with telemetry.span(telemetry_keys.SPAN_ENV_STEP): self._frame = self._step_env() with telemetry.span(telemetry_keys.SPAN_MATERIALIZE): - self._emit_payload(self._frame['obs']) + self._emit_payload(self._frame[protocol.FRAME_OBS]) finally: self._cleanup.close() def _step_env(self) -> dict[str, Any]: + # Stepping is reachable only while ``_active``, which ``reset`` sets once the connection is up. + assert self._conn is not None, 'stepped before the first reset connected' reads = ((name, receiver.read()) for name, receiver in self.commands.items()) commands = {name: msg for name, msg in reads if msg is not None} result = self._conn.step(self._adapter.action(commands)) diff --git a/positronic/simulator/env_server/server.py b/positronic/simulator/env_server/server.py index 2aa4256ca..530e7d605 100644 --- a/positronic/simulator/env_server/server.py +++ b/positronic/simulator/env_server/server.py @@ -12,7 +12,7 @@ Protocol (msgpack frames, see ``protocol``): client ``{'cmd': 'tasks', 'spec': ...}`` -> server ``{'tasks': [{...}, ...]}`` - client ``{'cmd': 'reset', 'token': ...}`` -> server ``{'obs', 'meta', 'robot_meta', 'control_dt'}`` + client ``{'cmd': 'reset', 'token': ...}`` -> server ``{'obs', 'meta', 'robot_meta', 'control_dt', 'horizon'?}`` client ``{'cmd': 'step', 'action': {...}}`` -> server ``{'obs', 'done', 'control_dt'}`` client ``{'cmd': 'close'}`` -> server ``{'ok': True}`` Any command whose handling raises returns ``{'error': str}`` instead, which the client re-raises. @@ -28,8 +28,10 @@ # (importing ``positronic.*`` would run the package's installed-version lookup and fail there). # Relative when they land as a package, top-level when copied in flat. try: + from . import protocol from .protocol import decode, encode except ImportError: + import protocol # pyright: ignore[reportMissingImports] from protocol import decode, encode logger = logging.getLogger(__name__) @@ -61,6 +63,10 @@ def reset(self, token: Any) -> dict[str, Any]: from (the language goal, scene ids); ``robot_meta`` is the robot model identity (URDF / joint names / control frame) recorded into the episode. Either is ``{}`` when the client owns that side — a static instruction, or an embodiment that ships its own model. + + ``horizon`` (optional) is the sim-enforced episode deadline in sim-seconds — the env's own time limit, + which it delivers as a terminal ``done`` on expiry. It is reported for observability, so a run can be + checked against the horizon the env actually resolved; omit it when the env enforces none. """ @abstractmethod @@ -101,20 +107,20 @@ def _handle(self, connection: ServerConnection) -> None: for raw in connection: msg = decode(raw) try: - match msg['cmd']: - case 'close': - connection.send(encode({'ok': True})) + match msg[protocol.REQUEST_CMD]: + case protocol.CMD_CLOSE: + connection.send(encode({protocol.RESPONSE_OK: True})) return - case 'tasks': - result = {'tasks': self._env.tasks(msg['spec'])} - case 'reset': - result = self._env.reset(msg['token']) - case 'step': - result = self._env.step(msg['action']) + case protocol.CMD_TASKS: + result = {protocol.RESPONSE_TASKS: self._env.tasks(msg[protocol.REQUEST_SPEC])} + case protocol.CMD_RESET: + result = self._env.reset(msg[protocol.REQUEST_TOKEN]) + case protocol.CMD_STEP: + result = self._env.step(msg[protocol.REQUEST_ACTION]) case other: raise ValueError(f'Unknown command: {other!r}') except Exception as e: - result = {'error': f'{type(e).__name__}: {e}'} + result = {protocol.RESPONSE_ERROR: f'{type(e).__name__}: {e}'} connection.send(encode(result)) def serve_forever(self) -> None: diff --git a/positronic/simulator/env_server/tests/mujoco_env.py b/positronic/simulator/env_server/tests/mujoco_env.py index 18423f51d..a4adf59ec 100644 --- a/positronic/simulator/env_server/tests/mujoco_env.py +++ b/positronic/simulator/env_server/tests/mujoco_env.py @@ -22,6 +22,7 @@ from positronic.drivers.roboarm import command as roboarm_command from positronic.eval import Eval, Observation, Task from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol from positronic.simulator.env_server.adapter import WireCommandAdapter from positronic.simulator.env_server.proxy import RemoteEnvControlSystem, remote_franka_embodiment from positronic.simulator.env_server.server import EnvProtocol @@ -118,36 +119,46 @@ def reset(self, token: Any) -> dict[str, Any]: self._sim.reset(token) self._gen = self._sim.run(_NeverStop(), self._clock) next(self._gen) # loop setup + first control-period sleep + robot_meta = self._robot_meta_recv.read() + assert robot_meta is not None, 'the sim publishes robot_meta as it resets, before the sleep stepped above' # Native ``stack_cubes`` has no language scene meta (its instruction is a static client string), so ``meta`` # is empty; the sim's robot identity (URDF / joints) is the ``robot_meta``. return { - 'obs': self._read_obs(), - 'meta': {}, - 'robot_meta': dict(self._robot_meta_recv.read().data), - 'control_dt': self._timestep, + protocol.FRAME_OBS: self._read_obs(), + protocol.FRAME_META: {}, + protocol.FRAME_ROBOT_META: dict(robot_meta.data), + protocol.FRAME_CONTROL_DT: self._timestep, } def step(self, action: dict[str, Any]) -> dict[str, Any]: assert self._gen is not None, 'step() called before reset()' # real Gym envs reject step-before-reset - command = action['command'] - match command['type']: - case 'hold': + command = action[protocol.ACTION_COMMAND] + match command[protocol.COMMAND_TYPE]: + case protocol.HOLD: cmd = None - case 'joint_pos': - cmd = roboarm_command.JointPosition(np.asarray(command['q'], dtype=np.float64)) - case 'joint_vel': - cmd = roboarm_command.JointDelta(np.asarray(command['dq'], dtype=np.float64)) - case 'cartesian': - cmd = roboarm_command.CartesianPosition(geom.Transform3D.from_vector(command['pose'], _ROTMAT)) - case 'cartesian_delta': - cmd = roboarm_command.CartesianDelta(geom.Transform3D.from_vector(command['delta'], _ROTMAT)) + case protocol.JOINT_POS: + q = np.asarray(command[protocol.COMMAND_JOINT_POS], dtype=np.float64) + cmd = roboarm_command.JointPosition(q) + case protocol.JOINT_VEL: + dq = np.asarray(command[protocol.COMMAND_JOINT_VEL], dtype=np.float64) + cmd = roboarm_command.JointDelta(dq) + case protocol.CARTESIAN: + pose = geom.Transform3D.from_vector(command[protocol.COMMAND_POSE], _ROTMAT) + cmd = roboarm_command.CartesianPosition(pose) + case protocol.CARTESIAN_DELTA: + delta = geom.Transform3D.from_vector(command[protocol.COMMAND_DELTA], _ROTMAT) + cmd = roboarm_command.CartesianDelta(delta) case other: raise ValueError(f'MujocoEnv got unsupported command type {other!r}') if cmd is not None: self._cmd_emit.emit(cmd) - self._grip_emit.emit(float(action['grip'])) + self._grip_emit.emit(float(action[protocol.ACTION_GRIP])) self._advance(self._timestep) - return {'obs': self._read_obs(), 'done': False, 'control_dt': self._timestep} + return { + protocol.FRAME_OBS: self._read_obs(), + protocol.FRAME_DONE: False, + protocol.FRAME_CONTROL_DT: self._timestep, + } def close(self) -> None: if self._gen is not None: diff --git a/positronic/simulator/env_server/tests/test_remote_env.py b/positronic/simulator/env_server/tests/test_remote_env.py index 6c8c07f17..70f3f6c92 100644 --- a/positronic/simulator/env_server/tests/test_remote_env.py +++ b/positronic/simulator/env_server/tests/test_remote_env.py @@ -1,3 +1,6 @@ +import socket +import subprocess +import sys import threading import time from contextlib import contextmanager, nullcontext @@ -25,9 +28,10 @@ from positronic.policy.codec import ActionTimestamp from positronic.policy.layers import ChunkedSchedule from positronic.policy.tests.test_harness import StubPolicy +from positronic.simulator.env_server import protocol from positronic.simulator.env_server.adapter import EnvAdapter, _in_env_control_frame, _wire_command from positronic.simulator.env_server.client import _CLOSE_ACK_TIMEOUT, EnvConnection -from positronic.simulator.env_server.launcher import free_port +from positronic.simulator.env_server.launcher import free_port, serve_subprocess from positronic.simulator.env_server.proxy import RemoteEnvControlSystem from positronic.simulator.env_server.server import EnvProtocol from positronic.simulator.env_server.tests.conftest import serve_env @@ -80,6 +84,26 @@ def _assert_obs_equal(a: dict, b: dict) -> None: np.testing.assert_array_equal(a['sim_state'][key], b['sim_state'][key]) +@pytest.mark.timeout(30.0) +def test_serve_subprocess_reports_a_server_that_dies_before_binding(): + """A server that raises during startup never binds, so its port stays closed exactly as a slow boot's + does.""" + spawn = lambda host, port: subprocess.Popen([sys.executable, '-c', 'raise SystemExit(3)']) # noqa: E731 + with pytest.raises(RuntimeError, match='status 3'), serve_subprocess(spawn, 'localhost'): + pass + + +@pytest.mark.timeout(30.0) +def test_serve_subprocess_yields_once_the_port_accepts(): + script = ( + 'import socket,sys,time\ns=socket.socket()\ns.bind(("localhost",int(sys.argv[1])))\ns.listen()\ntime.sleep(30)' + ) + spawn = lambda host, port: subprocess.Popen([sys.executable, '-c', script, str(port)]) # noqa: E731 + with serve_subprocess(spawn, 'localhost') as (host, port): + with socket.create_connection((host, port), timeout=1.0): + pass + + @pytest.mark.timeout(60.0) def test_transport_is_transparent(env_server): """The wire round-trips faithfully: the same seed + action sequence through the env in-process and @@ -89,8 +113,17 @@ def test_transport_is_transparent(env_server): direct = make_mujoco_env(list(CAMERAS.values())) direct_reset = direct.reset(seed) - base = np.asarray(direct_reset['obs']['q']) - actions = [{'command': {'type': 'joint_pos', 'q': base + 0.03 * i}, 'grip': 0.2 * (i % 2)} for i in range(1, 6)] + base = np.asarray(direct_reset[protocol.FRAME_OBS]['q']) + actions = [ + { + protocol.ACTION_COMMAND: { + protocol.COMMAND_TYPE: protocol.JOINT_POS, + protocol.COMMAND_JOINT_POS: base + 0.03 * i, + }, + protocol.ACTION_GRIP: 0.2 * (i % 2), + } + for i in range(1, 6) + ] direct_steps = [direct.step(action) for action in actions] direct.close() @@ -99,12 +132,12 @@ def test_transport_is_transparent(env_server): socket_steps = [conn.step(action) for action in actions] conn.close() - assert direct_reset['control_dt'] == socket_reset['control_dt'] - _assert_obs_equal(direct_reset['obs'], socket_reset['obs']) + assert direct_reset[protocol.FRAME_CONTROL_DT] == socket_reset[protocol.FRAME_CONTROL_DT] + _assert_obs_equal(direct_reset[protocol.FRAME_OBS], socket_reset[protocol.FRAME_OBS]) for direct_step, socket_step in zip(direct_steps, socket_steps, strict=True): - _assert_obs_equal(direct_step['obs'], socket_step['obs']) - assert direct_step['done'] == socket_step['done'] - assert direct_step['control_dt'] == socket_step['control_dt'] + _assert_obs_equal(direct_step[protocol.FRAME_OBS], socket_step[protocol.FRAME_OBS]) + assert direct_step[protocol.FRAME_DONE] == socket_step[protocol.FRAME_DONE] + assert direct_step[protocol.FRAME_CONTROL_DT] == socket_step[protocol.FRAME_CONTROL_DT] @pytest.mark.timeout(60.0) @@ -154,24 +187,23 @@ def test_close_gives_up_on_a_peer_that_never_answers(): assert time.monotonic() - started < _CLOSE_ACK_TIMEOUT + 10.0 -_HOLD = {'command': {'type': 'hold'}, 'grip': 0.0} +_HOLD = {protocol.ACTION_COMMAND: {protocol.COMMAND_TYPE: protocol.HOLD}, protocol.ACTION_GRIP: 0.0} def _settle(env, action: dict, steps: int) -> np.ndarray: """Apply ``action`` once, then idle ``steps`` ticks while the position actuators settle; return the final eef.""" - env.step(action) - out = {'obs': None} + out = env.step(action) for _ in range(steps): out = env.step(_HOLD) - return np.asarray(out['obs']['ee_pos']) + return np.asarray(out[protocol.FRAME_OBS]['ee_pos']) def test_a_pinned_control_mode_rides_the_wire(): """Honoring a mode is the env's, so the adapter delivers it rather than deciding for every env.""" mode = roboarm_command.Impedance(kq=(40.0,) * 7, kqd=(4.0,) * 7, kx=(750.0,) * 6, kxd=(37.0,) * 6) wired = _wire_command(roboarm_command.JointPosition(np.zeros(7), mode=mode)) - assert wired['mode'] == roboarm_command.to_wire(mode) - assert 'mode' not in _wire_command(roboarm_command.JointPosition(np.zeros(7))) + assert wired[protocol.COMMAND_MODE] == roboarm_command.to_wire(mode) + assert protocol.COMMAND_MODE not in _wire_command(roboarm_command.JointPosition(np.zeros(7))) class TestEnvControlFrame: @@ -187,13 +219,13 @@ class TestEnvControlFrame: def test_an_absolute_pose_arrives_in_the_env_frame(self): pose = geom.Transform3D(np.array([0.4, 0.1, 0.3]), geom.Rotation.from_euler([0.1, 0.2, 0.3])) wired = _wire_command(_in_env_control_frame(roboarm_command.CartesianPosition(pose), self.frame)) - np.testing.assert_allclose(wired['pose'], (pose * self.frame).as_vector(self.rotmat), atol=1e-12) + np.testing.assert_allclose(wired[protocol.COMMAND_POSE], (pose * self.frame).as_vector(self.rotmat), atol=1e-12) def test_a_delta_already_in_the_env_frame_wires_bare(self): delta = geom.Transform3D(np.array([0.0, 0.0, 0.04]), geom.Rotation.identity) cmd = roboarm_command.CartesianDelta(delta, frame=self.frame) wired = _wire_command(_in_env_control_frame(cmd, self.frame)) - np.testing.assert_allclose(wired['delta'], delta.as_vector(self.rotmat), atol=1e-12) + np.testing.assert_allclose(wired[protocol.COMMAND_DELTA], delta.as_vector(self.rotmat), atol=1e-12) def test_a_command_re_expressed_for_the_env_keeps_its_mode(self): """The frame a command is measured in has nothing to do with the law that drives to it.""" @@ -236,15 +268,28 @@ def test_cartesian_delta_matches_absolute_target(): abs_env = make_mujoco_env(list(CAMERAS.values())) reset = abs_env.reset(seed) - ee0 = np.asarray(reset['obs']['ee_pos']) - target = geom.Transform3D(ee0 + lift, geom.Rotation.from_quat(reset['obs']['ee_quat'])) - ee_abs = _settle(abs_env, {'command': {'type': 'cartesian', 'pose': target.as_vector(rotmat)}, 'grip': 0.0}, settle) + ee0 = np.asarray(reset[protocol.FRAME_OBS]['ee_pos']) + target = geom.Transform3D(ee0 + lift, geom.Rotation.from_quat(reset[protocol.FRAME_OBS]['ee_quat'])) + absolute = { + protocol.ACTION_COMMAND: { + protocol.COMMAND_TYPE: protocol.CARTESIAN, + protocol.COMMAND_POSE: target.as_vector(rotmat), + }, + protocol.ACTION_GRIP: 0.0, + } + ee_abs = _settle(abs_env, absolute, settle) abs_env.close() delta_env = make_mujoco_env(list(CAMERAS.values())) delta_env.reset(seed) delta = geom.Transform3D(lift, geom.Rotation.identity) - delta_action = {'command': {'type': 'cartesian_delta', 'delta': delta.as_vector(rotmat)}, 'grip': 0.0} + delta_action = { + protocol.ACTION_COMMAND: { + protocol.COMMAND_TYPE: protocol.CARTESIAN_DELTA, + protocol.COMMAND_DELTA: delta.as_vector(rotmat), + }, + protocol.ACTION_GRIP: 0.0, + } ee_delta = _settle(delta_env, delta_action, settle) ee_idle = _settle(delta_env, _HOLD, 50) # the delta already fired; idling must not re-compose it delta_env.close() @@ -279,16 +324,20 @@ def reset(self, token): self._steps = 0 meta = {'task': _COUNTDOWN} # scene meta the env reports only at reset; ``step`` omits it return { - 'obs': {'q': np.full(7, self._steps, dtype=np.float64)}, - 'meta': meta, - 'robot_meta': {}, - 'control_dt': self._control_dt, + protocol.FRAME_OBS: {'q': np.full(7, self._steps, dtype=np.float64)}, + protocol.FRAME_META: meta, + protocol.FRAME_ROBOT_META: {}, + protocol.FRAME_CONTROL_DT: self._control_dt, } def step(self, action): self._steps += 1 done = self._done_after is not None and self._steps >= self._done_after - return {'obs': {'q': np.full(7, self._steps, dtype=np.float64)}, 'done': done, 'control_dt': self._control_dt} + return { + protocol.FRAME_OBS: {'q': np.full(7, self._steps, dtype=np.float64)}, + protocol.FRAME_DONE: done, + protocol.FRAME_CONTROL_DT: self._control_dt, + } def close(self): pass @@ -311,7 +360,7 @@ def privileged(self, raw_obs): return {} def terminal(self, result): - return {eval_keys.SUCCESS: True} if result['done'] else None + return {eval_keys.SUCCESS: True} if result[protocol.FRAME_DONE] else None @pytest.mark.timeout(60.0) @@ -395,6 +444,21 @@ def test_proxy_caches_reset_meta_as_live_instruction_source(): assert task.instruction == 'countdown' # ... yet the reset-scoped cache holds +def test_the_canonical_contract_is_exactly_what_a_client_can_emit(): + """``protocol`` owns the contract and ``_wire_command`` is the only thing that writes it, so pinning the two + against each other keeps the set an env adoption must cover equal to the set a policy can actually emit.""" + pose = geom.Transform3D(np.zeros(3), geom.Rotation.identity) + commands = [ + roboarm_command.CartesianPosition(pose), + roboarm_command.CartesianDelta(pose), + roboarm_command.JointPosition(np.zeros(7)), + roboarm_command.JointDelta(np.zeros(7)), + None, # nothing held: the arm holds where it is + ] + tags = {_wire_command(command)[protocol.COMMAND_TYPE] for command in commands} + assert tags == set(protocol.CANONICAL_COMMAND_TYPES) + + @pytest.mark.timeout(60.0) def test_remote_eval_runs_to_timeout_without_done(env_server, tmp_path): """The real ``stack_cubes`` wrapper, end to end: no terminal, so the trial runs to the task timeout @@ -469,7 +533,7 @@ def test_full_chunk_executes_between_replans(env_server, tmp_path): ``chunk_len`` control periods apart.""" host, port = env_server probe = make_mujoco_env([]) - control_dt = probe.reset(0)['control_dt'] + control_dt = probe.reset(0)[protocol.FRAME_CONTROL_DT] probe.close() chunk_len = 5 @@ -503,7 +567,9 @@ def test_server_failure_crosses_as_error_frame(env_server): conn = EnvConnection(host, port) conn.reset(7) with pytest.raises(RuntimeError, match='bogus'): - conn.step({'command': {'type': 'bogus'}, 'grip': 0.0}) + conn.step({protocol.ACTION_COMMAND: {protocol.COMMAND_TYPE: 'bogus'}, protocol.ACTION_GRIP: 0.0}) # The socket is still usable after a delivered failure. - assert 'obs' in conn.step({'command': {'type': 'joint_pos', 'q': np.zeros(7)}, 'grip': 0.0}) + joints = {protocol.COMMAND_TYPE: protocol.JOINT_POS, protocol.COMMAND_JOINT_POS: np.zeros(7)} + step = conn.step({protocol.ACTION_COMMAND: joints, protocol.ACTION_GRIP: 0.0}) + assert protocol.FRAME_OBS in step conn.close() diff --git a/positronic/simulator/libero/adapter.py b/positronic/simulator/libero/adapter.py index e4c9ddd72..d1534a33d 100644 --- a/positronic/simulator/libero/adapter.py +++ b/positronic/simulator/libero/adapter.py @@ -13,6 +13,7 @@ import pimm from positronic import geom, keys from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol from positronic.simulator.env_server.adapter import WireCommandAdapter from positronic.simulator.libero import keys as libero_keys from positronic.simulator.mujoco.sim import MujocoFrankaState @@ -65,4 +66,4 @@ def privileged(self, raw_obs: dict[str, Any]) -> dict[str, Any]: def terminal(self, result: dict[str, Any]) -> dict[str, Any] | None: # ``done`` is LIBERO's success check rather than a step limit, so reaching it is the success. - return {eval_keys.SUCCESS: True} if result['done'] else None + return {eval_keys.SUCCESS: True} if result[protocol.FRAME_DONE] else None diff --git a/positronic/simulator/molmo_spaces/__init__.py b/positronic/simulator/molmo_spaces/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/positronic/simulator/molmo_spaces/adapter.py b/positronic/simulator/molmo_spaces/adapter.py new file mode 100644 index 000000000..ccd467cb4 --- /dev/null +++ b/positronic/simulator/molmo_spaces/adapter.py @@ -0,0 +1,77 @@ +"""``MolmoAdapter``: the canonical embodiment contract <-> MolmoSpaces' raw obs/command payloads, client-side. + +Runs in positronic's interpreter (the ``MolmoSpacesEnv`` server runs in MolmoSpaces' own). Mirrors the LIBERO +adapter on the observation side; the command side is ``WireCommandAdapter``'s forwarding. All action encoding — +the wire command into MolmoSpaces' per-move-group joint targets — lives server-side in ``env.py`` where the +MuJoCo model is, so the adapter holds no model and stays geometry-only. +""" + +from typing import Any + +import pimm +from positronic import geom, keys +from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol +from positronic.simulator.env_server.adapter import WireCommandAdapter +from positronic.simulator.molmo_spaces import keys as molmo_keys +from positronic.simulator.molmo_spaces import mapping +from positronic.simulator.mujoco.sim import MujocoFrankaState + +# Per default MolmoSpaces DROID camera, the benchmark-variant keys the upstream Pi policy falls back to; an +# explicitly configured non-default camera key is read as-is (no variants). +_CAMERA_VARIANTS = { + mapping.MOLMO_WRIST_CAMERA: mapping.MOLMO_WRIST_CAMERA_VARIANTS, + mapping.MOLMO_EXTERIOR_CAMERA: mapping.MOLMO_EXTERIOR_CAMERA_VARIANTS, +} + +# Which MolmoSpaces camera each logical observation reads on the DROID rig — the pairing the benchmarks record, +# and the one whose variants the table above resolves. +DEFAULT_CAMERA_DICT = {keys.WRIST_IMAGE: mapping.MOLMO_WRIST_CAMERA, keys.EXTERIOR_IMAGE: mapping.MOLMO_EXTERIOR_CAMERA} + + +class MolmoAdapter(WireCommandAdapter): + def __init__(self, camera_dict: dict[str, str]) -> None: + super().__init__() + self._camera_dict = camera_dict # logical observation name -> the MolmoSpaces obs camera key + + def task_params(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + eval_keys.TASK: record['name'], + molmo_keys.EPISODE_INDEX: record['episode_index'], + molmo_keys.TASK_HORIZON: record['task_horizon_sec'], + } + for record in records + ] + + def _reset_token(self, params: dict[str, Any]) -> Any: + # The benchmark episode selector rides the token. An absent seed leaves the episode spec's own in force. + return { + mapping.TOKEN_EPISODE_INDEX: params[molmo_keys.EPISODE_INDEX], + mapping.TOKEN_SEED: params.get(eval_keys.SEED), + } + + def observations(self, raw_obs: dict[str, Any]) -> dict[str, Any]: + # env.py reports the eef pose in the grasp-site world frame; ``eef_quat`` is scalar-first (wxyz, from + # ``mju_mat2Quat``), so ``from_quat`` is the matching decode. + ee_pose = geom.Transform3D(raw_obs[mapping.OBS_EEF_POS], geom.Rotation.from_quat(raw_obs[mapping.OBS_EEF_QUAT])) + state = MujocoFrankaState() + state.encode(raw_obs[mapping.OBS_JOINT_POS], raw_obs[mapping.OBS_JOINT_VEL], ee_pose) + obs: dict[str, Any] = {keys.ROBOT_STATE: state, keys.GRIP: float(raw_obs[mapping.OBS_GRIP])} + for logical, molmo_key in self._camera_dict.items(): + env_key = mapping.resolve_camera_key(raw_obs, molmo_key, _CAMERA_VARIANTS.get(molmo_key, ())) + frame = raw_obs[env_key] # MolmoSpaces renders top-down already — no flip + adapter = pimm.shared_memory.NumpySMAdapter(shape=frame.shape, dtype=frame.dtype) + adapter.array[:] = frame + obs[logical] = adapter + return obs + + def privileged(self, raw_obs: dict[str, Any]) -> dict[str, Any]: + # The env's full MuJoCo state — recorded as ground truth so success can be recomputed offline, never fed + # to the policy (mirrors the libero adapter). + return {mapping.OBS_SIM_STATE: raw_obs[mapping.OBS_SIM_STATE]} + + def terminal(self, result: dict[str, Any]) -> dict[str, Any] | None: + # ``done`` covers termination and timeout; ``success`` is the task's judged success, so a timeout stays + # honest. + return {eval_keys.SUCCESS: bool(result[protocol.FRAME_SUCCESS])} if result[protocol.FRAME_DONE] else None diff --git a/positronic/simulator/molmo_spaces/env.py b/positronic/simulator/molmo_spaces/env.py new file mode 100644 index 000000000..1522f113c --- /dev/null +++ b/positronic/simulator/molmo_spaces/env.py @@ -0,0 +1,449 @@ +"""MolmoSpaces — AllenAI's MuJoCo manipulation benchmark — behind the env-server protocol. + +MolmoSpaces pins ``mujoco ~=3.5`` + its asset stack on Python 3.11, so this never shares positronic's venv: the +launcher runs it with the molmospaces ``.venv``'s python (``env.py --host ... --port ... --benchmark_dir ...``), +with the positronic-free ``server``/``protocol`` and this package's ``mapping`` module on ``PYTHONPATH``. It +imports only ``molmo_spaces`` (+ mujoco/numpy) and those, never ``positronic``. + +positronic owns the control loop: this server drives a single MolmoSpaces ``BaseMujocoTask`` per episode directly +(``JsonEvalTaskSampler.sample_task`` builds the full sim/scene/renderer; ``reset``/``step``/``is_done``/ +``judge_success`` drive it), replacing MolmoSpaces' own ``JsonEvalRunner`` loop. The reset token selects the +benchmark episode (index into ``benchmark.json``) and an optional seed; the client-side ``MolmoAdapter`` maps the +raw payload this server reports into the canonical embodiment contract. + +Command side: the ``MolmoAdapter`` forwards a joint command (the DROID rig runs the joint-position controller); +this server integrates it onto the measured joints and steps the per-move-group ``{arm, gripper}`` action. +Observation side: MolmoSpaces' obs carries the joint positions/velocities and camera frames, but the +end-effector *world* pose is read from the robot view's grasp-site frame here, alongside the gripper closure, into +the raw payload the adapter assembles into a ``MujocoFrankaState``. +""" + +# ``molmo_spaces`` (+ its transitive configs/tasks) and the flat ``protocol`` resolve only inside MolmoSpaces' +# own venv, where the launcher runs this module; pyright checks it against positronic's deps, which cannot see +# them. Each of those imports carries its own ``reportMissingImports`` suppression, so an import that should +# resolve here — anything from positronic, which this module must never take — still fails the check. + +import argparse +import os +import sys +import types + +import mapping # positronic-free wire mappings, on PYTHONPATH; numpy only, so it pulls in no GL + +# MolmoSpaces renders MuJoCo scenes, so the GL backend must be selected before any mujoco/molmo_spaces import. +# The launcher sets it in the subprocess env; default it here too so a direct invocation (e.g. a validate/e2e +# run) still boots. Set before the imports below. +os.environ.setdefault(mapping.GL_BACKEND_ENV, mapping.GL_BACKEND_DEFAULT) + + +# MolmoSpaces' renderer module, which the stub below stands in for on Linux. +_CGL_PACKAGE = 'mujoco.cgl' +_CGL_MODULE = f'{_CGL_PACKAGE}.cgl' + + +def _install_cgl_noop_stub() -> None: + # HACK: MolmoSpaces' renderer hardcodes a macOS CGL context on the CPU (device_id=None) render path + # (opengl_rendering.py does ``from mujoco.cgl import cgl``), which dlopens Apple's OpenGL.framework and + # crashes at renderer init on Linux — so a CPU-rendered server (MUJOCO_GL=osmesa or mesa software EGL) + # dies before the first observation. CGL locking is a no-op off macOS, so stub the module: the import + # resolves and the (un)lock does nothing. Untouched on a GPU box, where the EGL path never imports it. + # macOS keeps the real module, where those locks guard an actual context. + if sys.platform == 'darwin' or _CGL_PACKAGE in sys.modules: + return + cgl = types.ModuleType(_CGL_MODULE) + cgl.CGLLockContext = cgl.CGLUnlockContext = lambda *args, **kwargs: None # pyright: ignore[reportAttributeAccessIssue] + package = types.ModuleType(_CGL_PACKAGE) + package.cgl = cgl # pyright: ignore[reportAttributeAccessIssue] + sys.modules[_CGL_PACKAGE] = package + sys.modules[_CGL_MODULE] = cgl + + +_install_cgl_noop_stub() + +from pathlib import Path # noqa: E402 +from typing import Any # noqa: E402 + +import mujoco # noqa: E402 +import numpy as np # noqa: E402 +import protocol # noqa: E402 -- the positronic-free wire contract, on PYTHONPATH # pyright: ignore[reportMissingImports] + +# server resolves to a module without these symbols under positronic's deps (the real one is on the molmo +# venv's PYTHONPATH), so the symbols read as unknown here. +from server import EnvProtocol, EnvServer # noqa: E402 # pyright: ignore[reportAttributeAccessIssue] + +# Imported for its import-time ``_assert_data_versions_match()``: MolmoSpaces pins the asset versions a +# benchmark may be evaluated against, and this is the only place upstream enforces it. Driving the sampler +# directly skips the native entrypoint, so without this a run on mismatched asset packs would score where +# MolmoSpaces itself refuses to. +import molmo_spaces.evaluation.eval_main # noqa: E402, F401 # pyright: ignore[reportMissingImports] +import molmo_spaces.evaluation.json_eval_runner # noqa: E402, F401 -- load first: breaks a circular import that importing json_eval_task_sampler directly hits # pyright: ignore[reportMissingImports] +from molmo_spaces.configs.policy_configs import DummyPolicyConfig # noqa: E402 # pyright: ignore[reportMissingImports] +from molmo_spaces.configs.robot_configs import ( # noqa: E402 # pyright: ignore[reportMissingImports] + ActionNoiseConfig, + FrankaRobotConfig, +) +from molmo_spaces.evaluation.benchmark_schema import ( # noqa: E402 # pyright: ignore[reportMissingImports] + load_all_episodes, +) +from molmo_spaces.evaluation.configs.evaluation_configs import ( # noqa: E402 # pyright: ignore[reportMissingImports] + JsonBenchmarkEvalConfig, +) +from molmo_spaces.tasks.json_eval_task_sampler import ( # noqa: E402 # pyright: ignore[reportMissingImports] + JsonEvalTaskSampler, +) + +# Damped-least-squares differential IK, matching the LIBERO rig's solver (positronic/simulator/libero/env.py): +# the same iteration budget, damping and convergence tolerance, on MuJoCo's own site/body Jacobian. +_IK_ITERS = 100 +_IK_DAMPING = 0.05 +_IK_TOL = 1e-4 + + +class _DroidPickEvalConfig(JsonBenchmarkEvalConfig): + """The minimal eval config to build a Franka DROID pick task standalone. + + ``JsonBenchmarkEvalConfig`` defaults every ``MlSpacesExpConfig`` field except the robot and policy configs; + the sampler overrides ``task_type``/``scene_dataset``/``data_split``/``camera_config``/``house_inds`` from the + episode spec, so only these two are supplied. The policy config is a ``DummyPolicyConfig`` — positronic owns + the policy, and ``sample_task`` never calls the framework's ``policy_factory`` (only reads + ``force_enable_depth``). + """ + + robot_config: FrankaRobotConfig = FrankaRobotConfig() + policy_config: DummyPolicyConfig = DummyPolicyConfig() + + def model_post_init(self, __context: Any) -> None: + super().model_post_init(__context) + self.robot_config.action_noise_config = ActionNoiseConfig(enabled=False) + + +# MolmoSpaces reports a move group's leaf frame as one of MuJoCo's frame kinds; the arm's is a site. +_SITE_FRAME = 'site' + + +def _discovery_hint() -> str: + """The benchmark dirs found under ``MLSPACES_ASSETS_DIR``, appended to a path that holds none.""" + assets = os.environ.get(mapping.ASSETS_DIR_ENV) + if not assets: + return f' Point {mapping.ASSETS_DIR_ENV} at the MolmoSpaces asset packs to have the available ones listed.' + root = Path(assets) / mapping.ASSETS_BENCHMARKS_DIR + found = sorted(str(p.parent) for p in root.rglob(mapping.MOLMO_BENCHMARK_MANIFEST)) + if not found: + return f' No {mapping.MOLMO_BENCHMARK_MANIFEST} found under {root}.' + return f' Available under {root}: {", ".join(found)}' + + +def _assert_measures_at_grasp_site(robot_view: Any) -> None: + """Fail unless the arm move group's leaf frame is ``mapping.MOLMO_GRASP_SITE``. + + That frame is what every pose this server reports is measured in, and the eval declares its recorded + model's control frame at the same point. Nothing else ties the two together, so a scene whose arm resolves + somewhere else would misframe every recorded pose silently — for the viewer, for offline IK and for any + frame a policy asks for. + """ + arm = robot_view.get_move_group(mapping.MOLMO_ARM_GROUP) + if arm.leaf_frame_type != _SITE_FRAME: + raise ValueError(f'arm move group measures at a {arm.leaf_frame_type}, not the expected site') + name = mujoco.mj_id2name(arm.mj_model, mujoco.mjtObj.mjOBJ_SITE, arm.leaf_frame_id) # pyright: ignore[reportAttributeAccessIssue] + if name != mapping.MOLMO_GRASP_SITE and not name.endswith(f'/{mapping.MOLMO_GRASP_SITE}'): + raise ValueError(f'arm move group measures at site {name!r}, expected {mapping.MOLMO_GRASP_SITE!r}') + + +class MolmoSpacesEnv(EnvProtocol): + """A MolmoSpaces benchmark behind the ``tasks``/``reset``/``step``/``close`` the env server serves. + + ``tasks`` answers the benchmark's episode records. Each reset builds from the token's episode index (into + the loaded ``benchmark.json``) and its seed: MolmoSpaces' ``task.reset()`` does not re-place the scene — + ``sample_task`` does — so each reset rebuilds the task for a clean, deterministic scene (benchmark episodes + are exact-pose deterministic, so a rebuild reproduces them). ``step`` integrates the forwarded joint command + onto the measured joints, drives the per-move-group action, and reports MolmoSpaces' + ``is_done``/``judge_success``. + """ + + def __init__(self, benchmark_dir: Path, task_horizon_steps: int | None = None) -> None: + self._benchmark_dir = benchmark_dir + self._episodes = load_all_episodes(benchmark_dir) + # An explicit per-run horizon override (steps), mirroring MolmoSpaces' ``--task_horizon_steps``; ``None`` + # reads the benchmark's own ``task_horizon_sec``. + self._task_horizon_override = task_horizon_steps + self._sampler: Any = None + self._task: Any = None + self._robot_view: Any = None + self._control_dt: float | None = None + # The episode's enforced horizon in sim-seconds (``task_horizon`` steps x the control period), reported + # at reset. + self._horizon_sec: float | None = None + self._meta: dict[str, Any] | None = None + # The RGB camera keys the current episode renders, emitted every frame. + self._camera_names: list[str] = [] + # Scratch ``MjData`` the kinematics probes (``_fk``/``_ik``) run on, allocated once per episode and + # refreshed from the live buffer per call — a Cartesian policy solves IK every control step, so the + # allocation stays out of the loop. Rebuilt in ``_build``, since it is sized by the episode's model. + self._scratch: Any = None + + def _build(self, episode_index: int, seed: int | None) -> None: + if self._sampler is not None: + self._sampler.close() # release the prior episode's sim/renderer before building the next + episode = self._episodes[episode_index] + cfg = _DroidPickEvalConfig() + # Determinism enters at sampler construction (seed_task_sampling). + cfg.seed = mapping.resolve_episode_seed(episode, episode_index, seed) + # With ``task_horizon`` set, the task enforces it and ``is_done`` reports expiry, so a horizon-expired + # trial ends with a terminal ``done`` exactly as the native benchmark scores it. The horizon is the + # benchmark's, not an episode's. + cfg.task_horizon = mapping.resolve_task_horizon_steps( + self._episodes, cfg.policy_dt_ms, self._task_horizon_override + ) + self._sampler = JsonEvalTaskSampler(cfg, episode) + self._task = self._sampler.sample_task(house_index=episode.house_index) + self._robot_view = self._task.env.current_robot.robot_view + _assert_measures_at_grasp_site(self._robot_view) + self._scratch = None # sized by this episode's model; allocated on the first probe + self._control_dt = cfg.policy_dt_ms / 1000.0 + self._horizon_sec = cfg.task_horizon * self._control_dt + # The authoritative benchmark prompt, straight from the episode spec — not + # ``task.get_task_description()``, which upstream reconstructs per task type (e.g. OpeningTask emits + # "Open the ..." even for a close episode), so a reconstruction could diverge from the benchmark goal. + self._meta = { + mapping.META_TASK: episode.language.task_description, + mapping.META_HOUSE_INDEX: episode.house_index, + } + + def tasks(self, spec: dict[str, Any]) -> list[dict[str, Any]]: + """The episode records ``spec`` selects: ``episodes`` is one index, or a list of them; absent, the whole + benchmark. Every record carries the one horizon the benchmark enforces, converted against the config + that enforces it.""" + if not self._episodes: + raise ValueError( + f'no benchmark episodes under {self._benchmark_dir}; expected a ' + f'{mapping.MOLMO_BENCHMARK_MANIFEST} or a legacy house_*/episode_*.json layout.{_discovery_hint()}' + ) + count = len(self._episodes) + selection = spec.get('episodes') + indices = list(range(count)) if selection is None else [selection] if isinstance(selection, int) else selection + # A negative index would silently run a from-the-end episode mislabeled by its own index. + out_of_range = [i for i in indices if not 0 <= i < count] + if out_of_range: + raise ValueError( + f'episodes {out_of_range} out of range for the {count} episodes under {self._benchmark_dir}' + ) + cfg = _DroidPickEvalConfig() + steps = mapping.resolve_task_horizon_steps(self._episodes, cfg.policy_dt_ms, self._task_horizon_override) + horizon_sec = steps * cfg.policy_dt_ms / 1000.0 + return [ + {'name': self._episodes[i].language.task_description, 'episode_index': i, 'task_horizon_sec': horizon_sec} + for i in indices + ] + + def reset(self, token: dict[str, Any]) -> dict[str, Any]: + self._build(token[mapping.TOKEN_EPISODE_INDEX], token.get(mapping.TOKEN_SEED)) + obs, _info = self._task.reset() # obs is a list, one dict per env; n_batch == 1 + env_obs = obs[0] + self._camera_names = [k for k, v in env_obs.items() if mapping.is_rgb_frame(v)] + # robot_meta is empty: this venv cannot import positronic to emit the Franka model, so the eval supplies + # it via ``static_meta`` (``bundled_franka_model``). + return { + protocol.FRAME_OBS: self._observe(env_obs), + protocol.FRAME_META: self._meta, + protocol.FRAME_ROBOT_META: {}, + protocol.FRAME_CONTROL_DT: self._control_dt, + protocol.FRAME_HORIZON: self._horizon_sec, + } + + def step(self, action: dict[str, Any]) -> dict[str, Any]: + arm = mapping.wire_command_to_arm_action( + action[protocol.ACTION_COMMAND], self._measured_arm_q(), ik=self._ik, current_eef=self._measured_eef_pose() + ) + gripper = np.array([mapping.grip_command_to_actuator(action[protocol.ACTION_GRIP])], dtype=np.float32) + obs, _reward, _term, _trunc, _infos = self._task.step({ + mapping.MOLMO_ARM_GROUP: arm, + mapping.MOLMO_GRIPPER_GROUP: gripper, + }) + # The trial ends on the task's judged success, on a MolmoSpaces terminal, or on horizon expiry — the + # latter two through ``is_done``. ``success`` is ORed in for end-on-success, the benchmark's scoring + # semantics: without it a successful rollout that kept sending joint commands would idle to the horizon. + # It stays ``judge_success()`` alone, so a horizon expiry ends the trial with ``success=False``, as + # native scoring has it. + success = bool(self._task.judge_success()) + done = success or bool(self._task.is_done()) + return { + protocol.FRAME_OBS: self._observe(obs[0]), + protocol.FRAME_DONE: done, + protocol.FRAME_SUCCESS: success, + protocol.FRAME_CONTROL_DT: self._control_dt, + } + + def _measured_arm_q(self) -> np.ndarray: + return np.asarray(self._robot_view.get_move_group(mapping.MOLMO_ARM_GROUP).joint_pos, dtype=np.float32) + + def _measured_eef_pose(self) -> tuple[np.ndarray, np.ndarray]: + """The measured grasp-site world pose as ``(translation, 3x3 rotation)`` — the frame a Cartesian + command targets and the one ``_observe`` reports, so command and observation share a frame.""" + eef_world = np.asarray( + self._robot_view.get_move_group(mapping.MOLMO_ARM_GROUP).leaf_frame_to_world, dtype=np.float64 + ) + return eef_world[:3, 3].copy(), eef_world[:3, :3].copy() + + def _scratch_data(self, move_group: Any) -> Any: + """The scratch ``MjData``, refreshed from the live one, for off-sim kinematics probing. + + A fresh ``MjData`` seeded with ``qpos`` alone is NOT equivalent: MolmoSpaces places the robot in a scene + whose pose also rides on state outside ``qpos`` (mocap bodies among it), which a fresh buffer resets to + the model defaults — the grasp site then resolves metres away from the live one. Copying the whole + struct keeps every such field, so the probe differs from the live scene only in the joints the caller + sets, and copying into a retained buffer keeps the per-step allocation out of the control loop. + """ + if self._scratch is None: + self._scratch = mujoco.MjData(move_group.mj_model) # pyright: ignore[reportAttributeAccessIssue] + mujoco.mj_copyData(self._scratch, move_group.mj_model, move_group.mj_data) # pyright: ignore[reportAttributeAccessIssue] + return self._scratch + + def _fk(self, q: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """The grasp-site world pose a candidate arm configuration reaches. + + Evaluated on a scratch ``MjData`` seeded from the live scene (objects intact), so the live sim is never + perturbed: set the arm joints, propagate, read the leaf frame. The inverse of ``_ik``. + """ + arm = self._robot_view.get_move_group(mapping.MOLMO_ARM_GROUP) + data = self._scratch_data(arm) + data.qpos[np.asarray(arm.joint_posadr)] = np.asarray(q, dtype=np.float64).reshape(-1) + mujoco.mj_forward(arm.mj_model, data) # pyright: ignore[reportAttributeAccessIssue] + return _leaf_pose(arm, data) + + def _ik(self, target_pos: np.ndarray, target_rot: np.ndarray) -> np.ndarray: + """Absolute world grasp-site target -> the arm joint targets that reach it. + + Damped-least-squares differential IK on MuJoCo's own leaf-frame Jacobian, mirroring the LIBERO rig's + solver. It iterates on a scratch ``MjData`` seeded from the live scene (objects intact), so probing + candidate joint configurations never perturbs the sim being stepped. Joint targets stay inside the + move group's limits, and a target the arm cannot reach yields the closest configuration the iteration + reached rather than raising — an unreachable waypoint holds near the limit instead of aborting a trial. + """ + arm = self._robot_view.get_move_group(mapping.MOLMO_ARM_GROUP) + model = arm.mj_model + posadr = np.asarray(arm.joint_posadr) + veladr = np.asarray(arm.joint_veladr) + limits = np.asarray(arm.joint_pos_limits, dtype=np.float64) + data = self._scratch_data(arm) + q = np.asarray(arm.joint_pos, dtype=np.float64).copy() + for _ in range(_IK_ITERS): + data.qpos[posadr] = q + mujoco.mj_forward(model, data) # pyright: ignore[reportAttributeAccessIssue] + cur_pos, cur_rot = _leaf_pose(arm, data) + err = _pose_error(target_pos, target_rot, cur_pos, cur_rot) + if np.linalg.norm(err) < _IK_TOL: + break + jac = np.zeros((6, model.nv)) + self._leaf_jacobian(arm, model, data, jac) + jac = jac[:, veladr] + dq = jac.T @ np.linalg.solve(jac @ jac.T + _IK_DAMPING**2 * np.eye(6), err) + q = np.clip(q + dq, limits[:, 0], limits[:, 1]) + return q + + @staticmethod + def _leaf_jacobian(move_group: Any, model: Any, data: Any, out: np.ndarray) -> None: + """The ``(6, nv)`` leaf-frame Jacobian into *out*, evaluated on *data*. + + Mirrors the move group's own ``get_jacobian`` but against a caller-supplied ``MjData``, which the IK + iteration needs (the group's method is bound to the live one). + """ + if move_group.leaf_frame_type == _SITE_FRAME: + mujoco.mj_jacSite(model, data, out[:3], out[3:], move_group.leaf_frame_id) # pyright: ignore[reportAttributeAccessIssue] + else: + mujoco.mj_jacBody(model, data, out[:3], out[3:], move_group.leaf_frame_id) # pyright: ignore[reportAttributeAccessIssue] + + def _observe(self, env_obs: dict[str, Any]) -> dict[str, Any]: + """The raw observation payload for one env frame: measured joints, the eef world pose, grip, camera frames. + + MolmoSpaces' obs carries the joint positions/velocities and camera frames; the eef *world* pose is read + from the arm move group's grasp-site frame, since obs exposes only a robot-relative tcp pose. + """ + arm = self._robot_view.get_move_group(mapping.MOLMO_ARM_GROUP) + eef_world = np.asarray(arm.leaf_frame_to_world, dtype=np.float64) # 4x4 grasp-site world transform + eef_quat = np.zeros(4) # filled wxyz below + rot9 = np.ascontiguousarray(eef_world[:3, :3].reshape(9)) + # mju_mat2Quat is a C binding absent from mujoco's type stubs, so pyright can't see the attribute. + mujoco.mju_mat2Quat(eef_quat, rot9) # pyright: ignore[reportAttributeAccessIssue] + payload = { + mapping.OBS_JOINT_POS: np.asarray(arm.joint_pos, dtype=np.float32), + mapping.OBS_JOINT_VEL: np.asarray(arm.joint_vel, dtype=np.float32), + mapping.OBS_EEF_POS: eef_world[:3, 3].astype(np.float32), + mapping.OBS_EEF_QUAT: eef_quat.astype(np.float32), + mapping.OBS_GRIP: np.float32( + mapping.normalize_grip_qpos(env_obs[mapping.MOLMO_OBS_QPOS][mapping.MOLMO_GRIPPER_GROUP]) + ), + # The full MuJoCo generalized state: every body's pose + velocity, objects included. + mapping.OBS_SIM_STATE: self._full_physics_state(), + } + for name in self._camera_names: + payload[name] = np.ascontiguousarray(env_obs[name]) + return payload + + def _full_physics_state(self) -> np.ndarray: + """The scene's complete integrable state: ``mjSTATE_INTEGRATION``, the minimal subset a deterministic + MuJoCo sim restores from to reproduce its forward trajectory — positions and velocities, and with them + mocap bodies, actuator activation, controls and the solver warm-start. Object poses in it let analysis + recompute success. Positions start at index 1, after the scalar time.""" + data = self._robot_view.mj_data + model = data.model + spec = mujoco.mjtState.mjSTATE_INTEGRATION # pyright: ignore[reportAttributeAccessIssue] + state = np.empty(mujoco.mj_stateSize(model, spec), dtype=np.float64) # pyright: ignore[reportAttributeAccessIssue] + mujoco.mj_getState(model, data, state, spec) # pyright: ignore[reportAttributeAccessIssue] + return state + + def close(self) -> None: + if self._sampler is not None: + self._sampler.close() + self._sampler = None + self._task = None + + +# rules-allow: stranded-definition — this file keeps its pure MuJoCo helpers at module scope as a set: +# `_assert_measures_at_grasp_site`, `_leaf_pose` and `_pose_error` all take plain model/data arguments, hold no +# `self`, and are called only from `MolmoSpacesEnv`. Moving one into the class splits the set for no gain; +# moving all three is a layout decision for the file, not a fix to this definition. +def _leaf_pose(move_group: Any, data: Any) -> tuple[np.ndarray, np.ndarray]: + """A move group's leaf-frame world pose read off *data* — which may be a scratch ``MjData``, unlike the + group's own ``leaf_frame_to_world``, so IK can probe candidate joints without touching the live sim.""" + if move_group.leaf_frame_type == _SITE_FRAME: + pos, mat = data.site_xpos[move_group.leaf_frame_id], data.site_xmat[move_group.leaf_frame_id] + else: + pos, mat = data.xpos[move_group.leaf_frame_id], data.xmat[move_group.leaf_frame_id] + return np.array(pos, dtype=np.float64), np.array(mat, dtype=np.float64).reshape(3, 3) + + +def _pose_error(target_pos: np.ndarray, target_rot: np.ndarray, cur_pos: np.ndarray, cur_rot: np.ndarray) -> np.ndarray: + """The world-frame 6-vector error ``[translation, axis-angle rotation]`` from a measured to a target pose. + + Both halves are expressed in the world frame, matching the world-frame leaf Jacobian the IK step solves + against. The rotation error is the axis-angle of ``R_target @ R_cur^T``, via MuJoCo's quaternion helpers. + """ + quat = np.zeros(4) + mujoco.mju_mat2Quat(quat, np.ascontiguousarray((target_rot @ cur_rot.T).reshape(9))) # pyright: ignore[reportAttributeAccessIssue] + rot_err = np.zeros(3) + mujoco.mju_quat2Vel(rot_err, quat, 1.0) # pyright: ignore[reportAttributeAccessIssue] + return np.concatenate([np.asarray(target_pos, dtype=np.float64).reshape(3) - cur_pos, rot_err]) + + +def main() -> None: + parser = argparse.ArgumentParser(description='Serve MolmoSpaces over the env-server protocol.') + parser.add_argument(protocol.OPT_HOST, default='localhost') + parser.add_argument(protocol.OPT_PORT, type=int, required=True) + parser.add_argument( + mapping.OPT_BENCHMARK_DIR, required=True, help=f'dir containing {mapping.MOLMO_BENCHMARK_MANIFEST}' + ) + parser.add_argument( + mapping.OPT_TASK_HORIZON_STEPS, + type=int, + default=None, + help='override the benchmark horizon (steps per episode)', + ) + args = parser.parse_args() + if not os.environ.get(mapping.ASSETS_DIR_ENV): + parser.error(f'{mapping.ASSETS_DIR_ENV} must point at the MolmoSpaces asset packs') + env = MolmoSpacesEnv(Path(args.benchmark_dir), args.task_horizon_steps) + EnvServer(env, args.host, args.port).serve_forever() + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/molmo_spaces/keys.py b/positronic/simulator/molmo_spaces/keys.py new file mode 100644 index 000000000..fcbdc9bb7 --- /dev/null +++ b/positronic/simulator/molmo_spaces/keys.py @@ -0,0 +1,7 @@ +"""The keys of a MolmoSpaces trial's params: the episode the eval selects and the horizon the sim enforces.""" + +# The benchmark episode a trial runs, as ``task_params`` names it from the env's task records; ``_reset_token`` +# reads it back into the token that selects the episode. +EPISODE_INDEX = 'eval.episode_index' +# The sim-enforced episode deadline in sim-seconds; the eval config sets the trial's backstop deadline from it. +TASK_HORIZON = 'eval.task_horizon' diff --git a/positronic/simulator/molmo_spaces/launcher.py b/positronic/simulator/molmo_spaces/launcher.py new file mode 100644 index 000000000..4ac47956d --- /dev/null +++ b/positronic/simulator/molmo_spaces/launcher.py @@ -0,0 +1,128 @@ +"""Launches the MolmoSpaces env server as a subprocess and owns its lifetime. + +positronic starts the server: the env runs in MolmoSpaces' own interpreter — a per-checkout ``.venv`` with the +``molmospaces[mujoco]`` stack (mujoco ~=3.5, the resource-manager asset layer, torch) installed into it, far too +heavy and Python-version-pinned (3.11) to share positronic's venv. The positronic-free ``env_server`` package and +this package's ``mapping`` module ride ``PYTHONPATH`` so ``env.py`` imports the dumb ``server``/``protocol`` and +the pure wire mappings without dragging in positronic; ``molmo_spaces`` resolves from the venv. + +MolmoSpaces renders MuJoCo scenes, so the server needs a GL backend (``MUJOCO_GL``) and its asset packs +(``MLSPACES_ASSETS_DIR``). Both env vars pass through from the caller; unset, ``MUJOCO_GL`` takes the backend +the host platform offers — ``egl`` (GPU) on Linux, ``cgl`` on macOS, which has no EGL and rejects it. A +GPU-less Linux box overrides with ``MUJOCO_GL=osmesa`` for CPU software rendering. +""" + +import fcntl +import os +import subprocess +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager +from pathlib import Path + +from positronic.simulator.env_server import protocol +from positronic.simulator.env_server.launcher import ensure_pinned_checkout, serve_subprocess +from positronic.simulator.molmo_spaces import mapping + +_ENV_SCRIPT = Path(__file__).parent / 'env.py' +_ENV_SERVER_DIR = Path(__file__).parents[1] / 'env_server' +_MAPPING_DIR = Path(__file__).parent # ``mapping.py`` — imported flat by env.py, positronic-free + +_MOLMO_REPO = 'https://github.com/allenai/molmospaces.git' +_MOLMO_COMMIT = 'c2f1b583f087e1d3994e1377574843b759d9d0f8' +_MOLMO_SRC = Path.home() / '.cache' / 'positronic' / 'molmospaces' / 'src' + +# MolmoSpaces ships no lockfile, so a bare install re-resolves every transitive dep on each fresh box. This +# constraints file pins the full resolution (a frozen known-good venv, minus molmo-spaces' own editable line), +# fed to the install via ``-c`` so the pinned commit always builds the same environment. Regenerate it when +# ``_MOLMO_COMMIT`` bumps — see the file header. +_MOLMO_CONSTRAINTS = Path(__file__).parent / 'molmo_constraints.txt' + +# MolmoSpaces pins Python 3.11 and installs its MuJoCo renderer stack via the ``mujoco`` extra (classic renderer, +# mujoco ~=3.5). ``mujoco-filament`` is the alternative for bench-v2 filament scenes; the classic renderer is the +# eval default. +_MOLMO_PYTHON = '3.11' +_MOLMO_EXTRA = 'mujoco' + +# ``env.py`` imports positronic's ``env_server`` off PYTHONPATH, which needs ``websockets`` (the wire server) and +# ``msgpack`` (the frame codec). MolmoSpaces currently pulls both, but that is incidental to its own deps — install +# them explicitly so env_server's wire contract holds even if MolmoSpaces drops them. Constraints mirror positronic's. +_WIRE_DEPS = ('websockets>=15.0.1', 'msgpack') + + +@contextmanager +def _checkout_lock() -> Iterator[None]: + """Serialize checkout + ``uv sync`` across processes sharing the cache, so a warm-cache fan-out of eval jobs + mounting one ``~/.cache/positronic/molmospaces`` filesystem does not race a forced checkout against a sync.""" + _MOLMO_SRC.parent.mkdir(parents=True, exist_ok=True) + with open(_MOLMO_SRC.parent / 'setup.lock', 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def ensure_molmo_venv() -> Path: + """The MolmoSpaces venv python, after ensuring the pinned checkout and its installed stack exist. + + Install the stack before returning: a cold first install far exceeds any client's connect deadline, which + should only cover the sim's boot. Install the ``mujoco`` extra explicitly into a venv the way MolmoSpaces' + own image does, rather than ``uv sync`` — which also resolves the ``curobo`` extra, a CUDA build that needs a + GPU toolchain and is not on the eval task path. Both steps are idempotent and fast when warm. MolmoSpaces + ships no uv.lock, so ``molmo_constraints.txt`` pins the transitive resolution (``-c``) for a reproducible env. + """ + venv = _MOLMO_SRC / '.venv' + with _checkout_lock(): + src = ensure_pinned_checkout(_MOLMO_REPO, _MOLMO_COMMIT, _MOLMO_SRC) + if not venv.exists(): + subprocess.run(['uv', 'venv', '--python', _MOLMO_PYTHON, str(venv)], check=True) + subprocess.run( + ['uv', 'pip', 'install', '-c', str(_MOLMO_CONSTRAINTS), '-e', f'.[{_MOLMO_EXTRA}]', *_WIRE_DEPS], + cwd=str(src), + env={**os.environ, 'VIRTUAL_ENV': str(venv)}, + check=True, + ) + return venv / 'bin' / 'python' + + +def molmo_subprocess_env() -> dict[str, str]: + """The environment a molmo-venv script runs under: the positronic-free ``env_server``/``mapping`` on + PYTHONPATH and a GL backend. GPU OpenGL by default; a GPU-less box exports MUJOCO_GL=osmesa, or relies on + mesa's software EGL, for CPU rendering.""" + return { + **os.environ, + 'PYTHONPATH': os.pathsep.join([str(_ENV_SERVER_DIR), str(_MAPPING_DIR)]), + mapping.GL_BACKEND_ENV: os.environ.get(mapping.GL_BACKEND_ENV, mapping.GL_BACKEND_DEFAULT), + } + + +def _spawn(host: str, port: int, benchmark_dir: Path, task_horizon_steps: int | None) -> subprocess.Popen: + # env.py exits on these before it binds the port. Check them here, where the failure can name the missing + # precondition instead of reaching the caller as a bare pre-bind exit status. + if not os.environ.get(mapping.ASSETS_DIR_ENV): + raise ValueError(f'{mapping.ASSETS_DIR_ENV} must point at the MolmoSpaces asset packs') + if not benchmark_dir.is_dir(): + raise ValueError(f'benchmark dir {benchmark_dir} does not exist') + python = ensure_molmo_venv() + command = [ + str(python), + str(_ENV_SCRIPT), + protocol.OPT_HOST, + host, + protocol.OPT_PORT, + str(port), + mapping.OPT_BENCHMARK_DIR, + str(benchmark_dir), + ] + if task_horizon_steps is not None: + command += [mapping.OPT_TASK_HORIZON_STEPS, str(task_horizon_steps)] + return subprocess.Popen(command, env=molmo_subprocess_env()) + + +def serve_molmo_spaces( + benchmark_dir: Path, host: str = 'localhost', task_horizon_steps: int | None = None +) -> AbstractContextManager[tuple[str, int]]: + """The MolmoSpaces env server as a ``serve`` context manager (the ``serve_subprocess`` contract). + + ``benchmark_dir`` (a dir holding ``benchmark.json``) is fixed for the run; the reset token selects the + episode within it, so one task-agnostic server serves every trial. ``task_horizon_steps`` optionally overrides + the benchmark's own horizon (mirroring MolmoSpaces' ``--task_horizon_steps``); ``None`` reads it per episode. + """ + return serve_subprocess(lambda host, port: _spawn(host, port, benchmark_dir, task_horizon_steps), host) diff --git a/positronic/simulator/molmo_spaces/mapping.py b/positronic/simulator/molmo_spaces/mapping.py new file mode 100644 index 000000000..02d14b9ce --- /dev/null +++ b/positronic/simulator/molmo_spaces/mapping.py @@ -0,0 +1,266 @@ +"""Pure MolmoSpaces <-> positronic-wire mappings, free of both molmo_spaces and positronic. + +Imported from two interpreters: the client-side ``MolmoAdapter`` (positronic) resolves camera keys with +it, and the molmo-venv ``env.py`` builds its raw observation payload and decodes wire commands with it. It +imports numpy plus the positronic-free ``protocol`` (which owns the wire command tags), so it loads under a +bare pytest and inside the molmo venv alike — the fixture tests exercise it without either framework. The +MuJoCo reads that need the live model (joint velocities, the end-effector world pose) stay in ``env.py``; +only the framework-independent arithmetic lives here. +""" + +import sys +from collections.abc import Callable, Iterable +from typing import Any, TypeAlias + +import numpy as np + +# ``protocol`` lands as a package on the positronic side and flat on ``PYTHONPATH`` inside the molmo venv, +# where ``positronic`` is not installed — the same two-shape import ``server`` uses. +try: + from positronic.simulator.env_server import protocol +except ImportError: + import protocol # pyright: ignore[reportMissingImports] + +# The DROID rig runs 7 Franka arm joints; the reset token's per-move-group action names them 'arm'/'gripper'. +NUM_ARM_JOINTS = 7 + +# An absolute world target ``(translation, 3x3 rotation)`` -> the arm joint targets that reach it. Supplied +# by ``env.py``, which holds the model this module deliberately does not. +IkSolver: TypeAlias = Callable[[np.ndarray, np.ndarray], Any] +MOLMO_ARM_GROUP = 'arm' +MOLMO_GRIPPER_GROUP = 'gripper' + +# The MolmoSpaces site the arm move group's leaf frame resolves to, and so the frame this adoption reports +# poses in and resolves Cartesian targets against. The eval declares its recorded model's control frame at the +# same physical point, and ``env.py`` checks the live scene against this name so the two cannot drift apart. +# A scene prefixes every model name with the robot's namespace (``robot_0/``), so the live name ends with this. +MOLMO_GRASP_SITE = 'gripper/grasp_site' + +# Where the MolmoSpaces asset packs live, and the subdirectory of that root holding the benchmarks. +ASSETS_DIR_ENV = 'MLSPACES_ASSETS_DIR' +ASSETS_BENCHMARKS_DIR = 'benchmarks' + +# A benchmark dir's manifest: the JSON list of episode specs ``load_all_episodes`` reads, and what marks a +# directory as a benchmark for discovery. +MOLMO_BENCHMARK_MANIFEST = 'benchmark.json' + +# The env-server subprocess CLI, spelled by the launcher building the command and by ``env.py``'s parser +# declaring it — two interpreters, so a rename that misses one fails at spawn rather than at import. +OPT_BENCHMARK_DIR = '--benchmark_dir' +OPT_TASK_HORIZON_STEPS = '--task_horizon_steps' + +# MuJoCo's backend selector, and the backend this adoption asks for. MuJoCo validates the value against the +# host platform and raises on one it does not offer there, so the default follows the platform: EGL is the +# headless-GPU path on Linux, CGL the only context macOS has. A GPU-less Linux box overrides with osmesa. +GL_BACKEND_ENV = 'MUJOCO_GL' +GL_BACKEND_DEFAULT = 'cgl' if sys.platform == 'darwin' else 'egl' + +# The reset token: which benchmark episode to build, and the seed overriding the episode spec's own. +TOKEN_EPISODE_INDEX = 'episode_index' +TOKEN_SEED = 'seed' + +# The reset frame's scene meta: the episode's resolved language goal and the ProcTHOR house it runs in. +META_TASK = 'task' +META_HOUSE_INDEX = 'house_index' + +# The MolmoSpaces observation field holding the per-move-group joint positions, which is where the +# gripper closure is read from. +MOLMO_OBS_QPOS = 'qpos' + +# The benchmark episode spec's task definition, and the horizon it declares in sim-seconds. +MOLMO_EPISODE_TASK = 'task' +MOLMO_TASK_HORIZON_SEC = 'task_horizon_sec' + +# The raw observation payload ``env.py`` reports and ``MolmoAdapter`` reads back. +OBS_JOINT_POS = 'joint_pos' +OBS_JOINT_VEL = 'joint_vel' +OBS_EEF_POS = 'eef_pos' +OBS_EEF_QUAT = 'eef_quat' +OBS_GRIP = 'grip' +OBS_SIM_STATE = 'sim_state' + +# MolmoSpaces DROID rig camera names (FrankaDroidCameraSystem); a benchmark's own variants replace the defaults +# and the adapter resolves them so the default camera_dict works across the benchmarks: the light-randomization +# suite records the exterior as ``droid_shoulder_light_randomization`` (MolmoSpaces' Pi policy prefers it), and +# the RandCam suite records it as ``randomized_zed2_analogue_1`` (its ``--camera_names`` exterior); the Zed wrist +# variant is ``wrist_camera_zed_mini``. +MOLMO_WRIST_CAMERA = 'wrist_camera' +MOLMO_EXTERIOR_CAMERA = 'exo_camera_1' +MOLMO_WRIST_CAMERA_VARIANTS = ('wrist_camera_zed_mini',) +MOLMO_EXTERIOR_CAMERA_VARIANTS = ('droid_shoulder_light_randomization', 'randomized_zed2_analogue_1') + +# The Robotiq 2F-85 finger qpos saturates at this closure; the DROID observation's grip is normalized against +# it into the [0, 1] closure the policy was trained on (molmospaces pi_policy.py:126). +GRIPPER_QPOS_CLOSED = 0.824033 + +# The Robotiq gripper actuator is a single command, 0 fully open .. 255 fully closed (franka_droid_view.py:43). +ROBOTIQ_OPEN = 0.0 +ROBOTIQ_CLOSED = 255.0 + + +def is_rgb_frame(value: Any) -> bool: + """Whether an observation entry is a rendered camera frame — which is how the camera keys are discovered, + since MolmoSpaces names them per benchmark and the obs dict carries no other HWC uint8 array.""" + return isinstance(value, np.ndarray) and value.ndim == 3 and value.shape[2] == 3 and value.dtype == np.uint8 + + +def normalize_grip_qpos(gripper_qpos: Any, gripper_qpos_closed: float = GRIPPER_QPOS_CLOSED) -> float: + """A Robotiq finger qpos -> the [0, 1] closure the observation reports (0 open, 1 closed).""" + value = float(np.asarray(gripper_qpos).reshape(-1)[0]) + return float(np.clip(value / gripper_qpos_closed, 0.0, 1.0)) + + +def grip_command_to_actuator(grip: float) -> float: + """A wire grip closure ([0, 1], 1 = closed) -> the Robotiq actuator command ([0, 255], 255 = closed). + + Continuous: the pi05 codec already binarizes the grip channel (``binarize_grip``), so the rig maps the + closure straight through rather than re-thresholding it here. + """ + return float(np.clip(grip, 0.0, 1.0)) * ROBOTIQ_CLOSED + + +def unpack_wire_pose(vector: Any) -> tuple[np.ndarray, np.ndarray]: + """A wire pose ``[t(3), R(9)]`` -> ``(translation, 3x3 rotation)``. + + The client encodes every pose with ``Transform3D.as_vector(ROTATION_MATRIX)``: translation first, then the + rotation matrix row-major. + """ + vec = np.asarray(vector, dtype=np.float64).reshape(-1) + if vec.shape[0] != 12: + raise ValueError(f'wire pose must be [t(3), R(9)], got {vec.shape[0]} values') + return vec[:3].copy(), vec[3:].reshape(3, 3).copy() + + +def compose_world_delta(cur_pos: Any, cur_rot: Any, delta_pos: Any, delta_rot: Any) -> tuple[np.ndarray, np.ndarray]: + """The absolute pose a world-frame ``cartesian_delta`` targets from a measured pose. + + Translation adds in the world frame and rotation left-multiplies (``goal_ori = R(delta) @ ee_ori``) — the + convention positronic's ``apply_cartesian_delta`` and LIBERO's own delta bridging both use. + """ + return ( + np.asarray(cur_pos, dtype=np.float64).reshape(3) + np.asarray(delta_pos, dtype=np.float64).reshape(3), + np.asarray(delta_rot, dtype=np.float64).reshape(3, 3) @ np.asarray(cur_rot, dtype=np.float64).reshape(3, 3), + ) + + +def _require_ik(ik: IkSolver | None, kind: str) -> IkSolver: + """The caller's IK solver, or a loud failure — a Cartesian target is unresolvable without the live model.""" + if ik is None: + raise ValueError(f'command {kind!r} needs an ik solver; none was supplied') + return ik + + +def wire_command_to_arm_action( + command: dict[str, Any], current_q: Any, *, ik: IkSolver | None = None, current_eef: tuple[Any, Any] | None = None +) -> np.ndarray: + """A tagged wire command + the live measured arm joints -> the 7 absolute joint targets molmo steps. + + This is where the adoption covers the canonical command contract: MolmoSpaces' Franka natively takes only + joint-position targets, so every canonical type is converted into one. ``joint_pos`` passes through, + ``joint_vel`` integrates the per-step delta onto the measured joints (positronic applies ``JointDelta`` as + ``q + dq``), and ``hold`` re-commands the measured joints. + + The Cartesian pair needs the live model, which this module deliberately does not hold: the caller passes + ``ik`` (an absolute world target ``(pos, rot)`` -> joint targets) and, for ``cartesian_delta``, the measured + ``current_eef`` pose the delta composes onto. Both are supplied by ``env.py``, which owns the sim. + """ + current = np.asarray(current_q, dtype=np.float32).reshape(-1) + match command[protocol.COMMAND_TYPE]: + case protocol.JOINT_POS: + target = np.asarray(command[protocol.COMMAND_JOINT_POS], dtype=np.float32).reshape(-1) + case protocol.JOINT_VEL: + dq = np.asarray(command[protocol.COMMAND_JOINT_VEL], dtype=np.float32).reshape(-1) + if dq.shape[0] != current.shape[0]: + raise ValueError(f'joint delta {dq.shape[0]} vs measured joints {current.shape[0]}') + target = current + dq + case protocol.HOLD: + target = current + case protocol.CARTESIAN: + solver = _require_ik(ik, protocol.CARTESIAN) + target = np.asarray(solver(*unpack_wire_pose(command[protocol.COMMAND_POSE])), dtype=np.float32).reshape(-1) + case protocol.CARTESIAN_DELTA: + solver = _require_ik(ik, protocol.CARTESIAN_DELTA) + if current_eef is None: + raise ValueError(f'command {protocol.CARTESIAN_DELTA!r} needs the measured eef pose; none supplied') + delta_pos, delta_rot = unpack_wire_pose(command[protocol.COMMAND_DELTA]) + target_pos, target_rot = compose_world_delta(*current_eef, delta_pos, delta_rot) + target = np.asarray(solver(target_pos, target_rot), dtype=np.float32).reshape(-1) + case other: + raise ValueError( + f'{other!r} is not a canonical command type; the contract is {list(protocol.CANONICAL_COMMAND_TYPES)}' + ) + return target.astype(np.float32) + + +def resolve_camera_key(available: Any, key: str, variants: tuple[str, ...] = ()) -> str: + """The MolmoSpaces observation key to read for a camera role, mirroring the upstream policy's precedence. + + A present benchmark variant wins over ``key`` (matching molmo_spaces pi_policy); with no variants ``key`` + is read as-is. Raises with the candidate list on a miss. + """ + keys = set(available) + for candidate in (*variants, key): + if candidate in keys: + return candidate + raise KeyError(f'observation has none of {(*variants, key)}; available: {sorted(keys)}') + + +def resolve_episode_seed(episode: Any, episode_index: int, override_seed: int | None = None) -> int: + """The seed an episode runs under, mirroring MolmoSpaces' own precedence. + + An explicit ``override_seed`` wins, then the episode spec's own seed. A spec carrying none falls back to + the episode index, which is what ``JsonEvalRunner.get_episode_seed`` does — a constant instead would put + every unseeded episode of a benchmark on one random stream, and none of them on the native one. + """ + if override_seed is not None: + return int(override_seed) + spec_seed = getattr(episode, 'seed', None) + return int(spec_seed) if spec_seed is not None else int(episode_index) + + +def declared_task_horizon_sec(declared: Iterable[float | None]) -> float: + """The one horizon a benchmark declares, in sim-seconds, over every episode's ``task_horizon_sec``. + + The horizon belongs to the benchmark, not to an episode within it, so a benchmark that declares none, one + that disagrees with itself, and one that declares a non-positive span all have no horizon to run at. + Callers pass the values already read from their own representation of the specs: parsed episode objects in + the molmo venv, raw JSON on the positronic side. + """ + horizons = set() + for value in declared: + if value is None: + raise ValueError( + f'benchmark episodes carry no {MOLMO_TASK_HORIZON_SEC} in their task dict — the horizon is part ' + 'of the task definition; add it to the benchmark' + ) + if value <= 0: + raise ValueError(f'benchmark declares a non-positive {MOLMO_TASK_HORIZON_SEC} of {value}s') + horizons.add(value) + if len(horizons) != 1: + raise ValueError(f'benchmark declares inconsistent {MOLMO_TASK_HORIZON_SEC} values {sorted(horizons)}') + return float(horizons.pop()) + + +def resolve_task_horizon_steps(episodes: Any, policy_dt_ms: float, override_steps: int | None = None) -> int: + """A benchmark's enforced horizon in policy steps, mirroring MolmoSpaces' own resolution. + + Upstream's ``determine_task_horizon`` (``evaluation/eval_main.py``, the entrypoint its README documents) + resolves in this order and nothing else: an explicit ``--task_horizon_steps`` override, then the benchmark's + own ``task_horizon_sec`` from the episodes' task dicts, converted with ``round(sec * 1000 / policy_dt_ms)``. + It raises when any episode declares none, and again when the episodes disagree. This reproduces all three, + raises included: ``JsonBenchmarkEvalConfig.task_horizon``'s 500-step default is a config default upstream + overwrites before the runner ever sees it. A horizon that resolves below one step is refused on either + path, so no route reaches the task with a budget it expires inside. + """ + if override_steps is not None: + if override_steps < 1: + raise ValueError(f'task_horizon_steps override must be at least 1 step, got {override_steps}') + return override_steps + sec = declared_task_horizon_sec(episode.task.get(MOLMO_TASK_HORIZON_SEC) for episode in episodes) + steps = round(sec * 1000.0 / policy_dt_ms) + if steps < 1: + raise ValueError( + f'benchmark {MOLMO_TASK_HORIZON_SEC} of {sec}s rounds to {steps} steps at a {policy_dt_ms}ms policy ' + 'period — the episode would expire before its first action' + ) + return steps diff --git a/positronic/simulator/molmo_spaces/molmo_constraints.txt b/positronic/simulator/molmo_spaces/molmo_constraints.txt new file mode 100644 index 000000000..bfa0d807f --- /dev/null +++ b/positronic/simulator/molmo_spaces/molmo_constraints.txt @@ -0,0 +1,177 @@ +# Pinned transitive dependency set for the MolmoSpaces env-server venv (ensure_molmo_venv, launcher.py). +# MolmoSpaces ships no lockfile, so a cold 'uv pip install -e .[mujoco]' re-resolves every dep afresh; this +# constraints file (uv pip install -c ...) pins that resolution so every box builds the same env. +# +# Regenerate from a known-good venv (the one the parity/e2e run validated): +# VIRTUAL_ENV= uv pip freeze | grep -v '^-e ' > molmo_constraints.txt (then re-add this header) +# molmo-spaces itself is installed editable from source (-e .), so its freeze line is excluded here. +absl-py==2.5.0 +aiohappyeyeballs==2.7.1 +aiohttp==3.14.3 +aiosignal==1.4.0 +annotated-types==0.8.0 +antlr4-python3-runtime==4.9.3 +anyio==4.14.2 +attrs==26.1.0 +beaker-py==2.7.1 +boto3==1.43.55 +botocore==1.43.55 +certifi==2026.7.22 +cffi==2.1.0 +charset-normalizer==3.4.9 +click==8.4.2 +cloudpickle==3.1.2 +coacd==1.0.11 +compress-json==1.1.1 +contourpy==1.3.3 +cryptography==49.0.0 +cycler==0.12.1 +datasets==5.0.0 +decorator==5.3.1 +decord==0.6.0 +dill==0.4.1 +einops==0.8.2 +etils==1.14.0 +evdev==1.9.3 +farama-notifications==0.0.6 +fastjsonschema==2.21.2 +ffmpeg-python==0.2.0 +filelock==3.32.0 +fonttools==4.63.0 +frozenlist==1.8.0 +fsspec==2026.4.0 +ftfy==6.3.1 +future==1.0.0 +glfw==2.10.2 +google-crc32c==1.8.0 +grpcio==1.83.0 +gymnasium==1.3.0 +h11==0.16.0 +h5py==3.16.0 +hf-xet==1.5.2 +hidapi==0.15.0 +httpcore==1.0.9 +httpx==0.28.1 +huggingface-hub==1.24.0 +idna==3.18 +imageio==2.37.4 +imageio-ffmpeg==0.6.0 +iniconfig==2.3.0 +jax==0.6.2 +jaxlib==0.6.2 +jinja2==3.1.6 +jmespath==1.1.0 +joblib==1.5.3 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +jupyter-core==5.9.1 +kiwisolver==1.5.0 +lazy-loader==0.5 +lmdb==1.7.5 +lxml==6.1.1 +markdown==3.10.2 +markupsafe==3.0.3 +matplotlib==3.11.1 +ml-dtypes==0.5.4 +molmospaces-resources==0.0.1b4 +moviepy==2.2.1 +mpmath==1.3.0 +msgpack==1.2.1 +msgpack-numpy==0.4.8 +mujoco==3.5.0 +mujoco-mjx==3.5.0 +mujoco-warp==3.5.0.2 +multidict==6.7.1 +multiprocess==0.70.19 +nbformat==5.10.4 +nbstripout==0.9.1 +networkx==3.6.1 +nltk==3.9.4 +numpy==2.4.6 +numpy-quaternion==2024.0.13 +nvidia-cublas-cu12==12.6.4.1 +nvidia-cuda-cupti-cu12==12.6.80 +nvidia-cuda-nvrtc-cu12==12.6.77 +nvidia-cuda-runtime-cu12==12.6.77 +nvidia-cudnn-cu12==9.5.1.17 +nvidia-cufft-cu12==11.3.0.4 +nvidia-cufile-cu12==1.11.1.6 +nvidia-curand-cu12==10.3.7.77 +nvidia-cusolver-cu12==11.7.1.2 +nvidia-cusparse-cu12==12.5.4.2 +nvidia-cusparselt-cu12==0.6.3 +nvidia-ml-py==13.610.43 +nvidia-nccl-cu12==2.26.2 +nvidia-nvjitlink-cu12==12.6.85 +nvidia-nvtx-cu12==12.6.77 +omegaconf==2.3.1 +open-clip-torch==3.2.0 +opencv-python==5.0.0.93 +opt-einsum==3.4.0 +packaging==26.2 +pandas==3.0.5 +pillow==11.3.0 +platformdirs==4.11.0 +pluggy==1.6.0 +prior==1.0.3 +proglog==0.1.12 +propcache==0.5.2 +protobuf==6.33.6 +psutil==7.2.2 +pyarrow==25.0.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic-core==2.46.4 +pygithub==2.9.1 +pygments==2.20.0 +pyjwt==2.13.0 +pynacl==1.6.2 +pynput==1.8.2 +pyopengl==3.1.10 +pyparsing==3.3.2 +pytest==9.1.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +python-xlib==0.33 +pyyaml==6.0.3 +qrcode==8.2 +referencing==0.37.0 +regex==2026.7.19 +requests==2.34.2 +rpds-py==2026.6.3 +s3transfer==0.19.2 +safetensors==0.8.0 +scikit-image==0.26.0 +scipy==1.17.1 +sentry-sdk==2.66.1 +setuptools==83.0.0 +shapely==2.1.2 +shortuuid==1.0.13 +six==1.17.0 +stringcase==1.2.0 +sympy==1.14.0 +teledex==0.0.7 +tensorboard==2.21.0 +tensorboard-data-server==0.7.2 +termcolor==3.3.0 +tifffile==2026.3.3 +timm==1.0.28 +toppra==0.6.3 +torch==2.7.1 +torchvision==0.22.1 +tqdm==4.69.0 +traitlets==5.15.1 +trimesh==4.12.2 +triton==3.3.1 +typing-extensions==4.16.0 +typing-inspection==0.4.2 +urllib3==2.7.0 +wandb==0.28.1 +warp-lang==1.15.0 +wcwidth==0.8.2 +websockets==16.1.1 +werkzeug==3.1.8 +xxhash==3.8.1 +yarl==1.24.5 +zipp==4.1.0 +zstandard==0.25.0 diff --git a/positronic/simulator/molmo_spaces/tests/__init__.py b/positronic/simulator/molmo_spaces/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/positronic/simulator/molmo_spaces/tests/droid_obs.npz b/positronic/simulator/molmo_spaces/tests/droid_obs.npz new file mode 100644 index 0000000000000000000000000000000000000000..217586f4399b14d46e903a4b04e74ff378cb05a5 GIT binary patch literal 1528 zcmWIWW@gc4U|`??Vnv1n5APiM4+XpoA`DsinRzAg1^LB#c?Fe>3<3;ufXYB13PLf= zek1-$THxe~fMWq`=S_;56SE|5>4Ln;X^Yk^UNCQ3d`Qrg8RGNfr%d7!D)&C4>Brh7 zo~|Ocgz2Z$GpkuvTwA$B81&c_EdE?gw(q!C=-I6QGXprqnTNFvsYzwXs#R zsW6~9B>RZWG?+s;fDTD7$}E66q6KQX8>Nnbq#s5-Ha50uH2dExap%MA7XaE{UX)o} z5}%xyn_83zb4PilHSGJ6tIbV z#?)SM}%aT{+9O$eS&<_6~b&XW#XhwHepjy$hDd%17D1&o1w& zyY=|s+`nIDFWM5Mw^n54)ZKrIUcTl#4t)Q%qUF%G^ zR%pgY*9yvvpqvKEb=*J<0}YHuKzot0Cb~vY&H?2LP!8dN=>pOXjQgM(VfhGMCn%|d zk}@b+gUV1CXkc__Vt^-nbX}kX3QBmOL?{5$0^&0s2kJskuIRc!$pgFYc4nYA(Gp95 TH!B;+3U(lz4fKsW3y22*h1lLz literal 0 HcmV?d00001 diff --git a/positronic/simulator/molmo_spaces/tests/e2e.py b/positronic/simulator/molmo_spaces/tests/e2e.py new file mode 100644 index 000000000..81cb88905 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/e2e.py @@ -0,0 +1,92 @@ +"""End-to-end check that the MolmoSpaces env server works over the socket + the adapter maps its payload. + +The mapping/adapter unit tests exercise the transforms in-process; this drives the **real boundary**: the +launcher spawns the env-server subprocess in MolmoSpaces' own venv, and a client resets + steps it over the +actual socket, then feeds the wire payload through ``MolmoAdapter`` — validating the launcher, the wire +protocol, ``env.py``'s task drive, and the observation mapping together. A hold command holds the arm, so a +healthy server keeps the joints steady and reports frames of the right shape; a wrong obs key, quaternion +order, or a broken wire codec fails the mapping. + +Needs the MolmoSpaces asset packs (``MLSPACES_ASSETS_DIR``) and a GL backend (``MUJOCO_GL``; a GPU-less box uses +mesa software EGL — ``EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1``). Run on a box with those:: + + MLSPACES_ASSETS_DIR=... MUJOCO_GL=egl EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1 \ + uv run --locked python -m positronic.simulator.molmo_spaces.tests.e2e --benchmark_dir +""" + +import argparse +from pathlib import Path + +import numpy as np + +from positronic import keys +from positronic.simulator.env_server import protocol +from positronic.simulator.env_server.client import EnvConnection +from positronic.simulator.molmo_spaces import mapping +from positronic.simulator.molmo_spaces.adapter import DEFAULT_CAMERA_DICT, MolmoAdapter +from positronic.simulator.molmo_spaces.launcher import serve_molmo_spaces + + +def _check_sim_state(adapter: MolmoAdapter, raw_obs: dict) -> np.ndarray: + """The privileged full MuJoCo state must survive the wire and reach the recorder as a finite qpos+qvel vector.""" + sim_state = adapter.privileged(raw_obs)[mapping.OBS_SIM_STATE] + assert isinstance(sim_state, np.ndarray) and sim_state.ndim == 1 and sim_state.size > 0, ( + f'privileged sim_state malformed: {type(sim_state)} shape={getattr(sim_state, "shape", None)}' + ) + assert np.isfinite(sim_state).all(), 'privileged sim_state carries non-finite values' + return sim_state + + +def run( + benchmark_dir: Path, + *, + episodes: int = 1, + steps: int = 5, + camera_dict: dict[str, str] | None = None, + task_horizon_steps: int | None = None, +) -> None: + """Reset + step the first ``episodes`` benchmark episodes over the socket, mapping each frame with the adapter.""" + camera_dict = camera_dict or DEFAULT_CAMERA_DICT + adapter = MolmoAdapter(camera_dict) + with serve_molmo_spaces(benchmark_dir, task_horizon_steps=task_horizon_steps) as (host, port): + conn = EnvConnection(host, port) + try: + for i in range(episodes): + frame = conn.reset({mapping.TOKEN_EPISODE_INDEX: i, mapping.TOKEN_SEED: None}) + obs = adapter.observations(frame[protocol.FRAME_OBS]) + assert keys.ROBOT_STATE in obs and keys.GRIP in obs, f'missing contract keys: {sorted(obs)}' + assert all(logical in obs for logical in camera_dict), f'missing cameras: {sorted(obs)}' + q = obs[keys.ROBOT_STATE].q + assert q.shape == (7,), f'unexpected joint shape {q.shape}' + sim_state = _check_sim_state(adapter, frame[protocol.FRAME_OBS]) + print( + f' episode {i}: reset ok — task={frame[protocol.FRAME_META][mapping.META_TASK]!r} ' + f'grip={obs[keys.GRIP]:.3f} ' + f'q0={q[0]:.4f} sim_state={sim_state.size}d' + ) + out = {protocol.FRAME_DONE: False} + for _ in range(steps): + hold = {protocol.ACTION_COMMAND: {protocol.COMMAND_TYPE: protocol.HOLD}, protocol.ACTION_GRIP: 0.0} + out = conn.step(hold) + adapter.observations(out[protocol.FRAME_OBS]) # the mapping round-trips on step frames too + _check_sim_state(adapter, out[protocol.FRAME_OBS]) + print(f' episode {i}: {steps} steps ok (done={out[protocol.FRAME_DONE]})') + finally: + conn.close() + print('E2E PASSED') + + +def main() -> None: + parser = argparse.ArgumentParser(description='Drive the MolmoSpaces env server over the socket.') + parser.add_argument('--benchmark_dir', required=True, help='dir containing benchmark.json') + parser.add_argument('--episodes', type=int, default=1) + parser.add_argument('--steps', type=int, default=5) + parser.add_argument( + '--task_horizon_steps', type=int, default=None, help='override the benchmark horizon (steps per episode)' + ) + args = parser.parse_args() + run(Path(args.benchmark_dir), episodes=args.episodes, steps=args.steps, task_horizon_steps=args.task_horizon_steps) + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/molmo_spaces/tests/make_fixture.py b/positronic/simulator/molmo_spaces/tests/make_fixture.py new file mode 100644 index 000000000..b6353549d --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/make_fixture.py @@ -0,0 +1,56 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["numpy"] +# /// +"""Regenerate the synthetic MolmoSpaces raw-observation fixture used by test_adapter.py. + +``env.py`` reports the clean raw payload the adapter maps into the canonical contract — measured joints and +velocities, the eef world pose, the grip closure, and one frame per camera. MolmoSpaces renders real MuJoCo +scenes needing the full asset stack and a GPU, so committing a real payload is impractical; the adapter under +test only touches observation *structure* (keys, shapes, dtypes, the MujocoFrankaState assembly, camera key +mapping), which a tiny synthetic payload exercises exactly. Frames are small (36x64, the DROID 16:9 aspect) and +color-marked so a wrist/exterior swap is visible. + +Run: uv run --no-project positronic/simulator/molmo_spaces/tests/make_fixture.py +Output: droid_obs.npz next to this script (well under 100 KB) +""" + +from pathlib import Path +from typing import Any + +import numpy as np + +from positronic.simulator.molmo_spaces import mapping + +RIG_HEIGHT, RIG_WIDTH = 36, 64 # (H, W); DROID exo/wrist cameras are 16:9. + + +def _marked_frame(base_rgb: tuple[int, int, int]) -> np.ndarray: + """A solid-color frame with a white top-left block — an orientation marker a flip or swap would move.""" + frame = np.zeros((RIG_HEIGHT, RIG_WIDTH, 3), dtype=np.uint8) + frame[:] = base_rgb + frame[:8, :12] = 255 + return frame + + +def build_payload() -> dict[str, Any]: # grip is a float32 scalar, the rest are arrays + return { + mapping.OBS_JOINT_POS: np.array([0.0, -0.785, 0.0, -2.356, 0.0, 1.571, 0.785], dtype=np.float32), + mapping.OBS_JOINT_VEL: np.linspace(-0.2, 0.2, 7, dtype=np.float32), + mapping.OBS_EEF_POS: np.array([0.4, 0.0, 0.35], dtype=np.float32), + # Identity orientation, scalar-first (wxyz) as env.py reports via mju_mat2Quat. + mapping.OBS_EEF_QUAT: np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32), + mapping.OBS_GRIP: np.float32(0.5), + mapping.MOLMO_WRIST_CAMERA: _marked_frame((200, 40, 40)), # reddish wrist view + mapping.MOLMO_EXTERIOR_CAMERA: _marked_frame((40, 160, 40)), # greenish exterior view + } + + +def main() -> None: + out = Path(__file__).parent / 'droid_obs.npz' + np.savez_compressed(out, **build_payload()) # pyright: ignore[reportArgumentType] -- numpy's savez **kwds stub + print(f'Wrote {out} ({out.stat().st_size} bytes)') + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/molmo_spaces/tests/make_replay_fixture.py b/positronic/simulator/molmo_spaces/tests/make_replay_fixture.py new file mode 100644 index 000000000..2ba77c0ea --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/make_replay_fixture.py @@ -0,0 +1,214 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["numpy"] +# /// +"""Regenerate a deterministic-replay fixture from a recorded MolmoSpaces eval episode. + +``test_replay.py`` replays a real pi05 rollout open-loop against the sim and asserts it reproduces. This +script distils one recorded episode into the fixture that replay needs: the commanded joint targets and grip +per step, taken from the recording, plus checkpoints of the ``sim_state`` those commands produce, taken by +replaying them here. The commands are what the recording pins; the checkpoints pin the integration's current +trajectory, so a later run that drifts from it fails. Regenerate them together whenever the recorded +``sim_state`` changes shape or the pinned MolmoSpaces commit moves. + +Two properties make the distillation exact. The recorded commands are *absolute* joint targets, so the +replay never reads the measured state back — it is genuinely open-loop, and the only thing under test is the +sim rollout plus the env-server path. And the proxy applies whichever command was last received when it +steps, so sampling the command signal at each observation frame's timestamp (``Signal.time`` — the same +last-value-at-or-before semantics a pimm receiver has) reconstructs the stream the sim saw, unchanged +commands included. + +It reconstructs that stream only as far as the recording pins it: an episode's command signals stop before +its observations do (internal#130), so the fixture keeps the prefix up to the final recorded command and +counts the rest as the recording's gap. + +Commands are stored as float32, the dtype ``env.py`` casts them to, so the fixture holds the bits the sim +actually applied rather than the float64 the recorder wrote. + +Run (needs positronic for the dataset reader, and the MolmoSpaces assets for the replay — hence +``--locked``, not ``--no-project``):: + + MLSPACES_ASSETS_DIR=... MUJOCO_GL=egl EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1 \ + uv run --locked python positronic/simulator/molmo_spaces/tests/make_replay_fixture.py \ + --dataset_dir ~/.cache/positronic/s3/_/inference/molmo_battle_test/2026-07-29/sweep_jp \ + --episode_index 3 --episode_index 6 + +Output: ``replay_ep.npz`` next to this script, one per episode (tens of KB — actions and checkpoints +only, never the videos). +""" + +import argparse +import os +import re +from pathlib import Path + +import numpy as np + +from positronic import keys +from positronic.dataset.local_dataset import DiskEpisode +from positronic.dataset.signal import Signal +from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol +from positronic.simulator.env_server.client import EnvConnection +from positronic.simulator.molmo_spaces import keys as molmo_keys +from positronic.simulator.molmo_spaces import launcher, mapping + +# The fixture's own fields, as a distilled episode records them. +FIELD_EPISODE_INDEX = 'episode_index' +FIELD_BENCHMARK_PATH = 'benchmark_path' +FIELD_TASK = 'task' +FIELD_COMMANDS = 'commands' +FIELD_GRIPS = 'grips' +FIELD_UNREPLAYABLE_TAIL_STEPS = 'unreplayable_tail_steps' +FIELD_CHECKPOINT_STEPS = 'checkpoint_steps' +FIELD_CHECKPOINT_SIM_STATE = 'checkpoint_sim_state' +FIELD_EXPECTED_SUCCESS = 'expected_success' + +# Checkpoint stride over the replayed steps: dense enough that drift is caught early rather than only at the +# end state, sparse enough to keep the fixture small. The final step is always included on top. +CHECKPOINT_STRIDE = 8 + +# The eval CLI records its full command line in the dataset's run metadata; the benchmark the episodes were +# recorded against is the one argument the replay must resolve on the box it runs on. +_BENCHMARK_ARG = re.compile(r'--eval\.benchmark_dir=(\S+)') + + +def find_episode_dir(dataset_dir: Path, episode_index: int) -> Path: + """The recorded episode directory whose spec carries ``episode_index``.""" + for path in sorted(dataset_dir.rglob('static.json')): + episode_dir = path.parent + if DiskEpisode(episode_dir).static.get(molmo_keys.EPISODE_INDEX) == episode_index: + return episode_dir + raise SystemExit(f'no recorded episode with {molmo_keys.EPISODE_INDEX}={episode_index} under {dataset_dir}') + + +def read_benchmark_path(dataset_dir: Path) -> str: + """The evaluated benchmark's path under the asset packs' ``benchmarks/`` root, from the run metadata. + + The path is kept from ``benchmarks/`` down — suite, scene dataset, task, benchmark — because the leaf + name alone is ambiguous: the same benchmark name exists under every scene dataset (ithor, + procthor-10k, ...) with different episodes, and replaying the wrong one silently replays a different + scene. Everything above ``benchmarks/`` is the box's own asset root and varies, so it is dropped. + """ + metadata = sorted(dataset_dir.glob('run_metadata_*.yaml')) + if not metadata: + raise SystemExit(f'no run_metadata_*.yaml in {dataset_dir} — cannot tell which benchmark was evaluated') + match = _BENCHMARK_ARG.search(metadata[-1].read_text()) + if match is None: + raise SystemExit(f'{metadata[-1]} records no --eval.benchmark_dir') + parts = Path(match.group(1)).parts + if mapping.ASSETS_BENCHMARKS_DIR not in parts: + raise SystemExit(f'evaluated benchmark {match.group(1)} is not under a benchmarks/ asset root') + return str(Path(*parts[parts.index(mapping.ASSETS_BENCHMARKS_DIR) + 1 :])) + + +def sample_at(signal: Signal, timestamps: list[int]) -> list: + """The signal's value at each timestamp — the last one at or before it, a pimm receiver's semantics.""" + sampled = signal.time[timestamps] + assert isinstance(sampled, Signal) # a sequence of timestamps samples a Signal, a single one a record + return [value for value, _ts in sampled] + + +def replay_commands( + benchmark_dir: Path, episode_index: int, commands: np.ndarray, grips: np.ndarray +) -> list[np.ndarray]: + """Step the commands open-loop through a MolmoSpaces env server, returning the sim state each produced. + + Stops early if the sim ends the trial, so a caller can tell a full replay from a truncated one by the + length of what comes back. + """ + states: list[np.ndarray] = [] + with launcher.serve_molmo_spaces(benchmark_dir) as (host, port): + conn = EnvConnection(host, port) + try: + # No seed: the benchmark episode carries its own, exactly as the recorded run left it unset. + conn.reset({mapping.TOKEN_EPISODE_INDEX: episode_index, mapping.TOKEN_SEED: None}) + for command, grip in zip(commands, grips, strict=True): + action = { + protocol.ACTION_COMMAND: { + protocol.COMMAND_TYPE: protocol.JOINT_POS, + protocol.COMMAND_JOINT_POS: command, + }, + protocol.ACTION_GRIP: float(grip), + } + out = conn.step(action) + states.append(np.asarray(out[protocol.FRAME_OBS][mapping.OBS_SIM_STATE], dtype=np.float64)) + if out[protocol.FRAME_DONE]: + break + finally: + conn.close() + return states + + +def build_fixture(episode_dir: Path, benchmark_path: str, assets_dir: Path) -> dict[str, np.ndarray]: + episode = DiskEpisode(episode_dir) + states = episode[mapping.OBS_SIM_STATE] + # rules-allow: hardcoded-keys — 'target_grip' is a canonical channel name spelled across every + # adoption and the eval configs; it belongs in positronic.keys, as its own sweep (internal#211). + commands, grips = episode[keys.TARGET_JOINTS], episode['target_grip'] + # Frame 0 is the reset observation; every later frame is one step. + frame_ts = [ts for _value, ts in states] + step_ts = frame_ts[1:] + played = [np.asarray(value, dtype=np.float32) for value in sample_at(commands, step_ts)] + grip = [float(np.asarray(value).reshape(-1)[0]) for value in sample_at(grips, step_ts)] + + # The recording's command signals stop before its observations do (internal#130), so only the steps up to + # and including the first one that reads the final recorded command are pinned by the recording; past that + # the commands the run actually applied were never written, and no substitute reproduces them. Replay that + # prefix and report the rest as the recording's gap rather than replaying commands it does not contain. + last_command_ts = commands[len(commands) - 1][1] + replayable = int(np.searchsorted(step_ts, last_command_ts, side='left')) + 1 + + steps = np.arange(1, replayable + 1) + checkpoints = np.unique(np.concatenate([steps[::CHECKPOINT_STRIDE], steps[-1:]])) + episode_index = int(episode.static[molmo_keys.EPISODE_INDEX]) + played_prefix = np.stack(played[:replayable]) + grip_prefix = np.array(grip[:replayable], dtype=np.float32) + benchmark_dir = assets_dir / mapping.ASSETS_BENCHMARKS_DIR / benchmark_path + replayed = replay_commands(benchmark_dir, episode_index, played_prefix, grip_prefix) + if len(replayed) != replayable: + raise SystemExit( + f'episode {episode_index}: the sim ended after {len(replayed)} of {replayable} replayable steps, ' + 'so the recording and the integration no longer agree on the trial length' + ) + return { + FIELD_EPISODE_INDEX: np.asarray(episode.static[molmo_keys.EPISODE_INDEX], dtype=np.int32), + FIELD_BENCHMARK_PATH: np.asarray(benchmark_path), + FIELD_TASK: np.asarray(episode.static[mapping.META_TASK]), + FIELD_COMMANDS: played_prefix, + FIELD_GRIPS: grip_prefix, + FIELD_UNREPLAYABLE_TAIL_STEPS: np.asarray(len(step_ts) - replayable, dtype=np.int32), + FIELD_CHECKPOINT_STEPS: checkpoints.astype(np.int32), + FIELD_CHECKPOINT_SIM_STATE: np.stack([replayed[int(step) - 1] for step in checkpoints]), + FIELD_EXPECTED_SUCCESS: np.asarray(episode.static[eval_keys.SUCCESS], dtype=bool), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description='Distil recorded eval episodes into replay fixtures.') + parser.add_argument('--dataset_dir', type=Path, required=True, help='recorded eval run (holds run_metadata)') + parser.add_argument( + '--episode_index', type=int, action='append', required=True, help='benchmark episode to distil; repeatable' + ) + args = parser.parse_args() + + assets = os.environ.get(mapping.ASSETS_DIR_ENV) + if not assets: + raise SystemExit( + f'{mapping.ASSETS_DIR_ENV} must point at the MolmoSpaces asset packs — the checkpoints ' + 'are taken by replaying the recorded commands, which needs the benchmark scene' + ) + + benchmark_path = read_benchmark_path(args.dataset_dir) + for episode_index in args.episode_index: + fixture = build_fixture(find_episode_dir(args.dataset_dir, episode_index), benchmark_path, Path(assets)) + if not fixture[FIELD_EXPECTED_SUCCESS]: + raise SystemExit(f'episode {episode_index} did not succeed — replay fixtures pin successful rollouts') + out = Path(__file__).parent / f'replay_ep{episode_index:02d}.npz' + np.savez_compressed(out, **fixture) # pyright: ignore[reportArgumentType] -- numpy's savez **kwds stub + steps = len(fixture[FIELD_COMMANDS]) + print(f'Wrote {out} ({out.stat().st_size} bytes, {steps} steps, {benchmark_path})') + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/molmo_spaces/tests/parity.py b/positronic/simulator/molmo_spaces/tests/parity.py new file mode 100644 index 000000000..e5f2b8f7f --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/parity.py @@ -0,0 +1,178 @@ +"""Native-vs-positronic parity check for the MolmoSpaces integration. + +The fidelity check ``docs/architecture.md`` ("Benchmarks are native; adoptions are faithful") mandates for every +sim-env integration, added here for MolmoSpaces. It drives one pinned benchmark episode twice — natively through +MolmoSpaces' own stack (``parity_native.py``: ``JsonEvalTaskSampler`` -> ``reset``/``step``/``is_done``/ +``judge_success``, MolmoSpaces' native horizon) and through the positronic path (launcher -> env server -> wire -> +the raw payload the ``MolmoAdapter`` maps) — feeding the *same* scripted actions (hold the arm, gripper open) and +asserts they agree byte-for-byte. + +MolmoSpaces benchmark episodes are exact-pose deterministic, so the strong fidelity form applies: identical call +sequence (same step count, same terminating step) and byte-identical outcomes modulo wire format (joint +positions/velocities, eef pose, gripper closure and success verdict equal at every step; camera frames equal by +content hash). Holding the arm never succeeds, so the episode runs out its horizon — exercising the horizon case +explicitly: both stacks terminate at exactly the native ``task_horizon`` step, via the wire ``done``, with +``success=False``. The two rollouts run in separate MuJoCo processes, so equality also confirms the render path is +deterministic across processes. + +This asserts fidelity against the pinned ``_MOLMO_COMMIT`` (``launcher.py``); re-run it on any bump of that pin +before merge — a sim version change can silently shift the horizon or the rollout. + +Needs the MolmoSpaces asset packs (``MLSPACES_ASSETS_DIR``) and a GL backend (``MUJOCO_GL``; a GPU-less box uses +mesa software EGL — ``EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1``), and a benchmark whose task spec carries +``task_horizon_sec`` (the horizon the sim owns). Run on a box with those:: + + MLSPACES_ASSETS_DIR=... MUJOCO_GL=egl EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1 \ + uv run --locked python -m positronic.simulator.molmo_spaces.tests.parity --benchmark_dir +""" + +import argparse +import hashlib +import os +import subprocess +import tempfile +from pathlib import Path + +import numpy as np + +from positronic.simulator.env_server import protocol +from positronic.simulator.env_server.client import EnvConnection +from positronic.simulator.molmo_spaces import launcher, mapping +from positronic.simulator.molmo_spaces.tests import parity_record + +# parity_native.py runs only in MolmoSpaces' venv (it imports the flat, positronic-free ``env``), so reference it +# by path — importing it into positronic's interpreter would fail on that import. +_PARITY_NATIVE = Path(__file__).parent / 'parity_native.py' +_HOLD = {protocol.ACTION_COMMAND: {protocol.COMMAND_TYPE: protocol.HOLD}, protocol.ACTION_GRIP: 0.0} +_ARRAY_FIELDS = (mapping.OBS_JOINT_POS, mapping.OBS_JOINT_VEL, mapping.OBS_EEF_POS, mapping.OBS_EEF_QUAT) + + +def _drive_positronic(benchmark_dir: Path, episode_index: int, seed: int, max_steps: int) -> dict: + """Drive one episode through launcher -> env server -> wire, holding the arm to the sim's own ``done``.""" + fields: dict[str, list] = {k: [] for k in (*_ARRAY_FIELDS, mapping.OBS_GRIP)} + camera_names: list[str] = [] + cam_hashes: dict[str, list[str]] = {} + + def record(obs: dict) -> None: + for key in fields: + fields[key].append(obs[key]) + for name in camera_names: + cam_hashes[name].append(hashlib.sha256(np.ascontiguousarray(obs[name]).tobytes()).hexdigest()) + + with launcher.serve_molmo_spaces(benchmark_dir) as (host, port): + conn = EnvConnection(host, port) + try: + frame = conn.reset({mapping.TOKEN_EPISODE_INDEX: episode_index, mapping.TOKEN_SEED: seed}) + reported_horizon = frame[protocol.FRAME_HORIZON] + camera_names = [k for k, v in frame[protocol.FRAME_OBS].items() if mapping.is_rgb_frame(v)] + cam_hashes = {name: [] for name in camera_names} + record(frame[protocol.FRAME_OBS]) + out = {protocol.FRAME_DONE: False, protocol.FRAME_SUCCESS: False} + step = 0 + while not out[protocol.FRAME_DONE] and step < max_steps: + out = conn.step(_HOLD) + step += 1 + record(out[protocol.FRAME_OBS]) + finally: + conn.close() + return { + **{key: np.stack(fields[key]) for key in _ARRAY_FIELDS}, + mapping.OBS_GRIP: np.array(fields[mapping.OBS_GRIP], dtype=np.float32), + parity_record.CAMERA_NAMES: camera_names, + 'cam_hashes': cam_hashes, + 'reported_horizon': reported_horizon, + parity_record.TERMINATION_STEP: step, + parity_record.FINAL_SUCCESS: bool(out[protocol.FRAME_SUCCESS]), + } + + +def _native_env() -> dict[str, str]: + """The molmo-venv environment plus this directory, so the reference resolves ``parity_record`` flat. + + The launcher's PYTHONPATH carries what the *server* needs; ``parity_record`` is the comparison's own, so the + comparison adds it rather than the launcher knowing about a check. + """ + env = launcher.molmo_subprocess_env() + return {**env, 'PYTHONPATH': os.pathsep.join([env['PYTHONPATH'], str(Path(__file__).parent)])} + + +def _run_native(benchmark_dir: Path, episode_index: int, seed: int, max_steps: int, out_path: Path) -> dict: + """Drive the native reference (``parity_native.py``) in MolmoSpaces' venv and load its recorded rollout.""" + python = launcher.ensure_molmo_venv() + subprocess.run( + [ + str(python), + str(_PARITY_NATIVE), + parity_record.OPT_BENCHMARK_DIR, + str(benchmark_dir), + parity_record.OPT_EPISODE_INDEX, + str(episode_index), + parity_record.OPT_SEED, + str(seed), + parity_record.OPT_MAX_STEPS, + str(max_steps), + parity_record.OPT_OUT, + str(out_path), + ], + env=_native_env(), + check=True, + ) + return dict(np.load(out_path, allow_pickle=False)) + + +def _assert_parity(native: dict, positronic: dict, max_steps: int) -> None: + horizon = int(native[parity_record.HORIZON_STEPS]) + n_term = int(native[parity_record.TERMINATION_STEP]) + p_term = positronic[parity_record.TERMINATION_STEP] + assert n_term < max_steps, f'native never terminated in {max_steps} steps — raise --max_steps above the horizon' + assert p_term < max_steps, f'positronic never terminated in {max_steps} steps — raise --max_steps above the horizon' + # The horizon case: holding the arm never succeeds, so both stacks run out the native horizon and stop there. + assert n_term == horizon == p_term, ( + f'terminating step differs: native {n_term}, horizon {horizon}, positronic {p_term}' + ) + assert not bool(native[parity_record.FINAL_SUCCESS]) and not positronic[parity_record.FINAL_SUCCESS], ( + 'a held arm must not score success' + ) + # The env reports its horizon at reset (in sim-seconds); it must match native's and equal timeout's yardstick. + n_horizon = float(native[parity_record.HORIZON_SEC]) + assert n_horizon == positronic['reported_horizon'], ( + f'reported horizon differs: native {n_horizon}s, positronic {positronic["reported_horizon"]}s' + ) + + assert list(native[parity_record.CAMERA_NAMES]) == positronic[parity_record.CAMERA_NAMES], ( + 'camera sets differ between the stacks' + ) + for field in (*_ARRAY_FIELDS, mapping.OBS_GRIP): + n, p = native[field], positronic[field] + assert n.shape == p.shape, f'{field} shape differs: native {n.shape}, positronic {p.shape}' + assert np.array_equal(n, p), f'{field} differs between native and positronic rollouts' + for name in positronic[parity_record.CAMERA_NAMES]: + n_hashes, p_hashes = list(native[f'{parity_record.CAM_HASH_PREFIX}{name}']), positronic['cam_hashes'][name] + assert n_hashes == p_hashes, f'camera {name} frames differ between native and positronic rollouts' + + +def run(benchmark_dir: Path, *, episode_index: int = 0, seed: int = 0, max_steps: int = 1200) -> None: + """Run the same episode natively and through positronic and assert byte-identical parity.""" + with tempfile.TemporaryDirectory() as tmp: + native = _run_native(benchmark_dir, episode_index, seed, max_steps, Path(tmp) / 'native.npz') + positronic = _drive_positronic(benchmark_dir, episode_index, seed, max_steps) + _assert_parity(native, positronic, max_steps) + frames = positronic[parity_record.TERMINATION_STEP] + 1 + horizon = native[parity_record.HORIZON_STEPS] + print(f'PARITY PASSED — episode {episode_index}: {frames} frames, terminated at horizon {horizon}') + + +def main() -> None: + parser = argparse.ArgumentParser(description='Native-vs-positronic parity check for MolmoSpaces.') + parser.add_argument( + '--benchmark_dir', required=True, help='dir containing benchmark.json (task_horizon_sec required)' + ) + parser.add_argument('--episode_index', type=int, default=0) + parser.add_argument('--seed', type=int, default=0) + parser.add_argument('--max_steps', type=int, default=1200, help='safety cap; must exceed the benchmark horizon') + args = parser.parse_args() + run(Path(args.benchmark_dir), episode_index=args.episode_index, seed=args.seed, max_steps=args.max_steps) + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/molmo_spaces/tests/parity_native.py b/positronic/simulator/molmo_spaces/tests/parity_native.py new file mode 100644 index 000000000..05c4b7e7e --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/parity_native.py @@ -0,0 +1,144 @@ +"""Native-drive reference for the MolmoSpaces parity test — MolmoSpaces' own stack, no positronic. + +Runs in MolmoSpaces' venv, flat off ``PYTHONPATH`` like ``env.py`` (positronic-free: ``molmo_spaces`` + this +package's ``mapping``/``env`` modules). It drives one benchmark episode through MolmoSpaces' native rollout — +``JsonEvalTaskSampler`` -> ``reset``/``step``/``is_done``/``judge_success``, the sequence ``JsonEvalRunner`` runs — +holding the arm every step, and records the per-step raw sim state, per-camera frame hashes, and where the native +horizon terminates the episode. ``parity.py`` drives the *same* episode through the positronic env-server path and +asserts byte-identical outcomes against this reference. + +The reference derives what it compares from MolmoSpaces, not from the integration: the horizon from upstream's +own ``determine_task_horizon``, and each observation field read off the robot view here — the gripper closure +through upstream's own normalisation (``policy/learned_policy/pi_policy.py``). So an integration that resolves +the horizon or maps an observation differently from MolmoSpaces shows up as a parity failure rather than being +reproduced on both sides. + +Both rollouts build the task from ``env._DroidPickEvalConfig``: the comparison needs one task definition, +and MolmoSpaces has no per-benchmark config to derive a second from. + +Needs ``MLSPACES_ASSETS_DIR`` + a GL backend, like ``e2e.py``. Invoked by ``parity.py``; not run by hand. +""" + +# The ``molmo_spaces`` stack resolves only inside MolmoSpaces' own venv, where this reference runs; pyright +# checks it against positronic's deps, which cannot see it. Each such import carries its own +# ``reportMissingImports`` suppression, so one that should resolve here still fails the check. + +import argparse +import hashlib +from pathlib import Path + +# env.py (imported flat off PYTHONPATH, like mapping/server) sets MUJOCO_GL and installs the CGL stub at import, +# GL-safely pulling in the molmo_spaces stack — so import it before any other molmo_spaces import. +import env # noqa: E402 +import mapping # noqa: E402 -- positronic-free wire mappings, on PYTHONPATH +import mujoco # noqa: E402 +import numpy as np # noqa: E402 +import parity_record # noqa: E402 -- the record's field names, on PYTHONPATH beside this file + +from molmo_spaces.evaluation.benchmark_schema import ( # noqa: E402 # pyright: ignore[reportMissingImports] + load_all_episodes, +) +from molmo_spaces.evaluation.eval_main import ( # noqa: E402 # pyright: ignore[reportMissingImports] + determine_task_horizon, +) +from molmo_spaces.tasks.json_eval_task_sampler import ( # noqa: E402 # pyright: ignore[reportMissingImports] + JsonEvalTaskSampler, +) + +# The Robotiq finger qpos the DROID observation's closure is normalised against, as MolmoSpaces' own policies +# read it (``np.clip(obs["qpos"]["gripper"][0] / 0.824033, 0, 1)``, pi_policy.py:126). Transcribed from upstream +# rather than read from ``mapping``, so a wrong value there is what this reference catches instead of sharing. +_GRIPPER_QPOS_CLOSED = 0.824033 + + +def _observe(robot_view, env_obs: dict, camera_names: list[str]) -> dict: + """One frame's compared values, read off MolmoSpaces directly: measured joints, the grasp-site world pose, + the gripper closure, and each camera's frame.""" + arm = robot_view.get_move_group(mapping.MOLMO_ARM_GROUP) + eef_world = np.asarray(arm.leaf_frame_to_world, dtype=np.float64) + quat = np.zeros(4) # wxyz + mujoco.mju_mat2Quat(quat, np.ascontiguousarray(eef_world[:3, :3].reshape(9))) # pyright: ignore[reportAttributeAccessIssue] + qpos = env_obs[mapping.MOLMO_OBS_QPOS][mapping.MOLMO_GRIPPER_GROUP] + grip = np.clip(qpos[0] / _GRIPPER_QPOS_CLOSED, 0.0, 1.0) + return { + mapping.OBS_JOINT_POS: np.asarray(arm.joint_pos, dtype=np.float32), + mapping.OBS_JOINT_VEL: np.asarray(arm.joint_vel, dtype=np.float32), + mapping.OBS_EEF_POS: eef_world[:3, 3].astype(np.float32), + mapping.OBS_EEF_QUAT: quat.astype(np.float32), + mapping.OBS_GRIP: np.float32(grip), + **{name: np.ascontiguousarray(env_obs[name]) for name in camera_names}, + } + + +def _run(benchmark_dir: Path, episode_index: int, seed: int, max_steps: int, out_path: Path) -> None: + episodes = load_all_episodes(benchmark_dir) + episode = episodes[episode_index] + cfg = env._DroidPickEvalConfig() + cfg.seed = seed + native_horizon = determine_task_horizon([episode], None, cfg.policy_dt_ms) + cfg.task_horizon = native_horizon + sampler = JsonEvalTaskSampler(cfg, episode) + task = sampler.sample_task(house_index=episode.house_index) + robot_view = task.env.current_robot.robot_view + + obs, _info = task.reset() + camera_names = [k for k, v in obs[0].items() if mapping.is_rgb_frame(v)] + fields: dict[str, list] = { + k: [] + for k in ( + mapping.OBS_JOINT_POS, + mapping.OBS_JOINT_VEL, + mapping.OBS_EEF_POS, + mapping.OBS_EEF_QUAT, + mapping.OBS_GRIP, + ) + } + cam_hashes: dict[str, list[str]] = {name: [] for name in camera_names} + + def record(env_obs: dict) -> None: + payload = _observe(robot_view, env_obs, camera_names) + for key in fields: + fields[key].append(payload[key]) + for name in camera_names: + cam_hashes[name].append(hashlib.sha256(payload[name].tobytes()).hexdigest()) + + record(obs[0]) + step, success = 0, False + # The native rollout: hold the measured joints (the gripper open) and let the sim run until its own is_done — + # is_terminal or horizon expiry. A hold never succeeds, so this drives the horizon case. max_steps bounds a sim + # that never terminates (a wrong horizon); the caller asserts termination lands below it. + while not bool(task.is_done()) and step < max_steps: + measured_q = np.asarray(robot_view.get_move_group(mapping.MOLMO_ARM_GROUP).joint_pos, dtype=np.float32) + action = {mapping.MOLMO_ARM_GROUP: measured_q, mapping.MOLMO_GRIPPER_GROUP: np.array([0.0], dtype=np.float32)} + obs, _reward, _term, _trunc, _infos = task.step(action) + step += 1 + record(obs[0]) + success = bool(task.judge_success()) + if success: # end-on-success, matching env.py's step (a hold never reaches it) + break + sampler.close() + + recorded: dict = {key: np.stack(values) for key, values in fields.items()} + recorded.update({f'{parity_record.CAM_HASH_PREFIX}{name}': np.array(cam_hashes[name]) for name in camera_names}) + recorded[parity_record.CAMERA_NAMES] = np.array(camera_names) + recorded[parity_record.HORIZON_STEPS] = native_horizon + recorded[parity_record.HORIZON_SEC] = native_horizon * (cfg.policy_dt_ms / 1000.0) # env.py reports this at reset + recorded[parity_record.TERMINATION_STEP] = step + recorded[parity_record.FINAL_SUCCESS] = success + # numpy's savez **kwds stub reads a dict-unpack as possibly supplying ``allow_pickle`` (as in make_fixture.py). + np.savez(out_path, **recorded) # pyright: ignore[reportArgumentType] + + +def main() -> None: + parser = argparse.ArgumentParser(description='Native-drive MolmoSpaces reference for the parity test.') + parser.add_argument(parity_record.OPT_BENCHMARK_DIR, required=True) + parser.add_argument(parity_record.OPT_EPISODE_INDEX, type=int, default=0) + parser.add_argument(parity_record.OPT_SEED, type=int, required=True) + parser.add_argument(parity_record.OPT_MAX_STEPS, type=int, required=True) + parser.add_argument(parity_record.OPT_OUT, required=True, help='npz path for the recorded native rollout') + args = parser.parse_args() + _run(Path(args.benchmark_dir), args.episode_index, args.seed, args.max_steps, Path(args.out)) + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/molmo_spaces/tests/parity_record.py b/positronic/simulator/molmo_spaces/tests/parity_record.py new file mode 100644 index 000000000..cad4e5570 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/parity_record.py @@ -0,0 +1,23 @@ +"""The interface between ``parity_native.py`` and ``parity.py``: the options one is spawned with, and the npz +field names it writes about a rollout for the other to read back. + +Imported from two interpreters, like ``mapping``: as a package module by the comparison, and flat off +``PYTHONPATH`` by the native reference inside MolmoSpaces' venv. It holds names only — no imports at all — so +both shapes resolve without a fallback. + +The per-camera frame hashes are one field per camera name, under ``CAM_HASH_PREFIX``. +""" + +# The native reference's CLI: ``parity.py`` builds the command, ``parity_native.py``'s parser declares it. +OPT_BENCHMARK_DIR = '--benchmark_dir' +OPT_EPISODE_INDEX = '--episode_index' +OPT_SEED = '--seed' +OPT_MAX_STEPS = '--max_steps' +OPT_OUT = '--out' + +CAM_HASH_PREFIX = 'cam_hash__' +CAMERA_NAMES = 'camera_names' +HORIZON_STEPS = 'native_horizon' +HORIZON_SEC = 'horizon_sec' +TERMINATION_STEP = 'termination_step' +FINAL_SUCCESS = 'final_success' diff --git a/positronic/simulator/molmo_spaces/tests/replay_ep03.npz b/positronic/simulator/molmo_spaces/tests/replay_ep03.npz new file mode 100644 index 0000000000000000000000000000000000000000..3b0629cfe3d10834d2f0f788546fdc9c71fbf910 GIT binary patch literal 83328 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 literal 0 HcmV?d00001 diff --git a/positronic/simulator/molmo_spaces/tests/replay_ep06.npz b/positronic/simulator/molmo_spaces/tests/replay_ep06.npz new file mode 100644 index 0000000000000000000000000000000000000000..174827ed56f4ed9d68611ca04cc360e73c3fbcfa GIT binary patch literal 64232 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 (%-3h7ZPW2V7vE3`G`$Ved?T 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 Dfo4s5 literal 0 HcmV?d00001 diff --git a/positronic/simulator/molmo_spaces/tests/test_adapter.py b/positronic/simulator/molmo_spaces/tests/test_adapter.py new file mode 100644 index 000000000..685723a36 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/test_adapter.py @@ -0,0 +1,97 @@ +"""Unit tests for ``MolmoAdapter``: the raw env-server payload -> canonical embodiment contract. + +Runs without molmo_spaces (the env server lives in its own interpreter); it needs positronic, which is where +the adapter runs. Exercises the observation mapping against a synthetic raw payload (``droid_obs.npz``), the +terminal, and the reset token. + +Run: uv run --locked pytest positronic/simulator/molmo_spaces/tests/test_adapter.py --no-cov +""" + +from pathlib import Path + +import numpy as np + +from positronic import keys +from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol +from positronic.simulator.molmo_spaces import keys as molmo_keys +from positronic.simulator.molmo_spaces import mapping +from positronic.simulator.molmo_spaces.adapter import DEFAULT_CAMERA_DICT as CAMERA_DICT +from positronic.simulator.molmo_spaces.adapter import MolmoAdapter + +FIXTURE = Path(__file__).parent / 'droid_obs.npz' + + +def _payload() -> dict: + return dict(np.load(FIXTURE).items()) + + +def test_observations_assemble_robot_state(): + payload = _payload() + obs = MolmoAdapter(CAMERA_DICT).observations(payload) + state = obs[keys.ROBOT_STATE] + assert np.allclose(state.q, payload[mapping.OBS_JOINT_POS]) + assert np.allclose(state.dq, payload[mapping.OBS_JOINT_VEL]) + assert np.allclose(state.ee_pose.translation, payload[mapping.OBS_EEF_POS]) + assert np.allclose(state.ee_pose.rotation.as_quat, payload[mapping.OBS_EEF_QUAT]) # wxyz round-trips + assert obs[keys.GRIP] == 0.5 + + +def test_observations_camera_passthrough_no_swap(): + payload = _payload() + obs = MolmoAdapter(CAMERA_DICT).observations(payload) + # Frames pass through untouched (no resize/flip — the codec/client own preprocessing/transport). + assert np.array_equal(obs[keys.WRIST_IMAGE].array, payload[mapping.MOLMO_WRIST_CAMERA]) + assert np.array_equal(obs[keys.EXTERIOR_IMAGE].array, payload[mapping.MOLMO_EXTERIOR_CAMERA]) + # Fixture marks wrist reddish, exterior greenish; a swap would flip the dominant channel. + wrist_mean = obs[keys.WRIST_IMAGE].array.reshape(-1, 3).mean(axis=0) + exterior_mean = obs[keys.EXTERIOR_IMAGE].array.reshape(-1, 3).mean(axis=0) + assert wrist_mean[0] > wrist_mean[1] + assert exterior_mean[1] > exterior_mean[0] + + +def test_observations_resolve_benchmark_variant_camera(): + # A Zed-wrist benchmark replaces the default key; the adapter must still land the reddish wrist view on + # image.wrist. + payload = _payload() + payload[mapping.MOLMO_WRIST_CAMERA_VARIANTS[0]] = payload.pop(mapping.MOLMO_WRIST_CAMERA) + obs = MolmoAdapter(CAMERA_DICT).observations(payload) + wrist_mean = obs[keys.WRIST_IMAGE].array.reshape(-1, 3).mean(axis=0) + assert wrist_mean[0] > wrist_mean[1] + + +def test_privileged_forwards_sim_state(): + # The full MuJoCo state is recorded as privileged ground truth (never fed to the policy), so success can be + # recomputed offline. + state = np.arange(10, dtype=np.float64) + out = MolmoAdapter(CAMERA_DICT).privileged({mapping.OBS_SIM_STATE: state}) + assert list(out) == [mapping.OBS_SIM_STATE] and out[mapping.OBS_SIM_STATE] is state + + +def test_terminal_reports_success_only_when_done(): + adapter = MolmoAdapter(CAMERA_DICT) + done_ok = {protocol.FRAME_DONE: True, protocol.FRAME_SUCCESS: True} + done_fail = {protocol.FRAME_DONE: True, protocol.FRAME_SUCCESS: False} + running = {protocol.FRAME_DONE: False, protocol.FRAME_SUCCESS: False} + assert adapter.terminal(done_ok) == {eval_keys.SUCCESS: True} + assert adapter.terminal(done_fail) == {eval_keys.SUCCESS: False} + assert adapter.terminal(running) is None + + +def test_task_params_name_an_episode_the_way_the_reset_token_reads_it(): + adapter = MolmoAdapter(CAMERA_DICT) + params = adapter.task_params([{'name': 'put the banana in the bowl', 'episode_index': 3, 'task_horizon_sec': 30.0}]) + assert params == [ + {eval_keys.TASK: 'put the banana in the bowl', molmo_keys.EPISODE_INDEX: 3, molmo_keys.TASK_HORIZON: 30.0} + ] + + +def test_reset_token_carries_episode_and_seed(): + adapter = MolmoAdapter(CAMERA_DICT) + expected = {mapping.TOKEN_EPISODE_INDEX: 3, mapping.TOKEN_SEED: 7} + assert adapter.reset_token({molmo_keys.EPISODE_INDEX: 3, eval_keys.SEED: 7}) == expected + # An absent seed falls back to the spec's own (None here). + assert adapter.reset_token({molmo_keys.EPISODE_INDEX: 2}) == { + mapping.TOKEN_EPISODE_INDEX: 2, + mapping.TOKEN_SEED: None, + } diff --git a/positronic/simulator/molmo_spaces/tests/test_mapping.py b/positronic/simulator/molmo_spaces/tests/test_mapping.py new file mode 100644 index 000000000..6d133a030 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/test_mapping.py @@ -0,0 +1,258 @@ +"""Unit tests for the pure MolmoSpaces <-> wire mappings. + +Runs with NEITHER molmo_spaces nor positronic's heavy stack: ``mapping`` imports only numpy, so these pin the +gripper normalization, the wire-command -> joint-target integration, and the camera-key precedence without a +sim or a GPU. + +Run: uv run --locked pytest positronic/simulator/molmo_spaces/tests/test_mapping.py --no-cov +""" + +import types + +import numpy as np +import pytest + +from positronic.simulator.env_server import protocol +from positronic.simulator.molmo_spaces import mapping + + +def test_grip_qpos_normalization(): + closed = mapping.GRIPPER_QPOS_CLOSED + assert mapping.normalize_grip_qpos(0.0) == 0.0 + assert abs(mapping.normalize_grip_qpos(closed / 2) - 0.5) < 1e-6 + assert abs(mapping.normalize_grip_qpos(closed) - 1.0) < 1e-6 + assert mapping.normalize_grip_qpos(closed * 2) == 1.0 # saturates, never exceeds 1 + # A two-finger qpos reads the first finger. + assert abs(mapping.normalize_grip_qpos(np.array([closed / 2, closed / 2])) - 0.5) < 1e-6 + + +def test_grip_command_to_actuator(): + assert mapping.grip_command_to_actuator(0.0) == mapping.ROBOTIQ_OPEN == 0.0 + assert mapping.grip_command_to_actuator(1.0) == mapping.ROBOTIQ_CLOSED == 255.0 + assert mapping.grip_command_to_actuator(0.5) == 127.5 # continuous — the codec owns binarization + assert mapping.grip_command_to_actuator(2.0) == 255.0 # clipped + + +def test_wire_command_joint_pos_passthrough(): + current = np.arange(mapping.NUM_ARM_JOINTS, dtype=np.float32) + q = np.full(mapping.NUM_ARM_JOINTS, 0.3, dtype=np.float32) + out = mapping.wire_command_to_arm_action( + {protocol.COMMAND_TYPE: protocol.JOINT_POS, protocol.COMMAND_JOINT_POS: q}, current + ) + assert out.dtype == np.float32 and out.shape == (mapping.NUM_ARM_JOINTS,) + assert np.array_equal(out, q) # absolute target, independent of the measured joints + + +def test_wire_command_joint_vel_integrates_onto_measured(): + current = np.arange(mapping.NUM_ARM_JOINTS, dtype=np.float32) + dq = np.full(mapping.NUM_ARM_JOINTS, 0.1, dtype=np.float32) + out = mapping.wire_command_to_arm_action( + {protocol.COMMAND_TYPE: protocol.JOINT_VEL, protocol.COMMAND_JOINT_VEL: dq}, current + ) + assert np.allclose(out, current + dq) # positronic applies JointDelta as q + dq + + +def test_wire_command_hold_recommands_measured(): + current = np.linspace(-1.0, 1.0, mapping.NUM_ARM_JOINTS, dtype=np.float32) + out = mapping.wire_command_to_arm_action({protocol.COMMAND_TYPE: protocol.HOLD}, current) + assert np.array_equal(out, current) + + +def test_wire_command_joint_count_mismatch_raises(): + current = np.zeros(mapping.NUM_ARM_JOINTS, dtype=np.float32) + with pytest.raises(ValueError): + mapping.wire_command_to_arm_action( + {protocol.COMMAND_TYPE: protocol.JOINT_VEL, protocol.COMMAND_JOINT_VEL: np.zeros(6, dtype=np.float32)}, + current, + ) + + +def test_wire_command_cartesian_unsupported(): + current = np.zeros(mapping.NUM_ARM_JOINTS, dtype=np.float32) + with pytest.raises(ValueError): + mapping.wire_command_to_arm_action( + {protocol.COMMAND_TYPE: protocol.CARTESIAN, protocol.COMMAND_POSE: np.zeros(12)}, current + ) + + +def test_camera_key_default_and_variant_precedence(): + default = mapping.MOLMO_WRIST_CAMERA + variants = mapping.MOLMO_WRIST_CAMERA_VARIANTS + # Default present, no variant -> the default. + assert mapping.resolve_camera_key({default: 1}, default, variants) == default + # A benchmark-variant key present wins over the default (matches molmo_spaces pi_policy precedence). + both = {default: 1, variants[0]: 1} + assert mapping.resolve_camera_key(both, default, variants) == variants[0] + # Variant only (default absent) -> the variant. + assert mapping.resolve_camera_key({variants[0]: 1}, default, variants) == variants[0] + + +def test_camera_key_explicit_nondefault_read_as_is(): + # An explicitly configured non-default key is read as-is, never shadowed by a variant decoy. + obs = {'my_cam': 1, mapping.MOLMO_WRIST_CAMERA_VARIANTS[0]: 1} + assert mapping.resolve_camera_key(obs, 'my_cam') == 'my_cam' + + +def test_camera_key_miss_raises(): + with pytest.raises(KeyError): + mapping.resolve_camera_key({'other': 1}, mapping.MOLMO_WRIST_CAMERA) + + +def _episodes(*horizons_sec): + return [ + types.SimpleNamespace(task={} if sec is None else {mapping.MOLMO_TASK_HORIZON_SEC: sec}) for sec in horizons_sec + ] + + +def test_task_horizon_reads_the_benchmark_task_dict(): + # Where MolmoSpaces' benchmark generator writes it, and where determine_task_horizon reads it. + assert mapping.resolve_task_horizon_steps(_episodes(30, 30), 66.0) == 455 # round(30 * 1000 / 66) + + +def test_task_horizon_missing_raises(): + # The raise mirrors upstream's resolver. + with pytest.raises(ValueError): + mapping.resolve_task_horizon_steps(_episodes(30, None), 66.0) + + +def test_task_horizon_disagreeing_across_episodes_raises(): + # The horizon belongs to the benchmark, so one run has one; upstream refuses the same manifest. + with pytest.raises(ValueError, match='inconsistent'): + mapping.resolve_task_horizon_steps(_episodes(20, 30), 66.0) + + +def test_task_horizon_non_positive_raises(): + # A zero or negative span is no horizon at all; the override path already refuses one below a step. + with pytest.raises(ValueError, match='non-positive'): + mapping.resolve_task_horizon_steps(_episodes(0), 66.0) + with pytest.raises(ValueError, match='non-positive'): + mapping.resolve_task_horizon_steps(_episodes(-5), 66.0) + + +def test_task_horizon_rounding_below_one_step_raises(): + # 0.03s of a 66ms period rounds to 0 steps, which would expire the episode before its first action. + with pytest.raises(ValueError, match='rounds to 0 steps'): + mapping.resolve_task_horizon_steps(_episodes(0.03), 66.0) + + +def test_task_horizon_override_wins(): + # An explicit override pins the horizon, beating the benchmark field (mirrors --task_horizon_steps), and lets + # a benchmark that declares none, or disagrees, still resolve. + assert mapping.resolve_task_horizon_steps(_episodes(20), 66.0, override_steps=500) == 500 + assert mapping.resolve_task_horizon_steps(_episodes(20, 30, None), 66.0, override_steps=455) == 455 + + +def test_exterior_camera_variants_cover_light_randomization_and_randcam(): + # The default exterior mapping must resolve both benchmark exterior names: RandCam records + # randomized_zed2_analogue_1, not exo_camera_1. + default = mapping.MOLMO_EXTERIOR_CAMERA + variants = mapping.MOLMO_EXTERIOR_CAMERA_VARIANTS + for name in ('droid_shoulder_light_randomization', 'randomized_zed2_analogue_1'): + assert mapping.resolve_camera_key({name: 1}, default, variants) == name + + +def test_unpack_wire_pose_round_trips_translation_and_rotation(): + # The client encodes a pose as Transform3D.as_vector(ROTATION_MATRIX): translation, then R row-major. + rot = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) # +90 deg about z + pos, out = mapping.unpack_wire_pose(np.concatenate([[1.0, 2.0, 3.0], rot.reshape(-1)])) + assert np.array_equal(pos, [1.0, 2.0, 3.0]) + assert np.array_equal(out, rot) + + +def test_unpack_wire_pose_rejects_wrong_width(): + with pytest.raises(ValueError): + mapping.unpack_wire_pose(np.zeros(7)) # a quaternion-encoded pose is not the wire form + + +def test_compose_world_delta_adds_translation_and_left_multiplies_rotation(): + # World-frame convention: goal_pos = ee_pos + dpos, goal_ori = R(delta) @ ee_ori. Left-multiplication is + # what keeps the delta world-framed; composing in the body frame would rotate the translation too. + cur_rot = np.eye(3) + delta_rot = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) + pos, rot = mapping.compose_world_delta([1.0, 0.0, 0.0], cur_rot, [0.0, 2.0, 0.0], delta_rot) + assert np.allclose(pos, [1.0, 2.0, 0.0]) + assert np.allclose(rot, delta_rot) + + +def test_cartesian_command_resolves_through_the_supplied_ik(): + # env.py owns the solver (it needs the live model); mapping only routes the target into it. + solved = np.arange(mapping.NUM_ARM_JOINTS, dtype=np.float64) + seen = {} + + def ik(pos, rot): + seen['pos'], seen['rot'] = pos, rot + return solved + + rot = np.eye(3) + cmd = { + protocol.COMMAND_TYPE: protocol.CARTESIAN, + protocol.COMMAND_POSE: np.concatenate([[0.4, 0.1, 0.3], rot.reshape(-1)]), + } + out = mapping.wire_command_to_arm_action(cmd, np.zeros(mapping.NUM_ARM_JOINTS), ik=ik) + assert out.dtype == np.float32 and np.allclose(out, solved) + assert np.allclose(seen['pos'], [0.4, 0.1, 0.3]) and np.allclose(seen['rot'], rot) + + +def test_cartesian_delta_composes_onto_the_measured_eef_before_solving(): + # The delta is relative to the *measured* pose, so the solver must see the composed absolute target. + seen = {} + + def ik(pos, rot): + seen['pos'], seen['rot'] = pos, rot + return np.zeros(mapping.NUM_ARM_JOINTS) + + cmd = { + protocol.COMMAND_TYPE: protocol.CARTESIAN_DELTA, + protocol.COMMAND_DELTA: np.concatenate([[0.0, 0.1, 0.0], np.eye(3).reshape(-1)]), + } + mapping.wire_command_to_arm_action( + cmd, np.zeros(mapping.NUM_ARM_JOINTS), ik=ik, current_eef=(np.array([0.5, 0.0, 0.2]), np.eye(3)) + ) + assert np.allclose(seen['pos'], [0.5, 0.1, 0.2]) + + +def test_cartesian_without_an_ik_solver_raises(): + # A caller that holds no model cannot resolve a Cartesian target — fail loud rather than silently holding. + cmd = { + protocol.COMMAND_TYPE: protocol.CARTESIAN, + protocol.COMMAND_POSE: np.concatenate([np.zeros(3), np.eye(3).reshape(-1)]), + } + with pytest.raises(ValueError, match='ik solver'): + mapping.wire_command_to_arm_action(cmd, np.zeros(mapping.NUM_ARM_JOINTS)) + + +def test_unknown_command_names_the_canonical_contract(): + with pytest.raises(ValueError, match='cartesian'): # the message lists the contract the tag is not part of + mapping.wire_command_to_arm_action({protocol.COMMAND_TYPE: 'wrench'}, np.zeros(mapping.NUM_ARM_JOINTS)) + + +@pytest.mark.parametrize('command_type', protocol.CANONICAL_COMMAND_TYPES) +def test_every_canonical_command_type_converts_to_joint_targets(command_type): + """The contract is total, so every canonical type converts to the joint targets MolmoSpaces natively steps. + This is the model-free half of that property — the routing, through a stub solver; ``validate.py`` drives + the same types through the real IK against a live scene.""" + pose = np.concatenate([np.zeros(3), np.eye(3).reshape(-1)]) + payload = { + protocol.JOINT_POS: {protocol.COMMAND_JOINT_POS: np.zeros(mapping.NUM_ARM_JOINTS)}, + protocol.JOINT_VEL: {protocol.COMMAND_JOINT_VEL: np.zeros(mapping.NUM_ARM_JOINTS)}, + protocol.HOLD: {}, + protocol.CARTESIAN: {protocol.COMMAND_POSE: pose}, + protocol.CARTESIAN_DELTA: {protocol.COMMAND_DELTA: pose}, + }[command_type] + + target = mapping.wire_command_to_arm_action( + {protocol.COMMAND_TYPE: command_type, **payload}, + np.zeros(mapping.NUM_ARM_JOINTS), + ik=lambda _pos, _rot: np.zeros(mapping.NUM_ARM_JOINTS), + current_eef=(np.zeros(3), np.eye(3)), + ) + + assert target.shape == (mapping.NUM_ARM_JOINTS,) + + +def test_episode_seed_prefers_the_override_then_the_spec_then_the_index(): + spec = types.SimpleNamespace(seed=7) + assert mapping.resolve_episode_seed(spec, 3, 99) == 99 + assert mapping.resolve_episode_seed(spec, 3) == 7 + assert mapping.resolve_episode_seed(types.SimpleNamespace(seed=None), 3) == 3 + assert mapping.resolve_episode_seed(types.SimpleNamespace(), 5) == 5 diff --git a/positronic/simulator/molmo_spaces/tests/test_replay.py b/positronic/simulator/molmo_spaces/tests/test_replay.py new file mode 100644 index 000000000..a7f849a43 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/test_replay.py @@ -0,0 +1,91 @@ +"""Deterministic replay of recorded pi05 rollouts against the MolmoSpaces integration. + +The parity check (``parity.py``) pins that positronic drives the sim exactly as MolmoSpaces' own stack does, +but it drives a scripted hold — no real policy behaviour ever reaches the sim. This replays the commands a +real pi05 rollout emitted, open-loop from the benchmark's own seed, and asserts the sim retraces the pinned +trajectory step for step. + +It reproduces because the recorded commands are absolute joint targets, so no observation feeds back into the +stream: the replay depends only on the sim rollout and the env-server path between positronic and it. MuJoCo +CPU physics is deterministic for a pinned build, and the render nondeterminism ``exp-004`` found never reaches +an open-loop replay, which reads no images. Same-host replay of the pinned ``_MOLMO_COMMIT`` reproduces the +fixture's ``sim_state`` *exactly* — zero deviation across every replayed step of both fixtures — so a +divergence here is a real change in the integration (command mapping, horizon, the wire, scene selection), +not sim noise. + +The rollout stops where the recording stops pinning it: an eval episode's command signals end before its +observations do, so the last few steps of every recording apply commands that were never written down +(internal#130). Those steps are excluded, which is why this asserts trajectory fidelity and not the recorded +``eval.success`` — success lands inside that unwritten tail. Closing internal#130 is what would let the +verdict itself be replayed. + +Fixtures (``replay_ep*.npz``, from ``make_replay_fixture.py``) hold only the commands, the grip and +``sim_state`` checkpoints — never the videos. The commands come from the recording; the checkpoints are taken +by replaying them, so what this pins is the integration's own trajectory under a real policy's commands, and +they are regenerated together. The benchmark is a multi-hundred-MB asset pack that cannot be committed, so +the test skips unless this box has it. + +Run on a box with the asset packs (a GPU-less one uses mesa software EGL):: + + MLSPACES_ASSETS_DIR=... MUJOCO_GL=egl EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1 \ + uv run --locked pytest positronic/simulator/molmo_spaces/tests/test_replay.py --no-cov +""" + +import os +from pathlib import Path + +import numpy as np +import pytest + +from positronic.simulator.molmo_spaces import mapping +from positronic.simulator.molmo_spaces.tests import make_replay_fixture as fixture_fields +from positronic.simulator.molmo_spaces.tests.make_replay_fixture import replay_commands + +FIXTURES = sorted(Path(__file__).parent.glob('replay_ep*.npz')) + +# Replay on the recording's own host reproduces every checkpoint bit-for-bit, so this budget is for the float +# drift a different CPU can introduce. It sits orders of magnitude below the ~centimetre scale that decides a +# pick, so it cannot mask a real regression; a cross-host run that exceeds it is worth investigating rather +# than widening. +SIM_STATE_TOL = 1e-6 + + +def _benchmark_dir(benchmark_path: str) -> Path: + """The benchmark the fixture was recorded against, resolved in this box's asset packs. + + The fixture pins the path from ``benchmarks/`` down, so the lookup is exact rather than a name search: + the same benchmark name sits under every scene dataset with different episodes, and resolving to the + wrong one would replay these commands against a different scene. + """ + assets = os.environ.get(mapping.ASSETS_DIR_ENV) + if not assets: + pytest.skip(f'{mapping.ASSETS_DIR_ENV} is unset — MolmoSpaces asset packs are needed to replay') + benchmark_dir = Path(assets) / mapping.ASSETS_BENCHMARKS_DIR / benchmark_path + if not (benchmark_dir / mapping.MOLMO_BENCHMARK_MANIFEST).is_file(): + pytest.skip(f'{benchmark_dir} is absent — this asset pack cannot replay the fixture') + return benchmark_dir + + +@pytest.mark.parametrize('fixture_path', FIXTURES, ids=lambda path: path.stem) +def test_recorded_rollout_replays_the_pinned_trajectory(fixture_path: Path): + fixture = np.load(fixture_path, allow_pickle=False) + commands, grips = fixture[fixture_fields.FIELD_COMMANDS], fixture[fixture_fields.FIELD_GRIPS] + episode_index = int(fixture[fixture_fields.FIELD_EPISODE_INDEX]) + benchmark_dir = _benchmark_dir(str(fixture[fixture_fields.FIELD_BENCHMARK_PATH])) + states = replay_commands(benchmark_dir, episode_index, commands, grips) + + # The sim must not end the trial inside the replayed prefix: the recording ran every one of these steps, + # so an early terminal means the integration now scores or expires the episode differently. + assert len(states) == len(commands), ( + f'replay of episode {episode_index} terminated after {len(states)} of {len(commands)} recorded steps ' + f'(its {int(fixture[fixture_fields.FIELD_UNREPLAYABLE_TAIL_STEPS])} unrecorded tail steps are excluded)' + ) + + # Checkpoints along the way, not just the end state: drift shows up long before it would flip a verdict. + checkpoint_steps = fixture[fixture_fields.FIELD_CHECKPOINT_STEPS] + for step, pinned in zip(checkpoint_steps, fixture[fixture_fields.FIELD_CHECKPOINT_SIM_STATE], strict=True): + replayed = states[int(step) - 1] # states[i] holds step i + 1; the fixture indexes steps from 1 + deviation = float(np.max(np.abs(replayed - pinned))) + assert deviation <= SIM_STATE_TOL, ( + f'sim_state diverged by {deviation:.3e} at step {step} of episode {episode_index}' + ) diff --git a/positronic/simulator/molmo_spaces/tests/test_tasks.py b/positronic/simulator/molmo_spaces/tests/test_tasks.py new file mode 100644 index 000000000..94e3a2926 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/test_tasks.py @@ -0,0 +1,95 @@ +"""Which episodes a MolmoSpaces eval runs: the adapter maps the env's records, the config sweeps them. + +``RemoteEnvControlSystem.tasks`` is stubbed, since a real episode list needs the MolmoSpaces venv; +``tests/e2e.py`` runs the same command against the real benchmark. +""" + +import logging +from typing import Any + +import pytest + +from positronic.cfg.eval.sim.molmo import _TIMEOUT_MARGIN_SEC, benchmark +from positronic.eval import keys as eval_keys +from positronic.simulator.env_server.proxy import RemoteEnvControlSystem +from positronic.simulator.molmo_spaces import keys as molmo_keys + +_HORIZON_SEC = 30.0 + + +@pytest.fixture +def asked(monkeypatch) -> list[Any]: + """The specs the eval sends the proxy; every spec is answered with two episodes of one horizon.""" + specs: list[Any] = [] + + def tasks(self, selection: Any) -> list[dict[str, Any]]: + specs.append(selection) + return [ + { + eval_keys.TASK: 'put the banana in the bowl', + molmo_keys.EPISODE_INDEX: 0, + molmo_keys.TASK_HORIZON: _HORIZON_SEC, + }, + { + eval_keys.TASK: 'put the mug on the shelf', + molmo_keys.EPISODE_INDEX: 1, + molmo_keys.TASK_HORIZON: _HORIZON_SEC, + }, + ] + + monkeypatch.setattr(RemoteEnvControlSystem, 'tasks', tasks) + return specs + + +def test_the_env_answers_which_episodes_the_sweep_runs(asked): + """The sweep is asked for when the run starts; an unset seed leaves the episode's own seed in force.""" + ev = benchmark.override(benchmark_dir='unused', trial_count=2).instantiate() + assert asked == [] + + trials = list(ev.tasks()) + + assert asked == [{}] + scenes = [trial.prepare_args[eval_keys.SCENE] for trial in trials] + assert [scene[molmo_keys.EPISODE_INDEX] for scene in scenes] == [0, 0, 1, 1] + assert all(eval_keys.SEED not in scene for scene in scenes) + assert [trial.meta[eval_keys.TRIAL_INDEX] for trial in trials] == [0, 1, 2, 3] + + +def test_an_episode_selection_rides_the_spec(asked): + ev = benchmark.override(benchmark_dir='unused', episodes=[0, 1]).instantiate() + ev.tasks() + assert asked == [{'episodes': [0, 1]}] + + +def test_an_explicit_seed_sweeps_each_episode(asked): + ev = benchmark.override(benchmark_dir='unused', seed=5, trial_count=2).instantiate() + scenes = [trial.prepare_args[eval_keys.SCENE] for trial in ev.tasks()] + assert [scene[eval_keys.SEED] for scene in scenes] == [5, 6, 5, 6] + + +def test_a_non_positive_trial_count_is_refused(): + with pytest.raises(ValueError, match='trial_count'): + benchmark.override(benchmark_dir='unused', trial_count=0).instantiate() + + +def test_timeout_defaults_to_the_benchmark_horizon_plus_a_margin(asked): + ev = benchmark.override(benchmark_dir='unused').instantiate() + trials = list(ev.tasks()) + assert [trial.timeout_sec for trial in trials] == [_HORIZON_SEC + _TIMEOUT_MARGIN_SEC] * 2 + + +def test_explicit_timeout_can_only_lower_the_deadline(asked, caplog): + with caplog.at_level(logging.WARNING): + short = benchmark.override(benchmark_dir='unused', timeout=20.0).instantiate().tasks() + long = benchmark.override(benchmark_dir='unused', timeout=999.0).instantiate().tasks() + assert short[0].timeout_sec == 20.0 + assert long[0].timeout_sec == _HORIZON_SEC + _TIMEOUT_MARGIN_SEC + assert len(caplog.records) == 2, 'both directions differ from the backstop, so both warn' + + +def test_timeout_matching_the_backstop_is_silent(asked, caplog): + backstop = _HORIZON_SEC + _TIMEOUT_MARGIN_SEC + with caplog.at_level(logging.WARNING): + trials = benchmark.override(benchmark_dir='unused', timeout=backstop).instantiate().tasks() + assert trials[0].timeout_sec == backstop + assert not caplog.records diff --git a/positronic/simulator/molmo_spaces/tests/validate.py b/positronic/simulator/molmo_spaces/tests/validate.py new file mode 100644 index 000000000..6cd430d25 --- /dev/null +++ b/positronic/simulator/molmo_spaces/tests/validate.py @@ -0,0 +1,160 @@ +"""Validate the MolmoSpaces rig's command transforms against the live sim. + +MolmoSpaces' Franka runs a joint-position controller, so every other command reaches it only through the +conversions in ``mapping``, the Cartesian pair among them through the differential IK in ``env.py``. That +solver is arithmetic over the live MuJoCo model, which no unit test can reach (``mapping``'s tests cover the +routing with a stub solver, not the kinematics), so it is checked here against a real benchmark scene — the +same shape of check ``simulator/libero/validate.py`` runs for the LIBERO rig. + +Four properties. The kinematic three read the arm's grasp site (the frame the env observes in, so command and +observation share a frame); the fourth is the adoption's coverage of the command contract: + +- **FK identity** — the scratch-``MjData`` recompute of the measured joints reproduces the live grasp-site read, + confirming the scratch evaluation is seeded correctly and reads the same frame. +- **IK round-trip** — for reachable targets sampled by perturbing the measured joints, ``_fk(_ik(pose))`` + recovers the pose. This is the property a Cartesian policy depends on; the sampling stays near the measured + configuration so every target is reachable and the check tests the solver, not the workspace. +- **Cartesian hold** — commanding the pose the arm already holds resolves to the joints it already holds, which + is what makes an absolute Cartesian setpoint stable when a policy re-sends it. +- **Command contract** — every canonical command type converts to joint targets through the live IK. The + contract is total, so this is where the adoption's coverage of it is verified rather than asserted. + +Runs in MolmoSpaces' venv, flat off ``PYTHONPATH`` like ``parity_native.py`` (positronic-free: ``molmo_spaces`` +plus this package's ``mapping``/``env``), so positronic's interpreter cannot import it. Needs the asset packs +(``MLSPACES_ASSETS_DIR``) and a GL backend (``MUJOCO_GL``; a GPU-less box uses mesa software EGL). Launch it the +way ``parity.py`` launches the native reference — the venv python under ``launcher.molmo_subprocess_env()``:: + + uv run --locked python -c " + import subprocess + from positronic.simulator.molmo_spaces import launcher + subprocess.run([str(launcher.ensure_molmo_venv()), + 'positronic/simulator/molmo_spaces/tests/validate.py', '--benchmark_dir', ''], + env=launcher.molmo_subprocess_env(), check=True)" +""" + +# The flat ``protocol`` module resolves only inside MolmoSpaces' own venv, where this validation runs; pyright +# checks it against positronic's deps, which cannot see it. That import carries its own +# ``reportMissingImports`` suppression, so one that should resolve here still fails the check. + +import argparse +from pathlib import Path + +# env.py sets MUJOCO_GL and installs the CGL stub at import, GL-safely pulling in the molmo_spaces stack — so +# import it before any other molmo_spaces import. Reaching into its private ``_fk``/``_ik`` is the point: this +# validates that exact solver, not a re-derivation of it. +import env # noqa: E402 +import mapping # noqa: E402 -- positronic-free wire mappings, on PYTHONPATH +import numpy as np +import protocol # pyright: ignore[reportMissingImports] -- flat on PYTHONPATH beside ``server``, see ``launcher`` + +# Sampled targets perturb each measured joint by up to this much (radians): far enough that the solver has real +# work to do, near enough that every target stays reachable and away from the limits. +_JOINT_JITTER = 0.1 +_IK_SAMPLES = 16 +# The solver iterates to _IK_TOL on the 6-vector error; these are the per-component budgets that implies. +_POS_ATOL = 1e-3 # metres +_ORI_ATOL = 1e-2 # radians +# The live site is read after the sim has stepped, so it carries residual motion the scratch recompute of the +# same joints cannot reproduce exactly; float precision, not float64, is the right bar for the identity. +_FK_ATOL = 1e-5 +# The step the relative commands carry: small enough that the target stays reachable from the measured +# configuration, large enough that the conversion is not the identity. +_DELTA_POS = 0.01 # metres +_DELTA_Q = 0.01 # radians + + +def _ori_error(target_rot: np.ndarray, rot: np.ndarray) -> float: + return float(np.linalg.norm(env._pose_error(np.zeros(3), target_rot, np.zeros(3), rot)[3:])) + + +def _check_fk_identity(sim_env) -> None: + pos_fk, rot_fk = sim_env._fk(sim_env._measured_arm_q()) + pos_live, rot_live = sim_env._measured_eef_pose() + assert np.allclose(pos_fk, pos_live, atol=_FK_ATOL), f'fk pos {pos_fk} vs live {pos_live}' + assert np.allclose(rot_fk, rot_live, atol=_FK_ATOL), f'fk rot {rot_fk} vs live {rot_live}' + print(f' fk identity: OK (matches the grasp-site read, atol {_FK_ATOL})') + + +def _check_ik_roundtrip(sim_env) -> None: + measured = np.asarray(sim_env._measured_arm_q(), dtype=np.float64) + for _ in range(_IK_SAMPLES): + target_pos, target_rot = sim_env._fk(measured + np.random.uniform(-_JOINT_JITTER, _JOINT_JITTER, measured.size)) + pos, rot = sim_env._fk(sim_env._ik(target_pos, target_rot)) + ang = _ori_error(target_rot, rot) + assert np.allclose(pos, target_pos, atol=_POS_ATOL), f'ik pos off by {pos - target_pos}' + assert ang < _ORI_ATOL, f'ik orientation off by {ang} rad' + print(f' ik round-trip: OK ({_IK_SAMPLES} reachable targets, pos<{_POS_ATOL} m, ori<{_ORI_ATOL} rad)') + + +def _check_cartesian_command_is_a_noop_at_the_measured_pose(sim_env) -> None: + # Commanding the pose the arm already holds must resolve to (essentially) the joints it already holds — + # the property that makes an absolute Cartesian setpoint stable when a policy re-sends it. + pos, rot = sim_env._measured_eef_pose() + command = {protocol.COMMAND_TYPE: protocol.CARTESIAN, protocol.COMMAND_POSE: np.concatenate([pos, rot.reshape(-1)])} + target = env.mapping.wire_command_to_arm_action(command, sim_env._measured_arm_q(), ik=sim_env._ik) + drift = np.abs(np.asarray(target, dtype=np.float64) - np.asarray(sim_env._measured_arm_q(), dtype=np.float64)) + assert drift.max() < 1e-3, f'holding the measured pose moved the joints by {drift.max()} rad' + print(f' cartesian hold: OK (max joint drift {drift.max():.2e} rad)') + + +def _check_every_canonical_command_converts(sim_env) -> None: + """Drive every canonical command type through the real conversion — the adoption's whole obligation. + + The command contract is total: MolmoSpaces' Franka natively takes joint-position targets alone, so each + canonical type has to reach it as one. ``mapping``'s unit tests pin that routing against a stub solver; + here each type runs through the live IK and the measured pose, so a type the rig cannot actually resolve + fails. The iteration is over ``protocol.CANONICAL_COMMAND_TYPES`` rather than a list written here, so a + type added to the wire fails this check until the rig converts it. + """ + measured = np.asarray(sim_env._measured_arm_q(), dtype=np.float64) + pos, rot = sim_env._measured_eef_pose() + identity_rot = np.eye(3).reshape(-1) + payloads = { + protocol.CARTESIAN: {protocol.COMMAND_POSE: np.concatenate([pos, rot.reshape(-1)])}, + protocol.CARTESIAN_DELTA: {protocol.COMMAND_DELTA: np.concatenate([np.full(3, _DELTA_POS), identity_rot])}, + protocol.JOINT_POS: {protocol.COMMAND_JOINT_POS: measured}, + protocol.JOINT_VEL: {protocol.COMMAND_JOINT_VEL: np.full(measured.size, _DELTA_Q)}, + protocol.HOLD: {}, + } + unmapped = [kind for kind in protocol.CANONICAL_COMMAND_TYPES if kind not in payloads] + assert not unmapped, f'the rig has no wire payload for canonical command types {unmapped}' + + for kind in protocol.CANONICAL_COMMAND_TYPES: + command = {protocol.COMMAND_TYPE: kind, **payloads[kind]} + target = env.mapping.wire_command_to_arm_action( + command, measured, ik=sim_env._ik, current_eef=sim_env._measured_eef_pose() + ) + target = np.asarray(target, dtype=np.float64) + assert target.shape == measured.shape, f'{kind}: joint targets {target.shape} vs measured {measured.shape}' + assert np.all(np.isfinite(target)), f'{kind}: non-finite joint targets {target}' + covered = ', '.join(protocol.CANONICAL_COMMAND_TYPES) + print(f' command contract: OK ({covered} -> {measured.size} joint targets)') + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate the MolmoSpaces rig's Cartesian command transform.") + parser.add_argument('--benchmark_dir', required=True, help='dir containing benchmark.json') + parser.add_argument('--episode_index', type=int, default=0) + parser.add_argument('--seed', type=int, default=0) + # These checks are pure kinematics — they never step the sim, so the episode horizon is irrelevant to them. + # The override keeps a benchmark that declares no ``task_horizon_sec`` (the checked-in test benchmark is one) + # usable as a scene here, instead of failing the build over a field this run never reads. + parser.add_argument('--task_horizon_steps', type=int, default=1) + args = parser.parse_args() + np.random.seed(0) + + sim_env = env.MolmoSpacesEnv(Path(args.benchmark_dir), args.task_horizon_steps) + sim_env.reset({mapping.TOKEN_EPISODE_INDEX: args.episode_index, mapping.TOKEN_SEED: args.seed}) + print(f'molmo_spaces episode {args.episode_index} (seed {args.seed})') + try: + _check_fk_identity(sim_env) + _check_ik_roundtrip(sim_env) + _check_cartesian_command_is_a_noop_at_the_measured_pose(sim_env) + _check_every_canonical_command_converts(sim_env) + finally: + sim_env.close() + print('all checks passed') + + +if __name__ == '__main__': + main() diff --git a/positronic/simulator/robolab/adapter.py b/positronic/simulator/robolab/adapter.py index 4fc47d876..d0743722c 100644 --- a/positronic/simulator/robolab/adapter.py +++ b/positronic/simulator/robolab/adapter.py @@ -14,6 +14,7 @@ from positronic import geom, keys from positronic.drivers.roboarm.models import DROID_EE_FRAME from positronic.eval import keys as eval_keys +from positronic.simulator.env_server import protocol from positronic.simulator.env_server.adapter import WireCommandAdapter from positronic.simulator.mujoco.sim import MujocoFrankaState from positronic.simulator.robolab import keys as robolab_keys @@ -68,4 +69,4 @@ def privileged(self, raw_obs: dict[str, Any]) -> dict[str, Any]: def terminal(self, result: dict[str, Any]) -> dict[str, Any] | None: # ``done`` covers termination and truncation, so the trial ends either way; ``success`` is True only # when the task's success condition fired, keeping timeouts honest. - return {eval_keys.SUCCESS: bool(result['success'])} if result['done'] else None + return {eval_keys.SUCCESS: bool(result[protocol.FRAME_SUCCESS])} if result[protocol.FRAME_DONE] else None diff --git a/positronic/tests/test_keys.py b/positronic/tests/test_keys.py index 10a32366f..053247f4b 100644 --- a/positronic/tests/test_keys.py +++ b/positronic/tests/test_keys.py @@ -4,6 +4,7 @@ from positronic import keys from positronic.eval import keys as eval_keys from positronic.simulator.libero import keys as libero_keys +from positronic.simulator.molmo_spaces import keys as molmo_keys from positronic.simulator.robolab import keys as robolab_keys # Namespaced raw wire keys that denote an observation signal, and the keys a trial records — the params it @@ -33,6 +34,8 @@ libero_keys.SETTLE_STEPS, eval_keys.TASK, robolab_keys.INSTRUCTION_TYPE, + molmo_keys.EPISODE_INDEX, + molmo_keys.TASK_HORIZON, keys.OBS_TIME_NS, keys.WALL_TIME_NS, } diff --git a/positronic/vendors/openpi/codecs.py b/positronic/vendors/openpi/codecs.py index 5979b4f60..83ca90dbf 100644 --- a/positronic/vendors/openpi/codecs.py +++ b/positronic/vendors/openpi/codecs.py @@ -53,7 +53,7 @@ def __init__( 'observation.state': self._derive_state, 'observation.images.left': partial(self._derive_image, wrist_camera), 'observation.images.side': partial(self._derive_image, exterior_camera), - 'task': Get(keys.TASK, ''), + keys.TASK: Get(keys.TASK, ''), } state_dim = sum(state_features.values()) @@ -128,7 +128,8 @@ def observation(state_features: dict[str, int], exterior_camera: str, wrist_came # Pretrained DROID models read joints and gripper as separate observation keys and the language -# prompt under `prompt` (see openpi `droid_policy.DroidInputs`). +# prompt under `prompt` (see openpi `droid_policy.DroidInputs`), lowercased — the checkpoints were +# trained on lowercased language and MolmoSpaces' Pi baseline normalizes the same way. droid_obs = cfn.Config( GenericObservationCodec, state={openpi.JOINT_POSITION: {keys.JOINTS: 7}, openpi.GRIPPER_POSITION: {keys.GRIP: 1}}, @@ -137,6 +138,7 @@ def observation(state_features: dict[str, int], exterior_camera: str, wrist_came openpi.EXTERIOR_IMAGE_LEFT: (keys.EXTERIOR_IMAGE, (224, 224)), }, task_field=openpi.PROMPT, + lowercase_task=True, ) ee = codecs.compose.override(obs=ee_obs, action=codecs.absolute_pos_action) diff --git a/pyproject.toml b/pyproject.toml index 60ee3692c..9f5e118e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,14 +267,16 @@ include = ["client", "pimm", "positronic"] # mujoco ships no `.pyi` and re-exports from binary extension modules, so its whole API reads as # attribute errors without these. Regenerate with utilities/generate_mujoco_stubs.py. stubPath = "stubs" -# vendors/* pull untyped ML libraries; they are typed per-vendor in a later pass. +# vendors/* pull untyped ML libraries; they are typed per-vendor in a later pass. molmo_spaces/env.py stays +# checked even though it runs in MolmoSpaces' own venv: its foreign imports are suppressed in-file by a +# scoped `reportMissingImports` plus targeted per-line ignores, so every other diagnostic still fires. exclude = [ "**/.venv", "**/node_modules", "positronic/vendors", ] -# `exclude` only drops vendors as analysis roots; a checked module that imports them -# still pulls their diagnostics. `ignore` suppresses those transitive vendor errors too. +# `exclude` only drops these as analysis roots; a checked module that imports them still pulls their +# diagnostics. `ignore` suppresses those transitive errors too. ignore = ["positronic/vendors"] # ``robolab/env.py`` runs in RoboLab's isolated interpreter with the env-server modules (``server``, @@ -301,4 +303,7 @@ positronic = [ "drivers/**/*.urdf", "server/static/**", "server/templates/**", + # The launcher feeds this to `uv pip install -c` when it builds the MolmoSpaces venv, so a packaged + # install needs it on disk just as much as a checkout does. + "simulator/molmo_spaces/molmo_constraints.txt", ]