"""采集一组新的自建模拟器演练局(问题三全向源 / 问题四混合源)。 为什么需要它:论文正文的定量论据取自自建模拟器演练局,但历史留档里混入了 算法尚未优化完全时的局(例如一次演练出现 33 次失败清除、10 分钟虚拟时间 仍只清 10/13 个源)。这类局不能代表当前实现,需要重新采一组。 本脚本**不自己造数据、也不写评测机日志**:它启动评测机 ``referee``、 调用 ``judge.start("q3_practice"|"q4_practice", seed=...)`` 开始一局演练, 再用与 ``mcm26b.cli run`` 完全相同的生产策略对接接口。 于是: - 真值与行为日志由评测机自己写进 ``/sessions/<案例编码>/``, 与历史留档同源同格式,可直接被 ``scripts/build_practice_data.py`` 复算; - 策略端只看见 HTTP 响应,不存在"为了好看而改数据"的空间; - 每局由 ``--seed`` 决定,可重放。 有效性判定沿用项目口径(与 ``scripts/run_suite.py`` 一致,不放宽): ``清完全部源 ∧ 无缺席误判 ∧ 主动 /exit ∧ 零异常 ∧ 零失败清除``。 最后一项是刻意加严的:算法未优化完全时会出现"最终清完了,但中途大量 /clear 打空"的局(本次复测就抓到 3~114 次失败清除)。这类局的总虚拟时间与检测次数 被无效试探撑大,不能代表当前实现,必须排除。 uv run python scripts/run_practice_episodes.py --problem 3 --counts 10,13,16 \ --seeds 300-309 --out referee-data uv run python scripts/run_practice_episodes.py --problem 4 --counts 10,13 \ --seeds 400-411 """ from __future__ import annotations import argparse import json import socket import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) from mcm26b.config import ProblemConfig # noqa: E402 from mcm26b.planner import DirectionalExtension # noqa: E402 from mcm26b.planner.bayes_loop import BayesLoop # noqa: E402 from mcm26b.protocol.client import SimulatorClient # noqa: E402 STRATEGIES = {3: BayesLoop, 4: DirectionalExtension} DEFAULT_SEEDS = {3: (300, 309), 4: (400, 411)} def free_port() -> int: """取一个空闲回环端口,评测机接口与 WebUI 都只用回环 [题面 附件2 1.5]。""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.bind(("127.0.0.1", 0)) return probe.getsockname()[1] def parse_seeds(text: str | None, problem: int) -> list[int]: """``300-309`` 或 ``300,301`` 形式的种子表。""" if not text: low, high = DEFAULT_SEEDS[problem] return list(range(low, high + 1)) seeds: list[int] = [] for chunk in text.split(","): chunk = chunk.strip() if not chunk: continue if "-" in chunk: low, high = (int(part) for part in chunk.split("-", 1)) seeds.extend(range(low, high + 1)) else: seeds.append(int(chunk)) return seeds def build_config(base_url: str, margin_deg: float) -> ProblemConfig: """与 ``mcm26b.cli run`` 相同的生产配置:默认关闭 persistent 复扫。""" from dataclasses import replace # noqa: PLC0415 config = ProblemConfig() return replace(config, session=replace(config.session, base_url=base_url), search=replace(config.search, bearing_rounding_margin_deg=margin_deg, persistent_search=False)) def run_episode(problem: int, count: int, seed: int, robot_id: str, data_dir: Path, margin_deg: float, countdown_s: float, directional: int) -> dict: """跑一局演练;返回该局的核对结果(真值只在此处用于判定)。""" from referee import Referee # noqa: PLC0415 from referee.server import ActionInterfaceServer # noqa: PLC0415 port = free_port() judge = Referee(action_port=port, data_dir=data_dir) ActionInterfaceServer(judge) module = f"q{problem}_practice" session = judge.start(module, robot_id, seed=seed, source_count=count, directional_count=directional, countdown_s=countdown_s) deadline = time.monotonic() + session.countdown_s + 15.0 while session.phase == "countdown" and time.monotonic() < deadline: time.sleep(0.02) result = {"problem": problem, "seed": seed, "count": count, "case_code": session.case_code} if session.phase != "open": judge.abort() result.update(valid=False, error="评测机未能开放接口") return result config = build_config(f"http://127.0.0.1:{port}", margin_deg) client = SimulatorClient(robot_id, config=config) started = time.perf_counter() try: summary = STRATEGIES[problem](client, config).run() result["strategy_error"] = None except Exception as exc: # pylint: disable=broad-except summary = {"cleared_channels": [], "absent_channels": [], "exit_reason": "technical_error", "anomalies": [str(exc)]} result["strategy_error"] = f"{type(exc).__name__}: {exc}" result["compute_wall_s"] = round(time.perf_counter() - started, 3) settle = time.monotonic() + 10.0 while judge.session.phase != "finished" and time.monotonic() < settle: time.sleep(0.02) if judge.session.phase != "finished": judge.abort() state = judge.snapshot() truth = sorted(source.channel for source in session.sources) cleared = sorted(summary.get("cleared_channels", [])) absent = sorted(summary.get("absent_channels", [])) # 失败清除次数从评测机行为日志里数,而不是策略自报,避免口径分歧。 clear_calls = clear_failures = 0 log_path = data_dir / "sessions" / session.case_code / "log.jsonl" if log_path.exists(): for line in log_path.read_text(encoding="utf-8").splitlines(): if '"path": "/clear"' not in line: continue event = json.loads(line) if (event.get("event") != "action" or (event.get("response") or {}).get("accepted") is not True): continue clear_calls += 1 if (event["response"].get("clear_result")) != "success": clear_failures += 1 result.update( end_reason=state.get("end_reason"), cleared_channels=cleared, cleared_count=len(cleared), truth_channels=truth, source_count=len(truth), directional_count=sum(1 for s in session.sources if s.directional), absent_misjudged=sorted(set(absent) & set(truth)), anomalies=len(summary.get("anomalies", [])), virtual_time_s=state.get("virtual_time_s"), clear_calls=clear_calls, clear_failures=clear_failures, measurements=len([s for s in summary.get("steps", []) if s.get("action") == "measure"]), valid=(bool(truth) and cleared == truth and not (set(absent) & set(truth)) and summary.get("exit_reason") == "user_exit" and not summary.get("anomalies") and clear_failures == 0 and result["strategy_error"] is None)) return result def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--problem", type=int, choices=(3, 4), required=True) parser.add_argument("--counts", default="10,13,16", help="每局的源数量表(逗号分隔),逐局循环取用") parser.add_argument("--seeds", default=None, help="种子表,支持 300-309 或 300,301;缺省用内置区间") parser.add_argument("--directional", type=int, default=3, help="问题四每局定向源个数(题面未规定,自建模拟器默认为 3)") parser.add_argument("--directional-cycle", default=None, help="问题四定向源个数循环表,如 3,6,8;给定时覆盖 --directional," "用于让演练局的定向占比也覆盖题面的不确定性") parser.add_argument("--robot-id", required=True, help="评测机登录队号,逐字节一致;不写入代码,运行期传入") parser.add_argument("--data-dir", type=Path, default=ROOT / "referee-data") parser.add_argument("--margin-deg", type=float, default=0.005) parser.add_argument("--countdown-s", type=float, default=0.2) parser.add_argument("--out", type=Path, default=None, help="逐局核对结果的 JSON 输出路径") args = parser.parse_args() counts = [int(item) for item in args.counts.split(",") if item.strip()] directional_cycle = ([int(item) for item in args.directional_cycle.split(",") if item.strip()] if args.directional_cycle else [args.directional]) seeds = parse_seeds(args.seeds, args.problem) print(f"问题{args.problem}:{len(seeds)} 局,源数循环 {counts}," f"定向源循环 {directional_cycle},数据目录 {args.data_dir}", flush=True) results = [] for index, seed in enumerate(seeds): outcome = run_episode(args.problem, counts[index % len(counts)], seed, args.robot_id, args.data_dir, args.margin_deg, args.countdown_s, directional_cycle[index % len(directional_cycle)]) results.append(outcome) verdict = "有效" if outcome.get("valid") else "无效" print(f" [{index + 1:2d}/{len(seeds)}] seed={seed:5d} n={outcome['count']:2d} " f"{outcome['case_code']} 清除 {outcome['cleared_count']}/{outcome['source_count']} " f"T={outcome.get('virtual_time_s') or 0:9.1f}s 墙钟={outcome['compute_wall_s']:6.2f}s " f"失败清除={outcome['clear_failures']} 缺席误判={len(outcome['absent_misjudged'])} " f"异常={outcome['anomalies']} {outcome['end_reason']} -> {verdict}", flush=True) valid = [row for row in results if row.get("valid")] print(f"\n有效 {len(valid)}/{len(results)} 局;" f"无效局的种子:{[r['seed'] for r in results if not r.get('valid')]}") if args.out: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(json.dumps({"problem": args.problem, "counts": counts, "seeds": seeds, "directional": args.directional, "results": results}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"核对结果已写入 {args.out}") return 0 if valid else 1 if __name__ == "__main__": raise SystemExit(main())