Cen #7

Merged
chenlin merged 4 commits from Cen into main 2026-09-07 09:15:38 +08:00
35 changed files with 4847 additions and 811 deletions
Showing only changes of commit 63d67a645b - Show all commits
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mujoco-web-platform",
"version": "0.8.1",
"version": "0.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mujoco-web-platform",
"version": "0.8.1",
"version": "0.8.2",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mujoco-web-platform",
"version": "0.8.1",
"version": "0.8.2",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
+7 -3
View File
@@ -57,7 +57,7 @@ python training_server/server.py \
在主工作台连接训练服务后,点击“打开自调参 Agent 工作台”。默认预算为 12 个唯一配置:所有配置先训练 300 iterations,前 4 名续训到 900,前 2 名续训到 2000;默认使用 GPU 0 和 4096 个并行环境。首次使用建议先降低为 256–512 environments 做 smoke test。
固定评估使用站立、前进/侧移、转向和组合命令以及 3 个固定 seed。最终分数不直接使用可被权重放大的总 reward,而由速度跟踪 35%、动作平滑 20%、姿态稳定 15%、减少跌倒 15%、足端滑移 10%、能耗 5% 的权重无关指标组成。跌倒率高于基线 2% 或速度误差恶化超过 5% 的 trial 不晋级。逐轮审批模式会自动运行基线,之后每条 Agent 建议都等待批准、修改后批准或拒绝反馈。
固定评估使用站立、前进/侧移、转向和组合命令以及 3 个固定 seed。最终分数不直接使用可被权重放大的总 reward,而由速度跟踪 35%、动作平滑 20%、姿态稳定 15%、减少跌倒 15%、足端滑移 10%、能耗 5% 的权重无关指标组成。跌倒率高于基线 2% 或速度误差恶化超过 5% 的 trial 不晋级。逐轮审批模式会自动运行基线,之后每条 Agent 建议都等待批准、修改后批准或拒绝反馈;运行中的 session 也可在逐轮审批和全自动之间切换,切到全自动时会批准当前待处理建议
最佳结果保存为不可变 preset,可在普通训练面板的“奖励配置”中选择,也可导出 JSON;不会覆盖仓库里的 Python 默认奖励配置。
@@ -71,12 +71,16 @@ python training_server/server.py \
- `GET /api/tuning/capabilities``POST /api/tuning/agent/test`:检查/测试 Agent
- `GET|POST /api/tuning/sessions``GET|DELETE /api/tuning/sessions/{id}`:列出、创建、查询、停止 session;
- `POST /api/tuning/sessions/{id}/pause|resume`:暂停后续调度或恢复;
- `POST /api/tuning/sessions/{id}/mode`:运行时切换 `automatic`/`approval` 模式;
- `PUT /api/tuning/sessions/{id}/constraints`:以 revision CAS 保存参数固定值/工程上下限,服务端在 Agent、fallback 与人工修改三条路径统一强制;
- `POST /api/tuning/sessions/{id}/step`:发放且只消费一个 Trial 调度令牌,完成训练与固定评估后重新暂停;
- `POST /api/tuning/sessions/{id}/rollback`:把同 Session 内已完成且通过安全门槛的 Trial 设为非破坏性后续基准,可同时验证其 checkpoint
- `POST /api/tuning/sessions/{id}/proposals/{proposalId}/approve|reject`:审批、修改或拒绝建议;
- `GET /api/tuning/sessions/{id}/trials/{trialId}/metrics`:查询降采样 scalar
- `GET /api/tuning/sessions/{id}/trials/{trialId}/metrics?afterStep=N`:查询降采样或增量 scalar
- `GET /api/tuning/sessions/{id}/artifacts/best/policy.onnx`:下载最佳策略;
- `GET /api/tuning/presets`:列出可供普通训练复用的最佳奖励 preset。
普通任务状态在服务重启后丢失,但日志、checkpoint 和 ONNX 保留在 `training_server/rl/logs/rsl_rl/`;调参状态及产物持久化在 `logs/auto_tuning/`。API 只接收 32 位资源 ID,不接收客户端文件路径;奖励 patch 受到名称、符号、上下界、每轮最多 4 项及 `0.5×–2×` 变化率校验。
普通任务状态在服务重启后丢失,但日志、checkpoint 和 ONNX 保留在 `training_server/rl/logs/rsl_rl/`;调参状态、调度令牌、参数护栏、回滚基准及产物持久化在 `logs/auto_tuning/`候选配置数可在 1–100 间设置(包含基线配置,仍受连续无提升早停约束)。API 只接收 32 位资源 ID,不接收客户端文件路径;奖励 patch 受到名称、符号、上下界、Session 护栏、每轮最多 4 项及 `0.5×–2×` 变化率校验。
## 测试
+41 -4
View File
@@ -27,6 +27,8 @@ from urllib.parse import parse_qs, unquote, urlsplit
from tuning.manager import TuningError, TuningManager
from tuning.process import GpuLease, ResourceBusyError
from tuning.schema import RewardConfigError
from tuning.scoring import EvaluationError
VERSION = "0.4.0"
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
@@ -498,7 +500,7 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
self._json(HTTPStatus.NOT_FOUND, {"error": "调参 session、trial 或 proposal 不存在"})
elif isinstance(error, ResourceBusyError):
self._json(HTTPStatus.CONFLICT, {"error": str(error)})
elif isinstance(error, TuningError):
elif isinstance(error, (TuningError, RewardConfigError, EvaluationError)):
self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
else:
self._json(
@@ -552,7 +554,7 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
self._ensure_origin()
self.send_response(HTTPStatus.NO_CONTENT)
self._cors()
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Max-Age", "600")
self.end_headers()
@@ -591,14 +593,18 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
tags = [tag for value in query.get("tags", []) for tag in value.split(",") if tag]
try:
max_points = int(query.get("maxPoints", ["1000"])[0])
after_raw = query.get("afterStep", [None])[0]
after_step = int(after_raw) if after_raw is not None else None
except ValueError as error:
raise TuningError("maxPoints 必须是整数") from error
raise TuningError("maxPoints/afterStep 必须是整数") from error
if not 10 <= max_points <= 5000:
raise TuningError("maxPoints 必须在 105000 之间")
if after_step is not None and after_step < -1:
raise TuningError("afterStep 不能小于 -1")
self._json(
HTTPStatus.OK,
self.tuning_manager.metrics(
match.group(1), match.group(2), tags or None, max_points
match.group(1), match.group(2), tags or None, max_points, after_step
),
)
return
@@ -640,6 +646,22 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
)
self._json(HTTPStatus.ACCEPTED, action(match.group(1)))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(step|rollback)", path)
if match:
action = (
self.tuning_manager.step
if match.group(2) == "step"
else self.tuning_manager.rollback
)
self._json(HTTPStatus.ACCEPTED, action(match.group(1), self._payload()))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/mode", path)
if match:
self._json(
HTTPStatus.ACCEPTED,
self.tuning_manager.set_mode(match.group(1), self._payload()),
)
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/proposals/([0-9a-f]{32})/(approve|reject)",
path,
@@ -658,6 +680,21 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
except Exception as error:
self._error(error)
def do_PUT(self) -> None:
try:
self._ensure_request()
path = urlsplit(self.path).path
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/constraints", path)
if match:
self._json(
HTTPStatus.ACCEPTED,
self.tuning_manager.set_constraints(match.group(1), self._payload()),
)
return
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
except Exception as error:
self._error(error)
def do_DELETE(self) -> None:
try:
self._ensure_request()
+45 -1
View File
@@ -12,6 +12,7 @@ from tuning.schema import ( # noqa: E402
RewardConfigError,
merge_proposal,
validate_configuration,
validate_constraints,
validate_proposal,
)
from tuning.scoring import ( # noqa: E402
@@ -19,7 +20,7 @@ from tuning.scoring import ( # noqa: E402
EvaluationError,
score_evaluation,
)
from tuning.storage import TuningStorage # noqa: E402
from tuning.storage import StorageConflict, TuningStorage # noqa: E402
class RewardSchemaTest(unittest.TestCase):
@@ -69,6 +70,33 @@ class RewardSchemaTest(unittest.TestCase):
BASE_REWARD_CONFIGURATION,
)
def test_session_constraints_reject_unknown_out_of_range_and_fixed_changes(self):
constraints = validate_constraints(
{
"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0},
"params.foot_gait.period": {"kind": "range", "min": 0.5, "max": 0.7},
}
)
validate_proposal(
{"params": {"foot_gait.period": 0.65}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "已固定"):
validate_proposal(
{"weights": {"track_linear_velocity": 1.1}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "工程锁定范围"):
validate_proposal(
{"params": {"foot_gait.period": 0.75}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "未知参数约束"):
validate_constraints({"weights.not_allowed": {"kind": "fixed", "value": 1.0}})
class ScoringTest(unittest.TestCase):
baseline = {
@@ -174,8 +202,24 @@ class StorageTest(unittest.TestCase):
self.assertEqual(sampled[0]["step"], 0)
self.assertEqual(sampled[-1]["step"], 99)
self.assertIn(50.0, [point["value"] for point in sampled])
control = self.storage.replace_constraints(
session["id"],
0,
{"weights.pose": {"kind": "range", "min": 0.5, "max": 1.5}},
)
self.assertEqual(control["constraintsRevision"], 1)
with self.assertRaises(StorageConflict):
self.storage.replace_constraints(session["id"], 0, {})
self.storage.grant_dispatch_token(session["id"])
with self.assertRaises(StorageConflict):
self.storage.grant_dispatch_token(session["id"])
self.assertTrue(self.storage.use_dispatch_token(session["id"]))
self.assertFalse(self.storage.use_dispatch_token(session["id"]))
incremental = self.storage.metrics(trial["id"], max_points=100, after_step=90)[0]
self.assertEqual(incremental["points"][0]["step"], 91)
reopened = TuningStorage(self.storage.path)
self.assertEqual(reopened.get_session(session["id"])["mode"], "approval")
self.assertEqual(reopened.get_control(session["id"])["constraintsRevision"], 1)
def test_recovery_marks_inflight_records(self):
session = self.storage.create_session(
@@ -148,6 +148,8 @@ class TuningManagerTest(unittest.TestCase):
self.fail("session did not wait for approval")
proposal = detail["proposals"][-1]
patch = {"weights": {"pose": 1.1}, "params": {}}
with self.assertRaisesRegex(Exception, "feedback"):
self.manager.approve(session["id"], proposal["id"], {"feedback": {}})
approved = self.manager.approve(
session["id"], proposal["id"], {"feedback": "ok", "patch": patch}
)
@@ -155,6 +157,34 @@ class TuningManagerTest(unittest.TestCase):
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_runtime_mode_switch_auto_approves_pending_proposal(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
changed = self.manager.set_mode(session["id"], {"mode": "automatic"})
self.assertEqual(changed["mode"], "automatic")
self.assertEqual(changed["proposals"][-1]["state"], "approved")
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
def test_trial_count_is_user_configurable(self):
payload = self.payload()
payload["trialCount"] = 1
_, config, _, _ = self.manager.parse_create(payload)
self.assertEqual(config["trialCount"], 1)
payload["trialCount"] = 100
_, config, _, _ = self.manager.parse_create(payload)
self.assertEqual(config["trialCount"], 100)
payload["trialCount"] = 101
with self.assertRaisesRegex(Exception, "trialCount"):
self.manager.parse_create(payload)
def test_resume_discards_only_interrupted_trial_and_continues(self):
mode, config, objective, fallback = self.manager.parse_create(self.payload())
session = self.manager.storage.create_session(mode, config, objective, fallback)
@@ -178,8 +208,10 @@ class TuningManagerTest(unittest.TestCase):
"trial-001-rung-0",
)
self.manager.storage.update_trial(interrupted["id"], state="interrupted")
self.manager.storage.reset_step_gate(session["id"])
self.manager.storage.update_session(session["id"], state="interrupted")
self.manager.resume(session["id"])
self.assertEqual(self.manager.storage.get_control(session["id"])["runPolicy"], "continuous")
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
self.assertNotIn(interrupted["id"], [trial["id"] for trial in completed["trials"]])
@@ -190,6 +222,129 @@ class TuningManagerTest(unittest.TestCase):
self.manager.parse_create({"taskId": "Other"})
self.assertEqual(self.manager.test_agent()["model"], "fake")
def test_pause_closes_persistent_dispatch_gate_at_trial_boundary(self):
mode, config, objective, fallback = self.manager.parse_create(self.payload())
session = self.manager.storage.create_session(mode, config, objective, fallback)
self.manager.storage.update_session(session["id"], state="running")
self.manager.storage.grant_dispatch_token(session["id"])
paused = self.manager.pause(session["id"])
self.assertEqual(paused["state"], "paused")
self.assertEqual(paused["control"]["runPolicy"], "step")
self.assertEqual(paused["control"]["dispatchTokens"], 0)
def test_step_token_executes_exactly_one_trial_then_pauses(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
baseline_count = len([trial for trial in detail["trials"] if trial["state"] == "completed"])
stepped = self.manager.step(session["id"], {"count": 1})
self.assertEqual(stepped["control"]["runPolicy"], "step")
self.assertEqual(stepped["control"]["dispatchTokens"], 1)
proposal = stepped["proposals"][-1]
self.manager.approve(session["id"], proposal["id"], {})
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
completed_count = len(
[trial for trial in detail["trials"] if trial["state"] == "completed"]
)
if detail["state"] == "paused" and completed_count == baseline_count + 1:
break
time.sleep(0.01)
else:
self.fail("single-step trial did not pause at the next boundary")
time.sleep(0.05)
self.assertEqual(
len(
[
trial
for trial in self.manager.detail(session["id"])["trials"]
if trial["state"] == "completed"
]
),
baseline_count + 1,
)
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_constraints_are_revisioned_and_enforced_during_approval(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
proposal = detail["proposals"][-1]
constrained = self.manager.set_constraints(
session["id"],
{
"revision": 0,
"constraints": {"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0}},
},
)
self.assertEqual(constrained["control"]["constraintsRevision"], 1)
with self.assertRaisesRegex(Exception, "已固定"):
self.manager.approve(
session["id"],
proposal["id"],
{"patch": {"weights": {"track_linear_velocity": 1.1}, "params": {}}},
)
with self.assertRaisesRegex(Exception, "revision"):
self.manager.set_constraints(session["id"], {"revision": 0, "constraints": {}})
self.manager.approve(session["id"], proposal["id"], {})
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_rollback_uses_safe_completed_trial_as_next_proposal_base(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
baseline = detail["trials"][0]
old_proposal = detail["proposals"][-1]
rolled_back = self.manager.rollback(
session["id"], {"trialId": baseline["id"], "checkpoint": True}
)
self.assertEqual(rolled_back["state"], "paused")
self.assertEqual(rolled_back["control"]["activeBaseTrialId"], baseline["id"])
self.assertEqual(
next(item for item in rolled_back["proposals"] if item["id"] == old_proposal["id"])[
"state"
],
"rejected",
)
self.manager.step(session["id"], {"count": 1})
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if (
detail["state"] == "awaiting_approval"
and detail["proposals"][-1]["id"] != old_proposal["id"]
):
break
time.sleep(0.01)
else:
self.fail("rollback base did not produce a replacement proposal")
self.assertEqual(detail["proposals"][-1]["baseTrialId"], baseline["id"])
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
if __name__ == "__main__":
unittest.main()
+312 -28
View File
@@ -18,10 +18,12 @@ from .process import GpuLease, ResourceBusyError, terminate_process
from .schema import (
BASE_REWARD_CONFIGURATION,
merge_proposal,
validate_configuration_constraints,
validate_constraints,
validate_proposal,
)
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation, validate_objective_weights
from .storage import TuningStorage, now_iso
from .storage import StorageConflict, TuningStorage, now_iso
from .study import OptunaStudies
from .tensorboard import ingest_scalars
@@ -98,7 +100,7 @@ class TuningManager:
)
):
raise TuningError("gpuIds 必须是非空非负整数数组")
trial_count = self._integer(payload, "trialCount", 12, 4, 20)
trial_count = self._integer(payload, "trialCount", 12, 1, 100)
rung0 = self._integer(payload, "initialIterations", 300, 1, 1000000)
rung1 = self._integer(payload, "middleIterations", 900, rung0, 1000000)
rung2 = self._integer(payload, "finalIterations", 2000, rung1, 1000000)
@@ -159,6 +161,15 @@ class TuningManager:
session["trials"] = self.storage.list_trials(session_id)
session["proposals"] = self.storage.list_proposals(session_id)
session["audit"] = self.storage.audit_events(session_id)
control = self.storage.get_control(session_id)
current = next(
(trial for trial in session["trials"] if trial["id"] == session["currentTrialId"]),
None,
)
control["effectiveAfterCurrent"] = bool(
current and current["state"] in {"training", "evaluating"}
)
session["control"] = control
return session
def list(self) -> list[dict]:
@@ -274,8 +285,8 @@ class TuningManager:
policy = run_dir / "policy.onnx"
if return_code != 0 or checkpoint is None or not policy.is_file():
raise TuningError(f"训练失败(返回码 {return_code})或缺少 checkpoint/policy.onnx")
self._wait_if_paused(session_id, self.cancel_events[session_id])
# 暂停只关闭后续 Trial 调度门;已经开始的 Trial 必须连同固定评估一起
# 完成,避免把一次单步令牌错误地消耗在半个 Trial 上。
eval_output = run_dir / "evaluation.json"
eval_command = [
self.python,
@@ -332,6 +343,9 @@ class TuningManager:
score=scored["score"],
eligible=scored["eligible"],
)
best = self._best_highest_rung(session_id)
if best is not None:
self.storage.update_session(session_id, best_trial_id=best["id"])
try:
optuna_number = self.studies.record(
session_id,
@@ -363,6 +377,13 @@ class TuningManager:
default=None,
)
def _best_highest_rung(self, session_id: str) -> dict | None:
for rung in (2, 1, 0):
best = self._best(session_id, rung=rung)
if best is not None:
return best
return None
def _proposal_context(self, session: dict) -> dict:
trials = self.storage.list_trials(session["id"])[-12:]
rejected_feedback = [
@@ -374,6 +395,7 @@ class TuningManager:
"task": session["config"]["taskId"],
"objectiveWeights": session["objectiveWeights"],
"allowlist": "服务端将验证固定 schema;最多四项修改",
"parameterConstraints": self.storage.get_control(session["id"])["constraints"],
"rejectedFeedback": rejected_feedback,
"trials": [
{
@@ -414,6 +436,8 @@ class TuningManager:
"model": "optuna-fallback",
}
source = "fallback"
constraints = self.storage.get_control(session["id"])["constraints"]
result["patch"] = validate_proposal(result["patch"], previous, constraints)
proposal = self.storage.create_proposal(
session["id"],
base_trial_id,
@@ -487,7 +511,10 @@ class TuningManager:
if t["rung"] == 0 and t["state"] == "completed"
]
next_number = len({t["number"] for t in completed_rung0})
best_score = max((t["score"] or 0.0 for t in completed_rung0), default=0.0)
best_score = max(
(t["score"] if t["score"] is not None else float("-inf") for t in completed_rung0),
default=0.0,
)
no_improve = 0
while (
next_number < session["config"]["trialCount"]
@@ -495,12 +522,19 @@ class TuningManager:
):
if cancel.is_set():
raise TuningError("session 已取消")
self._wait_if_paused(session_id, cancel)
base = self._best(session_id, rung=0) or completed_rung0[0]
self._wait_for_dispatch(session_id, cancel)
control = self.storage.get_control(session_id)
base = (
self.storage.get_trial(control["activeBaseTrialId"])
if control["activeBaseTrialId"]
else self._best(session_id, rung=0) or completed_rung0[0]
)
proposal = self._request_proposal(
session, base["rewardConfig"], base["id"], next_number
)
if session["mode"] == "approval":
# 模式允许在 session 运行期间切换,因此每次决策都读取最新持久化值。
current_mode = self.storage.get_session(session_id)["mode"]
if current_mode == "approval":
self.storage.update_session(
session_id, state="awaiting_approval", message="等待批准 Agent 建议"
)
@@ -515,8 +549,9 @@ class TuningManager:
else:
self.storage.decide_proposal(proposal["id"], "approved", "自动模式")
proposal = self.storage.get_proposal(proposal["id"])
self._wait_if_paused(session_id, cancel)
reward_config = merge_proposal(base["rewardConfig"], proposal["patch"])
self._claim_dispatch(session_id, cancel)
constraints = self.storage.get_control(session_id)["constraints"]
reward_config = merge_proposal(base["rewardConfig"], proposal["patch"], constraints)
trial = self.storage.create_trial(
session_id,
next_number,
@@ -527,8 +562,16 @@ class TuningManager:
f"trial-{next_number:03d}-rung-0",
)
result = self._execute_trial(session, trial)
if result["eligible"] and (result["score"] or -999) > best_score + 0.01:
best_score = result["score"]
if control["activeBaseTrialId"]:
self.storage.set_active_base(session_id, None)
self._pause_after_step(session_id)
result_score = result["score"]
if (
result["eligible"]
and result_score is not None
and result_score > best_score + 0.01
):
best_score = result_score
no_improve = 0
else:
no_improve += 1
@@ -537,13 +580,17 @@ class TuningManager:
# Promote top configurations; each new rung resumes its own previous checkpoint.
for rung in (1, 2):
self._wait_if_paused(session_id, cancel)
previous = [
t
for t in self.storage.list_trials(session_id)
if t["rung"] == rung - 1 and t["state"] == "completed" and t["eligible"]
]
previous.sort(key=lambda t: t["score"] or -999, reverse=True)
previous.sort(
key=lambda trial: (
trial["score"] if trial["score"] is not None else float("-inf")
),
reverse=True,
)
promoted_numbers = {
trial["number"]
for trial in self.storage.list_trials(session_id)
@@ -554,6 +601,7 @@ class TuningManager:
continue
if cancel.is_set():
raise TuningError("session 已取消")
self._claim_dispatch(session_id, cancel)
root = self._session_root(session_id)
checkpoint = root / parent["checkpointPath"]
trial = self.storage.create_trial(
@@ -566,12 +614,9 @@ class TuningManager:
f"trial-{parent['number']:03d}-rung-{rung}",
)
self._execute_trial(session, trial, checkpoint)
self._pause_after_step(session_id)
best = (
self._best(session_id, rung=2)
or self._best(session_id, rung=1)
or self._best(session_id, rung=0)
)
best = self._best_highest_rung(session_id)
if best is None:
raise TuningError("没有通过安全门槛的 trial")
preset_name = f"{session['config']['runName']}-{session_id[:8]}"
@@ -602,10 +647,42 @@ class TuningManager:
self.workers.pop(session_id, None)
self.processes.pop(session_id, None)
def _wait_if_paused(self, session_id: str, cancel: threading.Event) -> None:
def _wait_for_dispatch(self, session_id: str, cancel: threading.Event) -> None:
"""Wait at a scheduler boundary without consuming a one-shot token."""
with self.condition:
while self.storage.get_session(session_id)["state"] == "paused" and not cancel.is_set():
while not cancel.is_set():
session = self.storage.get_session(session_id)
control = self.storage.get_control(session_id)
if session["state"] == "paused":
self.condition.wait(timeout=1.0)
continue
if control["runPolicy"] == "continuous" or control["dispatchTokens"] > 0:
return
self.storage.update_session(
session_id,
state="paused",
message="单步 Trial 已完成;等待下一个调度令牌",
)
self.storage.audit(session_id, "step_gate_waiting", {})
self.condition.wait(timeout=1.0)
raise TuningError("session 已取消")
def _claim_dispatch(self, session_id: str, cancel: threading.Event) -> None:
while not cancel.is_set():
self._wait_for_dispatch(session_id, cancel)
if self.storage.use_dispatch_token(session_id):
return
raise TuningError("session 已取消")
def _pause_after_step(self, session_id: str) -> None:
control = self.storage.get_control(session_id)
if control["runPolicy"] == "step" and control["dispatchTokens"] == 0:
self.storage.update_session(
session_id,
state="paused",
message="单步 Trial 已完成;后续调度已暂停",
)
self.storage.audit(session_id, "step_trial_completed", {})
def approve(self, session_id: str, proposal_id: str, payload: Any) -> dict:
proposal = self.storage.get_proposal(proposal_id)
@@ -613,11 +690,15 @@ class TuningManager:
raise TuningError("proposal 不属于该 session")
patch = proposal["patch"]
feedback = None
base = self.storage.get_trial(proposal["baseTrialId"])
constraints = self.storage.get_control(session_id)["constraints"]
if isinstance(payload, dict):
feedback = payload.get("feedback")
if "patch" in payload:
base = self.storage.get_trial(proposal["baseTrialId"])
patch = validate_proposal(payload["patch"], base["rewardConfig"])
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)
if not self.storage.decide_proposal(proposal_id, "approved", feedback, patch):
raise TuningError("proposal 已处理")
self.storage.audit(
@@ -642,22 +723,213 @@ class TuningManager:
self.condition.notify_all()
return self.detail(session_id)
def set_mode(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or payload.get("mode") not in ("automatic", "approval"):
raise TuningError("mode 必须是 automatic 或 approval")
mode = payload["mode"]
with self.condition:
session = self.storage.get_session(session_id)
if session["state"] not in RUNNING_STATES | {"awaiting_approval", "paused"}:
raise TuningError("当前状态不能切换运行模式")
previous = session["mode"]
if previous == mode:
return self.detail(session_id)
self.storage.update_session(session_id, mode=mode)
approved_ids = []
if mode == "automatic":
constraints = self.storage.get_control(session_id)["constraints"]
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] != "pending":
continue
base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], base["rewardConfig"], constraints)
except Exception as error:
self.storage.decide_proposal(
proposal["id"], "rejected", f"参数护栏已变化:{error}"
)
continue
if self.storage.decide_proposal(
proposal["id"], "approved", "运行时切换为全自动模式"
):
approved_ids.append(proposal["id"])
if session["state"] == "awaiting_approval":
self.storage.update_session(
session_id, state="running", message="已切换为全自动模式,继续调参"
)
self.storage.audit(
session_id,
"session_mode_changed",
{"from": previous, "to": mode, "autoApprovedProposalIds": approved_ids},
)
self.condition.notify_all()
return self.detail(session_id)
def set_constraints(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or set(payload) != {"revision", "constraints"}:
raise TuningError("参数护栏请求必须包含 revision 与 constraints")
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)
if session["state"] not in ACTIVE_SESSION_STATES:
raise TuningError("终态 session 不能修改参数护栏")
control = self.storage.get_control(session_id)
base = None
if control["activeBaseTrialId"]:
base = self.storage.get_trial(control["activeBaseTrialId"])
elif session["currentTrialId"]:
base = self.storage.get_trial(session["currentTrialId"])
else:
base = self._best_highest_rung(session_id)
if base is not None:
validate_configuration_constraints(base["rewardConfig"], constraints)
try:
updated = self.storage.replace_constraints(session_id, revision, constraints)
except StorageConflict as error:
raise ResourceBusyError(str(error)) from error
rejected = []
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] != "pending":
continue
proposal_base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], proposal_base["rewardConfig"], constraints)
except Exception as error:
message = f"参数护栏 revision {updated['constraintsRevision']}{error}"
if self.storage.decide_proposal(proposal["id"], "rejected", message):
rejected.append(proposal["id"])
self.storage.audit(
session_id,
"constraints_updated",
{
"revision": updated["constraintsRevision"],
"paths": sorted(constraints),
"rejectedProposalIds": rejected,
},
)
if rejected:
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def step(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or payload.get("count", 1) != 1:
raise TuningError("单步调度一次只能发放 1 个 Trial 令牌")
session = self.storage.get_session(session_id)
if session["state"] not in {"paused", "awaiting_approval"}:
raise TuningError("请先暂停或等待 Proposal 审批,再执行单步 Trial")
control = self.storage.get_control(session_id)
if control["dispatchTokens"] > 0:
raise TuningError("已有未消费的单步 Trial 令牌")
try:
self.storage.grant_dispatch_token(session_id)
except StorageConflict as error:
raise ResourceBusyError(str(error)) from error
if session["state"] == "paused":
has_pending = any(
proposal["state"] == "pending"
for proposal in self.storage.list_proposals(session_id)
)
self.storage.update_session(
session_id,
state="awaiting_approval" if has_pending else "running",
message="单步令牌已就绪;请审批 Proposal"
if has_pending
else "已授权执行一个 Trial",
)
self.storage.audit(session_id, "step_token_granted", {"count": 1})
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def rollback(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict):
raise TuningError("rollback 请求体必须是对象")
session = self.storage.get_session(session_id)
if session["state"] not in {"paused", "awaiting_approval"}:
raise TuningError("回滚只能在安全暂停或等待审批时执行")
target_id = payload.get("trialId")
if payload.get("target") == "best":
target_id = session["bestTrialId"] or (self._best_highest_rung(session_id) or {}).get(
"id"
)
if not isinstance(target_id, str):
raise TuningError("rollback 必须指定 trialId 或 target=best")
target = self.storage.get_trial(target_id)
if target["sessionId"] != session_id:
raise TuningError("rollback Trial 不属于该 session")
if target["state"] != "completed" or not target["eligible"]:
raise TuningError("只能回滚到已完成且通过安全门槛的 Trial")
checkpoint = payload.get("checkpoint", False)
if not isinstance(checkpoint, bool):
raise TuningError("checkpoint 必须是布尔值")
if checkpoint:
path_value = target.get("checkpointPath")
if not path_value:
raise TuningError("目标 Trial 没有可用 checkpoint")
root = self._session_root(session_id)
path = (root / path_value).resolve()
if not path.is_relative_to(root) or not path.is_file():
raise TuningError("目标 checkpoint 不存在或路径非法")
superseded = []
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] == "pending" and self.storage.decide_proposal(
proposal["id"], "rejected", f"由回滚到 Trial {target_id[:8]} 取代"
):
superseded.append(proposal["id"])
self.storage.set_active_base(session_id, target_id)
self.storage.reset_step_gate(session_id)
self.storage.update_session(
session_id,
state="paused",
message=f"已回滚到 Trial {target['number']} / Rung {target['rung']};等待单步或继续",
)
self.storage.audit(
session_id,
"rollback_selected",
{
"trialId": target_id,
"checkpoint": checkpoint,
"supersededProposalIds": superseded,
},
)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def pause(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] not in RUNNING_STATES | {"awaiting_approval"}:
raise TuningError("当前状态不能暂停")
# 以持久化调度门记录暂停意图,而不是只依赖 session.state。当前 Trial
# 的训练/评估会继续完成,下一次 _claim_dispatch 必须等待显式继续或单步。
self.storage.reset_step_gate(session_id)
self.storage.update_session(
session_id, state="paused", message="已暂停后续调度;当前子进程将完成"
session_id, state="paused", message="已暂停后续调度;在途 Trial(如有)将完整结束"
)
return self.detail(session_id)
def resume(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] == "paused":
self.storage.update_session(session_id, state="running", message="继续调参")
self.storage.set_run_policy(session_id, "continuous")
has_pending = any(
proposal["state"] == "pending"
for proposal in self.storage.list_proposals(session_id)
)
self.storage.update_session(
session_id,
state="awaiting_approval" if has_pending else "running",
message="请审批待处理 Proposal" if has_pending else "连续调参已恢复",
)
with self.condition:
self.condition.notify_all()
elif session["state"] == "interrupted":
self.storage.set_run_policy(session_id, "continuous")
self.storage.update_session(session_id, state="queued", message="从最近完整结果恢复")
self._start_worker(session_id, resume=True)
else:
@@ -665,7 +937,9 @@ class TuningManager:
return self.detail(session_id)
def cancel(self, session_id: str) -> dict:
self.storage.get_session(session_id)
session = self.storage.get_session(session_id)
if session["state"] not in ACTIVE_SESSION_STATES:
return self.detail(session_id)
self.storage.update_session(session_id, state="cancelled", message="正在取消")
event = self.cancel_events.get(session_id)
if event:
@@ -678,12 +952,22 @@ class TuningManager:
return self.detail(session_id)
def metrics(
self, session_id: str, trial_id: str, tags: list[str] | None, max_points: int
self,
session_id: str,
trial_id: str,
tags: list[str] | None,
max_points: int,
after_step: int | None = None,
) -> dict:
trial = self.storage.get_trial(trial_id)
if trial["sessionId"] != session_id:
raise TuningError("trial 不属于该 session")
return {"trialId": trial_id, "series": self.storage.metrics(trial_id, tags, max_points)}
series = self.storage.metrics(trial_id, tags, max_points, after_step)
next_step = max(
(point["step"] for item in series for point in item["points"]),
default=after_step,
)
return {"trialId": trial_id, "series": series, "nextStep": next_step}
def best_artifact(self, session_id: str) -> Path:
session = self.storage.get_session(session_id)
+72 -4
View File
@@ -93,6 +93,68 @@ def _cross_validate(config: Mapping[str, Mapping[str, float]]) -> None:
raise RewardConfigError("pose.walking_threshold 必须小于 pose.running_threshold")
def _path_spec(path: str) -> tuple[str, str, NumericSpec]:
if path.startswith("weights."):
section, name = "weights", path.removeprefix("weights.")
spec = WEIGHT_SPECS.get(name)
elif path.startswith("params."):
section, name = "params", path.removeprefix("params.")
spec = PARAMETER_SPECS.get(name)
else:
section, name, spec = "", "", None
if spec is None:
raise RewardConfigError(f"未知参数约束:{path}")
return section, name, spec
def validate_constraints(value: Any) -> 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):
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)
constraint = _mapping(raw_constraint, raw_path)
kind = constraint.get("kind")
if kind == "fixed":
if set(constraint) != {"kind", "value"}:
raise RewardConfigError(f"{raw_path} fixed 约束只能包含 kind/value")
fixed = _number(f"{raw_path}.value", constraint["value"], spec)
result[raw_path] = {"kind": "fixed", "value": fixed}
elif kind == "range":
if set(constraint) != {"kind", "min", "max"}:
raise RewardConfigError(f"{raw_path} range 约束只能包含 kind/min/max")
minimum = _number(f"{raw_path}.min", constraint["min"], spec)
maximum = _number(f"{raw_path}.max", constraint["max"], spec)
if minimum > maximum:
raise RewardConfigError(f"{raw_path} 下限不能大于上限")
result[raw_path] = {"kind": "range", "min": minimum, "max": maximum}
else:
raise RewardConfigError(f"{raw_path}.kind 必须是 range 或 fixed")
return result
def validate_configuration_constraints(value: Any, constraints: Any) -> None:
"""Ensure a complete reward configuration satisfies every session constraint."""
config = validate_configuration(value)
checked = validate_constraints(constraints)
for path, constraint in checked.items():
section, name, _ = _path_spec(path)
current = config[section][name]
if constraint["kind"] == "fixed":
if current != constraint["value"]:
raise RewardConfigError(
f"{path} 已固定为 {constraint['value']},不能设为 {current}"
)
elif current < constraint["min"] or current > constraint["max"]:
raise RewardConfigError(
f"{path}={current} 超出工程锁定范围 {constraint['min']}{constraint['max']}"
)
def validate_configuration(value: Any) -> dict[str, dict[str, float]]:
"""Validate a complete configuration and reject missing/unknown fields."""
root = _mapping(value, "rewardConfig")
@@ -118,8 +180,10 @@ def validate_configuration(value: Any) -> dict[str, dict[str, float]]:
return config
def validate_proposal(value: Any, previous: Any) -> dict[str, dict[str, float]]:
"""Validate a sparse Agent patch relative to a complete previous config."""
def validate_proposal(
value: Any, previous: Any, constraints: Any | None = None
) -> dict[str, dict[str, float]]:
"""Validate a sparse Agent patch relative to a complete previous config and guardrails."""
current = validate_configuration(previous)
root = _mapping(value, "proposal")
if not set(root).issubset({"weights", "params"}):
@@ -167,12 +231,16 @@ def validate_proposal(value: Any, previous: Any) -> dict[str, dict[str, float]]:
candidate["weights"].update(patch["weights"])
candidate["params"].update(patch["params"])
_cross_validate(candidate)
if constraints is not None:
validate_configuration_constraints(candidate, constraints)
return patch
def merge_proposal(previous: Any, proposal: Any) -> dict[str, dict[str, float]]:
def merge_proposal(
previous: Any, proposal: Any, constraints: Any | None = None
) -> dict[str, dict[str, float]]:
current = validate_configuration(previous)
patch = validate_proposal(proposal, current)
patch = validate_proposal(proposal, current, constraints)
merged = deepcopy(current)
merged["weights"].update(patch["weights"])
merged["params"].update(patch["params"])
+170 -2
View File
@@ -12,7 +12,11 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
class StorageConflict(RuntimeError):
"""Optimistic-concurrency revision mismatch."""
def now_iso() -> str:
@@ -135,11 +139,29 @@ class TuningStorage:
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL,
trial_id TEXT NOT NULL, reward_config_json TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_controls(
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
run_policy TEXT NOT NULL DEFAULT 'continuous'
CHECK(run_policy IN ('continuous','step')),
dispatch_tokens INTEGER NOT NULL DEFAULT 0 CHECK(dispatch_tokens >= 0),
active_base_trial_id TEXT,
revision INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_constraints(
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
path TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('range','fixed')),
min_value REAL, max_value REAL, fixed_value REAL,
updated_at TEXT NOT NULL,
PRIMARY KEY(session_id, path)
);
CREATE INDEX IF NOT EXISTS idx_trials_session ON trials(session_id, number, rung);
CREATE INDEX IF NOT EXISTS idx_proposals_session ON proposals(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_metrics_trial_tag ON metric_points(trial_id, tag, step);
"""
)
connection.execute(
"INSERT OR IGNORE INTO session_controls(session_id) SELECT id FROM sessions"
)
connection.execute(
"INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", (SCHEMA_VERSION,)
)
@@ -170,6 +192,7 @@ class TuningStorage:
"VALUES (?, 'queued', ?, ?, ?, ?, ?, '等待基线训练', ?)",
(session_id, mode, at, at, _json(config), _json(objective), int(fallback)),
)
connection.execute("INSERT INTO session_controls(session_id) VALUES (?)", (session_id,))
connection.execute(
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
"VALUES (?,?,?,?)",
@@ -212,6 +235,7 @@ class TuningStorage:
def update_session(self, session_id: str, **changes: Any) -> bool:
columns = {
"state": "state",
"mode": "mode",
"message": "message",
"current_trial_id": "current_trial_id",
"best_trial_id": "best_trial_id",
@@ -230,6 +254,143 @@ class TuningStorage:
)
return cursor.rowcount == 1
def get_control(self, session_id: str) -> dict:
row = (
self.connection()
.execute("SELECT * FROM session_controls WHERE session_id=?", (session_id,))
.fetchone()
)
if row is None:
raise KeyError(session_id)
constraint_rows = (
self.connection()
.execute(
"SELECT * FROM session_constraints WHERE session_id=? ORDER BY path", (session_id,)
)
.fetchall()
)
constraints = {}
for constraint in constraint_rows:
if constraint["kind"] == "fixed":
value = {"kind": "fixed", "value": constraint["fixed_value"]}
else:
value = {
"kind": "range",
"min": constraint["min_value"],
"max": constraint["max_value"],
}
constraints[constraint["path"]] = value
return {
"runPolicy": row["run_policy"],
"dispatchTokens": row["dispatch_tokens"],
"constraintsRevision": row["revision"],
"constraints": constraints,
"activeBaseTrialId": row["active_base_trial_id"],
}
def replace_constraints(
self, session_id: str, expected_revision: int, constraints: dict
) -> dict:
at = now_iso()
with self.transaction() as connection:
row = connection.execute(
"SELECT revision FROM session_controls WHERE session_id=?", (session_id,)
).fetchone()
if row is None:
raise KeyError(session_id)
if row["revision"] != expected_revision:
raise StorageConflict(
f"参数护栏 revision 已变化(当前 {row['revision']},请求 {expected_revision}"
)
connection.execute("DELETE FROM session_constraints WHERE session_id=?", (session_id,))
for path, constraint in constraints.items():
connection.execute(
"INSERT INTO session_constraints("
"session_id,path,kind,min_value,max_value,fixed_value,updated_at) "
"VALUES (?,?,?,?,?,?,?)",
(
session_id,
path,
constraint["kind"],
constraint.get("min"),
constraint.get("max"),
constraint.get("value"),
at,
),
)
connection.execute(
"UPDATE session_controls SET revision=revision+1 WHERE session_id=?",
(session_id,),
)
return self.get_control(session_id)
def grant_dispatch_token(self, session_id: str) -> dict:
"""Atomically grant the sole outstanding one-Trial token."""
with self.transaction() as connection:
cursor = connection.execute(
"UPDATE session_controls SET run_policy='step',dispatch_tokens=1 "
"WHERE session_id=? AND dispatch_tokens=0",
(session_id,),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM session_controls WHERE session_id=?", (session_id,)
).fetchone()
if exists is None:
raise KeyError(session_id)
raise StorageConflict("已有未消费的单步 Trial 令牌")
return self.get_control(session_id)
def use_dispatch_token(self, session_id: str) -> bool:
"""Atomically consume one step token; continuous mode never needs a token."""
with self.transaction() as connection:
row = connection.execute(
"SELECT run_policy,dispatch_tokens FROM session_controls WHERE session_id=?",
(session_id,),
).fetchone()
if row is None:
raise KeyError(session_id)
if row["run_policy"] == "continuous":
return True
if row["dispatch_tokens"] <= 0:
return False
connection.execute(
"UPDATE session_controls SET dispatch_tokens=dispatch_tokens-1 WHERE session_id=?",
(session_id,),
)
return True
def set_run_policy(self, session_id: str, policy: str) -> dict:
if policy not in {"continuous", "step"}:
raise ValueError(policy)
cursor = self.connection().execute(
"UPDATE session_controls SET run_policy=?,"
"dispatch_tokens=CASE WHEN ?='continuous' THEN 0 ELSE dispatch_tokens END "
"WHERE session_id=?",
(policy, policy, session_id),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def reset_step_gate(self, session_id: str) -> dict:
cursor = self.connection().execute(
"UPDATE session_controls SET run_policy='step',dispatch_tokens=0 WHERE session_id=?",
(session_id,),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def set_active_base(self, session_id: str, trial_id: str | None) -> dict:
cursor = self.connection().execute(
"UPDATE session_controls SET active_base_trial_id=? WHERE session_id=?",
(trial_id, session_id),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def create_trial(
self,
session_id: str,
@@ -424,13 +585,20 @@ class TuningStorage:
)
def metrics(
self, trial_id: str, tags: list[str] | None = None, max_points: int = 1000
self,
trial_id: str,
tags: list[str] | None = None,
max_points: int = 1000,
after_step: int | None = None,
) -> list[dict]:
parameters: list[Any] = [trial_id]
clause = "trial_id=?"
if tags:
clause += f" AND tag IN ({','.join('?' for _ in tags)})"
parameters.extend(tags)
if after_step is not None:
clause += " AND step>?"
parameters.append(after_step)
rows = (
self.connection()
.execute(
+1 -1
View File
@@ -121,7 +121,7 @@ npm run training-server -- \
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。普通训练还可以选择自调参产生的命名 reward preset,而不会改写仓库默认配置。
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面提供自动/逐轮审批模式、目标权重与预算配置、TensorBoard scalar 筛选/平滑/缩放、trial/rung 排行、固定评估指标、Agent 决策时间线、参数 patch 修改审批、暂停/恢复/停止、最佳 preset JSON 和 ONNX 导出。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面采用 Cyber-Industrial 三栏控制台:左侧展示 Session/ASHA 晋级树和 Trial 对比选择,中间使用 uPlot 叠加多 Trial 增量收敛曲线(金线标记历史最优)及六维物理评分,右侧以可折叠因果时间线展示 rationale、expected impact、置信度和评估结果。工具栏支持自动/逐轮审批切换、一次一 Trial 的调度令牌、服务端参数范围/固定值护栏、回滚历史最优或复现任意安全 TrialReward Merge Patch 与回滚差异由按需加载的 Monaco Diff 审查。scalar 以 1 Hz 非重入方式增量轮询,进入固定容量环形缓冲并由 `requestAnimationFrame` 合批后调用 `uPlot.setData`,不会在每次轮询时重建图表。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
@@ -62,13 +62,14 @@ describe('LocalTrainingClient', () => {
);
vi.stubGlobal('fetch', fetchMock);
const client = new LocalTrainingClient('http://127.0.0.1:8765', 'deep-secret');
await client.tuningMetrics('a'.repeat(32), 'b'.repeat(32), ['Evaluation/score'], 500);
await client.tuningMetrics('a'.repeat(32), 'b'.repeat(32), ['Evaluation/score'], 500, 120);
await client.decideProposal('a'.repeat(32), 'c'.repeat(32), 'approve', {
feedback: 'ok',
patch: { weights: { pose: 1.1 }, params: {} },
});
expect(fetchMock.mock.calls[0][0]).toContain('/api/tuning/sessions/');
expect(fetchMock.mock.calls[0][0]).toContain('maxPoints=500');
expect(fetchMock.mock.calls[0][0]).toContain('afterStep=120');
expect(fetchMock.mock.calls[0][0]).not.toContain('deep-secret');
const approval = fetchMock.mock.calls[1][1] as RequestInit;
expect(approval.method).toBe('POST');
@@ -119,11 +120,25 @@ describe('LocalTrainingClient', () => {
});
await client.tuningSession('a'.repeat(32));
await client.tuningAction('a'.repeat(32), 'pause');
await client.setTuningMode('a'.repeat(32), 'approval');
await client.setTuningConstraints('a'.repeat(32), 2, {
'weights.pose': { kind: 'range', min: 0.5, max: 1.5 },
});
await client.stepTuning('a'.repeat(32));
await client.rollbackTuning('a'.repeat(32), 'b'.repeat(32), true);
await client.cancelTuning('a'.repeat(32));
await client.presets();
const policy = await client.downloadBestPolicy('a'.repeat(32));
expect(policy.size).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(9);
expect(fetchMock).toHaveBeenCalledTimes(13);
const modeRequest = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/mode'))?.[1] as
RequestInit | undefined;
expect(JSON.parse(String(modeRequest?.body))).toEqual({ mode: 'approval' });
const constraintsRequest = fetchMock.mock.calls.find(([url]) =>
String(url).endsWith('/constraints'),
)?.[1] as RequestInit | undefined;
expect(constraintsRequest?.method).toBe('PUT');
expect(JSON.parse(String(constraintsRequest?.body))).toMatchObject({ revision: 2 });
});
it('拒绝非 HTTP 地址和空访问令牌', () => {
@@ -1,4 +1,5 @@
import type {
ParameterConstraint,
RewardPreset,
TuningCapability,
TuningCreateRequest,
@@ -101,9 +102,41 @@ export class LocalTrainingClient {
method: 'POST',
});
}
setTuningMode(id: string, mode: TuningSession['mode']): Promise<TuningSession> {
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/mode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
}
cancelTuning(id: string): Promise<TuningSession> {
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
setTuningConstraints(
id: string,
revision: number,
constraints: Record<string, ParameterConstraint>,
): Promise<TuningSession> {
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/constraints`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision, constraints }),
});
}
stepTuning(id: string): Promise<TuningSession> {
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/step`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 1 }),
});
}
rollbackTuning(id: string, trialId: string, checkpoint = false): Promise<TuningSession> {
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trialId, checkpoint }),
});
}
decideProposal(
sessionId: string,
proposalId: string,
@@ -127,11 +160,16 @@ export class LocalTrainingClient {
trialId: string,
tags: string[] = [],
maxPoints = 1000,
afterStep?: number,
signal?: AbortSignal,
): Promise<TuningMetricsResponse> {
const query = new URLSearchParams({ maxPoints: String(maxPoints) });
if (tags.length) query.set('tags', tags.join(','));
if (afterStep !== undefined && Number.isFinite(afterStep))
query.set('afterStep', String(afterStep));
return this.json(
`/api/tuning/sessions/${encodeURIComponent(sessionId)}/trials/${encodeURIComponent(trialId)}/metrics?${query}`,
{ signal },
);
}
presets(): Promise<RewardPreset[]> {
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
vi.unstubAllGlobals();
@@ -101,4 +102,60 @@ describe('LocalTrainingPanel', () => {
expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
});
it('接收调参窗口传回的策略文件,无需主工作台重复持有令牌', async () => {
const onPolicyReady = vi.fn();
const reply = vi.spyOn(window, 'postMessage').mockImplementation(() => undefined);
render(<LocalTrainingPanel onPolicyReady={onPolicyReady} />);
const policy = new File([new Uint8Array([1, 2, 3])], 'best-policy.onnx', {
type: 'application/octet-stream',
});
window.dispatchEvent(
new MessageEvent('message', {
origin: window.location.origin,
source: window,
data: {
type: 'mujoco-tuning-import-policy',
sessionId: 'a'.repeat(32),
policy,
},
}),
);
await waitFor(() => expect(onPolicyReady).toHaveBeenCalledWith(policy));
expect(reply).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mujoco-tuning-import-policy-result',
ok: true,
}),
window.location.origin,
);
});
it('旧调参消息缺少主工作台令牌时显示错误而不是抛出未处理异常', async () => {
const reply = vi.spyOn(window, 'postMessage').mockImplementation(() => undefined);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
window.dispatchEvent(
new MessageEvent('message', {
origin: window.location.origin,
source: window,
data: {
type: 'mujoco-tuning-import-policy',
sessionId: 'a'.repeat(32),
},
}),
);
expect(await screen.findByRole('alert')).toHaveTextContent('请输入训练服务访问令牌');
expect(reply).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mujoco-tuning-import-policy-result',
ok: false,
error: '请输入训练服务访问令牌',
}),
window.location.origin,
);
});
});
@@ -129,18 +129,44 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
typeof event.data !== 'object'
)
return;
const data = event.data as { type?: string; sessionId?: string };
const data = event.data as { type?: string; sessionId?: string; policy?: unknown };
const source = event.source as Window;
if (data.type === 'mujoco-tuning-ready') {
(event.source as Window).postMessage(
{ type: 'mujoco-tuning-credentials', endpoint, token },
event.origin,
);
source.postMessage({ type: 'mujoco-tuning-credentials', endpoint, token }, event.origin);
}
if (data.type === 'mujoco-tuning-import-policy' && data.sessionId) {
void new LocalTrainingClient(endpoint, token)
.downloadBestPolicy(data.sessionId)
.then(onPolicyReady)
.catch((value: unknown) => setError(errorText(value)));
const reply = (ok: boolean, message?: string) => {
try {
source.postMessage(
{
type: 'mujoco-tuning-import-policy-result',
sessionId: data.sessionId,
ok,
error: message,
},
event.origin,
);
} catch {
/* 调参窗口可能已关闭;不影响主工作台继续导入 */
}
};
void (async () => {
try {
const policy =
data.policy === undefined
? await new LocalTrainingClient(endpoint, token).downloadBestPolicy(data.sessionId!)
: data.policy;
if (!(policy instanceof File) || !/\.onnx$/i.test(policy.name))
throw new Error('调参工作台返回的 ONNX 策略无效');
if (policy.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
onPolicyReady(policy);
reply(true);
} catch (value) {
const message = errorText(value);
setError(message);
reply(false, message);
}
})();
}
};
window.addEventListener('message', receive);
+21 -1
View File
@@ -43,6 +43,7 @@ export interface TrainingJob {
}
export type TuningMode = 'automatic' | 'approval';
export type TuningRunPolicy = 'continuous' | 'step';
export type TuningSessionState =
| 'queued'
| 'running'
@@ -94,11 +95,14 @@ export interface TuningCreateRequest {
fallbackEnabled: boolean;
}
export type TuningTrialState =
'queued' | 'training' | 'evaluating' | 'completed' | 'interrupted' | 'failed' | 'cancelled';
export interface TuningTrial {
id: string;
sessionId: string;
number: number;
state: string;
state: TuningTrialState;
rung: number;
targetIterations: number;
rewardConfig: RewardConfiguration;
@@ -138,6 +142,18 @@ export interface TuningAuditEvent {
createdAt: string;
}
export type ParameterConstraint =
{ kind: 'range'; min: number; max: number } | { kind: 'fixed'; value: number };
export interface TuningControlState {
runPolicy: TuningRunPolicy;
dispatchTokens: number;
constraintsRevision: number;
constraints: Record<string, ParameterConstraint>;
activeBaseTrialId?: string;
effectiveAfterCurrent: boolean;
}
export interface TuningSession {
id: string;
state: TuningSessionState;
@@ -154,6 +170,8 @@ export interface TuningSession {
trials: TuningTrial[];
proposals: TuningProposal[];
audit: TuningAuditEvent[];
/** 旧服务响应可能缺失;前端会回退到 continuous + 空约束。 */
control?: TuningControlState;
}
export interface ScalarPoint {
@@ -170,6 +188,8 @@ export interface ScalarSeries {
export interface TuningMetricsResponse {
trialId: string;
series: ScalarSeries[];
/** 本响应中最大的 step;用于下一次增量请求。 */
nextStep?: number;
}
export interface RewardPreset {
@@ -0,0 +1,141 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TuningSession } from '../training/types';
import { AgentDecisionTimeline } from './AgentDecisionTimeline';
import { resetTuningStore, useTuningStore } from './tuningStore';
const objectives = {
velocity_tracking: 0.35,
action_smoothness: 0.2,
posture_stability: 0.15,
fall_avoidance: 0.15,
foot_slip: 0.1,
energy: 0.05,
};
function fixture(): TuningSession {
const baseConfig = { weights: { pose: 1 }, params: {} };
const resultConfig = { weights: { pose: 1.1 }, params: {} };
return {
id: 'a'.repeat(32),
state: 'paused',
mode: 'approval',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:02:00Z',
config: {
taskId: 'Unitree-Go2-Flat',
mode: 'approval',
runName: 'timeline',
numEnvs: 16,
seed: 42,
gpuIds: [0],
trialCount: 2,
initialIterations: 300,
middleIterations: 900,
finalIterations: 2000,
evalNumEnvs: 8,
evalSteps: 10,
objectiveWeights: objectives,
fallbackEnabled: false,
rungs: [300, 900, 2000],
promote: [2, 2, 2],
},
objectiveWeights: objectives,
message: 'paused',
bestTrialId: '2'.repeat(32),
consecutiveNoImprove: 0,
fallbackEnabled: false,
trials: [
{
id: '1'.repeat(32),
sessionId: 'a'.repeat(32),
number: 0,
state: 'completed',
rung: 0,
targetIterations: 300,
rewardConfig: baseConfig,
score: 0,
eligible: true,
createdAt: '2026-01-01T00:00:00Z',
message: 'done',
},
{
id: '2'.repeat(32),
sessionId: 'a'.repeat(32),
number: 1,
state: 'completed',
rung: 0,
targetIterations: 300,
rewardConfig: resultConfig,
proposalId: '3'.repeat(32),
score: 0.12,
eligible: true,
evaluation: {
metrics: { linear_velocity_rmse: 0.2 },
score: { score: 0.12, eligible: true, components: { velocity_tracking: 0.2 } },
},
createdAt: '2026-01-01T00:01:00Z',
message: 'done',
},
],
proposals: [
{
id: '3'.repeat(32),
sessionId: 'a'.repeat(32),
baseTrialId: '1'.repeat(32),
state: 'approved',
source: 'agent',
patch: { weights: { pose: 1.1 }, params: {} },
rationale: '提高姿态奖励以降低躯干倾角。',
expectedImpact: { posture_stability: '姿态误差预计下降 8%' },
confidence: 0.82,
createdAt: '2026-01-01T00:00:30Z',
},
],
audit: [],
control: {
runPolicy: 'step',
dispatchTokens: 0,
constraintsRevision: 0,
constraints: {},
effectiveAfterCurrent: false,
},
};
}
beforeEach(() => {
resetTuningStore();
vi.unstubAllGlobals();
useTuningStore.getState().setConnection('http://127.0.0.1:8765', 'secret');
useTuningStore.getState().applySessionSnapshot(fixture());
});
describe('AgentDecisionTimeline', () => {
it('展示 rationale、预期影响、置信度与 Proposal→Trial 因果结果', () => {
render(<AgentDecisionTimeline />);
expect(screen.getByText('提高姿态奖励以降低躯干倾角。')).toBeInTheDocument();
expect(screen.getByText('姿态误差预计下降 8%')).toBeInTheDocument();
expect(screen.getByText('82%')).toBeInTheDocument();
expect(screen.getByText('+0.1200')).toBeInTheDocument();
expect(screen.getByText('→ 1.1000')).toBeInTheDocument();
});
it('在安全边界一键把关联 Trial 设为复现基准', async () => {
const response = fixture();
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(response), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
render(<AgentDecisionTimeline />);
fireEvent.click(screen.getByRole('button', { name: '一键复现该轮' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/rollback$/);
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
trialId: '2'.repeat(32),
checkpoint: false,
});
});
});
@@ -0,0 +1,478 @@
import { lazy, Suspense, useMemo, useState } from 'react';
import {
Bot,
BrainCircuit,
Check,
ChevronDown,
ChevronRight,
CircleDot,
CopyCheck,
GitCompareArrows,
RotateCcw,
Sparkles,
Target,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, Dialog } from '../components/ui';
import type {
RewardConfiguration,
TuningProposal,
TuningSessionState,
TuningTrial,
} from '../training/types';
import {
formatMetric,
mergeRewardPatch,
OBJECTIVE_META,
PARAMETER_BY_PATH,
rewardConfigurationDiff,
} from './domain';
import { useTuningStore } from './tuningStore';
const RewardConfigDiffEditor = lazy(() =>
import('./RewardConfigDiffEditor').then((module) => ({ default: module.RewardConfigDiffEditor })),
);
interface DecisionView {
proposal: TuningProposal;
base?: TuningTrial;
result?: TuningTrial;
}
function trialForProposal(
trials: readonly TuningTrial[],
proposalId: string,
): TuningTrial | undefined {
return trials
.filter((trial) => trial.proposalId === proposalId)
.sort((left, right) => right.rung - left.rung)[0];
}
function parseEditedConfiguration(
text: string,
base: RewardConfiguration,
): TuningProposal['patch'] {
const value = JSON.parse(text) as Partial<RewardConfiguration>;
if (!value || typeof value !== 'object' || !value.weights || !value.params)
throw new Error('编辑结果必须是包含 weights 与 params 的完整 Reward Configuration');
const patch: TuningProposal['patch'] = { weights: {}, params: {} };
for (const section of ['weights', 'params'] as const) {
const editedSection = value[section]!;
if (Object.keys(editedSection).some((key) => !(key in base[section])))
throw new Error(`${section} 包含服务端白名单之外的参数`);
if (Object.keys(base[section]).some((key) => !(key in editedSection)))
throw new Error(`${section} 不能删除参数`);
for (const [key, previous] of Object.entries(base[section])) {
const next = editedSection[key];
if (typeof next !== 'number' || !Number.isFinite(next))
throw new Error(`${section}.${key} 必须是有限数值`);
if (next !== previous) patch[section][key] = next;
}
}
const count = Object.keys(patch.weights).length + Object.keys(patch.params).length;
if (!count) throw new Error('修改结果与基准完全相同');
if (count > 4) throw new Error('每轮最多修改 4 个标量');
return patch;
}
function ProposalReviewDialog({
view,
close,
}: {
view: DecisionView & { base: TuningTrial };
close(): void;
}) {
const baseConfig = view.base.rewardConfig;
const candidate = mergeRewardPatch(baseConfig, view.proposal.patch);
const [edited, setEdited] = useState(() => JSON.stringify(candidate, null, 2));
const [feedback, setFeedback] = useState('');
const [problem, setProblem] = useState<string>();
const busy = useTuningStore((state) =>
state.busyOperations.includes(`proposal:${view.proposal.id}`),
);
const decide = async (action: 'approve' | 'reject') => {
try {
setProblem(undefined);
const patch = action === 'approve' ? parseEditedConfiguration(edited, baseConfig) : undefined;
await useTuningStore.getState().decideProposal(view.proposal.id, action, {
feedback: feedback.trim() || undefined,
patch,
});
if (!useTuningStore.getState().error) close();
} catch (value) {
setProblem(value instanceof Error ? value.message : String(value));
}
};
return (
<Dialog
open
onClose={close}
title={`Reward Merge Patch 审查 · Proposal ${view.proposal.id.slice(0, 8)}`}
className="!max-w-6xl"
footer={
<div className="flex flex-wrap items-end justify-between gap-3">
<label className="min-w-64 flex-1 text-[9px] text-text-tertiary">
<input
aria-label="Proposal 审批反馈"
className="field mt-1 h-8 w-full px-2 text-xs"
value={feedback}
onChange={(event) => setFeedback(event.target.value)}
/>
</label>
<div className="flex gap-2">
<Button
variant="danger"
icon={<X className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void decide('reject')}
>
</Button>
<Button
variant="primary"
icon={<Check className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void decide('approve')}
>
Patch
</Button>
</div>
</div>
}
>
<div className="mb-3 grid gap-2 md:grid-cols-[1fr_auto]">
<p className="rounded border border-border bg-app p-2 text-[10px] leading-4 text-text-secondary">
Monaco Merge
Patch
</p>
<Badge tone="accent"> {Math.round(view.proposal.confidence * 100)}%</Badge>
</div>
{problem && (
<p
role="alert"
className="mb-2 rounded border border-danger-border bg-danger-soft p-2 text-[10px] text-danger"
>
{problem}
</p>
)}
<div className="overflow-hidden rounded-lg border border-border bg-[#09111e]">
<Suspense
fallback={
<div className="grid h-[430px] place-items-center text-xs text-text-tertiary">
Monaco JSON Diff
</div>
}
>
<RewardConfigDiffEditor
original={baseConfig}
modified={candidate}
height={430}
readOnly={false}
onModifiedChange={setEdited}
/>
</Suspense>
</div>
</Dialog>
);
}
function PatchRows({ view }: { view: DecisionView }) {
if (!view.base)
return (
<pre className="overflow-auto rounded bg-input p-2 text-[9px] text-text-secondary">
{JSON.stringify(view.proposal.patch, null, 2)}
</pre>
);
const candidate = mergeRewardPatch(view.base.rewardConfig, view.proposal.patch);
const changes = rewardConfigurationDiff(view.base.rewardConfig, candidate);
return (
<div className="space-y-1">
{changes.map((change) => {
const definition = PARAMETER_BY_PATH.get(change.path);
return (
<div
key={change.path}
className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 rounded border border-border bg-input px-2 py-1.5"
>
<div className="min-w-0">
<p className="truncate text-[9px] font-medium text-text-secondary">
{definition?.label ?? change.path}
</p>
<code className="block truncate text-[8px] text-text-tertiary">{change.path}</code>
</div>
<span className="font-mono text-[9px] text-text-tertiary">
{formatMetric(change.before)}
</span>
<span className="rounded bg-accent/10 px-1.5 py-0.5 font-mono text-[9px] text-accent">
{formatMetric(change.after)}
</span>
</div>
);
})}
</div>
);
}
function ExpectedImpact({ proposal }: { proposal: TuningProposal }) {
const values = Object.entries(proposal.expectedImpact ?? {});
if (!values.length)
return <p className="text-[9px] text-text-tertiary">Agent </p>;
return (
<div className="grid gap-1 sm:grid-cols-2">
{values.map(([key, value]) => {
const label = OBJECTIVE_META.find((item) => item.key === key)?.label ?? key;
return (
<div key={key} className="rounded border border-border bg-app px-2 py-1.5">
<p className="text-[8px] uppercase tracking-wider text-text-tertiary">{label}</p>
<p className="mt-0.5 text-[9px] leading-4 text-text-secondary">{String(value)}</p>
</div>
);
})}
</div>
);
}
function ResultSummary({ result, base }: { result?: TuningTrial; base?: TuningTrial }) {
if (!result)
return <p className="rounded bg-app p-2 text-[9px] text-text-tertiary"> Trial</p>;
const delta =
result.score !== undefined &&
result.score !== null &&
base?.score !== undefined &&
base.score !== null
? result.score - base.score
: undefined;
return (
<div className="grid grid-cols-3 gap-1.5">
<div className="rounded border border-border bg-app p-2">
<p className="text-[8px] text-text-tertiary"></p>
<p className="mt-1 text-[9px] text-text-secondary">
T{result.number} · R{result.rung}
</p>
</div>
<div className="rounded border border-border bg-app p-2">
<p className="text-[8px] text-text-tertiary">Score</p>
<p className="mt-1 font-mono text-[9px] text-text-secondary">
{formatMetric(result.score, 5)}
</p>
</div>
<div
className={`rounded border p-2 ${result.eligible ? 'border-success-border bg-success-soft' : 'border-danger-border bg-danger-soft'}`}
>
<p className="text-[8px] text-text-tertiary"></p>
<p
className={`mt-1 font-mono text-[9px] ${result.eligible ? 'text-success' : 'text-danger'}`}
>
{delta === undefined
? result.eligible
? '安全门通过'
: '安全门拒绝'
: `${delta >= 0 ? '+' : ''}${delta.toFixed(4)}`}
</p>
</div>
</div>
);
}
function canReplay(state: TuningSessionState | undefined): boolean {
return state === 'paused' || state === 'awaiting_approval';
}
export function AgentDecisionTimeline() {
const [proposalIds, trialIds, entitiesRevision, sessionState] = useTuningStore(
useShallow(
(state) =>
[state.proposalIds, state.trialIds, state.entitiesRevision, state.sessionState] as const,
),
);
const views = useMemo(() => {
void entitiesRevision;
const state = useTuningStore.getState();
const trials = trialIds.map((id) => state.trialsById[id]).filter(Boolean);
return proposalIds
.map((id) => state.proposalsById[id])
.filter(Boolean)
.map((proposal) => ({
proposal,
base: proposal.baseTrialId ? state.trialsById[proposal.baseTrialId] : undefined,
result: trialForProposal(trials, proposal.id),
}))
.reverse();
}, [entitiesRevision, proposalIds, trialIds]);
const [expandedId, setExpandedId] = useState<string | null>();
const [reviewing, setReviewing] = useState<DecisionView>();
const effectiveExpandedId = expandedId === undefined ? views[0]?.proposal.id : expandedId;
return (
<>
<section className="flex h-full min-h-0 flex-col rounded-xl border border-border bg-surface">
<header className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2.5">
<div>
<h2 className="flex items-center gap-2 text-xs font-semibold">
<BrainCircuit className="h-4 w-4 text-accent" /> Decision Timeline
</h2>
<p className="mt-0.5 text-[9px] text-text-tertiary">Proposal </p>
</div>
<Badge>{views.length} </Badge>
</header>
<div className="min-h-56 flex-1 overflow-auto p-3 panel-scroll">
{views.length === 0 ? (
<div className="grid h-40 place-items-center text-center text-[10px] text-text-tertiary">
<div>
<Bot className="mx-auto mb-2 h-6 w-6 opacity-50" />
线Agent
</div>
</div>
) : (
<ol className="space-y-0">
{views.map((view, index) => {
const open = effectiveExpandedId === view.proposal.id;
const pending = view.proposal.state === 'pending';
const contentId = `proposal-${view.proposal.id}`;
return (
<li
key={view.proposal.id}
className="relative grid grid-cols-[18px_minmax(0,1fr)] gap-2 pb-3"
>
<div className="relative flex justify-center">
{index < views.length - 1 && (
<span className="absolute bottom-[-12px] top-3 w-px bg-border" />
)}
<span
className={`relative z-10 mt-1 grid h-4 w-4 place-items-center rounded-full border ${pending ? 'border-warning-border bg-warning-soft text-warning' : view.result?.eligible ? 'border-success-border bg-success-soft text-success' : 'border-border bg-app text-text-tertiary'}`}
>
<CircleDot className="h-2.5 w-2.5" />
</span>
</div>
<article
className={`overflow-hidden rounded-lg border ${pending ? 'border-warning-border bg-warning-soft/30' : 'border-border bg-app/60'}`}
>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
className="flex w-full items-center gap-2 px-2.5 py-2 text-left hover:bg-element-hover/50"
onClick={() => setExpandedId(open ? null : view.proposal.id)}
>
{open ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium">
Proposal {view.proposal.id.slice(0, 8)}
</p>
<p className="mt-0.5 text-[8px] text-text-tertiary">
{new Date(view.proposal.createdAt).toLocaleString()} ·{' '}
{view.proposal.source}
</p>
</div>
<Badge
tone={
pending
? 'warning'
: view.proposal.state === 'approved'
? 'success'
: 'neutral'
}
>
{pending
? '待审批'
: view.proposal.state === 'approved'
? '已批准'
: '已拒绝'}
</Badge>
</button>
{open && (
<div
id={contentId}
className="space-y-3 border-t border-border px-2.5 py-2.5"
>
<div>
<p className="mb-1 flex items-center gap-1 text-[8px] uppercase tracking-wider text-text-tertiary">
<Sparkles className="h-3 w-3" /> Agent rationale
</p>
<p className="text-[10px] leading-[1.55] text-text-secondary">
{view.proposal.rationale}
</p>
</div>
<div>
<p className="mb-1 flex items-center gap-1 text-[8px] uppercase tracking-wider text-text-tertiary">
<GitCompareArrows className="h-3 w-3" />
</p>
<PatchRows view={view} />
</div>
<div>
<p className="mb-1 flex items-center gap-1 text-[8px] uppercase tracking-wider text-text-tertiary">
<Target className="h-3 w-3" /> Expected impact
</p>
<ExpectedImpact proposal={view.proposal} />
</div>
<div>
<div className="mb-1 flex justify-between text-[8px] text-text-tertiary">
<span></span>
<span className="font-mono">
{Math.round(view.proposal.confidence * 100)}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-element-active">
<div
className="h-full rounded-full bg-accent"
style={{
width: `${Math.max(0, Math.min(100, view.proposal.confidence * 100))}%`,
}}
/>
</div>
</div>
<ResultSummary result={view.result} base={view.base} />
<div className="flex flex-wrap gap-1.5">
{pending && (
<Button
variant="primary"
icon={<CopyCheck className="h-3.5 w-3.5" />}
onClick={() => setReviewing(view)}
>
Monaco Diff
</Button>
)}
{view.result?.state === 'completed' && (
<Button
icon={<RotateCcw className="h-3.5 w-3.5" />}
disabled={!canReplay(sessionState)}
title={
canReplay(sessionState)
? '将该轮配置设为后续调度基准'
: '请先暂停调度'
}
onClick={() =>
void useTuningStore.getState().rollbackToTrial(view.result!.id)
}
>
</Button>
)}
</div>
</div>
)}
</article>
</li>
);
})}
</ol>
)}
</div>
</section>
{reviewing?.base && (
<ProposalReviewDialog
key={reviewing.proposal.id}
view={reviewing as DecisionView & { base: TuningTrial }}
close={() => setReviewing(undefined)}
/>
)}
</>
);
}
@@ -0,0 +1,116 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MetricsComparisonBoard } from './MetricsComparisonBoard';
import { resetTuningStore, useTuningStore } from './tuningStore';
const plotSpies = vi.hoisted(() => ({
constructors: vi.fn(),
setData: vi.fn(),
destroy: vi.fn(),
}));
vi.mock('uplot', () => {
class FakeUPlot {
data: unknown[];
over = document.createElement('div');
scales = { x: { min: 0, max: 10 }, y: { min: 0, max: 10 } };
series: Array<{ scale?: string }>;
ctx = {
save: () => undefined,
restore: () => undefined,
beginPath: () => undefined,
arc: () => undefined,
fill: () => undefined,
stroke: () => undefined,
};
constructor(options: { series: Array<{ scale?: string }> }, data: unknown[]) {
this.data = data;
this.series = options.series;
plotSpies.constructors();
}
setData(data: unknown[]): void {
this.data = data;
plotSpies.setData();
}
setSize(): void {}
setScale(): void {}
valToPos(value: number): number {
return value;
}
destroy(): void {
plotSpies.destroy();
}
}
return { default: FakeUPlot };
});
beforeEach(() => {
resetTuningStore();
plotSpies.constructors.mockClear();
plotSpies.setData.mockClear();
plotSpies.destroy.mockClear();
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', () => undefined);
const first = {
id: 'a',
sessionId: 's',
number: 0,
state: 'completed' as const,
rung: 0,
targetIterations: 300,
rewardConfig: { weights: {}, params: {} },
score: 0,
eligible: true,
createdAt: '',
message: '',
};
const best = { ...first, id: 'b', number: 1, score: 0.2 };
useTuningStore.setState({
visibleTrialIds: ['a', 'b'],
bestTrialId: 'b',
selectedTrialId: 'a',
trialsById: { a: first, b: best },
trialIds: ['a', 'b'],
metricTag: 'Train/reward',
knownMetricTags: ['Train/reward'],
});
useTuningStore.getState().enqueueMetricBatch({
trialId: 'a',
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 1 }] }],
});
useTuningStore.getState().enqueueMetricBatch({
trialId: 'b',
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 2 }] }],
});
useTuningStore.getState().flushMetricBatches();
});
describe('MetricsComparisonBoard', () => {
it('scalar 更新只调用 setData,不重建 uPlot,并在卸载时销毁', async () => {
const view = render(<MetricsComparisonBoard />);
expect(screen.getByRole('img', { name: /2 个 Trial/ })).toBeInTheDocument();
await waitFor(() => expect(plotSpies.constructors).toHaveBeenCalledTimes(1));
const previousUpdates = plotSpies.setData.mock.calls.length;
act(() => {
useTuningStore.getState().enqueueMetricBatch({
trialId: 'a',
series: [{ tag: 'Train/reward', points: [{ step: 2, wallTime: 2, value: 3 }] }],
});
useTuningStore.getState().flushMetricBatches();
});
await waitFor(() =>
expect(plotSpies.setData.mock.calls.length).toBeGreaterThan(previousUpdates),
);
expect(plotSpies.constructors).toHaveBeenCalledTimes(1);
view.unmount();
expect(plotSpies.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,495 @@
import { useEffect, useMemo, useRef } from 'react';
import { Activity, Crosshair, RotateCcw, TrendingUp, Trophy, ZoomIn, ZoomOut } from 'lucide-react';
import uPlot from 'uplot';
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, Select } from '../components/ui';
import type { ScalarPoint, TuningTrial } from '../training/types';
import { formatMetric, OBJECTIVE_META } from './domain';
import { useTuningStore } from './tuningStore';
const LINE_COLORS = ['#60a5fa', '#a78bfa', '#f472b6', '#2dd4bf', '#fb7185', '#94a3b8'];
const BEST_COLOR = '#f3bd5c';
const SELECTED_COLOR = '#38d39f';
const PREFERRED_TAGS = [
'Train/mean_reward',
'Evaluation/linear_velocity_rmse',
'Episode_Reward/track_linear_velocity',
];
interface TrialCurve {
trialId: string;
label: string;
points: ScalarPoint[];
color: string;
best: boolean;
selected: boolean;
}
function smooth(values: (number | null)[], factor: number): (number | null)[] {
if (factor <= 0) return values;
let previous: number | undefined;
return values.map((value) => {
if (value === null) return null;
previous = previous === undefined ? value : factor * previous + (1 - factor) * value;
return previous;
});
}
function alignCurves(curves: readonly TrialCurve[], smoothing: number): uPlot.AlignedData {
const steps = Array.from(
new Set(curves.flatMap((curve) => curve.points.map((point) => point.step))),
).sort((left, right) => left - right);
const data: uPlot.AlignedData = [steps];
for (const curve of curves) {
const byStep = new Map(curve.points.map((point) => [point.step, point.value]));
data.push(
smooth(
steps.map((step) => byStep.get(step) ?? null),
smoothing,
),
);
}
return data;
}
function bestNodePlugin(bestSeriesIndex: number): uPlot.Plugin {
return {
hooks: {
draw: [
(chart) => {
if (bestSeriesIndex < 1) return;
const xValues = chart.data[0];
const yValues = chart.data[bestSeriesIndex];
let index = yValues.length - 1;
while (index >= 0 && yValues[index] === null) index -= 1;
if (index < 0) return;
const xValue = xValues[index];
const yValue = yValues[index];
if (xValue === undefined || yValue === null || yValue === undefined) return;
const x = chart.valToPos(xValue, 'x', true);
const y = chart.valToPos(yValue, chart.series[bestSeriesIndex].scale ?? 'y', true);
const context = chart.ctx;
context.save();
context.shadowColor = BEST_COLOR;
context.shadowBlur = 10 * devicePixelRatio;
context.fillStyle = BEST_COLOR;
context.strokeStyle = '#09111e';
context.lineWidth = 2 * devicePixelRatio;
context.beginPath();
context.arc(x, y, 5 * devicePixelRatio, 0, Math.PI * 2);
context.fill();
context.stroke();
context.restore();
},
],
},
};
}
function MultiTrialPlot({
curves,
smoothing,
tag,
}: {
curves: TrialCurve[];
smoothing: number;
tag: string;
}) {
const host = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
const data = useMemo(() => alignCurves(curves, smoothing), [curves, smoothing]);
const dataRef = useRef(data);
const manualZoom = useRef(false);
const schema = curves
.map((curve) => `${curve.trialId}:${curve.label}:${curve.color}:${curve.best}`)
.join('|');
useEffect(() => {
const element = host.current;
if (!element || curves.length === 0) return;
let resizeFrame = 0;
const width = Math.max(320, Math.floor(element.getBoundingClientRect().width));
const bestIndex = curves.findIndex((curve) => curve.best);
const chart = new uPlot(
{
width,
height: 338,
padding: [12, 12, 0, 0],
legend: { show: true, live: true },
cursor: {
drag: { x: true, y: false, setScale: true, dist: 8 },
focus: { prox: 24 },
points: { size: 7 },
},
focus: { alpha: 0.22 },
scales: { x: { time: false } },
axes: [
{
label: 'Iteration',
stroke: '#8fa0b5',
grid: { stroke: '#213044', width: 1 },
ticks: { stroke: '#40566f' },
},
{
stroke: '#8fa0b5',
grid: { stroke: '#213044', width: 1 },
ticks: { stroke: '#40566f' },
size: 58,
},
],
series: [
{ label: 'Iteration' },
...curves.map((curve) => ({
label: curve.label,
stroke: curve.color,
width: curve.best ? 3 : curve.selected ? 2.5 : 1.5,
alpha: curve.best || curve.selected ? 1 : 0.6,
spanGaps: true,
points: { show: false },
})),
],
plugins: [bestNodePlugin(bestIndex < 0 ? -1 : bestIndex + 1)],
},
// schema(曲线数量)变化时必须使用本次 render 的数据,不能使用要到下一
// 个 effect 才更新的 ref,否则 uPlot 会短暂收到错误的列数。
data,
element,
);
chartRef.current = chart;
const markManualZoom = () => {
manualZoom.current = true;
};
chart.over.addEventListener('mousedown', markManualZoom);
const wheelZoom = (event: WheelEvent) => {
if (!event.deltaY) return;
event.preventDefault();
manualZoom.current = true;
const bounds = chart.over.getBoundingClientRect();
if (!bounds.width) return;
const scale = chart.scales.x;
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') return;
const ratio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
const anchor = scale.min + (scale.max - scale.min) * ratio;
const factor = Math.min(1.8, Math.max(0.55, Math.exp(event.deltaY * 0.0015)));
chart.setScale('x', {
min: anchor - (anchor - scale.min) * factor,
max: anchor + (scale.max - anchor) * factor,
});
};
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
let lastWidth = width;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
const nextWidth = Math.max(320, Math.floor(element.getBoundingClientRect().width));
if (nextWidth !== lastWidth) {
lastWidth = nextWidth;
chart.setSize({ width: nextWidth, height: 338 });
}
});
});
observer.observe(element);
return () => {
observer.disconnect();
cancelAnimationFrame(resizeFrame);
chart.over.removeEventListener('mousedown', markManualZoom);
chart.over.removeEventListener('wheel', wheelZoom);
chartRef.current = null;
chart.destroy();
};
// schema 仅在曲线身份/样式变化时改变;数据更新由下方 setData effect 处理。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schema]);
useEffect(() => {
dataRef.current = data;
const chart = chartRef.current;
if (!chart) return;
const frame = requestAnimationFrame(() => chart.setData(data, !manualZoom.current));
return () => cancelAnimationFrame(frame);
}, [data]);
const zoom = (factor: number) => {
const chart = chartRef.current;
if (!chart) return;
manualZoom.current = true;
const scale = chart.scales.x;
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') return;
const center = (scale.min + scale.max) / 2;
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
chart.setScale('x', { min: center - radius, max: center + radius });
};
const reset = () => {
const chart = chartRef.current;
if (!chart) return;
manualZoom.current = false;
chart.setData(dataRef.current, true);
};
return (
<section className="overflow-hidden rounded-xl border border-border bg-app/80 shadow-[inset_0_1px_0_rgb(255_255_255/0.025)]">
<header className="flex min-h-10 flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<p className="flex items-center gap-1.5 truncate text-xs font-semibold" title={tag}>
<TrendingUp className="h-3.5 w-3.5 text-accent" /> {tag}
</p>
<p className="mt-0.5 text-[9px] text-text-tertiary"> · · hover </p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
aria-label="收敛曲线放大"
className="rounded p-1.5 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(0.7)}
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="收敛曲线缩小"
className="rounded p-1.5 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(1.4)}
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="重置收敛曲线缩放"
className="rounded p-1.5 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={reset}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</header>
<div
ref={host}
role="img"
aria-label={`${curves.length} 个 Trial 的 ${tag} 收敛曲线,金色曲线为历史最优`}
className="min-w-0 overflow-hidden p-1"
/>
</section>
);
}
function ScoreBreakdown({ current, best }: { current?: TuningTrial; best?: TuningTrial }) {
const currentComponents = current?.evaluation?.score?.components ?? {};
const bestComponents = best?.evaluation?.score?.components ?? {};
return (
<section className="rounded-xl border border-border bg-surface p-3">
<header className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-xs font-semibold">Score Breakdown</h3>
<p className="mt-0.5 text-[9px] text-text-tertiary">线 · 线 0</p>
</div>
{best && (
<Badge tone="warning">
<Trophy className="h-3 w-3" /> T{best.number}
</Badge>
)}
</header>
<div className="grid gap-x-4 gap-y-2 lg:grid-cols-2">
{OBJECTIVE_META.map(({ key, label }) => {
const currentValue = currentComponents[key] ?? 0;
const bestValue = bestComponents[key] ?? 0;
const currentWidth = Math.min(50, Math.abs(currentValue) * 50);
const bestPosition = 50 + Math.max(-1, Math.min(1, bestValue)) * 50;
return (
<div key={key}>
<div className="mb-1 flex items-center justify-between text-[9px]">
<span className="text-text-secondary">{label}</span>
<span
className={currentValue < 0 ? 'font-mono text-danger' : 'font-mono text-accent'}
>
{currentValue >= 0 ? '+' : ''}
{formatMetric(currentValue, 3)}
</span>
</div>
<div className="relative h-2 overflow-visible rounded-full bg-element-active">
<span className="absolute inset-y-[-2px] left-1/2 w-px bg-border-strong" />
<span
className={`absolute top-0 h-2 rounded-full ${currentValue < 0 ? 'bg-danger' : 'bg-accent'}`}
style={
currentValue < 0
? { right: '50%', width: `${currentWidth}%` }
: { left: '50%', width: `${currentWidth}%` }
}
/>
<span
title={`最优 ${formatMetric(bestValue, 3)}`}
className="absolute top-[-3px] h-3.5 w-0.5 rounded bg-warning"
style={{ left: `${bestPosition}%` }}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
function MetricKpis({ current, best }: { current?: TuningTrial; best?: TuningTrial }) {
const currentMetrics = current?.evaluation?.metrics ?? {};
const bestMetrics = best?.evaluation?.metrics ?? {};
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
{OBJECTIVE_META.map(({ key, shortLabel, metric }) => {
const currentValue = currentMetrics[metric];
const bestValue = bestMetrics[metric];
const worse =
currentValue !== undefined && bestValue !== undefined && currentValue > bestValue;
return (
<div key={key} className="rounded-lg border border-border bg-surface px-2.5 py-2">
<p className="truncate text-[9px] uppercase tracking-wider text-text-tertiary">
{shortLabel}
</p>
<p className={`mt-1 font-mono text-sm ${worse ? 'text-warning' : 'text-text-primary'}`}>
{formatMetric(currentValue)}
</p>
<p className="mt-0.5 truncate font-mono text-[8px] text-text-tertiary">
best {formatMetric(bestValue)}
</p>
</div>
);
})}
</div>
);
}
export function MetricsComparisonBoard() {
const [visibleTrialIds, bestTrialId, selectedTrialId, metricTag, smoothing] = useTuningStore(
useShallow(
(state) =>
[
state.visibleTrialIds,
state.bestTrialId,
state.selectedTrialId,
state.metricTag,
state.smoothing,
] as const,
),
);
const [knownMetricTags, metricsRevision] = useTuningStore(
useShallow((state) => [state.knownMetricTags, state.metricsRevision] as const),
);
const current = useTuningStore((state) =>
selectedTrialId ? state.trialsById[selectedTrialId] : undefined,
);
const best = useTuningStore((state) => (bestTrialId ? state.trialsById[bestTrialId] : undefined));
const effectiveTag =
metricTag ||
PREFERRED_TAGS.find((tag) => knownMetricTags.includes(tag)) ||
knownMetricTags[0] ||
'';
useEffect(() => {
if (!metricTag && effectiveTag) useTuningStore.getState().setMetricTag(effectiveTag);
}, [effectiveTag, metricTag]);
const curves = useMemo(() => {
void metricsRevision;
if (!effectiveTag) return [];
const snapshots = useTuningStore.getState().readMetricSeries(visibleTrialIds, effectiveTag);
const ordered = [...snapshots].sort((left, right) => {
if (left.trialId === bestTrialId) return 1;
if (right.trialId === bestTrialId) return -1;
return visibleTrialIds.indexOf(left.trialId) - visibleTrialIds.indexOf(right.trialId);
});
return ordered.map((series, index) => {
const trial = useTuningStore.getState().trialsById[series.trialId];
const isBest = series.trialId === bestTrialId;
const isSelected = series.trialId === selectedTrialId;
return {
trialId: series.trialId,
label: trial
? `T${trial.number} · R${trial.rung}${isBest ? ' · BEST' : ''}`
: series.trialId.slice(0, 6),
points: series.points,
best: isBest,
selected: isSelected,
color: isBest
? BEST_COLOR
: isSelected
? SELECTED_COLOR
: LINE_COLORS[index % LINE_COLORS.length],
} satisfies TrialCurve;
});
}, [bestTrialId, effectiveTag, metricsRevision, selectedTrialId, visibleTrialIds]);
const ensureBestVisible = () => {
if (bestTrialId) useTuningStore.getState().setTrialVisible(bestTrialId, true);
};
return (
<div className="space-y-3">
<section className="rounded-xl border border-border bg-surface p-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold">
<Activity className="h-4 w-4 text-accent" /> Trial
</h2>
<p className="mt-1 text-[9px] text-text-tertiary">
scalar · 4096 · rAF
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-[9px] text-text-tertiary">
<span className="mb-1 block">Scalar tag</span>
<Select
aria-label="收敛指标"
className="w-64 max-w-[55vw]"
value={effectiveTag}
onChange={(event) => useTuningStore.getState().setMetricTag(event.target.value)}
>
{!effectiveTag && <option value=""> scalar</option>}
{knownMetricTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</Select>
</label>
<label className="w-28 text-[9px] text-text-tertiary">
<span className="mb-1 block"> {smoothing.toFixed(2)}</span>
<input
aria-label="曲线平滑"
className="control-slider"
type="range"
min="0"
max="0.95"
step="0.05"
value={smoothing}
onChange={(event) =>
useTuningStore.getState().setSmoothing(Number(event.target.value))
}
/>
</label>
{bestTrialId && !visibleTrialIds.includes(bestTrialId) && (
<Button icon={<Trophy className="h-3.5 w-3.5" />} onClick={ensureBestVisible}>
</Button>
)}
</div>
</div>
</section>
<MetricKpis current={current} best={best} />
{curves.length && effectiveTag ? (
<MultiTrialPlot curves={curves} smoothing={smoothing} tag={effectiveTag} />
) : (
<div className="grid h-[390px] place-items-center rounded-xl border border-dashed border-border bg-app/60 text-center text-xs text-text-tertiary">
<div>
<Crosshair className="mx-auto mb-2 h-6 w-6 opacity-50" />
Trial scalar
</div>
</div>
)}
<ScoreBreakdown current={current} best={best} />
</div>
);
}
@@ -0,0 +1,61 @@
import { loader, DiffEditor } from '@monaco-editor/react';
import * as monaco from 'monaco-editor/editor/editor.api';
import 'monaco-editor/languages/features/json/register';
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/language/json/json.worker?worker';
import type { RewardConfiguration } from '../training/types';
type MonacoGlobal = typeof globalThis & {
MonacoEnvironment?: { getWorker?: (_moduleId: string, label: string) => Worker };
};
(globalThis as MonacoGlobal).MonacoEnvironment = {
getWorker: (_moduleId, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()),
};
loader.config({ monaco });
export function RewardConfigDiffEditor({
original,
modified,
height = 360,
readOnly = true,
onModifiedChange,
}: {
original: RewardConfiguration;
modified: RewardConfiguration;
height?: number;
readOnly?: boolean;
onModifiedChange?: (value: string) => void;
}) {
return (
<DiffEditor
height={height}
language="json"
theme="vs-dark"
original={JSON.stringify(original, null, 2)}
modified={JSON.stringify(modified, null, 2)}
keepCurrentOriginalModel={false}
keepCurrentModifiedModel={false}
onMount={(editor) => {
const model = editor.getModifiedEditor().getModel();
if (model && onModifiedChange) {
onModifiedChange(model.getValue());
model.onDidChangeContent(() => onModifiedChange(model.getValue()));
}
}}
options={{
readOnly,
originalEditable: false,
renderSideBySide: true,
minimap: { enabled: false },
fontSize: 11,
lineNumbersMinChars: 3,
folding: true,
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
padding: { top: 8, bottom: 8 },
}}
/>
);
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { ScalarRingBuffer } from './ScalarRingBuffer';
const point = (step: number, value = step) => ({ step, wallTime: step / 10, value });
describe('ScalarRingBuffer', () => {
it('固定容量覆盖最旧点并保持按 step 排序', () => {
const buffer = new ScalarRingBuffer(3);
buffer.appendMany([point(2), point(1), point(3), point(4)]);
expect(buffer.size).toBe(3);
expect(buffer.snapshot().map((value) => value.step)).toEqual([1, 3, 4]);
expect(buffer.latestStep).toBe(4);
});
it('按 step 去重更新且忽略非有限数据', () => {
const buffer = new ScalarRingBuffer(4);
expect(buffer.append(point(1, 2))).toBe(true);
expect(buffer.append(point(1, 2))).toBe(false);
expect(buffer.append(point(1, 3))).toBe(true);
expect(buffer.append({ step: 2, wallTime: 0, value: Number.NaN })).toBe(false);
expect(buffer.snapshot()).toEqual([{ step: 1, wallTime: 0.1, value: 3 }]);
});
});
@@ -0,0 +1,96 @@
import type { ScalarPoint, ScalarSeries } from '../training/types';
/**
* step
*
* step step ->
* React store
*/
export class ScalarRingBuffer {
readonly capacity: number;
private readonly steps: Float64Array;
private readonly wallTimes: Float64Array;
private readonly values: Float64Array;
private readonly slotByStep = new Map<number, number>();
private head = 0;
private length = 0;
constructor(capacity = 4096) {
if (!Number.isInteger(capacity) || capacity < 2)
throw new Error('环形缓冲容量必须是大于 1 的整数');
this.capacity = capacity;
this.steps = new Float64Array(capacity);
this.wallTimes = new Float64Array(capacity);
this.values = new Float64Array(capacity);
}
get size(): number {
return this.length;
}
get latestStep(): number | undefined {
if (!this.length) return undefined;
let latest = Number.NEGATIVE_INFINITY;
for (const step of this.slotByStep.keys()) latest = Math.max(latest, step);
return Number.isFinite(latest) ? latest : undefined;
}
append(point: ScalarPoint): boolean {
if (![point.step, point.wallTime, point.value].every(Number.isFinite)) return false;
const existing = this.slotByStep.get(point.step);
if (existing !== undefined) {
if (this.wallTimes[existing] === point.wallTime && this.values[existing] === point.value)
return false;
this.wallTimes[existing] = point.wallTime;
this.values[existing] = point.value;
return true;
}
let slot: number;
if (this.length < this.capacity) {
slot = (this.head + this.length) % this.capacity;
this.length += 1;
} else {
slot = this.head;
this.slotByStep.delete(this.steps[slot]);
this.head = (this.head + 1) % this.capacity;
}
this.steps[slot] = point.step;
this.wallTimes[slot] = point.wallTime;
this.values[slot] = point.value;
this.slotByStep.set(point.step, slot);
return true;
}
appendMany(points: readonly ScalarPoint[]): boolean {
let changed = false;
for (const point of points) changed = this.append(point) || changed;
return changed;
}
snapshot(): ScalarPoint[] {
const points = Array.from(this.slotByStep, ([step, slot]) => ({
step,
wallTime: this.wallTimes[slot],
value: this.values[slot],
}));
points.sort((left, right) => left.step - right.step);
return points;
}
clear(): void {
this.slotByStep.clear();
this.head = 0;
this.length = 0;
}
}
export interface BufferedMetricSeries {
trialId: string;
tag: string;
buffer: ScalarRingBuffer;
}
export function snapshotSeries(series: BufferedMetricSeries): ScalarSeries {
return { tag: series.tag, points: series.buffer.snapshot() };
}
+6 -1
View File
@@ -35,13 +35,18 @@ describe('TuningApp', () => {
);
});
vi.stubGlobal('fetch', fetchMock);
render(<TuningApp />);
const { container } = render(<TuningApp />);
expect(container.firstElementChild).toHaveClass('h-full', 'overflow-y-auto');
fireEvent.change(screen.getByLabelText('访问令牌(仅当前标签页)'), {
target: { value: 'training-secret' },
});
fireEvent.click(screen.getByRole('button', { name: '连接/刷新' }));
expect(await screen.findByText(/deepseek-v4-flash/)).toBeInTheDocument();
expect(screen.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeInTheDocument();
const trialCount = screen.getByLabelText('调参次数(候选配置数)');
expect(trialCount).toHaveValue(12);
fireEvent.change(trialCount, { target: { value: '36' } });
expect(trialCount).toHaveValue(36);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
for (const call of fetchMock.mock.calls) {
expect(String(call[0])).not.toContain('training-secret');
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import { AgentDecisionTimeline } from './AgentDecisionTimeline';
import { MetricsComparisonBoard } from './MetricsComparisonBoard';
import { TuningControlToolbar } from './TuningControlToolbar';
import { TuningLeaderboard } from './TuningLeaderboard';
import { TuningSessionRail } from './TuningSessionRail';
import { useTuningPolling } from './useTuningPolling';
export function TuningConsole() {
useTuningPolling();
return (
<div className="flex min-h-0 flex-1 flex-col">
<TuningControlToolbar />
<div className="grid grid-cols-1 xl:min-h-0 xl:flex-1 xl:grid-cols-[286px_minmax(520px,1fr)_390px]">
<TuningSessionRail />
<main className="h-[920px] min-w-0 overflow-auto bg-app p-3 panel-scroll xl:h-auto">
<div className="mx-auto max-w-[1500px] space-y-3">
<MetricsComparisonBoard />
<TuningLeaderboard />
</div>
</main>
<aside className="h-[640px] min-h-0 overflow-auto border-t border-border bg-panel p-3 panel-scroll xl:h-auto xl:border-l xl:border-t-0">
<AgentDecisionTimeline />
</aside>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { TuningControlToolbar } from './TuningControlToolbar';
import { resetTuningStore, useTuningStore } from './tuningStore';
beforeEach(() => {
resetTuningStore();
const trial = {
id: 'best',
sessionId: 'session',
number: 1,
state: 'completed' as const,
rung: 0,
targetIterations: 300,
rewardConfig: { weights: { track_linear_velocity: 1 }, params: {} },
score: 0.1,
eligible: true,
createdAt: '',
message: '',
};
useTuningStore.setState({
sessionId: 'session',
sessionState: 'paused',
sessionMode: 'approval',
sessionMessage: '等待工程师操作',
currentTrialId: trial.id,
selectedTrialId: trial.id,
bestTrialId: trial.id,
trialsById: { [trial.id]: trial },
trialIds: [trial.id],
});
});
describe('TuningControlToolbar', () => {
it('展示 FSM、单步/回滚控制并打开参数安全护栏', () => {
render(<TuningControlToolbar />);
expect(screen.getByText('安全暂停')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /单步 Trial/ })).toBeEnabled();
expect(screen.getByRole('button', { name: '回滚最优' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: /参数护栏/ }));
expect(screen.getByRole('dialog', { name: '参数安全护栏 · Lock Range / Clamp' })).toBeVisible();
const policy = screen.getByLabelText('线速度跟踪锁定策略');
fireEvent.change(policy, { target: { value: 'fixed' } });
expect(screen.getByLabelText('线速度跟踪固定值')).toBeDisabled();
expect(screen.getByText(/安全边界由训练服务再次校验/)).toBeInTheDocument();
});
});
@@ -0,0 +1,523 @@
import { lazy, Suspense, useMemo, useState } from 'react';
import {
Bot,
ChevronRight,
LockKeyhole,
Pause,
Play,
RotateCcw,
Save,
ShieldCheck,
SkipForward,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, Dialog, Select } from '../components/ui';
import type { ParameterConstraint, RewardConfiguration } from '../training/types';
import { ACTIVE_SESSION_STATES, PARAMETER_DEFINITIONS, STATE_META, parameterValue } from './domain';
import { useTuningStore } from './tuningStore';
const RewardConfigDiffEditor = lazy(() =>
import('./RewardConfigDiffEditor').then((module) => ({ default: module.RewardConfigDiffEditor })),
);
const FSM_PHASES = ['Agent 分析', 'PPO 训练', '固定评估', '人工审批'] as const;
type DraftMode = 'free' | 'range' | 'fixed';
interface ConstraintDraft {
mode: DraftMode;
min: string;
max: string;
value: string;
}
function createDrafts(
constraints: Record<string, ParameterConstraint>,
current: RewardConfiguration | undefined,
): Record<string, ConstraintDraft> {
return Object.fromEntries(
PARAMETER_DEFINITIONS.map((definition) => {
const constraint = constraints[definition.path];
const value = parameterValue(current, definition.path);
if (constraint?.kind === 'range')
return [
definition.path,
{
mode: 'range',
min: String(constraint.min),
max: String(constraint.max),
value: String(value),
},
];
if (constraint?.kind === 'fixed')
return [
definition.path,
{
mode: 'fixed',
min: String(definition.minimum),
max: String(definition.maximum),
value: String(constraint.value),
},
];
return [
definition.path,
{
mode: 'free',
min: String(definition.minimum),
max: String(definition.maximum),
value: String(value),
},
];
}),
);
}
function serializeConstraints(
drafts: Record<string, ConstraintDraft>,
): Record<string, ParameterConstraint> {
const constraints: Record<string, ParameterConstraint> = {};
for (const definition of PARAMETER_DEFINITIONS) {
const draft = drafts[definition.path];
if (!draft || draft.mode === 'free') continue;
if (draft.mode === 'fixed') {
const value = Number(draft.value);
if (!Number.isFinite(value) || value < definition.minimum || value > definition.maximum)
throw new Error(
`${definition.label}固定值必须在 ${definition.minimum}${definition.maximum}`,
);
if (!definition.allowZero && value === 0)
throw new Error(`${definition.label}不允许固定为 0`);
constraints[definition.path] = { kind: 'fixed', value };
continue;
}
const min = Number(draft.min);
const max = Number(draft.max);
if (
!Number.isFinite(min) ||
!Number.isFinite(max) ||
min < definition.minimum ||
max > definition.maximum ||
min > max
)
throw new Error(
`${definition.label}范围必须满足 ${definition.minimum} ≤ 下限 ≤ 上限 ≤ ${definition.maximum}`,
);
if (!definition.allowZero && min <= 0 && max >= 0)
throw new Error(`${definition.label}的范围不能包含 0`);
constraints[definition.path] = { kind: 'range', min, max };
}
return constraints;
}
function FsmStrip({ state }: { state: keyof typeof STATE_META }) {
const meta = STATE_META[state];
const terminal = ['succeeded', 'failed', 'cancelled'].includes(state);
return (
<div className="flex min-w-0 items-center gap-1" aria-label={`Agent 状态:${meta.label}`}>
{FSM_PHASES.map((label, index) => {
const reached = terminal ? state === 'succeeded' : index <= meta.phase;
const active = !terminal && index === meta.phase && state !== 'paused';
return (
<div key={label} className="flex min-w-0 items-center gap-1">
<div className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${active ? 'animate-pulse bg-accent shadow-[0_0_9px_var(--ui-accent)]' : reached ? 'bg-accent' : 'bg-border-strong'}`}
/>
<span
className={`hidden whitespace-nowrap text-[9px] 2xl:inline ${reached ? 'text-text-secondary' : 'text-text-tertiary'}`}
>
{label}
</span>
</div>
{index < FSM_PHASES.length - 1 && (
<ChevronRight className="h-3 w-3 shrink-0 text-border-strong" />
)}
</div>
);
})}
</div>
);
}
function ParameterConstraintDialog({
open,
close,
current,
}: {
open: boolean;
close(): void;
current?: RewardConfiguration;
}) {
const constraints = useTuningStore((state) => state.parameterConstraints);
const revision = useTuningStore((state) => state.constraintsRevision);
const effectiveAfterCurrent = useTuningStore((state) => state.constraintsEffectiveAfterCurrent);
const busy = useTuningStore((state) => state.busyOperations.includes('constraints'));
const [drafts, setDrafts] = useState(() => createDrafts(constraints, current));
const [query, setQuery] = useState('');
const [problem, setProblem] = useState<string>();
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return PARAMETER_DEFINITIONS;
return PARAMETER_DEFINITIONS.filter(
(definition) =>
definition.label.toLowerCase().includes(normalized) ||
definition.path.toLowerCase().includes(normalized),
);
}, [query]);
const update = (path: string, patch: Partial<ConstraintDraft>) =>
setDrafts((value) => ({ ...value, [path]: { ...value[path], ...patch } }));
const save = async () => {
try {
const value = serializeConstraints(drafts);
setProblem(undefined);
await useTuningStore.getState().saveParameterConstraints(value);
if (!useTuningStore.getState().error) close();
} catch (error) {
setProblem(error instanceof Error ? error.message : String(error));
}
};
return (
<Dialog
open={open}
onClose={close}
title="参数安全护栏 · Lock Range / Clamp"
className="!max-w-5xl"
footer={
<div className="flex items-center justify-between gap-3">
<p className="text-[9px] text-text-tertiary">
Revision {revision} ·
</p>
<div className="flex gap-2">
<Button onClick={close}></Button>
<Button
variant="primary"
icon={<Save className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void save()}
>
</Button>
</div>
</div>
}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="rounded-md border border-success-border bg-success-soft px-3 py-2 text-[10px] text-success">
<ShieldCheck className="mr-1 inline h-3.5 w-3.5" /> Agent
</div>
<input
aria-label="搜索可锁定参数"
className="field h-8 w-64 px-2 text-xs"
placeholder="搜索 reward / hyperparameter"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{effectiveAfterCurrent && (
<p className="mb-2 rounded border border-warning-border bg-warning-soft px-2 py-1.5 text-[10px] text-warning">
Trial Proposal
</p>
)}
{problem && (
<p
role="alert"
className="mb-2 rounded border border-danger-border bg-danger-soft p-2 text-[10px] text-danger"
>
{problem}
</p>
)}
<div className="max-h-[52vh] overflow-auto rounded-lg border border-border panel-scroll">
<table className="w-full min-w-[760px] text-left text-[10px]">
<thead className="sticky top-0 z-10 bg-surface text-text-tertiary">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-2 py-2 font-medium"></th>
<th className="px-2 py-2 font-medium"></th>
<th className="px-2 py-2 font-medium"></th>
<th className="px-2 py-2 font-medium"> / </th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{filtered.map((definition) => {
const draft = drafts[definition.path];
const currentValue = parameterValue(current, definition.path);
return (
<tr
key={definition.path}
className="border-t border-border hover:bg-element-hover/50"
>
<td className="px-3 py-2">
<p className="font-medium text-text-secondary">{definition.label}</p>
<code className="text-[8px] text-text-tertiary">{definition.path}</code>
</td>
<td className="px-2 py-2 font-mono text-text-primary">{currentValue}</td>
<td className="px-2 py-2">
<Select
aria-label={`${definition.label}锁定策略`}
value={draft.mode}
onChange={(event) =>
update(definition.path, { mode: event.target.value as DraftMode })
}
>
<option value="free">Agent </option>
<option value="range"></option>
<option value="fixed"></option>
</Select>
</td>
<td className="px-2 py-2">
<input
aria-label={`${definition.label}工程下限`}
type="number"
step="any"
disabled={draft.mode !== 'range'}
className="field h-7 w-28 px-2 font-mono disabled:opacity-40"
value={draft.min}
onChange={(event) => update(definition.path, { min: event.target.value })}
/>
</td>
<td className="px-2 py-2">
<input
aria-label={`${definition.label}${draft.mode === 'fixed' ? '固定值' : '工程上限'}`}
type="number"
step="any"
disabled={draft.mode === 'free' || draft.mode === 'fixed'}
className="field h-7 w-28 px-2 font-mono disabled:opacity-40"
value={draft.mode === 'fixed' ? draft.value : draft.max}
onChange={(event) =>
update(
definition.path,
draft.mode === 'fixed'
? { value: event.target.value }
: { max: event.target.value },
)
}
/>
</td>
<td className="px-3 py-2 font-mono text-[9px] text-text-tertiary">
[{definition.minimum}, {definition.maximum}]
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Dialog>
);
}
export function TuningControlToolbar() {
const [state, mode, message, bestTrialId, currentTrialId] = useTuningStore(
useShallow(
(value) =>
[
value.sessionState,
value.sessionMode,
value.sessionMessage,
value.bestTrialId,
value.currentTrialId,
] as const,
),
);
const [busyOperations, constraints, dispatchTokens, runPolicy] = useTuningStore(
useShallow(
(value) =>
[
value.busyOperations,
value.parameterConstraints,
value.dispatchTokens,
value.runPolicy,
] as const,
),
);
const current = useTuningStore((value) => {
const id = currentTrialId ?? value.selectedTrialId ?? value.trialIds.at(-1);
return id ? value.trialsById[id] : undefined;
});
const best = useTuningStore((value) => (bestTrialId ? value.trialsById[bestTrialId] : undefined));
const [constraintsOpen, setConstraintsOpen] = useState(false);
const [rollbackOpen, setRollbackOpen] = useState(false);
if (!state || !mode) return null;
const meta = STATE_META[state];
const busy = busyOperations.length > 0;
const resumable = state === 'paused' || state === 'interrupted';
const pausable = ['queued', 'running', 'evaluating', 'awaiting_approval'].includes(state);
const stepAllowed = state === 'paused' || state === 'awaiting_approval';
const rollbackAllowed = Boolean(best) && (state === 'paused' || state === 'awaiting_approval');
const active = ACTIVE_SESSION_STATES.has(state);
const modeSwitchable = [
'queued',
'running',
'evaluating',
'awaiting_approval',
'paused',
].includes(state);
return (
<>
<section className="border-b border-border bg-panel/95 px-3 py-2 shadow-[0_8px_30px_rgb(0_0_0/0.16)] backdrop-blur">
<div className="flex flex-wrap items-center gap-2">
<div className="flex min-w-[210px] items-center gap-2 border-r border-border pr-3">
<div className="grid h-8 w-8 place-items-center rounded-lg border border-success-border bg-accent-soft text-accent">
<Bot className="h-4 w-4" />
</div>
<div className="min-w-0" aria-live="polite">
<div className="flex items-center gap-2">
<span className="text-[9px] uppercase tracking-[0.14em] text-text-tertiary">
Agent FSM
</span>
<Badge tone={meta.tone}>{meta.label}</Badge>
</div>
<p className="mt-0.5 max-w-72 truncate text-[9px] text-text-tertiary" title={message}>
{message}
</p>
</div>
</div>
<FsmStrip state={state} />
<div className="ml-auto flex flex-wrap items-center gap-1.5">
<Select
aria-label="运行时调参模式"
value={mode}
disabled={!modeSwitchable || busy}
onChange={(event) =>
void useTuningStore
.getState()
.setRuntimeMode(event.target.value as 'automatic' | 'approval')
}
>
<option value="automatic"></option>
<option value="approval"></option>
</Select>
<Button
icon={
resumable ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />
}
disabled={busy || (!resumable && !pausable)}
onClick={() => void useTuningStore.getState().pauseOrResume()}
>
{resumable ? '继续' : '暂停'}
</Button>
<Button
icon={<SkipForward className="h-3.5 w-3.5" />}
disabled={busy || !stepAllowed}
title={stepAllowed ? '只发放一个 Trial 调度令牌' : '请先暂停或等待 Proposal 审批'}
onClick={() => void useTuningStore.getState().stepNextTrial()}
>
Trial
{runPolicy === 'step' && dispatchTokens > 0 ? ` · ${dispatchTokens}` : ''}
</Button>
<Button
icon={<RotateCcw className="h-3.5 w-3.5" />}
disabled={busy || !rollbackAllowed}
title={
rollbackAllowed
? '回滚后续调度基准到历史最优'
: '请先暂停,且至少需要一个安全最优 Trial'
}
onClick={() => setRollbackOpen(true)}
>
</Button>
<Button
icon={<LockKeyhole className="h-3.5 w-3.5" />}
disabled={busy || !active}
onClick={() => setConstraintsOpen(true)}
>
{Object.keys(constraints).length > 0 && (
<span className="rounded bg-accent/15 px-1 font-mono text-[9px] text-accent">
{Object.keys(constraints).length}
</span>
)}
</Button>
</div>
</div>
</section>
{constraintsOpen && (
<ParameterConstraintDialog
key={`${useTuningStore.getState().constraintsRevision}:${current?.id ?? 'none'}`}
open
close={() => setConstraintsOpen(false)}
current={current?.rewardConfig}
/>
)}
<Dialog
open={rollbackOpen}
onClose={() => setRollbackOpen(false)}
title="安全回滚到历史最优 Trial"
className="!max-w-5xl"
footer={
<div className="flex items-center justify-between gap-3">
<p className="text-[9px] text-text-tertiary">
Proposal
</p>
<div className="flex gap-2">
<Button onClick={() => setRollbackOpen(false)}></Button>
<Button
variant="primary"
icon={<RotateCcw className="h-3.5 w-3.5" />}
disabled={!bestTrialId || busyOperations.includes('rollback')}
onClick={() => {
if (!bestTrialId) return;
void useTuningStore
.getState()
.rollbackToTrial(bestTrialId, true)
.then(() => setRollbackOpen(false));
}}
>
Checkpoint
</Button>
</div>
</div>
}
>
<div className="mb-3 grid gap-2 sm:grid-cols-3">
<div className="rounded-lg border border-border bg-app p-2.5">
<p className="text-[9px] text-text-tertiary"> Trial</p>
<p className="mt-1 font-mono text-sm">
{current ? `T${current.number} · R${current.rung}` : '—'}
</p>
</div>
<div className="rounded-lg border border-warning-border bg-warning-soft p-2.5">
<p className="text-[9px] text-warning"> Best Trial</p>
<p className="mt-1 font-mono text-sm text-warning">
{best ? `T${best.number} · R${best.rung}` : '—'}
</p>
</div>
<div className="rounded-lg border border-border bg-app p-2.5">
<p className="text-[9px] text-text-tertiary">Best Score</p>
<p className="mt-1 font-mono text-sm">{best?.score?.toFixed(5) ?? '—'}</p>
</div>
</div>
{current && best ? (
<div className="overflow-hidden rounded-lg border border-border bg-[#09111e]">
<Suspense
fallback={
<div className="grid h-[360px] place-items-center text-xs text-text-tertiary">
Monaco Diff
</div>
}
>
<RewardConfigDiffEditor
original={current.rewardConfig}
modified={best.rewardConfig}
/>
</Suspense>
</div>
) : (
<div className="grid h-40 place-items-center text-xs text-text-tertiary">
</div>
)}
</Dialog>
</>
);
}
@@ -0,0 +1,95 @@
import { ShieldAlert, ShieldCheck, Trophy } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Badge } from '../components/ui';
import { formatMetric } from './domain';
import { useTuningStore } from './tuningStore';
export function TuningLeaderboard() {
const [trialIds, entitiesRevision, bestTrialId, selectedTrialId] = useTuningStore(
useShallow(
(state) =>
[state.trialIds, state.entitiesRevision, state.bestTrialId, state.selectedTrialId] as const,
),
);
const ranked = trialIds
.map((id) => useTuningStore.getState().trialsById[id])
.filter((trial) => trial?.score !== undefined && trial.score !== null)
.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity));
void entitiesRevision;
return (
<section className="overflow-hidden rounded-xl border border-border bg-surface">
<header className="flex items-center justify-between border-b border-border px-3 py-2.5">
<h2 className="text-xs font-semibold">Trial Leaderboard</h2>
<Badge>{ranked.length} </Badge>
</header>
<div className="max-h-64 overflow-auto panel-scroll">
<table className="w-full min-w-[560px] text-left text-[9px]">
<thead className="sticky top-0 bg-surface text-text-tertiary">
<tr>
<th className="px-3 py-2 font-medium">Rank</th>
<th className="px-2 py-2 font-medium">Trial / Rung</th>
<th className="px-2 py-2 font-medium">Iterations</th>
<th className="px-2 py-2 font-medium">Score</th>
<th className="px-2 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{ranked.map((trial, index) => {
const selected = trial.id === selectedTrialId;
const best = trial.id === bestTrialId;
return (
<tr
key={trial.id}
className={`border-t border-border ${selected ? 'bg-accent/10' : 'hover:bg-element-hover/50'}`}
>
<td className="px-3 py-2">
<span className="flex items-center gap-1 font-mono">
{index + 1}
{best && <Trophy className="h-3 w-3 text-warning" />}
</span>
</td>
<td className="px-2 py-2">
<button
type="button"
className="font-medium text-text-secondary hover:text-accent"
onClick={() => useTuningStore.getState().selectTrial(trial.id)}
>
T{trial.number} / R{trial.rung}
</button>
</td>
<td className="px-2 py-2 font-mono text-text-tertiary">
{trial.targetIterations}
</td>
<td
className={`px-2 py-2 font-mono ${best ? 'text-warning' : 'text-text-primary'}`}
>
{formatMetric(trial.score, 5)}
</td>
<td className="px-2 py-2">
<span
className={`inline-flex items-center gap-1 ${trial.eligible ? 'text-success' : 'text-danger'}`}
>
{trial.eligible ? (
<ShieldCheck className="h-3 w-3" />
) : (
<ShieldAlert className="h-3 w-3" />
)}
{trial.eligible ? '通过' : '拒绝晋级'}
</span>
</td>
<td className="px-3 py-2 text-text-tertiary">
{trial.endedAt ? new Date(trial.endedAt).toLocaleString() : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
{!ranked.length && (
<p className="p-6 text-center text-[10px] text-text-tertiary"></p>
)}
</div>
</section>
);
}
@@ -0,0 +1,119 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { TuningCreateRequest, TuningTrial } from '../training/types';
import { TuningSessionRail } from './TuningSessionRail';
import { resetTuningStore, useTuningStore } from './tuningStore';
const objectives = {
velocity_tracking: 0.35,
action_smoothness: 0.2,
posture_stability: 0.15,
fall_avoidance: 0.15,
foot_slip: 0.1,
energy: 0.05,
};
const config: TuningCreateRequest & { rungs: number[]; promote: number[] } = {
taskId: 'Unitree-Go2-Flat',
mode: 'approval',
runName: 'rail-test',
numEnvs: 16,
seed: 42,
gpuIds: [0],
trialCount: 1,
initialIterations: 300,
middleIterations: 900,
finalIterations: 2000,
evalNumEnvs: 16,
evalSteps: 20,
objectiveWeights: objectives,
fallbackEnabled: false,
rungs: [300, 900, 2000],
promote: [1, 0, 0],
};
beforeEach(() => {
resetTuningStore();
vi.restoreAllMocks();
vi.unstubAllGlobals();
const trial: TuningTrial = {
id: 'b'.repeat(32),
sessionId: 'a'.repeat(32),
number: 0,
state: 'completed',
rung: 0,
targetIterations: 300,
rewardConfig: { weights: { track_linear_velocity: 1 }, params: {} },
score: 0,
eligible: true,
createdAt: '2026-01-01T00:00:00Z',
endedAt: '2026-01-01T00:01:00Z',
message: '完成',
};
useTuningStore.setState({
endpoint: 'http://127.0.0.1:8765',
token: 'child-secret',
sessionId: trial.sessionId,
sessionState: 'succeeded',
sessionMode: 'approval',
sessionConfig: config,
sessionMessage: '全部完成',
bestTrialId: trial.id,
selectedTrialId: trial.id,
visibleTrialIds: [trial.id],
trialIds: [trial.id],
trialsById: { [trial.id]: trial },
});
});
afterEach(() => {
Object.defineProperty(window, 'opener', { configurable: true, value: null });
});
describe('TuningSessionRail', () => {
it('在调参页下载最佳策略并把文件传回主工作台', async () => {
const postMessage = vi.fn();
Object.defineProperty(window, 'opener', {
configurable: true,
value: { closed: false, postMessage },
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(new Blob([new Uint8Array([1, 2, 3])]), {
status: 200,
headers: { 'Content-Type': 'application/octet-stream' },
}),
);
vi.stubGlobal('fetch', fetchMock);
render(<TuningSessionRail />);
fireEvent.click(screen.getByRole('button', { name: '导入' }));
await waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
const [message, targetOrigin] = postMessage.mock.calls[0] as [
{ type: string; sessionId: string; policy: File; token?: string },
string,
];
expect(message.type).toBe('mujoco-tuning-import-policy');
expect(message.sessionId).toBe('a'.repeat(32));
expect(message.policy).toBeInstanceOf(File);
expect(message.policy.name).toBe('best-policy-aaaaaaaa.onnx');
expect(message).not.toHaveProperty('token');
expect(targetOrigin).toBe(window.location.origin);
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('Authorization')).toBe(
'Bearer child-secret',
);
expect(screen.getByRole('button', { name: '已发送' })).toBeInTheDocument();
window.dispatchEvent(
new MessageEvent('message', {
origin: window.location.origin,
data: {
type: 'mujoco-tuning-import-policy-result',
sessionId: 'a'.repeat(32),
ok: true,
},
}),
);
expect(await screen.findByRole('button', { name: '已导入' })).toBeInTheDocument();
});
});
@@ -0,0 +1,344 @@
import { useEffect, useState } from 'react';
import {
ArrowLeft,
Download,
FileJson,
GitBranch,
Layers3,
Square,
Trophy,
Upload,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, ProgressBar } from '../components/ui';
import { LocalTrainingClient } from '../training/LocalTrainingClient';
import { STATE_META, TRIAL_STATE_LABELS } from './domain';
import { useTuningStore } from './tuningStore';
function downloadFile(file: File): void {
const url = URL.createObjectURL(file);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.name;
anchor.click();
URL.revokeObjectURL(url);
}
function AshaPromotionTree() {
const [trialIds, entitiesRevision, config, bestTrialId] = useTuningStore(
useShallow(
(state) =>
[state.trialIds, state.entitiesRevision, state.sessionConfig, state.bestTrialId] as const,
),
);
const rungs = (config?.rungs ?? [300, 900, 2000]).map((iterations, rung) => {
const trials = trialIds
.map((id) => useTuningStore.getState().trialsById[id])
.filter((trial) => trial?.rung === rung);
const completed = trials.filter((trial) => trial.state === 'completed').length;
return {
rung,
iterations,
total: config?.promote?.[rung] ?? trials.length,
completed,
bestHere: trials.some((trial) => trial.id === bestTrialId),
};
});
void entitiesRevision;
return (
<section className="rounded-lg border border-border bg-app/70 p-2.5">
<div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
<GitBranch className="h-3.5 w-3.5 text-accent" /> ASHA
</h2>
<span className="font-mono text-[8px] text-text-tertiary">η dynamic</span>
</div>
<div className="space-y-0">
{rungs.map((rung, index) => (
<div key={rung.rung} className="grid grid-cols-[18px_minmax(0,1fr)] gap-2">
<div className="relative flex justify-center">
{index < rungs.length - 1 && (
<span className="absolute bottom-0 top-4 w-px bg-border-strong" />
)}
<span
className={`relative z-10 mt-2 h-2.5 w-2.5 rounded-full border ${rung.bestHere ? 'border-warning bg-warning shadow-[0_0_8px_rgb(243_189_92/0.5)]' : rung.completed ? 'border-accent bg-accent' : 'border-border-strong bg-surface'}`}
/>
</div>
<div className="mb-2 rounded border border-border bg-surface px-2 py-1.5">
<div className="flex items-center justify-between text-[9px]">
<span className="font-medium">Rung {rung.rung}</span>
<span className="font-mono text-text-tertiary">{rung.iterations} it</span>
</div>
<div className="mt-1 flex items-center justify-between text-[8px] text-text-tertiary">
<span>
{rung.completed}/{rung.total || '—'}
</span>
{rung.bestHere && <span className="text-warning">Best </span>}
</div>
</div>
</div>
))}
</div>
</section>
);
}
export function TuningSessionRail() {
const [policyImportState, setPolicyImportState] = useState<
'idle' | 'downloading' | 'sent' | 'imported' | 'downloaded'
>('idle');
const [sessionId, state, config, message, trialIds, selectedTrialId, bestTrialId] =
useTuningStore(
useShallow(
(value) =>
[
value.sessionId,
value.sessionState,
value.sessionConfig,
value.sessionMessage,
value.trialIds,
value.selectedTrialId,
value.bestTrialId,
] as const,
),
);
const [visibleTrialIds, entitiesRevision, busy, endpoint, token] = useTuningStore(
useShallow(
(value) =>
[
value.visibleTrialIds,
value.entitiesRevision,
value.busyOperations.length > 0,
value.endpoint,
value.token,
] as const,
),
);
const reportError = (value: unknown) =>
useTuningStore.setState({ error: value instanceof Error ? value.message : String(value) });
useEffect(() => {
const receive = (event: MessageEvent) => {
if (event.origin !== location.origin || typeof event.data !== 'object') return;
const data = event.data as {
type?: string;
sessionId?: string;
ok?: boolean;
error?: string;
};
if (data.type !== 'mujoco-tuning-import-policy-result' || data.sessionId !== sessionId)
return;
if (data.ok) setPolicyImportState('imported');
else {
setPolicyImportState('idle');
useTuningStore.setState({ error: data.error || '主工作台未能导入 ONNX 策略' });
}
};
window.addEventListener('message', receive);
return () => window.removeEventListener('message', receive);
}, [sessionId]);
if (!sessionId || !state || !config) return null;
const trials = trialIds.map((id) => useTuningStore.getState().trialsById[id]).filter(Boolean);
const completed = trials.filter((trial) => trial.state === 'completed').length;
const expected =
config.trialCount + config.promote.slice(1).reduce((sum, value) => sum + value, 0);
const bestTrial = bestTrialId ? useTuningStore.getState().trialsById[bestTrialId] : undefined;
const importBestPolicy = async () => {
setPolicyImportState('downloading');
try {
const policy = await new LocalTrainingClient(endpoint, token).downloadBestPolicy(sessionId);
const opener = window.opener;
if (opener && !opener.closed) {
opener.postMessage(
{ type: 'mujoco-tuning-import-policy', sessionId, policy },
location.origin,
);
setPolicyImportState('sent');
} else {
downloadFile(policy);
setPolicyImportState('downloaded');
}
} catch (value) {
setPolicyImportState('idle');
reportError(value);
}
};
void entitiesRevision;
return (
<aside className="flex h-[620px] min-h-0 flex-col border-b border-border bg-panel xl:h-auto xl:border-b-0 xl:border-r">
<div className="shrink-0 border-b border-border p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs font-semibold" title={config.runName}>
{config.runName}
</p>
<p
className="mt-0.5 truncate font-mono text-[8px] text-text-tertiary"
title={sessionId}
>
{sessionId}
</p>
</div>
<Badge tone={STATE_META[state].tone}>{STATE_META[state].label}</Badge>
</div>
<div className="mt-3">
<ProgressBar
value={expected ? completed / expected : 0}
label={`${completed} / ${expected} Trial 阶段`}
/>
</div>
<p
className="mt-2 line-clamp-2 rounded border border-border bg-app px-2 py-1.5 text-[9px] leading-4 text-text-tertiary"
title={message}
>
{message}
</p>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-3 panel-scroll">
<AshaPromotionTree />
{bestTrial && (
<section className="rounded-lg border border-warning-border bg-warning-soft/30 p-2.5">
<div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-[10px] font-semibold text-warning">
<Trophy className="h-3.5 w-3.5" /> Best Artifact
</h2>
<span className="font-mono text-[8px] text-warning">
T{bestTrial.number} · R{bestTrial.rung}
</span>
</div>
<div className="grid grid-cols-3 gap-1">
<Button
icon={<Download className="h-3 w-3" />}
onClick={() =>
void new LocalTrainingClient(endpoint, token)
.downloadBestPolicy(sessionId)
.then(downloadFile)
.catch(reportError)
}
>
ONNX
</Button>
<Button
icon={<Upload className="h-3 w-3" />}
disabled={busy || policyImportState === 'downloading'}
onClick={() => void importBestPolicy()}
>
{
{
idle: '导入',
downloading: '下载中',
sent: '已发送',
imported: '已导入',
downloaded: '已下载',
}[policyImportState]
}
</Button>
<Button
icon={<FileJson className="h-3 w-3" />}
onClick={() => {
const file = new File(
[JSON.stringify(bestTrial.rewardConfig, null, 2) + '\n'],
`reward-preset-${sessionId.slice(0, 8)}.json`,
{ type: 'application/json' },
);
downloadFile(file);
}}
>
Preset
</Button>
</div>
</section>
)}
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
<Layers3 className="h-3.5 w-3.5 text-accent" /> Trial Runs
</h2>
<span className="text-[8px] text-text-tertiary"> 6 </span>
</div>
<div className="space-y-1.5">
{trials.map((trial) => {
const selected = trial.id === selectedTrialId;
const visible = visibleTrialIds.includes(trial.id);
const best = trial.id === bestTrialId;
return (
<div
key={trial.id}
className={`group rounded-lg border transition-colors ${selected ? 'border-accent bg-accent/10' : best ? 'border-warning-border bg-warning-soft/40' : 'border-border bg-app hover:bg-element-hover/50'}`}
>
<div className="flex items-center gap-1.5 p-1.5">
<input
type="checkbox"
aria-label={`对比 Trial ${trial.number} Rung ${trial.rung}`}
checked={visible}
onChange={(event) =>
useTuningStore.getState().setTrialVisible(trial.id, event.target.checked)
}
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => useTuningStore.getState().selectTrial(trial.id)}
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-[10px] font-medium">
T{trial.number}{' '}
<span className="text-text-tertiary">/ R{trial.rung}</span>
{best && (
<Trophy className="h-3 w-3 text-warning" aria-label="历史最优" />
)}
</span>
<span className="text-[8px] text-text-tertiary">
{TRIAL_STATE_LABELS[trial.state] ?? trial.state}
</span>
</div>
<div className="mt-1 flex items-center justify-between font-mono text-[8px] text-text-tertiary">
<span>{trial.targetIterations} it</span>
<span className={best ? 'text-warning' : ''}>
{trial.score === undefined || trial.score === null
? 'score —'
: trial.score.toFixed(5)}
</span>
</div>
</button>
</div>
</div>
);
})}
</div>
</section>
</div>
<div className="grid shrink-0 grid-cols-2 gap-1.5 border-t border-border p-3">
<Button
icon={<ArrowLeft className="h-3.5 w-3.5" />}
onClick={() => useTuningStore.getState().leaveSession()}
>
</Button>
<Button
variant="danger"
icon={<Square className="h-3 w-3" />}
disabled={
busy ||
![
'queued',
'running',
'evaluating',
'awaiting_approval',
'paused',
'interrupted',
].includes(state)
}
onClick={() => void useTuningStore.getState().cancelSession()}
>
Session
</Button>
</div>
</aside>
);
}
+218
View File
@@ -0,0 +1,218 @@
import type {
ObjectiveWeights,
RewardConfiguration,
TuningSessionState,
TuningTrial,
} from '../training/types';
export type RewardSection = keyof RewardConfiguration;
export interface ParameterDefinition {
path: string;
section: RewardSection;
key: string;
label: string;
minimum: number;
maximum: number;
defaultValue: number;
allowZero: boolean;
precision: number;
}
const weight = (
key: string,
label: string,
minimum: number,
maximum: number,
defaultValue: number,
allowZero = true,
precision = 4,
): ParameterDefinition => ({
path: `weights.${key}`,
section: 'weights',
key,
label,
minimum,
maximum,
defaultValue,
allowZero,
precision,
});
const parameter = (
key: string,
label: string,
minimum: number,
maximum: number,
defaultValue: number,
precision = 4,
): ParameterDefinition => ({
path: `params.${key}`,
section: 'params',
key,
label,
minimum,
maximum,
defaultValue,
allowZero: false,
precision,
});
/** 与 training_server/tuning/schema.py 同步的只读前端目录;服务端仍是安全边界。 */
export const PARAMETER_DEFINITIONS: readonly ParameterDefinition[] = [
weight('track_linear_velocity', '线速度跟踪', 0.5, 3, 1, false),
weight('track_angular_velocity', '角速度跟踪', 0.25, 2, 1, false),
weight('body_orientation_l2', '躯干姿态', -3, -0.1, -1, false),
weight('pose', '目标姿态', 0, 2.5, 1),
weight('body_ang_vel', '机身角速度', -0.2, 0, -0.05),
weight('angular_momentum', '角动量', -0.1, 0, -0.025),
weight('is_terminated', '跌倒终止', -400, -50, -200, false, 2),
weight('joint_acc_l2', '关节加速度', -2e-6, 0, -2.5e-7, true, 8),
weight('joint_pos_limits', '关节限位', -30, -2, -10, false, 2),
weight('action_rate_l2', '动作平滑', -0.2, -0.005, -0.05),
weight('foot_gait', '步态相位', 0, 1.5, 0.5),
weight('foot_clearance', '抬脚高度', -3, 0, -1),
weight('foot_slip', '足端滑移', -1, 0, -0.25),
weight('soft_landing', '柔和落足', -0.005, 0, -0.001, true, 6),
weight('stand_still', '静止姿态', -3, 0, -1),
weight('electrical_power', '电功率', -0.005, 0, 0, true, 6),
parameter('track_linear_velocity.std', '线速度核宽', 0.25, 1, 0.5),
parameter('track_angular_velocity.std', '角速度核宽', 0.35, 1.2, Math.sqrt(0.5)),
parameter('pose.std_standing_scale', '站立姿态尺度', 0.5, 2, 1),
parameter('pose.std_walking_scale', '行走姿态尺度', 0.5, 2, 1),
parameter('pose.std_running_scale', '奔跑姿态尺度', 0.5, 2, 1),
parameter('pose.walking_threshold', '行走阈值', 0.05, 0.5, 0.1),
parameter('pose.running_threshold', '奔跑阈值', 1, 2.5, 1.5),
parameter('foot_gait.period', '步态周期', 0.4, 0.8, 0.6),
parameter('foot_gait.threshold', '步态阈值', 0.45, 0.65, 0.56),
parameter('foot_gait.command_threshold', '步态命令阈值', 0.02, 0.3, 0.1),
parameter('foot_clearance.target_height', '目标抬脚高度', 0.06, 0.16, 0.1),
parameter('foot_clearance.command_threshold', '抬脚命令阈值', 0.02, 0.3, 0.1),
parameter('foot_slip.command_threshold', '滑移命令阈值', 0.02, 0.3, 0.1),
parameter('soft_landing.command_threshold', '落足命令阈值', 0.02, 0.3, 0.1),
parameter('stand_still.command_threshold', '静止命令阈值', 0.02, 0.3, 0.1),
] as const;
export const PARAMETER_BY_PATH = new Map(
PARAMETER_DEFINITIONS.map((definition) => [definition.path, definition]),
);
export const OBJECTIVE_META: ReadonlyArray<{
key: keyof ObjectiveWeights;
label: string;
shortLabel: string;
metric: string;
}> = [
{
key: 'velocity_tracking',
label: '速度跟踪',
shortLabel: '速度',
metric: 'linear_velocity_rmse',
},
{
key: 'action_smoothness',
label: '动作平滑度',
shortLabel: '平滑',
metric: 'mean_action_acc',
},
{
key: 'posture_stability',
label: '躯干姿态稳定',
shortLabel: '姿态',
metric: 'orientation_error',
},
{
key: 'fall_avoidance',
label: '跌倒规避',
shortLabel: '防跌',
metric: 'fall_rate',
},
{ key: 'foot_slip', label: '接触滑移抑制', shortLabel: '滑移', metric: 'slip_velocity' },
{ key: 'energy', label: '机械能耗', shortLabel: '能耗', metric: 'mechanical_power' },
];
export const STATE_META = {
queued: { label: '分析排队', phase: 0, tone: 'neutral' },
running: { label: '策略训练中', phase: 1, tone: 'accent' },
evaluating: { label: '固定步态评估中', phase: 2, tone: 'warning' },
awaiting_approval: { label: '等待人工审批', phase: 3, tone: 'warning' },
paused: { label: '安全暂停', phase: 3, tone: 'neutral' },
interrupted: { label: '服务已中断', phase: 3, tone: 'warning' },
succeeded: { label: '调优完成', phase: 4, tone: 'success' },
failed: { label: '调优失败', phase: 4, tone: 'warning' },
cancelled: { label: '已取消', phase: 4, tone: 'neutral' },
} as const satisfies Record<
TuningSessionState,
{ label: string; phase: number; tone: 'neutral' | 'accent' | 'success' | 'warning' }
>;
export const ACTIVE_SESSION_STATES = new Set<TuningSessionState>([
'queued',
'running',
'evaluating',
'awaiting_approval',
'paused',
'interrupted',
]);
export const TRIAL_STATE_LABELS: Record<string, string> = {
queued: '等待调度',
training: '训练中',
evaluating: '评估中',
completed: '完成',
interrupted: '中断',
failed: '失败',
cancelled: '取消',
};
export function parameterValue(config: RewardConfiguration | undefined, path: string): number {
const definition = PARAMETER_BY_PATH.get(path);
if (!definition) return Number.NaN;
return config?.[definition.section][definition.key] ?? definition.defaultValue;
}
export interface RewardDiffEntry {
path: string;
before: number;
after: number;
}
export function rewardConfigurationDiff(
before: RewardConfiguration,
after: RewardConfiguration,
): RewardDiffEntry[] {
const changes: RewardDiffEntry[] = [];
for (const section of ['weights', 'params'] as const) {
const keys = new Set([...Object.keys(before[section]), ...Object.keys(after[section])]);
for (const key of keys) {
const previous = before[section][key];
const next = after[section][key];
if (previous !== next)
changes.push({ path: `${section}.${key}`, before: previous, after: next });
}
}
return changes;
}
export function mergeRewardPatch(
base: RewardConfiguration,
patch: Partial<{ weights: Record<string, number>; params: Record<string, number> }>,
): RewardConfiguration {
return {
weights: { ...base.weights, ...(patch.weights ?? {}) },
params: { ...base.params, ...(patch.params ?? {}) },
};
}
export function latestCompletedTrial(trials: readonly TuningTrial[]): TuningTrial | undefined {
return [...trials]
.reverse()
.find((trial) => trial.state === 'completed' && trial.evaluation !== undefined);
}
export function formatMetric(value: number | null | undefined, digits = 4): string {
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
const magnitude = Math.abs(value);
if (magnitude !== 0 && (magnitude < 1e-3 || magnitude >= 1e4)) return value.toExponential(2);
return value.toFixed(digits);
}
+153
View File
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { TuningSession, TuningTrial } from '../training/types';
import { resetTuningStore, useTuningStore } from './tuningStore';
const trial = (id: string, number: number, score: number): TuningTrial => ({
id,
sessionId: 's'.repeat(32),
number,
state: 'completed',
rung: 0,
targetIterations: 300,
rewardConfig: { weights: { pose: 1 + number * 0.1 }, params: {} },
score,
eligible: true,
evaluation: {
metrics: { linear_velocity_rmse: 0.2 },
score: { score, eligible: true, components: {} },
},
createdAt: '2026-01-01T00:00:00Z',
endedAt: '2026-01-01T00:01:00Z',
message: 'done',
});
function session(): TuningSession {
const first = trial('a'.repeat(32), 0, 0);
const best = trial('b'.repeat(32), 1, 0.2);
return {
id: 's'.repeat(32),
state: 'paused',
mode: 'approval',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:02:00Z',
config: {
taskId: 'Unitree-Go2-Flat',
mode: 'approval',
runName: 'test',
numEnvs: 16,
seed: 42,
gpuIds: [0],
trialCount: 2,
initialIterations: 300,
middleIterations: 900,
finalIterations: 2000,
evalNumEnvs: 8,
evalSteps: 10,
objectiveWeights: {
velocity_tracking: 0.35,
action_smoothness: 0.2,
posture_stability: 0.15,
fall_avoidance: 0.15,
foot_slip: 0.1,
energy: 0.05,
},
fallbackEnabled: false,
rungs: [300, 900, 2000],
promote: [2, 2, 2],
},
objectiveWeights: {
velocity_tracking: 0.35,
action_smoothness: 0.2,
posture_stability: 0.15,
fall_avoidance: 0.15,
foot_slip: 0.1,
energy: 0.05,
},
message: 'paused',
currentTrialId: best.id,
bestTrialId: best.id,
consecutiveNoImprove: 0,
fallbackEnabled: false,
trials: [first, best],
proposals: [],
audit: [],
control: {
runPolicy: 'step',
dispatchTokens: 0,
constraintsRevision: 3,
constraints: { 'weights.pose': { kind: 'range', min: 0.8, max: 1.4 } },
activeBaseTrialId: first.id,
effectiveAfterCurrent: false,
},
};
}
beforeEach(() => resetTuningStore());
describe('tuningStore slices', () => {
it('规范化 Session 实体并同步服务端控制状态', () => {
useTuningStore.getState().applySessionSnapshot(session());
const state = useTuningStore.getState();
expect(state.sessionState).toBe('paused');
expect(state.bestTrialId).toBe('b'.repeat(32));
expect(state.trialIds).toHaveLength(2);
expect(state.trialsById['b'.repeat(32)].score).toBe(0.2);
expect(state.visibleTrialIds).toContain('b'.repeat(32));
expect(state.runPolicy).toBe('step');
expect(state.constraintsRevision).toBe(3);
expect(state.activeBaseTrialId).toBe('a'.repeat(32));
useTuningStore.getState().setVisibleTrialIds([]);
useTuningStore.getState().applySessionSnapshot(session());
expect(useTuningStore.getState().visibleTrialIds).toEqual([]);
});
it('离开 Session 时清理指标缓冲与服务端控制快照', () => {
useTuningStore.getState().applySessionSnapshot(session());
useTuningStore.getState().enqueueMetricBatch({
trialId: 'a',
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 2 }] }],
});
useTuningStore.getState().flushMetricBatches();
useTuningStore.getState().leaveSession();
const state = useTuningStore.getState();
expect(state.sessionId).toBeUndefined();
expect(state.metricBuffers).toEqual({});
expect(state.knownMetricTags).toEqual([]);
expect(state.runPolicy).toBe('continuous');
expect(state.parameterConstraints).toEqual({});
});
it('将多个 scalar delta 在一次 flush 中去重并只发布轻量 revision', () => {
const store = useTuningStore.getState();
store.enqueueMetricBatch({
trialId: 'a',
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 2 }] }],
});
store.enqueueMetricBatch({
trialId: 'a',
series: [
{
tag: 'Train/reward',
points: [
{ step: 1, wallTime: 1, value: 3 },
{ step: 2, wallTime: 2, value: 4 },
],
},
],
});
expect(useTuningStore.getState().metricsRevision).toBe(0);
useTuningStore.getState().flushMetricBatches();
const state = useTuningStore.getState();
expect(state.metricsRevision).toBe(1);
expect(state.metricVersionByTrial.a).toBe(1);
expect(state.knownMetricTags).toEqual(['Train/reward']);
expect(state.metricCursor('a', 'Train/reward')).toBe(2);
expect(state.readMetricSeries(['a'], 'Train/reward')[0].points).toEqual([
{ step: 1, wallTime: 1, value: 3 },
{ step: 2, wallTime: 2, value: 4 },
]);
});
});
+558
View File
@@ -0,0 +1,558 @@
import { create, type StateCreator } from 'zustand';
import { LocalTrainingClient } from '../training/LocalTrainingClient';
import {
DEFAULT_TRAINING_ENDPOINT,
localStored,
rememberTrainingConnection,
sessionStored,
TRAINING_ENDPOINT_KEY,
TRAINING_TOKEN_KEY,
TUNING_SESSION_KEY,
} from '../training/storage';
import type {
ParameterConstraint,
ScalarPoint,
ScalarSeries,
TuningCapability,
TuningCreateRequest,
TuningProposal,
TuningSession,
TuningSessionState,
TuningTrial,
} from '../training/types';
import { ScalarRingBuffer } from './ScalarRingBuffer';
const MAX_VISIBLE_TRIALS = 6;
const METRIC_BUFFER_CAPACITY = 4096;
function errorText(value: unknown): string {
return value instanceof Error ? value.message : String(value);
}
function sameIds(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function stableIds(previous: readonly string[], next: string[]): string[] {
return sameIds(previous, next) ? (previous as string[]) : next;
}
function mergeEntities<T extends { id: string }>(
previous: Record<string, T>,
values: readonly T[],
): Record<string, T> {
let changed = Object.keys(previous).length !== values.length;
const next: Record<string, T> = {};
for (const value of values) {
const old = previous[value.id];
const shared = old && JSON.stringify(old) === JSON.stringify(value) ? old : value;
next[value.id] = shared;
changed = changed || shared !== old;
}
return changed ? next : previous;
}
function defaultVisibleTrialIds(
trials: readonly TuningTrial[],
bestTrialId: string | undefined,
selectedTrialId: string | undefined,
): string[] {
const completed = trials
.filter((trial) => trial.state === 'completed')
.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity))
.slice(0, MAX_VISIBLE_TRIALS)
.map((trial) => trial.id);
const ordered = [bestTrialId, selectedTrialId, ...completed].filter((id): id is string =>
Boolean(id),
);
return [...new Set(ordered)].slice(0, MAX_VISIBLE_TRIALS);
}
export interface SessionSlice {
endpoint: string;
token: string;
capability?: TuningCapability;
connectionState: 'idle' | 'connecting' | 'ready' | 'error';
sessions: TuningSession[];
sessionId?: string;
sessionState?: TuningSessionState;
sessionMode?: TuningSession['mode'];
sessionMessage: string;
sessionConfig?: TuningSession['config'];
objectiveWeights?: TuningSession['objectiveWeights'];
currentTrialId?: string;
bestTrialId?: string;
selectedTrialId?: string;
consecutiveNoImprove: number;
entitiesRevision: number;
trialsById: Record<string, TuningTrial>;
trialIds: string[];
proposalsById: Record<string, TuningProposal>;
proposalIds: string[];
audit: TuningSession['audit'];
error?: string;
setConnection(endpoint: string, token: string): void;
connect(): Promise<void>;
openSession(id: string): Promise<void>;
startSession(request: TuningCreateRequest): Promise<void>;
refreshSession(expectedId?: string): Promise<void>;
applySessionSnapshot(session: TuningSession): void;
selectTrial(id: string): void;
leaveSession(): void;
clearError(): void;
}
interface MetricBatch {
trialId: string;
series: ScalarSeries[];
}
export interface TrialMetricSeries extends ScalarSeries {
trialId: string;
}
export interface MetricsBufferSlice {
metricBuffers: Record<string, Record<string, ScalarRingBuffer>>;
metricVersionByTrial: Record<string, number>;
metricsRevision: number;
knownMetricTags: string[];
metricTag: string;
smoothing: number;
enqueueMetricBatch(batch: MetricBatch): void;
flushMetricBatches(): void;
clearMetricBuffers(trialId?: string): void;
setMetricTag(tag: string): void;
setSmoothing(value: number): void;
readMetricSeries(trialIds: readonly string[], tag: string): TrialMetricSeries[];
metricCursor(trialId: string, tag: string): number | undefined;
}
export interface ControlSlice {
visibleTrialIds: string[];
runPolicy: 'continuous' | 'step';
dispatchTokens: number;
constraintsRevision: number;
parameterConstraints: Record<string, ParameterConstraint>;
activeBaseTrialId?: string;
constraintsEffectiveAfterCurrent: boolean;
busyOperations: string[];
setTrialVisible(id: string, visible: boolean): void;
setVisibleTrialIds(ids: string[]): void;
pauseOrResume(): Promise<void>;
cancelSession(): Promise<void>;
setRuntimeMode(mode: TuningSession['mode']): Promise<void>;
stepNextTrial(): Promise<void>;
saveParameterConstraints(constraints: Record<string, ParameterConstraint>): Promise<void>;
rollbackToTrial(trialId: string, checkpoint?: boolean): Promise<void>;
decideProposal(
proposalId: string,
action: 'approve' | 'reject',
payload: { feedback?: string; patch?: TuningProposal['patch'] },
): Promise<void>;
}
export type TuningStore = SessionSlice & MetricsBufferSlice & ControlSlice;
type Slice<T> = StateCreator<TuningStore, [], [], T>;
function clientFrom(state: Pick<TuningStore, 'endpoint' | 'token'>): LocalTrainingClient {
return new LocalTrainingClient(state.endpoint, state.token);
}
function createSessionSlice(set: Parameters<Slice<SessionSlice>>[0], get: () => TuningStore) {
const apply = (session: TuningSession) => get().applySessionSnapshot(session);
return {
endpoint: localStored(TRAINING_ENDPOINT_KEY, DEFAULT_TRAINING_ENDPOINT),
token: sessionStored(TRAINING_TOKEN_KEY),
connectionState: 'idle' as const,
sessions: [],
sessionMessage: '',
consecutiveNoImprove: 0,
entitiesRevision: 0,
trialsById: {},
trialIds: [],
proposalsById: {},
proposalIds: [],
audit: [],
setConnection: (endpoint: string, token: string) => set({ endpoint, token }),
connect: async () => {
set({ connectionState: 'connecting', error: undefined });
try {
const api = clientFrom(get());
const [capability, sessions] = await Promise.all([
api.tuningCapability(),
api.tuningSessions(),
]);
rememberTrainingConnection(api.endpoint, api.token);
set({ capability, sessions, connectionState: 'ready' });
const remembered = localStored(TUNING_SESSION_KEY);
const target = sessions.find((item) => item.id === remembered) ?? sessions[0];
if (target) apply(await api.tuningSession(target.id));
} catch (value) {
set({ connectionState: 'error', error: errorText(value) });
}
},
openSession: async (id: string) => {
set({ connectionState: 'connecting', error: undefined });
try {
const session = await clientFrom(get()).tuningSession(id);
apply(session);
localStorage.setItem(TUNING_SESSION_KEY, id);
set({ connectionState: 'ready' });
} catch (value) {
set({ connectionState: 'error', error: errorText(value) });
}
},
startSession: async (request: TuningCreateRequest) => {
set({ connectionState: 'connecting', error: undefined });
try {
const session = await clientFrom(get()).startTuning(request);
apply(session);
localStorage.setItem(TUNING_SESSION_KEY, session.id);
set({ connectionState: 'ready' });
} catch (value) {
set({ connectionState: 'error', error: errorText(value) });
}
},
refreshSession: async (expectedId?: string) => {
const id = expectedId ?? get().sessionId;
if (!id) return;
try {
const session = await clientFrom(get()).tuningSession(id);
if (get().sessionId === id) apply(session);
} catch (value) {
if (get().sessionId === id) set({ error: errorText(value) });
}
},
applySessionSnapshot: (session: TuningSession) => {
if (get().sessionId && get().sessionId !== session.id) get().clearMetricBuffers();
set((state) => {
const trialsById = mergeEntities(state.trialsById, session.trials ?? []);
const proposalsById = mergeEntities(state.proposalsById, session.proposals ?? []);
const trialIds = stableIds(
state.trialIds,
(session.trials ?? []).map((trial) => trial.id),
);
const proposalIds = stableIds(
state.proposalIds,
(session.proposals ?? []).map((proposal) => proposal.id),
);
const trialIdSet = new Set(trialIds);
const selectedTrialId =
(state.selectedTrialId && trialIdSet.has(state.selectedTrialId)
? state.selectedTrialId
: undefined) ??
session.currentTrialId ??
session.bestTrialId ??
trialIds.at(-1);
const currentVisible = state.visibleTrialIds.filter((id) => trialIdSet.has(id));
const sameSession = state.sessionId === session.id;
const newlyImportant = sameSession
? [
session.bestTrialId !== state.bestTrialId ? session.bestTrialId : undefined,
session.currentTrialId !== state.currentTrialId ? session.currentTrialId : undefined,
]
: [];
const visibleTrialIds = sameSession
? [
...new Set(
[...newlyImportant, ...currentVisible].filter((id): id is string => Boolean(id)),
),
].slice(0, MAX_VISIBLE_TRIALS)
: defaultVisibleTrialIds(session.trials ?? [], session.bestTrialId, selectedTrialId);
const control = session.control;
return {
sessionId: session.id,
sessionState: session.state,
sessionMode: session.mode,
sessionMessage: session.message,
sessionConfig: session.config,
objectiveWeights: session.objectiveWeights,
currentTrialId: session.currentTrialId,
bestTrialId: session.bestTrialId,
selectedTrialId,
consecutiveNoImprove: session.consecutiveNoImprove,
entitiesRevision:
trialsById !== state.trialsById || proposalsById !== state.proposalsById
? state.entitiesRevision + 1
: state.entitiesRevision,
trialsById,
trialIds,
proposalsById,
proposalIds,
audit: session.audit ?? [],
visibleTrialIds: stableIds(state.visibleTrialIds, visibleTrialIds),
runPolicy: control?.runPolicy ?? 'continuous',
dispatchTokens: control?.dispatchTokens ?? 0,
constraintsRevision: control?.constraintsRevision ?? 0,
parameterConstraints: control?.constraints ?? {},
activeBaseTrialId: control?.activeBaseTrialId,
constraintsEffectiveAfterCurrent: control?.effectiveAfterCurrent ?? false,
};
});
},
selectTrial: (selectedTrialId: string) => set({ selectedTrialId }),
leaveSession: () => {
get().clearMetricBuffers();
set({
sessionId: undefined,
sessionState: undefined,
sessionMode: undefined,
sessionMessage: '',
sessionConfig: undefined,
objectiveWeights: undefined,
currentTrialId: undefined,
bestTrialId: undefined,
selectedTrialId: undefined,
consecutiveNoImprove: 0,
entitiesRevision: 0,
trialsById: {},
trialIds: [],
proposalsById: {},
proposalIds: [],
audit: [],
visibleTrialIds: [],
runPolicy: 'continuous',
dispatchTokens: 0,
constraintsRevision: 0,
parameterConstraints: {},
activeBaseTrialId: undefined,
constraintsEffectiveAfterCurrent: false,
busyOperations: [],
});
},
clearError: () => set({ error: undefined }),
} satisfies SessionSlice;
}
const pendingMetricBatches = new Map<string, Map<string, ScalarPoint[]>>();
let metricFrame = 0;
function scheduleMetricFrame(flush: () => void): void {
if (metricFrame) return;
if (typeof requestAnimationFrame === 'function') {
metricFrame = requestAnimationFrame(() => {
metricFrame = 0;
flush();
});
} else {
metricFrame = -1;
queueMicrotask(() => {
metricFrame = 0;
flush();
});
}
}
const createMetricsBufferSlice: Slice<MetricsBufferSlice> = (set, get) => ({
metricBuffers: {},
metricVersionByTrial: {},
metricsRevision: 0,
knownMetricTags: [],
metricTag: '',
smoothing: 0.25,
enqueueMetricBatch: ({ trialId, series }) => {
let trial = pendingMetricBatches.get(trialId);
if (!trial) {
trial = new Map();
pendingMetricBatches.set(trialId, trial);
}
for (const item of series) {
const points = trial.get(item.tag) ?? [];
points.push(...item.points);
trial.set(item.tag, points);
}
scheduleMetricFrame(get().flushMetricBatches);
},
flushMetricBatches: () => {
if (!pendingMetricBatches.size) return;
const batches = [...pendingMetricBatches];
pendingMetricBatches.clear();
set((state) => {
const metricBuffers = { ...state.metricBuffers };
const metricVersionByTrial = { ...state.metricVersionByTrial };
const known = new Set(state.knownMetricTags);
let changed = false;
for (const [trialId, tags] of batches) {
const trialBuffers = { ...(metricBuffers[trialId] ?? {}) };
let trialChanged = false;
for (const [tag, points] of tags) {
known.add(tag);
const buffer = trialBuffers[tag] ?? new ScalarRingBuffer(METRIC_BUFFER_CAPACITY);
if (buffer.appendMany(points)) {
trialBuffers[tag] = buffer;
trialChanged = true;
}
}
if (trialChanged) {
metricBuffers[trialId] = trialBuffers;
metricVersionByTrial[trialId] = (metricVersionByTrial[trialId] ?? 0) + 1;
changed = true;
}
}
const knownMetricTags = [...known].sort();
const tagsChanged = !sameIds(state.knownMetricTags, knownMetricTags);
if (!changed && !tagsChanged) return state;
return {
metricBuffers,
metricVersionByTrial,
metricsRevision: state.metricsRevision + (changed ? 1 : 0),
knownMetricTags: tagsChanged ? knownMetricTags : state.knownMetricTags,
};
});
},
clearMetricBuffers: (trialId) => {
if (trialId) {
pendingMetricBatches.delete(trialId);
set((state) => {
const metricBuffers = { ...state.metricBuffers };
const metricVersionByTrial = { ...state.metricVersionByTrial };
delete metricBuffers[trialId];
delete metricVersionByTrial[trialId];
return {
metricBuffers,
metricVersionByTrial,
metricsRevision: state.metricsRevision + 1,
};
});
} else {
pendingMetricBatches.clear();
set((state) => ({
metricBuffers: {},
metricVersionByTrial: {},
knownMetricTags: [],
metricTag: '',
metricsRevision: state.metricsRevision + 1,
}));
}
},
setMetricTag: (metricTag) => set({ metricTag }),
setSmoothing: (smoothing) => set({ smoothing: Math.min(0.95, Math.max(0, smoothing)) }),
readMetricSeries: (trialIds, tag) => {
const { metricBuffers } = get();
return trialIds.flatMap((trialId) => {
const buffer = metricBuffers[trialId]?.[tag];
return buffer ? [{ trialId, tag, points: buffer.snapshot() }] : [];
});
},
metricCursor: (trialId, tag) => get().metricBuffers[trialId]?.[tag]?.latestStep,
});
function createControlSlice(set: Parameters<Slice<ControlSlice>>[0], get: () => TuningStore) {
const run = async (name: string, operation: () => Promise<TuningSession>) => {
if (get().busyOperations.includes(name)) return;
set((state) => ({ busyOperations: [...state.busyOperations, name], error: undefined }));
try {
get().applySessionSnapshot(await operation());
} catch (value) {
set({ error: errorText(value) });
} finally {
set((state) => ({ busyOperations: state.busyOperations.filter((item) => item !== name) }));
}
};
return {
visibleTrialIds: [],
runPolicy: 'continuous' as const,
dispatchTokens: 0,
constraintsRevision: 0,
parameterConstraints: {},
constraintsEffectiveAfterCurrent: false,
busyOperations: [],
setTrialVisible: (id: string, visible: boolean) =>
set((state) => {
if (visible && state.visibleTrialIds.includes(id)) return state;
if (!visible)
return { visibleTrialIds: state.visibleTrialIds.filter((value) => value !== id) };
return { visibleTrialIds: [...state.visibleTrialIds, id].slice(-MAX_VISIBLE_TRIALS) };
}),
setVisibleTrialIds: (ids: string[]) =>
set({ visibleTrialIds: [...new Set(ids)].slice(0, MAX_VISIBLE_TRIALS) }),
pauseOrResume: async () => {
const { sessionId, sessionState } = get();
if (!sessionId) return;
const action =
sessionState === 'paused' || sessionState === 'interrupted' ? 'resume' : 'pause';
await run(action, () => clientFrom(get()).tuningAction(sessionId, action));
},
cancelSession: async () => {
const id = get().sessionId;
if (id) await run('cancel', () => clientFrom(get()).cancelTuning(id));
},
setRuntimeMode: async (mode: TuningSession['mode']) => {
const id = get().sessionId;
if (id && mode !== get().sessionMode)
await run('mode', () => clientFrom(get()).setTuningMode(id, mode));
},
stepNextTrial: async () => {
const id = get().sessionId;
if (id) await run('step', () => clientFrom(get()).stepTuning(id));
},
saveParameterConstraints: async (constraints: Record<string, ParameterConstraint>) => {
const { sessionId, constraintsRevision } = get();
if (sessionId)
await run('constraints', () =>
clientFrom(get()).setTuningConstraints(sessionId, constraintsRevision, constraints),
);
},
rollbackToTrial: async (trialId: string, checkpoint = false) => {
const id = get().sessionId;
if (id)
await run('rollback', () => clientFrom(get()).rollbackTuning(id, trialId, checkpoint));
},
decideProposal: async (proposalId, action, payload) => {
const id = get().sessionId;
if (id)
await run(`proposal:${proposalId}`, () =>
clientFrom(get()).decideProposal(id, proposalId, action, payload),
);
},
} satisfies ControlSlice;
}
export const useTuningStore = create<TuningStore>()((set, get, api) => ({
...createSessionSlice(set, get),
...createMetricsBufferSlice(set, get, api),
...createControlSlice(set, get),
}));
export function resetTuningStore(): void {
pendingMetricBatches.clear();
if (metricFrame > 0 && typeof cancelAnimationFrame === 'function')
cancelAnimationFrame(metricFrame);
metricFrame = 0;
useTuningStore.setState({
capability: undefined,
connectionState: 'idle',
sessions: [],
sessionId: undefined,
sessionState: undefined,
sessionMode: undefined,
sessionMessage: '',
sessionConfig: undefined,
objectiveWeights: undefined,
currentTrialId: undefined,
bestTrialId: undefined,
selectedTrialId: undefined,
consecutiveNoImprove: 0,
entitiesRevision: 0,
trialsById: {},
trialIds: [],
proposalsById: {},
proposalIds: [],
audit: [],
error: undefined,
metricBuffers: {},
metricVersionByTrial: {},
metricsRevision: 0,
knownMetricTags: [],
metricTag: '',
smoothing: 0.25,
visibleTrialIds: [],
runPolicy: 'continuous',
dispatchTokens: 0,
constraintsRevision: 0,
parameterConstraints: {},
activeBaseTrialId: undefined,
constraintsEffectiveAfterCurrent: false,
busyOperations: [],
});
}
@@ -0,0 +1,93 @@
import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { LocalTrainingClient } from '../training/LocalTrainingClient';
import { ACTIVE_SESSION_STATES } from './domain';
import { useTuningStore } from './tuningStore';
const POLL_INTERVAL_MS = 1000;
/**
* 1Hz session/tag Abort
* scalar store rAF
*/
export function useTuningPolling(): void {
const [endpoint, token, sessionId, sessionState] = useTuningStore(
useShallow(
(state) => [state.endpoint, state.token, state.sessionId, state.sessionState] as const,
),
);
const [visibleTrialIds, metricTag] = useTuningStore(
useShallow((state) => [state.visibleTrialIds, state.metricTag] as const),
);
useEffect(() => {
if (!sessionId || !sessionState || !ACTIVE_SESSION_STATES.has(sessionState)) return;
let disposed = false;
let timer = 0;
const poll = async () => {
await useTuningStore.getState().refreshSession(sessionId);
if (!disposed) timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS);
};
timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS);
return () => {
disposed = true;
window.clearTimeout(timer);
};
}, [sessionId, sessionState]);
useEffect(() => {
if (!sessionId || !token || visibleTrialIds.length === 0) return;
let disposed = false;
let timer = 0;
let controller: AbortController | undefined;
const hydratedTrials = new Set<string>();
const repeat = Boolean(sessionState && ACTIVE_SESSION_STATES.has(sessionState));
const poll = async () => {
controller = new AbortController();
const api = new LocalTrainingClient(endpoint, token);
const tags = metricTag ? [metricTag] : [];
const requests = visibleTrialIds.map(async (trialId) => {
// 切换 tag/Trial 后先补齐完整窗口,再使用 step cursor。否则用于发现 tag 的
// 128 点预览会把 cursor 推到末尾,使选中曲线永远无法回填 4096 点历史。
const cursor =
metricTag && hydratedTrials.has(trialId)
? useTuningStore.getState().metricCursor(trialId, metricTag)
: undefined;
const response = await api.tuningMetrics(
sessionId,
trialId,
tags,
metricTag ? 4096 : 128,
cursor,
controller?.signal,
);
hydratedTrials.add(trialId);
if (!disposed && useTuningStore.getState().sessionId === sessionId)
useTuningStore.getState().enqueueMetricBatch({
trialId,
series: response.series,
});
});
const results = await Promise.allSettled(requests);
if (!disposed) {
const failure = results.find(
(result): result is PromiseRejectedResult =>
result.status === 'rejected' && result.reason?.name !== 'AbortError',
);
if (failure)
useTuningStore.setState({
error:
failure.reason instanceof Error ? failure.reason.message : String(failure.reason),
});
// 终态 Session 只做一次最终补齐,避免历史会话永久保持 1 Hz 网络活动。
if (repeat) timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS);
}
};
void poll();
return () => {
disposed = true;
controller?.abort();
window.clearTimeout(timer);
};
}, [endpoint, metricTag, sessionId, sessionState, token, visibleTrialIds]);
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" class="theme-dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />