论文完成

This commit is contained in:
2026-09-13 18:05:59 +08:00
parent 9fb15f53f5
commit 89021eb9f8
234 changed files with 28597 additions and 2164 deletions
+224
View File
@@ -0,0 +1,224 @@
"""采集一组新的自建模拟器演练局(问题三全向源 / 问题四混合源)。
为什么需要它:论文正文的定量论据取自自建模拟器演练局,但历史留档里混入了
算法尚未优化完全时的局(例如一次演练出现 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())