feat(training): release V0.9.1 避障训练与基础策略迁移
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 10:50:13 +08:00
parent fa5485049a
commit 438e56bcc8
113 changed files with 15027 additions and 539 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"representation": "boxes-v1",
"approximation": true,
"size": 12,
"friction": 0.8,
"boxes": [
{ "pos": [0, 0, -0.1], "size": [6, 6, 0.1], "yaw": 0 },
{ "pos": [1, 2, 0.5], "size": [0.4, 0.3, 0.5], "yaw": 0 }
],
"spawn": [-2, -1, 0.32],
"spawnQuaternion": [0.7071067811865476, 0, 0, 0.7071067811865476],
"target": [2, -1],
"actualObstacleCount": 1
}
@@ -0,0 +1,90 @@
"""Regenerate CPU mj_ray references: run from repository root in .venv."""
import json
import math
import sys
from pathlib import Path
import mujoco
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from task_config import OBSTACLE_TASK, deployment_metadata, validate_task_config # noqa: E402
def generate():
config = validate_task_config(
OBSTACLE_TASK, {"sensorCfg": {"sensorMode": "multi_ring_raycast"}}, 42
)
deployment = deployment_metadata(OBSTACLE_TASK, config, 42)
boxes = deployment["terrain"]["boxes"]
# Low obstacle directly ahead; platform edge is a bounded-floor drop (no invented height map).
layouts = {
"default": boxes,
"low": [boxes[0], {"pos": [0.9, 0, 0.025], "size": [0.2, 0.5, 0.025], "yaw": 0}],
"edge": [boxes[0]],
}
cases = []
for layout, position, euler in [
("default", [-5, 0, 0.32], [0, 0, 0]),
("default", [-3, 1, 0.42], [0.23, -0.31, 0.51]),
("default", [-2, -2, 0.5], [-0.35, 0.27, -0.63]),
("low", [0, 0, 0.32], [0, 0, 0]),
("low", [0, 0, 0.32], [0.2, 0.1, -0.1]),
("edge", [5.45, 0, 0.32], [0, 0, 0]),
]:
xml = (
"<mujoco><worldbody>"
+ "".join(
'<geom type="box" pos="{}" size="{}"/>'.format(
" ".join(map(str, b["pos"])), " ".join(map(str, b["size"]))
)
for b in layouts[layout]
)
+ "</worldbody></mujoco>"
)
model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
mujoco.mj_forward(model, data)
q = np.zeros(4)
mujoco.mju_euler2Quat(q, np.array(euler), "xyz")
matrix = np.zeros(9)
mujoco.mju_quat2Mat(matrix, q)
matrix = matrix.reshape(3, 3)
origin = np.array(position) + matrix @ np.array([0.3, 0, 0.05])
distances, ids = [], []
for pitch in [0, -20, -45]:
for i in range(16):
yaw = math.radians(-45 + i * 90 / 15)
p = math.radians(pitch)
direction = matrix @ np.array(
[math.cos(p) * math.cos(yaw), math.cos(p) * math.sin(yaw), math.sin(p)]
)
geom = np.array([-1], dtype=np.int32)
distance = mujoco.mj_ray(model, data, origin, direction, None, 1, -1, geom)
distances.append(distance)
ids.append(int(geom[0]))
cases.append(
dict(
layout=layout,
position=position,
quaternion=q.tolist(),
distances=distances,
hitIds=ids,
depth=[1 if d < 0 else min(1, d / 4) for d in distances],
)
)
root = Path("web_platform/src/rl/fixtures")
(root / "multiRingGolden.json").write_text(
json.dumps(
dict(source=f"CPU MuJoCo {mujoco.__version__} mj_ray", layouts=layouts, cases=cases),
indent=2,
)
+ "\n"
)
(root / "multiRingDeployment.json").write_text(json.dumps(deployment, indent=2) + "\n")
Path("/tmp/go2-multi-ring-stage5/task.json").write_text(json.dumps(config))
if __name__ == "__main__":
generate()
+254
View File
@@ -0,0 +1,254 @@
"""Authoritative custom boxes validation and CPU compilation, no training/RNG."""
import copy
import json
import math
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
for path in (ROOT, ROOT / "rl"):
sys.path.insert(0, str(path))
from task_config import ( # noqa: E402
OBSTACLE_TASK,
TaskConfigError,
build_terrain_layout,
deployment_metadata,
validate_custom_terrain,
validate_task_config,
)
def layout():
return json.loads((Path(__file__).parent / "fixtures/custom-boxes.json").read_text())
class CustomBoxesTest(unittest.TestCase):
def test_payload_roundtrip_without_rng_and_aliasing(self):
source = layout()
with patch("task_config.random.Random", side_effect=AssertionError("must not run RNG")):
config = validate_task_config(
OBSTACLE_TASK,
{
"terrainPreset": "custom_boxes",
"customTerrainBoxes": source,
},
42,
)
self.assertEqual(build_terrain_layout(config), source)
self.assertEqual(deployment_metadata(OBSTACLE_TASK, config, 42)["terrain"], source)
source["boxes"][1]["pos"][0] = 9
self.assertEqual(config["customTerrainBoxes"], layout())
def test_strict_fields_numbers_floor_safety_and_count(self):
mutations = [
lambda t: t.update(path="../../etc/passwd"),
lambda t: t.pop("target"),
lambda t: t.update(approximation=False),
lambda t: t.update(actualObstacleCount=True),
lambda t: t.update(actualObstacleCount=9),
lambda t: t.update(size=25),
lambda t: t.update(friction=True),
lambda t: t.update(spawnQuaternion=[2, 0, 0, 0]),
lambda t: t.update(spawn=[-2, -1, 0.4]),
lambda t: t.update(target=[6, 0]),
lambda t: t["boxes"][0]["pos"].__setitem__(2, 0),
lambda t: t["boxes"][1].update(mesh="../../model.stl"),
lambda t: t["boxes"][1].update(yaw=True),
lambda t: t["boxes"][1].update(pos=[6, 2, 0.5]),
lambda t: t["boxes"][1].update(pos=[1, 2, -0.3]),
lambda t: t["boxes"][1].update(pos=[1, 2, 12]),
lambda t: t.update(
boxes=t["boxes"] + [copy.deepcopy(t["boxes"][1])] * 256, actualObstacleCount=257
),
]
for bad in (float("nan"), float("inf"), -float("inf"), 10**400, 0, -0.0, -1, True):
mutations.append(lambda t, bad=bad: t["boxes"][1]["size"].__setitem__(0, bad))
for mutate in mutations:
value = layout()
mutate(value)
with self.subTest(value=str(value)[:200]), self.assertRaises(TaskConfigError):
validate_custom_terrain(value)
for key in ("spawn", "target"):
value = layout()
# Circle tangent to the right face: reject; floor alone is exempt.
value[key][:2] = [1.9, 2]
with self.assertRaisesRegex(TaskConfigError, "安全区"):
validate_custom_terrain(value)
value[key][:2] = [1.91, 2]
validate_custom_terrain(value)
# Precise circular corner test, not an expanded square approximation.
value[key][:2] = [1.8, 2.7]
validate_custom_terrain(value)
def test_conflict_missing_path_and_training_entry(self):
from scripts.train import _load_task_config
for payload in (
{"terrainPreset": "custom_boxes"},
{"terrainPreset": "custom_boxes", "customTerrainBoxes": "../../file.json"},
{"terrainPreset": "plane", "customTerrainBoxes": layout()},
{
"terrainPreset": "custom_boxes",
"customTerrainBoxes": layout(),
"terrainParams": {"size": 8, "friction": 0.8},
},
):
with self.assertRaises(TaskConfigError):
validate_task_config(OBSTACLE_TASK, payload, 42)
config = validate_task_config(
OBSTACLE_TASK, {"terrainPreset": "custom_boxes", "customTerrainBoxes": layout()}, 42
)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "task.json"
path.write_text(json.dumps(config))
self.assertEqual(_load_task_config(OBSTACLE_TASK, str(path), 42), config)
config["customTerrainBoxes"]["boxes"][1]["size"][0] = -0.0
path.write_text(json.dumps(config))
with self.assertRaises(TaskConfigError):
_load_task_config(OBSTACLE_TASK, str(path), 42)
config["customTerrainBoxesPath"] = "/etc/passwd"
path.write_text(json.dumps(config))
with self.assertRaises(ValueError):
_load_task_config(OBSTACLE_TASK, str(path), 42)
def test_server_payload_and_limit(self):
from server import DEFAULT_TASKS, MAX_REQUEST_BYTES, ApiError, TrainingManager
with tempfile.TemporaryDirectory() as directory:
manager = TrainingManager(
Path(directory), sys.executable, DEFAULT_TASKS, check_environment=False
)
payload = dict(
taskId=OBSTACLE_TASK,
terrainPreset="custom_boxes",
customTerrainBoxes=layout(),
numEnvs=2,
maxIterations=1,
seed=42,
device="cpu",
gpuIds=[],
)
config = manager.parse_config(payload)
self.assertEqual(config.deployment["terrain"], layout())
self.assertEqual(config.task_config["customTerrainBoxes"], layout())
self.assertIn("custom_boxes", manager.health()["taskMetadata"][2]["terrainPresets"])
self.assertIn(
"--task-config", manager.command_for(config, Path(directory) / "server-owned.json")
)
with self.assertRaises(ApiError):
manager.parse_config({**payload, "customTerrainBoxesPath": "/etc/passwd"})
full = layout()
full["boxes"] += [copy.deepcopy(full["boxes"][1])] * 255
full["actualObstacleCount"] = 256
# Worst typical double precision expansion still fits the bounded request envelope.
full["boxes"][1:] = [
dict(
pos=[1.123456789012345, 2.123456789012345, 0.5123456789012345],
size=[0.4123456789012345, 0.3123456789012345, 0.5123456789012345],
yaw=0,
)
for _ in range(256)
]
self.assertLess(
len(json.dumps({**payload, "customTerrainBoxes": full}).encode()), MAX_REQUEST_BYTES
)
manager.parse_config({**payload, "customTerrainBoxes": full})
def test_http_body_length_is_bounded_and_allows_full_layout(self):
import io
from server import MAX_REQUEST_BYTES, ApiError, TrainingRequestHandler
value = layout()
value["boxes"] += [copy.deepcopy(value["boxes"][1])] * 255
value["actualObstacleCount"] = 256
body = json.dumps({"terrainPreset": "custom_boxes", "customTerrainBoxes": value}).encode()
handler = object.__new__(TrainingRequestHandler)
handler.headers = {"Content-Length": str(len(body))}
handler.rfile = io.BytesIO(body)
self.assertEqual(handler._payload()["customTerrainBoxes"], value)
for size in (0, MAX_REQUEST_BYTES + 1):
handler.headers = {"Content-Length": str(size)}
with self.assertRaises(ApiError):
handler._payload()
def test_real_cpu_compilation_and_custom_origin_quaternion_goal(self):
import mujoco
import numpy as np
from mjlab.terrains import TerrainGenerator
from src.tasks.obstacle_avoidance.env_cfg import (
apply_obstacle_configuration,
unitree_go2_obstacle_env_cfg,
)
value = layout()
config = validate_task_config(
OBSTACLE_TASK, {"terrainPreset": "custom_boxes", "customTerrainBoxes": value}, 42
)
cfg = unitree_go2_obstacle_env_cfg()
apply_obstacle_configuration(cfg, config)
generator = TerrainGenerator(cfg.scene.terrain.terrain_generator)
spec = mujoco.MjSpec()
generator.compile(spec)
model = spec.compile()
np.testing.assert_allclose(model.geom_pos, [b["pos"] for b in value["boxes"]], atol=1e-12)
np.testing.assert_allclose(model.geom_size, [b["size"] for b in value["boxes"]], atol=1e-12)
np.testing.assert_allclose(generator.terrain_origins[0, 0], [-2, -1, 0])
self.assertEqual(
cfg.scene.entities["robot"].init_state.rot, tuple(value["spawnQuaternion"])
)
self.assertEqual(cfg.commands["twist"].goal_offset, (4, 0))
self.assertEqual(cfg.terminations["outside_map"].params, {"size": 12})
self.assertTrue(math.isfinite(model.geom_size.sum()))
@unittest.skipUnless(os.environ.get("GO2_CUSTOM_BOXES_SMOKE") == "1", "opt-in 2env/1step smoke")
def test_two_env_one_step_custom_layout(self):
import numpy as np
import torch
import warp as wp
from mjlab.envs import ManagerBasedRlEnv
from src.tasks.obstacle_avoidance.env_cfg import (
apply_obstacle_configuration,
unitree_go2_obstacle_env_cfg,
)
from warp._src import context
if not torch.cuda.is_available():
self.skipTest("CUDA unavailable")
if not hasattr(wp, "context"):
wp.context = context
cfg = unitree_go2_obstacle_env_cfg()
custom = validate_task_config(
OBSTACLE_TASK, {"terrainPreset": "custom_boxes", "customTerrainBoxes": layout()}, 42
)
apply_obstacle_configuration(cfg, custom, randomize_navigation=False)
cfg.scene.num_envs = 2
env = ManagerBasedRlEnv(cfg, device="cuda:0")
try:
obs, _ = env.reset()
self.assertEqual(tuple(obs["actor"].shape), (2, 81))
np.testing.assert_allclose(env.scene.env_origins.cpu(), [[-2, -1, 0]] * 2, atol=1e-6)
np.testing.assert_allclose(
env.scene["robot"].data.root_link_pos_w.cpu(), [layout()["spawn"]] * 2, atol=1e-6
)
np.testing.assert_allclose(
env.scene["robot"].data.root_link_quat_w.cpu(),
[layout()["spawnQuaternion"]] * 2,
atol=1e-6,
)
np.testing.assert_allclose(
env.command_manager.get_term("twist").errors()[1].cpu(), [4, 4], atol=1e-6
)
obs, reward, terminated, truncated, _ = env.step(
torch.zeros((2, 12), device=env.device)
)
self.assertTrue(torch.isfinite(obs["actor"]).all())
self.assertTrue(torch.isfinite(reward).all())
self.assertFalse(terminated.any() or truncated.any())
finally:
env.close()
+214
View File
@@ -0,0 +1,214 @@
"""Multi-ring contract, CPU reference generation and opt-in real 97-D environment."""
import os
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
for p in (ROOT, ROOT / "rl"):
sys.path.insert(0, str(p))
from task_config import ( # noqa: E402
OBSTACLE_TASK,
TaskConfigError,
deployment_metadata,
validate_task_config,
)
class MultiRingTest(unittest.TestCase):
def test_fresh_cli_registers_flat_rough_obstacle(self):
import subprocess
for task in ("Unitree-Go2-Flat", "Unitree-Go2-Rough", OBSTACLE_TASK):
result = subprocess.run(
[sys.executable, "-u", "scripts/train.py", task, "--help"],
cwd=ROOT / "rl",
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("--env.scene.num-envs", result.stdout)
def test_whitelist_and_legacy_defaults(self):
for mode, count in (("single_ring_raycast", 32), ("multi_ring_raycast", 48)):
c = validate_task_config(OBSTACLE_TASK, {"sensorCfg": {"sensorMode": mode}}, 42)
s = c["sensorCfg"]
self.assertEqual(s["rayCount"], count)
self.assertEqual(
deployment_metadata(OBSTACLE_TASK, c, 42)["observationSize"], 49 + count
)
self.assertEqual(validate_task_config(OBSTACLE_TASK, c, 42), c)
for patch in (
{"rayCount": 64},
{"pitchAngles": [0, -45, -20]},
{"yawCount": True},
{"angleUnit": "rad"},
{"rayOrder": "yaw-major"},
{"sensorMode": "camera_depth"},
{"yawAngles": [0] * s["yawCount"]},
{"garbage": 1},
):
with self.subTest(patch=patch), self.assertRaises(TaskConfigError):
validate_task_config(OBSTACLE_TASK, {"sensorCfg": {**s, **patch}}, 42)
self.assertEqual(validate_task_config(OBSTACLE_TASK, {}, 42)["sensorCfg"]["rayCount"], 32)
def test_floor_identity_fail_closed_and_fixed_body_world_transform(self):
import mujoco
from src.tasks.obstacle_avoidance.mdp import standard_floor_id
floor = '<geom name="terrain_0" type="box" pos="-.5 0 -.1" size="6 6 .1"/>'
model = mujoco.MjModel.from_xml_string(
'<mujoco><worldbody><body pos=".5 0 0">' + floor + "</body></worldbody></mujoco>"
)
self.assertEqual(standard_floor_id(model, 12), 0)
for geoms in (
floor.replace("terrain_0", "other"),
floor.replace("6 6 .1", "5 5 .1"),
floor + floor.replace("terrain_0", "duplicate"),
):
m = mujoco.MjModel.from_xml_string(
'<mujoco><worldbody><body pos=".5 0 0">' + geoms + "</body></worldbody></mujoco>"
)
with self.assertRaises(ValueError):
standard_floor_id(m, 12)
def test_pattern_floor_classification_and_reward(self):
import torch
from src.tasks.obstacle_avoidance.mdp import (
ForwardFanPatternCfg,
forward_depth,
obstacle_proximity,
)
offsets, rays = ForwardFanPatternCfg(sensor_mode="multi_ring_raycast").generate_rays(
None, "cpu"
)
self.assertEqual(tuple(rays.shape), (48, 3))
torch.testing.assert_close(offsets, torch.tensor([0.3, 0, 0.05]).repeat(48, 1))
torch.testing.assert_close(rays.norm(dim=1), torch.ones(48))
for i, pitch in enumerate([0, -20, -45]):
self.assertAlmostEqual(
rays[i * 16, 2].item(),
__import__("math").sin(pitch * __import__("math").pi / 180),
places=6,
)
self.assertLess(rays[i * 16, 1], 0)
self.assertGreater(rays[i * 16 + 15, 1], 0)
# floor, 5cm obstacle, side, outside floor, miss; obs is never overwritten.
data = SimpleNamespace(
distances=torch.tensor([[0.2], [0.2], [0.2], [0.2], [-1.0]]),
hit_pos_w=torch.tensor(
[[[0.0, 0, 0]], [[0, 0, 0.05]], [[0, 0, 0]], [[7, 0, 0]], [[0, 0, 0]]]
),
normals_w=torch.tensor(
[[[0.0, 0, 1]], [[0, 0, 1]], [[1, 0, 0]], [[0, 0, 1]], [[0, 0, 0]]]
),
)
env = SimpleNamespace(
scene={"forward_scan": SimpleNamespace(data=data)}, _multi_ring_floor_id=0
)
torch.testing.assert_close(
forward_depth(env)[:, 0], torch.tensor([0.05, 0.05, 0.05, 0.05, 1])
)
torch.testing.assert_close(
obstacle_proximity(env, floor_size=12), torch.tensor([0.0, 0.36, 0.36, 0.36, 0.0])
)
self.assertGreater(obstacle_proximity(env)[0], 0) # legacy unchanged
@unittest.skipUnless(os.environ.get("GO2_RUN_MULTI_SMOKE") == "1", "opt-in GPU smoke")
def test_real_2env_1step_97_shape_floor_reward_and_cpu_parity(self):
import mujoco
import numpy as np
import torch
import warp as wp
from mjlab.envs import ManagerBasedRlEnv
from src.tasks.obstacle_avoidance.env_cfg import (
apply_obstacle_configuration,
unitree_go2_obstacle_env_cfg,
)
from src.tasks.obstacle_avoidance.mdp import (
ForwardFanPatternCfg,
floor_top_hits,
obstacle_proximity,
)
from warp._src import context
if not hasattr(wp, "context"):
wp.context = context
custom = validate_task_config(
OBSTACLE_TASK,
{
"terrainPreset": "plane",
"sensorCfg": {"sensorMode": "multi_ring_raycast", "safetyDistance": 1},
},
42,
)
cfg = unitree_go2_obstacle_env_cfg()
apply_obstacle_configuration(cfg, custom)
cfg.scene.num_envs = 2
env = ManagerBasedRlEnv(cfg, device="cuda:0")
try:
obs, _ = env.reset()
m = env.sim.mj_model
print(
"FLOOR_DIAGNOSTIC",
[
(
m.geom(i).name,
int(m.geom_bodyid[i]),
m.geom_pos[i].tolist(),
m.geom_size[i].tolist(),
m.geom_quat[i].tolist(),
)
for i in range(m.ngeom)
if m.geom_group[i] == 0
],
)
obs, reward, _, _, _ = env.step(torch.zeros((2, 12), device=env.device))
self.assertEqual(tuple(obs["actor"].shape), (2, 97))
self.assertTrue(torch.isfinite(obs["actor"]).all() and torch.isfinite(reward).all())
scan = env.scene["forward_scan"].data
self.assertTrue(floor_top_hits(scan, 12).any())
self.assertTrue((obs["actor"][:, 63:95] < 1).any())
torch.testing.assert_close(
obstacle_proximity(env, safety_distance=1, floor_size=12),
torch.zeros(2, device=env.device),
)
model = env.sim.mj_model
data = mujoco.MjData(model)
offsets, rays = ForwardFanPatternCfg(sensor_mode="multi_ring_raycast").generate_rays(
None, "cpu"
)
for e in range(2):
data.qpos[:] = env.sim.data.qpos[e].cpu().numpy()
mujoco.mj_forward(model, data)
body = model.body("robot/base_link").id
rotation = data.xmat[body].reshape(3, 3)
expected = []
for o, d in zip(offsets.numpy(), rays.numpy(), strict=True):
t = mujoco.mj_ray(
model,
data,
data.xpos[body] + rotation @ o,
rotation @ d,
np.array([1, 0, 0, 0, 0, 0], dtype=np.uint8),
1,
-1,
np.array([-1], dtype=np.int32),
)
expected.append(1 if t < 0 else min(1, t / 4))
np.testing.assert_allclose(obs["actor"][e, 47:95].cpu(), expected, atol=2e-5)
print(
"MULTI_SMOKE: 2env x 1step actor=(2,97); CPU mj_ray all48 parity; "
"floor obs retained, proximity=0; reward finite"
)
finally:
env.close()
if __name__ == "__main__":
unittest.main()
+223
View File
@@ -0,0 +1,223 @@
"""Optional installed-mjlab checks; GPU smoke is opt-in, never a full training run."""
import importlib.util
import os
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
SERVICE_ROOT = Path(__file__).resolve().parents[1]
for root in (SERVICE_ROOT, SERVICE_ROOT / "rl"):
sys.path.insert(0, str(root))
HAS_MJLAB = importlib.util.find_spec("mjlab") is not None
@unittest.skipUnless(HAS_MJLAB, "mjlab is not installed in this Python")
class ObstacleContractTest(unittest.TestCase):
def test_training_config_file_is_revalidated(self):
import json
import tempfile
from scripts.train import _load_task_config
from task_config import OBSTACLE_TASK, TaskConfigError, validate_task_config
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "training_config.json"
custom = validate_task_config(OBSTACLE_TASK, {"sensorCfg": {"fov": 60}}, 42)
source.write_text(json.dumps(custom))
self.assertEqual(_load_task_config(OBSTACLE_TASK, str(source), 42), custom)
with self.assertRaises(ValueError):
_load_task_config(OBSTACLE_TASK, str(source), 43)
custom["sensorCfg"]["maxDistance"] = -1
source.write_text(json.dumps(custom))
with self.assertRaises(TaskConfigError):
_load_task_config(OBSTACLE_TASK, str(source), 42)
source.write_text("x" * (128 * 1024 + 1))
with self.assertRaises(ValueError):
_load_task_config(OBSTACLE_TASK, str(source), 42)
def test_pattern_order_normalization_and_body_offsets(self):
import torch
from src.tasks.obstacle_avoidance.mdp import ForwardFanPatternCfg, forward_depth
offsets, directions = ForwardFanPatternCfg(fov=90).generate_rays(None, "cpu")
self.assertEqual(tuple(directions.shape), (32, 3))
torch.testing.assert_close(offsets, torch.tensor([0.3, 0, 0.05]).repeat(32, 1))
torch.testing.assert_close(directions.norm(dim=1), torch.ones(32))
self.assertAlmostEqual(directions[0, 1].item(), -(0.5**0.5), places=6)
self.assertAlmostEqual(directions[-1, 1].item(), 0.5**0.5, places=6)
scene = {
"forward_scan": SimpleNamespace(
data=SimpleNamespace(
distances=torch.tensor([[-1, 0, 1, 4, 8]], dtype=torch.float32)
)
)
}
result = forward_depth(SimpleNamespace(scene=scene), max_distance=4)
torch.testing.assert_close(result, torch.tensor([[1, 0, 0.25, 1, 1]]))
def test_navigation_matches_body_heading_and_arrival_stop(self):
import torch
from src.tasks.obstacle_avoidance.mdp import NavigationCommandCfg
data = SimpleNamespace(
root_link_pos_w=torch.tensor([[-5.0, 0, 0.32]]),
root_link_quat_w=torch.tensor([[1.0, 0, 0, 0]]),
)
class Scene(dict):
env_origins = torch.tensor([[-5.0, 0, 0]])
env = SimpleNamespace(
num_envs=1, device="cpu", scene=Scene(robot=SimpleNamespace(data=data))
)
command = NavigationCommandCfg(resampling_time_range=(1e9, 1e9)).build(env)
torch.testing.assert_close(command.command, torch.tensor([[0.6, 0, 0]]))
self.assertAlmostEqual(command.errors()[1].item(), 10)
data.root_link_quat_w[:] = torch.tensor([[0.5**0.5, 0, 0, 0.5**0.5]])
self.assertAlmostEqual(command.command[0, 2].item(), -1)
data.root_link_pos_w[0, 0] = 4.8
torch.testing.assert_close(command.command, torch.zeros((1, 3)))
def test_navigation_reset_randomizes_safe_distant_pairs_and_heading(self):
import torch
from src.tasks.obstacle_avoidance.mdp import NavigationCommandCfg
class Robot:
def __init__(self, count):
self.data = SimpleNamespace(
default_root_state=torch.tensor([[0, 0, 0.32] + [0] * 10] * count),
root_link_pos_w=torch.zeros((count, 3)),
root_link_quat_w=torch.zeros((count, 4)),
)
def write_root_link_pose_to_sim(self, pose, env_ids):
self.data.root_link_pos_w[env_ids] = pose[:, :3]
self.data.root_link_quat_w[env_ids] = pose[:, 3:]
def write_root_link_velocity_to_sim(self, velocity, env_ids):
self.velocity = velocity
class Scene(dict):
env_origins = torch.zeros((64, 3))
robot = Robot(64)
env = SimpleNamespace(num_envs=64, device="cpu", scene=Scene(robot=robot))
command = NavigationCommandCfg(
resampling_time_range=(1e9, 1e9),
navigation_points=((-2, 0), (-1, 0), (0, 0), (1, 0), (2, 0)),
component_starts=(0,),
component_counts=(5,),
fallback_pairs=(((-2, 0), (2, 0)),),
min_goal_distance=2,
).build(env)
torch.manual_seed(7)
command.sample_episode(torch.arange(64))
self.assertTrue(
((command.goals_w - robot.data.root_link_pos_w[:, :2]).norm(dim=1) >= 2).all()
)
self.assertGreater(torch.unique(robot.data.root_link_pos_w[:, :2], dim=0).shape[0], 1)
self.assertGreater(torch.unique(command.goals_w, dim=0).shape[0], 1)
torch.testing.assert_close(robot.data.root_link_quat_w.norm(dim=1), torch.ones(64))
self.assertTrue((robot.velocity == 0).all())
def test_all_terrain_presets_compile_at_exported_world_coordinates(self):
import mujoco
import numpy as np
from mjlab.terrains import TerrainGenerator
from src.tasks.obstacle_avoidance.env_cfg import unitree_go2_obstacle_env_cfg
from src.tasks.obstacle_avoidance.terrain import apply_terrain_configuration
from task_config import (
OBSTACLE_TASK,
TERRAIN_PRESETS,
build_terrain_layout,
validate_task_config,
)
for preset in (p for p in TERRAIN_PRESETS if p != "custom_boxes"):
with self.subTest(preset=preset):
custom = validate_task_config(OBSTACLE_TASK, {"terrainPreset": preset}, 7)
cfg = unitree_go2_obstacle_env_cfg()
apply_terrain_configuration(cfg, custom)
generator = TerrainGenerator(cfg.scene.terrain.terrain_generator)
spec = mujoco.MjSpec()
generator.compile(spec)
model = spec.compile()
layout = build_terrain_layout(custom)
np.testing.assert_allclose(model.geom_pos, [box["pos"] for box in layout["boxes"]])
np.testing.assert_allclose(
model.geom_size, [box["size"] for box in layout["boxes"]]
)
np.testing.assert_allclose(generator.terrain_origins[0, 0], [-5, 0, 0])
np.testing.assert_allclose(model.geom_friction[:, 0], layout["friction"])
self.assertTrue((model.geom_group == 0).all())
@unittest.skipUnless(os.environ.get("GO2_RUN_MJLAB_SMOKE") == "1", "opt-in GPU smoke")
def test_real_environment_81_observation_and_mujoco_raycast_parity(self):
import mujoco
import numpy as np
import torch
import warp as wp
from mjlab.envs import ManagerBasedRlEnv
from src.tasks.obstacle_avoidance.env_cfg import (
apply_obstacle_configuration,
unitree_go2_obstacle_env_cfg,
)
from src.tasks.obstacle_avoidance.mdp import ForwardFanPatternCfg
from task_config import JOINT_NAMES, OBSTACLE_TASK, validate_task_config
from warp._src import context
if not torch.cuda.is_available():
self.skipTest("CUDA is unavailable")
if not hasattr(wp, "context"):
wp.context = context
cfg = unitree_go2_obstacle_env_cfg()
apply_obstacle_configuration(
cfg, validate_task_config(OBSTACLE_TASK, {}, 42), randomize_navigation=False
)
cfg.scene.num_envs = 2
env = ManagerBasedRlEnv(cfg, device="cuda:0")
try:
obs, _ = env.reset()
self.assertEqual(tuple(obs["actor"].shape), (2, 81))
self.assertEqual(list(env.scene["robot"].joint_names), JOINT_NAMES)
np.testing.assert_allclose(env.scene.env_origins.cpu(), [[-5, 0, 0]] * 2)
np.testing.assert_allclose(
env.scene["robot"].data.root_link_pos_w.cpu(), [[-5, 0, 0.32]] * 2, atol=1e-6
)
for _ in range(3):
obs, reward, terminated, truncated, _ = env.step(
torch.zeros((2, 12), device=env.device)
)
self.assertTrue(torch.isfinite(obs["actor"]).all())
self.assertTrue(torch.isfinite(reward).all())
self.assertFalse(terminated.any() or truncated.any())
model = env.sim.mj_model
data = mujoco.MjData(model)
data.qpos[:] = env.sim.data.qpos[0].cpu().numpy()
mujoco.mj_forward(model, data)
body = model.body("robot/base_link").id
rotation = data.xmat[body].reshape(3, 3)
offsets, directions = ForwardFanPatternCfg().generate_rays(None, "cpu")
expected = []
for offset, direction in zip(offsets.numpy(), directions.numpy(), strict=True):
distance = mujoco.mj_ray(
model,
data,
data.xpos[body] + rotation @ offset,
rotation @ direction,
np.array([1, 0, 0, 0, 0, 0], dtype=np.uint8),
1,
-1,
np.array([-1], dtype=np.int32),
)
expected.append(1 if distance < 0 else min(1, distance / 4))
np.testing.assert_allclose(obs["actor"][0, 47:79].cpu(), expected, atol=2e-5)
finally:
env.close()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,512 @@
"""Obstacle-specific schema, orchestration, objective math and opt-in real rollout."""
import json
import os
import sys
import tempfile
import threading
import unittest
from copy import deepcopy
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
SERVICE = Path(__file__).resolve().parents[1]
for root in (SERVICE, SERVICE / "rl"):
sys.path.insert(0, str(root))
from task_config import build_terrain_layout, validate_task_config # noqa: E402
from tuning import obstacle_scoring as scoring # noqa: E402
from tuning.advisor import DeepSeekAdvisor # noqa: E402
from tuning.manager import TuningError, TuningManager # noqa: E402
from tuning.process import GpuLease, ResourceBusyError # noqa: E402
from tuning.schema import ( # noqa: E402
OBSTACLE_TASK,
RewardConfigError,
apply_reward_configuration,
base_configuration,
merge_proposal,
validate_configuration,
validate_constraints,
validate_proposal,
)
from tuning.scoring import EvaluationError # noqa: E402
def sample(**kw):
return (
dict(
distance=1.0,
clearance=0.5,
action_delta=0.0,
ray_hit=0.0,
collision=0.0,
fall=0.0,
terminal=0.0,
**{},
)
| kw
)
def evaluation(custom, n=2):
metrics = scoring.score_trajectory([sample()] * scoring.STEPS)
return {
"protocol": scoring.protocol(custom, n),
"metrics": metrics,
"seedMetrics": [
{"seed": seed, "episodes": n, "rolloutSteps": scoring.STEPS, "metrics": metrics}
for seed in scoring.SEEDS
],
}
class ObstacleSchemaTest(unittest.TestCase):
def test_ranges_task_isolation_nonfinite_and_patch_no_mutation(self):
base = base_configuration(OBSTACLE_TASK)
original = deepcopy(base)
for section, key, low, high in (
("weights", "avoidance_weight", 0.5, 5),
("weights", "collision_penalty", -10, -0.5),
("weights", "action_smoothness", -0.05, -0.001),
("params", "target_velocity", 0.3, 1.2),
):
for value in (low, high):
changed = deepcopy(base)
changed[section][key] = value
self.assertEqual(validate_configuration(changed, OBSTACLE_TASK), changed)
for value in (low - 0.00001, high + 0.00001, float("nan"), float("inf"), True):
with self.subTest(key=key, value=value), self.assertRaises(RewardConfigError):
validate_proposal({section: {key: value}}, base, task_id=OBSTACLE_TASK)
for proposal in (
{"weights": {"pose": 1.1}},
{"weights": {"target_velocity": 0.8}},
{"params": {"seed": 101}},
{"unknown": {}},
{"weights": {"avoidance_weight": 4.01}},
):
with self.assertRaises(RewardConfigError):
validate_proposal(proposal, base, task_id=OBSTACLE_TASK)
with self.assertRaises(RewardConfigError):
validate_configuration(base)
with self.assertRaises(RewardConfigError):
validate_configuration(base_configuration(), OBSTACLE_TASK)
with self.assertRaises(RewardConfigError):
validate_constraints({"weights.pose": {"kind": "fixed", "value": 1}}, OBSTACLE_TASK)
candidate = merge_proposal(
base, {"params": {"target_velocity": 1.2}}, task_id=OBSTACLE_TASK
)
self.assertEqual(candidate["params"]["target_velocity"], 1.2)
self.assertEqual(base, original)
def test_real_training_config_mapping_and_command(self):
import torch
from scripts.train import (
_configure_task_and_rewards,
_load_reward_config,
_load_task_config,
)
from src.tasks.obstacle_avoidance.env_cfg import (
apply_obstacle_configuration,
unitree_go2_obstacle_env_cfg,
)
base = base_configuration(OBSTACLE_TASK)
base["weights"].update(avoidance_weight=3, collision_penalty=-7, action_smoothness=-0.02)
base["params"]["target_velocity"] = 0.9
custom = validate_task_config(OBSTACLE_TASK, {"sensorCfg": {"fov": 60}}, 42)
with tempfile.TemporaryDirectory() as directory:
reward, task = Path(directory) / "reward.json", Path(directory) / "task.json"
reward.write_text(json.dumps(base))
task.write_text(json.dumps(custom))
cfg = unitree_go2_obstacle_env_cfg()
apply_obstacle_configuration(cfg, _load_task_config(OBSTACLE_TASK, str(task), 42))
apply_reward_configuration(
cfg, _load_reward_config(str(reward), None, OBSTACLE_TASK), OBSTACLE_TASK
)
deployment, checked = _configure_task_and_rewards(
OBSTACLE_TASK,
SimpleNamespace(
env=unitree_go2_obstacle_env_cfg(),
task_config=str(task),
reward_config=str(reward),
reward_config_json=None,
agent=SimpleNamespace(seed=42),
),
)
self.assertEqual(checked, base)
self.assertEqual(deployment["navigation"]["speed"], 0.9)
self.assertEqual(deployment["sensorCfg"]["avoidanceWeight"], 3)
self.assertEqual(cfg.rewards["obstacle_proximity"].weight, -3)
self.assertEqual(cfg.rewards["obstacle_collision"].weight, -7)
self.assertIs(
cfg.rewards["obstacle_collision"].func, cfg.terminations["illegal_contact"].func
)
self.assertEqual(cfg.rewards["action_rate_l2"].weight, -0.02)
self.assertNotIn("target_velocity", cfg.rewards)
self.assertEqual(cfg.commands["twist"].speed, 0.9)
class Scene(dict):
env_origins = torch.tensor([[-5.0, 0, 0]])
scene = Scene(
robot=SimpleNamespace(
data=SimpleNamespace(
root_link_pos_w=torch.tensor([[-5.0, 0, 0.32]]),
root_link_quat_w=torch.tensor([[1.0, 0, 0, 0]]),
)
)
)
command = cfg.commands["twist"].build(
SimpleNamespace(num_envs=1, device="cpu", scene=scene)
)
self.assertAlmostEqual(command.command[0, 0].item(), 0.9, places=6)
def test_mock_deepseek_task_context_no_network(self):
advisor = DeepSeekAdvisor()
output = SimpleNamespace(
weights={"avoidance_weight": 2.2},
params={"target_velocity": 0.7},
rationale="绕行与目标导航",
expected_impact={},
confidence=0.8,
)
fake = SimpleNamespace(run_sync=lambda prompt: SimpleNamespace(output=output))
with patch.object(advisor, "_agent", return_value=fake):
result = advisor.propose({"task": OBSTACLE_TASK}, base_configuration(OBSTACLE_TASK))
self.assertEqual(result["patch"]["params"], {"target_velocity": 0.7})
class ObstacleScoringTest(unittest.TestCase):
def test_hand_calculated_scores_and_first_terminal(self):
samples = [
sample(clearance=0.25, action_delta=0.5),
sample(distance=0.4, clearance=0.25, action_delta=0.5),
]
metrics = scoring.score_trajectory(samples, 2)
self.assertEqual(metrics["success"], 1)
self.assertEqual(metrics["time"], 0)
self.assertEqual(metrics["clearance"], 0.5)
self.assertEqual(metrics["smooth"], 0.5)
self.assertAlmostEqual(scoring.score_evaluation(metrics)["score"], 0.65)
failed = scoring.score_trajectory([sample(distance=0.1, terminal=1, fall=1)], 1000)
self.assertEqual((failed["success"], failed["time"], failed["clearance"]), (0, 0, 0))
contact = scoring.score_trajectory([sample(distance=0.1, terminal=1, collision=1)], 1000)
self.assertEqual((contact["arrival_rate"], contact["success"], contact["time"]), (1, 0, 0))
with self.assertRaises(EvaluationError):
scoring.score_trajectory([sample()], 1000)
with self.assertRaises(EvaluationError):
scoring.score_trajectory([sample(terminal=1), sample()], 2)
with self.assertRaises(EvaluationError):
scoring.score_trajectory([sample(clearance=float("nan"))], 1)
def test_recorder_excludes_floor_and_keeps_terminal_before_reset(self):
import torch
from scripts.evaluate_obstacle import FirstEpisodeRecorder
class Scene(dict):
env_origins = torch.zeros((2, 3))
robot = SimpleNamespace(
root_link_pos_w=torch.tensor([[0.0, 0, 0.32], [0.0, 0, 0.1]]),
projected_gravity_b=torch.tensor([[0.0, 0, -1.0], [0.0, 1.0, 0.0]]),
)
scene = Scene(
robot=SimpleNamespace(data=robot),
nonfoot_ground_touch=SimpleNamespace(
data=SimpleNamespace(force_history=torch.zeros((2, 1, 4, 3)))
),
forward_scan=SimpleNamespace(
data=SimpleNamespace(distances=torch.ones((2, 32))),
cfg=SimpleNamespace(max_distance=4),
),
)
command = SimpleNamespace(errors=lambda: (None, torch.tensor([0.2, 0.2]), None))
env = SimpleNamespace(
scene=scene,
device="cpu",
num_envs=2,
action_manager=SimpleNamespace(action=torch.ones((2, 12)) * 0.5),
command_manager=SimpleNamespace(get_term=lambda name: command),
termination_manager=SimpleNamespace(compute=lambda: torch.ones(2, dtype=torch.bool)),
)
floor = {"pos": [0, 0, -0.1], "size": [6, 6, 0.1]}
recorder = FirstEpisodeRecorder(env, {"spawn": [0, 0, 0.32], "boxes": [floor]})
env.termination_manager.compute()
robot.root_link_pos_w[:] = torch.tensor([0.0, 0, 0.32]) # Simulated auto-reset overwrite.
robot.projected_gravity_b[:] = torch.tensor([0.0, 0, -1.0])
env.termination_manager.compute()
self.assertEqual([len(s) for s in recorder.samples], [1, 1])
upright, lying = [scoring.score_trajectory(s, 2) for s in recorder.samples]
self.assertEqual(upright["clearance"], 0.5) # 1 valid safe step / 2, not floor distance 0.
self.assertEqual(upright["success"], 1)
self.assertEqual(lying["clearance"], 0)
self.assertEqual(lying["success"], 0)
self.assertEqual(lying["time"], 0)
self.assertEqual(lying["fall_rate"], 1)
def test_threshold_boundary_and_fail_closed(self):
base = scoring.score_trajectory([sample(distance=0.1)], 1)
base.update(success=0.5, fall_rate=0.1, no_fall=0.9)
current = dict(base, success=0.48, fall_rate=0.12, no_fall=0.88)
self.assertTrue(scoring.score_evaluation(current, base)["eligible"])
for changed in (
dict(current, success=0.479999),
dict(current, fall_rate=0.120001, no_fall=0.879999),
):
self.assertFalse(scoring.score_evaluation(changed, base)["eligible"])
self.assertEqual(scoring.score_evaluation(changed, base)["score"], -1)
custom = validate_task_config(OBSTACLE_TASK, {}, 42)
expected = scoring.protocol(custom, 2)
valid = evaluation(custom)
scoring.validate_evaluation(valid, expected)
for mutate in (
lambda v: v["seedMetrics"].pop(),
lambda v: v["metrics"].pop("success"),
lambda v: v["protocol"].update(stepsPerSeed=999),
lambda v: v["seedMetrics"][0].update(episodes=1),
lambda v: v["metrics"].update(smooth=float("inf")),
):
bad = deepcopy(valid)
mutate(bad)
with self.assertRaises(EvaluationError):
scoring.validate_evaluation(bad, expected)
def test_fixed_three_seed_and_custom_authoritative_map(self):
custom = validate_task_config(OBSTACLE_TASK, {}, 42)
scenes = scoring.evaluation_scenarios(custom)
self.assertEqual([s["seed"] for s in scenes], [101, 202, 303])
self.assertNotEqual(scenes[0]["terrain"], scenes[1]["terrain"])
layout = build_terrain_layout(custom)
layout["approximation"] = True
custom = validate_task_config(
OBSTACLE_TASK, {"terrainPreset": "custom_boxes", "customTerrainBoxes": layout}, 42
)
fixed = scoring.protocol(custom, 2)
self.assertEqual(fixed["sceneMode"], "fixed-custom-map")
self.assertEqual([s["terrain"] for s in fixed["scenarios"]], [layout] * 3)
self.assertEqual(fixed, scoring.protocol(deepcopy(custom), 2))
class ObstacleManagerTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
root = Path(self.temp.name)
self.manager = TuningManager(SERVICE / "rl", sys.executable, root, GpuLease())
payload = {
"taskId": OBSTACLE_TASK,
"mode": "approval",
"evalNumEnvs": 2,
"numEnvs": 2,
"initialIterations": 1,
"middleIterations": 1,
"finalIterations": 1,
"sensorCfg": {"fov": 60, "avoidanceWeight": 3},
}
mode, config, objective, fallback = self.manager.parse_create(payload)
self.session = self.manager.storage.create_session(mode, config, objective, fallback)
self.trial = self.manager.storage.create_trial(
self.session["id"],
0,
0,
1,
self.manager._base_configuration(self.session),
None,
"trial-000-rung-0",
)
def tearDown(self):
self.manager.shutdown()
self.temp.cleanup()
def test_argv_json_real_train_and_eval_ingress(self):
commands = []
custom = self.session["config"]["taskConfig"]
def run(session_id, command, cwd, environment, log_path):
commands.append(command)
task_path = Path(command[command.index("--task-config") + 1])
reward_path = Path(command[command.index("--reward-config") + 1])
from scripts.train import _load_reward_config, _load_task_config
self.assertEqual(_load_task_config(OBSTACLE_TASK, str(task_path), 42), custom)
self.assertEqual(
_load_reward_config(str(reward_path), None, OBSTACLE_TASK),
self.trial["rewardConfig"],
)
if "scripts/train.py" in command:
(log_path.parent / "model_0.pt").write_bytes(b"mock-checkpoint")
(log_path.parent / "policy.onnx").write_bytes(b"mock-onnx")
else:
Path(command[command.index("--output") + 1]).write_text(
json.dumps(evaluation(custom))
)
return 0
self.manager.cancel_events[self.session["id"]] = threading.Event()
with (
patch.object(self.manager, "_run_command", side_effect=run),
patch.object(self.manager.studies, "record", return_value=0),
):
result = self.manager._execute_trial(self.session, self.trial)
self.assertEqual(result["state"], "completed")
self.assertIn("--steps-per-seed=1000", commands[1])
self.assertIn(OBSTACLE_TASK, commands[0])
context = self.manager._proposal_context(self.session)
self.assertIn("32", context["taskContext"])
self.assertEqual(set(context["allowlist"]["params"]), {"target_velocity"})
def test_cas_stale_guardrails_and_mode_roundtrip(self):
sid = self.session["id"]
constraint = {"params.target_velocity": {"kind": "range", "min": 0.4, "max": 0.8}}
self.manager.set_constraints(sid, {"revision": 0, "constraints": constraint})
before = self.manager.detail(sid)
with self.assertRaises(ResourceBusyError):
self.manager.set_constraints(sid, {"revision": 0, "constraints": {}})
self.assertEqual(self.manager.detail(sid), before)
for bad in (
{"weights.pose": {"kind": "fixed", "value": 1}},
{"params.target_velocity": {"kind": "fixed", "value": float("nan")}},
):
with self.assertRaises(RewardConfigError):
self.manager.set_constraints(sid, {"revision": 1, "constraints": bad})
self.assertEqual(self.manager.detail(sid), before)
self.manager.storage.update_session(sid, state="awaiting_approval")
proposal = self.manager.storage.create_proposal(
sid, self.trial["id"], {"params": {"target_velocity": 0.7}}, "test", {}, 0.8, "agent"
)
self.assertEqual(self.manager.set_mode(sid, {"mode": "automatic"})["mode"], "automatic")
self.assertEqual(self.manager.storage.get_proposal(proposal["id"])["state"], "approved")
self.assertEqual(self.manager.set_mode(sid, {"mode": "approval"})["mode"], "approval")
with self.assertRaises(RewardConfigError):
validate_proposal(
{"params": {"target_velocity": 0.9}},
self.trial["rewardConfig"],
constraint,
OBSTACLE_TASK,
)
def test_protocol_fields_cannot_be_changed(self):
for values in (
{"evalSteps": 999},
{"seeds": [1, 2, 3]},
{"objectiveWeights": {}},
{"taskConfig": {"unknown": 1}},
{"taskConfig": {"seed": True}},
{"seed": 1, "taskConfig": {"seed": True}},
):
with self.assertRaises(TuningError):
self.manager.parse_create({"taskId": OBSTACLE_TASK, **values})
class IsolatedEvaluationTest(unittest.TestCase):
def test_spawn_results_and_failure_cancel_missing_seed_fail_closed(self):
import subprocess
from scripts.evaluate import EvaluateConfig
from scripts.evaluate_obstacle import evaluate_isolated_seeds
custom = validate_task_config(OBSTACLE_TASK, {}, 42)
fixed = scoring.protocol(custom, 2)
reward = base_configuration(OBSTACLE_TASK)
with tempfile.TemporaryDirectory() as directory:
checkpoint = Path(directory) / "model.pt"
checkpoint.write_bytes(b"test checkpoint snapshot")
cfg = EvaluateConfig(checkpoint=str(checkpoint), output="unused", num_envs=2)
def run(command, **kwargs):
self.assertFalse(kwargs["shell"])
self.assertEqual(command[0], sys.executable)
request = json.loads(Path(command[2]).read_text())
result = evaluation(custom)["seedMetrics"][0]
result["seed"] = request["scenario"]["seed"]
result["checkpointSha256"] = request["checkpointSha256"]
Path(command[3]).write_text(json.dumps(result))
with patch("scripts.evaluate_obstacle.subprocess.run", side_effect=run) as mocked:
results = evaluate_isolated_seeds(OBSTACLE_TASK, cfg, fixed, reward)
self.assertEqual([r["seed"] for r in results], [101, 202, 303])
self.assertEqual(mocked.call_count, 3)
for failure in (
subprocess.CalledProcessError(1, "worker"),
subprocess.TimeoutExpired("worker", 1200),
KeyboardInterrupt(),
):
with patch(
"scripts.evaluate_obstacle.subprocess.run", side_effect=failure
) as mocked:
with self.assertRaises(type(failure)):
evaluate_isolated_seeds(OBSTACLE_TASK, cfg, fixed, reward)
self.assertEqual(mocked.call_count, 1)
with (
patch("scripts.evaluate_obstacle.subprocess.run"),
self.assertRaises(FileNotFoundError),
):
evaluate_isolated_seeds(OBSTACLE_TASK, cfg, fixed, reward)
def wrong_seed(command, **kwargs):
run(command, **kwargs)
path = Path(command[3])
result = json.loads(path.read_text())
result["seed"] = 0
path.write_text(json.dumps(result))
with (
patch("scripts.evaluate_obstacle.subprocess.run", side_effect=wrong_seed),
self.assertRaises(EvaluationError),
):
evaluate_isolated_seeds(OBSTACLE_TASK, cfg, fixed, reward)
@unittest.skipUnless(
os.environ.get("GO2_RUN_TUNING_SMOKE") == "1", "opt-in real GPU checkpoint rollout"
)
class ObstacleRolloutSmoke(unittest.TestCase):
def test_actual_checkpoint_policy_statistics_and_first_terminal(self):
from dataclasses import asdict
import src.tasks # noqa: F401
import torch
from mjlab.envs import ManagerBasedRlEnv
from mjlab.rl import RslRlVecEnvWrapper
from mjlab.tasks.registry import load_rl_cfg, load_runner_cls
from scripts.evaluate import EvaluateConfig
from scripts.evaluate_obstacle import FirstEpisodeRecorder, configure_seed
cfg = EvaluateConfig(
checkpoint=os.environ["GO2_TUNING_CHECKPOINT"],
output="/tmp/unused.json",
num_envs=2,
device="cuda:0",
)
custom = validate_task_config(OBSTACLE_TASK, {}, 42)
scenario = scoring.evaluation_scenarios(custom)[0]
env_cfg = configure_seed(OBSTACLE_TASK, cfg, scenario, base_configuration(OBSTACLE_TASK))
env_cfg.episode_length_s = 0.02 # Test-only one-step timeout exercises auto-reset capture.
env = ManagerBasedRlEnv(env_cfg, device="cuda:0")
agent_cfg = load_rl_cfg(OBSTACLE_TASK)
wrapped = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions)
try:
runner = load_runner_cls(OBSTACLE_TASK)(
wrapped, asdict(agent_cfg), log_dir=None, device="cuda:0"
)
runner.load(
cfg.checkpoint, load_cfg={"actor": True}, strict=True, map_location="cuda:0"
)
saved = torch.load(cfg.checkpoint, weights_only=False)["actor_state_dict"]
actual = runner.alg.actor.state_dict()
normalizers = [k for k in saved if "normaliz" in k]
self.assertTrue(normalizers)
for key in normalizers:
torch.testing.assert_close(actual[key], saved[key].to(actual[key].device))
obs, _ = env.reset(seed=101)
recorder = FirstEpisodeRecorder(env, scenario["terrain"])
with torch.inference_mode():
policy = runner.get_inference_policy(device="cuda:0")
for _ in range(2):
obs, _, _, _ = wrapped.step(policy(obs))
self.assertEqual([len(s) for s in recorder.samples], [1, 1])
self.assertTrue(all(s[0]["terminal"] for s in recorder.samples))
scoring.validate_metrics(recorder.metrics(horizon=2))
finally:
wrapped.close()
+475
View File
@@ -0,0 +1,475 @@
"""CPU transfer regression; real source and <=4env/1iteration GPU checks are opt-in."""
import copy
import importlib.util
import io
import json
import os
import sys
import tempfile
import unittest
from dataclasses import asdict, replace
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
for root in (ROOT, ROOT / "rl"):
sys.path.insert(0, str(root))
HAS_STACK = all(importlib.util.find_spec(m) is not None for m in ("mjlab", "torch", "onnxruntime"))
@unittest.skipUnless(HAS_STACK, "installed RL + CPU ORT stack required")
class PretrainedTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
import torch
torch.set_num_threads(1)
from pretrained import ValidatedSource, make_reference_actor
from src.tasks.velocity.config.go2.env_cfgs import unitree_go2_flat_env_cfg
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
torch.manual_seed(42)
actor = make_reference_actor()
with torch.no_grad():
actor.obs_normalizer._mean.uniform_(-0.2, 0.2)
actor.obs_normalizer._var.uniform_(0.1, 1.2)
actor.obs_normalizer._std.copy_(actor.obs_normalizer._var.sqrt())
actor.obs_normalizer.count.fill_(983138304)
cls.source = ValidatedSource(actor.state_dict(), {"source_id": "synthetic"})
cls.env_cfg = asdict(unitree_go2_flat_env_cfg())
cls.agent_cfg = asdict(unitree_go2_ppo_runner_cfg())
def test_extension_preserves_actions_and_learns_new_columns(self):
import torch
from pretrained import comparison_observations, make_reference_actor, warm_start_actor
from tensordict import TensorDict
source_actor = make_reference_actor().eval()
source_actor.load_state_dict(self.source.actor_state)
base = comparison_observations()
expected = source_actor.mlp(source_actor.obs_normalizer(base)).detach()
for dim in (47, 81, 97):
with self.subTest(dim=dim):
actor = make_reference_actor(dim)
warm_start_actor(actor, self.source)
for key in ("_mean", "_var", "_std"):
value = getattr(actor.obs_normalizer, key)
self.assertTrue(
torch.equal(value[:, :47], self.source.actor_state[f"obs_normalizer.{key}"])
)
self.assertTrue(
torch.equal(
value[:, 47:],
torch.full_like(value[:, 47:], 0 if key == "_mean" else 1),
)
)
self.assertTrue(
torch.equal(
actor.obs_normalizer.count, self.source.actor_state["obs_normalizer.count"]
)
)
self.assertEqual(actor.mlp[0].weight[:, 47:].count_nonzero().item(), 0)
self.assertTrue(actor.mlp[0].weight.requires_grad)
extra = torch.rand(len(base), dim - 47) * 2 - 1
x = torch.cat((base, extra), dim=1)
torch.testing.assert_close(
actor.mlp(actor.obs_normalizer(x)), expected, atol=3e-6, rtol=3e-6
)
initial_error = (actor.mlp(actor.obs_normalizer(x)) - expected).abs().max().item()
x[:, 47:] *= -1
torch.testing.assert_close(
actor.mlp(actor.obs_normalizer(x)), expected, atol=3e-6, rtol=3e-6
)
before = actor.obs_normalizer._mean.clone()
obs = TensorDict({"actor": x}, batch_size=[len(base)])
actor.update_normalization(obs)
self.assertEqual(actor.obs_normalizer.count.item(), 983138304 + len(base))
self.assertLess(
(actor.obs_normalizer._mean[:, :47] - before[:, :47]).abs().max().item(), 1e-6
)
# Updating statistics is not eval identity. Random gravity probes include
# out-of-distribution values in nearly constant source channels.
updated_source = copy.deepcopy(source_actor).train()
updated_source.update_normalization(
TensorDict({"actor": base}, batch_size=[len(base)])
)
updated_expected = updated_source.mlp(updated_source.obs_normalizer(base)).detach()
torch.testing.assert_close(actor(obs), updated_expected, atol=3e-6, rtol=3e-6)
self.assertLess((actor(obs) - expected).abs().max().item(), 2e-4)
if hasattr(self, "evidence"):
self.evidence.setdefault("cpu_transfer", {})[str(dim)] = {
"initial_max_abs_error": initial_error,
"updated_source_max_abs_error": (actor(obs) - updated_expected)
.abs()
.max()
.item(),
"random_batch_action_max_abs_change": (actor(obs) - expected)
.abs()
.max()
.item(),
}
loss = actor(obs).square().mean()
loss.backward()
grad = actor.mlp[0].weight.grad
self.assertTrue(torch.isfinite(grad).all())
if dim > 47:
self.assertGreater(grad[:, 47:].abs().max().item(), 0)
torch.optim.Adam(actor.parameters(), lr=1e-4).step()
if dim > 47:
self.assertGreater(actor.mlp[0].weight[:, 47:].abs().max().item(), 0)
stream = io.BytesIO()
torch.save(actor.state_dict(), stream)
stream.seek(0)
restored = make_reference_actor(dim)
restored.load_state_dict(torch.load(stream, weights_only=True), strict=True)
for k, v in actor.state_dict().items():
self.assertTrue(torch.equal(v, restored.state_dict()[k]), k)
def test_rejects_shapes_nonfinite_and_runtime_activation(self):
import torch
from pretrained import (
PretrainedError,
ValidatedSource,
make_reference_actor,
warm_start_actor,
)
for key, value in (
("mlp.0.weight", torch.zeros(512, 46)),
("obs_normalizer._std", torch.full((1, 47), float("nan"))),
("distribution.std_param", torch.zeros(12)),
):
state = dict(self.source.actor_state)
state[key] = value
with self.assertRaises(PretrainedError):
warm_start_actor(make_reference_actor(81), ValidatedSource(state, {}))
actor = make_reference_actor(81)
actor.mlp[1] = torch.nn.ReLU()
with self.assertRaisesRegex(PretrainedError, "architecture"):
warm_start_actor(actor, self.source)
with self.assertRaises(PretrainedError):
warm_start_actor(make_reference_actor(82), self.source)
def test_semantics_fail_closed(self):
from pretrained import PretrainedError, _plain, validate_semantics
env, agent = _plain(self.env_cfg), _plain(self.agent_cfg)
validate_semantics(env, agent, self.env_cfg, self.agent_cfg)
for edit in (
lambda e: e["observations"]["actor"]["terms"]["phase"]["params"].update(period=0.7),
lambda e: e["observations"]["actor"]["terms"]["joint_vel"].update(scale=0.1),
lambda e: e["actions"]["joint_pos"].update(scale=0.5),
lambda e: e["scene"]["entities"]["robot"]["articulation"]["actuators"][0].update(
armature=0.1
),
):
bad = copy.deepcopy(env)
edit(bad)
with self.assertRaises(PretrainedError):
validate_semantics(bad, agent, self.env_cfg, self.agent_cfg)
with self.assertRaises(PretrainedError):
validate_semantics(env, agent, bad, self.agent_cfg)
bad_agent = copy.deepcopy(agent)
bad_agent["actor"]["activation"] = "relu"
with self.assertRaises(PretrainedError):
validate_semantics(env, bad_agent, self.env_cfg, self.agent_cfg)
def test_compiled_runtime_contract(self):
from unittest.mock import patch
from pretrained import BASE_TERMS, JOINTS, PretrainedError, validate_runtime_contract
metadata = {
"joint_names": JOINTS,
"observation_names": BASE_TERMS + ["forward_depth", "target_error"],
"command_names": ["twist"],
"action_scale": 0.25,
"joint_stiffness": [20, 20, 40] * 4,
"joint_damping": [1, 1, 2] * 4,
"default_joint_pos": [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8] * 2,
}
with patch("mjlab.rl.exporter_utils.get_base_metadata", return_value=metadata):
validate_runtime_contract(None)
metadata["joint_names"] = list(reversed(JOINTS))
with self.assertRaisesRegex(PretrainedError, "joint order"):
validate_runtime_contract(None)
def test_safe_yaml_and_allowed_roots(self):
from pretrained import PretrainedError, _read_allowed, _yaml_data
with self.assertRaises(PretrainedError):
_yaml_data(b"x: !!python/object/apply:os.system ['touch /tmp/never-pretrained']")
with self.assertRaises(PretrainedError):
_yaml_data(b"x: 1\nx: 2")
self.assertEqual(_yaml_data(b"axis: {0: 1, 1: 2}"), {"axis": {0: 1, 1: 2}})
self.assertEqual(
_yaml_data(b"x: !!python/name:os.system ''"), {"x": {"symbol": "os.system"}}
)
with tempfile.TemporaryDirectory() as directory, tempfile.TemporaryDirectory() as outside:
root = Path(directory)
secret = Path(outside) / "secret.pt"
secret.write_bytes(b"x")
(root / "link.pt").symlink_to(secret)
for path in (secret, root / "link.pt", root / ".." / Path(outside).name / "secret.pt"):
with self.assertRaises(PretrainedError):
_read_allowed(path, [root], 128)
with self.assertRaises(PretrainedError):
_read_allowed(secret, [Path(outside)], 0)
def test_fresh_runner_only_and_trial_consistency(self):
import torch
from pretrained import PretrainedError, initialize_runner, make_reference_actor
actors = []
for _ in range(2):
actor = make_reference_actor(81)
critic = torch.nn.Linear(108, 1)
original = copy.deepcopy(critic.state_dict())
optimizer = torch.optim.Adam(list(actor.parameters()) + list(critic.parameters()))
runner = SimpleNamespace(
current_learning_iteration=0,
alg=SimpleNamespace(actor=actor, critic=critic, optimizer=optimizer),
)
initialize_runner(runner, self.source)
self.assertFalse(optimizer.state)
self.assertEqual(runner.current_learning_iteration, 0)
for k in original:
self.assertTrue(torch.equal(original[k], critic.state_dict()[k]))
actors.append(actor.state_dict())
runner.current_learning_iteration = 1
with self.assertRaises(PretrainedError):
initialize_runner(runner, self.source)
runner.current_learning_iteration = 0
optimizer.state[actor.mlp[0].weight] = {"step": torch.tensor(1)}
with self.assertRaises(PretrainedError):
initialize_runner(runner, self.source)
for k in actors[0]:
self.assertTrue(torch.equal(actors[0][k], actors[1][k]))
def test_cli_modes_do_not_reinitialize_resume(self):
from scripts.train import TrainConfig, _load_pretrained
from src.tasks.velocity.config.go2.env_cfgs import unitree_go2_flat_env_cfg
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
cfg = TrainConfig(env=unitree_go2_flat_env_cfg(), agent=unitree_go2_ppo_runner_cfg())
self.assertIsNone(_load_pretrained(cfg))
self.assertIsNone(_load_pretrained(replace(cfg, resume_checkpoint="trial.pt")))
with self.assertRaisesRegex(ValueError, "mutually exclusive"):
_load_pretrained(
replace(cfg, resume_checkpoint="trial.pt", pretrained_checkpoint="source.pt")
)
cfg.agent.resume = True
with self.assertRaisesRegex(ValueError, "mutually exclusive"):
_load_pretrained(replace(cfg, pretrained_checkpoint="source.pt"))
with self.assertRaisesRegex(ValueError, "ONNX alone"):
_load_pretrained(replace(cfg, pretrained_onnx="policy.onnx"))
@unittest.skipUnless(
HAS_STACK and os.environ.get("GO2_PRETRAINED_SOURCE"),
"set GO2_PRETRAINED_SOURCE to explicit allowed source directory",
)
class RealPretrainedTest(PretrainedTest):
@classmethod
def setUpClass(cls):
super().setUpClass()
from pretrained import read_pretrained_source
directory = Path(os.environ["GO2_PRETRAINED_SOURCE"])
cls.source = read_pretrained_source(
directory / "model_10000.pt",
allowed_roots=[directory],
target_env=cls.env_cfg,
target_agent=cls.agent_cfg,
)
cls.evidence = {"source": cls.source.manifest}
@classmethod
def tearDownClass(cls):
if os.environ.get("GO2_PRETRAINED_EVIDENCE_DIR"):
directory = Path(os.environ["GO2_PRETRAINED_EVIDENCE_DIR"])
directory.mkdir(parents=True, exist_ok=True)
(directory / "real-source-evidence.json").write_text(
json.dumps(cls.evidence, indent=2) + "\n"
)
def test_wrong_checkpoint_actor_rejected_by_onnx(self):
from pretrained import PretrainedError, make_reference_actor, verify_onnx
# No bulk loading: a fresh random actor is sufficient to prove fail-closed identity.
onnx = (Path(os.environ["GO2_PRETRAINED_SOURCE"]) / "policy.onnx").read_bytes()
with self.assertRaisesRegex(PretrainedError, "does not match"):
verify_onnx(onnx, make_reference_actor())
def test_extended_export_matches_cpu_actor(self):
import numpy as np
import onnxruntime as ort
import torch
from pretrained import comparison_observations, make_reference_actor, warm_start_actor
errors = {}
with tempfile.TemporaryDirectory() as directory:
for dim in (81, 97):
actor = make_reference_actor(dim).eval()
warm_start_actor(actor, self.source)
export = actor.as_onnx(verbose=False)
path = str(Path(directory) / f"policy-{dim}.onnx")
torch.onnx.export(
export,
export.get_dummy_inputs(),
path,
input_names=export.input_names,
output_names=export.output_names,
opset_version=18,
dynamo=False,
)
session = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
x = torch.cat((comparison_observations(), torch.rand(48, dim - 47) * 2 - 1), dim=1)
with torch.no_grad():
expected = export(x).numpy()
actual = np.concatenate(
[session.run(None, {"obs": row[None].numpy()})[0] for row in x]
)
np.testing.assert_allclose(actual, expected, atol=2e-5, rtol=2e-5)
errors[str(dim)] = float(np.abs(actual - expected).max())
self.evidence["extended_onnx_max_abs_error"] = errors
@unittest.skipUnless(
os.environ.get("GO2_PRETRAINED_GPU_SMOKE") == "1", "opt-in <=4env x 1iteration GPU smoke"
)
def test_real_observations_and_one_ppo_iteration(self):
import torch
import warp as wp
if not hasattr(wp, "context"):
from warp._src import context
wp.context = context
from mjlab.envs import ManagerBasedRlEnv
from mjlab.rl import RslRlVecEnvWrapper
from pretrained import initialize_runner, make_reference_actor, validate_runtime_contract
from src.tasks.obstacle_avoidance.env_cfg import unitree_go2_obstacle_env_cfg
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
from src.tasks.velocity.rl.runner import VelocityOnPolicyRunner
from task_config import OBSTACLE_TASK, deployment_metadata, validate_task_config
from tensordict import TensorDict
env_cfg = unitree_go2_obstacle_env_cfg()
env_cfg.scene.num_envs = 4
env_cfg.seed = 42
agent_cfg = unitree_go2_ppo_runner_cfg()
agent_cfg.logger = "tensorboard"
agent_cfg.max_iterations = 1
with tempfile.TemporaryDirectory() as directory:
raw = ManagerBasedRlEnv(env_cfg, device="cuda:0")
self.evidence["compiled_runtime"] = validate_runtime_contract(raw)
raw.platform_deployment = deployment_metadata(
OBSTACLE_TASK, validate_task_config(OBSTACLE_TASK, {}, 42), 42
)
env = RslRlVecEnvWrapper(raw)
try:
runner = VelocityOnPolicyRunner(env, asdict(agent_cfg), directory, "cuda:0")
manifest = initialize_runner(runner, self.source)
actor = runner.alg.actor
cpu_actor = make_reference_actor().eval()
cpu_actor.load_state_dict(self.source.actor_state)
obs = env.get_observations()
base = obs["actor"][:, :47].cpu()
with torch.no_grad():
expected = cpu_actor(TensorDict({"actor": base}, batch_size=[4]))
before = actor(obs).cpu()
torch.testing.assert_close(before, expected, atol=2e-5, rtol=2e-5)
old_mean = actor.obs_normalizer._mean.clone()
actor.update_normalization(obs)
mean_change = (
(actor.obs_normalizer._mean[:, :47] - old_mean[:, :47]).abs().max().item()
)
after = actor(obs).detach().cpu()
self.assertTrue(torch.isfinite(after).all())
torch.testing.assert_close(before, after, atol=2e-5, rtol=2e-5)
loss = actor(obs).square().mean()
loss.backward()
grad = actor.mlp[0].weight.grad[:, 47:]
self.assertTrue(torch.isfinite(grad).all())
self.assertGreater(grad.abs().max().item(), 0)
grad_max = grad.abs().max().item()
runner.alg.optimizer.zero_grad()
critic_before = copy.deepcopy(runner.alg.critic.state_dict())
runner.learn(num_learning_iterations=1, init_at_random_ep_len=True)
self.assertGreater(actor.mlp[0].weight[:, 47:].abs().max().item(), 0)
self.assertTrue(runner.alg.optimizer.state)
self.assertTrue(
any(
not torch.equal(v, runner.alg.critic.state_dict()[k])
for k, v in critic_before.items()
)
)
saved = torch.load(
Path(directory) / "model_0.pt", map_location="cpu", weights_only=True
)
# Same-trial resume uses the installed PPO loader: restore all states.
actor_state = copy.deepcopy(actor.state_dict())
# Production promotion starts a fresh process/runner; do not reload
# into tensors created by the previous rollout's inference_mode.
resumed = VelocityOnPolicyRunner(env, asdict(agent_cfg), directory, "cuda:0")
self.assertTrue(resumed.alg.load(saved, None, strict=True))
resumed.current_learning_iteration = saved["iter"] + 1
self.assertEqual(resumed.current_learning_iteration, 1)
self.assertTrue(resumed.alg.optimizer.state)
actor = resumed.alg.actor
for k, v in actor_state.items():
self.assertTrue(torch.equal(v.cpu(), actor.state_dict()[k].cpu()), k)
import numpy as np
import onnxruntime as ort
session = ort.InferenceSession(
str(Path(directory) / "policy.onnx"), providers=["CPUExecutionProvider"]
)
final_obs = env.get_observations()
with torch.no_grad():
expected_final = actor(final_obs).cpu().numpy()
actual = np.concatenate(
[
session.run(None, {"obs": row[None].cpu().numpy()})[0]
for row in final_obs["actor"]
]
)
np.testing.assert_allclose(actual, expected_final, atol=2e-5, rtol=2e-5)
# Independently run source ORT on observations actually sampled from MuJoCo.
source_session = ort.InferenceSession(
str(Path(os.environ["GO2_PRETRAINED_SOURCE"]) / "policy.onnx"),
providers=["CPUExecutionProvider"],
)
source_actions = np.concatenate(
[source_session.run(None, {"obs": row[None].numpy()})[0] for row in base]
)
np.testing.assert_allclose(source_actions, before.numpy(), atol=2e-5, rtol=2e-5)
self.evidence["gpu_smoke"] = {
"num_envs": 4,
"iterations": 1,
"initialization": manifest,
"real_observation_source_onnx_max_abs_error": float(
np.abs(source_actions - before.numpy()).max()
),
"normalization_batch_action_max_abs_change": (after - before)
.abs()
.max()
.item(),
"normalization_batch_base_mean_max_abs_change": mean_change,
"new_columns_gradient_max": grad_max,
"new_columns_weight_max_after_ppo": actor.mlp[0]
.weight[:, 47:]
.abs()
.max()
.item(),
"export_max_abs_error": float(np.abs(actual - expected_final).max()),
"count_restored": actor.obs_normalizer.count.item(),
"all_actor_tensors_restored_exactly": True,
}
finally:
env.close()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,307 @@
"""Registered source authority, immutable snapshots, and same-trial continuation."""
import hashlib
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from pretrained_sources import PretrainedSources, SourceError, regular_bytes
from server import ApiError, TrainingManager
from test_tuning_manager import BASE_METRICS, FakeAdvisor
from tuning.manager import TuningError, TuningManager
from tuning.process import GpuLease
from tuning.schema import BASE_REWARD_CONFIGURATION, RewardConfigError, validate_proposal
def fake_validate(_self, directory, checkpoint, *_args):
artifacts = {}
for key, relative in {
"checkpoint": checkpoint,
"onnx": "policy.onnx",
"env": "params/env.yaml",
"agent": "params/agent.yaml",
}.items():
data = (directory / relative).read_bytes()
artifacts[key] = {
"name": Path(relative).name,
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
}
return {
"source_id": hashlib.sha256(json.dumps(artifacts, sort_keys=True).encode()).hexdigest(),
"artifacts": artifacts,
"source_iteration": 10000,
}
class RegisteredSourcesTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.original = self.root / "original"
(self.original / "params").mkdir(parents=True)
for relative in ("model_10000.pt", "policy.onnx", "params/env.yaml", "params/agent.yaml"):
(self.original / relative).write_bytes(relative.encode())
self.config = self.root / "sources.json"
self.config.write_text(
json.dumps(
{
"allowedRoots": [str(self.original)],
"sources": [
{
"id": "base",
"label": "基础行走",
"checkpoint": str(self.original / "model_10000.pt"),
"onnx": str(self.original / "policy.onnx"),
}
],
}
)
)
self.validator = patch.object(PretrainedSources, "_validate", fake_validate)
self.validator.start()
self.registry = PretrainedSources(
self.config,
self.root / "snapshots",
sys.executable,
Path(__file__).resolve().parents[1] / "rl",
)
def tearDown(self):
self.validator.stop()
self.temp.cleanup()
def test_registered_snapshot_never_rereads_mutated_original_and_checks_sha(self):
bound = self.registry.bind(self.registry.catalog()[0]["id"], "Unitree-Go2-Flat")
original_sha = bound["manifest"]["artifacts"]["checkpoint"]["sha256"]
(self.original / "model_10000.pt").write_bytes(b"changed after registration")
self.assertEqual(
self.registry.bind(self.registry.catalog()[0]["id"], "Unitree-Go2-Flat"), bound
)
args = self.registry.arguments(bound)
checkpoint = Path(args[args.index("--pretrained-checkpoint") + 1])
self.assertEqual(hashlib.sha256(checkpoint.read_bytes()).hexdigest(), original_sha)
restored = PretrainedSources(None, self.root / "snapshots", sys.executable, self.root)
self.assertEqual(restored.arguments(bound), args)
re_registered = PretrainedSources(
self.config, self.root / "snapshots", sys.executable, self.root
)
with self.assertRaisesRegex(SourceError, "已变化"):
re_registered.bind(bound["sourceId"], "Unitree-Go2-Flat")
self.assertEqual(restored.arguments(bound), args)
checkpoint.chmod(0o644)
checkpoint.write_bytes(b"tamper")
with self.assertRaisesRegex(SourceError, "SHA"):
restored.arguments(bound)
checkpoint.parent.chmod(0o755)
checkpoint.unlink()
with self.assertRaises(SourceError):
restored.verify(bound)
def test_rejects_path_symlink_fifo_size_and_invalid_registry(self):
with self.assertRaises(SourceError):
self.registry.bind(str(self.original / "model_10000.pt"), "Unitree-Go2-Flat")
with self.assertRaisesRegex(SourceError, "Rough"):
self.registry.bind(self.registry.catalog()[0]["id"], "Unitree-Go2-Rough")
(self.original / "link.pt").symlink_to(self.original / "model_10000.pt")
with self.assertRaises(SourceError):
regular_bytes(self.original / "link.pt", self.original, 256)
with self.assertRaises(SourceError):
regular_bytes(self.original / ".." / "sources.json", self.original, 256)
with self.assertRaises(SourceError):
regular_bytes(self.original / "policy.onnx", self.original, 1)
os.mkfifo(self.original / "pipe")
with self.assertRaises(SourceError):
regular_bytes(self.original / "pipe", self.original, 256)
self.config.write_text('{"allowedRoots": [], "sources": [], "python": "evil"}')
with self.assertRaises(SourceError):
PretrainedSources(self.config, self.root / "other", sys.executable, self.root)
def test_missing_checkpoint_is_visible_failure_not_random_fallback(self):
(self.original / "model_10000.pt").unlink()
registry = PretrainedSources(self.config, self.root / "other", sys.executable, self.root)
self.assertFalse(registry.catalog()[0]["ready"])
with self.assertRaisesRegex(SourceError, "pt"):
registry.bind("base", "Unitree-Go2-Flat")
def test_training_request_only_id_and_public_identity(self):
manager = TrainingManager(
Path(__file__).resolve().parents[1] / "rl",
sys.executable,
("Unitree-Go2-Flat", "Unitree-Go2-Rough"),
check_environment=False,
sources=self.registry,
)
payload = {
"taskId": "Unitree-Go2-Flat",
"numEnvs": 4,
"maxIterations": 1,
"seed": 42,
"device": "cpu",
"pretrainedSourceId": self.registry.catalog()[0]["id"],
}
config = manager.parse_config(payload)
self.assertEqual(
config.pretrained,
self.registry.bind(self.registry.catalog()[0]["id"], "Unitree-Go2-Flat"),
)
command = manager.command_for(config)
self.assertIn("--pretrained-source-id", command)
self.assertNotIn("--resume-checkpoint", command)
self.assertNotIn(str(self.original), json.dumps(manager.health()["pretrainedSources"]))
for key in ("pretrainedCheckpoint", "allowedRoots", "pretrained", "resumeCheckpoint"):
with self.assertRaises(ApiError):
manager.parse_config({**payload, key: "/etc/passwd"})
with self.assertRaises(ApiError):
manager.parse_config({**payload, "pretrainedSourceId": None})
with self.assertRaises(ApiError):
manager.parse_config({**payload, "taskId": "Unitree-Go2-Rough"})
old = manager.parse_config({k: v for k, v in payload.items() if k != "pretrainedSourceId"})
self.assertIsNone(old.pretrained)
self.assertNotIn("--pretrained-checkpoint", manager.command_for(old))
def tuning(self):
return TuningManager(
Path(__file__).resolve().parents[1] / "rl",
sys.executable,
self.root / "tuning",
GpuLease(),
advisor=FakeAdvisor(),
sources=self.registry,
)
def session(self, manager, mode="approval"):
_, config, objective, fallback = manager.parse_create(
{
"mode": mode,
"pretrainedSourceId": self.registry.catalog()[0]["id"],
"trialCount": 2,
"numEnvs": 4,
"initialIterations": 1,
"middleIterations": 2,
"finalIterations": 3,
}
)
return manager.storage.create_session(mode, config, objective, fallback)
def test_baseline_new_trial_warmstart_and_rung_resumes_only_own_checkpoint(self):
manager = self.tuning()
session = self.session(manager)
manager.cancel_events[session["id"]] = threading.Event()
commands = []
def run(_session, command, _cwd, _env, _log):
commands.append(command)
if "--output-dir" in command:
directory = Path(command[command.index("--output-dir") + 1])
(directory / "model_0.pt").write_bytes(b"trial checkpoint")
(directory / "policy.onnx").write_bytes(b"trial policy")
(directory / "initialization.json").write_text(
json.dumps(session["config"]["pretrained"]["manifest"])
)
else:
Path(command[command.index("--output") + 1]).write_text(
json.dumps({"metrics": BASE_METRICS})
)
return 0
with patch.object(manager, "_run_command", run):
parent = None
for number, rung in ((0, 0), (1, 0), (1, 1)):
trial = manager.storage.create_trial(
session["id"],
number,
rung,
rung + 1,
BASE_REWARD_CONFIGURATION,
None,
f"trial-{number}-{rung}",
)
result = manager._execute_trial(session, trial, parent if rung else None)
parent = manager._session_root(session["id"]) / result["checkpointPath"]
train = [c for c in commands if "--output-dir" in c]
self.assertEqual(
train[0][train[0].index("--pretrained-source-id") + 1],
train[1][train[1].index("--pretrained-source-id") + 1],
)
self.assertIn("--resume-checkpoint", train[2])
self.assertNotIn("--pretrained-checkpoint", train[2])
self.assertIn("trial-1-0/model_0.pt", train[2][-1])
self.assertEqual(len([c for c in commands if "--steps-per-seed=1000" in c]), 3)
with self.assertRaises(RewardConfigError):
validate_proposal({"pretrainedSourceId": "other"}, BASE_REWARD_CONFIGURATION)
def test_restart_states_source_constraints_and_explicit_recovery(self):
manager = self.tuning()
sessions = []
for state in ("queued", "paused", "awaiting_approval", "succeeded"):
session = self.session(manager)
trial = manager.storage.create_trial(
session["id"], 0, 0, 1, BASE_REWARD_CONFIGURATION, None, "baseline"
)
manager.storage.update_trial(
trial["id"],
state="completed",
evaluation={"metrics": BASE_METRICS},
eligible=True,
score=0,
)
proposal = manager.storage.create_proposal(
session["id"], trial["id"], {"weights": {"pose": 1.1}}, "proposal", {}, 0.8
)
manager.storage.replace_constraints(
session["id"], 0, {"weights.pose": {"kind": "range", "min": 0, "max": 2}}
)
manager.storage.update_session(session["id"], state=state)
sessions.append((session, proposal, state))
restored = self.tuning() # Actual SQLite reopening, no worker/API calls on construction.
self.assertFalse(restored.workers)
for session, _proposal, previous in sessions:
value = restored.detail(session["id"])
self.assertEqual(
value["state"], "succeeded" if previous == "succeeded" else "interrupted"
)
self.assertEqual(value["config"]["pretrained"], session["config"]["pretrained"])
self.assertEqual(value["control"]["constraintsRevision"], 1)
self.assertEqual(value["mode"], "approval")
self.assertEqual(value["proposals"][0]["state"], "pending")
session, proposal, _ = sessions[0]
# Stop before dispatch: recovery must invalidate pending first, never execute it.
with (
patch.object(restored, "_wait_for_dispatch", side_effect=RuntimeError("test boundary")),
patch.object(restored.advisor, "propose") as advisor,
):
restored._run_session(session["id"], True, threading.Event())
advisor.assert_not_called()
self.assertEqual(restored.storage.get_proposal(proposal["id"])["state"], "rejected")
self.assertIn(
"recovery_invalidated", restored.storage.get_proposal(proposal["id"])["feedback"]
)
self.assertEqual(len(restored.storage.list_trials(session["id"])), 1)
session = sessions[1][0]
with patch.object(restored, "_start_worker") as start:
restored.resume(session["id"])
with self.assertRaises(TuningError):
restored.resume(session["id"])
start.assert_called_once()
session = sessions[2][0]
snapshot = self.registry.verify(session["config"]["pretrained"])
file = snapshot / "policy.onnx"
file.chmod(0o644)
file.write_bytes(b"corrupt")
with patch.object(restored, "_start_worker") as start:
with self.assertRaises(SourceError):
restored.resume(session["id"])
start.assert_not_called()
self.assertEqual(restored.storage.get_session(session["id"])["state"], "interrupted")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,735 @@
"""Single-file upload security, strict graph reconstruction, and shared warm-start seam."""
import copy
import errno
import hashlib
import http.client
import importlib.util
import io
import json
import os
import socket
import sys
import tempfile
import threading
import unittest
from contextlib import contextmanager
from dataclasses import asdict
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
for root in (ROOT, ROOT / "rl"):
sys.path.insert(0, str(root))
from pretrained_sources import PretrainedSources, SourceError # noqa: E402
from server import TrainingManager, TrainingRequestHandler # noqa: E402
def fixture_onnx(state):
import onnx
from onnx import helper, numpy_helper
from pretrained_upload import SEMANTICS
tensors = [
numpy_helper.from_array(value.numpy(), key)
for key, value in state.items()
if key.startswith("mlp.") or key == "obs_normalizer._mean"
]
tensors.append(
numpy_helper.from_array((state["obs_normalizer._std"] + 0.01).numpy(), "onnx::Div_24")
)
nodes = [
helper.make_node("Sub", ["obs", "obs_normalizer._mean"], ["centered"]),
helper.make_node("Div", ["centered", "onnx::Div_24"], ["normalized"]),
]
previous = "normalized"
for i in (0, 2, 4, 6):
output = "actions" if i == 6 else f"linear{i}"
nodes.append(
helper.make_node(
"Gemm", [previous, f"mlp.{i}.weight", f"mlp.{i}.bias"], [output], transB=1
)
)
previous = output
if i < 6:
previous = f"elu{i}"
nodes.append(helper.make_node("Elu", [output], [previous], alpha=1.0))
model = helper.make_model(
helper.make_graph(
nodes,
"actor",
[helper.make_tensor_value_info("obs", onnx.TensorProto.FLOAT, [1, 47])],
[helper.make_tensor_value_info("actions", onnx.TensorProto.FLOAT, [1, 12])],
tensors,
),
opset_imports=[helper.make_opsetid("", 18)],
ir_version=8,
)
helper.set_model_props(
model, {key: ",".join(map(str, value)) for key, value in SEMANTICS.items()}
)
return model
@unittest.skipUnless(
all(importlib.util.find_spec(m) is not None for m in ("torch", "mjlab", "onnx", "onnxruntime")),
"installed CPU RL/ONNX stack required",
)
class UploadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
import torch
from pretrained import make_reference_actor
torch.set_num_threads(1)
torch.manual_seed(123)
actor = make_reference_actor()
actor.obs_normalizer.count.fill_(983138304)
cls.state = actor.state_dict()
stream = io.BytesIO()
torch.save({"actor_state_dict": cls.state, "iter": 10000}, stream)
cls.pt = stream.getvalue()
cls.onnx = fixture_onnx(cls.state).SerializeToString()
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.registry = PretrainedSources(None, self.root / "store", sys.executable, ROOT / "rl")
def tearDown(self):
self.temp.cleanup()
def upload(self, data=None, fmt="pt", name="policy.pt"):
data = self.pt if data is None else data
return self.registry.receive_upload(
io.BytesIO(data), len(data), fmt, "go2-legacy47-v1", name
)
def test_single_file_persistence_dedup_and_binding_cli(self):
record = self.upload(name="../../etc/evil\n.pt")
self.assertEqual(record["label"], "evil_.pt")
bound = record["initialization"]
directory = self.registry.verify(bound)
self.assertEqual(
{p.name for p in directory.iterdir()},
{"upload.pt", "actor.pt", "upload.json", "label.json"},
)
self.assertEqual(self.upload(name="renamed.pt"), record)
restored = PretrainedSources(None, self.root / "store", sys.executable, ROOT / "rl")
self.assertEqual(restored.catalog(), [record])
self.assertEqual(restored.bind(record["id"], "Unitree-Go2-Flat"), bound)
args = restored.arguments(bound)
self.assertIn("--pretrained-upload-manifest", args)
self.assertNotIn("--resume-checkpoint", args)
manager = TrainingManager(
ROOT / "rl",
sys.executable,
("Unitree-Go2-Flat",),
check_environment=False,
sources=restored,
)
cfg = manager.parse_config(
{
"taskId": "Unitree-Go2-Flat",
"numEnvs": 4096,
"maxIterations": 1,
"seed": 42,
"device": "cpu",
"pretrainedSourceId": record["id"],
}
)
self.assertEqual(cfg.pretrained, bound)
self.assertIn("--pretrained-upload-manifest", manager.command_for(cfg))
from scripts.train import TrainConfig, _load_pretrained
from src.tasks.velocity.config.go2.env_cfgs import unitree_go2_flat_env_cfg
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
train = TrainConfig(
env=unitree_go2_flat_env_cfg(),
agent=unitree_go2_ppo_runner_cfg(),
pretrained_checkpoint=str(directory / "actor.pt"),
pretrained_upload_manifest=str(directory / "upload.json"),
pretrained_allowed_roots=[str(directory)],
pretrained_source_id=record["id"],
)
source = _load_pretrained(train)
self.assertEqual(source.manifest, bound["manifest"])
from pretrained import public_initialization_metadata
public = public_initialization_metadata(source.manifest)
self.assertEqual(public["uploadSha256"], hashlib.sha256(self.pt).hexdigest())
self.assertEqual(public["templateId"], "go2-legacy47-v1")
self.assertNotIn(str(self.root), json.dumps(public))
# Changing upload content never changes the previously bound job.
newer = self.upload(self.onnx, "onnx")
self.assertNotEqual(newer["id"], record["id"])
self.assertEqual(restored.arguments(cfg.pretrained), args)
(directory / "actor.pt").chmod(0o644)
(directory / "actor.pt").write_bytes(b"tamper")
with self.assertRaisesRegex(SourceError, "SHA"):
restored.arguments(bound)
broken = PretrainedSources(None, self.root / "store", sys.executable, ROOT / "rl")
self.assertFalse(next(r for r in broken.catalog() if r["id"] == record["id"])["ready"])
with self.assertRaises(SourceError):
broken.bind(record["id"], "Unitree-Go2-Flat")
def test_tuning_same_binding_new_trial_and_own_rung_resume(self):
from test_tuning_manager import BASE_METRICS, FakeAdvisor
from tuning.manager import TuningManager
from tuning.process import GpuLease
from tuning.schema import BASE_REWARD_CONFIGURATION
record = self.upload(self.onnx, "onnx")
manager = TuningManager(
ROOT / "rl",
sys.executable,
self.root / "tuning",
GpuLease(),
advisor=FakeAdvisor(),
sources=self.registry,
)
_, config, objective, fallback = manager.parse_create(
{
"mode": "approval",
"pretrainedSourceId": record["id"],
"trialCount": 2,
"numEnvs": 4096,
"initialIterations": 1,
"middleIterations": 2,
"finalIterations": 3,
}
)
session = manager.storage.create_session("approval", config, objective, fallback)
self.assertEqual(config["pretrained"], record["initialization"])
from pretrained import public_initialization_metadata
public = public_initialization_metadata(config["pretrained"]["manifest"])
self.assertEqual(public["sourceFormat"], "onnx")
self.assertIsNone(public["sourceIteration"])
self.assertEqual(public["derivedFields"]["normalizer_count"]["value"], 1_000_000)
self.assertNotIn(
"onnxSha256", public
) # Do not confuse generated checkpoint with user upload.
manager.cancel_events[session["id"]] = threading.Event()
commands = []
def run(_session, command, _cwd, _env, _log):
commands.append(command)
if "--output-dir" in command:
directory = Path(command[command.index("--output-dir") + 1])
(directory / "model_0.pt").write_bytes(b"own checkpoint with updated statistics")
(directory / "policy.onnx").write_bytes(b"trial policy")
(directory / "initialization.json").write_text(
json.dumps(record["initialization"]["manifest"])
)
else:
Path(command[command.index("--output") + 1]).write_text(
json.dumps({"metrics": BASE_METRICS})
)
return 0
with patch.object(manager, "_run_command", run):
parent = None
for number, rung in ((0, 0), (1, 0), (1, 1)):
trial = manager.storage.create_trial(
session["id"],
number,
rung,
rung + 1,
BASE_REWARD_CONFIGURATION,
None,
f"trial-{number}-{rung}",
)
result = manager._execute_trial(session, trial, parent if rung else None)
parent = manager._session_root(session["id"]) / result["checkpointPath"]
train = [c for c in commands if "--output-dir" in c]
for command in train[:2]:
self.assertIn("--pretrained-upload-manifest", command)
self.assertEqual(command[command.index("--pretrained-source-id") + 1], record["id"])
self.assertNotIn("--pretrained-upload-manifest", train[2])
self.assertNotIn("--pretrained-checkpoint", train[2])
self.assertIn("trial-1-0/model_0.pt", train[2][-1])
restored_sources = PretrainedSources(None, self.registry.store, sys.executable, ROOT / "rl")
restored = TuningManager(
ROOT / "rl",
sys.executable,
self.root / "tuning",
GpuLease(),
advisor=FakeAdvisor(),
sources=restored_sources,
)
self.assertEqual(
restored.detail(session["id"])["config"]["pretrained"], record["initialization"]
)
self.assertFalse(restored.workers)
def test_input_limits_disconnect_timeout_capacity_and_cleanup(self):
for length, fmt, template in (
(0, "pt", "go2-legacy47-v1"),
(256 * 1024**2 + 1, "pt", "go2-legacy47-v1"),
(64 * 1024**2 + 1, "onnx", "go2-legacy47-v1"),
(1, "zip", "go2-legacy47-v1"),
(1, "pt", ""),
):
with (
self.subTest(length=length, fmt=fmt, template=template),
self.assertRaises(SourceError),
):
self.registry.receive_upload(io.BytesIO(b"x"), length, fmt, template, "x")
with self.assertRaisesRegex(SourceError, "中断"):
self.registry.receive_upload(io.BytesIO(b"x"), 100, "pt", "go2-legacy47-v1", "x")
with (
patch("pretrained_sources.time.monotonic", side_effect=[0, 61]),
self.assertRaisesRegex(SourceError, "超时"),
):
self.upload()
trickle = unittest.mock.Mock()
trickle.read.side_effect = AssertionError("buffered read could bypass total deadline")
trickle.read1.return_value = b"x"
with (
patch("pretrained_sources.time.monotonic", side_effect=[0, 1, 61]),
self.assertRaisesRegex(SourceError, "超时"),
):
self.registry.receive_upload(trickle, 10, "pt", "go2-legacy47-v1", "x")
self.assertEqual(trickle.read1.call_count, 1)
stream = unittest.mock.Mock()
stream.read1.side_effect = TimeoutError()
with self.assertRaises(SourceError):
self.registry.receive_upload(stream, 10, "pt", "go2-legacy47-v1", "x")
with self.registry.upload_slot(1), self.assertRaisesRegex(SourceError, "正在上传"):
self.upload()
self.assertEqual(list(self.registry.store.iterdir()), [])
import subprocess
with (
patch(
"pretrained_sources.subprocess.run",
side_effect=subprocess.TimeoutExpired("validator", 60),
),
self.assertRaisesRegex(SourceError, "超时"),
):
self.upload()
self.assertEqual(list(self.registry.store.iterdir()), [])
huge = self.registry.store / "quota"
with huge.open("wb") as stream:
stream.truncate(2 * 1024**3)
with self.assertRaisesRegex(SourceError, "上限"):
self.upload()
huge.unlink()
for n in range(32):
(self.registry.store / f"{n:064x}").mkdir()
with self.assertRaisesRegex(SourceError, "上限"):
self.upload()
def test_unsafe_pickle_invalid_checkpoint_and_zip_never_publish(self):
import torch
marker = self.root / "unsafe-executed"
class Unsafe:
def __reduce__(self):
return (os.system, (f"touch {marker}",))
bad = dict(self.state)
bad["mlp.0.weight"] = torch.full((512, 47), float("nan"))
wrong = dict(self.state)
wrong["mlp.0.weight"] = torch.zeros(512, 97)
cases = [b"PK\x03\x04bad zip", b"not a model"]
for payload in (
{"actor_state_dict": bad},
{"actor_state_dict": wrong},
{"actor_state_dict": self.state, "metadata": {"joint_names": ["wrong"]}},
{"actor_state_dict": self.state, "evil": Unsafe()},
):
stream = io.BytesIO()
torch.save(payload, stream)
cases.append(stream.getvalue())
for data in cases:
with self.subTest(size=len(data)), self.assertRaises(SourceError):
self.upload(data)
self.assertEqual(self.registry.catalog(), [])
self.assertEqual(list(self.registry.store.iterdir()), [])
self.assertFalse((self.root / "unsafe-executed").exists())
def test_strict_graph_rejects_bad_edges_ops_attributes_external_and_metadata(self):
import onnx
from pretrained import PretrainedError
from pretrained_upload import _onnx
base = onnx.load_model_from_string(self.onnx)
def external(m):
m.graph.initializer[0].data_location = onnx.TensorProto.EXTERNAL
m.graph.initializer[0].external_data.add(key="location", value="/etc/passwd")
def attrs(m):
m.graph.node[2].attribute.add(name="transA", type=onnx.AttributeProto.INT, i=1)
def metadata(m):
next(p for p in m.metadata_props if p.key == "joint_names").value = "wrong"
def extra(m):
m.graph.node.append(onnx.helper.make_node("Identity", ["actions"], ["branch"]))
def shape(m):
m.graph.input[0].type.tensor_type.shape.dim[1].dim_value = 97
edits = [
external,
attrs,
metadata,
extra,
shape,
lambda m: setattr(m.graph.node[2], "domain", "evil"),
lambda m: setattr(m.graph.node[3], "op_type", "Relu"),
lambda m: m.graph.node[4].input.__setitem__(0, "normalized"),
lambda m: m.graph.node[0].input.reverse(),
lambda m: m.graph.node[4].input.__setitem__(1, "mlp.0.weight"),
lambda m: setattr(m.graph.output[0], "name", "linear0"),
lambda m: setattr(m.graph.initializer[0], "data_type", onnx.TensorProto.DOUBLE),
]
for edit in edits:
model = copy.deepcopy(base)
edit(model)
with self.subTest(edit=edit), self.assertRaises((PretrainedError, ValueError)):
_onnx(model.SerializeToString())
bad = copy.deepcopy(base)
external(bad)
with self.assertRaisesRegex(SourceError, "external"):
self.upload(bad.SerializeToString(), "onnx")
self.assertEqual(self.registry.catalog(), [])
def test_http_upload_storage_errors_are_503_but_read_failures_are_400(self):
manager = TrainingManager(
ROOT / "rl",
sys.executable,
("Unitree-Go2-Flat",),
check_environment=False,
sources=self.registry,
)
reset_timeouts = []
class Handler(TrainingRequestHandler):
access_token = "upload-test-token"
read_error = None
def log_message(self, *_args):
pass
def _upload(self):
original_stream = self.rfile
if self.read_error is not None:
self.rfile = unittest.mock.Mock(wraps=original_stream)
self.rfile.read1.side_effect = self.read_error
try:
super()._upload()
finally:
self.rfile = original_stream
reset_timeouts.append(self.connection.gettimeout())
Handler.manager = manager
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
original_open = Path.open
url = (
"/api/training/pretrained-sources/upload"
"?format=pt&template=go2-legacy47-v1&name=policy.pt"
)
try:
for phase, error in (
("open", OSError(errno.ENOSPC, "disk full", "/private/upload.pt")),
("open", OSError(errno.EACCES, "permission denied", "/private/upload.pt")),
("write", OSError(errno.ENOSPC, "disk full", "/private/upload.pt")),
("close", OSError(errno.ENOSPC, "flush failed", "/private/upload.pt")),
("read", ConnectionResetError(errno.ECONNRESET, "connection reset")),
("read", TimeoutError("read timeout")),
("truncated", None),
):
with self.subTest(phase=phase, error=error):
Handler.read_error = error if phase == "read" else None
@contextmanager
def faulty_output(path, *args, phase=phase, error=error, **kwargs):
with original_open(path, *args, **kwargs) as output:
if phase == "write":
output = unittest.mock.Mock(wraps=output)
output.write.side_effect = error
yield output
if phase == "close":
raise error
def open_file(
path,
*args,
phase=phase,
error=error,
output_factory=faulty_output,
**kwargs,
):
if path.name == "upload.pt" and args == ("xb",):
if phase == "open":
raise error
return output_factory(path, *args, **kwargs)
return original_open(path, *args, **kwargs)
conn = http.client.HTTPConnection(*server.server_address, timeout=10)
try:
with patch.object(Path, "open", open_file):
conn.request(
"POST",
url,
body=b"x",
headers={
"Authorization": "Bearer upload-test-token",
"Content-Type": "application/octet-stream",
"Content-Length": "2" if phase == "truncated" else "1",
},
)
if phase == "truncated":
conn.sock.shutdown(socket.SHUT_WR)
response = conn.getresponse()
message = json.loads(response.read())["error"]
finally:
conn.close()
storage_error = phase in ("open", "write", "close")
self.assertEqual(response.status, 503 if storage_error else 400)
self.assertIn("磁盘空间/权限" if storage_error else "中断", message)
self.assertNotIn("/private", message)
self.assertNotIn(str(self.root), message)
self.assertEqual(reset_timeouts[-1], 10)
self.assertEqual(list(self.registry.store.iterdir()), [])
self.assertEqual(self.registry.catalog(), [])
self.assertEqual(manager.jobs, {})
finally:
server.shutdown()
server.server_close()
thread.join()
def test_http_auth_json_limit_binary_headers_and_incomplete_body(self):
manager = TrainingManager(
ROOT / "rl",
sys.executable,
("Unitree-Go2-Flat",),
check_environment=False,
sources=self.registry,
)
class Handler(TrainingRequestHandler):
access_token = "upload-test-token"
def log_message(self, *_args):
pass
Handler.manager = manager
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = (
"/api/training/pretrained-sources/upload"
"?format=pt&template=go2-legacy47-v1&name=..%2Ftest.pt"
)
headers = {
"Authorization": "Bearer upload-test-token",
"Content-Type": "application/octet-stream",
}
def request(path=url, data=b"x", extra=None):
conn = http.client.HTTPConnection(*server.server_address, timeout=30)
conn.request("POST", path, body=data, headers={**headers, **(extra or {})})
response = conn.getresponse()
status, body = response.status, response.read()
conn.close()
return status, json.loads(body)
try:
self.assertEqual(request(extra={"Authorization": "Bearer wrong"})[0], 401)
self.assertEqual(request(extra={"Origin": "https://evil.example"})[0], 403)
self.assertEqual(request(extra={"Host": "evil.example"})[0], 403)
self.assertEqual(request(extra={"Content-Type": "application/json"})[0], 400)
self.assertEqual(request(extra={"Content-Length": str(256 * 1024**2 + 1)})[0], 413)
self.assertEqual(request(extra={"Content-Encoding": "gzip"})[0], 400)
self.assertEqual(request(extra={"Transfer-Encoding": "chunked"})[0], 400)
self.assertEqual(
request(path=url.replace("template=go2-legacy47-v1", "template="))[0], 400
)
self.assertEqual(
request(path="/api/training/jobs", data=b" " * (128 * 1024 + 1))[0], 413
)
# A blocked decoder is isolated from health/catalog requests.
import subprocess
from types import SimpleNamespace
Handler.tuning_manager = SimpleNamespace(capability=lambda: {})
started, release = threading.Event(), threading.Event()
original_run = subprocess.run
def blocked(*args, **kwargs):
started.set()
release.wait(10)
return original_run(*args, **kwargs)
outcomes = []
with patch("pretrained_sources.subprocess.run", side_effect=blocked):
worker = threading.Thread(target=lambda: outcomes.append(request(data=self.pt)))
worker.start()
self.assertTrue(started.wait(5))
health = http.client.HTTPConnection(*server.server_address, timeout=3)
health.request("GET", "/api/training/health", headers=headers)
response = health.getresponse()
self.assertEqual(response.status, 200)
self.assertTrue(json.loads(response.read())["pretrainedUpload"]["enabled"])
health.close()
release.set()
worker.join(30)
status, record = outcomes[0]
self.assertEqual(status, 201)
self.assertEqual(record["label"], "test.pt")
status, onnx_record = request(
path=url.replace("format=pt", "format=onnx"), data=self.onnx
)
self.assertEqual(status, 201)
self.assertEqual(onnx_record["initialization"]["manifest"]["sourceFormat"], "onnx")
raw = socket.create_connection(server.server_address, timeout=10)
raw.sendall(
(
f"POST {url} HTTP/1.0\r\nHost: localhost\r\n"
"Authorization: Bearer upload-test-token\r\n"
"Content-Type: application/octet-stream\r\nContent-Length: 1000\r\n\r\nx"
).encode()
)
raw.shutdown(socket.SHUT_WR)
self.assertIn(b"400", raw.recv(4096))
raw.close()
self.assertFalse(list(self.registry.store.glob("upload-*")))
finally:
server.shutdown()
server.server_close()
thread.join()
def test_onnx_expansion_and_checkpoint_preserve_updated_count(self):
import torch
from pretrained import (
ValidatedSource,
comparison_observations,
make_reference_actor,
warm_start_actor,
)
from pretrained_upload import _onnx
from tensordict import TensorDict
state, _, identity = _onnx(self.onnx)
self.assertLess(identity["max_abs_error"], 2e-5)
base = comparison_observations()
reference = make_reference_actor().eval()
reference.load_state_dict(state)
expected = reference.mlp(reference.obs_normalizer(base)).detach()
for dim in (81, 97):
actor = make_reference_actor(dim)
warm_start_actor(actor, ValidatedSource(state, {}))
self.assertEqual(actor.mlp[0].weight[:, 47:].count_nonzero().item(), 0)
values = torch.cat((base, torch.ones(len(base), dim - 47)), dim=1)
torch.testing.assert_close(
actor.mlp(actor.obs_normalizer(values)), expected, atol=2e-5, rtol=2e-5
)
batch = TensorDict({"actor": values}, batch_size=[len(base)])
actor.update_normalization(batch)
self.assertEqual(actor.obs_normalizer.count.item(), 1_000_048)
self.assertGreater(actor.obs_normalizer._mean[:, 47:].min().item(), 0)
actor(batch).square().mean().backward()
grad = actor.mlp[0].weight.grad[:, 47:]
self.assertTrue(torch.isfinite(grad).all())
self.assertGreater(grad.abs().max().item(), 0)
torch.optim.Adam(actor.parameters(), lr=1e-4).step()
buffer = io.BytesIO()
torch.save(actor.state_dict(), buffer)
buffer.seek(0)
restored = make_reference_actor(dim)
restored.load_state_dict(torch.load(buffer, weights_only=True))
for key, tensor in actor.state_dict().items():
self.assertTrue(torch.equal(tensor, restored.state_dict()[key]), key)
@unittest.skipUnless(
os.environ.get("GO2_UPLOAD_REAL_DIR"), "opt-in read-only real single-file probes"
)
class RealSingleFileUploadTest(UploadTest):
"""Inherited tests run against real uploads without ever requesting a sidecar."""
@classmethod
def setUpClass(cls):
import torch
torch.set_num_threads(1)
root = Path(os.environ["GO2_UPLOAD_REAL_DIR"])
# Explicit two files read separately as bytes; each upload receives only one.
cls.pt = (root / "model_10000.pt").read_bytes()
cls.onnx = (root / "policy.onnx").read_bytes()
cls.state = torch.load(io.BytesIO(cls.pt), map_location="cpu", weights_only=True)[
"actor_state_dict"
]
def test_real_independent_uploads_ort_and_batch_drift(self):
import torch
from pretrained import comparison_observations, make_reference_actor, verify_onnx
from pretrained_upload import read_uploaded_source
from src.tasks.velocity.config.go2.env_cfgs import unitree_go2_flat_env_cfg
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
evidence = {}
for fmt, data in (("pt", self.pt), ("onnx", self.onnx)):
with tempfile.TemporaryDirectory() as empty:
registry = PretrainedSources(None, Path(empty), sys.executable, ROOT / "rl")
record = registry.receive_upload(
io.BytesIO(data), len(data), fmt, "go2-legacy47-v1", f"single.{fmt}"
)
directory = registry.verify(record["initialization"])
source = read_uploaded_source(
directory / "actor.pt",
allowed_roots=[directory],
manifest_path=directory / "upload.json",
target_env=asdict(unitree_go2_flat_env_cfg()),
target_agent=asdict(unitree_go2_ppo_runner_cfg()),
)
actor = make_reference_actor().eval()
actor.load_state_dict(source.actor_state)
identity = verify_onnx(self.onnx, actor)
self.assertLess(identity["max_abs_error"], 2e-5)
evidence[fmt] = {
"sourceId": record["id"],
"sha256": hashlib.sha256(data).hexdigest(),
"identity": identity,
"normalizer_count": actor.obs_normalizer.count.item(),
"updates": {},
}
probes = comparison_observations()[32:]
before = actor.mlp(actor.obs_normalizer(probes)).detach()
# Ordinary UI default=4096 and service maximum=16384 environments.
for batch_size in (4096, 16384):
updated = copy.deepcopy(actor).train()
updated.obs_normalizer.update(probes.repeat(batch_size // len(probes), 1))
after = updated.mlp(updated.obs_normalizer(probes)).detach()
evidence[fmt]["updates"][str(batch_size)] = {
"rate": batch_size / updated.obs_normalizer.count.item(),
"max_action_drift": (after - before).abs().max().item(),
"max_mean_drift": (
updated.obs_normalizer._mean - actor.obs_normalizer._mean
)
.abs()
.max()
.item(),
"count_after": updated.obs_normalizer.count.item(),
}
self.assertTrue(torch.isfinite(after).all())
self.assertEqual(
PretrainedSources(None, Path(empty), sys.executable, ROOT / "rl").catalog(),
[record],
)
print("REAL_UPLOAD_EVIDENCE=" + json.dumps(evidence, sort_keys=True))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,163 @@
"""Opt-in real CPU runner integration: four environments, one ONNX-derived PPO iteration."""
import copy
import io
import json
import os
import sys
import tempfile
import unittest
from dataclasses import asdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
for root in (ROOT, ROOT / "rl"):
sys.path.insert(0, str(root))
@unittest.skipUnless(os.environ.get("GO2_UPLOAD_RUNNER_OUTPUT"), "opt-in four-env CPU runner smoke")
class UploadedRunnerTest(unittest.TestCase):
def test_real_upload_runner_ppo_export_and_same_trial_restore(self):
import numpy as np
import onnxruntime as ort
import torch
import warp as wp
if not hasattr(wp, "context"):
from warp._src import context
wp.context = context
from mjlab.envs import ManagerBasedRlEnv
from mjlab.rl import RslRlVecEnvWrapper
from pretrained import initialize_runner, make_reference_actor, validate_runtime_contract
from pretrained_sources import PretrainedSources
from pretrained_upload import read_uploaded_source
from src.tasks.obstacle_avoidance.env_cfg import unitree_go2_obstacle_env_cfg
from src.tasks.velocity.config.go2.rl_cfg import unitree_go2_ppo_runner_cfg
from src.tasks.velocity.rl.runner import VelocityOnPolicyRunner
from task_config import OBSTACLE_TASK, deployment_metadata, validate_task_config
torch.set_num_threads(1)
source_root = Path(os.environ["GO2_UPLOAD_REAL_DIR"])
output = Path(os.environ["GO2_UPLOAD_RUNNER_OUTPUT"])
output.mkdir(parents=True, exist_ok=True)
# Only these explicit files; each decoder sees its independent empty root.
original_onnx = (source_root / "policy.onnx").read_bytes()
options = ort.SessionOptions()
options.intra_op_num_threads = options.inter_op_num_threads = 1
original = ort.InferenceSession(original_onnx, options, providers=["CPUExecutionProvider"])
evidence = {}
for fmt, data in (
("pt", (source_root / "model_10000.pt").read_bytes()),
("onnx", original_onnx),
):
with tempfile.TemporaryDirectory() as store:
registry = PretrainedSources(None, Path(store), sys.executable, ROOT / "rl")
record = registry.receive_upload(
io.BytesIO(data), len(data), fmt, "go2-legacy47-v1", f"single.{fmt}"
)
bound = record["initialization"]
directory = registry.verify(bound)
cfg = unitree_go2_obstacle_env_cfg()
cfg.scene.num_envs = 4
cfg.seed = 42
agent = unitree_go2_ppo_runner_cfg()
agent.logger = "tensorboard"
agent.max_iterations = 1
source = read_uploaded_source(
directory / "actor.pt",
allowed_roots=[directory],
manifest_path=directory / "upload.json",
target_env=asdict(cfg),
target_agent=asdict(agent),
)
raw = ManagerBasedRlEnv(cfg, device="cpu")
env = RslRlVecEnvWrapper(raw)
try:
validate_runtime_contract(raw)
raw.platform_deployment = deployment_metadata(
OBSTACLE_TASK, validate_task_config(OBSTACLE_TASK, {}, 42), 42
)
log = output / fmt
log.mkdir(exist_ok=True)
runner = VelocityOnPolicyRunner(env, asdict(agent), str(log), "cpu")
raw.platform_initialization = initialize_runner(runner, source)
(log / "initialization.json").write_text(
json.dumps(raw.platform_initialization)
)
actor = runner.alg.actor
obs = env.get_observations()
reference = make_reference_actor().eval()
reference.load_state_dict(source.actor_state)
with torch.no_grad():
before = actor(obs)
expected = reference.mlp(reference.obs_normalizer(obs["actor"][:, :47]))
torch.testing.assert_close(before, expected, atol=2e-5, rtol=2e-5)
onnx_actions = np.concatenate(
[
original.run(None, {"obs": row[None].numpy()})[0]
for row in obs["actor"][:, :47]
]
)
np.testing.assert_allclose(before.numpy(), onnx_actions, atol=2e-5, rtol=2e-5)
self.assertEqual(actor.mlp[0].weight[:, 47:].count_nonzero().item(), 0)
self.assertFalse(runner.alg.optimizer.state)
actor(obs).square().mean().backward()
gradient = actor.mlp[0].weight.grad[:, 47:]
self.assertTrue(torch.isfinite(gradient).all())
self.assertGreater(gradient.abs().max().item(), 0)
evidence[fmt] = {
"source_id": record["id"],
"real_observation_ort_error": float(
np.abs(before.numpy() - onnx_actions).max()
),
"new_column_gradient_max": gradient.abs().max().item(),
}
runner.alg.optimizer.zero_grad()
if fmt == "pt":
continue # Only ONNX-derived branch runs the one approved PPO iteration.
runner.learn(num_learning_iterations=1, init_at_random_ep_len=True)
self.assertGreater(actor.mlp[0].weight[:, 47:].abs().max().item(), 0)
saved = torch.load(log / "model_0.pt", map_location="cpu", weights_only=True)
updated = copy.deepcopy(actor.state_dict())
resumed = VelocityOnPolicyRunner(env, asdict(agent), str(log), "cpu")
self.assertTrue(resumed.alg.load(saved, None, strict=True))
self.assertTrue(resumed.alg.optimizer.state)
for key, tensor in updated.items():
self.assertTrue(
torch.equal(tensor, resumed.alg.actor.state_dict()[key]), key
)
self.assertGreater(resumed.alg.actor.obs_normalizer.count.item(), 1_000_000)
exported = ort.InferenceSession(
str(log / "policy.onnx"), options, providers=["CPUExecutionProvider"]
)
metadata = json.loads(
exported.get_modelmeta().custom_metadata_map["pretrained_initialization"]
)
self.assertEqual(metadata["sourceFormat"], "onnx")
self.assertEqual(
metadata["uploadSha256"], source.manifest["artifacts"]["upload"]["sha256"]
)
self.assertNotIn(str(source_root), json.dumps(metadata))
final_obs = env.get_observations()
with torch.no_grad():
expected = resumed.alg.actor(final_obs).numpy()
actual = np.concatenate(
[
exported.run(None, {"obs": row[None].numpy()})[0]
for row in final_obs["actor"]
]
)
np.testing.assert_allclose(actual, expected, atol=2e-5, rtol=2e-5)
evidence[fmt].update(
ppo_iterations=1,
num_envs=4,
count_restored=resumed.alg.actor.obs_normalizer.count.item(),
export_max_error=float(np.abs(actual - expected).max()),
new_columns_max_after_ppo=actor.mlp[0].weight[:, 47:].abs().max().item(),
source_metadata=metadata,
)
finally:
env.close()
(output / "evidence.json").write_text(json.dumps(evidence, indent=2))
print("REAL_RUNNER_UPLOAD_EVIDENCE=" + json.dumps(evidence))
@@ -0,0 +1,137 @@
"""Persisted preset identity -> resolver -> training validation; no training/API calls."""
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server import ApiError, TrainingManager # noqa: E402
from tuning.manager import TuningManager # noqa: E402
from tuning.schema import ( # noqa: E402
FLAT_TASK,
OBSTACLE_TASK,
RewardConfigError,
base_configuration,
)
from tuning.storage import TuningStorage # noqa: E402
class RewardPresetTaskTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
root = Path(self.temp.name)
(root / "scripts").mkdir()
(root / "scripts/train.py").write_text("raise AssertionError('must not train')")
self.storage = TuningStorage(root / "tuning.sqlite3")
# Real resolver with real storage; no advisor, processes or training workers are needed.
self.tuning = object.__new__(TuningManager)
self.tuning.storage = self.storage
self.training = TrainingManager(
root, sys.executable, (FLAT_TASK, OBSTACLE_TASK), check_environment=False
)
self.training.preset_resolver = self.tuning.preset_config
def tearDown(self):
self.storage.connection().close()
self.temp.cleanup()
def save(self, config, reward=None):
session = self.storage.create_session("approval", config, {}, False)
reward = reward or base_configuration(config.get("taskId", FLAT_TASK))
trial = self.storage.create_trial(session["id"], 0, 0, 1, reward, None, "trial")
return self.storage.save_preset(session["id"], session["id"], trial["id"], reward)
def payload(self, preset):
return dict(
taskId=FLAT_TASK,
rewardPresetId=preset["id"],
numEnvs=2,
maxIterations=1,
seed=42,
device="cpu",
)
def assert_rejected(self, preset):
for entry in (self.training.parse_config, self.training.start):
with self.assertRaises(ApiError) as raised:
entry(self.payload(preset))
self.assertEqual(raised.exception.status, 400)
self.assertEqual(self.training.jobs, {})
self.assertIsNone(self.training.lease.public())
def test_obstacle_list_identity_and_cross_task_rejected_before_job_creation(self):
preset = self.save({"taskId": OBSTACLE_TASK})
self.assertEqual(preset["taskId"], OBSTACLE_TASK)
self.assertEqual(self.storage.list_presets(), [preset])
self.assertEqual(self.storage.get_preset(preset["id"]), preset)
self.assertEqual(
self.tuning.preset_config(preset["id"], OBSTACLE_TASK), preset["rewardConfig"]
)
with self.assertRaisesRegex(RewardConfigError, "任务"):
self.tuning.preset_config(preset["id"])
self.assert_rejected(preset)
def test_historical_flat_without_task_id_and_explicit_flat_remain_valid(self):
for config in ({}, {"taskId": FLAT_TASK}):
with self.subTest(config=config):
preset = self.save(config)
self.assertEqual(preset["taskId"], FLAT_TASK)
self.assertEqual(self.tuning.preset_config(preset["id"]), preset["rewardConfig"])
parsed = self.training.parse_config(self.payload(preset))
self.assertEqual(parsed.reward_config, base_configuration())
self.assertIn("--reward-config-json", self.training.command_for(parsed))
self.assertEqual({p["taskId"] for p in self.storage.list_presets()}, {FLAT_TASK})
def test_malformed_preset_and_corrupt_or_missing_source_fail_closed(self):
preset = self.save({})
connection = self.storage.connection()
valid_reward = json.dumps(preset["rewardConfig"])
for bad_reward in (
'{"weights":{},"params":{}}',
"null",
"{broken",
json.dumps(base_configuration(OBSTACLE_TASK)),
):
with self.subTest(reward=bad_reward):
connection.execute(
"UPDATE presets SET reward_config_json=? WHERE id=?", (bad_reward, preset["id"])
)
with self.assertRaises(RewardConfigError):
self.storage.list_presets()
self.assert_rejected(preset)
connection.execute(
"UPDATE presets SET reward_config_json=? WHERE id=?", (valid_reward, preset["id"])
)
for source in (
"null",
"[]",
"{broken",
'{"taskId":null}',
'{"taskId":"unknown"}',
json.dumps({"taskId": OBSTACLE_TASK}),
):
with self.subTest(source=source):
connection.execute(
"UPDATE sessions SET config_json=? WHERE id=?", (source, preset["sessionId"])
)
with self.assertRaises(RewardConfigError):
self.storage.get_preset(preset["id"])
self.assert_rejected(preset)
connection.execute("DELETE FROM sessions WHERE id=?", (preset["sessionId"],))
with self.assertRaises(RewardConfigError):
self.storage.list_presets()
self.assert_rejected(preset)
def test_save_and_service_both_validate_complete_task_schema(self):
with self.assertRaises(RewardConfigError):
self.save({}, base_configuration(OBSTACLE_TASK))
self.assertEqual(self.storage.list_presets(), [])
# The service itself must reject partial/malformed configs even from a broken resolver.
self.training.preset_resolver = lambda _id, _task: {"weights": {"pose": 1}, "params": {}}
self.assert_rejected({"id": "f" * 32})
if __name__ == "__main__":
unittest.main()
+72 -2
View File
@@ -10,6 +10,7 @@ from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server import ( # noqa: E402
DEFAULT_TASKS,
MAX_JOBS,
ApiError,
TrainingJob,
@@ -79,6 +80,70 @@ out.write_bytes(b'onnx')
with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(wandbMode="login"))
def test_custom_task_metadata_and_validated_deployment(self):
self.manager.tasks = DEFAULT_TASKS
health = self.manager.health()
task = next(
item for item in health["taskMetadata"] if item["id"] == "Unitree-Go2-ObstacleAvoidance"
)
self.assertEqual(task["sensorTypes"], ["raycast"])
config = self.manager.parse_config(
self.payload(
taskId=task["id"],
terrainPreset="discrete_obstacles",
terrainParams={"obstacle_count": 8, "friction": 0.9},
sensorCfg={"type": "raycast", "fov": 100, "maxDistance": 5},
)
)
self.assertEqual(config.deployment["observationSize"], 81)
self.assertEqual(config.deployment["terrain"]["actualObstacleCount"], 8)
self.assertEqual(config.deployment["sensorCfg"]["rayCount"], 32)
self.assertEqual(
TrainingJob(id="a" * 32, config=config).public()["deployment"], config.deployment
)
rough = self.manager.parse_config(self.payload(taskId="Unitree-Go2-Rough"))
self.assertFalse(rough.deployment["browserCompatible"])
self.assertEqual(rough.deployment["observationSize"], 234)
def test_custom_config_rejects_unknown_nonfinite_and_incompatible_fields(self):
self.manager.tasks = DEFAULT_TASKS
for fields in (
{"terrainPreset": "plane;touch /tmp/injected"},
{"terrainPreset": ["plane"]},
{"terrainParams": {"friction": float("nan")}},
{"terrainParams": {"size": float("inf")}},
{"terrainParams": {"size": 10**400}},
{"terrainParams": {"obstacle_count": True}},
{"terrainParams": {"obstacle_count": 1.5}},
{"terrainParams": {"mjcf": "<include/>"}},
{"terrainParams": {"obstacle_height_min": 1, "obstacle_height_max": 0.1}},
{"sensorCfg": {"rayCount": 1_000_000}},
{"sensorCfg": {"maxDistance": 1, "safetyDistance": 1}},
{"sensorCfg": {"fov": float("nan")}},
{"sensorType": "camera_depth"},
{"rewardPresetId": "a" * 32},
):
with self.subTest(fields=fields), self.assertRaises(ApiError):
self.manager.parse_config(
self.payload(taskId="Unitree-Go2-ObstacleAvoidance", **fields)
)
with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(sensorCfg={"fov": 90}))
def test_custom_job_passes_server_owned_json_file_without_shell(self):
self.manager.tasks = DEFAULT_TASKS
config = self.manager.parse_config(self.payload(taskId="Unitree-Go2-ObstacleAvoidance"))
job = TrainingJob(id="b" * 32, config=config)
self.manager.jobs[job.id] = job
with patch("server.subprocess.Popen", wraps=subprocess.Popen) as popen:
self.manager._run(job)
args = popen.call_args.args[0]
self.assertNotIn("shell", popen.call_args.kwargs)
path = Path(args[args.index("--task-config") + 1])
self.assertTrue(path.is_relative_to(self.root))
self.assertEqual(json.loads(path.read_text()), config.task_config)
self.assertEqual(job.state, "succeeded")
def test_builds_argument_array_without_shell(self):
config = self.manager.parse_config(self.payload(device="gpu", gpuIds=[0, 2]))
command = self.manager.command_for(config)
@@ -89,8 +154,13 @@ out.write_bytes(b'onnx')
def test_resolves_reward_preset_to_inline_validated_trainer_argument(self):
preset_id = "f" * 32
reward_config = {"weights": {"pose": 1.2}, "params": {}}
self.manager.preset_resolver = lambda value: reward_config if value == preset_id else None
from tuning.schema import base_configuration
reward_config = base_configuration()
reward_config["weights"]["pose"] = 1.2
self.manager.preset_resolver = lambda value, task: (
reward_config if value == preset_id and task == "Unitree-Go2-Flat" else None
)
config = self.manager.parse_config(self.payload(rewardPresetId=preset_id))
command = self.manager.command_for(config)
index = command.index("--reward-config-json")
+83
View File
@@ -0,0 +1,83 @@
import json
import math
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from task_config import ( # noqa: E402
FLAT_TASK,
OBSTACLE_TASK,
TERRAIN_PRESETS,
build_terrain_layout,
deployment_metadata,
navigation_candidates,
validate_task_config,
)
class TaskConfigTest(unittest.TestCase):
def test_flat_legacy_request_does_not_override_environment(self):
self.assertIsNone(validate_task_config(FLAT_TASK, {}, 42))
self.assertNotIn("terrain", deployment_metadata(FLAT_TASK, None, 42))
def test_layouts_are_bounded_finite_and_leave_flat_spawn_goal(self):
for preset in (p for p in TERRAIN_PRESETS if p != "custom_boxes"):
with self.subTest(preset=preset):
custom = validate_task_config(OBSTACLE_TASK, {"terrainPreset": preset}, 42)
layout = build_terrain_layout(custom)
self.assertLessEqual(len(layout["boxes"]), 257)
self.assertLess(len(json.dumps(layout)), 64 * 1024)
self.assertEqual(layout["spawn"], [-5, 0, 0.32])
self.assertEqual(layout["target"], [5, 0])
for box in layout["boxes"]:
self.assertTrue(all(math.isfinite(v) for v in box["pos"] + box["size"]))
self.assertTrue(all(v > 0 for v in box["size"]))
self.assertEqual(box["yaw"], 0)
for box in layout["boxes"][1:]:
self.assertGreaterEqual(box["pos"][0] - box["size"][0], -4)
self.assertLessEqual(box["pos"][0] + box["size"][0], 4)
def test_layout_is_deterministic_and_export_is_authoritative(self):
custom = validate_task_config(OBSTACLE_TASK, {}, 42)
original = build_terrain_layout(custom)
self.assertEqual(original, build_terrain_layout(custom))
self.assertNotEqual(original, build_terrain_layout({**custom, "seed": 43}))
metadata = deployment_metadata(OBSTACLE_TASK, custom, 42)
self.assertEqual(metadata["terrain"], original)
self.assertEqual(metadata["observationSize"], 47 + 32 + 2)
self.assertEqual(metadata["jointNames"][0], "FL_hip_joint")
self.assertEqual(metadata["navigation"]["distanceScale"], 12)
self.assertTrue(metadata["sensorCfg"]["includeGround"])
self.assertEqual(metadata["navigation"]["trainingReset"], "random-connected-free-pair")
def test_navigation_candidates_are_safe_connected_and_have_distant_fallbacks(self):
layout = build_terrain_layout(validate_task_config(OBSTACLE_TASK, {}, 42))
candidates = navigation_candidates(layout)
self.assertGreater(len(candidates["points"]), 2)
self.assertEqual(len(candidates["componentStarts"]), len(candidates["fallbackPairs"]))
for point in candidates["points"]:
self.assertTrue(all(abs(value) <= layout["size"] / 2 - 0.55 for value in point))
for box in layout["boxes"][1:]:
distance = math.hypot(
max(abs(point[0] - box["pos"][0]) - box["size"][0], 0),
max(abs(point[1] - box["pos"][1]) - box["size"][1], 0),
)
self.assertGreater(distance, candidates["clearance"])
for first, second in candidates["fallbackPairs"]:
self.assertGreaterEqual(math.dist(first, second), candidates["minDistance"])
def test_obstacle_count_is_capped_by_spacing_capacity(self):
custom = validate_task_config(
OBSTACLE_TASK,
{
"terrainParams": {"size": 8, "spacing": 3, "obstacle_count": 100},
},
0,
)
layout = build_terrain_layout(custom)
self.assertEqual(layout["actualObstacleCount"], 2)
if __name__ == "__main__":
unittest.main()