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