Files
mcm-26-b/tests/test_training_ui.py
T
2026-09-12 01:56:34 +08:00

958 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""训练前端回归:配置契约、进度解析、HTTP API 与真实子进程生命周期。
覆盖范围与动机:
- ``/api/train/schema`` 与 ``TrainConfig`` 必须同源(字段、默认值、预设能直接
提交),否则界面会允许后端拒绝的输入;
- SB3 表格解析必须给每一行打上 ``total_timesteps`` 横轴(曾把表格行当独立
点,曲线上出现重复的幽灵点);
- ``metric_offset``/``metric_end`` 游标契约(前端增量拉取依赖它);
- 用一段桩训练脚本真起子进程,验证启动/轮询/暂停/继续/停止/退出码全链路。
"""
# pytest 用参数名注入 fixture:`stub_repo`/`client` 这类参数在函数体里不直接
# 引用也是正常用法(它们的作用是 setup/teardown),这里统一放行。
# pylint: disable=unused-argument
from __future__ import annotations
import dataclasses
import http.client
import json
import sys
import threading
import time
from pathlib import Path
import pytest
pytest.importorskip("mcm26b")
from mcm26b import process_control as pc
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, os, 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)
# 默认 sleep 可由环境变量控制:HTTP 端到端用例无法直接追加命令行参数,
# 用小 sleep 拉长运行窗口,才能稳定地测"暂停/继续"。
parser.add_argument("--sleep", type=float,
default=float(os.environ.get("STUB_TRAIN_SLEEP", "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")
parser.add_argument("--ignore-control", action="store_true")
parser.add_argument("--control-file", default=None)
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 read_control():
if not args.control_file or args.ignore_control:
return {}
try:
with open(args.control_file, encoding="utf-8") as handle:
return json.load(handle)
except Exception:
return {}
def control_pause_or_stop(step):
"""返回 True 表示应退出;暂停时阻塞到继续或停止(模拟协作式暂停)。"""
state = read_control()
if state.get("stop"):
print(json.dumps({"event": "stop_requested", "signal": "control_file"}), flush=True)
return True
if state.get("paused"):
print(json.dumps({"event": "paused", "timesteps": step}), flush=True)
while True:
time.sleep(0.05)
state = read_control()
if state.get("stop"):
print(json.dumps({"event": "stop_requested", "signal": "control_file"}),
flush=True)
return True
if not state.get("paused"):
break
print(json.dumps({"event": "resumed", "timesteps": step}), flush=True)
return False
def sleep_with_control(seconds, step):
"""把长 sleep 切成 0.1s 片,保证停止/暂停请求能被及时响应。"""
deadline = time.monotonic() + max(0.0, seconds)
while True:
if control_pause_or_stop(step):
raise SystemExit(0)
remaining = deadline - time.monotonic()
if remaining <= 0:
return
time.sleep(min(0.1, remaining))
def render_table(step, fps):
return (
"-----------------------------\n"
"| rollout/ | |\n"
"| ep_len_mean | 9.67 |\n"
"| ep_rew_mean | -3.24 |\n"
"| time/ | |\n"
f"| fps | {fps:<8d} |\n"
f"| total_timesteps | {step:<8d} |\n"
"-----------------------------\n"
)
for index in range(args.lines):
step = 32 * (index + 1)
sleep_with_control(args.sleep, step)
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 = {field.name for field in dataclasses.fields(defaults)} - 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.status = "running"
self.config = make_config()
_on_line = td.TrainingJob._on_line
_sync_control_events = td.TrainingJob._sync_control_events
_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(f"| ep_rew_mean | {-3.0 + index:.2f} |")
job._on_line("| time/ | |")
job._on_line(f"| total_timesteps | {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 = list(job.command) + ["--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_job_command_carries_control_file(stub_repo):
"""控制文件路径必须随命令下发,否则暂停/停止退化成超时强杀。"""
config = make_config(model="output/hrl/ui/test/control/q3.zip")
job = td.TrainingJob(config, control_dir=Path(stub_repo).parent)
assert "--control-file" in job.command
assert str(job.control_path) in job.command
assert job.control_path.parent == Path(stub_repo).parent
def test_stop_gracefully_marks_stopped_without_forced_kill(stub_repo):
config = make_config(model="output/hrl/ui/test/stop/q3.zip")
job = td.TrainingJob(config)
job.command = list(job.command) + ["--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"
# 桩脚本读控制文件后主动退出:不应走到强杀分支
assert job.forced_kill is False
def test_pause_and_resume_via_control_file(stub_repo):
config = make_config(model="output/hrl/ui/test/pause/q3.zip")
job = td.TrainingJob(config)
job.command = list(job.command) + ["--sleep", "0.2", "--lines", "60"]
job.start()
assert wait_until(lambda: job.status == "running", timeout=10)
assert job.pause() is True
# 训练脚本回显 paused 事件;状态由 _sync_control_events 校正
assert wait_until(
lambda: any('"event": "paused"' in line for line in job.snapshot()["log_lines"]),
timeout=10), "训练脚本未回显 paused 事件"
assert job.status == "paused"
paused_metrics = len(job.snapshot()["metrics"])
time.sleep(0.6)
assert len(job.snapshot()["metrics"]) <= paused_metrics + 1, "暂停后仍在产出指标"
assert job.resume() is True
assert wait_until(
lambda: any('"event": "resumed"' in line for line in job.snapshot()["log_lines"]),
timeout=10), "训练脚本未回显 resumed 事件"
assert wait_until(lambda: job.status == "running", timeout=5)
assert job.stop() is True
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=15)
assert job.status == "stopped"
def test_stop_forced_kill_when_control_ignored(stub_repo, monkeypatch):
"""训练进程不读控制文件时必须超时强杀,并留下恢复指引。"""
monkeypatch.setattr(td, "STOP_GRACE_S", 0.5)
config = make_config(model="output/hrl/ui/test/stubborn/q3.zip")
job = td.TrainingJob(config)
job.command = list(job.command) + ["--ignore-control", "--sleep", "5", "--lines", "50"]
job.start()
assert wait_until(lambda: job.status == "running", timeout=10)
# 造一份"磁盘上已有滚动恢复点"的现场,强杀后应被记录进恢复指引
ckpt_dir = job.config.model_path.parent / "checkpoints"
ckpt_dir.mkdir(parents=True, exist_ok=True)
latest = ckpt_dir / "q3_ckpt_latest.zip"
latest.write_bytes(b"PK\x03\x04fake")
assert job.stop() is True
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=20)
assert job.forced_kill is True
assert job.snapshot(log_tail=5)["forced_kill"] is True
marker = json.loads(
(job.config.model_path.parent / "forced_stop.json").read_text(encoding="utf-8"))
assert marker["event"] == "forced_stop"
assert marker["reason"] == "grace_timeout"
assert marker["recovery"]["model"] == str(latest)
assert marker["recovery"]["can_resume"] is True
def test_discover_recovery_prefers_resume_hint(tmp_path):
run_dir = tmp_path / "run"
(run_dir / "checkpoints").mkdir(parents=True)
latest = run_dir / "checkpoints" / "q3_ckpt_latest.zip"
latest.write_bytes(b"PK\x03\x04fake")
final = run_dir / "q3.zip"
final.write_bytes(b"PK\x03\x04fake")
(run_dir / "resume.json").write_text(json.dumps({
"model": str(latest), "timesteps": 128, "reason": "interval",
"created_unix_s": 5.0}), encoding="utf-8")
state = td.discover_recovery(run_dir, final)
assert state["can_resume"] is True
assert state["model"] == str(latest)
assert state["timesteps"] == 128
assert state["source"] == "resume.json"
def test_discover_recovery_falls_back_to_scan_on_dangling_hint(tmp_path):
run_dir = tmp_path / "run"
(run_dir / "checkpoints").mkdir(parents=True)
latest = run_dir / "checkpoints" / "q3_ckpt_latest.zip"
latest.write_bytes(b"PK\x03\x04fake")
# 悬空指针(目标文件已不在)必须回退到扫描,而不是给出一个打不开的路径
(run_dir / "resume.json").write_text(
json.dumps({"model": str(run_dir / "gone.zip")}), encoding="utf-8")
state = td.discover_recovery(run_dir, run_dir / "q3.zip")
assert state["can_resume"] is True
assert state["model"] == str(latest)
assert state["source"] == "scan"
def test_discover_recovery_with_nothing_on_disk(tmp_path):
state = td.discover_recovery(tmp_path, tmp_path / "q3.zip")
assert state["can_resume"] is False and state["model"] is None
assert state["reason"] == "none"
def test_command_carries_checkpoint_and_recovery_flags():
config = make_config(model="output/x/q3.zip", recovery_every=100,
checkpoint_every=500, checkpoint_keep=2)
joined = " ".join(td.build_command(config))
assert "--recovery-every 100" in joined
assert "--checkpoint-every 500" in joined
assert "--checkpoint-keep 2" in joined
def test_config_rejects_bad_recovery_settings():
with pytest.raises(ValueError):
make_config(recovery_every=-1)
with pytest.raises(ValueError):
make_config(checkpoint_every=-1)
with pytest.raises(ValueError):
make_config(checkpoint_keep=0)
def test_pause_resume_return_false_without_live_process(stub_repo):
config = make_config(model="output/hrl/ui/test/nolive/q3.zip")
job = td.TrainingJob(config)
assert job.pause() is False and job.resume() is False
job.start()
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
assert job.pause() is False and job.resume() is False
# ---------------------------------------------------- 跨平台进程控制
def test_control_file_roundtrip_and_corruption(tmp_path):
path = tmp_path / "control.json"
assert pc.read_control(path) == pc.ControlState() # 缺失 = 无指令
pc.write_control(path, paused=True)
assert pc.read_control(path).paused is True
pc.write_control(path, stop=True)
state = pc.read_control(path)
assert state.stop is True and state.paused is False
path.write_text("{ 半截 JSON", encoding="utf-8")
assert pc.read_control(path) == pc.ControlState() # 损坏 = 无指令
def test_spawn_kwargs_isolates_child_process_group(monkeypatch):
monkeypatch.setattr(pc, "IS_WINDOWS", False)
assert pc.spawn_kwargs() == {"start_new_session": True}
monkeypatch.setattr(pc, "IS_WINDOWS", True)
kwargs = pc.spawn_kwargs()
# Windows 上 start_new_session 被 CPython 静默忽略,绝不能再用它
assert "start_new_session" not in kwargs
if kwargs:
assert kwargs["creationflags"] == pc._CREATE_NEW_PROCESS_GROUP
def test_hard_kill_on_windows_uses_taskkill(monkeypatch):
commands = []
class _FakeProcess:
pid = 4242
def kill(self): # pragma: no cover - Windows 分支不应走到这里
commands.append("Popen.kill")
monkeypatch.setattr(pc, "IS_WINDOWS", True)
monkeypatch.setattr(pc.subprocess, "run",
lambda argv, **kwargs: commands.append(argv))
pc._hard_kill_tree(_FakeProcess())
assert commands and commands[0][0] == "taskkill"
assert "/PID" in commands[0] and "4242" in commands[0]
def test_hard_kill_without_killpg_does_not_raise(monkeypatch):
"""回归:Windows 上 os 没有 killpg/getpgid,旧代码会抛 AttributeError。"""
killed = []
class _FakeProcess:
pid = 4242
def kill(self):
killed.append(True)
class _PosixlessOS:
"""模拟 Windows:只有 getpid 之类,没有 killpg/getpgid。"""
monkeypatch.setattr(pc, "IS_WINDOWS", False)
monkeypatch.setattr(pc, "os", _PosixlessOS())
pc._hard_kill_tree(_FakeProcess())
assert killed == [True]
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
# 等训练进程真正退出后再点一次:应返回 False 而不是报错
assert wait_until(
lambda: call(port, "GET", "/api/train/state?log_tail=5")[1]["job"]["status"]
in ("stopped", "failed"), timeout=15)
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is False
def test_http_pause_resume_and_state(client, monkeypatch):
monkeypatch.setenv("STUB_TRAIN_SLEEP", "0.6")
_ui, port = client
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/http-pause/q3.zip",
"timesteps": 4096, "rollout_steps": 32, "batch_size": 32})
assert status == 200
def status_now():
return call(port, "GET", "/api/train/state?log_tail=20")[1]["job"]["status"]
def is_running():
return status_now() == "running"
assert wait_until(is_running, timeout=10)
status, body = call(port, "POST", "/api/train/pause", {})
assert status == 200 and body["paused"] is True
assert wait_until(lambda: status_now() == "paused", timeout=10), status_now()
# 暂停中仍视为"有任务在跑":不允许再开一局
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/http-pause2/q3.zip"})
assert status == 409 and "已有训练任务" in body["error"]
status, body = call(port, "POST", "/api/train/resume", {})
assert status == 200 and body["resumed"] is True
assert wait_until(is_running, timeout=10), status_now()
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is True
assert wait_until(lambda: status_now() in ("stopped", "failed"), timeout=15)
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 上的路径断言有明确前置条件