Merge remote-tracking branch 'origin/gpt' into q3-bayes
This commit is contained in:
+1
-1
@@ -81,7 +81,7 @@ def _run(args) -> int:
|
||||
config=config)
|
||||
truth = sorted(source.channel for source in sources)
|
||||
transport = LocalSimTransport(LocalSimulator(sources, robot_id=args.robot_id,
|
||||
config=config))
|
||||
config=config, stress_mode=True))
|
||||
elif args.real_budget_s is not None:
|
||||
print("--real-budget-s 仅在 --self-test 下生效;正式测试以服务器 "
|
||||
"/enter 返回的 remaining_real_duration_s 为准。", file=sys.stderr)
|
||||
|
||||
@@ -22,6 +22,7 @@ def recompute_virtual_time(events: list[dict],
|
||||
position = (0.0, 0.0)
|
||||
channel = 1
|
||||
virtual = 0.0
|
||||
virtual_us = 0
|
||||
steps, mismatches = [], []
|
||||
for index, event in enumerate(events):
|
||||
path, request = event["path"], event["request"]
|
||||
@@ -31,17 +32,20 @@ def recompute_virtual_time(events: list[dict],
|
||||
continue
|
||||
if path == "/enter":
|
||||
position, channel, virtual = (0.0, 0.0), 1, response["virtual_time_s"]
|
||||
virtual_us = round(virtual * 1_000_000)
|
||||
elif path == "/measure":
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
predicted = measure_cost(position, pos, channel, request["channel"], cost)
|
||||
position, channel = pos, request["channel"]
|
||||
virtual += predicted.total_s
|
||||
virtual_us += predicted.total_us
|
||||
virtual = virtual_us / 1_000_000
|
||||
elif path == "/clear":
|
||||
pos = (request["position"]["x"], request["position"]["y"])
|
||||
success = response.get("clear_result") == CLEAR_SUCCESS
|
||||
predicted = clear_cost(position, pos, success, cost)
|
||||
position = pos
|
||||
virtual += predicted.total_s
|
||||
virtual_us += predicted.total_us
|
||||
virtual = virtual_us / 1_000_000
|
||||
elif path == "/exit":
|
||||
pass # /exit 不推进虚拟时钟 [题面]
|
||||
else:
|
||||
|
||||
@@ -138,7 +138,7 @@ class SimulatorClient:
|
||||
response = self._call("/enter", payload)
|
||||
self.state.apply_enter(response)
|
||||
remaining = response["remaining_real_duration_s"]
|
||||
if not isinstance(remaining, (int, float)) or remaining <= 0:
|
||||
if not isinstance(remaining, (int, float)) or remaining < 0:
|
||||
raise ProtocolViolation("remaining_real_duration_s 缺失或非法", body=response)
|
||||
self._deadline = self.clock() + remaining
|
||||
self._server_deadline_ms = (response["real_timestamp_ms"]
|
||||
|
||||
@@ -24,7 +24,13 @@ class CostBreakdown:
|
||||
|
||||
@property
|
||||
def total_s(self) -> float:
|
||||
return self.move_s + self.switch_s + self.action_s
|
||||
return self.total_us / 1_000_000
|
||||
|
||||
@property
|
||||
def total_us(self) -> int:
|
||||
"""逐段量化后按整数微秒累计,与官方虚拟时钟一致。"""
|
||||
return sum(round(value * 1_000_000)
|
||||
for value in (self.move_s, self.switch_s, self.action_s))
|
||||
|
||||
|
||||
def measure_cost(prev_pos: Vec2, pos: Vec2, prev_channel: int, channel: int,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""自建模拟器与测试侧传输:只对测试端开放真值,策略端仅见观测。"""
|
||||
|
||||
from .playback import PlaybackTransport
|
||||
from .simulator import LocalSimulator, Source
|
||||
from .simulator import LocalSimulator, SimulatorClosed, Source
|
||||
from .transport import LocalSimTransport, ScriptedFaultTransport
|
||||
|
||||
__all__ = ["LocalSimTransport", "LocalSimulator", "PlaybackTransport",
|
||||
"ScriptedFaultTransport", "Source"]
|
||||
"ScriptedFaultTransport", "Source", "SimulatorClosed"]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""自检场景生成器(测试端专用)。
|
||||
|
||||
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
|
||||
默认完全按题面生成:源总数 10~16 随机、位置在目标圆域内均匀分布、
|
||||
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内均匀分布、
|
||||
频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
|
||||
这些均匀分布是测试假设,题面并未规定官方场景的概率分布。
|
||||
边界情形(近圆域边界源、接收半径两档、定向朝外源)由专门测试显式构造。
|
||||
"""
|
||||
|
||||
|
||||
+103
-28
@@ -3,8 +3,8 @@
|
||||
用途(P1 验收"自建回放测试"):不依赖官方模拟器即可测试客户端、
|
||||
故障恢复与后续策略。真值 Source 只对测试端开放,策略端只能拿到响应。
|
||||
|
||||
与官方实现的边界:本类只承诺"协议可观测行为一致"(响应字段、
|
||||
耗时推进、幂等与拒绝语义);示向度误差分布是测试假设,不代表官方实现。
|
||||
与官方实现的边界:复现公开规定的响应字段、耗时、截止、幂等与拒绝语义,
|
||||
不复现TCP限流、UI、上传生命周期;示向度误差分布是测试假设,不代表官方实现。
|
||||
误差场为逐点固定:同一(源频道,位置)的示向度误差由种子确定性决定,
|
||||
重复测量读数不变 [题面/官方演练实测];不同位置误差独立(白噪声假设)。
|
||||
"""
|
||||
@@ -15,7 +15,10 @@ import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from math import atan2, degrees, hypot, isfinite
|
||||
from time import time
|
||||
from time import monotonic, time
|
||||
from threading import Event, Lock
|
||||
from unicodedata import category
|
||||
from copy import deepcopy
|
||||
|
||||
from ..config import ProblemConfig
|
||||
from ..protocol.rules import (ACTION_FIELDS, CLEAR_NO_TARGET, CLEAR_RESULTS,
|
||||
@@ -23,6 +26,11 @@ from ..protocol.rules import (ACTION_FIELDS, CLEAR_NO_TARGET, CLEAR_RESULTS,
|
||||
EXIT_USER, MEASURE_RESULTS, NEAR, NO_SIGNAL,
|
||||
POSITION_FIELDS)
|
||||
from ..protocol.timing import clear_cost, measure_cost
|
||||
from ..protocol.transport import TransportError
|
||||
|
||||
|
||||
class SimulatorClosed(TransportError):
|
||||
"""测试结束,官方接口此时关闭连接,不再返回业务响应。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -38,12 +46,18 @@ class Source:
|
||||
|
||||
|
||||
class LocalSimulator:
|
||||
"""handle(path, payload) -> (http_status, body_dict)。"""
|
||||
"""handle返回状态码及JSON;测试结束后抛出SimulatorClosed。
|
||||
|
||||
默认执行官方时限;stress_mode允许配置更长预算和窗口。
|
||||
clock为单调秒时钟,wall_clock为Unix秒时钟;窗口起点默认为接口开放时刻。
|
||||
rtt_ms仅保留旧调用兼容,不再制造响应时间戳或延迟。
|
||||
"""
|
||||
|
||||
def __init__(self, sources: list[Source], *, robot_id: str = "2026001",
|
||||
config: ProblemConfig = ProblemConfig(),
|
||||
error_half_deg: float | None = None, error_seed: int = 20260910,
|
||||
rtt_ms: float = 40.0):
|
||||
rtt_ms: float = 40.0, clock=monotonic, wall_clock=time,
|
||||
window_started_at: float | None = None, stress_mode: bool = False):
|
||||
channels = [s.channel for s in sources]
|
||||
if len(channels) != len(set(channels)):
|
||||
raise ValueError("每个频道最多对应一个干扰源")
|
||||
@@ -60,7 +74,17 @@ class LocalSimulator:
|
||||
self.error_half_deg = (self.field.bearing_error_half_deg
|
||||
if error_half_deg is None else error_half_deg)
|
||||
self.error_seed = error_seed
|
||||
self._rtt_ms = rtt_ms
|
||||
self._clock = clock
|
||||
self._wall_clock = wall_clock
|
||||
self.stress_mode = stress_mode
|
||||
self._real_limit = (self.field.max_real_duration_s if stress_mode
|
||||
else min(1200, self.field.max_real_duration_s))
|
||||
start = clock() if window_started_at is None else window_started_at
|
||||
self._deadline = start + (max(self.field.test_window_s, self._real_limit)
|
||||
if stress_mode else min(1500, self.field.test_window_s))
|
||||
self._lock = Lock()
|
||||
self._pending = None
|
||||
self.closed_reason = None
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
self.position = (0.0, 0.0)
|
||||
@@ -71,6 +95,55 @@ class LocalSimulator:
|
||||
|
||||
# ------------------------------------------------------------------ 主入口
|
||||
def handle(self, path: str, payload) -> tuple[int, dict]:
|
||||
# 只在登记/发布时持锁;动作执行期间不同请求必须立即得到409。
|
||||
fingerprint = self._fingerprint(path, payload)
|
||||
request_id = payload.get("request_id") if isinstance(payload, dict) else None
|
||||
with self._lock:
|
||||
self.ensure_open()
|
||||
if self._pending is not None:
|
||||
pending = self._pending
|
||||
if pending["id"] != request_id or pending["fingerprint"] != fingerprint:
|
||||
return 409, self._reject(path)
|
||||
owner = False
|
||||
else:
|
||||
pending = {"id": request_id, "fingerprint": fingerprint,
|
||||
"done": Event(), "result": None, "error": None}
|
||||
self._pending = pending
|
||||
self._registered_at = self._clock()
|
||||
owner = True
|
||||
if not owner:
|
||||
pending["done"].wait()
|
||||
if pending["error"] is not None:
|
||||
raise pending["error"]
|
||||
return deepcopy(pending["result"])
|
||||
try:
|
||||
result = self._handle(path, payload)
|
||||
pending["result"] = deepcopy(result)
|
||||
return result
|
||||
except BaseException as exc:
|
||||
pending["error"] = exc
|
||||
raise
|
||||
finally:
|
||||
with self._lock:
|
||||
try:
|
||||
self.ensure_open()
|
||||
except SimulatorClosed:
|
||||
pass # 已登记动作仍可取得完整响应。
|
||||
self._pending = None
|
||||
pending["done"].set()
|
||||
|
||||
def ensure_open(self):
|
||||
if self.closed_reason is None:
|
||||
if self.exited:
|
||||
self.closed_reason = "user_exit"
|
||||
elif self.virtual_time_s >= self.field.max_virtual_duration_s:
|
||||
self.closed_reason = "virtual_time_limit"
|
||||
elif self._clock() >= self._deadline:
|
||||
self.closed_reason = "real_time_limit"
|
||||
if self.closed_reason is not None:
|
||||
raise SimulatorClosed(self.closed_reason)
|
||||
|
||||
def _handle(self, path: str, payload) -> tuple[int, dict]:
|
||||
if path not in ACTION_FIELDS:
|
||||
return 404, self._reject(path)
|
||||
if not isinstance(payload, dict):
|
||||
@@ -78,10 +151,9 @@ class LocalSimulator:
|
||||
# 1) 公共字段存在性与类型(缺失/类型错 -> 400 [题面])
|
||||
for name, spec in COMMON_FIELDS.items():
|
||||
value = payload.get(name)
|
||||
if not isinstance(value, str) or (spec.max_bytes
|
||||
and len(value.encode("utf-8")) > spec.max_bytes):
|
||||
return 400, self._reject(path)
|
||||
if not value:
|
||||
if (not isinstance(value, str) or not value
|
||||
or any(category(c) in ("Cc", "Cf", "Cs") for c in value)
|
||||
or (spec.max_bytes and len(value.encode("utf-8")) > spec.max_bytes)):
|
||||
return 400, self._reject(path)
|
||||
# 2) 动作字段存在性 -> 400
|
||||
spec = ACTION_FIELDS[path]
|
||||
@@ -120,19 +192,14 @@ class LocalSimulator:
|
||||
if self.entered or self.exited:
|
||||
return 200, self._not_accepted()
|
||||
self.entered = True
|
||||
self._deadline = min(self._deadline, self._registered_at + self._real_limit)
|
||||
return 200, self._ok({
|
||||
"max_virtual_duration_s": self.field.max_virtual_duration_s,
|
||||
"max_real_duration_s": self.field.max_real_duration_s,
|
||||
"remaining_real_duration_s": self.field.max_real_duration_s,
|
||||
"max_real_duration_s": self._real_limit,
|
||||
"remaining_real_duration_s": int(self._deadline - self._registered_at),
|
||||
})
|
||||
if not self.entered or self.exited:
|
||||
return 200, self._not_accepted()
|
||||
if path in ("/measure", "/clear") \
|
||||
and self.virtual_time_s >= self.field.max_virtual_duration_s:
|
||||
# 虚拟世界限时 360000s 防死循环 [题面/附件2 §4.5:达限结束测试]。
|
||||
# 官方在达限时关闭接口;本地以 accepted=false 模拟,作为计划层
|
||||
# 虚拟安全余量(search.virtual_reserve_s)失效时的兜底。
|
||||
return 200, self._not_accepted()
|
||||
if path == "/exit":
|
||||
self.exited = True
|
||||
return 200, self._ok({"exit_reason": EXIT_USER})
|
||||
@@ -143,12 +210,12 @@ class LocalSimulator:
|
||||
for key in ("x", "y"):
|
||||
value = position[key]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) \
|
||||
or not isfinite(value) or abs(value) > self.field.coord_abs_limit_m:
|
||||
or abs(value) > self.field.coord_abs_limit_m or not isfinite(value):
|
||||
return 400, self._reject(path)
|
||||
channel = payload.get("channel")
|
||||
if isinstance(channel, bool) or not isinstance(channel, (int, float)) \
|
||||
or not float(channel).is_integer() \
|
||||
or not self.field.channel_min <= channel <= self.field.channel_max:
|
||||
or not self.field.channel_min <= channel <= self.field.channel_max \
|
||||
or not float(channel).is_integer():
|
||||
return 400, self._reject(path)
|
||||
channel = int(channel)
|
||||
pos = (float(position["x"]), float(position["y"]))
|
||||
@@ -207,7 +274,8 @@ class LocalSimulator:
|
||||
坐标按 1e-6 m 量化后与种子、频道一起哈希,映射到
|
||||
[-error_half_deg, +error_half_deg);不同位置近似独立(白噪声假设)。
|
||||
"""
|
||||
key = f"{self.error_seed}|{channel}|{round(pos[0], 6)}|{round(pos[1], 6)}"
|
||||
x, y = (round(float(value), 6) or 0.0 for value in pos)
|
||||
key = f"{self.error_seed}|{channel}|{x}|{y}"
|
||||
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
||||
unit = int.from_bytes(digest[:8], "big") / 2.0 ** 64
|
||||
return (2.0 * unit - 1.0) * self.error_half_deg
|
||||
@@ -224,21 +292,28 @@ class LocalSimulator:
|
||||
|
||||
# ------------------------------------------------------------------ 计时与响应
|
||||
def _advance(self, seconds: float) -> None:
|
||||
self.virtual_time_s += seconds
|
||||
self._virtual_us += round(seconds * 1_000_000)
|
||||
|
||||
@property
|
||||
def virtual_time_s(self):
|
||||
return self._virtual_us / 1_000_000
|
||||
|
||||
@virtual_time_s.setter
|
||||
def virtual_time_s(self, seconds):
|
||||
self._virtual_us = round(seconds * 1_000_000)
|
||||
|
||||
def _ok(self, extra: dict) -> dict:
|
||||
self._rtt_ms += 40.0
|
||||
body = {"accepted": True,
|
||||
"real_timestamp_ms": 1_760_000_000_000.0 + self._rtt_ms,
|
||||
"real_timestamp_ms": int(self._wall_clock() * 1000),
|
||||
"virtual_time_s": self.virtual_time_s, **extra}
|
||||
return body
|
||||
|
||||
def _not_accepted(self) -> dict:
|
||||
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
|
||||
return {"accepted": False, "real_timestamp_ms": int(self._wall_clock() * 1000),
|
||||
"virtual_time_s": 0} # accepted=false 时恒为 0 [题面]
|
||||
|
||||
def _reject(self, path: str) -> dict:
|
||||
return {"accepted": False, "real_timestamp_ms": 1_760_000_000_000.0,
|
||||
return {"accepted": False, "real_timestamp_ms": int(self._wall_clock() * 1000),
|
||||
"virtual_time_s": 0, "detail": f"{path} 请求被拒绝"}
|
||||
|
||||
@staticmethod
|
||||
@@ -246,4 +321,4 @@ class LocalSimulator:
|
||||
canonical = json.dumps({"path": path, "payload": payload},
|
||||
sort_keys=True, ensure_ascii=False,
|
||||
separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
return hashlib.sha256(canonical.encode("utf-8", errors="surrogatepass")).hexdigest()
|
||||
|
||||
@@ -15,6 +15,28 @@ from urllib.parse import urlsplit
|
||||
|
||||
from ..protocol.transport import Transport, TransportError, TransportResponse
|
||||
from .simulator import LocalSimulator
|
||||
from ..protocol.rules import ACTION_FIELDS
|
||||
|
||||
|
||||
def _object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _invalid_constant(value):
|
||||
raise ValueError("non-finite JSON number")
|
||||
|
||||
|
||||
def _depth(value):
|
||||
if isinstance(value, dict):
|
||||
return 1 + max((_depth(v) for v in value.values()), default=0)
|
||||
if isinstance(value, list):
|
||||
return 1 + max((_depth(v) for v in value), default=0)
|
||||
return 0
|
||||
|
||||
|
||||
class LocalSimTransport(Transport):
|
||||
@@ -25,8 +47,38 @@ class LocalSimTransport(Transport):
|
||||
self.received: list[tuple[str, dict]] = []
|
||||
|
||||
def post(self, url, body, headers, timeout):
|
||||
path = urlsplit(url).path
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
return self.request("POST", url, body, headers, timeout)
|
||||
|
||||
def request(self, method, url, body, headers, timeout=None):
|
||||
"""验证原始请求边界;内存回环不模拟TCP、UI或连接限流。"""
|
||||
self.simulator.ensure_open()
|
||||
target = urlsplit(url)
|
||||
path = target.path
|
||||
def reject(status):
|
||||
return TransportResponse(status, json.dumps(
|
||||
self.simulator._reject(path), ensure_ascii=False).encode("utf-8"))
|
||||
if path not in ACTION_FIELDS or "?" in url or "#" in url:
|
||||
return reject(404)
|
||||
if method != "POST":
|
||||
return reject(405)
|
||||
normalized = {key.lower(): value.strip().lower() for key, value in headers.items()}
|
||||
if len(normalized) != len(headers):
|
||||
return reject(415)
|
||||
media = [part.strip() for part in normalized.get("content-type", "").split(";")]
|
||||
if (media[0] != "application/json" or len(media) > 2
|
||||
or (len(media) == 2 and media[1].replace(" ", "")
|
||||
not in ('charset=utf-8', 'charset="utf-8"'))
|
||||
or normalized.get("content-encoding", "identity") != "identity"):
|
||||
return reject(415)
|
||||
if len(body) > 65536:
|
||||
return reject(413)
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"), object_pairs_hook=_object,
|
||||
parse_constant=_invalid_constant)
|
||||
if not isinstance(payload, dict) or _depth(payload) > 16:
|
||||
return reject(400)
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
return reject(400)
|
||||
self.received.append((path, payload))
|
||||
status, response = self.simulator.handle(path, payload)
|
||||
return TransportResponse(status, json.dumps(response, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
Reference in New Issue
Block a user