# JSONMend 真值评测登记（EVAL registration）

本文件是 I-4 的**预登记**：在跑任何一次评测之前写定数据集、变异算子、真值定义、
比较规则、排除规则、指标定义与报告格式。评测结果写在
[EVAL.md](EVAL.md)，其头部记录本文件的 sha256，因此登记内容与结果不可能在事后
被改写而不留痕迹。

登记版本：`eval-2`（2026-10-15）。`eval-1` 的首次运行结果、以及因此产生的修订，
完整记录在第 10 节。

## 1. 为什么需要真值

差分评测（[DIVERGENCES.md](DIVERGENCES.md)）只能回答「和 oracle 一致吗」，
不能回答「修对了吗」：两个实现可以同样地错。本评测用**构造真值**替代 oracle：
语料由生成器造出，每个用例的正确答案在生成时就知道，因此
「结果 ≠ 真值」是一个确定的、可归因的事实。

## 2. 数据集

| 项 | 值 |
|----|----|
| 生成器 | `code/tests/eval/corpus.py` |
| 版本 | `eval-2` |
| 随机种子 | `20261015`（固定；同一版本必须逐字节复现同一语料） |
| 目标用例数 | 1200 |
| 导出 | `code/tests/eval/corpus.jsonl`，每行 `{id, family, input, truth, note}` |
| 数据集指纹 | 导出文件的 sha256，记录在 EVAL.md 头部 |

文档生成：递归随机树，键为随机短标识符（含非 ASCII 键），字符串含转义与非 ASCII，
数字为整数或 6 位以内有效数字的十进制小数（不含 `NaN`/`Infinity`，不含
`1e400` 这类会变成非有限值的指数），深度 ≤ 5，节点数 ≤ 40。

## 3. 变异算子与真值规则

每个算子在**原文**上施加一处修改，真值由生成器从变异前的值树按右列规则算出，
**不使用被测实现，也不使用任何 oracle**。

| 族 | 变异 | 真值 |
|----|------|------|
| `clean` | 无（合法 JSON） | 原值 |
| `ws` | 在 token 边界插入空格/换行 | 原值 |
| `quotes` | 所有 `"` 换成 `'`（仅用于不含反斜杠与撇号的文档，见 §10.2） | 原值 |
| `trailing_comma` | 在某个 `}`/`]` 前插入 `,` | 原值 |
| `double_comma` | 在某个 `,` 后再插入一个 `,` | 原值 |
| `delete_comma` | 删除某个 `,`（两侧不得都是裸 token，见 §10.3） | 原值 |
| `delete_colon` | 删除某个 `:` | 原值 |
| `fence` | 用 ```` ```json ```` 围栏包裹 | 原值 |
| `prose` | 前缀一句说明文字（评测时加 `--skip-prose`；仅用于容器根，见 §10.4） | 原值 |
| `comment` | 末尾追加 ` // done` | 原值 |
| `bom` | 前缀 U+FEFF | 原值 |
| `dup_key` | 复制一个成员（键与值都相同） | 原值 |
| `mismatched_closer` | 把最深容器的闭合符换成另一种 | 原值 |
| `extra_closer` | 末尾多一个闭合符 | 原值 |
| `literal_case` | `true`/`false`/`null` 换成 `True`/`False`/`None` | 原值 |
| `control_char` | 在某个字符串内插入一个裸控制字符 | 原值（该字符保留在字符串里） |
| `missing_value` | 删掉某个对象成员的值（`"k":`） | 该成员变为 `null` |
| `ellipsis` | 把某个成员/元素的值换成 `...` | 该成员/元素被丢弃 |
| `truncate_child` | 在某个容器的第 k 个子节点之后截断 | 该容器只保留前 k+1 个子节点 |
| `truncate_string` | 在某个字符串值内部截断（切点不在转义序列内） | 保留前 k+1 个子节点，第 k+1 个为截断后的字符串 |

`dup_key` 之所以用**相同的值**复制成员，是为了让真值与 `duplicate_keys`
策略（KeepFirst/KeepLast）无关，从而不依赖任何实现选择。

**可观测性闸门（§10.1）**：除 `ws`（空白可证不影响取值）与 `dup_key`
（重复的键值完全相同，可证不丢信息）之外，变异后的文本必须**不再是合法 JSON**
（用 Python 标准库 `json.loads` 判定，它是 JSON 规范解析器，不是修复实现，
因此不泄漏真值）。若变异后仍是合法 JSON，说明这次改动在语义上不可观测，
真值主张不成立，该次生成被丢弃。

## 4. 值比较规则

- 结构比较：对象比较键集合与对应的值（忽略键顺序），数组按顺序比较，类型必须一致
  （`true` 与 `1` 不相等，`1` 与 `1.0` 相等）。
- 数字：`math.isclose(a, b, rel_tol=1e-12, abs_tol=0)`。
- 字符串：逐码点相等（UTF-16 码元序列相同）。
- 用例结果取自 `jsonmend --batch` 输出的 `value` 字段（失败时为 `null`），
  不比较修复后的文本排版。

## 5. 排除规则

只有下列情形可以从统计中排除，且必须计入报告：

1. 生成器无法为该族产出合法用例（例如 `quotes` 要求文档不含反斜杠）；
2. 变异后的文本与原文逐字节相同。

**结果不可用于排除任何用例。** 任何在跑完之后发现的排除需求都必须新开一版登记
（`eval-2`）。

## 6. 指标定义

| 指标 | 定义 |
|------|------|
| `N` | 计入统计的用例数 |
| 恢复率 | `value == truth` 的比例 |
| 失败率 | `rc != 0`（没有值）的比例 |
| 差异数 | `value != truth` 的用例数（含失败） |
| **漏标率** | `value != truth` 且 `exactness ∈ {exact, cosmetic}` 的比例（分母为差异数） |
| 诚实标红率 | `value != truth` 且 `exactness == lossy` 的比例（分母为差异数） |
| 分标签恢复率 | `P(value == truth | exactness == L)`，`L ∈ {exact, cosmetic, lossy}` |
| 分族恢复率 | 每个族的恢复率与漏标率 |

## 7. 主验收判据

1. **漏标率 = 0**：任何「结果 ≠ 真值」的用例都不允许被标为 `exact` 或 `cosmetic`。
2. `clean` 族的 `exactness` 必须全部为 `exact`，且恢复率为 1。
3. `ws`、`fence`、`bom`、`prose`、`comment`、`trailing_comma`、`double_comma`、
   `delete_comma`、`delete_colon`、`dup_key`、`mismatched_closer`、`extra_closer`、
   `literal_case`、`quotes`、`control_char` 这些**仅涉及记法**的族，恢复率必须为 1，
   且 `exactness` 必须是 `exact` 或 `cosmetic`（不得为 `lossy`）。
4. `truncate_child`、`truncate_string`、`missing_value`、`ellipsis` 这些**丢信息**的族，
   恢复率必须为 1 且 `exactness` 必须是 `lossy`。

判据 3 与 4 是「标签与事实一致」的双向检查：既不许多报，也不许少报。

## 7b. 第五轮补充登记（0.4.0）

第四轮的主评测只跑了 `--batch` 一次性路径，且语料把歧义类收紧排除了，因此
「漏标率 0」是关于**保留下来的用例**的结论，不是关于难例的结论。第五轮在不改动
`eval-2` 语料与上述判据的前提下，追加三份**独立登记**，各自冻结、各自给出指纹：

### 7b.1 路径矩阵 `path-1`（预登记）

| 项 | 值 |
|----|----|
| 脚本 | `code/tests/eval/run_eval_paths.py` |
| 语料 | `eval-2` 全部 1200 条（不改动） |
| 分块种子 | `20261016`（与语料种子独立） |
| 路径 | `--chunk 1/3/7`、每例随机分块（1,2,4,5,11,13,16 B）、`llm --batch` 双 provider 工具流、`llm --text --batch` 双 provider 文本流 |
| 判据 | 每条路径 1200/1200，且与 `--batch` 基线**零分歧**；任一用例在任一路径上退化即退出码 1 |
| 结果 | `docs/EVAL_PATHS.md`（8 条路径全部 100%，分歧 0） |

文本流路径按族分开调用：`prose` 族加 `--skip-prose`，其余族不加。**这不是为了好看**：
`--skip-prose` 只从 `{` 或 `[` 开始取值，标量根（`0`、`42`、`false`）会被当成前言跳过，
所以默认关闭；把它当默认会静默丢掉 60 条 clean 标量用例。该行为有专门的测试
（`llm/documented_wbtest.mbt`「a scalar root survives the text path」）。

### 7b.2 歧义对抗语料 `amb-1`（预登记）

| 项 | 值 |
|----|----|
| 脚本 | `code/tests/eval/run_ambiguity.py` |
| 用例数 | 24（10 例歧义 + 14 例对照），手写并冻结 |
| 指纹 | 对 `(id, input, 期望歧义类, 期望闸门)` 的 sha256：`c75989b123c438c3e12e70c2efb5b35bf5f9b821042f41c63e64a8856f031afe` |
| 判据 | 10 例歧义全部标记，`--for-execution` 全部以 `E011` 拒绝；14 例对照全部放行；默认路径的值必须等于登记值 |
| 结果 | `docs/EVAL_AMBIGUITY.md`，失败 0 |

这一族的作用是补上 7.1 的缺口：它是**故意挑出来的难例**，而不是生成器碰巧产出的。
首次运行时抓到两个真问题（`None` 被误判为「仅大小写不同」的 R022；`{"a":"x}` 的期望值
写错），都已修复或改登记，说明这套用例确实在测东西。

### 7b.3 参照库对照 `ref-1`（预登记）

| 项 | 值 |
|----|----|
| 脚本 | `code/tests/eval/refcompare.py` + `code/tests/eval/oracle_node.mjs` |
| 语料 | 同一份 `eval-2` 1200 条 |
| 参照 | `jsonrepair` 3.15.0（npm）、`json_repair` 0.64.0（PyPI），版本写进报告头部 |
| 指标 | 恢复真值、**静默错值**（可解析但与真值不同且无任何提示）、显式报错、产出非 JSON、分族恢复率 |
| 判据 | JSONMend 静默错值必须为 0；`--for-execution` 接受的值与真值不符的必须为 0 |
| 结果 | `docs/EVAL_REFERENCE.md`：JSONMend 静默错值 0，`jsonrepair` 122，`json_repair` 189；闸门拒绝 **254/1200**（`eval-3` 标签修订后，见 §11）且放行的错值 0 |

「静默错值」是这次对照唯一重要的列：参照库没有表达「这条修复改变了语义」的通道，
所以它们的错值无法与正确值区分；JSONMend 的错值全部落在 `lossy`，且执行闸会拒绝。

## 8. 环境

| 项 | 值 |
|----|----|
| 被测二进制 | `code/_build/native/release/build/cmd/jsonmend/jsonmend.exe`（sha256 记录在 EVAL.md） |
| 工具链 | `moon 0.1.20261012 (748b63c 2026-10-12)` |
| 运行器 | Python 3（仅标准库） |
| 网络 | 不需要 |

## 9. 未覆盖的内容（诚实声明）

- 本评测不测性能；吞吐与内存见 [BENCHMARK.md](BENCHMARK.md)。
- 本评测的真值来自**合成语料**，不代表真实模型输出的分布；真实分布下的
  恢复率需要线上回放，属于下一轮工作。
- 长文档截断（>1 MiB）只由 TC-P-01 属性测试覆盖，不在本语料内。
- 每个用例只施加**一处**变异；多处变异的组合效应不在本轮登记内。

## 10. 修订记录：`eval-1` → `eval-2`

### 10.1 为什么会有 eval-2

`eval-1` 是第一次运行前写定的登记。跑完后 1200 条里有 101 条「结果 ≠ 真值」，
其中 16 条被标为 `exact`/`cosmetic`（按定义即「漏标」）。逐条归因后分成两类：

| 类别 | 条数 | 结论 |
|------|------|------|
| **语料不合法**：真值主张本身站不住，被测实现的取值其实是对的 | 101（其中 16 条同时表现为「漏标」） | 修订语料（§10.2–10.5） |
| **实现标签过于保守**：取值正确，但把纯记法修复标成了 `lossy` | 300 | 修订实现（§10.6） |

第二类不是 `eval-1` 的「差异」，而是在语料修好、取值 100% 恢复之后暴露出来的：
判据 3 要求记法族不得标 `lossy`，实测 900 条里有 300 条标了 `lossy`。
`eval-1` 阶段**没有发现任何一条取值错误**——jsonmend 的取值在所有 1200 条上都是对的，
错的要么是真值主张，要么是标签的保守程度。

两类都不允许通过「事后排除用例」来掩盖：语料的修订只允许**收紧生成器的适用条件**
（让用例本身有唯一正确答案），实现的修订只允许让标签更贴近事实。

### 10.2 `quotes`：撇号歧义

`"it's fine"` 的 `"` 全部换成 `'` 后得到 `'it's fine'`，它有两种同样合理的读法。
只有当文档中不含撇号时，`'` 才只可能来自被替换的 `"`。**修订：文档含 `'` 时跳过该族。**

### 10.3 `delete_comma`：裸 token 合并

`[1,2]` 删掉逗号得到 `[12]`，这**仍是合法 JSON**，只是取值不同；任何修复器都
不可能知道少了一个逗号。同样地 `[false,false]` → `[falsefalse]` 会走 R004 被加引号。
**修订：逗号两侧同时是裸 token 字符（数字/字母/`+`/`-`/`.`/`_`）时跳过。**
相邻字符串（`["a""b"]`）不属于此列：R006 能正确补回逗号。

### 10.4 `prose`：标量根

`--skip-prose` 的语义是「丢弃第一个 `{` 或 `[` 之前的文字」。当根是标量时
（`Sure! ...\n42`）没有容器可以定位，说明文字与 JSON 无法区分。
**修订：`prose` 只施加在容器根文档上。** 这是 `--skip-prose` 的一个真实边界，
已记入 [DIVERGENCES.md](DIVERGENCES.md) 与 [RULES.md](RULES.md) R021 一节。

### 10.5 截断族的祖先语义

`truncate_child` / `truncate_string` 最初只截断**最内层**容器，于是真值里保留了
截断点之后、属于祖先容器的兄弟节点——那些节点在输入里根本不存在。
**修订：真值改为「丢弃所有起点在截断点之后的节点」（`prune_after`）**，
即截断会同时砍掉每一层祖先的尾部。这是语义修正，不是放宽。

### 10.6 实现修订（由本次评测发现）

| 修订 | 依据 |
|------|------|
| R015 `MismatchedCloser`、R017 `DropExtraCloser`、R021 `SkipProse` 级别由 Warning 降为 Info | 三者都只改记法，不丢也不造值；原级别把「记法修复」误报为「信息损失」 |
| 新增 R022 `NormalizeLiteral`（Info）：`True`/`FALSE` 只是重新大小写 | 原实现把「重新大小写」与「补全字面量」（`tru`→`true`，确实造值）都记为 R012 Warning，标签偏保守 |
| R018 `MergeDuplicateKey` 在两次取值**结构相等**时记为 Info | 丢弃一个与保留值完全相同的重复键不损失任何信息 |
| `Engine::report` 支持显式 `severity` 覆盖 | 上述两条需要调用点决定级别，`severity_in(code, slot)` 无法表达 |

R022 是**新增**码，不改动 R001–R021 的任何含义（码只追加，见
[ERRORS.md](ERRORS.md)）。

### 10.7 eval-2 的结果

`eval-1` → `eval-2`：恢复率 91.58% → 100.00%，漏标 16 → 0，
记法族的 `lossy` 误报 300 → 0，四条判据全部 PASS。
两次的完整数字见 [EVAL.md](EVAL.md) 第 6 节。

### 10.8 仍未覆盖

`eval-2` 没有放宽任何验收判据，只收紧了「什么算一个合法用例」。
因此 `eval-2` 的 100% 不应被读作「在任意输入上都 100% 恢复」，
它只说明：**在上述 19 类单点变异 + 1 个未变异对照组的合成语料上，jsonmend 的取值与标签都与事实一致。**

## 11. 修订记录：`eval-2` → `eval-3`（0.4.0）

### 11.1 触发

第五轮的对抗语料 `amb-1` 在首次运行时发现：`None` 被分类为 `R022 NormalizeLiteral`
（仅改记法，`cosmetic`）。R022 的判据当时写成「替换文本与原字面量**长度相同**」，
于是 `None`（4 个码元）与 `null`（4 个码元）被判为「只是重新大小写」。
这是错的：`None` 是**另一个词**，把它补成 `null` 是发明取值（`R012 ConvertLiteral`）。

### 11.2 实现修订（不改语料）

判据由「长度相同」改为「仅 ASCII 大小写不同」：`ascii_lower(text) == canon`。
影响面是 `literal_case` 族里的 `null` → `None` 用例：11 条由 `cosmetic` 变为 `lossy`。
取值全部不变（11/11 仍恢复 `null`），因此 `DIVERGENCES.md` 逐字节不变。

### 11.3 判据修订（新增 3b，比原判据更严）

`eval-2` 的判据 3 把 `literal_case` 整个算作「仅涉及记法」的族。这个前提本身不成立：
生成器的 `LITERALS = [("true","True"), ("false","False"), ("null","None")]` 里，
前两条是大小写变异，第三条是**换词**（Python 的 `None`）。修订方式**不是**放宽判据 3，
而是把它拆开：

| 判据 | 内容 | `eval-2` 结果 | `eval-3` 结果 |
|------|------|------|------|
| 3 | 记法族（**排除** `null`→`None` 子例）恢复率 1 且标签不为 `lossy` | 900 条中 889 条 exact/cosmetic → **FAIL** | 889 条中 889 条 exact/cosmetic → PASS |
| 3b（新增） | `literal_case` 的 `null`→`None` 子例必须恢复原值且标签为 `lossy` | 未检查（11 条被当成 cosmetic） | 11 条中 11 条恢复、11 条 lossy → PASS |

也就是说：修订后**没有任何一条用例被排除**，被排除的是「这一子例属于记法族」这个错误前提；
新增的 3b 是原判据从未提出过的要求，它比原判据更严（原本这 11 条只要「不是 lossy」就算通过，
现在必须「是 lossy」）。

### 11.4 连带影响

`--for-execution`（`max_loss = cosmetic`）现在会拒绝这 11 条，因此
`docs/EVAL_REFERENCE.md` 的执行闸数字由 **243 / 1200** 变为 **254 / 1200**；
闸门放行且与真值不符的用例仍为 **0**。语料与指纹（`c528375a…`）不变，
其余路径（分块、适配器）不受影响，因为它们的判据是取值而不是标签。

### 11.5 教训（写给下一轮）

「标签自洽」这类判据很容易写成一个**同义反复**：只要把某族定义成「仅记法」，
该族就自动满足「不得为 lossy」。本次修订把 `literal_case` 拆成两个判据，
正是为了消除这种自证。其余族也存在同类风险（例如 `mismatched_closer` 是否可能
同时改变取值），已在 §9 的未覆盖清单里如实记录。
