holy fuking shit
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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, \
|
||||
|
||||
Reference in New Issue
Block a user