Merge remote-tracking branch 'origin/gpt' into q3-bayes
This commit is contained in:
@@ -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分钟。
|
||||
|
||||
建议先区分“官方约束模式”和“可放宽参数的压力测试模式”,优先修复独立截止时钟与终止状态;随后统一整数微秒计时和坐标规范化;最后补充原始协议输入、标识符及并发验收。当前物理算法自检结果仍有价值,但不能据此保证官方超时、重试、错误请求和并发行为都已验证。
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user