holy fuking shit

This commit is contained in:
Horldsence
2026-09-12 01:56:34 +08:00
parent 36f354736c
commit d975309a81
83 changed files with 2748 additions and 896 deletions
+6 -6
View File
@@ -62,7 +62,7 @@ class BayesLoopAcceptanceTests(unittest.TestCase):
def test_sixteen_sources_stop_by_count_bound(self):
sources = random_omni_scene(seed=6, count=16, config=CONFIG)
summary, report, simulator = _run_scene(sources)
summary, report, _ = _run_scene(sources)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(summary["stop_reason"], "sixteen_cleared")
@@ -72,7 +72,7 @@ class BayesLoopAcceptanceTests(unittest.TestCase):
def test_empty_arena_all_absent(self):
"""0 源:每个频道都必须由 PF 排除盘证明缺席后正常退出。"""
summary, report, simulator = _run_scene([], config=NO_PERSIST)
summary, report, _ = _run_scene([], config=NO_PERSIST)
self.assertTrue(summary["completed"])
self.assertEqual(summary["stop_reason"], "all_resolved")
self.assertEqual(sorted(summary["absent_channels"]),
@@ -89,7 +89,7 @@ class BayesLoopAcceptanceTests(unittest.TestCase):
sources = [Source(c + 1, 1800.0 * math.cos(math.pi / 6 + 2 * math.pi * c / 4),
1800.0 * math.sin(math.pi / 6 + 2 * math.pi * c / 4),
1000.0) for c in range(4)]
summary, report, simulator = _run_scene(sources, config=NO_PERSIST)
summary, report, _ = _run_scene(sources, config=NO_PERSIST)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(sorted(summary["cleared_channels"]), truth)
@@ -112,7 +112,7 @@ class BayesLoopAcceptanceTests(unittest.TestCase):
def test_transport_fault_retries_keep_session_clean(self):
sources = random_omni_scene(seed=5, count=10, config=CONFIG)
script = [("drop_before", 1)] # 首个请求丢弃一次:幂等重试路径
summary, report, simulator = _run_scene(sources, fault_script=script)
summary, report, _ = _run_scene(sources, fault_script=script)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(sorted(summary["cleared_channels"]), truth)
@@ -141,9 +141,9 @@ class BayesLoopAcceptanceTests(unittest.TestCase):
def test_cli_strategy_registry(self):
"""CLI --strategy 注册表:bayes/seven 用七站,grid 用 49 点网格。"""
import mcm26b.cli as cli
from mcm26b import cli
from mcm26b.coverage import coverage_points
from mcm26b.planner.seven import SevenBatch, seven_points
from mcm26b.planner.seven import seven_points
self.assertIs(cli.Q3_STRATEGIES["bayes"], BayesLoop)
for name, loop_cls in cli.Q3_STRATEGIES.items():
simulator = LocalSimulator([], robot_id="T001", config=CONFIG)
+1 -1
View File
@@ -132,7 +132,7 @@ class BeliefDirectionTests(TestCase):
def test_near_shrinks_to_five_meters(self):
belief = BeliefState(CONFIG)
belief.update_measure(2, (100.0, 100.0), NEAR, None)
center, radius = belief.candidate_set(2).mec()
_, radius = belief.candidate_set(2).mec()
self.assertLessEqual(radius, 5.1)
self.assertTrue(belief.candidate_set(2).contains((101.0, 101.0)))
self.assertEqual(belief.channels[2].status, ChannelStatus.DETECTED)
+3 -2
View File
@@ -16,7 +16,8 @@ class GeometryTests(unittest.TestCase):
self.assertEqual(result['status'], 'polygon')
self.assertEqual(len(result['vertices']), 3)
self.assertAlmostEqual(result['diameter_m'], 40, places=7)
self.assertAlmostEqual(result['minimum_enclosing_circle']['radius_m'], 40/math.sqrt(3), places=7)
self.assertAlmostEqual(result['minimum_enclosing_circle']['radius_m'],
40/math.sqrt(3), places=7)
self.assertFalse(result['diameter_circle']['covers'])
self.assertFalse(result['guaranteed_clearable_at_mec_center'])
@@ -84,7 +85,7 @@ class GeometryTests(unittest.TestCase):
c, r = smallest_enclosing_circle([(0,0),(1,0),(3,0)])
self.assertAlmostEqual(r, 1.5)
def test_random_bounded_truth_containment_and_monotonicity(self):
def test_random_truth_containment_and_monotonic(self):
rng = random.Random(20260910)
for _ in range(100):
target = (rng.uniform(-300,300), rng.uniform(-300,300))
+16 -10
View File
@@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[1]
class CertificateTests(unittest.TestCase):
def test_incremental_circle_against_exhaustive_reference(self):
def test_incremental_circle_matches_reference(self):
rng = random.Random(271828)
for _ in range(250):
points = [(rng.uniform(-20, 20), rng.uniform(-20, 20))
@@ -25,7 +25,7 @@ class CertificateTests(unittest.TestCase):
self.assertLess(distance(center, expected_c), 1e-6)
self.assertTrue(all(distance(center, p) <= radius for p in points))
def test_circle_large_cocircular_duplicate_and_collinear_sets(self):
def test_circle_cocircular_duplicate_and_collinear(self):
# More than Python's recursion limit, with analytically known answers.
points = [(1e6+30*math.cos(i*math.tau/1500),
-2e6+30*math.sin(i*math.tau/1500)) for i in range(1500)]
@@ -37,15 +37,16 @@ class CertificateTests(unittest.TestCase):
self.assertEqual(c, (749.5, 1499))
self.assertEqual(smallest_enclosing_circle([(3, 4)]*10), ((3, 4), 0))
def test_witness_and_clearance_margin_from_saved_observations(self):
def test_witness_and_clearance_margin_from_saved(self):
path = next((ROOT/'output/q1/practice').glob('*_observations.json'))
rows = json.loads(path.read_text(encoding='utf-8'))['measurements']
result = solve([Measurement(**m) for m in rows])
circle = result['diameter_circle']
self.assertFalse(circle['covers'])
self.assertIn(circle['witness_vertex'], result['vertices'])
self.assertAlmostEqual(distance(circle['witness_vertex'], circle['center'])-circle['radius_m'],
circle['max_excess_m'])
self.assertAlmostEqual(
distance(circle['witness_vertex'], circle['center'])-circle['radius_m'],
circle['max_excess_m'])
for i in circle['outside_vertex_indices']:
self.assertGreater(distance(result['vertices'][i], circle['center']),
circle['radius_m']+circle['coverage_tolerance_m'])
@@ -61,7 +62,7 @@ class CertificateTests(unittest.TestCase):
self.assertTrue(r['diameter_circle']['covers'])
self.assertIsNone(r['diameter_circle']['witness_vertex'])
def test_first_sector_disk_covers_all_angles_and_distances(self):
def test_first_sector_disk_covers_all_angles(self):
for angle in [0, 359.9, -180]:
for delta in [0, 1, 1.005, 45]:
m = Measurement(-800, 1200, angle, delta)
@@ -72,11 +73,15 @@ class CertificateTests(unittest.TestCase):
for k in range(11):
phi = math.radians(angle-delta+2*delta*j/20)
p = (m.x+150*k*math.cos(phi), m.y+150*k*math.sin(phi))
self.assertLessEqual(distance(p, enclosing['center']), enclosing['radius_m']+1e-9)
self.assertLessEqual(
distance(p, enclosing['center']),
enclosing['radius_m']+1e-9)
if candidate:
for t in range(8):
q = (candidate['center'][0]+candidate['radius_m']*math.cos(t*math.tau/8),
candidate['center'][1]+candidate['radius_m']*math.sin(t*math.tau/8))
q = (candidate['center'][0]
+ candidate['radius_m']*math.cos(t*math.tau/8),
candidate['center'][1]
+ candidate['radius_m']*math.sin(t*math.tau/8))
self.assertLessEqual(distance(p, q), 1000+1e-9)
if delta <= 1.005:
self.assertTrue(cert['candidate_nonempty_certified'])
@@ -100,7 +105,8 @@ class CertificateTests(unittest.TestCase):
self.assertEqual(outputs[0]['error_policy']['effective_half_angles_deg'], [1, 1, 1])
self.assertEqual(outputs[1]['error_policy']['effective_half_angles_deg'], [1.005]*3)
self.assertGreater(outputs[1]['diameter_m'], outputs[0]['diameter_m'])
bad = subprocess.run(base+['--rounding-margin-deg', '-1'], cwd=ROOT, capture_output=True)
bad = subprocess.run(base+['--rounding-margin-deg', '-1'], cwd=ROOT,
capture_output=True, check=False)
self.assertNotEqual(bad.returncode, 0)
+14 -14
View File
@@ -90,7 +90,7 @@ class LayoutTests(unittest.TestCase):
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):
def test_mask_nonempty_and_matches_candidate_slots(self):
env = _env(problem=3)
env.reset(seed=7)
for _ in range(6):
@@ -98,7 +98,7 @@ class LayoutTests(unittest.TestCase):
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(
_obs, _r, term, trunc, _info = env.step(
int(env._loop.heuristic_action(env._current_candidates)))
if term or trunc:
break
@@ -114,7 +114,7 @@ class LayoutTests(unittest.TestCase):
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):
def test_build_candidates_keeps_clear_and_locate(self):
host = _CandidateHost(detected=[1, 2, 3, 4, 17, 18, 19, 20],
certified=[17, 18, 19, 20])
candidates = host.build_candidates()
@@ -130,7 +130,7 @@ class LayoutTests(unittest.TestCase):
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):
def test_build_candidates_fallback_covers_slots(self):
channels = list(range(17, 21))
host = _CandidateHost(detected=channels, blocked=channels)
candidates = host.build_candidates()
@@ -151,11 +151,11 @@ class LayoutTests(unittest.TestCase):
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)
_, _ = env.reset(seed=11)
steps = 0
while True:
action = env._loop.heuristic_action(env._current_candidates)
obs, r, term, trunc, info = env.step(action)
_, _, term, trunc, info = env.step(action)
steps += 1
if term or trunc:
break
@@ -170,7 +170,7 @@ class HeuristicLoopTests(unittest.TestCase):
env.reset(seed=21)
while True:
action = env._loop.heuristic_action(env._current_candidates)
obs, r, term, trunc, info = env.step(action)
_, _, term, trunc, info = env.step(action)
if term or trunc:
break
self.assertTrue(term)
@@ -185,8 +185,8 @@ class HeuristicLoopTests(unittest.TestCase):
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)
_virtual_before = env._client.state.virtual_time_s
_, r, term, trunc, info = env.step(action)
outcome = env._last_outcome
if outcome.invalid:
self.assertAlmostEqual(r, -reward_cfg.invalid, places=6)
@@ -206,13 +206,13 @@ class HeuristicLoopTests(unittest.TestCase):
if term or trunc:
break
def test_invalid_action_penalized_without_simulator_interaction(self):
def test_invalid_action_penalized_without_sim_call(self):
env = _env(problem=3, stage_index=0, max_episode_steps=50)
env.reset(seed=3)
mask = env.action_masks()
invalid_action = int(numpy.argmin(mask))
virtual_before = env._client.state.virtual_time_s
obs, r, term, trunc, info = env.step(invalid_action)
_, r, _, _, _ = env.step(invalid_action)
self.assertAlmostEqual(r, -env.reward.invalid, places=6)
self.assertAlmostEqual(env._client.state.virtual_time_s,
virtual_before, places=6)
@@ -228,7 +228,7 @@ class LowLevelTests(unittest.TestCase):
goal = tuple(info["goal"])
total = 0
for _ in range(60):
obs, r, term, trunc, info = env.step(env.expert_action())
obs, _, term, trunc, _ = env.step(env.expert_action())
total += 1
if term or trunc:
break
@@ -237,7 +237,7 @@ class LowLevelTests(unittest.TestCase):
efficiency = straight / (total * env.step_m)
self.assertGreater(efficiency, 0.7, "专家路径效率应接近直线")
def test_executor_straightline_is_goal_only_and_policy_path_snaps(self):
def test_executor_straightline_is_goal_only(self):
executor = LowLevelExecutor(policy=None)
self.assertEqual(executor.roll((0.0, 0.0), (500.0, 200.0)),
[(500.0, 200.0)])
@@ -257,7 +257,7 @@ class CurriculumTests(unittest.TestCase):
self.assertLessEqual(prev.bearing_error_half_deg,
nxt.bearing_error_half_deg)
def test_training_config_defaults_to_production_fidelity(self):
def test_training_config_defaults_to_production(self):
config = training_config(CONFIG, STAGES[3])
self.assertEqual(config.field.channel_max, 20)
self.assertEqual(config.field.bearing_error_half_deg, 1.0)
+244 -16
View File
@@ -1,10 +1,13 @@
"""高层训练落盘保证:checkpoint、SIGTERM 优雅保存、worker 信号屏蔽。
"""高层训练落盘保证:checkpoint、控制文件优雅停止、信号兜底、worker 信号屏蔽。
背景:训练前端的「停止」向整个进程组发 SIGTERM,而旧脚本只在 ``learn()``
正常返回后才保存,导致"停一下就全丢"。这里把三条保证固定成回归测试:
背景:训练前端的「停止」以前直接向进程组发 SIGTERM,而旧脚本只在 ``learn()``
正常返回后才保存,导致"停一下就全丢";在 Windows 上 ``os.killpg`` 更是直接
``AttributeError``。现在停止/暂停走**控制文件**(跨平台),信号只作兜底。这里把
保证固定成回归测试:
1. 定期 checkpoint 可续训(zip + 同名元数据);
2. 进程组收到 SIGTERM 后仍能保存最终模型与元数据;
3. subproc worker 忽略停止信号,不抢在主进程收尾之前退出。
2. 控制文件发出 stop 后仍能保存最终模型与元数据(Windows 同样成立);
3. POSIX 下进程组 SIGTERM 也能优雅收尾;
4. subproc worker 忽略停止信号,不抢在主进程收尾之前退出。
"""
from __future__ import annotations
@@ -15,11 +18,14 @@ import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
import pytest
from mcm26b.process_control import spawn_kwargs, terminate_process_tree, write_control
def _train_module():
"""按路径加载训练脚本(不触发 torch 导入,torch 只在函数内部惰性导入)。"""
@@ -39,13 +45,20 @@ def _require_rl():
class _Run:
"""一次子进程训练:stdout 走文件,避免管道写满把子进程卡死。"""
"""一次子进程训练:stdout 走文件,避免管道写满把子进程卡死。
与训练前端一致:用控制文件请求暂停/停止,进程组隔离走
:func:`mcm26b.process_control.spawn_kwargs`(POSIX ``setsid`` /
Windows ``CREATE_NEW_PROCESS_GROUP``)。
"""
def __init__(self, run_dir: Path, *extra: str):
script = Path(__file__).parents[1] / "scripts" / "train_hrl_high.py"
self.run_dir = run_dir
self.log_path = run_dir / "train_stdout.log"
self.log_file = self.log_path.open("w", encoding="utf-8")
self.control_path = run_dir / "control.json"
write_control(self.control_path)
cmd = [sys.executable, str(script),
"--problem", "3", "--stage", "1", "--timesteps", "200000",
"--seed", "7", "--envs", "2", "--vec-env", "subproc",
@@ -53,13 +66,28 @@ class _Run:
"--reward-profile", "time", "--eval-episodes", "1",
"--rollout-steps", "32", "--batch-size", "32", "--n-epochs", "1",
"--max-wall-s", "600", "--force", "--verbose", "0",
"--control-file", str(self.control_path),
"--model", str(run_dir / "q3.zip"), *extra]
self.proc = subprocess.Popen(
self.proc = subprocess.Popen( # pylint: disable=consider-using-with
cmd, stdout=self.log_file, stderr=subprocess.STDOUT, text=True,
start_new_session=True)
**spawn_kwargs())
def checkpoints(self) -> list[Path]:
return sorted((self.run_dir / "checkpoints").glob("q3_ckpt_*.zip"))
"""历史 checkpoint(``<stem>_<步数>.zip``)。
刻意排除滚动恢复点 ``<stem>_latest.zip``:它随写随覆盖,不是"第几个
checkpoint",混进来会让序号断言失去意义。
"""
found = (self.run_dir / "checkpoints").glob("q3_ckpt_[0-9]*.zip")
return sorted(path for path in found
if path.stem.rsplit("_", 1)[-1].isdigit())
def recovery(self) -> Path:
return self.run_dir / "checkpoints" / "q3_ckpt_latest.zip"
def resume_hint(self) -> dict:
return json.loads((self.run_dir / "resume.json").read_text(
encoding="utf-8"))
def wait_for(self, predicate, timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
@@ -71,8 +99,35 @@ class _Run:
time.sleep(0.4)
return predicate()
def logs(self) -> str:
try:
return self.log_path.read_text(encoding="utf-8", errors="replace")
except OSError: # pragma: no cover - 文件被并发写时的极端情况
return ""
def pause(self) -> None:
write_control(self.control_path, paused=True)
def resume(self) -> None:
write_control(self.control_path, paused=False)
def _finish(self) -> str:
self.log_file.close()
return self.logs()
def stop(self) -> str:
"""复现训练前端的停止语义:对整个进程组发 SIGTERM。"""
"""复现训练前端的停止语义:写控制文件请求优雅收尾(跨平台)。"""
write_control(self.control_path, stop=True)
forced = terminate_process_tree(self.proc, grace_s=240.0)
if forced:
self.log_file.close()
pytest.fail("控制文件停止请求后训练进程未在 240s 内退出(优雅停止失败)")
return self._finish()
def stop_by_signal(self) -> str:
"""POSIX 兜底路径:对整个进程组发 SIGTERM(Windows 不支持 killpg)。"""
if os.name == "nt": # pragma: no cover - 仅在 Windows 上走到
pytest.skip("Windows 无 os.killpg,信号路径不适用")
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
@@ -84,8 +139,12 @@ class _Run:
self.proc.wait(timeout=30)
self.log_file.close()
pytest.fail("SIGTERM 后训练进程未在 240s 内退出(优雅停止失败)")
return self._finish()
def hard_kill(self) -> None:
"""模拟"急停":立即强杀整棵进程树,不给训练脚本任何收尾机会。"""
terminate_process_tree(self.proc, grace_s=0.0)
self.log_file.close()
return self.log_path.read_text(encoding="utf-8", errors="replace")
def _run_dir(tmp_path: Path) -> Path:
@@ -119,7 +178,7 @@ def test_worker_ignores_stop_signals_but_main_does_not(monkeypatch):
class _Worker:
name = "SpawnProcess-3"
monkeypatch.setattr(multiprocessing, "current_process", lambda: _Worker())
monkeypatch.setattr(multiprocessing, "current_process", _Worker)
try:
module._ignore_stop_signals_in_worker()
assert signal.getsignal(signal.SIGTERM) == signal.SIG_IGN
@@ -131,7 +190,7 @@ def test_worker_ignores_stop_signals_but_main_does_not(monkeypatch):
class _Main:
name = "MainProcess"
monkeypatch.setattr(multiprocessing, "current_process", lambda: _Main())
monkeypatch.setattr(multiprocessing, "current_process", _Main)
module._ignore_stop_signals_in_worker()
assert signal.getsignal(signal.SIGTERM) == original
@@ -168,14 +227,139 @@ def test_policy_hyperparams_records_effective_values():
assert module._policy_hyperparams(object())["n_steps"] == 0
def test_control_poll_state_machine(tmp_path):
"""控制文件轮询状态机:抽样读盘、暂停阻塞、stop 打断、暂停时长累计。"""
module = _train_module()
path = tmp_path / "control.json"
write_control(path, paused=True)
poll = module._ControlPoll(path, poll_every=1, poll_s=0.05)
assert poll.poll().paused is True
events = []
def _resume_later():
time.sleep(0.15)
write_control(path, paused=False)
threading.Thread(target=_resume_later, daemon=True).start()
stopped = poll.wait_while_paused(lambda: events.append("pause"),
lambda: events.append("resume"))
assert stopped is False
assert events == ["pause", "resume"]
assert poll.paused_total_s >= 0.1
# 暂停期间收到 stop:wait 必须返回 True,让调用方转入收尾
write_control(path, paused=True)
def _stop_later():
time.sleep(0.15)
write_control(path, stop=True)
threading.Thread(target=_stop_later, daemon=True).start()
assert poll.wait_while_paused(lambda: None, lambda: None) is True
assert poll.poll().stop is True
def test_atomic_model_save_keeps_previous_snapshot_on_failure(tmp_path):
"""保存途中失败/被杀:目标路径仍是上一份完整快照,不会出现半截 zip。"""
module = _train_module()
target = tmp_path / "q3_ckpt_latest.zip"
target.write_bytes(b"OLD-COMPLETE")
class _FailingModel:
def save(self, path):
Path(path).write_bytes(b"PARTIAL")
raise RuntimeError("磁盘写满")
with pytest.raises(RuntimeError):
module._save_model_atomic(_FailingModel(), target)
assert target.read_bytes() == b"OLD-COMPLETE"
leftovers = [p.name for p in tmp_path.iterdir() if p.name != target.name]
assert leftovers and all(name.startswith(".tmp-") for name in leftovers)
def test_write_json_atomic_replaces_in_one_step(tmp_path):
module = _train_module()
path = tmp_path / "resume.json"
module._write_json_atomic(path, {"model": "a.zip", "timesteps": 1})
assert json.loads(path.read_text(encoding="utf-8"))["timesteps"] == 1
module._write_json_atomic(path, {"model": "b.zip", "timesteps": 2})
assert json.loads(path.read_text(encoding="utf-8"))["model"] == "b.zip"
assert not list(tmp_path.glob(".tmp-*"))
def test_prune_checkpoints_never_deletes_recovery_point(tmp_path):
"""滚动恢复点与临时文件不参与历史清理。"""
module = _train_module()
directory = tmp_path / "checkpoints"
directory.mkdir()
latest = directory / "q3_ckpt_latest.zip"
latest.write_bytes(b"RECOVERY")
(directory / ".tmp-q3_ckpt_000000009.zip").write_bytes(b"PARTIAL")
for index in range(5):
(directory / f"q3_ckpt_{index:09d}.zip").write_bytes(b"x")
module._prune_checkpoints(directory, "q3_ckpt", 1)
assert latest.exists() and latest.read_bytes() == b"RECOVERY"
assert (directory / ".tmp-q3_ckpt_000000009.zip").exists()
# --------------------------------------------------------------- 集成回归
def test_recovery_point_exists_before_training_and_is_loadable(tmp_path):
"""起步基线:训练刚起就有可加载、可续训的产物(--checkpoint-every 0 也成立)。"""
_require_rl()
from sb3_contrib import MaskablePPO
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "0")
try:
assert run.wait_for(lambda: run.recovery().exists(), 120.0), \
"起步恢复点未落盘"
loaded = MaskablePPO.load(str(run.recovery()))
assert loaded.num_timesteps >= 0
hint = run.resume_hint()
assert Path(hint["model"]) == run.recovery()
assert hint["timesteps_semantics"] == "additional"
finally:
run.stop()
def test_hard_kill_still_leaves_resumable_recovery_point(tmp_path):
"""急停(强杀、无收尾)后仍能续训:滚动恢复点 + resume.json 都在且可加载。"""
_require_rl()
from sb3_contrib import MaskablePPO
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "0",
"--recovery-every", "32")
try:
assert run.wait_for(lambda: run.recovery().exists(), 120.0), \
"起步恢复点未落盘"
first = run.recovery().stat().st_mtime_ns
assert run.wait_for(
lambda: run.recovery().stat().st_mtime_ns != first, 300.0), \
"滚动恢复点未按 --recovery-every 刷新"
assert run.proc.poll() is None, "训练已自行结束,无法验证急停"
finally:
run.hard_kill() # 急停:强杀整棵进程树,没有任何 Python 收尾
hint = run.resume_hint()
assert Path(hint["model"]).is_file(), hint
assert Path(hint["model"]) == run.recovery(), hint
loaded = MaskablePPO.load(str(run.recovery()))
assert loaded.num_timesteps >= 32, loaded.num_timesteps
metadata = json.loads(run.recovery().with_suffix(".json").read_text(
encoding="utf-8"))
assert metadata["recovery"] is True
assert metadata["checkpoint_reason"] in ("baseline", "interval")
# 急停没有 graceful 收尾,所以不会有"stopped_by_signal"的最终模型
assert not (run.run_dir / "q3.zip").exists()
def test_checkpoint_is_written_and_loadable(tmp_path):
_require_rl()
from sb3_contrib import MaskablePPO
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
try:
assert run.wait_for(lambda: bool(run.checkpoints()), 300.0), \
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内产出 checkpoint"
finally:
run.stop()
@@ -193,16 +377,60 @@ def test_checkpoint_is_written_and_loadable(tmp_path):
assert (hyper["n_steps"], hyper["batch_size"], hyper["n_epochs"]) == (32, 32, 1)
def test_control_file_stop_saves_model(tmp_path):
"""控制文件 stop 是训练前端的默认停止路径,Windows 上同样必须成立。"""
_require_rl()
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
model_path = run.run_dir / "q3.zip"
try:
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内进入 rollout 阶段"
assert run.proc.poll() is None, "训练进程提前退出,无法验证优雅停止"
finally:
output = run.stop()
assert run.proc.returncode == 0, \
f"控制文件停止应正常退出,实际 {run.proc.returncode}\n{output}"
assert model_path.exists(), "控制文件停止后最终模型未落盘"
metadata = json.loads(model_path.with_suffix(".json").read_text(
encoding="utf-8"))
assert metadata["stopped_by_signal"] is True
assert metadata["stop_signal"] == "control_file"
assert metadata["checkpoint_files"], "元数据应记录已产出的 checkpoint"
assert not (run.run_dir / "training_failure.json").exists()
assert "saved_after_stop" in output
def test_control_file_pause_then_resume_keeps_training_alive(tmp_path):
"""暂停是协作式的:进程存活、停在 step 边界,continue 后照常收尾。"""
_require_rl()
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
try:
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内进入 rollout 阶段"
run.pause()
assert run.wait_for(lambda: '"event": "paused"' in run.logs(), 120.0), \
"训练脚本未在 120s 内进入暂停"
assert run.proc.poll() is None, "暂停不应结束进程"
run.resume()
assert run.wait_for(lambda: '"event": "resumed"' in run.logs(), 120.0), \
"训练脚本未在 120s 内恢复"
finally:
run.stop()
assert run.proc.returncode == 0
@pytest.mark.skipif(os.name == "nt", reason="Windows 没有 os.killpg,信号兜底路径不适用")
def test_sigterm_saves_model_instead_of_losing_the_run(tmp_path):
_require_rl()
run = _Run(_run_dir(tmp_path), "--checkpoint-every", "64")
model_path = run.run_dir / "q3.zip"
try:
assert run.wait_for(lambda: bool(run.checkpoints()), 300.0), \
assert run.wait_for(run.checkpoints, 300.0), \
"训练未在 300s 内进入 rollout 阶段"
assert run.proc.poll() is None, "训练进程提前退出,无法验证优雅停止"
finally:
output = run.stop()
output = run.stop_by_signal()
assert run.proc.returncode == 0, \
f"优雅停止应正常退出,实际 {run.proc.returncode}\n{output}"
+2 -3
View File
@@ -1,7 +1,6 @@
"""低层导航运动学与训练入口的行为回归;不验证策略收益。"""
import importlib.util
import math
from pathlib import Path
import pytest
@@ -59,7 +58,7 @@ def test_expert_reaches_all_200_fixed_seed_targets():
def test_failed_executor_reports_failure_without_free_terminal_move():
class WestPolicy:
def select(self, observation):
def select(self, _observation):
return 4
executor = LowLevelExecutor(WestPolicy(), max_steps=3)
@@ -73,7 +72,7 @@ def test_failed_executor_reports_failure_without_free_terminal_move():
def test_env_and_executor_share_step_transitions():
class EastPolicy:
def select(self, observation):
def select(self, _observation):
return 0
env = LowLevelEnv(max_steps=3)
+3
View File
@@ -75,6 +75,9 @@ def test_time_only_reward_has_no_per_source_shaping():
reason="RL 依赖组未安装")
def test_project_compat_layer_reexports_real_classes():
from mcm26b.hrl.hierarchical_env import HierarchicalEnv
# project 是仓库根部的兼容层:pytest 经 rootdir 把仓库根放进 sys.path,
# 而单独 lint tests/ 时 pylint 的 sys.path 不含仓库根,故此处局部豁免。
# pylint: disable=import-error
from project.env.local_env import HierarchicalEnv as CompatEnv
from project.env.simulator_client import SimulatorClient
from project.policies.high_level_ppo import HighLevelPolicy
+7 -5
View File
@@ -43,21 +43,21 @@ def test_fallback_failure_then_success_records_actual_time_position_and_clear():
assert loop.client.state.channel == 20
assert loop.fallback_log[-1]["attempts"] == 2
assert 20 not in loop.blocked_probes
assert loop.anomalies == []
assert not loop.anomalies
finally:
loop.client.exit()
def test_invalid_policy_output_is_logged_and_replaced_by_valid_action():
class InvalidPolicy:
def select(self, observation, mask):
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):
def execute(action, candidates): # pylint: disable=unused-argument
executed.append(action)
loop.stop_reason = "test_complete"
@@ -78,8 +78,10 @@ def test_opportunistic_measure_precedes_candidate_snapshot():
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 []):
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:
+2 -2
View File
@@ -134,7 +134,7 @@ class StepTimingTests(unittest.TestCase):
def test_steps_recorded_with_virtual_breakdown(self):
sources = random_omni_scene(seed=5, count=2)
summary, report, _ = _run_scene(sources)
summary, _, _ = _run_scene(sources)
steps = summary["steps"]
self.assertEqual(steps[0]["action"], "enter")
self.assertEqual(steps[-1]["action"], "exit")
@@ -228,7 +228,7 @@ class SimulatorVirtualCapTests(unittest.TestCase):
class AbortFinalizationTests(unittest.TestCase):
"""断连超过重试上限:仍须 /exit、产出未完成 summary,不得静默崩溃。"""
def test_transport_failure_aborts_with_summary_and_exit(self):
def test_transport_failure_aborts_with_summary(self):
"""首个检测动作三次断连:run() 正常返回,/exit 成功,stop_reason 留档。"""
summary, _, _ = _run_scene(
random_omni_scene(seed=5, count=2),
+8 -8
View File
@@ -97,7 +97,7 @@ class ProbeLadderGeometryTests(unittest.TestCase):
loop.blocked_probes[7] = [blocked_point]
return loop._next_probe(region, 7)
def test_antipode_reflects_blocked_probe_through_center(self):
def test_antipode_reflects_blocked_probe_in_center(self):
"""圆心为原点、原探点 (200,0):对跖复核应返回 (-200,0)。"""
region = CandidateRegion.disk((0.0, 0.0), 100.0, 192) # safe≈900>=200
probe = self._probe_after_block(region, (200.0, 0.0))
@@ -113,7 +113,7 @@ class ProbeLadderGeometryTests(unittest.TestCase):
self.assertAlmostEqual(math.hypot(probe[0], probe[1]), safe, places=6)
self.assertLess(probe[0], 0.0, "应位于原探点的对跖方向")
def test_degenerate_center_block_walks_full_safe_radius(self):
def test_degenerate_center_block_walks_safe_radius(self):
"""被遮挡探点恰在圆心:沿 +x 走满安全盘半径,不再钳到 1m。"""
region = CandidateRegion.disk((0.0, 0.0), 100.0, 192)
probe = self._probe_after_block(region, (0.0, 0.0))
@@ -125,7 +125,7 @@ class ProbeLadderGeometryTests(unittest.TestCase):
class MixedLoopAcceptanceTests(unittest.TestCase):
def test_mixed_ten_sources_all_cleared(self):
sources = mixed_scene(seed=5, count=10, directional=3)
summary, report, simulator, _, loop = _run_scene(sources)
summary, report, simulator, _, _ = _run_scene(sources)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"])
self.assertEqual(summary["stop_reason"], "all_resolved")
@@ -155,7 +155,7 @@ class MixedLoopAcceptanceTests(unittest.TestCase):
self.assertEqual(sorted(simulator.cleared), truth)
self.assertEqual(report["mismatches"], [])
def test_outward_boundary_directional_source_not_missed(self):
def test_outward_boundary_directional_not_missed(self):
"""朝向背离圆心、r_eff=1000 的近边界源:覆盖保证仍发现并清除。"""
source = Source(channel=7, x=1790.0, y=0.0, r_eff_m=1000.0,
directional=True, heading_deg=0.0)
@@ -169,8 +169,8 @@ class MixedLoopAcceptanceTests(unittest.TestCase):
"""固定批量种子回归:任何种子下不得全清失败或破坏守恒性。"""
for seed in range(5, 10):
sources = mixed_scene(seed=seed, count=10, directional=4)
summary, report, simulator, _, _ = _run_scene(sources,
config=SMALL_CAP)
summary, report, _, _, _ = _run_scene(sources,
config=SMALL_CAP)
truth = sorted(s.channel for s in sources)
self.assertTrue(summary["completed"], f"seed={seed}")
self.assertEqual(sorted(summary["cleared_channels"]), truth,
@@ -194,7 +194,7 @@ class JointConstraintBaselineTests(unittest.TestCase):
for witness in region.witnesses(300.0):
self.assertTrue(region.contains(witness, tol_m=0.05))
def test_directional_source_retained_through_blocked_probes(self):
def test_directional_source_kept_after_blocked_probes(self):
"""端到端保留性:定向源被遮挡若干次后仍被清除,不因 no_signal 误删。"""
source = Source(channel=3, x=600.0, y=800.0, r_eff_m=1500.0,
directional=True, heading_deg=200.0)
@@ -261,7 +261,7 @@ class FallbackDisciplineTests(unittest.TestCase):
cells = fallback_clear_cells(region.polygon, CONFIG)
self.assertLessEqual(len(cells), CONFIG.search.fallback_max_cells)
def test_fallback_exhaustion_records_anomaly_without_deletion(self):
def test_fallback_exhaustion_records_anomaly(self):
"""后备全失败(空域):守恒性异常留痕,且不删除空间候选。"""
simulator = LocalSimulator([], robot_id="T001", config=CONFIG)
client = SimulatorClient("T001", config=CONFIG,
-1
View File
@@ -13,7 +13,6 @@ from dataclasses import replace
from random import Random
from mcm26b.config import ProblemConfig
from mcm26b.geometry.localization import smallest_enclosing_circle
from mcm26b.particles import ChannelParticles, angular_gap
from mcm26b.protocol.rules import DIRECTION, NEAR, NO_SIGNAL
from mcm26b.planner.seven import seven_points
+2 -2
View File
@@ -31,7 +31,7 @@ def _k1_region() -> CandidateRegion:
class SelectionGuaranteeTests(TestCase):
def test_selected_point_receives_every_sector_candidate(self):
def test_selected_point_gets_every_sector_candidate(self):
"""安全盘内选点对解析角域 K1 内任意候选源的距离 <= 1000m。
直接对截断角域 K1 = Ω ∩ W(s1, θ±δ) ∩ B(s1,1500) 稠密采样验证,
@@ -113,7 +113,7 @@ class ClearCertificateTests(TestCase):
class SelectSecondPointTests(TestCase):
def test_plan_lies_in_analytic_safe_disk_or_is_equivalent(self):
def test_plan_in_analytic_safe_disk_is_equivalent(self):
"""P3 入口输出与 geometry 解析证书一致(或提供同等保证)。"""
simulator = LocalSimulator([], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator))
+1 -1
View File
@@ -38,7 +38,7 @@ def test_seven_stations_cannot_prove_directional_absence():
def test_stopped_opportunistic_measure_is_serial_and_never_repeats_location():
sim, client, loop = setup([Source(1, 800, 200, 1500)])
_, client, loop = setup([Source(1, 800, 200, 1500)])
client.enter()
loop._record_measure(1, (0, 0), client.measure((0, 0), 1))
client.measure((600, 0), 2)
+4 -2
View File
@@ -40,8 +40,10 @@ class PracticeReplayTests(unittest.TestCase):
self.assertEqual(data['source'],'official_practice')
self.assertIsNone(data['ground_truth'])
directions=[e for e in events if e['response'].get('measure_result')=='direction']
self.assertEqual(directions[0]['request']['position'],directions[1]['request']['position'])
self.assertEqual(directions[0]['response']['svd_deg'],directions[1]['response']['svd_deg'])
self.assertEqual(directions[0]['request']['position'],
directions[1]['request']['position'])
self.assertEqual(directions[0]['response']['svd_deg'],
directions[1]['response']['svd_deg'])
if __name__=='__main__': unittest.main()
+9 -9
View File
@@ -45,7 +45,7 @@ class FaultRecoveryTests(unittest.TestCase):
outcome = client.measure((0, 0), 1)
self.assertEqual(outcome.virtual_time_s, 5.0)
# measure 只被服务器执行了一次:请求发出两次,但虚拟时刻只推进一次。
measure_bodies = [body for body in faults.sent_requests[1:]]
measure_bodies = list(faults.sent_requests[1:])
self.assertEqual(len(measure_bodies), 2, "应发出原始请求+一次重试")
first, retry = (json.loads(b) for b in measure_bodies)
self.assertEqual(first["request_id"], retry["request_id"],
@@ -56,22 +56,22 @@ class FaultRecoveryTests(unittest.TestCase):
def test_drop_before_apply_is_safe_to_retry(self):
"""连接失败(未送达)后重试成功,无残留效果。"""
client, faults, simulator = self._client(["drop_before", None])
client, _, simulator = self._client(["drop_before", None])
client.enter()
client.measure((100, 0), 1)
self.assertEqual(simulator.virtual_time_s, 25.0) # 100/5 移动 + 5 检测
self.assertEqual(client.state.virtual_time_s, 25.0)
def test_server_500_retried_with_same_id(self):
client, faults, simulator = self._client([None, ("status", 500), None, None])
client, _, _ = self._client([None, ("status", 500), None, None])
client.enter()
client.measure((0, 0), 1)
self.assertEqual(client.state.virtual_time_s, 5.0)
def test_exhausted_retries_raise_and_leave_state_clean(self):
# enter 放行后,measure 连续注入断连直至超过 max_attempts=3。
client, faults, simulator = self._client([None, "drop_before", "drop_before",
"drop_before"])
client, _, simulator = self._client([None, "drop_before", "drop_before",
"drop_before"])
client.enter()
with self.assertRaises(TransportError):
client.measure((0, 0), 1)
@@ -83,7 +83,7 @@ class FaultRecoveryTests(unittest.TestCase):
self.assertEqual(outcome.virtual_time_s, 5.0)
def test_accepted_false_raises_and_state_untouched(self):
client, faults, simulator = self._client([None])
client, _, _ = self._client([None])
with self.assertRaises(ProtocolError) as ctx:
client.measure((0, 0), 1) # 未 enter -> accepted=false
self.assertEqual(ctx.exception.status, 200)
@@ -103,7 +103,7 @@ class DeadlineBudgetTests(unittest.TestCase):
now[0] += 300.0
self.assertAlmostEqual(client.real_remaining_s, 900.0)
def test_action_blocked_when_reserve_margin_cannot_be_kept(self):
def test_action_blocked_when_reserve_margin_low(self):
now = [0.0]
simulator = LocalSimulator([], robot_id="T001")
client = SimulatorClient("T001", transport=LocalSimTransport(simulator),
@@ -172,8 +172,8 @@ class PracticeReplayTests(unittest.TestCase):
events = [dict(path=e["path"], request=e["request"], response=e["response"])
for e in self._events()]
stats = summarize(events)
directions = [e for e in events
if e["response"].get("measure_result") == "direction"]
_directions = [e for e in events
if e["response"].get("measure_result") == "direction"]
self.assertGreater(stats["measures"], 0)
self.assertEqual(stats["clears"], 0, "该次演练只采集观测,未调用 /clear")
+1 -1
View File
@@ -29,7 +29,7 @@ def test_runtime_code_never_imports_referee():
"print('clean')\n"
)
result = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, cwd=ROOT)
text=True, cwd=ROOT, check=False)
assert result.returncode == 0, result.stderr
assert "clean" in result.stdout
+3 -4
View File
@@ -4,7 +4,6 @@ RL 依赖属于可选组 [dependency-groups] rl;未安装 numpy/gymnasium 时
全部跳过(运行闭环不依赖它们)。
"""
import math
import unittest
from pathlib import Path
@@ -74,13 +73,13 @@ class TrainingEnvironmentTests(unittest.TestCase):
self.assertIsInstance(reward, float)
self.assertFalse(terminated and truncated)
def test_q4_directional_episode_reports_blocked_or_fallback(self):
def test_q4_directional_reports_blocked_or_fallback(self):
env = LocalizationEnv(problem=4, directional_probability=1.0)
obs, info = env.reset(seed=7)
_, info = env.reset(seed=7)
self.assertTrue(info["source"]["directional"])
terminated = truncated = False
for _ in range(env.max_episode_steps + 2):
obs, reward, terminated, truncated, info = env.step(
_, _, terminated, truncated, info = env.step(
env.action_space.sample())
if terminated or truncated:
break
+5 -3
View File
@@ -9,7 +9,7 @@ np = pytest.importorskip('numpy')
pytest.importorskip('gymnasium')
from mcm26b.rl.env import LocalizationEnv
from mcm26b.rl.features import action_mask, build_observation, candidate_actions
from mcm26b.rl.features import build_observation, candidate_actions
def test_training_observation_matches_runtime_channel_belief():
@@ -85,10 +85,12 @@ def test_bc_and_ppo_forward_mapping_agree():
spec.loader.exec_module(module)
torch.set_num_threads(1)
env = LocalizationEnv()
ppo = PPO('MlpPolicy',env,policy_kwargs={'net_arch':[128,128]},device='cpu',n_steps=16,batch_size=8)
ppo = PPO('MlpPolicy',env,policy_kwargs={'net_arch':[128,128]},device='cpu',
n_steps=16,batch_size=8)
bc = module.BCMlp()
state = bc.net.state_dict()
ppo.policy.mlp_extractor.policy_net.load_state_dict({k:v for k,v in state.items() if not k.startswith('4.')})
ppo.policy.mlp_extractor.policy_net.load_state_dict(
{k:v for k,v in state.items() if not k.startswith('4.')})
ppo.policy.action_net.load_state_dict({'weight':state['4.weight'],'bias':state['4.bias']})
x = torch.randn(10,401)
torch.testing.assert_close(bc(x),ppo.policy.action_net(ppo.policy.mlp_extractor.policy_net(x)))
+290 -16
View File
@@ -6,11 +6,16 @@
- SB3 表格解析必须给每一行打上 ``total_timesteps`` 横轴(曾把表格行当独立
点,曲线上出现重复的幽灵点);
- ``metric_offset``/``metric_end`` 游标契约(前端增量拉取依赖它);
- 用一段桩训练脚本真起子进程,验证启动/轮询/停止/退出码全链路。
- 用一段桩训练脚本真起子进程,验证启动/轮询/暂停/继续/停止/退出码全链路。
"""
# pytest 用参数名注入 fixture:`stub_repo`/`client` 这类参数在函数体里不直接
# 引用也是正常用法(它们的作用是 setup/teardown),这里统一放行。
# pylint: disable=unused-argument
from __future__ import annotations
import dataclasses
import http.client
import json
import sys
@@ -22,6 +27,7 @@ import pytest
pytest.importorskip("mcm26b")
from mcm26b import process_control as pc
from mcm26b.training_ui import demo, server as td
from mcm26b.training_ui.schema import (CONFIG_FIELDS, config_schema,
preset_payloads)
@@ -32,7 +38,7 @@ REPO = Path(__file__).resolve().parents[1]
# 最后打印一行元数据。全部行为由参数控制,不依赖 torch/SB3。
STUB_SCRIPT = r'''
"""训练前端测试桩:模拟 SB3 verbose=1 表格 + --progress-json 输出。"""
import argparse, json, signal, sys, time
import argparse, json, os, signal, sys, time
from pathlib import Path
parser = argparse.ArgumentParser()
@@ -41,12 +47,17 @@ 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)
# 默认 sleep 可由环境变量控制:HTTP 端到端用例无法直接追加命令行参数,
# 用小 sleep 拉长运行窗口,才能稳定地测"暂停/继续"。
parser.add_argument("--sleep", type=float,
default=float(os.environ.get("STUB_TRAIN_SLEEP", "0")))
parser.add_argument("--lines", type=int, default=4)
parser.add_argument("--table-first", action="store_true")
parser.add_argument("--progress-json", action="store_true")
parser.add_argument("--crash", action="store_true")
parser.add_argument("--ignore-sigterm", action="store_true")
parser.add_argument("--ignore-control", action="store_true")
parser.add_argument("--control-file", default=None)
args, _extra = parser.parse_known_args()
model = Path(args.model)
@@ -65,6 +76,49 @@ print("NORMAL-LOG-LINE")
sys.stdout.flush()
def read_control():
if not args.control_file or args.ignore_control:
return {}
try:
with open(args.control_file, encoding="utf-8") as handle:
return json.load(handle)
except Exception:
return {}
def control_pause_or_stop(step):
"""返回 True 表示应退出;暂停时阻塞到继续或停止(模拟协作式暂停)。"""
state = read_control()
if state.get("stop"):
print(json.dumps({"event": "stop_requested", "signal": "control_file"}), flush=True)
return True
if state.get("paused"):
print(json.dumps({"event": "paused", "timesteps": step}), flush=True)
while True:
time.sleep(0.05)
state = read_control()
if state.get("stop"):
print(json.dumps({"event": "stop_requested", "signal": "control_file"}),
flush=True)
return True
if not state.get("paused"):
break
print(json.dumps({"event": "resumed", "timesteps": step}), flush=True)
return False
def sleep_with_control(seconds, step):
"""把长 sleep 切成 0.1s 片,保证停止/暂停请求能被及时响应。"""
deadline = time.monotonic() + max(0.0, seconds)
while True:
if control_pause_or_stop(step):
raise SystemExit(0)
remaining = deadline - time.monotonic()
if remaining <= 0:
return
time.sleep(min(0.1, remaining))
def render_table(step, fps):
return (
"-----------------------------\n"
@@ -72,16 +126,15 @@ def render_table(step, fps):
"| ep_len_mean | 9.67 |\n"
"| ep_rew_mean | -3.24 |\n"
"| time/ | |\n"
"| fps | %-8d |\n"
"| total_timesteps | %-8d |\n"
"-----------------------------\n" % (fps, step)
f"| fps | {fps:<8d} |\n"
f"| total_timesteps | {step:<8d} |\n"
"-----------------------------\n"
)
for index in range(args.lines):
if args.sleep:
time.sleep(args.sleep)
step = 32 * (index + 1)
sleep_with_control(args.sleep, step)
fps = 17 + index
progress = {
"event": "progress", "timesteps": step, "n_updates": index + 1,
@@ -153,7 +206,7 @@ def test_schema_matches_train_config_defaults():
assert field["default"] == getattr(defaults, field["key"]), \
f"{field['key']} 默认值与后端不一致"
exposed = {field["key"] for field in schema["fields"]}
missing = set(defaults.__dataclass_fields__) - exposed
missing = {field.name for field in dataclasses.fields(defaults)} - exposed
assert not missing, f"TrainConfig 字段未暴露给界面: {sorted(missing)}"
groups = {group["key"] for group in schema["groups"]}
for field in schema["fields"]:
@@ -227,9 +280,11 @@ class _FakeJob:
self._last_table_steps = None
self._last_json_steps = None
self._last_monitor_sig = None
self.status = "running"
self.config = make_config()
_on_line = td.TrainingJob._on_line
_sync_control_events = td.TrainingJob._sync_control_events
_parse_progress_json = td.TrainingJob._parse_progress_json
_parse_sb3_table = td.TrainingJob._parse_sb3_table
_flush_table = td.TrainingJob._flush_table
@@ -274,9 +329,9 @@ def test_sb3_table_with_separators_emits_one_point_per_table():
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(f"| ep_rew_mean | {-3.0 + index:.2f} |")
job._on_line("| time/ | |")
job._on_line("| total_timesteps | %d |" % steps)
job._on_line(f"| total_timesteps | {steps} |")
job._on_line("-----------------------------")
job._flush_table()
assert [point["timesteps"] for point in job.metrics] == [32, 64, 96], list(job.metrics)
@@ -476,7 +531,7 @@ def test_job_lifecycle_with_stub_script(stub_repo):
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.command = list(job.command) + ["--crash"]
job.start()
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
assert job.status == "failed"
@@ -484,15 +539,200 @@ def test_job_reports_nonzero_exit_code(stub_repo):
assert job.snapshot(log_tail=5)["process"]["tone"] == "bad"
def test_stop_sends_sigterm_and_marks_stopped(stub_repo):
def test_job_command_carries_control_file(stub_repo):
"""控制文件路径必须随命令下发,否则暂停/停止退化成超时强杀。"""
config = make_config(model="output/hrl/ui/test/control/q3.zip")
job = td.TrainingJob(config, control_dir=Path(stub_repo).parent)
assert "--control-file" in job.command
assert str(job.control_path) in job.command
assert job.control_path.parent == Path(stub_repo).parent
def test_stop_gracefully_marks_stopped_without_forced_kill(stub_repo):
config = make_config(model="output/hrl/ui/test/stop/q3.zip")
job = td.TrainingJob(config)
job.command = td.build_command(config) + ["--sleep", "5", "--lines", "50"]
job.command = list(job.command) + ["--sleep", "5", "--lines", "50"]
job.start()
assert wait_until(lambda: job.status == "running", timeout=10)
assert job.stop() is True
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=15)
assert job.status == "stopped"
# 桩脚本读控制文件后主动退出:不应走到强杀分支
assert job.forced_kill is False
def test_pause_and_resume_via_control_file(stub_repo):
config = make_config(model="output/hrl/ui/test/pause/q3.zip")
job = td.TrainingJob(config)
job.command = list(job.command) + ["--sleep", "0.2", "--lines", "60"]
job.start()
assert wait_until(lambda: job.status == "running", timeout=10)
assert job.pause() is True
# 训练脚本回显 paused 事件;状态由 _sync_control_events 校正
assert wait_until(
lambda: any('"event": "paused"' in line for line in job.snapshot()["log_lines"]),
timeout=10), "训练脚本未回显 paused 事件"
assert job.status == "paused"
paused_metrics = len(job.snapshot()["metrics"])
time.sleep(0.6)
assert len(job.snapshot()["metrics"]) <= paused_metrics + 1, "暂停后仍在产出指标"
assert job.resume() is True
assert wait_until(
lambda: any('"event": "resumed"' in line for line in job.snapshot()["log_lines"]),
timeout=10), "训练脚本未回显 resumed 事件"
assert wait_until(lambda: job.status == "running", timeout=5)
assert job.stop() is True
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=15)
assert job.status == "stopped"
def test_stop_forced_kill_when_control_ignored(stub_repo, monkeypatch):
"""训练进程不读控制文件时必须超时强杀,并留下恢复指引。"""
monkeypatch.setattr(td, "STOP_GRACE_S", 0.5)
config = make_config(model="output/hrl/ui/test/stubborn/q3.zip")
job = td.TrainingJob(config)
job.command = list(job.command) + ["--ignore-control", "--sleep", "5", "--lines", "50"]
job.start()
assert wait_until(lambda: job.status == "running", timeout=10)
# 造一份"磁盘上已有滚动恢复点"的现场,强杀后应被记录进恢复指引
ckpt_dir = job.config.model_path.parent / "checkpoints"
ckpt_dir.mkdir(parents=True, exist_ok=True)
latest = ckpt_dir / "q3_ckpt_latest.zip"
latest.write_bytes(b"PK\x03\x04fake")
assert job.stop() is True
assert wait_until(lambda: job.status in ("stopped", "failed"), timeout=20)
assert job.forced_kill is True
assert job.snapshot(log_tail=5)["forced_kill"] is True
marker = json.loads(
(job.config.model_path.parent / "forced_stop.json").read_text(encoding="utf-8"))
assert marker["event"] == "forced_stop"
assert marker["reason"] == "grace_timeout"
assert marker["recovery"]["model"] == str(latest)
assert marker["recovery"]["can_resume"] is True
def test_discover_recovery_prefers_resume_hint(tmp_path):
run_dir = tmp_path / "run"
(run_dir / "checkpoints").mkdir(parents=True)
latest = run_dir / "checkpoints" / "q3_ckpt_latest.zip"
latest.write_bytes(b"PK\x03\x04fake")
final = run_dir / "q3.zip"
final.write_bytes(b"PK\x03\x04fake")
(run_dir / "resume.json").write_text(json.dumps({
"model": str(latest), "timesteps": 128, "reason": "interval",
"created_unix_s": 5.0}), encoding="utf-8")
state = td.discover_recovery(run_dir, final)
assert state["can_resume"] is True
assert state["model"] == str(latest)
assert state["timesteps"] == 128
assert state["source"] == "resume.json"
def test_discover_recovery_falls_back_to_scan_on_dangling_hint(tmp_path):
run_dir = tmp_path / "run"
(run_dir / "checkpoints").mkdir(parents=True)
latest = run_dir / "checkpoints" / "q3_ckpt_latest.zip"
latest.write_bytes(b"PK\x03\x04fake")
# 悬空指针(目标文件已不在)必须回退到扫描,而不是给出一个打不开的路径
(run_dir / "resume.json").write_text(
json.dumps({"model": str(run_dir / "gone.zip")}), encoding="utf-8")
state = td.discover_recovery(run_dir, run_dir / "q3.zip")
assert state["can_resume"] is True
assert state["model"] == str(latest)
assert state["source"] == "scan"
def test_discover_recovery_with_nothing_on_disk(tmp_path):
state = td.discover_recovery(tmp_path, tmp_path / "q3.zip")
assert state["can_resume"] is False and state["model"] is None
assert state["reason"] == "none"
def test_command_carries_checkpoint_and_recovery_flags():
config = make_config(model="output/x/q3.zip", recovery_every=100,
checkpoint_every=500, checkpoint_keep=2)
joined = " ".join(td.build_command(config))
assert "--recovery-every 100" in joined
assert "--checkpoint-every 500" in joined
assert "--checkpoint-keep 2" in joined
def test_config_rejects_bad_recovery_settings():
with pytest.raises(ValueError):
make_config(recovery_every=-1)
with pytest.raises(ValueError):
make_config(checkpoint_every=-1)
with pytest.raises(ValueError):
make_config(checkpoint_keep=0)
def test_pause_resume_return_false_without_live_process(stub_repo):
config = make_config(model="output/hrl/ui/test/nolive/q3.zip")
job = td.TrainingJob(config)
assert job.pause() is False and job.resume() is False
job.start()
assert wait_until(lambda: job.status in ("exited", "failed"), timeout=30)
assert job.pause() is False and job.resume() is False
# ---------------------------------------------------- 跨平台进程控制
def test_control_file_roundtrip_and_corruption(tmp_path):
path = tmp_path / "control.json"
assert pc.read_control(path) == pc.ControlState() # 缺失 = 无指令
pc.write_control(path, paused=True)
assert pc.read_control(path).paused is True
pc.write_control(path, stop=True)
state = pc.read_control(path)
assert state.stop is True and state.paused is False
path.write_text("{ 半截 JSON", encoding="utf-8")
assert pc.read_control(path) == pc.ControlState() # 损坏 = 无指令
def test_spawn_kwargs_isolates_child_process_group(monkeypatch):
monkeypatch.setattr(pc, "IS_WINDOWS", False)
assert pc.spawn_kwargs() == {"start_new_session": True}
monkeypatch.setattr(pc, "IS_WINDOWS", True)
kwargs = pc.spawn_kwargs()
# Windows 上 start_new_session 被 CPython 静默忽略,绝不能再用它
assert "start_new_session" not in kwargs
if kwargs:
assert kwargs["creationflags"] == pc._CREATE_NEW_PROCESS_GROUP
def test_hard_kill_on_windows_uses_taskkill(monkeypatch):
commands = []
class _FakeProcess:
pid = 4242
def kill(self): # pragma: no cover - Windows 分支不应走到这里
commands.append("Popen.kill")
monkeypatch.setattr(pc, "IS_WINDOWS", True)
monkeypatch.setattr(pc.subprocess, "run",
lambda argv, **kwargs: commands.append(argv))
pc._hard_kill_tree(_FakeProcess())
assert commands and commands[0][0] == "taskkill"
assert "/PID" in commands[0] and "4242" in commands[0]
def test_hard_kill_without_killpg_does_not_raise(monkeypatch):
"""回归:Windows 上 os 没有 killpg/getpgid,旧代码会抛 AttributeError。"""
killed = []
class _FakeProcess:
pid = 4242
def kill(self):
killed.append(True)
class _PosixlessOS:
"""模拟 Windows:只有 getpid 之类,没有 killpg/getpgid。"""
monkeypatch.setattr(pc, "IS_WINDOWS", False)
monkeypatch.setattr(pc, "os", _PosixlessOS())
pc._hard_kill_tree(_FakeProcess())
assert killed == [True]
def test_reader_flushes_truncated_table_on_exit(stub_repo):
@@ -628,8 +868,42 @@ def test_http_start_poll_stop(client):
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is True
# 等训练进程真正退出后再点一次:应返回 False 而不是报错
assert wait_until(
lambda: call(port, "GET", "/api/train/state?log_tail=5")[1]["job"]["status"]
in ("stopped", "failed"), timeout=15)
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is False # 已结束,返回 False 而不是报错
assert status == 200 and body["stopped"] is False
def test_http_pause_resume_and_state(client, monkeypatch):
monkeypatch.setenv("STUB_TRAIN_SLEEP", "0.6")
_ui, port = client
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/http-pause/q3.zip",
"timesteps": 4096, "rollout_steps": 32, "batch_size": 32})
assert status == 200
def status_now():
return call(port, "GET", "/api/train/state?log_tail=20")[1]["job"]["status"]
def is_running():
return status_now() == "running"
assert wait_until(is_running, timeout=10)
status, body = call(port, "POST", "/api/train/pause", {})
assert status == 200 and body["paused"] is True
assert wait_until(lambda: status_now() == "paused", timeout=10), status_now()
# 暂停中仍视为"有任务在跑":不允许再开一局
status, body = call(port, "POST", "/api/train/start",
{"model": "output/hrl/ui/test/http-pause2/q3.zip"})
assert status == 409 and "已有训练任务" in body["error"]
status, body = call(port, "POST", "/api/train/resume", {})
assert status == 200 and body["resumed"] is True
assert wait_until(is_running, timeout=10), status_now()
status, body = call(port, "POST", "/api/train/stop", {})
assert status == 200 and body["stopped"] is True
assert wait_until(lambda: status_now() in ("stopped", "failed"), timeout=15)
def test_http_rejects_second_job_while_running(client):
@@ -661,7 +935,7 @@ def test_http_binds_loopback_only(client):
def test_port_conflict_reports_actionable_error(client):
ui, port = client
_ui, port = client
with pytest.raises(OSError) as excinfo:
td.TrainingUI(ui_port=port)
assert "ui-port" in str(excinfo.value)
+1
View File
@@ -36,6 +36,7 @@ def test_training_ui_frontend_logic(tmp_path):
result = subprocess.run(
["node", str(HARNESS)], capture_output=True, text=True, timeout=120,
check=False,
env={**os.environ, "REPO": str(REPO),
"DEMO_STATE": str(scenarios_path), "SCHEMA": str(schema_path)})
assert result.returncode == 0, \