684 lines
28 KiB
Python
684 lines
28 KiB
Python
"""训练前端回归:配置契约、进度解析、HTTP API 与真实子进程生命周期。
|
||
|
||
覆盖范围与动机:
|
||
- ``/api/train/schema`` 与 ``TrainConfig`` 必须同源(字段、默认值、预设能直接
|
||
提交),否则界面会允许后端拒绝的输入;
|
||
- SB3 表格解析必须给每一行打上 ``total_timesteps`` 横轴(曾把表格行当独立
|
||
点,曲线上出现重复的幽灵点);
|
||
- ``metric_offset``/``metric_end`` 游标契约(前端增量拉取依赖它);
|
||
- 用一段桩训练脚本真起子进程,验证启动/轮询/停止/退出码全链路。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import http.client
|
||
import json
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
pytest.importorskip("mcm26b")
|
||
|
||
from mcm26b.training_ui import demo, server as td
|
||
from mcm26b.training_ui.schema import (CONFIG_FIELDS, config_schema,
|
||
preset_payloads)
|
||
|
||
REPO = Path(__file__).resolve().parents[1]
|
||
|
||
# 桩训练脚本:先写 Monitor CSV,再按轮次输出"progress JSON + SB3 表格",
|
||
# 最后打印一行元数据。全部行为由参数控制,不依赖 torch/SB3。
|
||
STUB_SCRIPT = r'''
|
||
"""训练前端测试桩:模拟 SB3 verbose=1 表格 + --progress-json 输出。"""
|
||
import argparse, json, signal, sys, time
|
||
from pathlib import Path
|
||
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--problem", type=int, default=3)
|
||
parser.add_argument("--stage", type=int, default=1)
|
||
parser.add_argument("--timesteps", type=int, default=96)
|
||
parser.add_argument("--model", default="out/model.zip")
|
||
parser.add_argument("--rollout-steps", type=int, default=32)
|
||
parser.add_argument("--sleep", type=float, default=0.0)
|
||
parser.add_argument("--lines", type=int, default=4)
|
||
parser.add_argument("--table-first", action="store_true")
|
||
parser.add_argument("--progress-json", action="store_true")
|
||
parser.add_argument("--crash", action="store_true")
|
||
parser.add_argument("--ignore-sigterm", action="store_true")
|
||
args, _extra = parser.parse_known_args()
|
||
|
||
model = Path(args.model)
|
||
model.parent.mkdir(parents=True, exist_ok=True)
|
||
monitor = model.parent / "high_curve_q3_env0.monitor.csv"
|
||
with monitor.open("w", encoding="utf-8") as handle:
|
||
handle.write("#{\"t_start\": 0.0}\n")
|
||
handle.write("r,l,t\n")
|
||
for index in range(3):
|
||
handle.write("%.3f,%d,%.3f\n" % (float(index), 10 + index, index * 5.0))
|
||
|
||
if args.ignore_sigterm:
|
||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||
|
||
print("NORMAL-LOG-LINE")
|
||
sys.stdout.flush()
|
||
|
||
|
||
def render_table(step, fps):
|
||
return (
|
||
"-----------------------------\n"
|
||
"| rollout/ | |\n"
|
||
"| ep_len_mean | 9.67 |\n"
|
||
"| ep_rew_mean | -3.24 |\n"
|
||
"| time/ | |\n"
|
||
"| fps | %-8d |\n"
|
||
"| total_timesteps | %-8d |\n"
|
||
"-----------------------------\n" % (fps, step)
|
||
)
|
||
|
||
|
||
for index in range(args.lines):
|
||
if args.sleep:
|
||
time.sleep(args.sleep)
|
||
step = 32 * (index + 1)
|
||
fps = 17 + index
|
||
progress = {
|
||
"event": "progress", "timesteps": step, "n_updates": index + 1,
|
||
"ep_rew_mean": -3.2 + index, "ep_len_mean": 9.7,
|
||
"value_loss": 12.5 - index, "approx_kl": 0.01,
|
||
"entropy_loss": 1.1, "explained_variance": 0.2, "fps": float(fps),
|
||
}
|
||
payload = "PROGRESS-" + json.dumps(progress)
|
||
table = render_table(step, fps)
|
||
if args.table_first:
|
||
print(table, end="")
|
||
print(payload, flush=True)
|
||
else:
|
||
print(payload, flush=True)
|
||
print(table, end="", flush=True)
|
||
print("EPISODE-%d r=%.2f" % (index, float(index)), flush=True)
|
||
|
||
print("INFO " + json.dumps({"event": "metadata", "actual_timesteps": args.timesteps}))
|
||
if args.crash:
|
||
raise SystemExit(3)
|
||
raise SystemExit(0)
|
||
'''
|
||
|
||
|
||
def wait_until(condition, timeout=15.0, interval=0.05):
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
if condition():
|
||
return True
|
||
time.sleep(interval)
|
||
return False
|
||
|
||
|
||
@pytest.fixture()
|
||
def stub_repo(tmp_path, monkeypatch):
|
||
"""把 TRAIN_SCRIPT 指到桩脚本,避免测试真的训练神经网络。"""
|
||
script = tmp_path / "stub_train.py"
|
||
script.write_text(STUB_SCRIPT, encoding="utf-8")
|
||
monkeypatch.setattr(td, "TRAIN_SCRIPT", script)
|
||
return script
|
||
|
||
|
||
def make_config(**overrides):
|
||
payload = {"problem": 3, "stage": 1, "timesteps": 96, "seed": 1,
|
||
"envs": 1, "vec_env": "dummy", "device": "cpu",
|
||
"config_profile": "coarse-pretrain", "reward_profile": "time",
|
||
"model": "output/hrl/ui/test/q3.zip", "eval_episodes": 0,
|
||
"rollout_steps": 32, "batch_size": 32, "n_epochs": 2,
|
||
"max_wall_s": 300.0, "verbose": 1, "force": True}
|
||
payload.update(overrides)
|
||
return td.TrainConfig.from_payload(payload)
|
||
|
||
|
||
def run_job(config, timeout=30.0):
|
||
job = td.TrainingJob(config)
|
||
job.start()
|
||
assert wait_until(lambda: job.status in ("exited", "failed", "stopped"),
|
||
timeout=timeout), f"子进程未在 {timeout}s 内结束: {job.status}"
|
||
return job
|
||
|
||
|
||
# ---------------------------------------------------------------- 配置契约
|
||
def test_schema_matches_train_config_defaults():
|
||
schema = config_schema()
|
||
assert schema["fields"] and schema["groups"] and schema["metrics"]
|
||
defaults = td.TrainConfig()
|
||
for field in schema["fields"]:
|
||
assert hasattr(defaults, field["key"]), f"字段 {field['key']} 不在 TrainConfig 中"
|
||
assert field["default"] == getattr(defaults, field["key"]), \
|
||
f"{field['key']} 默认值与后端不一致"
|
||
exposed = {field["key"] for field in schema["fields"]}
|
||
missing = set(defaults.__dataclass_fields__) - exposed
|
||
assert not missing, f"TrainConfig 字段未暴露给界面: {sorted(missing)}"
|
||
groups = {group["key"] for group in schema["groups"]}
|
||
for field in schema["fields"]:
|
||
assert field["group"] in groups, f"{field['key']} 的分组不存在"
|
||
|
||
|
||
def test_schema_metric_catalog_covers_progress_payload():
|
||
"""进度 JSON 的每个键都要么在指标字典里,要么有明确理由不进图。"""
|
||
emitted = set(demo.demo_metrics(count=2)[0]) | {"ep_len_mean"}
|
||
catalog = {metric["key"] for metric in config_schema()["metrics"]}
|
||
known_extras = {"timesteps", "total_timesteps", "iterations", "time_elapsed"}
|
||
assert emitted <= catalog | known_extras, sorted(emitted - catalog - known_extras)
|
||
|
||
|
||
def test_presets_are_accepted_by_backend():
|
||
for key, payload in preset_payloads().items():
|
||
config = td.TrainConfig.from_payload(payload)
|
||
assert config.timesteps > 0, key
|
||
command = td.build_command(config)
|
||
assert "--progress-json" in command and "--model" in command
|
||
|
||
|
||
def test_config_rejects_out_of_range_and_unknown_combinations():
|
||
with pytest.raises(ValueError):
|
||
make_config(problem=5)
|
||
with pytest.raises(ValueError):
|
||
make_config(stage=9)
|
||
with pytest.raises(ValueError):
|
||
make_config(rollout_steps=4096) # > timesteps
|
||
with pytest.raises(ValueError):
|
||
make_config(resume=True, force=True) # 互斥
|
||
with pytest.raises(ValueError):
|
||
make_config(model="")
|
||
with pytest.raises(ValueError):
|
||
make_config(vec_env="joblib")
|
||
|
||
|
||
def test_config_accepts_paths_with_separators():
|
||
r"""回归:_SAFE_ARG_RE 曾把 \x00 写成字面反斜杠,导致所有相对路径被拒。"""
|
||
config = make_config(model="output/hrl/ui/smoke/q3.zip",
|
||
init="output/hrl/ui/q3-main/q3.zip")
|
||
assert config.model_path.is_absolute()
|
||
assert config.model_path.as_posix().endswith("output/hrl/ui/smoke/q3.zip")
|
||
assert "--init" in td.build_command(config)
|
||
with pytest.raises(ValueError):
|
||
make_config(model="bad\x01name.zip") # 控制字符仍须拒绝
|
||
|
||
|
||
def test_build_command_is_shell_free_argument_list():
|
||
config = make_config(model="output/x/q3.zip")
|
||
command = td.build_command(config)
|
||
assert all(isinstance(item, str) for item in command)
|
||
assert str(td.TRAIN_SCRIPT) in command
|
||
assert "output/x/q3.zip" in " ".join(command)
|
||
# 参数列表不经过 shell:没有把整条命令拼成一个字符串
|
||
assert not any(" --" in item for item in command)
|
||
|
||
|
||
# ---------------------------------------------------------------- 进度解析
|
||
class _FakeJob:
|
||
"""借用 TrainingJob 的解析器,但不启动进程、不需要真实配置。"""
|
||
|
||
def __init__(self):
|
||
import collections
|
||
self.logs = collections.deque(maxlen=100)
|
||
self.metrics = collections.deque(maxlen=100)
|
||
self._lock = threading.RLock()
|
||
self._table_sections = []
|
||
self._table_steps = None
|
||
self._pending_table = None
|
||
self._last_table_steps = None
|
||
self._last_json_steps = None
|
||
self._last_monitor_sig = None
|
||
self.config = make_config()
|
||
|
||
_on_line = td.TrainingJob._on_line
|
||
_parse_progress_json = td.TrainingJob._parse_progress_json
|
||
_parse_sb3_table = td.TrainingJob._parse_sb3_table
|
||
_flush_table = td.TrainingJob._flush_table
|
||
_flush_pending_with_fallback = td.TrainingJob._flush_pending_with_fallback
|
||
_append_metric = td.TrainingJob._append_metric
|
||
_refresh_monitor = td.TrainingJob._refresh_monitor
|
||
|
||
|
||
def test_sb3_table_rows_get_timestep_axis():
|
||
"""表格行必须继承所属表的 total_timesteps,不能退化为点序号横轴。
|
||
|
||
刻意不打印表间分隔线,复刻"只有段头、没有横线"的真实输出变体。
|
||
"""
|
||
job = _FakeJob()
|
||
for line in ("| rollout/ | |",
|
||
"| ep_len_mean | 9.67 |",
|
||
"| ep_rew_mean | -3.24 |",
|
||
"| time/ | |",
|
||
"| fps | 17 |",
|
||
"| total_timesteps | 32 |",
|
||
"| rollout/ | |",
|
||
"| ep_rew_mean | -2.90 |",
|
||
"| time/ | |",
|
||
"| fps | 19 |",
|
||
"| total_timesteps | 64 |",
|
||
"-----------------------------"):
|
||
job._on_line(line)
|
||
job._flush_table()
|
||
assert [point["timesteps"] for point in job.metrics] == [32, 64], list(job.metrics)
|
||
# 每张表合并为一条(rollout/ 与 time/ 的字段都在同一条里),刻度取本表
|
||
assert job.metrics[0]["ep_rew_mean"] == -3.24
|
||
assert job.metrics[0]["fps"] == 17
|
||
assert job.metrics[1]["ep_rew_mean"] == -2.9
|
||
assert job.metrics[1]["fps"] == 19
|
||
assert all(point["timesteps"] != float(index) for index, point in
|
||
enumerate(job.metrics)), "横轴不得退化为点序号"
|
||
|
||
|
||
def test_sb3_table_with_separators_emits_one_point_per_table():
|
||
"""带分隔线的标准 SB3 输出:每张表恰好一条进度点,刻度取自本表。"""
|
||
job = _FakeJob()
|
||
for index, steps in enumerate((32, 64, 96)):
|
||
job._on_line("-----------------------------")
|
||
job._on_line("| rollout/ | |")
|
||
job._on_line("| ep_rew_mean | %.2f |" % (-3.0 + index))
|
||
job._on_line("| time/ | |")
|
||
job._on_line("| total_timesteps | %d |" % steps)
|
||
job._on_line("-----------------------------")
|
||
job._flush_table()
|
||
assert [point["timesteps"] for point in job.metrics] == [32, 64, 96], list(job.metrics)
|
||
assert [point["ep_rew_mean"] for point in job.metrics] == [-3.0, -2.0, -1.0]
|
||
|
||
|
||
def test_truncated_table_falls_back_to_last_known_axis():
|
||
"""被截断的表(缺少 time/ 段)不用旧刻度冒充,挂起到进程结束才兜底提交。"""
|
||
job = _FakeJob()
|
||
for line in ("| rollout/ | |",
|
||
"| ep_rew_mean | -3.24 |",
|
||
"| time/ | |",
|
||
"| total_timesteps | 32 |",
|
||
"-----------------------------",
|
||
"| rollout/ | |",
|
||
"| ep_rew_mean | -2.90 |",
|
||
"-----------------------------"):
|
||
job._on_line(line)
|
||
job._flush_table()
|
||
assert [point["timesteps"] for point in job.metrics] == [32], list(job.metrics)
|
||
job._flush_pending_with_fallback()
|
||
assert [point["timesteps"] for point in job.metrics] == [32, 32], list(job.metrics)
|
||
assert job.metrics[-1]["ep_rew_mean"] == -2.9
|
||
|
||
|
||
def test_table_without_any_axis_is_discarded():
|
||
"""完全没有刻度来源的表宁可丢弃,也不能让曲线出现序号横轴的幽灵点。"""
|
||
job = _FakeJob()
|
||
for line in ("| rollout/ | |",
|
||
"| ep_rew_mean | -3.24 |",
|
||
"-----------------------------"):
|
||
job._on_line(line)
|
||
job._flush_table()
|
||
job._flush_pending_with_fallback()
|
||
assert not job.metrics
|
||
|
||
|
||
def test_progress_json_is_not_deduped_against_table_by_timestep():
|
||
"""同一 timesteps 的表格行与 JSON 进度行只保留一份,避免重复点。"""
|
||
job = _FakeJob()
|
||
job._on_line('{"event": "progress", "timesteps": 32, "ep_rew_mean": -1.0}')
|
||
for line in ("| rollout/ | |",
|
||
"| ep_rew_mean | -1.0 |",
|
||
"| total_timesteps | 32 |"):
|
||
job._on_line(line)
|
||
job._flush_table()
|
||
assert len(job.metrics) == 1, list(job.metrics)
|
||
|
||
|
||
def test_metadata_json_is_not_treated_as_progress():
|
||
"""收尾打印的模型元数据不带 event=progress,不能污染曲线。"""
|
||
job = _FakeJob()
|
||
job._on_line(json.dumps({"level": "high", "actual_timesteps": 96,
|
||
"fps": 999, "value_loss": 12345}))
|
||
assert not job.metrics
|
||
|
||
|
||
def test_convergence_states_are_distinguishable():
|
||
def point(step, reward, kl=0.01, ev=0.5, fps=100.0):
|
||
return {"timesteps": step, "ep_rew_mean": reward, "approx_kl": kl,
|
||
"explained_variance": ev, "fps": fps}
|
||
|
||
empty = td.summarize_convergence([], [])
|
||
assert empty["tone"] == "gray" and empty["reward_points"] == 0
|
||
|
||
rising = td.summarize_convergence(
|
||
[point(index * 100, 1.0 + index * 0.5) for index in range(1, 15)], [])
|
||
assert rising["tone"] == "ok"
|
||
assert "斜率为正" in " ".join(rising["notes"])
|
||
|
||
plateau = td.summarize_convergence(
|
||
[point(index * 100, 5.0) for index in range(1, 15)], [])
|
||
assert "平稳" in plateau["status"], plateau
|
||
assert plateau["reward_spread"] == 0.0
|
||
|
||
unstable = td.summarize_convergence(
|
||
[point(index * 100, 1.0 * index, kl=0.4) for index in range(1, 15)], [])
|
||
assert unstable["tone"] == "bad" and "KL" in unstable["status"]
|
||
|
||
unfitted = td.summarize_convergence(
|
||
[point(index * 100, 1.0 * index, ev=-0.3) for index in range(1, 15)], [])
|
||
assert unfitted["tone"] == "warn"
|
||
|
||
monitor_only = td.summarize_convergence(
|
||
[], [{"r": 1.0}, {"r": 2.0}, {"r": 3.0}])
|
||
assert monitor_only["reward_points"] == 3
|
||
|
||
missing = [dict(point(index * 100, 1.0 * index), approx_kl=None,
|
||
explained_variance=None) for index in range(1, 9)]
|
||
still_ok = td.summarize_convergence(missing, [])
|
||
assert still_ok["latest_kl"] is None and still_ok["tone"] == "ok"
|
||
|
||
|
||
def test_describe_process_progress_and_eta():
|
||
snapshot = {
|
||
"status": "running", "elapsed_s": 10.0,
|
||
"config": {"timesteps": 4000, "max_wall_s": 900.0},
|
||
"metrics": [{"timesteps": 500, "fps": 50.0},
|
||
{"timesteps": 1000, "fps": 100.0}],
|
||
"convergence": {}, "log_total": 3, "metric_total": 2,
|
||
}
|
||
process = td.describe_process(snapshot)
|
||
assert process["percent"] == 25.0
|
||
assert process["done_steps"] == 1000
|
||
assert process["rate_per_s"] == 75.0
|
||
assert process["remaining_s"] == pytest.approx(40.0, rel=1e-6)
|
||
assert process["phase"] == "update"
|
||
assert [node["key"] for node in process["timeline"]][-1] == "done"
|
||
assert process["timeline"][-1]["state"] == "pending"
|
||
|
||
# 无进度点时退化为墙钟上限估计,而不是给出 None
|
||
bare = td.describe_process({"status": "running", "elapsed_s": 30.0,
|
||
"config": {"timesteps": 1000, "max_wall_s": 100.0},
|
||
"metrics": [], "convergence": {}})
|
||
assert bare["percent"] == 0.0
|
||
assert bare["remaining_s"] == pytest.approx(70.0)
|
||
|
||
# fps 只出现在 SB3 表格行里,中间夹着若干无 fps 的 JSON 行时仍要能估速率
|
||
gapped = td.describe_process({
|
||
"status": "running", "elapsed_s": 30.0,
|
||
"config": {"timesteps": 640, "max_wall_s": 300.0},
|
||
"metrics": [{"timesteps": 100, "fps": None}, {"timesteps": 120, "fps": 18.0},
|
||
{"timesteps": 140, "fps": None}, {"timesteps": 160, "fps": None},
|
||
{"timesteps": 180, "fps": None}, {"timesteps": 200, "fps": None},
|
||
{"timesteps": 220, "fps": 20.0}],
|
||
"convergence": {}})
|
||
assert gapped["rate_per_s"] == 19.0, gapped["rate_per_s"]
|
||
assert gapped["remaining_s"] == pytest.approx(420 / 19.0, rel=1e-6)
|
||
|
||
# 完全没有 fps 时退化为墙钟上限估计
|
||
no_fps = td.describe_process(
|
||
{"status": "running", "elapsed_s": 30.0,
|
||
"config": {"timesteps": 1000, "max_wall_s": 100.0},
|
||
"metrics": [{"timesteps": 10}, {"timesteps": 20}], "convergence": {}})
|
||
assert no_fps["rate_per_s"] is None
|
||
assert no_fps["remaining_s"] == pytest.approx(70.0)
|
||
|
||
# 已结束:时间线最后节点为 active,且不再外推剩余时间
|
||
ended = td.describe_process({"status": "exited", "elapsed_s": 50.0,
|
||
"config": {"timesteps": 1000, "max_wall_s": 100.0},
|
||
"metrics": [{"timesteps": 1000, "fps": 10.0}],
|
||
"convergence": {}})
|
||
assert ended["terminal"] is True
|
||
assert ended["timeline"][-1]["state"] == "active"
|
||
assert ended["remaining_s"] is None
|
||
|
||
|
||
def test_describe_process_curriculum_transfer_uses_delta_steps():
|
||
"""--init 课程迁移:计数器从上一阶段继续,进度/ETA 必须按本次增量算。"""
|
||
snapshot = {
|
||
"status": "running", "elapsed_s": 10.0,
|
||
"config": {"timesteps": 8000, "max_wall_s": 3600.0},
|
||
"metrics": [{"timesteps": 110000, "start_timesteps": 109776, "fps": 50.0},
|
||
{"timesteps": 111776, "start_timesteps": 109776, "fps": 50.0}],
|
||
"convergence": {}, "log_total": 2, "metric_total": 2,
|
||
}
|
||
process = td.describe_process(snapshot)
|
||
assert process["done_steps"] == 111776 # 绝对计数照实显示
|
||
assert process["start_steps"] == 109776
|
||
assert process["trained_steps"] == 2000 # 本次增量
|
||
assert process["percent"] == 25.0 # 而不是被夹到 100%
|
||
assert process["remaining_s"] == pytest.approx(120.0, rel=1e-6)
|
||
|
||
|
||
def test_demo_snapshot_contract():
|
||
for scenario in demo.DEMO_SCENARIOS:
|
||
snapshot = demo.demo_snapshot(scenario=scenario)
|
||
job = td.TrainingJob(make_config())
|
||
# 示例快照必须与真实快照同键,否则前端在预览模式下会缺字段
|
||
for key in job.snapshot(log_tail=1):
|
||
assert key in snapshot, f"{scenario} 缺少快照键 {key}"
|
||
assert snapshot["process"]["percent"] < 100.0
|
||
assert snapshot["convergence"]["reward_points"] > 0
|
||
assert json.dumps(snapshot, ensure_ascii=False, allow_nan=False)
|
||
with pytest.raises(ValueError):
|
||
demo.demo_metrics(scenario="nope")
|
||
|
||
|
||
# ---------------------------------------------------------------- 子进程
|
||
def test_job_lifecycle_with_stub_script(stub_repo):
|
||
config = make_config(model="output/hrl/ui/test/live/q3.zip",
|
||
verbose=1, force=True)
|
||
job = run_job(config)
|
||
assert job.status == "exited" and job.returncode == 0
|
||
snapshot = job.snapshot(log_tail=50)
|
||
assert snapshot["status"] == "exited"
|
||
assert snapshot["process"]["percent"] == 100.0
|
||
assert snapshot["process"]["terminal"] is True
|
||
assert any("NORMAL-LOG-LINE" in line for line in snapshot["log_lines"])
|
||
assert len(snapshot["monitor"]) == 3
|
||
steps = [point.get("timesteps") for point in snapshot["metrics"]]
|
||
assert steps == [32, 64, 96, 128], steps
|
||
assert snapshot["metric_end"] == len(snapshot["metrics"]) + snapshot["metric_offset"]
|
||
assert snapshot["convergence"]["reward_points"] >= 1
|
||
|
||
|
||
def test_job_reports_nonzero_exit_code(stub_repo):
|
||
config = make_config(model="output/hrl/ui/test/crash/q3.zip")
|
||
job = td.TrainingJob(config)
|
||
job.command = td.build_command(config) + ["--crash"]
|
||
job.start()
|
||
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
|
||
assert job.status == "failed"
|
||
assert job.returncode == 3
|
||
assert job.snapshot(log_tail=5)["process"]["tone"] == "bad"
|
||
|
||
|
||
def test_stop_sends_sigterm_and_marks_stopped(stub_repo):
|
||
config = make_config(model="output/hrl/ui/test/stop/q3.zip")
|
||
job = td.TrainingJob(config)
|
||
job.command = td.build_command(config) + ["--sleep", "5", "--lines", "50"]
|
||
job.start()
|
||
assert wait_until(lambda: job.status == "running", timeout=10)
|
||
assert job.stop() is True
|
||
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=15)
|
||
assert job.status == "stopped"
|
||
|
||
|
||
def test_reader_flushes_truncated_table_on_exit(stub_repo):
|
||
"""进程被截断时,_read_loop 收尾必须把半张表按已知刻度兜底提交。"""
|
||
config = make_config(model="output/hrl/ui/test/truncated/q3.zip")
|
||
job = td.TrainingJob(config)
|
||
job.start()
|
||
# 直接注入一段"只有 rollout/ 段、没有 time/ 段"的表格,模拟中途被打断
|
||
job._on_line("| rollout/ | |")
|
||
job._on_line("| ep_rew_mean | -3.24 |")
|
||
job._on_line("| total_timesteps | 32 |")
|
||
job._on_line("| rollout/ | |")
|
||
job._on_line("| ep_rew_mean | -2.90 |")
|
||
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
|
||
snapshot = job.snapshot(log_tail=5)
|
||
steps = [point["timesteps"] for point in snapshot["metrics"]]
|
||
# 桩脚本自己产出的四张完整表 + 兜底提交的半张表(刻度沿用 128)
|
||
assert steps[-1] == 128, steps
|
||
assert any(point.get("ep_rew_mean") == -2.9 for point in snapshot["metrics"]), steps
|
||
|
||
|
||
def test_metric_cursor_pagination(stub_repo):
|
||
config = make_config(model="output/hrl/ui/test/cursor/q3.zip")
|
||
job = run_job(config)
|
||
full = job.snapshot(log_tail=5)["metrics"]
|
||
assert [point["timesteps"] for point in full] == [32, 64, 96, 128]
|
||
first = job.snapshot(log_tail=5, after_metric=0)
|
||
assert first["metric_offset"] == 0
|
||
assert first["metric_end"] == len(first["metrics"])
|
||
tail = job.snapshot(log_tail=5, after_metric=3)
|
||
assert tail["metric_offset"] == 3
|
||
assert len(tail["metrics"]) == 1
|
||
assert tail["metric_end"] == 4
|
||
beyond = job.snapshot(log_tail=5, after_metric=9999)
|
||
assert beyond["metrics"] == []
|
||
assert beyond["metric_offset"] == 4
|
||
|
||
|
||
# ---------------------------------------------------------------- HTTP
|
||
@pytest.fixture()
|
||
def client(stub_repo):
|
||
ui = td.TrainingUI(ui_port=0)
|
||
threading.Thread(target=ui.serve_forever, daemon=True).start()
|
||
port = ui.httpd.server_address[1]
|
||
yield ui, port
|
||
ui.shutdown()
|
||
|
||
|
||
def call(port, method, path, obj=None):
|
||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
||
body = json.dumps(obj).encode("utf-8") if obj is not None else None
|
||
conn.request(method, path, body=body,
|
||
headers={"Content-Type": "application/json"})
|
||
response = conn.getresponse()
|
||
data = response.read()
|
||
conn.close()
|
||
try:
|
||
parsed = json.loads(data) if data else None
|
||
except ValueError:
|
||
parsed = data.decode("utf-8", "replace")
|
||
return response.status, parsed
|
||
|
||
|
||
def test_http_static_whitelist(client):
|
||
_ui, port = client
|
||
for path, needle in (("/", "<!doctype html>"), ("/app.js", "absorbMetrics"),
|
||
("/charts.js", "sparkline"), ("/style.css", ".panel")):
|
||
status, body = call(port, "GET", path)
|
||
assert status == 200, path
|
||
assert needle in body, path
|
||
status, body = call(port, "GET", "/../pyproject.toml")
|
||
assert status == 404 and "error" in body
|
||
|
||
|
||
def test_http_schema_and_preview(client):
|
||
_ui, port = client
|
||
status, schema = call(port, "GET", "/api/train/schema")
|
||
assert status == 200
|
||
assert len(schema["fields"]) == len(CONFIG_FIELDS)
|
||
assert all(key in schema for key in ("groups", "presets", "metrics"))
|
||
|
||
status, payload = call(port, "GET", "/api/train/state?preview=1&scenario=kl-spike")
|
||
assert status == 200
|
||
job = payload["job"]
|
||
assert job["preview"] is True
|
||
assert "KL" in job["convergence"]["status"]
|
||
status, body = call(port, "GET", "/api/train/state?preview=1&scenario=bogus")
|
||
assert status == 400 and "bogus" in body["error"]
|
||
|
||
|
||
def test_http_state_query_validation(client):
|
||
_ui, port = client
|
||
status, body = call(port, "GET", "/api/train/state?log_tail=abc")
|
||
assert status == 400 and "log_tail" in body["error"]
|
||
status, body = call(port, "GET", "/api/train/state?log_tail=0")
|
||
assert status == 400
|
||
status, body = call(port, "GET", "/api/train/state?metric_limit=999999")
|
||
assert status == 400
|
||
status, body = call(port, "POST", "/api/train/start", [1, 2, 3])
|
||
assert status == 400
|
||
status, body = call(port, "POST", "/api/train/nope", {})
|
||
assert status == 404
|
||
|
||
|
||
def test_http_start_poll_stop(client):
|
||
_ui, port = client
|
||
status, body = call(port, "POST", "/api/train/start",
|
||
{"model": "output/hrl/ui/test/http/q3.zip",
|
||
"timesteps": 128, "rollout_steps": 32, "batch_size": 32})
|
||
assert status == 200
|
||
first = body["job"]
|
||
assert first["status"] in ("running", "created")
|
||
assert first["pid"]
|
||
assert "--progress-json" in first["command"]
|
||
|
||
def finished():
|
||
state = call(port, "GET", "/api/train/state?log_tail=5")[1]
|
||
return state["job"]["status"] == "exited"
|
||
|
||
assert wait_until(finished, timeout=30)
|
||
status, state = call(port, "GET", "/api/train/state?log_tail=50")
|
||
job = state["job"]
|
||
assert job["process"]["percent"] == 100.0
|
||
assert job["process"]["label"] == "正常结束"
|
||
assert len(job["monitor"]) == 3
|
||
|
||
status, body = call(port, "POST", "/api/train/start",
|
||
{"model": "output/hrl/ui/test/http2/q3.zip"})
|
||
assert status == 200
|
||
started = body["job"]
|
||
assert started["job_id"] != first["job_id"]
|
||
assert started["status"] in ("running", "created")
|
||
|
||
status, body = call(port, "POST", "/api/train/stop", {})
|
||
assert status == 200 and body["stopped"] is True
|
||
status, body = call(port, "POST", "/api/train/stop", {})
|
||
assert status == 200 and body["stopped"] is False # 已结束,返回 False 而不是报错
|
||
|
||
|
||
def test_http_rejects_second_job_while_running(client):
|
||
_ui, port = client
|
||
status, body = call(port, "POST", "/api/train/start",
|
||
{"model": "output/hrl/ui/test/busy/q3.zip",
|
||
"timesteps": 9000, "rollout_steps": 3000,
|
||
"batch_size": 1000})
|
||
assert status == 200
|
||
status, body = call(port, "POST", "/api/train/start",
|
||
{"model": "output/hrl/ui/test/busy2/q3.zip"})
|
||
assert status == 409 and "已有训练任务" in body["error"]
|
||
call(port, "POST", "/api/train/stop", {})
|
||
|
||
|
||
def test_http_invalid_config_returns_409(client):
|
||
_ui, port = client
|
||
status, body = call(port, "POST", "/api/train/start", {"stage": 99})
|
||
assert status == 409 and "stage" in body["error"]
|
||
status, body = call(port, "POST", "/api/train/start",
|
||
{"model": "output/x.zip", "resume": True, "force": True})
|
||
assert status == 409 and "互斥" in body["error"]
|
||
|
||
|
||
def test_http_binds_loopback_only(client):
|
||
ui, _port = client
|
||
host = ui.httpd.server_address[0]
|
||
assert host == "127.0.0.1"
|
||
|
||
|
||
def test_port_conflict_reports_actionable_error(client):
|
||
ui, port = client
|
||
with pytest.raises(OSError) as excinfo:
|
||
td.TrainingUI(ui_port=port)
|
||
assert "ui-port" in str(excinfo.value)
|
||
|
||
|
||
# ---------------------------------------------------------------- CLI
|
||
def test_cli_print_schema_and_preview(capsys):
|
||
assert td.run_training_ui(["--print-schema"]) == 0
|
||
schema = json.loads(capsys.readouterr().out)
|
||
assert schema["fields"]
|
||
assert td.run_training_ui(["--print-preview"]) == 0
|
||
payload = json.loads(capsys.readouterr().out)
|
||
assert payload["job"]["preview"] is True
|
||
|
||
|
||
def test_training_ui_module_entrypoint_exists():
|
||
text = (REPO / "scripts" / "train_hrl_ui.py").read_text(encoding="utf-8")
|
||
assert "run_training_ui" in text
|
||
assert sys.executable # 仅让 Windows 上的路径断言有明确前置条件
|