用约束序"摇摆"(Wobble)测试揪出类型推断的排序依赖:Ruff/ty 中 TY_CONSTRAINT_SET_ORDER 完整实战指南
ty 是 Ruff 仓库中用 Rust 实现的 Python 类型检查器,其约束集求解以约束与类型变量的出现顺序作为决策图变量的自然排序。TY_CONSTRAINT_SET_ORDER 环境变量允许对这一内部顺序做系统性的"摇摆"(wobble),从而检验类型推断与诊断展示是否在本质上是排序无关(order-independent)的。本指南以仓库内技能文档 .agents/skills/wobbling-ty-constraint-order/SKILL.md 为主体,结合 constraints.rs 源码与 mdtest 回归用例,说明该变量的取值语义、底层实现、完整运行脚本以及失败结果的专业解读方法。读完本文后,你可以独立驱动一轮约束序摇摆回归,判断 ty 的推断语义或展示出的解类型是否仍残留对内部排序的依赖。
一、问题背景:为什么求解结果可能依赖约束的内部顺序
ty 的类型推断把约束收集为 ConstraintSet(源码层面的 OwnedConstraintSet 与 ConstraintSetStorage),并通过求解器在这些约束上计算解。求解过程中用到的决策图以约束为 BDD/TDD 变量,而变量的自然排序就是约束被加入构建器(builder)的顺序。这一设计在 crates/ty_python_semantic/src/types/constraints.rs 中有明确记载:
ConstraintId::ordering的文档注释(constraints.rs)说明:只要排序一致,任何变量排序对正确性而言都是可接受的,但不同排序会带来显著不同的性能特征。ty 刻意选择"按约束加入构建器的顺序"排序,因为它跨运行稳定、且不受项目内其他文件分析顺序的影响;作为优化还会反转这一顺序,让更早进入 arena 的约束在 BDD 中更接近终端节点,从而在合并小 BDD 时减少节点搬移。注释还承认,早期按 typevar 聚类的"更聪明"排序经实测会得到更大的 BDD,最终被放弃。BoundTypeVarInstance::can_be_bound_for(constraints.rs)在构建 typevar 到 typevar 的约束时施加一个(任意的)类型变量顺序,确保约束的边界相对于被约束的 typevar 处于该顺序的"更晚"位置,从而无环地建立传递关系;同时该顺序要保证:若某 typevar 以另一 typevar 为边界,那么作用于该边界的约束会出现在 BDD 中更靠下的位置。
换句话说,约束的添加顺序与类型变量的定向顺序共同决定了决策图的内部形状(internal TDD shape)。回归说明文件 crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md 明确承认了现状:
当前实现是"稳定"的——对同一份源码多次运行 ty 会得到相同结果;但仍有一些残留位置,其输出取决于所选择的 BDD 变量排序。
该文件还区分了两种暴露解的方式(见其开头注释):ConstraintSet.solutions_for 逐个 typevar 暴露显式解;ConstraintSet.solutions 额外保留路径与绑定(binding)顺序,使本会被路径并集吞掉的重复解与 Never 解仍然可见。由于 mdtest 的诊断快照记录的是默认稳定序下的输出,普通测试总是绿的;而那些以 # TODO: sometimes: 标注的备选输出,正是其他变量排序下可能出现、且尚未被消除的排序依赖。wobbling-ty-constraint-order 这一技能就是把这些"有时会冒出来"的输出系统性地逼出来。
二、TY_CONSTRAINT_SET_ORDER 的取值语义
TY_CONSTRAINT_SET_ORDER 同时扰动两类本地顺序:
- 构建器局部的 TDD 变量顺序(即约束作为决策图变量的排序,作用于 constraints.rs 的
ordering()); - 用于定向 typevar 到 typevar 约束的局部 typevar 顺序(作用于
can_be_bound_for中的比较)。
该设置在每个测试进程的生命周期内固定不变(见下文实现分析)。取值规则如下:
| 取值 | 含义 |
|---|---|
未设置 或 0 |
正常排序(不做扰动)。0 等价于未设置,因为 index ^ 0 == index。 |
reverse |
同时反转上述两种排序。 |
| 任意整数 | 将每个本地 ID 与这个掩码做异或(XOR)。 |
掩码的几何直觉很直观,技能文档给出了三个典型例子:
1(0b1)交换相邻 ID:0↔1、2↔3、4↔5……;3(0b11)反转每四个一组的块:翻转两个低 bit 等价于在 4 元素块内部做逆序;- 2 的幂(如
4、8)交换相邻的等大小块:翻转单个 bit 会把每 2^k 个元素组成的相邻块两两对调。
掩码越大,越能打散原本紧凑的 arena ID,因此小掩码立刻就能扰动高密度的 arena ID,非常适合作为最小侵入的探针。推荐遍历的掩码序列为 normal、reverse、1、2、3、4、7、8、15——它们覆盖了相邻交换、按 2/4/8/16 分块的块内逆序与相邻块对调等一组互补的排列扰动。
三、底层实现:wobble_index 与进程级一次性读取
环境变量的读取与变换集中在 crates/ty_python_semantic/src/types/constraints.rs 的 wobble_index 函数:
fn wobble_index(index: usize) -> usize {
#[derive(Clone, Copy)]
enum Order { Normal, Reverse, Xor(usize) }
static ORDER: LazyLock<Order> = LazyLock::new(|| {
let Some(value) = std::env::var_os(EnvVars::TY_CONSTRAINT_SET_ORDER) else {
return Order::Normal;
};
if value == "reverse" {
return Order::Reverse;
}
value
.to_str()
.and_then(|value| value.parse::<usize>().ok())
.map_or(Order::Normal, Order::Xor)
});
match *ORDER {
Order::Normal => index,
Order::Reverse => !index,
Order::Xor(mask) => index ^ mask,
}
}
几个值得注意的实现事实:
- 进程内只读一次:
wobble_index用LazyLock静态缓存Order,第一次调用时读一次环境变量并解析("reverse"之外的非整数值一律回退为Normal)。这正是技能文档"该设置对每个测试进程生命周期固定"的源码依据——同一进程内后续所有 ID 变换都使用同一排列。 reverse的实现是按位取反!index:在 arena ID 受边界约束的前提下,!index恰好把大小关系整体翻转,等价于对本地 ID 空间做逆序。- 变量声明位于 crates/ty_static/src/env_vars.rs 的
EnvVars(由ruff_macros::attribute_env_vars_metadata派生元数据),并标注#[attr_hidden]——这是一个面向内部回归测试、不对外宣传的调试开关。
wobble_index 在两个调用点生效,可以交叉验证技能文档对"双作用域"的描述:
BoundTypeVarInstance::can_be_bound_for(constraints.rs):wobble_index(typevar_id(self)) < wobble_index(typevar_id(typevar))——扰动 typevar 的定向顺序;ConstraintId::ordering(constraints.rs):std::cmp::Reverse(wobble_index(self.index()))——扰动约束作为 BDD/TDD 变量的排序(注意外层Reverse是正常模式下"先到者更靠下"的既有优化,wobble 施加在其内部)。
重要边界:不扰动 Salsa-backed 值的哈希
技能文档特别强调:这个开关不会扰动 Salsa 支撑值(Salsa-backed values)的哈希。因为约束集节点一旦被 intern(如 typevar_cache、node_cache),其身份/哈希仍由内容的原始形态决定,wobble_index 只改变构建与求解过程中变量之间的相对顺序,不改变节点本身的身份与缓存键。因此 wobble 失败的来源应定位到顺序敏感的求解/展示逻辑,而不是哈希碰撞或缓存键变化。
四、完整运行脚本:从基线到多掩码轮巡
由于 wobble 故意改变内部 TDD 形状,图形结构相关的单元快照必然随之变化(见下文),因此只应运行 mdtests。标准流程是:先建立正常(normal)基线,再依次以 reverse 和各 XOR 掩码运行,逐次对比。
4.1 脚本主体
以下脚本即技能文档给出的推荐流程,逐段说明了其用途:
set -u
# 决定日志目录:优先使用调用方显式指定的 TY_CONSTRAINT_ORDER_LOG_DIR,
# 否则由 mktemp 依据系统临时目录配置挑选(-t 前缀便于辨识)。
if test -n "${TY_CONSTRAINT_ORDER_LOG_DIR:-}"; then
log_dir="$TY_CONSTRAINT_ORDER_LOG_DIR"
mkdir -p "$log_dir"
else
log_dir="$(mktemp -d -t ty-constraint-order.XXXXXXXX)"
fi
printf '%s\n' "logs: $log_dir"
# runner 探测:优先 cargo-nextest(更快、输出更可控),否则回退到 cargo test。
if cargo nextest --version >/dev/null 2>&1; then
runner=nextest
else
runner=test
fi
printf '%s\n' "runner: cargo $runner"
# 编译期提速与输出精简:O1 + 行级调试信息即可满足回归定位需求。
export CARGO_PROFILE_DEV_OPT_LEVEL=1
export CARGO_PROFILE_DEV_DEBUG=line-tables-only
# 快照更新一律关闭:任何形式的自动更新都会掩盖 wobble 想要暴露的失败。
export INSTA_UPDATE=no
export MDTEST_UPDATE_SNAPSHOTS=0
unset INSTA_FORCE_PASS || true
for order in normal reverse 1 2 3 4 7 8 15; do
if test "$order" = normal; then
unset TY_CONSTRAINT_SET_ORDER || true
else
export TY_CONSTRAINT_SET_ORDER="$order"
fi
log="$log_dir/ty-constraint-order-${order}.log"
if test "$runner" = nextest; then
cargo nextest run -p ty_python_semantic --test mdtest \
--no-fail-fast --status-level fail --failure-output immediate-final \
>"$log" 2>&1
else
cargo test -p ty_python_semantic --test mdtest >"$log" 2>&1
fi
status=$?
printf '%-7s exit=%s\n' "$order" "$status"
grep -E 'Summary \[|test result:' "$log" | tail -1 || true
printf '%s\n' " log: $log"
done
4.2 脚本要点逐条拆解
- 固定到单一测试目标:命令始终限定
-p ty_python_semantic --test mdtest,只跑 ty 的 mdtest 回归。原因有二:其一,wobble 的目标正是 mdtest 中记录的 reveal/error 期望是否随排序漂移;其二,约束集图形结构的单元快照(如 constraints.rs 中以tdd_*命名、断言 TDD 分支形状的测试)在 wobble 下预期会失败,绝不能纳入本轮回归,否则会把"预期的形状变化"误报为缺陷。 normal走 unset 分支而非设0:两者效果等价(Xor(0)恒等),脚本显式 unset 以保持环境干净、避免歧义。- nextest 分支:
--no-fail-fast保证一轮内跑完所有失败用例以便集中分析;--status-level fail只输出失败级别摘要;--failure-output immediate-final在失败时立即打印最终输出。cargo test回退分支则保留默认行为,适合未安装cargo-nextest的环境。 - 快照开关三件套:
INSTA_UPDATE=no(禁用 insta 快照更新)、MDTEST_UPDATE_SNAPSHOTS=0(禁用 mdtest 内嵌snapshot块更新)、unset INSTA_FORCE_PASS(撤销 CI/开发流程里"强制通过并静默改快照"的设置)。三者共同保证:任何 wobble 运行都不允许以更新快照的方式"通过"。mdtest 的快照更新开关定义与用法可分别见 crates/mdtest/src/lib.rs、crates/mdtest/src/lib.rs(非"0"即启用,缺失的快照块会提示用MDTEST_UPDATE_SNAPSHOTS=1补插)以及 crates/ty_test/README.md。 - 日志归档:每一轮输出独立落到
$log_dir/ty-constraint-order-<order>.log,退出码与末行Summary [/test result:摘要同时打印,方便肉眼快速扫过九轮结果,再逐份深入失败日志。
4.3 运行前置条件
技能文档的 front-matter 声明了兼容性要求:需要 Cargo、mktemp 与 POSIX 兼容 shell;有 cargo-nextest 时用它,否则回退 cargo test。仓库根目录的 AGENTS.md 提供了不开启 wobble 时的常规 mdtest 用法作为对照——正常开发/更新快照场景使用 INSTA_FORCE_PASS=1 INSTA_UPDATE=always MDTEST_UPDATE_SNAPSHOTS=1 cargo nextest run -p ty_python_semantic --test mdtest,并支持 MDTEST_TEST_FILTER 环境变量或 -- mdtest::<path> 尾参做单文件过滤。本文场景刻意与之相反:所有更新开关都关闭。
五、结果解读:什么算失败,失败说明了什么
每一轮结束先看打印的 exit= 与摘要行判断成败,然后进入对应日志精读。对于每个失败用例,应报告五要素:
- mdtest 文件:如
regression/constraint_set_ordering.md(mdtest 用例源文件位于 crates/ty_python_semantic/resources/mdtest 下); - 所在 section:mdtest 按
##小节组织,可精确定位到具体场景; - 行号:报告揭示类型或期望错误所在的具体行;
- 期望结果:当前默认稳定序下快照/注释中记录的 revealed 类型或 error 期望;
- 实际诊断 / revealed 类型:wobble 本轮实际产出的内容。
一次 wobble 失败,就是一条证据:说明类型推断的语义,或展示出来的解类型(solution type),仍然依赖约束集内部的变量排序。此时应当去修根因,而绝不允许更新 mdtest 期望来让 wobble 轮转绿——那样等于把仍在飘移的行为固化成"新常态",恰恰毁掉了这套回归的初衷。
失败形态长什么样
以 crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md 为例,文件中大量 # TODO: sometimes: 注释就是已知的排序敏感输出清单,典型表现有:
- 并集元素顺序漂移:如
# TODO: sometimes: revealed int | str对照默认的revealed: str | int; - 多余/缺失的
Solution:如嵌套传递约束小节中,solutions_for的结果在tuple[Solution[T=list[int]], Solution[]]与含T=Never、重复T=list[int]的备选形态间漂移; - 应被吸收的解未吸收:
Constraint absorption is independent of source order一节验证(scalar & (scalar | tuple_))与((scalar | tuple_) & scalar)都必须吸收出T=str,而真正需要两个解的(scalar | tuple_)仍输出两个解——吸收与否不应偏向某一匹配; - 负化备选注入正证据:
Negated alternatives do not infer positive evidence一节要求¬((T ≤ int) ∨ (T ≤ str))不得给T任何正限制,失败的备选形态是出现T=Never的假解; - 高扇出(high-fanout)截断差异:
High-fanout sequents and inferred-union truncation一节用 12 个下界 × 12 个上界的关系耗尽共有 sequent 燃料预算,验证剩余解、元素顺序与截断诊断展示都不依赖"哪条蕴含先被遇到"。
这些用例都声明了 [environment] python-version = "3.13"(PEP 695 泛型语法所需),并通过 from ty_extensions._internal import ConstraintSet 直接驱动求解器。也就是说,wobble 轮跑的就是这些"刻意制造排序压力"的回归文件;你在失败报告中定位到的文件与行号,通常正好对应某个尚未消除的 TODO: sometimes: 漂移点,或者是新引入的排序依赖。
六、与 constraint_set_ordering.md 互补的独立验证
不要把 TY_CONSTRAINT_SET_ORDER 当作唯一武器。技能文档明确指出:该旋钮不扰动 Salsa 支撑值的哈希,而路径内绑定顺序(binding order)的漂移还可能源于用 FxHashMap<BoundTypeVarInstance, ...> 收集解后以不确定顺序 drain 所致。针对后者,regression/constraint_set_ordering.md 中的 Solution binding order follows constraint source order 小节(constraint_set_ordering.md)走的是另一条独立路径:它只变换 typevar 的声明顺序(bindings_tuv[T, U, V] 与 bindings_vtu[V, T, U] 声明同一组约束),并要求:
- 同一约束组(
(T=int) ∧ (U=str) ∧ (V=bytes))不论 typevar 声明顺序如何,解都必须是tuple[Solution[T=int, U=str, V=bytes]]; - 约束源顺序反转(
bindings_reverse_source先约束V再约束T)时,解内的绑定顺序要跟随首次引入各 typevar 的约束的源顺序(tuple[Solution[V=bytes, U=str, T=int]]); - 被吸收分支中的解不得残留(
bindings_absorbed必须输出tuple[Solution[T=str, U=bytes]],不能带上X)。
把这两者结合理解:TY_CONSTRAINT_SET_ORDER 负责扰动"求解时使用的变量排列"(TDD 变量序 + typevar 定向序),该 mdtest 小节负责在变量声明序维度上单独施压。技能文档最后一句与文件头部(constraint_set_ordering.md)互为印证——后者还提示"可以使用 wobbling-ty-constraint-order agent 技能自动化该过程",即本文所述的整套流程,两者共同构成对 ty 约束集确定性(determinism)的双维度回归覆盖。
七、实操注意事项速查
- 只在 Ruff 仓库根目录执行:脚本中的
-p ty_python_semantic是 workspace 内 crate 选择,需在包含 Cargo.toml 的仓库根运行;工作目录无关性由TY_CONSTRAINT_ORDER_LOG_DIR输出提示确认。 - 绝不在 wobble 轮开启快照更新:
INSTA_UPDATE=no、MDTEST_UPDATE_SNAPSHOTS=0是底线;若环境里预设了INSTA_FORCE_PASS=1(如 AGENTS.md 中的常规开发模式),必须先unset。 - 区分"预期差异"与"真失败":约束集图形结构单测快照在 wobble 下必然不同——那些不属于本轮关注范围;只有 mdtest 的 reveal/error 期望与实际结果不一致才算 wobble failure。
- 失败是改进信号,不是测试缺陷:每条 wobble 失败都对应一处"推断语义或展示解类型仍依赖排序"的实现事实,应回溯
ConstraintSet的求解、路径并集、吸收与展示逻辑修复根因。 - 与主干的常规回归互补:本文技能针对排序依赖;ty 的整体 mdtest 回归(含快照更新模式)仍按 AGENTS.md 常规执行,二者不要互相替代。
延伸阅读:实现层阅读入口为 crates/ty_python_semantic/src/types/constraints.rs(wobble_index、ConstraintId::ordering、BoundTypeVarInstance::can_be_bound_for,以及 tdd_* 结构单测),其求解投影见 crates/ty_python_semantic/src/types/constraints/projection.rs(solutions/solutions_with);环境变量元数据声明见 crates/ty_static/src/env_vars.rs;回归用例与快照机制分别见 crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md 与 crates/mdtest/src/lib.rs。本技能文档本身(SKILL.md)可作为团队内部复现该回归流程的标准操作规程。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00