revert: 移除全部机器学习代码,回退到无 ML 版本

删除 RL/HRL/训练前端及其文档、脚本、测试与依赖:
- src/mcm26b/{rl,hrl,training_ui}/、planner/{rl,hrl}_loop.py、process_control.py
- project/(PPO 策略、训练、环境、评估)
- scripts/{train_rl,train_hrl_*,benchmark_rl,benchmark_hrl,pretrain_*_expert 等}
- tests/{test_rl*,test_hrl*,test_training_ui*}、tests/js/training_ui_harness.cjs
- docs/{algo-rl,hrl-*,training-ui*} 与相关计划条目、论文 PDF 回退

CLI 恢复为 grid/seven/bayes + directional,移除 --strategy rl|hrl、
--q4-strategy、--rl-model、--hrl-* 等参数;pyproject 删除 rl 依赖组与
PyTorch CUDA index,uv.lock 同步重生成。

保留此后的非 ML 改动(referee 回放、Q1/Q3 脚本、代码清理等)。
验证:pytest 232 passed;Q3/Q4 --self-test 全清通过。
This commit is contained in:
Horldsence
2026-09-12 08:47:51 +08:00
parent d975309a81
commit 61fd22598a
84 changed files with 9 additions and 13159 deletions
-285
View File
@@ -1,285 +0,0 @@
/* 训练前端 JS 逻辑回归(由 pytest 经 node 调起,见 tests/test_training_ui_js.py)。
以 DOM 桩加载发布的 charts.js / app.js 原文,用服务端真实生成的示例快照
(services/demo.py 的 demo_snapshot JSON)驱动渲染,断言:
1) 表单由 schema 生成、字段数与后端契约一致;
2) 指标卡/曲线/日志/逐回合表都拿到数据并渲染,无异常抛出;
3) 三种收敛场景的判读文案正确落到顶部 chip;
4) 增量指标游标合并不丢点也不重点(曾是该前端的典型 bug);
5) charts.js 的刻度与格式函数在极端输入下不抛异常。
曾回归出两个"加载即白屏"隐患:job.process 缺失时直接取下标、以及游标
与 metric_limit 同时生效时重复追加同批进度点。本文件防其复发。 */
"use strict";
const fs = require("fs");
const path = require("path");
const REPO = process.env.REPO;
if (!REPO) throw new Error("REPO env required");
const STATIC = path.join(REPO, "src", "mcm26b", "training_ui", "static");
const DEMO = process.env.DEMO_STATE;
if (!DEMO) throw new Error("DEMO_STATE env required");
const scenarios = JSON.parse(fs.readFileSync(DEMO, "utf8"));
const schema = JSON.parse(fs.readFileSync(process.env.SCHEMA, "utf8"));
// ---------------------------------------------------------------- DOM 桩
/* className 是唯一真源:代码里会直接赋值 className(见 chip()),也会用
classList.add/toggle;如果 classList 自带一个独立 Set,两者就会不同步,
测试会误报。这里让 classList 每次读写都落回 className。 */
function makeClassList(el) {
const read = () => String(el.className || "").split(/\s+/).filter(Boolean);
const write = (list) => { el.className = [...new Set(list)].join(" "); };
return {
add: (...c) => write(read().concat(c)),
remove: (...c) => write(read().filter((x) => !c.includes(x))),
toggle: (c, force) => {
const on = force === undefined ? !read().includes(c) : !!force;
write(on ? read().concat(c) : read().filter((x) => x !== c));
return on;
},
contains: (c) => read().includes(c),
};
}
const ctxStub = new Proxy({}, {
get(t, prop) {
if (!(prop in t)) {
t[prop] = typeof prop === "string" &&
/^(arc|beginPath|closePath|fill|stroke|moveTo|lineTo|fillText|strokeText|strokeRect|fillRect|clearRect|setLineDash|setTransform|save|restore|roundRect|measureText|scale|translate)$/.test(prop)
? (prop === "measureText" ? () => ({ width: 40 }) : () => {})
: function () {};
}
return t[prop];
},
set(t, prop, v) { t[prop] = v; return true; },
});
const allElements = [];
/* querySelectorAll 桩:支持 app.js 实际用到的选择器
[data-node="x"] / [data-link="n"] → 时间线节点与连接线
.chart-tab → 曲线页签(用于切换 active)
tbody → 逐回合表体
子元素按 tag 记账,因此 page.querySelector("tbody") 每次返回同一个对象。 */
function matchesSelector(el, sel) {
const attr = /^\[data-([a-z]+)="?([^"\]]*)"?\]$/.exec(sel);
if (attr) return el.dataset && String(el.dataset[attr[1]]) === attr[2];
if (sel.startsWith(".")) return String(el.className || "").split(/\s+/).includes(sel.slice(1));
if (el.tag === sel) return true;
const byTag = el.byTag && el.byTag[sel];
return Boolean(byTag);
}
function makeEl(id, tag) {
const el = {
id, tag: tag || id, textContent: "", innerHTML: "", value: "", checked: true,
disabled: false, className: "",
files: [], width: 600, height: 240, style: {}, handlers: {}, children: [],
byTag: {}, dataset: {}, scrollTop: 0, scrollHeight: 100, clientHeight: 100,
get classList() { return makeClassList(this); },
addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); },
removeEventListener() {}, click() {}, matches() { return false; },
appendChild(child) { this.children.push(child); return child; },
removeChild(child) { this.children = this.children.filter((c) => c !== child); },
setAttribute() {}, focus() {},
querySelector(sel) { return this.querySelectorAll(sel)[0] || this.childFor(sel); },
// 表单里的静态子元素(如 <tbody>)不在 children 里,按需惰性创建并缓存
childFor(sel) {
if (!this.byTag[sel]) this.byTag[sel] = makeEl(`${id}:${sel}`, sel);
return this.byTag[sel];
},
querySelectorAll(sel) {
const found = [];
const walk = (node) => {
for (const child of node.children || []) {
if (matchesSelector(child, sel)) found.push(child);
walk(child);
}
};
walk(this);
return found;
},
getContext() { return ctxStub; },
getBoundingClientRect() { return { left: 0, top: 0, width: 600, height: 240 }; },
};
allElements.push(el);
return el;
}
const els = new Map();
const $get = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
// 几个会被读取初值的控件(否则 value 为空字符串,Number("") === 0)
for (const [id, value] of [["log-tail", "400"], ["chart-window", "120"]]) $get(id).value = value;
$get("chart-cursor").checked = true;
$get("log-follow").checked = true;
$get("log-json").checked = true;
$get("log-errors-only").checked = false; // 只保留"显示进度 JSON"这一项过滤
$get("preview-scenario").value = "rising";
globalThis.requestAnimationFrame = (cb) => { cb(0); return 1; };
globalThis.cancelAnimationFrame = () => {};
globalThis.performance = { now: () => Date.now() };
globalThis.setInterval = () => 0;
globalThis.location = { port: "8036" };
globalThis.confirm = () => true;
globalThis.alert = () => {};
globalThis.addEventListener = () => {};
globalThis.devicePixelRatio = 2;
globalThis.window = globalThis;
globalThis.document = {
getElementById: $get,
createElement: (tag) => makeEl(tag),
addEventListener() {},
};
// 元素挂载计数:断言"指标卡确实被创建"而不是静默空转
let createdCount = 0;
const realCreate = globalThis.document.createElement;
globalThis.document.createElement = (tag) => { createdCount++; return realCreate(tag); };
globalThis.fetch = async (url) => {
const json = async () => {
if (url.startsWith("/api/train/schema")) return schema;
if (url.includes("state")) {
const match = /scenario=([a-z-]+)/.exec(url);
const key = match ? match[1] : "rising";
return { job: scenarios[key] };
}
throw new Error("unexpected fetch " + url);
};
return { ok: true, status: 200, json };
};
// ---------------------------------------------------------------- 装载发布代码
const chartsSrc = fs.readFileSync(path.join(STATIC, "charts.js"), "utf8");
const appSrc = fs.readFileSync(path.join(STATIC, "app.js"), "utf8");
new Function(chartsSrc)();
const appApi = new Function(
appSrc + "\n;return { seriesOf, classify, highlight, absorbMetrics, state, render, fmtNumber, fmtDuration };"
)();
// app.js 末尾会自动 boot(),这里等它把 schema/首次快照渲染完
const flush = async () => { for (let i = 0; i < 40; i++) await new Promise((r) => setImmediate(r)); };
function assert(cond, message) { if (!cond) throw new Error(message); }
function stripTags(html) { return String(html).replace(/<[^>]*>/g, ""); }
(async () => {
await flush();
// 1) schema 驱动表单
const groups = $get("form-groups").children;
assert(groups.length === schema.groups.length,
`表单分组数应为 ${schema.groups.length},实际 ${groups.length}`);
const cfgField = $get("cfg-timesteps");
assert(cfgField && String(cfgField.value) === String(schema.fields.find((f) => f.key === "timesteps").default),
`默认值未写入表单: ${cfgField && cfgField.value}`);
assert($get("presets").children.length === schema.presets.length, "预设按钮数不匹配");
// 2) 首帧(rising 场景)渲染
const state = appApi.state;
assert(state.metrics.length > 0, "首次快照后本地进度点为空");
const chips = $get("chip-converge").innerHTML;
assert(chips.includes(scenarios.rising.convergence.status),
`收敛 chip 未渲染: ${chips}`);
assert($get("stat-pid").textContent === "PID 42424", `PID 未渲染: ${$get("stat-pid").textContent}`);
assert($get("metric-grid").children.length === schema.metrics.length,
`指标卡数量应为 ${schema.metrics.length},实际 ${$get("metric-grid").children.length}`);
assert($get("chart-tabs").children.length === schema.metrics.length,
"曲线页签数量与指标字典不一致");
assert(stripTags($get("log-view").innerHTML).includes("progress"),
"日志区没有渲染进度行");
assert($get("episode-table").querySelector("tbody") !== null, "逐回合表缺少 tbody");
const tbody = $get("episode-table").querySelector("tbody");
assert(tbody.innerHTML.includes("<tr>"),
`逐回合表未渲染任何行: monitor=${(scenarios.rising.monitor || []).length}`);
assert(Number($get("steps-total").textContent.replace(/,/g, "")) === scenarios.rising.config.timesteps,
`总步数渲染异常: ${$get("steps-total").textContent}`);
// 3) 三种收敛场景切到对应 chip
const tones = { rising: "ok", plateau: "ok", "kl-spike": "bad" };
for (const key of Object.keys(scenarios)) {
const rendered = appApi.render(scenarios[key]);
void rendered;
const html = $get("chip-converge").innerHTML;
assert(html.includes(scenarios[key].convergence.status),
`场景 ${key} 的判读未落到 chip: ${html}`);
assert($get("chip-converge").classList.contains(tones[key]),
`场景 ${key} 的色调应为 ${tones[key]}`);
}
assert(String($get("chart2-title").textContent).length > 0, "第二张曲线标题为空");
// 4) 增量游标合并:续接 / 有缺口 / 过期批次 三条分支
const job = JSON.parse(JSON.stringify(scenarios.rising));
const total = job.metrics.length;
const reset = () => {
appApi.state.metrics = [];
appApi.state.metricCache = [];
appApi.state.metricNext = 0;
appApi.state.metricTotal = 0;
};
const feed = (metrics, offset) => appApi.absorbMetrics(
{ ...job, metrics, metric_offset: offset, metric_total: total });
const stamp = (i) => job.metrics[i].timesteps;
reset();
feed(job.metrics.slice(0, 10), 0);
assert(appApi.state.metrics.length === 10, `首批应 10 点,实际 ${appApi.state.metrics.length}`);
feed(job.metrics.slice(10, 14), 10);
assert(appApi.state.metrics.length === 14, `续接后应 14 点,实际 ${appApi.state.metrics.length}`);
assert(appApi.state.metricNext === 14, `metricNext 应为 14,实际 ${appApi.state.metricNext}`);
feed([], 14);
assert(appApi.state.metrics.length === 14, `无新点不得改变长度,实际 ${appApi.state.metrics.length}`);
// 有缺口(服务端窗口前移):整体替换,避免曲线出现空洞
feed(job.metrics.slice(20, 30), 20);
assert(appApi.state.metrics.length === 10, `有缺口时应替换为 10 点,实际 ${appApi.state.metrics.length}`);
assert(appApi.state.metrics[0].timesteps === stamp(20), "替换后起点错位");
assert(appApi.state.metricNext === 30, `游标未随替换更新: ${appApi.state.metricNext}`);
// 过期批次(服务端计数回退):忽略,本地不动
feed(job.metrics.slice(0, 5), 0);
assert(appApi.state.metrics.length === 10, `过期批次应被忽略,实际 ${appApi.state.metrics.length}`);
assert(appApi.state.metrics[0].timesteps === stamp(20), "过期批次污染了本地缓存");
// 字符串形式的 offset:必须按数值比较,不得退化成字典序
reset();
feed(job.metrics.slice(0, 10), "0");
feed(job.metrics.slice(10, 14), "10");
assert(appApi.state.metrics.length === 14,
`字符串 offset 应正常续接为 14 点,实际 ${appApi.state.metrics.length}`);
// 缺失 offset(旧版服务端):退化为按到达顺序追加,不得丢点
reset();
appApi.absorbMetrics({ ...job, metrics: job.metrics.slice(0, 6) });
assert(appApi.state.metrics.length === 6, `缺 offset 时首批应 6 点,实际 ${appApi.state.metrics.length}`);
// 5) 日志分类与高亮
assert(appApi.classify("Traceback (most recent call last):") === "err", "Traceback 未归入错误行");
assert(appApi.classify("UserWarning: x") === "warn", "警告未归入 warn");
assert(appApi.classify('{"event": "progress"}') === "json", "进度 JSON 未归入 json");
assert(appApi.highlight("abc", "b").includes("<mark>b</mark>"), "过滤高亮未生效");
const marked = appApi.highlight("<script>", "script");
assert(marked.startsWith("&lt;") && marked.endsWith("&gt;") && marked.includes("<mark>"),
`命中过滤时也必须转义 HTML: ${marked}`);
assert(!appApi.highlight("<script>", "").includes("<script>"),
"未过滤时也应转义 HTML");
// 6) 序列抽取:跳过 None 且横轴用 timesteps
const series = appApi.seriesOf("ep_rew_mean");
assert(series.length > 0 && isFinite(series[0][0]) && isFinite(series[0][1]),
"seriesOf 产出非法坐标");
for (let i = 1; i < series.length; i++) {
assert(series[i][0] >= series[i - 1][0], "seriesOf 横轴未单调递增");
}
// 7) charts.js 边界输入不抛异常
const Charts = globalThis.Charts;
const canvas = makeEl("probe");
Charts.line(canvas, { series: [] });
Charts.line(canvas, { series: [{ points: [[1, 1]] }] });
Charts.line(canvas, { series: [{ points: [[1, 1], [1, 1]] }] });
Charts.line(canvas, { series: [{ points: [[0, 1e-12], [1, 1e12]] }] });
Charts.sparkline(canvas, []);
Charts.sparkline(canvas, [3, 3, 3, 3]);
assert(Charts.fmt(NaN) === "—", "fmt(NaN) 应为占位符");
assert(Charts.fmt(12345) === "12345.0", `五位数应保持定点: ${Charts.fmt(12345)}`);
assert(Charts.fmt(1.2e6).includes("e+6"), `六位数应转科学计数: ${Charts.fmt(1.2e6)}`);
assert(Charts.fmt(0.000012) === "1.20e-5", `极小值应转科学计数: ${Charts.fmt(0.000012)}`);
console.log("training-ui harness: ALL PASS");
})().catch((err) => {
console.error("training-ui harness FAILED:", err && err.stack || err);
process.exit(1);
});
-286
View File
@@ -1,286 +0,0 @@
"""HRL 验收:槽位布局、观测/掩码同源性、整场环境闭环与奖励恒等式。
HRL 依赖属于可选组 [dependency-groups] rl;未安装 numpy/gymnasium 时
全部跳过(运行闭环不依赖它们)。
"""
import math
from types import SimpleNamespace
import unittest
pytest = None
try:
import pytest
numpy = pytest.importorskip("numpy")
pytest.importorskip("gymnasium")
except Exception: # pragma: no cover - 无 RL 依赖环境
numpy = None
if numpy is not None:
from mcm26b.config import ProblemConfig
from mcm26b.hrl.constants import (CHANNEL_COUNT, CLEAR_OFFSET, CLEAR_SLOTS,
FALLBACK_OFFSET, FALLBACK_SLOTS, HIGH_GLOBAL_DIM,
HIGH_OBS_DIM, LOCATE_OFFSET,
LOCATE_SLOTS,
N_CANDIDATES, PER_CANDIDATE_DIM,
PER_CHANNEL_DIM, STATION_OFFSET, STATION_SLOTS)
from mcm26b.hrl.curriculum import STAGES, make_scene, training_config
from mcm26b.hrl.executor import LowLevelExecutor, build_low_observation
from mcm26b.hrl.hierarchical_env import HierarchicalEnv
from mcm26b.hrl.low_level_env import LowLevelEnv
from mcm26b.hrl.scheduler import HRLControllerMixin
CONFIG = ProblemConfig()
def _absent(info: dict) -> int:
return info.get("absent_proven", 0)
def _env(problem: int = 3, stage_index: int = 0, **kwargs) -> HierarchicalEnv:
return HierarchicalEnv(problem=problem, stage=STAGES[stage_index], **kwargs)
if numpy is not None:
class _CandidateHost(HRLControllerMixin):
"""只提供候选所需感知原语,槽位分配和掩码均调用生产实现。"""
def __init__(self, detected, certified=(), blocked=()):
self.config = CONFIG
self.problem = 4 if blocked else 3
self._certified = set(certified)
self._clear_failed = set()
self.blocked_probes = {
channel: [(0.0, 0.0)] * CONFIG.search.max_blocked_probes
for channel in blocked
}
region = SimpleNamespace(
polygon=[(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0)],
mec=lambda: ((5.0, 5.0), math.sqrt(32.0)))
self.belief = SimpleNamespace(
channels_by_status=lambda status: list(detected),
candidate_set=lambda channel: region)
self.ledger = SimpleNamespace(points=[])
def _best_certificate(self, channel):
return SimpleNamespace(certified=channel in self._certified,
center=(float(channel), 0.0))
def _select_point(self, channel):
return (float(channel), 1.0)
def _hrl_probe_point(self, region, channel):
return (float(channel), 2.0)
@unittest.skipIf(numpy is None, "RL 依赖组未安装")
class LayoutTests(unittest.TestCase):
def test_high_obs_dim_matches_component_contract(self):
expected = (HIGH_GLOBAL_DIM + CHANNEL_COUNT * PER_CHANNEL_DIM
+ N_CANDIDATES * PER_CANDIDATE_DIM)
self.assertEqual(HIGH_OBS_DIM, expected)
self.assertEqual(HIGH_OBS_DIM, 1248)
self.assertEqual(N_CANDIDATES, 120)
def test_observation_finite_and_deterministic(self):
env = _env(problem=3)
obs, _ = env.reset(seed=2026)
self.assertEqual(obs.shape, (HIGH_OBS_DIM,))
self.assertTrue(numpy.isfinite(obs).all())
again = env._loop.hrl_observation(env._current_candidates)
self.assertTrue(numpy.array_equal(obs, again))
def test_mask_nonempty_and_matches_candidate_slots(self):
env = _env(problem=3)
env.reset(seed=7)
for _ in range(6):
mask = env.action_masks()
self.assertTrue(mask.any())
slots = {cand.slot for cand in env._current_candidates}
self.assertEqual({int(i) for i in numpy.flatnonzero(mask)}, slots)
_obs, _r, term, trunc, _info = env.step(
int(env._loop.heuristic_action(env._current_candidates)))
if term or trunc:
break
def test_q4_probe_and_fallback_slots_exist_in_layout(self):
self.assertEqual(CLEAR_SLOTS, CHANNEL_COUNT)
self.assertEqual(LOCATE_SLOTS, CHANNEL_COUNT)
self.assertEqual(FALLBACK_SLOTS, CHANNEL_COUNT)
self.assertEqual(STATION_SLOTS, 49)
self.assertEqual(CLEAR_OFFSET, 0)
self.assertEqual(LOCATE_OFFSET, CLEAR_OFFSET + CLEAR_SLOTS)
self.assertEqual(STATION_OFFSET, LOCATE_OFFSET + LOCATE_SLOTS)
self.assertEqual(FALLBACK_OFFSET, STATION_OFFSET + STATION_SLOTS)
self.assertEqual(N_CANDIDATES - (FALLBACK_OFFSET + FALLBACK_SLOTS), 11)
def test_build_candidates_keeps_clear_and_locate(self):
host = _CandidateHost(detected=[1, 2, 3, 4, 17, 18, 19, 20],
certified=[17, 18, 19, 20])
candidates = host.build_candidates()
expected = {(CLEAR_OFFSET + channel - 1, "clear", channel)
for channel in range(17, 21)}
expected.update((LOCATE_OFFSET + channel - 1, "locate", channel)
for channel in range(1, 5))
self.assertEqual({(c.slot, c.kind, c.channel) for c in candidates}, expected)
self.assertEqual(len(candidates), 8)
mask = host.hrl_action_mask(candidates)
self.assertEqual(set(numpy.flatnonzero(mask)), {c.slot for c in candidates})
for channel in range(17, 21):
selected = host.candidate_by_slot(candidates, CLEAR_OFFSET + channel - 1)
self.assertEqual((selected.kind, selected.channel), ("clear", channel))
def test_build_candidates_fallback_covers_slots(self):
channels = list(range(17, 21))
host = _CandidateHost(detected=channels, blocked=channels)
candidates = host.build_candidates()
fallbacks = [c for c in candidates if c.kind == "fallback"]
self.assertEqual({c.channel for c in fallbacks}, set(channels))
self.assertEqual(len(candidates), 8) # 四个 probe 与四个 fallback 并存
for candidate in fallbacks:
self.assertEqual(candidate.slot, FALLBACK_OFFSET + candidate.channel - 1)
self.assertGreaterEqual(candidate.slot, FALLBACK_OFFSET)
self.assertLess(candidate.slot, FALLBACK_OFFSET + FALLBACK_SLOTS)
self.assertGreater(candidate.targets, 0)
mask = host.hrl_action_mask(candidates)
self.assertTrue(mask[FALLBACK_OFFSET + 19])
self.assertFalse(mask[FALLBACK_OFFSET + FALLBACK_SLOTS:].any())
@unittest.skipIf(numpy is None, "RL 依赖组未安装")
class HeuristicLoopTests(unittest.TestCase):
def test_q3_full_episode_clears_all_sources(self):
env = _env(problem=3, stage_index=3, max_episode_steps=500)
_, _ = env.reset(seed=11)
steps = 0
while True:
action = env._loop.heuristic_action(env._current_candidates)
_, _, term, trunc, info = env.step(action)
steps += 1
if term or trunc:
break
self.assertTrue(term)
self.assertGreaterEqual(info["cleared"], 10)
self.assertEqual(info["cleared"] + _absent(info), 20)
self.assertEqual(info["accounted"], 20)
self.assertEqual(info["anomalies"], [])
def test_q4_episode_accounts_all_channels(self):
env = _env(problem=4, stage_index=3, max_episode_steps=600)
env.reset(seed=21)
while True:
action = env._loop.heuristic_action(env._current_candidates)
_, _, term, trunc, info = env.step(action)
if term or trunc:
break
self.assertTrue(term)
self.assertEqual(info["accounted"], 20)
self.assertEqual(info["anomalies"], [])
self.assertGreaterEqual(info["cleared"], 10)
def test_reward_identity_from_outcome_terms(self):
"""非终止步奖励恒等于各分项加权和(prompt §5.2 的可折算实现)。"""
env = _env(problem=3, stage_index=1, max_episode_steps=50)
env.reset(seed=5)
reward_cfg = env.reward
for _ in range(8):
action = env._loop.heuristic_action(env._current_candidates)
_virtual_before = env._client.state.virtual_time_s
_, r, term, trunc, info = env.step(action)
outcome = env._last_outcome
if outcome.invalid:
self.assertAlmostEqual(r, -reward_cfg.invalid, places=6)
else:
expected = (-outcome.delta_virtual_s / reward_cfg.time_scale
+ reward_cfg.clear * len(outcome.clear_success)
+ reward_cfg.detect * len(outcome.new_detections)
+ reward_cfg.info_weight
* sum(outcome.info_gains.values()))
if outcome.redundant:
expected -= reward_cfg.redundant
if term:
expected += (reward_cfg.terminal_accounted
if info["accounted"] == 20
else -reward_cfg.terminal_unresolved)
self.assertAlmostEqual(r, expected, places=6)
if term or trunc:
break
def test_invalid_action_penalized_without_sim_call(self):
env = _env(problem=3, stage_index=0, max_episode_steps=50)
env.reset(seed=3)
mask = env.action_masks()
invalid_action = int(numpy.argmin(mask))
virtual_before = env._client.state.virtual_time_s
_, r, _, _, _ = env.step(invalid_action)
self.assertAlmostEqual(r, -env.reward.invalid, places=6)
self.assertAlmostEqual(env._client.state.virtual_time_s,
virtual_before, places=6)
@unittest.skipIf(numpy is None, "RL 依赖组未安装")
class LowLevelTests(unittest.TestCase):
def test_low_env_shapes_and_expert_reaches_goal(self):
env = LowLevelEnv(seed=99)
obs, info = env.reset(seed=99)
self.assertEqual(obs.shape, (10,))
start = tuple(info["position"])
goal = tuple(info["goal"])
total = 0
for _ in range(60):
obs, _, term, trunc, _ = env.step(env.expert_action())
total += 1
if term or trunc:
break
self.assertTrue(term, "直线专家应在步数上限内到达")
straight = math.dist(start, goal)
efficiency = straight / (total * env.step_m)
self.assertGreater(efficiency, 0.7, "专家路径效率应接近直线")
def test_executor_straightline_is_goal_only(self):
executor = LowLevelExecutor(policy=None)
self.assertEqual(executor.roll((0.0, 0.0), (500.0, 200.0)),
[(500.0, 200.0)])
def test_low_observation_source_shared(self):
obs = build_low_observation((0.0, 0.0), (300.0, 0.0), None, 0)
self.assertEqual(obs.shape, (10,))
self.assertAlmostEqual(float(obs[4]), 1.0, places=6) # cos(bearing)=1
@unittest.skipIf(numpy is None, "RL 依赖组未安装")
class CurriculumTests(unittest.TestCase):
def test_stages_are_monotonic_in_difficulty(self):
for prev, nxt in zip(STAGES, STAGES[1:]):
self.assertLessEqual(prev.sources_max, nxt.sources_max)
self.assertLessEqual(prev.channel_max, nxt.channel_max)
self.assertLessEqual(prev.bearing_error_half_deg,
nxt.bearing_error_half_deg)
def test_training_config_defaults_to_production(self):
config = training_config(CONFIG, STAGES[3])
self.assertEqual(config.field.channel_max, 20)
self.assertEqual(config.field.bearing_error_half_deg, 1.0)
self.assertEqual(config.bayes, CONFIG.bayes)
def test_fast_config_preserves_certificate_grid(self):
config = training_config(CONFIG, STAGES[3], fidelity="fast")
self.assertEqual(config.bayes.particle_spacing_m,
CONFIG.bayes.particle_spacing_m)
self.assertLess(config.bayes.particle_spacing_m * math.sqrt(2) / 2,
config.field.clear_radius_m)
self.assertLessEqual(config.bayes.score_subsample,
CONFIG.bayes.score_subsample)
def test_scene_source_count_and_channel_bounds(self):
from random import Random
rng = Random(1)
sources, channels = make_scene(rng, training_config(CONFIG, STAGES[1]),
3, STAGES[1])
self.assertEqual(len(sources), STAGES[1].sources_max)
self.assertTrue(all(1 <= c <= STAGES[1].channel_max for c in channels))
self.assertEqual(len(set(channels)), len(channels))
if __name__ == "__main__": # pragma: no cover
unittest.main()
-444
View File
@@ -1,444 +0,0 @@
"""高层训练落盘保证:checkpoint、控制文件优雅停止、信号兜底、worker 信号屏蔽。
背景:训练前端的「停止」以前直接向进程组发 SIGTERM,而旧脚本只在 ``learn()``
正常返回后才保存,导致"停一下就全丢";在 Windows 上 ``os.killpg`` 更是直接
``AttributeError``。现在停止/暂停走**控制文件**(跨平台),信号只作兜底。这里把
保证固定成回归测试:
1. 定期 checkpoint 可续训(zip + 同名元数据);
2. 控制文件发出 stop 后仍能保存最终模型与元数据(Windows 同样成立);
3. POSIX 下进程组 SIGTERM 也能优雅收尾;
4. subproc worker 忽略停止信号,不抢在主进程收尾之前退出。
"""
from __future__ import annotations
import importlib.util
import json
import multiprocessing
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
import pytest
from mcm26b.process_control import spawn_kwargs, terminate_process_tree, write_control
def _train_module():
"""按路径加载训练脚本(不触发 torch 导入,torch 只在函数内部惰性导入)。"""
path = Path(__file__).parents[1] / "scripts" / "train_hrl_high.py"
spec = importlib.util.spec_from_file_location("train_hrl_high_ckpt", path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _require_rl():
pytest.importorskip("stable_baselines3")
pytest.importorskip("sb3_contrib")
pytest.importorskip("torch")
class _Run:
"""一次子进程训练:stdout 走文件,避免管道写满把子进程卡死。
与训练前端一致:用控制文件请求暂停/停止,进程组隔离走
:func:`mcm26b.process_control.spawn_kwargs`(POSIX ``setsid`` /
Windows ``CREATE_NEW_PROCESS_GROUP``)。
"""
def __init__(self, run_dir: Path, *extra: str):
script = Path(__file__).parents[1] / "scripts" / "train_hrl_high.py"
self.run_dir = run_dir
self.log_path = run_dir / "train_stdout.log"
self.log_file = self.log_path.open("w", encoding="utf-8")
self.control_path = run_dir / "control.json"
write_control(self.control_path)
cmd = [sys.executable, str(script),
"--problem", "3", "--stage", "1", "--timesteps", "200000",
"--seed", "7", "--envs", "2", "--vec-env", "subproc",
"--device", "cpu", "--config-profile", "coarse-pretrain",
"--reward-profile", "time", "--eval-episodes", "1",
"--rollout-steps", "32", "--batch-size", "32", "--n-epochs", "1",
"--max-wall-s", "600", "--force", "--verbose", "0",
"--control-file", str(self.control_path),
"--model", str(run_dir / "q3.zip"), *extra]
self.proc = subprocess.Popen( # pylint: disable=consider-using-with
cmd, stdout=self.log_file, stderr=subprocess.STDOUT, text=True,
**spawn_kwargs())
def checkpoints(self) -> list[Path]:
"""历史 checkpoint(``<stem>_<步数>.zip``)。
刻意排除滚动恢复点 ``<stem>_latest.zip``:它随写随覆盖,不是"第几个
checkpoint",混进来会让序号断言失去意义。
"""
found = (self.run_dir / "checkpoints").glob("q3_ckpt_[0-9]*.zip")
return sorted(path for path in found
if path.stem.rsplit("_", 1)[-1].isdigit())
def recovery(self) -> Path:
return self.run_dir / "checkpoints" / "q3_ckpt_latest.zip"
def resume_hint(self) -> dict:
return json.loads((self.run_dir / "resume.json").read_text(
encoding="utf-8"))
def wait_for(self, predicate, timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if predicate():
return True
if self.proc.poll() is not None:
return predicate()
time.sleep(0.4)
return predicate()
def logs(self) -> str:
try:
return self.log_path.read_text(encoding="utf-8", errors="replace")
except OSError: # pragma: no cover - 文件被并发写时的极端情况
return ""
def pause(self) -> None:
write_control(self.control_path, paused=True)
def resume(self) -> None:
write_control(self.control_path, paused=False)
def _finish(self) -> str:
self.log_file.close()
return self.logs()
def stop(self) -> str:
"""复现训练前端的停止语义:写控制文件请求优雅收尾(跨平台)。"""
write_control(self.control_path, stop=True)
forced = terminate_process_tree(self.proc, grace_s=240.0)
if forced:
self.log_file.close()
pytest.fail("控制文件停止请求后训练进程未在 240s 内退出(优雅停止失败)")
return self._finish()
def stop_by_signal(self) -> str:
"""POSIX 兜底路径:对整个进程组发 SIGTERM(Windows 不支持 killpg)。"""
if os.name == "nt": # pragma: no cover - 仅在 Windows 上走到
pytest.skip("Windows 无 os.killpg,信号路径不适用")
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
self.proc.terminate()
try:
self.proc.wait(timeout=240)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=30)
self.log_file.close()
pytest.fail("SIGTERM 后训练进程未在 240s 内退出(优雅停止失败)")
return self._finish()
def hard_kill(self) -> None:
"""模拟"急停":立即强杀整棵进程树,不给训练脚本任何收尾机会。"""
terminate_process_tree(self.proc, grace_s=0.0)
self.log_file.close()
def _run_dir(tmp_path: Path) -> Path:
run_dir = tmp_path / "run"
run_dir.mkdir(parents=True, exist_ok=True)
return run_dir
# --------------------------------------------------------------- 纯函数回归
def test_prune_checkpoints_keeps_newest(tmp_path):
module = _train_module()
directory = tmp_path / "checkpoints"
directory.mkdir()
for index in range(5):
for suffix in (".zip", ".json"):
target = directory / f"q3_ckpt_{index:09d}{suffix}"
target.write_bytes(b"x")
stamp = 1_700_000_000 + index
os.utime(target, (stamp, stamp))
removed = module._prune_checkpoints(directory, "q3_ckpt", 2)
assert len(removed) == 3
remaining = sorted(path.name for path in directory.iterdir())
assert remaining == ["q3_ckpt_000000003.json", "q3_ckpt_000000003.zip",
"q3_ckpt_000000004.json", "q3_ckpt_000000004.zip"]
def test_worker_ignores_stop_signals_but_main_does_not(monkeypatch):
module = _train_module()
original = signal.getsignal(signal.SIGTERM)
class _Worker:
name = "SpawnProcess-3"
monkeypatch.setattr(multiprocessing, "current_process", _Worker)
try:
module._ignore_stop_signals_in_worker()
assert signal.getsignal(signal.SIGTERM) == signal.SIG_IGN
assert signal.getsignal(signal.SIGINT) == signal.SIG_IGN
finally:
signal.signal(signal.SIGTERM, original)
signal.signal(signal.SIGINT, signal.default_int_handler)
class _Main:
name = "MainProcess"
monkeypatch.setattr(multiprocessing, "current_process", _Main)
module._ignore_stop_signals_in_worker()
assert signal.getsignal(signal.SIGTERM) == original
def test_policy_hyperparams_records_effective_values():
"""元数据必须记实际生效的超参(resume 时来自 checkpoint,而非命令行)。"""
module = _train_module()
class _Schedule:
def __init__(self, value):
self._value = value
def current_value(self):
return self._value
class _Model:
n_steps = 256
batch_size = 256
n_epochs = 10
gamma = 1.0
gae_lambda = 0.95
learning_rate = _Schedule(3e-4)
clip_range = _Schedule(0.2)
ent_coef = 0.005
vf_coef = 0.5
max_grad_norm = 0.5
hyper = module._policy_hyperparams(_Model())
assert hyper["n_steps"] == 256 and hyper["batch_size"] == 256
assert hyper["n_epochs"] == 10
assert hyper["learning_rate"] == 3e-4
assert hyper["clip_range"] == 0.2
assert hyper["gamma"] == 1.0 and hyper["ent_coef"] == 0.005
assert module._policy_hyperparams(object())["n_steps"] == 0
def test_control_poll_state_machine(tmp_path):
"""控制文件轮询状态机:抽样读盘、暂停阻塞、stop 打断、暂停时长累计。"""
module = _train_module()
path = tmp_path / "control.json"
write_control(path, paused=True)
poll = module._ControlPoll(path, poll_every=1, poll_s=0.05)
assert poll.poll().paused is True
events = []
def _resume_later():
time.sleep(0.15)
write_control(path, paused=False)
threading.Thread(target=_resume_later, daemon=True).start()
stopped = poll.wait_while_paused(lambda: events.append("pause"),
lambda: events.append("resume"))
assert stopped is False
assert events == ["pause", "resume"]
assert poll.paused_total_s >= 0.1
# 暂停期间收到 stop:wait 必须返回 True,让调用方转入收尾
write_control(path, paused=True)
def _stop_later():
time.sleep(0.15)
write_control(path, stop=True)
threading.Thread(target=_stop_later, daemon=True).start()
assert poll.wait_while_paused(lambda: None, lambda: None) is True
assert poll.poll().stop is True
def test_atomic_model_save_keeps_previous_snapshot_on_failure(tmp_path):
"""保存途中失败/被杀:目标路径仍是上一份完整快照,不会出现半截 zip。"""
module = _train_module()
target = tmp_path / "q3_ckpt_latest.zip"
target.write_bytes(b"OLD-COMPLETE")
class _FailingModel:
def save(self, path):
Path(path).write_bytes(b"PARTIAL")
raise RuntimeError("磁盘写满")
with pytest.raises(RuntimeError):
module._save_model_atomic(_FailingModel(), target)
assert target.read_bytes() == b"OLD-COMPLETE"
leftovers = [p.name for p in tmp_path.iterdir() if p.name != target.name]
assert leftovers and all(name.startswith(".tmp-") for name in leftovers)
def test_write_json_atomic_replaces_in_one_step(tmp_path):
module = _train_module()
path = tmp_path / "resume.json"
module._write_json_atomic(path, {"model": "a.zip", "timesteps": 1})
assert json.loads(path.read_text(encoding="utf-8"))["timesteps"] == 1
module._write_json_atomic(path, {"model": "b.zip", "timesteps": 2})
assert json.loads(path.read_text(encoding="utf-8"))["model"] == "b.zip"
assert not list(tmp_path.glob(".tmp-*"))
def test_prune_checkpoints_never_deletes_recovery_point(tmp_path):
"""滚动恢复点与临时文件不参与历史清理。"""
module = _train_module()
directory = tmp_path / "checkpoints"
directory.mkdir()
latest = directory / "q3_ckpt_latest.zip"
latest.write_bytes(b"RECOVERY")
(directory / ".tmp-q3_ckpt_000000009.zip").write_bytes(b"PARTIAL")
for index in range(5):
(directory / f"q3_ckpt_{index:09d}.zip").write_bytes(b"x")
module._prune_checkpoints(directory, "q3_ckpt", 1)
assert latest.exists() and latest.read_bytes() == b"RECOVERY"
assert (directory / ".tmp-q3_ckpt_000000009.zip").exists()
# --------------------------------------------------------------- 集成回归
def test_recovery_point_exists_before_training_and_is_loadable(tmp_path):
"""起步基线:训练刚起就有可加载、可续训的产物(--checkpoint-every 0 也成立)。"""
_require_rl()
from sb3_contrib import MaskablePPO
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "0")
try:
assert run.wait_for(lambda: run.recovery().exists(), 120.0), \
"起步恢复点未落盘"
loaded = MaskablePPO.load(str(run.recovery()))
assert loaded.num_timesteps >= 0
hint = run.resume_hint()
assert Path(hint["model"]) == run.recovery()
assert hint["timesteps_semantics"] == "additional"
finally:
run.stop()
def test_hard_kill_still_leaves_resumable_recovery_point(tmp_path):
"""急停(强杀、无收尾)后仍能续训:滚动恢复点 + resume.json 都在且可加载。"""
_require_rl()
from sb3_contrib import MaskablePPO
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "0",
"--recovery-every", "32")
try:
assert run.wait_for(lambda: run.recovery().exists(), 120.0), \
"起步恢复点未落盘"
first = run.recovery().stat().st_mtime_ns
assert run.wait_for(
lambda: run.recovery().stat().st_mtime_ns != first, 300.0), \
"滚动恢复点未按 --recovery-every 刷新"
assert run.proc.poll() is None, "训练已自行结束,无法验证急停"
finally:
run.hard_kill() # 急停:强杀整棵进程树,没有任何 Python 收尾
hint = run.resume_hint()
assert Path(hint["model"]).is_file(), hint
assert Path(hint["model"]) == run.recovery(), hint
loaded = MaskablePPO.load(str(run.recovery()))
assert loaded.num_timesteps >= 32, loaded.num_timesteps
metadata = json.loads(run.recovery().with_suffix(".json").read_text(
encoding="utf-8"))
assert metadata["recovery"] is True
assert metadata["checkpoint_reason"] in ("baseline", "interval")
# 急停没有 graceful 收尾,所以不会有"stopped_by_signal"的最终模型
assert not (run.run_dir / "q3.zip").exists()
def test_checkpoint_is_written_and_loadable(tmp_path):
_require_rl()
from sb3_contrib import MaskablePPO
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
try:
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内产出 checkpoint"
finally:
run.stop()
archives = run.checkpoints()
assert archives, "checkpoint 目录为空"
loaded = MaskablePPO.load(str(archives[-1]))
assert loaded.num_timesteps >= 64
metadata = json.loads(archives[-1].with_suffix(".json").read_text(
encoding="utf-8"))
assert metadata["checkpoint"] is True
assert metadata["checkpoint_timesteps"] == loaded.num_timesteps
assert metadata["model_sha256"]
hyper = metadata["ppo_hyperparams"]
assert (hyper["n_steps"], hyper["batch_size"], hyper["n_epochs"]) == (32, 32, 1)
def test_control_file_stop_saves_model(tmp_path):
"""控制文件 stop 是训练前端的默认停止路径,Windows 上同样必须成立。"""
_require_rl()
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
model_path = run.run_dir / "q3.zip"
try:
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内进入 rollout 阶段"
assert run.proc.poll() is None, "训练进程提前退出,无法验证优雅停止"
finally:
output = run.stop()
assert run.proc.returncode == 0, \
f"控制文件停止应正常退出,实际 {run.proc.returncode}\n{output}"
assert model_path.exists(), "控制文件停止后最终模型未落盘"
metadata = json.loads(model_path.with_suffix(".json").read_text(
encoding="utf-8"))
assert metadata["stopped_by_signal"] is True
assert metadata["stop_signal"] == "control_file"
assert metadata["checkpoint_files"], "元数据应记录已产出的 checkpoint"
assert not (run.run_dir / "training_failure.json").exists()
assert "saved_after_stop" in output
def test_control_file_pause_then_resume_keeps_training_alive(tmp_path):
"""暂停是协作式的:进程存活、停在 step 边界,continue 后照常收尾。"""
_require_rl()
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
try:
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内进入 rollout 阶段"
run.pause()
assert run.wait_for(lambda: '"event": "paused"' in run.logs(), 120.0), \
"训练脚本未在 120s 内进入暂停"
assert run.proc.poll() is None, "暂停不应结束进程"
run.resume()
assert run.wait_for(lambda: '"event": "resumed"' in run.logs(), 120.0), \
"训练脚本未在 120s 内恢复"
finally:
run.stop()
assert run.proc.returncode == 0
@pytest.mark.skipif(os.name == "nt", reason="Windows 没有 os.killpg,信号兜底路径不适用")
def test_sigterm_saves_model_instead_of_losing_the_run(tmp_path):
_require_rl()
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
model_path = run.run_dir / "q3.zip"
try:
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内进入 rollout 阶段"
assert run.proc.poll() is None, "训练进程提前退出,无法验证优雅停止"
finally:
output = run.stop_by_signal()
assert run.proc.returncode == 0, \
f"优雅停止应正常退出,实际 {run.proc.returncode}\n{output}"
assert model_path.exists(), "SIGTERM 后最终模型未落盘"
metadata = json.loads(model_path.with_suffix(".json").read_text(
encoding="utf-8"))
assert metadata["stopped_by_signal"] is True
assert metadata["stop_signal"] == "SIGTERM"
assert metadata["checkpoint_files"], "元数据应记录已产出的 checkpoint"
assert not (run.run_dir / "training_failure.json").exists()
assert "saved_after_stop" in output
-119
View File
@@ -1,119 +0,0 @@
"""低层导航运动学与训练入口的行为回归;不验证策略收益。"""
import importlib.util
from pathlib import Path
import pytest
np = pytest.importorskip("numpy")
pytest.importorskip("gymnasium")
from mcm26b.hrl.executor import LowLevelExecutor, navigation_transition
from mcm26b.hrl.low_level_env import LowLevelEnv
def _training_script():
spec = importlib.util.spec_from_file_location(
"train_hrl_low_revision", Path(__file__).parents[1]
/ "scripts" / "train_hrl_low.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_shortened_step_preserves_selected_direction_without_snapping():
position, moved = navigation_transition((0.0, 0.0), (30.0, 40.0), 0)
assert position == pytest.approx((50.0, 0.0))
assert moved == pytest.approx(50.0)
assert position != (30.0, 40.0)
with pytest.raises(ValueError):
navigation_transition((0, 0), (300, 0), 8)
def test_near_goal_reward_and_time_charge_actual_distance():
env = LowLevelEnv()
env.reset(seed=1)
env._position, env._goal = (0.0, 0.0), (30.0, 0.0)
_, reward, terminated, truncated, info = env.step(0)
assert terminated and not truncated
assert info["path_length_m"] == pytest.approx(30.0)
assert info["step_virtual_time_s"] == pytest.approx(6.0)
assert reward == pytest.approx(env.reach_bonus - 6.0 / env.step_cost_scale)
def test_expert_reaches_all_200_fixed_seed_targets():
env = LowLevelEnv()
for seed in range(200):
env.reset(seed=seed)
for _ in range(env.max_steps):
obs, _, terminated, truncated, info = env.step(env.expert_action())
assert env.observation_space.contains(obs)
if terminated or truncated:
break
assert terminated, (seed, info)
assert info["distance_m"] <= env.reach_m
assert info["virtual_time_s"] == pytest.approx(
info["path_length_m"] / env.config.cost.speed_mps)
def test_failed_executor_reports_failure_without_free_terminal_move():
class WestPolicy:
def select(self, _observation):
return 4
executor = LowLevelExecutor(WestPolicy(), max_steps=3)
path = executor.roll((0, 0), (300, 0))
assert len(path) == 3
assert path[-1] == pytest.approx((-300, 0))
assert executor.last_roll_info == pytest.approx({
"reached": False, "truncated": True,
"final_distance_m": 600.0, "path_length_m": 300.0})
def test_env_and_executor_share_step_transitions():
class EastPolicy:
def select(self, _observation):
return 0
env = LowLevelEnv(max_steps=3)
env.reset(seed=3)
start, goal = (0.0, 0.0), (350.0, 25.0)
env._position, env._goal = start, goal
actual = []
for _ in range(3):
_, _, _, _, info = env.step(0)
actual.append(info["position"])
executor = LowLevelExecutor(EastPolicy(), max_steps=3)
assert executor.roll(start, goal) == actual
assert executor.last_roll_info["path_length_m"] == info["path_length_m"]
def test_bc_holdout_keeps_each_episode_wholly_in_one_partition():
script = _training_script()
episode_ids = np.repeat(np.arange(20), 5)
mask = script.bc_validation_mask(episode_ids, seed=7)
assert mask.any() and not mask.all()
for episode in np.unique(episode_ids):
assert len(set(mask[episode_ids == episode])) == 1
assert np.array_equal(mask, script.bc_validation_mask(episode_ids, seed=7))
def test_bc_copy_and_maskable_ppo_learn_with_two_environments():
torch = pytest.importorskip("torch")
pytest.importorskip("sb3_contrib")
from stable_baselines3.common.vec_env import DummyVecEnv
previous_threads = torch.get_num_threads()
torch.set_num_threads(1)
script = _training_script()
env = DummyVecEnv([LowLevelEnv, LowLevelEnv])
try:
bc = script._bc_module(10, 8, script.HIDDEN)()
model = script.copy_into_ppo(bc, "cpu", env, seed=9, n_steps=8)
model.learn(total_timesteps=16)
assert model.n_envs == 2
assert model.num_timesteps == 16
assert np.all(env.env_method("action_masks"))
finally:
env.close()
torch.set_num_threads(previous_threads)
-86
View File
@@ -1,86 +0,0 @@
"""P1 交付回归:benchmark manifest/真值隔离、纯时间奖励与 project 兼容层。
这些测试不训练神经网络,只验证评测入口和接口契约;重训练属于 smoke 手测。
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
np = pytest.importorskip("numpy")
pytest.importorskip("referee")
from mcm26b.config import ProblemConfig
def _benchmark_module():
path = Path(__file__).parents[1] / "scripts" / "benchmark_hrl.py"
spec = importlib.util.spec_from_file_location("benchmark_hrl_p1", path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
finally:
pass
return module
def test_manifest_roundtrip_and_truth_summary():
module = _benchmark_module()
manifest = module.make_default_manifest(
problem=3, scenes=2, sources=3, directional=0,
start_seed=123, config=ProblemConfig())
scenes = module.scenes_from_manifest(manifest)
assert [scene.scene_id for scene in scenes] == ["scene-123", "scene-124"]
assert all(scene.truth_channels for scene in scenes)
assert all(1 <= channel <= 20 for scene in scenes
for channel in scene.truth_channels)
def test_benchmark_heuristic_is_valid_and_truth_isolated(tmp_path):
module = _benchmark_module()
manifest = module.make_default_manifest(
problem=3, scenes=1, sources=3, directional=0,
start_seed=777, config=ProblemConfig())
scene = module.scenes_from_manifest(manifest)[0]
run_dir = tmp_path / "run"
result = module.run_scene_variant(
scene, "hrl_heuristic", config=ProblemConfig(), hrl_model=None,
run_dir=run_dir)
row = result["row"]
assert row["valid"] is True
assert row["cleared_count"] == len(scene.truth_channels) == 3
assert row["cleared_matches_truth"] is True
assert row["no_source_declared_absent"] is True
assert (run_dir / "session.jsonl").exists()
# manifest/run summary 中不出现策略可消费的 simulator.sources 真值对象。
assert "sources" not in row
def test_time_only_reward_has_no_per_source_shaping():
from mcm26b.hrl.hierarchical_env import HRLReward
reward = HRLReward.time_only()
assert reward.clear == 0.0
assert reward.detect == 0.0
assert reward.info_weight == 0.0
assert reward.time_scale == 100.0
assert reward.failure >= 3600.0
@pytest.mark.skipif(importlib.util.find_spec("gymnasium") is None,
reason="RL 依赖组未安装")
def test_project_compat_layer_reexports_real_classes():
from mcm26b.hrl.hierarchical_env import HierarchicalEnv
# project 是仓库根部的兼容层:pytest 经 rootdir 把仓库根放进 sys.path,
# 而单独 lint tests/ 时 pylint 的 sys.path 不含仓库根,故此处局部豁免。
# pylint: disable=import-error
from project.env.local_env import HierarchicalEnv as CompatEnv
from project.env.simulator_client import SimulatorClient
from project.policies.high_level_ppo import HighLevelPolicy
assert CompatEnv is HierarchicalEnv
assert SimulatorClient.__module__ == "mcm26b.protocol.client"
assert HighLevelPolicy.__module__ == "mcm26b.hrl.policy"
-88
View File
@@ -1,88 +0,0 @@
"""HRL 协议执行的分支回归;所有模拟器交互仅在本地进行。"""
from unittest.mock import patch
import pytest
pytest.importorskip("numpy")
from mcm26b.config import ProblemConfig
from mcm26b.harness import LocalSimTransport
from mcm26b.hrl.constants import FALLBACK_OFFSET, N_CANDIDATES, STATION_OFFSET
from mcm26b.hrl.scheduler import Candidate
from mcm26b.planner.hrl_loop import HRLLoop
from mcm26b.protocol import SimulatorClient
from referee import LocalSimulator, Source
def make_loop(*, problem=4, high_policy=None):
simulator = LocalSimulator(
[Source(channel=20, x=45.0, y=0.0, r_eff_m=1000.0)],
robot_id="hrl-branch-test")
client = SimulatorClient("hrl-branch-test", transport=LocalSimTransport(simulator))
client.enter()
return HRLLoop(client, ProblemConfig(), problem=problem, high_policy=high_policy)
def test_fallback_failure_then_success_records_actual_time_position_and_clear():
loop = make_loop()
try:
observation = loop.client.measure((0.0, 0.0), 20)
loop._record_measure(20, (0.0, 0.0), observation)
loop.blocked_probes[20] = [(0.0, 0.0)]
candidate = Candidate(FALLBACK_OFFSET + 19, "fallback", (999.0, 999.0),
channel=20)
with patch("mcm26b.hrl.scheduler.fallback_clear_cells",
return_value=[(20.0, 0.0), (40.0, 0.0)]):
outcome = loop.hrl_step(candidate.slot, [candidate])
assert outcome.clear_success == (20,)
assert loop.cleared == [20]
assert outcome.position_reached == (40.0, 0.0)
# 40m / 5m/s + 3s失败 + 5s成功;clear不产生换频时间。
assert outcome.delta_virtual_s == pytest.approx(16.0)
assert loop.client.state.channel == 20
assert loop.fallback_log[-1]["attempts"] == 2
assert 20 not in loop.blocked_probes
assert not loop.anomalies
finally:
loop.client.exit()
def test_invalid_policy_output_is_logged_and_replaced_by_valid_action():
class InvalidPolicy:
def select(self, _observation, _mask):
return N_CANDIDATES + 1
loop = make_loop(problem=3, high_policy=InvalidPolicy())
candidate = Candidate(STATION_OFFSET, "station", (0.0, 0.0), station_index=0)
executed = []
def execute(action, candidates): # pylint: disable=unused-argument
executed.append(action)
loop.stop_reason = "test_complete"
try:
with patch.object(loop, "build_candidates", return_value=[candidate]), \
patch.object(loop, "hrl_observation", return_value=None), \
patch.object(loop, "heuristic_action", return_value=candidate.slot), \
patch.object(loop, "hrl_step", side_effect=execute):
loop._coverage_phase()
assert executed == [candidate.slot]
assert loop.anomalies[0]["kind"] == "hrl_invalid_policy_action"
finally:
loop.client.exit()
def test_opportunistic_measure_precedes_candidate_snapshot():
loop = make_loop(problem=3)
order = []
try:
assert loop.config.bayes.opportunistic_measure
with patch.object(loop, "_measure_at_stop",
side_effect=lambda: order.append("measure")), \
patch.object(loop, "build_candidates",
side_effect=lambda: order.append("snapshot") or []):
loop._coverage_phase()
assert order == ["measure", "snapshot"]
finally:
loop.client.exit()
-101
View File
@@ -1,101 +0,0 @@
"""RL 扩展验收:特征/动作布局、训练环境闭环与模型接口守卫。
RL 依赖属于可选组 [dependency-groups] rl;未安装 numpy/gymnasium 时
全部跳过(运行闭环不依赖它们)。
"""
import unittest
from pathlib import Path
pytest = None
try:
import pytest
numpy = pytest.importorskip("numpy")
pytest.importorskip("gymnasium")
except Exception: # pragma: no cover - 无 RL 依赖环境
numpy = None
if numpy is not None:
from mcm26b.belief import CandidateRegion
from mcm26b.config import ProblemConfig
from mcm26b.rl._constants import GLOBAL_DIM, NUM_ACTIONS, OBS_DIM, PER_ACTION_DIM
from mcm26b.rl.env import LocalizationEnv
from mcm26b.rl.features import build_observation, candidate_actions
from mcm26b.rl.policy import default_model_path
CONFIG = ProblemConfig()
STATION = (0.0, 0.0)
BEARING = 30.0
def _region():
region = CandidateRegion.initial(STATION, CONFIG)
region.clip_wedge(STATION, BEARING, 1.005)
return region
@unittest.skipIf(numpy is None, "RL 依赖组未安装")
class FeatureLayoutTests(unittest.TestCase):
def test_action_candidates_have_stable_count_and_order(self):
region = _region()
first = candidate_actions(region, STATION, CONFIG, [])
self.assertEqual(len(first), NUM_ACTIONS)
second = candidate_actions(region, STATION, CONFIG, [])
self.assertEqual(first, second)
self.assertEqual(first[0], region.mec()[0]) # 0 号候选 = MEC 圆心
def test_observation_dimension_matches_model_contract(self):
region = _region()
obs = build_observation(region, STATION, 3, CONFIG, problem=3,
blocked=[], virtual_time_s=0.0)
self.assertEqual(obs.shape, (OBS_DIM,))
self.assertEqual(OBS_DIM, GLOBAL_DIM + NUM_ACTIONS * PER_ACTION_DIM)
def test_blocked_changes_observation_but_not_layout(self):
region = _region()
clean = build_observation(region, STATION, 3, CONFIG, problem=4,
blocked=[], virtual_time_s=0.0)
blocked = build_observation(region, STATION, 3, CONFIG, problem=4,
blocked=[(100.0, 0.0)], virtual_time_s=0.0)
self.assertEqual(clean.shape, blocked.shape)
self.assertFalse(numpy.allclose(clean, blocked))
@unittest.skipIf(numpy is None, "RL 依赖组未安装")
class TrainingEnvironmentTests(unittest.TestCase):
def test_q3_episode_reset_and_step_shapes(self):
env = LocalizationEnv(problem=3)
obs, info = env.reset(seed=1)
self.assertEqual(obs.shape, (OBS_DIM,))
self.assertIn("source", info)
obs, reward, terminated, truncated, info = env.step(0)
self.assertEqual(obs.shape, (OBS_DIM,))
self.assertIsInstance(reward, float)
self.assertFalse(terminated and truncated)
def test_q4_directional_reports_blocked_or_fallback(self):
env = LocalizationEnv(problem=4, directional_probability=1.0)
_, info = env.reset(seed=7)
self.assertTrue(info["source"]["directional"])
terminated = truncated = False
for _ in range(env.max_episode_steps + 2):
_, _, terminated, truncated, info = env.step(
env.action_space.sample())
if terminated or truncated:
break
self.assertTrue(terminated or truncated)
# 定向分支:要么清除,要么走过后备清除/截断语义。
self.assertIn("fallback_attempts", info)
def test_missing_model_raises_clear_error(self):
from mcm26b.rl.policy import PointSelectionPolicy
with self.assertRaises(RuntimeError):
PointSelectionPolicy("output/rl/does-not-exist.zip")
def test_default_model_paths(self):
self.assertEqual(default_model_path(3), Path("output/rl/q3_ppo.zip"))
self.assertEqual(default_model_path(4), Path("output/rl/q4_ppo.zip"))
if __name__ == "__main__":
unittest.main()
-97
View File
@@ -1,97 +0,0 @@
"""训练/部署观测和动作的一致性,以及RL改进的目标口径。"""
import importlib.util
import math
from pathlib import Path
import pytest
np = pytest.importorskip('numpy')
pytest.importorskip('gymnasium')
from mcm26b.rl.env import LocalizationEnv
from mcm26b.rl.features import build_observation, candidate_actions
def test_training_observation_matches_runtime_channel_belief():
env = LocalizationEnv(problem=3, handoff_probability=1)
obs, _ = env.reset(seed=1)
belief = env._belief.channels[env._channel]
expected = build_observation(belief.region, env._client.state.position, env._channel,
env.config, env.problem, env._blocked, belief,
virtual_time_s=env._client.state.virtual_time_s,
receiver_channel=env._client.state.channel)
np.testing.assert_array_equal(obs, expected)
assert obs[7] == np.float32(1/12)
assert tuple(obs[14:17]) == (0,1,0)
assert env._client.state.channel != env._channel
env.close()
def test_mask_rejects_measured_and_duplicate_candidates():
env = LocalizationEnv(problem=3)
env.reset(seed=17)
belief = env._belief.channels[env._channel]
region = belief.region
position = env._client.state.position
candidates = candidate_actions(region, position, env.config)
belief.measurements.append(dict(position=candidates[0], result='no_signal', svd_deg=None))
mask = env.action_masks()
assert not mask[0]
keys = [(round(p[0]*2),round(p[1]*2)) for p,v in zip(candidates,mask) if v]
assert len(keys) == len(set(keys))
assert any(mask)
assert all(math.dist(p,region.mec()[0]) <= 1000-region.mec()[1]+1e-6
for p,v in zip(candidates,mask) if v)
env.close()
def test_q4_blocked_sequence_resets_on_direction():
env = LocalizationEnv(problem=4,directional_probability=1,handoff_probability=0)
env.reset(seed=7)
for expected in ('no_signal','direction'):
region = env._belief.candidate_set(env._channel)
candidates = candidate_actions(region,env._client.state.position,env.config,env._blocked)
# 只有测试端可用真值选择一个指定分支,策略从不接触该接口。
index = next(i for i,p in enumerate(candidates)
if env._simulator._observe(p,env._channel)[0] == expected)
_, _, terminated, truncated, _ = env.step(index)
assert len(env._blocked) == (1 if expected=='no_signal' else 0)
if expected=='no_signal':
assert not terminated and not truncated
env.close()
def test_success_reward_is_constant_minus_actual_virtual_cost():
env = LocalizationEnv(problem=3,handoff_probability=0)
env.reset(seed=3)
total = 0
for _ in range(16):
legal = np.flatnonzero(env.action_masks())
action = int(0 if 0 in legal else legal[0])
_, reward, terminated, truncated, info = env.step(action)
total += reward
if terminated or truncated:
break
assert info['cleared']
assert total == pytest.approx(20-info['virtual_cost_s']/100)
assert env._client.state.exited
def test_bc_and_ppo_forward_mapping_agree():
torch = pytest.importorskip('torch')
from stable_baselines3 import PPO
spec = importlib.util.spec_from_file_location('pretrain',Path('scripts/pretrain_rl_expert.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
torch.set_num_threads(1)
env = LocalizationEnv()
ppo = PPO('MlpPolicy',env,policy_kwargs={'net_arch':[128,128]},device='cpu',
n_steps=16,batch_size=8)
bc = module.BCMlp()
state = bc.net.state_dict()
ppo.policy.mlp_extractor.policy_net.load_state_dict(
{k:v for k,v in state.items() if not k.startswith('4.')})
ppo.policy.action_net.load_state_dict({'weight':state['4.weight'],'bias':state['4.bias']})
x = torch.randn(10,401)
torch.testing.assert_close(bc(x),ppo.policy.action_net(ppo.policy.mlp_extractor.policy_net(x)))
env.close()
-957
View File
@@ -1,957 +0,0 @@
"""训练前端回归:配置契约、进度解析、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 上的路径断言有明确前置条件
-44
View File
@@ -1,44 +0,0 @@
"""训练前端 JS 逻辑回归:用 DOM 桩跑发布的 charts.js/app.js 原文。
动机:该前端的多数 bug 只在浏览器逻辑里暴露(游标重复追加、classList 取错、
字段缺失导致渲染中断),Python 侧测试无法覆盖。这里用服务端真实生成的
schema 与示例快照驱动一次完整渲染,有 node 则执行,无 node 自动跳过。
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
HARNESS = REPO / "tests" / "js" / "training_ui_harness.cjs"
@pytest.mark.skipif(shutil.which("node") is None, reason="需要 node 环境运行 JS 桩测试")
def test_training_ui_frontend_logic(tmp_path):
sys.path.insert(0, str(REPO / "src"))
from mcm26b.training_ui.demo import DEMO_SCENARIOS, demo_snapshot
from mcm26b.training_ui.schema import config_schema
scenarios = {key: demo_snapshot(scenario=key) for key in DEMO_SCENARIOS}
scenarios_path = tmp_path / "scenarios.json"
schema_path = tmp_path / "schema.json"
scenarios_path.write_text(json.dumps(scenarios, ensure_ascii=False),
encoding="utf-8")
schema_path.write_text(json.dumps(config_schema(), ensure_ascii=False),
encoding="utf-8")
result = subprocess.run(
["node", str(HARNESS)], capture_output=True, text=True, timeout=120,
check=False,
env={**os.environ, "REPO": str(REPO),
"DEMO_STATE": str(scenarios_path), "SCHEMA": str(schema_path)})
assert result.returncode == 0, \
f"训练前端逻辑测试失败:\n{result.stdout}\n{result.stderr}"
assert "ALL PASS" in result.stdout