diff --git a/README.md b/README.md index 722b80f6..b6663964 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,23 @@ See [examples/README.md](examples/README.md) for the example runner and baseline The published site provides the same English and Chinese information architecture. Model-specific instructions are generated from registered example READMEs under **Models & Cookbook**. The build also publishes `/TeleFuser/llms.txt`. +- [docs/en/blog/index.md](docs/en/blog/index.md): optimization design, profiling evidence, results, and related work +- [docs/en/service.md](docs/en/service.md): REST serving, task APIs, OpenAI-compatible APIs +- [docs/en/stream_server.md](docs/en/stream_server.md): LiveKit streaming, session APIs, data topics, and deployment +- [docs/en/stream_scheduler.md](docs/en/stream_scheduler.md): actor-based stage scheduling, backpressure, lifecycle, metrics, and LingBot placement +- [docs/en/vla.md](docs/en/vla.md): semantic VLA action contracts, embodiment mapping, sessions, runtime safety, and simulator adapters +- [docs/en/parallel.md](docs/en/parallel.md): distributed inference architecture +- [docs/en/communication.md](docs/en/communication.md): collectives, CUDA IPC, synchronization, and transport lifecycle +- [docs/en/latent_cache.md](docs/en/latent_cache.md): CacheSeek latent cache integration +- [docs/en/feature_cache.md](docs/en/feature_cache.md): `AdaTaylorCache` +- [docs/en/model_loading.md](docs/en/model_loading.md): model loading patterns +- [docs/en/attention.md](docs/en/attention.md): attention backends and configuration +- [docs/en/torch_compile_compatibility.md](docs/en/torch_compile_compatibility.md): compile-related constraints +- [docs/en/adding_new_model.md](docs/en/adding_new_model.md): integrating new models +- [docs/en/adding_new_example.md](docs/en/adding_new_example.md): authoring examples and pipeline contracts +- [docs/en/abot_world.md](docs/en/abot_world.md): ABot-World single-GPU interactive pipeline, controls, and tests +- [examples/lingbot_vla_v2/README.md](examples/lingbot_vla_v2/README.md): LingBot-VLA v2 inference, structured service, parity, and validation boundaries +- [examples/swiftvr/README.md](examples/swiftvr/README.md): SwiftVR checkpoint loading, streaming usage, performance, and acceleration options ## Known Limitations diff --git a/docs/en/vla.md b/docs/en/vla.md new file mode 100644 index 00000000..4801f03f --- /dev/null +++ b/docs/en/vla.md @@ -0,0 +1,173 @@ +# VLA Action Integration + +TeleFuser keeps model lifecycle, GPU placement, request cancellation, and monitoring in the existing pipeline and +service layers. VLA integration adds semantic action boundaries around those facilities; it does not create a second +model server. + +## Data Flow + +```text +robot observation + -> EmbodimentAdapter.encode_observation + -> VLAPolicy.predict + -> ModelActionChunk + -> EmbodimentAdapter.decode_actions + -> RobotActionChunk + -> ChunkExecutor (horizon, age, safety) + -> SimulatorAdapter.execute +``` + +Tensor width is never used to infer action meaning. Every chunk carries an `ActionSpaceSpec` with representation, +ordered dimension names, units, frame, control rate, and normalization identity. Session opening rejects a policy and +embodiment pair whose action spaces are semantically incompatible. + +Each embodiment also declares an `ObservationSpaceSpec` with ordered state dimensions, named image dtype, layout, +and channel requirements, plus the timestamp unit and clock domain. Image height and width remain dynamic so +simulators can choose their native camera resolution. + +The public modules are: + +- `telefuser.vla.contracts`: observations, requests, capabilities, action spaces, and model/robot chunks. +- `telefuser.vla.policy`: the model-facing `VLAPolicy` protocol. +- `telefuser.vla.embodiment`: observation encoding and model-to-robot action mapping. +- `telefuser.vla.registry`: explicit registration of already-loaded policies and embodiments. +- `telefuser.vla.session`: transport-neutral OPEN, PREDICT, RESET, and CLOSE lifecycle. +- `telefuser.vla.serialization`: versioned JSON/Base64 wire formats for action and observation spaces, observations, + and chunks. +- `telefuser.vla.runtime`: scheduling, deterministic chunk state, action trimming, age checks, and safety policies. +- `telefuser.integrations.sim`: simulator protocol and the dependency-free RoboTwin callback adapter. +- `telefuser.service.vla_session`: the additive generic VLA WebSocket application factory. +- `telefuser.service.vla_replica`: worker-local OPEN, PREDICT, RESET, and CLOSE dispatch for pipeline replicas. +- `telefuser.client.AsyncVLAClient`: remote session client with concurrent request correlation. + +## LingBot-VLA v2 Compatibility + +`LingBotVlaV2VLAPolicy` wraps `LingBotVlaV2Pipeline`; the pipeline's existing tensor input and return types are not +changed. It labels the normalized canonical `[T,55]` result as a `ModelActionChunk`. + +`RobotWinProfile` is the first `EmbodimentAdapter`. It preserves the existing normalization code and maps a canonical +chunk to an absolute-position `[H,14]` `RobotActionChunk` in the declared dual-arm joint order. Its model and robot +action spaces are exported as `LINGBOT_VLA_V2_ACTION_SPACE` and `ROBOTWIN_ACTION_SPACE`. + +The generic VLA WebSocket is the primary online integration path. The existing LingBot RoboTwin WebSocket endpoint +is a compatibility adapter for unmodified upstream `WebsocketClientPolicy` clients and calls the same policy, +embodiment, session, and runtime path internally. Its URL, MessagePack request fields, metadata frame, response fields, +reset behavior, and latest-wins +scheduler behavior remain compatible. The old model-specific scheduler import is retained as an alias to the common +runtime scheduler. + +The standalone endpoint owns one resident pipeline, so each connection session is inherently pinned to that policy +instance. The shared HTTP structured-task route remains unchanged and continues to return the existing canonical +JSON result. + +## Session Lifecycle + +Create and register loaded components, then open a typed session: + +```python +from telefuser.pipelines.lingbot_vla_v2 import LingBotVlaV2VLAPolicy, RobotWinProfile +from telefuser.vla import VLARegistry, VLASessionManager + +registry = VLARegistry() +registry.register_policy("lingbot-vla-v2", LingBotVlaV2VLAPolicy(pipeline)) +registry.register_embodiment("robotwin", RobotWinProfile.default()) + +sessions = VLASessionManager(registry) +session = sessions.open( + "connection-1", + model_id="lingbot-vla-v2", + embodiment_id="robotwin", + episode_id="episode-1", + execute_horizon=8, +) +robot_chunk = session.predict(robot_observation, "pick up the block", sequence_id=0, seed=7) +sessions.reset("connection-1") +sessions.close("connection-1") +``` + +Sequence IDs must strictly increase between resets. A policy result must echo the request episode, sequence, and +observation timestamp. `ChunkExecutor` can reject stale observations when `max_observation_age_ns` and a same-clock +`now_ns` are supplied. Network round-trip deadlines must still be enforced by the simulator client because monotonic +clocks on separate machines are not comparable. + +## Generic WebSocket Protocol + +`create_vla_session_app` exposes `/v1/vla/session` for an in-process registry, while +`create_pipeline_pool_vla_session_app` exposes the same protocol through worker-local sessions pinned by +`PipelinePool`. Neither factory changes an existing TeleFuser service route. The server +first sends `HELLO` with protocol version `1.0`, wire encoding, supported operations, registered component IDs, and +payload limits. A client then uses `OPEN`, ordered `PREDICT`, `RESET`, and `CLOSE`. `OPEN` can declare the expected +robot action and observation spaces; semantic incompatibility is rejected before inference. + +The protocol returns machine-readable error codes for malformed messages, unsupported versions, unknown components +or sessions, contract mismatches, superseded work, out-of-order or expired observations, request timeout, unavailable +sessions, and replica failure. Tensor payloads are dense, typed, shape-checked Base64 data inside bounded JSON +messages. This is the stable interoperability format; the LingBot-RoboTwin compatibility endpoint continues to use +its existing MessagePack format. New model and simulator integrations must target the generic protocol rather than +add behavior to the model-specific compatibility endpoint. + +`request_ttl_ms` is measured with server monotonic time and bounds inference delivery. Observation age is checked +separately using `observation_timestamp_ns` and `observation_clock_now_ns`, which must come from the same clock domain. +The server permits inference and observation delivery to overlap. Per session it runs one replica request and retains +only the newest waiting observation; replaced requests return `superseded`. `RESET` and `CLOSE` are barriers: they +invalidate earlier tickets and wait for any non-cancellable replica call before acknowledging. A discarded stateful +inference triggers policy reset before the retained observation runs. After a timed-out inference finishes, the +session is reset before it accepts more prediction work. + +## Replica Session Leases + +`PipelinePool.open_session` removes one healthy replica from the ordinary request pool and binds it to a +`session_id`. Calls through `acquire_session` always reach that replica and are serialized per session. +`close_session` returns a live replica to ordinary capacity. Replica exit removes the binding and raises +`ReplicaDeadError`; pool shutdown marks sessions as closing, waits for active session calls, clears every binding, and +then stops replicas. + +This API is additive. Existing HTTP work continues to use `PipelinePool.acquire()`, whose allocation behavior and +callers are unchanged. When `PipelinePool.start_all(..., vla_provider_factory="get_vla_provider")` is requested, each +worker constructs its own provider around the already-loaded pipeline and handles VLA lifecycle RPC. The provider is +optional; pipeline files and callers that do not enable it keep the original task and shutdown protocols. + +## Chunk State Machine + +`ActionChunkStateMachine` makes action lifecycle explicit: pending inference, ready buffer, execution, and terminal +executed, superseded, expired, or rejected states. It supports latest-sequence admission, separate observation-age and +network-TTL checks, configurable discard/retain handling after `execute_horizon`, reset, and deterministic hold/stop +states after disconnect. Stateful policies must provide a recovery callback; it is invoked when an inference that may +have mutated history is discarded. + +The generic server tracks inference admission through PENDING, READY, SUPERSEDED, EXPIRED, and REJECTED. It does not +claim that a returned action was executed. `SimulatorChunkRuntime` runs on the simulator client and owns READY, +EXECUTING, EXECUTED, HOLD, and STOP transitions while calling a `SimulatorAdapter` one action at a time. + +The bundled RoboTwin statistics do not declare an authoritative simulator control frequency, so both exported +LingBot/RoboTwin specs use `control_hz=None`. The remote simulator must resolve its actual control period rather than +guessing one in the inference process. + +## Simulator Boundary + +`RoboTwinSimulatorAdapter` has no RoboTwin, SAPIEN, Vulkan, or ROS dependency. The RTX-side process provides observe, +execute, and reset callbacks; the adapter validates semantics and converts each action to contiguous CPU `float32` +before invoking RoboTwin. This keeps simulator dependencies outside the H100 inference environment. + +Adding another simulator requires a new `SimulatorAdapter`; it must not add model-specific decoding. Adding another +robot requires an `EmbodimentAdapter`. Adding another VLA requires a `VLAPolicy` wrapper around its maintained +pipeline. + +## Service Boundary + +The generic WebSocket application remains an explicit factory rather than an automatically mounted `telefuser serve` +route. The LingBot example supplies a standalone server that starts `PipelinePool` with the optional VLA provider. +This keeps existing HTTP routing and every non-VLA pipeline unchanged. A real simulator still owns control timing, +actuator feedback, and verification that each returned action was actually applied. + +For LingBot-VLA v2, the standalone generic WebSocket is the primary continuous-control entrypoint. The direct Python +entrypoint remains the reference/offline baseline, and the native HTTP structured service remains for existing +TeleFuser callers. The RoboTwin MessagePack server is a legacy compatibility entrypoint only; it can be removed after +all upstream clients migrate to `/v1/vla/session`. + +The direct inference CLI and structured HTTP task remain separate because they provide reference and batch workflows, +not simulator session transports. The legacy RoboTwin MessagePack endpoint should be removed only after the RTX client +passes generic-protocol action delivery, reset, timeout, reconnect, and latest-wins parity checks. + +No new dependencies, environment variables, shared model configuration fields, CLI options, HTTP schemas, or +existing service routes are introduced by this semantic and transport layer. diff --git a/examples/lingbot_vla_v2/README.md b/examples/lingbot_vla_v2/README.md index e29c99db..bea2d696 100644 --- a/examples/lingbot_vla_v2/README.md +++ b/examples/lingbot_vla_v2/README.md @@ -25,10 +25,12 @@ The parity reference uses [Robbyant/lingbot-vla-v2](https://github.com/Robbyant/ | CUDA Graph | Supported | Dynamic eager prefix with an opt-in fixed-shape action-denoising graph | | Quantization | Partial | Profile-specific release status; see Configuration and Performance | | Native server API | Supported | Asynchronous structured task API and `TFClient` | -| RoboTwin policy protocol | Supported | Standalone persistent MessagePack WebSocket service | +| RoboTwin policy protocol | Compatibility | Legacy MessagePack endpoint for unmodified upstream clients | | Request replicas | Supported | One complete policy copy per GPU | | Single-policy FSDP, TP, or PP | Unsupported | The integration does not split one policy across GPUs | | RoboTwin action mapping | Supported | Unnormalizes canonical output to absolute-position `50 x 14` chunks | +| Semantic VLA contract | Supported | Model and robot action spaces are explicit; see [VLA Action Integration](../../docs/en/vla.md) | +| Generic VLA session server | Preferred | Versioned JSON WebSocket with replica-affine sessions | ## Requirements @@ -190,6 +192,19 @@ Compare a deterministic quantized capture with the corresponding TeleFuser BF16 ## Serving +The generic VLA session server is the only recommended online path for new simulator integrations. The four current +entrypoints share one `LingBotVlaV2Pipeline`; they are access modes, not separate model implementations: + +| Entry point | Role | Recommendation | +| --- | --- | --- | +| `lingbot_vla_v2_inference.py` | Direct Python reference and offline baseline | Keep for regression/debugging | +| `lingbot_vla_v2_native_service.py` via `telefuser serve` | Native HTTP structured requests | Keep for TeleFuser compatibility | +| `lingbot_vla_v2_vla_server.py` | Stateful generic VLA WebSocket | **Primary simulator path** | +| `lingbot_vla_v2_robotwin_server.py` | Upstream RoboTwin MessagePack compatibility | Legacy; remove after client migration | + +For a production or simulation deployment, start only the generic WebSocket server. The other entries remain for +baseline comparison and backward compatibility and do not change the model or session implementation. + Start the native structured service: ```bash @@ -228,11 +243,37 @@ CUDA_VISIBLE_DEVICES=0,1 TF_MODEL_ZOO_PATH=/path/to/model_zoo \ This creates one complete policy per GPU; it does not enable tensor or pipeline parallelism within a policy. -### RoboTwin Policy Server +### Generic VLA Session Server + +Start the preferred additive WebSocket service without mounting routes into `telefuser serve`: + +```bash +CUDA_VISIBLE_DEVICES=0 TF_MODEL_ZOO_PATH=/path/to/model_zoo \ + .venv-vla/bin/python -m examples.lingbot_vla_v2.lingbot_vla_v2_vla_server \ + --parallelism 1 --num-replicas 1 --host 0.0.0.0 --port 8000 +``` + +The service exposes `GET /healthz` and `/v1/vla/session`. Each `OPEN` reserves one pipeline replica for that session; +`PREDICT`, `RESET`, and `CLOSE` are sent to the same worker-local `VLASession`. Closing or disconnecting releases the +replica. The H100 cuDNN SDPA guard is applied inside each LingBot worker before model loading. + +This protocol returns semantic `RobotActionChunk` values. The simulator process should deserialize the chunk, pass it +to `SimulatorChunkRuntime`, and then execute it through its own `SimulatorAdapter`. The inference server tracks +pending/ready/expired inference only and does not infer simulator execution. The simulator side can call +`execute_ready_with_report()` (or `execute_ready_async()` from an async client) to obtain a small transport-neutral +report containing `executed`, `failed`, or `no_action`, the chunk sequence, and the executed step count. The report can +be forwarded on an existing control channel; it is intentionally not a new WebSocket operation. -The standalone policy server implements the persistent MessagePack WebSocket protocol used by the upstream -`WebsocketClientPolicy`. It is isolated from `telefuser serve`: no TeleFuser API routes, service schemas, or other -model integrations are changed. +For a minimal P0 check, use one fake adapter test for report status and one async test for non-blocking execution. A +long-running soak, cross-machine clock comparison, and full simulator episode are not required to validate this +runtime API. + +### Legacy RoboTwin Protocol Compatibility + +This compatibility server implements the persistent MessagePack WebSocket protocol used by the upstream +`WebsocketClientPolicy`. Use it only when the upstream client cannot yet consume the generic VLA session protocol. +New transport, scheduling, and simulator integrations belong on the generic VLA path. The compatibility endpoint is +isolated from `telefuser serve`: no TeleFuser API routes, service schemas, or other model integrations are changed. Install the protocol dependency in the TeleFuser inference environment: @@ -328,6 +369,14 @@ profile, and returns absolute-position actions in raw RoboTwin order. `--use-len start with 50 for upstream-equivalent open-loop execution. The adapter accepts episode reset messages but deliberately rejects runtime checkpoint switching. +Internally this compatibility endpoint uses the shared `VLAPolicy`, `EmbodimentAdapter`, `VLASessionManager`, and +`ChunkExecutor` contracts. The wire protocol and the existing `LingBotVlaV2Pipeline` API remain unchanged. The +declared control rate is intentionally unresolved until the remote RoboTwin loop supplies its actual frequency. + +Keep this endpoint until the RTX client has passed end-to-end action delivery, reset, timeout, reconnect, and +latest-wins parity checks through the generic protocol. After that migration, the compatibility module can be removed +without changing the model pipeline or the generic VLA service. + For split-machine deployment, run the model endpoint and the repository-owned XPolicyLab proxy on the H100 inference host. The proxy does not load a second model; it translates XPolicyLab observations to the direct TeleFuser protocol: @@ -370,6 +419,33 @@ The base checkpoint remains marked `unverified_official_6b_base`. This endpoint action mapping, transport, and simulator execution continuity; it does not establish RoboTwin task success without an embodiment-validated checkpoint. +## Local MuJoCo smoke simulation + +When the RTX RoboTwin workstation is unavailable, the generic VLA action path can be exercised locally with MuJoCo. +The setup script installs MuJoCo into the existing TeleFuser `.venv` and stages EGL/OSMesa packages under the ignored +`.venv-mujoco-libs` directory; it never runs `apt install` or changes system Python. The scene reads the existing +RoboTwin ALOHA-Agilex URDF and meshes directly from `/data/RoboTwin` and adds a tabletop, cube, and three cameras. + +```bash +bash examples/lingbot_vla_v2/setup_mujoco_local.sh + +# Physics + three-camera local smoke (no VLA model required) +.venv/bin/python examples/lingbot_vla_v2/lingbot_vla_v2_mujoco.py \ + --mode local --image-size 256 --execute-horizon 8 \ + --output-dir work_dirs/lingbot_vla_v2/mujoco_local + +# Complete local simulator -> generic VLA WebSocket -> simulator loop +.venv/bin/python examples/lingbot_vla_v2/lingbot_vla_v2_mujoco.py \ + --mode websocket --server-url ws://127.0.0.1:18080/v1/vla/session \ + --chunks 2 --execute-horizon 8 \ + --output-dir work_dirs/lingbot_vla_v2/mujoco_websocket +``` + +The adapter uses a semantic 14-dimensional `absolute_qpos` contract, PD torque control, and the existing +`SimulatorChunkRuntime`. `--mode local` validates model loading, rendering, action mapping, and execution without +loading LingBot. `--mode websocket` additionally validates the live generic VLA session and requires a running VLA +server. This is a chain/physics smoke test, not a RoboTwin task-success or checkpoint-quality evaluation. + ## Validation The repository includes strict upstream parity, runtime, quantization, structured-service, fault, and AIPerf diff --git a/examples/lingbot_vla_v2/lingbot_vla_v2_inference.py b/examples/lingbot_vla_v2/lingbot_vla_v2_inference.py index 9ccb553a..711d0a98 100644 --- a/examples/lingbot_vla_v2/lingbot_vla_v2_inference.py +++ b/examples/lingbot_vla_v2/lingbot_vla_v2_inference.py @@ -1,4 +1,8 @@ -"""Run the LingBot-VLA v2 base checkpoint with a RobotWin observation adapter.""" +"""Run the LingBot-VLA v2 base checkpoint with a RobotWin observation adapter. + +This is the reference/offline entrypoint for model output checks. Continuous +simulator control should use the generic VLA WebSocket service instead. +""" from __future__ import annotations diff --git a/examples/lingbot_vla_v2/lingbot_vla_v2_mujoco.py b/examples/lingbot_vla_v2/lingbot_vla_v2_mujoco.py new file mode 100644 index 00000000..36cd24d7 --- /dev/null +++ b/examples/lingbot_vla_v2/lingbot_vla_v2_mujoco.py @@ -0,0 +1,283 @@ +"""Run the generic LingBot VLA loop against a local headless MuJoCo scene.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + +import click +import numpy as np +import torch +from PIL import Image + +from telefuser.client import AsyncVLAClient +from telefuser.integrations.sim import MuJoCoJointBinding, MuJoCoSimulatorAdapter +from telefuser.pipelines.lingbot_vla_v2.robot_profile import ( + ROBOTWIN_ACTION_ORDER, + ROBOTWIN_ACTION_SPACE, + ROBOTWIN_CAMERA_KEYS, + ROBOTWIN_OBSERVATION_SPACE, +) +from telefuser.vla import RobotActionChunk +from telefuser.vla.runtime import ChunkStatus, SimulatorChunkRuntime + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_URDF = Path("/data/RoboTwin/assets/embodiments/aloha-agilex/urdf/arx5_description_isaac.urdf") +LOCAL_MESA_LIBDIR = REPOSITORY_ROOT / ".venv-mujoco-libs/root/usr/lib/x86_64-linux-gnu" +CAMERA_NAMES = { + ROBOTWIN_CAMERA_KEYS[0]: "telefuser_cam_high", + ROBOTWIN_CAMERA_KEYS[1]: "telefuser_cam_left", + ROBOTWIN_CAMERA_KEYS[2]: "telefuser_cam_right", +} + + +def _ensure_headless_runtime() -> None: + """Re-exec once so the dynamic loader sees repository-local OSMesa.""" + library_path = str(LOCAL_MESA_LIBDIR) + current_paths = os.environ.get("LD_LIBRARY_PATH", "").split(":") + if os.environ.get("MUJOCO_GL") == "osmesa" and library_path in current_paths: + return + if not (LOCAL_MESA_LIBDIR / "libOSMesa.so.8").is_file(): + raise RuntimeError( + "repository-local OSMesa is missing; run examples/lingbot_vla_v2/setup_mujoco_local.sh first" + ) + environment = os.environ.copy() + environment["MUJOCO_GL"] = "osmesa" + environment["LD_LIBRARY_PATH"] = ":".join(path for path in (library_path, *current_paths) if path) + os.execve(sys.executable, [sys.executable, *sys.argv], environment) + + +def _build_smoke_model(urdf_path: Path) -> Any: + """Add a small tabletop scene and three fixed cameras to the RoboTwin URDF.""" + import mujoco + + spec = mujoco.MjSpec.from_file(str(urdf_path)) + spec.option.timestep = 0.002 + spec.worldbody.add_light(name="telefuser_key_light", pos=[1.0, 0.0, 2.5], dir=[-0.3, 0.0, -1.0]) + spec.worldbody.add_geom( + name="telefuser_floor", + type=mujoco.mjtGeom.mjGEOM_PLANE, + size=[3.0, 3.0, 0.05], + rgba=[0.24, 0.26, 0.28, 1.0], + ) + spec.worldbody.add_geom( + name="telefuser_table", + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[0.7, 0.0, 0.66], + size=[0.55, 0.7, 0.05], + rgba=[0.45, 0.32, 0.22, 1.0], + friction=[1.0, 0.01, 0.001], + ) + cube = spec.worldbody.add_body(name="telefuser_cube", pos=[0.65, 0.0, 0.77]) + cube.add_freejoint(name="telefuser_cube_free") + cube.add_geom( + name="telefuser_cube_geom", + type=mujoco.mjtGeom.mjGEOM_BOX, + size=[0.05, 0.05, 0.05], + mass=0.1, + rgba=[0.85, 0.12, 0.08, 1.0], + friction=[1.0, 0.01, 0.001], + ) + spec.worldbody.add_camera( + name=CAMERA_NAMES[ROBOTWIN_CAMERA_KEYS[0]], + pos=[2.2, 0.0, 1.55], + zaxis=[1.55, 0.0, 0.75], + fovy=55.0, + ) + spec.worldbody.add_camera( + name=CAMERA_NAMES[ROBOTWIN_CAMERA_KEYS[1]], + pos=[1.15, 1.25, 1.15], + zaxis=[0.5, 0.95, 0.35], + fovy=60.0, + ) + spec.worldbody.add_camera( + name=CAMERA_NAMES[ROBOTWIN_CAMERA_KEYS[2]], + pos=[1.15, -1.25, 1.15], + zaxis=[0.5, -0.95, 0.35], + fovy=60.0, + ) + return spec.compile() + + +def _joint_bindings() -> tuple[MuJoCoJointBinding, ...]: + return ( + *(MuJoCoJointBinding(ROBOTWIN_ACTION_ORDER[index], (f"fl_joint{index + 1}",)) for index in range(6)), + MuJoCoJointBinding(ROBOTWIN_ACTION_ORDER[6], ("fl_joint7", "fl_joint8"), True), + *(MuJoCoJointBinding(ROBOTWIN_ACTION_ORDER[index + 7], (f"fr_joint{index + 1}",)) for index in range(6)), + MuJoCoJointBinding(ROBOTWIN_ACTION_ORDER[13], ("fr_joint7", "fr_joint8"), True), + ) + + +def _create_adapter(urdf_path: Path, image_size: int, steps_per_action: int) -> MuJoCoSimulatorAdapter: + model = _build_smoke_model(urdf_path) + return MuJoCoSimulatorAdapter( + model, + ROBOTWIN_ACTION_SPACE, + ROBOTWIN_OBSERVATION_SPACE, + _joint_bindings(), + camera_names=CAMERA_NAMES, + image_height=image_size, + image_width=image_size, + steps_per_action=steps_per_action, + ) + + +def _save_images(images: dict[str, Any], output_dir: Path | None) -> None: + if output_dir is None: + return + output_dir.mkdir(parents=True, exist_ok=True) + for name, image in images.items(): + Image.fromarray(np.asarray(image)).save(output_dir / f"{name.rsplit('.', maxsplit=1)[-1]}.png") + + +def _run_local_smoke( + adapter: MuJoCoSimulatorAdapter, + *, + execute_horizon: int, + output_dir: Path | None, +) -> dict[str, Any]: + initial = adapter.reset() + targets = initial.state.values.repeat(execute_horizon, 1) + targets[:, 0] += torch.linspace(0.01, 0.06, execute_horizon) + targets[:, 7] -= torch.linspace(0.01, 0.04, execute_horizon) + targets[:, 6] = 0.5 + targets[:, 13] = 0.5 + chunk = RobotActionChunk( + actions=targets, + action_space=ROBOTWIN_ACTION_SPACE, + valid_length=execute_horizon, + observation_timestamp_ns=initial.state.timestamp_ns, + sequence_id=0, + episode_id="mujoco-local-smoke", + ) + runtime = SimulatorChunkRuntime( + adapter, + ROBOTWIN_ACTION_SPACE, + chunk.episode_id, + execute_horizon=execute_horizon, + ) + status = runtime.accept(chunk, observation_clock_now_ns=initial.state.timestamp_ns) + if status is not ChunkStatus.READY: + raise RuntimeError(f"local action chunk was not ready: {status.value}") + executed = runtime.execute_ready() + final = adapter.observe() + _save_images(dict(final.images), output_dir) + image_std = {name: float(np.asarray(image).std()) for name, image in final.images.items()} + if any(value <= 1.0 for value in image_std.values()): + raise RuntimeError(f"MuJoCo returned an empty or nearly uniform camera frame: {image_std}") + return { + "mode": "local", + "executed_actions": executed, + "simulation_time_s": final.metadata["simulation_time_s"], + "state_delta_l2": float(torch.linalg.vector_norm(final.state.values - initial.state.values)), + "image_shape": {name: list(np.asarray(image).shape) for name, image in final.images.items()}, + "image_std": image_std, + "runtime_state": runtime.state.value, + } + + +async def _run_websocket_loop( + adapter: MuJoCoSimulatorAdapter, + *, + server_url: str, + instruction: str, + seed: int, + chunks: int, + execute_horizon: int, + output_dir: Path | None, +) -> dict[str, Any]: + episode_id = "mujoco-websocket-smoke" + runtime = SimulatorChunkRuntime( + adapter, + ROBOTWIN_ACTION_SPACE, + episode_id, + execute_horizon=execute_horizon, + ) + observation = adapter.reset() + executed_total = 0 + async with AsyncVLAClient(server_url) as client: + await client.open_session( + "mujoco-local", + model_id="lingbot-vla-v2", + embodiment_id="robotwin", + episode_id=episode_id, + execute_horizon=execute_horizon, + expected_robot_action_space=ROBOTWIN_ACTION_SPACE, + expected_robot_observation_space=ROBOTWIN_OBSERVATION_SPACE, + ) + for sequence_id in range(chunks): + chunk = await client.predict( + "mujoco-local", + observation, + instruction, + sequence_id, + seed=seed, + ) + status = runtime.accept(chunk, observation_clock_now_ns=observation.state.timestamp_ns) + if status is not ChunkStatus.READY: + raise RuntimeError(f"server action chunk was not ready: {status.value}") + executed_total += runtime.execute_ready() + observation = adapter.observe() + _save_images(dict(observation.images), output_dir) + return { + "mode": "websocket", + "chunks": chunks, + "executed_actions": executed_total, + "simulation_time_s": observation.metadata["simulation_time_s"], + "final_state": observation.state.values.tolist(), + "runtime_state": runtime.state.value, + } + + +@click.command() +@click.option("--mode", type=click.Choice(("local", "websocket")), default="local", show_default=True) +@click.option("--urdf", "urdf_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), default=DEFAULT_URDF) +@click.option("--server-url", default="ws://127.0.0.1:18080/v1/vla/session", show_default=True) +@click.option("--instruction", default="pick up the red block", show_default=True) +@click.option("--seed", type=int, default=7, show_default=True) +@click.option("--chunks", type=click.IntRange(min=1), default=2, show_default=True) +@click.option("--execute-horizon", type=click.IntRange(min=1), default=8, show_default=True) +@click.option("--steps-per-action", type=click.IntRange(min=1), default=5, show_default=True) +@click.option("--image-size", type=click.IntRange(min=64), default=256, show_default=True) +@click.option("--output-dir", type=click.Path(path_type=Path, file_okay=False), default=None) +def main( + mode: str, + urdf_path: Path, + server_url: str, + instruction: str, + seed: int, + chunks: int, + execute_horizon: int, + steps_per_action: int, + image_size: int, + output_dir: Path | None, +) -> None: + """Validate local physics alone or the complete generic VLA WebSocket loop.""" + _ensure_headless_runtime() + adapter = _create_adapter(urdf_path.resolve(), image_size, steps_per_action) + try: + if mode == "local": + result = _run_local_smoke(adapter, execute_horizon=execute_horizon, output_dir=output_dir) + else: + result = asyncio.run( + _run_websocket_loop( + adapter, + server_url=server_url, + instruction=instruction, + seed=seed, + chunks=chunks, + execute_horizon=execute_horizon, + output_dir=output_dir, + ) + ) + click.echo(json.dumps(result, indent=2, sort_keys=True)) + finally: + adapter.close() + + +if __name__ == "__main__": + main() diff --git a/examples/lingbot_vla_v2/lingbot_vla_v2_native_service.py b/examples/lingbot_vla_v2/lingbot_vla_v2_native_service.py index 9095ad17..53f779ea 100644 --- a/examples/lingbot_vla_v2/lingbot_vla_v2_native_service.py +++ b/examples/lingbot_vla_v2/lingbot_vla_v2_native_service.py @@ -1,4 +1,9 @@ -"""Native TeleFuser service contract for LingBot-VLA v2 action inference.""" +"""Native TeleFuser HTTP service contract for LingBot-VLA v2. + +This module is also the pipeline definition loaded by the generic VLA +WebSocket server. It remains available for ``telefuser serve`` compatibility; +the WebSocket server is the primary continuous-control entrypoint. +""" from __future__ import annotations @@ -7,11 +12,16 @@ from typing import Any from telefuser.pipelines.lingbot_vla_v2.pipeline import LingBotVlaV2Pipeline -from telefuser.pipelines.lingbot_vla_v2.runtime import get_lingbot_vla_v2_pipeline +from telefuser.pipelines.lingbot_vla_v2.runtime import ( + configure_lingbot_vla_v2_h100_sdpa, + get_lingbot_vla_v2_pipeline, +) from telefuser.pipelines.lingbot_vla_v2.service import ( LingBotVlaV2ActionRequest, predict_lingbot_vla_v2_action, ) +from telefuser.pipelines.lingbot_vla_v2.vla_policy import create_lingbot_vla_v2_session_manager +from telefuser.service.vla_replica import VLAReplicaProvider from telefuser.utils.logging import logger TF_MODEL_ZOO_PATH = Path(os.environ.get("TF_MODEL_ZOO_PATH", "model_zoo")).expanduser() @@ -84,6 +94,7 @@ def get_pipeline(parallelism: int = 1) -> LingBotVlaV2Pipeline: """Load one policy replica for the native TeleFuser service.""" if parallelism != 1: raise ValueError("LingBot-VLA v2 supports parallelism=1 per replica; use --num-replicas for a pipeline pool") + configure_lingbot_vla_v2_h100_sdpa(PPL_CONFIG["device"]) logger.info( f"Loading LingBot-VLA v2 service profile quantization={PPL_CONFIG['quantization'] or 'bf16'} " f"cuda_graph={PPL_CONFIG['cuda_graph']}" @@ -98,6 +109,11 @@ def get_pipeline(parallelism: int = 1) -> LingBotVlaV2Pipeline: ) +def get_vla_provider(pipeline: LingBotVlaV2Pipeline) -> VLAReplicaProvider: + """Create worker-local semantic VLA sessions around the loaded pipeline.""" + return VLAReplicaProvider(create_lingbot_vla_v2_session_manager(pipeline)) + + def run_structured( pipeline: LingBotVlaV2Pipeline, instruction: str, diff --git a/examples/lingbot_vla_v2/lingbot_vla_v2_robotwin_server.py b/examples/lingbot_vla_v2/lingbot_vla_v2_robotwin_server.py index 62905647..ec28f8e1 100644 --- a/examples/lingbot_vla_v2/lingbot_vla_v2_robotwin_server.py +++ b/examples/lingbot_vla_v2/lingbot_vla_v2_robotwin_server.py @@ -1,4 +1,9 @@ -"""Serve LingBot-VLA v2 through the upstream RoboTwin policy protocol.""" +"""Legacy compatibility server for the upstream RoboTwin policy protocol. + +New simulator integrations should use the generic VLA WebSocket protocol in +``lingbot_vla_v2_vla_server.py``. This endpoint remains only for unmodified +upstream ``WebsocketClientPolicy`` clients. +""" from __future__ import annotations @@ -18,44 +23,32 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from telefuser.pipelines.lingbot_vla_v2 import ( + ROBOTWIN_ACTION_ORDER, ROBOTWIN_CAMERA_KEYS, - LingBotVlaV2Observation, RobotWinProfile, + create_lingbot_vla_v2_session_manager, ) -from telefuser.pipelines.lingbot_vla_v2.action_scheduler import ActionChunkScheduler from telefuser.pipelines.lingbot_vla_v2.runtime import ( LINGBOT_VLA_V2_QUANTIZATION_CHOICES, + configure_lingbot_vla_v2_h100_sdpa, get_lingbot_vla_v2_pipeline, ) from telefuser.utils.logging import logger +from telefuser.vla import RobotObservation, RobotState +from telefuser.vla.runtime import ActionChunkScheduler ROBOTWIN_PROTOCOL_VERSION = "1.0" ROBOTWIN_ACTION_TYPE = "absolute_qpos" ROBOTWIN_ACTION_DTYPE = "float32" ROBOTWIN_MAX_REQUEST_BYTES = 16 * 1024 * 1024 -ROBOTWIN_ACTION_ORDER = ( - "left_arm_joint_0", - "left_arm_joint_1", - "left_arm_joint_2", - "left_arm_joint_3", - "left_arm_joint_4", - "left_arm_joint_5", - "left_gripper", - "right_arm_joint_0", - "right_arm_joint_1", - "right_arm_joint_2", - "right_arm_joint_3", - "right_arm_joint_4", - "right_arm_joint_5", - "right_gripper", -) _TRACE_ID_FIELDS = ("request_id", "episode_id") +_VLA_SESSION_ID_FIELD = "_telefuser_vla_session_id" class _Pipeline(Protocol): config: Any - def __call__(self, observation: LingBotVlaV2Observation, seed: int | None = None) -> Any: ... + def __call__(self, observation: Any, seed: int | None = None) -> Any: ... def close(self) -> None: ... @@ -130,6 +123,21 @@ def _trace_fields(request: Mapping[str, Any]) -> dict[str, str | int]: return fields +def _optional_nonnegative_int(request: Mapping[str, Any], field: str) -> int | None: + value = request.get(field) + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"{field} must be a non-negative integer") + try: + parsed = operator.index(value) + except TypeError as error: + raise ValueError(f"{field} must be a non-negative integer") from error + if parsed < 0: + raise ValueError(f"{field} must be a non-negative integer") + return parsed + + class RobotWinPolicyAdapter: """Translate upstream RoboTwin observations to the TeleFuser VLA SDK.""" @@ -146,6 +154,8 @@ def __init__( self.profile = profile or pipeline.config.robot_profile self.use_length = use_length self._lock = threading.Lock() + self._next_sequence: dict[str, int] = {} + self._sessions = create_lingbot_vla_v2_session_manager(pipeline, profile=self.profile) @property def metadata(self) -> dict[str, Any]: @@ -173,28 +183,52 @@ def infer(self, request: Mapping[str, Any]) -> dict[str, Any]: if missing: raise ValueError(f"RoboTwin observation is missing fields: {missing}") - observation = LingBotVlaV2Observation( - task=request["task"], - state=request["observation.state"], + seed = _optional_seed(request) + episode_id = str(trace_fields.get("episode_id", "default")) + session_id_value = request.get(_VLA_SESSION_ID_FIELD, f"legacy:{episode_id}") + if not isinstance(session_id_value, str) or not session_id_value: + raise ValueError("internal VLA session ID must be a non-empty string") + sequence_id = _optional_nonnegative_int(request, "sequence_id") + observation_timestamp_ns = _optional_nonnegative_int(request, "observation_timestamp_ns") + if observation_timestamp_ns is None: + observation_timestamp_ns = time.time_ns() + robot_observation = RobotObservation( + state=RobotState( + values=torch.tensor(request["observation.state"], dtype=torch.float32, device="cpu"), + dimension_names=ROBOTWIN_ACTION_ORDER, + timestamp_ns=observation_timestamp_ns, + ), images={key: request[key] for key in ROBOTWIN_CAMERA_KEYS}, ) - seed = _optional_seed(request) adapter_started_at = time.monotonic() with self._lock: lock_wait_ms = (time.monotonic() - adapter_started_at) * 1000.0 - pipeline_started_at = time.monotonic() - canonical_chunk = self.pipeline(observation, seed=seed) - pipeline_ms = (time.monotonic() - pipeline_started_at) * 1000.0 - mapping_started_at = time.monotonic() - action_chunk = self.profile.structure_actions( - canonical_chunk.canonical_normalized_actions, + if sequence_id is None: + sequence_id = self._next_sequence.get(session_id_value, 0) + self._next_sequence[session_id_value] = sequence_id + 1 + if session_id_value not in self._sessions.session_ids(): + self._sessions.open( + session_id_value, + model_id="lingbot-vla-v2", + embodiment_id=self.profile.embodiment_id, + episode_id=episode_id, + execute_horizon=self.use_length, + ) + vla_timings: dict[str, float] = {} + action_chunk = self._sessions.get(session_id_value).predict( + robot_observation, + request["task"], + sequence_id, + seed=seed, + timings=vla_timings, ) - action_mapping_ms = (time.monotonic() - mapping_started_at) * 1000.0 - if action_chunk.horizon < self.use_length: + pipeline_ms = vla_timings["policy_ms"] + action_mapping_ms = vla_timings["decode_actions_ms"] + vla_timings["prepare_actions_ms"] + if action_chunk.valid_length < self.use_length: raise RuntimeError( - f"policy returned horizon {action_chunk.horizon}, shorter than use_length={self.use_length}" + f"policy returned horizon {action_chunk.valid_length}, shorter than use_length={self.use_length}" ) - actions = np.ascontiguousarray(action_chunk.raw_actions[: self.use_length].numpy(), dtype=np.float32) + actions = np.ascontiguousarray(action_chunk.actions[: self.use_length].numpy(), dtype=np.float32) expected_shape = (self.use_length, self.profile.raw_state_dim) if actions.shape != expected_shape: raise RuntimeError(f"mapped actions must have shape {expected_shape}, got {actions.shape}") @@ -202,8 +236,8 @@ def infer(self, request: Mapping[str, Any]) -> dict[str, Any]: raise RuntimeError("mapped actions must contain only finite values") response: dict[str, Any] = { "action": actions, - "policy_verified": canonical_chunk.policy_verified, - "verification_status": canonical_chunk.verification_status, + "policy_verified": action_chunk.metadata["policy_verified"], + "verification_status": action_chunk.metadata["verification_status"], "server_timing": { "lock_wait_ms": lock_wait_ms, "pipeline_ms": pipeline_ms, @@ -222,10 +256,25 @@ def _reset(self, request: Mapping[str, Any]) -> dict[str, Any]: raise ValueError(f"unsupported robot profile: {robot_name!r}") if request.get("path_to_pi_model") not in (None, ""): raise ValueError("runtime checkpoint switching is not supported") + episode_id = str(request.get("episode_id", "default")) + session_id = request.get(_VLA_SESSION_ID_FIELD, f"legacy:{episode_id}") + if isinstance(session_id, str) and session_id in self._sessions.session_ids(): + self._sessions.reset(session_id, episode_id) + self._next_sequence[session_id] = 0 return {"action": None} + def release_session(self, session_id: str) -> None: + """Release semantic session state after a WebSocket disconnect.""" + with self._lock: + if session_id in self._sessions.session_ids(): + self._sessions.close(session_id) + self._next_sequence.pop(session_id, None) + def close(self) -> None: """Release resources owned by the resident policy.""" + for session_id in self._sessions.session_ids(): + self._sessions.close(session_id) + self._next_sequence.clear() self.pipeline.close() @@ -305,9 +354,12 @@ async def deliver( trace_fields = _trace_fields(request) episode_id = trace_fields.get("episode_id", "default") session_key = f"{connection_key}:{episode_id!r}" + request[_VLA_SESSION_ID_FIELD] = session_key if request.get("reset", False): for previous_session_key in session_keys - {session_key}: scheduler.release_session(previous_session_key) + await asyncio.to_thread(adapter.release_session, previous_session_key) + scheduler.release_session(session_key) session_keys.intersection_update({session_key}) session_keys.add(session_key) response_future = scheduler.submit(request, session_key=session_key) @@ -334,29 +386,14 @@ async def deliver( task.cancel() if delivery_tasks: await asyncio.gather(*delivery_tasks, return_exceptions=True) + if session_keys: + await asyncio.gather( + *(asyncio.to_thread(adapter.release_session, session_key) for session_key in session_keys) + ) return app -def _configure_h100_sdpa_backends(device: str) -> None: - """Avoid unsupported cuDNN SDPA plans in the isolated H100 policy process.""" - resolved_device = torch.device(device) - if resolved_device.type != "cuda" or not torch.cuda.is_available(): - return - if "H100" not in torch.cuda.get_device_name(resolved_device): - return - - if hasattr(torch.backends.cuda, "enable_cudnn_sdp"): - torch.backends.cuda.enable_cudnn_sdp(False) - if hasattr(torch.backends.cuda, "enable_flash_sdp"): - torch.backends.cuda.enable_flash_sdp(True) - if hasattr(torch.backends.cuda, "enable_math_sdp"): - torch.backends.cuda.enable_math_sdp(True) - if hasattr(torch.backends.cuda, "enable_mem_efficient_sdp"): - torch.backends.cuda.enable_mem_efficient_sdp(True) - logger.info("Disabled cuDNN SDPA for the LingBot-VLA v2 H100 policy process") - - @click.command() @click.option("--model-root", required=True, type=click.Path(exists=True, file_okay=False)) @click.option("--qwen3vl-root", required=True, type=click.Path(exists=True, file_okay=False)) @@ -388,8 +425,8 @@ def main( cuda_graph: bool, quantization: str | None, ) -> None: - """Start one resident LingBot-VLA v2 policy for a RoboTwin client.""" - _configure_h100_sdpa_backends(device) + """Start the legacy-compatible policy endpoint for an upstream RoboTwin client.""" + configure_lingbot_vla_v2_h100_sdpa(device) pipeline = get_lingbot_vla_v2_pipeline( model_root, qwen3vl_root, diff --git a/examples/lingbot_vla_v2/lingbot_vla_v2_vla_server.py b/examples/lingbot_vla_v2/lingbot_vla_v2_vla_server.py new file mode 100644 index 00000000..6c0614bd --- /dev/null +++ b/examples/lingbot_vla_v2/lingbot_vla_v2_vla_server.py @@ -0,0 +1,70 @@ +"""Primary generic VLA WebSocket service backed by TeleFuser replicas.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import uvicorn +from fastapi import FastAPI + +from telefuser.service.core.config import ServerConfig +from telefuser.service.core.pipeline_pool import PipelinePool +from telefuser.service.security.security_validator import SecurityLevel +from telefuser.service.vla_session import create_pipeline_pool_vla_session_app + +DEFAULT_PIPELINE_FILE = Path(__file__).with_name("lingbot_vla_v2_native_service.py") + + +def create_app( + *, + pipeline_file: str | Path = DEFAULT_PIPELINE_FILE, + parallelism: int = 1, + num_replicas: int = 1, + skip_validation: bool = False, +) -> FastAPI: + """Load LingBot replicas and create the standalone generic VLA app.""" + config = ServerConfig(num_replicas=num_replicas, security_level=SecurityLevel.NONE) + replica_device_ids = config.resolve_replica_device_ids(parallelism) + pool = PipelinePool( + num_replicas=num_replicas, + replica_device_ids=replica_device_ids, + security_level_name=config.security_level.name, + config=config, + ) + started = pool.start_all( + ppl_file=str(Path(pipeline_file).expanduser().resolve()), + parallelism_per_replica=len(replica_device_ids[0]), + task="vla_action", + skip_validation=skip_validation, + vla_provider_factory="get_vla_provider", + ) + if not started: + raise RuntimeError("failed to start LingBot-VLA v2 pipeline replicas") + return create_pipeline_pool_vla_session_app(pool) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--parallelism", type=int, default=1) + parser.add_argument("--num-replicas", type=int, default=1) + parser.add_argument("--pipeline-file", type=Path, default=DEFAULT_PIPELINE_FILE) + parser.add_argument("--skip-validation", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + app = create_app( + pipeline_file=args.pipeline_file, + parallelism=args.parallelism, + num_replicas=args.num_replicas, + skip_validation=args.skip_validation, + ) + uvicorn.run(app, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/examples/lingbot_vla_v2/setup_mujoco_local.sh b/examples/lingbot_vla_v2/setup_mujoco_local.sh new file mode 100755 index 00000000..c390125b --- /dev/null +++ b/examples/lingbot_vla_v2/setup_mujoco_local.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +venv_python="${repo_root}/.venv/bin/python" +local_lib_root="${repo_root}/.venv-mujoco-libs" + +if [[ ! -x "${venv_python}" ]]; then + echo "Expected the TeleFuser virtual environment at ${repo_root}/.venv" >&2 + exit 1 +fi +if ! command -v uv >/dev/null 2>&1; then + echo "uv is required to install into the existing virtual environment" >&2 + exit 1 +fi +if ! command -v apt-get >/dev/null 2>&1 || ! command -v dpkg-deb >/dev/null 2>&1; then + echo "apt-get and dpkg-deb are required to stage repository-local OSMesa libraries" >&2 + exit 1 +fi + +uv pip install --python "${venv_python}" "mujoco==3.13.0" +mkdir -p "${local_lib_root}/root" +pushd "${local_lib_root}" >/dev/null +apt-get download libegl1 libopengl0 libosmesa6 +for package in ./*.deb; do + dpkg-deb -x "${package}" root +done +popd >/dev/null + +echo "MuJoCo and repository-local OSMesa are ready under ${repo_root}" diff --git a/telefuser/client/__init__.py b/telefuser/client/__init__.py index c9e6352c..782ef8e7 100644 --- a/telefuser/client/__init__.py +++ b/telefuser/client/__init__.py @@ -17,6 +17,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any + from .tf_client import ( TFClient, TaskCreationError, @@ -25,10 +27,24 @@ TeleFuserError, ) +if TYPE_CHECKING: + from .vla import AsyncVLAClient, VLAClientError + __all__ = [ "TFClient", + "AsyncVLAClient", "TeleFuserError", "TaskCreationError", "TaskFailedError", "TaskTimeoutError", + "VLAClientError", ] + + +def __getattr__(name: str) -> Any: + """Load the optional VLA client without changing the existing HTTP client import path.""" + if name in {"AsyncVLAClient", "VLAClientError"}: + from . import vla + + return getattr(vla, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/telefuser/client/vla.py b/telefuser/client/vla.py new file mode 100644 index 00000000..0fe65cb9 --- /dev/null +++ b/telefuser/client/vla.py @@ -0,0 +1,244 @@ +"""Async client for the generic TeleFuser VLA WebSocket protocol.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Mapping +from typing import Any + +from telefuser.vla.contracts import ActionSpaceSpec, ObservationSpaceSpec, RobotActionChunk, RobotObservation +from telefuser.vla.serialization import ( + VLA_SESSION_PROTOCOL_VERSION, + VLA_WIRE_ENCODING, + action_space_to_wire, + dumps_wire_message, + loads_wire_message, + observation_space_to_wire, + robot_action_chunk_from_wire, + robot_observation_to_wire, +) + +DEFAULT_MAX_VLA_MESSAGE_BYTES = 64 * 1024 * 1024 + + +class VLAClientError(RuntimeError): + """Error returned by the VLA session service.""" + + def __init__(self, code: str, message: str, *, request_id: str | int | None = None) -> None: + super().__init__(message) + self.code = code + self.request_id = request_id + + +class AsyncVLAClient: + """Maintain one multiplexed connection to a VLA session service.""" + + def __init__( + self, + url: str, + *, + max_message_bytes: int = DEFAULT_MAX_VLA_MESSAGE_BYTES, + open_timeout_s: float = 10.0, + ) -> None: + if not isinstance(url, str) or not url: + raise ValueError("url must be a non-empty string") + if not isinstance(max_message_bytes, int) or isinstance(max_message_bytes, bool) or max_message_bytes < 1: + raise ValueError("max_message_bytes must be a positive integer") + if isinstance(open_timeout_s, bool) or not isinstance(open_timeout_s, (int, float)) or open_timeout_s <= 0: + raise ValueError("open_timeout_s must be positive") + self.url = url + self.max_message_bytes = max_message_bytes + self.open_timeout_s = float(open_timeout_s) + self.hello: dict[str, Any] | None = None + self._websocket: Any = None + self._reader: asyncio.Task[None] | None = None + self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._sessions: set[str] = set() + self._send_lock = asyncio.Lock() + self._request_sequence = 0 + + async def connect(self) -> dict[str, Any]: + """Connect and validate protocol capabilities.""" + if self._websocket is not None: + raise RuntimeError("VLA client is already connected") + try: + from websockets.asyncio.client import connect + except ImportError: + from websockets import connect + + websocket = await connect( + self.url, + max_size=self.max_message_bytes, + open_timeout=self.open_timeout_s, + ) + try: + hello = loads_wire_message(await websocket.recv(), max_message_bytes=self.max_message_bytes) + if hello.get("type") != "HELLO": + raise VLAClientError("invalid_message", "VLA service did not send HELLO") + if hello.get("protocol_version") != VLA_SESSION_PROTOCOL_VERSION: + raise VLAClientError("unsupported_version", "VLA service protocol version is not supported") + if hello.get("encoding") != VLA_WIRE_ENCODING: + raise VLAClientError("unsupported_encoding", "VLA service wire encoding is not supported") + except Exception: + await websocket.close() + raise + self._websocket = websocket + self.hello = hello + self._reader = asyncio.create_task(self._read_responses()) + return dict(hello) + + async def open_session( + self, + session_id: str, + *, + model_id: str, + embodiment_id: str, + episode_id: str, + execute_horizon: int | None = None, + max_observation_age_ns: int | None = None, + expected_robot_action_space: ActionSpaceSpec | None = None, + expected_robot_observation_space: ObservationSpaceSpec | None = None, + ) -> dict[str, Any]: + """Open one session and optionally negotiate robot contracts.""" + payload: dict[str, Any] = { + "type": "OPEN", + "protocol_version": VLA_SESSION_PROTOCOL_VERSION, + "session_id": session_id, + "model_id": model_id, + "embodiment_id": embodiment_id, + "episode_id": episode_id, + } + if execute_horizon is not None: + payload["execute_horizon"] = execute_horizon + if max_observation_age_ns is not None: + payload["max_observation_age_ns"] = max_observation_age_ns + if expected_robot_action_space is not None: + payload["expected_robot_action_space"] = action_space_to_wire(expected_robot_action_space) + if expected_robot_observation_space is not None: + payload["expected_robot_observation_space"] = observation_space_to_wire(expected_robot_observation_space) + response = await self._request(payload) + self._sessions.add(session_id) + return response + + async def predict( + self, + session_id: str, + observation: RobotObservation, + instruction: str, + sequence_id: int, + *, + seed: int | None = None, + request_ttl_ms: float | None = None, + observation_clock_now_ns: int | None = None, + ) -> RobotActionChunk: + """Submit one observation and return its semantic robot action chunk.""" + payload: dict[str, Any] = { + "type": "PREDICT", + "session_id": session_id, + "sequence_id": sequence_id, + "observation_timestamp_ns": observation.state.timestamp_ns, + "instruction": instruction, + "observation": robot_observation_to_wire(observation), + } + if seed is not None: + payload["seed"] = seed + if request_ttl_ms is not None: + payload["request_ttl_ms"] = request_ttl_ms + if observation_clock_now_ns is not None: + payload["observation_clock_now_ns"] = observation_clock_now_ns + response = await self._request(payload) + chunk = response.get("chunk") + if not isinstance(chunk, Mapping): + raise VLAClientError("invalid_message", "ACTION_CHUNK response is missing chunk") + return robot_action_chunk_from_wire(chunk) + + async def reset_session(self, session_id: str, *, episode_id: str | None = None) -> None: + """Reset model history and queued action chunks for one session.""" + payload: dict[str, Any] = {"type": "RESET", "session_id": session_id} + if episode_id is not None: + payload["episode_id"] = episode_id + await self._request(payload) + + async def close_session(self, session_id: str) -> None: + """Close one server-side session while retaining the connection.""" + await self._request({"type": "CLOSE", "session_id": session_id}) + self._sessions.discard(session_id) + + async def aclose(self) -> None: + """Close owned sessions and the WebSocket connection.""" + if self._websocket is None: + return + for session_id in tuple(self._sessions): + with contextlib.suppress(Exception): + await self.close_session(session_id) + websocket = self._websocket + self._websocket = None + await websocket.close() + if self._reader is not None: + self._reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reader + self._reader = None + self._fail_pending(VLAClientError("connection_closed", "VLA client connection closed")) + + async def __aenter__(self) -> "AsyncVLAClient": + await self.connect() + return self + + async def __aexit__(self, *_exc_info: object) -> None: + await self.aclose() + + async def _request(self, payload: dict[str, Any]) -> dict[str, Any]: + websocket = self._websocket + if websocket is None: + raise RuntimeError("VLA client is not connected") + self._request_sequence += 1 + request_id = f"vla-{self._request_sequence}" + payload["request_id"] = request_id + response = asyncio.get_running_loop().create_future() + self._pending[request_id] = response + try: + async with self._send_lock: + await websocket.send(dumps_wire_message(payload)) + return await response + except BaseException: + self._pending.pop(request_id, None) + raise + + async def _read_responses(self) -> None: + try: + while True: + payload = loads_wire_message(await self._websocket.recv(), max_message_bytes=self.max_message_bytes) + request_id = payload.get("request_id") + if not isinstance(request_id, str): + raise VLAClientError("invalid_message", "VLA response requires a string request_id") + response = self._pending.pop(request_id, None) + if response is None: + continue + if payload.get("type") == "ERROR": + error = payload.get("error") + if not isinstance(error, Mapping): + response.set_exception(VLAClientError("invalid_message", "invalid VLA error response")) + else: + response.set_exception( + VLAClientError( + str(error.get("code", "internal_error")), + str(error.get("message", "VLA request failed")), + request_id=request_id, + ) + ) + else: + response.set_result(payload) + except asyncio.CancelledError: + raise + except Exception as error: + self._fail_pending( + error if isinstance(error, VLAClientError) else VLAClientError("connection_closed", str(error)) + ) + + def _fail_pending(self, error: Exception) -> None: + for response in self._pending.values(): + if not response.done(): + response.set_exception(error) + self._pending.clear() diff --git a/telefuser/integrations/__init__.py b/telefuser/integrations/__init__.py new file mode 100644 index 00000000..5fd93994 --- /dev/null +++ b/telefuser/integrations/__init__.py @@ -0,0 +1 @@ +"""Optional integrations between TeleFuser and external runtimes.""" diff --git a/telefuser/integrations/sim/__init__.py b/telefuser/integrations/sim/__init__.py new file mode 100644 index 00000000..6999cfa1 --- /dev/null +++ b/telefuser/integrations/sim/__init__.py @@ -0,0 +1,12 @@ +"""Simulator-neutral VLA integration interfaces.""" + +from .base import SimulatorAdapter +from .mujoco import MuJoCoJointBinding, MuJoCoSimulatorAdapter +from .robotwin import RoboTwinSimulatorAdapter + +__all__ = [ + "MuJoCoJointBinding", + "MuJoCoSimulatorAdapter", + "RoboTwinSimulatorAdapter", + "SimulatorAdapter", +] diff --git a/telefuser/integrations/sim/base.py b/telefuser/integrations/sim/base.py new file mode 100644 index 00000000..599bae83 --- /dev/null +++ b/telefuser/integrations/sim/base.py @@ -0,0 +1,25 @@ +"""Simulator adapter protocol consumed by the generic VLA runtime.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from telefuser.vla.contracts import RobotObservation +from telefuser.vla.runtime.executor import RobotAction + + +@runtime_checkable +class SimulatorAdapter(Protocol): + """Minimal interface implemented by one simulator connection.""" + + def observe(self) -> RobotObservation: + """Capture the latest semantically described robot observation.""" + ... + + def execute(self, action: RobotAction) -> None: + """Submit one semantically described robot action.""" + ... + + def reset(self) -> RobotObservation: + """Reset the simulator and return its initial observation.""" + ... diff --git a/telefuser/integrations/sim/mujoco.py b/telefuser/integrations/sim/mujoco.py new file mode 100644 index 00000000..bf32823d --- /dev/null +++ b/telefuser/integrations/sim/mujoco.py @@ -0,0 +1,254 @@ +"""Optional MuJoCo implementation of the generic simulator boundary.""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from types import ModuleType +from typing import Any, Mapping, Sequence + +import numpy as np +import torch + +from telefuser.vla.contracts import ActionSpaceSpec, ObservationSpaceSpec, RobotObservation, RobotState +from telefuser.vla.runtime.executor import RobotAction + + +@dataclass(frozen=True) +class MuJoCoJointBinding: + """Map one semantic action dimension to one or more MuJoCo joints.""" + + dimension_name: str + joint_names: tuple[str, ...] + normalized_to_joint_range: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.dimension_name, str) or not self.dimension_name: + raise ValueError("dimension_name must be a non-empty string") + if not isinstance(self.joint_names, tuple) or not self.joint_names: + raise ValueError("joint_names must be a non-empty tuple") + if any(not isinstance(name, str) or not name for name in self.joint_names): + raise ValueError("joint_names must contain non-empty strings") + if len(set(self.joint_names)) != len(self.joint_names): + raise ValueError("joint_names must be unique within one binding") + if not isinstance(self.normalized_to_joint_range, bool): + raise TypeError("normalized_to_joint_range must be a boolean") + + +@dataclass(frozen=True) +class _ResolvedJoint: + qpos_address: int + dof_address: int + limited: bool + lower: float + upper: float + + +class MuJoCoSimulatorAdapter: + """Apply semantic robot actions to a MuJoCo model with PD control. + + MuJoCo is imported only when this adapter is instantiated, so the package + remains optional and cannot affect existing TeleFuser pipelines. + """ + + def __init__( + self, + model: Any, + action_space: ActionSpaceSpec, + observation_space: ObservationSpaceSpec, + joint_bindings: Sequence[MuJoCoJointBinding], + *, + camera_names: Mapping[str, str] | None = None, + image_height: int = 256, + image_width: int = 256, + steps_per_action: int = 5, + position_gain: float = 80.0, + damping_gain: float = 8.0, + max_force: float = 200.0, + ) -> None: + self._mujoco = self._import_mujoco() + if not isinstance(action_space, ActionSpaceSpec): + raise TypeError("action_space must be an ActionSpaceSpec") + if not isinstance(observation_space, ObservationSpaceSpec): + raise TypeError("observation_space must be an ObservationSpaceSpec") + if isinstance(steps_per_action, bool) or not isinstance(steps_per_action, int) or steps_per_action < 1: + raise ValueError("steps_per_action must be a positive integer") + if isinstance(image_height, bool) or not isinstance(image_height, int) or image_height < 1: + raise ValueError("image_height must be a positive integer") + if isinstance(image_width, bool) or not isinstance(image_width, int) or image_width < 1: + raise ValueError("image_width must be a positive integer") + for name, value in ( + ("position_gain", position_gain), + ("damping_gain", damping_gain), + ("max_force", max_force), + ): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not np.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be positive and finite") + + bindings = tuple(joint_bindings) + if tuple(binding.dimension_name for binding in bindings) != action_space.dimension_names: + raise ValueError("joint binding order must match action_space.dimension_names") + physical_names = [name for binding in bindings for name in binding.joint_names] + if len(set(physical_names)) != len(physical_names): + raise ValueError("a MuJoCo joint cannot be controlled by multiple action dimensions") + + self.model = model + self.data = self._mujoco.MjData(model) + self.action_space = action_space + self.observation_space = observation_space + self.joint_bindings = bindings + self.steps_per_action = steps_per_action + self.position_gain = float(position_gain) + self.damping_gain = float(damping_gain) + self.max_force = float(max_force) + self._resolved_bindings = tuple(self._resolve_binding(binding) for binding in bindings) + self._camera_names = dict(camera_names or {}) + expected_images = {image.name for image in observation_space.images} + if set(self._camera_names) != expected_images: + raise ValueError("camera_names keys must exactly match observation_space image names") + for camera_name in self._camera_names.values(): + if self._mujoco.mj_name2id(model, self._mujoco.mjtObj.mjOBJ_CAMERA, camera_name) < 0: + raise ValueError(f"MuJoCo model does not contain camera {camera_name!r}") + self._renderer = ( + self._mujoco.Renderer(model, height=image_height, width=image_width) if self._camera_names else None + ) + self._lock = threading.RLock() + self._closed = False + self._mujoco.mj_forward(self.model, self.data) + + @staticmethod + def _import_mujoco() -> ModuleType: + try: + import mujoco + except ImportError as error: + raise RuntimeError( + "MuJoCo is optional; install it in the simulator virtual environment before using this adapter" + ) from error + return mujoco + + def observe(self) -> RobotObservation: + """Return current joint state and named RGB camera frames.""" + with self._lock: + self._ensure_open() + self._mujoco.mj_forward(self.model, self.data) + timestamp_ns = time.monotonic_ns() + state = torch.tensor(self._read_action_state(), dtype=torch.float32) + images: dict[str, np.ndarray] = {} + if self._renderer is not None: + for observation_name, camera_name in self._camera_names.items(): + self._renderer.update_scene(self.data, camera=camera_name) + images[observation_name] = np.ascontiguousarray(self._renderer.render().copy()) + observation = RobotObservation( + state=RobotState(state, self.action_space.dimension_names, timestamp_ns), + images=images, + metadata={"simulator": "mujoco", "simulation_time_s": float(self.data.time)}, + ) + self.observation_space.validate(observation) + return observation + + def execute(self, action: RobotAction) -> None: + """Advance physics while driving joints toward one absolute action.""" + if not isinstance(action, RobotAction): + raise TypeError("MuJoCo execute expects RobotAction") + self.action_space.require_compatible(action.action_space, context="MuJoCo action space") + values = action.values.detach().to(device="cpu", dtype=torch.float64).numpy() + if values.shape != (self.action_space.dimension,): + raise ValueError(f"MuJoCo action must have shape ({self.action_space.dimension},), got {values.shape}") + if not np.isfinite(values).all(): + raise ValueError("MuJoCo action must contain only finite values") + + with self._lock: + self._ensure_open() + targets = self._resolve_targets(values) + for _ in range(self.steps_per_action): + self._mujoco.mj_forward(self.model, self.data) + self.data.qfrc_applied.fill(0.0) + for target, joints in zip(targets, self._resolved_bindings, strict=True): + for joint in joints: + position_error = target - float(self.data.qpos[joint.qpos_address]) + velocity = float(self.data.qvel[joint.dof_address]) + force = ( + float(self.data.qfrc_bias[joint.dof_address]) + + self.position_gain * position_error + - self.damping_gain * velocity + ) + self.data.qfrc_applied[joint.dof_address] = np.clip( + force, + -self.max_force, + self.max_force, + ) + self._mujoco.mj_step(self.model, self.data) + self.data.qfrc_applied.fill(0.0) + + def reset(self) -> RobotObservation: + """Reset model state and return the first rendered observation.""" + with self._lock: + self._ensure_open() + self._mujoco.mj_resetData(self.model, self.data) + self._mujoco.mj_forward(self.model, self.data) + return self.observe() + + def close(self) -> None: + """Release the optional offscreen renderer.""" + with self._lock: + if self._closed: + return + if self._renderer is not None: + self._renderer.close() + self._closed = True + + def _resolve_binding(self, binding: MuJoCoJointBinding) -> tuple[_ResolvedJoint, ...]: + resolved: list[_ResolvedJoint] = [] + for name in binding.joint_names: + joint_id = self._mujoco.mj_name2id(self.model, self._mujoco.mjtObj.mjOBJ_JOINT, name) + if joint_id < 0: + raise ValueError(f"MuJoCo model does not contain joint {name!r}") + joint_type = int(self.model.jnt_type[joint_id]) + supported_types = { + int(self._mujoco.mjtJoint.mjJNT_HINGE), + int(self._mujoco.mjtJoint.mjJNT_SLIDE), + } + if joint_type not in supported_types: + raise ValueError(f"MuJoCo joint {name!r} must be a hinge or slide joint") + limited = bool(self.model.jnt_limited[joint_id]) + lower, upper = (float(value) for value in self.model.jnt_range[joint_id]) + if binding.normalized_to_joint_range and (not limited or upper <= lower): + raise ValueError(f"normalized MuJoCo joint {name!r} requires a finite increasing range") + resolved.append( + _ResolvedJoint( + qpos_address=int(self.model.jnt_qposadr[joint_id]), + dof_address=int(self.model.jnt_dofadr[joint_id]), + limited=limited, + lower=lower, + upper=upper, + ) + ) + return tuple(resolved) + + def _resolve_targets(self, values: np.ndarray) -> tuple[float, ...]: + targets: list[float] = [] + for value, binding, joints in zip(values, self.joint_bindings, self._resolved_bindings, strict=True): + target = float(value) + if binding.normalized_to_joint_range: + target = float(np.clip(target, 0.0, 1.0)) + target = joints[0].lower + target * (joints[0].upper - joints[0].lower) + lower = max((joint.lower for joint in joints if joint.limited), default=-np.inf) + upper = min((joint.upper for joint in joints if joint.limited), default=np.inf) + targets.append(float(np.clip(target, lower, upper))) + return tuple(targets) + + def _read_action_state(self) -> np.ndarray: + values = np.empty(len(self.joint_bindings), dtype=np.float32) + for index, (binding, joints) in enumerate(zip(self.joint_bindings, self._resolved_bindings, strict=True)): + joint_values = np.asarray([self.data.qpos[joint.qpos_address] for joint in joints], dtype=np.float64) + value = float(joint_values.mean()) + if binding.normalized_to_joint_range: + value = (value - joints[0].lower) / (joints[0].upper - joints[0].lower) + value = float(np.clip(value, 0.0, 1.0)) + values[index] = value + return values + + def _ensure_open(self) -> None: + if self._closed: + raise RuntimeError("MuJoCo simulator adapter is closed") diff --git a/telefuser/integrations/sim/robotwin.py b/telefuser/integrations/sim/robotwin.py new file mode 100644 index 00000000..041b3f30 --- /dev/null +++ b/telefuser/integrations/sim/robotwin.py @@ -0,0 +1,68 @@ +"""Dependency-free boundary between the VLA runtime and RoboTwin processes.""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import torch + +from telefuser.vla.contracts import ActionSpaceSpec, RobotActionChunk, RobotObservation +from telefuser.vla.runtime.executor import ChunkExecutor, RobotAction + + +class RoboTwinSimulatorAdapter: + """Validate semantic actions before invoking RoboTwin-owned callbacks. + + The callbacks keep RoboTwin, SAPIEN, and Vulkan imports in the remote + simulator process rather than making them TeleFuser dependencies. + """ + + def __init__( + self, + action_space: ActionSpaceSpec, + *, + observe_fn: Callable[[], RobotObservation], + execute_fn: Callable[[np.ndarray], None], + reset_fn: Callable[[], RobotObservation], + ) -> None: + self.action_space = action_space + self._observe_fn = observe_fn + self._execute_fn = execute_fn + self._reset_fn = reset_fn + + def observe(self) -> RobotObservation: + """Read one observation from the RoboTwin process.""" + observation = self._observe_fn() + if not isinstance(observation, RobotObservation): + raise TypeError("RoboTwin observe callback must return RobotObservation") + return observation + + def execute(self, action: RobotAction) -> None: + """Submit one validated float32 action vector to RoboTwin.""" + if not isinstance(action, RobotAction): + raise TypeError("RoboTwin execute expects RobotAction") + self.action_space.require_compatible(action.action_space, context="RoboTwin action space") + values = action.values.detach().to(device="cpu", dtype=torch.float32).numpy() + values = np.ascontiguousarray(values, dtype=np.float32) + if values.shape != (self.action_space.dimension,): + raise ValueError(f"RoboTwin action must have shape ({self.action_space.dimension},), got {values.shape}") + if not np.isfinite(values).all(): + raise ValueError("RoboTwin action must contain only finite values") + self._execute_fn(values) + + def execute_chunk(self, chunk: RobotActionChunk) -> int: + """Execute the valid portion of an already prepared chunk.""" + self.action_space.require_compatible(chunk.action_space, context="RoboTwin action space") + count = 0 + for action in ChunkExecutor.iter_actions(chunk): + self.execute(action) + count += 1 + return count + + def reset(self) -> RobotObservation: + """Reset RoboTwin and validate its initial observation.""" + observation = self._reset_fn() + if not isinstance(observation, RobotObservation): + raise TypeError("RoboTwin reset callback must return RobotObservation") + return observation diff --git a/telefuser/pipelines/lingbot_vla_v2/__init__.py b/telefuser/pipelines/lingbot_vla_v2/__init__.py index de38aed1..d51d7fc3 100644 --- a/telefuser/pipelines/lingbot_vla_v2/__init__.py +++ b/telefuser/pipelines/lingbot_vla_v2/__init__.py @@ -3,7 +3,20 @@ from .data import LingBotVlaV2InputProcessor, LingBotVlaV2Inputs, LingBotVlaV2Observation from .pipeline import LingBotVlaV2CanonicalActionChunk, LingBotVlaV2Pipeline, LingBotVlaV2PipelineConfig from .policy import LingBotVlaV2PolicyStage -from .robot_profile import ROBOTWIN_CAMERA_KEYS, LingBotVlaV2ActionChunk, RobotWinProfile +from .robot_profile import ( + LINGBOT_VLA_V2_ACTION_SPACE, + ROBOTWIN_ACTION_ORDER, + ROBOTWIN_ACTION_SPACE, + ROBOTWIN_CAMERA_KEYS, + ROBOTWIN_OBSERVATION_SPACE, + LingBotVlaV2ActionChunk, + RobotWinProfile, +) +from .vla_policy import ( + LINGBOT_VLA_V2_MODEL_ID, + LingBotVlaV2VLAPolicy, + create_lingbot_vla_v2_session_manager, +) __all__ = [ "LingBotVlaV2ActionChunk", @@ -14,6 +27,13 @@ "LingBotVlaV2Pipeline", "LingBotVlaV2PipelineConfig", "LingBotVlaV2PolicyStage", + "LingBotVlaV2VLAPolicy", + "create_lingbot_vla_v2_session_manager", + "LINGBOT_VLA_V2_ACTION_SPACE", + "LINGBOT_VLA_V2_MODEL_ID", + "ROBOTWIN_ACTION_ORDER", + "ROBOTWIN_ACTION_SPACE", "ROBOTWIN_CAMERA_KEYS", + "ROBOTWIN_OBSERVATION_SPACE", "RobotWinProfile", ] diff --git a/telefuser/pipelines/lingbot_vla_v2/action_scheduler.py b/telefuser/pipelines/lingbot_vla_v2/action_scheduler.py index 73439d4f..5427a74f 100644 --- a/telefuser/pipelines/lingbot_vla_v2/action_scheduler.py +++ b/telefuser/pipelines/lingbot_vla_v2/action_scheduler.py @@ -1,271 +1,5 @@ -"""Bounded latest-wins scheduling for LingBot-VLA v2 action chunks.""" +"""Compatibility import for the generic VLA action scheduler.""" -from __future__ import annotations +from telefuser.vla.runtime.scheduler import ActionChunkScheduler -import asyncio -import math -import operator -import time -from collections import OrderedDict -from dataclasses import dataclass -from typing import Any, Callable, Mapping - - -@dataclass(slots=True) -class _ScheduledAction: - request: Mapping[str, Any] - session_key: str - generation: int - received_at: float - deadline_at: float | None - future: asyncio.Future[dict[str, Any]] - - -class ActionChunkScheduler: - """Serialize GPU inference while retaining only the newest pending chunk per session.""" - - def __init__( - self, - infer: Callable[[Mapping[str, Any]], dict[str, Any]], - *, - max_pending_sessions: int = 32, - ) -> None: - if max_pending_sessions < 1: - raise ValueError("max_pending_sessions must be positive") - self._infer = infer - self._max_pending_sessions = max_pending_sessions - self._pending: OrderedDict[str, _ScheduledAction] = OrderedDict() - self._latest_generation: dict[str, int] = {} - self._latest_sequence: dict[str, int] = {} - self._wake = asyncio.Event() - self._worker: asyncio.Task[None] | None = None - self._closed = False - - @property - def metadata(self) -> dict[str, Any]: - """Describe the additive scheduling controls accepted by the endpoint.""" - return { - "scheduling": { - "mode": "latest_wins", - "max_pending_per_session": 1, - "max_pending_sessions": self._max_pending_sessions, - "sequence_field": "sequence_id", - "ttl_field": "request_ttl_ms", - "inflight_cancellation": False, - } - } - - async def start(self) -> None: - """Start the single inference worker on the current event loop.""" - if self._worker is not None: - return - if self._closed: - raise RuntimeError("action scheduler is closed") - self._worker = asyncio.create_task(self._run(), name="lingbot-vla-v2-action-scheduler") - - def submit(self, request: Mapping[str, Any], *, session_key: str) -> asyncio.Future[dict[str, Any]]: - """Admit one request and return a future without waiting for inference.""" - if self._worker is None or self._closed: - raise RuntimeError("action scheduler is not running") - if not session_key: - raise ValueError("session_key must be non-empty") - - loop = asyncio.get_running_loop() - future: asyncio.Future[dict[str, Any]] = loop.create_future() - received_at = time.monotonic() - ttl_ms = self._optional_ttl_ms(request) - sequence_id = self._optional_sequence_id(request) - previous = self._pending.get(session_key) - if previous is None and len(self._pending) >= self._max_pending_sessions: - future.set_result( - self._discarded_response( - request, - status="overloaded", - message="the action scheduler has no free pending-session slot", - ) - ) - return future - if sequence_id is not None: - latest_sequence = self._latest_sequence.get(session_key) - if latest_sequence is not None and sequence_id <= latest_sequence: - future.set_result( - self._discarded_response( - request, - status="stale_sequence", - message=f"sequence_id={sequence_id} is not newer than {latest_sequence}", - ) - ) - return future - self._latest_sequence[session_key] = sequence_id - - generation = self._latest_generation.get(session_key, 0) + 1 - self._latest_generation[session_key] = generation - - deadline_at = None if ttl_ms is None else received_at + ttl_ms / 1000.0 - job = _ScheduledAction( - request=dict(request), - session_key=session_key, - generation=generation, - received_at=received_at, - deadline_at=deadline_at, - future=future, - ) - if previous is not None: - self._resolve( - previous, - self._discarded_response( - previous.request, - status="superseded", - message="a newer observation replaced this pending action request", - ), - ) - self._pending[session_key] = job - self._wake.set() - return future - - def release_session(self, session_key: str) -> None: - """Discard pending work and sequence state for a disconnected session.""" - pending = self._pending.pop(session_key, None) - if pending is not None: - self._resolve( - pending, - self._discarded_response( - pending.request, - status="session_closed", - message="the client session closed before inference", - ), - ) - self._latest_generation.pop(session_key, None) - self._latest_sequence.pop(session_key, None) - - async def close(self) -> None: - """Drain an in-flight call and reject work that has not started.""" - if self._closed: - return - self._closed = True - for job in self._pending.values(): - self._resolve( - job, - self._discarded_response( - job.request, - status="server_stopping", - message="the action scheduler is stopping", - ), - ) - self._pending.clear() - self._wake.set() - if self._worker is not None: - await self._worker - self._worker = None - - async def _run(self) -> None: - while True: - await self._wake.wait() - if self._closed and not self._pending: - return - if not self._pending: - self._wake.clear() - continue - - _, job = self._pending.popitem(last=False) - if not self._pending: - self._wake.clear() - if job.future.done(): - continue - discard = self._discard_reason(job) - if discard is not None: - self._resolve(job, discard) - continue - - inference_started_at = time.monotonic() - try: - response = await asyncio.to_thread(self._infer, job.request) - except Exception as error: - if not job.future.done(): - job.future.set_exception(error) - continue - inference_ms = (time.monotonic() - inference_started_at) * 1000.0 - - discard = self._discard_reason(job) - if discard is not None: - self._resolve(job, discard) - continue - completed_at = time.monotonic() - response = dict(response) - server_timing = dict(response.get("server_timing", {})) - server_timing.update( - queue_wait_ms=(inference_started_at - job.received_at) * 1000.0, - infer_ms=inference_ms, - scheduler_total_ms=(completed_at - job.received_at) * 1000.0, - ) - response.update(scheduler_status="completed", server_timing=server_timing) - for field in ("request_id", "episode_id", "sequence_id"): - if field in job.request: - response.setdefault(field, job.request[field]) - if job.deadline_at is not None: - response["request_ttl_ms"] = (job.deadline_at - job.received_at) * 1000.0 - self._resolve(job, response) - - def _discard_reason(self, job: _ScheduledAction) -> dict[str, Any] | None: - if job.deadline_at is not None and time.monotonic() >= job.deadline_at: - return self._discarded_response( - job.request, - status="expired", - message="the action request exceeded request_ttl_ms", - ) - if self._latest_generation.get(job.session_key) != job.generation: - return self._discarded_response( - job.request, - status="superseded", - message="a newer observation superseded this action result", - ) - return None - - @staticmethod - def _resolve(job: _ScheduledAction, response: dict[str, Any]) -> None: - if not job.future.done(): - job.future.set_result(response) - - @staticmethod - def _optional_sequence_id(request: Mapping[str, Any]) -> int | None: - value = request.get("sequence_id") - if value is None: - return None - if isinstance(value, bool): - raise ValueError("sequence_id must be a non-negative integer") - try: - sequence_id = operator.index(value) - except TypeError as error: - raise ValueError("sequence_id must be a non-negative integer") from error - if sequence_id < 0: - raise ValueError("sequence_id must be a non-negative integer") - return sequence_id - - @staticmethod - def _optional_ttl_ms(request: Mapping[str, Any]) -> float | None: - value = request.get("request_ttl_ms") - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, int | float): - raise ValueError("request_ttl_ms must be a positive finite number") - ttl_ms = float(value) - if not math.isfinite(ttl_ms) or ttl_ms <= 0: - raise ValueError("request_ttl_ms must be a positive finite number") - return ttl_ms - - @staticmethod - def _discarded_response( - request: Mapping[str, Any], - *, - status: str, - message: str, - ) -> dict[str, Any]: - response: dict[str, Any] = { - "action": None, - "scheduler_status": status, - "error": {"code": status, "message": message}, - } - for field in ("request_id", "episode_id", "sequence_id"): - if field in request: - response[field] = request[field] - return response +__all__ = ["ActionChunkScheduler"] diff --git a/telefuser/pipelines/lingbot_vla_v2/robot_profile.py b/telefuser/pipelines/lingbot_vla_v2/robot_profile.py index e460b88d..eff67389 100644 --- a/telefuser/pipelines/lingbot_vla_v2/robot_profile.py +++ b/telefuser/pipelines/lingbot_vla_v2/robot_profile.py @@ -10,6 +10,17 @@ import torch +from telefuser.vla.contracts import ( + ActionSpaceSpec, + ImageObservationSpec, + ModelActionChunk, + ModelObservation, + ObservationSpaceSpec, + RobotActionChunk, + RobotObservation, + RobotState, +) + ROBOTWIN_CAMERA_KEYS = ( "observation.images.cam_high", "observation.images.cam_left_wrist", @@ -19,6 +30,44 @@ CANONICAL_DIM = 55 ARM_SLICE = slice(0, 12) EFFECTOR_SLICE = slice(28, 30) +ROBOTWIN_ACTION_ORDER = ( + "left_arm_joint_0", + "left_arm_joint_1", + "left_arm_joint_2", + "left_arm_joint_3", + "left_arm_joint_4", + "left_arm_joint_5", + "left_gripper", + "right_arm_joint_0", + "right_arm_joint_1", + "right_arm_joint_2", + "right_arm_joint_3", + "right_arm_joint_4", + "right_arm_joint_5", + "right_gripper", +) +LINGBOT_VLA_V2_ACTION_SPACE = ActionSpaceSpec( + representation="canonical_normalized", + dimension_names=tuple(f"canonical_action_{index}" for index in range(CANONICAL_DIM)), + units=("normalized",) * CANONICAL_DIM, + frame=None, + control_hz=None, + normalized=True, + normalization_profile="lingbot_vla_v2_canonical", +) +ROBOTWIN_ACTION_SPACE = ActionSpaceSpec( + representation="absolute_qpos", + dimension_names=ROBOTWIN_ACTION_ORDER, + units=("radian",) * 6 + ("normalized_position",) + ("radian",) * 6 + ("normalized_position",), + frame="robot_joint", + control_hz=None, + normalized=False, +) +ROBOTWIN_OBSERVATION_SPACE = ObservationSpaceSpec( + state_dimension_names=ROBOTWIN_ACTION_ORDER, + images=tuple(ImageObservationSpec(name=key) for key in ROBOTWIN_CAMERA_KEYS), + allow_extra_images=True, +) @dataclass(frozen=True) @@ -39,6 +88,7 @@ class RobotWinProfile: """Map RobotWin observations and actions to LingBot's canonical space.""" name = "robotwin" + embodiment_id = "robotwin" camera_keys = ROBOTWIN_CAMERA_KEYS canonical_dim = CANONICAL_DIM raw_state_dim = ROBOTWIN_STATE_DIM @@ -81,6 +131,50 @@ def action_mask(self) -> torch.Tensor: mask[EFFECTOR_SLICE] = True return mask + @property + def model_action_space(self) -> ActionSpaceSpec: + """Return the semantic action space accepted from LingBot-VLA v2.""" + return LINGBOT_VLA_V2_ACTION_SPACE + + @property + def robot_action_space(self) -> ActionSpaceSpec: + """Return the semantic action space emitted for RoboTwin.""" + return ROBOTWIN_ACTION_SPACE + + @property + def observation_space(self) -> ObservationSpaceSpec: + """Return the RoboTwin state and camera contract.""" + return ROBOTWIN_OBSERVATION_SPACE + + def encode_observation(self, observation: RobotObservation) -> ModelObservation: + """Validate a RoboTwin observation while retaining raw state for the model processor.""" + self.observation_space.validate(observation) + return ModelObservation( + state=observation.state.values, + images={key: observation.images[key] for key in self.camera_keys}, + metadata=observation.metadata, + ) + + def decode_actions( + self, + actions: ModelActionChunk, + robot_state: RobotState, + ) -> RobotActionChunk: + """Map a semantic LingBot chunk to an absolute RoboTwin joint chunk.""" + self.model_action_space.require_compatible(actions.action_space, context="LingBot model action space") + if robot_state.dimension_names != ROBOTWIN_ACTION_ORDER: + raise ValueError("RobotWin state dimension order does not match the robot profile") + structured = self.structure_actions(actions.actions[: actions.valid_length]) + return RobotActionChunk( + actions=structured.raw_actions, + action_space=self.robot_action_space, + valid_length=structured.horizon, + observation_timestamp_ns=actions.observation_timestamp_ns, + sequence_id=actions.sequence_id, + episode_id=actions.episode_id, + metadata=actions.metadata, + ) + def normalize_state(self, raw_state: torch.Tensor | Sequence[float]) -> torch.Tensor: """Convert one raw 14-D RobotWin state to normalized canonical 55-D space.""" state = torch.as_tensor(raw_state, dtype=torch.float32, device="cpu") diff --git a/telefuser/pipelines/lingbot_vla_v2/runtime.py b/telefuser/pipelines/lingbot_vla_v2/runtime.py index 83333399..2bb319d8 100644 --- a/telefuser/pipelines/lingbot_vla_v2/runtime.py +++ b/telefuser/pipelines/lingbot_vla_v2/runtime.py @@ -8,6 +8,7 @@ from telefuser.core.config import ModelRuntimeConfig, QuantConfig, QuantKernelBackend, QuantType from telefuser.core.module_manager import ModuleManager from telefuser.models.lingbot_vla_v2_loader import load_lingbot_vla_v2 +from telefuser.utils.logging import logger from .pipeline import LingBotVlaV2Pipeline, LingBotVlaV2PipelineConfig @@ -19,6 +20,24 @@ def _apply_cuda_runtime_flags(device: torch.device) -> None: torch.set_float32_matmul_precision("high") +def configure_lingbot_vla_v2_h100_sdpa(device: str) -> None: + """Avoid unsupported cuDNN SDPA plans in a LingBot H100 process.""" + resolved_device = torch.device(device) + if resolved_device.type != "cuda" or not torch.cuda.is_available(): + return + if "H100" not in torch.cuda.get_device_name(resolved_device): + return + if hasattr(torch.backends.cuda, "enable_cudnn_sdp"): + torch.backends.cuda.enable_cudnn_sdp(False) + if hasattr(torch.backends.cuda, "enable_flash_sdp"): + torch.backends.cuda.enable_flash_sdp(True) + if hasattr(torch.backends.cuda, "enable_math_sdp"): + torch.backends.cuda.enable_math_sdp(True) + if hasattr(torch.backends.cuda, "enable_mem_efficient_sdp"): + torch.backends.cuda.enable_mem_efficient_sdp(True) + logger.info("Disabled cuDNN SDPA for the LingBot-VLA v2 H100 policy process") + + def lingbot_vla_v2_quant_config(quantization: str | QuantType | None) -> QuantConfig: """Resolve a public LingBot-VLA v2 online-quantization name.""" if quantization is None: diff --git a/telefuser/pipelines/lingbot_vla_v2/vla_policy.py b/telefuser/pipelines/lingbot_vla_v2/vla_policy.py new file mode 100644 index 00000000..4b3329f5 --- /dev/null +++ b/telefuser/pipelines/lingbot_vla_v2/vla_policy.py @@ -0,0 +1,83 @@ +"""Semantic VLA policy wrapper for the existing LingBot-VLA v2 pipeline.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from telefuser.vla.contracts import ModelActionChunk, VLACapabilities, VLARequest +from telefuser.vla.registry import VLARegistry +from telefuser.vla.session import VLASessionManager + +from .data import LingBotVlaV2Observation +from .robot_profile import LINGBOT_VLA_V2_ACTION_SPACE, RobotWinProfile + +LINGBOT_VLA_V2_MODEL_ID = "lingbot-vla-v2" + + +class _LingBotPipeline(Protocol): + def __call__(self, observation: LingBotVlaV2Observation, seed: int | None = None) -> Any: ... + + +class LingBotVlaV2VLAPolicy: + """Adapt the stable LingBot pipeline API to the shared semantic contract.""" + + def __init__(self, pipeline: _LingBotPipeline, *, max_horizon: int = 50) -> None: + if not isinstance(max_horizon, int) or isinstance(max_horizon, bool) or max_horizon < 1: + raise ValueError("max_horizon must be positive") + self.pipeline = pipeline + self._capabilities = VLACapabilities( + model_id=LINGBOT_VLA_V2_MODEL_ID, + output_action_space=LINGBOT_VLA_V2_ACTION_SPACE, + max_horizon=max_horizon, + stateful=False, + supports_seed=True, + ) + + def capabilities(self) -> VLACapabilities: + """Describe the canonical LingBot action output.""" + return self._capabilities + + def predict(self, request: VLARequest) -> ModelActionChunk: + """Run the existing pipeline and attach semantic request provenance.""" + observation = LingBotVlaV2Observation( + task=request.instruction, + state=request.observation.state, + images=request.observation.images, + ) + output = self.pipeline(observation, seed=request.seed) + if output.horizon > self._capabilities.max_horizon: + raise RuntimeError( + f"LingBot pipeline returned horizon {output.horizon}, exceeding declared maximum " + f"{self._capabilities.max_horizon}" + ) + return ModelActionChunk( + actions=output.canonical_normalized_actions, + action_space=self._capabilities.output_action_space, + valid_length=output.horizon, + observation_timestamp_ns=request.observation_timestamp_ns, + sequence_id=request.sequence_id, + episode_id=request.episode_id, + metadata={ + "checkpoint_variant": output.checkpoint_variant, + "policy_verified": output.policy_verified, + "verification_status": output.verification_status, + }, + ) + + def reset(self, episode_id: str) -> None: + """Validate RESET for the currently stateless LingBot base policy.""" + if not isinstance(episode_id, str) or not episode_id: + raise ValueError("episode_id must be a non-empty string") + + +def create_lingbot_vla_v2_session_manager( + pipeline: _LingBotPipeline, + *, + profile: RobotWinProfile | None = None, +) -> VLASessionManager: + """Register the shared LingBot policy and RoboTwin embodiment once.""" + resolved_profile = profile or RobotWinProfile.default() + registry = VLARegistry() + registry.register_policy(LINGBOT_VLA_V2_MODEL_ID, LingBotVlaV2VLAPolicy(pipeline)) + registry.register_embodiment(resolved_profile.embodiment_id, resolved_profile) + return VLASessionManager(registry) diff --git a/telefuser/service/core/pipeline_pool.py b/telefuser/service/core/pipeline_pool.py index 74b982fb..97645a2e 100644 --- a/telefuser/service/core/pipeline_pool.py +++ b/telefuser/service/core/pipeline_pool.py @@ -7,10 +7,12 @@ from __future__ import annotations import asyncio +import contextlib import multiprocessing as mp_stdlib import os import threading from contextlib import asynccontextmanager +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, AsyncIterator from telefuser.platforms import current_platform @@ -30,6 +32,13 @@ from .task_manager import TaskManager +@dataclass +class _ReplicaSessionLease: + replica_id: int + lock: asyncio.Lock + closing: bool = False + + class PipelinePool: """Pool of pipeline replicas in separate subprocesses. @@ -55,11 +64,17 @@ def __init__( self._instance_status: list[str] = [] self._live_count = num_replicas self._status_lock = threading.Lock() + self._session_leases: dict[str, _ReplicaSessionLease] = {} + self._opening_sessions: set[str] = set() + self._session_leases_lock = asyncio.Lock() + self._session_shutdown = False + self._session_shutdown_event = asyncio.Event() self.is_running = False self._cached_server_metadata: dict[str, Any] = {} self._cached_supported_tasks: tuple[str, ...] = () self._cached_task_contracts: dict[str, Any] = {} + self._cached_vla_metadata: dict[str, Any] = {} def start_all( self, @@ -67,13 +82,22 @@ def start_all( parallelism_per_replica: int, task: str, skip_validation: bool, + *, + vla_provider_factory: str | None = None, ) -> bool: """Start all replica subprocesses. Returns True on success.""" device_env_var = current_platform.device_control_env_var original_cvd = os.environ.get(device_env_var) try: - return self._start_all_replicas(ppl_file, parallelism_per_replica, task, skip_validation, device_env_var) + return self._start_all_replicas( + ppl_file, + parallelism_per_replica, + task, + skip_validation, + device_env_var, + vla_provider_factory, + ) finally: self._restore_cvd(device_env_var, original_cvd) @@ -84,6 +108,7 @@ def _start_all_replicas( task: str, skip_validation: bool, device_env_var: str, + vla_provider_factory: str | None, ) -> bool: for i, device_ids in enumerate(self._replica_device_ids): visible_devices = ",".join(device_ids) @@ -108,6 +133,7 @@ def _start_all_replicas( self._security_level_name, skip_validation, self._server_config_data, + vla_provider_factory, ), daemon=False, ) @@ -141,6 +167,12 @@ def _start_all_replicas( self._cached_server_metadata = payload.get("server_metadata", {}) self._cached_supported_tasks = tuple(payload.get("supported_tasks", [])) self._cached_task_contracts = payload.get("task_contracts", {}) + self._cached_vla_metadata = payload.get("vla", {}) + elif payload.get("vla", {}) != self._cached_vla_metadata: + logger.error(f"Replica {i} VLA metadata differs from replica 0") + p.terminate() + self._cleanup_started() + return False handle = ReplicaHandle( replica_id=i, @@ -154,6 +186,8 @@ def _start_all_replicas( self._available.put_nowait(i) self.is_running = True + self._session_shutdown = False + self._session_shutdown_event.clear() logger.info(f"Pipeline pool started: num_replicas={self._num_replicas}") return True @@ -174,6 +208,8 @@ def _restore_cvd(env_var: str, original: str | None) -> None: def _evict_replica(self, idx: int, reason: str) -> None: """Evict a dead replica: mark status, shutdown, shrink capacity.""" with self._status_lock: + if self._instance_status[idx] == "dead": + return self._instance_status[idx] = "dead" self._live_count -= 1 live = self._live_count @@ -227,6 +263,108 @@ async def acquire(self) -> AsyncIterator[ReplicaHandle]: self._instance_status[idx] = "idle" self._available.put_nowait(idx) + async def open_session(self, session_id: str, *, timeout_s: float | None = None) -> int: + """Reserve one healthy replica for a long-lived session.""" + self._validate_session_id(session_id) + if timeout_s is not None and ( + isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) or timeout_s <= 0 + ): + raise ValueError("timeout_s must be None or a positive number") + async with self._session_leases_lock: + if self._session_shutdown: + raise RuntimeError("pipeline pool is shutting down") + if session_id in self._session_leases or session_id in self._opening_sessions: + raise ValueError(f"pipeline session is already open: {session_id!r}") + self._opening_sessions.add(session_id) + + idx: int | None = None + registered = False + try: + idx = await self._take_available_replica(timeout_s=None if timeout_s is None else float(timeout_s)) + async with self._session_leases_lock: + if self._session_shutdown: + raise RuntimeError("pipeline pool is shutting down") + self._session_leases[session_id] = _ReplicaSessionLease(idx, asyncio.Lock()) + self._opening_sessions.discard(session_id) + registered = True + with self._status_lock: + self._instance_status[idx] = "session_reserved" + return idx + finally: + if not registered: + async with self._session_leases_lock: + self._opening_sessions.discard(session_id) + if idx is not None: + self._return_available_replica(idx) + + @asynccontextmanager + async def acquire_session(self, session_id: str) -> AsyncIterator[ReplicaHandle]: + """Acquire the replica pinned to a session, serializing its requests.""" + lease = await self._get_session_lease(session_id) + async with lease.lock: + async with self._session_leases_lock: + if self._session_leases.get(session_id) is not lease or lease.closing: + raise KeyError(f"unknown or closing pipeline session: {session_id!r}") + + idx = lease.replica_id + handle = self._handles[idx] + if self._handle_is_dead(handle): + async with self._session_leases_lock: + self._session_leases.pop(session_id, None) + self._evict_replica(idx, "session replica process exited") + raise ReplicaDeadError(f"Replica {idx} assigned to session {session_id!r} is not alive") + + with self._status_lock: + self._instance_status[idx] = "session_busy" + try: + yield handle + except ReplicaDeadError: + async with self._session_leases_lock: + self._session_leases.pop(session_id, None) + self._evict_replica(idx, "died during session execution") + raise + else: + with self._status_lock: + if self._instance_status[idx] != "dead": + self._instance_status[idx] = "session_reserved" + + async def close_session(self, session_id: str) -> int: + """Release a session lease and return its live replica to the idle pool.""" + lease = await self._get_session_lease(session_id) + async with self._session_leases_lock: + if self._session_leases.get(session_id) is not lease or lease.closing: + raise KeyError(f"unknown or closing pipeline session: {session_id!r}") + lease.closing = True + + async with lease.lock: + async with self._session_leases_lock: + if self._session_leases.get(session_id) is not lease: + raise KeyError(f"unknown pipeline session: {session_id!r}") + self._session_leases.pop(session_id) + self._return_available_replica(lease.replica_id) + return lease.replica_id + + async def session_replica_id(self, session_id: str) -> int: + """Return the stable replica assignment for an open session.""" + return (await self._get_session_lease(session_id)).replica_id + + async def session_bindings(self) -> dict[str, int]: + """Return a monitoring snapshot of active session-to-replica bindings.""" + async with self._session_leases_lock: + return {session_id: lease.replica_id for session_id, lease in sorted(self._session_leases.items())} + + async def run_vla_operation( + self, + session_id: str, + operation: str, + payload: dict[str, Any], + *, + timeout_s: float | None = None, + ) -> dict[str, Any]: + """Run one VLA operation on the replica permanently assigned to a session.""" + async with self.acquire_session(session_id) as handle: + return await handle.run_vla_operation(operation, payload, timeout_s=timeout_s) + async def run_task_with_stop_event( self, task_data: dict, @@ -240,12 +378,97 @@ async def run_task_with_stop_event( async def aclose(self) -> None: """Shutdown all replicas.""" + async with self._session_leases_lock: + self._session_shutdown = True + self._session_shutdown_event.set() + leases = tuple(self._session_leases.values()) + for lease in leases: + lease.closing = True + for lease in leases: + async with lease.lock: + pass + async with self._session_leases_lock: + self._session_leases.clear() + self._opening_sessions.clear() for h in self._handles: h.shutdown() self._handles.clear() self._instance_status.clear() self.is_running = False + async def _get_session_lease(self, session_id: str) -> _ReplicaSessionLease: + self._validate_session_id(session_id) + async with self._session_leases_lock: + try: + lease = self._session_leases[session_id] + except KeyError as error: + raise KeyError(f"unknown pipeline session: {session_id!r}") from error + if lease.closing: + raise KeyError(f"unknown or closing pipeline session: {session_id!r}") + return lease + + async def _take_available_replica(self, timeout_s: float | None) -> int: + loop = asyncio.get_running_loop() + deadline = None if timeout_s is None else loop.time() + timeout_s + while True: + if self._session_shutdown: + raise RuntimeError("pipeline pool is shutting down") + with self._status_lock: + if self._live_count == 0: + raise RuntimeError("All pipeline replicas are dead; no inference capacity") + wait_s = 5.0 if deadline is None else min(5.0, max(0.0, deadline - loop.time())) + if wait_s == 0: + raise TimeoutError("timed out waiting for an available pipeline replica") + available = asyncio.create_task(self._available.get()) + shutdown = asyncio.create_task(self._session_shutdown_event.wait()) + try: + done, _ = await asyncio.wait( + (available, shutdown), + timeout=wait_s, + return_when=asyncio.FIRST_COMPLETED, + ) + if shutdown in done: + raise RuntimeError("pipeline pool is shutting down") + if available not in done: + if deadline is not None and loop.time() >= deadline: + raise TimeoutError("timed out waiting for an available pipeline replica") + continue + idx = available.result() + finally: + for task in (available, shutdown): + if not task.done(): + task.cancel() + for task in (available, shutdown): + with contextlib.suppress(asyncio.CancelledError): + await task + handle = self._handles[idx] + if self._handle_is_dead(handle): + self._evict_replica(idx, "unavailable while opening session") + continue + return idx + + def _return_available_replica(self, idx: int) -> None: + if idx >= len(self._handles): + return + handle = self._handles[idx] + if self._handle_is_dead(handle): + if idx < len(self._instance_status) and self._instance_status[idx] != "dead": + self._evict_replica(idx, "session closed after replica exit") + return + with self._status_lock: + self._instance_status[idx] = "idle" + self._available.put_nowait(idx) + + @staticmethod + def _handle_is_dead(handle: ReplicaHandle) -> bool: + process = getattr(handle, "process", None) + return handle._dead or process is not None and not process.is_alive() + + @staticmethod + def _validate_session_id(session_id: str) -> None: + if not isinstance(session_id, str) or not session_id: + raise ValueError("session_id must be a non-empty string") + # --- Metadata proxy (pool overlay) --- def server_metadata(self) -> dict: @@ -274,6 +497,10 @@ def get_task_contract(self, task: str) -> Any: """Return the task-level contract for a declared task, if available.""" return self._cached_task_contracts.get(task) + def vla_metadata(self) -> dict[str, Any]: + """Return component metadata reported consistently by all VLA replicas.""" + return dict(self._cached_vla_metadata) + def pool_status(self) -> list[dict]: """Return per-replica status for monitoring.""" with self._status_lock: diff --git a/telefuser/service/core/replica_worker.py b/telefuser/service/core/replica_worker.py index 61945089..9162328d 100644 --- a/telefuser/service/core/replica_worker.py +++ b/telefuser/service/core/replica_worker.py @@ -31,6 +31,14 @@ class ReplicaDeadError(RuntimeError): pass +class ReplicaVLAError(RuntimeError): + """Structured application error returned by a live VLA replica.""" + + def __init__(self, error_type: str, message: str) -> None: + super().__init__(message) + self.error_type = error_type + + # --------------------------------------------------------------------------- # Subprocess entry point # --------------------------------------------------------------------------- @@ -48,6 +56,7 @@ def _replica_main( security_level_name: str, skip_validation: bool, server_config_data: dict[str, Any] | None = None, + vla_provider_factory_name: str | None = None, ) -> None: """Entry point for a replica subprocess. @@ -91,22 +100,57 @@ def _replica_main( loop.close() return - metadata = _collect_metadata(svc) + vla_provider = None + if vla_provider_factory_name is not None: + try: + module = svc._module + if module is None or not hasattr(module, vla_provider_factory_name): + raise RuntimeError( + f"Pipeline file must define optional VLA provider factory {vla_provider_factory_name}(pipeline)" + ) + factory = getattr(module, vla_provider_factory_name) + vla_provider = factory(svc.pipeline) + if not callable(getattr(vla_provider, "dispatch", None)): + raise TypeError("VLA provider must define dispatch(operation, payload)") + if not callable(getattr(vla_provider, "metadata", None)): + raise TypeError("VLA provider must define metadata()") + except Exception as e: + conn.send(("error", f"Replica {replica_id} VLA provider init failed: {e}\n{traceback.format_exc()}")) + loop.run_until_complete(svc.aclose()) + conn.close() + loop.close() + return + + try: + metadata = _collect_metadata(svc, vla_provider=vla_provider) + except Exception as e: + conn.send(("error", f"Replica {replica_id} metadata collection failed: {e}\n{traceback.format_exc()}")) + if vla_provider is not None and callable(getattr(vla_provider, "close", None)): + vla_provider.close() + loop.run_until_complete(svc.aclose()) + conn.close() + loop.close() + return conn.send(("ready", metadata)) logger.info(f"Replica {replica_id} ready, entering task loop") try: - loop.run_until_complete(_task_loop(replica_id, svc, conn, cancel_event, logger)) + loop.run_until_complete(_task_loop(replica_id, svc, conn, cancel_event, logger, vla_provider=vla_provider)) except Exception as e: logger.error(f"Replica {replica_id} loop crashed: {e}") finally: + if vla_provider is not None and callable(getattr(vla_provider, "close", None)): + try: + vla_provider.close() + except Exception as e: + logger.warning(f"Replica {replica_id} VLA provider cleanup failed: {e}") loop.run_until_complete(svc.aclose()) loop.close() conn.close() logger.info(f"Replica {replica_id} exited") -def _collect_metadata(svc: Any) -> dict: +def _collect_metadata(svc: Any, *, vla_provider: Any | None = None) -> dict: """Collect metadata from initialized PipelineService for pool caching.""" task_contracts: dict[str, Any] = {} for t in svc.supported_tasks(): @@ -115,11 +159,17 @@ def _collect_metadata(svc: Any) -> dict: task_contracts[t] = tc.to_metadata() elif isinstance(tc, dict): task_contracts[t] = tc - return { + metadata = { "server_metadata": svc.server_metadata(), "supported_tasks": list(svc.supported_tasks()), "task_contracts": task_contracts, } + if vla_provider is not None: + vla_metadata = vla_provider.metadata() + if not isinstance(vla_metadata, dict): + raise TypeError("VLA provider metadata must be a dictionary") + metadata["vla"] = vla_metadata + return metadata def _cancel_watcher_fn( @@ -139,6 +189,8 @@ async def _task_loop( conn: Connection, cancel_event: mp_stdlib.Event, logger: Any, + *, + vla_provider: Any | None = None, ) -> None: """Persistent async task loop inside the replica subprocess.""" loop = asyncio.get_running_loop() @@ -179,6 +231,18 @@ async def _task_loop( finally: cancel_event.set() forwarder_done.wait(1.0) + continue + + if msg[0] == "vla": + _, operation, payload = msg + if vla_provider is None: + conn.send(("vla_error", {"type": "RuntimeError", "message": "replica has no VLA provider"})) + continue + try: + result = await asyncio.to_thread(vla_provider.dispatch, operation, payload) + conn.send(("ok", result)) + except Exception as e: + conn.send(("vla_error", {"type": type(e).__name__, "message": str(e)})) def _recv_with_poll(conn: Connection, timeout: float) -> Any: @@ -279,6 +343,48 @@ async def run_task( raise RuntimeError(f"Replica {self.replica_id}: {payload}") return payload + async def run_vla_operation( + self, + operation: str, + payload: dict[str, Any], + *, + timeout_s: float | None = None, + ) -> dict[str, Any]: + """Send one session-affine VLA operation to this replica.""" + if not isinstance(operation, str) or not operation: + raise ValueError("operation must be a non-empty string") + if timeout_s is not None and ( + isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) or timeout_s <= 0 + ): + raise ValueError("timeout_s must be None or a positive number") + ipc_timeout = (float(timeout_s) if timeout_s is not None else 600.0) + _TASK_IPC_MARGIN_S + loop = asyncio.get_running_loop() + if not self.process.is_alive(): + self._dead = True + raise ReplicaDeadError(f"Replica {self.replica_id} process is not alive") + try: + self.conn.send(("vla", operation, payload)) + result = await loop.run_in_executor(None, self._recv_with_health_check, ipc_timeout) + except (EOFError, OSError) as error: + self._dead = True + raise ReplicaDeadError(f"Replica {self.replica_id} IPC failed: {error}") from error + if result is None: + self._dead = True + raise ReplicaDeadError( + f"Replica {self.replica_id} did not respond within {ipc_timeout}s " + f"(process alive: {self.process.is_alive()})" + ) + tag, response = result + if tag == "vla_error": + if not isinstance(response, dict): + raise RuntimeError(f"Replica {self.replica_id} returned an invalid VLA error") + raise ReplicaVLAError(str(response.get("type", "RuntimeError")), str(response.get("message", ""))) + if tag == "error": + raise RuntimeError(f"Replica {self.replica_id}: {response}") + if tag != "ok" or not isinstance(response, dict): + raise RuntimeError(f"Replica {self.replica_id} returned an invalid VLA response") + return response + def _recv_with_health_check(self, total_timeout: float) -> tuple[str, Any] | None: """Receive with periodic health checks. Returns (tag, payload) or None.""" from telefuser.utils.logging import logger diff --git a/telefuser/service/vla_replica.py b/telefuser/service/vla_replica.py new file mode 100644 index 00000000..a8297551 --- /dev/null +++ b/telefuser/service/vla_replica.py @@ -0,0 +1,175 @@ +"""Worker-local adapter between replica RPC and semantic VLA sessions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from telefuser.vla import VLASessionManager +from telefuser.vla.serialization import ( + DEFAULT_MAX_TENSOR_BYTES, + action_space_from_wire, + action_space_to_wire, + observation_space_from_wire, + observation_space_to_wire, + robot_action_chunk_to_wire, + robot_observation_from_wire, +) + + +class VLAReplicaProvider: + """Dispatch VLA lifecycle operations inside one pipeline replica.""" + + def __init__( + self, + sessions: VLASessionManager, + *, + max_tensor_bytes: int = DEFAULT_MAX_TENSOR_BYTES, + ) -> None: + if not isinstance(max_tensor_bytes, int) or isinstance(max_tensor_bytes, bool) or max_tensor_bytes < 1: + raise ValueError("max_tensor_bytes must be a positive integer") + self.sessions = sessions + self.max_tensor_bytes = max_tensor_bytes + + def metadata(self) -> dict[str, Any]: + """Return stable component discovery metadata without opening a session.""" + return { + "model_ids": list(self.sessions.registry.model_ids()), + "embodiment_ids": list(self.sessions.registry.embodiment_ids()), + } + + def dispatch(self, operation: str, payload: Mapping[str, Any]) -> dict[str, Any]: + """Execute one validated worker-local VLA operation.""" + if not isinstance(operation, str): + raise ValueError("VLA replica operation must be a string") + if not isinstance(payload, Mapping): + raise ValueError("VLA replica payload must be an object") + operation = operation.upper() + if operation == "OPEN": + return self._open(payload) + session_id = _required_string(payload, "session_id") + if operation == "PREDICT": + return self._predict(session_id, payload) + if operation == "RESET": + self.sessions.reset(session_id, _optional_string(payload, "episode_id")) + return {"session_id": session_id} + if operation == "CLOSE": + self.sessions.close(session_id) + return {"session_id": session_id} + raise ValueError(f"unsupported VLA replica operation: {operation!r}") + + def close(self) -> None: + """Release every worker-local session during replica shutdown.""" + for session_id in self.sessions.session_ids(): + self.sessions.close(session_id) + + def _open(self, payload: Mapping[str, Any]) -> dict[str, Any]: + session_id = _required_string(payload, "session_id") + model_id = _required_string(payload, "model_id") + embodiment_id = _required_string(payload, "embodiment_id") + policy = self.sessions.registry.get_policy(model_id) + embodiment = self.sessions.registry.get_embodiment(embodiment_id) + expected_payload = payload.get("expected_robot_action_space") + if expected_payload is not None: + if not isinstance(expected_payload, Mapping): + raise ValueError("expected_robot_action_space must be an object") + action_space_from_wire(expected_payload).require_compatible( + embodiment.robot_action_space, + context="client and embodiment robot action space", + ) + expected_observation_payload = payload.get("expected_robot_observation_space") + if expected_observation_payload is not None: + if not isinstance(expected_observation_payload, Mapping): + raise ValueError("expected_robot_observation_space must be an object") + observation_space_from_wire(expected_observation_payload).require_compatible( + embodiment.observation_space, + context="client and embodiment robot observation space", + ) + self.sessions.open( + session_id, + model_id=model_id, + embodiment_id=embodiment_id, + episode_id=_required_string(payload, "episode_id"), + execute_horizon=_optional_positive_int(payload, "execute_horizon"), + max_observation_age_ns=_optional_positive_int(payload, "max_observation_age_ns"), + ) + capabilities = policy.capabilities() + return { + "session_id": session_id, + "model_id": model_id, + "embodiment_id": embodiment_id, + "model_action_space": action_space_to_wire(capabilities.output_action_space), + "robot_action_space": action_space_to_wire(embodiment.robot_action_space), + "robot_observation_space": observation_space_to_wire(embodiment.observation_space), + "max_horizon": capabilities.max_horizon, + "stateful": capabilities.stateful, + "supports_seed": capabilities.supports_seed, + } + + def _predict(self, session_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: + observation_payload = payload.get("observation") + if not isinstance(observation_payload, Mapping): + raise ValueError("PREDICT requires an observation object") + max_tensor_bytes = payload.get("_max_tensor_bytes", self.max_tensor_bytes) + if not isinstance(max_tensor_bytes, int) or isinstance(max_tensor_bytes, bool) or max_tensor_bytes < 1: + raise ValueError("_max_tensor_bytes must be a positive integer") + observation = robot_observation_from_wire(observation_payload, max_tensor_bytes=max_tensor_bytes) + timestamp_ns = _required_nonnegative_int(payload, "observation_timestamp_ns") + if timestamp_ns != observation.state.timestamp_ns: + raise ValueError("PREDICT observation_timestamp_ns must match observation.state.timestamp_ns") + chunk = self.sessions.get(session_id).predict( + observation, + _required_string(payload, "instruction"), + _required_nonnegative_int(payload, "sequence_id"), + seed=_optional_integer(payload, "seed"), + now_ns=_optional_nonnegative_int(payload, "observation_clock_now_ns"), + ) + return {"session_id": session_id, "chunk": robot_action_chunk_to_wire(chunk)} + + +def _required_string(payload: Mapping[str, Any], field: str) -> str: + value = payload.get(field) + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _optional_string(payload: Mapping[str, Any], field: str) -> str | None: + value = payload.get(field) + if value is None: + return None + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _required_nonnegative_int(payload: Mapping[str, Any], field: str) -> int: + value = _optional_nonnegative_int(payload, field) + if value is None: + raise ValueError(f"{field} is required") + return value + + +def _optional_nonnegative_int(payload: Mapping[str, Any], field: str) -> int | None: + value = payload.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"{field} must be a non-negative integer") + return value + + +def _optional_positive_int(payload: Mapping[str, Any], field: str) -> int | None: + value = _optional_nonnegative_int(payload, field) + if value is not None and value < 1: + raise ValueError(f"{field} must be a positive integer") + return value + + +def _optional_integer(payload: Mapping[str, Any], field: str) -> int | None: + value = payload.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"{field} must be an integer") + return value diff --git a/telefuser/service/vla_session.py b/telefuser/service/vla_session.py new file mode 100644 index 00000000..c83b4b55 --- /dev/null +++ b/telefuser/service/vla_session.py @@ -0,0 +1,777 @@ +"""Generic versioned WebSocket transport for typed VLA sessions.""" + +from __future__ import annotations + +import asyncio +import contextlib +import math +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from typing import Any, Protocol + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect + +from telefuser.service.core.pipeline_pool import PipelinePool +from telefuser.service.core.replica_worker import ReplicaDeadError, ReplicaVLAError +from telefuser.vla import VLASessionManager +from telefuser.vla.contracts import ActionSpaceSpec, ObservationSpaceSpec, RobotActionChunk +from telefuser.vla.runtime import ActionChunkStateMachine, ChunkStatus +from telefuser.vla.runtime.chunk_state import ChunkTicket +from telefuser.vla.serialization import ( + DEFAULT_MAX_TENSOR_BYTES, + VLA_SESSION_PROTOCOL_VERSION, + VLA_WIRE_ENCODING, + action_space_from_wire, + action_space_to_wire, + dumps_wire_message, + loads_wire_message, + observation_space_from_wire, + observation_space_to_wire, + robot_action_chunk_from_wire, + robot_action_chunk_to_wire, + robot_observation_from_wire, +) + +DEFAULT_MAX_VLA_MESSAGE_BYTES = 64 * 1024 * 1024 + + +class VLAErrorCode(str, Enum): + """Stable error categories returned by the VLA session protocol.""" + + INVALID_MESSAGE = "invalid_message" + UNSUPPORTED_VERSION = "unsupported_version" + SESSION_EXISTS = "session_exists" + UNKNOWN_SESSION = "unknown_session" + UNKNOWN_COMPONENT = "unknown_component" + ACTION_SPACE_MISMATCH = "action_space_mismatch" + OBSERVATION_SPACE_MISMATCH = "observation_space_mismatch" + OUT_OF_ORDER = "out_of_order" + SUPERSEDED = "superseded" + EXPIRED = "expired" + TIMEOUT = "timeout" + REPLICA_UNAVAILABLE = "replica_unavailable" + SESSION_UNAVAILABLE = "session_unavailable" + INTERNAL_ERROR = "internal_error" + + +class VLAProtocolError(ValueError): + """Protocol failure carrying a stable machine-readable error code.""" + + def __init__(self, code: VLAErrorCode, message: str) -> None: + super().__init__(message) + self.code = code + + +class _VLABackend(Protocol): + def metadata(self) -> Mapping[str, Any]: ... + + async def open(self, payload: Mapping[str, Any]) -> dict[str, Any]: ... + + async def predict(self, session_id: str, payload: Mapping[str, Any]) -> RobotActionChunk: ... + + async def reset(self, session_id: str, episode_id: str | None) -> None: ... + + async def close(self, session_id: str) -> None: ... + + +class _LocalVLABackend: + def __init__(self, sessions: VLASessionManager, max_tensor_bytes: int) -> None: + self.sessions = sessions + self.max_tensor_bytes = max_tensor_bytes + + def metadata(self) -> Mapping[str, Any]: + return { + "model_ids": list(self.sessions.registry.model_ids()), + "embodiment_ids": list(self.sessions.registry.embodiment_ids()), + } + + async def open(self, payload: Mapping[str, Any]) -> dict[str, Any]: + session_id = _required_string(payload, "session_id") + if session_id in self.sessions.session_ids(): + raise VLAProtocolError(VLAErrorCode.SESSION_EXISTS, f"VLA session is already open: {session_id!r}") + model_id = _required_string(payload, "model_id") + embodiment_id = _required_string(payload, "embodiment_id") + try: + policy = self.sessions.registry.get_policy(model_id) + embodiment = self.sessions.registry.get_embodiment(embodiment_id) + except KeyError as error: + raise VLAProtocolError(VLAErrorCode.UNKNOWN_COMPONENT, str(error)) from error + _validate_expected_action_space(payload, embodiment.robot_action_space) + _validate_expected_observation_space(payload, embodiment.observation_space) + await asyncio.to_thread( + self.sessions.open, + session_id, + model_id=model_id, + embodiment_id=embodiment_id, + episode_id=_required_string(payload, "episode_id"), + execute_horizon=_optional_positive_int(payload, "execute_horizon"), + max_observation_age_ns=_optional_positive_int(payload, "max_observation_age_ns"), + ) + capabilities = policy.capabilities() + return { + "session_id": session_id, + "model_id": model_id, + "embodiment_id": embodiment_id, + "model_action_space": action_space_to_wire(capabilities.output_action_space), + "robot_action_space": action_space_to_wire(embodiment.robot_action_space), + "robot_observation_space": observation_space_to_wire(embodiment.observation_space), + "max_horizon": capabilities.max_horizon, + "stateful": capabilities.stateful, + "supports_seed": capabilities.supports_seed, + } + + async def predict(self, session_id: str, payload: Mapping[str, Any]) -> RobotActionChunk: + observation_payload = payload.get("observation") + if not isinstance(observation_payload, Mapping): + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "PREDICT requires an observation object") + observation = robot_observation_from_wire(observation_payload, max_tensor_bytes=self.max_tensor_bytes) + timestamp_ns = _required_nonnegative_int(payload, "observation_timestamp_ns") + if timestamp_ns != observation.state.timestamp_ns: + raise VLAProtocolError( + VLAErrorCode.INVALID_MESSAGE, + "PREDICT observation_timestamp_ns must match observation.state.timestamp_ns", + ) + return await asyncio.to_thread( + self.sessions.get(session_id).predict, + observation, + _required_string(payload, "instruction"), + _required_nonnegative_int(payload, "sequence_id"), + seed=_optional_integer(payload, "seed"), + now_ns=_optional_nonnegative_int(payload, "observation_clock_now_ns"), + ) + + async def reset(self, session_id: str, episode_id: str | None) -> None: + await asyncio.to_thread(self.sessions.reset, session_id, episode_id) + + async def close(self, session_id: str) -> None: + self.sessions.close(session_id) + + +class _PipelinePoolVLABackend: + def __init__(self, pool: PipelinePool, max_tensor_bytes: int) -> None: + self.pool = pool + self.max_tensor_bytes = max_tensor_bytes + + def metadata(self) -> Mapping[str, Any]: + return self.pool.vla_metadata() + + async def open(self, payload: Mapping[str, Any]) -> dict[str, Any]: + session_id = _required_string(payload, "session_id") + try: + await self.pool.open_session(session_id) + return await self.pool.run_vla_operation(session_id, "OPEN", dict(payload)) + except Exception: + with contextlib.suppress(KeyError): + await self.pool.close_session(session_id) + raise + + async def predict(self, session_id: str, payload: Mapping[str, Any]) -> RobotActionChunk: + replica_payload = dict(payload) + replica_payload["_max_tensor_bytes"] = self.max_tensor_bytes + response = await self.pool.run_vla_operation(session_id, "PREDICT", replica_payload) + chunk_payload = response.get("chunk") + if not isinstance(chunk_payload, Mapping): + raise RuntimeError("VLA replica returned an invalid action chunk") + return robot_action_chunk_from_wire(chunk_payload, max_tensor_bytes=self.max_tensor_bytes) + + async def reset(self, session_id: str, episode_id: str | None) -> None: + payload: dict[str, Any] = {"session_id": session_id} + if episode_id is not None: + payload["episode_id"] = episode_id + await self.pool.run_vla_operation(session_id, "RESET", payload) + + async def close(self, session_id: str) -> None: + try: + await self.pool.run_vla_operation(session_id, "CLOSE", {"session_id": session_id}) + finally: + with contextlib.suppress(KeyError): + await self.pool.close_session(session_id) + + +@dataclass +class _PredictionJob: + message: Mapping[str, Any] + ticket: ChunkTicket + response: asyncio.Future[dict[str, Any]] + + +class _SessionRuntime: + """Run one prediction at a time while retaining only the latest waiting request.""" + + def __init__( + self, + backend: _VLABackend, + session_id: str, + state: ActionChunkStateMachine, + *, + stateful: bool, + ) -> None: + self.backend = backend + self.session_id = session_id + self.state = state + self.stateful = stateful + self._waiting: _PredictionJob | None = None + self._runner: asyncio.Task[None] | None = None + self._accepting = True + self._recovering = False + self._barrier_active = False + + def submit(self, message: Mapping[str, Any]) -> asyncio.Future[dict[str, Any]]: + """Admit a request immediately without waiting for the replica.""" + loop = asyncio.get_running_loop() + response: asyncio.Future[dict[str, Any]] = loop.create_future() + if not self._accepting or self._recovering: + response.set_exception( + VLAProtocolError(VLAErrorCode.SESSION_UNAVAILABLE, "session is not accepting predictions") + ) + return response + + timestamp_ns = _required_nonnegative_int(message, "observation_timestamp_ns") + timeout_s = _optional_timeout_s(message) + ticket = self.state.submit( + _required_nonnegative_int(message, "sequence_id"), + timestamp_ns, + request_ttl_ms=None if timeout_s is None else timeout_s * 1000.0, + observation_clock_now_ns=_optional_nonnegative_int(message, "observation_clock_now_ns"), + ) + admission = self.state.status(ticket) + if admission is ChunkStatus.REJECTED: + response.set_exception( + VLAProtocolError(VLAErrorCode.OUT_OF_ORDER, self.state.reason(ticket) or "action chunk was rejected") + ) + return response + if admission is ChunkStatus.EXPIRED: + response.set_exception( + VLAProtocolError(VLAErrorCode.EXPIRED, self.state.reason(ticket) or "observation expired") + ) + return response + + job = _PredictionJob(message, ticket, response) + if self._waiting is not None: + self._fail( + self._waiting, + VLAProtocolError(VLAErrorCode.SUPERSEDED, "a newer observation superseded the waiting request"), + ) + self._waiting = job + if self._runner is None: + self._runner = asyncio.create_task(self._run()) + return response + + async def reset(self, episode_id: str | None) -> None: + """Apply a barrier, discard earlier results, and reset policy history.""" + self._accepting = False + self._barrier_active = True + self.state.reset(episode_id) + if self._waiting is not None: + self._fail( + self._waiting, + VLAProtocolError(VLAErrorCode.SESSION_UNAVAILABLE, "request was discarded by RESET"), + ) + self._waiting = None + await self._await_runner() + await self.backend.reset(self.session_id, episode_id) + self.state.reset(episode_id) + self._barrier_active = False + self._accepting = True + + async def close(self, *, disconnect: bool = False) -> None: + """Stop admission, isolate late results, and close the backend session.""" + self._accepting = False + self._barrier_active = True + self.state.reset() + if self._waiting is not None: + self._fail( + self._waiting, + VLAProtocolError(VLAErrorCode.SESSION_UNAVAILABLE, "request was discarded by CLOSE"), + ) + self._waiting = None + await self._await_runner() + await self.backend.close(self.session_id) + if disconnect: + self.state.disconnect() + + async def _run(self) -> None: + try: + while self._waiting is not None: + job = self._waiting + self._waiting = None + if self.state.status(job.ticket) is not ChunkStatus.PENDING: + self._fail( + job, + VLAProtocolError( + VLAErrorCode.SUPERSEDED, + self.state.reason(job.ticket) or "action chunk was superseded", + ), + ) + continue + await self._predict(job) + except Exception as error: + self._accepting = False + if self._waiting is not None: + self._fail(self._waiting, error) + self._waiting = None + finally: + self._runner = None + + async def _predict(self, job: _PredictionJob) -> None: + self.state.mark_inference_started(job.ticket) + predict_task = asyncio.create_task(self.backend.predict(self.session_id, job.message)) + timeout_s = _optional_timeout_s(job.message) + try: + if timeout_s is None: + chunk = await predict_task + else: + chunk = await asyncio.wait_for(asyncio.shield(predict_task), timeout=timeout_s) + except asyncio.TimeoutError: + self._recovering = True + self._fail(job, VLAProtocolError(VLAErrorCode.TIMEOUT, "PREDICT exceeded request_ttl_ms")) + try: + try: + chunk = await predict_task + except Exception as error: + self.state.reject(job.ticket, str(error) or "timed-out VLA inference failed") + else: + self.state.complete( + job.ticket, + chunk, + observation_clock_now_ns=_optional_nonnegative_int( + job.message, + "observation_clock_now_ns", + ), + ) + if not self._barrier_active: + await self.backend.reset(self.session_id, None) + self.state.reset() + finally: + self._recovering = False + return + except Exception as error: + self.state.reject(job.ticket, str(error) or "VLA inference failed") + self._fail(job, error) + return + + completion = self.state.complete( + job.ticket, + chunk, + observation_clock_now_ns=_optional_nonnegative_int(job.message, "observation_clock_now_ns"), + ) + if completion is ChunkStatus.READY: + self._succeed( + job, + { + "type": "ACTION_CHUNK", + "protocol_version": VLA_SESSION_PROTOCOL_VERSION, + "request_id": _request_id(job.message), + "session_id": self.session_id, + "chunk": robot_action_chunk_to_wire(chunk), + }, + ) + return + code = VLAErrorCode.EXPIRED if completion is ChunkStatus.EXPIRED else VLAErrorCode.SUPERSEDED + self._fail(job, VLAProtocolError(code, self.state.reason(job.ticket) or "action chunk was discarded")) + if self.stateful and not self._barrier_active: + await self.backend.reset(self.session_id, None) + + async def _await_runner(self) -> None: + if self._runner is not None: + await self._runner + + @staticmethod + def _succeed(job: _PredictionJob, response: dict[str, Any]) -> None: + if not job.response.done(): + job.response.set_result(response) + + @staticmethod + def _fail(job: _PredictionJob, error: Exception) -> None: + if not job.response.done(): + job.response.set_exception(error) + + +def create_vla_session_app( + sessions: VLASessionManager, + *, + max_message_bytes: int = DEFAULT_MAX_VLA_MESSAGE_BYTES, + max_tensor_bytes: int = DEFAULT_MAX_TENSOR_BYTES, +) -> FastAPI: + """Create an additive WebSocket app for an already-loaded VLA registry.""" + return _create_vla_session_app( + _LocalVLABackend(sessions, max_tensor_bytes), + max_message_bytes=max_message_bytes, + max_tensor_bytes=max_tensor_bytes, + ) + + +def create_pipeline_pool_vla_session_app( + pool: PipelinePool, + *, + max_message_bytes: int = DEFAULT_MAX_VLA_MESSAGE_BYTES, + max_tensor_bytes: int = DEFAULT_MAX_TENSOR_BYTES, + close_pool_on_shutdown: bool = True, +) -> FastAPI: + """Create a VLA WebSocket app backed by session-affine pipeline replicas.""" + app = _create_vla_session_app( + _PipelinePoolVLABackend(pool, max_tensor_bytes), + max_message_bytes=max_message_bytes, + max_tensor_bytes=max_tensor_bytes, + ) + if close_pool_on_shutdown: + + @app.on_event("shutdown") + async def close_pool() -> None: + await pool.aclose() + + return app + + +def _create_vla_session_app( + backend: _VLABackend, + *, + max_message_bytes: int, + max_tensor_bytes: int, +) -> FastAPI: + if not isinstance(max_message_bytes, int) or isinstance(max_message_bytes, bool) or max_message_bytes < 1: + raise ValueError("max_message_bytes must be a positive integer") + if not isinstance(max_tensor_bytes, int) or isinstance(max_tensor_bytes, bool) or max_tensor_bytes < 1: + raise ValueError("max_tensor_bytes must be a positive integer") + + app = FastAPI(title="TeleFuser VLA Session") + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + @app.websocket("/v1/vla/session") + async def session_socket(websocket: WebSocket) -> None: + await websocket.accept() + owned_sessions: set[str] = set() + runtimes: dict[str, _SessionRuntime] = {} + delivery_tasks: set[asyncio.Task[None]] = set() + send_lock = asyncio.Lock() + metadata = backend.metadata() + await _locked_send( + websocket, + send_lock, + { + "type": "HELLO", + "protocol_version": VLA_SESSION_PROTOCOL_VERSION, + "encoding": VLA_WIRE_ENCODING, + "operations": ["OPEN", "PREDICT", "RESET", "CLOSE"], + "capabilities": { + "observation_contract": True, + "concurrent_predict": True, + "prediction_queue": "latest-wins", + "control_barriers": True, + }, + "model_ids": list(metadata.get("model_ids", [])), + "embodiment_ids": list(metadata.get("embodiment_ids", [])), + "max_message_bytes": max_message_bytes, + "max_tensor_bytes": max_tensor_bytes, + }, + ) + try: + while True: + request_id: str | int | None = None + try: + message = loads_wire_message( + await websocket.receive_text(), + max_message_bytes=max_message_bytes, + ) + request_id = _request_id(message) + operation = message.get("type") + if isinstance(operation, str) and operation.upper() == "PREDICT": + session_id = _required_string(message, "session_id") + if session_id not in owned_sessions: + raise VLAProtocolError( + VLAErrorCode.UNKNOWN_SESSION, + f"session is not open on this connection: {session_id!r}", + ) + response_future = runtimes[session_id].submit(message) + task = asyncio.create_task( + _deliver_prediction(websocket, send_lock, response_future, request_id=request_id) + ) + delivery_tasks.add(task) + task.add_done_callback(delivery_tasks.discard) + continue + response = await _handle_control_message( + backend, + message, + owned_sessions=owned_sessions, + runtimes=runtimes, + ) + except WebSocketDisconnect: + break + except Exception as error: + response = _error_response(error, request_id=request_id) + await _locked_send(websocket, send_lock, response) + finally: + for runtime in tuple(runtimes.values()): + with contextlib.suppress(Exception): + await runtime.close(disconnect=True) + for task in tuple(delivery_tasks): + task.cancel() + for task in tuple(delivery_tasks): + with contextlib.suppress(asyncio.CancelledError): + await task + + return app + + +async def _handle_control_message( + backend: _VLABackend, + message: Mapping[str, Any], + *, + owned_sessions: set[str], + runtimes: dict[str, _SessionRuntime], +) -> dict[str, Any]: + operation = message.get("type") + if not isinstance(operation, str): + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "message type must be a string") + operation = operation.upper() + request_id = _request_id(message) + if operation == "OPEN": + return await _open_session( + backend, + message, + owned_sessions=owned_sessions, + runtimes=runtimes, + request_id=request_id, + ) + + session_id = _required_string(message, "session_id") + if session_id not in owned_sessions: + raise VLAProtocolError(VLAErrorCode.UNKNOWN_SESSION, f"session is not open on this connection: {session_id!r}") + if operation == "PREDICT": + raise RuntimeError("PREDICT must be scheduled by the connection receiver") + if operation == "RESET": + episode_id = _optional_string(message, "episode_id") + await runtimes[session_id].reset(episode_id) + return _success_response("RESET", session_id, request_id) + if operation == "CLOSE": + await runtimes[session_id].close() + owned_sessions.remove(session_id) + runtimes.pop(session_id).state.disconnect() + return _success_response("CLOSE", session_id, request_id) + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"unsupported VLA operation: {operation!r}") + + +async def _open_session( + backend: _VLABackend, + message: Mapping[str, Any], + *, + owned_sessions: set[str], + runtimes: dict[str, _SessionRuntime], + request_id: str | int | None, +) -> dict[str, Any]: + version = message.get("protocol_version") + if version != VLA_SESSION_PROTOCOL_VERSION: + raise VLAProtocolError(VLAErrorCode.UNSUPPORTED_VERSION, f"unsupported protocol_version: {version!r}") + session_id = _required_string(message, "session_id") + if session_id in owned_sessions: + raise VLAProtocolError(VLAErrorCode.SESSION_EXISTS, f"VLA session is already open: {session_id!r}") + opened = await backend.open(message) + owned_sessions.add(session_id) + max_horizon = opened.get("max_horizon") + if not isinstance(max_horizon, int) or isinstance(max_horizon, bool) or max_horizon < 1: + await backend.close(session_id) + owned_sessions.remove(session_id) + raise RuntimeError("VLA backend returned an invalid max_horizon") + state = ActionChunkStateMachine( + _required_string(message, "episode_id"), + execute_horizon=_optional_positive_int(message, "execute_horizon") or max_horizon, + max_observation_age_ns=_optional_positive_int(message, "max_observation_age_ns"), + ) + runtimes[session_id] = _SessionRuntime( + backend, + session_id, + state, + stateful=bool(opened.get("stateful", False)), + ) + return { + "type": "OPENED", + "protocol_version": VLA_SESSION_PROTOCOL_VERSION, + "request_id": request_id, + **opened, + } + + +def _validate_expected_action_space(payload: Mapping[str, Any], robot_action_space: ActionSpaceSpec) -> None: + expected_payload = payload.get("expected_robot_action_space") + if expected_payload is None: + return + if not isinstance(expected_payload, Mapping): + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "expected_robot_action_space must be an object") + action_space_from_wire(expected_payload).require_compatible( + robot_action_space, + context="client and embodiment robot action space", + ) + + +def _validate_expected_observation_space( + payload: Mapping[str, Any], + robot_observation_space: ObservationSpaceSpec, +) -> None: + expected_payload = payload.get("expected_robot_observation_space") + if expected_payload is None: + return + if not isinstance(expected_payload, Mapping): + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "expected_robot_observation_space must be an object") + observation_space_from_wire(expected_payload).require_compatible( + robot_observation_space, + context="client and embodiment robot observation space", + ) + + +async def _send(websocket: WebSocket, payload: Mapping[str, Any]) -> None: + await websocket.send_text(dumps_wire_message(payload)) + + +async def _locked_send( + websocket: WebSocket, + lock: asyncio.Lock, + payload: Mapping[str, Any], +) -> None: + async with lock: + await _send(websocket, payload) + + +async def _deliver_prediction( + websocket: WebSocket, + lock: asyncio.Lock, + response: asyncio.Future[dict[str, Any]], + *, + request_id: str | int | None, +) -> None: + try: + payload = await response + except Exception as error: + payload = _error_response(error, request_id=request_id) + with contextlib.suppress(WebSocketDisconnect, RuntimeError): + await _locked_send(websocket, lock, payload) + + +def _error_response(error: Exception, *, request_id: str | int | None) -> dict[str, Any]: + code = _error_code(error) + message = str(error) if code is not VLAErrorCode.INTERNAL_ERROR else "internal VLA session error" + return { + "type": "ERROR", + "protocol_version": VLA_SESSION_PROTOCOL_VERSION, + "request_id": request_id, + "error": {"code": code.value, "message": message}, + } + + +def _error_code(error: Exception) -> VLAErrorCode: + if isinstance(error, VLAProtocolError): + return error.code + if isinstance(error, ReplicaDeadError): + return VLAErrorCode.REPLICA_UNAVAILABLE + if isinstance(error, ReplicaVLAError): + if error.error_type == "KeyError": + message = str(error).lower() + if "model_id" in message or "embodiment_id" in message: + return VLAErrorCode.UNKNOWN_COMPONENT + return VLAErrorCode.UNKNOWN_SESSION + if error.error_type in {"ValueError", "TypeError"}: + return _value_error_code(str(error)) + return VLAErrorCode.INTERNAL_ERROR + if isinstance(error, KeyError): + return VLAErrorCode.UNKNOWN_SESSION + if isinstance(error, ValueError): + return _value_error_code(str(error)) + if isinstance(error, RuntimeError) and "replica" in str(error).lower(): + return VLAErrorCode.REPLICA_UNAVAILABLE + return VLAErrorCode.INTERNAL_ERROR + + +def _value_error_code(message: str) -> VLAErrorCode: + normalized = message.lower() + if "observation space" in normalized or "observation-space" in normalized: + return VLAErrorCode.OBSERVATION_SPACE_MISMATCH + if "action space" in normalized or "action-space" in normalized: + return VLAErrorCode.ACTION_SPACE_MISMATCH + if "sequence_id" in normalized or "must increase" in normalized: + return VLAErrorCode.OUT_OF_ORDER + if "stale" in normalized or "expired" in normalized: + return VLAErrorCode.EXPIRED + if "already open" in normalized: + return VLAErrorCode.SESSION_EXISTS + return VLAErrorCode.INVALID_MESSAGE + + +def _success_response(operation: str, session_id: str, request_id: str | int | None) -> dict[str, Any]: + return { + "type": f"{operation}_ACK", + "protocol_version": VLA_SESSION_PROTOCOL_VERSION, + "request_id": request_id, + "session_id": session_id, + } + + +def _request_id(message: Mapping[str, Any]) -> str | int | None: + value = message.get("request_id") + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (str, int)) or isinstance(value, str) and not value: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "request_id must be a non-empty string or integer") + return value + + +def _required_string(message: Mapping[str, Any], field: str) -> str: + value = message.get(field) + if not isinstance(value, str) or not value: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"{field} must be a non-empty string") + return value + + +def _optional_string(message: Mapping[str, Any], field: str) -> str | None: + value = message.get(field) + if value is None: + return None + if not isinstance(value, str) or not value: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"{field} must be a non-empty string") + return value + + +def _required_nonnegative_int(message: Mapping[str, Any], field: str) -> int: + value = _optional_nonnegative_int(message, field) + if value is None: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"{field} is required") + return value + + +def _optional_nonnegative_int(message: Mapping[str, Any], field: str) -> int | None: + value = message.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"{field} must be a non-negative integer") + return value + + +def _optional_positive_int(message: Mapping[str, Any], field: str) -> int | None: + value = _optional_nonnegative_int(message, field) + if value is not None and value < 1: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"{field} must be a positive integer") + return value + + +def _optional_integer(message: Mapping[str, Any], field: str) -> int | None: + value = message.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, f"{field} must be an integer") + return value + + +def _optional_timeout_s(message: Mapping[str, Any]) -> float | None: + value = message.get("request_ttl_ms") + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "request_ttl_ms must be a positive finite number") + timeout_ms = float(value) + if not math.isfinite(timeout_ms) or timeout_ms <= 0: + raise VLAProtocolError(VLAErrorCode.INVALID_MESSAGE, "request_ttl_ms must be a positive finite number") + return timeout_ms / 1000.0 diff --git a/telefuser/vla/__init__.py b/telefuser/vla/__init__.py new file mode 100644 index 00000000..ce212696 --- /dev/null +++ b/telefuser/vla/__init__.py @@ -0,0 +1,63 @@ +"""Public semantic contracts for vision-language-action integrations.""" + +from .contracts import ( + ActionChunk, + ActionSpaceSpec, + ImageObservationSpec, + ModelActionChunk, + ModelObservation, + ObservationSpaceSpec, + RobotActionChunk, + RobotObservation, + RobotState, + VLACapabilities, + VLARequest, +) +from .embodiment import EmbodimentAdapter +from .policy import VLAPolicy +from .registry import VLARegistry +from .serialization import ( + VLA_SESSION_PROTOCOL_VERSION, + VLA_WIRE_ENCODING, + VLA_WIRE_SCHEMA_VERSION, + action_space_from_wire, + action_space_to_wire, + observation_space_from_wire, + observation_space_to_wire, + robot_action_chunk_from_wire, + robot_action_chunk_to_wire, + robot_observation_from_wire, + robot_observation_to_wire, +) +from .session import VLASession, VLASessionContract, VLASessionManager + +__all__ = [ + "ActionChunk", + "ActionSpaceSpec", + "EmbodimentAdapter", + "ImageObservationSpec", + "ModelActionChunk", + "ModelObservation", + "ObservationSpaceSpec", + "RobotActionChunk", + "RobotObservation", + "RobotState", + "VLACapabilities", + "VLAPolicy", + "VLARegistry", + "VLARequest", + "VLA_WIRE_ENCODING", + "VLA_WIRE_SCHEMA_VERSION", + "VLA_SESSION_PROTOCOL_VERSION", + "VLASession", + "VLASessionContract", + "VLASessionManager", + "action_space_from_wire", + "action_space_to_wire", + "observation_space_from_wire", + "observation_space_to_wire", + "robot_action_chunk_from_wire", + "robot_action_chunk_to_wire", + "robot_observation_from_wire", + "robot_observation_to_wire", +] diff --git a/telefuser/vla/contracts.py b/telefuser/vla/contracts.py new file mode 100644 index 00000000..3846473c --- /dev/null +++ b/telefuser/vla/contracts.py @@ -0,0 +1,334 @@ +"""Semantic contracts shared by VLA policies, embodiments, and simulators.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field, replace +from types import MappingProxyType +from typing import Any, Mapping + +import torch + + +@dataclass(frozen=True) +class ImageObservationSpec: + """Describe one named image in a robot observation.""" + + name: str + dtype: str = "uint8" + layout: str = "HWC" + channels: int = 3 + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name: + raise ValueError("image observation name must be a non-empty string") + if not isinstance(self.dtype, str) or not self.dtype: + raise ValueError("image observation dtype must be a non-empty string") + if self.layout not in {"HWC", "CHW"}: + raise ValueError("image observation layout must be HWC or CHW") + if not isinstance(self.channels, int) or isinstance(self.channels, bool) or self.channels < 1: + raise ValueError("image observation channels must be a positive integer") + + +@dataclass(frozen=True) +class ObservationSpaceSpec: + """Describe state and named image inputs expected by an embodiment.""" + + state_dimension_names: tuple[str, ...] + images: tuple[ImageObservationSpec, ...] = () + allow_extra_images: bool = True + timestamp_unit: str = "nanosecond" + timestamp_clock: str = "observation_source_monotonic" + + def __post_init__(self) -> None: + if not isinstance(self.state_dimension_names, tuple) or not self.state_dimension_names: + raise ValueError("state_dimension_names must be a non-empty tuple") + if any(not isinstance(name, str) or not name for name in self.state_dimension_names): + raise ValueError("state_dimension_names must contain non-empty strings") + if len(set(self.state_dimension_names)) != len(self.state_dimension_names): + raise ValueError("state_dimension_names must be unique") + if not isinstance(self.images, tuple) or any( + not isinstance(spec, ImageObservationSpec) for spec in self.images + ): + raise ValueError("images must be a tuple of ImageObservationSpec values") + if len({spec.name for spec in self.images}) != len(self.images): + raise ValueError("image observation names must be unique") + if not isinstance(self.allow_extra_images, bool): + raise ValueError("allow_extra_images must be a boolean") + if self.timestamp_unit != "nanosecond": + raise ValueError("timestamp_unit must be nanosecond") + if not isinstance(self.timestamp_clock, str) or not self.timestamp_clock: + raise ValueError("timestamp_clock must be a non-empty string") + + def require_compatible(self, actual: "ObservationSpaceSpec", *, context: str = "observation space") -> None: + """Reject semantic mismatches between declared observation contracts.""" + mismatches = [] + if self.state_dimension_names != actual.state_dimension_names: + mismatches.append("state_dimension_names") + if {spec.name: spec for spec in self.images} != {spec.name: spec for spec in actual.images}: + mismatches.append("images") + if self.allow_extra_images != actual.allow_extra_images: + mismatches.append("allow_extra_images") + if self.timestamp_unit != actual.timestamp_unit: + mismatches.append("timestamp_unit") + if self.timestamp_clock != actual.timestamp_clock: + mismatches.append("timestamp_clock") + if mismatches: + raise ValueError(f"{context} mismatch in fields: {mismatches}") + + def validate(self, observation: "RobotObservation") -> None: + """Validate one observation without imposing image height or width.""" + if not isinstance(observation, RobotObservation): + raise TypeError("observation must be a RobotObservation") + if observation.state.dimension_names != self.state_dimension_names: + raise ValueError("robot observation space mismatch in state_dimension_names") + expected_names = {spec.name for spec in self.images} + actual_names = set(observation.images) + missing = sorted(expected_names - actual_names) + if missing: + raise ValueError(f"robot observation space is missing images: {missing}") + if not self.allow_extra_images: + extra = sorted(actual_names - expected_names) + if extra: + raise ValueError(f"robot observation space has unexpected images: {extra}") + for spec in self.images: + image = observation.images[spec.name] + shape = getattr(image, "shape", None) + dtype = getattr(image, "dtype", None) + if shape is None or dtype is None: + try: + image = torch.as_tensor(image) + except (TypeError, ValueError) as error: + raise TypeError(f"robot observation image {spec.name!r} must be tensor-like") from error + shape = image.shape + dtype = image.dtype + if len(shape) != 3: + raise ValueError(f"robot observation image {spec.name!r} must be rank 3") + channel_axis = 2 if spec.layout == "HWC" else 0 + if shape[channel_axis] != spec.channels: + raise ValueError( + f"robot observation image {spec.name!r} must have {spec.channels} channels in {spec.layout} layout" + ) + dtype_name = str(dtype).removeprefix("torch.") + if dtype_name != spec.dtype: + raise ValueError(f"robot observation image {spec.name!r} dtype must be {spec.dtype}, got {dtype_name}") + + +@dataclass(frozen=True) +class ActionSpaceSpec: + """Describe action meaning independently of its tensor shape.""" + + representation: str + dimension_names: tuple[str, ...] + units: tuple[str, ...] + frame: str | None + control_hz: float | None + normalized: bool + normalization_profile: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.representation, str) or not self.representation: + raise ValueError("action representation must be a non-empty string") + if not isinstance(self.dimension_names, tuple): + raise ValueError("dimension_names must be a tuple") + if not self.dimension_names or any(not isinstance(name, str) or not name for name in self.dimension_names): + raise ValueError("dimension_names must contain non-empty strings") + if len(set(self.dimension_names)) != len(self.dimension_names): + raise ValueError("dimension_names must be unique") + if not isinstance(self.units, tuple): + raise ValueError("units must be a tuple") + if len(self.units) != len(self.dimension_names) or any( + not isinstance(unit, str) or not unit for unit in self.units + ): + raise ValueError("units must contain one non-empty value per action dimension") + if self.frame is not None and (not isinstance(self.frame, str) or not self.frame): + raise ValueError("frame must be None or a non-empty string") + if self.control_hz is not None: + if isinstance(self.control_hz, bool) or not isinstance(self.control_hz, (int, float)): + raise ValueError("control_hz must be None or a positive finite number") + if not math.isfinite(self.control_hz) or self.control_hz <= 0: + raise ValueError("control_hz must be None or a positive finite number") + if not isinstance(self.normalized, bool): + raise ValueError("normalized must be a boolean") + if self.normalized and (not isinstance(self.normalization_profile, str) or not self.normalization_profile): + raise ValueError("normalized action spaces require a normalization_profile") + if not self.normalized and self.normalization_profile is not None: + raise ValueError("unnormalized action spaces cannot declare a normalization_profile") + + @property + def dimension(self) -> int: + """Return the action vector width.""" + return len(self.dimension_names) + + def require_compatible(self, actual: "ActionSpaceSpec", *, context: str = "action space") -> None: + """Reject semantic mismatches without inferring meaning from tensor width.""" + fields = ( + "representation", + "dimension_names", + "units", + "frame", + "normalized", + "normalization_profile", + ) + mismatches = [name for name in fields if getattr(self, name) != getattr(actual, name)] + if self.control_hz is not None and actual.control_hz is not None and self.control_hz != actual.control_hz: + mismatches.append("control_hz") + if mismatches: + raise ValueError(f"{context} mismatch in fields: {mismatches}") + + +@dataclass(frozen=True) +class ActionChunk: + """A time-ordered action tensor with explicit semantics and provenance.""" + + actions: torch.Tensor + action_space: ActionSpaceSpec + valid_length: int + observation_timestamp_ns: int + sequence_id: int + episode_id: str + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not isinstance(self.actions, torch.Tensor): + raise TypeError("actions must be a torch.Tensor") + if self.actions.ndim != 2: + raise ValueError(f"actions must have shape [T,A], got {tuple(self.actions.shape)}") + if self.actions.shape[1] != self.action_space.dimension: + raise ValueError( + f"actions width must match the action space dimension {self.action_space.dimension}, " + f"got {self.actions.shape[1]}" + ) + if not isinstance(self.valid_length, int) or isinstance(self.valid_length, bool): + raise ValueError("valid_length must be an integer within the action tensor horizon") + if not 1 <= self.valid_length <= self.actions.shape[0]: + raise ValueError("valid_length must be within the action tensor horizon") + if not isinstance(self.observation_timestamp_ns, int) or isinstance(self.observation_timestamp_ns, bool): + raise ValueError("observation_timestamp_ns must be a non-negative integer") + if self.observation_timestamp_ns < 0: + raise ValueError("observation_timestamp_ns must be a non-negative integer") + if not isinstance(self.sequence_id, int) or isinstance(self.sequence_id, bool): + raise ValueError("sequence_id must be a non-negative integer") + if self.sequence_id < 0: + raise ValueError("sequence_id must be a non-negative integer") + if not isinstance(self.episode_id, str) or not self.episode_id: + raise ValueError("episode_id must be a non-empty string") + object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata))) + + @property + def horizon(self) -> int: + """Return the allocated action horizon.""" + return int(self.actions.shape[0]) + + def trim(self, length: int) -> "ActionChunk": + """Return the same semantic chunk limited to at most ``length`` actions.""" + if not isinstance(length, int) or isinstance(length, bool) or length < 1: + raise ValueError("chunk trim length must be positive") + valid_length = min(length, self.valid_length) + return replace(self, actions=self.actions[:valid_length], valid_length=valid_length) + + +@dataclass(frozen=True) +class ModelActionChunk(ActionChunk): + """Action chunk expressed in a model-owned action space.""" + + +@dataclass(frozen=True) +class RobotActionChunk(ActionChunk): + """Action chunk expressed in an embodiment-owned robot action space.""" + + +@dataclass(frozen=True) +class RobotState: + """One robot state vector with explicit dimension order.""" + + values: torch.Tensor + dimension_names: tuple[str, ...] + timestamp_ns: int + + def __post_init__(self) -> None: + if not isinstance(self.values, torch.Tensor): + raise TypeError("robot state values must be a torch.Tensor") + if not isinstance(self.dimension_names, tuple) or any( + not isinstance(name, str) or not name for name in self.dimension_names + ): + raise ValueError("robot state dimension_names must be a tuple of non-empty strings") + if self.values.ndim != 1 or self.values.shape[0] != len(self.dimension_names): + raise ValueError("robot state values must be one-dimensional and match dimension_names") + if len(set(self.dimension_names)) != len(self.dimension_names): + raise ValueError("robot state dimension_names must be unique") + if not isinstance(self.timestamp_ns, int) or isinstance(self.timestamp_ns, bool) or self.timestamp_ns < 0: + raise ValueError("robot state timestamp_ns must be a non-negative integer") + + +@dataclass(frozen=True) +class RobotObservation: + """Simulator observation before embodiment-specific model encoding.""" + + state: RobotState + images: Mapping[str, Any] + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "images", MappingProxyType(dict(self.images))) + object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata))) + + +@dataclass(frozen=True) +class ModelObservation: + """Observation representation accepted by one VLA policy.""" + + state: Any + images: Mapping[str, Any] + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "images", MappingProxyType(dict(self.images))) + object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata))) + + +@dataclass(frozen=True) +class VLARequest: + """One policy prediction request after embodiment encoding.""" + + observation: ModelObservation + instruction: str + episode_id: str + sequence_id: int + observation_timestamp_ns: int + seed: int | None = None + + def __post_init__(self) -> None: + if not isinstance(self.instruction, str) or not self.instruction.strip(): + raise ValueError("instruction must be a non-empty string") + if not isinstance(self.episode_id, str) or not self.episode_id: + raise ValueError("episode_id must be a non-empty string") + if not isinstance(self.sequence_id, int) or isinstance(self.sequence_id, bool) or self.sequence_id < 0: + raise ValueError("sequence_id must be a non-negative integer") + if ( + not isinstance(self.observation_timestamp_ns, int) + or isinstance(self.observation_timestamp_ns, bool) + or self.observation_timestamp_ns < 0 + ): + raise ValueError("observation_timestamp_ns must be a non-negative integer") + if self.seed is not None and (not isinstance(self.seed, int) or isinstance(self.seed, bool)): + raise ValueError("seed must be an integer or None") + + +@dataclass(frozen=True) +class VLACapabilities: + """Static policy behavior used for session compatibility checks.""" + + model_id: str + output_action_space: ActionSpaceSpec + max_horizon: int + stateful: bool = False + supports_seed: bool = True + + def __post_init__(self) -> None: + if not isinstance(self.model_id, str) or not self.model_id: + raise ValueError("model_id must be a non-empty string") + if not isinstance(self.max_horizon, int) or isinstance(self.max_horizon, bool) or self.max_horizon < 1: + raise ValueError("max_horizon must be positive") + if not isinstance(self.stateful, bool) or not isinstance(self.supports_seed, bool): + raise ValueError("stateful and supports_seed must be booleans") diff --git a/telefuser/vla/embodiment.py b/telefuser/vla/embodiment.py new file mode 100644 index 00000000..a92fa096 --- /dev/null +++ b/telefuser/vla/embodiment.py @@ -0,0 +1,36 @@ +"""Embodiment protocol separating model coordinates from robot controls.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from .contracts import ( + ActionSpaceSpec, + ModelActionChunk, + ModelObservation, + ObservationSpaceSpec, + RobotActionChunk, + RobotObservation, + RobotState, +) + + +@runtime_checkable +class EmbodimentAdapter(Protocol): + """Encode robot observations and decode model actions for one embodiment.""" + + @property + def embodiment_id(self) -> str: ... + + @property + def model_action_space(self) -> ActionSpaceSpec: ... + + @property + def robot_action_space(self) -> ActionSpaceSpec: ... + + @property + def observation_space(self) -> ObservationSpaceSpec: ... + + def encode_observation(self, observation: RobotObservation) -> ModelObservation: ... + + def decode_actions(self, actions: ModelActionChunk, robot_state: RobotState) -> RobotActionChunk: ... diff --git a/telefuser/vla/policy.py b/telefuser/vla/policy.py new file mode 100644 index 00000000..b3bcba9a --- /dev/null +++ b/telefuser/vla/policy.py @@ -0,0 +1,18 @@ +"""Policy protocol for model-independent VLA serving.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from .contracts import ModelActionChunk, VLACapabilities, VLARequest + + +@runtime_checkable +class VLAPolicy(Protocol): + """Adapt one model pipeline to the semantic VLA action contract.""" + + def capabilities(self) -> VLACapabilities: ... + + def predict(self, request: VLARequest) -> ModelActionChunk: ... + + def reset(self, episode_id: str) -> None: ... diff --git a/telefuser/vla/registry.py b/telefuser/vla/registry.py new file mode 100644 index 00000000..c345fd48 --- /dev/null +++ b/telefuser/vla/registry.py @@ -0,0 +1,66 @@ +"""Explicit component registry for VLA policies and embodiments.""" + +from __future__ import annotations + +from typing import TypeVar + +from .embodiment import EmbodimentAdapter +from .policy import VLAPolicy + +T = TypeVar("T") + + +class VLARegistry: + """Resolve already-loaded policy and embodiment adapters by stable IDs.""" + + def __init__(self) -> None: + self._policies: dict[str, VLAPolicy] = {} + self._embodiments: dict[str, EmbodimentAdapter] = {} + + def register_policy(self, model_id: str, policy: VLAPolicy, *, replace: bool = False) -> None: + """Register one loaded policy adapter.""" + if policy.capabilities().model_id != model_id: + raise ValueError("registered model_id must match policy capabilities") + self._register(self._policies, model_id, policy, replace=replace) + + def register_embodiment( + self, + embodiment_id: str, + embodiment: EmbodimentAdapter, + *, + replace: bool = False, + ) -> None: + """Register one embodiment adapter.""" + if embodiment.embodiment_id != embodiment_id: + raise ValueError("registered embodiment_id must match the adapter") + self._register(self._embodiments, embodiment_id, embodiment, replace=replace) + + def get_policy(self, model_id: str) -> VLAPolicy: + """Return a registered policy or fail with a stable lookup error.""" + try: + return self._policies[model_id] + except KeyError as error: + raise KeyError(f"unknown VLA model_id: {model_id!r}") from error + + def get_embodiment(self, embodiment_id: str) -> EmbodimentAdapter: + """Return a registered embodiment or fail with a stable lookup error.""" + try: + return self._embodiments[embodiment_id] + except KeyError as error: + raise KeyError(f"unknown VLA embodiment_id: {embodiment_id!r}") from error + + def model_ids(self) -> tuple[str, ...]: + """List registered model IDs in deterministic order.""" + return tuple(sorted(self._policies)) + + def embodiment_ids(self) -> tuple[str, ...]: + """List registered embodiment IDs in deterministic order.""" + return tuple(sorted(self._embodiments)) + + @staticmethod + def _register(registry: dict[str, T], component_id: str, component: T, *, replace: bool) -> None: + if not isinstance(component_id, str) or not component_id: + raise ValueError("component ID must be a non-empty string") + if component_id in registry and not replace: + raise ValueError(f"VLA component is already registered: {component_id!r}") + registry[component_id] = component diff --git a/telefuser/vla/runtime/__init__.py b/telefuser/vla/runtime/__init__.py new file mode 100644 index 00000000..7ce4c573 --- /dev/null +++ b/telefuser/vla/runtime/__init__.py @@ -0,0 +1,31 @@ +"""Runtime utilities for semantic VLA action chunks.""" + +from .chunk_state import ( + ActionChunkStateMachine, + ChunkStatus, + ChunkTicket, + DisconnectPolicy, + RemainderPolicy, + RuntimeState, +) +from .executor import ChunkExecutor, RobotAction +from .safety import ActionSafetyPolicy, BoundedActionSafety, FiniteActionSafety +from .scheduler import ActionChunkScheduler +from .simulator import ChunkExecutionReport, SimulatorChunkRuntime + +__all__ = [ + "ActionChunkStateMachine", + "ActionSafetyPolicy", + "ActionChunkScheduler", + "BoundedActionSafety", + "ChunkExecutor", + "ChunkExecutionReport", + "ChunkStatus", + "ChunkTicket", + "DisconnectPolicy", + "FiniteActionSafety", + "RobotAction", + "RemainderPolicy", + "RuntimeState", + "SimulatorChunkRuntime", +] diff --git a/telefuser/vla/runtime/chunk_state.py b/telefuser/vla/runtime/chunk_state.py new file mode 100644 index 00000000..ebe03fb6 --- /dev/null +++ b/telefuser/vla/runtime/chunk_state.py @@ -0,0 +1,447 @@ +"""Deterministic lifecycle for inferred action chunks.""" + +from __future__ import annotations + +import math +import time +from collections import OrderedDict +from collections.abc import Callable +from dataclasses import dataclass, replace +from enum import Enum +from threading import RLock + +from ..contracts import RobotActionChunk + + +class ChunkStatus(str, Enum): + """Lifecycle state of one submitted action chunk request.""" + + PENDING = "pending" + READY = "ready" + EXECUTING = "executing" + EXECUTED = "executed" + SUPERSEDED = "superseded" + EXPIRED = "expired" + REJECTED = "rejected" + + +class RuntimeState(str, Enum): + """Externally visible state of the chunk runtime.""" + + EMPTY = "empty" + PENDING = "pending" + READY = "ready" + EXECUTING = "executing" + HOLDING = "holding" + STOPPED = "stopped" + + +class RemainderPolicy(str, Enum): + """Treatment of actions beyond one execution horizon.""" + + DISCARD = "discard" + RETAIN = "retain" + + +class DisconnectPolicy(str, Enum): + """Fail-closed state selected when the transport disconnects.""" + + HOLD = "hold" + STOP = "stop" + + +@dataclass(frozen=True) +class ChunkTicket: + """Identity and independent deadlines for one prediction request.""" + + generation: int + sequence_id: int + observation_timestamp_ns: int + received_at: float + deadline_at: float | None + + +@dataclass +class _ChunkRecord: + ticket: ChunkTicket + status: ChunkStatus + inference_started: bool = False + inference_completed: bool = False + recovery_applied: bool = False + reason: str | None = None + + +class ActionChunkStateMachine: + """Track pending, ready, and executing chunks independently of transport.""" + + def __init__( + self, + episode_id: str, + *, + execute_horizon: int, + max_observation_age_ns: int | None = None, + remainder_policy: RemainderPolicy = RemainderPolicy.DISCARD, + disconnect_policy: DisconnectPolicy = DisconnectPolicy.HOLD, + stateful_policy: bool = False, + recover_stateful_policy: Callable[[str], None] | None = None, + clock: Callable[[], float] = time.monotonic, + terminal_history: int = 128, + ) -> None: + if not isinstance(episode_id, str) or not episode_id: + raise ValueError("episode_id must be a non-empty string") + if not isinstance(execute_horizon, int) or isinstance(execute_horizon, bool) or execute_horizon < 1: + raise ValueError("execute_horizon must be a positive integer") + if max_observation_age_ns is not None and ( + not isinstance(max_observation_age_ns, int) + or isinstance(max_observation_age_ns, bool) + or max_observation_age_ns < 1 + ): + raise ValueError("max_observation_age_ns must be a positive integer") + if stateful_policy and recover_stateful_policy is None: + raise ValueError("stateful policies require a discard recovery callback") + if not isinstance(terminal_history, int) or isinstance(terminal_history, bool) or terminal_history < 1: + raise ValueError("terminal_history must be a positive integer") + self.episode_id = episode_id + self.execute_horizon = execute_horizon + self.max_observation_age_ns = max_observation_age_ns + self.remainder_policy = RemainderPolicy(remainder_policy) + self.disconnect_policy = DisconnectPolicy(disconnect_policy) + self.stateful_policy = stateful_policy + self._recover_stateful_policy = recover_stateful_policy + self._clock = clock + self._terminal_history = terminal_history + self._generation = 0 + self._latest_sequence_id: int | None = None + self._latest_observation_timestamp_ns: int | None = None + self._pending: ChunkTicket | None = None + self._ready: tuple[ChunkTicket, RobotActionChunk] | None = None + self._executing: tuple[ChunkTicket, RobotActionChunk] | None = None + self._remainder: RobotActionChunk | None = None + self._disconnected_state: RuntimeState | None = None + self._records: OrderedDict[int, _ChunkRecord] = OrderedDict() + self._lock = RLock() + + @property + def state(self) -> RuntimeState: + """Return the current externally visible runtime state.""" + with self._lock: + if self._disconnected_state is not None: + return self._disconnected_state + if self._executing is not None: + return RuntimeState.EXECUTING + if self._ready is not None: + return RuntimeState.READY + if self._pending is not None: + return RuntimeState.PENDING + return RuntimeState.EMPTY + + @property + def latest_sequence_id(self) -> int | None: + """Return the newest accepted request sequence.""" + with self._lock: + return self._latest_sequence_id + + def submit( + self, + sequence_id: int, + observation_timestamp_ns: int, + *, + request_ttl_ms: float | None = None, + observation_clock_now_ns: int | None = None, + ) -> ChunkTicket: + """Admit a prediction request and supersede older pending or ready work.""" + self._validate_nonnegative_int(sequence_id, "sequence_id") + self._validate_nonnegative_int(observation_timestamp_ns, "observation_timestamp_ns") + if observation_clock_now_ns is not None: + self._validate_nonnegative_int(observation_clock_now_ns, "observation_clock_now_ns") + ttl_ms = self._validate_ttl(request_ttl_ms) + with self._lock: + now = self._now() + self._generation += 1 + ticket = ChunkTicket( + generation=self._generation, + sequence_id=sequence_id, + observation_timestamp_ns=observation_timestamp_ns, + received_at=now, + deadline_at=None if ttl_ms is None else now + ttl_ms / 1000.0, + ) + record = _ChunkRecord(ticket=ticket, status=ChunkStatus.PENDING) + self._records[ticket.generation] = record + if self._disconnected_state is not None: + self._set_terminal(record, ChunkStatus.REJECTED, "runtime is disconnected") + return ticket + if self._latest_sequence_id is not None and sequence_id <= self._latest_sequence_id: + self._set_terminal(record, ChunkStatus.REJECTED, "sequence_id is not newer than the latest request") + return ticket + if self._observation_is_stale(observation_timestamp_ns, observation_clock_now_ns): + self._set_terminal(record, ChunkStatus.EXPIRED, "observation timestamp is stale") + return ticket + if ( + self._latest_observation_timestamp_ns is not None + and observation_timestamp_ns < self._latest_observation_timestamp_ns + ): + self._set_terminal(record, ChunkStatus.REJECTED, "observation timestamp moved backwards") + return ticket + if self._pending is not None: + self._set_terminal( + self._record(self._pending), + ChunkStatus.SUPERSEDED, + "a newer observation superseded the pending request", + ) + if self._ready is not None: + self._set_terminal( + self._record(self._ready[0]), + ChunkStatus.SUPERSEDED, + "a newer observation superseded the ready chunk", + ) + self._ready = None + self._pending = ticket + self._latest_sequence_id = sequence_id + if ( + self._latest_observation_timestamp_ns is None + or observation_timestamp_ns > self._latest_observation_timestamp_ns + ): + self._latest_observation_timestamp_ns = observation_timestamp_ns + self._trim_history() + return ticket + + def mark_inference_started(self, ticket: ChunkTicket) -> ChunkStatus: + """Mark that a policy call may mutate state for this request.""" + with self._lock: + record = self._record(ticket) + if record.status is ChunkStatus.PENDING: + record.inference_started = True + return record.status + + def complete( + self, + ticket: ChunkTicket, + chunk: RobotActionChunk, + *, + observation_clock_now_ns: int | None = None, + ) -> ChunkStatus: + """Publish a completed chunk or deterministically discard it.""" + if observation_clock_now_ns is not None: + self._validate_nonnegative_int(observation_clock_now_ns, "observation_clock_now_ns") + with self._lock: + record = self._record(ticket) + record.inference_completed = True + if record.status is not ChunkStatus.PENDING or self._pending != ticket: + self._recover_discarded_state(record) + self._trim_history() + return record.status + try: + self._validate_chunk(ticket, chunk) + except Exception: + self._pending = None + self._set_terminal(record, ChunkStatus.REJECTED, "completed chunk failed validation") + self._recover_discarded_state(record) + raise + now = self._now() + if ticket.deadline_at is not None and now >= ticket.deadline_at: + self._pending = None + self._set_terminal(record, ChunkStatus.EXPIRED, "request_ttl_ms elapsed before completion") + self._recover_discarded_state(record) + return record.status + if self._observation_is_stale(ticket.observation_timestamp_ns, observation_clock_now_ns): + self._pending = None + self._set_terminal(record, ChunkStatus.EXPIRED, "observation became stale before completion") + self._recover_discarded_state(record) + return record.status + self._pending = None + self._ready = (ticket, chunk) + record.status = ChunkStatus.READY + record.reason = None + return record.status + + def reject(self, ticket: ChunkTicket, reason: str) -> ChunkStatus: + """Reject pending inference after a policy or transport failure.""" + if not isinstance(reason, str) or not reason: + raise ValueError("reason must be a non-empty string") + with self._lock: + record = self._record(ticket) + record.inference_completed = True + if record.status is not ChunkStatus.PENDING or self._pending != ticket: + self._recover_discarded_state(record) + return record.status + self._pending = None + self._set_terminal(record, ChunkStatus.REJECTED, reason) + self._recover_discarded_state(record) + return record.status + + def begin_execution(self) -> RobotActionChunk | None: + """Move the newest ready chunk into execution and apply execute_horizon.""" + with self._lock: + if self._disconnected_state is not None: + return None + if self._executing is not None: + raise RuntimeError("an action chunk is already executing") + if self._ready is None: + return None + ticket, chunk = self._ready + self._ready = None + execution_length = min(self.execute_horizon, chunk.valid_length) + execution_chunk = chunk.trim(execution_length) + if self.remainder_policy is RemainderPolicy.RETAIN and execution_length < chunk.valid_length: + remaining = chunk.actions[execution_length : chunk.valid_length] + self._remainder = replace(chunk, actions=remaining, valid_length=int(remaining.shape[0])) + else: + self._remainder = None + self._executing = (ticket, execution_chunk) + self._record(ticket).status = ChunkStatus.EXECUTING + return execution_chunk + + def finish_execution(self, *, success: bool = True) -> RuntimeState: + """Finish the active horizon and optionally expose retained remainder.""" + if not isinstance(success, bool): + raise ValueError("success must be a boolean") + with self._lock: + if self._executing is None: + raise RuntimeError("no action chunk is executing") + ticket, _ = self._executing + record = self._record(ticket) + self._executing = None + if not success: + self._remainder = None + self._set_terminal(record, ChunkStatus.REJECTED, "simulator execution failed") + elif self._remainder is not None: + self._ready = (ticket, self._remainder) + self._remainder = None + record.status = ChunkStatus.READY + record.reason = None + else: + record.status = ChunkStatus.EXECUTED + record.reason = None + self._trim_history() + return self.state + + def disconnect(self) -> RuntimeState: + """Discard buffered work and enter the configured fail-closed state.""" + with self._lock: + for ticket in self._active_tickets(): + self._set_terminal(self._record(ticket), ChunkStatus.REJECTED, "transport disconnected") + self._pending = None + self._ready = None + self._executing = None + self._remainder = None + self._disconnected_state = ( + RuntimeState.HOLDING if self.disconnect_policy is DisconnectPolicy.HOLD else RuntimeState.STOPPED + ) + self._trim_history() + return self._disconnected_state + + def reset(self, episode_id: str | None = None) -> None: + """Clear every chunk slot and restart sequence ordering.""" + if episode_id is not None and (not isinstance(episode_id, str) or not episode_id): + raise ValueError("episode_id must be a non-empty string") + with self._lock: + for ticket in self._active_tickets(): + record = self._record(ticket) + self._set_terminal(record, ChunkStatus.REJECTED, "runtime reset") + self._recover_discarded_state(record) + if episode_id is not None: + self.episode_id = episode_id + self._pending = None + self._ready = None + self._executing = None + self._remainder = None + self._latest_sequence_id = None + self._latest_observation_timestamp_ns = None + self._disconnected_state = None + self._trim_history() + + def status(self, ticket: ChunkTicket) -> ChunkStatus: + """Return the current status of a known ticket.""" + with self._lock: + return self._record(ticket).status + + def reason(self, ticket: ChunkTicket) -> str | None: + """Return the terminal reason for a known ticket.""" + with self._lock: + return self._record(ticket).reason + + def _observation_is_stale(self, timestamp_ns: int, observation_clock_now_ns: int | None) -> bool: + if self.max_observation_age_ns is None or observation_clock_now_ns is None: + return False + return observation_clock_now_ns - timestamp_ns > self.max_observation_age_ns + + def _validate_chunk(self, ticket: ChunkTicket, chunk: RobotActionChunk) -> None: + if not isinstance(chunk, RobotActionChunk): + raise TypeError("completed action must be a RobotActionChunk") + if chunk.episode_id != self.episode_id: + raise ValueError("completed chunk episode_id does not match the runtime") + if chunk.sequence_id != ticket.sequence_id: + raise ValueError("completed chunk sequence_id does not match its ticket") + if chunk.observation_timestamp_ns != ticket.observation_timestamp_ns: + raise ValueError("completed chunk observation timestamp does not match its ticket") + + def _recover_discarded_state(self, record: _ChunkRecord) -> None: + if not self.stateful_policy or not record.inference_started or record.recovery_applied: + return + assert self._recover_stateful_policy is not None + self._recover_stateful_policy(self.episode_id) + record.recovery_applied = True + + def _set_terminal(self, record: _ChunkRecord, status: ChunkStatus, reason: str) -> None: + record.status = status + record.reason = reason + self._trim_history() + + def _record(self, ticket: ChunkTicket) -> _ChunkRecord: + try: + return self._records[ticket.generation] + except KeyError as error: + raise KeyError(f"unknown or expired chunk ticket: {ticket.generation}") from error + + def _active_tickets(self) -> tuple[ChunkTicket, ...]: + tickets: list[ChunkTicket] = [] + if self._pending is not None: + tickets.append(self._pending) + if self._ready is not None: + tickets.append(self._ready[0]) + if self._executing is not None: + tickets.append(self._executing[0]) + return tuple({ticket.generation: ticket for ticket in tickets}.values()) + + def _trim_history(self) -> None: + terminal = { + ChunkStatus.EXECUTED, + ChunkStatus.SUPERSEDED, + ChunkStatus.EXPIRED, + ChunkStatus.REJECTED, + } + protected = {ticket.generation for ticket in self._active_tickets()} + protected.update( + generation + for generation, record in self._records.items() + if record.inference_started and not record.inference_completed + ) + removable = [ + generation + for generation, record in self._records.items() + if record.status in terminal and generation not in protected + ] + for generation in removable[: -self._terminal_history]: + self._records.pop(generation) + + def _now(self) -> float: + value = self._clock() + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError("chunk runtime clock must return a finite number") + return float(value) + + @staticmethod + def _validate_nonnegative_int(value: int, name: str) -> None: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"{name} must be a non-negative integer") + + @staticmethod + def _validate_ttl(value: float | None) -> float | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("request_ttl_ms must be a positive finite number") + ttl_ms = float(value) + if not math.isfinite(ttl_ms) or ttl_ms <= 0: + raise ValueError("request_ttl_ms must be a positive finite number") + return ttl_ms diff --git a/telefuser/vla/runtime/executor.py b/telefuser/vla/runtime/executor.py new file mode 100644 index 00000000..836c592d --- /dev/null +++ b/telefuser/vla/runtime/executor.py @@ -0,0 +1,86 @@ +"""Preparation of semantic robot action chunks for execution.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Iterator + +import torch + +from ..contracts import ActionSpaceSpec, RobotActionChunk, RobotState +from .safety import ActionSafetyPolicy, FiniteActionSafety + + +@dataclass(frozen=True) +class RobotAction: + """One executable action extracted from a validated chunk.""" + + values: torch.Tensor + action_space: ActionSpaceSpec + sequence_id: int + step_index: int + episode_id: str + + +class ChunkExecutor: + """Trim, validate, and expose actions without simulator-specific logic.""" + + def __init__( + self, + expected_action_space: ActionSpaceSpec, + *, + execute_horizon: int | None = None, + safety_policy: ActionSafetyPolicy | None = None, + max_observation_age_ns: int | None = None, + ) -> None: + if execute_horizon is not None and ( + not isinstance(execute_horizon, int) or isinstance(execute_horizon, bool) or execute_horizon < 1 + ): + raise ValueError("execute_horizon must be positive") + if max_observation_age_ns is not None and ( + not isinstance(max_observation_age_ns, int) + or isinstance(max_observation_age_ns, bool) + or max_observation_age_ns < 1 + ): + raise ValueError("max_observation_age_ns must be positive") + self.expected_action_space = expected_action_space + self.execute_horizon = execute_horizon + self.safety_policy = safety_policy or FiniteActionSafety() + self.max_observation_age_ns = max_observation_age_ns + + def prepare( + self, + chunk: RobotActionChunk, + robot_state: RobotState, + *, + now_ns: int | None = None, + ) -> RobotActionChunk: + """Return a bounded, safe chunk ready for simulator consumption.""" + self.expected_action_space.require_compatible(chunk.action_space, context="robot action space") + if self.max_observation_age_ns is not None: + if now_ns is None: + raise ValueError("now_ns is required when max_observation_age_ns is configured") + if not isinstance(now_ns, int) or isinstance(now_ns, bool) or now_ns < 0: + raise ValueError("now_ns must be a non-negative integer") + if now_ns < chunk.observation_timestamp_ns: + raise ValueError("now_ns cannot precede the observation timestamp") + if now_ns - chunk.observation_timestamp_ns > self.max_observation_age_ns: + raise ValueError("robot action chunk is stale") + valid_length = chunk.valid_length + if self.execute_horizon is not None: + valid_length = min(valid_length, self.execute_horizon) + prepared = replace(chunk, actions=chunk.actions[:valid_length], valid_length=valid_length) + self.safety_policy.validate(prepared, robot_state) + return prepared + + @staticmethod + def iter_actions(chunk: RobotActionChunk) -> Iterator[RobotAction]: + """Yield only the validated portion of a prepared action chunk.""" + for step_index in range(chunk.valid_length): + yield RobotAction( + values=chunk.actions[step_index], + action_space=chunk.action_space, + sequence_id=chunk.sequence_id, + step_index=step_index, + episode_id=chunk.episode_id, + ) diff --git a/telefuser/vla/runtime/safety.py b/telefuser/vla/runtime/safety.py new file mode 100644 index 00000000..5c749eef --- /dev/null +++ b/telefuser/vla/runtime/safety.py @@ -0,0 +1,61 @@ +"""Safety checks applied after embodiment action decoding.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +import torch + +from ..contracts import RobotActionChunk, RobotState + + +@runtime_checkable +class ActionSafetyPolicy(Protocol): + """Validate a robot action chunk before it reaches a simulator.""" + + def validate(self, chunk: RobotActionChunk, robot_state: RobotState) -> None: ... + + +class FiniteActionSafety: + """Reject non-finite state and action values.""" + + def validate(self, chunk: RobotActionChunk, robot_state: RobotState) -> None: + """Validate finite values in the executable portion of a chunk.""" + if not torch.isfinite(robot_state.values).all(): + raise ValueError("robot state must contain only finite values") + if not torch.isfinite(chunk.actions[: chunk.valid_length]).all(): + raise ValueError("robot actions must contain only finite values") + + +@dataclass(frozen=True) +class BoundedActionSafety(FiniteActionSafety): + """Apply per-dimension bounds and an optional first-step delta limit.""" + + lower: torch.Tensor + upper: torch.Tensor + max_initial_delta: torch.Tensor | None = None + + def validate(self, chunk: RobotActionChunk, robot_state: RobotState) -> None: + """Validate finite values, limits, and the transition from current state.""" + super().validate(chunk, robot_state) + dimension = chunk.action_space.dimension + if self.lower.shape != (dimension,) or self.upper.shape != (dimension,): + raise ValueError("safety bounds must match the robot action dimension") + if torch.any(self.lower > self.upper): + raise ValueError("safety lower bounds must not exceed upper bounds") + actions = chunk.actions[: chunk.valid_length] + lower = self.lower.to(device=actions.device, dtype=actions.dtype) + upper = self.upper.to(device=actions.device, dtype=actions.dtype) + if torch.any(actions < lower) or torch.any(actions > upper): + raise ValueError("robot actions exceed configured safety bounds") + if self.max_initial_delta is None: + return + if self.max_initial_delta.shape != (dimension,): + raise ValueError("max_initial_delta must match the robot action dimension") + if robot_state.values.shape != (dimension,): + raise ValueError("robot state dimension must match action delta safety checks") + maximum = self.max_initial_delta.to(device=actions.device, dtype=actions.dtype) + state = robot_state.values.to(device=actions.device, dtype=actions.dtype) + if torch.any(torch.abs(actions[0] - state) > maximum): + raise ValueError("first robot action exceeds configured state delta limits") diff --git a/telefuser/vla/runtime/scheduler.py b/telefuser/vla/runtime/scheduler.py new file mode 100644 index 00000000..24669f6f --- /dev/null +++ b/telefuser/vla/runtime/scheduler.py @@ -0,0 +1,270 @@ +"""Bounded latest-wins scheduling for action chunk inference.""" + +from __future__ import annotations + +import asyncio +import math +import operator +import time +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Callable, Mapping + + +@dataclass(slots=True) +class _ScheduledAction: + request: Mapping[str, Any] + session_key: str + generation: int + received_at: float + deadline_at: float | None + future: asyncio.Future[dict[str, Any]] + + +class ActionChunkScheduler: + """Serialize inference while retaining only the newest pending chunk per session.""" + + def __init__( + self, + infer: Callable[[Mapping[str, Any]], dict[str, Any]], + *, + max_pending_sessions: int = 32, + ) -> None: + if max_pending_sessions < 1: + raise ValueError("max_pending_sessions must be positive") + self._infer = infer + self._max_pending_sessions = max_pending_sessions + self._pending: OrderedDict[str, _ScheduledAction] = OrderedDict() + self._latest_generation: dict[str, int] = {} + self._latest_sequence: dict[str, int] = {} + self._wake = asyncio.Event() + self._worker: asyncio.Task[None] | None = None + self._closed = False + + @property + def metadata(self) -> dict[str, Any]: + """Describe the additive scheduling controls accepted by the endpoint.""" + return { + "scheduling": { + "mode": "latest_wins", + "max_pending_per_session": 1, + "max_pending_sessions": self._max_pending_sessions, + "sequence_field": "sequence_id", + "ttl_field": "request_ttl_ms", + "inflight_cancellation": False, + } + } + + async def start(self) -> None: + """Start the single inference worker on the current event loop.""" + if self._worker is not None: + return + if self._closed: + raise RuntimeError("action scheduler is closed") + self._worker = asyncio.create_task(self._run(), name="vla-action-chunk-scheduler") + + def submit(self, request: Mapping[str, Any], *, session_key: str) -> asyncio.Future[dict[str, Any]]: + """Admit one request and return a future without waiting for inference.""" + if self._worker is None or self._closed: + raise RuntimeError("action scheduler is not running") + if not session_key: + raise ValueError("session_key must be non-empty") + + loop = asyncio.get_running_loop() + future: asyncio.Future[dict[str, Any]] = loop.create_future() + received_at = time.monotonic() + ttl_ms = self._optional_ttl_ms(request) + sequence_id = self._optional_sequence_id(request) + previous = self._pending.get(session_key) + if previous is None and len(self._pending) >= self._max_pending_sessions: + future.set_result( + self._discarded_response( + request, + status="overloaded", + message="the action scheduler has no free pending-session slot", + ) + ) + return future + if sequence_id is not None: + latest_sequence = self._latest_sequence.get(session_key) + if latest_sequence is not None and sequence_id <= latest_sequence: + future.set_result( + self._discarded_response( + request, + status="stale_sequence", + message=f"sequence_id={sequence_id} is not newer than {latest_sequence}", + ) + ) + return future + self._latest_sequence[session_key] = sequence_id + + generation = self._latest_generation.get(session_key, 0) + 1 + self._latest_generation[session_key] = generation + deadline_at = None if ttl_ms is None else received_at + ttl_ms / 1000.0 + job = _ScheduledAction( + request=dict(request), + session_key=session_key, + generation=generation, + received_at=received_at, + deadline_at=deadline_at, + future=future, + ) + if previous is not None: + self._resolve( + previous, + self._discarded_response( + previous.request, + status="superseded", + message="a newer observation replaced this pending action request", + ), + ) + self._pending[session_key] = job + self._wake.set() + return future + + def release_session(self, session_key: str) -> None: + """Discard pending work and sequence state for a disconnected session.""" + pending = self._pending.pop(session_key, None) + if pending is not None: + self._resolve( + pending, + self._discarded_response( + pending.request, + status="session_closed", + message="the client session closed before inference", + ), + ) + self._latest_generation.pop(session_key, None) + self._latest_sequence.pop(session_key, None) + + async def close(self) -> None: + """Drain an in-flight call and reject work that has not started.""" + if self._closed: + return + self._closed = True + for job in self._pending.values(): + self._resolve( + job, + self._discarded_response( + job.request, + status="server_stopping", + message="the action scheduler is stopping", + ), + ) + self._pending.clear() + self._wake.set() + if self._worker is not None: + await self._worker + self._worker = None + + async def _run(self) -> None: + while True: + await self._wake.wait() + if self._closed and not self._pending: + return + if not self._pending: + self._wake.clear() + continue + + _, job = self._pending.popitem(last=False) + if not self._pending: + self._wake.clear() + if job.future.done(): + continue + discard = self._discard_reason(job) + if discard is not None: + self._resolve(job, discard) + continue + + inference_started_at = time.monotonic() + try: + response = await asyncio.to_thread(self._infer, job.request) + except Exception as error: + if not job.future.done(): + job.future.set_exception(error) + continue + inference_ms = (time.monotonic() - inference_started_at) * 1000.0 + + discard = self._discard_reason(job) + if discard is not None: + self._resolve(job, discard) + continue + completed_at = time.monotonic() + response = dict(response) + server_timing = dict(response.get("server_timing", {})) + server_timing.update( + queue_wait_ms=(inference_started_at - job.received_at) * 1000.0, + infer_ms=inference_ms, + scheduler_total_ms=(completed_at - job.received_at) * 1000.0, + ) + response.update(scheduler_status="completed", server_timing=server_timing) + for field in ("request_id", "episode_id", "sequence_id"): + if field in job.request: + response.setdefault(field, job.request[field]) + if job.deadline_at is not None: + response["request_ttl_ms"] = (job.deadline_at - job.received_at) * 1000.0 + self._resolve(job, response) + + def _discard_reason(self, job: _ScheduledAction) -> dict[str, Any] | None: + if job.deadline_at is not None and time.monotonic() >= job.deadline_at: + return self._discarded_response( + job.request, + status="expired", + message="the action request exceeded request_ttl_ms", + ) + if self._latest_generation.get(job.session_key) != job.generation: + return self._discarded_response( + job.request, + status="superseded", + message="a newer observation superseded this action result", + ) + return None + + @staticmethod + def _resolve(job: _ScheduledAction, response: dict[str, Any]) -> None: + if not job.future.done(): + job.future.set_result(response) + + @staticmethod + def _optional_sequence_id(request: Mapping[str, Any]) -> int | None: + value = request.get("sequence_id") + if value is None: + return None + if isinstance(value, bool): + raise ValueError("sequence_id must be a non-negative integer") + try: + sequence_id = operator.index(value) + except TypeError as error: + raise ValueError("sequence_id must be a non-negative integer") from error + if sequence_id < 0: + raise ValueError("sequence_id must be a non-negative integer") + return sequence_id + + @staticmethod + def _optional_ttl_ms(request: Mapping[str, Any]) -> float | None: + value = request.get("request_ttl_ms") + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError("request_ttl_ms must be a positive finite number") + ttl_ms = float(value) + if not math.isfinite(ttl_ms) or ttl_ms <= 0: + raise ValueError("request_ttl_ms must be a positive finite number") + return ttl_ms + + @staticmethod + def _discarded_response( + request: Mapping[str, Any], + *, + status: str, + message: str, + ) -> dict[str, Any]: + response: dict[str, Any] = { + "action": None, + "scheduler_status": status, + "error": {"code": status, "message": message}, + } + for field in ("request_id", "episode_id", "sequence_id"): + if field in request: + response[field] = request[field] + return response diff --git a/telefuser/vla/runtime/simulator.py b/telefuser/vla/runtime/simulator.py new file mode 100644 index 00000000..e84e0f18 --- /dev/null +++ b/telefuser/vla/runtime/simulator.py @@ -0,0 +1,161 @@ +"""Client-side action lifecycle for simulator execution.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ..contracts import ActionSpaceSpec, RobotActionChunk +from .chunk_state import ( + ActionChunkStateMachine, + ChunkStatus, + DisconnectPolicy, + RemainderPolicy, + RuntimeState, +) +from .executor import ChunkExecutor + +if TYPE_CHECKING: + from telefuser.integrations.sim.base import SimulatorAdapter + + +@dataclass(frozen=True) +class ChunkExecutionReport: + """Small, transport-neutral report emitted by simulator-side execution. + + The report deliberately does not become a WebSocket operation. A remote + simulator can serialize it on its existing control channel while local + callers can inspect it directly. ``sequence_id`` is ``None`` when no + ready chunk was available. + """ + + status: str + sequence_id: int | None + episode_id: str + executed_steps: int + reason: str | None = None + + def __post_init__(self) -> None: + if self.status not in {"executed", "failed", "no_action"}: + raise ValueError("status must be executed, failed, or no_action") + if self.sequence_id is not None and ( + not isinstance(self.sequence_id, int) or isinstance(self.sequence_id, bool) or self.sequence_id < 0 + ): + raise ValueError("sequence_id must be a non-negative integer or None") + if not isinstance(self.episode_id, str) or not self.episode_id: + raise ValueError("episode_id must be a non-empty string") + if not isinstance(self.executed_steps, int) or isinstance(self.executed_steps, bool) or self.executed_steps < 0: + raise ValueError("executed_steps must be a non-negative integer") + if self.status == "no_action" and self.executed_steps != 0: + raise ValueError("no_action reports must have zero executed_steps") + if self.status == "failed" and not self.reason: + raise ValueError("failed reports require a reason") + + +class SimulatorChunkRuntime: + """Own READY/EXECUTING/EXECUTED states beside a simulator connection.""" + + def __init__( + self, + simulator: SimulatorAdapter, + action_space: ActionSpaceSpec, + episode_id: str, + *, + execute_horizon: int, + remainder_policy: RemainderPolicy = RemainderPolicy.DISCARD, + disconnect_policy: DisconnectPolicy = DisconnectPolicy.HOLD, + max_observation_age_ns: int | None = None, + ) -> None: + self.simulator = simulator + self.action_space = action_space + self.state_machine = ActionChunkStateMachine( + episode_id, + execute_horizon=execute_horizon, + remainder_policy=remainder_policy, + disconnect_policy=disconnect_policy, + max_observation_age_ns=max_observation_age_ns, + ) + + @property + def state(self) -> RuntimeState: + """Return the current simulator-side runtime state.""" + return self.state_machine.state + + def accept( + self, + chunk: RobotActionChunk, + *, + observation_clock_now_ns: int | None = None, + ) -> ChunkStatus: + """Accept a server-ready chunk without claiming that it was executed.""" + self.action_space.require_compatible(chunk.action_space, context="simulator action space") + ticket = self.state_machine.submit( + chunk.sequence_id, + chunk.observation_timestamp_ns, + observation_clock_now_ns=observation_clock_now_ns, + ) + if self.state_machine.status(ticket) is not ChunkStatus.PENDING: + return self.state_machine.status(ticket) + self.state_machine.mark_inference_started(ticket) + return self.state_machine.complete( + ticket, + chunk, + observation_clock_now_ns=observation_clock_now_ns, + ) + + def execute_ready(self) -> int: + """Execute one configured horizon and return the submitted step count.""" + report = self._execute_ready(raise_errors=True) + return report.executed_steps + + def execute_ready_with_report(self) -> ChunkExecutionReport: + """Execute one horizon and return a stable result for remote reporting. + + Unlike :meth:`execute_ready`, this method converts simulator failures + into a ``failed`` report after entering the configured fail-closed + state. The legacy method remains exception-based for compatibility. + """ + return self._execute_ready(raise_errors=False) + + async def execute_ready_async(self) -> ChunkExecutionReport: + """Run execution off the event loop for inference/execution overlap. + + Prediction scheduling remains the caller's responsibility. This + additive helper only prevents a synchronous simulator adapter from + blocking an async VLA client while a ready chunk is being applied. + """ + return await asyncio.to_thread(self.execute_ready_with_report) + + def _execute_ready(self, *, raise_errors: bool) -> ChunkExecutionReport: + chunk = self.state_machine.begin_execution() + if chunk is None: + return ChunkExecutionReport("no_action", None, self.state_machine.episode_id, 0) + executed = 0 + try: + for action in ChunkExecutor.iter_actions(chunk): + self.simulator.execute(action) + executed += 1 + except Exception as error: + self.state_machine.finish_execution(success=False) + self.state_machine.disconnect() + report = ChunkExecutionReport( + "failed", + chunk.sequence_id, + chunk.episode_id, + executed, + reason=str(error) or error.__class__.__name__, + ) + if raise_errors: + raise + return report + self.state_machine.finish_execution(success=True) + return ChunkExecutionReport("executed", chunk.sequence_id, chunk.episode_id, executed) + + def reset(self, episode_id: str | None = None) -> None: + """Clear buffered action state after the remote session is reset.""" + self.state_machine.reset(episode_id) + + def disconnect(self) -> RuntimeState: + """Discard buffered actions and enter the configured hold or stop state.""" + return self.state_machine.disconnect() diff --git a/telefuser/vla/serialization.py b/telefuser/vla/serialization.py new file mode 100644 index 00000000..6eb63419 --- /dev/null +++ b/telefuser/vla/serialization.py @@ -0,0 +1,312 @@ +"""Versioned JSON-safe serialization for public VLA contracts.""" + +from __future__ import annotations + +import base64 +import json +import math +from collections.abc import Mapping +from typing import Any + +import numpy as np +import torch + +from .contracts import ( + ActionSpaceSpec, + ImageObservationSpec, + ObservationSpaceSpec, + RobotActionChunk, + RobotObservation, + RobotState, +) + +VLA_WIRE_SCHEMA_VERSION = 1 +VLA_WIRE_ENCODING = "json-base64-v1" +VLA_SESSION_PROTOCOL_VERSION = "1.0" +DEFAULT_MAX_TENSOR_BYTES = 64 * 1024 * 1024 + +_DTYPES: dict[str, torch.dtype] = { + "bool": torch.bool, + "uint8": torch.uint8, + "int8": torch.int8, + "int16": torch.int16, + "int32": torch.int32, + "int64": torch.int64, + "float16": torch.float16, + "bfloat16": torch.bfloat16, + "float32": torch.float32, + "float64": torch.float64, +} +_DTYPE_NAMES = {dtype: name for name, dtype in _DTYPES.items()} + + +def action_space_to_wire(spec: ActionSpaceSpec) -> dict[str, Any]: + """Serialize an action-space contract without losing field meaning.""" + return { + "schema": "telefuser.vla.action_space", + "schema_version": VLA_WIRE_SCHEMA_VERSION, + "representation": spec.representation, + "dimension_names": list(spec.dimension_names), + "units": list(spec.units), + "frame": spec.frame, + "control_hz": spec.control_hz, + "normalized": spec.normalized, + "normalization_profile": spec.normalization_profile, + } + + +def action_space_from_wire(payload: Mapping[str, Any]) -> ActionSpaceSpec: + """Deserialize and validate an action-space contract.""" + _require_schema(payload, "telefuser.vla.action_space") + dimension_names_value = payload.get("dimension_names") + units_value = payload.get("units") + if not isinstance(dimension_names_value, list) or not isinstance(units_value, list): + raise ValueError("VLA action-space dimension_names and units must be arrays") + try: + dimension_names = tuple(dimension_names_value) + units = tuple(units_value) + return ActionSpaceSpec( + representation=payload["representation"], + dimension_names=dimension_names, + units=units, + frame=payload.get("frame"), + control_hz=payload.get("control_hz"), + normalized=payload["normalized"], + normalization_profile=payload.get("normalization_profile"), + ) + except (KeyError, TypeError) as error: + raise ValueError("invalid VLA action-space payload") from error + + +def observation_space_to_wire(spec: ObservationSpaceSpec) -> dict[str, Any]: + """Serialize an observation-space contract.""" + return { + "schema": "telefuser.vla.observation_space", + "schema_version": VLA_WIRE_SCHEMA_VERSION, + "state_dimension_names": list(spec.state_dimension_names), + "images": [ + {"name": image.name, "dtype": image.dtype, "layout": image.layout, "channels": image.channels} + for image in spec.images + ], + "allow_extra_images": spec.allow_extra_images, + "timestamp_unit": spec.timestamp_unit, + "timestamp_clock": spec.timestamp_clock, + } + + +def observation_space_from_wire(payload: Mapping[str, Any]) -> ObservationSpaceSpec: + """Deserialize and validate an observation-space contract.""" + _require_schema(payload, "telefuser.vla.observation_space") + state_names = payload.get("state_dimension_names") + images = payload.get("images") + if not isinstance(state_names, list) or not isinstance(images, list): + raise ValueError("VLA observation-space state_dimension_names and images must be arrays") + if any(not isinstance(image, Mapping) for image in images): + raise ValueError("VLA observation-space images must contain objects") + try: + return ObservationSpaceSpec( + state_dimension_names=tuple(state_names), + images=tuple( + ImageObservationSpec( + name=image["name"], + dtype=image.get("dtype", "uint8"), + layout=image.get("layout", "HWC"), + channels=image.get("channels", 3), + ) + for image in images + ), + allow_extra_images=payload.get("allow_extra_images", True), + timestamp_unit=payload.get("timestamp_unit", "nanosecond"), + timestamp_clock=payload.get("timestamp_clock", "observation_source_monotonic"), + ) + except (KeyError, TypeError) as error: + raise ValueError("invalid VLA observation-space payload") from error + + +def tensor_to_wire(value: torch.Tensor | np.ndarray) -> dict[str, Any]: + """Serialize a dense CPU copy of a tensor using explicit raw bytes.""" + tensor = torch.from_numpy(np.asarray(value).copy()) if isinstance(value, np.ndarray) else value + if not isinstance(tensor, torch.Tensor): + raise TypeError("VLA tensor payload must be a torch.Tensor or numpy.ndarray") + dtype_name = _DTYPE_NAMES.get(tensor.dtype) + if dtype_name is None: + raise ValueError(f"unsupported VLA tensor dtype: {tensor.dtype}") + contiguous = tensor.detach().to(device="cpu").contiguous() + raw = contiguous.view(torch.uint8).numpy().tobytes() + return { + "schema": "telefuser.vla.tensor", + "schema_version": VLA_WIRE_SCHEMA_VERSION, + "dtype": dtype_name, + "shape": list(contiguous.shape), + "data": base64.b64encode(raw).decode("ascii"), + } + + +def tensor_from_wire( + payload: Mapping[str, Any], + *, + max_bytes: int = DEFAULT_MAX_TENSOR_BYTES, +) -> torch.Tensor: + """Deserialize a bounded tensor payload into independent CPU storage.""" + _require_schema(payload, "telefuser.vla.tensor") + if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes < 1: + raise ValueError("max_bytes must be a positive integer") + dtype_name = payload.get("dtype") + if dtype_name not in _DTYPES: + raise ValueError(f"unsupported VLA tensor dtype: {dtype_name!r}") + shape_value = payload.get("shape") + if not isinstance(shape_value, list) or any( + not isinstance(size, int) or isinstance(size, bool) or size < 0 for size in shape_value + ): + raise ValueError("VLA tensor shape must contain non-negative integers") + encoded = payload.get("data") + if not isinstance(encoded, str): + raise ValueError("VLA tensor data must be a Base64 string") + try: + raw = base64.b64decode(encoded, validate=True) + except ValueError as error: + raise ValueError("VLA tensor data is not valid Base64") from error + if len(raw) > max_bytes: + raise ValueError(f"VLA tensor exceeds the {max_bytes}-byte limit") + dtype = _DTYPES[dtype_name] + element_size = torch.empty((), dtype=dtype).element_size() + element_count = math.prod(shape_value) + if len(raw) != element_count * element_size: + raise ValueError("VLA tensor byte length does not match dtype and shape") + if element_count == 0: + return torch.empty(tuple(shape_value), dtype=dtype) + return torch.frombuffer(bytearray(raw), dtype=dtype).reshape(tuple(shape_value)).clone() + + +def robot_observation_to_wire(observation: RobotObservation) -> dict[str, Any]: + """Serialize one robot observation and its named camera tensors.""" + if any(not isinstance(name, str) or not name for name in observation.images): + raise ValueError("robot observation image names must be non-empty strings") + images = {name: tensor_to_wire(_as_tensor(image)) for name, image in observation.images.items()} + payload = { + "schema": "telefuser.vla.robot_observation", + "schema_version": VLA_WIRE_SCHEMA_VERSION, + "state": { + "values": tensor_to_wire(observation.state.values), + "dimension_names": list(observation.state.dimension_names), + "timestamp_ns": observation.state.timestamp_ns, + }, + "images": images, + "metadata": dict(observation.metadata), + } + _require_json_value(payload["metadata"], "observation metadata") + return payload + + +def robot_observation_from_wire( + payload: Mapping[str, Any], + *, + max_tensor_bytes: int = DEFAULT_MAX_TENSOR_BYTES, +) -> RobotObservation: + """Deserialize one bounded robot observation.""" + _require_schema(payload, "telefuser.vla.robot_observation") + state_payload = payload.get("state") + images_payload = payload.get("images") + if not isinstance(state_payload, Mapping) or not isinstance(images_payload, Mapping): + raise ValueError("robot observation requires state and images objects") + try: + dimension_names = state_payload.get("dimension_names") + if not isinstance(dimension_names, list): + raise ValueError("robot state dimension_names must be an array") + if any(not isinstance(name, str) or not name for name in images_payload): + raise ValueError("robot observation image names must be non-empty strings") + state = RobotState( + values=tensor_from_wire(state_payload["values"], max_bytes=max_tensor_bytes), + dimension_names=tuple(dimension_names), + timestamp_ns=state_payload["timestamp_ns"], + ) + images = {name: tensor_from_wire(image, max_bytes=max_tensor_bytes) for name, image in images_payload.items()} + metadata = payload.get("metadata", {}) + _require_json_value(metadata, "observation metadata") + return RobotObservation(state=state, images=images, metadata=metadata) + except (KeyError, TypeError) as error: + raise ValueError("invalid robot observation payload") from error + + +def robot_action_chunk_to_wire(chunk: RobotActionChunk) -> dict[str, Any]: + """Serialize a semantically described robot action chunk.""" + metadata = dict(chunk.metadata) + _require_json_value(metadata, "action chunk metadata") + return { + "schema": "telefuser.vla.robot_action_chunk", + "schema_version": VLA_WIRE_SCHEMA_VERSION, + "actions": tensor_to_wire(chunk.actions), + "action_space": action_space_to_wire(chunk.action_space), + "valid_length": chunk.valid_length, + "observation_timestamp_ns": chunk.observation_timestamp_ns, + "sequence_id": chunk.sequence_id, + "episode_id": chunk.episode_id, + "metadata": metadata, + } + + +def robot_action_chunk_from_wire( + payload: Mapping[str, Any], + *, + max_tensor_bytes: int = DEFAULT_MAX_TENSOR_BYTES, +) -> RobotActionChunk: + """Deserialize and validate a robot action chunk.""" + _require_schema(payload, "telefuser.vla.robot_action_chunk") + try: + metadata = payload.get("metadata", {}) + _require_json_value(metadata, "action chunk metadata") + return RobotActionChunk( + actions=tensor_from_wire(payload["actions"], max_bytes=max_tensor_bytes), + action_space=action_space_from_wire(payload["action_space"]), + valid_length=payload["valid_length"], + observation_timestamp_ns=payload["observation_timestamp_ns"], + sequence_id=payload["sequence_id"], + episode_id=payload["episode_id"], + metadata=metadata, + ) + except (KeyError, TypeError) as error: + raise ValueError("invalid robot action chunk payload") from error + + +def dumps_wire_message(payload: Mapping[str, Any]) -> str: + """Encode one deterministic compact JSON WebSocket message.""" + return json.dumps(dict(payload), sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def loads_wire_message(payload: str, *, max_message_bytes: int) -> dict[str, Any]: + """Decode one size-bounded JSON WebSocket message.""" + if not isinstance(payload, str): + raise TypeError("VLA WebSocket messages must be JSON text") + if not isinstance(max_message_bytes, int) or isinstance(max_message_bytes, bool) or max_message_bytes < 1: + raise ValueError("max_message_bytes must be a positive integer") + if len(payload.encode("utf-8")) > max_message_bytes: + raise ValueError(f"VLA WebSocket message exceeds the {max_message_bytes}-byte limit") + try: + decoded = json.loads(payload) + except json.JSONDecodeError as error: + raise ValueError("VLA WebSocket message is not valid JSON") from error + if not isinstance(decoded, dict): + raise ValueError("VLA WebSocket message must be a JSON object") + return decoded + + +def _require_schema(payload: Mapping[str, Any], expected: str) -> None: + if not isinstance(payload, Mapping): + raise ValueError(f"{expected} payload must be an object") + if payload.get("schema") != expected: + raise ValueError(f"expected schema {expected!r}") + if payload.get("schema_version") != VLA_WIRE_SCHEMA_VERSION: + raise ValueError(f"unsupported VLA schema version: {payload.get('schema_version')!r}") + + +def _require_json_value(value: Any, name: str) -> None: + try: + json.dumps(value, allow_nan=False) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must contain finite JSON values") from error + + +def _as_tensor(value: Any) -> torch.Tensor | np.ndarray: + if isinstance(value, (torch.Tensor, np.ndarray)): + return value + raise TypeError("serialized robot images must be torch.Tensor or numpy.ndarray values") diff --git a/telefuser/vla/session.py b/telefuser/vla/session.py new file mode 100644 index 00000000..ac174fcc --- /dev/null +++ b/telefuser/vla/session.py @@ -0,0 +1,223 @@ +"""Session lifecycle for semantic VLA inference and action preparation.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from threading import RLock + +from .contracts import ModelActionChunk, RobotActionChunk, RobotObservation, VLARequest +from .embodiment import EmbodimentAdapter +from .policy import VLAPolicy +from .registry import VLARegistry +from .runtime.executor import ChunkExecutor +from .runtime.safety import ActionSafetyPolicy + + +@dataclass(frozen=True) +class VLASessionContract: + """Immutable component and action-space selection made during OPEN.""" + + session_id: str + episode_id: str + model_id: str + embodiment_id: str + + +class VLASession: + """Bind one loaded policy and embodiment for a continuous episode.""" + + def __init__( + self, + contract: VLASessionContract, + policy: VLAPolicy, + embodiment: EmbodimentAdapter, + executor: ChunkExecutor, + policy_lock: RLock | None = None, + ) -> None: + self.contract = contract + self.policy = policy + self.embodiment = embodiment + self.executor = executor + self._policy_lock = policy_lock or RLock() + self._last_sequence_id: int | None = None + self._lock = RLock() + + def predict( + self, + observation: RobotObservation, + instruction: str, + sequence_id: int, + *, + seed: int | None = None, + now_ns: int | None = None, + timings: dict[str, float] | None = None, + ) -> RobotActionChunk: + """Run one ordered observation through policy, embodiment, and runtime.""" + with self._lock: + if not isinstance(sequence_id, int) or isinstance(sequence_id, bool) or sequence_id < 0: + raise ValueError("sequence_id must be a non-negative integer") + if self._last_sequence_id is not None and sequence_id <= self._last_sequence_id: + raise ValueError( + f"sequence_id must increase within a session: got {sequence_id}, " + f"last accepted {self._last_sequence_id}" + ) + capabilities = self.policy.capabilities() + if seed is not None and not capabilities.supports_seed: + raise ValueError(f"VLA policy {capabilities.model_id!r} does not support seeded inference") + self.embodiment.observation_space.validate(observation) + stage_started_at = time.monotonic() + model_observation = self.embodiment.encode_observation(observation) + if timings is not None: + timings["encode_observation_ms"] = (time.monotonic() - stage_started_at) * 1000.0 + request = VLARequest( + observation=model_observation, + instruction=instruction, + episode_id=self.contract.episode_id, + sequence_id=sequence_id, + observation_timestamp_ns=observation.state.timestamp_ns, + seed=seed, + ) + stage_started_at = time.monotonic() + with self._policy_lock: + model_chunk = self.policy.predict(request) + if timings is not None: + timings["policy_ms"] = (time.monotonic() - stage_started_at) * 1000.0 + if not isinstance(model_chunk, ModelActionChunk): + raise TypeError("VLA policy must return ModelActionChunk") + capabilities.output_action_space.require_compatible( + model_chunk.action_space, + context="policy output action space", + ) + if model_chunk.valid_length > capabilities.max_horizon: + raise ValueError("policy output exceeds its declared maximum horizon") + if model_chunk.episode_id != request.episode_id: + raise ValueError("policy output episode_id does not match the request") + if model_chunk.sequence_id != request.sequence_id: + raise ValueError("policy output sequence_id does not match the request") + if model_chunk.observation_timestamp_ns != request.observation_timestamp_ns: + raise ValueError("policy output observation timestamp does not match the request") + stage_started_at = time.monotonic() + robot_chunk = self.embodiment.decode_actions(model_chunk, observation.state) + if timings is not None: + timings["decode_actions_ms"] = (time.monotonic() - stage_started_at) * 1000.0 + if not isinstance(robot_chunk, RobotActionChunk): + raise TypeError("embodiment adapter must return RobotActionChunk") + if robot_chunk.episode_id != model_chunk.episode_id: + raise ValueError("embodiment output episode_id does not match the model chunk") + if robot_chunk.sequence_id != model_chunk.sequence_id: + raise ValueError("embodiment output sequence_id does not match the model chunk") + if robot_chunk.observation_timestamp_ns != model_chunk.observation_timestamp_ns: + raise ValueError("embodiment output observation timestamp does not match the model chunk") + stage_started_at = time.monotonic() + prepared = self.executor.prepare(robot_chunk, observation.state, now_ns=now_ns) + if timings is not None: + timings["prepare_actions_ms"] = (time.monotonic() - stage_started_at) * 1000.0 + self._last_sequence_id = sequence_id + return prepared + + def reset(self, episode_id: str | None = None) -> None: + """Clear temporal policy and ordering state for the bound episode.""" + with self._lock: + old_episode_id = self.contract.episode_id + with self._policy_lock: + self.policy.reset(old_episode_id) + if episode_id is not None: + if not isinstance(episode_id, str) or not episode_id: + raise ValueError("episode_id must be a non-empty string") + self.contract = VLASessionContract( + session_id=self.contract.session_id, + episode_id=episode_id, + model_id=self.contract.model_id, + embodiment_id=self.contract.embodiment_id, + ) + self._last_sequence_id = None + + def close(self) -> None: + """Release policy-owned state associated with this session.""" + with self._lock: + with self._policy_lock: + self.policy.reset(self.contract.episode_id) + self._last_sequence_id = None + + +class VLASessionManager: + """Implement OPEN, PREDICT, RESET, and CLOSE without transport concerns.""" + + def __init__(self, registry: VLARegistry) -> None: + self.registry = registry + self._sessions: dict[str, VLASession] = {} + self._policy_locks: dict[int, RLock] = {} + self._lock = RLock() + + def open( + self, + session_id: str, + *, + model_id: str, + embodiment_id: str, + episode_id: str, + execute_horizon: int | None = None, + safety_policy: ActionSafetyPolicy | None = None, + max_observation_age_ns: int | None = None, + ) -> VLASession: + """Bind one session to loaded component instances and validate semantics.""" + if not isinstance(session_id, str) or not session_id: + raise ValueError("session_id must be a non-empty string") + if not isinstance(episode_id, str) or not episode_id: + raise ValueError("episode_id must be a non-empty string") + with self._lock: + if session_id in self._sessions: + raise ValueError(f"VLA session is already open: {session_id!r}") + policy = self.registry.get_policy(model_id) + embodiment = self.registry.get_embodiment(embodiment_id) + embodiment.model_action_space.require_compatible( + policy.capabilities().output_action_space, + context="policy and embodiment action space", + ) + contract = VLASessionContract( + session_id=session_id, + episode_id=episode_id, + model_id=model_id, + embodiment_id=embodiment_id, + ) + session = VLASession( + contract, + policy, + embodiment, + ChunkExecutor( + embodiment.robot_action_space, + execute_horizon=execute_horizon, + safety_policy=safety_policy, + max_observation_age_ns=max_observation_age_ns, + ), + self._policy_locks.setdefault(id(policy), RLock()), + ) + self._sessions[session_id] = session + return session + + def get(self, session_id: str) -> VLASession: + """Return one active session.""" + with self._lock: + try: + return self._sessions[session_id] + except KeyError as error: + raise KeyError(f"unknown VLA session_id: {session_id!r}") from error + + def reset(self, session_id: str, episode_id: str | None = None) -> None: + """Reset ordering and policy history for one active session.""" + self.get(session_id).reset(episode_id) + + def close(self, session_id: str) -> None: + """Close one session and release its policy state.""" + with self._lock: + try: + session = self._sessions.pop(session_id) + except KeyError as error: + raise KeyError(f"unknown VLA session_id: {session_id!r}") from error + session.close() + + def session_ids(self) -> tuple[str, ...]: + """List active session IDs in deterministic order.""" + with self._lock: + return tuple(sorted(self._sessions)) diff --git a/tests/integration/test_vla_replica_process.py b/tests/integration/test_vla_replica_process.py new file mode 100644 index 00000000..686cb5ef --- /dev/null +++ b/tests/integration/test_vla_replica_process.py @@ -0,0 +1,67 @@ +"""Integration test for the VLA session RPC across a real replica process.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +import torch + +from telefuser.service.core.pipeline_pool import PipelinePool +from telefuser.vla import RobotObservation, RobotState +from telefuser.vla.serialization import robot_action_chunk_from_wire, robot_observation_to_wire + +PIPELINE_FILE = Path(__file__).parents[1] / "unit" / "service" / "fixtures" / "fake_vla_pipeline.py" + + +@pytest.mark.slow +def test_pipeline_pool_dispatches_vla_lifecycle_across_replica_process() -> None: + pool = PipelinePool( + num_replicas=1, + replica_device_ids=[[]], + security_level_name="NONE", + ) + assert pool.start_all( + str(PIPELINE_FILE), + parallelism_per_replica=1, + task="vla_action", + skip_validation=True, + vla_provider_factory="get_vla_provider", + ) + + async def scenario() -> None: + try: + await pool.open_session("session") + opened = await pool.run_vla_operation( + "session", + "OPEN", + { + "session_id": "session", + "model_id": "fake", + "embodiment_id": "fake-robot", + "episode_id": "episode", + }, + ) + assert opened["model_id"] == "fake" + observation = RobotObservation(RobotState(torch.tensor([1.0]), ("joint",), 100), {}) + response = await pool.run_vla_operation( + "session", + "PREDICT", + { + "session_id": "session", + "sequence_id": 1, + "observation_timestamp_ns": 100, + "instruction": "move", + "observation": robot_observation_to_wire(observation), + }, + ) + chunk = robot_action_chunk_from_wire(response["chunk"]) + assert chunk.actions.flatten().tolist() == pytest.approx([1.25, 1.5]) + await pool.run_vla_operation("session", "RESET", {"session_id": "session"}) + await pool.run_vla_operation("session", "CLOSE", {"session_id": "session"}) + await pool.close_session("session") + finally: + await pool.aclose() + + asyncio.run(scenario()) diff --git a/tests/unit/integrations/sim/test_mujoco.py b/tests/unit/integrations/sim/test_mujoco.py new file mode 100644 index 00000000..7109954e --- /dev/null +++ b/tests/unit/integrations/sim/test_mujoco.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest +import torch + +mujoco = pytest.importorskip("mujoco") + +from telefuser.integrations.sim import MuJoCoJointBinding, MuJoCoSimulatorAdapter +from telefuser.vla import ActionSpaceSpec, ObservationSpaceSpec +from telefuser.vla.runtime import RobotAction + +ACTION_NAMES = tuple(f"action_{index}" for index in range(14)) +ACTION_SPACE = ActionSpaceSpec( + "absolute_qpos", + ACTION_NAMES, + ("radian",) * 6 + ("normalized_position",) + ("radian",) * 6 + ("normalized_position",), + "robot_joint", + None, + False, +) +OBSERVATION_SPACE = ObservationSpaceSpec(ACTION_NAMES) + + +def _model() -> mujoco.MjModel: + joint_names = ( + *(f"left_{index}" for index in range(6)), + "left_gripper_a", + "left_gripper_b", + *(f"right_{index}" for index in range(6)), + "right_gripper_a", + "right_gripper_b", + ) + bodies = [] + for index, name in enumerate(joint_names): + joint_type = "slide" if "gripper" in name else "hinge" + joint_range = "0 0.05" if "gripper" in name else "-1 1" + bodies.append( + f'
' + f'