Files
mcm-26-b/tests/test_safety_valves.py
T
2026-09-13 18:43:25 +08:00

92 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P1-4 安全阀与退出可解释性验收。
覆盖 论文“实验结果与分析”一节 §P1-4:
- summary 必须显式给出完成依据 completion_basis(数量上界 vs 逐频道证明);
- 逐频道缺席必须带证据来源 absence_evidence(pf_empty / coverage);
- 重复复扫计数 redundant_rescan_measures 是历史 98.84% 空复扫问题的回归指标;
- 虚拟安全余量不小于单动作耗时上界(配置被调小时仍成立)。
"""
import unittest
from dataclasses import replace
from mcm26b.config import ProblemConfig
from mcm26b.planner import DirectionalExtension, OmnidirectionalLoop
from mcm26b.planner.bayes_loop import BayesLoop
from mcm26b.protocol.client import SimulatorClient
from mcm26b.harness import LocalSimTransport, LocalSimulator
from mcm26b.harness import mixed_scene, random_omni_scene
CONFIG = replace(ProblemConfig(),
search=replace(ProblemConfig().search,
bearing_rounding_margin_deg=0.005,
persistent_search=False))
def _run(loop_cls, sources, config=CONFIG):
simulator = LocalSimulator(sources, robot_id="T-SAFE", config=config)
client = SimulatorClient("T-SAFE", config=config,
transport=LocalSimTransport(simulator))
return loop_cls(client, config).run(), simulator
class SafetyValveTests(unittest.TestCase):
def test_summary_completion_and_absence_evidence(self):
sources = random_omni_scene(seed=5, count=10, config=CONFIG)
summary, _ = _run(BayesLoop, sources)
truth = sorted(s.channel for s in sources)
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertEqual(summary["completion_basis"], "per_channel_proof")
self.assertEqual(sorted(summary["absent_channels"]),
sorted(set(range(1, 21)) - set(truth)))
evidence = summary["absence_evidence"]
self.assertEqual(sorted(int(c) for c in evidence),
sorted(summary["absent_channels"]))
self.assertTrue(set(evidence.values()) <= {"pf_empty", "coverage"})
self.assertIsInstance(summary["redundant_rescan_measures"], int)
self.assertGreaterEqual(summary["virtual_reserve_s"], 10_000.0)
def test_redundant_rescan_measure_is_counted(self):
"""同一测点重复检测必须被计数(正常模式应为 0)。"""
sources = random_omni_scene(seed=7, count=10, config=CONFIG)
summary, _ = _run(BayesLoop, sources)
self.assertEqual(summary["redundant_rescan_measures"], 0)
def test_count_cap_is_distinguished_from_proof(self):
"""16 源时按数量上界停止,completion_basis 不得写成逐频道证明。"""
sources = random_omni_scene(seed=11, count=16, config=CONFIG)
summary, _ = _run(BayesLoop, sources)
self.assertGreaterEqual(len(summary["cleared_channels"]), 16)
self.assertEqual(summary["completion_basis"], "count_cap")
def test_q4_completion_basis_present(self):
sources = mixed_scene(seed=3, count=10, directional=3, config=CONFIG)
summary, simulator = _run(DirectionalExtension, sources)
truth = sorted(s.channel for s in sources)
self.assertEqual(sorted(summary["cleared_channels"]), truth)
self.assertIn(summary["completion_basis"],
{"per_channel_proof", "count_cap"})
self.assertEqual(sorted(simulator.cleared), truth)
class VirtualReserveTests(unittest.TestCase):
def test_reserve_covers_single_action_bound(self):
"""配置余量被调小到 0 时,有效余量仍不得小于单动作上界。"""
tight = replace(ProblemConfig(),
search=replace(ProblemConfig().search,
virtual_reserve_s=0.0))
sources = random_omni_scene(seed=1, count=1, config=tight)
simulator = LocalSimulator(sources, robot_id="T-RES", config=tight)
client = SimulatorClient("T-RES", config=tight,
transport=LocalSimTransport(simulator))
loop = OmnidirectionalLoop(client, tight)
reach = max((x * x + y * y) ** 0.5 for x, y in loop.ledger.points)
bound = 2.0 * reach / tight.cost.speed_mps \
+ tight.cost.measure_s + tight.cost.switch_s
self.assertGreaterEqual(loop._min_virtual_reserve(), bound)
self.assertLess(loop._min_virtual_reserve(), 10_000.0)
if __name__ == "__main__":
unittest.main()