This commit is contained in:
Horldsence
2026-09-11 11:36:08 +08:00
parent 2d2a9a4e6a
commit a18a139a1d
93 changed files with 9245 additions and 3806 deletions
+202 -17
View File
@@ -1,16 +1,22 @@
"""统一命令行入口:mcm26b <子命令>。
- q1 问题一角域交会求解(P2 已完工)
- sim-check 用自建模拟器复现附件计时示例(P0 自检)
- sim-check 用评测机复现附件计时示例(P0 自检)
- run 闭环:发现—定位—清除—退出(--problem 3 全向 / 4 混合源;
--self-test 走本地模拟器)
--self-test 走独立评测机 referee)
解耦约定:运行时代码零导入评测机;referee/harness 仅在 --self-test 与
sim-check 分支内懒加载(测试侧 dev 依赖)。
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
import threading
import time
from dataclasses import replace
from pathlib import Path
@@ -22,16 +28,148 @@ from .planner.seven import SevenBatch
from .protocol.client import SimulatorClient
from .protocol.log import SessionLog
from .protocol.rules import EXIT_USER
from .sim import LocalSimTransport, LocalSimulator, Source
from .sim.scenes import mixed_scene, random_omni_scene
Q3_STRATEGIES = {"grid": OmnidirectionalLoop, "seven": SevenBatch, "bayes": BayesLoop}
def _load_referee():
"""懒加载独立评测机与测试侧适配层(运行时/正式对接不触发)。"""
from .harness import (LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
from referee import LocalSimulator
return (LocalSimulator, LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
def _referee_missing():
print("未安装独立评测机包 referee(mcm26b-referee)。请先 `uv sync` 安装"
" dev 依赖,或 pip install mcm26b-referee;正式对接官方模拟器无需本包。",
file=sys.stderr)
return 2
# ---------------------------------------------------------------- 一键本地评测
def _port_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _any_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _acquire_port(name: str, requested: int) -> int:
"""端口被占时积极询问:交互终端逐次询问(回车=自动改空闲端口),
非交互环境(脚本/CI)直接改用空闲端口并告知,绝不静默失败。"""
if _port_free(requested):
return requested
if sys.stdin.isatty():
while True:
auto = _any_free_port()
try:
answer = input(f"[一键评测] {name}端口 {requested} 已被占用:"
f"回车改用空闲端口 {auto},或输入其他端口:").strip()
except (EOFError, KeyboardInterrupt):
return auto
if not answer:
return auto
if answer.isdigit() and 1024 <= int(answer) <= 65535:
if _port_free(int(answer)):
return int(answer)
print(f"端口 {answer} 同样被占用,请再试。")
continue
print("请输入 1024..65535 的端口号,或直接回车让评测机自选。")
auto = _any_free_port()
print(f"[一键评测] {name}端口 {requested} 已被占用(非交互环境),"
f"自动改用空闲端口 {auto}。", file=sys.stderr)
return auto
def _start_local_judge(args):
"""启动独立评测机(机器狗接口 + WebUI 回放)并确认开始一局演练。
返回 (judge, session, action_port, ui_port);失败返回 None(已打印原因)。
"""
try:
from referee import Referee
from referee.server import ActionInterfaceServer
from referee.webui import WebUIServer
except ImportError:
_referee_missing()
return None
action_port = _acquire_port("机器狗接口", args.judge_port)
ui_port = None
judge = Referee(action_port=action_port, data_dir=Path(args.judge_data_dir))
ActionInterfaceServer(judge)
if not args.judge_no_ui:
ui_port = _acquire_port("WebUI", args.judge_ui_port)
ui = WebUIServer(judge, ui_port)
threading.Thread(target=ui.serve_forever, daemon=True,
name="mcm26b-local-judge-ui").start()
module = f"q{args.problem}_practice"
session = judge.start(module, args.robot_id, seed=args.seed,
countdown_s=args.judge_countdown_s)
deadline = time.monotonic() + session.countdown_s + 10
while session.phase == "countdown" and time.monotonic() < deadline:
time.sleep(0.05)
if session.phase != "open":
print("[一键评测] 评测机未能开放接口,已中止。", file=sys.stderr)
judge.abort()
return None
print(f"[一键评测] 案例编码 {session.case_code} · {session.module.title}"
f" · 接口 http://127.0.0.1:{action_port}")
if ui_port is not None:
print(f"[一键评测] WebUI http://127.0.0.1:{ui_port}"
f"(实时界面与\"回放\"Tab 逐步回看,数据目录 {args.judge_data_dir})")
return judge, session, action_port, ui_port
def _settle_local_judge(judge, timeout_s: float = 3.0) -> None:
"""等看门狗确认本局结束(/exit 后毫秒级);仅超时才兜底手工中止。"""
deadline = time.monotonic() + timeout_s
while judge.session.phase != "finished" and time.monotonic() < deadline:
time.sleep(0.05)
if judge.session.phase != "finished":
judge.abort()
def _finish_local_judge(judge, session, summary: dict) -> None:
"""等待评测机确认结束,把结束原因、真值统计与回放入口并入 summary。"""
_settle_local_judge(judge, timeout_s=10.0)
state = judge.snapshot()
truth_channels = sorted(s.channel for s in session.sources)
cleared = sorted(summary.get("cleared_channels", []))
summary["local_judge"] = {
"case_code": session.case_code,
"module": session.module.title,
"end_reason": state.get("end_reason"),
"end_reason_text": state.get("end_reason_text"),
"virtual_time_s": state.get("virtual_time_s"),
"request_count": state.get("request_count"),
"truth": {"source_channels": truth_channels,
"source_count": len(truth_channels),
"omni_count": sum(1 for s in session.sources if not s.directional),
"directional_count": sum(1 for s in session.sources if s.directional)},
"all_cleared": cleared == truth_channels,
"log": state.get("log"),
}
def _sim_check(args) -> int:
"""附件1表2/附件2第10节计时示例:期望 105/111/194/199 秒。"""
simulator = LocalSimulator([], robot_id=args.robot_id)
client = SimulatorClient(args.robot_id, transport=LocalSimTransport(simulator))
try:
local_simulator, local_sim_transport, *_ = _load_referee()
except ImportError:
return _referee_missing()
simulator = local_simulator([], robot_id=args.robot_id)
client = SimulatorClient(args.robot_id, transport=local_sim_transport(simulator))
client.enter()
steps = []
steps.append(client.measure((300, 400), 1).virtual_time_s) # 移动500m+检测5s
@@ -50,11 +188,14 @@ def _sim_check(args) -> int:
def _run(args) -> int:
"""问题3/4闭环。--self-test 时真值只存在于测试端入口,策略端仅见响应。
"""问题3/4闭环。
实机预算:--self-test 时本地模拟器按 --real-budget-s(默认
session.self_test_real_budget_s = 1800s,即 30 分钟实机)返回
remaining_real_duration_s;正式对接以服务器 /enter 返回为准(题面
三种对接方式:--local-judge 一键本地评测(自动起 referee 并对接);
--self-test 内存回环自检(不占端口);默认对接 --base-url 的模拟器
(官方或本地 referee)。真值只存在于测试端入口,策略端仅见响应。
实机预算:--self-test 时评测机按 --real-budget-s(默认 1800s)返回
remaining_real_duration_s;其余方式以服务器 /enter 返回为准(题面
程序运行上限 20 分钟,不可延长)。
"""
config = ProblemConfig()
@@ -68,7 +209,26 @@ def _run(args) -> int:
truth = None
transport = None
real_budget_s = None
if args.self_test:
judge = judge_session = None
if args.local_judge:
if args.self_test:
print("--local-judge 与 --self-test 不能同时使用。", file=sys.stderr)
return 2
if args.base_url:
print("--local-judge 忽略 --base-url(接口地址由评测机自动分配)。",
file=sys.stderr)
started = _start_local_judge(args)
if started is None:
return 2
judge, judge_session, action_port, _ui_port = started
config = replace(config, session=replace(
config.session, base_url=f"http://127.0.0.1:{action_port}"))
elif args.self_test:
try:
(local_simulator, local_sim_transport,
mixed_scene, random_omni_scene, referee_spec) = _load_referee()
except ImportError:
return _referee_missing()
real_budget_s = (args.real_budget_s if args.real_budget_s is not None
else config.session.self_test_real_budget_s)
config = replace(config,
@@ -81,8 +241,10 @@ def _run(args) -> int:
sources = random_omni_scene(seed=args.seed, count=args.sources,
config=config)
truth = sorted(source.channel for source in sources)
transport = LocalSimTransport(LocalSimulator(sources, robot_id=args.robot_id,
config=config, stress_mode=True))
simulator = local_simulator(sources, robot_id=args.robot_id,
spec=referee_spec(config, max_real_duration_s=real_budget_s),
stress_mode=True)
transport = local_sim_transport(simulator)
elif args.real_budget_s is not None:
print("--real-budget-s 仅在 --self-test 下生效;正式测试以服务器 "
"/enter 返回的 remaining_real_duration_s 为准。", file=sys.stderr)
@@ -92,7 +254,11 @@ def _run(args) -> int:
loop_cls = DirectionalExtension
else:
loop_cls = Q3_STRATEGIES[args.strategy]
summary = loop_cls(client, config).run()
try:
summary = loop_cls(client, config).run()
finally:
if judge is not None:
_settle_local_judge(judge)
summary["robot_id"] = args.robot_id
summary["strategy"] = ("directional" if args.problem == 4 else args.strategy)
if real_budget_s is not None:
@@ -100,13 +266,18 @@ def _run(args) -> int:
if args.summary_steps is not None and len(summary.get("steps", [])) > args.summary_steps:
summary["steps"] = summary["steps"][:args.summary_steps]
summary["steps_truncated"] = True
all_cleared = None
if truth is not None:
# 仅测试端核对:清除集合是否与真值一致。
all_cleared = sorted(summary["cleared_channels"]) == truth
summary["truth"] = {"source_channels": truth,
"source_count": len(truth),
"all_cleared": sorted(summary["cleared_channels"]) == truth}
"all_cleared": all_cleared}
if judge is not None:
_finish_local_judge(judge, judge_session, summary)
all_cleared = summary["local_judge"]["all_cleared"]
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if summary["completed"] and (truth is None or summary["truth"]["all_cleared"]) else 1
return 0 if summary["completed"] and (all_cleared is not False) else 1
def main(argv=None) -> int:
@@ -148,8 +319,22 @@ def main(argv=None) -> int:
run.add_argument("--persistent", action="store_true",
help="复扫保险模式:找全证明后继续复扫未清除频道直到限时"
"(默认关闭:虚拟时间即成绩口径,找全即 /exit)")
run.add_argument("--local-judge", action="store_true",
help="一键本地评测:自动启动独立评测机 referee(机器狗接口+"
"WebUI 实时/回放),确认开始一局演练并对接跑完整闭环;"
"端口被占时主动询问换端口(非交互环境自动改用空闲端口)")
run.add_argument("--judge-port", type=int, default=2026,
help="一键评测的机器狗接口端口(默认 2026;被占时询问/自选)")
run.add_argument("--judge-ui-port", type=int, default=8026,
help="一键评测的 WebUI 端口(默认 8026;被占时询问/自选)")
run.add_argument("--judge-no-ui", action="store_true",
help="一键评测不启动 WebUI(只保留机器狗接口)")
run.add_argument("--judge-countdown-s", type=float, default=5.0,
help="一键评测的确认开始倒计时秒数(题面默认 5 秒,可缩短加速)")
run.add_argument("--judge-data-dir", default="output/referee",
help="一键评测的会话日志目录(默认 output/referee,供 WebUI 回放)")
run.add_argument("--self-test", action="store_true",
help="走本地模拟器与随机场景(测试端自检,不连官方)")
help="走独立评测机 referee 与随机场景(测试端自检,不连官方)")
run.add_argument("--seed", type=int, default=20260910, help="自检场景种子")
run.add_argument("--sources", type=int, default=None,
help="自检源数量(默认按题面在 10~16 中随机)")
+10 -5
View File
@@ -110,8 +110,8 @@ class BayesConfig:
因此幸存集永远包含真源(定理见 docs/algo-3-bayes.md §2)。
"""
particle_spacing_m: float = 25.0 # 网格间距;Voronoi 裕量 ≈ 17.68m;
# 全域约 1.6 万格/频道,20 频道内存与耗时可控
particle_spacing_m: float = 14.65 # 网格间距;Voronoi 裕量 ≈ 10.35m;
# 全域约 4.7 万格/频道,查询走粗格索引
# ---- 信息增益评分 [工程,Optuna TPE 调参回填 2026-09-11] ----
# 全量消融(14 场景,对齐找全口径):均值 3650s、配对降幅 31.0%、
# 14/14 全清零异常;调参记录见 output/q3/bayes-tuning/。
@@ -123,11 +123,16 @@ class BayesConfig:
# entropy 口径下信息项为 O(1) 对数值,λ须极小
wide_rings: tuple = (1.0, 1.5, 2.0) # 无保证接收盘时的探索环(× PF MEC 半径)
# ---- 站点与覆盖 [工程] ----
station_ring_radius_m: float = 1159.7 # 七点环半径;调参后仍 < 1178.5
# (覆盖半径须 < 1000 − √3·1800·...解析界)
station_ring_radius_m: float = 1200.0 # 七点环半径 [调参后保留 1200 而非
# Optuna 的 1159.7]:环 1200 时七站
# 排除盘对外圈格心(|c| ≤ ω+裕量)
# 的最大距离 977.3m ≤ 1000 − 裕量
# 10.35m,PF 可独立证明缺席;
# 1159.7 虽均值再快 ~60s 但外圈差
# 3.3m,缺席只能靠覆盖账本兜底
# ---- 任务排序 [工程] ----
two_opt_enabled: bool = True # 开放路径 2-opt 改进(最近邻打底)
two_opt_passes: int = 3 # 2-opt 最大改进轮数
two_opt_passes: int = 4 # 2-opt 最大改进轮数
# ---- PF 清除证书 [工程] ----
grid_cert_enabled: bool = True # 允许用幸存集 MEC 颁发清除证书(凸证书之外)
+18
View File
@@ -0,0 +1,18 @@
"""测试侧脚手架:把独立评测机(referee 包)接入本队传输与测试。
分层边界(与官方模拟器同构):
- 评测机本体在独立包 ``referee``(零 mcm26b 依赖),运行时代码绝不导入它;
- 本包只在测试/自检入口把 referee 的接口边界适配成 :class:`Transport`,
并提供故障注入与官方演练日志回放。
策略端只见 HTTP 响应;真值 Source 只存在于 referee 与测试端。
"""
from .playback import PlaybackTransport
from .scenes import mixed_scene, random_omni_scene, referee_spec
from .simulator import LocalSimulator
from .transport import LocalSimTransport, ScriptedFaultTransport
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
"ScriptedFaultTransport", "mixed_scene", "random_omni_scene",
"referee_spec"]
+38
View File
@@ -0,0 +1,38 @@
"""回放传输:把既有演练日志作为服务器响应原样回放给客户端。
用于 P1 验收"本地状态机与官方响应一致":官方演练记录
(output/q1/practice/*_actions.json)逐条喂给客户端,客户端重建的
状态必须与当时服务器返回的虚拟时刻逐步吻合。
按录制顺序匹配(客户端的 request_id 生成规则与采集器不同,
故不参与匹配;故障重试场景请用 ScriptedFaultTransport)。
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from ..protocol.transport import Transport, TransportResponse
class PlaybackTransport(Transport):
"""events: 演练日志 [{path, request, response, ...}, ...]。"""
def __init__(self, events: list[dict]):
self.events = list(events)
self.index = 0
self.served: list[dict] = []
def post(self, url, body, headers, timeout):
if self.index >= len(self.events):
raise AssertionError("回放日志已耗尽仍有请求")
event = self.events[self.index]
path = urlsplit(url).path
if path != event["path"]:
raise AssertionError(
f"回放顺序不一致: 客户端请求 {path}, 录制预期 {event['path']}")
self.index += 1
self.served.append(event)
return TransportResponse(
200, json.dumps(event["response"], ensure_ascii=False).encode("utf-8"))
+55
View File
@@ -0,0 +1,55 @@
"""自检场景生成(测试端专用):委托 referee.scenes,映射本队配置。
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内
均匀分布、频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
均匀分布是测试假设,题面并未规定官方场景的概率分布。
"""
from __future__ import annotations
from referee import scenes as _referee_scenes
from ..config import ProblemConfig
def referee_spec(config: ProblemConfig = ProblemConfig(), *,
max_real_duration_s: float | None = None):
"""把本队 ProblemConfig 的题面常量映射为 referee 的 Spec。
max_real_duration_s 仅用于 --self-test 的实机预算覆盖(默认 1800s);
正式对接以服务器 /enter 返回为准,评测机默认保持题面 1200s。
"""
from referee import CostSpec, FieldSpec, Spec
field, cost = config.field, config.cost
return Spec(
field=FieldSpec(
omega_radius_m=field.omega_radius_m,
channel_min=field.channel_min, channel_max=field.channel_max,
source_count_min=field.source_count_min,
source_count_max=field.source_count_max,
r_eff_min_m=field.r_eff_min_m, r_eff_max_m=field.r_eff_max_m,
near_radius_m=field.near_radius_m, clear_radius_m=field.clear_radius_m,
bearing_error_half_deg=field.bearing_error_half_deg,
bearing_decimals=field.bearing_decimals,
coord_abs_limit_m=field.coord_abs_limit_m,
directional_half_angle_deg=field.directional_half_angle_deg,
max_virtual_duration_s=field.max_virtual_duration_s,
max_real_duration_s=(field.max_real_duration_s if max_real_duration_s is None
else max_real_duration_s),
test_window_s=field.test_window_s),
cost=CostSpec(speed_mps=cost.speed_mps, measure_s=cost.measure_s,
switch_s=cost.switch_s, clear_success_s=cost.clear_success_s,
clear_fail_s=cost.clear_fail_s))
def random_omni_scene(seed: int = 20260910, count: int | None = None,
config: ProblemConfig = ProblemConfig()):
"""全向源场景 [题面 问题3];签名沿用重构前 mcm26b.sim.scenes。"""
return _referee_scenes.random_omni_scene(seed, count, referee_spec(config))
def mixed_scene(seed: int = 20260910, count: int | None = None,
directional: int = 3, config: ProblemConfig = ProblemConfig()):
"""问题4混合场景:count 个源中 directional 个为定向源。"""
return _referee_scenes.mixed_scene(seed, count, directional, referee_spec(config))
+21
View File
@@ -0,0 +1,21 @@
"""兼容构造入口:本队 ProblemConfig -> referee.LocalSimulator。
评测机实现在独立包 referee(config= 换成了 spec=);本子类只做配置映射,
让既有测试与脚本继续以 ``config=ProblemConfig`` 构造,映射规则见
:func:`harness.scenes.referee_spec`。真值类型 Source 亦来自 referee。
"""
from __future__ import annotations
from referee import LocalSimulator as _RefereeLocalSimulator
from ..config import ProblemConfig
from .scenes import referee_spec
class LocalSimulator(_RefereeLocalSimulator):
def __init__(self, sources, *, robot_id: str = "2026001",
config: ProblemConfig = ProblemConfig(), **kwargs):
super().__init__(sources, robot_id=robot_id,
spec=referee_spec(config), **kwargs)
+61
View File
@@ -0,0 +1,61 @@
"""传输适配:评测机接口边界 <-> 本队 Transport 接口 + 故障注入脚本。
LocalSimTransport 把 referee 包的 handle_raw(与 HTTP 服务器共用的同一
边界实现)包成内存回环传输,不经过网络栈;故障注入语义与既有测试一致:
- ``"drop_before"`` 连接失败,动作未送达服务器(可安全重试);
- ``"timeout_after"`` 服务器已受理并生效,但响应丢失(必须靠幂等回放);
- ``("status", n)`` 服务器返回指定 HTTP 状态码(如 500/429);
- ``None`` 正常放行。
脚本耗尽后一律放行。
"""
from __future__ import annotations
from urllib.parse import urlsplit
from referee.interface import handle_raw
from ..protocol.transport import Transport, TransportError, TransportResponse
class LocalSimTransport(Transport):
"""内存回环:把独立评测机接到 Transport 接口(与真实 HTTP 同一边界)。"""
def __init__(self, simulator):
# simulator: referee.LocalSimulator(类型注解走字符串,避免导入开销)
self.simulator = simulator
self.received: list[tuple[str, dict]] = []
def post(self, url, body, headers, timeout):
return self.request("POST", url, body, headers, timeout)
def request(self, method, url, body, headers, timeout=None):
"""验证原始请求边界;内存回环不模拟 TCP、UI 或连接限流。"""
target = urlsplit(url)
raw_target = target.path + (f"?{target.query}" if target.query else "")
result = handle_raw(self.simulator, method, raw_target,
list(headers.items()), bytes(body),
on_request=lambda path, payload, _res:
self.received.append((path, payload)))
return TransportResponse(result.status, result.body)
class ScriptedFaultTransport(Transport):
"""按脚本注入故障后转发给内层传输。"""
def __init__(self, inner: Transport, script: list):
self.inner = inner
self.script = list(script)
self.sent_requests: list[bytes] = []
def post(self, url, body, headers, timeout):
self.sent_requests.append(body)
fault = self.script.pop(0) if self.script else None
if fault == "drop_before":
raise TransportError("injected: connection refused before send")
if fault == "timeout_after":
self.inner.post(url, body, headers, timeout) # 服务器已受理
raise TransportError("injected: read timeout after server applied")
if isinstance(fault, tuple) and fault and fault[0] == "status":
return TransportResponse(int(fault[1]), b'{"accepted": false, "real_timestamp_ms": 0, "virtual_time_s": 0}')
return self.inner.post(url, body, headers, timeout)
+1 -1
View File
@@ -16,7 +16,7 @@ m = sqrt(2)/2·h 是格心到格内任一点的最大距离。所有 kill 判定
- 清除证书:MEC(幸存).R + m <= 20 − 工程余量 ⟹ 真源距清除点 <= 20;
- 缺席证明:幸存集为空 ⟹ 可行集为空。
观测语义与 sim.Simulator 一致 [题面]:
观测语义与独立评测机 referee.LocalSimulator 一致 [题面]:
- no_signal:距离 > r_eff >= 1000 ⟹ 可行集 ∩ B(s,1000) = ∅;
- direction:距离 <= 1500,示向度误差 |读数−真值| <= δ_eff(含舍入裕量);
- near:距离 <= 5。