Merge remote-tracking branch 'origin/gpt' into q3-bayes

This commit is contained in:
Horldsence
2026-09-11 09:12:24 +08:00
13 changed files with 509 additions and 46 deletions
+1 -1
View File
@@ -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)
+6 -2
View File
@@ -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:
+1 -1
View File
@@ -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"]
+7 -1
View File
@@ -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,
+2 -2
View File
@@ -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"]
+2 -1
View File
@@ -1,8 +1,9 @@
"""自检场景生成器(测试端专用)。
真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应;
默认完全按题面生成:源总数 10~16 随机、位置在目标圆域内均匀分布、
默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内均匀分布、
频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。
这些均匀分布是测试假设,题面并未规定官方场景的概率分布。
边界情形(近圆域边界源、接收半径两档、定向朝外源)由专门测试显式构造。
"""
+103 -28
View File
@@ -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()
+54 -2
View File
@@ -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"))