mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-20 16:31:55 +00:00
236 lines
9.9 KiB
Python
236 lines
9.9 KiB
Python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Golden parity contract test: remote request path == local RTC compute path.
|
|
|
|
The local side replicates exactly what ``RTCInferenceEngine._rtc_loop``
|
|
(rtc.py) does per iteration; the remote side runs the same observation
|
|
through the wire codec (encode -> decode), ``PolicyServer.run_inference_request``,
|
|
and the action-chunk codec — no network, no threads. With the same
|
|
deterministic policy and identical inputs, both ActionQueues must stay
|
|
byte-identical merge after merge.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
pytest.importorskip("msgpack")
|
|
|
|
from lerobot.policies.rtc import ActionQueue # noqa: E402
|
|
from lerobot.policies.rtc.configuration_rtc import RTCConfig # noqa: E402
|
|
from lerobot.policies.utils import prepare_observation_for_inference # noqa: E402
|
|
from lerobot.policy_server import codec # noqa: E402
|
|
from lerobot.policy_server.schema import MsgHeader, ObservationMsg # noqa: E402
|
|
from lerobot.policy_server.server import _normalize_prev_actions_length # noqa: E402
|
|
from lerobot.policy_server.session import Session # noqa: E402
|
|
from lerobot.utils.constants import OBS_STATE, OBS_STR # noqa: E402
|
|
from lerobot.utils.feature_utils import build_dataset_frame # noqa: E402
|
|
from tests.policy_server.conftest import ( # noqa: E402
|
|
ACTION_NAMES,
|
|
CHUNK_SIZE,
|
|
STATE_DIM,
|
|
TASK,
|
|
MockChunkPolicy,
|
|
make_logic_server,
|
|
make_mock_processors,
|
|
make_robot_obs,
|
|
)
|
|
|
|
# Must match make_manifest()'s default RTCConfig (enabled=True, horizon=10).
|
|
EXECUTION_HORIZON = 10
|
|
ROBOT_TYPE = "mock_robot"
|
|
# Fixed per-cycle inference-delay hints; cycle 2 exercises a non-zero delay.
|
|
DELAYS = [0, 2, 1]
|
|
# Actions consumed from both queues between cycles (makes prefixes non-trivial).
|
|
CONSUME_K = 4
|
|
|
|
STATE_ONLY_FEATURES = {
|
|
OBS_STATE: {
|
|
"dtype": "float32",
|
|
"shape": (STATE_DIM,),
|
|
"names": list(ACTION_NAMES),
|
|
},
|
|
}
|
|
|
|
|
|
def _make_queue() -> ActionQueue:
|
|
return ActionQueue(RTCConfig(enabled=True, execution_horizon=EXECUTION_HORIZON))
|
|
|
|
|
|
def _local_cycle(policy, pre, post, queue, features, obs, delay) -> None:
|
|
"""Replicates the loop body of RTCInferenceEngine._rtc_loop (rtc.py)."""
|
|
idx_before = queue.get_action_index()
|
|
prev_actions = queue.get_left_over()
|
|
|
|
obs_batch = build_dataset_frame(features, obs, prefix=OBS_STR)
|
|
obs_batch = prepare_observation_for_inference(obs_batch, torch.device("cpu"), TASK, ROBOT_TYPE)
|
|
obs_batch["task"] = [TASK]
|
|
preprocessed = pre(obs_batch)
|
|
|
|
if prev_actions is not None:
|
|
prev_actions = _normalize_prev_actions_length(prev_actions, target_steps=EXECUTION_HORIZON)
|
|
|
|
actions = policy.predict_action_chunk(
|
|
preprocessed, inference_delay=delay, prev_chunk_left_over=prev_actions
|
|
)
|
|
original = actions.squeeze(0).clone()
|
|
processed = post(actions).squeeze(0)
|
|
queue.merge(original, processed, delay, idx_before)
|
|
|
|
|
|
def _remote_cycle(server, session, queue, features, obs, delay, seq_id) -> None:
|
|
"""Replicates RemoteInferenceEngine._request_cycle with the wire codec in
|
|
the loop (encode -> decode on both legs) but no network or threads."""
|
|
idx_before = queue.get_action_index()
|
|
|
|
obs_frame = build_dataset_frame(features, obs, prefix=OBS_STR)
|
|
state = obs_frame.pop(OBS_STATE, None)
|
|
images = {k: v for k, v in obs_frame.items() if isinstance(v, np.ndarray) and v.ndim == 3}
|
|
|
|
prefix_model: np.ndarray | None = None
|
|
prefix_robot: np.ndarray | None = None
|
|
left_over = queue.get_left_over()
|
|
if left_over is not None and left_over.numel():
|
|
prefix_model = left_over[:EXECUTION_HORIZON].to(torch.float32).numpy()
|
|
processed_left_over = queue.get_processed_left_over()
|
|
if processed_left_over is not None and processed_left_over.numel():
|
|
prefix_robot = processed_left_over[:EXECUTION_HORIZON].to(torch.float32).numpy()
|
|
|
|
msg = ObservationMsg(
|
|
state=state,
|
|
images=images,
|
|
task=TASK,
|
|
inference_delay_steps=delay,
|
|
prefix_model=prefix_model,
|
|
prefix_robot=prefix_robot,
|
|
jpeg_quality=0, # raw image codec: byte-exact transport
|
|
)
|
|
decoded = codec.decode_observation(codec.encode_observation(msg))
|
|
|
|
# The float32 wire dtype must introduce zero drift: byte-exact roundtrip.
|
|
assert decoded.state.dtype == np.float32
|
|
assert decoded.state.tobytes() == np.ascontiguousarray(state).tobytes()
|
|
if prefix_model is not None:
|
|
assert decoded.prefix_model.tobytes() == np.ascontiguousarray(prefix_model).tobytes()
|
|
if prefix_robot is not None:
|
|
assert decoded.prefix_robot.tobytes() == np.ascontiguousarray(prefix_robot).tobytes()
|
|
for name, img in images.items():
|
|
assert np.array_equal(decoded.images[name], img)
|
|
|
|
reply = server.run_inference_request(session, MsgHeader(seq_id=seq_id), decoded)
|
|
chunk = codec.decode_action_chunk(codec.encode_action_chunk(reply))
|
|
|
|
# Reply leg is byte-exact too (float32 in, float32 on the wire).
|
|
assert chunk.chunk_model.tobytes() == np.ascontiguousarray(reply.chunk_model).tobytes()
|
|
assert chunk.chunk_robot.tobytes() == np.ascontiguousarray(reply.chunk_robot).tobytes()
|
|
|
|
queue.merge(
|
|
torch.from_numpy(np.ascontiguousarray(chunk.chunk_model)),
|
|
torch.from_numpy(np.ascontiguousarray(chunk.chunk_robot)),
|
|
delay,
|
|
idx_before,
|
|
)
|
|
|
|
|
|
def _drive_parity(features) -> tuple[MockChunkPolicy, MockChunkPolicy]:
|
|
"""Run DELAYS cycles through both paths, asserting queue parity after
|
|
each merge and consuming CONSUME_K actions from both queues between
|
|
cycles. Returns (local_policy, remote_policy) for call-level checks."""
|
|
policy_local = MockChunkPolicy()
|
|
pre_local, post_local = make_mock_processors()
|
|
queue_local = _make_queue()
|
|
|
|
policy_remote = MockChunkPolicy()
|
|
server = make_logic_server(policy=policy_remote)
|
|
pre_remote, post_remote = make_mock_processors()
|
|
session = Session(
|
|
session_id="parity",
|
|
client_uuid="parity-client",
|
|
task=TASK,
|
|
robot_type=ROBOT_TYPE,
|
|
rtc_enabled=True,
|
|
preprocessor=pre_remote,
|
|
postprocessor=post_remote,
|
|
)
|
|
queue_remote = _make_queue()
|
|
|
|
for cycle, delay in enumerate(DELAYS):
|
|
obs = make_robot_obs(seed=float(cycle + 1))
|
|
_local_cycle(policy_local, pre_local, post_local, queue_local, features, obs, delay)
|
|
_remote_cycle(server, session, queue_remote, features, obs, delay, seq_id=cycle + 1)
|
|
|
|
assert queue_local.queue is not None and queue_remote.queue is not None
|
|
assert torch.equal(queue_local.queue, queue_remote.queue), (
|
|
f"robot-space queues diverged (cycle {cycle})"
|
|
)
|
|
assert torch.equal(queue_local.original_queue, queue_remote.original_queue), (
|
|
f"model-space queues diverged (cycle {cycle})"
|
|
)
|
|
assert queue_local.queue.shape == (CHUNK_SIZE - min(delay, CHUNK_SIZE), len(ACTION_NAMES))
|
|
|
|
# Consume the same k actions on both sides so the next cycle's RTC
|
|
# prefixes are non-trivial (and identical).
|
|
for _ in range(CONSUME_K):
|
|
action_local = queue_local.get()
|
|
action_remote = queue_remote.get()
|
|
assert action_local is not None and action_remote is not None
|
|
assert torch.equal(action_local, action_remote)
|
|
|
|
return policy_local, policy_remote
|
|
|
|
|
|
def test_remote_path_matches_local_rtc_path_state_only():
|
|
"""3 cycles, state-only features: queues stay byte-identical."""
|
|
_drive_parity(STATE_ONLY_FEATURES)
|
|
|
|
|
|
def test_remote_path_matches_local_rtc_path_with_images(hw_features):
|
|
"""Images in the loop (raw codec) must not perturb state-driven outputs."""
|
|
_drive_parity(hw_features)
|
|
|
|
|
|
def test_policy_inputs_identical_across_paths(hw_features):
|
|
"""The strongest contract: both policies saw byte-identical inputs."""
|
|
policy_local, policy_remote = _drive_parity(hw_features)
|
|
|
|
assert len(policy_local.calls) == len(policy_remote.calls) == len(DELAYS)
|
|
for i, (local_call, remote_call) in enumerate(zip(policy_local.calls, policy_remote.calls, strict=True)):
|
|
assert torch.equal(local_call["state"], remote_call["state"]), f"state diverged (call {i})"
|
|
assert local_call["state"].dtype == remote_call["state"].dtype == torch.float32
|
|
assert local_call["inference_delay"] == remote_call["inference_delay"] == DELAYS[i]
|
|
if local_call["prev_chunk_left_over"] is None:
|
|
assert remote_call["prev_chunk_left_over"] is None
|
|
else:
|
|
assert torch.equal(local_call["prev_chunk_left_over"], remote_call["prev_chunk_left_over"])
|
|
assert local_call["prev_chunk_left_over"].shape == (EXECUTION_HORIZON, len(ACTION_NAMES))
|
|
|
|
# First cycle has no leftover; later cycles must carry a real prefix.
|
|
assert policy_local.calls[0]["prev_chunk_left_over"] is None
|
|
assert all(call["prev_chunk_left_over"] is not None for call in policy_local.calls[1:])
|
|
|
|
|
|
def test_float32_wire_dtype_is_byte_exact():
|
|
"""Round-tripping non-dyadic float32 values through the tensor codec
|
|
must reproduce the exact bytes (no dtype casts, no re-quantization)."""
|
|
rng = np.random.default_rng(7)
|
|
arr = (rng.standard_normal((CHUNK_SIZE, STATE_DIM)) * 0.1).astype(np.float32)
|
|
decoded = codec.decode_tensor(codec.encode_tensor(arr))
|
|
assert decoded.dtype == np.float32
|
|
assert decoded.shape == arr.shape
|
|
assert decoded.tobytes() == arr.tobytes()
|
|
assert torch.equal(torch.from_numpy(decoded), torch.from_numpy(arr))
|