feat(training): release V0.9.1 避障训练与基础策略迁移
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user