225 lines
11 KiB
Python
225 lines
11 KiB
Python
"""采集一组新的自建模拟器演练局(问题三全向源 / 问题四混合源)。
|
||
|
||
为什么需要它:论文正文的定量论据取自自建模拟器演练局,但历史留档里混入了
|
||
算法尚未优化完全时的局(例如一次演练出现 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())
|