diff --git a/README.md b/README.md index 421e75c..78e5718 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/output/reviews/simulator-equivalence-7930eb6.md b/output/reviews/simulator-equivalence-7930eb6.md new file mode 100644 index 0000000..0ca6125 --- /dev/null +++ b/output/reviews/simulator-equivalence-7930eb6.md @@ -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分钟。 + +建议先区分“官方约束模式”和“可放宽参数的压力测试模式”,优先修复独立截止时钟与终止状态;随后统一整数微秒计时和坐标规范化;最后补充原始协议输入、标识符及并发验收。当前物理算法自检结果仍有价值,但不能据此保证官方超时、重试、错误请求和并发行为都已验证。 diff --git a/output/reviews/simulator-fixes-7930eb6.md b/output/reviews/simulator-fixes-7930eb6.md new file mode 100644 index 0000000..e8f124a --- /dev/null +++ b/output/reviews/simulator-fixes-7930eb6.md @@ -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/上传生命周期及隐藏随机分布不在等价范围内。 diff --git a/src/mcm26b/cli.py b/src/mcm26b/cli.py index 966d04a..4f99064 100644 --- a/src/mcm26b/cli.py +++ b/src/mcm26b/cli.py @@ -77,7 +77,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) diff --git a/src/mcm26b/evaluation/__init__.py b/src/mcm26b/evaluation/__init__.py index 06bc9cd..2fb2ec8 100644 --- a/src/mcm26b/evaluation/__init__.py +++ b/src/mcm26b/evaluation/__init__.py @@ -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: diff --git a/src/mcm26b/protocol/client.py b/src/mcm26b/protocol/client.py index 398ae33..13e4ec3 100644 --- a/src/mcm26b/protocol/client.py +++ b/src/mcm26b/protocol/client.py @@ -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"] diff --git a/src/mcm26b/protocol/timing.py b/src/mcm26b/protocol/timing.py index 52a74d1..baf1be8 100644 --- a/src/mcm26b/protocol/timing.py +++ b/src/mcm26b/protocol/timing.py @@ -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, diff --git a/src/mcm26b/sim/__init__.py b/src/mcm26b/sim/__init__.py index 810712d..c3f656b 100644 --- a/src/mcm26b/sim/__init__.py +++ b/src/mcm26b/sim/__init__.py @@ -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"] diff --git a/src/mcm26b/sim/scenes.py b/src/mcm26b/sim/scenes.py index 1e5bd3d..9a7f8d4 100644 --- a/src/mcm26b/sim/scenes.py +++ b/src/mcm26b/sim/scenes.py @@ -1,8 +1,9 @@ """自检场景生成器(测试端专用)。 真值只进入测试与 CLI ``--self-test`` 入口,策略端仅见模拟器响应; -默认完全按题面生成:源总数 10~16 随机、位置在目标圆域内均匀分布、 +默认采用满足题面范围的自建随机场景:源总数 10~16 随机、位置在目标圆域内均匀分布、 频道互异(1..20 抽样)、有效接收半径 [1000,1500] 均匀。 +这些均匀分布是测试假设,题面并未规定官方场景的概率分布。 边界情形(近圆域边界源、接收半径两档、定向朝外源)由专门测试显式构造。 """ diff --git a/src/mcm26b/sim/simulator.py b/src/mcm26b/sim/simulator.py index 9f24f70..a76f391 100644 --- a/src/mcm26b/sim/simulator.py +++ b/src/mcm26b/sim/simulator.py @@ -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() diff --git a/src/mcm26b/sim/transport.py b/src/mcm26b/sim/transport.py index 326f081..1d2c316 100644 --- a/src/mcm26b/sim/transport.py +++ b/src/mcm26b/sim/transport.py @@ -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")) diff --git a/tests/test_loop_q3.py b/tests/test_loop_q3.py index 1f88549..2f0fd6f 100644 --- a/tests/test_loop_q3.py +++ b/tests/test_loop_q3.py @@ -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): diff --git a/tests/test_simulator_conformance.py b/tests/test_simulator_conformance.py new file mode 100644 index 0000000..181ff48 --- /dev/null +++ b/tests/test_simulator_conformance.py @@ -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())