This commit is contained in:
Horldsence
2026-09-11 21:00:39 +08:00
parent 2a3ead5d3d
commit 1725cd0891
168 changed files with 170687 additions and 874 deletions
+285
View File
@@ -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("&lt;") && marked.endsWith("&gt;") && marked.includes("<mark>"),
`命中过滤时也必须转义 HTML: ${marked}`);
assert(!appApi.highlight("<script>", "").includes("<script>"),
"未过滤时也应转义 HTML");
// 6) 序列抽取:跳过 None 且横轴用 timesteps
const series = appApi.seriesOf("ep_rew_mean");
assert(series.length > 0 && isFinite(series[0][0]) && isFinite(series[0][1]),
"seriesOf 产出非法坐标");
for (let i = 1; i < series.length; i++) {
assert(series[i][0] >= series[i - 1][0], "seriesOf 横轴未单调递增");
}
// 7) charts.js 边界输入不抛异常
const Charts = globalThis.Charts;
const canvas = makeEl("probe");
Charts.line(canvas, { series: [] });
Charts.line(canvas, { series: [{ points: [[1, 1]] }] });
Charts.line(canvas, { series: [{ points: [[1, 1], [1, 1]] }] });
Charts.line(canvas, { series: [{ points: [[0, 1e-12], [1, 1e12]] }] });
Charts.sparkline(canvas, []);
Charts.sparkline(canvas, [3, 3, 3, 3]);
assert(Charts.fmt(NaN) === "—", "fmt(NaN) 应为占位符");
assert(Charts.fmt(12345) === "12345.0", `五位数应保持定点: ${Charts.fmt(12345)}`);
assert(Charts.fmt(1.2e6).includes("e+6"), `六位数应转科学计数: ${Charts.fmt(1.2e6)}`);
assert(Charts.fmt(0.000012) === "1.20e-5", `极小值应转科学计数: ${Charts.fmt(0.000012)}`);
console.log("training-ui harness: ALL PASS");
})().catch((err) => {
console.error("training-ui harness FAILED:", err && err.stack || err);
process.exit(1);
});
+286
View File
@@ -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()
+120
View File
@@ -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)
+83
View File
@@ -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"
+86
View File
@@ -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()
+666
View File
@@ -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 上的路径断言有明确前置条件
+43
View File
@@ -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