"""HRL 验收:槽位布局、观测/掩码同源性、整场环境闭环与奖励恒等式。 HRL 依赖属于可选组 [dependency-groups] rl;未安装 numpy/gymnasium 时 全部跳过(运行闭环不依赖它们)。 """ import math from types import SimpleNamespace import unittest pytest = None try: import pytest numpy = pytest.importorskip("numpy") pytest.importorskip("gymnasium") except Exception: # pragma: no cover - 无 RL 依赖环境 numpy = None if numpy is not None: from mcm26b.config import ProblemConfig from mcm26b.hrl.constants import (CHANNEL_COUNT, CLEAR_OFFSET, CLEAR_SLOTS, FALLBACK_OFFSET, FALLBACK_SLOTS, HIGH_GLOBAL_DIM, HIGH_OBS_DIM, LOCATE_OFFSET, LOCATE_SLOTS, N_CANDIDATES, PER_CANDIDATE_DIM, PER_CHANNEL_DIM, STATION_OFFSET, STATION_SLOTS) from mcm26b.hrl.curriculum import STAGES, make_scene, training_config from mcm26b.hrl.executor import LowLevelExecutor, build_low_observation from mcm26b.hrl.hierarchical_env import HierarchicalEnv from mcm26b.hrl.low_level_env import LowLevelEnv from mcm26b.hrl.scheduler import HRLControllerMixin CONFIG = ProblemConfig() def _absent(info: dict) -> int: return info.get("absent_proven", 0) def _env(problem: int = 3, stage_index: int = 0, **kwargs) -> HierarchicalEnv: return HierarchicalEnv(problem=problem, stage=STAGES[stage_index], **kwargs) if numpy is not None: class _CandidateHost(HRLControllerMixin): """只提供候选所需感知原语,槽位分配和掩码均调用生产实现。""" def __init__(self, detected, certified=(), blocked=()): self.config = CONFIG self.problem = 4 if blocked else 3 self._certified = set(certified) self._clear_failed = set() self.blocked_probes = { channel: [(0.0, 0.0)] * CONFIG.search.max_blocked_probes for channel in blocked } region = SimpleNamespace( polygon=[(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0)], mec=lambda: ((5.0, 5.0), math.sqrt(32.0))) self.belief = SimpleNamespace( channels_by_status=lambda status: list(detected), candidate_set=lambda channel: region) self.ledger = SimpleNamespace(points=[]) def _best_certificate(self, channel): return SimpleNamespace(certified=channel in self._certified, center=(float(channel), 0.0)) def _select_point(self, channel): return (float(channel), 1.0) def _hrl_probe_point(self, region, channel): return (float(channel), 2.0) @unittest.skipIf(numpy is None, "RL 依赖组未安装") class LayoutTests(unittest.TestCase): def test_high_obs_dim_matches_component_contract(self): expected = (HIGH_GLOBAL_DIM + CHANNEL_COUNT * PER_CHANNEL_DIM + N_CANDIDATES * PER_CANDIDATE_DIM) self.assertEqual(HIGH_OBS_DIM, expected) self.assertEqual(HIGH_OBS_DIM, 1248) self.assertEqual(N_CANDIDATES, 120) def test_observation_finite_and_deterministic(self): env = _env(problem=3) obs, _ = env.reset(seed=2026) self.assertEqual(obs.shape, (HIGH_OBS_DIM,)) self.assertTrue(numpy.isfinite(obs).all()) again = env._loop.hrl_observation(env._current_candidates) self.assertTrue(numpy.array_equal(obs, again)) def test_mask_nonempty_and_matches_candidate_slots(self): env = _env(problem=3) env.reset(seed=7) for _ in range(6): mask = env.action_masks() self.assertTrue(mask.any()) slots = {cand.slot for cand in env._current_candidates} self.assertEqual({int(i) for i in numpy.flatnonzero(mask)}, slots) _obs, _r, term, trunc, _info = env.step( int(env._loop.heuristic_action(env._current_candidates))) if term or trunc: break def test_q4_probe_and_fallback_slots_exist_in_layout(self): self.assertEqual(CLEAR_SLOTS, CHANNEL_COUNT) self.assertEqual(LOCATE_SLOTS, CHANNEL_COUNT) self.assertEqual(FALLBACK_SLOTS, CHANNEL_COUNT) self.assertEqual(STATION_SLOTS, 49) self.assertEqual(CLEAR_OFFSET, 0) self.assertEqual(LOCATE_OFFSET, CLEAR_OFFSET + CLEAR_SLOTS) self.assertEqual(STATION_OFFSET, LOCATE_OFFSET + LOCATE_SLOTS) self.assertEqual(FALLBACK_OFFSET, STATION_OFFSET + STATION_SLOTS) self.assertEqual(N_CANDIDATES - (FALLBACK_OFFSET + FALLBACK_SLOTS), 11) def test_build_candidates_keeps_clear_and_locate(self): host = _CandidateHost(detected=[1, 2, 3, 4, 17, 18, 19, 20], certified=[17, 18, 19, 20]) candidates = host.build_candidates() expected = {(CLEAR_OFFSET + channel - 1, "clear", channel) for channel in range(17, 21)} expected.update((LOCATE_OFFSET + channel - 1, "locate", channel) for channel in range(1, 5)) self.assertEqual({(c.slot, c.kind, c.channel) for c in candidates}, expected) self.assertEqual(len(candidates), 8) mask = host.hrl_action_mask(candidates) self.assertEqual(set(numpy.flatnonzero(mask)), {c.slot for c in candidates}) for channel in range(17, 21): selected = host.candidate_by_slot(candidates, CLEAR_OFFSET + channel - 1) self.assertEqual((selected.kind, selected.channel), ("clear", channel)) def test_build_candidates_fallback_covers_slots(self): channels = list(range(17, 21)) host = _CandidateHost(detected=channels, blocked=channels) candidates = host.build_candidates() fallbacks = [c for c in candidates if c.kind == "fallback"] self.assertEqual({c.channel for c in fallbacks}, set(channels)) self.assertEqual(len(candidates), 8) # 四个 probe 与四个 fallback 并存 for candidate in fallbacks: self.assertEqual(candidate.slot, FALLBACK_OFFSET + candidate.channel - 1) self.assertGreaterEqual(candidate.slot, FALLBACK_OFFSET) self.assertLess(candidate.slot, FALLBACK_OFFSET + FALLBACK_SLOTS) self.assertGreater(candidate.targets, 0) mask = host.hrl_action_mask(candidates) self.assertTrue(mask[FALLBACK_OFFSET + 19]) self.assertFalse(mask[FALLBACK_OFFSET + FALLBACK_SLOTS:].any()) @unittest.skipIf(numpy is None, "RL 依赖组未安装") class HeuristicLoopTests(unittest.TestCase): def test_q3_full_episode_clears_all_sources(self): env = _env(problem=3, stage_index=3, max_episode_steps=500) _, _ = env.reset(seed=11) steps = 0 while True: action = env._loop.heuristic_action(env._current_candidates) _, _, term, trunc, info = env.step(action) steps += 1 if term or trunc: break self.assertTrue(term) self.assertGreaterEqual(info["cleared"], 10) self.assertEqual(info["cleared"] + _absent(info), 20) self.assertEqual(info["accounted"], 20) self.assertEqual(info["anomalies"], []) def test_q4_episode_accounts_all_channels(self): env = _env(problem=4, stage_index=3, max_episode_steps=600) env.reset(seed=21) while True: action = env._loop.heuristic_action(env._current_candidates) _, _, term, trunc, info = env.step(action) if term or trunc: break self.assertTrue(term) self.assertEqual(info["accounted"], 20) self.assertEqual(info["anomalies"], []) self.assertGreaterEqual(info["cleared"], 10) def test_reward_identity_from_outcome_terms(self): """非终止步奖励恒等于各分项加权和(prompt §5.2 的可折算实现)。""" env = _env(problem=3, stage_index=1, max_episode_steps=50) env.reset(seed=5) reward_cfg = env.reward for _ in range(8): action = env._loop.heuristic_action(env._current_candidates) _virtual_before = env._client.state.virtual_time_s _, r, term, trunc, info = env.step(action) outcome = env._last_outcome if outcome.invalid: self.assertAlmostEqual(r, -reward_cfg.invalid, places=6) else: expected = (-outcome.delta_virtual_s / reward_cfg.time_scale + reward_cfg.clear * len(outcome.clear_success) + reward_cfg.detect * len(outcome.new_detections) + reward_cfg.info_weight * sum(outcome.info_gains.values())) if outcome.redundant: expected -= reward_cfg.redundant if term: expected += (reward_cfg.terminal_accounted if info["accounted"] == 20 else -reward_cfg.terminal_unresolved) self.assertAlmostEqual(r, expected, places=6) if term or trunc: break def test_invalid_action_penalized_without_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 _, 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) @unittest.skipIf(numpy is None, "RL 依赖组未安装") class LowLevelTests(unittest.TestCase): def test_low_env_shapes_and_expert_reaches_goal(self): env = LowLevelEnv(seed=99) obs, info = env.reset(seed=99) self.assertEqual(obs.shape, (10,)) start = tuple(info["position"]) goal = tuple(info["goal"]) total = 0 for _ in range(60): obs, _, term, trunc, _ = env.step(env.expert_action()) total += 1 if term or trunc: break self.assertTrue(term, "直线专家应在步数上限内到达") straight = math.dist(start, goal) efficiency = straight / (total * env.step_m) self.assertGreater(efficiency, 0.7, "专家路径效率应接近直线") def test_executor_straightline_is_goal_only(self): executor = LowLevelExecutor(policy=None) self.assertEqual(executor.roll((0.0, 0.0), (500.0, 200.0)), [(500.0, 200.0)]) def test_low_observation_source_shared(self): obs = build_low_observation((0.0, 0.0), (300.0, 0.0), None, 0) self.assertEqual(obs.shape, (10,)) self.assertAlmostEqual(float(obs[4]), 1.0, places=6) # cos(bearing)=1 @unittest.skipIf(numpy is None, "RL 依赖组未安装") class CurriculumTests(unittest.TestCase): def test_stages_are_monotonic_in_difficulty(self): for prev, nxt in zip(STAGES, STAGES[1:]): self.assertLessEqual(prev.sources_max, nxt.sources_max) self.assertLessEqual(prev.channel_max, nxt.channel_max) self.assertLessEqual(prev.bearing_error_half_deg, nxt.bearing_error_half_deg) def test_training_config_defaults_to_production(self): config = training_config(CONFIG, STAGES[3]) self.assertEqual(config.field.channel_max, 20) self.assertEqual(config.field.bearing_error_half_deg, 1.0) self.assertEqual(config.bayes, CONFIG.bayes) def test_fast_config_preserves_certificate_grid(self): config = training_config(CONFIG, STAGES[3], fidelity="fast") self.assertEqual(config.bayes.particle_spacing_m, CONFIG.bayes.particle_spacing_m) self.assertLess(config.bayes.particle_spacing_m * math.sqrt(2) / 2, config.field.clear_radius_m) self.assertLessEqual(config.bayes.score_subsample, CONFIG.bayes.score_subsample) def test_scene_source_count_and_channel_bounds(self): from random import Random rng = Random(1) sources, channels = make_scene(rng, training_config(CONFIG, STAGES[1]), 3, STAGES[1]) self.assertEqual(len(sources), STAGES[1].sources_max) self.assertTrue(all(1 <= c <= STAGES[1].channel_max for c in channels)) self.assertEqual(len(set(channels)), len(channels)) if __name__ == "__main__": # pragma: no cover unittest.main()