This commit is contained in:
Horldsence
2026-09-11 21:00:39 +08:00
parent 2a3ead5d3d
commit 1725cd0891
168 changed files with 170687 additions and 874 deletions
+35 -5
View File
@@ -257,19 +257,38 @@ def _run(args) -> int:
else:
strategy = args.strategy
loop_cls = Q3_STRATEGIES.get(strategy)
high_policy = None
low_policy = None
try:
if strategy == "rl":
# 惰性导入:默认运行闭环不加载 numpy/torch/stable-baselines3。
from .planner.rl_loop import RLBayesLoop, RLDirectionalLoop
loop_cls = RLBayesLoop if args.problem == 3 else RLDirectionalLoop
elif strategy == "hrl":
# P1:高层 MaskablePPO 调度。缺少模型时显式报错,不降级伪装。
if not args.hrl_model:
raise RuntimeError(
"--strategy hrl/--q4-strategy hrl 需要 --hrl-model;"
"无模型启发式请使用 benchmark_hrl.py 的 hrl_heuristic 变体。")
from .hrl.policy import HighLevelPolicy, LowLevelPolicy
from .planner.hrl_loop import HRLLoop
high_policy = HighLevelPolicy(
args.hrl_model, device=args.hrl_device, problem=args.problem)
if args.hrl_low_model:
low_policy = LowLevelPolicy(args.hrl_low_model, device=args.hrl_device)
loop_cls = HRLLoop
if loop_cls is None:
raise RuntimeError(f"未知策略: {strategy}")
if strategy == "rl":
summary = loop_cls(client, config, model_path=args.rl_model).run()
elif strategy == "hrl":
summary = loop_cls(
client, config, problem=args.problem, high_policy=high_policy,
low_policy=low_policy, trace_executor=args.trace_low).run()
else:
summary = loop_cls(client, config).run()
except RuntimeError as exc:
print(f"RL 策略不可用: {exc}", file=sys.stderr)
print(f"策略不可用: {exc}", file=sys.stderr)
return 2
finally:
if judge is not None:
@@ -323,19 +342,30 @@ def main(argv=None) -> int:
run.add_argument("--log", default=None, help="会话日志 JSONL 输出路径")
run.add_argument("--problem", type=int, choices=(3, 4), default=3,
help="问题3=全向源,问题4=混合定向源(默认 3)")
run.add_argument("--strategy", choices=tuple(Q3_STRATEGIES) + ("rl",),
run.add_argument("--strategy", choices=tuple(Q3_STRATEGIES) + ("rl", "hrl"),
default="bayes",
help="问题3策略:bayes=粒子滤波+信息增益+滚动调度(默认,"
"自建消融相对 seven 再降 19.5%%),seven=七点批量,"
"grid=49点网格基线,rl=PPO 强化学习选点(需先训练模型);"
"grid=49点网格基线,rl=PPO 强化学习选点(需先训练模型),"
"hrl=高层 MaskablePPO 分层调度(需 --hrl-model);"
"仅 --problem 3 生效")
run.add_argument("--q4-strategy", choices=tuple(Q4_STRATEGIES) + ("rl",),
run.add_argument("--q4-strategy", choices=tuple(Q4_STRATEGIES) + ("rl", "hrl"),
default="directional",
help="问题4策略:directional=49点覆盖+遮挡梯子+后备清除(默认),"
"rl=PPO 强化学习选点(需先训练模型);仅 --problem 4 生效")
"rl=PPO 强化学习选点(需先训练模型),"
"hrl=高层 MaskablePPO 分层调度(需 --hrl-model);仅 --problem 4 生效")
run.add_argument("--rl-model", default=None,
help="RL 模型路径(默认 output/rl/q3_ppo.zip 或 "
"output/rl/q4_ppo.zip;训练脚本 scripts/train_rl.py)")
run.add_argument("--hrl-model", default=None,
help="高层 HRL MaskablePPO 模型路径(--strategy hrl 时必填;"
"训练脚本 scripts/train_hrl_high.py)")
run.add_argument("--hrl-low-model", default=None,
help="可选低层 HRL 模型;当前只用于轨迹推演留痕,不改变协议终点")
run.add_argument("--hrl-device", choices=("auto", "cpu", "cuda", "mps"),
default="auto", help="HRL 模型推理设备")
run.add_argument("--trace-low", action="store_true",
help="开启低层到达推演留痕(本地虚拟轨迹,不增加协议耗时)")
run.add_argument("--bearing-margin-deg", type=float, default=0.005,
help="示向度舍入裕量(服务器保留两位小数;默认 0.005°)")
run.add_argument("--real-budget-s", type=float, default=None,
+14
View File
@@ -0,0 +1,14 @@
"""分层强化学习实验:整场子目标调度环境与独立的八方向到达实验。
与 :mod:`mcm26b.rl`(单步 PPO 选点)的差异:高层在整场 10–16 源的统一
候选槽位上做调度决策(证书清除 / 信息增益定位 / 覆盖站扫频 / 后备清除),
当前运行闭环将子目标端点提交协议,八方向策略仅用于可选本地路径留痕,
不影响真实请求及虚拟计时。完整高层训练、CLI 与配对评估仍待接通;
优化架构及逐项验收见 docs/hrl-sol-execution.md。
信念(belief + 网格幸存集)、证书审批与后备清除复用 planner 基础设施;
每次改动仍须验证真源保留和真实全清,不能用内部状态自洽替代评测。
本包属于训练/推理侧组件,运行时对依赖保持惰性(stable-baselines3 /
sb3-contrib 仅在显式加载模型时导入)。
"""
+49
View File
@@ -0,0 +1,49 @@
"""HRL 观测/动作空间常量:模型接口的唯一字面量来源。
槽位布局(训练与推理共享,改动任一值都会使已训练模型失配,须同步重训):
0–19 clear 频道 c 的证书清除(槽位 = c-1,证书就绪才有效)
20–39 locate 频道 c 的定位测量(信息增益选点或定向遮挡梯子探点)
40–88 station 覆盖站(问题3:七点环 0–6 有效;问题4:49 点网格)
89–108 fallback 频道 c 的后备网格清除(仅问题4,遮挡梯子耗尽后开放)
109–119 保留(恒屏蔽)
高层观测 = 全局 8 维 + 20 频道 × 14 维 + 120 槽位 × 8 维 = 1248 维;
低层观测 10 维、动作 Discrete(8)(8 方向,步长 ≤100 m)。
"""
FEATURE_VERSION = 4
ACTION_LAYOUT_VERSION = 1
REWARD_VERSION = 1
CHANNEL_COUNT = 20
# ------------------------------------------------------------ 高层动作槽位
CLEAR_SLOTS = CHANNEL_COUNT
LOCATE_SLOTS = CHANNEL_COUNT
STATION_SLOTS = 49
FALLBACK_SLOTS = CHANNEL_COUNT
CLEAR_OFFSET = 0
LOCATE_OFFSET = CLEAR_OFFSET + CLEAR_SLOTS
STATION_OFFSET = LOCATE_OFFSET + LOCATE_SLOTS
FALLBACK_OFFSET = STATION_OFFSET + STATION_SLOTS
N_CANDIDATES = 120
# ------------------------------------------------------------ 高层观测
PER_CHANNEL_DIM = 14
HIGH_GLOBAL_DIM = 8
PER_CANDIDATE_DIM = 8
HIGH_OBS_DIM = (HIGH_GLOBAL_DIM + CHANNEL_COUNT * PER_CHANNEL_DIM
+ N_CANDIDATES * PER_CANDIDATE_DIM) # 1248
# ------------------------------------------------------------ 低层接口
LOW_OBS_DIM = 10
LOW_NUM_ACTIONS = 8 # 8 方向,45° 等分
LOW_STEP_M = 100.0 # 每步最大步长 [题面 低层动作 50–100 m]
LOW_REACH_M = 20.0 # 到达阈值(与清除半径一致)
LOW_MAX_STEPS = 60 # 直径穿越 ~51 步上界
# ------------------------------------------------------------ 观测归一化
POS_SCALE_M = 2000.0 # 位置/位移归一化尺度(Ω 半径 1800 m 外扩)
RADIUS_SCALE_M = 2000.0
COST_SCALE_S = 600.0 # 槽位预计服务耗时归一化尺度
MEASURE_COUNT_SCALE = 12.0
+109
View File
@@ -0,0 +1,109 @@
"""课程学习与域随机化:训练场景按阶段由易到难(prompt §7)。
四阶段课程:1 源/5 频道/无噪声 → 3 源/10 频道/±0.5° → 8 源/16 频道/±1°
→ 10–16 源/20 频道/±1°(问题4 最后阶段按比例混入定向源)。域随机化
覆盖源位置(Ω 圆域均匀)、频道(互异抽样)、有效接收半径、朝向与逐局
误差种子 [prompt §7.2];机器狗初始位置固定 (0,0)——协议语义如此,
随机化它反而制造训练-部署差异(与 v1"训练部署特征错位"教训一致)。
真值只进入训练/评估场景生成,不进入任何观测;策略端仅见模拟器响应。
"""
from __future__ import annotations
import dataclasses
import math
from dataclasses import dataclass
from random import Random
from ..config import ProblemConfig
@dataclass(frozen=True)
class Stage:
"""课程阶段参数:源数范围、频道上限、示向度噪声与定向源比例。"""
name: str
sources_min: int
sources_max: int
channel_max: int # 频道 > channel_max 的频道保证无源
bearing_error_half_deg: float
directional_ratio: float # 问题4 最后阶段定向源比例;问题3 恒 0
recommended_timesteps: int
STAGES: tuple[Stage, ...] = (
Stage("s1-single", 1, 1, 5, 0.0, 0.0, 60_000),
Stage("s2-few", 3, 3, 10, 0.5, 0.0, 80_000),
Stage("s3-many", 8, 8, 16, 1.0, 0.0, 100_000),
Stage("s4-full", 10, 16, 20, 1.0, 0.4, 140_000),
)
def stage_with_noise(config: ProblemConfig, stage: Stage) -> ProblemConfig:
"""把阶段噪声写入 ProblemConfig(belief/PF 的保守裕量随之同步)。"""
field = dataclasses.replace(
config.field, bearing_error_half_deg=stage.bearing_error_half_deg)
return dataclasses.replace(config, field=field)
# 快速模式只减少选点评分的样本,不改变证明使用的幸存网格。29.3m 格距的
# Voronoi 裕量约20.72m,已经超过20m清除半径,会使粒子证书永远不可达。
# 默认使用生产配置;快速模式必须显式指定并在训练元数据记录。
TRAINING_SCORE_SUBSAMPLE = 240
TRAINING_MC_REPRESENTATIVES = 5
def training_config(config: ProblemConfig, stage: Stage, *,
fidelity: str = "production") -> ProblemConfig:
"""阶段噪声配置;fast只降低评分采样量,production与部署同参数。"""
if fidelity not in ("production", "fast"):
raise ValueError("fidelity 必须是 production 或 fast")
noisy = stage_with_noise(config, stage)
if fidelity == "production":
return noisy
bayes = dataclasses.replace(
noisy.bayes,
score_subsample=TRAINING_SCORE_SUBSAMPLE,
mc_representatives=TRAINING_MC_REPRESENTATIVES)
return dataclasses.replace(noisy, bayes=bayes)
def make_scene(rng: Random, config: ProblemConfig, problem: int,
stage: Stage, *, domain_randomization: bool = True
) -> tuple[list, list[int]]:
"""生成一局课程场景,返回 (referee.Source 列表, 已占用频道列表)。
源位置在 Ω 圆域均匀分布、频道从 1..stage.channel_max 互异抽样、
有效接收半径 [1000,1500] 均匀 [题面];问题4 按 stage.directional_ratio
混入定向源,朝向均匀。domain_randomization=False 时只用 rng 的确定
序列(仍随机但可整场复现),供评估固定场景对照。
"""
from referee import Source
field = config.field
count = rng.randint(stage.sources_min, stage.sources_max)
channels = rng.sample(range(field.channel_min, stage.channel_max + 1), count)
sources = []
for channel in channels:
radius = field.omega_radius_m * math.sqrt(rng.random())
angle = rng.uniform(0.0, 2.0 * math.pi)
r_eff = rng.uniform(field.r_eff_min_m, field.r_eff_max_m)
directional = (problem == 4
and rng.random() < stage.directional_ratio)
heading = rng.uniform(0.0, 360.0) if directional else 0.0
if not domain_randomization:
# 固定化退耦:半径/朝向取区间代表值,保留位置随机(评估用)。
r_eff = field.r_eff_min_m
sources.append(Source(channel=channel, x=radius * math.cos(angle),
y=radius * math.sin(angle), r_eff_m=r_eff,
directional=directional, heading_deg=heading))
return sources, channels
def training_spec(config: ProblemConfig, stage: Stage, *,
max_real_duration_s: float = 3600.0):
"""带阶段噪声的 referee Spec(训练侧使用;正式对接以 /enter 为准)。"""
from ..harness import referee_spec
return referee_spec(stage_with_noise(config, stage),
max_real_duration_s=max_real_duration_s)
+113
View File
@@ -0,0 +1,113 @@
"""低层到达执行器与观测:8 方向步进(prompt §4.2),训练/部署同源。
低层只负责"怎么去":给定当前位置与子目标,输出离散 8 方向步进序列。
步长每步 ≤ ``LOW_STEP_M``,近目标时缩短步长;到达阈值 ``LOW_REACH_M``
与清除半径一致。推演完全本地,不与模拟器交互、不产生虚拟耗时——协议
语义下移动耗时 = 两次动作位置间的直线距离 ÷ 速度,仅由终点决定。
本模块仅供独立导航实验和轨迹诊断;当前 HTTP 执行路径仍直接服务高层
目标,不能据此声称低层 PPO 改善正式任务耗时。policy=None 使用解析
直线控制器;PPO 推演超时不会免费跳到目标。
"""
from __future__ import annotations
import math
import numpy as np
from ..config import Vec2
from .constants import (LOW_MAX_STEPS, LOW_NUM_ACTIONS, LOW_OBS_DIM,
LOW_REACH_M, LOW_STEP_M, POS_SCALE_M)
__all__ = ["build_low_observation", "LowLevelExecutor", "action_bearing",
"navigation_transition"]
def action_bearing(action: int) -> float:
"""离散方向 -> 弧度方位(0 = +x,逆时针 45° 等分)。"""
return float(action) * 2.0 * math.pi / LOW_NUM_ACTIONS
def navigation_transition(position: Vec2, goal: Vec2, action: int, *,
step_m: float = LOW_STEP_M) -> tuple[Vec2, float]:
"""训练与轨迹推演共享的运动学;返回位置和实际位移长度。
方向仍由策略决定,近目标时仅缩短步长,不把位置吸附到目标。
这是无障碍平面导航实验;不引入观测不可见的绝对位置裁剪。
"""
if not 0 <= int(action) < LOW_NUM_ACTIONS:
raise ValueError("低层动作必须是 0..7")
if not math.isfinite(step_m) or step_m <= 0:
raise ValueError("step_m 必须是有限正数")
distance = min(float(step_m), math.dist(position, goal))
bearing = action_bearing(int(action))
next_position = (position[0] + distance * math.cos(bearing),
position[1] + distance * math.sin(bearing))
return next_position, math.dist(position, next_position)
def build_low_observation(position: Vec2, goal: Vec2, prev_action: int | None,
steps: int) -> np.ndarray:
"""低层观测(shape=(LOW_OBS_DIM,),训练/推理逐位一致)。"""
dx, dy = goal[0] - position[0], goal[1] - position[1]
distance = math.hypot(dx, dy)
bearing = math.atan2(dy, dx)
prev_sin = prev_cos = 0.0
if prev_action is not None:
prev_bearing = action_bearing(prev_action)
prev_sin, prev_cos = math.sin(prev_bearing), math.cos(prev_bearing)
return np.asarray([
dx / POS_SCALE_M,
dy / POS_SCALE_M,
distance / POS_SCALE_M,
math.sin(bearing),
math.cos(bearing),
steps / LOW_MAX_STEPS,
prev_sin,
prev_cos,
min(distance / LOW_STEP_M, 3.0) / 3.0,
1.0,
], dtype=np.float32)
class LowLevelExecutor:
"""子目标到达推演:低层 PPO 或直线控制器(policy=None)。"""
def __init__(self, policy=None, *, step_m: float = LOW_STEP_M,
reach_m: float = LOW_REACH_M, max_steps: int = LOW_MAX_STEPS):
self.policy = policy
self.step_m = float(step_m)
self.reach_m = float(reach_m)
self.max_steps = int(max_steps)
self.last_roll_info: dict = {}
def roll(self, start: Vec2, goal: Vec2) -> list[Vec2]:
"""返回真实推演途经点;last_roll_info 如实记录到达/截断。"""
goal = (float(goal[0]), float(goal[1]))
if self.policy is None:
self.last_roll_info = {"reached": True, "truncated": False,
"final_distance_m": 0.0,
"path_length_m": math.dist(start, goal)}
return [goal]
position = (float(start[0]), float(start[1]))
waypoints: list[Vec2] = []
prev_action: int | None = None
path_length = 0.0
for steps in range(self.max_steps):
if math.dist(position, goal) <= self.reach_m:
break
observation = build_low_observation(position, goal, prev_action,
steps)
action = int(self.policy.select(observation))
position, moved = navigation_transition(position, goal, action,
step_m=self.step_m)
path_length += moved
waypoints.append(position)
prev_action = action
distance = math.dist(position, goal)
self.last_roll_info = {"reached": distance <= self.reach_m,
"truncated": distance > self.reach_m,
"final_distance_m": distance,
"path_length_m": path_length}
return waypoints
+261
View File
@@ -0,0 +1,261 @@
"""高层 PPO 训练环境:整场 10–16 源的发现-定位-清除调度(prompt §2/§5.2)。
与 v2 :class:`~mcm26b.rl.env.LocalizationEnv`(单源 ≤16 步子任务)的本质
差异:本环境的每一步是一次完整的高层调度决策(候选槽位选择 + 任务执行),
环境语义与运行闭环 :class:`~mcm26b.planner.hrl_loop.HRLLoop` 共用同一
混入层(hrl.scheduler),训练-部署观测逐位同源。
奖励(prompt §5.2 的可折算实现,权重见 :class:`HRLReward`):
r = -Δ虚拟耗时/20 + 100·清除 + 5·新发现 + 0.5·Σ熵减 - 2·冗余检测
终止:全部频道清除/证明缺席(+50);截断仍有未决频道(-100);异常(-100)。
主口径是虚拟耗时——与题目目标函数(最小化平均定位清除时间/虚拟总时间)
一致,清除/发现/熵减是塑形项(消融实验逐项验证其必要性)。
真值只在 reset 布景时使用,绝不进入观测;感知读数全部来自
referee.LocalSimulator(与官方 HTTP 模拟器同协议语义)。
"""
from __future__ import annotations
from dataclasses import dataclass
from random import Random
import gymnasium as gym
import numpy as np
from gymnasium import spaces
from ..belief import ChannelStatus
from ..config import ProblemConfig
from ..hrl.curriculum import STAGES, Stage, make_scene, training_config
from ..hrl.constants import HIGH_OBS_DIM, N_CANDIDATES
from ..planner.hrl_loop import HRLLoop
from ..protocol.client import SimulatorClient
from ..protocol.log import SessionLog
__all__ = ["HRLReward", "HierarchicalEnv"]
@dataclass
class HRLReward:
"""高层奖励权重。
默认保留 prompt §5.2 的可解释塑形,便于对照实验;P0.4 建议的
纯时间口径可通过 :meth:`time_only` 显式启用,训练脚本默认使用它。
"""
time_scale: float = 20.0 # r -= Δ虚拟耗时 / time_scale
clear: float = 100.0 # 每清除一个源
detect: float = 5.0 # 每新发现一个频道有信号
info_weight: float = 0.5 # 粒子云熵减(log 幸存格数)内在奖励
redundant: float = 2.0 # 同频道同点重复检测惩罚
invalid: float = 2.0 # 掩码外动作(无掩码消融路径)
terminal_accounted: float = 50.0 # 全部频道清除/证毕的收尾奖励
terminal_unresolved: float = 100.0 # 截断/停机时仍有未决频道的惩罚
failure: float = 100.0 # 协议/守恒性异常惩罚
@classmethod
def time_only(cls) -> "HRLReward":
"""P0.4 纯时间目标:只惩罚已发生虚拟时间,去掉塑形项。
终止奖/罚是固定常数,不逐源叠加;失败罚按虚拟时限/时间尺度
设定为足以覆盖一整局时间成本。
"""
return cls(
time_scale=100.0,
clear=0.0,
detect=0.0,
info_weight=0.0,
redundant=0.0,
invalid=2.0,
terminal_accounted=30.0,
terminal_unresolved=300.0,
failure=3600.0,
)
class HierarchicalEnv(gym.Env):
"""整场分层调度 Gymnasium 环境(问题3/4,课程参数化)。
stage=None 时使用 STAGES 末阶段(完整任务口径);课程训练按阶段
传入。domain_randomization=False 保留随机位置但固定半径代表值,
供配对评估。
"""
metadata = {"render_modes": []}
def __init__(self, config: ProblemConfig | None = None, *,
problem: int = 3, stage: Stage | None = None,
max_episode_steps: int = 300,
domain_randomization: bool = True,
reward: HRLReward | None = None,
real_budget_s: float = 3600.0,
error_seed: int | None = None,
fidelity: str = "production"):
super().__init__()
if problem not in (3, 4):
raise ValueError("problem 必须是 3 或 4")
self.base_config = config or ProblemConfig()
self.problem = int(problem)
self.stage = stage or STAGES[-1]
self.max_episode_steps = int(max_episode_steps)
self.domain_randomization = bool(domain_randomization)
self.reward = reward or HRLReward()
self.real_budget_s = float(real_budget_s)
self.error_seed = error_seed
self.fidelity = fidelity
# 默认与部署同精度;fast仅减少评分采样,保留证书网格精度。
self.train_config = training_config(self.base_config, self.stage,
fidelity=fidelity)
self.observation_space = spaces.Box(low=-12.0, high=12.0,
shape=(HIGH_OBS_DIM,),
dtype=np.float32)
self.action_space = spaces.Discrete(N_CANDIDATES)
self._scene_rng = Random()
self._loop: HRLLoop | None = None
self._client: SimulatorClient | None = None
self._steps = 0
self._terminated = False
self._truncated = False
self._start_virtual_s = 0.0
self._current_candidates: list = []
self._failure_reason: str | None = None
self._last_outcome = None
# ------------------------------------------------------------ 场景
def _make_episode(self) -> None:
from ..harness import LocalSimTransport
from referee import LocalSimulator
sources, _channels = make_scene(
self._scene_rng, self.train_config, self.problem, self.stage,
domain_randomization=self.domain_randomization)
self._simulator = LocalSimulator(
sources, robot_id="hrl-train",
spec=self._training_spec(),
error_seed=(self.error_seed if self.error_seed is not None
else self._scene_rng.randint(0, 2 ** 31 - 1)),
stress_mode=True)
self._client = SimulatorClient(
"hrl-train", config=self.train_config,
transport=LocalSimTransport(self._simulator), log=SessionLog())
self._client.enter()
self._loop = HRLLoop(self._client, self.train_config,
problem=self.problem)
def _training_spec(self):
from ..harness import referee_spec
from ..hrl.curriculum import training_spec
return training_spec(self.train_config, self.stage,
max_real_duration_s=self.real_budget_s)
# ------------------------------------------------------------ Gym API
def reset(self, *, seed: int | None = None, options: dict | None = None):
super().reset(seed=seed)
if seed is not None:
self._scene_rng = Random(seed)
self._steps = 0
self._terminated = False
self._truncated = False
self._failure_reason = None
self._make_episode()
self._start_virtual_s = self._client.state.virtual_time_s
self._current_candidates = self._loop.build_candidates()
observation = self._loop.hrl_observation(self._current_candidates)
return observation, self._info()
def action_masks(self) -> np.ndarray:
"""MaskablePPO 动作掩码接口(与观测同源的候选槽位有效性)。"""
return self._loop.hrl_action_mask(self._current_candidates)
def _info(self) -> dict:
loop = self._loop
belief = loop.belief
cleared = len(loop.cleared)
absent = sum(1 for ch in belief.channels.values()
if ch.status is ChannelStatus.ABSENT)
return {
"steps": self._steps,
"cleared": cleared,
"absent_proven": absent,
"accounted": cleared + absent,
"virtual_time_s": self._client.state.virtual_time_s,
"episode_virtual_cost_s": (self._client.state.virtual_time_s
- self._start_virtual_s),
"stop_reason": loop.stop_reason,
"failure_reason": self._failure_reason,
"anomalies": list(loop.anomalies),
}
def step(self, action):
if self._terminated or self._truncated:
raise RuntimeError("step() 需要在 terminated/truncated 后先 reset()")
loop = self._loop
self._steps += 1
try:
outcome = loop.hrl_step(action, candidates=self._current_candidates)
except Exception as exc: # 协议/守恒性异常:与 v2 环境同口径收尾
self._failure_reason = type(exc).__name__ + ': ' + str(exc)
observation = np.zeros((HIGH_OBS_DIM,), dtype=np.float32)
self._terminated = True
return (observation, -self.reward.failure, True, False,
self._info())
self._last_outcome = outcome
if outcome.invalid:
reward = -self.reward.invalid
else:
reward = self._reward_from(outcome)
# 终止判定:停机原因/全部证毕;或候选耗尽时走运行闭环 run() 的
# 同款安全网(覆盖缺席标记 + 阻塞定位清除),安全网的虚拟耗时
# 计入本步奖励(归因给促成该局面的决策序列)。
terminated = loop.stop_reason is not None or loop._all_accounted()
if not terminated:
self._current_candidates = loop.build_candidates()
if not self._current_candidates:
virtual_before = self._client.state.virtual_time_s
loop._mark_absent_after_coverage()
loop._resolve_detected()
reward -= (self._client.state.virtual_time_s - virtual_before) \
/ self.reward.time_scale
self._current_candidates = []
terminated = True
else:
self._current_candidates = []
if terminated:
reward += (self.reward.terminal_accounted
if loop._all_accounted()
else -self.reward.terminal_unresolved)
truncated = False
if not terminated and self._steps >= self.max_episode_steps:
truncated = True
if not loop._all_accounted():
reward -= self.reward.terminal_unresolved
self._terminated = terminated
self._truncated = truncated
if terminated or truncated:
observation = np.zeros((HIGH_OBS_DIM,), dtype=np.float32)
else:
self._current_candidates = loop.build_candidates()
observation = loop.hrl_observation(self._current_candidates)
return observation, float(reward), terminated, truncated, self._info()
def _reward_from(self, outcome) -> float:
reward = -outcome.delta_virtual_s / self.reward.time_scale
reward += self.reward.clear * len(outcome.clear_success)
reward += self.reward.detect * len(outcome.new_detections)
reward += self.reward.info_weight * sum(outcome.info_gains.values())
if outcome.redundant:
reward -= self.reward.redundant
return reward
def close(self):
client = getattr(self, "_client", None)
if client is not None and client.state.entered \
and not client.state.exited:
try:
client.exit()
except Exception:
pass
+158
View File
@@ -0,0 +1,158 @@
"""低层 PPO 训练环境:随机起点-子目标的 8 方向到达任务(prompt §4.2/§5.1)。
奖励为实际位移耗时惩罚、转向惩罚与到达奖励。步长上限 100m,近目标
缩短步长,避免固定步长振荡。此环境仅用于独立导航实验;正式 HTTP
路径尚不执行低层 PPO 的途经点,不能将导航成绩当成正式任务收益。
策略端只见相对位姿观测,真值(子目标坐标)经相对向量进入观测,
与部署执行器完全同源(build_low_observation)。
"""
from __future__ import annotations
import math
from random import Random
import gymnasium as gym
import numpy as np
from gymnasium import spaces
from ..config import ProblemConfig
from .constants import (LOW_MAX_STEPS, LOW_NUM_ACTIONS, LOW_OBS_DIM,
LOW_REACH_M, LOW_STEP_M, POS_SCALE_M)
from .executor import (action_bearing, build_low_observation,
navigation_transition)
__all__ = ["LowLevelEnv"]
class LowLevelEnv(gym.Env):
"""低层到达任务:观测 10 维,动作 Discrete(8),逐步计费。"""
metadata = {"render_modes": []}
def __init__(self, config: ProblemConfig | None = None, *,
seed: int | None = None,
step_m: float = LOW_STEP_M, reach_m: float = LOW_REACH_M,
max_steps: int = LOW_MAX_STEPS,
min_distance_m: float = 300.0,
turn_penalty: float = 0.05, reach_bonus: float = 2.0,
step_cost_scale: float = 100.0,
truncation_penalty: float = 2.0):
super().__init__()
self.config = config or ProblemConfig()
self.step_m = float(step_m)
self.reach_m = float(reach_m)
self.max_steps = int(max_steps)
self.min_distance_m = float(min_distance_m)
self.turn_penalty = float(turn_penalty)
self.reach_bonus = float(reach_bonus)
self.step_cost_scale = float(step_cost_scale)
self.truncation_penalty = float(truncation_penalty)
self.observation_space = spaces.Box(low=-np.inf, high=np.inf,
shape=(LOW_OBS_DIM,),
dtype=np.float32)
self.action_space = spaces.Discrete(LOW_NUM_ACTIONS)
self._rng = Random(seed)
self._position = (0.0, 0.0)
self._goal = (0.0, 0.0)
self._steps = 0
self._prev_action: int | None = None
self._terminated = False
self._truncated = False
self._path_length_m = 0.0
self._virtual_time_s = 0.0
self._straight_m = 0.0
# ------------------------------------------------------------ Gym API
def reset(self, *, seed: int | None = None, options: dict | None = None):
super().reset(seed=seed)
if seed is not None:
self._rng = Random(seed)
radius = self.config.field.omega_radius_m
self._position = self._sample_point(radius)
for _ in range(100):
self._goal = self._sample_point(radius)
if math.dist(self._position, self._goal) >= self.min_distance_m:
break
self._steps = 0
self._prev_action = None
self._terminated = False
self._truncated = False
self._path_length_m = 0.0
self._virtual_time_s = 0.0
self._straight_m = math.dist(self._position, self._goal)
return self._observe(), self._info()
def action_masks(self) -> np.ndarray:
"""无障碍低层八个方向均可用,兼容 MaskablePPO。"""
return np.ones(LOW_NUM_ACTIONS, dtype=bool)
def _sample_point(self, radius: float) -> tuple[float, float]:
angle = self._rng.uniform(0.0, 2.0 * math.pi)
distance = radius * math.sqrt(self._rng.random())
return (distance * math.cos(angle), distance * math.sin(angle))
def step(self, action):
if self._terminated or self._truncated:
raise RuntimeError("step() 需要在 terminated/truncated 后先 reset()")
action = int(action)
if self._prev_action is not None:
turn = abs((action_bearing(action)
- action_bearing(self._prev_action) + math.pi)
% (2.0 * math.pi) - math.pi) / math.pi
else:
turn = 0.0
self._position, moved = navigation_transition(
self._position, self._goal, action, step_m=self.step_m)
self._prev_action = action
self._steps += 1
step_cost_s = moved / self.config.cost.speed_mps
self._path_length_m += moved
self._virtual_time_s += step_cost_s
reward = -step_cost_s / self.step_cost_scale
reward -= self.turn_penalty * turn
distance = math.dist(self._position, self._goal)
terminated = distance <= self.reach_m
truncated = False
if terminated:
reward += self.reach_bonus
elif self._steps >= self.max_steps:
truncated = True
reward -= self.truncation_penalty
self._terminated = terminated
self._truncated = truncated
info = self._info()
info["final_distance_m"] = distance
info["step_distance_m"] = moved
info["step_virtual_time_s"] = step_cost_s
return self._observe(), float(reward), terminated, truncated, info
def _observe(self) -> np.ndarray:
return build_low_observation(self._position, self._goal,
self._prev_action, self._steps)
def _info(self) -> dict:
return {
"position": self._position,
"goal": self._goal,
"steps": self._steps,
"distance_m": math.dist(self._position, self._goal),
"straight_m": self._straight_m,
"path_length_m": self._path_length_m,
"virtual_time_s": self._virtual_time_s,
}
# ------------------------------------------------------------ 专家(BC)
def expert_action(self) -> int:
"""直线专家:选择方位最接近子目标方向的离散方向(BC 标签)。"""
bearing = math.atan2(self._goal[1] - self._position[1],
self._goal[0] - self._position[0])
best, best_gap = 0, float("inf")
for action in range(LOW_NUM_ACTIONS):
gap = abs((action_bearing(action) - bearing + math.pi)
% (2.0 * math.pi) - math.pi)
if gap < best_gap:
best, best_gap = action, gap
return best
+135
View File
@@ -0,0 +1,135 @@
"""HRL 策略加载与推理(运行时接入 planner 的唯一入口,模式同 rl.policy)。
模型文件由 scripts/train_hrl_low.py / train_hrl_high.py 生成,并伴随同名
JSON 元数据(feature_version/algorithm/problem/维度);加载时校验,失配
即拒绝运行,杜绝静默错位。stable-baselines3 / torch 均为惰性导入。
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from .constants import (ACTION_LAYOUT_VERSION, FEATURE_VERSION,
HIGH_OBS_DIM, LOW_NUM_ACTIONS, LOW_OBS_DIM,
N_CANDIDATES, REWARD_VERSION)
__all__ = ["HighLevelPolicy", "LowLevelPolicy",
"default_high_model_path", "default_low_model_path"]
def _resolve_device(device: str) -> str:
if device != "auto":
return device
try:
import torch
return "cuda" if torch.cuda.is_available() else "cpu"
except ImportError:
return "cpu"
def _load_maskable(model_path: Path, device: str, *,
obs_dim: int, act_dim: int, problem: int):
if not model_path.is_file():
raise RuntimeError(
f"未找到 HRL 模型 {model_path}。请先训练:\n"
f" uv sync --group rl --group dev\n"
f" uv run python scripts/train_hrl_high.py --problem {problem}")
try:
from sb3_contrib import MaskablePPO
except ImportError as exc:
raise RuntimeError(
"缺少 RL 运行依赖(sb3-contrib)。请执行 `uv sync --group rl` "
"后重试。") from exc
meta_path = model_path.with_suffix('.json')
metadata = json.loads(meta_path.read_text(encoding='utf-8')) \
if meta_path.exists() else {}
# 高层模型必须携带完整语义元数据;缺字段不能默认兼容,防止旧槽位/
# 旧奖励语义模型被静默加载。低层导航模型只校验基础特征版本。
strict = (act_dim == N_CANDIDATES)
if strict:
required = ("feature_version", "action_layout_version",
"reward_version", "algorithm", "problem",
"obs_dim", "act_dim")
missing = [key for key in required if key not in metadata]
if missing:
raise RuntimeError(
f"HRL 高层模型元数据缺少字段 {missing};请用当前 "
f"scripts/train_hrl_high.py 重训。")
if metadata.get("feature_version") != FEATURE_VERSION:
raise RuntimeError(
f"HRL 模型特征版本失配: 期望 {FEATURE_VERSION},"
f"实际 {metadata.get('feature_version')}。请用当前版本重训。")
if metadata.get("action_layout_version") != ACTION_LAYOUT_VERSION:
raise RuntimeError(
f"HRL 模型动作布局版本失配: 期望 {ACTION_LAYOUT_VERSION},"
f"实际 {metadata.get('action_layout_version')}。请重训。")
if metadata.get("reward_version") != REWARD_VERSION:
raise RuntimeError(
f"HRL 模型奖励版本失配: 期望 {REWARD_VERSION},"
f"实际 {metadata.get('reward_version')}。请重训。")
else:
if metadata.get("feature_version") != FEATURE_VERSION:
raise RuntimeError(
f"低层模型特征版本失配: 期望 {FEATURE_VERSION},"
f"实际 {metadata.get('feature_version')}。请重训。")
if metadata.get("algorithm") != "maskable_ppo":
raise RuntimeError("HRL 模型必须是 maskable_ppo 训练产物")
if problem is not None and metadata.get("problem") not in (None, problem):
raise RuntimeError("模型训练问题编号与当前第三/四问不匹配")
model = MaskablePPO.load(str(model_path), device=device)
try:
loaded_obs = model.observation_space.shape[0]
loaded_act = model.action_space.n
except Exception as exc:
raise RuntimeError(f"模型空间与 HRL 接口不兼容: {model_path}") from exc
if loaded_obs != obs_dim or loaded_act != act_dim:
raise RuntimeError(
f"模型接口失配: 期望 obs={obs_dim}/act={act_dim},"
f"实际 obs={loaded_obs}/act={loaded_act}。请重训。")
return model
class HighLevelPolicy:
"""高层子目标调度策略:观测+掩码 -> 槽位编号(确定性 argmax)。"""
def __init__(self, model_path: str | Path, *, device: str = "auto",
problem: int | None = None):
self.device = _resolve_device(device)
self.path = Path(model_path)
self.model = _load_maskable(self.path, self.device, obs_dim=HIGH_OBS_DIM,
act_dim=N_CANDIDATES, problem=problem)
meta_path = self.path.with_suffix('.json')
self.metadata = json.loads(meta_path.read_text(encoding='utf-8')) \
if meta_path.exists() else {}
def select(self, observation: np.ndarray, action_masks: np.ndarray) -> int:
action, _state = self.model.predict(
observation, action_masks=action_masks, deterministic=True)
return int(action)
class LowLevelPolicy:
"""低层到达策略:相对位姿观测 -> 8 方向(确定性 argmax)。"""
def __init__(self, model_path: str | Path, *, device: str = "auto"):
self.device = _resolve_device(device)
self.path = Path(model_path)
self.model = _load_maskable(self.path, self.device, obs_dim=LOW_OBS_DIM,
act_dim=LOW_NUM_ACTIONS, problem=None)
def select(self, observation: np.ndarray) -> int:
action, _state = self.model.predict(observation, deterministic=True)
return int(action)
def default_high_model_path(problem: int) -> Path:
if problem not in (3, 4):
raise ValueError("problem 必须是 3 或 4")
return Path("output/hrl") / f"q{problem}_hrl_high.zip"
def default_low_model_path() -> Path:
return Path("output/hrl") / "hrl_low.zip"
+493
View File
@@ -0,0 +1,493 @@
"""高层调度核心:候选槽位构建、观测、动作掩码与任务执行。
本模块是 :class:`~mcm26b.planner.hrl_loop.HRLLoop`(运行闭环)与
:class:`~mcm26b.hrl.hierarchical_env.HierarchicalEnv`(训练环境)共用的
混入层,保证训练-部署特征逐位同源(v1"训练部署特征错位"审核教训)。
宿主须为 :class:`~mcm26b.planner.bayes_loop.BayesLoop` 派生闭环,提供
client/belief/particles/ledger/_record_measure 等感知与执行原语;本层只做
三件事——
1. 把整场调度决策离散化为固定槽位(constants.N_CANDIDATES):
clear(20) + locate(20) + station(49) + fallback(20) + 保留(11);
2. 由可见观测(belief + 网格幸存集 + 覆盖账本)构造观测与动作掩码,
绝不读取源真值;
3. 执行选中候选并报告事实(虚拟耗时增量、新发现、熵减、清除结果),
奖励折算由训练环境负责。
Q4 定向源语义:no_signal 不能删除空间候选(可能是朝向遮挡),故问题4 的
幸存集只按 direction/near 更新;遮挡证据走 blocked_probes 梯子与后备清除
槽位,与 DirectionalExtension 的保守口径一致。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import NamedTuple
import numpy as np
from ..belief import ChannelStatus
from ..config import ProblemConfig, Vec2
from ..protocol.rules import CLEAR_SUCCESS, DIRECTION, NEAR, NO_SIGNAL
from ..protocol.timing import move_time
from .constants import (CHANNEL_COUNT, CLEAR_OFFSET, COST_SCALE_S,
FALLBACK_OFFSET, HIGH_GLOBAL_DIM, HIGH_OBS_DIM,
LOCATE_OFFSET, MEASURE_COUNT_SCALE, N_CANDIDATES,
PER_CANDIDATE_DIM, PER_CHANNEL_DIM, POS_SCALE_M,
RADIUS_SCALE_M, STATION_OFFSET)
from ..planner import fallback_clear_cells, safe_disk_radius
__all__ = ["Candidate", "StepOutcome", "HRLControllerMixin", "point_key"]
def point_key(point: Vec2) -> tuple[int, int]:
"""候选点去重键:0.5m 网格取整(与 bayes_loop 同口径)。"""
return (int(round(point[0] * 2)), int(round(point[1] * 2)))
class Candidate(NamedTuple):
"""一个高层候选子目标。slot 为固定槽位编号,供动作掩码与特征定位。"""
slot: int
kind: str # "clear" | "locate" | "probe" | "station" | "fallback"
position: Vec2
channel: int | None = None
station_index: int | None = None
service_s: float = 0.0 # 预计服务耗时(含换频,不含移动)
targets: int = 1 # station: 待测频道数;fallback: 格数;其余 1
@dataclass
class StepOutcome:
"""一次高层决策-执行的客观结果(奖励折算由训练环境负责)。"""
action: int
kind: str | None = None
channel: int | None = None
invalid: bool = False # 掩码外动作(无掩码消融路径):未与模拟器交互
delta_virtual_s: float = 0.0
new_detections: tuple[int, ...] = ()
info_gains: dict[int, float] = field(default_factory=dict) # 频道 -> 熵减(log)
clear_success: tuple[int, ...] = ()
redundant: bool = False
position_reached: Vec2 | None = None
class HRLControllerMixin:
"""高层调度混入:依赖宿主(HRLLoop)提供 BayesLoop 感知与执行原语。
宿主须在 ``__init__`` 初始化:``_obs_mec_cache={}``、
``_station_pending_map={}``、``_initial_alive={channel: len(pf)}``
(观测侧缓存与归一化基准;证书相关计算仍用 pf.mec() 精确值)。
"""
problem: int
# ------------------------------------------------------------ 候选构建
def build_candidates(self) -> list[Candidate]:
"""构建当前全部有效候选(固定槽位;训练/推理/heuristic 专家同源)。
副作用:缓存每频道证书(``self._cert_cache``)与站点待测集
(``self._station_pending_map``)供观测构建复用;调用方须在本
函数之后、任何状态变更之前构建观测。
"""
config = self.config
detected = self.belief.channels_by_status(ChannelStatus.DETECTED)
by_slot: dict[int, Candidate] = {}
cert_cache: dict[int, object] = {}
station_map: dict[int, list[int]] = {}
# --- clear 槽位:证书就绪且未因失败等待补测
for channel in detected:
cert = self._best_certificate(channel)
cert_cache[channel] = cert
if cert is not None and cert.certified \
and channel not in self._clear_failed:
by_slot[CLEAR_OFFSET + channel - 1] = Candidate(
CLEAR_OFFSET + channel - 1, "clear", tuple(cert.center),
channel=channel,
service_s=config.cost.clear_success_s, targets=1)
# --- locate / probe 槽位:未就绪清除的已发现频道
for channel in detected:
if CLEAR_OFFSET + channel - 1 in by_slot:
continue
region = self.belief.candidate_set(channel)
if region is None:
continue
blocked = self.blocked_probes.get(channel, []) \
if self.problem == 4 else []
if blocked:
point = self._hrl_probe_point(region, channel)
kind = "probe"
else:
point = self._select_point(channel)
kind = "locate"
if point is None:
continue
by_slot[LOCATE_OFFSET + channel - 1] = Candidate(
LOCATE_OFFSET + channel - 1, kind, tuple(point), channel=channel,
service_s=config.cost.measure_s + config.cost.switch_s, targets=1)
# --- station 槽位:尚有 UNKNOWN 待测频道的覆盖站
for index, point in enumerate(self.ledger.points):
pending = self._station_pending(index)
station_map[index] = pending
if not pending:
continue
switches = len(pending) - (1 if self.client.state.channel in pending else 0)
by_slot[STATION_OFFSET + index] = Candidate(
STATION_OFFSET + index, "station", tuple(point),
station_index=index,
service_s=(config.cost.measure_s * len(pending)
+ config.cost.switch_s * max(switches, 0)),
targets=len(pending))
# --- fallback 槽位(仅问题4):遮挡梯子耗尽且格数在预算内
if self.problem == 4:
for channel in detected:
blocked = self.blocked_probes.get(channel, [])
if len(blocked) < config.search.max_blocked_probes:
continue
region = self.belief.candidate_set(channel)
if region is None:
continue
cells = fallback_clear_cells(region.polygon, config)
if not cells or len(cells) > config.search.fallback_max_cells:
continue
by_slot[FALLBACK_OFFSET + channel - 1] = Candidate(
FALLBACK_OFFSET + channel - 1, "fallback",
tuple(region.mec()[0]), channel=channel,
service_s=config.cost.clear_fail_s * len(cells),
targets=len(cells))
self._cert_cache = cert_cache
self._station_pending_map = station_map
self._last_candidates = list(by_slot.values())
return self._last_candidates
def candidate_by_slot(self, candidates: list[Candidate],
action: int) -> Candidate | None:
for cand in candidates:
if cand.slot == int(action):
return cand
return None
# ------------------------------------------------------------ 动作掩码
def hrl_action_mask(self, candidates: list[Candidate]) -> np.ndarray:
"""槽位有效性掩码:无候选槽位恒屏蔽(训练/推理共用)。"""
mask = np.zeros(N_CANDIDATES, dtype=bool)
for cand in candidates:
mask[cand.slot] = True
return mask
# ------------------------------------------------------------ 观测
def _hrl_obs_mec(self, channel: int) -> tuple[Vec2, float]:
"""观测用 MEC:幸存子采样上的最小包围圆,按 pf.version 缓存。
仅作策略观测特征(近似 MEU/σ),证书相关计算一律用 pf.mec()
精确值——两者分开缓存,互不影响。
"""
pf = self.particles[channel]
cached = self._obs_mec_cache.get(channel)
if cached is not None and cached[0] == pf.version:
return cached[1], cached[2]
points = pf.subsample(600)
if not points:
return (0.0, 0.0), 0.0
from ..geometry.localization import smallest_enclosing_circle
center, radius = smallest_enclosing_circle(points)
self._obs_mec_cache[channel] = (pf.version, center, radius)
return center, radius
def _hrl_channel_status_index(self, channel: int) -> int:
"""六状态编码:UNKNOWN/DETECTED/TRACKING/LOCATED/CLEARED/ABSENT。"""
status = self.belief.channels[channel].status
if status is ChannelStatus.UNKNOWN:
return 0
if status is ChannelStatus.CLEARED:
return 4
if status is ChannelStatus.ABSENT:
return 5
cert = getattr(self, "_cert_cache", {}).get(channel)
if cert is not None and getattr(cert, "certified", False):
return 3
_, radius = self._hrl_obs_mec(channel)
return 1 if radius > 500.0 else 2
def hrl_observation(self, candidates: list[Candidate]) -> np.ndarray:
"""构造高层观测(shape=(HIGH_OBS_DIM,),训练/推理逐位一致)。"""
config = self.config
field = config.field
position = self.client.state.position
belief = self.belief
detected_count = len(belief.channels_by_status(ChannelStatus.DETECTED))
accounted = sum(1 for ch in belief.channels.values()
if ch.status in (ChannelStatus.CLEARED,
ChannelStatus.ABSENT))
cleared = len(self.cleared)
glob = [
position[0] / POS_SCALE_M,
position[1] / POS_SCALE_M,
min(self.client.state.virtual_time_s, field.max_virtual_duration_s)
/ field.max_virtual_duration_s,
cleared / 16.0,
accounted / CHANNEL_COUNT,
(self.client.state.channel - field.channel_min)
/ max(field.channel_max - field.channel_min, 1),
float(self.problem == 4),
detected_count / 16.0,
]
assert len(glob) == HIGH_GLOBAL_DIM
pending_station = np.zeros(CHANNEL_COUNT + 1, dtype=bool)
for index, channels in getattr(self, "_station_pending_map", {}).items():
for channel in channels:
pending_station[channel] = True
channel_features: list[float] = []
for channel in range(field.channel_min, field.channel_max + 1):
ch = belief.channels[channel]
status6 = [0.0] * 6
status6[self._hrl_channel_status_index(channel)] = 1.0
pf = self.particles[channel]
mec_visible = ch.status is ChannelStatus.DETECTED
if mec_visible:
center, radius = self._hrl_obs_mec(channel)
else:
center, radius = (0.0, 0.0), 0.0
initial = max(self._initial_alive.get(channel, 1), 1)
channel_features.extend([
*status6,
center[0] / POS_SCALE_M,
center[1] / POS_SCALE_M,
radius / RADIUS_SCALE_M,
math.log(len(pf) + 1) / math.log(initial + 1)
if initial > 1 else 0.0,
min(len(ch.measurements), MEASURE_COUNT_SCALE * 2)
/ MEASURE_COUNT_SCALE / 2,
math.hypot(center[0] - position[0], center[1] - position[1])
/ POS_SCALE_M if mec_visible else 0.0,
len(self.blocked_probes.get(channel, []))
/ max(config.search.max_blocked_probes, 1)
if self.problem == 4 else 0.0,
1.0 if (ch.status is ChannelStatus.UNKNOWN
and pending_station[channel]) else 0.0,
])
assert len(channel_features) == CHANNEL_COUNT * PER_CHANNEL_DIM
by_slot = {cand.slot: cand for cand in candidates}
candidate_features: list[float] = []
for slot in range(N_CANDIDATES):
cand = by_slot.get(slot)
if cand is None:
candidate_features.extend([0.0] * PER_CANDIDATE_DIM)
continue
dx, dy = cand.position[0] - position[0], cand.position[1] - position[1]
move_s = move_time(position, cand.position, config.cost)
candidate_features.extend([
1.0 if cand.kind == "clear" else 0.0,
1.0 if cand.kind in ("locate", "probe") else 0.0,
1.0 if cand.kind == "station" else 0.0,
1.0 if cand.kind == "fallback" else 0.0,
dx / POS_SCALE_M,
dy / POS_SCALE_M,
min((move_s + cand.service_s) / COST_SCALE_S, 2.0),
min(cand.targets / 16.0, 1.0),
])
assert len(candidate_features) == N_CANDIDATES * PER_CANDIDATE_DIM
observation = np.asarray(glob + channel_features + candidate_features,
dtype=np.float32)
assert observation.shape == (HIGH_OBS_DIM,)
return observation
# ------------------------------------------------------------ 启发式专家
def heuristic_action(self, candidates: list[Candidate]) -> int:
"""把 BayesLoop 任务排序映射为槽位(BC 专家 + 无策略降级路径)。
任务表与排序完全复用 :meth:`BayesLoop._build_tasks` /
:meth:`BayesLoop._order_tasks`;映射失败(如 Q4 梯子任务不在
任务表中)退化为最近有效候选。
"""
by_slot = {cand.slot: cand for cand in candidates}
if not by_slot:
raise RuntimeError("heuristic_action:当前无可用候选")
try:
order = self._order_tasks(self._build_tasks())
first = order[0]
if first.kind == "clear" and first.channel is not None:
slot = CLEAR_OFFSET + first.channel - 1
elif first.kind == "locate" and first.channel is not None:
slot = LOCATE_OFFSET + first.channel - 1
else:
slot = STATION_OFFSET + (first.station_index or 0)
if slot in by_slot:
return slot
except Exception:
pass
position = self.client.state.position
return min(by_slot, key=lambda s: (
math.dist(position, by_slot[s].position), s))
# ------------------------------------------------------------ 决策执行
def hrl_step(self, action: int,
candidates: list[Candidate] | None = None) -> StepOutcome:
"""执行一次高层决策;candidates 为空则现场构建(训练环境逐位同源)。"""
action = int(action)
self._reset_step_recording()
old_virtual = self.client.state.virtual_time_s
if candidates is None:
candidates = self.build_candidates()
cand = self.candidate_by_slot(candidates, action)
if cand is None:
return StepOutcome(action=action, invalid=True)
if self.stop_reason is not None:
return StepOutcome(action=action, kind=cand.kind,
channel=cand.channel, invalid=True)
if cand.kind == "station":
self._execute_station(cand.station_index)
elif cand.kind in ("locate", "probe"):
self._hrl_execute_locate(cand)
elif cand.kind == "clear":
self._hrl_execute_clear(cand)
elif cand.kind == "fallback":
self._hrl_execute_fallback(cand)
else: # pragma: no cover - 布局守卫
raise ValueError(f"未知候选类型: {cand.kind}")
outcome = StepOutcome(action=action, kind=cand.kind,
channel=cand.channel,
position_reached=tuple(self.client.state.position))
outcome.delta_virtual_s = (self.client.state.virtual_time_s - old_virtual)
new_detections: list[int] = []
info_gains: dict[int, float] = {}
redundant = False
for event in self._step_events:
if event["was_unknown"] and event["result"] in (DIRECTION, NEAR):
new_detections.append(event["channel"])
gain = math.log10(event["pf_before"] + 1) - math.log10(event["pf_after"] + 1)
info_gains[event["channel"]] = (info_gains.get(event["channel"], 0.0)
+ min(max(gain, 0.0), 2.0))
redundant = redundant or event["redundant"]
outcome.new_detections = tuple(new_detections)
outcome.info_gains = info_gains
outcome.redundant = redundant
outcome.clear_success = tuple(self._step_clears)
self._step_clears = []
return outcome
# ------------------------------------------------------------ 执行原语
def _hrl_execute_locate(self, cand: Candidate) -> None:
"""定位测量(含 Q4 梯子探点):记录证据,不自动清除(高层决策)。"""
config = self.config
channel = cand.channel
pf = self.particles[channel]
before = len(pf)
outcome = self.client.measure(cand.position, channel)
self._record_measure(channel, cand.position, outcome)
self._may_clear.add(channel)
if self.problem == 4:
if outcome.result == NO_SIGNAL:
if channel not in self.directional_observed:
region = self.belief.candidate_set(channel)
safe = safe_disk_radius(region, config) if region else -1.0
if safe >= 0 and math.dist(cand.position, region.mec()[0]) <= safe:
self.directional_observed.append(channel)
self.blocked_probes.setdefault(channel, []).append(cand.position)
else:
self.blocked_probes.pop(channel, None)
if len(pf) == before:
self._stall[channel] = self._stall.get(channel, 0) + 1
self.no_shrink_measures += 1
else:
self._stall[channel] = 0
def _hrl_execute_clear(self, cand: Candidate) -> None:
"""证书清除:证书失效(不应被掩码允许)时静默跳过。"""
cert = self._best_certificate(cand.channel)
if cert is None or not cert.certified:
self._anomaly("hrl_clear_without_certificate", cand.channel)
return
if self._attempt_clear(cand.channel, cert.center, cert):
self._clear_failed.discard(cand.channel)
self._stall.pop(cand.channel, None)
else:
self._clear_failed.add(cand.channel)
self._may_clear.discard(cand.channel)
def _hrl_execute_fallback(self, cand: Candidate) -> None:
"""Q4 后备网格清除(与 DirectionalExtension._fallback_clear 同口径)。"""
channel = cand.channel
region = self.belief.candidate_set(channel)
if region is None:
return
config, session = self.config, self.config.session
cells = fallback_clear_cells(region.polygon, config)
entry = {"channel": channel, "cells": len(cells)}
self.fallback_log.append(entry)
if len(cells) > config.search.fallback_max_cells:
entry["outcome"] = "skipped_cell_cap"
return
remaining = self.client.real_remaining_s
if remaining is not None and len(cells) * session.real_rtt_p95_s \
+ session.reserve_exit_s > remaining:
entry["outcome"] = "skipped_budget"
return
remaining_cells = list(cells)
attempts = 0
while remaining_cells:
if self._virtual_cap_reached():
entry["outcome"] = "skipped_virtual_budget"
entry["attempts"] = attempts
return
cell = min(remaining_cells, key=lambda c: (
math.dist(self.client.state.position, c), c))
remaining_cells.remove(cell)
attempts += 1
outcome = self.client.clear(cell, channel)
if outcome.result == CLEAR_SUCCESS:
self.belief.mark_cleared(channel)
self.cleared.append(channel)
self._step_clears.append(channel)
self.blocked_probes.pop(channel, None)
entry["outcome"] = "cleared"
entry["attempts"] = attempts
return
entry["outcome"] = "exhausted"
entry["attempts"] = attempts
self._anomaly("hrl_fallback_exhausted", channel, cells=len(cells))
def _hrl_probe_point(self, region, channel: int) -> Vec2:
"""定向遮挡梯子探点(与 DirectionalExtension._next_probe 同口径)。"""
from ..planner import select_measurement_point
blocked = self.blocked_probes.get(channel, [])
center, radius = region.mec()
if not blocked:
return select_measurement_point(region, self.client.state.position,
self.config).position
safe = safe_disk_radius(region, self.config)
first = blocked[0]
if len(blocked) == 1 and safe >= 0:
dx, dy = center[0] - first[0], center[1] - first[1]
norm = math.hypot(dx, dy)
if norm <= 1e-9:
return (center[0] + safe, center[1])
scale = min(1.0, safe / norm)
return (center[0] + dx * scale, center[1] + dy * scale)
base = math.atan2(first[1] - center[1], first[0] - center[0])
azimuth = base + math.radians(45.0 * (len(blocked) - 1))
return (center[0] + radius * math.cos(azimuth),
center[1] + radius * math.sin(azimuth))
# ------------------------------------------------------------ 事件捕获
def _reset_step_recording(self) -> None:
self._step_events = []
self._step_clears = []
def _record_event(self, **event) -> None:
self._step_events.append(event)
+174
View File
@@ -0,0 +1,174 @@
"""P7 分层强化学习闭环(``--strategy hrl`` / ``--q4-strategy hrl``)。
边界与保守性声明(与 rl_loop.py 同口径):
- 高层只在固定候选槽位上做调度决策(证书清除 / 信息增益定位 / 覆盖站 /
Q4 梯子探点与后备清除),候选几何全部由既有规则生成;
- 感知(belief + 网格幸存集)、清除证书、跳测规则、异常留痕完全继承
:class:`BayesLoop`,不触碰真源保留定理的前提;
- 问题3 的幸存集按全部读数更新(no_signal 删盘);问题4 定向源的
no_signal 不能排除朝向遮挡,幸存集只按 direction/near 更新,遮挡
证据走 blocked_probes 梯子与后备清除槽位(DirectionalExtension 口径);
- 高层策略缺失时经 :meth:`heuristic_action` 降级为 BayesLoop 任务排序,
闭环仍然完备可运行。
问题4 发现覆盖使用 49 点网格(七点环的覆盖证明只对全向源成立)。
"""
from __future__ import annotations
from pathlib import Path
from ..belief import ChannelStatus
from ..config import ProblemConfig, Vec2
from ..coverage import CoverageLedger, coverage_points
from ..hrl.scheduler import HRLControllerMixin, point_key
from ..protocol.rules import NO_SIGNAL
from .bayes_loop import BayesLoop
class HRLLoop(HRLControllerMixin, BayesLoop):
"""分层闭环:BayesLoop 感知/证书 + 高层槽位调度(问题3/4 共用)。"""
def __init__(self, client, config: ProblemConfig = ProblemConfig(), *,
problem: int = 3, high_policy=None, low_policy=None,
trace_executor: bool = False):
super().__init__(client, config)
if problem not in (3, 4):
raise ValueError("problem 必须是 3 或 4")
self.problem = int(problem)
self.high_policy = high_policy
self.low_policy = low_policy
self.trace_executor = bool(trace_executor)
self.executor_trace: list[dict] = []
self.hrl_decisions: list[dict] = []
self._step_events: list[dict] = []
self._step_clears: list[int] = []
self._cert_cache: dict = {}
self._last_candidates: list = []
self._obs_mec_cache: dict = {}
self._station_pending_map: dict = {}
# 观测里幸存格数的归一化基准(初始全集),使特征对训练/部署的
# 网格密度差不变。
self._initial_alive = {channel: len(pf)
for channel, pf in self.particles.items()}
if problem == 4:
self.directional_observed: list[int] = []
self.blocked_probes: dict[int, list[Vec2]] = {}
self.fallback_log: list[dict] = []
# 49 点网格:七点环覆盖证明只对全向源成立 [口径见模块注释]
self.ledger = CoverageLedger(config, points=coverage_points(config))
# ------------------------------------------------------------ 主循环
def _coverage_phase(self) -> None:
"""统一任务循环:每步构建候选,由高层策略(或启发式降级)选槽执行。"""
while self.stop_reason is None:
if self._count_limit_reached() or self._virtual_cap_reached():
return
# 顺路观测会改变信念、证书和候选;必须发生在本轮快照之前。
if self.high_policy is None and self.problem == 3 \
and self.config.bayes.opportunistic_measure:
self._measure_at_stop()
if self.stop_reason is not None or self._count_limit_reached() \
or self._virtual_cap_reached():
return
candidates = self.build_candidates()
if not candidates:
return
if self.high_policy is not None:
observation = self.hrl_observation(candidates)
mask = self.hrl_action_mask(candidates)
action = self.high_policy.select(observation, mask)
else:
# 降级路径:BayesLoop 任务排序(无 RL 依赖时闭环仍完备)
action = self.heuristic_action(candidates)
if self.candidate_by_slot(candidates, action) is None:
# 非法策略输出不能造成零耗时无限循环;事实记入异常再安全调度。
self._anomaly("hrl_invalid_policy_action", 0, action=int(action))
action = self.heuristic_action(candidates)
cand = self.candidate_by_slot(candidates, action)
if cand is not None:
self._log_low_level(cand)
self.hrl_decisions.append({
"action": int(action), "kind": cand.kind,
"channel": cand.channel,
"virtual_time_s": self.client.state.virtual_time_s})
self.hrl_step(action, candidates=candidates)
# ------------------------------------------------------------ 感知记录
def _record_measure(self, channel: int, position: Vec2, outcome) -> None:
"""BayesLoop._record_measure 的事件捕获版;问题4 no_signal 不删格。
Q4 语义:no_signal 可能是朝向遮挡 [观测推理],删除幸存格会破坏
真源保留,因此问题4 幸存集只按 direction/near 更新;缺席证明
交由 49 点网格覆盖账本(继承的 _mark_absent_after_coverage)。
"""
key = point_key(position)
redundant = key in self._measured_points.get(channel, set())
belief = self.belief.channels[channel]
was_unknown = belief.status is ChannelStatus.UNKNOWN
pf = self.particles[channel]
before = len(pf)
self.belief.update_measure(channel, position, outcome.result,
outcome.svd_deg)
self._measured_points.setdefault(channel, set()).add(key)
self._clear_failed.discard(channel)
belief = self.belief.channels[channel]
if belief.status not in (ChannelStatus.CLEARED, ChannelStatus.ABSENT):
if not (self.problem == 4 and outcome.result == NO_SIGNAL):
pf.apply_measure(position, outcome.result, outcome.svd_deg)
if not len(pf):
if belief.status is ChannelStatus.UNKNOWN:
self.belief.mark_absent(channel)
if channel not in self.pf_absent_channels:
self.pf_absent_channels.append(channel)
else:
self._anomaly("pf_empty_while_detected", channel,
position=position)
self._record_event(channel=channel, result=outcome.result,
pf_before=before, pf_after=len(pf),
redundant=redundant, was_unknown=was_unknown)
def _attempt_clear(self, channel: int, target: Vec2, cert) -> bool:
success = super()._attempt_clear(channel, target, cert)
if success and hasattr(self, "blocked_probes"):
self.blocked_probes.pop(channel, None)
if success:
self._step_clears.append(channel)
return success
# ------------------------------------------------------------ 低层执行留痕
def _log_low_level(self, cand) -> None:
"""低层到达推演留痕(本地步进,不与模拟器交互,不产生虚拟耗时)。"""
if not self.trace_executor:
return
from ..hrl.executor import LowLevelExecutor
executor = getattr(self, "_executor", None)
if executor is None:
executor = self._executor = LowLevelExecutor(self.low_policy)
waypoints = executor.roll(self.client.state.position, cand.position)
if self.low_policy is not None:
self.executor_trace.append({
"kind": cand.kind, "channel": cand.channel,
"goal": [round(v, 2) for v in cand.position],
"waypoints": [[round(v, 2) for v in p] for p in waypoints],
"navigation": dict(executor.last_roll_info),
})
# ------------------------------------------------------------ 收尾
def _summary(self, info) -> dict:
report = super()._summary(info)
report["strategy"] = "hrl_q4" if self.problem == 4 else "hrl"
report["hrl_problem"] = self.problem
kinds: dict[str, int] = {}
for decision in self.hrl_decisions:
kinds[decision["kind"]] = kinds.get(decision["kind"], 0) + 1
report["hrl_action_kinds"] = kinds
report["hrl_decisions"] = self.hrl_decisions
report["hrl_executor_trace"] = self.executor_trace
if self.problem == 4:
report["directional_observed_channels"] = \
list(self.directional_observed)
report["fallback_clears"] = self.fallback_log
report["fallback_clear_attempts"] = sum(
entry.get("attempts", 0) for entry in self.fallback_log)
return report
+15
View File
@@ -0,0 +1,15 @@
"""模型训练前端:本地回环 Web UI + 训练子进程监控。
启动:
python -m mcm26b.training_ui --ui-port 8036
或:
python scripts/train_hrl_ui.py --ui-port 8036
子命令:
python -m mcm26b.training_ui --print-schema # 配置契约
python -m mcm26b.training_ui --print-preview # 离线示例快照
"""
from .server import TrainingUI, TrainingJob, run_training_ui
from .schema import config_schema
__all__ = ["TrainingUI", "TrainingJob", "run_training_ui", "config_schema"]
+4
View File
@@ -0,0 +1,4 @@
from .server import run_training_ui
if __name__ == "__main__":
raise SystemExit(run_training_ui())
+211
View File
@@ -0,0 +1,211 @@
"""训练前端示例快照:把一段"看起来像真的" PPO 训练曲线合成为合法快照。
用途有两个,都不涉及真实训练:
1. ``GET /api/train/state?preview=1`` 让界面在没有训练进程时也能验证渲染
(曲线、指标卡、进程时间线、日志着色、逐回合表);
2. 单元测试可以断言前端的收敛判读分支(上升/平坦/KL 过猛)各有稳定输入。
曲线是解析函数 + 固定种子的确定性噪声,不含任何真实评测数据。
"""
from __future__ import annotations
import math
import random
import time
from typing import Any
__all__ = ["DEMO_CONFIG", "DEMO_SCENARIOS", "demo_metrics", "demo_monitor",
"demo_log_lines", "demo_snapshot"]
DEMO_SCENARIOS = ("rising", "plateau", "kl-spike")
DEMO_CONFIG: dict[str, Any] = {
"problem": 3, "stage": 2, "timesteps": 12000, "seed": 320001,
"envs": 4, "vec_env": "subproc", "device": "mps",
"config_profile": "production", "reward_profile": "time",
"model": "output/hrl/ui/demo/q3.zip", "init": None, "resume": False,
"eval_episodes": 2, "rollout_steps": 256, "batch_size": 256,
"n_epochs": 10, "max_wall_s": 1200.0, "local_selector": "geometry",
"local_model": None, "verbose": 1,
}
DEMO_COMMAND = [
"python", "scripts/train_hrl_high.py", "--problem", "3", "--stage", "2",
"--timesteps", "12000", "--seed", "320001", "--envs", "4",
"--vec-env", "subproc", "--device", "mps", "--config-profile", "production",
"--reward-profile", "time", "--model", "output/hrl/ui/demo/q3.zip",
"--eval-episodes", "2", "--rollout-steps", "256", "--batch-size", "256",
"--n-epochs", "10", "--max-wall-s", "1200.0", "--local-selector",
"geometry", "--progress-json", "--verbose", "1",
]
DEMO_STEPS = 48
def _finite(value: float) -> float | None:
if value != value or value in (float("inf"), float("-inf")):
return None
return round(value, 8)
def demo_metrics(count: int = DEMO_STEPS, steps: int = 12000, *,
seed: int = 7, scenario: str = "rising",
total_steps: int | None = None, fps: float = 420.0
) -> list[dict[str, Any]]:
"""合成 count 个进度点,横轴覆盖到 ``total_steps`` 的 ``count/DEMO_STEPS``。
场景(用于验证收敛判读的各个分支,不含真实数据):
rising 奖励对数饱和上升、KL 正常 → "收敛趋势良好"
plateau 奖励已进入平台期 → "已趋于平稳(疑似收敛)"
kl-spike 末段近似 KL 冲高 → "KL 偏大,策略更新可能过猛"
"""
if scenario not in DEMO_SCENARIOS:
raise ValueError(f"未知示例场景: {scenario}")
total = int(total_steps if total_steps is not None else steps)
rng = random.Random(seed)
points: list[dict[str, Any]] = []
per = total / DEMO_STEPS
ceiling = 42.0
span = min(1.0, max(1e-6, count / float(DEMO_STEPS)))
for index in range(1, count + 1):
t = (index / float(DEMO_STEPS)) / span # 折到整段训练的进度比例
steps_now = int(round(index * per))
if scenario == "plateau":
reward = 41.5 + rng.uniform(-0.35, 0.35)
value_loss_curve = 3.6 + rng.uniform(-0.2, 0.2)
approx_kl = max(0.001, 0.004 + rng.uniform(-0.001, 0.001))
explained = min(0.95, 0.88 + rng.uniform(-0.02, 0.02))
elif scenario == "kl-spike":
reward = ceiling * (1.0 - math.exp(-3.1 * t)) + rng.uniform(-1.2, 1.2)
value_loss_curve = 62.0 * math.exp(-2.4 * t) + 3.4
base_kl = 0.031 * math.exp(-1.7 * t)
approx_kl = base_kl + (0.075 if t > 0.8 else 0.0) + rng.uniform(-0.003, 0.003)
explained = -0.09 + 1.05 * (1.0 - math.exp(-2.2 * t))
else:
noise = rng.uniform(-1.4, 1.4) * (1.0 - 0.6 * t)
reward = ceiling * (1.0 - math.exp(-3.1 * t)) + noise
value_loss_curve = 62.0 * math.exp(-2.4 * t) + 3.4
approx_kl = 0.031 * math.exp(-1.7 * t)
explained = -0.09 + 1.05 * (1.0 - math.exp(-2.2 * t))
value_loss = value_loss_curve + rng.uniform(-0.5, 0.5)
policy_loss = -0.021 + 0.006 * math.sin(index / 4.0) + rng.uniform(-0.002, 0.002)
entropy = 1.35 - 0.55 * (t if scenario != "plateau" else 0.9) \
+ rng.uniform(-0.02, 0.02)
points.append({
"timesteps": steps_now,
"n_updates": index * 12,
"ep_rew_mean": _finite(reward),
"ep_len_mean": _finite(24.0 + 21.0 * t + rng.uniform(-1.5, 1.5)),
"value_loss": _finite(value_loss),
"policy_gradient_loss": _finite(policy_loss),
"approx_kl": _finite(max(0.0008, approx_kl + rng.uniform(-0.002, 0.002))),
"entropy_loss": _finite(entropy),
"explained_variance": _finite(min(0.97, explained
+ rng.uniform(-0.03, 0.03))),
# 吞吐尽量接近真实量级(envs=4 时约 400+ 步/秒),否则剩余时间
# 估计会给出荒谬的 ETA
"fps": _finite(max(30.0, fps * (0.92 + 0.16 * t)
+ rng.uniform(-12.0, 12.0))),
})
return points
def demo_monitor(count: int = 60, seed: int = 11) -> list[dict[str, Any]]:
rng = random.Random(seed)
rows = []
clock = 0.0
for index in range(count):
t = index / max(1, count - 1)
clock += rng.uniform(4.0, 14.0)
rows.append({
"r": _finite(38.0 * (1.0 - math.exp(-3.0 * t)) + rng.uniform(-3.0, 3.0)),
"l": float(rng.randint(28, 74)),
"t": round(clock, 4),
})
return rows
def demo_log_lines() -> list[str]:
"""一段包含各类着色分支的日志:SB3 表格、进度 JSON、警告与收尾。"""
lines = [
"=== 训练前端离线预览:以下为示例快照,非真实训练输出 ===",
"使用设备: mps",
"创建 4 个 DummyVecEnv/SubprocVecEnv 环境 (stage=2, problem=3)",
"Monitor CSV: output/hrl/ui/demo/high_curve_q3_env0.monitor.csv",
"-----------------------------",
"| rollout/ | |",
"| ep_len_mean | 31.4 |",
"| ep_rew_mean | 12.83 |",
"| time/ | |",
"| fps | 412 |",
"| total_timesteps | 512 |",
"-----------------------------",
]
for index in range(1, 9):
t = index / 8.0
lines.append(
'{"event": "progress", "timesteps": %d, "n_updates": %d, '
'"ep_rew_mean": %.3f, "ep_len_mean": %.2f, "value_loss": %.4f, '
'"policy_gradient_loss": %.5f, "approx_kl": %.5f, '
'"entropy_loss": %.4f, "explained_variance": %.4f, "fps": %.1f}'
% (index * 1500, index * 12, 42.0 * (1 - math.exp(-3.1 * t)),
24 + 21 * t, 62.0 * math.exp(-2.4 * t) + 3.4, -0.021 + 0.006 * t,
0.031 * math.exp(-1.7 * t), 1.35 - 0.55 * t,
-0.09 + 1.05 * (1 - math.exp(-2.2 * t)), 390 + 70 * t)
)
lines += [
"UserWarning: 示例警告行,用于验证警告着色",
"确定性评估:2/2 成功,accounted=20,无异常",
"模型已保存: output/hrl/ui/demo/q3.zip",
"元数据已写入: output/hrl/ui/demo/q3.json",
]
return lines
def demo_snapshot(*, log_tail: int = 400, scenario: str = "rising",
done_ratio: float = 0.62, fps: float = 420.0) -> dict[str, Any]:
"""合成一次"跑到中途"的快照(键与 TrainingJob.snapshot 一致)。
``done_ratio`` 控制已完成的步数比例,用来让预览更接近真实运行中状态
(而不是刚好 100%);``scenario`` 选择收敛判读分支;``fps`` 决定剩余
时间估计的吞吐量级。
"""
from .server import describe_process, summarize_convergence
now = time.time()
ratio = min(1.0, max(0.05, float(done_ratio)))
metrics = demo_metrics(count=max(6, int(round(DEMO_STEPS * ratio))),
total_steps=int(DEMO_CONFIG["timesteps"]),
scenario=scenario, fps=fps)
monitor = demo_monitor(count=max(6, int(round(60 * ratio))))
config = dict(DEMO_CONFIG)
lines = demo_log_lines()
logs = (lines * 12)[-max(1, int(log_tail)):]
status = "running"
payload: dict[str, Any] = {
"job_id": "job-preview",
"status": status,
"pid": 42424,
"created_at": now - 186.0,
"started_at": now - 184.0,
"finished_at": None,
"elapsed_s": 184.0,
"returncode": None,
"error": None,
"command": list(DEMO_COMMAND),
"model": str(config["model"]),
"config": config,
"log_lines": logs,
"log_total": len(logs) * 3,
"metrics": metrics,
"metric_offset": 0,
"metric_end": len(metrics),
"metric_total": len(metrics),
"monitor": monitor,
"preview": True,
"preview_scenario": scenario,
}
payload["convergence"] = summarize_convergence(metrics, monitor)
payload["process"] = describe_process(payload)
return payload
+308
View File
@@ -0,0 +1,308 @@
"""训练前端配置契约:字段元数据、分组、预设与指标字典。
为什么单独成模块:前端表单的取值域、单位、分组与后端 ``TrainConfig`` 的
校验规则必须来自同一处,否则界面会允许后端拒绝的输入(或反之)。这里只
声明"可展示"的元数据,真正的合法性判定仍由 :class:`TrainConfig.validate`
单点负责——前端只做提前提示,不复制规则。
指标字典 ``METRIC_CATALOG`` 同样是唯一出处:``server.summarize_convergence``
用它取数,``/api/train/schema`` 把它交给前端画图,避免两边各写一份键名。
"""
from __future__ import annotations
from typing import Any
__all__ = [
"CONFIG_FIELDS", "CONFIG_GROUPS", "PRESETS", "METRIC_CATALOG",
"config_schema", "preset_payloads", "metric_keys",
]
# 字段元数据:
# kind number|integer|text|select|bool
# 其余键 前端直接使用(label/unit/group/choices/min/max/step/help/advanced)
CONFIG_FIELDS: list[dict[str, Any]] = [
# ---------------------------------------------------------- 环境与课程
{
"key": "problem", "kind": "select", "group": "env",
"label": "问题", "default": 3, "choices": [
{"value": 3, "label": "问题 3(全向源)"},
{"value": 4, "label": "问题 4(含定向源)"},
],
"help": "决定场景源类型与末段课程配置。",
},
{
"key": "stage", "kind": "select", "group": "env",
"label": "课程阶段", "default": 1, "choices": [
{"value": 1, "label": "1 · 单源 / 5 频道 / 无噪"},
{"value": 2, "label": "2 · 3 源 / 10 频道 / ±0.5°"},
{"value": 3, "label": "3 · 8 源 / 16 频道 / ±1°"},
{"value": 4, "label": "4 · 10–16 源 / 20 频道 / ±1°"},
],
"help": "课程递进;跨阶段续训用下方 --init 迁移权重。",
},
{
"key": "config_profile", "kind": "select", "group": "env",
"label": "配置档", "default": "production", "choices": [
{"value": "production", "label": "production(生产保真)"},
{"value": "coarse-pretrain", "label": "coarse-pretrain(快速粗训)"},
],
"help": "coarse-pretrain 显式映射为 fast 评分配置并写入模型元数据。",
},
{
"key": "reward_profile", "kind": "select", "group": "env",
"label": "奖励口径", "default": "time", "choices": [
{"value": "time", "label": "time(P0.4 纯时间目标)"},
{"value": "prompt", "label": "prompt(§5.2 原始塑形)"},
],
"help": "对照实验用;口径固化进模型元数据,不静默替换。",
},
{
"key": "seed", "kind": "integer", "group": "env",
"label": "随机种子", "default": 320001, "min": 0, "max": 2_147_483_647,
"help": "不同种子用于多次重复训练与方差估计。",
},
# ---------------------------------------------------------- PPO 超参
{
"key": "rollout_steps", "kind": "integer", "group": "ppo",
"label": "采样步数 n_steps", "default": 128, "min": 1, "max": 100_000,
"help": "每次更新前每个环境采集的步数;近似一个 rollout 的宽度。",
},
{
"key": "batch_size", "kind": "integer", "group": "ppo",
"label": "批大小 batch_size", "default": 128, "min": 1, "max": 100_000,
"help": "建议 batch_size ≤ n_steps × envs。",
},
{
"key": "n_epochs", "kind": "integer", "group": "ppo",
"label": "更新轮数 n_epochs", "default": 3, "min": 1, "max": 100,
"help": "每批数据的 SGD 轮数;过大易使 KL 飙升。",
},
# ---------------------------------------------------------- 运行环境
{
"key": "envs", "kind": "integer", "group": "runtime",
"label": "并行环境数", "default": 1, "min": 1, "max": 32,
"help": "SubprocVecEnv 下每个 worker 一个进程与 Monitor 文件。",
},
{
"key": "vec_env", "kind": "select", "group": "runtime",
"label": "向量化方式", "default": "dummy", "choices": [
{"value": "dummy", "label": "dummy(单进程,调试友好)"},
{"value": "subproc", "label": "subproc(多进程,吞吐优先)"},
],
"help": "envs>1 且追求吞吐时选 subproc。",
},
{
"key": "device", "kind": "select", "group": "runtime",
"label": "计算设备", "default": "auto", "choices": [
{"value": "auto", "label": "auto(自动选择)"},
{"value": "cpu", "label": "cpu"},
{"value": "cuda", "label": "cuda(NVIDIA)"},
{"value": "mps", "label": "mps(Apple 芯片)"},
],
"help": "CPU 路径最稳;GPU 需与 torch 构建匹配。",
},
{
"key": "local_selector", "kind": "select", "group": "runtime",
"label": "低层执行器", "default": "geometry", "choices": [
{"value": "geometry", "label": "geometry(几何选点,默认)"},
{"value": "rl", "label": "rl(低层 PPO,接口预留)"},
],
"help": "rl 仅记录元数据,当前 HTTP 执行路径仍由高层宏动作终点决定。",
},
# ---------------------------------------------------------- 训练规模
{
"key": "timesteps", "kind": "integer", "group": "scale",
"label": "总步数 timesteps", "default": 2048, "min": 1,
"max": 10_000_000, "help": "本次训练的环境步总数(续训时为增量步数)。",
},
{
"key": "max_wall_s", "kind": "number", "group": "scale",
"label": "墙钟上限 (s)", "default": 1200.0, "min": 0.1, "max": 7200.0,
"step": 30, "help": "硬上限:到点由回调主动停止并保存模型。",
},
{
"key": "eval_episodes", "kind": "integer", "group": "scale",
"label": "评估回合数", "default": 2, "min": 0, "max": 1000,
"help": "训练结束后确定性评估;0 表示跳过。",
},
{
"key": "verbose", "kind": "select", "group": "scale",
"label": "SB3 日志级别", "default": 1, "choices": [
{"value": 0, "label": "0 · 静默(只留 JSON 进度)"},
{"value": 1, "label": "1 · 表格(默认)"},
{"value": 2, "label": "2 · 调试"},
],
"help": "前端已解析 SB3 表格,默认 1 便于对照原始输出。",
},
# ---------------------------------------------------------- 模型路径
{
"key": "model", "kind": "text", "group": "paths",
"label": "模型输出路径", "default": "output/hrl/ui/q3.zip",
"help": "相对仓库根;同目录会写入 .json 元数据与 Monitor CSV。",
},
{
"key": "init", "kind": "text", "group": "paths", "optional": True,
"label": "初始权重 --init", "default": None,
"help": "留空=从零;填上一阶段 .zip 即课程迁移(步数计数重置)。",
},
{
"key": "local_model", "kind": "text", "group": "paths", "optional": True,
"label": "低层模型 --local-model", "default": None,
"advanced": True,
"help": "仅 local_selector=rl 时有意义。",
},
{
"key": "resume", "kind": "bool", "group": "paths",
"label": "续训 --resume", "default": False,
"help": "从模型输出路径已有 .zip 继续,timesteps 按增量理解。",
},
{
"key": "force", "kind": "bool", "group": "paths",
"label": "覆盖已有模型 --force", "default": False,
"help": "输出路径已存在时必须勾选,否则训练脚本会拒绝覆盖。与续训互斥。",
},
]
CONFIG_GROUPS: list[dict[str, str]] = [
{"key": "env", "label": "环境与课程", "hint": "题目、课程阶段与奖励口径"},
{"key": "ppo", "label": "PPO 超参数", "hint": "采样宽度与更新强度"},
{"key": "runtime", "label": "运行环境", "hint": "并行、设备与低层执行器"},
{"key": "scale", "label": "训练规模", "hint": "步数、墙钟上限与评估"},
{"key": "paths", "label": "模型与迁移", "hint": "产物路径、续训与课程迁移"},
]
# 预设 = 只覆盖差异字段;后端 from_payload 会补齐其余默认值。
PRESETS: list[dict[str, Any]] = [
{
"key": "smoke",
"label": "冒烟(约 1 分钟)",
"note": "64 步、CPU、无评估——只验证链路与界面。",
"payload": {
"problem": 3, "stage": 1, "timesteps": 64, "seed": 1,
"envs": 1, "vec_env": "dummy", "device": "cpu",
"config_profile": "coarse-pretrain", "reward_profile": "time",
"model": "output/hrl/ui/smoke/q3.zip", "eval_episodes": 0,
"rollout_steps": 32, "batch_size": 32, "n_epochs": 2,
"max_wall_s": 180.0, "verbose": 1, "force": True,
},
},
{
"key": "local-check",
"label": "本地验证(约 10 分钟)",
"note": "stage 1 单源课程 2048 步 + 2 回合评估。",
"payload": {
"problem": 3, "stage": 1, "timesteps": 2048, "seed": 320001,
"envs": 1, "vec_env": "dummy", "device": "auto",
"config_profile": "production", "reward_profile": "time",
"model": "output/hrl/ui/local/q3.zip", "eval_episodes": 2,
"rollout_steps": 128, "batch_size": 128, "n_epochs": 3,
"max_wall_s": 900.0, "verbose": 1, "force": True,
},
},
{
"key": "q3-main",
"label": "Q3 主训练",
"note": "stage 3 八源课程,4 进程并行;建议在训练机上跑。",
"payload": {
"problem": 3, "stage": 3, "timesteps": 200000, "seed": 320001,
"envs": 4, "vec_env": "subproc", "device": "auto",
"config_profile": "production", "reward_profile": "time",
"model": "output/hrl/ui/q3-main/q3.zip", "eval_episodes": 5,
"rollout_steps": 256, "batch_size": 256, "n_epochs": 10,
"max_wall_s": 7200.0, "verbose": 1,
},
},
{
"key": "q4-transfer",
"label": "Q4 课程迁移",
"note": "从 Q3 权重迁移到问题 4 末段课程(含定向源)。",
"payload": {
"problem": 4, "stage": 4, "timesteps": 120000, "seed": 420001,
"envs": 4, "vec_env": "subproc", "device": "auto",
"config_profile": "production", "reward_profile": "time",
"model": "output/hrl/ui/q4-main/q4.zip",
"init": "output/hrl/ui/q3-main/q3.zip", "eval_episodes": 5,
"rollout_steps": 256, "batch_size": 256, "n_epochs": 10,
"max_wall_s": 7200.0, "verbose": 1,
},
},
]
# 指标字典:键来自训练脚本 --progress-json 的单行 JSON(短名)与 SB3 表格
# (归一化去前缀)。前端按此顺序绘图,收敛判据在 server.summarize_convergence。
METRIC_CATALOG: list[dict[str, Any]] = [
{
"key": "ep_rew_mean", "label": "平均回合奖励", "role": "primary",
"color": "#4f8cff", "format": "number", "better": "up",
"help": "最近若干回合的平均回报;主要收敛判据。",
},
{
"key": "ep_len_mean", "label": "平均回合长度", "role": "secondary",
"color": "#38bdf8", "format": "number", "better": "neutral",
"help": "回合步数;与奖励同时上升通常代表策略更完整而非更快。",
},
{
"key": "approx_kl", "label": "近似 KL", "role": "stability",
"color": "#f59e0b", "format": "sci", "better": "down",
"help": "策略更新幅度;> 0.05 视为更新过猛。",
},
{
"key": "explained_variance", "label": "解释方差", "role": "stability",
"color": "#34d399", "format": "number", "better": "up",
"help": "价值函数拟合优度;< 0 表示还不如均值预测。",
},
{
"key": "value_loss", "label": "价值损失", "role": "stability",
"color": "#f87171", "format": "sci", "better": "down",
"help": "critic 拟合误差。",
},
{
"key": "policy_gradient_loss", "label": "策略梯度损失", "role": "stability",
"color": "#c084fc", "format": "sci", "better": "neutral",
"help": "actor 的代理损失;量级小且符号可变属正常。",
},
{
"key": "entropy_loss", "label": "策略熵", "role": "exploration",
"color": "#fbbf24", "format": "number", "better": "neutral",
"help": "探索度;若长期贴近 0 说明策略过早确定。",
},
{
"key": "n_updates", "label": "更新次数", "role": "throughput",
"color": "#93a1bd", "format": "integer", "better": "up",
"help": "已完成的 PPO 更新轮数。",
},
{
"key": "fps", "label": "吞吐 FPS", "role": "throughput",
"color": "#22d3ee", "format": "number", "better": "up",
"help": "每秒环境步数,反映硬件与并行配置是否吃满。",
},
]
_METRIC_BY_KEY = {item["key"]: item for item in METRIC_CATALOG}
def metric_keys() -> list[str]:
return [item["key"] for item in METRIC_CATALOG]
def metric_meta(key: str) -> dict[str, Any]:
return _METRIC_BY_KEY.get(key, {"key": key, "label": key, "role": "other",
"color": "#93a1bd", "format": "number",
"better": "neutral", "help": ""})
def config_schema() -> dict[str, Any]:
"""返回给前端的完整配置契约(字段 + 分组 + 预设 + 指标字典)。"""
return {
"fields": CONFIG_FIELDS,
"groups": CONFIG_GROUPS,
"presets": [{"key": item["key"], "label": item["label"],
"note": item["note"], "payload": dict(item["payload"])}
for item in PRESETS],
"metrics": METRIC_CATALOG,
}
def preset_payloads() -> dict[str, dict[str, Any]]:
return {item["key"]: dict(item["payload"]) for item in PRESETS}
File diff suppressed because it is too large Load Diff
+756
View File
@@ -0,0 +1,756 @@
/* 训练前端主逻辑:纯原生 JS,无外部依赖。
职责分工:
- 表单由 /api/train/schema 的字段元数据生成,不在前端复制校验规则;
- 训练数值全部取自 /api/train/state 快照(服务端已算好进度、吞吐、
收敛判读),前端只做格式化与渲染,避免两套算法对不上;
- 指标增量拉取(after_metric 游标),日志按尾部行数拉取。 */
"use strict";
const $ = (id) => document.getElementById(id);
const REFRESH_MS = 1500;
// 与服务端 MAX_METRICS 对齐:本地缓存上限,避免长时间训练把内存吃满
const METRIC_LIMIT = 5000;
const state = {
schema: null,
preview: false, // 离线预览:不再启动进程,只渲染示例快照
polling: false,
saving: false, // 配置表单是否处于"训练中"锁定态
metrics: [], // 本地累积的进度点(按 timesteps 递增)
metricCache: [], // metrics 的同一个数组(显式持有,避免 slice/concat 时丢引用)
metricNext: 0, // 本地已吃到的下一个下标(= 服务端下次应返回的 offset)
metricTotal: 0, // 服务端累计进度点总数(增量拉取的游标)
chartKey: "ep_rew_mean",
jobKey: null, // job_id 变化时清空本地缓存
lastLogSignature: "",
};
// ---------------------------------------------------------------- 工具
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;",
'"': "&quot;", "'": "&#39;" }[c]));
}
function fmtNumber(value, digits) {
if (value === null || value === undefined || value === "" || Number.isNaN(Number(value))) return "—";
const num = Number(value);
const abs = Math.abs(num);
if (abs !== 0 && (abs < 1e-3 || abs >= 1e5)) return num.toExponential(2);
const d = digits === undefined ? (abs >= 100 ? 1 : abs >= 1 ? 2 : 4) : digits;
return num.toFixed(d);
}
function fmtInt(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
return Math.round(Number(value)).toLocaleString("en-US");
}
function fmtDuration(seconds) {
if (seconds === null || seconds === undefined || !isFinite(seconds) || seconds < 0) return "—";
const s = Math.round(seconds);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m${String(sec).padStart(2, "0")}s`;
return `${sec}s`;
}
function fmtClock(epochSeconds) {
if (!epochSeconds) return "—";
const d = new Date(epochSeconds * 1000);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
}
function chip(id, label, value, tone) {
const el = $(id);
if (!el) return;
el.className = `chip ${tone || "gray"}`;
el.innerHTML = `${label}<b>${escapeHtml(value)}</b>`;
}
function setText(id, text) { const el = $(id); if (el) el.textContent = text; }
// ---------------------------------------------------------------- 启动
async function boot() {
try {
const res = await fetch("/api/train/schema");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.schema = await res.json();
} catch (err) {
state.schema = { fields: [], groups: [], presets: [], metrics: [] };
showBanner("bad", `无法读取配置契约(${err.message}):请确认已通过 python -m mcm26b.training_ui 启动前端。`);
}
buildForm();
await poll();
bindChartInteractions();
setInterval(poll, REFRESH_MS);
}
function showBanner(tone, html) {
const el = $("banner");
if (!html) { el.classList.add("hidden"); el.innerHTML = ""; return; }
el.className = `banner ${tone || ""}`;
el.innerHTML = html;
el.classList.remove("hidden");
}
// ---------------------------------------------------------------- 表单
function buildForm() {
const presets = $("presets");
presets.innerHTML = "";
for (const preset of state.schema.presets || []) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "preset";
btn.innerHTML = `${escapeHtml(preset.label)}<span class="note">${escapeHtml(preset.note || "")}</span>`;
btn.onclick = () => { applyValues(preset.payload); $("form-error").textContent = ""; };
presets.appendChild(btn);
}
const wrap = $("form-groups");
wrap.innerHTML = "";
for (const group of state.schema.groups || []) {
const box = document.createElement("fieldset");
box.className = "group";
const fields = (state.schema.fields || []).filter((f) => f.group === group.key);
box.innerHTML = `<legend>${escapeHtml(group.label)}</legend>` +
`<p class="ghint">${escapeHtml(group.hint || "")}</p>`;
for (const field of fields) box.appendChild(buildField(field));
wrap.appendChild(box);
}
applyValues({});
}
function buildField(field) {
const row = document.createElement("div");
row.className = "field";
row.id = `field-${field.key}`;
const label = document.createElement("label");
label.className = "flabel";
label.innerHTML = `<span>${escapeHtml(field.label)}</span>` +
(field.unit ? `<span class="unit">${escapeHtml(field.unit)}</span>` : "");
let control;
if (field.kind === "select") {
control = document.createElement("select");
for (const choice of field.choices || []) {
const option = document.createElement("option");
option.value = String(choice.value);
option.textContent = choice.label;
control.appendChild(option);
}
} else if (field.kind === "bool") {
control = document.createElement("input");
control.type = "checkbox";
row.classList.add("check");
} else {
control = document.createElement("input");
control.type = field.kind === "text" ? "text" : "number";
if (field.kind === "number") control.step = field.step || "any";
if (field.min !== undefined) control.min = field.min;
if (field.max !== undefined) control.max = field.max;
}
control.id = `cfg-${field.key}`;
control.dataset.key = field.key;
control.addEventListener("input", () => { row.classList.add("dirty"); row.classList.remove("invalid"); });
row.appendChild(label);
row.appendChild(control);
if (field.help) {
const help = document.createElement("span");
help.className = "fhelp";
help.textContent = field.help;
row.appendChild(help);
}
return row;
}
function setControl(field, value) {
const control = $(`cfg-${field.key}`);
if (!control) return;
if (field.kind === "bool") { control.checked = Boolean(value); return; }
if (value === null || value === undefined) { control.value = ""; return; }
control.value = String(value);
}
function applyValues(values) {
for (const field of state.schema.fields || []) {
const has = Object.prototype.hasOwnProperty.call(values, field.key);
setControl(field, has ? values[field.key] : field.default);
const row = $(`field-${field.key}`);
if (row) row.classList.remove("dirty", "invalid");
}
}
function collectValues() {
const out = {};
for (const field of state.schema.fields || []) {
const control = $(`cfg-${field.key}`);
if (!control) continue;
if (field.kind === "bool") { out[field.key] = control.checked; continue; }
const raw = String(control.value || "").trim();
if (raw === "") { out[field.key] = null; continue; }
if (field.kind === "integer") out[field.key] = Number.parseInt(raw, 10);
else if (field.kind === "number") out[field.key] = Number.parseFloat(raw);
else if (field.kind === "select") {
const choices = field.choices || [];
const match = choices.find((c) => String(c.value) === raw);
out[field.key] = match ? match.value : raw;
} else out[field.key] = raw;
}
return out;
}
/* 与后端 validate() 对齐的前置提示:只做提示,不阻止提交后端的权威判定 */
function validateLocally(values) {
const problems = [];
for (const field of state.schema.fields || []) {
const value = values[field.key];
if (value === null || value === undefined) continue;
if (field.kind === "integer" || field.kind === "number") {
if (!isFinite(value)) { problems.push(`${field.label} 不是有效数字`); markInvalid(field.key, true); continue; }
if (field.min !== undefined && value < field.min) { problems.push(`${field.label} 不能小于 ${field.min}`); markInvalid(field.key, true); continue; }
if (field.max !== undefined && value > field.max) { problems.push(`${field.label} 不能大于 ${field.max}`); markInvalid(field.key, true); continue; }
markInvalid(field.key, false);
}
}
if (values.rollout_steps && values.timesteps && values.rollout_steps > values.timesteps) {
problems.push("采样步数 n_steps 不能大于总步数 timesteps");
markInvalid("rollout_steps", true);
}
if (values.batch_size && values.rollout_steps && values.envs
&& values.batch_size > values.rollout_steps * values.envs) {
problems.push("批大小 batch_size 大于 n_steps × envs,PPO 每轮数据不足以分批");
markInvalid("batch_size", true);
}
return problems;
}
function markInvalid(key, bad) {
const row = $(`field-${key}`);
if (row) row.classList.toggle("invalid", Boolean(bad));
}
// ---------------------------------------------------------------- 轮询
async function poll() {
if (state.polling) return;
if (state.preview) return;
state.polling = true;
try {
const query = new URLSearchParams({
log_tail: $("log-tail").value || "400",
metric_limit: String(METRIC_LIMIT),
});
if (state.metricTotal > 0) query.set("after_metric", String(state.metricTotal));
const res = await fetch(`/api/train/state?${query.toString()}`);
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
chip("chip-conn", "连接", "已连接", "ok");
render(payload.job);
} catch (err) {
chip("chip-conn", "连接", "断开", "bad");
} finally {
state.polling = false;
}
}
/* 合并一次快照返回的进度点。服务端用 metric_offset 给出这批点的绝对起点,
客户端只需与"本地已吃到的下一个下标"(metricNext)对账:
- offset === metricNext:正常续接(增量拉取),concat;
- offset > metricNext:本地缓存与这批点之间缺了若干点(例如页面刷新后
服务端窗口已前移),保留本地旧点会让曲线出现空洞,因此整体替换;
- offset < metricNext:本地缓存比服务端更新(服务端重启/计数回退),
忽略这批过期点,不动本地缓存。
判据是"绝对下标"而不是长度:游标是服务端总数,本地可能被裁剪得更短,
拿长度比较会把同一批点重复追加(本函数注释即为此 bug 的回归说明)。
下标必须显式转 Number:JSON 里若出现字符串形式的数字,JS 的 >= 会退化成
字典序比较("14" >= "014" 为 false),静默走错分支。 */
function absorbMetrics(job) {
const incoming = Array.isArray(job.metrics) ? job.metrics : [];
const rawOffset = Number(job.metric_offset);
const offset = Number.isFinite(rawOffset) ? rawOffset : null;
const cache = state.metricCache || [];
if (incoming.length && offset !== null) {
if (!cache.length || offset > state.metricNext) {
// 首批,或与本地之间有缺口(服务端窗口前移):整体替换,避免曲线断档
state.metricCache = incoming.slice();
state.metricNext = offset + incoming.length;
} else if (offset === state.metricNext) {
state.metricCache = cache.concat(incoming);
state.metricNext = offset + incoming.length;
} else {
return; // 过期批次:本地已有更新的点,直接忽略
}
}
if (state.metricCache.length > METRIC_LIMIT) {
state.metricCache = state.metricCache.slice(state.metricCache.length - METRIC_LIMIT);
}
state.metrics = state.metricCache;
const total = Number(job.metric_total);
if (Number.isFinite(total)) state.metricTotal = total;
}
// ---------------------------------------------------------------- 渲染
function render(job) {
if (!job) {
renderIdle();
return;
}
if (job.job_id !== state.jobKey) {
state.jobKey = job.job_id;
state.metrics = [];
state.metricTotal = 0;
state.lastLogSignature = "";
}
absorbMetrics(job);
renderProcess(job);
renderConvergence(job);
renderCharts();
renderLog(job);
renderEpisodes(job);
renderBanner(job);
renderJobLine(job);
}
function renderIdle() {
chip("chip-process", "进程", "未启动", "gray");
chip("chip-progress", "进度", "—", "gray");
chip("chip-elapsed", "墙钟", "—", "gray");
chip("chip-device", "设备", "—", "gray");
setText("process-hint", "启动任务后显示子进程与步进进度");
setText("steps-done", "0"); setText("steps-total", "0");
setText("progress-percent", "0.0%");
$("bar-fill").style.width = "0%";
setText("eta", "—"); setText("eta-clock", "—"); setText("metric-total", "0");
for (const id of ["stat-pid", "stat-fps", "stat-elapsed", "stat-rc"]) setText(id, "—");
setText("stat-logs", "0");
$("timeline").innerHTML = "";
setText("command-preview", "");
chip("chip-converge", "判读", "等待数据", "gray");
$("converge-notes").innerHTML = "";
$("metric-grid").innerHTML = '<p class="hint">尚未启动训练,暂无参数。</p>';
setFormLocked(false);
}
function renderJobLine(job) {
const cfg = job.config || {};
const parts = [
`任务 ${job.job_id}`, `问题 ${cfg.problem}`, `阶段 ${cfg.stage}`,
`口径 ${cfg.reward_profile}/${cfg.config_profile}`,
`seed ${cfg.seed}`, `envs ${cfg.envs}·${cfg.vec_env}`,
];
setText("job-line", parts.join(" · "));
chip("chip-device", "设备", `${cfg.device || "?"}`, "cyan");
}
function renderProcess(job) {
const process = job.process || {};
const tone = process.tone || "gray";
chip("chip-process", "进程", process.label || job.status, tone);
chip("chip-elapsed", "墙钟", fmtDuration(process.elapsed_s), tone === "gray" ? "gray" : "cyan");
const percent = process.percent;
setText("chip-progress-value", "");
chip("chip-progress", "进度",
percent === null || percent === undefined ? "—" : `${percent.toFixed(1)}%`,
percent !== null && percent >= 100 ? "ok" : "cyan");
setText("steps-done", fmtInt(process.done_steps));
setText("steps-total", fmtInt(process.total_steps));
setText("progress-percent", percent === null || percent === undefined ? "—" : `${percent.toFixed(2)}%`);
const fill = $("bar-fill");
fill.style.width = `${percent === null || percent === undefined ? 0 : percent}%`;
fill.className = "bar-fill" + (tone === "bad" ? " bad" : tone === "warn" ? " warn" : tone === "ok" && percent >= 100 ? " ok" : "");
setText("process-hint", process.hint || "");
setText("eta", fmtDuration(process.remaining_s));
setText("eta-clock", fmtClock(process.eta_epoch_s));
setText("metric-total", fmtInt(process.metric_total));
setText("stat-pid", process.terminal ? "已退出" : (job.pid ? `PID ${job.pid}` : "—"));
setText("stat-pgid", process.terminal ? "已回收" : "独立会话");
const fps = state.metrics.length ? lastFinite(state.metrics, "fps") : null;
setText("stat-fps", fps === null ? "—" : fmtNumber(fps, 1));
setText("stat-elapsed", fmtDuration(process.elapsed_s));
const rc = $("stat-rc");
rc.textContent = job.returncode === null || job.returncode === undefined ? "—" : String(job.returncode);
rc.className = "v" + (job.returncode === 0 ? " ok" : job.returncode ? " bad" : "");
setText("stat-logs", `${fmtInt(process.log_total)} / ${fmtInt(process.log_capacity)}`);
renderTimeline(process);
setText("command-preview", job.command ? `$ ${job.command.join(" ")}` : "");
setFormLocked(job.status === "running" || job.status === "created" || job.status === "stopping");
$("btn-stop").disabled = !(job.status === "running" || job.status === "created");
}
function renderTimeline(process) {
const wrap = $("timeline");
const nodes = process.timeline || [];
if (!nodes.length) { wrap.innerHTML = ""; return; }
const active = nodes.findIndex((n) => n.state === "active");
if (wrap.dataset.signature !== nodes.map((n) => n.label).join("|")) {
wrap.innerHTML = "";
nodes.forEach((node, index) => {
if (index > 0) {
const link = document.createElement("span");
link.className = "link";
link.dataset.link = String(index);
wrap.appendChild(link);
}
const el = document.createElement("span");
el.className = "node";
el.dataset.node = node.key;
el.innerHTML = `<span class="dot"></span><span class="txt">${escapeHtml(node.label)}</span>`;
wrap.appendChild(el);
});
wrap.dataset.signature = nodes.map((n) => n.label).join("|");
}
nodes.forEach((node, index) => {
const el = wrap.querySelector(`[data-node="${node.key}"]`);
if (!el) return;
let cls = "node";
if (node.state === "done") cls += " done";
else if (node.state === "active") cls += " active";
if (node.state === "active" && process.tone === "bad") cls += " failed";
el.className = cls;
const link = wrap.querySelector(`[data-link="${index}"]`);
if (link) link.className = "link" + (index <= active ? " done" : "");
});
}
function setFormLocked(locked) {
state.saving = locked;
for (const field of state.schema.fields || []) {
const control = $(`cfg-${field.key}`);
if (control) control.disabled = locked;
}
$("btn-start").disabled = locked;
$("btn-start").textContent = locked ? "训练中…" : "开始训练";
}
function renderBanner(job) {
const process = job.process || {};
if (state.preview) {
showBanner("warn", "离线预览模式:正在渲染示例快照,<b>不会</b>启动训练进程。点「刷新」可返回实时轮询。");
return;
}
if (job.status === "failed") {
showBanner("bad", `训练异常结束(退出码 <b>${job.returncode === null ? "?" : job.returncode}</b>):${escapeHtml(job.error || "见日志尾部")}。`);
} else if (job.status === "stopped") {
showBanner("warn", "已按请求停止训练(SIGTERM 独立进程组),本轮模型可能未保存。");
} else if (job.status === "exited") {
showBanner("ok", "训练与评估流程正常结束,模型与元数据已写入输出目录。");
} else if (process.remaining_s !== null && process.remaining_s !== undefined
&& process.eta_epoch_s === null && job.status === "running") {
showBanner("warn", `尚未产出可估算吞吐的进度点,剩余时间按墙钟上限回退估计:<b>${fmtDuration(process.remaining_s)}</b>。`);
} else {
showBanner("", "");
}
}
// ---------------------------------------------------------------- 收敛
function renderConvergence(job) {
const convergence = job.convergence || {};
chip("chip-converge", "判读", convergence.status || "等待数据", convergence.tone || "gray");
const notes = convergence.notes || [];
$("converge-notes").innerHTML = notes.length
? notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
: "<li>暂无判读说明。</li>";
const grid = $("metric-grid");
grid.innerHTML = "";
const catalog = state.schema.metrics || [];
for (const metric of catalog) {
const points = seriesOf(metric.key);
const values = points.map((p) => p[1]);
const value = values.length ? values[values.length - 1] : null;
const prev = values.length > 1 ? values[values.length - 2] : null;
const delta = value !== null && prev !== null ? value - prev : null;
const card = document.createElement("div");
card.className = "metric-card";
const alert = isAlert(metric.key, value);
if (alert) card.classList.add("alert");
const trendClass = delta === null || Math.abs(delta) < 1e-12 ? "flat"
: delta > 0 ? "up" : "down";
const arrow = delta === null || Math.abs(delta) < 1e-12 ? "→" : delta > 0 ? "▲" : "▼";
card.innerHTML =
`<div class="mhead"><span class="mname">${escapeHtml(metric.label)}</span>` +
`<span class="mvalue">${escapeHtml(fmtValue(metric, value))}</span></div>` +
`<canvas class="spark" data-spark="${metric.key}"></canvas>` +
`<div class="mfoot"><span class="trend ${trendClass}" data-delta="${metric.key}">${arrow} ${escapeHtml(delta === null ? "—" : fmtValue(metric, delta))}</span>` +
`<span>${points.length} 点</span></div>`;
if (alert) card.title = alert;
grid.appendChild(card);
const canvas = card.querySelector("canvas");
if (canvas && values.length >= 2) {
window.Charts.sparkline(canvas, values.slice(-120), { color: metric.color });
}
}
}
function isAlert(key, value) {
if (value === null || value === undefined) return null;
if (key === "approx_kl" && value > 0.05) return "近似 KL 超过 0.05:策略更新幅度过大";
if (key === "explained_variance" && value < 0) return "解释方差为负:价值函数尚未拟合";
if (key === "entropy_loss" && Math.abs(value) < 1e-3) return "策略熵接近 0:探索可能过早停止";
return null;
}
function fmtValue(metric, value) {
if (value === null || value === undefined || !isFinite(value)) return "—";
if (metric.format === "integer") return fmtInt(value);
return fmtNumber(value);
}
function seriesOf(key) {
const points = [];
for (const row of state.metrics) {
const value = Number(row[key]);
if (!isFinite(value)) continue;
const x = Number(row.timesteps ?? row.total_timesteps ?? points.length + 1);
points.push([isFinite(x) ? x : points.length + 1, value]);
}
return points;
}
function lastFinite(rows, key) {
for (let i = rows.length - 1; i >= 0; i--) {
const value = Number(rows[i][key]);
if (isFinite(value)) return value;
}
return null;
}
// ---------------------------------------------------------------- 图表
function renderCharts() {
const catalog = state.schema.metrics || [];
const tabs = $("chart-tabs");
if (tabs.dataset.signature !== catalog.map((m) => m.key).join("|")) {
tabs.innerHTML = "";
for (const metric of catalog) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chart-tab";
btn.dataset.key = metric.key;
btn.textContent = metric.label;
btn.onclick = () => { state.chartKey = metric.key; renderCharts(); };
tabs.appendChild(btn);
}
tabs.dataset.signature = catalog.map((m) => m.key).join("|");
}
for (const btn of tabs.querySelectorAll(".chart-tab")) {
btn.classList.toggle("active", btn.dataset.key === state.chartKey);
}
const windowSize = Number($("chart-window").value || 0);
const primary = catalog.find((m) => m.role === "primary") || { key: "ep_rew_mean", label: "奖励", color: "#4f8cff" };
const rewardPoints = slice(seriesOf(primary.key), windowSize);
const layout = window.Charts.line($("chart-reward"), {
series: [{ name: primary.label, points: rewardPoints, color: primary.color, fill: true }],
xFormat: (v) => `${fmtInt(v)}步`,
emptyText: "等待首个 rollout 的 ep_rew_mean……",
});
setText("chart-title", primary.label);
setText("chart-sub", describeSeries(rewardPoints, primary));
const metric = catalog.find((m) => m.key === state.chartKey) || primary;
const metricPoints = slice(seriesOf(metric.key), windowSize);
const refs = [];
if (metric.key === "approx_kl") refs.push({ value: 0.05, color: "#f87171", label: "过猛阈值 0.05" });
if (metric.key === "explained_variance") refs.push({ value: 0, color: "#93a1bd", label: "0(不如均值预测)" });
const layout2 = window.Charts.line($("chart-metric"), {
series: [{ name: metric.label, points: metricPoints, color: metric.color, fill: true }],
refs: refs,
xFormat: (v) => `${fmtInt(v)}步`,
emptyText: `等待 ${metric.key} 数据……`,
});
setText("chart2-title", metric.label);
setText("chart2-sub", describeSeries(metricPoints, metric));
state.layouts = { reward: layout, metric: layout2, metricKey: metric.key, primary: primary };
}
function slice(points, size) {
if (!size || points.length <= size) return points;
return points.slice(points.length - size);
}
function describeSeries(points, metric) {
if (!points.length) return "(无数据)";
if (points.length === 1) return `仅 1 点 · 最新 ${fmtValue(metric, points[0][1])}`;
const first = points[0], last = points[points.length - 1];
const delta = last[1] - first[1];
const sign = delta > 0 ? "+" : "";
return `${points.length} 点 · 窗口内 ${sign}${fmtValue(metric, delta)} · ` +
`最新 ${fmtValue(metric, last[1])} @ ${fmtInt(last[0])} 步`;
}
function bindChartInteractions() {
for (const id of ["chart-reward", "chart-metric"]) {
const canvas = $(id);
if (!canvas) continue;
const overlay = document.createElement("canvas");
overlay.className = "chart-overlay";
overlay.style.width = "100%"; overlay.style.height = "100%";
if (canvas.parentElement) {
canvas.parentElement.style.position = "relative";
canvas.parentElement.appendChild(overlay);
}
const move = (event) => {
const show = $("chart-cursor").checked;
const rect = canvas.getBoundingClientRect();
const which = id === "chart-reward" ? "reward" : "metric";
const layout = state.layouts && state.layouts[which];
const ctx = overlay.getContext("2d");
const dpr = window.devicePixelRatio || 1;
if (overlay.width !== Math.round(rect.width * dpr) || overlay.height !== Math.round(rect.height * dpr)) {
overlay.width = Math.round(rect.width * dpr);
overlay.height = Math.round(rect.height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
if (!layout || !show || !isFinite(layout.bounds.x0)) return;
window.Charts.drawCursor(overlay, layout, {
mouse: { x: event.clientX - rect.left, y: event.clientY - rect.top },
xFormat: (v) => `${fmtInt(v)}步`,
});
};
canvas.addEventListener("mousemove", move);
canvas.addEventListener("mouseleave", () => {
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
});
}
$("chart-window").addEventListener("change", renderCharts);
$("chart-cursor").addEventListener("change", renderCharts);
}
// ---------------------------------------------------------------- 日志
function renderLog(job) {
const lines = job.log_lines || [];
const filter = ($("log-filter").value || "").trim().toLowerCase();
const showJson = $("log-json").checked;
const errorsOnly = $("log-errors-only").checked;
const view = $("log-view");
const signature = `${lines.length}|${job.log_total}|${filter}|${showJson}|${errorsOnly}|${lines[lines.length - 1] || ""}`;
if (signature === state.lastLogSignature) return;
state.lastLogSignature = signature;
const follow = $("log-follow").checked;
const atBottom = view.scrollTop + view.clientHeight >= view.scrollHeight - 24;
const keep = follow || atBottom;
const html = [];
let shown = 0;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const lower = raw.toLowerCase();
if (filter && !lower.includes(filter)) continue;
if (errorsOnly && !/error|traceback|failed|exception|错误|失败/.test(lower)) continue;
if (!showJson && raw.trim().startsWith("{") && raw.includes('"event"')) continue;
shown++;
const cls = classify(raw);
html.push(`<span class="l ${cls}">${highlight(raw, filter)}</span>`);
}
view.innerHTML = html.length ? html.join("") : '<span class="l dim">(无匹配日志行)</span>';
if (keep) view.scrollTop = view.scrollHeight;
setText("log-shown", `显示 ${shown} / 共 ${fmtInt(job.log_total)} 行`);
setText("log-meta", `stdout/stderr 合并捕获 · 服务端保留最近 ${fmtInt(job.process ? job.process.log_capacity : 4000)} 行`);
}
function classify(line) {
const text = line.toLowerCase();
if (/error|traceback|exception|failed|错误|失败/.test(text)) return "err";
if (/warn|deprecat|警告/.test(text)) return "warn";
if (line.trim().startsWith("{")) return "json";
if (/^\s*[-=]{3,}|已保存|success|complete|评估|metadata/.test(text)) return "meta";
if (!line.trim()) return "dim";
return "";
}
function highlight(line, filter) {
const escaped = escapeHtml(line);
if (!filter) return escaped;
try {
return escaped.replace(new RegExp(`(${filter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"),
"<mark>$1</mark>");
} catch (err) { return escaped; }
}
// ---------------------------------------------------------------- 逐回合
function renderEpisodes(job) {
const rows = job.monitor || [];
const tbody = $("episode-table").querySelector("tbody");
const tail = rows.slice(-200).reverse();
tbody.innerHTML = tail.map((row, index) => {
const seq = rows.length - index;
return `<tr><td>${seq}</td><td>${fmtNumber(row.r)}</td>` +
`<td>${fmtNumber(row.l, 0)}</td><td>${fmtNumber(row.t, 3)}</td></tr>`;
}).join("") || '<tr><td colspan="4" class="hint">尚无逐回合记录(Monitor CSV 随训练写入)</td></tr>';
}
// ---------------------------------------------------------------- 交互
$("config-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (state.saving) return;
const err = $("form-error");
err.textContent = "";
const values = collectValues();
const problems = validateLocally(values);
if (problems.length) { err.textContent = problems[0]; return; }
const payload = {};
for (const [key, value] of Object.entries(values)) {
if (value !== null) payload[key] = value;
}
if (payload.resume && !payload.model) { err.textContent = "续训需要填写模型输出路径"; return; }
const res = await fetch("/api/train/start", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { err.textContent = body.error || `启动失败(HTTP ${res.status})`; return; }
state.preview = false;
state.jobKey = null;
state.metrics = [];
state.metricCache = [];
state.metricNext = 0;
state.metricTotal = 0;
await poll();
});
$("btn-stop").addEventListener("click", async () => {
if (!confirm("确认停止当前训练?\n将向独立进程组发送 SIGTERM,本轮模型可能未保存。")) return;
const res = await fetch("/api/train/stop", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
});
const body = await res.json().catch(() => ({}));
if (!res.ok) showBanner("bad", `停止失败:${escapeHtml(body.error || res.status)}`);
await poll();
});
$("btn-refresh").addEventListener("click", async () => {
state.preview = false;
showBanner("", "");
await poll();
});
$("btn-preview").addEventListener("click", async () => {
try {
const scenario = $("preview-scenario") ? $("preview-scenario").value : "rising";
const res = await fetch(`/api/train/state?preview=1&scenario=${encodeURIComponent(scenario)}&log_tail=400`);
const payload = await res.json();
if (!payload.job) throw new Error(payload.error || "服务端未返回示例快照");
state.preview = true;
state.jobKey = null;
state.metrics = [];
state.metricCache = [];
state.metricNext = 0;
state.metricTotal = 0;
render(payload.job);
} catch (err) {
showBanner("bad", `离线预览不可用:${escapeHtml(err.message)}`);
}
});
$("btn-reset").addEventListener("click", () => {
applyValues({});
$("form-error").textContent = "";
});
for (const id of ["log-filter", "log-json", "log-errors-only", "log-tail"]) {
$(id).addEventListener("input", () => {
state.lastLogSignature = "";
poll();
});
}
boot();
+255
View File
@@ -0,0 +1,255 @@
/* 训练前端图表:原生 canvas 折线图,无外部依赖(离线可用,与评测前端同风格)。
只做三件事:坐标轴自适应、多序列折线、悬停读数;不引入缩放/图例等交互,
因为训练曲线每 2 秒整体重绘一次,重量级图表库在这里只会增加风险。
对外接口(挂到 globalThis.Charts):
Charts.fit(canvas) 按 DPR 调整画布像素
Charts.line(canvas, {series, refs, ...}) 折线图
Charts.sparkline(canvas, values, opts) 指标卡片内的小趋势线
数据约定:series = [{ points: [[x, y], ...], color, width, fill }] */
(function (global) {
"use strict";
var AXIS = "#2a3550", GRID = "rgba(42,53,80,.55)", DIM = "#93a1bd", TEXT = "#e6ecf8";
var FONT = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
function fit(canvas) {
var dpr = global.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : null;
var cssWidth = (rect && rect.width) || canvas.clientWidth || canvas.width || 640;
var cssHeight = (rect && rect.height) || canvas.clientHeight || canvas.height || 200;
var w = Math.max(160, Math.round(cssWidth * dpr));
var h = Math.max(80, Math.round(cssHeight * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
var ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx: ctx, w: w / dpr, h: h / dpr };
}
/* 第 0 层:数值格式(科学计数法只用于真正小而大的量级) */
function fmt(value) {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
var abs = Math.abs(value);
if (abs !== 0 && (abs < 1e-3 || abs >= 1e5)) return value.toExponential(2);
if (abs >= 100) return value.toFixed(1);
if (abs >= 1) return value.toFixed(2);
if (abs >= 0.01) return value.toFixed(3);
return value.toFixed(4);
}
function niceTicks(min, max, count) {
if (!isFinite(min) || !isFinite(max)) return [0, 1];
if (min === max) { var pad = Math.abs(min) > 1e-9 ? Math.abs(min) * 0.1 : 0.5;
min -= pad; max += pad; }
var span = max - min;
var raw = span / Math.max(1, count);
var mag = Math.pow(10, Math.floor(Math.log10(raw)));
var norm = raw / mag;
var step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
var start = Math.ceil(min / step) * step;
var ticks = [];
for (var v = start; v <= max + step * 1e-6 && ticks.length < 40; v += step) {
ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v);
}
if (!ticks.length) ticks = [min, max];
return ticks;
}
function bounds(series) {
var xs = [], ys = [];
for (var i = 0; i < series.length; i++) {
var pts = series[i].points || [];
for (var j = 0; j < pts.length; j++) {
var p = pts[j];
if (!p || !isFinite(p[0]) || !isFinite(p[1])) continue;
xs.push(p[0]); ys.push(p[1]);
}
}
if (!xs.length) return null;
var x0 = Math.min.apply(null, xs), x1 = Math.max.apply(null, xs);
var y0 = Math.min.apply(null, ys), y1 = Math.max.apply(null, ys);
if (x0 === x1) { x0 -= 0.5; x1 += 0.5; }
if (y0 === y1) { var pad = Math.abs(y0) > 1e-9 ? Math.abs(y0) * 0.08 : 0.5;
y0 -= pad; y1 += pad; }
return { x0: x0, x1: x1, y0: y0, y1: y1 };
}
/* ------------------------------------------------------------ 折线图 */
function line(canvas, options) {
var opts = options || {};
var axes = fit(canvas), ctx = axes.ctx, W = axes.w, H = axes.h;
var padL = opts.padLeft || 62, padR = 14, padT = 10, padB = 24;
ctx.clearRect(0, 0, W, H);
var series = (opts.series || []).filter(function (s) {
return s && s.points && s.points.length;
});
var box = bounds(series);
if (!box) {
ctx.fillStyle = DIM; ctx.font = FONT;
ctx.fillText(opts.emptyText || "等待数据……", padL, H / 2);
return null;
}
var yTicks = niceTicks(box.y0, box.y1, 4);
var yMin = Math.min(box.y0, yTicks[0]), yMax = Math.max(box.y1, yTicks[yTicks.length - 1]);
var plotW = Math.max(10, W - padL - padR), plotH = Math.max(10, H - padT - padB);
var sx = function (x) { return padL + (x - box.x0) / (box.x1 - box.x0) * plotW; };
var sy = function (y) { return padT + (1 - (y - yMin) / (yMax - yMin)) * plotH; };
// 网格与刻度
ctx.font = FONT; ctx.lineWidth = 1;
for (var i = 0; i < yTicks.length; i++) {
var y = sy(yTicks[i]);
ctx.strokeStyle = GRID; ctx.beginPath();
ctx.moveTo(padL, y + 0.5); ctx.lineTo(padL + plotW, y + 0.5); ctx.stroke();
ctx.fillStyle = DIM; ctx.textAlign = "right";
ctx.fillText(fmt(yTicks[i]), padL - 6, y + 3.5);
}
ctx.textAlign = "center";
for (var t = 0; t <= 4; t++) {
var xv = box.x0 + (box.x1 - box.x0) * (t / 4);
var x = sx(xv);
ctx.fillStyle = DIM;
ctx.fillText(opts.xFormat ? opts.xFormat(xv) : fmt(xv), x, H - 7);
}
ctx.textAlign = "left";
ctx.strokeStyle = AXIS; ctx.beginPath();
ctx.moveTo(padL + 0.5, padT); ctx.lineTo(padL + 0.5, padT + plotH);
ctx.lineTo(padL + plotW, padT + plotH); ctx.stroke();
// 参考线(例如 KL 的 0.05 阈值、解释方差的 0)
var refs = opts.refs || [];
for (var r = 0; r < refs.length; r++) {
var ref = refs[r];
if (ref.value === undefined || ref.value < yMin || ref.value > yMax) continue;
var ry = sy(ref.value);
ctx.save();
ctx.setLineDash([4, 4]); ctx.strokeStyle = ref.color || "#f59e0b";
ctx.beginPath(); ctx.moveTo(padL, ry); ctx.lineTo(padL + plotW, ry); ctx.stroke();
ctx.restore();
if (ref.label) {
ctx.fillStyle = ref.color || "#f59e0b"; ctx.font = FONT;
ctx.fillText(ref.label, padL + 6, ry - 4);
}
}
// 序列
for (var s = 0; s < series.length; s++) {
var item = series[s];
var pts = item.points;
ctx.strokeStyle = item.color || "#4f8cff";
ctx.lineWidth = item.width || 1.6;
ctx.lineJoin = "round";
ctx.beginPath();
for (var k = 0; k < pts.length; k++) {
var px = sx(pts[k][0]), py = sy(pts[k][1]);
if (k === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
if (item.fill && pts.length > 1) {
ctx.save();
ctx.globalAlpha = 0.12; ctx.fillStyle = item.color || "#4f8cff";
ctx.lineTo(sx(pts[pts.length - 1][0]), padT + plotH);
ctx.lineTo(sx(pts[0][0]), padT + plotH);
ctx.closePath(); ctx.fill(); ctx.restore();
}
// 末点高亮:一眼看到最新值
var last = pts[pts.length - 1];
ctx.fillStyle = item.color || "#4f8cff";
ctx.beginPath(); ctx.arc(sx(last[0]), sy(last[1]), 2.6, 0, Math.PI * 2); ctx.fill();
}
return { bounds: box, sx: sx, sy: sy, padL: padL, padT: padT,
plotW: plotW, plotH: plotH, yMin: yMin, yMax: yMax, series: series };
}
/* 悬停读数:把鼠标 x 映射到最接近的数据点,返回一次绘制的覆盖层信息 */
function drawCursor(canvas, layout, options) {
var opts = options || {};
if (!layout || !layout.series || !layout.series.length) return;
var axes = fit(canvas), ctx = axes.ctx;
var mouse = opts.mouse;
if (!mouse || mouse.x < layout.padL || mouse.x > layout.padL + layout.plotW) return;
var target = layout.bounds.x0 +
(mouse.x - layout.padL) / layout.plotW * (layout.bounds.x1 - layout.bounds.x0);
var lines = [], snapX = target;
for (var s = 0; s < layout.series.length; s++) {
var pts = layout.series[s].points;
if (!pts.length) continue;
// 数据已按 x 递增,二分定位最近点
var lo = 0, hi = pts.length - 1, best = pts[hi];
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (pts[mid][0] === target) { best = pts[mid]; break; }
if (pts[mid][0] < target) { best = pts[mid]; lo = mid + 1; }
else { hi = mid - 1; }
}
var next = pts[Math.min(pts.length - 1, lo)];
if (next && Math.abs(next[0] - target) < Math.abs(best[0] - target)) best = next;
snapX = best[0];
lines.push({ label: layout.series[s].name || "", color: layout.series[s].color,
value: best[1] });
}
var cx = layout.sx(snapX);
ctx.save();
ctx.setLineDash([3, 3]); ctx.strokeStyle = "rgba(230,236,248,.35)";
ctx.beginPath(); ctx.moveTo(cx, layout.padT);
ctx.lineTo(cx, layout.padT + layout.plotH); ctx.stroke();
ctx.restore();
for (var i = 0; i < lines.length; i++) {
var item = lines[i];
var cy = layout.sy(item.value);
ctx.fillStyle = item.color || "#4f8cff";
ctx.beginPath(); ctx.arc(cx, cy, 3.2, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = "rgba(11,15,26,.9)"; ctx.lineWidth = 1.4; ctx.stroke();
}
var text = (opts.xFormat ? opts.xFormat(snapX) : fmt(snapX)) + " · " +
lines.map(function (item) { return (item.label ? item.label + " " : "") + fmt(item.value); })
.join(" ");
ctx.font = FONT;
var tw = ctx.measureText(text).width + 14;
var bx = Math.min(cx + 8, layout.padL + layout.plotW - tw);
bx = Math.max(layout.padL + 2, bx);
var by = layout.padT + 4;
ctx.fillStyle = "rgba(23,30,46,.94)"; ctx.strokeStyle = AXIS;
ctx.beginPath();
if (ctx.roundRect) { ctx.roundRect(bx, by, tw, 20, 6); ctx.fill(); ctx.stroke(); }
else { ctx.fillRect(bx, by, tw, 20); ctx.strokeRect(bx, by, tw, 20); }
ctx.fillStyle = TEXT; ctx.textAlign = "left";
ctx.fillText(text, bx + 7, by + 14);
}
/* ------------------------------------------------------------ 迷你趋势线 */
function sparkline(canvas, values, options) {
var opts = options || {};
var axes = fit(canvas), ctx = axes.ctx, W = axes.w, H = axes.h;
ctx.clearRect(0, 0, W, H);
var clean = (values || []).filter(function (v) { return isFinite(v); });
if (clean.length < 2) {
ctx.strokeStyle = GRID; ctx.beginPath();
ctx.moveTo(0, H - 1.5); ctx.lineTo(W, H - 1.5); ctx.stroke();
return;
}
var lo = Math.min.apply(null, clean), hi = Math.max.apply(null, clean);
if (lo === hi) { lo -= 0.5; hi += 0.5; }
var pad = 2;
var X = function (i) { return (i / (clean.length - 1)) * W; };
var Y = function (v) { return H - pad - (v - lo) / (hi - lo) * (H - pad * 2); };
ctx.strokeStyle = opts.color || "#4f8cff"; ctx.lineWidth = 1.4;
ctx.beginPath();
for (var i = 0; i < clean.length; i++) {
if (i === 0) ctx.moveTo(X(i), Y(clean[i])); else ctx.lineTo(X(i), Y(clean[i]));
}
ctx.stroke();
if (opts.fill !== false) {
ctx.save(); ctx.globalAlpha = 0.14; ctx.fillStyle = opts.color || "#4f8cff";
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fill(); ctx.restore();
}
var lastX = X(clean.length - 1), lastY = Y(clean[clean.length - 1]);
ctx.fillStyle = opts.color || "#4f8cff";
ctx.beginPath(); ctx.arc(lastX - 1.5, lastY, 2.2, 0, Math.PI * 2); ctx.fill();
}
global.Charts = { fit: fit, line: line, drawCursor: drawCursor,
sparkline: sparkline, fmt: fmt };
})(typeof globalThis !== "undefined" ? globalThis : this);
+174
View File
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>模型训练前端 · CUMCM 2026 B HRL</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<h1>模型训练前端 <span class="sub">HRL 高层 MaskablePPO</span></h1>
<p class="tagline" id="job-line">尚未启动训练任务</p>
</div>
<div class="topstatus">
<span class="chip gray" id="chip-process">进程<b>未启动</b></span>
<span class="chip gray" id="chip-progress">进度<b>—</b></span>
<span class="chip gray" id="chip-elapsed">墙钟<b>—</b></span>
<span class="chip gray" id="chip-device">设备<b>—</b></span>
<span class="chip gray" id="chip-conn">连接<b>探测中</b></span>
</div>
<div class="topactions">
<button id="btn-refresh" type="button" title="立即拉取一次快照">刷新</button>
<label class="inline" title="离线预览:读取示例快照,用于无训练进程时检查界面">
预览
<select id="preview-scenario">
<option value="rising">上升</option>
<option value="plateau">平台期</option>
<option value="kl-spike">KL 过猛</option>
</select>
</label>
<button id="btn-preview" type="button" title="渲染示例快照,不启动任何训练进程">离线预览</button>
<button id="btn-stop" class="danger" type="button" disabled>停止训练</button>
</div>
</header>
<main>
<div id="banner" class="banner hidden"></div>
<!-- =============================================== 1. 执行进度与进程 -->
<section class="panel" id="panel-process">
<div class="panel-head">
<h2>执行进度与进程</h2>
<span class="hint" id="process-hint">启动任务后显示子进程与步进进度</span>
</div>
<div class="progress-row">
<div class="progress-main">
<div class="progress-labels">
<span><b id="steps-done">0</b> / <span id="steps-total">0</span> 环境步</span>
<span class="hint" id="progress-percent">0.0%</span>
</div>
<div class="bar"><div class="bar-fill" id="bar-fill"></div></div>
<div class="progress-foot">
<span class="hint">剩余估计 <b id="eta">—</b></span>
<span class="hint">预计结束 <b id="eta-clock">—</b></span>
<span class="hint">进度点 <b id="metric-total">0</b></span>
</div>
</div>
<div class="stat-grid">
<div class="stat"><span class="k">子进程 PID</span><b class="v" id="stat-pid">—</b></div>
<div class="stat"><span class="k">进程组 / 会话</span><b class="v" id="stat-pgid">独立</b></div>
<div class="stat"><span class="k">吞吐 FPS</span><b class="v" id="stat-fps">—</b></div>
<div class="stat"><span class="k">已用墙钟</span><b class="v" id="stat-elapsed">—</b></div>
<div class="stat"><span class="k">退出码</span><b class="v" id="stat-rc">—</b></div>
<div class="stat"><span class="k">日志行数</span><b class="v" id="stat-logs">0</b></div>
</div>
</div>
<div class="timeline" id="timeline"></div>
<p class="hint" id="command-preview"></p>
</section>
<!-- =============================================== 2. 训练配置 -->
<section class="panel" id="panel-config">
<div class="panel-head">
<h2>训练配置</h2>
<span class="hint">字段与后端 <code>TrainConfig</code> 同源;留空字段取默认值</span>
</div>
<div class="presets" id="presets"></div>
<form id="config-form" autocomplete="off">
<div class="form-groups" id="form-groups"></div>
<div class="form-foot">
<button id="btn-start" class="primary" type="submit">开始训练</button>
<button id="btn-reset" type="button">恢复默认</button>
<span class="form-error" id="form-error"></span>
</div>
</form>
</section>
<!-- =============================================== 3. 参数收敛 -->
<section class="panel" id="panel-convergence">
<div class="panel-head">
<h2>参数收敛状态</h2>
<span class="hint">判据为最近窗口的斜率与波动,仅供人工复核</span>
</div>
<div class="converge-head">
<span class="chip gray" id="chip-converge">判读<b>等待数据</b></span>
<ul class="notes" id="converge-notes"></ul>
</div>
<div class="metric-grid" id="metric-grid"></div>
<div class="chart-toolbar">
<div class="chart-tabs" id="chart-tabs"></div>
<label class="inline">最近点数
<select id="chart-window">
<option value="0">全部</option>
<option value="40">40</option>
<option value="120" selected>120</option>
<option value="400">400</option>
</select>
</label>
<label class="inline"><input type="checkbox" id="chart-cursor" checked> 悬停读数</label>
</div>
<div class="chart-stack">
<figure class="chart-figure">
<figcaption><b id="chart-title">平均回合奖励</b><span class="hint" id="chart-sub"></span></figcaption>
<canvas id="chart-reward" height="240"></canvas>
</figure>
<figure class="chart-figure">
<figcaption><b id="chart2-title">近似 KL</b><span class="hint" id="chart2-sub"></span></figcaption>
<canvas id="chart-metric" height="180"></canvas>
</figure>
</div>
</section>
<!-- =============================================== 4. 实时运行日志 -->
<section class="panel" id="panel-log">
<div class="panel-head">
<h2>实时运行日志</h2>
<span class="hint" id="log-meta">stdout/stderr 合并捕获</span>
</div>
<div class="log-toolbar">
<input id="log-filter" type="search" placeholder="过滤关键字,如 rollout / progress / error">
<label class="inline"><input type="checkbox" id="log-json" checked> 显示进度 JSON</label>
<label class="inline"><input type="checkbox" id="log-errors-only"> 只看错误</label>
<label class="inline"><input type="checkbox" id="log-follow" checked> 自动跟随到底部</label>
<label class="inline">尾部行数
<select id="log-tail">
<option value="200">200</option>
<option value="400" selected>400</option>
<option value="1000">1000</option>
<option value="2000">2000</option>
</select>
</label>
<span class="hint" id="log-shown"></span>
</div>
<pre class="log-view" id="log-view">(暂无输出)</pre>
</section>
<!-- =============================================== 5. 逐回合奖励 -->
<section class="panel" id="panel-episodes">
<div class="panel-head">
<h2>逐回合记录</h2>
<span class="hint">来自训练脚本写入的 Monitor CSV(每回合奖励/长度/时刻)</span>
</div>
<div class="table-wrap">
<table id="episode-table">
<thead><tr><th>#</th><th>奖励 r</th><th>长度 l</th><th>时刻 t (s)</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
<footer>
本地回环训练前端:与评测前端共用「白名单静态资源 + JSON 轮询 API」模式;
命令由服务端以参数列表构造(不经过 shell),停止时向独立进程组发送 SIGTERM。
</footer>
<script src="/charts.js"></script>
<script src="/app.js"></script>
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
/* 训练前端样式:沿用评测前端(referee/static/style.css)的暗色变量与圆角面板
语言,保证两个界面像同一套系统的两个页面;本页信息密度更高:
进度条、进程时间线、参数卡片网格、曲线与日志终端。 */
:root {
--bg: #0f1420; --panel: #171e2e; --panel2: #1d2740; --line: #2a3550;
--text: #e6ecf8; --dim: #93a1bd; --accent: #4f8cff; --ok: #34d399;
--warn: #f59e0b; --bad: #f87171; --cyan: #38bdf8; --gray: #6b7280;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.5 "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
code, .mono { background: var(--panel2); padding: 1px 6px; border-radius: 4px; color: var(--cyan); font-family: var(--mono); font-size: 12px; }
main { max-width: 1240px; margin: 0 auto; padding: 16px 22px 48px; }
footer { color: var(--dim); font-size: 12px; text-align: center; padding: 14px 22px; border-top: 1px solid var(--line); }
.hidden { display: none !important; }
.hint { color: var(--dim); font-size: 12px; }
h2 { font-size: 15px; margin: 0; }
/* ------------------------------------------------------------ 顶栏 */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 12px 22px; border-bottom: 1px solid var(--line);
background: rgba(23,30,46,.96); backdrop-filter: blur(6px);
}
.brand h1 { font-size: 17px; margin: 0; }
.brand .sub { font-size: 12px; color: var(--dim); font-weight: normal; margin-left: 8px; }
.brand .tagline { margin: 3px 0 0; color: var(--dim); font-size: 12px; font-family: var(--mono); }
.topstatus { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; }
.topactions { display: flex; gap: 8px; }
.chip {
background: var(--panel2); border: 1px solid var(--line); border-radius: 999px;
padding: 3px 12px; font-size: 12px; color: var(--dim); white-space: nowrap;
}
.chip b { color: var(--text); font-weight: 600; margin-left: 4px; }
.chip.gray b { color: var(--gray); }
.chip.ok b { color: var(--ok); }
.chip.warn b { color: var(--warn); }
.chip.bad b { color: var(--bad); }
.chip.cyan b { color: var(--cyan); }
button, .button {
background: var(--panel2); color: var(--text); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 14px; font-size: 13px; cursor: pointer;
}
button:hover:enabled, .button:hover { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover:enabled { filter: brightness(1.08); }
button.danger { background: transparent; border-color: var(--bad); color: var(--bad); }
button.danger:hover:enabled { background: rgba(248,113,113,.12); }
.banner {
border: 1px solid var(--bad); border-left: 4px solid var(--bad);
background: rgba(248,113,113,.08); border-radius: 8px;
padding: 10px 14px; margin: 14px 0 0; font-size: 13px;
}
.banner.ok { border-color: var(--ok); border-left-color: var(--ok); background: rgba(52,211,153,.08); }
.banner.warn { border-color: var(--warn); border-left-color: var(--warn); background: rgba(245,158,11,.08); }
.banner b { font-family: var(--mono); }
/* ------------------------------------------------------------ 面板 */
.panel {
border: 1px solid var(--line); border-radius: 12px; background: var(--panel);
padding: 14px 16px; margin-top: 16px;
}
.panel-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.panel-head h2 { color: var(--text); }
.panel-head h2::before {
content: ""; display: inline-block; width: 3px; height: 13px; margin-right: 8px;
background: var(--accent); border-radius: 2px; vertical-align: -1px;
}
/* ------------------------------------------------------------ 进度 */
.progress-row { display: flex; gap: 20px; align-items: stretch; flex-wrap: wrap; }
.progress-main { flex: 1 1 380px; min-width: 320px; }
.progress-labels { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
.progress-labels b { font-family: var(--mono); font-size: 15px; }
.bar { height: 12px; border-radius: 999px; background: #0b0f1a; border: 1px solid var(--line); overflow: hidden; }
.bar-fill {
height: 100%; width: 0%; border-radius: 999px;
background: linear-gradient(90deg, #2f6fe0, var(--accent) 60%, var(--cyan));
transition: width .3s ease;
}
.bar-fill.warn { background: linear-gradient(90deg, #b45309, var(--warn)); }
.bar-fill.bad { background: linear-gradient(90deg, #b91c1c, var(--bad)); }
.bar-fill.ok { background: linear-gradient(90deg, #059669, var(--ok)); }
.progress-foot { display: flex; gap: 18px; margin-top: 7px; flex-wrap: wrap; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; flex: 1 1 420px; }
.stat {
border: 1px solid var(--line); border-radius: 8px; background: var(--panel2);
padding: 7px 10px; display: flex; flex-direction: column; gap: 2px;
}
.stat .k { color: var(--dim); font-size: 11px; }
.stat .v { font-family: var(--mono); font-size: 15px; }
.stat .v.ok { color: var(--ok); }
.stat .v.warn { color: var(--warn); }
.stat .v.bad { color: var(--bad); }
.timeline { display: flex; align-items: center; gap: 0; margin-top: 16px; flex-wrap: wrap; }
.node { display: flex; align-items: center; gap: 8px; }
.node .dot {
width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line);
background: var(--panel2); flex: none;
}
.node .txt { font-size: 12px; color: var(--dim); }
.node.done .dot { border-color: var(--ok); background: var(--ok); }
.node.done .txt { color: var(--text); }
.node.active .dot { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 0 4px rgba(79,140,255,.18); }
.node.active .txt { color: var(--accent); font-weight: 600; }
.node.failed .dot { border-color: var(--bad); background: var(--bad); }
.node.failed .txt { color: var(--bad); }
.link { width: 34px; height: 2px; background: var(--line); flex: none; margin: 0 8px; }
.link.done { background: var(--ok); }
#command-preview { margin: 12px 0 0; font-family: var(--mono); word-break: break-all; line-height: 1.7; }
/* ------------------------------------------------------------ 表单 */
.presets { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.preset {
border: 1px solid var(--line); border-radius: 8px; background: var(--panel2);
padding: 7px 12px; font-size: 12px; cursor: pointer; color: var(--text);
}
.preset:hover { border-color: var(--accent); }
.preset .note { display: block; color: var(--dim); font-size: 11px; margin-top: 2px; }
.form-groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(268px, 1fr)); gap: 14px; }
fieldset.group { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px 12px; margin: 0; background: #141b29; }
fieldset.group legend { color: var(--dim); font-size: 12px; padding: 0 6px; }
fieldset.group .ghint { color: var(--dim); font-size: 11px; margin: 0 0 8px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.field .flabel { font-size: 12px; color: var(--dim); display: flex; gap: 6px; align-items: baseline; }
.field .flabel .unit { color: var(--gray); font-size: 11px; }
.field input[type="text"], .field input[type="number"], .field select {
background: var(--panel2); border: 1px solid var(--line); color: var(--text);
border-radius: 6px; padding: 6px 9px; font-size: 13px; width: 100%;
font-family: var(--mono);
}
.field input:focus, .field select:focus, .log-toolbar input:focus { outline: none; border-color: var(--accent); }
.field .fhelp { font-size: 11px; color: var(--gray); }
.field.check { flex-direction: row; align-items: center; gap: 8px; }
.field.check input { width: auto; }
.field.dirty input, .field.dirty select { border-color: rgba(79,140,255,.65); }
.field.invalid input, .field.invalid select { border-color: var(--bad); }
.form-foot { display: flex; align-items: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
.form-error { color: var(--bad); font-size: 12px; }
/* ------------------------------------------------------------ 收敛 */
.converge-head { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 12px; }
.notes { margin: 0; padding-left: 18px; color: var(--dim); font-size: 12px; max-width: 760px; }
.notes li { margin: 2px 0; }
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(186px, 1fr)); gap: 10px; }
.metric-card {
border: 1px solid var(--line); border-radius: 10px; background: var(--panel2);
padding: 9px 11px; display: flex; flex-direction: column; gap: 5px;
}
.metric-card .mhead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.metric-card .mname { font-size: 12px; color: var(--dim); }
.metric-card .mvalue { font-family: var(--mono); font-size: 17px; }
.metric-card .mfoot { display: flex; justify-content: space-between; font-size: 11px; color: var(--gray); gap: 6px; }
.metric-card canvas { width: 100%; height: 40px; display: block; }
.trend.up { color: var(--ok); } .trend.down { color: var(--warn); }
.trend.flat { color: var(--dim); } .trend.none { color: var(--gray); }
.metric-card.alert { border-color: rgba(248,113,113,.55); }
.metric-card.alert .mvalue { color: var(--bad); }
/* ------------------------------------------------------------ 图表 */
.chart-toolbar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 16px 0 8px; }
.chart-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.chart-tab {
border: 1px solid var(--line); background: var(--transparent, transparent);
color: var(--dim); border-radius: 999px; padding: 4px 12px; font-size: 12px;
}
.chart-tab.active { border-color: var(--accent); color: var(--text); background: var(--panel2); }
label.inline { font-size: 12px; color: var(--dim); display: flex; gap: 6px; align-items: center; }
label.inline input[type="checkbox"] { width: auto; }
label.inline select, .log-toolbar select {
background: var(--panel2); color: var(--text); border: 1px solid var(--line);
border-radius: 6px; padding: 4px 8px; font-size: 12px;
}
.chart-stack { display: flex; flex-direction: column; gap: 14px; }
.chart-figure { margin: 0; border: 1px solid var(--line); border-radius: 10px; background: #0b0f1a; padding: 10px 12px 4px; }
.chart-figure figcaption { display: flex; gap: 10px; align-items: baseline; font-size: 12px; margin-bottom: 6px; }
.chart-figure figcaption b { color: var(--text); font-size: 13px; }
.chart-figure canvas { width: 100%; display: block; }
/* ------------------------------------------------------------ 日志 */
.log-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.log-toolbar input[type="search"] {
background: var(--panel2); border: 1px solid var(--line); color: var(--text);
border-radius: 6px; padding: 6px 10px; font-size: 12px; min-width: 240px;
}
.log-view {
margin: 0; background: #0b0f1a; border: 1px solid var(--line); border-radius: 10px;
padding: 10px 12px; height: 320px; overflow: auto; font-family: var(--mono);
font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word;
}
.log-view .l { display: block; }
.log-view .l.err { color: var(--bad); }
.log-view .l.warn { color: var(--warn); }
.log-view .l.json { color: var(--cyan); }
.log-view .l.meta { color: var(--ok); }
.log-view .l.dim { color: var(--gray); }
.log-view mark { background: rgba(245,158,11,.28); color: inherit; border-radius: 2px; }
/* ------------------------------------------------------------ 表格 */
.table-wrap { border: 1px solid var(--line); border-radius: 10px; overflow: auto; max-height: 300px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { position: sticky; top: 0; background: var(--panel2); color: var(--dim); font-weight: 500; }
td { font-family: var(--mono); }
tbody tr:hover { background: rgba(79,140,255,.06); }
@media (max-width: 760px) {
.topstatus { margin-left: 0; }
.progress-row { gap: 12px; }
}