diff --git a/.env.example b/.env.example index 590a271..73167b7 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S # ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样 # ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ── # LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait +# LLM__CALL_DEADLINE_S= # 一次逻辑调用的墙钟硬边界(秒);缺省不设 = 不启用 +# ── 治理对象是"等待"(退避/配额轮询/熔断冷却/结构化重问/embedding 分批共享一份), +# ── 不是单次 HTTP 超时(那是 TIMEOUT_S)。清理仍在 finally 跑完: 返回时刻 = 期限 + 清理耗时, +# ── 且到期 ≠ 未产出、≠ 未计费。到期抛 CallDeadlineExceeded(不属四分类、 +# ── 不属 GatewayUnavailableError 族、无 retry_after_s);非法值(0/负/nan/inf)装配期报错 ── # ══ 装配选择(PGW_*)══ PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9658b..047b20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 未发布 + +给一次逻辑调用加了一条**可选**墙钟硬边界(issue #22),并修好取消路径的 TPM 结算与 `Retry-After` 非有限值防御。 + +### 关于调用期限,请先读这三句 + +| # | 承诺 | 展开 | +| --- | --- | --- | +| 1 | **期限治理的是「等待」,不是「返回时刻」** | 到期后取消在飞的尝试,但清理(遥测写入、限流结算、缓存收尾)仍在 `finally` 里跑完,**允许超出期限**。实测构造(两次慢遥测写入)里返回时刻达期限的 **5–7 倍**;库只对「等待被切断」给承诺,对「多久返回」不给上界 | +| 2 | **到期 ≠ 未产出、≠ 未计费** | 上游可能已经算完并计费,只是结果在返回路上被丢弃(缓存写入慢于期限就是一例)。把 `CallDeadlineExceeded` 当成「这次没花钱」会低估成本 | +| 3 | **不配置就是 1.3.5 语义,逐字不变** | `call_deadline_s` 缺省 `None` 时根本不进 `asyncio.timeout` 上下文。故 1.3.5 的两条长等仍在: 纯 429 序列(429 不消耗重试预算)仍可能长时间等待;**有限大的 `Retry-After`(如 3600s)仍照睡**——库有意不用 `backoff_max_s` 去夹它,唯一制约手段就是本版这条期限 | + +### 公共面新增(三项,全为纯新增) + +| # | 位置 | 内容 | +| --- | --- | --- | +| 1 | `polygateway.CallDeadlineExceeded` | 新异常;带 `scope` / `deadline_s`,**无 `retry_after_s`**(到期不含「何时可再试」,给 `0.0` 会指示下游立刻重打饱和渠道) | +| 2 | 配置键 `{SCOPE}__CALL_DEADLINE_S` | 缺省不设 = 不启用;非法值(0/负/`nan`/`inf`/非数)在**装配期**当场 `ValueError` | +| 3 | 三个 client 构造参数 + 四个公开方法的 keyword-only 参数 | `GatewayClient` / `EmbeddingClient` / `OcrClient` 的 `call_deadline_s`;`chat` / `embed` / `recognize_text` / `parse_layout` 可 per-call 覆盖(`None` = 继承装配值,**不提供「本次关闭」**)。一次 `embed` 的 N 个批次共享同一份期限,不随批数放大 | + +> [!WARNING] +> **`except GatewayUnavailableError` 接不住 `CallDeadlineExceeded`。** 新异常直接继承 `PolyGatewayError`,既不属四分类,也不在 `GatewayUnavailableError` 族内——期限到期是**调用方自己设的边界**,不是网关不可用。只有显式配了期限的调用方才会遇到它,需要处理就单列一条 `except`。遥测侧无需改动: 三个边界既有的 `except PolyGatewayError` 会接住它并照常写一条 `terminal_failure` 行(`error_type='CallDeadlineExceeded'`),**零新增列**。 + +### 行为变更:取消路径的 TPM 结算口径 + +| 情形 | 1.3.5 | 本版 | +| --- | --- | --- | +| 取消发生在**端口已开始、结算尚未确定**时 | `settle(0)`,入场预扣整笔退还 | 按 `est` **保留预扣**(方向是宁多扣不空退: 上游可能已计费) | +| 结算已确定(含真实 usage 恰为 0 的成功、已判 `SourceDead` 的 `0`) | 按已算出的值 | **一字不变**,取消不覆写 | +| 未被四分类接住的异常逃逸(`RuntimeError` 等) | `0` | **仍按 `0`**,本版不扩大语义(已登记为残留) | + +启用期限后库自身会常规性触发取消路径,故这条记账修复与期限同版交付。OCR 的 `settle(0)` 不变——无 token 是事实而非「未知」。非取消路径的最终结算值与 1.3.5 逐字相同,只是算得更早(失败分支的结算决定前移到其第一个 `await` 之前)。 + +### 其他 + +- `Retry-After: inf` / `1e999`(`float()` 会把它舍成 `inf`)此前会原样进入退避并让该次尝试睡到天荒地老;现按「无提示」处理,退回纯指数退避,并发**一条**带源名与判据词的 warning(不回显原始头,429 风暴下会淹掉真信号)。`nan`、空串、负数、HTTP-date 的既有值语义一字未动。 +- 限流 Lua、`Permit` 端口签名、遥测 schema、缓存 key 公式、重试预算与退避算法、熔断语义**均未改动**。 + ## 1.3.5(2026-09-09) 把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19、#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。 diff --git a/README.md b/README.md index a1f3f3c..b8c8831 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ | 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) | | 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | +| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** | | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 | @@ -185,13 +186,17 @@ vectors = (await embed.embed(["文本 a", "文本 b"])).vectors ### 4. 业务侧异常处理 ```python -from polygateway import GatewayUnavailableError, RequestRejectedError +from polygateway import CallDeadlineExceeded, GatewayUnavailableError, RequestRejectedError try: resp = await client.chat(messages) except GatewayUnavailableError as exc: # 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算 schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断 +except CallDeadlineExceeded as exc: + # 只在自己配了调用期限时出现: 不在 GatewayUnavailableError 族内,上一条接不住; + # 且无 retry_after_s(到期不含"何时可再试"),重投时机由业务侧定 + schedule_retry(after_s=None) # exc.scope / exc.deadline_s 供诊断 except RequestRejectedError: ... # 请求本身有问题(400/格式拒绝): 不重试,直接失败 ``` @@ -463,6 +468,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** **网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。 +**有限大的 `Retry-After` 仍照睡**: 能力表那条「尊重 `Retry-After`」是字面意思——库**有意不用 `backoff_max_s` 去夹服务端给的提示**(夹住就是提前重打已明确说「还没好」的网关),服务端给 3600s 就真睡 3600s;**唯一的制约手段是 1.3.6 的调用期限**(上表「调用期限」行)。自 1.3.6 起,`inf` / `-inf` / `1e999` 这类**非有限**取值按「无提示」处理(退回纯指数退避 + 一条带源名的 warning),不再造成无限等待;`nan`、空串、负数、HTTP-date 的既有语义不变。 + ### 哪些异常会到达调用方 上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明: @@ -473,9 +480,12 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** | `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) | | `ResultInvalidError` | | | `SourceNotConfiguredError` | | +| `CallDeadlineExceeded`(1.3.6 起) | | **`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。 +**`CallDeadlineExceeded` 既不属四分类、也不属 `GatewayUnavailableError` 族**: 它直接继承 `PolyGatewayError`,表达的是「调用方自己设的墙钟边界到了」而非网关不可用,故**只有显式配了 `call_deadline_s` / `{SCOPE}__CALL_DEADLINE_S` 的调用方才可能遇到它**,不配就永远不会出现。它**无 `retry_after_s`**,且 §4 那段 `except GatewayUnavailableError` **接不住**它——要处理就得单列一条分支。 + **`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。 ## 配置参考 diff --git a/research-wiki/findings/2026-09-10-136-call-deadline-validation.md b/research-wiki/findings/2026-09-10-136-call-deadline-validation.md new file mode 100644 index 0000000..a6407f9 --- /dev/null +++ b/research-wiki/findings/2026-09-10-136-call-deadline-validation.md @@ -0,0 +1,100 @@ +--- +type: finding +node_id: finding:2026-09-10-136-call-deadline-validation +title: "1.3.6 可选调用期限与取消结算验证记录" +date: 2026-09-10 +--- + +# 1.3.6 可选调用期限与取消结算验证记录 + +> 范围:分支 `feature/1.3.6-call-budgets` 上的 T1–T3 三个行为提交(`1ff83bb` / `9474c76` / `da77b12`)与 T4 文档提交。设计 `research-wiki/designs/2026-09-09-136-call-budgets-design.md`(人类已批准),计划 `research-wiki/plans/2026-09-10-136-call-deadline.md`。 +> 本文件是**证据索引**:原始输出在 `tests/outputs/136/`(按纪律**不提交**),此处只记路径、命令、退出码与结论。 +> 环境:conda 环境 `PolyGateway`,**Python 3.12.13**(`conda run -n PolyGateway python -V` 实测)。所有 pytest/lint 命令均**不接管道**,退出码直取。 +> 版本号未 bump、未 tag、未发布——发布清单(CLAUDE.md §4.4.1)不在本轮范围。 + +## 1. 红绿证据索引 + +| 任务 | 阶段 | 证据文件 | 结果 | +| --- | --- | --- | --- | +| T1 取消结算 | 红/绿 | **未落盘**(见 §1.1 诚实说明) | 见 §1.1 | +| T1 真实 Redis | 绿(本轮在 HEAD `da77b12` 上复跑) | `tests/outputs/136/t4/redis_cross_connection.txt` | `8 passed`,`exit=0` | +| T2 期限 | 红(值域+形态) | `tests/outputs/136/t2/red_deadline.txt` | 收集期 `1 error`(`deadline.py` 缺席),`exit=2` | +| T2 期限 | 绿(`test_deadline.py`) | `tests/outputs/136/t2/green_deadline.txt` | `22 passed`,`exit=0` | +| T2 接线中途 | 绿 | `tests/outputs/136/t2/unit_contracts_midway.txt`、`unit_contracts_after_wiring.txt` | 各 `1560 passed, 17 skipped` | +| T2 入口冒烟 | 绿 | `tests/outputs/136/t2/entry_smoke.txt` | 四个方法签名含 `call_deadline_s`;到期异常 `has retry_after_s: False`,`exit=0` | +| T2 回归门 | 绿 | `tests/outputs/136/t2/final_unit_contracts.txt` | `1572 passed, 17 skipped`,`pytest_exit=0` | +| T2 lint | 红→绿 | `tests/outputs/136/t2/lint.txt`(`Found 3 errors`,`lint_exit=2`)→ `lint_final.txt`(`All checks passed!` + `Contracts: 1 kept, 0 broken.`,`lint_exit=0`) | 修后绿 | +| T2c 补测 | 红 pass1 | `tests/outputs/136/t2c/red_pass1_import_absent.txt` | `3 errors in 0.29s`(三个模块收集期 ImportError) | +| T2c 补测 | 红 pass2 | `tests/outputs/136/t2c/red_pass2_real_reasons.txt` + `red_method_and_reasons.txt` | `32 failed`;分布见 §1.2 | +| T2c 补测 | 绿 | `green_unit_after_hardening.txt`(`1530 passed`)、`green_client_recheck.txt`(`110 passed`,`EXIT=0`)、`green_recheck_client_embedding.txt`(`165 passed`)、`green_unit_contracts.txt`(`1592 passed, 17 skipped`)、`final_unit_contracts.txt`(`1606 passed, 17 skipped`) | 全绿 | +| T2c lint | 绿 | `tests/outputs/136/t2c/lint.txt` | `All checks passed!` + `Contracts: 1 kept, 0 broken.` | +| T3 `Retry-After` | 红 | `tests/outputs/136/t3/red.txt` | `6 failed, 8 passed, 129 deselected` | +| T3 `Retry-After` | 绿 | `green_file.txt`(`143 passed`)、`green_unit_contracts.txt`(`1606 passed, 17 skipped`) | 全绿 | +| T3 lint | 绿 | `tests/outputs/136/t3/lint.txt` | `All checks passed!` + `Contracts: 1 kept, 0 broken.` | + +`tests/outputs/136/t2/lsp_noise_refutation.txt` 与 `t2c/lsp_noise_refutation.txt` 记录编辑器 LSP 报的 import/属性告警属环境噪声(`pydantic` 在 conda 环境可解析),不是代码缺陷。 + +### 1.1 T1 的证据形态(诚实说明) + +T1(`1ff83bb`)的**先红后通过证据产生于当时的会话工具输出,未落盘为 `tests/outputs/136/t1/` 文件**。本文件不追认那次输出,只登记两项**当下可复核**的替代证据: + +| 替代证据 | 内容 | +| --- | --- | +| 提交 `1ff83bb` 的 diff | 三个源文件 + 四个测试文件共 213 插入;测试侧含 S3/S7(取消结算按 `est`)、S5-dead(仍 `0`)、S8(`RuntimeError` 逃逸仍 `0`)、真实 usage 恰为 0 的成功仍 `0` 四组断言 | +| 本轮在 HEAD `da77b12` 上重跑真实 Redis | `pytest tests/integration/test_redis_cross_connection.py -q` → `8 passed`,`exit=0`(`tests/outputs/136/t4/redis_cross_connection.txt`) | + +结论口径:**T1 的“红”只有会话内证据、无归档文件**;T1 的“绿”在当前 HEAD 上已被真实 Redis 复现证实。 + +### 1.2 T2c 两趟红证据的方法说明(诚实说明) + +T2c 的红证据是在**基线 `1ff83bb`(T2 之前)**的 `git worktree --detach` 检出上取的,用 `PYTHONPATH=/src` 覆盖 editable `.pth`(已实测 `polygateway` 加载自 worktree 且 `deadline.py` 缺席): + +| 趟次 | 做法 | 结果 | +| --- | --- | --- | +| pass1 | 用例原样跑 | 三个测试模块**收集期** ImportError(`CallDeadlineExceeded` 不存在)→ `3 errors`。只证明符号缺席,**没有执行到函数体** | +| pass2 | 仅把缺失的**导入符号**替换成**本地占位异常类**(shim),让函数体真正跑起来 | `32 failed`:**27 条“参数/属性不存在”**(`chat()` 9、`embed()` 4、`GatewaySettings.__init__()` 3、`GatewaySettings.call_deadline_s` 属性 3、`recognize_text()` 2、`GatewayClient.__init__()` 2、`parse_layout()`/`OcrClient.__init__()`/`EmbeddingClient.__init__()`/`GatewayClient._call_deadline_s` 各 1)+ **5 条 `Failed: DID NOT RAISE ValueError`** | + +**该 shim 是一次性本地脚手架,未提交、不在任何分支上**;它只替换导入符号,不改被测源码。故 pass2 的红**是针对预 T2 源码的真实失败原因分布**,而非构造错误——但读者需知这份红**无法从仓库检出复现**,只能从上表与 `red_method_and_reasons.txt` 复核。 + +## 2. 命令与退出码 + +| 命令(前缀均为 `conda run -n PolyGateway python -m`,lint 为 `make lint`) | 何时 | 结果 | +| --- | --- | --- | +| `pytest tests/unit/test_deadline.py -q` | T2 红 | `exit=2`(collection error,符合预期) | +| `pytest tests/unit/test_deadline.py -q` | T2 绿 | `22 passed`,`exit=0` | +| `pytest tests/unit tests/contracts -q` | T2 门 | `1572 passed, 17 skipped`,`exit=0` | +| `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_config.py -q -rf -k "…deadline…"` | T2c 红 | `32 failed`(基线 worktree,见 §1.2) | +| `pytest tests/unit tests/contracts -q` | T2c 门 | `1606 passed, 17 skipped`,`exit=0` | +| `pytest tests/unit/test_openai_compat.py -q -rf -k RetryAfterNonFinite` | T3 红 | `6 failed, 8 passed, 129 deselected` | +| `pytest tests/unit/test_openai_compat.py -q` | T3 绿 | `143 passed` | +| `pytest tests/unit tests/contracts -q` | T3 门 | `1606 passed, 17 skipped`,`exit=0` | +| `pytest tests/integration/test_redis_cross_connection.py -q` | T1/T4 复跑 | `8 passed`,`exit=0` | +| `make lint`(ruff + import-linter) | T2/T2c/T3 收尾 | `All checks passed!`;`Contracts: 1 kept, 0 broken.`(新 `polygateway.deadline` 层在内) | + +T4(本次文档提交)**不含行为变更**,故未跑测试;仅新增上表最后一行的真实 Redis 复跑作为 §1.1 的替代证据。 + +## 3. 豁免索引(哪些门没跑,为什么,谁来兜) + +| 未执行项 | 原因 | 兜底责任 | +| --- | --- | --- | +| `pytest -m slow`(真实网关 e2e、Redis 时间语义变体) | 成败取决于外部服务当下状态,默认被 `addopts = "-m 'not slow'"` 排除;计划 §5 明确本轮不跑 | **发布清单(CLAUDE.md §4.4.1)第 4 步**,合并 main 后统一执行 | +| `tests/e2e/` 四个文件 | 同上,本版零付费调用 | 同上 | +| 真实网关的期限行为实测 | 期限用例用真实事件循环时钟+假 transport 构造,余量 4–10 倍,不依赖网关 | 发布清单第 4 步的 e2e 顺带覆盖;**本轮无真实网关证据** | +| 模型能力矩阵复验 | 本版未触碰推理/能力表 | 不适用 | +| 跨 Python 版本验证 | 见 §4 残余三 | 未兜底,登记为残余 | + +真实 Redis **不在豁免之列**:T1 已证、本轮在 HEAD 上复跑(`8 passed`),未以 memory 后端冒充。 + +## 4. 残余风险(三条,逐条复述设计 §12) + +| # | 残余 | 诚实口径 | +| --- | --- | --- | +| 1 | 取消结算按 `est` 保留预扣 | 这是**保守选择,不是“上游已计费”的证明**。库无法知道端口已开始的那次调用是否真的产生了计费用量;方向定为宁多扣不空退(多扣只损失本窗口一点额度,空退会让已计费用量绕过闸门)。真实 usage 已知(含恰为 0)与已判 `SourceDead` 的 `0` 不被覆写;未分类异常逃逸仍按 `0`,属**已知残留,本版不动** | +| 2 | 清理期自抛 `TimeoutError` 时**无终态遥测行** | 与 1.3.5 的裸 `TimeoutError` 穿透**同一口径**(这条路径一直存在、一直没有终态行),本版没有让它变坏;**但期限把这条路径常态化了**——启用期限后触发清理的频率上升,其可达性随之上升。`with_call_deadline` 的局部变量身份比较保证这种 `TimeoutError` **不会**被误标成 `CallDeadlineExceeded`(`test_deadline.py` 有断言钉住) | +| 3 | 跨 Python 版本仅 3.12.13 有实证 | `asyncio.timeout` 的 `cm.expired()`/`uncancel()` 行为与清理期异常传播是探针在 **3.12.13 单一版本**上实测的;3.13+的行为未验证。库声明 3.12+,故这是**真实的验证缺口**,不是理论担忧 | + +## 5. 未在本轮做的事 + +- 不实现 issue #24(长尾对冲):未获批准,代码与文档均无 hedge 机制,本版**不缓解 #24**。 +- 不修 `ResultInvalid`/`RequestRejected` 已计费坏结果仍退全款:属另一族记账语义,未批准,登记待立 issue。 +- 不 bump 版本号、不打 tag、不构建、不上传 registry、不同步 wiki——全部留给发布清单。