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
+7 -1
View File
@@ -68,9 +68,15 @@ uv run python scripts/benchmark_q1.py # MEC 基准与舍入敏感性
- `protocol.client.SimulatorClient`:串行发送、幂等重试(同 ID 同内容原样重发)、
连接/读超时、基于 `remaining_real_duration_s` 的本地截止时钟与退出预留、JSONL 会话日志;
- `sim.LocalSimulator`:按附件语义复现模拟器(观测门限、定向覆盖角、幂等回放、
400/accepted=false 语义),真值只对测试端开放;`ScriptedFaultTransport` 注入断连/超时/500,
整数微秒计时、现实/虚拟截止、并发409和请求校验),真值只对测试端开放;`ScriptedFaultTransport` 注入断连/超时/500,
`PlaybackTransport` 回放官方演练日志。
本地模拟器默认执行至多1200秒程序预算、1500秒接口开放窗口,截止后通过
`SimulatorClosed` 模拟连接关闭,已登记动作可以完成。可注入 `clock`、`wall_clock`
和 `window_started_at` 测试迟到与超时。CLI `--self-test` 显式使用 `stress_mode=True`,
保留默认1800秒压力预算并扩展窗口;这不是官方演练时限。
内存传输不实现TCP连接限流、UI或日志上传;场景均匀分布及逐点哈希误差仍是测试假设。
```python
from mcm26b.protocol import SimulatorClient
client = SimulatorClient("参赛队号") # 默认 http://127.0.0.1:2026
@@ -0,0 +1,96 @@
# 本地模拟器与官方规则等价性审核
审核日期:2026-09-11。版本:`7930eb65bf33ac217e012c916fed0b9758e52587`,重点检查新增提交 `fc70d6e` 及当前完整模拟器实现。审核开始时工作区干净。未修改生产代码,未调用官方测试。
结论:**当前实现是可用于算法自检的简化模拟器,不能称为官方题面和通信协议的等价实现。** 基本物理门限、位置和频道状态、串行合法动作的计费公式大体一致;截止时间、错误请求、并发、微秒时钟以及一个同位置误差边界存在可复现差异。部分差异原先已有,新增的虚拟超限拦截仍未实现官方结束状态。
依据为项目原始 `issue/B题.pdf`(已逐页核对)、`issue/附件/附件2.docx` 的第1—9节;正文提及官方行为均来自这些规则,未假设已知官方源码或隐藏的随机分布。
## 已复现的问题
### 1. [P1] 没有模拟现实截止时间和25分钟窗口
位置:[simulator.py:118](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:118)、[simulator.py:229](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:229)。
`/enter` 总是返回配置的完整时长,没有测试窗口起点、进入时刻或服务端现实截止状态。响应时间戳也仅在每次成功动作后加40毫秒。模拟器本身从未依据现实经过时间拒绝动作。客户端的主动预算检查不能代替独立服务器约束;否则客户端预算代码失效时,自建测试仍可能成功。
复现将现实上限缩短为0.02秒,在 `/enter` 后真实等待约0.04秒,新的 `/measure` 仍被接受。缩短阈值只用于快速验证缺失的服务端截止机制,不表示官方允许小数秒上限。
官方附件2第4.5、6.2节要求截止时刻为25分钟窗口截止与进入后20分钟中较早者,剩余完整秒数范围0—1200,截止后不执行新请求。建议引入可注入单调时钟和窗口起点,独立于客户端模拟这些约束。
### 2. [P1] 虚拟超限后仍允许成功退出,掩盖了被强制结束
位置:[simulator.py:130](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:130)。这是 `fc70d6e` 新增逻辑中的差异。
当前超限检查只拒绝 `/measure` 和 `/clear`,且不设置终止状态;紧随其后的 `/exit` 仍返回 `accepted=true, exit_reason=user_exit`。
复现:从原点检测 `(1800000,0)` 后虚拟时间为360005秒,下一次检测被拒绝,但 `/exit` 成功。允许已经登记的跨限动作完成本身符合附件;错误在于超限后还能登记新的退出动作。
官方附件2第4.5、9.2节规定达到限时即结束并关闭接口,不能事后 `/exit` 查询或得到正常退出。建议统一终止状态,在越界动作完成后关闭后续新动作入口,内存传输用明确的连接关闭异常模拟,保留已登记请求的处理规则。
### 3. [P2] 传输层绕过了多项官方请求校验
位置:[transport.py:27](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/transport.py:27)。
`urlsplit(url).path` 丢弃查询参数,`json.loads` 默认接受重复键,headers完全未检查,也没有请求体大小检查。以下探针均得到200且接受:带查询参数的 `/enter?wrong=1`、Content-Type为 `text/plain`、重复 `request_id` 键、超过65536字节的合法JSON加空白请求体。非法JSON则直接抛出 `JSONDecodeError`,没有按官方返回400 JSON错误。
附件2第5.1—5.3节分别要求404、415、400、413及400。当前内存回环并不是HTTP服务,本身可以选择不实现完整网络栈,但必须明确它不覆盖协议等价性;若用于接口兼容验收,应补独立请求边界校验与错误映射。BOM、嵌套深度及Content-Encoding同样需要按规范验证。
### 4. [P2] 标识符校验遗漏控制字符和格式字符
位置:[simulator.py:79](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:79)。
公共字段只验证字符串、非空和UTF-8长度。`request_id="e\n"` 与 `request_id="e\u200b"` 的 `/enter` 都能成功,且占用幂等ID;官方附件2第5.1、6.3节要求400,并不占用ID。应按官方规定检查控制字符和不可见格式字符。仅检查ASCII控制字符也不足以覆盖U+200B等Unicode格式字符。
### 5. [P2] 并发不同动作未返回409,还会使用旧状态计费
位置:[simulator.py:104](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:104)。
校验、执行和幂等写入之间没有会话忙标志或同步机制。通过在观测函数放置线程屏障,令两个不同ID的测量同时处理,两个请求均返回200且接受;第二请求没有按附件2第1.4、5.3节返回409。两次费用计算还可能都从同一旧位置/频道出发。
顺序调用下的现有幂等测试无法发现该问题。若该类仅保证调用者串行,需缩小“协议行为一致”的声明;若用于验证官方并发规则,则需要原子化登记、忙状态及同ID未决请求处理,而不是仅依赖客户端不并发。
### 6. [P2] 虚拟时钟为浮点累计,没有官方整数微秒量化
位置:[simulator.py:226](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:226)。
从 `(0,0)` 测到 `(1,1)`,本地返回 `5.282842712474619`,而按官方微秒计时为 `5.282843`。用项目已保存的官方动作前缀也能观察到相同精度差异。连续累计时差可能超过现有复算的固定1微秒阈值。
附件2第4.1节规定内部按微秒累计、响应最多6位小数。建议把内部时钟改为整数微秒,并使用从官方记录验证过的逐动作量化规则;只在输出末尾取6位,无法消除逐步累计差异。模拟器与客户端共用连续计时函数的测试通过,只证明两者彼此一致,不能证明与官方一致。
### 7. [P2] 正负零被当作不同地点,违反固定地点固定误差
位置:[simulator.py:210](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/simulator.py:210)。
误差键直接拼接 `round()` 后浮点字符串,保留 `0.0` 与 `-0.0` 的区别。源设为 `(1000,0)`,同频道在 `(0.0,0.0)` 测得359.75°,随后在 `(-0.0,0.0)` 测得0.09°。两个检测位置是同一个几何点,却得到不同误差,可被利用成无移动的额外误差样本。
题面附录2第(1)项规定同一地点重复检测不改变误差。建议将量化坐标转换为规范化整数格索引,或至少将所有正负零统一,保证数值相等的坐标使用同一键。
## 符合题设的部分与尚不能判定等价的假设
| 项目 | 审核结果 |
| --- | --- |
| 距离超过实际接收半径返回无信号,等于半径仍可收 | 一致;额外检查了1000米及1000.0001米边界 |
| 5米以内near,超过5米返回方向 | 一致;检查了5米及5.0001米 |
| 20米以内可清除,定向背面也可清除 | 一致;检查了20米及20.0001米,清除不依赖朝向 |
| 清除后同源不能再次清除、同频道不再发信号 | 相关已有测试通过 |
| 移动距离/5,检测5秒,切频道1秒,清除成功5秒/失败3秒 | 连续公式一致;微秒累计另见问题6 |
| clear不改变测向频道;新enter初始原点和频道1 | 一致 |
| 合法串行请求相同ID相同内容回放、不同内容409 | 基本一致;不覆盖并发及终止后的连接状态 |
| 坐标范围、频道范围、未知字段与robot/arena不匹配 | 常规分支已有实现;不替代原始请求校验 |
| 源数10—16、源位置在圆内、半径1000—1500 | 默认生成器满足范围 |
| 源数、位置、接收半径均匀分布 | 题面没有规定均匀分布,只规定范围和未知性;这是合法的自建分布,不能称为官方分布 |
| 逐位置哈希白噪声误差 | 是一种测试假设;题面只规定有界及同地点固定,没有规定不同点独立或均匀 |
| 自检默认30分钟 | `cli.py` 和配置显式提供的工程模式;题面正式与演练均是至多20分钟程序预算。可保留压力模式,但与官方等价模式应分开 |
| 测试用0源/1源、指定半径等构造 | 可作为单元测试输入,无须禁止;不能当作官方合法案例生成的保证 |
| 官方随机种子、案例分布、加密日志、UI/上传生命周期 | 未复现或不可由公开说明推出,不作等价声明 |
[`scenes.py`](C:/Users/HelixCopex/code/MATH/src/mcm26b/sim/scenes.py:4) 中“默认完全按题面生成”应改成“采用满足题面范围的自建随机场景”,避免把新增的面积均匀与半径均匀假设解释成题面要求。第四问默认指定3个定向源也仅覆盖一种配置,不能据此代表未知定向数量的全部情况。
## 验证与建议顺序
运行 `pytest tests/test_protocol_timing.py tests/test_protocol_client.py tests/test_belief.py -q`:47项通过。额外6个距离门限探针通过。上述7类问题均有独立反例或控制条件复现,说明现有测试缺少对这些官方约束的覆盖。
复现脚本:[reproduce.py](C:/Users/HelixCopex/code/MATH/tmp/simulator-audit/reproduce.py);原始结果:[results.json](C:/Users/HelixCopex/code/MATH/tmp/simulator-audit/results.json)。脚本仅运行本地对象和内存传输,没有连接2026端口;现实超时探针使用短时限避免等待20分钟。
建议先区分“官方约束模式”和“可放宽参数的压力测试模式”,优先修复独立截止时钟与终止状态;随后统一整数微秒计时和坐标规范化;最后补充原始协议输入、标识符及并发验收。当前物理算法自检结果仍有价值,但不能据此保证官方超时、重试、错误请求和并发行为都已验证。
+24
View File
@@ -0,0 +1,24 @@
# 模拟器审核修复记录
日期:2026-09-11。对应 `simulator-equivalence-7930eb6.md` 的七项问题。
| 审核项 | 修复 |
| --- | --- |
| 现实截止 | 注入单调时钟、Unix时钟与窗口起点;默认取25分钟窗口与进入后20分钟的较早截止,返回剩余完整秒数。时间戳来自Unix时钟。 |
| 虚拟截止 | 整数微秒累计;已登记跨限动作完成并返回结果,此后包括exit在内的新请求抛出SimulatorClosed,模拟连接关闭。正常exit也关闭接口。 |
| 请求边界 | 内存传输校验精确路径、方法、媒体类型和编码、UTF-8/BOM、重复键、JSON对象、16层深度、65536字节上限;返回相应JSON错误。 |
| 标识符 | 拒绝Unicode控制、格式和代理字符,保留UTF-8字节长度检查;非法请求不写入幂等缓存。 |
| 并发 | 锁内原子登记;不同请求立即409,同ID不同内容409;同ID同内容等待首次结果,动作只执行一次。执行期间不持有登记锁。 |
| 微秒精度 | 各计费段量化为整数微秒;模拟器和离线复算使用整数累计,客户端共享量化费用模型。 |
| 正负零 | 哈希坐标规范化零值,同一几何点的正负零表示产生相同固定误差。 |
CLI自检显式使用 `stress_mode=True`,保留原有1800秒预算并相应扩展窗口。
直接构造模拟器默认遵守官方时间上限。场景生成文档明确均匀分布属于测试假设。
客户端接受合法的零秒剩余预算,后续动作仍受预算检查保护。
验证:`.venv/Scripts/python.exe -m pytest -q`,**144 passed in 33.33s**。
新增 `tests/test_simulator_conformance.py` 共31个测试案例,覆盖审核反例、临界值、
并发等待及截止期间动作完成。原有虚拟超限测试改为验证连接关闭,官方日志回放与策略测试通过。
本轮仅运行本地测试,没有连接2026端口,也没有消耗官方演练案例。
该实现仍是内存模拟器;TCP连接限流、官方UI/上传生命周期及隐藏随机分布不在等价范围内。
+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"))
+6 -7
View File
@@ -211,13 +211,12 @@ class SimulatorVirtualCapTests(unittest.TestCase):
simulator = LocalSimulator([], robot_id="T001", config=CONFIG)
simulator.entered = True
simulator.virtual_time_s = CONFIG.field.max_virtual_duration_s
status, body = simulator.handle(
"/measure", {"arena_id": "default", "robot_id": "T001",
"request_id": "x1",
"position": {"x": 0.0, "y": 0.0}, "channel": 1})
self.assertEqual(status, 200)
self.assertFalse(body["accepted"])
self.assertEqual(body["virtual_time_s"], 0)
from mcm26b.sim import SimulatorClosed
with self.assertRaises(SimulatorClosed):
simulator.handle(
"/measure", {"arena_id": "default", "robot_id": "T001",
"request_id": "x1",
"position": {"x": 0.0, "y": 0.0}, "channel": 1})
class AbortFinalizationTests(unittest.TestCase):
+200
View File
@@ -0,0 +1,200 @@
"""审核报告的独立反例:时限、原始协议、并发和量化。"""
import json
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from threading import Event
import pytest
from mcm26b.config import ProblemConfig
from mcm26b.sim import LocalSimulator, LocalSimTransport, SimulatorClosed, Source
def payload(id="e", **extra):
return dict(arena_id="default", robot_id="2026001", request_id=id, **extra)
def measure(id="m", x=0, y=0):
return payload(id, position=dict(x=x, y=y), channel=1)
def test_late_enter_and_deadline():
now = [1400.25]
sim = LocalSimulator([], clock=lambda: now[0], wall_clock=lambda: 1234.5,
window_started_at=0)
response = sim.handle("/enter", payload())[1]
assert response["remaining_real_duration_s"] == 99
assert response["real_timestamp_ms"] == 1234500
now[0] = 1500
for path, body in [("/measure", measure()), ("/exit", payload("x"))]:
with pytest.raises(SimulatorClosed):
sim.handle(path, body)
assert sim.virtual_time_s == 0
def test_program_deadline_and_subsecond_enter():
now = [100]
sim = LocalSimulator([], clock=lambda: now[0], window_started_at=0)
assert sim.handle("/enter", payload())[1]["remaining_real_duration_s"] == 1200
now[0] = 1300
with pytest.raises(SimulatorClosed):
sim.handle("/measure", measure())
late = LocalSimulator([], clock=lambda: 1499.5, window_started_at=0)
assert late.handle("/enter", payload())[1]["remaining_real_duration_s"] == 0
expired = LocalSimulator([], clock=lambda: 1500, window_started_at=0)
with pytest.raises(SimulatorClosed):
expired.handle("/enter", payload())
def test_stress_mode_is_explicit():
config = ProblemConfig()
config = replace(config, field=replace(config.field, max_real_duration_s=1800))
official = LocalSimulator([], config=config, clock=lambda: 0)
stress = LocalSimulator([], config=config, clock=lambda: 0, stress_mode=True)
assert official.handle("/enter", payload())[1]["remaining_real_duration_s"] == 1200
assert stress.handle("/enter", payload())[1]["remaining_real_duration_s"] == 1800
def test_virtual_crossing_action_finishes_then_connection_closes():
sim = LocalSimulator([])
sim.handle("/enter", payload())
assert sim.handle("/measure", measure(x=1800000))[1]["virtual_time_s"] == 360005
for path, body in [("/measure", measure("m2")), ("/exit", payload("x"))]:
with pytest.raises(SimulatorClosed):
sim.handle(path, body)
assert not sim.exited
def test_microseconds_accumulate_and_signed_zero_is_same_location():
sim = LocalSimulator([Source(1, 1000, 0, 1500)])
sim.handle("/enter", payload())
a = sim.handle("/measure", measure("a", 0.0))[1]
b = sim.handle("/measure", measure("b", -0.0))[1]
assert a["svd_deg"] == b["svd_deg"]
for i in range(100):
point = 1 if i % 2 == 0 else 0
sim.handle("/measure", measure(str(i), point, point))
assert sim.virtual_time_s == 10 + 100 * 5282843 / 1000000
@pytest.mark.parametrize("bad", ["e\n", "e\u200b", "e\u0085", "e\ud800"])
def test_identifier_rejection_does_not_consume_id(bad):
sim = LocalSimulator([])
assert sim.handle("/enter", payload(bad))[0] == 400
assert sim._idempotency == {}
assert sim.handle("/enter", payload())[1]["accepted"]
@pytest.mark.parametrize("body,status", [
(b'{"request_id":"a","request_id":"b"}', 400),
(b'{"position":{"x":1,"x":2}}', 400),
(b'{}' + b' ' * 65535, 413),
(b'\xef\xbb\xbf{}', 400), (b'\xff', 400), (b'{', 400),
(b'[]', 400), (b'{"x":NaN}', 400), (b'{"x":Infinity}', 400),
(b'{"a":' + b'[' * 16 + b'0' + b']' * 16 + b'}', 400),
], ids=["duplicate-root", "duplicate-nested", "oversize", "bom", "utf8",
"syntax", "non-object", "nan", "infinity", "depth"])
def test_raw_json(body, status):
sim = LocalSimulator([])
response = LocalSimTransport(sim).post("http://local/enter", body,
{"Content-Type": "application/json"}, None)
assert response.status == status
assert json.loads(response.body)["accepted"] is False
assert not sim.entered
@pytest.mark.parametrize("method,path,headers,status", [
("POST", "/enter?x=1", {"Content-Type": "application/json"}, 404),
("POST", "/enter?", {"Content-Type": "application/json"}, 404),
("POST", "/enter/", {"Content-Type": "application/json"}, 404),
("GET", "/enter", {"Content-Type": "application/json"}, 405),
("POST", "/enter", {"Content-Type": "text/plain"}, 415),
("POST", "/enter", {"Content-Type": "application/json;foo=bar"}, 415),
("POST", "/enter", {"Content-Type": "application/json", "Content-Encoding": "gzip"}, 415),
("POST", "/enter", {"content-type": "Application/JSON; charset=UTF-8"}, 200),
])
def test_http_boundary(method, path, headers, status):
transport = LocalSimTransport(LocalSimulator([]))
response = transport.request(method, "http://local" + path,
json.dumps(payload()).encode(), headers)
assert response.status == status
def test_concurrent_registration_and_action_finishing_after_deadline():
now = [0]
sim = LocalSimulator([], clock=lambda: now[0])
sim.handle("/enter", payload())
started, release = Event(), Event()
observe = sim._observe
calls = []
def blocking_observe(pos, channel):
calls.append(1)
started.set()
assert release.wait(5)
return observe(pos, channel)
sim._observe = blocking_observe
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(sim.handle, "/measure", measure())
try:
assert started.wait(5)
assert sim.handle("/measure", measure("other"))[0] == 409
assert sim.handle("/measure", measure(x=1))[0] == 409
waiting = Event()
completed = sim._pending["done"]
class ObservedEvent:
def wait(self):
waiting.set()
return completed.wait(5)
def set(self):
completed.set()
sim._pending["done"] = ObservedEvent()
duplicate = pool.submit(sim.handle, "/measure", measure())
assert waiting.wait(5)
finally:
release.set()
assert first.result(timeout=5) == duplicate.result(timeout=5)
assert calls == [1]
assert sim.virtual_time_s == 5
# 执行中跨过现实截止仍返回结果,后续新请求断连。
sim._observe = lambda pos, channel: (now.__setitem__(0, 1200) or observe(pos, channel))
assert sim.handle("/measure", measure("last"))[1]["accepted"]
with pytest.raises(SimulatorClosed):
sim.handle("/exit", payload("exit"))
def test_raw_size_and_depth_boundaries_are_inclusive():
sim = LocalSimulator([])
transport = LocalSimTransport(sim)
headers = {"Content-Type": "application/json"}
deep = b'{"extra":' + b'[' * 15 + b'0' + b']' * 15 + b'}'
# 深度16合法,因缺少公共字段得到400,补公共字段后应为业务拒绝200。
body = json.dumps({**payload(), **json.loads(deep)}).encode()
assert transport.post("http://local/enter", body, headers, None).status == 200
body = json.dumps(payload()).encode()
response = transport.post("http://local/enter", body.ljust(65536), headers, None)
assert json.loads(response.body)["accepted"]
def test_huge_numeric_fields_return_bad_request():
sim = LocalSimulator([])
sim.handle("/enter", payload())
huge = 10 ** 400
assert sim.handle("/measure", measure(x=huge))[0] == 400
body = measure()
body["channel"] = huge
assert sim.handle("/measure", body)[0] == 400
assert sim.handle("/measure", measure())[1]["accepted"]
def test_explicit_exit_closes_new_requests():
sim = LocalSimulator([])
sim.handle("/enter", payload())
assert sim.handle("/exit", payload("exit"))[1]["exit_reason"] == "user_exit"
assert sim.closed_reason == "user_exit"
with pytest.raises(SimulatorClosed):
sim.handle("/measure", measure())