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:
@@ -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("<") && marked.endsWith(">") && 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);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 上的路径断言有明确前置条件
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user