Files
mcm-26-b/scripts/run_practice_episodes.py
T
2026-09-13 18:05:59 +08:00

225 lines
11 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.
"""采集一组新的自建模拟器演练局(问题三全向源 / 问题四混合源)。
为什么需要它:论文正文的定量论据取自自建模拟器演练局,但历史留档里混入了
算法尚未优化完全时的局(例如一次演练出现 33 次失败清除、10 分钟虚拟时间
仍只清 10/13 个源)。这类局不能代表当前实现,需要重新采一组。
本脚本**不自己造数据、也不写评测机日志**:它启动评测机 ``referee``、
调用 ``judge.start("q3_practice"|"q4_practice", seed=...)`` 开始一局演练,
再用与 ``mcm26b.cli run`` 完全相同的生产策略对接接口。
于是:
- 真值与行为日志由评测机自己写进 ``<data-dir>/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", default="2026001")
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())