"""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()