# 方法学教训（lessons）

每条都是在真实数据上先被骗过一次、然后变成代码里的机制。代码注释与测试 docstring 里的
`docs/lessons.md#<锚点>` 形式的引用指向这里。条目按"它防的是什么错"组织，不按时间。

---

## 一、检验能不能说话

<a name="p-floor"></a>
### p 值地板（p-floor）

配对符号翻转检验在 n 个单元下的最小可能双侧 p 是 **2/2ⁿ**，与效应多大无关。
实测：3 个案例得 Δ=+0.911、CI [+0.893, +0.926]，p 却是 0.252——区间把 0 排除十万八千里，
检验却说"不显著"。地板：n=3 → 0.25，n=4 → 0.13，n=5 → 0.064，n=6 → 0.032。

**机制**：`claim_eval.p_floor(n)`、`min_units_for_alpha(alpha)`（alpha=0.05 → n≥6）。
`interpret()` 在地板 ≥ alpha 时报"**检验无力**"而非"未检出差异"——两句话给读者的行动完全不同。

<a name="floor-basis"></a>
### 地板的基数：不是单元数

**这是集成测试第一次运行就抓到的真 bug，且同一 bug 类在两处**：

- McNemar 精确检验可达的最小 p 只由**不一致对数** d 决定（p = 2/2ᵈ）。10 单元 / 5 不一致对：
  按单元算地板 0.002（看起来能显著），按不一致对算 0.0625（其实到不了）。
- 置换检验同理：符号翻转对**零差值对**是恒等操作，它们不进置换分布。5 对零差 + 1 对满差 → p=1.0000，
  恰是 p_floor(1)。

三种基数，三种措辞，三种处方——**说错会让人加错东西**：

| 基数 | 措辞 | 处方 |
|---|---|---|
显式 `floor_n`（McNemar） | 不一致对 | 加轮次，或换能拉开差距的题 |
`n_effective`（置换） | 非零差值对 | 有单元但差值为零，加轮次未必有用 |
单元数 | 配对单元 | 单纯样本少，加轮次即可 |

MDE 仍按单元数算（单元越多，出现不一致对的机会越多）——两个基数各管一件事。
**这类错单测天生抓不到**：各组件单独都对，错在接口处传了语义不同的同名量。`tests/test_pipeline.py` 为此而建。
连带教训：**诊断信息只该有一个来源**——下游自己重推病因时曾把零不一致对误报成"MDE 不可达"。

<a name="mde"></a>
### 最小可检出效应（MDE）

任务集固定为 n 时，能检出的最小单方面胜率。`detectable_effect(n)`。两条纪律：
- **MDE 要报最小性**：它是"最小可检出"，只测充分性会让虚高值过关。
- 31 题冒烟集的 MDE ≈ 0.24——**任务集看不见多小的差异，要写在报告里**。

<a name="null-bounds"></a>
### null 结论必须附"能排除多大效应"

"Δ=0.000, p=1.000" 曾被写成"效应根本不存在"——那个设计只有 16 单元，MDE=0.46，实际只能排除 ≥46%
的效应。收紧到 80 单元后，可报告的结论是"模型效应 < 10%"。

**机制**：`interpret()` 的 null 分支强制附 `rules_out`（MDE），样本量下不可达时标为**"无信息的 null，
不能当作『无差异』的证据"**——让"样本太小"这件事无法被写成"无差异"。三种"没显著"必须分开：
检验无力（地板）/ 无信息（MDE 不可达）/ 有界的 null。

<a name="saturation"></a>
### 饱和题贡献零信息

全系统全轮次同结果的题（恒过 / 恒败）不携带区分信息：McNemar 的有效样本是不一致对，这类题的贡献恒为 0，
加多少道都不提高功效。**MDE 应按有信息题数读，不按总题数。** `saturation(reports)`。

低 n 筛选会把噪声标成"有区分力"：10 道候选在 n=2 下挑出 1 道（0.5 vs 1.0），n=6 复核后两侧 6/6、
零不一致对。**筛选必须两阶段（筛 + 复核）**：`screen_tasks` / `screen_graded`。

<a name="power-formula"></a>
### 手算样本量的公式答的是另一个问题

`(1.96·sd/Δ)²` 答的是"CI 恰好排除 0"，不是"有 80% 功效检出"——后者是 `((z_{α/2}+z_power)·sd/Δ)²`，
大 **2.04 倍**。实测置换检验功效曲线：n=18 → 0.45，n=26 → 0.60，n=33 → 0.71，n=37 → 0.79，n=42 → 0.83。
用前者规划的那次实验只有五成把握，拿到显著有运气成分（结论经独立复现站得住，但样本量论证是错的）。

**机制**：`required_pairs(mean_diff, sd)`——内部真跑 `paired_compare` 做网格搜索，p 地板做下界。
**规划器与检验器必须用同一把尺子**（`required_tasks` 同理：早先用正态近似，实测在规划出的 n 上精确检验
只有 0.765 功效）。顺带自查出的 bug：每次 sim 复用同一重采样种子，会让所有置换检验用完全相同的翻转模式。

---

## 二、设计能不能回答问题

<a name="ceiling"></a>
### 天花板让交互项不可解释

2×2 里三个格子都是 1.000 时，交互项 −0.667 无法与天花板假象区分——在 1.000 上无法再加。
结论必须拆成**可测的一半**（bare-check 与 strict-single 并驾 → 自检单独补齐了脚手架的全部收益）
与**不可测的一半**（strict-check 是否优于 strict-single → 该格差异恒为 0 是设计所致）。

**机制**：`report()` 自动报出触顶/触底系统，多系统触顶时声明"这些系统之间的 null 与任何交互项都不可解释"。
靠人记得，就会有忘的时候。

<a name="headroom"></a>
### 找有余量的题：换失败机制，不是加难度

六次筛选、五次失败：更难的单步题、多约束题、多步计算题、格式脆弱题，严格提示下全部满分。
成功的一次不是把题做难，而是换了**失败机制**——模型在"该说什么"上很稳，栽在"能不能忍住不算"
（资料只给终值与比率，问初值）。**这一步没有统计工具能替代，只能靠理解模型在哪里真的会错。**

同时两条纪律：**点估计的方向不是结论，CI 是否含 0 才是**（首轮 6 单元 Δ=+0.122、p=0.496 时我已准备写
"推翻"）；**样本量该在看到 CI 之后按需补**，不是先定后测。

<a name="screening-reliability"></a>
### 筛选协议自身的可靠性

同 6 道候选独立筛两次，一致率 **5/6**。分歧那道真实水平约 0.89~1.0：真实 rate=0.9 时单轮抽中满分的概率
就是 0.9，n=2 全满分 0.81，n=3 仍 0.73——**近顶题在任何可负担轮数下都筛不稳**。

处方不是加轮数，而是把近顶区排除在 band 之外（`screen_graded` 默认上界 0.9）。
**"不等于 1.0"不代表"有余量"**——0.95 的题贡献的不一致对趋近于零，与恒过无异。

<a name="task-axes-are-specific"></a>
### 任务的敏感轴是特异的

为脚手架维度筛出的题（对格式敏感）改比模型：有效样本 1/4。为模型维度找区分题时 12/12 全饱和。
**两个维度必须各自筛选**；猜哪些题"应该"敏感会打脸（一批 8 道候选猜错 7 道）。

<a name="complete-report"></a>
### 报告要一次给全

效应量+CI、逐题置换 p、逐轮 McNemar、Holm 校正、不一致对分布+集中度、有效样本、拒答归属、
null 可排除范围、触顶/触底——手工拼装时漏掉任何一项都会让结论失真（null 界就曾被漏掉一轮）。
`paired_bench.report()` 保证九项一次给全。**集中度**（最大单题贡献 / 总不一致对）区分"跨题复现"与"单题异常"。

<a name="drift"></a>
### 结论会跨会话漂移

同模型同题两个时间窗得 0/8 与 8/8。记录在文档里的数字是历史声明；**只有可复跑的检查才能让漂移被发现
而不是被继承**。

<a name="reproducibility"></a>
### 记录的结论变成可执行断言

`reproduce_findings.py` 把三条核心结论变成检查，阈值宽于记录值以容纳正常噪声。判据设计的几条原则：
- **方向反转要报"重大发现"，不要当失败处理**；
- **饱和的处方是换题，不是放宽阈值**；
- **null 的复现比正向发现更容易骗人**：样本不足时"没测出差异"会被当成"复现成功"——必须分开
  "差异变得可检出"（真漂移，FAIL）与"null 仍成立但界更松"（WARN，不构成复现）；
- 阈值双侧约束：`0.0117 < p_max ≤ 0.05`——p_max 放到 1.05 时警报器永不会响。

---

## 三、工具能不能被信任

<a name="gates-self-test"></a>
### 门禁必须自测

`runtests.sh` 的结构自检（定义数 == 执行数、`__main__` 块恰好一个）、钩子、复现判据——每一层都有下一层验证它，
且每层的验证都在建立过程中抓到过至少一个真实缺陷。第一版 `-B` 测试曾是伪保护（只检查字符串出现）。

<a name="tail-safe"></a>
### 汇总行永远在最后

两个套件已红，`| tail -3` 只看到靠前文件的绿色。`runtests.sh` 末行永远是汇总（成败都在其中），任何 `tail`
都能看到。

<a name="mutation"></a>
### 变异测试：机械生成消除选择偏差

手挑的 42 个语义变异 + AST 机械生成的 400+ 个变异点，与已确认等价变异的基线比对（棘轮：新增存活即失败）。
几条实测纪律：
- **空变异对照**：仅 unparse 必须仍然全绿，否则该模块结果不可信（曾四次如实拒绝报告污染状态下的结果）；
- **等价变异应记录而非补测**：为它们写的测试只会把实现细节钉死；已归档 30 条，分类见 `mutate_auto.py` 头部；
- **变异点标识用函数限定名 + 同类出现序号**，不用行号——行号会随任何上方插入平移，基线会集体误报。

<a name="probe-classification"></a>
### 存活变异的探针分类法

对每个存活变异，把变异体 exec 成模块，用一组探针输入算输出向量，与基线逐项比对。
零差异 = 等价；有差异，则差异本身就指出该断言什么。比逐个猜快一个量级
（一次 11 个存活 → 5 等价 / 5 真缺口 / 1 死代码）。注意 `skip_ids` 依赖节点对象同一性，**必须同一棵树计算**。

<a name="injected-rand"></a>
### 蒙特卡洛函数注入确定性随机源

规划器的契约是统计性的，小扰动被随机性吸收——变异测试里它们的存活率远高于其他代码。
注入确定性 `rand`/`gauss` 后，边界（达标比较、搜索上界、计数器初值）才能被精确断言。
分段采样器（按调用序号决定输出）能把"哪几次 sim 通过"控制到精确的 80%。

<a name="float-equality"></a>
### 写了"测边界"不等于测到了边界

`sum([0.95]*18)/18 = 0.9499999999999997`——相等分支从未被走到，变异测试抓到了这点。
测相等要用二进制可精确表示的值（0.5、0.8、0.2），越界用 `math.nextafter`。

**同一条教训的第二次出现，由 CI 第一次运行抓到**：一个"增量恰好等于阈值"的测试用 `0.88 − 0.80` 构造，
它其实是 `0.07999999999999996`（小于 0.08）。Python 3.10 的 `sum()` 朴素累加，18 次舍入误差恰好把均值推回 0.08，
测试**靠运气通过**；3.12 起 `sum()` 改用 Neumaier 补偿求和，结果更准，刀锋翻转。本机只装了 3.10，从未察觉。
处方两条：构造值改成 `0.625 − 0.5 = 0.125`（精确）；`checkall.sh` 第 1b 层自动用本机所有其他解释器重跑快速套件
（`PYTHON=python3.13 sh runtests.sh`），CI 矩阵覆盖最低与最新版本。**测试只在一个解释器版本上绿过，就还没绿过。**

<a name="interrupt-safety"></a>
### try/finally 挡得住 Ctrl-C，挡不住 SIGKILL

变异工具会真的改写源文件。一次扫描被取消后，`claim_eval.py` 停在磁盘上的状态是"被 unparse + 一个活变异"，
快速套件变红；唯一的"备份"是 git HEAD，未提交的改动随 checkout 一起丢。

**机制**：写源文件前先落盘旁路备份 `*.mutate_backup`，启动时见到残留备份就自动还原——
且**必须在读取原件之前还原**，否则会把变异后的文件当原件备份，错误就此固化。

<a name="fast-suite-budget"></a>
### 快速套件的时间预算是契约

两个真跑蒙特卡洛的测试把 pre-commit 依赖的套件从 3 秒拖到 23 秒。边界用注入采样器（0.01s）留在第 1 层；
真校准改名为非 `test_` 前缀，挂到 `checkall.sh` 的慢层。**分层的理由要写在每层的头部。**
