6
This commit is contained in:
+202
-17
@@ -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
@@ -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 颁发清除证书(凸证书之外)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user