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

This commit is contained in:
2026-09-08 10:50:13 +08:00
parent fa5485049a
commit 438e56bcc8
113 changed files with 15027 additions and 539 deletions
+5 -2
View File
@@ -12,7 +12,8 @@ from .schema import validate_proposal
SYSTEM_PROMPT = """你是 Unitree Go2 强化学习奖励调参专家。
只根据提供的数值配置、训练曲线摘要和固定评估结果提出下一轮稀疏修改。
必须优先保持速度跟踪与跌倒安全门槛;每轮最多修改四个白名单标量,不得改变符号、函数、传感器或结构。
必须优先保持当前任务的客观安全门槛:Flat速度跟踪/跌倒,Obstacle无碰撞到达/跌倒。
每轮最多修改四个白名单标量,不得改变符号、函数、传感器、评估协议或结构。
不要建议 Python 代码、命令、文件路径或白名单外参数。输出必须符合 RewardProposal schema。
"""
@@ -101,7 +102,9 @@ class DeepSeekAdvisor:
result = self._agent().run_sync(prompt)
output = result.output
patch = validate_proposal(
{"weights": dict(output.weights), "params": dict(output.params)}, previous
{"weights": dict(output.weights), "params": dict(output.params)},
previous,
task_id=context.get("task", "Unitree-Go2-Flat"),
)
try:
usage = result.usage()
+257 -23
View File
@@ -13,11 +13,20 @@ from copy import deepcopy
from pathlib import Path
from typing import Any
from pretrained_sources import PretrainedSources, SourceError
from task_config import TaskConfigError, validate_task_config
from . import obstacle_scoring
from .advisor import AdvisorUnavailable, DeepSeekAdvisor
from .process import GpuLease, ResourceBusyError, terminate_process
from .schema import (
BASE_REWARD_CONFIGURATION,
FLAT_TASK,
OBSTACLE_TASK,
RewardConfigError,
base_configuration,
merge_proposal,
task_specs,
validate_configuration,
validate_configuration_constraints,
validate_constraints,
validate_proposal,
@@ -51,6 +60,7 @@ class TuningManager:
data_root: Path,
lease: GpuLease,
advisor: Any | None = None,
sources: PretrainedSources | None = None,
):
self.trainer_root = trainer_root.expanduser().resolve()
self.python = python
@@ -66,10 +76,22 @@ class TuningManager:
self.workers: dict[str, threading.Thread] = {}
self.processes: dict[str, subprocess.Popen[str]] = {}
self.cancel_events: dict[str, threading.Event] = {}
self.sources = sources
def capability(self) -> dict[str, Any]:
capability = self.advisor.capability()
capability.update({"ready": (self.trainer_root / "scripts" / "evaluate.py").is_file()})
capability.update(
{
"ready": (self.trainer_root / "scripts" / "evaluate.py").is_file(),
"pretrainedSources": self.sources.catalog() if self.sources else [],
"pretrainedUpload": {
"enabled": self.sources is not None,
"templateId": "go2-legacy47-v1",
"formats": {"pt": 256 * 1024**2, "onnx": 64 * 1024**2},
"endpoint": "/api/training/pretrained-sources/upload",
},
}
)
return capability
@staticmethod
@@ -85,8 +107,34 @@ class TuningManager:
mode = payload.get("mode", "automatic")
if mode not in ("automatic", "approval"):
raise TuningError("mode 必须是 automatic 或 approval")
if payload.get("taskId", "Unitree-Go2-Flat") != "Unitree-Go2-Flat":
raise TuningError("第一版只支持 Unitree-Go2-Flat")
task_id = payload.get("taskId", "Unitree-Go2-Flat")
task_specs(task_id)
allowed = {
"taskId",
"mode",
"runName",
"gpuIds",
"trialCount",
"initialIterations",
"middleIterations",
"finalIterations",
"numEnvs",
"seed",
"evalNumEnvs",
"evalSteps",
"earlyStopPatience",
"objectiveWeights",
"fallbackEnabled",
"terrainPreset",
"terrainParams",
"sensorType",
"sensorCfg",
"customTerrainBoxes",
"taskConfig",
"pretrainedSourceId",
}
if payload.keys() - allowed:
raise TuningError("未知session字段")
run_name = payload.get("runName", "auto-tune")
if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
raise TuningError("runName 格式无效")
@@ -105,7 +153,7 @@ class TuningManager:
rung1 = self._integer(payload, "middleIterations", 900, rung0, 1000000)
rung2 = self._integer(payload, "finalIterations", 2000, rung1, 1000000)
config = {
"taskId": "Unitree-Go2-Flat",
"taskId": task_id,
"numEnvs": self._integer(payload, "numEnvs", 4096, 1, 16384),
"seed": self._integer(payload, "seed", 42, 0, 2147483647),
"runName": run_name,
@@ -117,12 +165,78 @@ class TuningManager:
"evalSteps": self._integer(payload, "evalSteps", 1000, 10, 100000),
"earlyStopPatience": self._integer(payload, "earlyStopPatience", 4, 1, 20),
}
objective = validate_objective_weights(
payload.get("objectiveWeights", DEFAULT_OBJECTIVE_WEIGHTS)
)
if task_id == OBSTACLE_TASK:
if config["evalSteps"] != obstacle_scoring.STEPS:
raise TuningError("避障评估固定1000步,不能调整")
objective = deepcopy(obstacle_scoring.WEIGHTS)
if "objectiveWeights" in payload and payload["objectiveWeights"] != objective:
raise TuningError("避障评估权重固定")
task_payload = payload.get("taskConfig", payload)
if "taskConfig" in payload:
if not isinstance(task_payload, dict) or task_payload.keys() - {
"terrainPreset",
"terrainParams",
"sensorCfg",
"sensorType",
"seed",
"customTerrainBoxes",
}:
raise TuningError("taskConfig字段无效")
if any(
k in payload
for k in (
"terrainPreset",
"terrainParams",
"sensorCfg",
"sensorType",
"customTerrainBoxes",
)
):
raise TuningError("taskConfig不能与顶层场景配置混用")
task_seed = task_payload.get("seed", config["seed"])
if (
isinstance(task_seed, bool)
or not isinstance(task_seed, int)
or task_seed != config["seed"]
):
raise TuningError("taskConfig seed必须与session一致")
try:
config["taskConfig"] = validate_task_config(task_id, task_payload, config["seed"])
except TaskConfigError as error:
raise TuningError(str(error)) from error
base = base_configuration(task_id)
base["weights"]["avoidance_weight"] = config["taskConfig"]["sensorCfg"][
"avoidanceWeight"
]
validate_configuration_constraints(base, {}, task_id)
else:
if any(
k in payload
for k in (
"terrainPreset",
"terrainParams",
"sensorCfg",
"sensorType",
"customTerrainBoxes",
"taskConfig",
)
):
raise TuningError("Flat调参不接受避障场景字段")
objective = validate_objective_weights(
payload.get("objectiveWeights", DEFAULT_OBJECTIVE_WEIGHTS)
)
fallback = payload.get("fallbackEnabled", False)
if not isinstance(fallback, bool):
raise TuningError("fallbackEnabled 必须是布尔值")
if "pretrainedSourceId" in payload:
if self.sources is None:
raise TuningError("服务尚未注册基础策略,请配置--pretrained-sources")
try:
config["pretrained"] = self.sources.bind(
payload["pretrainedSourceId"], task_id, config.get("taskConfig")
)
except SourceError as error:
raise TuningError(str(error)) from error
return mode, config, objective, fallback
def create(self, payload: Any) -> dict:
@@ -262,8 +376,36 @@ class TuningManager:
"--reward-config",
str(reward_path),
]
task_path = None
if config["taskId"] == OBSTACLE_TASK:
task_path = run_dir / "task_config.json"
task_path.write_text(
json.dumps(config["taskConfig"], allow_nan=False), encoding="utf-8"
)
command.extend(("--task-config", str(task_path)))
pretrained = config.get("pretrained")
if pretrained is not None:
if self.sources is None:
raise TuningError("基础策略快照服务未配置,拒绝随机初始化")
self.sources.verify(pretrained)
if resume_checkpoint is not None:
if pretrained is not None:
origin_path = resume_checkpoint.parent / "initialization.json"
if (
not origin_path.is_file()
or origin_path.is_symlink()
or origin_path.stat().st_size > 64 * 1024
):
raise TuningError("续训checkpoint缺少有效基础策略来源记录,拒绝丢失来源")
origin = json.loads(origin_path.read_text(encoding="utf-8"))
if (
origin.get("source_id") != pretrained["sourceId"]
or origin.get("artifacts") != pretrained["manifest"]["artifacts"]
):
raise TuningError("续训checkpoint基础来源身份不匹配")
command.extend(("--resume-checkpoint", str(resume_checkpoint)))
elif pretrained is not None:
command.extend(self.sources.arguments(pretrained))
environment = os.environ.copy()
environment["WANDB_MODE"] = "disabled"
environment["WANDB_SILENT"] = "true"
@@ -304,6 +446,8 @@ class TuningManager:
"--gpu-ids",
json.dumps(config["gpuIds"], separators=(",", ":")),
]
if task_path is not None:
eval_command.extend(("--task-config", str(task_path)))
self.storage.update_trial(trial_id, state="evaluating", message="正在固定协议评估")
self.storage.update_session(
session_id, state="evaluating", message=f"正在评估 trial {trial['number']}"
@@ -316,7 +460,15 @@ class TuningManager:
raise TuningError(f"评估失败(返回码 {return_code})")
evaluation = json.loads(eval_output.read_text(encoding="utf-8"))
baseline_trial = self.storage.list_trials(session_id)[0]
if baseline_trial["evaluation"] is None:
if config["taskId"] == OBSTACLE_TASK:
expected = obstacle_scoring.protocol(config["taskConfig"], config["evalNumEnvs"])
metrics = obstacle_scoring.validate_evaluation(evaluation, expected)
baseline = baseline_trial["evaluation"]
baseline_metrics = (
obstacle_scoring.validate_evaluation(baseline, expected) if baseline else None
)
scored = obstacle_scoring.score_evaluation(metrics, baseline_metrics)
elif baseline_trial["evaluation"] is None:
scored = {
"eligible": True,
"score": 0.0,
@@ -394,7 +546,28 @@ class TuningManager:
return {
"task": session["config"]["taskId"],
"objectiveWeights": session["objectiveWeights"],
"allowlist": "服务端将验证固定 schema;最多四项修改",
"allowlist": {
section: {
key: {"min": spec.minimum, "max": spec.maximum} for key, spec in specs.items()
}
for section, specs in zip(
("weights", "params"), task_specs(session["config"]["taskId"]), strict=True
)
},
"taskContext": (
(
"97维目标导航:48条前视ray,3x16层pitch=[0,-20,-45]deg;仍有侧后/层间/坑盲区。"
if session["config"]["taskConfig"]["sensorCfg"].get("sensorMode")
== "multi_ring_raycast"
else "81维目标导航:32条前视ray仅单高度切片,存在侧后方/矮障碍/跌落盲区。"
)
+ "避障权重对应近障平方惩罚,collision_penalty为非足端>10N接触惩罚;"
"target_velocity是真实导航command速度而非reward权重。关注擦碰、绕行、目标到达和动作抖动。"
"评估固定seed/1000步/客观权重,不允许修改地图、传感器、起终点、协议或以训练奖励代替指标。"
)
if session["config"]["taskId"] == OBSTACLE_TASK
else "Flat速度跟踪与步态稳定;保留原六目标评估。",
"taskConfig": session["config"].get("taskConfig"),
"parameterConstraints": self.storage.get_control(session["id"])["constraints"],
"rejectedFeedback": rejected_feedback,
"trials": [
@@ -410,12 +583,14 @@ class TuningManager:
],
}
def _fallback_patch(self, previous: dict, index: int) -> dict:
def _fallback_patch(self, previous: dict, index: int, task_id="Unitree-Go2-Flat") -> dict:
names = ("track_linear_velocity", "action_rate_l2", "body_orientation_l2", "foot_slip")
if task_id == OBSTACLE_TASK:
names = ("avoidance_weight", "collision_penalty")
name = names[index % len(names)]
old = previous["weights"][name]
factor = 1.1 if index % 2 == 0 else 0.9
return validate_proposal({"weights": {name: old * factor}}, previous)
return validate_proposal({"weights": {name: old * factor}}, previous, task_id=task_id)
def _request_proposal(
self, session: dict, previous: dict, base_trial_id: str, index: int
@@ -427,7 +602,7 @@ class TuningManager:
if not session["fallbackEnabled"]:
raise AdvisorUnavailable(str(error)) from error
result = {
"patch": self._fallback_patch(previous, index),
"patch": self._fallback_patch(previous, index, session["config"]["taskId"]),
"rationale": f"Agent 不可用,显式 fallback:{error}",
"expectedImpact": {},
"confidence": 0.2,
@@ -437,7 +612,9 @@ class TuningManager:
}
source = "fallback"
constraints = self.storage.get_control(session["id"])["constraints"]
result["patch"] = validate_proposal(result["patch"], previous, constraints)
result["patch"] = validate_proposal(
result["patch"], previous, constraints, session["config"]["taskId"]
)
proposal = self.storage.create_proposal(
session["id"],
base_trial_id,
@@ -471,11 +648,34 @@ class TuningManager:
self.condition.wait(timeout=1.0)
raise TuningError("session 已取消")
@staticmethod
def _base_configuration(session):
base = base_configuration(session["config"]["taskId"])
if session["config"]["taskId"] == OBSTACLE_TASK:
base["weights"]["avoidance_weight"] = session["config"]["taskConfig"]["sensorCfg"][
"avoidanceWeight"
]
return base
def _run_session(self, session_id: str, resume: bool, cancel: threading.Event) -> None:
try:
session = self.storage.get_session(session_id)
trials = self.storage.list_trials(session_id)
if resume:
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] == "pending" and self.storage.decide_proposal(
proposal["id"],
"rejected",
"service_restart/recovery_invalidated:服务重启,需重新提案并审批(非用户拒绝)",
):
self.storage.audit(
session_id,
"recovery_proposal_invalidated",
{
"proposalId": proposal["id"],
"reason": "service_restart/recovery_invalidated",
},
)
root = self._session_root(session_id)
for interrupted in [trial for trial in trials if trial["state"] == "interrupted"]:
run_dir = (root / interrupted["runDir"]).resolve()
@@ -499,7 +699,7 @@ class TuningManager:
0,
0,
session["config"]["rungs"][0],
deepcopy(BASE_REWARD_CONFIGURATION),
self._base_configuration(session),
None,
baseline_dir,
)
@@ -551,7 +751,12 @@ class TuningManager:
proposal = self.storage.get_proposal(proposal["id"])
self._claim_dispatch(session_id, cancel)
constraints = self.storage.get_control(session_id)["constraints"]
reward_config = merge_proposal(base["rewardConfig"], proposal["patch"], constraints)
reward_config = merge_proposal(
base["rewardConfig"],
proposal["patch"],
constraints,
session["config"]["taskId"],
)
trial = self.storage.create_trial(
session_id,
next_number,
@@ -698,7 +903,12 @@ class TuningManager:
patch = payload["patch"]
if feedback is not None and (not isinstance(feedback, str) or len(feedback) > 2000):
raise TuningError("feedback 无效")
patch = validate_proposal(patch, base["rewardConfig"], constraints)
patch = validate_proposal(
patch,
base["rewardConfig"],
constraints,
self.storage.get_session(session_id)["config"]["taskId"],
)
if not self.storage.decide_proposal(proposal_id, "approved", feedback, patch):
raise TuningError("proposal 已处理")
self.storage.audit(
@@ -743,7 +953,12 @@ class TuningManager:
continue
base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], base["rewardConfig"], constraints)
validate_proposal(
proposal["patch"],
base["rewardConfig"],
constraints,
session["config"]["taskId"],
)
except Exception as error:
self.storage.decide_proposal(
proposal["id"], "rejected", f"参数护栏已变化:{error}"
@@ -771,8 +986,8 @@ class TuningManager:
revision = payload["revision"]
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
raise TuningError("constraints revision 必须是非负整数")
constraints = validate_constraints(payload["constraints"])
session = self.storage.get_session(session_id)
constraints = validate_constraints(payload["constraints"], session["config"]["taskId"])
if session["state"] not in ACTIVE_SESSION_STATES:
raise TuningError("终态 session 不能修改参数护栏")
control = self.storage.get_control(session_id)
@@ -784,7 +999,9 @@ class TuningManager:
else:
base = self._best_highest_rung(session_id)
if base is not None:
validate_configuration_constraints(base["rewardConfig"], constraints)
validate_configuration_constraints(
base["rewardConfig"], constraints, session["config"]["taskId"]
)
try:
updated = self.storage.replace_constraints(session_id, revision, constraints)
except StorageConflict as error:
@@ -796,7 +1013,12 @@ class TuningManager:
continue
proposal_base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], proposal_base["rewardConfig"], constraints)
validate_proposal(
proposal["patch"],
proposal_base["rewardConfig"],
constraints,
session["config"]["taskId"],
)
except Exception as error:
message = f"参数护栏 revision {updated['constraintsRevision']}:{error}"
if self.storage.decide_proposal(proposal["id"], "rejected", message):
@@ -914,7 +1136,16 @@ class TuningManager:
return self.detail(session_id)
def resume(self, session_id: str) -> dict:
# Serialize state transition and worker registration against duplicate requests.
with self.condition:
return self._resume_locked(session_id)
def _resume_locked(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["config"].get("pretrained") is not None:
if self.sources is None:
raise TuningError("基础策略快照服务未配置,无法恢复")
self.sources.verify(session["config"]["pretrained"])
if session["state"] == "paused":
self.storage.set_run_policy(session_id, "continuous")
has_pending = any(
@@ -982,8 +1213,11 @@ class TuningManager:
raise TuningError("最佳策略文件不存在")
return path
def preset_config(self, preset_id: str) -> dict:
return self.storage.get_preset(preset_id)["rewardConfig"]
def preset_config(self, preset_id: str, task_id: str = FLAT_TASK) -> dict:
preset = self.storage.get_preset(preset_id)
if preset["taskId"] != task_id:
raise RewardConfigError("奖励 preset 来源任务与训练任务不匹配")
return validate_configuration(preset["rewardConfig"], task_id)
def test_agent(self) -> dict:
try:
+130
View File
@@ -0,0 +1,130 @@
"""Fixed obstacle protocol; objective measurements never consume training rewards."""
import math
from statistics import fmean
from task_config import build_terrain_layout, validate_task_config
from .schema import OBSTACLE_TASK
from .scoring import EvaluationError
SEEDS = (101, 202, 303)
STEPS = 1000
WEIGHTS = {"success": 0.4, "time": 0.2, "clearance": 0.2, "smooth": 0.1, "no_fall": 0.1}
METRICS = (*WEIGHTS, "collision_rate", "fall_rate", "arrival_rate", "ray_hit_rate")
def evaluation_scenarios(custom):
scenarios = []
for seed in SEEDS:
value = validate_task_config(OBSTACLE_TASK, custom, seed)
scenarios.append(
{"seed": seed, "taskConfig": value, "terrain": build_terrain_layout(value)}
)
return scenarios
def protocol(custom, num_envs):
return {
"protocolVersion": "obstacle-v1",
"seeds": list(SEEDS),
"stepsPerSeed": STEPS,
"numEnvs": num_envs,
"objectiveWeights": WEIGHTS,
"sceneMode": "fixed-custom-map"
if custom["terrainPreset"] == "custom_boxes"
else "three-seed-layouts",
"scenarios": evaluation_scenarios(custom),
"episodePolicy": "first-episode-only; terminal snapshot before auto-reset; fixed horizon",
}
def score_trajectory(samples, horizon=STEPS):
"""One first-episode trajectory, one sample per policy step including first terminal."""
if not samples or len(samples) > horizon:
raise EvaluationError("轨迹样本数不完整")
keys = {"distance", "clearance", "action_delta", "ray_hit", "collision", "fall", "terminal"}
for sample in samples:
if set(sample) != keys:
raise EvaluationError("轨迹指标缺失/未知")
for key in keys:
value = sample[key]
if not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
raise EvaluationError(f"无效轨迹指标 {key}")
if key in {"ray_hit", "collision", "fall", "terminal"} and value > 1:
raise EvaluationError(f"无效标志 {key}")
if any(s["terminal"] for s in samples[:-1]) or (
len(samples) != horizon and not samples[-1]["terminal"]
):
raise EvaluationError("首episode轨迹不完整")
collision = any(s["collision"] for s in samples)
fall = any(s["fall"] for s in samples)
arrival = next((i + 1 for i, s in enumerate(samples) if s["distance"] < 0.5), None)
success = arrival is not None and not collision and not fall
# Missing steps after an early terminal earn zero clearance/smoothness, not a bonus.
return {
"success": float(success),
"time": 1 - arrival / horizon if success else 0.0,
"clearance": 0.0 if fall else sum(min(s["clearance"] / 0.5, 1) for s in samples) / horizon,
"smooth": sum(1 - min(s["action_delta"], 1) for s in samples) / horizon,
"no_fall": float(not fall),
"collision_rate": float(collision),
"fall_rate": float(fall),
"arrival_rate": float(arrival is not None),
"ray_hit_rate": fmean(s["ray_hit"] for s in samples),
}
def validate_metrics(value):
if not isinstance(value, dict) or set(value) != set(METRICS):
raise EvaluationError("避障评估指标缺失/未知")
for key, number in value.items():
if (
isinstance(number, bool)
or not isinstance(number, (int, float))
or not math.isfinite(number)
or not 0 <= number <= 1
):
raise EvaluationError(f"避障指标 {key} 必须在0–1且有限")
if not math.isclose(value["no_fall"] + value["fall_rate"], 1, abs_tol=1e-8):
raise EvaluationError("跌倒指标不一致")
return value
def validate_evaluation(value, expected_protocol):
if value.get("protocol") != expected_protocol:
raise EvaluationError("避障评估协议/场景不一致")
seeds = value.get("seedMetrics", [])
if len(seeds) != 3:
raise EvaluationError("避障评估必须完整包含3seed")
for item, seed in zip(seeds, SEEDS, strict=True):
if (
item.get("seed") != seed
or item.get("episodes") != expected_protocol["numEnvs"]
or item.get("rolloutSteps") != STEPS
):
raise EvaluationError("避障评估样本数不完整")
validate_metrics(item.get("metrics"))
metrics = validate_metrics(value.get("metrics"))
if any(
not math.isclose(metrics[k], fmean(s["metrics"][k] for s in seeds), abs_tol=1e-8)
for k in METRICS
):
raise EvaluationError("避障评估聚合不一致")
return metrics
def score_evaluation(current, baseline=None):
current = validate_metrics(current)
baseline = validate_metrics(baseline) if baseline is not None else current
eligible = (
current["fall_rate"] <= baseline["fall_rate"] + 0.02 + 1e-12
and current["success"] >= baseline["success"] - 0.02 - 1e-12
)
total = sum(WEIGHTS[k] * current[k] for k in WEIGHTS)
return {
"eligible": eligible,
"score": total if eligible else -1.0,
"components": {k: current[k] for k in WEIGHTS},
"metrics": current,
}
+75 -32
View File
@@ -68,6 +68,32 @@ BASE_REWARD_CONFIGURATION: dict[str, dict[str, float]] = {
}
FLAT_TASK = "Unitree-Go2-Flat"
OBSTACLE_TASK = "Unitree-Go2-ObstacleAvoidance"
OBSTACLE_WEIGHT_SPECS = {
"avoidance_weight": NumericSpec(0.5, 5.0, 2.0, False),
"collision_penalty": NumericSpec(-10.0, -0.5, -5.0, False),
"action_smoothness": NumericSpec(-0.05, -0.001, -0.05, False),
}
OBSTACLE_PARAMETER_SPECS = {"target_velocity": NumericSpec(0.3, 1.2, 0.6, False)}
def task_specs(task_id=FLAT_TASK):
if task_id == FLAT_TASK:
return WEIGHT_SPECS, PARAMETER_SPECS
if task_id == OBSTACLE_TASK:
return OBSTACLE_WEIGHT_SPECS, OBSTACLE_PARAMETER_SPECS
raise RewardConfigError(f"只支持Flat或Obstacle调参任务:{task_id}")
def base_configuration(task_id=FLAT_TASK):
weights, params = task_specs(task_id)
return {
"weights": {k: v.default for k, v in weights.items()},
"params": {k: v.default for k, v in params.items()},
}
def _number(name: str, value: Any, spec: NumericSpec) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise RewardConfigError(f"{name} 必须是数值")
@@ -87,19 +113,22 @@ def _mapping(value: Any, name: str) -> Mapping[str, Any]:
return value
def _cross_validate(config: Mapping[str, Mapping[str, float]]) -> None:
def _cross_validate(config: Mapping[str, Mapping[str, float]], task_id=FLAT_TASK) -> None:
if task_id == OBSTACLE_TASK:
return
params = config["params"]
if params["pose.walking_threshold"] >= params["pose.running_threshold"]:
raise RewardConfigError("pose.walking_threshold 必须小于 pose.running_threshold")
def _path_spec(path: str) -> tuple[str, str, NumericSpec]:
def _path_spec(path: str, task_id=FLAT_TASK) -> tuple[str, str, NumericSpec]:
weights, params = task_specs(task_id)
if path.startswith("weights."):
section, name = "weights", path.removeprefix("weights.")
spec = WEIGHT_SPECS.get(name)
spec = weights.get(name)
elif path.startswith("params."):
section, name = "params", path.removeprefix("params.")
spec = PARAMETER_SPECS.get(name)
spec = params.get(name)
else:
section, name, spec = "", "", None
if spec is None:
@@ -107,16 +136,16 @@ def _path_spec(path: str) -> tuple[str, str, NumericSpec]:
return section, name, spec
def validate_constraints(value: Any) -> dict[str, dict[str, float | str]]:
def validate_constraints(value: Any, task_id=FLAT_TASK) -> dict[str, dict[str, float | str]]:
"""Validate sparse per-session range/fixed safety constraints."""
root = _mapping(value, "constraints")
if len(root) > len(WEIGHT_SPECS) + len(PARAMETER_SPECS):
if len(root) > sum(len(specs) for specs in task_specs(task_id)):
raise RewardConfigError("constraints 数量超过白名单参数总数")
result: dict[str, dict[str, float | str]] = {}
for raw_path, raw_constraint in root.items():
if not isinstance(raw_path, str):
raise RewardConfigError("constraint path 必须是字符串")
_, _, spec = _path_spec(raw_path)
_, _, spec = _path_spec(raw_path, task_id)
constraint = _mapping(raw_constraint, raw_path)
kind = constraint.get("kind")
if kind == "fixed":
@@ -137,12 +166,12 @@ def validate_constraints(value: Any) -> dict[str, dict[str, float | str]]:
return result
def validate_configuration_constraints(value: Any, constraints: Any) -> None:
def validate_configuration_constraints(value: Any, constraints: Any, task_id=FLAT_TASK) -> None:
"""Ensure a complete reward configuration satisfies every session constraint."""
config = validate_configuration(value)
checked = validate_constraints(constraints)
config = validate_configuration(value, task_id)
checked = validate_constraints(constraints, task_id)
for path, constraint in checked.items():
section, name, _ = _path_spec(path)
section, name, _ = _path_spec(path, task_id)
current = config[section][name]
if constraint["kind"] == "fixed":
if current != constraint["value"]:
@@ -155,36 +184,37 @@ def validate_configuration_constraints(value: Any, constraints: Any) -> None:
)
def validate_configuration(value: Any) -> dict[str, dict[str, float]]:
def validate_configuration(value: Any, task_id=FLAT_TASK) -> dict[str, dict[str, float]]:
"""Validate a complete configuration and reject missing/unknown fields."""
root = _mapping(value, "rewardConfig")
if set(root) != {"weights", "params"}:
raise RewardConfigError("rewardConfig 只能包含 weights 和 params")
weights, params = task_specs(task_id)
raw_weights = _mapping(root["weights"], "weights")
raw_params = _mapping(root["params"], "params")
if set(raw_weights) != set(WEIGHT_SPECS):
if set(raw_weights) != set(weights):
raise RewardConfigError("weights 必须完整且不能包含未知奖励项")
if set(raw_params) != set(PARAMETER_SPECS):
if set(raw_params) != set(params):
raise RewardConfigError("params 必须完整且不能包含未知参数")
config = {
"weights": {
name: _number(f"weights.{name}", raw_weights[name], spec)
for name, spec in WEIGHT_SPECS.items()
for name, spec in weights.items()
},
"params": {
name: _number(f"params.{name}", raw_params[name], spec)
for name, spec in PARAMETER_SPECS.items()
name: _number(f"params.{name}", raw_params[name], spec) for name, spec in params.items()
},
}
_cross_validate(config)
_cross_validate(config, task_id)
return config
def validate_proposal(
value: Any, previous: Any, constraints: Any | None = None
value: Any, previous: Any, constraints: Any | None = None, task_id=FLAT_TASK
) -> dict[str, dict[str, float]]:
"""Validate a sparse Agent patch relative to a complete previous config and guardrails."""
current = validate_configuration(previous)
weights, params = task_specs(task_id)
current = validate_configuration(previous, task_id)
root = _mapping(value, "proposal")
if not set(root).issubset({"weights", "params"}):
raise RewardConfigError("proposal 只能包含 weights 和 params")
@@ -194,8 +224,8 @@ def validate_proposal(
raise RewardConfigError("proposal 至少需要一项修改")
if len(raw_weights) + len(raw_params) > MAX_PROPOSAL_CHANGES:
raise RewardConfigError(f"proposal 每轮最多修改 {MAX_PROPOSAL_CHANGES} 项")
unknown_weights = set(raw_weights) - set(WEIGHT_SPECS)
unknown_params = set(raw_params) - set(PARAMETER_SPECS)
unknown_weights = set(raw_weights) - set(weights)
unknown_params = set(raw_params) - set(params)
if unknown_weights:
raise RewardConfigError(f"未知奖励项:{', '.join(sorted(unknown_weights))}")
if unknown_params:
@@ -203,7 +233,7 @@ def validate_proposal(
patch: dict[str, dict[str, float]] = {"weights": {}, "params": {}}
for name, raw in raw_weights.items():
value_number = _number(f"weights.{name}", raw, WEIGHT_SPECS[name])
value_number = _number(f"weights.{name}", raw, weights[name])
old = current["weights"][name]
if old != 0.0 and value_number != 0.0:
ratio = abs(value_number / old)
@@ -216,7 +246,7 @@ def validate_proposal(
raise RewardConfigError(f"weights.{name} 没有发生变化")
patch["weights"][name] = value_number
for name, raw in raw_params.items():
value_number = _number(f"params.{name}", raw, PARAMETER_SPECS[name])
value_number = _number(f"params.{name}", raw, params[name])
old = current["params"][name]
ratio = abs(value_number / old)
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
@@ -230,26 +260,39 @@ def validate_proposal(
candidate = deepcopy(current)
candidate["weights"].update(patch["weights"])
candidate["params"].update(patch["params"])
_cross_validate(candidate)
_cross_validate(candidate, task_id)
if constraints is not None:
validate_configuration_constraints(candidate, constraints)
validate_configuration_constraints(candidate, constraints, task_id)
return patch
def merge_proposal(
previous: Any, proposal: Any, constraints: Any | None = None
previous: Any, proposal: Any, constraints: Any | None = None, task_id=FLAT_TASK
) -> dict[str, dict[str, float]]:
current = validate_configuration(previous)
patch = validate_proposal(proposal, current, constraints)
current = validate_configuration(previous, task_id)
patch = validate_proposal(proposal, current, constraints, task_id)
merged = deepcopy(current)
merged["weights"].update(patch["weights"])
merged["params"].update(patch["params"])
return validate_configuration(merged)
return validate_configuration(merged, task_id)
def apply_reward_configuration(env_cfg: Any, value: Any) -> None:
def apply_reward_configuration(env_cfg: Any, value: Any, task_id=FLAT_TASK) -> None:
"""Apply a validated full config to a fresh mjlab environment config."""
config = validate_configuration(value)
config = validate_configuration(value, task_id)
if task_id == OBSTACLE_TASK:
from mjlab.managers import RewardTermCfg
contact = env_cfg.terminations["illegal_contact"]
env_cfg.rewards["obstacle_collision"] = RewardTermCfg(
func=contact.func,
params=deepcopy(contact.params),
weight=config["weights"]["collision_penalty"],
)
env_cfg.rewards["obstacle_proximity"].weight = -config["weights"]["avoidance_weight"]
env_cfg.rewards["action_rate_l2"].weight = config["weights"]["action_smoothness"]
env_cfg.commands["twist"].speed = config["params"]["target_velocity"]
return
for name, weight in config["weights"].items():
if name not in env_cfg.rewards:
raise RewardConfigError(f"环境缺少奖励项:{name}")
+56 -29
View File
@@ -12,6 +12,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from .schema import FLAT_TASK, RewardConfigError, validate_configuration
SCHEMA_VERSION = 2
@@ -169,16 +171,37 @@ class TuningStorage:
def recover_interrupted(self) -> None:
at = now_iso()
with self.transaction() as connection:
previous = connection.execute(
"SELECT id,state FROM sessions WHERE state IN "
"('queued','running','evaluating','paused','awaiting_approval')"
).fetchall()
for row in previous:
connection.execute(
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
"VALUES (?,?,?,?)",
(
row["id"],
"service_restart_interrupted",
_json(
{
"previousState": row["state"],
"reason": "service_restart",
"requiresExplicitResume": True,
}
),
at,
),
)
connection.execute(
"UPDATE trials SET state='interrupted', ended_at=?, "
"message='服务重启中断,等待显式恢复' "
"WHERE state IN ('training','evaluating')",
"WHERE state IN ('queued','training','evaluating')",
(at,),
)
connection.execute(
"UPDATE sessions SET state='interrupted', updated_at=?, "
"message='服务重启中断,可从完整 checkpoint 恢复' "
"WHERE state IN ('running','evaluating')",
"WHERE state IN ('queued','running','evaluating','paused','awaiting_approval')",
(at,),
)
@@ -643,47 +666,51 @@ class TuningStorage:
for row in rows
]
def _preset_task(self, session_id: str) -> str:
# Identity comes only from the persisted source session, never client/preset labels.
try:
config = self.get_session(session_id)["config"]
except (KeyError, ValueError, TypeError) as error:
raise RewardConfigError("奖励 preset 来源 session 丢失或损坏") from error
if not isinstance(config, dict):
raise RewardConfigError("奖励 preset 来源 config 必须是对象")
# Original Flat-only sessions did not require taskId. Validate their preset below.
return config.get("taskId", FLAT_TASK)
def _preset(self, row) -> dict:
task_id = self._preset_task(row["session_id"])
try:
reward_config = validate_configuration(_decode(row["reward_config_json"]), task_id)
except (ValueError, TypeError) as error:
raise RewardConfigError("奖励 preset 配置无效:" + str(error)) from error
return {
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"taskId": task_id,
"rewardConfig": reward_config,
"createdAt": row["created_at"],
}
def save_preset(self, name: str, session_id: str, trial_id: str, reward_config: dict) -> dict:
reward_config = validate_configuration(reward_config, self._preset_task(session_id))
preset_id, at = uuid.uuid4().hex, now_iso()
self.connection().execute(
"INSERT INTO presets(id,name,session_id,trial_id,reward_config_json,created_at) "
"VALUES (?,?,?,?,?,?)",
(preset_id, name, session_id, trial_id, _json(reward_config), at),
)
return {
"id": preset_id,
"name": name,
"sessionId": session_id,
"trialId": trial_id,
"rewardConfig": reward_config,
"createdAt": at,
}
return self.get_preset(preset_id)
def get_preset(self, preset_id: str) -> dict:
row = self.connection().execute("SELECT * FROM presets WHERE id=?", (preset_id,)).fetchone()
if row is None:
raise KeyError(preset_id)
return {
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"rewardConfig": _decode(row["reward_config_json"]),
"createdAt": row["created_at"],
}
return self._preset(row)
def list_presets(self) -> list[dict]:
rows = (
self.connection().execute("SELECT * FROM presets ORDER BY created_at DESC").fetchall()
)
return [
{
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"rewardConfig": _decode(row["reward_config_json"]),
"createdAt": row["created_at"],
}
for row in rows
]
return [self._preset(row) for row in rows]