# 开发过程记录（归档）

这是 [`CHANGELOG.md`](../CHANGELOG.md) 在改成标准格式之前的内容：逐轮的实测数字、量过并**否决**的方案
及其理由、以及每条结论背后的证据。保留它是因为每个数字都该可追溯；它不随代码维护，格式也不再统一，
条目按当时的口径书写（报告的上限、被回退的实现都可能与当前代码不同）。

面向使用者的版本变化看 [`CHANGELOG.md`](../CHANGELOG.md)，当前能力与限度看
[`../README.md`](../README.md) 与 [`roadmap.md`](roadmap.md)，当前算法看 [`algorithms.md`](algorithms.md)。

---

## [Unreleased]

（当时在此累积下一轮开发。）

### Added — M5 第四十三轮：`blend2` 证到公开最优值（小规模覆盖面 4/10 → 5/10），并让两份报告的上限由实测决定

**为什么是它**：第四十一轮把 `blend2` 的界从 `7.479003457675444` 抬到 `7.571088603672391` 之后，它的 gap 只剩 `0.027896396327609096`（当前解本来就是公开最优 `7.598985`），而报告仍写 `node-limit`。本轮把节点预算一路放到 60 000，诊断给出的真相是：**它的树在 25 230 个松弛处就空了**，卡住的是**一格**"写不出证书"的松弛——那一格让整轮不能自称"树已跑尽"。第四十一轮加的恢复梯子（Bland 重启 → 去掉退化微扰重走）对它无效：调用方的微扰、0、放大到 `1e-9` 三种走法**全部拒签**。

**影子探针找到真正的旋钮**（在无结论的分支里对**同一模型**试几种走法，它们**不计入 `nodes`**，因此运行的轨迹逐字不变）：

| 走法 | 是否写出证书 | 枢轴 |
| --- | --- | --- |
| 调用方的增益定价（32 个候选） | **拒签** | — |
| 微扰取 0 / 取 `1e-9` | **拒签** | — |
| 增益候选 **0**（纯 Dantzig） | **写出** | 331 |
| 增益候选 4 | **写出** | 331 |
| 增益候选 128 | **写出** | 312 |

⇒ 决定那一格的是**定价规则**（它决定运行落在这张最优面的哪个顶点上），不是微扰。恢复梯子的第三步因此是"**同一个模型、用 Dantzig 规则定价**"（仍保留在"符号类失败"这道门上，结果照旧过全部自检与独立校验器；只保留候选数最少、规则最老、实测枢轴最少的那一个，另两个的数字留在上表）。实测（两格都是实测量，差在**轨迹**上 —— 下潜储备 `max_nodes / 16` 与启动份额都是预算的比例，所以预算是多少，树走的路也不同）：60 000 预算的探针报 **`mip=optimal nodes=25230 verified=25230 obj=7.598985`**，**报告采用的 30 000 预算**报 **`mip=optimal nodes=24934 verified=24934 obj=7.598985`** —— 两格都是该实例的公开最优值，且 `verified == nodes`（没有一格没有结论），`check-mip-objectives.ps1` 对两格都取等。

**两处报告上限现在都是实测定出来的**（都在 `bench/report.md` 的索引里逐字记录）：

| 口径 | 旧 | 新 | 依据 |
| --- | --- | --- | --- |
| 小规模清单的节点预算 | 20 000 | **30 000** | 清单里最深的证明（`blend2`）在 60 000 预算的探针里落在 **25 230** 个松弛上、在 30 000 本预算下落在 **24 934** 个；30 000 是最小的整千覆盖值 |
| 求解报告的枢轴上限 | 1 200 | **20 000** | 1 200 比同一份清单里 MIP 报告给每个松弛的上限低一个数量级，而且从未被量过；改成与其它报告一致后，两个"其实解得完"的实例不再被上限挡住 |

**实测（三份报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 小规模报告 `proven optimal` | 4 | **5** |
| 小规模报告 `blend2` 行 | `node-limit`、界 `7.571088603672391` | **`optimal`、`7.598985`（公开最优值，`check-mip-objectives.ps1` 取等）** |
| 求解报告 `solved to optimality` | 18 | **20** |
| 求解报告 `other outcomes` | 3 | **1** |
| 求解报告 `danoint` | `iteration-limit`、1200 枢轴 | **`optimal`、1907 枢轴、`62.637280418466226`**（`mod010` 同：3455 枢轴） |
| 求解报告 `pivot iterations (total)` / `largest solved model` | 6 974 / 576 行 | **12 336 / 664 行** |
| 两项对拍 | — | `checked 3 / checked 4`、`violations 0 / unverified nodes 0`；测试 198 |

**限度**：① `blend2` 的证明需要 **25 230** 个松弛（30 000 预算下 24 934），30 000 的预算覆盖它，而**同一实例在 20 000 预算下仍是 `node-limit`** —— 两个预算是两场实验，旧口径的数字留在本文件（这一条与"覆盖面 5/10"必须一起读）；② 恢复梯子现在最多四次走法（Bland → 无微扰 → Dantzig），**每一次都是一次完整求解**，且只在"符号类失败"时触发（语料里只有 `blend2` 走到）；③ `fast0507` 需要的不止 20 000 枢轴，仍在上限上停住（20 000 枢轴、目标 `164.252869961398` 是实测量、不是答案）；④ 更大预算下 `blend2` 之外的行是否也变（额外 10 000 个节点会给五个到预算的行更好的界与点）没有逐行归因到本轮的两个改动上。

### Fixed — M5 第四十二轮：发 `Infeasible` 之前，按校验器的方式量那条射线

**为什么是它**：第三十九轮修的是"热启动拒签没有交回冷路径"、第四十一轮修的是"内核从没量过模型变量自己的界" —— 都属于同一族：**生产者发出的证书被校验器拒**。**不可行主张**是最后一类没有被生产端量过的：内核报 `Infeasible` 之前只查一条代理判据 `Σ 人工 ≥ 最大违反量`（`infeasibility_is_certified`，第二十三轮），而那句话是关于**人工变量**的（算术是否自洽），不是关于**射线**的。第三十九轮的实测把它点破：一次"不限符号类"的无微扰恢复在 `noswot` 上回来的是 `Infeasible`，而**射线被校验器拒** —— `ray signs failed: 7653491597709528000`（不是舍入，是根本不像一条射线）；在分支定界里，被拒的证书**停掉整轮**（`bench/report-mip.ps1` 会拒绝写报告），比丢掉一个节点贵得多。

**改了什么**：`simplex/certificate.mbt` 新增校验器关于射线的三条测量，**式子与尺度逐字取校验器的那一条**：

| 新测量 | 校验器对应项 | 关键点 |
| --- | --- | --- |
| `ray_sign_violation` | `ray signs` | 符号约定按**射线自己的尺度**读：`verify` 量不可行证书用 `cost_scale(zero_cost)`，即地板值 **1**（射线是关于行的证明、不含目标），**不是**运行自己的 `max|cⱼ|`。为此 `max_dual_sign_violation` 的尺度改成由调用方传入（最优性那条路线继续传目标尺度） |
| `ray_box_absorption` | `ray is absorbed by the box` | 某变量的检验数（取 `c = 0`）指向一个无界方向 ⇒ 这条射线没有被盒子吸收 |
| `farkas_margin` | `farkas margin` | `Σⱼ min over [lⱼ,uⱼ](rⱼxⱼ) + D`（取 `c = 0`）必须**严格为正**（零边际是那条平凡组合、什么都不证明），按 `1 + |D|` 缩放；无界方向给哨兵而不是数字 |

**一条尺度用错在两个方向上都有代价**：太大就放行校验器会拒的射线（停整轮），太小就拒掉校验器会收的射线（白丢一棵子树）—— 这就是为什么尺度必须**从校验器那侧取**，而不是"看着合理"地拍一个。

`simplex/revised.mbt` 的 Phase I 不可行分支现在先做这三条测量，任何一条超容差就报 `NumericalFailure`（消息里带三个实测量与人工和），不发出注定被拒的证书。第二十三轮那条代理判据**保留**：它问的是**算术是否自洽**（`noswot` 节点 1558：原始残差 `1.94e-6` 而人工和只有 `1.2e-11`），三条测量问的是**射线是否是证明**，两者互不蕴含。

**实测（三份报告重生成，一份新测试）**：① 新测试 `the kernel measures a farkas ray the way the checker reads one` 把内核的三个数与校验器自己那条 `measured` **逐位对齐**（`rel=1e-12`），并用一个价格 `1e9` 的同构模型钉住"射线的尺度是地板 1、不随目标走"，另用一条符号错误的射线与一条弱射线（边际为负）钉住两条路都能被抓到；② **200 个随机模型的证书语料**（`every certificate on a random corpus is accepted`，三种主张都覆盖）在新检查下全绿 ⇒ 新检查**不会拒掉校验器会收的证书**；③ 解析报告、求解报告、**两份 mip 报告**除 commit 行外**逐字未动**（语料里当前所有不可行主张本来就都被校验器接受，所以这三条测量对现有数字是零影响）；④ 测试 197 → **198**。

**限度**：① 射线的**构造**仍是"Phase I 的最优对偶解"，不是从 Phase I 状态显式导出的构造 —— 本轮只保证**发出去的射线被量过**，不是"总是能造出一条射线"（造不出来时按"没有结论的松弛"计，搜索继续）；② **无界主张的射线**仍然只自检了"点可行"，没有自检校验器审的另外两项（行不阻挡该方向、目标严格改善）—— 同一族的下一个缺口，本轮**没有**实测到它会咬人，所以没有动；③ 三条测量的**时钟开销没有单独量**（各随 `nnz` 一遍，与间隙自检同量级，只在不可行结论发生时跑）。

### Fixed — M5 第四十一轮：给"写不出证书的基"换一条路走（`blend2` 的界 +1.23%、gap −77%）与变量界的自检

**为什么是它**：第三十九/四十轮之后，两份报告里"一个整数点都没有"的行已被 `gt2` 填上，而小规模清单里 `blend2` 的界 `7.479003457675444` **完全由"没有结论的松弛"撑住**。诊断（出口探针 + 20 000/40 000 节点两口径）：它的树在 **25 130 个松弛处就跑空了**，其中 **3 个节点**回来的是"基按检验数最优、但证书写不出来"，而这 3 个节点的**键就是运行报出的那个界**（节点 10 079：`key=7.479003457675444`，而它自己这次松弛的值是 `7.574291645430355`）。三个节点都只花了 **248 / 293 / 287 个枢轴**、失败项都是同一个：`dual signs 2.734138795192516e-7 / 1.1535345722906087e-7 / 1.1535345722894802e-7 on model row 185`，容差 `1e-7`。

**先试了"把基交给调用方自己的校验器"（roadmap 第 3 项），实测关闭**：把点、乘子与基交出去、再由本包自己那条"先原样、被拒才夹零"的修复链（`mip/duals.mbt`）试一遍 —— 原样被拒（符号），**夹零之后坏在另一项上**：`complementarity (columns) failed: 4.414531683233142e-6 / 1.0604979712534555e-6 / 1.0604979712545522e-6`，是容差的 **44 倍与 10.6 倍**。⇒ 那份基的乘子是**真的坏**（越界乘子作用在一行有松弛的行上，夹掉它就等于把界拉下来），不是舍入 ⇒ **这条路不是"换个地方判"，而是"没有证书可写"**。

**改了什么**：`simplex/revised.mbt::solve_problem` 的恢复梯子从一步扩到两步 —— 第一步照旧（Bland + 缩短重新分解间隔），**第二步是"同一个模型、去掉退化微扰"重走一遍**，且**只在前一步失败是"符号类"时**才走。理由是可解释的：微扰是加在每行右端项上让并列变严格的小量，它同时决定这次运行落在**对偶面的哪个顶点**上，而"符号被拒"正是关于这件事的陈述。同一轮补上一个**真实的自我检查缺口**：内核此前只量行残差与**非负性**，而"非负性"说的是内核列 `xⱼ ≥ 0`，**不是**模型变量的区间 `[lⱼ, uⱼ]` —— 新增 `max_bound_violation`，式子与尺度逐字取 `verify/checks.mbt::bound_violation`（`raw/(1+|xⱼ|)`、用 `model_lower`/`model_upper`）。

**实测（三份报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 小规模 `blend2` 的界 | `7.479003457675444` | **`7.571088603672391`**（+1.23%） |
| 小规模 `blend2` 的 gap | `0.11998154232455605` | **`0.027896396327609096`**（−76.8%） |
| 小规模 `blend2` 的"没有结论的松弛" | 3 | **1** |
| 小规模 `blend2` 的 `verified` | 19997 | **19999** |
| `blend2` 的当前解 | `7.598985`（公开最优） | **未动** |
| `noswot`（两口径）、`flugpl`、`p0201` | — | **逐字未动** |
| 测试 | 196 | **197** |

**同一轮量清的三条"不要这样做"**：① **无微扰走法不能用在所有失败上** —— 用在 `noswot` 的一次非符号失败上时，恢复回来的是一份 `Infeasible`，而它的**射线**被校验器拒（`ray signs failed: 7653491597709528000`）；在分支定界里被拒的证书**停掉整轮**（`mip-report*.ps1` 会拒绝写报告），比丢掉一个节点贵得多 ⇒ 恢复梯子的第二步因此**限定在符号这一类**。② **无微扰不能当默认值** —— 把它设成全局默认时整条轨迹都变：`blend2` 的界确实到 `7.483627666391449`（+0.06%），但**当前解从 `7.598985` 掉到 `8.970912`**（gap 从 0.12 变成 1.49）⇒ 它是一次恢复尝试，不是一个设置。③ **给失败换一个公开状态时要连 `warm_result_is_usable` 一起改** —— 本轮先做的版本把"符号拒签"换成了一个新状态，而那个谓词只认 `NumericalFailure`，于是**第三十九轮的冷回退被绕过**：`blend2` 的界掉到 `7.399764899326714`、没有结论的松弛从 3 涨到 10。那条状态改动最后**整体回退**（理由见上：交给校验器这条路实测关闭），但这条教训留在 `docs/roadmap.md`。

**限度**：① 恢复梯子的第二步**只被语料里的 `blend2` 走到** —— 证据是它那一行的变化（小规模：无结论的松弛 3 → 1、`verified` +2），而**宽口径、解析与求解三份报告除 commit 行外逐字未动**（即另外两处口径上这一步没有改变任何数字）；它没有单元测试能构造出相同情形，证据是那三行 `dual signs` 的实测，不是断言；② `blend2` 在 20 000 节点口径**仍未证明**（界 7.5711 对当前解 7.598985，gap 还有 0.0279）；③ 变量界自检的**时钟开销没有单独量**（它随变量数一遍，与间隙自检同量级）；④ "无微扰"这一手是**换顶点**，不是"更准"：它对符号类有效，对残差/间隙类无效（那些点在微扰下就已经不够准）。

### Added — M5 第四十轮：走法的步数按**实测价格**给（`gt2` 第一次拿到整数点）

**为什么是它**：第三十九轮把"没有结论的松弛"收干净之后，两份报告里唯一**一个整数点都没有**的行是 `gt2`（188 个整数变量、29 条行；报告里 `objective` 空、`gap` 是 `1e300` 哨兵）。第四轮把下潜的失败分成过三类（步数不够／死区／从未启动），本轮先用**出口探针**把它归类 —— 探针只在下潜的每个出口打印"这一次启动花了几步、用光时还剩几个分数整数"，是纯 I/O，**不改变任何数字**：

| 量 | 实测（`gt2`，20000 节点口径） |
| --- | --- |
| 启动次数 | **22 次**（前 2 次由 `dive_attempts`，其余由储备 `max_nodes/16` 支付） |
| 结束方式 | **22/22 全部"步数用光"** —— 没有一次落地，也没有一次进死区 |
| 启动时的分数整数 / 用光的步数 | 14~19 个 / 60 步 |
| 用光时还剩几个分数整数 | **1、1、2、2、2、3、4、5、6、7、7、8、8、8、9、10、11、12、14…**（最好的一次只剩 1 个） |
| 这一批启动总共花掉 | 1320 个松弛（6.6% 的预算），换来**零**个整数点 |

⇒ 启动判据里的 `2×分数变量` 那条**2 是下界而不是价格**（第六轮已经这么写）：`gt2` 的走法**每个整数要花约 4~5 步**，所以 60 步的走法**一次都落不了地**。同一份探针把上限放到 300 步时，四次启动里有一次**在 82 步落地**（`obj=51051`）—— 82 步 / 18 个整数 = **4.6 步每个整数**。

**改了什么**：启动判据改成按**实测价格**收费。新增 `mip/search.mbt::walk_step_price = 5`（`gt2` 落地那次 4.6 的取整），判据从 `2·fractional ≤ dive_steps` 改成 `walk_step_price·fractional ≤ dive_steps`，并把 `MipOptions::dive_steps` 的默认值从 **60 提到 150**。两个数**一起**才成立：`5×30 ≤ 150` 与旧的 `2×30 ≤ 60` 是**同一批启动**，所以变的是"每次启动能跑多久"，不是"从哪里启动"。判据被提成一个可测的谓词 `walk_fits_budget(fractional, ceiling)`（零个分数整数不是走法 —— 那个节点本身就是一个整点）。

**实测（两份报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 小规模 `gt2` 的当前解 | **没有**（`objective` 空） | **`51051`**（点通过报告自己的独立复核：行 `2.37e-13`、界 `0`、整数性 `1.19e-12`） |
| 小规模 `gt2` 的 gap | `1e300` 哨兵 | **`30818.506251865998`** |
| 小规模报告 summary 的 `reported points checked` | 9 | **10** |
| 小规模其余 9 行（四个已证最优 `flugpl` 13 806 / `khb05250` 107 / `p0201` 609 / `22433` 33、全部当前解、`blend2` 的 `7.598985` 与界 `7.479003457675444`） | — | **逐字未动**（`blend2` 只有"仍在开"计数 15414 → 15357） |
| 宽口径 15 行 | — | **14 行逐字未动**（含 3 个已证最优、`markshare1/2`、`noswot`、`pk1`、`p0201`、`misc07`、`50v-10`、`dcmulti`、`rout`、`22433`） |
| 宽口径 `blend2` 的界 | `7.180925257756193` | **`7.139367545067016`**（−0.58%；它的两次走法原来在 60 步被**截断**，现在走到 103 / 102 步才进死区，多吃掉的 86 个松弛来自 300 的预算） |
| 测试 | 195 | **196** |

**限度**：① 价格是**筛查线而不是落地保证** —— 两份清单里落地的走法实际花 **1.25**（`p0201` 10 步 / 8 个整数）到 **7.5**（`markshare1` 45 步 / 6 个）步每个整数，而进死区的走法能花到 **17**（`blend2` 103 步 / 6 个），所以"5"这个数不构成上界，它买到的是"再没有一次启动被上限截断"（两份清单共 34 次启动，除 `noswot` 宽口径那一次被**剩余预算**限到 123 步外，没有一次走到 150 的上限）；② 总的启动次数与启动判据不变，只有"能跑多久"变了；③ `blend2` 宽口径那一格是这一轮唯一付出去的东西，没有做"按预算份额给走法设上限"的变体 —— 按同一份探针记录的启动规模（`misc07` 16、`noswot` 20/25、`pk1` 13/14、`gt2` 16/17），把 300 节点口径的走法上限压回 60 会把这些**已经落地或正在工作**的启动挡掉，未做实测；④ `gt2` 的点 `51051` 离公开最优 `21166` 很远（界 `20232`），买的是"有东西可报告"而不是"点很好"。

### Fixed — M5 第三十九轮：热启动的"拒签"没有交回冷启动（`blend2` 的界 +1.07%、gap −40%）

**为什么是它**：第三十八轮末尾把"下一个靶子"定成 `blend2`（当前解已是公开最优 `7.598985`、报告的界 `7.3998`，看起来只差 2.6%）。本轮先量它的**根界-割轮次曲线**（第十八/二十五轮那台仪器，`blend2` 从未量过）：

| 割轮次 | 0 | 1 | 2 | 3 | 4 | 6 | 8 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 根界 | `6.915675114016799` | `7.024901954166015` | `7.033425128571356` | `7.0514803443282945` | 同左 | `7.051865312604021` | `7.054528262879878` |
| 割数 | 0 | 6 | 16 | 16 | 16 | 16 | 16 |

⇒ **报告的界 `7.3998` 不是根 LP**（无割根 LP 只有 `6.9157`，八轮割也只到 `7.0545`），它是**树**推上去的；割的供给也薄（只有 16 条合格割，第三轮起再给轮次只值 +0.04%）。再把节点预算从 20000 提到 40000：运行**在 24743 个松弛就把树跑空了**（`40 node relaxation(s) did not finish, so the tree is not exhausted`），而界**逐位不变** ⇒ 卡住它的既不是"树还很大"，也不是迭代上限（把每次松弛的上限提到 **4 倍**，结果逐位不变），而是**有 40 个松弛没有给出结论**。

**根因（影子探针定位）**：这 40 个松弛在**热启动**路径上回来的是 `SimplexStatus::NumericalFailure` —— 对偶路径找到的基按检验数是最优的，但**证书写不出来**（乘子越出证书的符号约定，或乘子给出的界合不上）；而**同一个模型冷启动重解全部得到结论**（探针打印了 40 次 `cold=ok`，它不计入 `nodes`/`verified`，所以运行的路径逐字不变）。`solve.mbt::solve_model_with_basis` 的文档字符串写着 *"anything short of that falls back to the cold path"*，但它只在 `solve_from_basis` 返回 `None` 时回退 —— **拒签是以"结果"而不是 `None` 返回的** ⇒ 那条回退被绕过。丢掉的那 40 个节点不再入队，**它们的键（父节点的松弛值）正是运行报出的界**。

**改了什么**：`simplex/solve.mbt::warm_result_is_usable` —— 热启动的结果是 `NumericalFailure` 时把模型交给冷路径；`IterationLimit` **不**交（那是关于上限的话，冷路径拿到同一个上限，按"没有结论的松弛"记账仍是调用方的事）。两个公开入口（`solve_model_with_basis` / `solve_standard_with_basis`）都走这条规则。**这同时是一个已发布入口的缺陷**：调用方自己拿一个 `SimplexBasis` 来热启动时，会遇到"冷启动能答、热启动拒答"。

**实测（三份报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 小规模 `blend2` 的界 | `7.399764899326714` | **`7.479003457675444`**（+1.07%） |
| 小规模 `blend2` 的 gap | `0.1992201006732861` | **`0.11998154232455605`**（−40%） |
| 小规模 `blend2` / `noswot` 的"没有结论的松弛" | 33 / **91** | **3 / 2** |
| 小规模 `noswot` 的 gap | `3.0000000001029505` | `3.0000000000513367` |
| 其余每一行（四个已证最优 `flugpl` 13 806 / `khb05250` 107 / `p0201` 609 / `22433` 33、全部当前解、宽口径 15 行） | — | **逐字未动** |
| 测试 | 194 | **195** |

**限度**：① 冷回退**不计入 `nodes`**（沿用 `None` 那条既有约定）⇒ `nodes` 列计的是"被判定的松弛"数，而一次热启动拒签内部的两次求解只算一个单位（时延不是报告口径的量，但读者要按这个口径读）；② `blend2` 在 20000 节点口径**仍未证明**（开着的节点从 15358 变成 15414 —— 被判定的节点会继续分支，树因此更大）；③ 本条只覆盖"拒签"这一类；④ 上一轮把 `blend2` 判成"只差 2.7% 的界"这件事本身**被本轮更正**：那个界是**前沿**（被丢下节点的键）而不是根松弛，所以"多切几轮/多给预算"都不是它的杠杆。

### Added — M5 第三十八轮：**采样取整**（零松弛代价的新整点来源）与下潜门从 1/10 放宽到 1/5

**为什么是它**：取整一直只试**三个**确定性方向（就近 / 向上 / 向下）。而取整空间的大小是"每个还没定下来的整数一个取值"的乘积 —— `gt2` 根松弛有 16 个分数整数，那就是 **65 536** 个点，而运行在每个节点上都只问了同样那三个。实测（第三十四轮的诊断谱）这些行的三个方向离可行 5%~99%，但它们只是空间里的三个点。

**改了什么**：`mip/rounding.mbt::sampled_candidate` —— **采样取整**：每个整数以**自己小数部分为概率**向上取整、否则向下（无偏），界与整数性照旧夹紧；流由调用方持有（`Int64` 对 2 的幂取模，**跨目标逐位一致**）。它**一次求解都不花**，所以只要确定性三方向全落空就抽一个样本（与它们同尺的 `point_violation` 判可行，**只接受严格更好的点**）。

**同一轮修掉的第三十七轮代价**：`run_point_is_far` 的线从 1/10 放到 **1/5**。理由是**两种走法的价格差**：修复花 1 个松弛、下潜最多花 `dive_steps` 个，所以下潜要更宽的余量才值。实测：1/10 时门在 `p0201` 的点离（当时还很远的）开界 **9%** 时就开了，代价是它的证明 `609 → 617`；1/5 把这一格关掉，而门**服务的那几行**本来就比五分之一更远（`markshare1` 100%、`markshare2` 100%、`pk1` 74%、`noswot` 26%、`misc07` 48%、`50v-10` 66%）。

**实测（两份报告重生成）**：

| 量 | 第三十七轮报告 | 本轮 |
| --- | --- | --- |
| 小规模 `noswot` 当前解 | `-35.000000000009315` | **`-40`**（gap `9.000000000094` → `3.000000000103`；公开最优 `-41.00000885`） |
| 小规模 `p0201` 的证明 | 617 节点 | **609**（第三十七轮的代价收回） |
| 宽口径 `p0201` 的界 | `7555.000000031646` | **`7555.000006642165`**（同样收回） |
| 其余每一行（`markshare1` 宽 `257.000000000293`、`noswot` 宽 `-35.000000000009315`、`flugpl` 13 806、`khb05250` 107、`22433` 33、`mod010` 31、`blend2` `7.598985`、`markshare1/2` 小、`pk1` 18、`50v-10`、`dcmulti`、`rout`、`gt2`/`blend2`/`flugpl` 宽无点） | — | **逐字未动**；覆盖面仍 3/32 与 4/10 |
| 测试 | 193 | **194** |

**一条方法论（第三十三轮的复述）**：采样取整**第一次量的时候是"no-op"** —— 那时它只服务"一个点都没有"的运行（那一批里两万次采样确实一次也没落出可行点），而**它真正推动的行（`noswot`）本来就有当前解**。**机制服务的状态决定了要在哪个人群里取样**：把样本挪到"有当前解但点很远"的人群里，同一个机制立刻给出 −35 → −40。

**限度**：① 采样取整对**离可行很远**的行无效：宽口径 300 个样本、小规模 2 万个样本，`gt2` / `blend2` / `flugpl` **一个可行点都没采到** —— 空间大不等于样本密度够；② 采样是**每节点一个样本**（节点数就是样本数），没有做"每节点多个样本"的敏感性；③ 1/5 与 1/10 都是**拟合线**（前者由两种走法的价格比给出理由，但没有做过扫描）；④ 采样器的流固定从种子 `1` 起（同一运行逐位可复现），"换一个种子会不会更好"没有量；⑤ 本轮**没有**改善覆盖面（3/32 与 4/10 未动），买的是"当前解更好"。

### Added — M5 第三十七轮：下潜的第二个启动理由（"当前解离界很远"），换 `markshare1` 与 `noswot` 的当前解

**为什么是它**：报告的"当前解"一列里最远离公开最优的那几行，正是**点很早就停住不动**的行 —— 实测 `markshare1`（小规模）在 `solved ≈ 860` 就到 112，此后把 400 次修复额度全花光而**最后三百次尝试一次也没赢过**（回值 515 / 158 / 180）；`pk1` 只跑出 65 次尝试（额度 400），因为修复的门读的是**该节点自己的松弛**，而 best-bound 的深节点上那个值本来就贴着当前解。两条"重新瞄准"的改法都实测为 **no-op**：① 让修复不再被"取整给出了点"挡住（取整几乎从不给出可行点，于是什么都没变：`markshare1` 81/400、`pk1` 2/65、`noswot` 0、`markshare2` 71/386）；② 把门的参照物换成**这次运行的开界**（计数与结果逐字相同：112 / 18 / −34 / 122）。⇒ **修复链是饱和的，不是缺预算**（这条负结论与上面两条一起记在这里，免得下一轮再走一遍）。

**改了什么**：真正能推动这些点的是**下潜**。原来它只在"没有当前解"时启动、拿到第一个点后永久关闭；现在多一个启动理由：**当前解离这次运行还能证明的最好下界很远**（`mip/search.mbt::run_point_is_far`，用的是与修复同一条 **1/10** 线，**没有引入新常数**）。这样启动的走法**只花它本来就有的 `dive_attempts` 次数，绝不动下潜储备** —— 储备是留给"一个点都没有"的运行的。**证明在跑的行门依旧关着**：那正是第二十六轮打开它时付出代价的地方（`flugpl` 13 806 → 16 306、`p0201` 586 → 938）。

**实测（两份报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 宽口径 `markshare1` 当前解 | `274.0000000003064` | **`257.000000000293`**（−6.2%） |
| `noswot` 当前解（宽 / 小） | `-34.00000000000816` | **`-35.000000000009315`**（gap `9.000000000039` → `8.000000000038`；界 `-43.000000000047194` 未动） |
| 其余每一行（`flugpl` 13 806、`khb05250` 107、`22433` 33、`mod010` 31、`blend2` 7.598985、`markshare2` 192、`pk1` 18、`50v-10`、`dcmulti`、`rout`、`gt2`） | — | **逐字未动**；覆盖面仍 3/32（宽）与 4/10（小） |
| 代价：小规模 `p0201` 的证明 | 609 节点 | **617**（+1.3% —— 与第三十四轮为同一根轴接受过的那类价格同形） |
| 代价：宽口径 `misc07` / `pk1` 的界 | `1637.7272727318607` / `9.948025263461858e-13` | `1634.1071428582152`（−0.22%）/ `1.004193262207216e-12` |
| 测试 | 192 | **193** |

> **一处引用更正**：本轮代码提交的信息里把 `misc07` 的界代价写成 `1634.9450549467501 → 1634.1071428582152`（−0.05%），那是**第三十五轮之前**的旧基线；对着**上一份报告**的实测是 `1637.7272727318607 → 1634.1071428582152`，即 **−0.22%**。数字以本表与两份报告为准。

**限度**：① 1/10 与修复共用（不是新拟合值，但也**没做敏感性**）；② 多出来的走法按 `dive_attempts` 记，宽口径下最多 2 × `dive_steps` = 120 个松弛（占 300 的 40%），这部分预算是从树里拿的；③ 这条门买的是"**当前解更好**"，**不是覆盖面**（`noswot` 只动 1 个单位、`markshare1` 距公开最优仍很远）；④ 门用的开界是**当前**的开界，若某行早期开界远而后来收紧，门会在早期打开一次（`p0201` 的 +8 个节点就是这一格）。

### Fixed — M5 第三十六轮：每个"花掉一次松弛"的地方都要先问预算（`rout` 报出过 301 > 300）

**为什么是它**：`max_nodes` 就是运行报出去的那个数 —— "relaxation budget of 300 reached" 这句话必须真的是它花掉的次数。有四个地方各自解一个模型然后 `solved = solved + 1`：节点自己的松弛、根割轮的**重解**、取整的**修复**、泵的**投影**。泵和走法都先问过预算（走法的步数被夹到"还剩多少"），另外两个没有 ⇒ **实测**：`--max-nodes 1` 且开着割时，根的松弛用掉那 1 次、割轮的重解又花一次（`nodes = 2`）；`max_nodes = 300` 时 **`rout` 报出 301** —— 它最后一个节点的松弛正好落在预算上，紧接着的修复又花了一次。

**改了什么**：两条路径都走同一个谓词 `mip/search.mbt::budget_left(max_nodes, solved)`（`solved < max_nodes`），与泵、走法问的是同一个问题。

**测试**：不是场景而是**遍历** —— 套件里每个模型 × 预算 1..8，断言运行报出的松弛数不超过给它的数。**红跑记录在案**：去掉割轮那条守卫，它在预算 1 上失败（正是上面那一格）。**限度**：修复那一格的边界没有被这条测试钉住（套件里的小模型走不到），它的证据是 `rout` 的实测与代码注释。

**本轮量到的两条"此路不通"（都留着）**：

1. **让"拉"停在目标上不能修好投影**：把每个整数夹在"它现在的位置"与"取整要它去的位置"之间，线性拉就变成了精确的 L1 距离（线段内 `x − target` 的符号固定）—— 但实测是**停住**而不是变好：取整的违反量逐轮**逐位重复**（`gt2` 连续五次 `0.16666666666666666`、`flugpl` 连续五次 `0.002409087374377858`、`blend2` 五次 `0.997444576162963`），因为投影回来的点，它的最近取整恰好还是被拉向的那一个。**已回退**。
2. **第三十五轮第一版给 `gt2` 在小规模口径拿到的那个点拿不回来**：那一版在每个没有点的节点都开泵，一百次里有一次落地；在这版代码产生的轨迹里**一次都不落** —— 影子探针（不计入 `nodes`/`verified`，因此运行的路径逐字不变）试了前 6 个节点各自的点与模型，又把窗口放大到"储备那么多"（100 次尝试、逐节点用自己的点 + 根模型）再试了一遍，每一次留下的违反量都在 **0.05~0.99** 之间。⇒ 那个点是**那条轨迹的性质**，不是泵能被告知的种子 —— 如实记为限度，而不是按第一版的数字宣称。

**顺带量清的两格（同一轮，用来决定不做什么）**：① `pump_iterations` 从 5 抬到 10/20 时，`rout` / `dcmulti` 的落地都发生在**第 3~4 次投影以内**（20 次的轨迹里每个落地的种子都在 ≤4 轮内归零）⇒ 5 对"泵服务的那批实例"已经够；抬高的效果全落在"尾巴窗口开得更早 ⇒ 末尾那个节点不同"上（`dcmulti` 一次 `188706.0 → 188239.9`、一次 `→ 188507.7`，**单样本、轨迹混沌**，按第三十四轮的纪律不当作政策保留）。② 把窗口改成"储备那么多"（小规模 100 次尝试）也不给 `gt2` 带来点，代价是前沿少 500 次松弛（仍在开 `18674 → 17769`）。

### Added — M5 第三十五轮：可行性泵，并且只在预算的尾巴上开（两条"完全没点"的运行拿到第一个点）

**为什么是它**：第三十三轮的取整从松弛里读点、第三十四轮的修复把读出来的点按整数固定后重解，而 `gt2` / `dcmulti` /
`rout` 上**三种取整都不满足模型**（实测离可行 5% / 99% / 57%），任何单变量移动也修不动。它们缺的是两者之间的**交替**：
取整得到整点，让松弛朝那个整点**投影**，再取整，如此往复 —— 这就是可行性泵。

**改了什么**：`mip/rounding.mbt::rounded_objective_model` 把投影写成**目标**而不是**距离**：某个整数取整后往上走，就给它一个
"抬上去有收益"的系数（往下走取镜像），于是投影**一行都不加** —— 用罚变量 + 每个整数一行的写法会把模型翻倍，而内核的代价跟着
行数走。每次投影都是**它自己那个模型的一次松弛**，同一个内核、同一台校验器、计入 `nodes`/`verified`：一条泵不出点的运行付的是
松弛，不是把尝试藏起来。

**"什么时候开泵"是量了两次才定下来的**：第一版在**第一个没有点的节点**就泵，实测代价是证明（`22433` 33 → **91** 个松弛、
`p0201` 609 → **819**、`flugpl` 13806 → **14306**）、当前解（`pk1` 18 → **20**）以及**一次下潜被顶掉** —— `markshare1` 的
取整在根上失败，泵在那里落了 5462，而**下潜的门就是"没有当前解"**，于是这条运行此后再没走过路，报出 1242（走法当时给的是
274）。现在的形态是**运行的最后一步**：`mip/search.mbt::pump_window_open` 要求预算里只剩不超过 `pump_iterations` 个松弛才开泵
（并且整个预算至少装得下四份泵的长度，否则根本不碰），于是**有当前解的运行永远不会泵**、没有点的运行花的是**树用完之后的那截
预算**。泵的起点是**搜索第一次判定的那个点**、用的是**它当时被求解的那个模型**：换成"停下来那个节点的点 + 那个节点收紧过的
模型"实测在 `dcmulti` / `rout` 上一个点都落不下来。

**实测（两份 mip 报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 宽口径 `dcmulti`（300 节点） | 无点（`gap` `1e+300`） | **`188706.00000021877`**（公开最优 `188182`，高 0.28%）/ gap `1164.6355243175349` |
| 宽口径 `rout` | 无点（`gap` `1e+300`） | **`1998.180000005329`**（公开最优 `1077.559999999999`）/ gap `1002.2214868964988` |
| 宽口径 `gt2` | 无点 | **仍然无点**（这一档的预算只够一次尝试、5 次投影） |
| 小规模 `gt2`（20000 节点） | 无点 | **仍然无点**（第一版在这一档拿到过 `28561.000000003667`，见下面"限度"④） |
| 宽报告 `reported points checked` | 10 | **12** |
| 两份报告里其余每一行（`mod010` 31 节点、`22433` 33、`khb05250` 107、`markshare1` 274、`markshare2` 192、`pk1` 18、`p0201` 7975、`50v-10`、`misc07`…） | — | **逐字未动**（第一版正是把这些打坏的：`22433` 33 → 91、`p0201` 609 → 819、`pk1` 18 → 20） |
| 代价：四条"没有点"的运行花掉预算的尾巴（宽口径 `flugpl` / `blend2`） | `1177678.5205482745` / `7.182434016727598` | `1177564.5000004936` / `7.180925257756193`（都更紧一点，代价是各少 5 个树松弛） |
| `relaxations verified` / `cuts`（宽报告） | 3770 / 325 | **3770 / 325**（同一预算内重新分配，一点没多花） |
| 测试 | 190 | **191** |

**限度**：① 泵/修复/取整出来的点**只用于剪枝与报告**，永不走证书路径（可行性由与校验器逐字同尺的式子判定）；②
`pump_iterations = 5` 与"预算至少装得下四份泵"都是**没有做敏感性**的常数；③ 泵**可能循环**（经典失效形态），所以是被次数界住
而不是跑到不动点；④ **第一版在小规模口径给 `gt2` 拿到的那个点 `28561.000000003667` 没有留下** —— 第一版的尝试额度是
`pumps_allowed = max_nodes / 200`（20000 节点下 **100 次**，逐节点一次、每次都用**那个节点自己的点与模型**），`gt2` 的点是这
一百次里的一次落下来的；尾巴窗口只买得起一次尝试，它没落下来。**买回它要付的账已经量出来了**（就是上面第一版的那张单子：
三个证明 + 一个当前解 + 一次下潜），所以下一轮的可量方向是"给泵另配一条'当前解离界很远才泵'的门"（与修复的 1/10 门同形），
而不是把窗口挪回开头。

### Added — M5 第三十四轮：把"取整出来但模型不接受"的点修回来（三个实例的当前解改善 16%~50%）

**为什么是它**：第三十三轮能从松弛里取整读点，但有几类实例**三种取整都不满足模型**：实测 `flugpl` 最近的取整把行差
**0.2%**、`dcmulti` **99%**、`rout` **57%**，而且**任何单变量移动都修不好**（探针：从最近取整出发的最好单步移动就是它
自己 —— 违反量分散在多行上，修好一行只是把下一行顶到最前面）。没被决定的是**连续变量**：把整数固定在某个取整上、**重解节点
模型** —— 就是下潜走的那条受限模型求解，同一台校验器、同一套计数（每次尝试 1 个松弛）。

**三个判断都是量出来的**：① **固定哪个取整不由"最近"决定** —— 一行可以禁止最近的那个方向而允许另一个（测试里的
`x + y = 2.5`、`x` 整数、`y ≥ 0`：最近的 `x = 3` 无论 `y` 怎么取都不可行，`x = 2` 配 `y = 0.5` 才行）⇒ 每四次尝试有一次换
另外两个方向；② **只在已有当前解时才修** —— 它是**改进**一个点，不是拿第一个点；`flugpl` 正是反例（它的取整在整条证明里
都补不完，有当前解之前的每次尝试都是树拿不到的松弛）；③ **只在当前解离节点松弛"还差得远"（相对 > 1/10）时才修** ——
`p0201` 正是反例（它带着只比界高 0.8% 的当前解就证到最优，放开预算时证明成本 `577 → 977`；加上这条门槛后回到 `609`，
`flugpl` 也回到 `13806`）。

**实测（两份 mip 报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| 小规模 `markshare1` 当前解 | `138.00000000001302` | **`112.00000000012874`** |
| 小规模 `markshare2` 当前解 | `144.99999999986127` | **`122.00000000014411`** |
| 小规模与宽口径 `pk1` 当前解 | `36.000000000050356` | **`18.000000000027132`**（两份都 −50%） |
| 宽口径 `markshare1` 当前解 | `478.00000000053296` | **`274.0000000003064`**（−43%） |
| 小规模 `noswot` 的界 | `-43.00000000010295` | 未动（`verified` 19 907 → 19 908） |
| 宽口径 `noswot` 的界 | `-43.00000000004889` | `-43.000000000047194`（+1.7e-12） |
| 代价：小规模 `p0201` 的证明成本 | 577 节点 | **609** 节点（+5.5%） |
| 代价：`pk1` 的界（小规模） | `4.036486853130988` | `4.00639174588539`（−0.7%） |
| 代价：`misc07` / `p0201`（宽口径）的界 | `1638.7500000000662` / `7557.500017562112` | `1637.7272727318607` / `7555.000006642165` |
| 其余行（`flugpl` 13 806、`gt2`、`blend2` `7.598985`、`khb05250` 107、`mod010` 31、`22433` 33…） | — | **未动** |
| 覆盖面 | 小规模 4/10、宽口径 3/32 | **不变** |
| `relaxations verified` 汇总（小 / 宽） | 134 397 / 3770 | 134 430 / 3770 |
| `check-mip-objectives.ps1` / 索引 / 测试 | checked 3 与 4、0 stale、188 | 同、**189** |

**限度**：① 1/10 这条线是**拟合**的（它把 `p0201` 的 0.8% 与 `markshare1` 的 100% 分开）；② 修好的点**只**用于剪枝与报告，
不走证书路径（可行性用与校验器同尺的式子判定，写在 `mip/rounding.mbt` 里）；③ `gt2` / `dcmulti` / `rout` 仍然没有点 ——
它们的取整离可行 5%~99%，这条修复修不动，下一轮的方向是"投影式修复"（可行性泵）；④ `flugpl` 这类"取整永远补不完"的实例
仍会白花最多 8 次尝试（没有第③条门槛的第一版白花 400 次，成本是 `14206 - 13806` 个节点）。

### Added — M5 第三十三轮：从松弛里"读"出一个整数点（`blend2` 的当前解变成公开最优）

**为什么是它**：找点的机制以前只有**下潜**，而它每固定一个变量就要重解一次 —— 能找到什么受花掉的预算限制。实测 `flugpl`（小规模口径）：
第一个整数点落在 **9000~12000** 之间，而它的证明总共 13806 个松弛 ⇒ **前面九千个松弛一个都剪不掉**（没有当前解可剪）；
宽口径的 `dcmulti` / `rout` / `50v-10` 干脆一个点都没有。

**改了什么**：新加 `mip/rounding.mbt` —— 松弛已经解出来、整数变量本来就是分数，把它们挪到整数只需要**一遍模型扫描、零次求解**。
试**三种**取整（就近、向上、向下，各自夹进该变量界内的整数范围），取其中**满足模型**且目标最好的那个。判据用**校验器的那把尺**：
行按 `1 + |bᵢ| + Σ|aᵢⱼxⱼ|`、界与整数性按 `1 + |xⱼ|`（与 `verify/checks.mbt` 同一式子）—— 当前解在树剪空时会变成答案，
所以这里接受的点必须是独立校验也会接受的点。

**实测（两份 mip 报告重生成）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| `bench/mip-report-small.md` 的 `blend2` 当前解 / gap | `11.699479000006125` / `4.299714100679411` | **`7.598985`（= 公开最优）/ `0.1992201006732861`** |
| 同报告 `p0201` | `7615.000000031186`，586 节点 | **`7615`（= 公开最优）**，**577 节点** |
| 同报告 `khb05250` | 114 节点 | **107 节点** |
| `bench/mip-report.md` 的 `50v-10` | 无解（`gap` `1e+300`） | **当前解 `9132.509983330965`、gap `6054.183695566563`** |
| 同报告 `p0201` 当前解 | `8305.000000091832`（gap 747.5） | **`7975`**（gap **417.4999824378883**） |
| 同报告 `khb05250` | 114 节点 | **107 节点** |
| 点复核（多行） | 残差 1e-11 量级 | **row/bound/integrality 全 0**（取整出来的点是精确的整数点） |
| `reported points checked`（宽 / 小） | 9 / 9 | **10** / 9 |
| `relaxations verified` 汇总（宽 / 小） | 3777 / 134414 | 3770 / **134397**（证得更早，花得更少） |
| 两项 `check-mip-objectives.ps1` | checked 3 / checked 4 | 同，`violations 0, not in the table 0, unverified nodes 0` |
| 测试 | 185 | **188** |

**一条方法论更正（必须留痕）**：同一个机制在**第三十轮**被量成"五个实例 20000 节点数字**逐位相同**"并因此**回退**。那五个
（`gt2` / `markshare1` / `markshare2` / `noswot` / `pk1`）**都已经有当前解** —— 而这条机制的用途恰恰是"**一个点都没有**"的运行。
**在被服务的人群之外取样，得到的"零效果"是假的**：换成 `blend2` / `p0201` / `50v-10` 这些点很少或没有点、且下潜落得差的实例，
同一份代码把 `blend2` 的当前解直接变成公开最优、给 `50v-10` 补上第一个点、让两个已证实例更便宜。

**API**：`MipOptions` 新增 `primal_rounding`（默认开）与 `without_primal_rounding()`；四条"问下潜/问一次松弛预算留下什么"的测试
改用关闭取整，公开面的"节点预算"测试改为断言契约（是上限、界仍开着、有解时那个解是模型的真点）而不是断言"没有解"。

**限度**：① 取整出来的点**只**用于剪枝与报告，永不主张最优 —— 它与下潜受同样约束（只有严格更好的点才被接受），
但**没有**经过 `verify` 的证书路径（它是可行性主张，用与校验器逐字同尺的式子判定）；② 代价是每个已解松弛一遍 `O(nnz)` 扫描，
在本机时钟噪声以下（未单独量）；③ `dcmulti` / `rout` / `gt2` 仍然**没有**点 —— 三种取整都不可行，说明它们的可行域离松弛点远，
这是下一轮可以量的方向（可行化策略/可行性泵）。

### Fixed — M5 第三十二轮：割轮重解拿到更长的迭代上限（宽口径覆盖 2/32 → 3/32）

**为什么是它**：一轮割只有在**重解给出结论且不变差**时才保留 —— 于是"重解没跑完"会连同**整轮的割**一起丢掉。而这个判据
说的是**每次松弛分到的迭代上限**，不是这一轮的价值。实测 `mod010`（300 节点口径，报告给每次松弛 5000 次迭代）：它的
**第二轮重解跑不完** → 整轮丢弃（cuts 10 而不是 20）→ 跑满 300 个节点、界停在 `6540.400000106969`，而公开最优是 `6548`：**
被它丢掉的那一轮正是能证死它的那一轮**。单独调高整轮的迭代上限也不到：6000、7000 都没变化，20000 才保留该轮、
实例**在 31 个节点证到最优**。

**改了什么**：割轮重解拿到 `cut_round_iteration_factor`（**4**）× 常规上限。代价按**轮**记而不是按节点记 ——
整轮运行最多 `cut_rounds × 4 × max_iterations` 次迭代，而在常规上限里就能跑完的重解**完全看不到差别**。

**一行实现细节值得留痕**：这一轮的第一版是"重解没跑完就**再试一次**"，它把一个被放弃的尝试计成"没有结论的松弛"，
于是 `mod010` 那行变成 `nodes 32 / verified 31`，`bench/check-mip-objectives.ps1` 报 **`unverified nodes 1` 并以非零
退出** —— 一个**证到了最优**的运行反而不满足"每个松弛都有结论"这项对拍。改成**一开始就给长上限**（一个模型、一次尝试、
一次计数）之后 `31/31`，两项对拍都回到 0。

**实测（同口径前后对比）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| `bench/mip-report.md`（32 实例 / 300 节点）**已证最优** | 2 | **3**（停在预算 13 → 12） |
| 同报告 `mod010` | `node-limit`：300 节点用光、界 `6540.400000106969`、gap `8.600002302022403`、cuts 10 | **`optimal`：31 节点、`verified` 31/31、`6548.000000416262`、cuts 20** |
| 同报告 `relaxations verified` 汇总 | 4045 | **3777**（`mod010` 少花 268 个松弛） |
| 同报告 `cuts` 汇总 | 315 | 325 |
| 同报告其余 18 行（含 `noswot` 界 `-43.00000000004889`、`gt2` 界 `20232.4937481341`、`50v-10` 界 `3078.326287764402`、`22433` 33 节点） | — | **逐字/逐字节相同** |
| `bench/mip-report-small.md`（10 实例 / 20000 节点） | — | **除 commit 行外逐字节相同** |
| `bench/check-mip-objectives.ps1` | checked 2 / checked 4 | **checked 3** / checked 4，均 `violations 0, not in the table 0, unverified nodes 0` |
| 测试 | 184 | **185** |

**限度**：① `4` 这个倍数是**测出来的下界**（6000、7000 不够、20000 够），不是唯一值 —— 更小的可行倍数没继续二分；
② 长上限只加在**割轮重解**上，节点松弛仍用 `max_iterations`，所以同一个 `max_iterations` 在报告里对两类工作含义不同
（重解那一类最多 4 倍），这一点写进了 `MipOptions::max_iterations` 的注释与 `docs/algorithms.md`；
③ 重解的迭代量**不进** `nodes`/`verified`（它是一次"模型"的求解），所以这一项代价只在 CHANGELOG/文档里可见、不在报告里可见。

### Changed — M5 第三十一轮：根割轮次改成"按收益走"（宽口径的覆盖面 1/32 → 2/32）

**为什么是它**：根割轮次一直是**固定的两轮**。把每一轮加到根界上的量数出来，这个"两"在两个方向上同时错：
`khb05250` 前四轮分别加 `5.6% / 2.4% / 1.5% / 0.4%`，`p0201` 是 `2.9% / 1.3% / 0.5% / 0.8%`，`flugpl` 是
`0.3% / 0.05% / 0.02%`，`dcmulti` 是 `0.7% / 0.3% / 0.03%`。一轮只加半个百分点的界**付不起它花掉的树**：
多给一轮，`p0201` 的证明成本 `586 → 1135` 节点、`flugpl` `13806 → 14938`；而**唯一持续加整个百分点**的
`khb05250` 是 `305 → 185 → 114`。

**改了什么**：`cut_rounds` 从"轮数"变成**上限**（默认 `2 → 4`），运行**前两轮无条件照旧**，之后**只有上一轮把根界
抬了至少 `cut_round_gain`（1.4%）才再开一轮**。"前两轮无条件"这一条正是让先前量过的实例原样不动的原因。

**实测（同口径前后对比）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| `bench/mip-report.md`（32 实例 / 300 节点）**已证最优** | 1 | **2**（停在预算 14 → 13） |
| 同报告 `khb05250` | `node-limit`：300 节点用光、gap `3599.951073616743`、cuts 20 | **`optimal`：114 节点、cuts 39** |
| 同报告 `gt2` 的界 | `20001.29542069701` | **`20232.4937481341`**（+1.16%） |
| 同报告 `50v-10` 的界 | `3032.505072008623` | **`3078.326287764402`**（+1.51%） |
| 同报告 `gt2` 的当前解 | `44072.99999998619` | **空**（"no integer point found yet"）⇒ 见下面的代价 |
| `bench/mip-report-small.md`（10 实例 / 20000 节点）的 `khb05250` | 305 节点 | **114 节点** |
| 同报告其余 8 行 | — | **逐字节相同**（`flugpl` 13806、`p0201` 586、`22433` 33、`blend2` `11.699479000006125` 都在内） |
| 同报告 `gt2` | 界 `20001.295420696988` / 解 `44072.99999998619` | 界 **`20232.493748134082`** / 解**空** |
| `cuts` 汇总（两份） | 266 / 176 | **315 / 205** |
| `relaxations verified` 汇总（两份） | 4231 / 134 605 | 4045 / **134 414**（`khb05250` 少花 191 个松弛） |
| `reported points checked`（两份） | 10 / 9 | 9 / 9 |
| `bench/check-mip-objectives.ps1` | checked 1 / checked 4 | **checked 2** / checked 4，均 `violations 0, not in the table 0, unverified nodes 0` |
| 测试 | 183 | **184** |

**代价（写清楚，不藏）**：`gt2` 在两份报告里都**丢掉了当前解**（`44072.99999998619` → 空、`gap` 回到 `1e300`）。
原因可追：多出来的那一轮割重塑了根，原来的下潜落点不再可达，而第三十轮的储备（过半预算后最多 1250 个松弛的走法）
在这条新根上也没能落地。取舍是明确的：**换来的是界抬 1.16%、`50v-10` 抬 1.51%、宽口径多证一个实例、`khb05250`
的证明便宜 63%**；`gt2` 那一格失去的是"已经多好"那个数（而且它本来就比公开最优差一倍以上：44 073 对 21 166），
保留的是"证到多远"那个数。

**限度**：① `cut_round_gain = 1.4%` 这个阈值是**从这四个实例的逐轮曲线里拟合的**（`khb05250` 2.4%/1.5% 对
`p0201` 1.3%/0.5%），它把"继续切"和"别切了"分开，但它是拟合值、不是推出来的；② `cut_rounds_always = 2` 保证
不减少任何实例的轮次，所以"前两轮不划算"那个方向**没有量**（第二轮该不该省没测）；③ 上限 4 轮同样没做敏感性。

**过程事实（CI 上看得见，写下来）**：本轮推送时网络刚刚恢复，代码提交 `f904e8d` 与报告提交 `c1c593d` 因此是**分两次**
推上去的 —— 前者的 CI（run `36219464442`）三个 `check` 任务**失败在 "benchmark reports are current" 这一步**，正是
"报告陈旧"的机械判定在起作用（推代码时报告还没重生成），而收尾提交 `c1c593d` 五个任务全绿。⇒ 第二十四轮那条顺序铁律
（代码 commit → 报告 commit → **一次推送**）得到一次反向验证：分两次推不会损坏证据，但会在 CI 上留下一次可预期的失败。

### Added — M5 第三十轮：给"一个整数点都没有"的运行留一份走法储备（`blend2` 第一次拿到整数点）

**为什么是它**：报告里有一类行不是"界不够好"，而是**什么都没有** —— `blend2` 在 20000 节点口径下跑完 20000 个
松弛，`objective` 列是空的、`gap` 是 `1e300` 哨兵、`note` 写着 "no integer point found yet"。而找点的**下潜**由一整套
条件把关（`depth ≥ 分数变量数`、`2×分数 ≤ dive_steps`、还有 `dive_attempts` 次上限，默认 2），这些条件对一个
"一个点都没有"的运行只会靠运气同时满足 —— 它已经把两次走法花完了。

**改了什么**：给这类运行留一份**预算份额**的储备（`max_nodes / 16`）用于**额外走法**，两个条件同时成立才动：
① **预算过半且仍然没有当前解**（正是报告里那个状态）；② **储备能负担一次完整的走法**（`dive_steps` 步）。
第②条不是修饰：一次被储备截短的走法只会"花松弛去发现树本来就要发现的东西"。

**实测（同口径前后对比）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| `bench/mip-report-small.md` 的 `blend2` | `objective` 空、`gap` `1e+300`、"no integer point found yet" | **`objective` `11.699479000006125`、`gap` `4.299714100679411`** —— 第一次有整数点，且该点**通过报告自己的独立复核**（worst row violation `9.99e-12`、bound `2.19e-12`、整数性达标） |
| 同报告其余 9 行 | — | **逐字节相同**（含 `markshare1` 138、`markshare2` 145、`noswot` −34、`pk1` 36） |
| 汇总 `reported points checked against the model` | 9 | **10** |
| 汇总 `relaxations verified` | 134 603 | **134 605**（同一个 20000 节点预算内，没有多花预算） |
| `bench/mip-report.md`（32 实例 / 300 节点） | — | 除 commit 行外**逐字节相同** |
| 4 个已证最优实例 | `flugpl` 13 806 / `khb05250` 305 / `p0201` 586 / `22433` 33 | **一字未动** |
| 两项 `bench/check-mip-objectives.ps1` | — | `violations 0, not in the table 0, unverified nodes 0` |
| 测试 | 182 | **183** |

**第二条条件是被第一版打出来的（这一轮的测量教训）**：只装"预算过半"这个触发时，300 节点口径的**五行界被拉低**
（`blend2` `7.182434016727598` → `7.170066386884816`、`flugpl` `1177678.5205482745` → `1177407.3529415769`、
`dcmulti` / `rout` / `50v-10` 同理）**却一个点都没找到** —— 那个预算下储备只有 `300/16 = 18` 步、不到一次走法
（60 步）。加上"必须能负担一整次走法"之后，那五行与改动前的报告**逐字节相同**，收益落在它真正存在的地方。
这正是第六轮那条判断的第二次应用：**被截断的走法是最不值得买的东西**。

**测试（182 → 183）**：`mip/search_wbtest.mbt` 把这条策略的两半都钉住 —— 300 节点（储备 18 步 < 60）在任何时刻
都不放行；20000 节点（储备 1250 步）在过半之前不放行、过半之后放行；储备被花到不足一次走法时停止额外走法。

**限度**：① 这条储备**只对完全没有整数点的运行生效**，所以"点已经有了但很差"的实例（`gt2` 44 073 对公开最优
21 166）一个字都没变 —— 第二十六轮量过那条更宽的路，代价落在证明成本上；② `max_nodes / 16` 这个比例**没有量过
敏感性**（16 是"储备能负担约 20 次走法"的取值，它同时也是"300 节点口径不生效"的原因）；③ `blend2` 的新点
`11.699479` 对公开最优 `7.598985`（界 `7.399765`）—— 这一轮买的是"有东西可报告"，不是"更接近最优"。

### Fixed — M5 第二十九轮：没有结论的节点不再把它的界一起带走（报告的界因此变保守）

**为什么是它**：第二十八轮结束时留下的杠杆 —— `mip/search.mbt::open_bound` 只遍历队列堆，而**被弹出且没有结论**的
节点不会重新入队（再解一次是同一份工作、同一个结论），它的键（**父节点的松弛目标值**，对它整棵子树都有效）就随
子树一起消失。方向是**偏乐观**的：这是 best-bound 搜索，弹出的正是键最小的那批节点，而报出的界就是队列里最小的键
⇒ 界被报得比运行实际建立的更好、gap 相应偏小。

**改了什么**：`open_bound` 现在取"队列的键 ∪ 被丢下节点的键"；**节点本身仍不入队**（入队就成了重试策略，会改动
`nodes` / `verified` 计数）。开集为空时仍返回 `0.0` 哨兵。预算到顶时的消息把两类"开着的活"分开说：仍在队列的
树节点，以及到达不了结论的松弛（`limit_result` 新增的 `unfinished` 参数）。

**实测（两份 mip 报告重生成，同口径前后对比）**：

| 量 | 修前 | 修后 |
| --- | --- | --- |
| `bench/mip-report-small.md` 的 `blend2` 界 | `7.5784463643777125` | **`7.399764899326714`**（**−2.36%**，更保守） |
| 同报告 `noswot` 的界 | `-43.000000000045816` | `-43.00000000010295`（相对量 1e-12） |
| 同报告 `noswot` 的 gap | `9.000000000037659` | `9.000000000094794` |
| `relaxations verified` 汇总 | 134 603 | **134 603**（一字未动） |
| 4 个已证最优实例 | `flugpl` 13 806 / `khb05250` 305 / `p0201` 586 / `22433` 33 | **一字未动** |
| `bench/mip-report.md`（32 实例 / 300 节点） | — | 除 commit 行与本轮改过的那段 `verify` 列说明外，32 个实例行**逐字未动** |
| `bench/check-mip-objectives.ps1` 两项 | — | `violations 0, not in the table 0, unverified nodes 0` |

**更正（本轮开工时写错的一条推论，必须留痕）**：`ef4a31e` 的提交信息说"300 节点口径下 `mod010` 与 `noswot` 各留下
一个未结论节点"—— 那是从 `verify < nodes` 的差额**读出来的推论、不是实测**，而且是错的。那一格差额来自**下潜的
走法**：`dive_for_incumbent` 有自己的 `relaxations` / `verified` 计数，一步到达不了结论只计 `nodes`、不计
`verified`，也**不进** `unfinished_nodes`。证据是修好之后跑出来的两份报告：300 节点那份**没有任何一行变化**
（主循环里没有未结论节点），而小规模报告的 `note` 列第一次把主循环的那类**点名**出来 —— `blend2` **34** 个、
`noswot` **92** 个（`noswot` 的 93 格差额 = 92 个主循环节点 + 1 步下潜）。

**两类"没有结论"的区别（本轮量清，并写进了报告脚本自己的说明）**：主循环的节点**丢的是界**（区域未探索，键是有效
的界，所以现在计进去）；下潜的一步**不丢界**（它出发的那个节点是有结论的，分支出来的子节点仍带着那个结论）。

**测试（180 → 182）**：`mip/search_wbtest.mbt` 直接测 `open_bound` 的三件事 —— 被丢下的键最小的时候它赢、更大的
时候不动界、最大化下镜像成立，外加开集为空仍给 `0.0` 哨兵；`mip/mip_test.mbt` 用注入式节点求解器跑一整轮
（根答 7、一个子节点答 6.5、另一个子节点没有结论），断言报出来的界是**运行真正建立的那个 7**，而不是队列里的 6.5
—— 后者正是修改前那一轮会报的数。

**限度**：① 根节点自己没结论时它的键仍是入队时的 `0.0`（既有行为，本轮没改；只在注入式求解器或根松弛撞迭代上限
时出现）；② **没有**把未结论节点重新入队重试（那是另一个策略，会改动 `nodes` / `verified`）；③ 下潜那类"没有结论"
仍只体现在 `verify` 列与 `nodes` 的差里，不单独计数。

### Fixed — M5 第二十八轮：内核补上第三件自检（对偶间隙），第二十七轮的校验器修正因此得以落地

**为什么是它**：第二十七轮把 `verify/checks.mbt::box_minimum` 的界算对了（落在容差带内的检验数按它指向的
那个界照实算），但那份修正**撤掉了** —— 修好校验器之后报告写不出来：`noswot` 节点 6454（depth 26）的一条
擦边证书被拒（`duality gap failed: 1.2169280658914303e-7 against tolerance 1e-7`，1.2 倍容差），
而结论是"该修的是生产端"。本轮就是把那个生产端修完。

**第一步是复现锚点（先测再改）**：只装校验器那半、跑同一条命令
（`moon run --target native --release cmd/parse -- bench/data/instances/noswot --mip --max-rows 300
--max-nodes 20000 --max-iterations 20000`），得到与第二十七轮**逐字相同**的拒签：
`mip=unverified nodes=3676 verified=3654`，exit 1，拒绝原话一字不差。锚点成立，才动手。

**改了什么（生产者侧自检）**：`simplex/revised.mbt::finish_optimal` 原本自检**残差**与**乘子符号**，
现在加上第三件 —— **对偶间隙** —— 用同一种形状：超容差先 `refactorize()` 重测，仍在容差外才返回
`SimplexStatus::NumericalFailure`（不发证书、不发 basis，消息里带实测的 gap / 目标值 / 界）。该节点于是在
MIP 层变成"没有结论的松弛"（`verified < nodes`、计入 open work），**搜索继续**而不是整轮停在 6454。

**桥怎么搭的（本轮最大的坑，也是"实现放哪一层"的决定依据）**：内核在自己的归一化空间算，校验器在模型空间算；
本项目为"生产者量的是校验器输入的**还原**、而不是输入本身"已经踩过三次（`max_negativity`、`row_scale`、
容差口径）。所以模型自己的量是**带过来的、不是反推的**：`KernelProblem` 新增 `model_cost` / `model_lower` /
`model_upper` / `model_rhs`，`simplex/certificate.mbt::duality_gap_violation` 再逐字复现校验器那一条式子 ——
同样的 cost（`sense_sign·c`）、同样的 `r = c − Aᵀy`（内核一列的项本来就是模型行、按行升序；进标准形时叠上去的
每个因子都是 ±1 的符号翻转，乘 ±1 精确，所以**连累加顺序都相同**）、同样的行关系符号、同样的框极小值规则、
同样的分母 `1 + |primal| + |L|`、同样的"没有有限界"哨兵。

**实测（同口径前后对比）**：

| 量 | 数字 |
| --- | --- |
| 内核在 `noswot` 20000 节点口径上测到超容差的次数 | 3 次：`1.2169280658914303e-7` / `1.1878936665256516e-7` / `2.8749685850370485e-4` |
| 其中第一个与校验器当初拒签的数字 | **逐位相同**（这就是"两边量同一个东西"的证据） |
| 三次 `refactorize()` 重测之后 | **0 次仍在容差外 ⇒ 0 次拒签**，证书随后被校验器接受 |
| 该实例整轮结果 | `mip=node-limit nodes=20000 verified=19907`，exit 0，报告写得出来 |
| `bench/parse-report.md` / `bench/solve-report.md` | 除头部 commit 行外**逐字节相同**（各只差那 1 行） |
| `bench/mip-report.md`（32 实例 / 300 节点） | 除头部 commit 行与**重写的那段 `verify` 列说明**外，32 个实例**逐字相同** |
| `bench/mip-report-small.md`（10 实例 / 20000 节点） | 除头部 commit 行与本轮改过的那段说明外，**只有 `noswot` 一行动了**：`verified` 19 905 → **19 907**、界 `-43.000000000044906` → `-43.000000000045816`（相对量 2e-14）、gap `9.00000000003675` → `9.000000000037659`、仍开着的树节点 14 861 → **14 853**；汇总里 `relaxations verified` 134 601 → **134 603** |
| 4 个已证最优实例 | 节点数**一字未动**（`flugpl` 13 806 / `khb05250` 305 / `p0201` 586 / `22433` 33）⇒ 没有丢覆盖 |
| `bench/check-mip-objectives.ps1` 两项 | `violations 0, not in the table 0, unverified nodes 0`（分别 checked 1 与 checked 4） |
| `bench/report.ps1` | 4 report(s)、**0 stale** |

**更正（发布 `0.1.2` 时补）**：本表初版把 300 节点那份 `bench/mip-report.md` 也写成"除头部 commit 行外逐字节
相同"，实际上它还带着本轮重写的那段 `verify` 列说明（只有 `bench/parse-report.md` / `bench/solve-report.md`
是只差头部）。`git diff c10ada4^ c10ada4 -- bench/` 可复核：解析 / 求解各 2 行，两份 mip 报告各 10 / 14 行。

**测试（174 → 180）**：新建 `verify/checks_wbtest.mbt` **4 条**白盒（带内按界计入、带外按指向的界取值、
带内不能报"没有界"，量级取真实实例的 `10²~10³` 界与 `10⁻⁶` 检验数）；新建
`simplex/certificate_wbtest.mbt` **2 条** —— 6 个模型（移位下界、等式行、自由变量、自由变量带上界、
按界固定的变量）在内核侧与校验器侧**量同一个数**（`approx_eq(rel=1e-15, abs=0)`，且两边对"是否在容差内"
的判断一致），外加一条专打**带内**路径的用例（解出最优基后把某个乘子推 `1e-10`，让检验数落进带内）。
**第二条在旧语义下会失败** —— 把旧的两行放回去实测过，所以它有牙齿。

**保留的判据逐条对应**：两份 mip 报告都写出来了 ✓；两项对拍 0 违反 ✓；4 个已证最优实例仍为 `optimal` 且
节点数逐字不变 ✓；`verified` 没有发生下降（`noswot` 反而 +2）✓ ⇒ **保留**（不是回退）。

**诚实边界（哪些是实测量、哪些没测）**：

- `noswot` 的树**确实换了**（仍开着的节点 14 861 → 14 853），所以"未结论节点 95 → 93"**不能**逐节点归因给那
  三次修复；机制证据来自**带临时探针的单实例运行**（3 次触发、那个逐位相同的数字、0 次拒签），
  探针没有提交。
- 那 6 个诚实模型的检验数**都不在容差带内** —— 把旧语义放回去时那条差分测试**仍然通过**。带内那半条式子
  靠的是专门构造的用例与语料上的锚点数字，不是靠那 6 个模型。
- 间隙自检的**时钟没有单独量**：它只在一次成功求解的收尾跑一遍（结构列一遍 + 模型行一遍，
  `O(nnz + m + n)`），不随枢轴数增长 —— 这是结构性论证、不是实测数字。它的**间接**代价是每次触发多做一次
  `refactorize()`（本语料 3 次）以及"树因此换了一条"。
- **容差口径**：生产者用的是内核的 `feasibility_tolerance`（默认 `1e-7`），MIP 层交给校验器的是
  `MipOptions::tolerance`（默认同为 `1e-7`；CLI 走默认值，所以本轮所有实测里两者是同一个数）。调用方只改
  其中一个，就会得到"生产端比校验器更严/更松"的组合 —— 这是**既有**的耦合（残差与符号自检早就用
  `feasibility_tolerance`），本轮没有改它，只把它写下来。
- 两条测量仍可能在**最后一位**上分道：模型行里若把同一个变量列了两次，模型层会合并、内核的 CSC 打包也会合并，
  于是内核算 `y·(a+b)` 而校验器算 `y·a + y·b`（代码注释里写了）。是舍入级的差别，不是不同的量。
- 本轮只要求内核"要么修好、要么不写"：`refactorize()` **修不好**时该节点就是 `verified < nodes` 的 open work
  （本语料上三次都修好了；修不好的情形没有实测样本，只有构造出的路径）。
- **这条新自检不覆盖"修复后的证书"**：`mip/duals.mbt::repaired_duals` 会把符号越界的乘子夹到 0 再交给
  校验器，而内核量的是**夹之前**的对偶解。也就是"生产者量与校验器判的量是同一个"有一个例外 —— 那条路上
  被拒仍然**整轮停下**（设计上分得开：内核明说自己没有证书 ⇒ 记成 open work；被校验器当场抓住 ⇒ 说明生产者
  错了，不该用降级掩盖）。本语料上没有走到这条分支。
- **"修不好"那一支没有测试**：语料里三次都被 `refactorize()` 修回容差内，"间隙在重新分解之后仍超容差 ⇒
  `NumericalFailure`"只有构造出的路径。MIP 侧"没有结论 ⇒ 记成 open work"由注入式节点求解器的测试兜着，
  内核侧这一支没有。
- **带内且那个方向没有有限界**时仍然丢掉这一项（宽松的一侧，与"带内不指向任何方向"是同一套读法；
  代码注释里写了，本清单补上）。

**顺带**：`bench/report-mip.ps1` 里那段解释"`verify` 列何时小于节点数"的文字，原来只举了乘子符号约定一个例子，
现在把它写成**一个类别**（发证书之前内核量两件：符号约定、以及乘子证明的界与点上的目标值之间的间隙），
两份 mip 报告因此重跑了一次；`docs/algorithms.md` 的自检清单与 `docs/api.md` 的 `NumericalFailure` 含义
同步补上第三件。

### 实测后回退 — M5 第二十七轮：校验器"对偶间隙"的界算错了（最多差到容差的 1000 倍），修好之后轮到生产端

**为什么是它**：上一轮留下的是一条"活缺陷"（`blend2` 一次下潜走法的证书被拒，`duality gap failed: 5.54e-5`）。
本轮先复现它，**复现失败 —— 而这本身就是一条要更正的结论**：

> **更正（第二十六轮的记录）**：那条拒签**不是**只靠 `dive_attempts` 就能触发的。把"拿到当前解之后继续走"的
> 闸门回退之后，`blend2 --dive-attempts 128` 在 20 000 节点口径下**跑到预算用尽、一次都不拒**
> （`mip=node-limit nodes=20000 verified=19972 cuts=16 obj=10.690633000006356 bound=7.565263777676549`）。
> 会拒签的那次走法，是**只有那个闸门存在才会发生**的走法。缺陷本身与闸门无关，但它的**触发路径**是。

**根因（读代码 + 算出来的一行算术，不是猜）**：校验器算"对偶间隙"用的 Lagrangian 下界是
`verify/checks.mbt::box_minimum` 给出的 `Σⱼ min_{lⱼ ≤ xⱼ ≤ uⱼ}(rⱼ·xⱼ)`，而它**在整个 `|rⱼ| ≤ tolerance·scale`
的区间里把这一项直接丢掉**（`scale = 1 + |cⱼ| + Σᵢ|aᵢⱼ·yᵢ|`），理由是"舍入量级的检验数不指向任何方向"。
这个理由对**方向**是对的，对**值**是错的：这一项的大小是 `|界| · |rⱼ|`，它由**检验数指向的那个界**决定，
与容差无关。`blend2` 的变量界是 `10²` 量级，所以一个 `10⁻⁶` 量级的检验数在一个界为 `10³` 的变量上就是
`10⁻³` 量级的一项 —— 是它喂给的那个检查（容差 `1e-7`）的**一万倍**，而第二十六轮实测到的
`duality gap failed: 0.00005542410016026804 against tolerance 1e-7`（**550 倍容差**）正是它的指纹。

**改动（已实现、已测、随后回退）**：`box_minimum` 只在**那个方向上没有有限界**时才丢掉这一项；
只要界有限，最小值就在那个界上，就照实算。容差仍然只管"这个检验数指向哪儿"，所以
"舍入量级的检验数不能被读成指向无穷"这条规矩一字未改。新增 `verify/checks_wbtest.mbt` **4 条白盒测试**
（174 → **178**，全绿，含全部"做错的证书必须被拒"的测试 —— 校验器变弱会先在那里露出来）：

| 测试 | 构造 | 期望 |
| --- | --- | --- |
| 容差以下的检验数仍要按界计入 | `scale = 1 + \|c₁\| + \|a·y\| = 1 + 0 + \|1×10\| = 11`（阈值 `1.1e-6`）、`r = −1e-6`、变量界 `1000` | 精确值 **`−1e-3`**（旧代码给 `0`） |
| 容差以上的检验数按指向的界取值 | `r = ±1e-3` | `r>0` 取 0、`r<0` 取 `−1` |
| 小的检验数不能报告"没有界" | 无上界变量的 `r = −1e-6` / `r = −1e-3` | 前者给有限值、后者给哨兵 |

**为什么回退：修好校验器之后，报告写不出来了（实测）**。在精确下界下
`bench/report-mip.ps1 -Manifest bench/data/instances/small.txt -MaxRows 300 -MaxNodes 20000 -MaxIterations 20000`
**拒绝写报告**，原话：

```
refused noswot: a relaxation's certificate was refused at node 6454 (depth 26):
duality gap failed: 1.2169280658914303e-7 against tolerance 1e-7
```

也就是 **1.2 倍容差的擦边证书** —— 旧的（偏高的）下界刚好把它盖住了。两条判断都成立：**修正是对的**
（证书能不能证明目标值，就取决于那个下界算得对不对），**拒签也是对的**（那个节点的对偶解确实没有把它声称的
目标值证明到这个容差）。⇒ **要修的是生产端，不是把校验器改回去**。这正是第二十三轮那条规矩的又一次应用：
"内核选择**不写**而不是**修好**" —— `simplex/revised.mbt::finish_optimal`（第 1705 行起）已经用同一套形状
自检**残差**与**乘子符号**（第十五轮），现在它还需要自检**间隙**：超容差先 `refactorize()` 重测，仍在容差外报
`NumericalFailure`（不发证书、不发 basis），于是那个节点变成"没有结论的松弛"（计入 open work、
`verified < nodes`），搜索**继续**，而不是整轮停在 6454。

**顺带量清的两条**：① 修正对 300 节点口径**没有任何影响** —— 重跑的 `bench/parse-report.md`、
`bench/solve-report.md` 与 300 节点的 `bench/mip-report.md`（32 实例、4231 个松弛过校验）与已提交版本
**除头部 commit 行外逐字节相同**，只有 20 000 节点口径会走到那个擦边节点；② 复现上面那条"更正"需要一个
临时加上的 `--dive-attempts`（库里的 `MipOptions::dive_attempts` 本来就没有命令行入口，本轮临时加了又随改动
一并回退）。

**为什么这一轮不在内核里把它修掉（写清楚，而不是含糊）**：内核在**自己的归一化空间**里算
（`KernelProblem` 带 `model_sign` / `objective_constant` / `var_offset` / `row_dual_factor` / `row_relation`
/ 自己发明的行），而校验器量的是**模型空间**的间隙。第十五轮为"乘子符号"已经搭过一次这座桥，而这个项目为
"生产者与被评判者量不同的东西"**踩过三次坑**。要把间隙自检做对，就得把校验器那条式子
（`Σⱼ min over 模型盒子(rⱼxⱼ) + D`，以及它自己的分母 `1 + |primal| + |bound|`）跨过那一整套变换复现出来，
而一篇写错的版本产出的是**假 `NumericalFailure`** —— 那是"降级"而不是"拒签"，报告数字会变却看不出错。
所以它需要一次带语料实测的单独一轮，而不是本轮顺手带上。

**验收状态**：代码全部回退（`git reset --hard` 回 `2256f2f`，那个提交**没有推送过**），实测四件套与改动前
逐位相同（`moon check --deny-warn` 退出 0、`moon test` 三目标 **174/174**、`bench/report.ps1` 读出
**4 份报告 0 stale**），工作树干净 ⇒ **本轮不需要重跑报告**。

### 实测后回退 — M5 第二十六轮：让下潜启发式在拿到当前解之后继续走（缺口塌掉一大半，但证明成本变差）并暴露一条活缺陷

**为什么是它**：上一轮结束时把缺口重心定在了**当前解**这一侧（`gt2` 44 073 对官方 21 166、`markshare1`
138 对 1、`pk1` 36 对 11、`noswot` −34 对 −41.00000885），而 `mip/search.mbt` 的下潜启发式由
`!has_incumbent` 守卫着 —— **一旦拿到第一个整数点就永久关闭**，这个守卫从未作为旋钮量过。本轮就量它。

**改动（两件，都被量过）**：

1. **闸门**：`!has_incumbent` 换成"这个节点还能赢过当前解"（`sign·relax_objective < sign·incumbent − tol`）。
   这是一个**必要条件**而不是过滤器：节点内任何点的目标值都不优于该节点的松弛值，所以松弛都不比当前解好的
   节点里不存在更好的点。新增 `MipOptions::dive_after_incumbent`（默认**关**，关时逐位等于改动前）与 CLI
   `--dive-after-incumbent` / `--dive-attempts <n>`。
2. **代价的界**：新启动的走法**不按次数记账，而按节点预算的份额记账**。原因是量出来的：同一个次数在
   两万节点口径是十六分之一、在三百节点口径是整个预算。所以"有当前解之后"启动的走法总共只能花
   `max_nodes / dive_share`（`dive_share = 8`）次松弛，**这是精确界**（最后一次走法只拿到份额的剩余部分），
   而第一个当前解的走法照旧由 `dive_attempts` 界住。

**实测一 —— 闸门确实开得很频繁（3 000 节点、`--dive-attempts 100`，仪器临时把 dives/spend/opportunities
写进 node-limit 消息）**：机会数 `markshare1` 89、`pk1` 315、`noswot` 138、`gt2` 56、`blend2` 51，当前解随之塌下来：

| 实例 | 关 | 开（3 000 节点） |
| --- | ---: | ---: |
| `markshare1` | 478.00000000053296 | **118.00000000318971** |
| `markshare2` | 144.99999999986127 | **92.00000000201659** |
| `pk1` | 36.000000000050356 | **17.000000000046374** |
| `noswot` | −34.00000000000816 | **−37.000000000008704** |
| **`gt2`** | 44 072.99999998619 | **21 166.000000006563**（官方最优值 21 165.99999999978） |
| `blend2` | 没有整数点 | **8.126579000001852**（该口径下第一次拿到点） |
| `khb05250` | optimal 305 | optimal **771** |
| `p0201` | optimal 586 | optimal **938** |
| `22433` | optimal 33 | optimal 33 |

**实测二 —— 收益对"允许花的预算"是单调的，没有拐点**（3 000 节点，允许次数 2/4/8/16/100）：
`gt2` 44 073 → 44 073 → 44 073 → 44 073 → 30 774 → **21 166**；`markshare1` 478 → 257 → 257 → 168 → **118**；
`pk1` 36 → 36 → 33 → 24 → 19 → **17**；`noswot` −34 → −35 → −36 → −36 → −37。
同时**界在被吃掉**：`blend2` 的界 7.385618460981052 → 7.3564690149749765（16 次），`pk1` 的界
1.1234189405921517 → 0.951817900634191。**⇒ 这条曲线是本轮最重要的读数：它没有"两轴同时赢"的设置。**

**实测三 —— 报告口径（20 000 节点）**：

| 允许次数 | `gt2` 当前解 / gap | `markshare1` | `noswot` / gap | `pk1` / gap | 四个已证最优的节点数 |
| --- | --- | ---: | --- | --- | --- |
| 关（基线） | 44 072.99999998619 / 24 071.70 | 138.00000000001302 | −34.00000000000816 / 9.00000000003675 | 36.000000000050356 / 31.963513146919368 | 13806 / 305 / 586 / 33 |
| 8 | 不变 | 138.00000000001302 | −36.00000000000962 / 7.000000000037005 | **24.000000000026187** / 19.979800142943873 | 13955 / 373 / 660 / 33 |
| 32 | **29 255** / 9 253.70 | **85.00000000061175** | −37.000000000008704 / 6.000000000036174 | **19.000000000021185** / 15.011888189199633 | 14498 / 588 / 885 / 33 |
| 128 | **21 166.000000006563**（=官方最优）/ 1 164.70 | 105.00000000254795 | −38.00000000001298 / 5.0000000000308304 | 15.000000000014932 / 11.140984038676724 | 16250 / 771 / 938 / 33 |

**实测四 —— 份额界在报告口径下的结果（`dive_share = 8`，两万节点的 10 实例 + 三百节点的全清单）**：

| 实例 | 基线（已提交报告） | 份额口径 |
| --- | --- | --- |
| `flugpl` | optimal 13 806 | optimal **16 306**（+18%） |
| `gt2` | 44 072.99999998619 / 界 20 001.295420696988 / gap 24 071.704579289202 | **29 255** / 20 001.295420696668 / **9 253.704579303332** |
| `blend2` | 界 7.5784463643777125 / verified 19966 / 无点 | **逐位相同**（`spend=0`：`blend2` 从未拿到当前解，这个闸门在它身上一次都没开） |
| `khb05250` | optimal 305 | optimal **771**（+153%） |
| `markshare1` | 138.00000000001302 | **118.00000000318971** |
| `markshare2` | 144.99999999986127 | **92.00000000201659** |
| `noswot` | −34.00000000000816 / −43.000000000044906 / gap 9.00000000003675 | **−37.000000000008704** / −43.00000000004367 / gap **6.000000000034966** |
| `p0201` | optimal 586 | optimal **938**（+60%） |
| `pk1` | 36.000000000050356 / 界 4.036486853130988 / gap 31.963513146919368 | **15.000000000014932** / 界 **3.823800574124356** / gap **11.176199425890577** |
| `22433` | optimal 33 | optimal 33 |

四条口径事实：**花费恰好等于份额**（20 000/8 = 2 500，5 个实例的 `spend` 全是 2 500；300 节点口径全是 37）；
**4 个已证最优一个没丢**、9 个点全部通过独立复核、**没有一次拒签**；300 节点全清单口径有变化但幅度都是个位数；
**闸门关掉时十个实例与已提交报告逐行逐位相同**（这也是锚点的复核）。

**结论：净收益为负，回退**（工作树 == `5b758ba`）。判据与第十八、二十轮一致，逐条写清楚：
① **4 个已证最优实例里 3 个证明成本变差**（`flugpl` 13 806 → 16 306、`khb05250` 305 → **771**、
`p0201` 586 → **938**；`22433` 不变），而**覆盖面一个没多**（仍是 4/10）；
② **界这一轴是混合偏负**：`gt2`/`noswot`/`markshare1`/`markshare2` 的界一字未动，`pk1` 的界反而从
4.036486853130988 **降到** 3.823800574124356（正是走法吃掉的预算）；
③ 收益全在**缺口**这一轴（`gt2` −62%、`pk1` −65%、`noswot` −33%、`markshare2` −37%、`markshare1` −14%），
但六个到预算的实例**一个也没被证明**，所以它没有推进"已证最优覆盖"这条队列目标。
**诚实边界**：这不是"收益太小"，而是**同一个旋钮上的单调取舍**（实测二那条曲线没有拐点），
所以"把它调小一点"不会变成两轴同时赢。

**本轮更值钱的产物是一条活缺陷（可从一个已发布的旋钮直接触发）**：`--dive-attempts 128` 时
`blend2` 在**节点 311** 以 `mip=unverified` 停下，理由原话是
`duality gap failed: 0.00005542410016026804 against tolerance 1e-7`，发生在**一次下潜的走法**里，
于是整轮搜索停止、CLI 退出码 1、**`bench/report-mip.ps1` 会因此拒绝写报告**。关键事实：`blend2` 当时
**没有当前解**，所以这些是"第一个当前解"的走法 —— **它与本轮的新闸门无关，单靠 `dive_attempts` 就能触发**
（这也是新闸门把代价改成按份额记账的原因之一）。复现配方：
`parse.exe bench/data/instances/blend2 --mip --max-rows 300 --max-nodes 20000 --max-iterations 20000 --dive-attempts 128`
（而 8 与 32 在同一个实例上跑满 20 000 个松弛、不撞）。

**也量清了下一轮的杠杆（机制层面的解释，不是猜测）**：机会数几乎等于节点数（`blend2` 17 307、`gt2` 19 451、
`pk1` 13 983），也就是说**闸门在几乎每个节点上都是开的**；而 `dive_attempts` 买到的是**最先遇到的那些机会**，
即最浅、最没决断的节点，每次走法无论是否落地都要花掉最多 60 次松弛。⇒ 下一步该量的是**把走法限制在
"几乎没有剩余整数变量"的节点上**（每个走法因此又便宜又容易落地），而不是继续调次数或份额。

**验收状态**：代码全部 `git checkout` 回 `5b758ba`（含 `mip/`、`cmd/parse/`、`moonopt.mbt` 与 `.mbti`），
实测四件套与改动前逐位相同（`moon check --deny-warn` 退出 0、`moon test` 三目标 **174/174**、
`bench/report.ps1` 读出 **4 份报告 0 stale**），工作树干净 ⇒ **本轮不需要重跑报告**。

### 实测后回退 — M5 第二十五轮：{0,1} cover 割（"新割族"这条）量完，根界增量仍是 +0

**为什么是它**：`docs/roadmap.md` 队列首位是"把已证最优覆盖面推上去"，三条候选路径的第一条是
"{0,1} cover / knapsack 割，对 `markshare`/`pk1` 这类 0-1 结构有依据"。前二十四轮里**割的选择规则**
（16–18 轮，五条）、**割族**（21 轮，多行 MIR 聚合）、**节点位置**（22 轮）都已量完并回退，而
`markshare1`/`markshare2`/`pk1`/`noswot` 的**根界增量在所有已试设置下都是 +0**。cover 割与它们不同的地方在于：
它**不需要分数性、也不读 tableau 行**，只需要模型里有一条"二值项系数之和超过容量"的行。

**先量结构，再动代码**。写了一个 MPS 结构探针，把每条行化成隐含背包（`≤` 与 `=` 行都能给出一条
`Σ_{j∈B} aⱼxⱼ ≤ K`：把非正系数二值项的项按各自盒子的界移到右边；`≥` 行方向相反，不做）：

| 实例 | 二值变量 | 隐含背包行 | 其中存在 cover 的行 | 落在这些行里的二值变量 |
| --- | ---: | ---: | ---: | ---: |
| `markshare1` | 50 | 6 | **6** | 50 |
| `markshare2` | 60 | 7 | **7** | 60 |
| `gt2` | 24 | 2 | 2 | 24 |
| `p0201` | 201 | 29 | 29 | 201 |
| `blend2` | 239 | 88 | 9 | 9 |
| `22433` | 231 | 32 | 1 | 231 |
| `pk1` / `noswot` / `flugpl` / `khb05250` | 55 / 75 / 0 / 24 | 0 | 0 | 0 |

`markshare1` 的六条行都是 `x₁ + Σⱼ aⱼxⱼ = 1116…1353`（一个非负连续变量 + 50 个二值变量，`Σa = 2233…2706`），
`markshare2` 是七条同形行（60 个二值变量，`Σa = 2648…3372`）——**"贴下界"的那两个实例正是 cover 有依据的地方**；
`pk1`/`noswot` 一条都没有。另外补一条量：`markshare1` 根松弛的最优点是**高度分数**的
（`x = 0.8502 / 0.2744 / 0.3237 / 0.8808 / 0.4477` 五个二值变量），而 cover 割在点 `x` 上的违背量恒等于
`1 − Σ_{j∈C}(1 − xⱼ)`，所以有违背的 cover 是存在的。

**过程事实（量错口径比不量更糟，本轮踩了一次）**：这个探针的第一版把 `rhs` 读成 **0**，于是得出
"markshare/pk1 一条背包行都没有，这条路是死的"——**与事实相反**。根因是 PowerShell 的 `-contains`
**不区分大小写**，而 `markshare1` 的 RHS 段集合名就叫 `rhs`，于是**每一行 RHS 数据都被当成段头吃掉了**
（`gt2` 的 `avail.04` 也因此从 `b=1` 读成 `b=0`）。抓出它的是第二个探针（把一条行的每一项连同列界一起打印）
给出的矛盾数字。修法是段关键字改用大小写敏感比较（`-ccontains`）。

**实测三 —— 顺带把根界-轮次曲线量成了仪器**（探针口径：`--mip --max-rows 300 --max-nodes (轮次+2)
--max-iterations 20000`，报出来的 `bound` 就是割后的根松弛界；同一条命令在改动前后**逐位相同**，
顺带把锚点复核了一遍：`gt2` 2 轮 `20 001.295 420 697 123`、`22433` 0 轮 `21 240.526 170 816 112`
与文档里记的两个锚点**逐位一致**）。它回答的是"割的钱花在哪一轮"：

| 实例（官方最优值） | 0 轮 | 1 轮 | 2 轮（出厂） | 4 轮 | 8 轮 | 8 轮的墙钟 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `gt2`（21166.00） | 13 460.23 | 17 001.93 | 20 001.30 | 20 679.35 | 20 984.52 | 0.16 s |
| `khb05250`（106 940 226） | 95 919 464 | 101 279 614 | 103 698 571 | 105 705 514 | 106 671 375 | 1.09 s |
| `p0201`（7615） | 6875.00 | 7075.00 | 7166.43 | 7255.36 | 7332.14 | 1.12 s |
| `flugpl`（1 201 500） | 1 167 185.73 | 1 171 213.72 | 1 171 748.83 | 1 172 070.51 | 1 172 363.30 | 0.03 s |
| `blend2`（7.598985） | 6.915675 | 7.024902 | 7.033425 | 7.062378 | 7.064243 | 0.56 s |
| `22433`（21 477） | 21 240.53 | 21 289.49 | 21 289.69 | 21 290.06 | 21 294.17 | **10.35 s** |
| `noswot`（−41.00000885） | −43.0000000000472 | −43.000000000044 | 同 | 同 | 同 | 0.08 s |
| `pk1`（11） | 1e−12 | 1.17e−12 | 1.00e−12 | 1.19e−12 | 1.19e−12 | 2.93 s |
| `markshare1`（1） | 0 | 0 | 0 | 0 | 0 | 1.70 s |
| `markshare2`（1） | 0 | 0 | 0 | 0 | 0 | 2.03 s |

两条读数值得记下来：① **六个实例的根界到第 8 轮还在涨**（`gt2` +983、`khb05250` +2 972 804、
`p0201` +166 相对 2 轮），但代价是根模型更厚 ⇒ 这正是第十八轮"更多轮 = 界↑成本↑↑"那条已知杠杆的
量化版本；② 四个"贴下界/官方最优"的实例里，`noswot`/`markshare1`/`markshare2`/`pk1` 的根界
**从 0 到 8 轮一动都不动**，其中 `noswot` 的 −43.000000000044 **就是它的 LP 松弛值**（官方 MIP 最优
−41.00000885 比它高 2），所以它的缺口不在割上。

**实现**：`verify/cuts.mbt` 新增 `implied_knapsack`（行的隐含背包，生产者与校验器**共用同一个读数**）、`CoverCertificate`（`row` + `items`）、`derive_cover_cut`、`verify_cover_cut`；`mip/cuts.mbt` 新增
`cover_cuts_for`，挂在同一套根割轮次里（"重解给出结论且不变差才保留"这条规矩没有改），
`MipOptions::cover_cuts`（默认 **0**，为 0 时行为与改动前逐位相同）与 CLI `--cover-cuts <n>`。
校验器一行都没有放松：它重算该行的隐含背包、逐个检查 cover 项是**该行的二值项**、不允许重复计数、
要求 `Σ_{j∈C} aⱼ − K` **大于产生它的算术**（尺度与舍入族同一条：`1 + |b| + Σ|aⱼ|`），
再与交上来的行逐项比对；拒绝即 `Unverified`，不跳过。新增 **7 条测试**（174 → 181）：
三变量背包的手算例、`=` 行带非负连续项的 markshare 形状、枚举全部 8 个整点确认割不砍掉可行点
**而故意加强一格的割确实砍掉一个**、五种不合论证的证书被拒、四种给不出背包的行给出理由。

**实测一 —— 根界（探针 `--mip --max-rows 300 --max-nodes (轮次+2) --max-iterations 20000`）**：
`markshare1` / `markshare2` 在 `--cut-rounds` ∈ {2,4,8} × `--cover-cuts` ∈ {0,10,50,200} 的 **24 格**里，
界**全部恰好是 0**（与不加 cover 逐位相同），而割数确实上去了：

| 实例 | 轮次 | cut-rounds 0 条 cover | 加 cover 后的割数 |
| --- | ---: | ---: | ---: |
| `markshare1` | 2 / 4 / 8 | 15 / 30 / 63 | **30 / 55 / 97** |
| `markshare2` | 2 / 4 / 8 | 17 / 37 / 76 | **32 / 55 / 95** |

**这不是被上限截断**：把每行的重启次数从 8 提到 **60**、`--cover-cuts` 提到 **500**，
割数**一格没变**（`markshare1` 97、`markshare2` 95）⇒ 有违背的 cover 供给已经**抽干**，界仍然是 0。
其余 8 个实例上 cover 一条都不生成，界与割数与不加时**逐位相同**（`pk1` `9.99778643026538e-13`、
`noswot` `-43.000000000044`、`gt2` `20001.295420697123`、`p0201` `7166.428571448296`）。

**实测二 —— 小规模清单全矩阵（报告口径，10 实例 / 20000 节点 / `--cover-cuts 10`）**：
8 个实例**逐位相同**（界、目标值、节点数、`verified`、割数全同），4 个已证最优实例的节点数
`flugpl` 13806 / `khb05250` 305 / `p0201` 586 / `22433` 33 **一格未动**，无拒签；而 `markshare1`/`markshare2`
的当前解**一个变好、一个变差**：

| 实例 | 基线当前解 | 加 cover | 界（两者都是） |
| --- | ---: | ---: | ---: |
| `markshare1` | 138.00000000001302 | **78.00000000059423** | 0 |
| `markshare2` | 144.99999999986127 | **307.9999999999052** | 0 |

**结论：净收益为负，回退**（工作树 == `ed75652`）。理由按三条轴分开写清楚：
① **界轴增益恰好为 0** —— 这是本轮 24 格探针 + 抽干实验的直接读数，和舍入族在 0/2/6 轮上的 +0 是同一个数；
② **当前解轴一好一坏**（`markshare1` −43%、`markshare2` +112%），合起来不是净收益；
③ **成本轴严格变差**（两个实例的根模型更厚：8 轮口径 `markshare1` 的割数 63 → 97、`markshare2` 76 → 95；
同口径、每行重启 60 的墙钟 `markshare1` 1.97 s → 2.60 s）—— 不过墙钟在这一族上不是可靠读数：
割改变了重解路径，量到过"开 cover 反而更快"的格子，所以成本这一轴以**割数**为准、墙钟只作旁证。
于是"割这条线全部关闭"这句话现在多一条依据：**选择规则、割族（舍入族的多行聚合、cover 族）、节点位置
三者都量完且都无净收益**。诚实边界：本轮量的是"该生成器找到的 cover"——每行给出的是确定性的贪心
（按"点在项上的亏空"排序取最短前缀，再反复丢掉亏最大且仍可丢的项），**没有做 lifting**；
不过上限提到 500、重启提到 60 之后割数不再增长，说明**上限不是瓶颈**，这一点与"family 没被充分试探"
是不同的陈述，不应混为一谈。

**代码零改动、报告不重跑**：探针跑完后把 `mip/`、`verify/`、`cmd/parse/`、`moonopt.mbt` 与
`.mbti` 全部 `git checkout` 回 `ed75652`，实测四件套与改动前逐位相同
（`moon check --deny-warn` 退出 0、三目标 **174/174**、`bench/report.ps1` 读出 **4 份报告 0 stale**），
工作树干净 ⇒ **本轮不需要重跑报告**（与第十六~十八、二十一、二十二轮同规）。

### Docs — 文档职责收口：README 收敛为入口，新增验收对照

三份根文档此前职责重叠。`README.md` 有 424 行，其中 68 行是逐轮进展叙述（与 `CHANGELOG.md`、
`docs/roadmap.md` 重复）、约 75 行是设计与数值策略说明（与 `docs/design.md`、`docs/algorithms.md` 重复）、
19 行是生态对照表（与 `docs/ecosystem-survey.md` 重复）。本轮**只动文档，不动一行代码**：

- **`README.md` 从 424 行收敛为 236 行**，只做入口：定位与状态、安装与最小例子、**五分钟从零复现**、
  可运行示例与 CLI、能力与边界表、承诺 ↔ 证据表、生态位摘要、项目结构、**文档导航表**（逐份说明哪个文档
  负责什么）、依赖、开发指引、数据与许可。逐轮进展与设计细节改为指向 `CHANGELOG.md` / `docs/roadmap.md` /
  `docs/design.md` / `docs/algorithms.md`。
- **新增 [`docs/acceptance.md`](docs/acceptance.md)**：把**验收要求逐条对到证据**（这是与 README 的
  能力承诺不同的另一条轴），并给出评审可执行的最短复现路径、发布物的三项验收、以及"为什么不是重复项目"
  的对照要点与已知限度清单。
- **`docs/ecosystem-survey.md`**：接收从 README 搬来的生态对照表，并补上 2026-09-21 用 `moon search` /
  `moon view` 复核到的第一手事实（`milp` / `presolve` 两个关键词的命中数各为 1，即本项目自己；
  `simplex` 命中的是图形学的**单纯形噪声**库 ⇒ 判断重合要看**包级 API**，而不是关键词命中数）；
  **VIPR 一节从"待核对"改成"已核对"** —— 读到了原文 `cert_spec_v1_1.md`（247 行，经 GitHub API 取下），
  并**更正此前一处理解**：1.1 版的 `DER` 节带 `asm` / `lin` / `rnd` / `uns` 四类 reason，
  **割能按"由哪一行舍入而来"导出成 `rnd` 派生约束**，所以"VIPR 不要求割的来源"那句是错的；
  真正的卡点是**算术域** —— VIPR 的数是有理数、检查器按精确算术工作，把浮点证书有理化等于换了一份主张，
  必须先重新论证它成立（正撞上本项目最硬的那条纪律：自检的尺度必须与它所替代的检查一致）。
  `RTP` 只回答"最优值区间"或"不可行"两种结论（全文 `unbounded` 出现 0 次）。
- **`AGENTS.md`** 的仓库地图补上"文档各司其职"一节，避免后续再把细节堆回 README。

**本轮没做，写下来而不是藏着**：`cmd/parse --json --reoptimize` 的 JSON 镜像**仍未做**，理由不是难度而是代价 ——
它在报告的依赖路径里（`cmd/parse`），改动即让四份报告变陈旧、要重跑约 37 分钟并重新推送；而它本身是
**有意为之的设计拒绝**（`docs/roadmap.md` 与 `cmd/parse/json_report.mbt` 都写了理由：那个旗标存在的意义就是
打印一次测量，文档里少了它就是另一个问题）。留到 9 月场验收之后再定。

**验收状态**：零代码改动，`.mbti` 未变；`moon check --deny-warn` 退出 0，`moon test` 在 native / wasm-gc /
js 三目标仍为 **172/172**（与改动前逐位一致）；四份报告仍为 `current`（`bench/report.ps1` 读出 0 stale ——
文档不在报告的依赖路径里），这也是本轮特意避开 `cmd/parse` 的原因。

### 实测后回退 — M5 第二十二轮：节点上的割（最后一个没量过的割杠杆）量完，净收益为负，并因此暴露一条活缺陷

**为什么是它**：选择规则（第十六~十八轮）与割族（第二十一轮）都已量完并关闭，`docs/roadmap.md` 里剩下的割杠杆
只有"节点上的割"。它此前没做是因为**树形**：节点 = 父节点 + 一条收紧的界，模型沿链从根重建，节点之间靠父基热启动，
而加行会让热启动失效（新行不在旧基里）。本轮按这个结构做了最小侵入的实现：

- `Node` 增加一个 `cuts` 字段（割池下标，`-1` 表示无），割池 `Array[NodeCut]`（`row` + `next`）只在运行时增长；
  割沿链继承，和界同一个道理：`node_model` 在应用完链上的界之后，再沿该节点的割链把行加回模型，
  **所以一个浅层节点分离出的割会被它的整棵子树继承**。
- 节点割**复用根上的同一套机制**：`cut_round` 取表行、`derive_cut` 生成、`@verify.verify_cut` **重新推导后才准入**
  （**校验器一行未改**）；整轮"重解给出结论且不变差才保留"，否则整轮丢弃、但仍计入已经花掉的松弛。
- 只在前 `node_cut_depth = 2` 层做、每个节点最多一轮：加行后必须**冷解**（旧基不描述新行），
  只有浅层节点的割能被足够大的子树摊薄。

**实测**（口径与历轮一致：`--max-rows 300 --max-iterations 20000`；证明成本用 `--max-nodes 20000`，
卡住组用 `--max-nodes 2000`。节点割确实触发：探针口径的 `cuts` 从 20 → **30**，2000 节点口径 20 → **80**）：

| 实例 | 基线 节点数 / 墙钟 | 节点割 |
| --- | --- | --- |
| `khb05250` | 305 / 19.7 s | **92 / 9.8 s**（唯一的大赢） |
| `flugpl` | 13 806 / 8.1 s | 14 655 / **26.3 s** |
| `p0201` | 586 / 29 s | 641 / **65.4 s** |
| `22433` | 33 / 1.9 s | 54 / **46.4 s** |

卡住组（2000 节点）：`gt2` 的界 **↑660**（20 001.30 → 20 662.16）但**丢了当前整数点**；
`markshare1` 478 → **428**、`markshare2` 192 → **158**（当前解变好）；`pk1` 界 0.542 → **0.995** 但当前解 36 → 40（变差）；
`blend2` 7.349 → 7.302（略差）；而 **`noswot` 在 962 个松弛处 `unverified` 停下**。

**结论：净收益为负，回退**（与第十六~二十一轮同规）。4 个可证最优实例里 3 个的证明成本变差、墙钟 3–24 倍；
唯一的大赢（`khb05250`）不足以抵消 —— 而且那些增益与损失**都是"树被改动"的后果**，改动本身在别处把树改坏了。

**本轮真正的产出是那条副作用**：`noswot` 的 `unverified` **不是节点割直接造成的** —— 报错原文是
`farkas margin failed: 0 against tolerance 1e-7`，发生在 **node 1558（depth 23）**，而节点割只作用于 depth ≤ 2。
也就是说：割改变了树 ⇒ 搜索在一个不同的节点上撞到了**已知未修的 Farkas 射线构造缺陷**。同一条命令、同一个
2000 节点预算，在**基线**上不撞：2000 / 5000 / 20000 三个预算实测都是 `node-limit`（`verified` 分别为
1986 / 4979 / 19905）。⇒ 这条缺陷不是纸面上的限度，而是**任何会改变树的改动都可能踩到的活风险**，
它的优先级因此上升（同时它是 9 月场验收里"已知限度"清单中的一条，见 `docs/acceptance.md`）。

**验收状态**：行为回到 `2be9758` **逐位相同**（`gt2` 探针 `20 001.295 420 697 123`、`p0201` **586** 节点 /
`7 615.000 000 031 186`，与报告一致），工作树干净；`moon check --deny-warn` 干净、三目标 **172/172**、
四份报告 **0 stale**（改的是 `mip/` 且已回退，所以报告仍有效）。设计与全部数字记在此处，供下一轮带旋钮重测。

### Fixed — M5 第二十三轮：修掉那条"活风险" —— 内核不再写出证明不了不可行的 Farkas 射线

**问题**（第二十二轮实测到的）：上一轮把割搬到节点上改变了搜索树，`noswot` 于是在 depth 23 撞到一条被拒的
证书并以 `unverified` 整轮停下，报错 `farkas margin failed: 0 against tolerance 1e-7`；而同一条命令、同一个
2000 节点预算，在**基线**上跑满 2000 / 5000 / 20000 个松弛都不撞。本轮先把它**复现并定位到数字**，再修。

**复现**（把节点割当作仪器重新装上，取完数就撤掉）：同一条命令稳定复现。在拒签处打印校验器的全部实测项与
射线统计，得到：

```
节点 1558（depth 23）：模型 212 行（原 182 行 + 继承的 30 条节点割）、128 变量
射线：212 项、89 个非零、最大 1          ← 形状正常
ok   ray signs            = 2.33e-27     ← 通过
ok   ray is absorbed      = 0            ← 通过
FAIL farkas margin        = 0            ← 恰好为 0：它证明的是 0 ≥ 0
内核自述：raw 残差 1.94e-6、scaled 1.94e-6、**人工和 1.2e-11**
```

**根因（两条测量互相矛盾）**：Phase I 最小化人工和 subject to `A x + a = b`，所以最优点的**人工和就是它为使行
成立而造出来的量**：某一行被违反 `v`，那一行的人工变量就是 `v`。于是 **`Σ a ≥ max v` 是一条必需条件**——
人工和不可能小于它所吸收的违反量。而上面那组数字是 `1.2e-11` 对 `1.94e-6`，**相差 5 个数量级**：同一个系统，
"违反量"与"人工和"给出了互相排斥的答案（Phase I 自己把它满足到了 1e-11）。这种状态下那条 duals 不可能是
Farkas 射线（校验器量出的 margin 恰为 0 正是它的指纹），而残差只比它自己的门槛
（`infeasibility_margin × tolerance = 1e-6`）高不到两倍。

**修法（生产者不写自己证明不了的东西）**：`simplex/revised.mbt` 在报 `Infeasible` 之前增加这条必需条件的自检
（`simplex/certificate.mbt::infeasibility_is_certified`；与第十五轮的对偶侧自检同一条规矩 —— 自检的尺度必须与它
所替代的检查一致，所以复用残差测试自己的 `infeasibility_margin`）。条件不成立时**不发证书**，报
`NumericalFailure` 并把两个实测量与原话写进消息：

> the rows measure as violated but phase one satisfied them, so there is no infeasibility certificate:
> scaled row residual 1.94e-6, raw 1.94e-6, artificial sum 1.2e-11
> (artificials below raw/10 cannot absorb the violation they would be proving)

MIP 层把 `NumericalFailure` 记成"没有结论的松弛"（开着的活、`verified < nodes`），于是搜索**继续跑**，而不是
整轮停下。修的是**可达性**，不是把缺陷藏起来：真正能证明的不可行照旧发出证书、照旧由校验器裁决。

**实测（同一触发点，修复前后）**：

| 命令 | 修复前 | 修复后 |
| --- | --- | --- |
| `noswot --mip --max-nodes 2000`（节点割仪器版） | `unverified`，962 个松弛就停 | **`node-limit`：跑满 2000 个松弛、`verified=1892`、`bound=-43.00000000003727`** |

（`2000 − 1892 = 108` 正是"没有结论"的松弛，即项目文档里的 open-work 口径。）

**基线零变化**：整机矩阵逐位相同 —— `gt2` 探针 `20 001.295 420 697 123`、`flugpl` 13 806、`khb05250` 305、
`p0201` 586 / `7 615.000 000 031 186`、`22433` 33 / `21 477.000 000 017 408`；卡住组 `gt2`
44 072.999 999 986 19 与 `20 001.295 420 696 326`、`noswot` −34.000 000 000 008 16 与 −43.000 000 000 046 73、
`markshare1` 478.000 000 000 532 96、`markshare2` 192.000 000 003 699 37、`pk1` 36.000 000 000 050 356 与
0.542 237 050 513 654 5、`blend2` 7.349 213 340 614 187 ⇒ 这个守卫只在病态状态下触发，语料上不改任何结论。

**顺带用测量排除掉的三条路**（不是推测）：① **LP 路径不是问题** —— 350 个随机模型（近矛盾、病态、
界驱动不可行、大尺度行、混合界类型）**全部** `verify=accepted`；② **热启动不是问题** —— 对偶单纯形早就把不可行
判定交回冷启动（`simplex/dual.mbt:274/317`；那套"没人验证的射线"在早期轮次已被删）；③ **基线树形旋钮不是问题**
—— `noswot` 的 16 组组合（割轮 0/1/2/3 × 迭代上限 5000/20000 × 节点预算 1000/2000）**全部** `node-limit`。
⇒ 唯一触发条件仍是"树被改动"，这也正是本条值得修的原因：**它封顶了所有改树类改动能走多远**。

**验收状态**：`.mbti` 未变（新函数是包内私有）；`moon check --deny-warn` 干净；`moon test` 在
native / wasm-gc / js 三目标 **173/173**（新增一条判据单测，用的是 `noswot` 上那两个真实量）；四份报告已在
含本改动的提交上重跑。

### Added — M5 第二十四轮：cutoff 传播（用 incumbent 的割收紧节点盒子）

**为什么是它**：割这条线在第二十二轮彻底关闭（选择规则、割族、节点位置三者都量完），`docs/roadmap.md` 队列的
下一项是 cutoff 传播 —— "有 incumbent 时把整数目标割喂进松弛"。

**先想清楚"割"和"界"在这里是不是同一件事**（这一条决定实现形态）：把 cutoff 作为**一行**加进节点模型，与
"用 cutoff 收紧节点的变量界"在剪枝上是**等价**的 —— 一行只在松弛已经知道"它能比 incumbent 好"的地方活跃，
而那种节点本来就该被界的测试剪掉；反过来，收紧盒子还顺带收紧了**后续分支**，而且不需要新行、不需要新的证书路径
（交给内核的仍然是一个模型，只是界更紧）。

**实现**（`mip/search.mbt::propagate_cutoff`，在节点求解之前调用）：目标是逐项求和，所以把一项用 cutoff 与
"其余各项在各自盒子上的最小可能贡献"夹住，就单独给这个变量定界 —— `value_k·x_k ≤ cutoff − Σ_{j≠k} min_j`，
整体 `O(n)`（先算一次各 `min_j` 与它们的和，再逐项减去）。只要有一项在其系数所需的那个方向没有界，这个论证就
做不了，于是什么都不改。盒子只被收窄、从不放宽；收空（或整数变量抽屉里不再有整数）就直接剪掉该节点，
**连一次求解都不花**。

**实测（报告口径，与已提交的报告逐行 diff；32 实例 / 300 节点 + 10 实例 / 20000 节点）**：

| 口径 | 结果 |
| --- | --- |
| `bench/mip-report.md`（32 实例 / 300 节点） | **只有 `gt2` 一行的界在第 12 位有效数字上动了**（`20001.29542069699` → `20001.29542069701`，相对量 1e-15 的舍入），其余逐字相同 |
| `bench/mip-report-small.md`（10 实例 / 20000 节点） | `gt2` 同样的噪声级变化；**`markshare2` 的当前解 `192.00000000369937` → `144.99999999986127`**（更好，且该点的可行性违反量从 `2.17e-13` 降到 `1.33e-14`） |
| 4 个已证最优实例 | 节点数 **一字未动**（`flugpl` 13 806 / `khb05250` 305 / `p0201` 586 / `22433` 33）⇒ 没有丢掉覆盖 |
| `bench/check-mip-objectives.ps1` | `violations 0, not in the table 0, unverified nodes 0` |

**结论：保留**（一个实例的当前解变好、其余为零改动或噪声级、无回归、无覆盖损失）。**诚实边界**：`markshare2`
的改善**可能来自传播本身、也可能来自"树被改动"** —— 这个项目里两者很难分开，所以不做比"报告口径整体无回归、
有一处更好"更强的宣称。

**过程事实（又踩一次，值得写下来）**：本轮第一次生成报告是在**提交代码之前**做的，报告因此记录的是改动前的
commit，按 `bench/report.ps1` 的判据它们立刻就是 `STALE`（CI 的"benchmark reports are current"会红），
于是重跑了一遍（34.5 分钟）。**顺序只能是：代码 commit → 生成报告 → 报告 commit → 推送**。

## [0.1.2] — 2026-09-25

补丁版本，起因是一条**已经发布出去的正确性缺陷**：`0.1.1` 里的独立校验器（`verify/checks.mbt::box_minimum`）
在 `|rⱼ| ≤ tolerance · scale` 时把 Lagrangian 下界的一整项丢掉，而那一项的大小是 `|界| · |rⱼ|` ——
由**检验数指向的那个界**决定，与容差无关。在 `blend2` 这类界为 `10²~10³` 的实例上，一个 `10⁻⁶` 的检验数
就是 `10⁻³` 的一项，是这个检查自身容差（`1e-7`）的一万倍 ⇒ 旧版本可能**接受一份真实对偶间隙超容差的
最优性证书**（也可能在另一头误拒正确的证书 —— 第二十六轮那条 `5.54e-5` 的拒签就是同一个算错量的另一面）。
修正后箱极小值在界有限时是精确的，容差只负责判断"这个检验数指向哪个方向"。

生产端同时补齐，否则修好校验器的代价是"整轮搜索在一条擦边证书上停下"（实测：只装校验器那半，
`noswot` 在节点 6454 被拒、`bench/report-mip.ps1` 拒绝写报告）：`simplex/revised.mbt::finish_optimal`
在宣布基最优之前现在量**三件** —— 残差、乘子符号约定、以及**对偶间隙** —— 形状与既有两件相同
（超容差先 `refactorize()` 重测，仍在容差外才报 `NumericalFailure`，于是那个节点是"没有结论的松弛"、
搜索继续）。内核在自己的归一化空间算、校验器在模型空间算，所以模型自己的量是**带过来**的而不是反推的
（`KernelProblem` 新增 `model_cost` / `model_lower` / `model_upper` / `model_rhs`），
`simplex/certificate.mbt::duality_gap_violation` 逐字复现校验器那一条式子（同 cost、同 `r = c − Aᵀy`
且累加顺序相同、同行关系符号、同框极小值规则、同分母、同"没有有限界"哨兵）。

- **行为影响**：本语料上除 `noswot` 一行的 `verified` 19 905 → 19 907、界 2e-14 相对量以外**没有数字变化**；
  四个已证最优实例的节点数一字未动（`flugpl` 13 806 / `khb05250` 305 / `p0201` 586 / `22433` 33）；
  300 节点口径与解析 / 求解两份报告逐字节相同。**覆盖面一个没多** —— 这一版修的是"答案可不可信"，
  不是"能解多大"。三条诚实边界（修复后的证书不在自检覆盖内、`refactorize()` 修不好那一支没有测试、
  带内且无有限界仍丢项）记在第二十八轮条目里。
- **测试**：174 → **180**。新增 `verify/checks_wbtest.mbt` 4 条白盒（带内按界计入、带外按指向的界取值、
  带内不能报"没有界"）与 `simplex/certificate_wbtest.mbt` 2 条（6 个模型在内核侧与校验器侧量同一个数，
  `rel = 1e-15`；以及专打带内路径的那条 —— **它在旧语义下会失败**）。
- 四份报告在含本修正的代码提交上重生成（`bench/report.ps1` 读出 4 report(s)、0 stale），
  `bench/check-mip-objectives.ps1` 两项 `violations 0 / not in the table 0 / unverified nodes 0`。

**发布物验收（本轮重跑的部分，做法见 `docs/roadmap.md` 的 M6 轮次）**：归档
`_build/publish/Freon793-moonopt-0.1.2.zip` 是 **125 个条目 / 462 588 字节**，`bench/data` 只剩本项目自己写的
清单 `small.txt` ⇒ **不含**任何 MIPLIB 第三方实例数据；把它解出来当模块根，`moon check --deny-warn`
退出 0、`moon test` 180/180 全绿 ⇒ 归档自包含。**没有重跑**的是"外部包只用公开面"那条，理由是可查的：
`git diff v0.1.1 HEAD -- '*.mbti'` 只有 4 行，全部是 `pub struct KernelProblem` 新增的字段
（`pub` 而非 `pub(all)`，包外既不能构造也不能读），也就是**可用公开面在本版一位没变**；
四项契约（线性 21 / 整数 20 / 不可行 / 预算 1 报 `node-limit`）的实测记录见 `docs/roadmap.md` 的 M6 E 轮。

## [0.1.1] — 2026-09-21

补丁版本，起因是发布后对着参赛章程与 MoonBit 发布文档逐条核对（`0.1.0` 发布当天）。没有功能改动，
但其中三项会让外部看到的东西与本仓库不一致，所以按语义化版本约定升 PATCH 重新发布：

- **CI 补上显式的构建步骤**：章程的仓库要求是"使用持续集成工具并且覆盖**检查、构建、测试**流程"，
  而工作流此前只有 `moon check` / `format diff` / `info check` / `moon test` —— 测试当然会编译，
  但"构建"应当是读者能在工作流里直接看到的一步，而不是需要知道"测试顺带编译"才能推出来的结论。
  现在 `check` job 有 `moon build`，`targets` job 有 `moon build --target <t>`。
- **命令行打印的版本号此前是 `0.1.0-dev`**：它硬编码在 `cmd/main/main.mbt` 里、是 `moon run cmd/main`
  的第一行输出，与 `moon.mod` 的版本脱节；现改为 `0.1.1`。
- **`homepage` 没有加，这是本轮量出来的一个"文档 vs 实现"差**：MoonBit 的发布文档把 `homepage` 与
  `license` / `keywords` / `repository` / `description` 并列为 `moon.mod` 的元数据，但**当前发布的工具链
  直接拒绝它** —— 本机 `moon 0.1.20260920`（也就是 CI 装的 `latest`）实测原文是
  `Unexpected key 'homepage' found in moon.mod.`，加上之后连 `moon check` / `moon test` 都无法开始
  （`Failed to calculate build plan`）。按"CI 必须绿"优先撤下该字段，并把结论记在这里：
  **文档列出但工具链不接受的字段不能用**，等工具链接受时再加。
- **`cmd/main` 的包文档与包 README**：包文档此前还写着"第一个里程碑只跑内置示例，`solve` / `verify` /
  `fmt` / `bench` 的参数解析将来才到"（早已落地），且该包在 mooncakes 上因"无公开 API + README 为空"
  被自动标注为 "documentation contains no information"；两处都改成实际形态。
- 顺带修掉 README 里两处过期表述：测试数 `140` → `172`；M5 从"十五轮已落地"改为"完成标准①②③全部满足"。

**发布物仍按同样三件事验收**：归档自包含（解出来 `moon check --deny-warn` 干净、`moon test` 172/172）、
不含任何第三方实例数据、以及一个只使用公开面的外部工程能编译并通过四项契约检查。

## [0.1.0] — 2026-09-21

首发版本。它的内容就是下面这一整份日志：四份基准报告（解析 / 求解 / 分支定界两张口径）、
稀疏修正单纯形内核（稀疏 LU 基分解、有界变量枢轴、对偶单纯形热启动、presolve/postsolve）、
MPS 与 LP 读写、分支定界与根割、独立校验器（最优性 / Farkas / 无界射线 + 证书 JSON）、
CLI（`solve` / `verify` / `fmt` / `bench`，文本与 JSON 两套渲染）。

安装：

```text
moon add Freon793/moonopt
```

发布物本身被验过三件事（细节见 `docs/roadmap.md` 的 M6 E 轮）：`moon package` 的归档**自包含**
（解出来后 `moon check --deny-warn` 干净、`moon test` 172/172 全绿）；归档**不含**任何第三方实例数据
（121 个条目 / 421 KB，`bench/data/instances` 只保留本项目自己写的清单 `small.txt`，排除规则从
"`.gitignore` 顺带生效"改成显式的 `.moonignore`）；一个**只使用公开面**的兄弟包能编译并通过四项契约检查
（线性模型 21、整数模型 20、不可行模型、以及"预算 1 时必须报 `node-limit` 而不是假装最优"）。
"字段可见性"这条也由该验收程序正面确认：包外可读 `Solution` 的 `objective` / `nodes` / `bound` / `message`。

已知限度与逐条证据在 README 的"承诺 ↔ 证据"表里，不在这里重复。

### 实测后回退 — M5 第二十一轮：多行 MIR 聚合（`y_B + λ·y_k`）在行序对照下重测，四个"根界抬不动"的实例仍然抬不动

**为什么要重测**：第十六轮量过这个割族，但当时它建立在**已被回退的 efficacy 排序之上**，所以那批数字回答的是
"聚合 vs efficacy"，而不是"聚合 vs 行序"。第十八轮关闭了"选择规则"这条线（五条规则没有一条在**根界**与
**证明成本**两个轴上同时赢过"行序取满上限"），留下三条路：换**割族**、更多轮、节点上的割。本轮走第一条，
判据按项目口径：**根界增量与证明成本一起看**，且每条割仍须能被 `verify_cut` 从 witness 重推。

**实现（实测后已回退）**：目标行取**最分数的 3 行**（基变量为整数且离整数的距离最大），每行配 **8 个伙伴行**
（按 tableau 行序），每对试 **3 个权重**（`+1`、`−1`、以及消掉目标行最大项的那个 λ）；`y_B + λ·y_k` 的行与右端项
由模型重算后走**同一个** `derive_cut` / `verify_cut`（**校验器一行未改**）。聚合仍是一条可被 MIR 舍入的行，
理由是**一条 tableau 行在其它基变量上的系数恰为 0**，所以组合后 `x_B` 的系数仍是 1 —— 这正是 `derive_cut`
对"被舍入的那一行"的唯一硬要求。两个上限**分开**（`cut_round_cap` 与 `aggregate_cap`），
这样"换族"与"割更多"才是两个问题、而不是一个被混淆的答案。`aggregate_cap = 0` 时聚合池根本不构建，
行为与出厂设置**逐位相同** —— 这一点由 C1 控制组实测确认。

**五组配置**（口径 = 报告自己的 `--max-rows 300 --max-iterations 20000`；根界用 `--max-nodes 4 --cut-rounds 2`
探针，证明成本用 `--max-nodes 20000`，卡住组用 `--max-nodes 2000`）：

| 配置 | 含义 |
| --- | --- |
| C1 | `cap10 agg0` —— 出厂设置，**控制组** |
| C2 | `cap20 agg0` —— 同族、割翻倍：**"割更多"的控制组** |
| C3 | `cap10 agg10` —— 出厂设置之上再加聚合，总割数与 C2 相当 |
| C4 | `cap5 agg5` —— 与 C1 **同总割数**、一半换成聚合族：**最锋利的一条对照** |
| C5 | `cap0 agg100` —— **只用聚合族且不截断上限**，用来把"族的贡献"与"上限截断"分开 |

根界（探针报出的 `bound`）：

| 实例 | C1 | C2 | C3 | C4 | C5 |
| --- | --- | --- | --- | --- | --- |
| `gt2` | 20 001.295 420 697 123 | **20 869.350 537 671 675** | 20 001.295 420 697 283 | **17 670.810 632 599 118** | 16 476.045 966 712 525 |
| `markshare1` | 0 | 0 | 0 | 0 | **0** |
| `markshare2` | 0 | 0 | 0 | 0 | **0** |
| `pk1` | 9.997 786 430 265 38e−13 | 1.072 924 491 794 916 8e−12 | 1.171 793 078 714 772 6e−12 | 1.006 823 709 178 528 6e−12 | 1.365 356 548 506 681 9e−12 |
| `noswot` | −43.000 000 000 044 | −43.000 000 000 044 01 | −43.000 000 000 051 49 | −43.000 000 000 044 02 | −43.000 000 000 044 29 |
| `blend2` | 7.033 425 128 571 356 | 7.033 704 897 150 538 | 7.033 425 128 566 59 | 6.936 795 748 384 19 | 7.053 861 300 007 692 |
| `22433` | 21 289.691 515 343 88 | 21 301.093 318 133 37 | 21 302.377 542 366 856 | 21 291.829 260 764 585 | 21 273.228 912 978 975 |
| `flugpl` | 1 171 748.830 758 916 2 | 与 C1 逐位相同 | 1 172 061.514 517 669 3 | 1 171 334.380 297 233 | 1 170 196.890 726 519 |
| `khb05250` | 103 698 570.725 383 91 | 105 549 805.165 106 8 | 103 738 247.271 550 97 | 101 755 165.378 208 62 | 96 455 334.000 076 92（`cuts=0`，整轮被丢弃） |
| `p0201` | 7 166.428 571 448 296 | 7 207.762 386 805 105 | 7 175.000 000 018 655 | 7 118.125 000 021 302 | 7 222.416 666 685 936 |

证明成本（`--max-nodes 20000` 下证到最优所需**节点数 / 墙钟**）：

| 实例 | C1 | C2 | C3 | C4 |
| --- | --- | --- | --- | --- |
| `flugpl` | 13 806 / 8.1 s | 13 806 / 8.0 s | 13 379 / 28 s | 13 129 / 9.6 s |
| `khb05250` | 305 / 19.7 s | **114 / 9.9 s** | 321 / 29.5 s | **2 116 / 160.9 s** |
| `p0201` | 586 / 29 s | 1 204 / 224.3 s | 525 / 50.5 s | 1 008 / 65.8 s |
| `22433` | 33 / 1.9 s | 35 / 4.2 s | 49 / 6.9 s | 47 / 2.5 s |

卡住组（`--max-nodes 2000`）的**当前解**（不是判据，只作旁证）：

| 实例 | C1 | C2 | C3 | C4 |
| --- | --- | --- | --- | --- |
| `gt2` | 44 073 | （无整数点） | **32 446** | （无整数点） |
| `markshare1` | 478 | 478 | **285** | 246 |
| `markshare2` | 192 | 192 | 700 | 555 |
| `pk1` | 36 | 29 | 31 | 58 |
| `noswot` | −34 | −37 | −36 | −37 |

**结论：回退（附四条读数）**

1. **那四个"根界抬不动"的实例仍然抬不动，而且这不是上限截断造成的。** C5 只用聚合族、上限放宽到 100（不截断），
   `markshare1` 两轮共加 **117** 条割、`markshare2` **126** 条，根界**仍然是 0**；`pk1` 仍在 1e−12 量级、
   `noswot` 仍在 −43.000 000 000 044 3 附近（三者与 C1 的差都在 1e−11 以内）。⇒ 这个族**本身**
   对这四个实例的根界没有贡献；换目标/伙伴/权重的规则不会改变这一点，C3 里它们的界与 C1 相同也由此得到解释。
   （这也是本轮唯一一条**不依赖截断**的结论：C5 是把族放开到全部候选后量的。）
2. **C4 是判决性对照**：与 C1 **同总割数**、只把一半换成聚合族 ⇒ 根界在 10 个实例里 **5 个变差**
   （`gt2` −11.7%、`khb05250` −1.9%、`blend2` −1.4%、`p0201` −0.7%、`flugpl` −0.03%），
   证明成本 **3/4 变差**（`khb05250` 305 → **2 116** 节点、19.7 → **160.9 s**）。而同族的 C2 拿到
   `gt2` +4.3% 与 `khb05250` 305 → **114**（9.9 s）：**赢的那一组割仍然是"同族多来几条"，不是"换族"**。
3. **墙钟时间在开聚合的配置下全线变差**：根模型更厚（每轮最多 20 条割）⇒ 每个节点都更贵
   （`p0201` 29 → 50.5 s、`blend2` 55.5 → 91.4 s、`noswot` 11.7 → 33.2 s、`markshare1` 0.9 → 19.4 s）。
   报告口径看的正是节点与时间，这条本身就该扣分。
4. 唯一的正面读数记在这里、但不当成收益：2000 节点口径下 C3 把 `gt2` 的当前解从 44 073 压到 32 446、
   `markshare1` 从 478 压到 285 —— 而同一个配置把 `markshare2` 从 192 抬到 700、`pk1` 从 36 抬到 58。
   方向不一致，且都不是本轮判据。
5. 因此按"没测出净收益的改动不留"**回退实现**（与第十六、十七轮同规）：出厂行为不变、**报告不必重跑**；
   设计（三个旋钮、权重集合、目标与伙伴的取法）与上面全部数字记在此处与 `docs/roadmap.md`，
   供下一轮换族时重建。**本轮没测的旋钮也写下来**：目标行的挑选规则、伙伴行的挑选规则、
   以及聚合池被上限截断时"保留哪 10 条"的次序（C3/C4 里 C5 已证明截断不是那四个实例的成因，
   但对 `gt2` 这类实例，截断次序仍有可能是 C3 没拿到 C2 那 +4.3% 的原因）。

**验收状态**：工作树与 `ef8f6d5` 逐字相同（`git status` 干净），出厂常量 `cut_round_cap = 10` / `aggregate_cap = 0`。
回退后重建并实测锚点：`gt2` 探针 `bound = 20 001.295 420 697 123`、`22433` **33** 节点 / `21 477.000 000 017 408`、
`markshare1` `cuts = 15` / `bound = 0` —— 与四份报告逐位相同，因此**报告仍然有效**（`bench/report.ps1` 读出 0 stale）。
另外记一条口径事实：本轮探针必须显式带 `--max-rows 300`，CLI 默认上限是 200，`blend2`（274 行）会被
`mip=skipped(rows=274 …)` 挡在门外 —— 用默认值量 `blend2` 会得到一条"看起来是数据"的空缺。

### Fixed — CI 的"钉住工具链"政策实测不可执行，改回 `latest` 并用三件事替代；同时完成 `test_unqualified_package` 迁移

**现象**：`56c6c7b` 推送后 CI 五个 job 全红，失败步骤都是 `install`，报错原文
`unsupported version: 0.1.20260904` —— 上一轮为了"钉住工具链"给 `setup-moonbit` 加的
`with: version:` 正是失败原因本身。

**排查（两条都读一手来源，不猜）**：

① **该 action 不接受版本号**。读它被钉住的那个 SHA 的源码：`normalizeVersion()` 只认
`latest | nightly | pre-release | stable | bleeding` 五个关键字（`stable`→`latest`、`bleeding`→`nightly`），
其余一律 `throw new Error("unsupported version: ${input}")`。它安装的方式是把官方脚本**用关键字**管道执行
（`curl -fsSL .../unix.sh | bash -s latest`；Windows 走 `MOONBIT_INSTALL_VERSION=latest` + `irm ... | iex`），
带版本号的 URL 只被 `fetchSha256` 用来算缓存键，且那里失败只 `warning` 不失败。
⇒ 通过这个 action 钉版本**在接口层面就不存在**。

② **绕开 action 自行安装钉版工具链也没有货**。官方安装脚本**确实**接受版本参数
（`unix.sh` 第 105 行 `version=${ARGUMENTS[0]:-${MOONBIT_INSTALL_VERSION:-latest}}`；
`powershell.ps1` 读 `$env:MOONBIT_INSTALL_VERSION`，用的是 `cores/core-$Version.zip` 并硬链 `moonx.exe`），
所以堵点不在脚本，在 CDN：**2026-09-21 探测 `cli.moonbitlang.com`，10 个版本号**
（`0.1.20260921/20/19/14/07/04`、`v0.1.…`、`0.1.20260920%2B7d59c7ec9` 等，覆盖 `binaries/` 与 `cores/`、
`.tar.gz`/`.zip`/`.sha256`）**全部返回 S3 风格的 `403 <Error><Code>AccessDenied</Code></Error>`**，
只有 `latest` / `nightly` / `pre-release` 返回 200；对 `latest` 发 `Range: bytes=0-0` 返回 **206**
⇒ **403 是 CDN 的策略，不是探测手法造成的假象**。没有可下载的钉版制品，"确定性"就无处可钉。

**决定：选项 A —— 回到 action 默认的 `latest`，用三件事替代钉版**（选项 B = 自装钉版工具链，被上面②否掉）：

1. **推送前在本机对着"已发布的那套工具链"验干净**，而不是让五个红 job 去发现。本轮把 `latest`
   （`moon 0.1.20260920 (914d7da 2026-09-20)` / `moonc v0.10.14+7d59c7ec9 (2026-09-18)` / `moonrun 0.1.20260920`）
   装进独立 `MOON_HOME`（`%TEMP%\moon-latest`，用 `Path` 前缀切换，不动系统 PATH）；
   下载的是 `binaries/latest/moonbit-windows-x86_64.zip` + `cores/core-latest.zip`，
   **压缩包 sha256 `faae225a8287d0ce69e44b5b3f754af988e97f4446056d8f32ceb3ddb998fce7` 与官方公布值一致**，
   随后在 `lib/core` 里跑安装脚本自己会跑的两条 `moon bundle`。
2. **每次运行把工具链写进注解**：两个 job 各加一条 `::notice title=toolchain::$(moon version --all)`。
   理由是本轮再次确认的那条事实 —— **job 日志要鉴权才读得到，annotations 匿名可读**，
   而一套不钉版又不记录版本的工具链，留下的是一份**没人能归因**的结论。
3. **报告用 CI 所跑的那套工具链重生成**（四份报告本来就在头部记录 `- Toolchain:`，
   `bench/report.ps1` 还会在几份报告的工具链行不一致时写出 `(reports disagree)`）。
   上一轮"钉版本"的第二个理由正是"报告与 CI 要说同一件事"，在无法钉版的前提下，
   唯一能满足它的做法是让报告跟着 CI 走。

**迁移本身（上一轮留下的第二项）**：权威清单不是 grep 估的，是**本机用新工具链跑一次不带
`--deny-warn` 的 `moon check`** 得到的 —— **81 条 `[0025] test_unqualified_package`、0 错误、3 个文件**。
本轮替换 **81 处**：`mip/mip_test.mbt` 58、`mip/cuts_test.mbt` 19、`simplex/differential_test.mbt` 4
（`@mip.MipStatus/MipOptions/MipResult/NodeStatus/NodeSolution/solve_mip`、`@simplex.solve_model/solve_standard`）。
**上一轮"5 个文件约 144 处"的估计过宽**：`model/model_test.mbt`、`verify/verify_test.mbt`、`verify/cuts_test.mbt`
一条警告都没有（它们引用的是别的包，本来就写着 `@pkg.`）。替换用带词边界与 `(?<![\w.@])` 反向断言的正则，
所以 `@oracle.solve_standard` 这类已限定的引用不会被二次前缀化，`solve_mip` 也不会误伤更长的同前缀标识符。

**验证（两套工具链都跑，数字如下）**：

| 项目 | `0.1.20260920`（CI 将跑的） | `0.1.20260904`（开发机默认） |
| --- | --- | --- |
| 清 `_build` 全量 `moon check` | 38 个任务，**0 警告 0 错误** | — |
| `moon check --deny-warn` | 退出码 **0** | 退出码 **0** |
| `moon test`（native / wasm-gc / js） | **172/172** ×3 | native **172/172** |
| `moon fmt` 后 `git diff` | 空 | **与新版输出逐字节相同**（三个文件 SHA256 逐一比对） |
| `moon info` 后 `.mbti` | **一行未变** | `git status` 只有那 3 个测试文件 |

`fmt`/`info` 两行是本轮专门量的 CI 风险点：`format diff` 与 `info check` 两步都靠 `git diff --exit-code`，
只要新工具链重排一行源码或改一行 `.mbti`，**在零语义改动下也会红**。实测两版排版一致、`.mbti` 不动，
所以这两步在 `latest` 上过得去，也不需要提交任何重生成产物。

**两处过程事实（值得长期记住）**：

① **编辑器缓存可能给出与磁盘不符的文件内容**。本轮 `read` 工具渲染 `mip/mip_test.mbt` 时给出了一份
**旧版内容**（含磁盘上并不存在的 `with_node_hook(solve_node=…, verify_node=…)` 与
`solve_mip_with_failing_node`），而真实 API 是 `with_node_solver(…)`。三处独立读法互相印证才定案：
`[IO.File]::ReadAllBytes` 量出 603 行纯 LF、`Select-String` 的行列号、以及**编译器自己报的行列号**
（`435:16`、`436:16`、`437:28` 与磁盘一致，与那份渲染不一致）；`git hash-object` 也确认磁盘 == `HEAD`
（blob `544de71`）。⇒ **动手改之前，用第二个读法核对将要匹配的字面串**；`edit` 按字面匹配（不按行号）
正是这次没被带偏的原因。
② **增量缓存会让"0 警告"变成空话**。改完第一次 `moon check` 报的是 `ran 2 tasks, now up to date`
（缓存命中、没有真正重编译，那份"0 警告"不作数）；**删掉 `_build` 全量重编译**得到 `ran 38 tasks` 且
摘要行**不再带 `(81 warnings, 0 errors)` 后缀**，才是证据。CI 每次都是全新 checkout，天然全量。

**四份报告的重生成（本轮的账）**：测试文件在四份报告各自声明的依赖路径下（`mip`、`simplex`），
所以这次改动让它们**全部陈旧**，重跑是本轮必须付的账 —— 合计 **约 37 分钟**
（parse 在提交代码前跑过；solve **107 s**、mip **859 s**、mip-small **1253 s**、索引 1 s、
`check-mip-objectives` 2 s）。三件事值得记：

1. **用的是 CI 将跑的那套工具链**（`0.1.20260920`），装在独立 `MOON_HOME` 里，
   它的可执行路径**逐字写进每份报告的 `- Toolchain:` 行**（不改写成好看的形式：那一行的作用是可追溯）。
2. **数字逐项未变**：parse 32 attempted / 32 parsed / 0 failed；solve **18 最优 / 11 行上限 / 0 规模拒绝 /
   3 迭代上限**；mip（300 节点）**1 最优 / 14 节点预算 / 17 跳过 / 4231 个松弛过 `verify` / 266 条割过
   `verify_cut` / 10 个整数点**；mip-small（20000 节点）**4 最优 / 6 节点预算 / 134601 过校验 / 176 条割 /
   9 个点**。与旧工具链那四份逐行 diff：**每份只差 4 行头部**（`from commit` 与 `- Toolchain:`），
   **一行数字都没变**；索引里四行报告条目也只差 commit 号，四个单元格全是 `current`。
3. `bench/report.ps1` 读出 **`4 report(s), 0 stale`**；`bench/check-mip-objectives.ps1` 现下载官方
   `miplib2017-v26.solu`（773 条）比对：**`22433` proven `21477.0000000174` == 公布最优 `21477`，
   violations 0**（这条检查要求的是**相等**，不是不等式）。

"两版工具链、同一份代码、四份报告数字逐行相同"是一次**实测**而不是推断，但**能说的范围只到这里**：
它证明这一轮的迁移没有改变任何运行时行为（本该如此 —— 改的是黑盒测试里引用被测包的写法），
也说明报告数字对"哪一版编译器在跑"不敏感；两版之间只隔一个编译器小版本，
**不能推广成"任何工具链都不影响数字"**。反过来说，钉版本真正能买到的只有"编译期警告口径的稳定"，
而那件事现在由**推送前的本机全量检查**与**注解里的工具链记录**覆盖。

### Fixed — 新工具链的 E0079：把 trait 方法的隐式提升写成显式声明（含完整的排查链）

**现象**：推送 `cf7667a` 后 CI 五个 job 全红，失败步骤是 `moon check --deny-warn`（三个平台）与
`moon test --deny-warn`（wasm-gc / js）—— 而**同一棵树在本机跑这三条命令都是绿的**（上一次 CI 绿灯在同一天 03:26）。

**排查链（每一环都有实测依据）**：
① 用 GitHub REST API 读 run/job 列表，先把"哪个 job 的哪一步失败"钉死（不是本轮新加的那一步：它在前面失败后被 skip）；
② job 日志要鉴权才读得到，**但 workflow command 会写进 run 的 annotations，而 annotations 可以匿名读** ——
于是先让失败步骤**把自己的输出与工具链版本写成注解**（这本身是一条该留的 CI 改进：以后 CI 失败会自己说清原因）；
③ 注解直接给出了答案：CI 装的是**当天发布的 `moonc v0.10.14+7d59c7ec9`（2026-09-18）**，报
`Error: [0079]` / `implicit_impl_as_method` —— 这是一条**默认开启的警告**（`--deny-warn` 把它变成失败），内容是
"`impl Trait for Type` 的方法被隐式提升为 `Type` 的普通方法"这一兼容行为已废弃；
④ 官方文档（E0079）要求的迁移，与 MoonBit core 自己的做法一致：把提升**写成显式声明**
（`pub extend T with Trait::{method}`，core 集中放在各包的 `extends.mbt` 里）。

**结论：与本次内核改动无关**（本机工具链 0.10.12 根本不报这条；仓库里此前也没有任何一行需要改）。
"哪些类型需要声明"不是猜的，是查出来的：core 的**58 个非 priv 派生类型全部**有该声明、
**19 个 priv 派生类型一个都没有**（priv 类型不进接口、没有东西可提升）⇒ 规则 = **每个非 priv 派生类型，
按它派生 trait 的每个方法一条声明**。

**改法**：9 个包各新增 `extends.mbt`（与 core 的惯例一致，迁移集中可审计），共 **31 个类型、51 条声明** ——
`Debug::{to_repr}` 31 条，`Eq::{equal}` 与 `Eq::{not_equal}` 各 10 条。每条都带 **`#doc(hidden)`**：
它们是 trait 自己的方法、不是新的承诺，`moon info` 实测 **`.mbti` 一行未变**，行为按构造不变
（原来隐式发生的提升，现在写出来）。需要该 trait 的包在 `moon.pkg` 里补了 `moonbitlang/core/debug`（标准库）。

**这个过程被 CI 纠正了两次，两次都值得记下来**：
① **Eq 的每个方法都要单独一条**：第一版只写了 `Eq::{equal}`，CI 的注解直接指出漏的是 `not_equal`；
② **我自己的核对脚本有盲区**：它从类型声明只向后看 30 行找 `derive(...)`，而 `derive` 坐在结构体的**右花括号**上 ——
`KernelProblem`（67 行体）、`SimplexOptions`（96 行）、`MipResult`（31 行）因此被漏掉，CI 又指出前两个。
改成"一直走到右花括号"之后，核对结果是 **31 类型 / 51 条声明 / 零缺失 / 零多余**。
（读 CI 报错的路径也再确认了一次：**run 页面的 HTML 里带 annotations，无需鉴权、也不吃 API 限流**。）

**这一次还暴露了同一批工具链要求的第二项迁移（已量清、留作下一轮）**：`test_unqualified_package` ——
黑盒测试（`*_test.mbt`）里引用被测包的类型要显式写 `@pkg.Name`，编译器原话是
"``MipStatus`` is implicitly imported in test. Use `@mip.MipStatus` instead."。
按"每个包的公开类型 × 该包的黑盒测试文件"在本机量出的规模是 **5 个文件、约 144 处**
（`mip` 55、`verify` 87、`model` 2）。**它没有和这一轮一起改，原因写清楚**：测试文件在四份报告的依赖路径里，
改它就要重跑全部报告（约 40 分钟），而"重跑是那一轮的账"—— 这项单独成轮更干净。

**CI 政策改动（本轮最重要的结构性修正）**：`setup-moonbit` 的版本输入由默认的 `latest` 改成
**显式钉住 `0.1.20260904`**。理由就是这次事故本身：跟踪 `latest` 意味着**一次编译器发布会在一行代码都没改的情况下
让五个 job 全红**；而且四份报告各自记录了产出它的工具链版本，**CI 应当测的正是那个版本**，
否则报告与 CI 说的不是同一件事。升级版本从此是**单独一轮**的事。
失败步骤现在还**额外跑一次不带 `--deny-warn` 的检查**，把它打印的**全部**警告写成注解
（`--deny-warn` 在第一个有警告的包上就停下、只报一个包；不带它则编译全部、列出所有警告）——
下一次升级工具链时一轮就能看到完整清单，而不是像这一次一轮一个包。

**两处值得长期记住的事实**：① **CI 的严格模式会掩盖后续错误**（第一个失败包之后的东西不再检查），
所以排查必须拿到"完整的"那份输出；② 读 CI 报错的可行路径是 **annotations**
（run 页面的 HTML 里就有），不是 job 日志（要鉴权）、也不是 API（有匿名限流）。

### Changed — M5 增强轮（自检开销）：先核对判据，再把量出来的那处开销收回去

本轮的判据原本是"**同一命令同一参数**下把 `bench/solve-report.md` 的 `solved to optimality`
从 18 提回 ≥20（历史值）"。**动手前先核对，判据本身站不住** —— 三条证据都是仓库自己的记录：

1. 写着 20 最优的那份报告（提交 `747de9e`）里 `fast0507` 记的是 **20000 个枢轴**（`danoint` 1761、
   `mod010` 12827，两个都超过 1200）—— 而 1200 才是上限。**那份报告是在 `report-solve.ps1` 的默认
   上限 `-MaxIterations 20000` 下生成的**，与 `bench/report.md` 公布的复现命令
   （`-Relax -MaxRows 1000 -MaxIterations 1200 -Presolve`）不是同一场实验。
2. 报告历史逐份核对（`git log --follow bench/solve-report.md`）：**上限 1200 的每一份都在 16~18**
   （`8b87763` / `0463751` / `7253034` 是 16、`27a22b4` 与当前是 18），**上限 20000 的每一份都在 19~20**
   （`f8ef7d2` / `2093c1c` 19，`3e370b8` / `a64169a` / `c5f9266` / `3713a1a` / `747de9e` 20）。
   也就是说 **"18"在 1200 上限下从来没有是过 20**。
3. `27a22b4` 的提交信息自己写着 "Same settings as before (LP relaxation, row limit 1000,
   **1200 iterations**, presolve)"，那一轮的口径就是 **18 最优 / 3 迭代上限**，与现在结构相同。

所以本轮**不去追那个数字**（1200 上限下 20 不可达，而且原因不是自检：实测把防停滞闩死的开销**整个**
拿掉，`mod010` 仍需 2693 枢轴，是上限的 2.2 倍），而是把"18 vs 20"查清、把让这件事没被发现的**报告缺陷**
修掉，再把量出来的开销收回去。

**报告缺陷（已修）**：`bench/report-solve.ps1` 的头部记录行数上限、**却不记录枢轴上限**，
于是两份上限不同的报告摆在一起时，没人能从报告本身看出它们不是同一场实验（这正是上面第 1 条的成因）。
现在头部写 `pivot limit <N>`，并写明"`pivots` 列等于上限的实例就是在那里停下的"。

**顺手把这条判决接进 CI**（队列里的"小项"）：`.github/workflows/check.yml` 新增一步
`benchmark reports are current` —— 跑 `bench/report.ps1` 到临时文件，然后**读索引文件里的表格单元格
`STALE (`** 并让 CI 红；为此把该 job 的 `actions/checkout` 改成 `fetch-depth: 0`
（判决要 `git diff <报告记的提交>..HEAD`，浅克隆答不出来）。
**这一步在本机按"跑本体"的方式验了两个方向，并因此抓到两个会让它一直红的写法**：
① 按裸词 `STALE` 搜全文 —— 索引的**解释性段落**里本来就有这个词，健康的树也会命中；
② 把脚本的控制台输出捕获下来解析 —— `report.ps1` 的摘要是 `Write-Host`，**不走管道**，捕获到的是空串。
现在还加了"退出码非零"与"索引文件存在"两条门禁（否则脚本失败会被读成"没有陈旧的报告"）。
负例用本轮改完内核、mip 报告还没重跑时抓下的索引实测（检出 2 个 `STALE (` 单元格、会抛错），
正例实测 `0 stale` 通过。**CI 本身在本机跑不了**，所以"在 CI 上生效"这一句没有实测证据，
只有本地同逻辑 + YAML 解析通过。

**先测再改：每条自检的开销（短路后同参数对比；native release、20 个 ≤1000 行实例、上限 20000、
presolve+relax、同一台机）**

| 短路掉的自检 | 实测（基线：总枢轴 24 408、wall 37.8 s） | 结论 |
| --- | --- | --- |
| 每枢轴的无条件对偶精化（`refine_duals`） | **16 778 枢轴（−31%）**、26.3 s | 轨迹上有值，但**按已发布口径是净负**（见下），回退 |
| 每迭代的方向残差自检（`direction_residual`） | **24 396 枢轴（−0.05%）**、38.3 s | 时钟与枢轴都在噪声内 ⇒ **保留原样** |
| 枢轴稳定性校验（`weak_pivot`） | 20 个实例**逐位相同** | 本清单上一次都不触发 ⇒ **保留原样** |
| 防停滞窗口（`stall_window`，禁止切 Bland） | **11 574 枢轴（−53%）**；`mod010` 15 527 → 2 693，其余 19 个逐位不变 | **真正的开销在这里** ⇒ 本轮修它 |

**每枢轴的时钟成本测不出来**，这一条改变了本轮的判断：基线 37.8 s / 24 408 = **1.55 ms/枢轴**，
短路掉对偶精化后 26.3 s / 16 778 = **1.57 ms/枢轴**（同一噪声带内）。一个迭代的开销由**增益定价**决定
（默认每迭代 32 次候选增益测量，每次都含一次方向求解 + 一次比值检验），任何一趟自检扫描都在它之下 ——
所以**这几条自检的代价不在时钟上，而在"它们把运行送上哪条枢轴路径"上**。

**默认上限下的全清单实测（同一份 manifest，只把上限换成 20000）**：**20 最优 / 11 跳过 /
1 迭代上限（`fast0507`，20000 枢轴后内核目标值 164.252 869 961 398）/ 总枢轴 12 336 / 373 秒** ——
这就是"20 最优"的现行实测（`danoint` 1907 枢轴、`mod010` 3455 枢轴，闩死解除前是 15 527）。
报告的发布口径没有改（仍是 1200 上限，见 `bench/report.md` 的复现命令）：**改的是报告头部的记录**
与文档里对这个数字的解释。重跑后的 `solve-report.md` 与上一份**只差两行**（生成提交、Mode 行新增
的枢轴上限），其余 68 行逐字节相同 —— 18 最优 / 6974 枢轴 / 21 个过化简 / 18 个重建解过三项检查
一格未动，`check-relaxation-bounds.ps1` 照旧 18 项 0 违反。

**文献对照（时间盒内做完，结论是"不照搬"）**：成熟实现对这类精度自检的通行做法是**检查频率**
而不是每迭代 —— MINOS 的 `Check Frequency`（默认 60）与 NAG `e04mf` 的
"every i-th minor iteration after the most recent basis factorization, a numerical test is made" 都是
"每隔 k 次迭代量一次、量出来不合格才重建"，其余迭代只留便宜的局部判据（主元元素相对阈值）。
本项目已有那条局部判据（`weak_pivot`），但**实测表明把它之上的整段精度自检降频在本清单上买不到东西**：
短路方向残差自检的收益是 −0.05%（噪声），而 `weak_pivot` 一次都不触发。按纪律"没测出收益的改动不留"，
**没有为此引入检查频率选项**（那会多一个要论证的阈值，换不到一个可测的收益）。
（许可与出处均只读公开文档：MINOS/NAG 属商业求解器的公开手册，只作做法参照，未取任何代码。）

**落地的修法（`simplex/revised.mbt`）**：防停滞的兜底一旦触发**再也不解除** —— `bland` 置真之后
就是永久的 Bland 定价。实测后果是 `mod010` 的 15 527 个枢轴里绝大多数是 Bland 枢轴。改成
**目标重新有改进（`stalled` 归零）就解除闩死**。这不放松防循环保证：**循环是零改进**，
所以它会在 `stall_window` 个枢轴内重新触发闩死；而以 Bland 起步的**恢复尝试**（`start_with_bland`）
保持闩死 —— 那次运行的整个目的就是换一条路，解除它等于把它交回刚刚失败的那条路。

实测（同一 20 实例清单、上限 20000）：**总枢轴 24 408 → 12 336（−49%）**，wall 37.8 → 25.4 s；
`mod010` **15 527 → 3455**（4.5×）、目标值 6532.083 391（与另一条路径差 8.8e-9 相对，是另一个最优顶点），
**其余 19 个实例逐位不变**。**已发布口径（上限 1200）逐位未动**：`mod010` 在 1200 枢轴上的内核目标值
仍是 5544.518 537 565 392，18 最优 / 6974 枢轴的报告数字不变（报告因头部改动重跑，见下一节）。

**同一条线上被否掉的候选（两个上限的数字都留下）**：把每枢轴的无条件对偶精化改成"只在下结论处精化"
（定价用未精化乘子，宣布最优前重新精化并把定价问题**再问一次**）。它在**默认上限**下是净赚的
（−31% 总枢轴，`mod010` 15 527 → 7 712），凭证也在（10 个实例 `verify` 全 `accepted`），
但**在已发布口径上是净负**：`22433` 的路径变长 24%（1035 → 1282 枢轴），于是它在 1200 上限下
从 `optimal` 变成 `iteration-limit`，报告的 `solved to optimality` 会从 18 掉到 **17**。
按"没测出收益、只测出代价的改动不留"回退，工作树回到该改动之前；数字与设计形状记在这里供重建。

**验收**：`moon check --deny-warn` 干净；**172 个测试在 native / wasm-gc / js 三目标全绿**；
`.mbti` **无变化**（改动全在包内私有函数与一个已存在的选项语义上，公开面未动）；
10 个实例的证书仍全部 `accepted`（对偶间隙 2e-13 ~ 4.4e-9）；M5 的四个可证最优实例
**节点数与已发布报告逐位相同**（`flugpl` 13806、`khb05250` 305、`p0201` 586、`22433` 33，
目标值不变、`verified == nodes`）。

**MIP 侧在同一预算下量到的收益（300 节点口径、全 manifest 重跑，逐项对比上一份报告）**：
报告里**只有 `mod010` 一行变了，而它变了整整一类**。旧报告那一行是
`node-limit / 目标空 / 界 0 / gap 1e+300 / nodes 1 / verified 0 / cuts 0`，注 `1 node relaxation(s) did not finish`
—— **它的根松弛自己就没跑完**（闩死后用 Bland 定价，5000 枢轴的松弛上限买不到一次结论，而该实例需要
15 527 个枢轴），所以整棵树既没有 incumbent 也没有界；新报告是
`objective 6549.000002408991 / bound 6540.400000106969 / gap 8.600002302022403 / nodes 300 / verified 299 /
cuts 10`，点复核 `feasible and whole`（行/界/整数性违反 ≤ 2.7e-9）。
总数因此变成 **过校验的松弛 3932 → 4231、过 `verify_cut` 的割 256 → 266、点复核 9 → 10**，
而 `1 最优 / 14 节点预算 / 17 跳过 / 0 其他` 的结构未变。**这条闩死不只是在 LP 上多花枢轴 ——
它让一个实例在 MIP 报告里整行没有信息。**

**本轮没做的（写下来而不是藏着）**：队列里的 ①cutoff 传播、②不同的割族（多行 MIR 聚合要用行序对照重测 /
节点上的割）、③让"没有证明可写"的最优基保住界、④Farkas 射线的构造、⑤扩大覆盖面，
以及 ⑥`cmd/parse --json` 的 `--reoptimize` 镜像 —— 都**没做**。
⑥ 尤其说明一下：`cmd/parse` 在四份报告的依赖路径里，动它就要重跑四份报告，所以它不该和别的改动挤在一轮里。
本轮另外做了两件与算法解耦的：本文件上面那条 CI 步骤，以及 `docs/api.md` 的状态码审计
（对照 MathOptInterface，发现**一处实质不对称**：线性侧的枢轴上限没有专门状态、和"数值失败/非法模型"
一起被并进 `NotSolved`，而整数侧的节点预算有 `NodeLimit`）与 `docs/ecosystem-survey.md`
（外部生态横评，逐条标许可证状态；VIPR 的三个问题给了**待核对**的答案骨架）。

### Changed — M6 第四轮（发布准备）：四份基准报告在当前代码上重跑，并查出"报告条目数与数据清单不符"的根因

上一轮的 `bench/report.md` 把四份报告全标成 `STALE`（其中 `parse-report.md` 落后十几轮、
`solve-report.md` 停在 `315107f`）。这一轮把它们**在当前代码上重跑**，并把重跑时暴露出来的问题查到底。
按用户要求，本轮**不发布**。

**重跑（提交 `4891704` 的树，命令就是 `bench/report.md` 里写的那几条）**：

| 报告 | 新口径 | 与旧报告的差异 |
| --- | --- | --- |
| `parse-report.md` | **32 attempted / 32 parsed / 0 failed** | 旧报告写 `33 attempted / 33 parsed`，且总计多 521 个变量 |
| `solve-report.md` | **18 最优 / 11 跳过上限 / 0 规模拒绝 / 3 迭代上限 / 21 个过化简 / 18 重建解过三项检查 / 0 次 Bland 恢复 / 6974 枢轴** | 旧报告写 20 最优 / 21 重建 / 1 个迭代上限 |
| `mip-report.md` | 32 实例 / 300 节点：**1 最优 / 14 节点预算 / 17 跳过 / 0 其他 / 3932 个松弛过校验 / 256 条割 / 9 个点复核** | 旧报告停在 `3ee797a`；重跑后**逐项相同**（第 11~15 轮改的是"证书被拒/写不出"那两条路径，在这条 300 节点口径上一次都没触发） |
| `mip-report-small.md` | 10 实例 / 20000 节点：**4 最优 / 6 节点预算 / 134601 个松弛过校验 / 176 条割 / 9 个点复核** | 旧报告停在 `4a4e002`；重跑后**逐项相同** |

重跑之后 `bench/report.ps1` 的陈旧性判决变成 **4 份报告、0 份 `STALE`**（此前是 4/4），
即仓库里每一份报告都被当前代码支撑；两份对拍脚本同时通过
（`check-relaxation-bounds.ps1` 18 项 0 违反、`check-mip-objectives.ps1` 主报告 1 项取等与小报告 4 项取等、
两者 `unverified nodes 0`）。

`check-relaxation-bounds.ps1` 对新求解报告 **checked 18 / violations 0**（18 项松弛值全部 ≤ 官方最优值）。

**查出的根因（"33 vs 32"不是玄学）**：`bench/fetch-instances.ps1` 的实例清单里 `"danoint"` **被列了两次**
（第 20 行与第 28 行），而 manifest 是**逐条**从 `$downloaded` 写出来的 —— 清单里的重复于是变成 manifest 里的
重复行，**每一份读 manifest 的报告都把那个实例处理两遍、把它对总计的贡献翻倍**。
算术能指名道姓：旧报告总变量 `173934`、新报告 `173413`，差 **521**，而 `danoint` 正好是 **521** 个变量
（约束 44480 vs 43816、非零 1651814 vs 1648581 同样对上）。修法两处：删掉清单里重复的那一条，
并在遍历前**去重**（`$Instances | Select-Object -Unique`），注释里写明"清单里的重复会变成 manifest 里的重复行"。
**这条缺陷影响的是全部四份报告的条目数与总量**，只是解析报告的数字最显眼。

**一处当时解释错了、后来查清的口径**（原文："求解报告的 20 → 18 不是数据变了，是**同一个 1200 枢轴上限
买到的进度变少了** —— 第三轮的方向自检、第九轮的 Phase I 每次求解一次重建因子、第十五轮的对偶侧自检
都在这个上限里计费"）：**这个解释与仓库自己的记录不符** —— 20 最优的那份报告用的是 20000 上限
（它的 `fast0507` 记着 20000 个枢轴），而 1200 上限下的每一份报告从来都是 16~18 最优；现行正文见本文件顶部
"自检开销"一节（三条证据 + 逐份报告的上限对照）。当时把 README 里的"20 个 LP 松弛求到最优"、
"20 项交叉校验"改成报告上的 18、并把 3 个迭代上限点名（`danoint` 内核目标值 22.473、
`mod010` 5544.519、`fast0507` 人工和 99.231）**这一步本身是对的**（README 的数字必须与已发布报告一致，
"33 成功"改成"32 成功"同理），错的是给出的原因，以及**报告头部没有记录枢轴上限**（已修）。

**元数据终审（发布前必须过的那一关，逐条对证据）**：

- `license = "Apache-2.0"` ←→ 仓库根有 `LICENSE`（11358 字节）✓；
- `readme` / `repository` / `name` / `version` 与仓库、README 一致 ✓；
- **依赖边界**（README 声明的"库包不依赖任何第三方包，只有 `cmd/parse` 用 `moonbitlang/x`"）逐包核对：
  `core` 无依赖、`model` 只依赖 `core`、`verify` 只依赖 `core`/`model`/`moonbitlang/core/string`、
  根包只依赖仓内包，只有 `cmd/parse` 引入 `moonbitlang/x` 的 `fs`/`sys`；
  `simplex` 对 `oracle`/`verify` 的依赖带 `for "test"` / `for "wbtest"` 条件（**不是**发布依赖）✓；
- **CI 承诺** 与 `.github/workflows/check.yml` 逐条对上：`moon check --deny-warn`、
  `moon fmt` + `git diff --exit-code`、`moon info` + `git diff --exit-code`、`moon test --deny-warn`，
  另有 wasm-gc / js 的矩阵任务 ✓；
- `description` 的四项能力（MPS/LP 互操作、带 presolve 与对偶热启动的稀疏修正单纯形、整数模型的分支定界、
  可独立校验的三类证书）**每一项都有实现与证据**——这条曾经是明确的"承诺 vs 证据"违例
  （2026-09-16 记下的三项尚不存在的能力），现在已随实现补齐，属**已结清**。

**发布本身按用户要求推迟**；顺带测到一个事实：`moon publish --dry-run` 在本机**无法在未登录时校验元数据**
（`failed to open credentials file: <moon home>/.moon/credentials.json, please login first`），
所以发布那一步必须先 `moon login`；本轮**没有上传任何东西**。

### Added — M6 第三轮（C）：公开 API 契约与算法说明两份文档 + README 的"承诺 ↔ 证据"对照表

M6 的交付物里有"API 文档、算法说明"这一条，完成标准里有"README 的每条承诺都有可复核证据"这一条。
这一轮把两件事都落成文件，而且**只写代码里已经成立的事**：

- **`docs/api.md`（公开 API 契约）**：逐包写清对外承诺什么、什么情况下返回哪个状态、哪些字段在哪些状态下
  有意义——顶层入口的 MIP 取舍（整数模型不化简、只有搜索证明过的结论才成为公开状态、三类情况一律 `NotSolved`）、
  `simplex` 的 `Err` 与 `SimplexStatus` 的分工（前者是"模型不在内核受理范围内"，后者是"没解出来"）、
  `NumericalFailure` 意味着"有答案但证据不成立时选择不报"、热启动"只是提速永不给出不同答案"、
  `presolve` 的"还原必须被独立复核"、`verify` 的乘子约定与"不依赖 simplex"、
  `mip` 的九条规则表（含每条规则的证据指向）、CLI 的退出码口径。
  每条规则后面都指到证据：测试文件、报告里的哪一列、或一条能重跑的命令。
- **`docs/algorithms.md`（算法说明）**：写实现里真正在跑的是什么——标准形变换表、"上界不是行"为什么值得单说
  （枢轴数随行数走、与列数几乎无关）、修正单纯形（稀疏 LU、乘积形式更新、有界变量枢轴、增益定价、
  Harris 比值检验、退化扰动、Bland 兜底）、**自检清单与它们各自量过的那次事故**、对偶单纯形与加行热启动、
  presolve/postsolve、割平面（有效性如何被独立检验 + 为什么值得这么多防线）、分支定界（best-bound、下潜的两条
  实测判断、割只在根、`verified` 与"无结论"的分工）、证书与校验（含 Farkas 射线**仍未验证**这条限度）、
  数值策略摘要。每一段都以一条可复核的数字结尾，来源标到 `CHANGELOG.md` 的轮次或 `bench/*.md`。
- **README 新增"承诺 ↔ 证据"对照表**（15 行）：把可检验的说法逐条指到证据上（报告的哪一列、哪条命令、
  哪个测试文件），并**在同一行写出限度**（例如"该报告当前被标为 `STALE`，重跑后才是当前代码的证据"、
  "另外 6 个实例到节点预算"、"Farkas 射线未验证"、"尚未发布"）。
  README 的"项目结构"一节同时补了 `docs/api.md` / `docs/algorithms.md` 两个入口，并修正了两处已过期的表述
  （`simplex` 还写着"对偶单纯形待补"、`cmd/parse` 还只写着"巡检 CLI"、`mip` 还写着"受范围闸门约束"）。

**这一轮没有改任何代码**：172 测试在 native/wasm-gc/js 三目标仍全绿，`moon check --deny-warn` 干净，
`.mbti` 无变化。

### Added — M6 第二轮（B）：CLI 子命令 `parse` / `solve` / `verify` / `fmt` / `bench` + 统一 JSON 输出

M6 的交付物第二条是"CLI 完整：`solve` / `verify` / `fmt` / `bench`，统一文本与 JSON 输出"。
在此之前 CLI 只有一面旗标墙（`cmd/parse -- <file> --mip --max-nodes ...`），没有动词、没有机器可读输出。
这一轮把两件事都补上，并且**动都没动老的文本输出**——`bench/` 里四个脚本解析的就是它、四份报告也是它生成的，
改它等于作废证据。

- **子命令**：`moon run cmd/parse -- <verb> [flags]`，动词表是 `parse` / `solve` / `verify` / `fmt` / `bench`；
  第一个参数不是这五个名字时就是老的旗标形式，逐位不变（实测 `--mip --max-nodes 20` 的输出与改动前一致）。
  旗标继续生效并**特化**动词：`solve --mip` 是分支定界，`solve --verify` 走证书校验，`bench --relax` 是逐实例松弛。
- **`fmt` 动词**：读模型再写出来（`--format mps|lp`，缺省用读进来的那个格式；`-o <path>` 指定输出）。
  写出去的是**库自己的 writer**（`@format.write_mps` / `write_lp`），不是为 CLI 另写一个格式化器。
  默认**打印到 stdout 且不写任何文件**——裸 `fmt <file>` 就地改写是这条动词唯一可能弄丢工作的方式；
  `-o` 只接受一个输入（一个输出路径装不下两个模型），非法用法退出 2。
- **统一 JSON 输出**（`--json`）：一份文档 = `{"tool","command","files":[...],"summary":{...}}`，
  每个文件一个对象，字段按运行产出（解析事实、`mode`、`status`、`objective`、`iterations`、
  `nodes`/`verified`/`cuts`/`bound`/`gap`、`presolve_*`、`point{row_violation,bound_violation,integrality_violation}`、
  `claim`/`accepted`/`reason`、`message`、`failed`）。**没跑出来的字段不出现**，而不是填 0 ——
  一份说"没有状态"的文档不会被误读，一个 0 会被当成数字。
- **语义与文本路径逐条对齐**：`refused`（模型不在内核受理范围内）**不算失败**、退出码仍为 0（与文本路径一致；
  实测 `bench` 在整数模型上逐条 `solve=refused` 而 `failed 0`）；证书被拒、点没通过复核、解析失败、
  证书读不出来才是失败。
- **`--json` 显式拒绝它还没镜像的东西**：`--json --reoptimize` 退出 2 并说明原因（那个旗标存在的意义就是
  打印一次测量，文档里少了它就是在回答另一个问题）。这条比"悄悄给一份缺字段的文档"重要。
- **数字格式**：JSON 里的数字用**最短往返表示**（`Double::to_string`，与 `@format.format_number` 给模型文件的
  是同一条政策），不做四舍五入 —— 取整会让下游拿到的报告数字与报告本身不符。这一条同时回答了 roadmap 里
  "定点数值格式化"：CLI 的数字政策是**有文档的、可精确回读的**表示，而不是一个有损的有效位约定。
- **实测**（native release）：`solve --relax --json` 给出 `objective=1167185.7255927906`（与文本路径的
  `solve=optimal obj=1167185.7255927906` 逐位一致）；`solve --mip --json` 给出
  `mode=mip status=node-limit nodes=20 bound=1171718.464052804`；`solve --presolve --json` 给出
  `presolved=true verdict=reduced rows_after_presolve=16 point.row_violation=2.65e-13`；
  `verify --json` 给出 `claim=optimal accepted=true`；每一份都用 `ConvertFrom-Json` 验证过是合法 JSON。
  修掉一个实测缺陷：JSON 路径最初经过会打印的读文件助手，一行 `cannot read ...` 落在文档中间 → 文档不可解析
  （`--json` 因此只用**静默读**）。
- **测试**：新增 `cmd/parse/main_wbtest.mbt` 8 条（动词表、JSON 转义、解析事实来自模型、parse 文档**不带**
  `status`/`objective`、solve 文档只报它真有的字段、不可读文件是失败文档、文档外壳与 summary、数字不取整）。

### Added — M6 第一轮（A）：`bench/report.md`，全部基准报告的入口 + "报告是否仍被当前代码支持"的机械化判定

M6 的完成标准第一条是"报告可由脚本一键复现"，而在此之前仓库里没有任何地方能回答一个更基本的问题：
**这四份报告里哪几份还是当前代码的证据**。`report-mip.md` 依赖的手写记录、`bench/README.md` 里的散落说明，
都在人手里而不是在机器里。这一轮把它变成机制：

- 新脚本 `bench/report.ps1`（ASCII、PowerShell 5.1）生成新报告 `bench/report.md`：四份报告的范围、
  从各自 Summary 表里**读出来**的关键数字、生成时的提交，加上按顺序的一键复现命令，加上每份报告的
  **陈旧性判决**——读报告自己记下的提交，把该报告依赖的源路径在 `那个提交..HEAD` 之间做差分，
  有改动就标 `STALE` 并列出改动文件（最多 3 个）。
- 判据故意偏保守：**改了注释也算改**（注释不可能动数字）。方向要偏安全——只在"可能不再描述当前代码"时标出，
  绝不在"可能仍然有效"时放行；被标出的报告只有两种结局：重跑它的命令，或者**测出它要打印的数字没变**并把
  那次测量写下来。
- 脚本按纪律**拒绝写**（非零码且不落盘）于三种情况，并已实测两条：列出的报告文件缺失（`exit 2`）、
  git 无法回答陈旧性（`exit 4`，含无仓库/无历史）；第三种是"某份报告的提交或某个汇总数字读不出来"
  （索引宁可不出，也不能印一个猜的数）。
- 实测的输出（本轮）：**四份报告全部标为 `STALE`**，这是实话而非故障——`parse-report.md`（`b338547`）
  与 `solve-report.md`（`315107f`）落后十几轮，`mip-report.md`（`3ee797a`）落在一轮割改动之前，
  而 `mip-report-small.md`（`4a4e002`）只差一个文件：`mip/cuts.mbt`（第十六轮加的那条注释，
  行为未变；该报告的数字在本轮之前已用 `22433` = 33 节点 / 21477.000000017408 与
  `p0201` = 586 节点 / 7615.000000031186 复核过逐位一致）。**索引把该重跑的报告摆到明面上**。
- 这个脚本读的是各报告自己的 Summary 表，因此它不可能印出与报告不一致的数字；报告加列/改名会让它
  在生成时拒绝，而不是印错。

**文档**：`bench/README.md` 的脚本表与"报告"一节新增入口说明（含陈旧性判据与三条拒绝条件）；
`README.md` 的状态块把 `bench/report.md` 指为基准报告入口。

**门禁**：本轮无 MoonBit 代码改动（`.mbti` 无变化），164 测试在 native/wasm-gc/js 三目标仍全绿，
`moon check --deny-warn` 干净。

### Tried and reverted — M5 第十八轮：割的价值是"树"的属性，不是"根松弛"的属性（第五条规则也被否；留下一个根界探针）

第十六、十七轮把"根割的选择规则"量了三条（行序截断、efficacy、单条实测增益），
本轮先把**根界**量成一件可复用的仪器，再用它把第五条规则（联合增益贪心）和"上限"这条路各量了一遍，
**两条都被否、按纪律回退**（行为逐位回到 `ba30ba1`，两份 bench 报告继续有效）。

**新仪器：4 个节点的根界探针**。`--mip --max-nodes 4 --cut-rounds 2` 会恰好用掉"根 + 2 轮割重解"，
再走一个子节点就停，于是报出来的 `bound` 是**割做完之后的根松弛界**（队列里那个没被处理的兄弟节点
仍带着它的目标值）。两个锚点校准过：`22433` 关割 `--max-nodes 2` 报 `21240.526170816112`，
与 `--solve --relax` 的 LP 根**逐位相同**；`gt2` 带割 `--max-nodes 4` 报 `20 001.295 420 697 123`，
与 2000 / 20000 节点运行的界（`…696 326` / `…697 057`）在 1e-9 内一致。两条使用它的口径要点
（两条都是本项目踩过的坑）：队列必须非空（`--max-nodes 1` 会报 `bound=0`，那是空队列哨兵），
且实现的测量预算不能把规则截断（第十七轮那条 `min(上限, max_nodes − solved)`）。

**实测一：割对根界的贡献（十个实例，行序、2 轮）**。

| 实例 | LP 根 | 带割根界 | 增量 | 已知最优 / 报告值 |
| --- | ---: | ---: | ---: | ---: |
| `flugpl` | 1 167 185.725 592 790 6 | 1 171 748.830 758 916 2 | **+4 563.11** | 1 201 500 |
| `gt2` | 13 460.233 074 427 319 | 20 001.295 420 697 123 | **+6 541.06** | —— |
| `blend2` | 6.915 675 114 016 799 | 7.033 425 128 571 356 | +0.117 75 | 7.598 985 |
| `khb05250` | 95 919 464.000 080 26 | 103 698 570.725 383 91 | **+7 779 106.7** | 106 940 226 |
| `markshare1` | 0 | 0 | **+0** | 138 |
| `markshare2` | 0 | 0 | **+0** | 192 |
| `noswot` | −43.000 000 000 044 004 | −43.000 000 000 044 | **+0** | —— |
| `p0201` | 6 875.000 016 026 327 | 7 166.428 571 448 296 | +291.43 | 7 615 |
| `pk1` | 1e-12 | 9.997 786 430 265 38e-13 | **+0** | 36 |
| `22433` | 21 240.526 170 816 112 | 21 289.691 515 343 88 | +49.17 | 21 477 |

**割对"证明成本"的贡献同样量了**：`khb05250` 关割要 **7 552** 个节点（293.6 s）才证到 106 940 226，
开割只要 **305**（18.3 s）；`22433` 关割 59 → 开割 33。也就是说**割是证明成本的主杠杆**，
而 `markshare1`/`markshare2`/`pk1`/`noswot` 这四条（增量 +0）正是它们卡在下界的原因。

**实测二：联合增益贪心（第五条规则）——比前四条更差，回退**。做法是逐条加：每一步把"已选集合 + 候选"
重解（热启动），取实际抬得最多的那条，直到没有候选还能抬高为止。实测（满预算 20000 节点、默认 2 轮）：

| 实例 | 行序（基线） | 联合增益贪心 |
| --- | --- | --- |
| `22433` | optimal @ **33** 节点 / 20 条割 | optimal @ **241** 节点 / **3** 条割 |
| `gt2` 界 | **20 001.295 420 697 057** | **18 840.275 942 564 807**（−1 161） |
| `p0201` | optimal @ **586** 节点 / 20 条割 | optimal @ **1 196** 节点 / **8** 条割 |

**这条结果把前面的现象统一解释了**：一条割"对根松弛没用"不等于"没用" —— 这一轮之前代码里就写着
（第十轮）："一轮即使没推动根本身的最优值也要保留，因为一条在某个最优点上松弛的割，
仍可能在更深的节点上起作用"。按根松弛的增益选，等于把那批"割更深的节点"的割全丢掉，
于是 `gt2` 在同样 20000 个节点下的界反而掉了 1 161。

**实测三：上限（cap 10 → 20，行序）——界到了天花板，代价是证明**。满预算口径下：

| 实例 | cap 10（基线） | cap 20 |
| --- | --- | --- |
| `gt2` 界 / 割数 / 耗时 | 20 001.295 420 697 057 / 20 / 约 37 s | **20 869.350 537 673 66**（+4.3%）/ 24 / 88.4 s |
| `p0201` / 耗时 | optimal @ **586** / 27 s | optimal @ **1 204** / 217.7 s |
| `22433` / 耗时 | optimal @ **33** / 2.0 s | optimal @ **35** / 6.8 s |

`gt2` 那个 **20 869.350 537 673 66** 正是第十六、十七轮那两条"聪明规则"在 cap 10 下拿到的同一个数
—— 也就是说它们并没有找到行序找不到的割集，只是用更少的割达到了 cap 20 用两倍割达到的天花板。
按"界变好但证明成本翻倍"的同一判据回退（与第十六轮回退 efficacy 的理由一致）。

**这一轮的结论（五条规则量完）**：**割的价值是"树"的属性，不是"根松弛"的属性**。
按根松弛的任何判据（违背量、efficacy、单条增益、联合增益）选出来的集合，没有一个赢过"按行序取满上限"；
唯一有实测界收益的杠杆是**更多割**（cap 20 把 `gt2` 的界推到 2 轮下的天花板 20 869.350 537 673 66），
而它的代价是证明成本（`p0201` 节点 2 倍、时间 8 倍）。所以**"选择规则"这条线到此为止**：剩下的杠杆是
①**不同的割族**（`markshare1`/`markshare2`/`pk1`/`noswot` 的增量是 +0，它们要的不是选择而是更强的割；
第十六轮的多行聚合实验是在 efficacy 排序**之上**做的，被混淆了，要用"行序对照"重测）、
②**更多轮**（界↑、成本↑↑，实测见第十六轮 0/2/6 轮：13 894.88 → 20 001.30 → 20 898.54）、
③**节点上的割**。

**一条工具纪律的自我更正**：本轮我用 PowerShell 的 `Set-Content -Encoding UTF8` 改了 `mip/cuts.mbt`
来做上限实验，PowerShell 5.1 写出了 UTF-8 BOM（首字节 `EF BB BF`，`git diff` 里表现为第一行
`///|` 被改）。由 `git diff` 与首字节检查发现，已 `git checkout -- mip/cuts.mbt` 恢复
（首字节回到 `47 47 47`），改用编辑工具重做。仓库里的 UTF-8 源文件**只能**用 read/write/edit 改，
这条纪律又一次被证明是必要的。

**验收状态**：行为逐位回到 `ba30ba1`（复跑 `22433` = 33 节点 / 21477.000000017408），
两份 bench 报告继续有效；164 测试在 native/wasm-gc/js 三目标全绿，`moon check --deny-warn` 干净，
`.mbti` 无变化。根界探针是**仪器**而不是提交内容：命令、锚点与两条口径要点都写在上面。

### Tried and reverted — M5 第十七轮：把根割的选择换成"实测"（连同它需要的暖启动一起实测净负，留下成本结构）

第十六轮的结论是"选择规则必须测出来，而不是换个几何代理"。本轮把**实测**真正实现了：

- `cut_round` 对每个通过的候选**单独加进模型重解一次**，按实际抬起的界排序、只留抬起了界的（测量解
  与节点解走同一条路：同一个求解器、同一台校验器、计入 `nodes`/`verified`、受同一预算约束）；
- 为此补上**加行之后的热启动**：`SimplexBasis::with_appended_rows` + `solve_model_after_adding_rows`
  （新行的乘子从 0 开始 ⇒ 检验数一个没动、基仍然对偶可行；新行自己的松弛列做基变量，它的值就是点差这一行
  多少 ⇒ 对偶单纯形负责修它），并把割轮的**整轮重解**也改走这条路（原本是冷解）。
  新增一条白盒测试钉住"加行后热解与冷解答案一致 + 行数对不上就返回 `None`"；165 个测试全绿。

**实测一：实测选择在 2000 节点口径上"一好三坏"，在满预算口径上把三处证明成本抬高**。

| 实例（2000 节点、2 轮割） | 行序截断（基线） | 按实测增益排序 |
| --- | --- | --- |
| `gt2` 界 / 割数 | 20 001.295 420 696 326 / 20 | **20 869.350 537 674 967** / 15 |
| `pk1` 界 / 当前解 / 割数 | 0.542 237 / 36.0 / 20 | 0.943 765 / 45.0 / **0** |
| `markshare1` 界 / 当前解 / 割数 | 0 / 478.0 / 15 | 0 / 345.0 / **0** |
| `markshare2` 界 / 当前解 / 割数 | 0 / 192.0 / 17 | 0 / 252.0 / **0** |
| `noswot` 界 / 当前解 / 割数 | （满预算报告：−43.000 000 000 044 906 / −34.0 / 10） | −43.000 000 000 088 07 / −37.0 / **0** |

`gt2` 的界 +4.3%（与第十六轮的 efficacy 同一水平），三个实例的**割数变成 0**（实测发现它们那些单行割
一条都不抬高根松弛，只剩"加了不会更差"，于是全被丢掉），节点也因此更快（`pk1` 2.9→1.4 s、
`markshare1` 1.0→0.4 s、`noswot` 13.3→8.7 s）。但满预算（20000 节点、默认 2 轮）口径下：

| 实例 | 证明最优所需节点（基线 → 实测选择） |
| --- | --- |
| `22433` | **33 → 185**（且 40 个松弛内连整数点都没有，界 0） |
| `p0201` | 586 → 1 104 |
| `khb05250` | 305 → 372 |
| `flugpl` | 13 806 → 14 520 |

四个实例的答案与状态不变（都仍 `optimal`、目标值逐位相同），但证明成本全面上升 —— 而"证明小规模最优"
正是 M5 的判据本身。按纪律回退。

**实测二（本轮真正的新信息）：成本结构在"冷解"上，而不是在"测多少条"上**。每次测量都是一次**冷解**：
`22433` 一轮展开 39 个候选 ⇒ 41 个松弛用了 **45.8 s**，而基线的 33 个松弛只要 **2.1 s**。
这里还有一条**测量纪律的自我更正**：我第一版成本探针用 `--max-nodes 1`，而那一版实现里测量预算取
`min(上限, max_nodes − solved)`，于是 `--max-nodes 1` 时预算是 0 —— 探针量的是一轮**什么也没测**的割轮，
自然"只要 4~40 ms"。真实的单次测量成本 = 一次冷解（`22433` 上约 1 s）。**探针必须与被测机制同口径**，
这条又踩了一次。

**实测三：热启动让测量便宜 14 倍，但它改的不是"多快"，而是"落在哪个最优顶点"**。
`22433` 的 41 个松弛：45.8 s（冷测量）→ **1.0 s**（热测量）。但把**割轮整轮重解**也换成热启动之后，
树变了（退化最优面里落到另一个顶点 ⇒ 传给子节点的基不同）：`flugpl` 13 806 → 13 808（7.8 → 10.1 s）、
`khb05250` 305 → **351**（18.3 → 22.6 s）、`p0201` 586 → **808**（27.2 → 31.6 s）、
`22433` 33 → 33（2.0 → **1.3 s**，唯一的赢面）。三个变差一个变好 ⇒ 回退。

**回退后的状态**：行为逐位回到 `a76eff4`（复跑 `22433` = 33 节点 / 21477.000000017408、
`p0201` = 586 节点 / 7615.000000031186，与报告一致，**两份 bench 报告继续有效、无需重跑**）；
热启动能力本身也一并撤掉（没有使用者的公开 API 就是契约上的空头承诺），
它的设计与实测数字写在 `docs/roadmap.md` 的下一步里，重建是十几行的事。

**这一轮留下的判断（三条规则已经量完）**：行序截断、efficacy、按实测增益 —— 后两条都把 `gt2` 的界抬高
4.3%，也都把证明变贵；也就是说**在这些实例上"把界抬得最高的一组割"不是"证明最快的一组割"**。
还没试过的是两类：① **把候选放在一起测**（贪心：先加最有效的一条，再对新界重测其余 —— 这需要热启动，
而热启动的测量成本已经量过是负担得起的）；② **先弄清树为什么这么敏感**（当前整数点来自下潜，
而下潜的走着实随割集变化：`markshare2` 的当前解在三个规则下是 192 / 252 / 730），再回去调规则。
本轮不继续试第三、第四种排序规则，是因为前两种的先验猜测实测出的好坏方向一致地指向"树的敏感性"这个
更底层的问题。

### Tried and reverted — M5 第十六轮：根割的选择判据（两个候选都被实测否掉；留下的是诊断）

队列首位是"MIR 多行聚合 / 节点上的割"，判据是"把 `markshare1`/`markshare2`/`pk1` 贴在下界的界抬起来"。
本轮先做诊断、再实现两个候选、各自量完前后数字，**两个都没有净收益，按纪律都回退**（行为回到 `a124c64`，
只多一条记录诊断的代码注释；也正因为行为未变，`bench/mip-report-small.md` 与 `bench/mip-report.md`
仍然有效、无需重跑）。

**诊断一：割的选择判据在候选之间是退化的（实测）**。`cut_round` 的规则是"只保留松弛当前点**违背**的割、
按违背量从大到小取满 `cut_round_cap`"。但**一轮里的每个单行舍入割在被导出的那个点上恰好违背 1**：
那是舍入构造本身的性质 —— 该点处所有参与计数的变量都坐在它们的移位所依据的界上，于是左端项全由移位常数
承担，违背量恰好是一个"自身尺度"单位。用 TEMPDUMP 探针在 `markshare1` 根节点上实测（`--max-nodes 2
--cut-rounds 1`），6 个候选的违背量是：

```text
x43 1.0000000000000018   x13 1   x29 1   x31 1   x39 1.0000000000000009   x60 1.0000000000000018
```

**全是 1（差在末位）**。也就是说"按强度取前 10"在候选多于 10 时**实际是按行序截断**（`> ` 严格比较，
并列时保留先到的）。这不是本轮新引入的缺陷，而是这套规则一直如此；它的后果是：截断保留下来的恰好是
哪 10 条割，是一件**没有依据**的事。

**诊断二：`markshare1`/`markshare2` 的界贴在下界，不是割太少，而是根割抬不动它（实测）**。同一预算
（2000 节点）下把根割轮次从 0 → 2 → 6 加大：

| 实例 | 0 轮 | 2 轮 | 6 轮 |
| --- | ---: | ---: | ---: |
| `gt2` 界 | 13 894.88 | 20 001.30 | 20 898.54 |
| `markshare1` 界 | 0 | 0 | 0 |
| `markshare2` 界 | 0 | 0 | 0 |

`markshare1`/`markshare2` 的根 LP 最优值就是 0，割加 6 轮也还是 0（模型结构是 6 条等式 + 12 个 ±1 代价的
连续变量 + 50 个零代价整数变量：LP 能在**很大的零代价子空间**里满足割，这一点是本轮读模型得出的判断，
不是测量）。同时量到一件对"界"很关键的事：`gt2` 的界**完全由根割决定** —— 同一配置下 2000 节点的界
`20 001.295 420 696 326` 与 20000 节点的界 `20 001.295 420 697 057` 只差 7e-10（相对），即此后 18000 个
节点对界没有任何贡献。

**候选 A：按 efficacy（到割超平面的距离）排序 —— 实测一好三坏，回退**。给每个候选量
`violation² / ‖a‖²`（无需开方，非负 efficacity 的排序与它的平方一致），再按它取满上限。同一预算
（2000 节点、2 轮割、每项 3 次运行、逐位可复现）：

| 实例 | 行序截断（基线） | 按 efficacy 排序 |
| --- | --- | --- |
| `gt2` 界 / 当前解 | 20 001.295 420 696 326 / 44 073.0 | **20 869.350 537 672 497** / **33 406.0** |
| `pk1` 界 / 当前解 | 0.542 237 050 513 654 5 / 36.0 | 0.784 235 538 669 262 / **46.0** |
| `markshare1` 当前解（界两边都是 0） | 478.0 | 384.0 |
| `markshare2` 当前解（界两边都是 0） | **192.0** | **730.0** |
| `noswot` 界 / 当前解 | −43.000 000 000 048 225 / −36.000 000 000 011 04 | 逐位相同 |

`gt2` 的界 +4.3%、gap 从 24 071.70 收到 12 536.65（同一预算），这是真的收益；但代价同样是真的、
而且落在判据本身所在的地方：

| 实例 | 证明最优所需节点（基线 → efficacy） |
| --- | --- |
| `p0201` | **586 → 1 325** |
| `khb05250` | 305 → 409 |
| `flugpl` | 13 806 → 14 498 |
| `22433` | 33 → 37 |

四个实例的**答案与状态不变**（都仍是 `optimal`，目标值逐位相同），但 `p0201` 的证明成本变成 2.26 倍：
**在 1000 节点预算下基线能证到 7615 而 efficacy 版本证不到**（实测 `--max-nodes 1000`：`node-limit`，
界 7 597.50、gap 67.50；`--max-nodes 700` 同样 `node-limit`）。也就是说这条改动在更紧的预算下会**丢掉
一个已证最优的实例**。把每轮上限从 10 提到 20 只把它从 1325 挪到 1198 节点（割数 20 → 40，`gt2` 的界
一位不变），不是解药。综合"1 项界变好、2 项当前解变差、4 项证明变贵（其中一项失去紧预算下的覆盖）"，
按"没测出净收益的改动不留"回退。

**候选 B：MIR 多行聚合（`y_B + λ·y_k`）—— 实测混合、净收益为负，回退**。以最分数的 3 个行为目标、
各配 8 个伙伴行、每对试 3 个权重（`+1`、`−1`，以及消掉目标行最大项的那个权重），候选与单行割一起进
同一个上限池（候选仍逐条过 `verify_cut`，校验器一行未改）。实测（在候选 A 之上、2000 节点、2 轮割）：

| 实例 | 候选 A（仅 efficacy） | 候选 A + 多行聚合 |
| --- | --- | --- |
| `gt2` 界 | 20 869.35 | **19 861.19**（−1 008，且当前解丢失） |
| `gt2` 6 轮界 | 21 160.90 | 21 122.29 |
| `pk1` 界 / 当前解 | 0.784 / 46.0 | **1.063** / **39.0** |
| `markshare1` 当前解 | 384.0 | 377.0 |
| `noswot` | — | 逐位相同 |

只留整权重 `λ ∈ {+1, −1}` 的变体在 `gt2` 上**逐位相同**（19 861.188 437 665 493），说明那几个"消项"
权重不是原因；真正的原因是聚合候选**挤掉了单行割**（同一个上限池里，聚合候选的 efficacy 更高，
于是被留下的 10 条割换了集合），而"哪一组 10 条割更好"在这两个实例上给出了相反的答案。
既然候选 A 本身已回退，候选 B 建立的它之上，一并回退。

**这一轮真正的产出是那两条诊断，以及一条被两个否定结果夹出来的下一步**：一轮里的割候选**在违背量上完全
并列**，所以任何"排序"规则都只是一个先验猜测 —— 而两个先验猜测实测出的好坏方向相反。要让选择不再是猜测，
判据必须是**测出来的**：把候选割各自加入根模型重解一次、按实际抬起来的界选（每轮代价是上限条数的
一次 LP 重解：`gt2`/`p0201` 这类 29–133 行模型上是每次几十毫秒，`blend2` 上是每个根重解一次的数量级，
可按 `cut_round_cap` 界住）。这条已写进 `docs/roadmap.md` 的下一步；本轮不实现它，是因为它必须先有
"用哪把尺子量候选"这个已被证否的前提，而不是再换一个几何代理。

**验收状态**：**行为零改动**（工作树只比 `a124c64` 多一条记录这条诊断的注释，选择逻辑逐字未变；
`p0201` 复跑仍是 `586` 节点 / `7615.000000031186`、`khb05250` 仍是 `305` 节点 / `106940226.0001297`，
与报告逐位相同），因此两份 bench 报告仍然有效、无需重跑。27 个 `mip` 测试、164 个全仓测试在
native/wasm-gc/js 三目标全绿，`moon check --deny-warn` 干净，`.mbti` 无变化。

### Fixed — M5 第十五轮：内核的停机构件补上"对偶侧自检"（判据达成：`blend2` 越过节点 30782）

第十四轮把缺陷夹到了唯一剩下的位置：**内核按检验数（自己的尺度、自己的容差）宣布基最优，而写出去的证书
随后要按行乘子的符号约定被校验器读 —— 内核从未检查过"我要写出去的 y 满足符号约定吗"**。这一轮就改这一处，
做法与内核早就对"原始可行性"做的事同形：

- `simplex/certificate.mbt` 新增 `State::max_dual_sign_violation`：**用校验器那条式子量**行乘子的符号违反
  （`raw · max|aᵢⱼ| / max|cⱼ|`，两者下限都是 1，`=` 行跳过），返回最大违反量与它所在的**模型行号**；
  配套 `State::row_multiplier_is_negative` 是"这一行是否可能违反"的判据 —— 非负乘子按定义贡献 0，
  所以常规求解（乘子全在约定内）只走一趟行循环、**不扫矩阵也不分配**，矩阵只在真有可疑行时扫一次。
- `finish_optimal`：把对偶侧的量与原始侧的量放进**同一次**恢复尝试（超容差就 `refactorize()` 再全量重测，
  对偶随之重算重测）；仍超容差时**不再发出证书**，而是报 `NumericalFailure`，消息带实测违反量、
  违反所在的模型行与容差。对偶侧的自检尺度与校验器完全相同，因此这条分支的含义是"这次运行没有可写下来的证明"，
  而不是"答案不对"。
- `KernelProblem::row_relation`（新字段，`assemble` 里与 `row_dual_factor` 同一处填）：符号约定只对 `≤`/`≥`
  行要求非负、对 `=` 行自由，而 `row_dual_factor` 的量级在任何关系下都是 1，答不出"这行是不是等式"。
  它进 `.mbti`（公开结构体多一个字段），这是本轮唯一的公开面变化。
- `simplex/moon.pkg` 增加 `for "wbtest"` 的 `verify` 依赖（只有白盒测试用；发布出去的 `simplex` 包
  **不依赖** `verify`，这与 `verify` 不依赖 `simplex` 是同一条边界）。

**判据（事先定死、可测）达成 —— `blend2` 关割 20000 节点，行数上限 300（native release）**：

| 口径 | 状态 | 松弛数 | 过校验 | 停在 / 界 |
| --- | --- | ---: | ---: | --- |
| 第十五轮前（基线，本轮复现） | `unverified` | 17075 | 17066 | 节点 30782（深度 60）：`dual signs 1.6827437132678818e-7`；修复件 `complementarity (columns) 2.9608520739327154e-6` |
| 第十五轮后 | `node-limit` | **20000** | 19983 | 跑满预算，**界 7.558872430439194**（"还没有整数点"，15651 个树节点仍开） |

差的那 17 个松弛正是自检拦下来的：它们**没有资格**被写成"最优"（乘子写不出满足符号约定的证明），
按"无结论的松弛"计入开着的活，而不是按"被拒的主张"结束整个搜索。

**覆盖面复测（`bench/report-mip.ps1 -Manifest bench/data/instances/small.txt -MaxRows 300 -MaxNodes 20000
-MaxIterations 20000 -Output bench/mip-report-small.md`，10 个实例）**：**4 最优 / 6 节点预算 / 0 跳过 /
134601 个松弛过校验 / 176 条割 / 9 个点通过独立复核**。

- **原 9 个实例的每一格数字都没变**（`flugpl` 13806/13806、`gt2` 20000/20000 界 20001.295420697057、
  `khb05250` 305/305、`markshare1/2` 20000/20000、`noswot` 20000/19905 界 −43.000000000044906、
  `p0201` 586/586、`pk1` 20000/20000、`22433` 33/33），**四个证明最优的实例仍是 `verified == nodes`**
  —— 也就是说这道自检在它们身上**一次都没触发**，没有把任何一个"已证最优"降级成节点预算。
- `bench/data/instances/small.txt` 里 `blend2` 从 **BLOCKED** 回到清单（该文件的排除理由同时改写）。
- `check-mip-objectives.ps1 -Report bench/mip-report-small.md`：4 项**取等**通过，0 违反、0 未校验节点。
- `blend2` **开割**口径同样不再被拒签：跑满 20000 个松弛（19966 过校验、16 条割），界 7.5784463643777125。

**代价（实测，方法是同一命令在改动前后各跑，输出逐位相同）**：自检每次最优求解多一趟 `O(行数)` 的行循环，
只有在"乘子确实越界"时才多一趟 `O(nnz)` 的矩阵扫描。**在这个分辨率下测不出开销**：

| 命令（native release，关割） | 无自检 | 有自检（行优先） | 逐次散布 |
| --- | ---: | ---: | --- |
| `flugpl` 满预算（12455 个松弛） | 1.22 / 1.24 / 1.26 s；另一批 1.304–1.343 s | 1.236–1.566 s（8 次，min 1.236） | 同一份代码重复跑就散布 ±5% |
| `blend2` 3000 节点 | 44.80 / 44.87 / 45.41 s | 43.37–45.27 s | 同上 |
| `noswot` 5000 节点 | 33.34 / 33.43 / 34.20 s | 33.09 / 33.25 / 33.78 s | 同上 |
| `khb05250` 满预算（7552 个松弛） | 301.89 s（1 次） | 300.69 s（1 次） | —— |

**这里有一条必须写下来的自我更正**：先做到的一版是**无条件扫矩阵**的，当时的前后配对看起来是 +3%~5%
（`flugpl` 1.22/1.24/1.26 → 1.28/1.28/1.29，区间不重叠），于是我把自检改成"行优先、无可疑行就不扫矩阵"。
但随后**同一份无自检代码**再跑一批就变成 1.304–1.343 s —— 也就是说那 5% 更可能是机器漂移而不是自检的代价，
**这个分辨率的时钟测不出单趟扫描**。行优先版本因此作为"代价随可疑行数走、而不是随矩阵走"的设计保留
（该省法是**精确**的：非负乘子按定义贡献 0），但**不声称**它被测出了收益。

**发现的缺陷与新的限度**：

1. 上一轮的"生产端修复"（`mip/duals.mbt` 把越界乘子夹到 0，再由校验器裁决）在**原件被拒、修复件被接受**
   的那类节点上，会让该节点以"运行自己的证书被拒过"的方式记成 `verified`；现在内核根本不发出这份证明，
   这类节点改成"无结论"，计入"树没穷尽"。两者的差别只在**怎么记**，但记法决定了整轮运行是
   `node-limit` 还是 `unverified`，所以必须写清楚。
2. **内核选择"不写"而不是"修好"**：一个只需夹掉零值乘子就能被校验器接受的最优基，现在会被记成无结论、
   丢掉它的界（`NumericalFailure` 不发 `basis`，子树无法热启动）。实测在四个能证明最优的实例上
   这类情况**一次都没有**出现，但它是这条修法明确付掉的代价，不是侥幸。
3. 这条自检只覆盖证书的**符号轴**；对偶可行性、两侧互补松弛、对偶间隙、与声称目标值的一致性依旧是
   **校验器一个人的判据**（内核不复述它们，也不该复述）。
4. `blend2` 仍未拿到任何整数点（关割口径 20000 个松弛、界 7.558872430439194），它进小规模清单后
   属于"到预算"那一列。

**门禁**：`moon fmt && moon info`（`.mbti` 只有 `KernelProblem::row_relation` 一处新增）、
`moon check --deny-warn` 干净、`moon test --deny-warn` 在 **native / wasm-gc / js** 三目标
**162 → 164 个测试全绿**（新增两条白盒测试：一是把内核的量与校验器的判决放在同一个模型上对齐
—— 同一个越界乘子，内核量出 `3.75e-6`、校验器判 `dual signs` 失败；同一个诚实的乘子，两边都通过；
同一个乘子在 `4e9` 目标系数的模型上是 `3.75e-12`、校验器也不再判符号 —— 二是"报 `Optimal` 的运行发出的
乘子必须过它自己这道闸"的不变量）。

**这条改动也落在纯 LP 路径上，所以顺手量了它的波及面**（`finish_optimal` 是两条路径共用的）。
在 LP 全清单（`bench/report-solve.ps1 -Relax -MaxRows 1000 -MaxIterations 1200 -Presolve`，
32 个实例，native release）上：**18 最优 / 11 跳过（超行数上限）/ 0 规模拒绝 / 3 迭代上限 /
0 数值失败 / 0 拒签 / 21 个实例过化简 / 18 个重建解全部通过三项检查 / 0 次 Bland 恢复**，
即自检在这 33 个实例上一次都没触发（结构与我们手上那份过期报告一致，三个迭代上限仍是
`danoint` / `mod010` / `fast0507`）。同一份数据也顺手量到一件**该记下来的事实**：同一个 1200 枢轴上限下
`mod010` 的内核目标值是 **5544.518537565392**、`danoint` 是 22.473328929394704，而
`bench/solve-report.md`（提交 `315107f`）里写的是 3505.2 与 22.6 —— 也就是说这个上限**现在买到的进度比那时少**。
这不是受控对比（两份报告之间隔着十二轮改动，其中第三轮的方向自检本身就实测过 LP 全清单总枢轴 +27%），
所以这里只记数字、不断言成因；把这几笔自检开销收回并重跑那份报告仍是待办（见 `docs/roadmap.md`）。

### Tried and reverted — M5 第十四轮：收紧节点容差口径（实测更差，按纪律回退；缺陷被两名候选的否定结果夹住）

第十三轮把候选缩到两条，这一轮做了第一条：**节点松弛换一套更严的容差口径**（同一个求解器、同一台校验器，
只把可行性与主元容差收紧 100 倍 —— 检查侧一点没放松，只是让搜索产出更"干净"的解）。新增
`SimplexOptions::tightened(factor)`，在 `kernel_node` 里以 100 倍应用。162 个测试全绿，然后按判据验收。

**实测（native release，`blend2`，行数上限 300、节点预算 20000、关割）**：

| 口径 | 松弛数 | 停在 | 运行自己的证书 | 修复后的证书 |
| --- | ---: | --- | --- | --- |
| 默认容差（基线） | 17075 | 节点 30782（深度 60） | `dual signs 1.6827437132678818e-7` | `complementarity (columns) 2.9608520739327154e-6` |
| 收紧 100 倍 | **1944** | **节点 3190（深度 15）** | `dual signs 2.5179791910972405e-7` | `complementarity (columns) 2.862848488314555e-6` |

**收紧之后更差**：同样的预算只跑到 1944 个松弛就被拒（节点从 30782 退到 3190，运行时间 334s → 44s），
而违反量**没有变小**（1.68e-7 → 2.52e-7）。按"没测出收益的改动不留"回退（工作树 == `4cf7876`）。

**这两个否定结果合起来说明什么（本轮真正的收获）**：那个越界乘子**既不是陈旧因子造成的**（第十三轮），
**也不是容差口径松造成的**（本轮）。于是"下一步该改哪里"被夹到了唯一剩下的位置：
**内核的停机构件本身没有用它将被评判的那条判据** —— 它按检验数（自己的尺度、自己的容差）宣布基最优，
而证书随后要按行乘子的符号约定被读；内核从没检查过"我将要写出去的那个 y 满足符号约定吗"。
下一轮据此改成与原始解同形的做法：**在 `finish_optimal` 里加一道对偶侧的自检**（尺度与校验器的符号检查一致），
不通过就先走**已有的恢复路径**（重建因子 → Bland 恢复重解），仍在容差外就报 `NumericalFailure`
而不是把一份注定被拒的证书发出去 —— 这正是该项目对"原始可行性"早就在做的事，只是对偶侧一直没有。
判据不变：`blend2` 关割在 20000 节点下必须越过节点 30782。

### Tried and reverted — M5 第十三轮：发出对偶前重建因子（实测无收益，按纪律回退）

按第十二轮的结论，这一轮做了定好的定向修复：在 `finish_optimal` 里，**发出对偶之前先量一次符号违反**
（尺度取成与校验器的符号检查完全相同：`raw · max|aᵢⱼ| / max|cⱼ|`），超过容差就 **重建因子再重算对偶**
—— 第九轮修假"不可行"用的就是这条药方。实现完成、162 个测试全绿，然后按事先定好的判据验收。

**实测（native release，`blend2`，行数上限 300、节点预算 20000、关割）**：

| 口径 | 停在 | 运行自己的证书 | 修复后的证书 |
| --- | --- | --- | --- |
| 修复前（第十二轮） | 节点 30782（深度 60） | `dual signs 1.6827437132678818e-7` | `complementarity (columns) 2.9608520739327154e-6` |
| 修复后（本轮） | **仍是节点 30782** | `dual signs 1.6827437132677863e-7` | `complementarity (columns) 2.9608520739327154e-6` |

违反量只动了最后几位 —— **重建因子没有改变这个节点的对偶**。也就是说：那个越界的乘子**不是陈旧因子造成的**，
它是"内核自己的最优性判据"与"校验器的符号判据"之间的**容差口径差**：内核按自己的检验数尺度（1e-7）认为
该基最优，而校验器按行最大系数/目标最大系数标出来的符号违反是 1.68e-7（超出 1.7 倍）。
按"没测出收益、只测出代价的改动不留"回退（工作树 == `827e7b1`）；代价本身也记下来：
这次改动会让每次求解多一趟 `O(nnz)` 的扫描，并在符号检查触发时多一次因子分解 —— 换不到东西就不留。

**否定结果的价值（写进 roadmap）**：它把"下一步该试什么"从两个候选里消掉了一个。
剩下的候选有两条，都不再是"重建因子"这一类：
① **拒签时就地换一套更严的容差重解该节点**（同一个求解器、同一个校验器、只收紧主元/可行性容差；
通过了就继续，两次都拒才停 —— 检查一点没放松，只是换一个更"干净"的解）；或
② **把内核自己的最优性判据收得比校验器更紧**（既然两者量的是同一个性质、尺度却不同，
    内核就得留出这段差额，否则它会一直认为自己最优而校验器一直拒签）。

### Fixed — M5 第十二轮：修复的顺序错了（先验原件、只修被拒的），并把深节点的拒签诊断到底

上一轮加的「生产端修复对偶、校验器裁决」是**无条件替换**证书：先夹掉越界乘子，再把夹过的证书交给校验器。
这一轮先改顺序 —— **先把运行自己产出的证书交给校验器，只有被拒时再试修复过的** —— 因为夹逼本身并不免费：
它的量是 `violation × cost_scale / row_sign_scale`，在目标系数很大的模型上会夹掉一个有实际影响的乘子，
把一份校验器本来会接受的证书改成在**另一项检查**上被拒。修「被拒的」，不是修「会过的」。

**实测（native release，`blend2`，行数上限 300、节点预算 20000）**：

- **关割**：`unverified`，停在节点 30782（深度 60）。消息里现在同时给出两次拒签的理由 ——
  **运行自己的证书**：`dual signs 1.6827437132678818e-7`（容差 `1e-7`）；
  **修复后的证书**：`complementarity (columns) 2.9608520739327154e-6`。
  也就是夹掉那个乘子把违反量**放大**了（1.7e-7 → 3.0e-6）：这个节点的夹逼**不是免费的**。
- **开割**（出厂默认）：`unverified`，停在节点 6075（深度 33），原件 `dual signs 1.4991114641491814e-6`，
  修复后的证书同样没过。
- 两次说的是同一件事：**问题在对偶本身的质量，不在证书的写法**。上一轮那条修复只在夹逼免费时有效
  （实测过的一例：关割轨迹上节点 11158 从"6336 个松弛后被拒"变成"继续到 17075 个松弛"），
  在这个节点上无效。因此这一轮**不做**新的夹逼规则（那只是把违反量搬来搬去），而是把定向修复写进
  roadmap 作为下一轮首要项：**发出对偶之前重建因子并重算对偶** —— 第九轮修假"不可行"用的就是同一条药方
  （判定前先 `refactorize()` 再量）：对偶对陈旧的乘积形式更新比原始解更敏感，原始解的残差自检过了
  不代表对偶也过。
- **拒签消息本身也改了**：两次拒签的理由都写进消息（`原件原因 (the repaired certificate: 修复件原因)`）。
  一条拒签就是一次诊断，下一个人不该为了知道"修复试过没有、拒在哪一项"再等一次 6 分钟的运行。

**清单记录同步收紧**：`bench/data/instances/small.txt` 里 `blend2` 的 BLOCKED 注记补上这次的两条原因与
节点号（它仍是 BLOCKED，也仍在主清单与 `bench/mip-report.md` 里）。

**测试** 161 → **162**：新增白盒测试固定三件事 —— 校验器接受的证书必须**原样**返回、不平白夹一刀（顺序）；
只被符号拒的证书夹完要能过；两次都过不去时保留**运行自己那份**证书的拒签理由。

### Fixed / Changed — M5 第十一轮：小规模实例的覆盖面测量，以及它撞出来的两个证据链缺陷

这一轮的目标是把完成标准 ①（小规模实例求到公开已知最优值）**量出来**，方法是把节点预算放到内核默认的
20000 再跑一遍小规模实例。测量本身做到了，但它同时撞出两处缺陷，其中一处让**上一轮起的对拍脚本一直是坏的**。

**① 对拍脚本按列位置读报告 —— 上轮插入 `cuts` 列后它就坏了（已修）**：
`check-mip-objectives.ps1` 原来用一条正则按**位置**取「nodes / verified」两列，`cuts` 列插在两者之间之后，
它把 `verified` 读成 `nodes`、把 `cuts` 读成 `verified`，于是对一个 33/33 全部过校验的最优运行报
`UNVERIFIED 22433: 33 nodes, 20 verified` 并以非零码退出。**这就是为什么缺陷必须每轮用一条命令重新走一遍**：
报告里没有数字变化，坏掉的是读数的人。现在按**表头列名**读（缺列即报错退出），修复后同一份报告
`checked 1, violations 0, unverified 0`。

**② 深节点上的对偶符号拒签（先于本轮割平面就存在，已修）**：`blend2` 在深预算下停在 `Unverified`。
把校验器的**完整判决**打出来才看清形状：只有 `dual signs` 一项失败（`2.29e-7`，另一个轨迹上 `1.105e-7`，
容差 `1e-7`），而**对偶可行性 = 0、两侧互补松弛 = 0、对偶间隙 = 3.5e-13、目标值精确** —— 最优性论证是对的，
只有一个乘子带错号：退化最优的对偶面不唯一，单纯形落到了锥外一点点。
修法是**生产端修复、校验器裁决**：新增 `mip/duals.mbt`，把「按校验器同一尺度量出的符号违反」那一项置零，
再把修好的证书交给 `verify` —— 如果这次夹逼真的动了界，校验器的**对偶间隙**会拒签（所以修复永远不是自己的裁判）。
实测效果：该实例从「6336 个松弛后被拒」变成「继续到 **17075** 个松弛才撞上另一类拒签」。
**修这条时又踩了同一条老坑，记下来**：第一版用 `verify` 的 `row_scale`（`1+|b|+Σ|aⱼxⱼ|`，那是**互补松弛**用的尺度）
去量符号，而符号检查用的是**该行最大系数**（下限 1）—— 尺度不一致，于是修复对校验器正在拒的那个乘子
**视而不见**（实测：修复前后拒签数值逐位相同）。这正是本项目第三次踩"自检的尺度必须与它所替代的检查一致"，
现在用白盒测试把边界钉住（`-1.1e-7` 必夹、`-0.9e-7` 必不夹，同一模型同一容差）。
三条相关实验也一并记录：把节点热启动换成冷启动**不是**原因（冷启动更早失败，节点 159、`2.29e-7`）；
开割与关割**都会**被拒（关割在节点 11158，所以缺陷先于本轮存在）。

**③ 同类拒签的第二张脸（仍未修，交给下一轮）**：同一实例节点 30782（深度 60）
`complementarity (columns) failed: 2.96e-6`。它已经不是"乘子带错号"能夹掉的那一类（列互补松弛同时牵涉
原始点与检验数），因此没有强行修：先把测得的东西写进 `docs/roadmap.md` 与 CHANGELOG，下一轮按
"导出节点模型 + 外部求解器判最优值/对偶"的老链条定位。

**覆盖面（完成标准 ① 的证据）**：新增清单 `bench/data/instances/small.txt` 与报告 `bench/mip-report-small.md`。
清单本身是**可复核的**：它把每个实例的**实测每节点成本**写在文件里，并说明排除项的理由
（`dcmulti` 87 ms/节点、`rout` 391 ms/节点、`misc07` 1000 节点超过 8 分钟 → 20000 节点跑不起；
`50v-10`/`mod010` 超出本视图的变量上限；`blend2` 记为 **BLOCKED（被上面那条拒签挡住）** 而不是悄悄移掉 ——
它仍在主清单与 `bench/mip-report.md` 里）。报告口径：行数 ≤300、节点预算 20000、迭代上限 20000、开根割：

| 实例 | 结果 | 节点 | 目标值 | 官方最优值 |
| --- | --- | ---: | ---: | ---: |
| `khb05250` | **optimal** | **305** | 106940226.0001297 | 106940226 |
| `p0201` | **optimal** | **586** | 7615.000000031186 | 7615 |
| `22433` | **optimal** | **33** | 21477.000000017408 | 21477 |
| `flugpl` | **optimal** | 13806 | 1201500.000000284 | 1201500 |
| `gt2` | node-limit | 20000 | obj 44072.99999998619 / 界 20001.295420697057 | 21166 |
| `markshare1` | node-limit | 20000 | obj 138 / 界 0 | 1 |
| `markshare2` | node-limit | 20000 | obj 192 / 界 0 | 1 |
| `noswot` | node-limit | 20000 | obj −34.00000000000816 / 界 −43.000000000044906 | −41.00000885 |
| `pk1` | node-limit | 20000 | obj 36.000000000050356 / 界 4.036486853130988 | 11 |

汇总：**4 最优 / 5 节点预算 / 0 跳过 / 0 其它 / 114635 个松弛过 `verify` / 9 个点通过独立复核**；
`check-mip-objectives.ps1` 对 4 项**取等**通过（0 违反、0 未校验节点）。**完成标准 ① 的覆盖面从 3 个转到 4 个**：
新增 `p0201`（**586 节点**证到 `7615`，此前 20000 节点也证不出），而割对"证到最优要多少节点"的效果是实测的 ——
`khb05250` **305 vs 无割 7543**、`22433` **33 vs 59**、`flugpl` 13806 vs 无割 12411（这个实例割没帮上忙）。
仍未证的 5 个里，`markshare1`/`markshare2`/`pk1` 的界仍贴在下界（0 / 0 / 4.04），是下一轮的首要目标。

**测试** 160 → **161**（新增白盒测试：修复夹什么、不夹什么，以及夹出来的证书必须被校验器接受）。

### Added — M5 第十轮：割平面（每条割自带可独立再推导的证明）

**为什么这一轮先设计再动手**：割是本项目里**唯一会静默出错**的部件。一条无效割会从它落地的那个节点
起、到整棵子树为止，把整数点（可能是真最优）删掉，而现有每条防线都会继续点头：后面的松弛被**正确地**
求解、它的证书是拿**带割的模型**去校验的（校验器当然同意）、报告的点复核也是拿带割的模型量、
`report-mip.ps1` 的"拒绝写不可靠报告"同样看不到问题 —— `verify` 拒绝的是**错的答案**，不是**错的模型**。
所以这一轮先给出"割的有效性如何被独立检验"，再实现。

**设计：割 = 一行 + 它由哪一行舍入而来的证明**。证明是**模型自己那些行的一个组合**：`rⱼ = Σᵢ yᵢ·aᵢⱼ`、
`β = Σᵢ yᵢ·bᵢ`（松弛行的松弛变量写进等式后，每个模型行就是一个等式）。若这一行里某个变量是基变量、
系数为 1、且它是整数变量，则 `x_B = β − 其余项` 左边是整数，经典舍入成立（Gomory 1960；
本实现用 Wolsey §8.7 的混合整数形式：整数变量按 `fⱼ/f₀`、`(1−fⱼ)/(1−f₀)` 计量，**连续变量不能当计数器**，
它按 `āⱼ/f₀`、`−āⱼ/(1−f₀)` 进入）。

**独立复核（`verify/cuts.mbt`，新）逐条量**：① 组合**重新算一遍**必须重现它自称的那一行（`r_B = 1`、
右端项一致）；② 基变量必须是整数变量；③ 行里其余每个变量都必须能从一个**有限界**写成非负变量
（`x−lb` 或 `ub−x`，松弛变量按关系带符号）—— 这是舍入论证用到两次的性质；④ 右端项的小数部分
必须**离 0 和 1 都足够远**，且判据是"产生这一行的算术有多大"（`combination_noise`）而不是一个绝对值；
⑤ **割必须比产生它的算术大**：系数全在舍入噪声量级的割等于凭噪声宣布"没有整数点存在"，一律拒绝；
⑥ 交上来的那一行必须与推出来的那一行**逐项相同**（容差与包内其它判据同一口径）。
一律"拒了就停"：`mip` 侧把拒绝当成 `Unverified` 终止，而不是跳过。

**落地**：`verify/cuts.mbt`（新；`CutRow`/`CutCertificate`/`derive_cut`/`verify_cut`/`CutVerdict`）、
`simplex/cuts.mbt`（新；把基逆的一行导成**模型行乘子** `TableauRow`，为此在 `assemble` 里显式记下
行归一化符号 `row_sign` —— 它与 `row_dual_factor` 不是同一个数，用错会把每一个取自 `≤` 行的割都带上
符号错误）、`mip/cuts.mbt`（新；选行、选移位方向、只保留**当前松弛点违背**的割、每轮最多 10 条）、
`mip/search.mbt`（根节点割轮次）、`MipOptions::cut_rounds`（默认 2）与测试钩子 `cut_hook`、
`MipResult::cuts`、CLI `--cut-rounds` 与输出里的 `cuts=`、报告脚本的 `cuts` 列与 `-CutRounds`。

**测试（151 → 160 个，三目标全绿）**：手算例（`2x₁+2x₂ ≤ 5` 的半行必然舍入成 `x₁+x₂ ≤ 2`）、
**故意做错的割必须被拒**（右端项强一个单位的那条被拒，且用它算出的那个可行整点被同一条测试指出来 ——
证明这道门不是空转）、四种缺条件的见证被拒（基系数不为 1／右端项是整数／行里有自由变量／基变量是连续变量）、
**300 轮随机小模型穷举**：每条推出的割都在**整个盒子里所有满足模型的整数点**上复核通过，
而"强一个单位"的同一行被判无效且**确实**砍掉一个可行整点；搜索侧的三个测试：根上确实拿到割、
开割与关割**答案一致**、故意做坏的割让运行停在 `Unverified` 且 `cuts = 0`。

**实测（native release，300 节点 / 5000 迭代，`--max-rows 300`，每项都是同一实例前后对比）**：

| 实例 | 关割（`--cut-rounds 0`） | 开割（默认 2 轮） |
| --- | --- | --- |
| `flugpl` | 界 1177555.0000006263，无整数点，167 个仍在开 | 18 条割，界 **1177678.5205482745**，156 个仍在开 |
| `blend2` | 界 7.1440091896721185 | 16 条割，界 **7.182434016727598** |
| `dcmulti` | 界 187346.03419809768 | 20 条割，界 **187541.88522474252** |
| `gt2` | 界 13862.860469132655，**无整数点** | 20 条割，**obj 44072.99999998619**，界 **20001.29542069699**，gap 24071.7 |
| `rout` | 界 993.3181782835248 | 20 条割，界 **995.9585131088393** |
| `50v-10` | 界 2950.3933315716213 | 20 条割，界 **3032.505072008623** |
| `misc07` | obj 3935，界 1649.8920265796348，gap 2285.108 | 20 条割，obj **3160**，界 1638.75，gap **1521.25** |
| `p0201` | obj 8955，界 7517.5，gap 1437.5 | 20 条割，obj **8305**，界 **7557.5**，gap **747.5**（腰斩） |
| `khb05250` | obj 115460947，界 102828749 | 20 条割，obj **106940226 = 官方最优值**，界 **106936626**，gap 3600 |
| `22433` | **最优**，59 个节点 | **最优**，**33 个节点**，20 条割 |
| `pk1` | obj 45.00000000010315 | 20 条割，obj **36.000000000050356** |
| `markshare2` | obj 252.0000000010893 | 17 条割，obj **192.00000000369937** |
| `noswot` | obj −37，界 −43 | 10 条割，obj −34，界 −43 |
| `markshare1` | obj 345.0000000003541，界 0 | 15 条割，obj 478，界 0 |

14 个实例里 **10 个变好**（6 个界更紧、5 个当前解更好、`gt2` 从"没有整数点"变成有整数点、
`22433` 证明最优的节点数 59 → 33），**2 个的当前解在预算内变差**（`noswot`、`markshare1`，界不变），
其中 `noswot` 触发了一条真缺陷（下面）。

**本轮发现并修掉的一条真缺陷：割轮次可能让整轮运行更差**。`noswot` 上 20 条割让根的**重解跑不完**
（退化 + 加行），而原始写法把重解的结论直接接管，于是运行在 **3 个松弛之后**就结束、界是 `0`
（比"从未问过"严格更差：没有割时是 300 节点、界 −43）。修法：**一轮割只有在重解给出结论且不比原来更差
时才保留，否则整轮丢弃**（丢弃轮次花掉的松弛照旧计入 `nodes`/`verified`，只有模型改动不留）。
为什么是"不变差"而不是"严格更好"：有效割只会**缩小**松弛的可行域，所以"松弛最优朝反方向动"是算术而
不是信息 —— 但根上目标没动的一轮仍可能裁掉更深节点的解（`pk1` 正是如此：根上目标没动，当前解从 45 变 36）；
`noswot` 的坏结果来自"重解没有结论"，被这条判据挡住（实测 10 条割被保留，结果不再崩）。
**顺带记两处本轮自己写错又量出来的方向错误**，因为它们正是"每个结论都要为它依赖的假设付一次测量"的例子：
① 割的**违背量**符号写反（`≥` 割应是 `rhs − a·x`），于是**所有有用的割被丢掉、无用的被留下**
（表现是 `cuts=0`，用探针把"表行 → 见证 → 推导"逐段打出来才定位）；② "一轮是否改善"的**方向**写反
（最小化下割把松弛最优**推高**，最大化下压低，统一到最小化尺度都是**变大**），于是有用的轮次被当成没收益丢弃。

**报告（证据）**：`bench/mip-report.md` 已按新代码重新生成（32 实例：**1 最优 / 14 节点预算 / 17 跳过 /
0 被拒证书 / 3932 个松弛过校验 / 256 条割过 `verify_cut` / 9 个点通过独立复核**，此前是 8 个点）。
新增的 `cuts` 列让"这个实例用了几条割"与其它数字一样可追溯。
（`mod010` 仍是 1 个节点后"有松弛没跑完"的 `node-limit`，这一点开割前就存在，不是本轮引入的。）

**本轮限度（如实记录）**：割只在**根节点**做（节点上做割会破坏"节点 = 父 + 一条界"的形状与节点间热启动，
留到下一轮）；6 个实例在 300 节点内仍拿不到整数点（`flugpl` / `blend2` / `dcmulti` / `rout` / `50v-10`
界都变紧但没踩到整点）；`misc07` 的**界**略变差（1649.89 → 1638.75，同一预算下树的状态不同所致，
割本身不可能让根界变差 —— 已由"丢轮"判据守住）。

### Fixed — M5 第九轮：一个"可行却被判不可行"的节点（Phase I 的判据读的是漂移过的算术状态）

第八轮换出来的那条轨迹（`misc07` 节点 380、深度 14、`farkas margin -3.3e-16` 被拒）值得追到底。
把节点模型与证书导出后问外部求解器：**HiGHS 说这个节点可行，最优值 `1677.0000000000002`**
（行违反 2.3e-13）—— 也就是内核的 `Infeasible` **判定本身错了**，而"Farkas margin 约等于 0、略微为负"
只是**症状**：这个区域根本没有不可行性可证，因此不存在 margin 为正的 Farkas 射线。
这正是第二轮修过一类的那条最危险的错误（**剪掉可行节点**可能让搜索报出假最优），只是这次的触发原因不同。

**根因（在判定处打印量测值得到的）**：
`scaled=1.4544977130727579e-6`（判据是 `10×1e-7 = 1e-6`）、`artificial_sum=1.17e-8`、
`basic_artificials=7`、最大基本人工值 `5.5e-9`、`iterations=907`。
人工和已经小到 `1e-8`、基本人工变量都在零附近 —— **按人工变量的口径这个点是可行的**；
而"缩放行残差 1.45e-6"量的是 `state.values()`，它由 `xb` 推出，`xb` 是**乘积形式更新累积出来的**：
907 次枢轴之后基本值已经漂移到不再满足 `B x_B = b`（原始残差 4.7e-5）。
于是判据把"这套算术状态飘了多远"读成了"这些行无法被满足"。

**修法（沿用本项目已有的同一条先例）**：`finish_optimal` 在相信残差之前会先重建因子；Phase I 的判定
现在做同一件事 —— 先 `refactorize()`（按**本次运行真正求解的那个右端项**重算基本值），再量残差、再下判定；
重建失败则报 `NumericalFailure`，不拿一个漂移状态去判不可行。这样"测得超容差"才是关于模型的陈述。

**实测**：
- 同一条复现轨迹（临时换回第八轮的分支规则）在 `misc07` 400 节点下**不再被拒**：
  `node-limit`、400/400 过校验、并拿到整数点 `3940`；
- **真的不可行照样判得出来**：同一批输出里 `scaled=0.297、artificial_sum=3.0` 的节点仍是 `Infeasible`；
- 基线轨迹上的影响（300 节点口径 13 个实例）：**0 拒签**，8 个逐项不变或更好
  （`pk1` 45 vs 52、`blend2` 界 7.1440091896721185 vs 7.173806763107341、
  `rout` 界 993.3181782835248 vs 995.4438221639083），
  `misc07` / `p0201` 的整数点变差（3935 vs 3155、8955 vs 8095）——
  原因是被误剪的节点现在会被探索，于是下潜的启动点跟着变了；
  报告已按新代码重新生成（1 最优 / 14 节点预算 / 17 跳过 / **0 拒签** / 3957 个松弛过校验）。
- **测试**：151 个测试全绿，其中包含第二轮就有的同族不变量"由构造保证可行的模型**不得**被判不可行"。
  这条缺陷的确定性复现依赖一条特定轨迹（`misc07` 节点 380、907 次枢轴后的漂移），
  因此没有把 212 行 × 260 列的节点模型嵌进测试文件，而是把完整复现信息留在这里。
- **仍然存疑（如实记录）**：这次被拒既然源于"假不可行"，那么"Phase I 的对偶解是否**总能**充当
  Farkas 射线"这个问题**仍然没有证据**——能说的是：校验器每次都在独立复核，没有证据的射线不可能通过
  （拒了就停成 `Unverified`），而能通过的那些在这个缺陷被修掉之前也无法区分。也就是说
  **Farkas 射线的构造本身仍是待验证的一条**，不是本轮修的这个问题。

### Tried and reverted — M5 第八轮：伪成本分支（信号方向反了，且暴露一个仍然活着的已知缺陷）

第七轮把"加厚下潜"判了死，于是这一轮按 roadmap 换成**分支规则**：不再永远挑"最分数"的变量，而是把搜索
**本来就在做、只是没留下来**的测量留下 —— 每个子节点解出的目标值减去父节点的界，就是这次分支**花了多少**；
按变量、按方向做**单位分数化**的平均（除去的部分正好能让平均值外推到当前分数化上），
分支时取"两侧预测代价之积"最大的变量（乘积规则：哪一侧预测为无用，乘积就趋近 0，正好避开"分不出区别"的
分支）。为了让两条规则可比，**没有任何历史时回落到最分数**，所以运行的前几个节点与改动前逐位一致。
实现完成、151 个测试全绿。**但实测是负的，且有一处是灾难性的**：

| 实例（300 节点口径） | 改动前 | 伪成本（仅"还没有整数点"阶段生效） |
| --- | --- | --- |
| `misc07` | obj **3155** / 界 1670.0000000046505 | obj **3145**（更好）/ 界 1652.5（更好）|
| `p0201` | obj 8095 / 界 7555 | obj **7795** / 界 **7485**（都更好）|
| `khb05250` | obj 115460947 / 界 102828749 | obj **112274426** / 界 **103604165** |
| `pk1` | obj 52.00000000006396 | obj **47.0000000000529** |
| `dcmulti` | 无整数点 | **obj 189389.4**（第一次拿到）|
| `noswot` | obj -37.00000000000531 | obj **-36.0**（变差）|
| `gt2` | 界 **13862.860469132655** | 界 **18382.435562070445**（差 32%）|
| `flugpl` / `blend2` / `rout` / `50v-10` | 见报告 | 界分别变差（1177555→1178648、7.1738→7.2084、995.44→999.89、2950.39→2962.03）|

决定性的两组测量：

- **把它放在"证明最优"这条口径上，结果是灾难**：`22433` 用原规则 **49 个节点证到最优**，
  换成本轮规则后 **20000 个节点连一个整数点都没找到**（界停在 21477.00000001858）；
  `flugpl` 同样从"12411 节点证到最优"变成"20000 节点无整数点"（`khb05250` 超时未跑完）。
- **一条被它换出来的轨迹撞上了仍然活着的已知缺陷**：`misc07` 在新轨迹上于节点 380（深度 14）被拒 ——
  `farkas margin failed: -3.2979747998528007e-16`（预算 250/400/600 都在同一点复现）。
  这正是 CHANGELOG 早前记过的"Phase I 最优点的对偶解不总满足 Farkas 射线约定"那条**未修**缺陷
  （本轮基线轨迹在 300/400/600 节点都没撞上，但缺陷本身没修，换条轨迹就能撞上，
  而它会让 `bench/report-mip.ps1` 拒绝写报告）。

结论（按纪律回退，四处结论都值得记住）：①**"目标值退化"是界（对偶）的信号，不是找到整数点（原始）的信号**
—— 用它当分支分数，等于系统地偏好"离当前点更远"的分支，所以在 `22433` 这种"松弛几乎整"的实例上
把搜索推离了整数点；找整数点该用的信号是**下潜那一族**（已经在第六轮做到顶）。
②两条规则各赢一半意味着报告的证据面会变成"一半界更好、一半更差"，而这**不是**一个可以宣称的改进；
按"没测出收益不留"回退（工作树 == `942d922`，报告无需重新生成）。
③**Farkas 证书这条已知缺陷现在有了一条可复现的触发轨迹**（`misc07` 节点 380、预算无关），
它已经从"挡不住现在的报告"变成"换一条分支轨迹就挡住报告"，因此它应该是下一轮的第一个目标，
而不是继续试分支规则。

### Tried and reverted — M5 第七轮：可回溯下潜（实测不改善它自己的指标，按纪律回退）

第七轮的假设：`flugpl` 这类"走进死区"的启动白费，是因为"某变量两个方向都不可行"直接结束了整次启动。
于是把下潜改成**带决策栈的 DFS**：每一步把（变量、决策前的界、决策前的点/目标/基、已试过哪一侧）压栈，
遇到死区就回退一层、试那一侧**没试过**的方向，栈空了才结束启动。实现完成、白盒测试（用注入求解器构造
"近侧进死区、远侧有整点"的模型）通过 —— **但实测不改善它自己的指标**：

- 同一口径 300 节点：13 个到预算的实例里 **12 个逐位相同**；8 个有整数点的实例目标值一字不差，
  6 个没有的仍然没有 —— 也就是说**回退一次都没换来一个新整数点**；
- `flugpl` 的界从 `1177555.0000006263` 变成 `1176580.000000414`（最小化实例，界变小 = 略好），
  但它的"仍在开"从 **167 降到 119**：走法把更多预算花在了回退探索上，树少走了一截；
- 把 `dive_steps` 临时提到 120 再测：**仍然没有新整数点**，界继续略好
  （`flugpl` 1173873.1699350239、`blend2` 7.0987634728748885、`gt2` 13846.746527825318），
  而树的工作量塌陷（仍在开分别只剩 43 / 55 / 61）。这说明**瓶颈是预算总量，不是走法不够彻底**：
  在 60 步预算里，走法连"换一条路"都来不及走完，加厚走法只是在同一份预算里重新分配。

结论与处置：这次回退改的是**搜索的彻底程度**，而这一族实例缺的是**预算**（以及更好的界），
所以按既定纪律（没测出收益、却测出代价的改动不留）**回退**，代码保持第六轮的贪心下潜。
回退后工作树与提交 `c504894` 逐项一致，报告无需重新生成。
下一步的杠杆是**割平面**（把界本身推下去）与**分支规则**（伪成本 / 受限强分支），不是继续加厚下潜。

### Fixed — M5 第六轮：下潜的步数预算按实测放大（有整数点的实例 5 → 8）

报告里"到预算仍拿不到整数点"的 9 个实例，先用探针把失败原因分开（三类，都是实测）：

- **步数用光，但走法在进展中**（markshare1/2、pk1、blend2、gt2）：`pk1` 走 30 步已从 12 个分数变量降到
  **1 个**，`markshare1` 从 6 降到 2 —— 差的是步数，不是方向；
- **走进死区**（flugpl）：某变量两个方向都不可行。这不是"卡住某个变量"，而是**这一区域根本没有整数点**
  （`x ≤ ⌊v⌋` 与 `x ≥ ⌈v⌉` 覆盖了所有整数取值，两边都不可行即无整数点），所以"换个变量接着走"是**错的** ——
  代码保持放弃，这个判断本身经得起推敲；
- **从未启动**（dcmulti / rout / 50v-10）：启动判据①（`depth >= 分数变量数`）在一整段预算里都不满足。

修的是第一类，而且是**修正一个下界**：`2×分数变量` 曾被当作"这次走法需要的步数"，但一步会重解，
重解可能让**另一个**变量变成分数，所以那个数只是下界。现在每次启动的预算是 `dive_steps`，
默认 **30 → 60**。代价仍由 `dive_attempts × dive_steps` 界住（300 松弛预算下的 120），
树少走多少在报告的"仍在开"那一列里可见，不是藏起来的。

**实测（同一口径：300 节点 / 300 行，同一份报告脚本）**：有整数点的实例 **5 → 8**，新增
`markshare1` **345.0000000003541**（下界 0）、`pk1` **52.00000000006396**（下界 1e-12）、
`markshare2` **252.0000000010893**（下界 0）；原有四个不变（`noswot` **-37**、`misc07` **3155**、
`p0201` **8095**、`khb05250` **115460947**）。报告"点复核"列 **5 → 8**。
仍无整数点的 6 个：`flugpl`（死区）、`blend2` / `gt2`（有进展但 60 步仍不够）、
`dcmulti` / `rout` / `50v-10`（判据①不满足，下潜从未启动）—— 这三类分别需要可回溯下潜、
更大的预算或割平面，限度写在 `docs/roadmap.md`。
**测试 +1（共 151）**：白盒钉住下潜的两个契约 —— 花费不超过预算、**最后一步落到整点也算数**
（同一模型给 1 步预算必须什么都不报、给 2 步才落到 `(3, 0)` 或 `(0, 3)`），
并且返回的点必须过行 / 界 / 整数性三项局部检查（由测试自己算，不采信下潜的自述）。

### Added — M5 第五轮：整数模型接进公开入口（松弛值不再是"答案"）

在此之前顶层 `Model::solve` 遇到整数变量返回 `NotSolved`，而 `mip` 已经在底层能答。这一轮把两者接上，
并且顺手修掉一处**文档对能力的误述**：包文档曾写"整数模型按 LP 松弛处理"，而代码实际是拒绝的
（有测试钉着"拒绝"这一行为）—— 现在这两句都改成了事实。

- **`Model::solve` / `solve_with` 把整数模型交给分支定界**，不再经过内核求解松弛，也**依旧不做化简**
  （答案不能取决于化简碰巧定住了什么）。公开面的新契约，每条都有测试：
  - `SolveStatus` 增加 **`NodeLimit`**：预算到顶时 `values` 是**可行但未被证明最优**的整数点，
    不是"没有结论"也不是"最优"；
  - `Solution` 增加 **`nodes` / `bound` / `gap`**：`nodes` 是这次运行解掉的松弛数（含下潜的），
    `bound` 是仍在界的最好下界、`gap` 是两者的差；线性求解下 `bound` = `objective`、`gap` = 0
    （线性解就是它自己的界），两个字段因此在所有路径上含义一致；
  - `SolveOptions` 增加 **`max_nodes`**（默认 20000，与 `mip` 默认取同一来源，避免两套默认值漂移）；
  - **映射规则不留模糊**：只有搜索自己的 `Optimal` / `Infeasible` 变成公开的 `Optimal` / `Infeasible`；
    **松弛无界、证书被拒、模型非法一律 `NotSolved`** 并带搜索自己的原话 —— 松弛的改善射线不含整数性、
    被拒的证书说明内核在某处错了（那是关于内核的话，不是关于这个模型的话），两者都不构成任何结论。
- **实测（`cmd/main` 演示里的数字）**：`max 5x + 4y, 6x + 4y ≤ 13, x,y ∈ [0,2]` 整数 —— 松弛给
  **12.1667**，公开入口给 **10**（`(2, 0)`，`nodes=9`，`bound=10`，`gap=0`）；同模型给 1 个松弛的预算
  则返回 `NodeLimit`（`nodes=1`、`bound=12.1667`、`gap=1e300`、无整数点），**不**把松弛值当答案。
- **测试 +3（共 150）**：整数模型经公开入口求到整数最优而非松弛值（并钉住"点整 + `bound`/`gap`"契约）；
  节点预算报成 `NodeLimit` 而不是最优性主张；无整数点经树穷尽证成 `Infeasible`；松弛无界不报成模型无界。
  `cmd/main` 的演示从"整数模型尚不支持"改成两段真演示（整数解 + 一次预算到顶），
  且**没有整数点时不再打印 `objective`**（预算到顶而没找到点的运行里那个 0 不是目标值 ——
  这正是 CHANGELOG 早前记过的"失败运行没有目标值"那类误读）。

### Added — M5 第四轮：取整启发式（先拿到一个整数点，界才开始剪枝）

第一份报告把缺口指得很清楚、而且是实测的：14 个到节点预算的实例里 **13 个"还没有整数点"**。
best-bound 纯分支定界先找界、不先找点，而**没有最优解可比的界剪不掉任何东西**。
这一轮加的是最便宜的那一类原始启发式：

- **下潜**（`mip/search.mbt` 的 `dive_for_incumbent`）：取最分数的整数变量、走它**较近的那一侧**、重解，
  如此往复。每一步都是"受限模型"的一次松弛，所以走到的点**必然可行**（每步都过 `@verify`），
  而"没有分数变量"就是它停下的条件，所以那个点**必然整数** —— 于是它可以当最优解的候选
  （只在比现有最优解更好时才接受）。某一侧回来不可行就**撤销**它、在同一深度试另一侧；两侧都不行就
  放弃这次下潜 —— 下潜不承担证明责任，它只提供候选，树该怎么走还是怎么走。
- **什么时候值得下潜：三条判据都是量出来的，不是拍出来的阈值。**
  ① `depth >= 分数变量个数` —— 下潜要自己决定这个节点尚未决定的每一个整数，所以只有在搜索已经决定了
  同样多的地方才划算。实测：`noswot` **落到点的那次下潜在深度 29、剩 15 个整数**处；
  而同一判据在深度 0/1 启动的那些（markshare1、pk1、flugpl、blend2、gt2、markshare2）
  **每一次都把步数用光、一个点也没落到**。
  ② `2×分数变量 <= dive_steps` —— 每个未定整数最多两次松弛（一次成功、一次试错回头）。
  ③ `dive_attempts` 次启动上限 —— 下潜花掉的预算就是树拿不到的预算（实测：把它从 2 提到 4，
  落到的候选完全相同，只是失败实例的树少走了约 20%，所以默认留在 2）。
- **下潜的松弛就是这次运行的松弛**：同一个求解器、同一台独立校验器，因此计入 `nodes` / `verified`
  （`verified == nodes` 这条不变量不变），也受**同一个**节点预算约束；下潜途中证书被拒同样把运行停成
  `Unverified` —— 一次下潜不是"比树更该信任内核"的理由。
- **实测（300 节点 / 300 行口径，同一份报告脚本前后对比）**：有整数点的实例从 **1 个变成 5 个** ——
  `noswot` **-37.00000000000335**（下界 -43.00000000004598，gap 6；这是该实例第一次拿到整数点，
  此前 5000 节点都拿不到）、`misc07` **3155.0000000123127**（下界 1670.0000000046505）、
  `p0201` **8095.000000008822602**（下界 7554.9999999863385）、
  `khb05250` **115460947.00018857**（下界 102828749.00007676），加上本来就证完的 `22433`。
  报告里"点复核"那一列 **1 → 5**，"还没有整数点" **13 → 9**，
  `bench/check-mip-objectives.ps1` 仍是 0 违反、0 未校验节点。
- **限度写在明处**：下潜是启发式，9 个实例仍然拿不到整数点（它们的 LP 在一整段预算里都没有
  "大部分整数已定"的节点，判据①因此从不满足：dcmulti / rout / 50v-10 一次都没启动，
  其余的启动了但用光步数）。要更进一步需要割平面或更强的下潜（回溯式），这一轮没有做。
- **测试 +3（共 147）**：同一模型同一预算下"开/关启发式"的差别（关掉时三个松弛之后仍无候选；
  打开时同三个松弛之内落到 `(3, 0)` 或 `(0, 3)`，目标值 3 通过独立复核且行/界/整数性三项全过）；
  启发式**不把预算变成答案**（预算太小仍停在 `NodeLimit`，且若给出了点也必须过独立复核）；
  以及下潜规模的计数（只数整数变量、容差内的值算整数）与 `floor/ceil/round` 的既有用例并列。

### Added — M5 第一轮（分支定界：整数模型的答案必须是被证明的）

- **新包 `mip`**（`mip.mbt` / `search.mbt`）：分支定界骨架。
  - 节点只存"父节点 + 一条收紧的界 + 父节点的最优基"，模型沿父链从根重建（`O(depth)` 次改界），
    因此节点树的内存不随模型大小乘以节点数增长；界以**两端一起存**的方式落进节点，
    否则子节点会与父节点同宽、树永远不收窄（本轮真踩过这个坑，第一个测试就抓到）。
  - **热启动**：子节点用 `solve_model_with_basis` 从父节点留下的基重新优化 ——
    界改动不动检验数，父基对偶可行，对偶单纯形只需修回原始可行性。M3 造的对偶单纯形在这里第一次有了用处。
  - 搜索：best-bound（堆按最小化口径的键排序）、按当前最优解剪枝、对**最分数**的整数变量二分；
    两个子节点各自只动一端界，越界的一侧（`x ≤ ⌊v⌋` 或 `x ≥ ⌈v⌉` 已在变量范围外）不生成。
  - **分支前每个松弛都由 `verify` 独立复核**：证书被拒就停在 `Unverified` 并把节点编号、深度与
    校验器的测量一起报出来，绝不拿未被证实的松弛去分支。`verified` 字段就是这件事的计数
    （开启校验时 `nodes - verified` 恰好是"没有得出结论的松弛"数）。
  - 状态语义：只有树穷尽（或每个开节点的界都被当前最优解关掉）才是 `Optimal`；
    节点预算到顶是 `NodeLimit`，给出**当前最优解**与**仍在界的最好的下界**（`gap` 是两者的差）；
    松弛无界是 `UnboundedRelaxation`（松弛的改善射线不含整数性，因此不声称模型无界）；
    模型校验失败是 `Invalid` —— "两条界互相矛盾"与"没有整数点满足这个模型"不是一句话。
- **`MipOptions::node_solver`**：可替换节点求解器，只为测试而存在 —— 它让"故意做错的求解器"能在
  节点这一层被 `verify` 拦住（M5 完成标准的第三条因此是可测的，而不是可声称的）。
- **CLI `--mip` / `--max-nodes`**：分支定界路径，报告里除状态外还打印
  `nodes` / `verified` / 解的目标值 / 仍在界的下界与 `gap`，并把解**独立复核**一遍：
  行与界用 `presolve` 的 `max_row_violation` / `max_bound_violation`，整数性由报告自己新写的
  `max_integrality_violation` 量（不借用搜索内部的取整），任一项不过就退出码非零。
  证书被拒同样退出码非零。
- **实测（native release，每个节点都通过校验）**：
  `flugpl` **12411 节点 / 目标 1201500.000000307**（MIPLIB 官方最优 1201500，相对 2.6e-13，1 秒）、
  `khb05250` **7543 节点 / 106940226.00006**（官方 106940226，5.7e-13，238 秒）、
  `22433` **59 节点 / 21477.00000002**（官方 21477，9e-13，2 秒）；
  三个解在原模型上的行违反 ≤ 1.2e-10、界违反 0、整数性违反 ≤ 4e-11。
  节点预算下的过程状态也已实测：`markshare1` 在 20000 节点内给出 61（下界 0），
  `pk1` 在 2000 节点内给不出整数点（下界 0.602）—— 两者都**没有**被说成最优。
- **`bench/report-mip.ps1`**：分支定界报告脚本，与线性报告同一套纪律 —— 内核退出码非零、
  条目数不符、出现被拒证书、或解没通过复核，都**拒绝写报告**（当前正是这种状态，见下面的已知缺陷）。
  `bench/check-mip-objectives.ps1` 把报告里每个 `optimal` 的目标值与 MIPLIB 官方最优值**逐一取等**
  （松弛只要求不等号，证明最优是更强的主张），并顺带断言 `verified == nodes`。
- **测试 +18（共 140）**：`mip` 包 16 个（小规模已知最优、无整数点被证明不可行、松弛无界不被说成模型无界、
  节点预算不声称最优、哨兵上界下的分支、校验计数与开关一致、**故意做错的松弛被拒**、
  **错的 Farkas 射线被拒**、无结论的松弛结束于上限而不是 `Unverified`、非法模型是 `Invalid`、
  以及 60 个随机模型与**穷举对拍**的一致性与第 3 条不变量）；内核比值检验回归 1 个；
  校验器缩放回归 1 个。

### Fixed — M5 的校验在真实例上抓到的三个确定性缺陷（两个已修）

- **内核：比值检验曾经允许负步长（`simplex/revised.mbt`）**。基本变量只要**略微**越出它正走向的那个界
  （长跑的舍入就会造成），`xb/rate` 或 `(cap−xb)/(−rate)` 就是负数；而严格那一遍的 `ratio_min`
  从 0 开始比较，第一个负比值的行会直接胜出 —— 于是进入变量**朝反方向走**，所有基本值同时离开界，
  而越界之后的行又被"轻微越界"守卫**跳过**，滚雪球到基本人工变量变成 −4。
  症状是 Phase I 把一个**可行**模型报成 `infeasible`（MIPLIB `22433` 的一个分支节点；
  外部求解器 HiGHS 在同一模型上给出最优解 21331.49615，与内核修好后的值一致到 4e-13）。
  抓它的链条正是本轮的设计：节点松弛证书被 `verify` 拒绝 → 把节点模型与证书导出来 →
  用独立求解器确认模型可行。修法三处一致：比值检验的两遍都把负比值钳到 0（"已经越过界就没有前进空间"），
  `row_step` 也不再返回负距离。**副作用是好的**：1000 行口径的 LP 全清单从 18 最优变成 **20 最优**。
- **校验器：列互补松弛用了绝对阈值（`verify/verify.mbt`）**。判"检验数是否为零"用绝对 `1e-7`，
  而本包其余每处判据都是带尺度的。`khb05250` 的第 11 列检验数是 `2.7e-7`、其尺度是 `5e6`
  （相对 5e-14，纯粹舍入），却被当成真实价格，于是要求该变量落在它没有理由到达的界上，
  把一份对偶间隙 4e-13 的**正确**证书判为违规 0.47。改成两半都相对：检验数相对自身尺度、
  距离相对变量自身大小，两者相乘。修后 `khb05250` 的证书 `accepted`，
  而"可行但非最优""乘子看着合理却不可行"等拒绝用例全部照旧被拒。

### Fixed — M5 第二轮：三例被拒证书背后有两个"不可行"判定是错的

第一轮记录的三例（`misc07` / `blend2` / `noswot`）这一轮用独立求解器逐例查清：**三个节点其实都是可行的**
（HiGHS 在同一模型上分别给出最优解 1647.857142857 / 7.727699757815 / -39.830059908477）——
也就是说内核的 `Infeasible` **判定本身**错了，校验器拒签恰好是正确的行为。三例是三个不同的缺陷，前两个已修：

- **Phase I 把"刚好越过容差"的测量当成了证明**（`simplex/revised.mbt`）。`misc07` 的节点报告
  `scaled row residual 1.0949e-7`、`artificial sum 6.078e-9`，而可行性容差是 `1e-7` —— 只超出 9%。
  容差本身就是"这个尺寸以下分辨不出真假"的界线：比值检验的带与舍入负值的钳制都允许等式系统被破坏到
  约"容差 × 该行系数和"，所以一个**可行**的点完全可能量出略高于容差的残差。此时报 `Infeasible` 不是保守，
  而是**把可行节点从分支定界树里剪掉** —— 足以让搜索报出一个不是最优的"最优解"。
  修法：判定不可行要求实测违反量**明显**超过容差（`infeasibility_margin = 10`），且残差按**这次运行真正
  求解的那个右端项**（含退化微扰）来量；报给调用方的数字、以及最终答案的门禁仍然对**未微扰**右端项测量，
  所以这里放宽不等于那里放宽。修后该节点解到 1647.8571326（与外部求解器差 6e-9，在该实例既有漂移范围内）。
- **对偶单纯形把"这个基救不回来"当成了"模型不可行"**（`simplex/dual.mbt`）。`blend2` 与 `noswot` 的节点在
  **热启动**路径上被判不可行，而同一模型冷启动直接给出最优解。"没有一列能把这行拉回来"是关于**当前基**的话，
  不是关于**模型**的证明。修法：这条路径不再下这个结论，把判定交给冷启动（Phase I 最小化人工和 + 残差实测），
  与"对偶可行性不变量一旦丢失就交给冷启动"同一条原则 —— 热启动可以更慢，不可以更自信。
  随之**删除**了为这条判定服务的 Farkas 射线机制（`State::farkas_ray` 与 `dual_ray_row` / `dual_ray_sign`）：
  一条没人验证的射线正是两个假"不可行"的来源；将来要恢复，前提是它能通过一个真正的自检。
- **校验器的符号检查没有按模型尺度缩放**（`verify/verify.mbt`）。乘子违反量原本按它自己的大小缩放
  （`|y| / (1 + |y|)`），对小的乘子等价于绝对阈值。现在按"这个错符号能造成多大影响"来量：
  乘子 × 该行最大系数，再与代价向量的大小相比（两边同为检验数的量纲）；Farkas 射线没有代价向量，
  同一式子退化为"按它所作用的那行来量"。
- **实测**：`misc07`（300 节点）与 `blend2`（300 节点）不再是 `unverified`，转为正常的 `node-limit` 过程状态；
  1000 行口径 LP 全清单仍是 **20 最优 / 11 跳过 / 0 拒绝 / 1 其它**（`fast0507`），逐项与改动前一致。
  测试 **142 全绿**（新增 2 个：由构造保证可行的模型**不得**被判不可行 —— 大系数、大偏移、含等式行；
  以及热启动路径**不得**下不可行结论）。

### Fixed — M5 第三轮：`noswot` 上被拒证书的三例全部定论，三例都是内核的错

定论 `noswot` 节点 182 的 `dual signs failed: 2.14e-6`（一个应当非负的乘子略微为负）需要把
"内核在退化点算错了对偶值"与"校验器对这个家族过严"分开。分开它的工具是**内核自己的残差测量**：
在 `finish_optimal` 里量 `Bᵀy = c_B` 的残差，`noswot` 的 100 次节点求解里绝大多数是 `1e-15` 量级，
**有 2 次是 `3.7151039578020573e-7`** —— 与那个"略微为负的乘子"逐位相同；那条基上真正的最优乘子是 `0`。
结论是前者：**校验器拒签是对的，被拒的是内核的证书**。顺着这条线又暴露出三个更严重的缺陷（依次被
同一台校验器拦住），因此这一轮修了四处，全部在内核：

- **对偶解在病态基上丢掉精度**（`simplex/revised.mbt`：`State::duals` 末尾加一步残差迭代精化）。
  三角求解保证的是**残差**小，不是**误差**小 —— 病态基上两者差一个条件数。`noswot` 那个节点上
  `y` 差了 `3.7e-7`，而 `y` 喂给内核的每一次决策（检验数、定价、还有没有列能改善目标），
  所以修在 `duals` 里而不是证书边界上：把残差 `c_B − Bᵀy` 当右端项交回同一个乘积形式求解器再解一次，
  `y ← y + Δ`。同一节点残差 **3.7e-7 → 3.97e-13**。代价是每次对偶求解多一趟 `O(nnz(B))` 与一次三角求解。
- **方向求解没有自检，`A·d = aⱼ` 可以错得离谱**。修完上一条后 `noswot` 的第一例拒签从节点 182 挪到
  节点 5084，性质更糟：内核把一个**有界**节点报成 `unbounded`（同一模型上 HiGHS 给出最优 `-43`）。
  在迭代循环里量方向残差后抓到的是一次枢轴 `d` 与自己那一列差了 **20**（列本身也只有 20）——
  于是那一步把基本值推出可行域 20，此后的比值检验在**不可行**的状态上"找不到限步行"，就成了假无界。
  修法：每迭代量一次方向残差（一趟 `O(nnz(A))`），超容差先重建因子再量，仍然超就按 `NumericalFailure`
  结束（交给既有的 Bland 恢复路径）——**不在没有依据的方向上枢轴**。
- **无界结论不检查它所依据的那个点**。无界性证明 = 可行点 + 改善射线，而"跑在可行点上"此前只是注释里的
  假设，`code == 1` 分支直接返回。现在这个点必须通过行残差自检（与 `finish_optimal` 同一条测量），
  过不了就报 `NumericalFailure`，不报 `Unbounded`。
- **非负性自检的尺度与校验器不一致**（`max_negativity`）。它按**整个解的最大元素**缩放，
  于是一个 `-7.7e-7` 挨着 `1e5` 量出 `7.7e-12` 顺利过闸，而 `verify` 按**每个变量自己的大小**
  （`raw/(1+|xⱼ|)`，界本来就该这么读）量出 `7.7e-7` 并拒签 —— 自检比它所替代的检查更松就不是自检。
  现在两者同口径。配套地，**Harris 带允许的越界量改按"步长产生的那个值"的大小算**（原来按越界前的值：
  一个从 6.7 落回零附近的基本值因此被允许越出 `-7.7e-7`），落到界外的部分被压回容差以内。
- **实测**：`noswot` 5000 节点 **0 拒签** —— `node-limit`，4970/5000 通过校验，30 个"没有结论的松弛"
  被计为开节点（不当作被证实的结论），下界 `-43.000000000043244`（官方最优 `-43`）；
  同一命令在本轮之前停在节点 182。三个已证最优的实例逐一复核不变：
  `flugpl` **12411 节点 / 1201500.000000307**、`khb05250` **7543 节点 / 106940226.00006**、
  `22433` **49 节点 / 21477.00000002**（上一轮是 59 节点，轨迹变了、答案没变）。
- **测试 +2（共 144）**：病态基（条件数约 1e10，两个法向相差 1e-9 的等式）上的证书不变量 ——
  乘子必须解 `Bᵀy = c_B`（残差 ≤ 1e-11）且校验器接受；以及非负性按变量自身尺度量
  （`-7.7e-7` 挨着 `1e5` 必须量出 `7.7e-7`，而不是 `7.7e-12`）。
  定论这一步的过程物（`TEMPDUMP` 探针、`mip-dump-*.py` 分析脚本）用完即删，不进仓库。
- **第一份 MIP 报告终于写出来了**（`bench/mip-report.md`，`-MaxRows 300 -MaxNodes 300 -MaxIterations 5000`，
  提交 `315107f`）：32 个实例 **1 最优 / 14 节点预算 / 17 跳过 / 0 被拒证书 / 0 其它**，
  3944 个松弛逐个过校验；`bench/check-mip-objectives.ps1` 对 `22433` 与官方最优值**取等**
  （21477.0000000198 == 21477，0 违反）。报告同时也说明了下一个缺口：14 个 `node-limit` 里
  **13 个"还没有整数点"**（纯分支定界 + best-bound 的固有现象），只有 `22433` 在 300 节点内证完。
  `mod010` 那一条是"1 个松弛没有得出结论"（被计为开节点，而不是被当成结论），正是新自检该有的样子。
- **这套自检的代价是实测的，如实记下**：同一口径的 LP 全清单（1000 行、1200 迭代、presolve）
  仍然 **20 最优 / 11 跳过 / 0 拒绝 / 1 其它**，逐实例结论不变，但总枢轴数 **16947 → 21620（+27%）**，
  `fast0507` 在同样的 20000 枢轴预算下从 `160.00896` 变成 `166.08033`（3.8%）。
  单看实例是两个方向都有：`danoint` 2151→1761、`22433` 1270→1087、`30n20b8` 646→575 变少，
  `mod010` 7606→12827、`bienst1/2` 577→653 变多 —— 每迭代多一趟 `O(nnz(A))` 的方向自检、
  以及按"步长产生的值"取容许量导致的更多退化枢轴，都会改变枢轴路径。
  这是"可控的作弊空间换不可控的错答案"这一取舍的当前价格，值得下一轮用更省的判据（例如只在
  主元可疑时量方向残差）换回来。

### Closed — `noswot` 节点 182 的对偶符号缺陷（第三轮定论，见上）

- 那条缺陷此前记为"未修"，现已定论为**内核缺陷**而非校验器过严：该基上真正的最优乘子是 `0`，
  内核算出 `-3.7e-7`。定论它的证据链是：节点模型与证书导出 → 外部求解器确认该节点最优值确为 `-43`
  → 在内核侧量 `Bᵀy = c_B` 的残差（100 次求解里 2 次是 `3.7e-7`，其余是 `1e-15`）。
  `bench/report-mip.ps1` 也因此第一次有了可写的完整报告。

### Added — M3（对偶单纯形热启动：改一条界之后重新优化，而不是从头再来）

- **有界变量对偶单纯形**（`simplex/dual.mbt`）：原始方法要有**可行**基再去最优，这个方法要有**最优**基
  （即对偶可行）再把可行性修回来 —— 这正是"改一条界"之后正确的工具：界改动不动任何检验数
  （检验数只取决于基与目标），所以上一个最优基仍然对偶可行，唯一的问题是有基本值落到界外。
  实现：取最越界的行、用 `B⁻¹` 的该行做权向量、经典对偶比值检验（`min |rⱼ/αⱼ|`，再在相对带内
  取最大 `|αⱼ|` 以稳住枢轴）；步长由调用方传给 `pivot`（与原始方法同一条不变量）。
- **热启动入口**：`SimplexResult` 新增 `basis`（最优基快照）、新类型 `SimplexBasis`，
  以及 `solve_model_with_basis` / `solve_standard_with_basis`。基只按**形状**受信：行数/列数不符、
  或入口处对偶不可行（换了目标、界把某列范围压塌等），一律退回冷启动 —— 热启动只允许是加速，
  不允许是另一个答案。
- **实测（native release，改一条**会切掉当前最优解**的界）**：热启动 vs 冷启动的枢轴数
  `flugpl` 1/21、`noswot` 6/135、`30n20b8` 14/558、`dcmulti` 32/559、`22433` 49/1008；
  状态与目标值全部一致，且每个热启动结果都带一份**校验器接受**的证书；
  1000 行口径全清单 18 最优 / 11 跳过 / 0 拒绝，与改动前逐实例相同（冷启动路径未动）。
- **过程中抓到一个真 bug，两个工具一起抓的**：`State::new_with_basis` 只**加上**了新基的
  `is_basic` 标记，没清掉冷启动基留下的旧标记，于是不在基里的列被当成基本列 —— 定价与对偶可行性
  检查双双跳过它们，`dcmulti` 因此报出**比冷启动答案高 18 个单位**的"最优解"。
  抓到它的正是 M4 的独立校验器（`--reoptimize` 打印 `verify=REJECTED(dual signs failed: 0.996)`，
  冷启动那份 `accepted`）。修法：进热启动基前把标记全部清零；另加一道永久防线 ——
  对偶单纯形结束时报最优之前，**再查一次对偶可行性**（一趟 O(nnz)），不成立就交给冷启动。
- **新 CLI 探针 `--reoptimize`**：求解 → 收紧一条会切掉最优解的界 → 分别用热启动与冷启动重解，
  打印两者枢轴数与证书校验结果。"热启动更省"这句话从此是**实测**而不是假设。
- **测试 +4（共 122）**：热启动答案与冷启动一致（状态/目标值）、收紧到不可行时对偶单纯形给出的
  Farkas 射线被独立校验器接受、形状不符的基被拒后退回冷启动、120 个随机模型语料上
  逐例对拍（三种结论都覆盖，并断言语料整体上热启动枢轴数更少）。

### Added — M4（证书与独立校验器：把"求解器说它对"变成"有人能独立复核"）

- **新包 `verify`**：只依赖 `core` / `model`（**不 import `simplex`**，耦合方向由清单文件本身证明），
  从模型与证书数组出发独立复核三种结论：
  - `Optimal`：原始可行性 + 乘子符号 + 对偶可行性（检验数与界相容）+ 互补松弛（行与列两侧）+
    **对偶间隙**（点上的目标值 = 乘子能证明的最好下界）+ 证书自报的目标值；
  - `Infeasible`：**Farkas 射线**（同一套下界取 `c = 0`，要求严格为正），
    此前内核只能给出"实测违反量"，那不是证明；
  - `Unbounded`：**可行起点 + 无界射线**（射线不被行、界卡住，且让目标严格改善）。
  乘子约定与弱对偶推导写在包文档里；行乘子的回映（关系符号 + 整行取反）由 `assemble`
  在归一化旁边算出（`row_dual_factor`），不在使用处临时推导；
- **内核产出证书**：`SimplexResult` 新增 `duals`（模型行口径）与 `ray`（模型变量口径）；
  `Unbounded` 同时给出可行起点（`values`），因为"无界"的证明是点 + 方向两件东西；
- **JSON**：`Certificate::to_json` / `from_json`（自写读写，库包不引第三方依赖；
  非有限数写成 `null` 且读回失败，形状不对整体拒绝而不是半读）；
- **CLI**：`cmd/parse --verify` 求解后校验并把每项实测值打出来；
  `--certificate <file.json>` **只读文件、不求解**地复核（同一文件对另一模型被拒、退出码 1），
  被拒的证书计入失败因此退出码非零；
- **验证**：118 个测试全绿（新增 14 个校验器测试 + 6 个内核证书测试）。被拒的错解包括
  "可行但非最优"、"偏离可行域一点点"、"看起来合理但与点不配套的对偶"、目标值声称错、
  形状不对、符号错的 Farkas 射线、零射线，以及**故意做错的求解器**给出的答案；
  200 个随机模型语料上三种结论都出现且证书全部通过；真实实例
  `flugpl` / `blend2` / `noswot` / `30n20b8` 的 `--verify` 全部 `accepted`（对偶间隙约 1e-13）；
- **过程中修掉一个真 bug**：`box_minimum` 按"严格正/负"判断检验数是否指向无穷，而对偶可行性检查带容差，
  两处阈值不一致导致 `flugpl` 的一次**正确**求解在其余六项全过的情况下被判 `duality gap = 1e300`；
  现在两处共用一个带尺度带容差的判据；
- `moon.mod` 描述的承诺面收敛：删掉尚未实现的 "dual simplex" 与 "branch-and-cut"，
  保留已落地的证书能力（发布前的"承诺 = 证据"纪律）。

### Added — M3（增益型定价：按"实际能走多远"选进入列，而不是只按检验数）

- **新选项 `gain_candidates`（默认 32）**：Dantzig 只按检验数定价，那是改进的**速率**而不是
  改进本身——覆盖最多未覆盖行的列速率最大，却常常在第一行接近紧的约束上被卡住，能走的步长极小。
  实测 `fast0507`：Dantzig 选中的列 `r = −121.1`、步长 `0.00632`、增益 `0.76`，而 24 列抽样里
  就有 `r = −6`、步长 `1.0`、增益 `6.0` 的列。实际增益 `|r| · step` 要跑一次比值检验才知道
  （代价与一次枢轴相当），所以每迭代只测一个有界候选集：Dantzig 的候选、最近增益最好的几列
  （候选池，每迭代重新测量，失效即淘汰）、以及一个**轮转窗口**扫过列空间（这是"发现"好列的方式）。
- **实测收益（同一枢轴预算）**：`fast0507` 在 20000 次枢轴时，内核目标值从 Dantzig 的
  **165.115** 降到 **156.778** —— 而 Dantzig 要跑到 **250000** 次枢轴才到 160.353。
  代价是每次枢轴从约 2.4 ms 升到约 14 ms（32 次候选评估，每次约 0.36 ms；`m` 从 63490
  降到 489 之后，这个常数比当年的 2.9 ms 小了近一个数量级）。
- **全清单同口径复核（1000 行、1200 迭代、presolve）**：**18 最优 / 11 跳过 / 0 拒绝 / 3 其他**——
  `bienst1` / `bienst2` 从 `iteration-limit` 变成**求到最优**（各 582 次枢轴，此前 1200 次内解不完），
  其余 16 个最优实例全部保持最优且目标值一致；全清单用时 96 → 110 秒。
- 已知取舍：misc07 的目标值相对偏差从约 2e-10 变成约 3.5e-9（换了枢轴路径、最优顶点不同，
  仍在 1e-9 量级附近），重建解在原模型上的三项检查照旧通过。

### Added — M3（有界变量枢轴：上界不再占一行内核行）

- **有限上界是界，不是行**：比值检验两个方向都读（基本变量降到零、或升到自己的上界），
  非基本变量可以停在任一端，进入变量被自己的范围先限住时**翻到另一端且不换基**
  （无枢轴、无 eta、因子不动）；基本变量升到上界离基时，它留在哪个界由**实际落到的值**判定。
  模型侧：`x ≤ ub` 变成列界 `y ≤ ub − lb`，只有"下界无界的自由变量 + 有限上界"
  （`p − n ≤ ub`，两个列之差没有单列界）和"界自相矛盾"（此时仍要报 infeasible）留成显式行；
- **实测收益**：`fast0507` 的内核行数 **63490 → 489**，每次枢轴 **47.5 ms → 2.4 ms**
  （150000 次枢轴 400 秒），**Phase I 首次跑完**（此前 20000 次上限下 Phase I 还停在人工和约 180）；
  1000 行口径全清单用时 **162 → 96 秒**；
- **过程中抓到的两个真 bug**（都是"步长"的账算错了，且都被全量清单而不是单元测试抓到）：
  ① `pivot` 用 `xb / rate` 反推步长，这只在"基本变量降向零"时成立，基本变量升向上界时
  会得到负步长并写坏状态 —— 现在**步长由调用方给出**（比值检验第二遍一并返回所选行的步长）；
  ② 人工变量驱逐那条**本意是零步长**的枢轴，因为方向为负被按"升到上界"解释，
  拿到人工变量的**无穷上界哨兵**（1e30）当步长，一步把状态写成 1e32 —— 现在显式传 0。
  修前 `50v-10` / `misc07` / `p0201` 分别被误报为 numerical-failure / unbounded / unbounded；
- **零回归是实测的**：1000 行口径全清单（`-Relax -MaxRows 1000 -MaxIterations 1200 -Presolve`）
  **16 最优 / 11 跳过 / 0 拒绝 / 5 其他**，与改动前逐实例一致，目标值差异都在 1e-10 相对量级内
  （内核换了表示，顶点会略有不同，这是 1e-9 精度声明范围内的正常现象）；
- **测试 +3（共 97）**：非基本变量停在上界时必须出现在解里（`values()` 的不变量，
  并断言"上界不占行"——20 个有界变量 + 1 条约束的内核行数是 1）；界不占行之后
  行数门禁仍能触发（用"自由变量 + 有限上界"造 9 行）；新增**有界模型的差分测试**：
  同一个模型，内核收到的是列界、`oracle` 收到的是额外的上界行（两种写法互不共用代码），
  200 个随机有界 LP 上状态与目标值必须一致。该测试立刻抓到一次分歧（seed 19 内核报
  unbounded、参照报 optimal）：核对了那个实例，**内核是对的** —— 参照实现把"无上界"的
  哨兵当成有限数，于是在无界实例上给出 `obj = -3e30` 的"最优解"，所以这份语料全部取有限上界，
  并在测试注释里写明原因。

### Fixed — M3（"Phase I 停滞"是一次测量误判，以及一条够不着的防停滞判据）

- **没有产出的运行不再打印目标**：`cmd/parse` 只在 `optimal` 时打印目标值，其余状态印 `-`。
  此前失败运行返回的目标是 0，而带 presolve 时 `ReducedModel::objective(0.0)` 会把它翻成
  **化简自身的目标常数**，看上去像一个结果。`fast0507` 因此被记成"Phase I 停滞在 13"：
  那个 13 是化简固定 8 个变量贡献的目标常数，与迭代次数无关，所以"500 次和 4000 次都是 13"
  是恒等式而不是现象（实测：`--presolve --max-iterations 1` 印 `obj=13`，去掉 `--presolve` 印 `obj=0`）；
- **Phase I 的进度写进消息**：迭代上限消息现在带实测人工和与枢轴数
  （`iteration limit reached during Phase I (artificial sum … after … pivots, bland=…)`）。
  实测 `fast0507`（presolve 后 63490 行）的人工和从 489 起单调下降：500 / 1500 / 6000 次枢轴时
  为 267.9 / 151.6 / 17.4（约 ×0.78 每 500 枢轴，零步长枢轴占 2.4%，最长连续无改进 6 次）——
  **既不是停滞也不是循环**；
- **防停滞窗口不再随 m 增长**（新选项 `SimplexOptions::stall_window`，默认 500 次枢轴）：
  旧判据是 `2m + 50`，在默认 20000 迭代上限下只可能对 `m < 9975` 生效；在 `fast0507` 的
  63490 行上它要求 127030 次连续无改进，即**一个不能触发的守卫**。
  新增测试固定"窗口只改变运行、不改变答案"（窗口取 0 / 1 / 10 / 500 时状态与目标值一致）；
- **零回归是实测的**：1000 行口径全清单（`-Relax -MaxRows 1000 -MaxIterations 1200 -Presolve`）
  每个实例的 status / 目标值 / 迭代数与上一份报告逐位相同，全清单里最长的连续无改进只有 91 次
  （`danoint` 的 Phase I），低于旧判据在任何实例上的阈值（最小的一个也 ≥ 122），
  所以两个判据在这份清单上都从未触发 —— 换句话说，这一轮把"停滞"当病根去调判据，本来什么也不会发生；
- 报告里失败实例的目标列由 `0` / `13` 改为 `-`，`danoint` 现在的说明是实测的
  "artificial sum 66.47 after 1200 pivots"，而不是一个凭空的 0。

### Added — M3（把"下一道墙"的测量记下来）

- `docs/roadmap.md` 与 `bench/README.md` 记录了这一轮的测量结论：
  ① **枢轴数随内核行数走**，而内核行数是模型行数 + 每个有限上界一行（`fast0507` 是 489 → 63490），
  小规模覆盖型 LP 探针给出 (20,100)→57、(20,900)→35、(40,300)→137、(120,150)→632、(240,300)→2220，
  与列数几乎无关；② **每次枢轴的增益才是瓶颈**：512 列分层抽样里有多达 168 列的
  `|rc| · step` 大于 Dantzig 选中的那列，最好的通常大 5–20 倍（例：`rc=−121.1`/步长 0.0063/增益 0.76
  对 `rc=−6`/步长 1.0/增益 6.0）；③ 精确评估一列方向约 2.9 ms（实测 512 列 × 60 次迭代给
  160 次枢轴的运行增加约 88 秒），所以增益型定价要先降下方向求解的 O(m) 常数或维护候选列表。
  这两条已写成 M3 待完成项（有界变量枢轴、增益型定价）；
- 顺带实测补齐：`danoint` 在默认 20000 迭代上限下 **3716 次枢轴、11.7 秒**求到最优
  （`obj = 62.6372804184694`，重建解在原模型上可行），此前报告里它是被 1200 上限截断的。

### Fixed — M3 (pivot stability check and one recovery attempt)

- **枢轴稳定性校验**（新选项 `pivot_relative_tolerance`，默认 1e-8）：比值检验选出的主元若小于
  该次方向最大元的这一比例，说明基逆已陈旧，内核先重建逆再重做比值检验后才枢轴。
  判断是**相对**量：绝对值阈值会在良态小问题上误触发、在病态大问题上沉默；
- **一次数值失败恢复**（新选项 `start_with_bland`）：运行以数值失败结束时，从初始基**重启一次**，
  改用 Bland 规则（确定、不会循环）并把重新分解间隔压到不超过 100。恢复结果同样必须通过残差自检，
  仍失败则返回**原始失败**并保留原始消息。
  效果：presolve 后的 `noswot` 报数值失败（基近奇异）→ 现在求到最优 `obj = -43`（171 次迭代），
  与无 presolve 的直接求解**结果一致**，两条独立路径互为旁证；
- **`--presolve` 的目标值显示修正，外加一道独立对照**：化简运行优化的是**化简后**的模型，
  其目标值不含被消元变量的贡献；此前 CLI 直接打印这个值，在 `flugpl` 上与未化简的结果差
  **162 000**，看起来像错答案，实际只是标签错（重建解在原模型上的目标值是
  `1167185.7255923206`，与未化简的 `1167185.7255923208` 一致）。
  现在打印的是加上 offset 后的原模型目标值，并且每次化简运行都会做**独立对照**：
  用原模型的目标向量重算重建解的目标值，与报告值对拍，不一致就标 `DISAGREES` ——
  "解可行但数字不对"这类错误因此进不了报告。300 行口径全量下 15 个最优实例全部通过该对拍；
- **诚实记在消息里**：恢复成功的运行会带 `recovered from a numerical failure by restarting with Bland's rule (...)`
  ——"重新走了一条路"和"那条路本来就对"不是一回事，报告不该混为一谈。

- **`bench/report-solve.ps1` 支持 `-Presolve`，并被切换为基准口径**：报告表格新增
  `presolve`（化简前后规模与各项计数）与 `check`（还原解在原模型上的最大行/界违反、
  以及用原模型目标向量重算的目标值）两列；任何重建检查失败（`FAILED` / `DISAGREES`）
  都会**拒绝写报告**（退出码 5）。基准报告现在同时是 presolve 正确性的证据：
  15 个最优实例的重建解全部通过三项检查，其中 `noswot` 的一次 Bland 恢复也被如实记录进报告；
- 报告脚本的括注解析改为贪婪匹配：恢复消息自带一层括号
  （`... with Bland's rule (basis became numerically singular)`），非贪婪模式会静默只留下内层。

- **基准口径提到 1000 行，并因此多解出 4 个实例**：报告从 `-MaxRows 300` 提升到
  `-MaxRows 1000`（用时 142 秒），`danoint`（664 行）、`bienst1` / `bienst2`（576 行）、
  `fiber`（363 行）进入可解范围：**19 最优 / 11 跳过 / 2 拒绝 / 0 数值失败**，
  19 个重建解全部通过三项检查，交叉校验 **19 项 0 违反**；
- 两个拒绝实例把化简的价值与边界都摆了出来：`30n20b8` 被化简掉 **7282 个变量**
  （18380→11098，内核行 18956→11591，降 39%），仍需约 1.0 GB 基逆；
  `fast0507` 是集覆盖问题，几乎没有可约的上限行（63009→63001 个变量），内核仍需 63490 行、
  约 30.8 GB —— 两者都在**分配之前**被拒绝，而不是崩溃或静默跳过；
- `bienst1` / `bienst2` 在报告里给出同一个松弛最优值 `11.724137931034488`（各 2130 次迭代），
  与官方最优值方向一致，闭合了早先"同一实例两个目标值"的疑点：那次 `obj = 11` 是崩溃进程的
  被污染读数，不是内核的第二个答案。

### Changed — M3 (the basis is factored sparsely, and the row ceiling moves with it)

- **稠密基逆换成稀疏 LU**：内核过去维护 `m×m` 稠密基逆，这正是规模上限的由来 ——
  MIPLIB 的 `fast0507`（内核 63490 行）需要约 30 GB，只能被门禁拒绝。现在改为
  **左看式稀疏 LU**（`P·B = L·U`，行主元按列内最大元选、阈值**相对**该列量级），
  每次单纯形需要的三种求解都走三角求解：`B x = b`、`Bᵀ y = c`（对偶值与 `B⁻¹` 的一行）。
  内存从 `O(m²)` 变成 `O(nnz + fill)`；
- **L 的元素按"原始行号"记录，而不是按位置号**：行主元交换会改变行↔位置映射，
  按位置记录的列会在后续交换中悄悄指向错误的行。这是实现过程中真实踩到的 bug，
  也正是两个 LU 测试（因子重建矩阵、两种求解对拍稠密参考）抓出来的；
- **测试 +4（共 92）**：因子重建 `P·B`、两种求解与测试内置的稠密参考一致（含 `B·x = b` 与
  `Bᵀ·y = c` 的残差回代）、奇异基被拒、带状基的因子保持稀疏（400 行三对角，元素数不到 `m²/8`）；
  测试内置稠密参考是有意的：随求解器一起发布的"参考"算不上参考；
- **规模门禁随之改口径**：`max_kernel_rows` 默认 4000 → **200000**（内存已线性，行数不再是真正的限制），
  并新增 `max_factor_entries`（默认 2×10⁷）作为**填充量预算** —— 填充是稀疏分解唯一无法事先预测的量，
  超预算即让分解失败，而不是无上限分配；
- **实测**：`30n20b8`（presolve 后 11591 行）**16 秒求到最优**，目标值 `1.5664076454608626`
  与稠密基逆时代的 `1.5664076455872395` 相差 1.3e-10 —— 两套线性代数后端的交叉印证；
  `fast0507`（63490 行）**不再被拒绝**，能跑起来，但 Phase I 在 500 次迭代（61 秒）内未收敛，
  其边界已从"内存"变成"时间"；1000 行口径报告里 `refused` 归零。

### Fixed — M3 (the root cause of the numerical failures: degeneracy, not scaling)

- 先测量后修改：扫描 noswot 的系数幅度是 **0.25 – 21（跨度比 84，中位数 1）**——
  这是个**良态**模型，所以"病态尺度"不是病根，行列均衡被测量直接否掉（省下一整套不会被用到的子系统）。
  真正的病根是**退化**：presolve 收紧的界把大量变量压到边界上，上界行的松弛基值成片为 0，
  比值检验面对 `min(0, 0)` 这样的平局无从选择，一连串退化枢轴正是基漂移到近奇异的途径；
- **新增退化扰动**（`degeneracy_perturbation`，默认 **1e-12**）：求解用的右端项按确定性模式加上
  `ε·(1+|bᵢ|)` 的微扰以打破平局；**残差自检始终针对未扰动的右端项**，所以扰动运行仍必须对调用方
  写下的模型可行。默认值是被测出来的：`1e-9` 能修好 noswot 但会移动目标值超过测试断言的 `1e-9`
  相对精度；`1e-12` 下 noswot **158 次迭代直接求到最优、不再触发 Bland 恢复**（此前 500 次迭代 +
  恢复），且全部精度断言仍然成立；
- 这解释了此前"presolved noswot 报基奇异"之谜的另一半：不是尺度、不是漂移、不是 eta 稀疏化取舍，
  而是退化。Bland 恢复保留为**最后一道**防线（它解决的问题依然存在），但不再是这类实例的必需路径。

### Changed — M3 (presolve becomes the default path of the public entry points)

- `SolveOptions` 新增 `presolve: Bool`（**默认 true**）：`solve` / `solve_with` 现在先化简再求解，
  并把解还原回**原变量**。还原结果必须**同时**通过三项检查才以 `Optimal` 返回：原模型的行、
  原模型的界、以及用原模型目标向量重算出的目标值与报告值一致；任何一项不过即返回 `NotSolved`
  并附上实测违反量。化简能自行证明不可行/无界时直接返回该判定，不启动内核；
  化简消掉全部变量时其自身答案也要过同一套检查。
- **整数模型不经化简**：内核在 M5 之前拒绝整数变量，这个承诺不能取决于化简是否碰巧把所有整数
  变量定住（那也正是一个非整数固定值会溜进来的地方），因此含整数变量的模型直接交给内核领取拒绝理由；
  相应地，测试里针对内核"迭代上限"与"整数拒绝"的用例显式传 `presolve: false`，让它们继续测内核本身。
- 新增 3 个门面测试（化简独立解出模型并还原取值、化简路径与仅内核路径一致、化简自证无界），
  测试总数 88。`cmd/main` 与两个示例的输出不变（production plan 目标 21、transportation 目标 11）。

### Added — M3 (presolve: model reduction and postsolve)
- `presolve` 包：空行/列消元、活动范围推出的冗余行、singleton 行转界、隐式界收紧、
  固定变量消元（含目标常数偏移）与 `postsolve` 解还原；证明不可行或无界时返回判定而不是调用内核。
  公开接口：`presolve`、`ReducedModel::{reduced,stats,verdict,reason,objective_offset,
  reconstruct,objective}`，以及 `max_row_violation` / `max_bound_violation` —— 后两个是
  **独立于化简记账**的可行性检查，还原解必须自己站得住；
- 测试 85 个（新增 10）：9 个手工可验的化简用例 + 150 个随机模型的差异测试
  （状态一致、目标值一致、还原解在原模型的行与界上可行）。生成器专门覆盖无约束列、
  上下界相等、singleton 行、宽松行与无穷界；差异测试还断言化简至少在这 150 例中的 40 例真正触发，
  否则"一致"就是空话；
- `cmd/parse --presolve`：逐实例打印化简前后规模与各项计数，并打印还原解在原模型上的
  最大行/界违反，可行才标 `(feasible)`；
- 实测（wasm、单进程）：`blend2` 274→186 行 / 353→336 变量 / 17 固定 / 108 界收紧，
  `khb05250` 1350→1299 变量 / 51 固定 / 2596 界收紧，`dcmulti` 290→272 行 / 533 界收紧，
  目标值与无 presolve 完全一致，还原解全部可行；
- **已知阻塞项**：`noswot` 在界收紧后内核于重新分解时报基奇异。已测量：基矩阵原始形态下无零行、
  无零列、无重复基列（尺度 21，相关列原始最大元为 1），但消元后该列候选元变为精确 0 /
  ~7e-17 —— 属该基的线性相关或近奇异。已排除"重新分解间隔过疏"（500→100/50 仍失败）与
  "eta 权重丢弃判据"（不丢弃仍失败，实验已回滚）。下一步方向见 `docs/roadmap.md`：
  行列均衡、基修复、枢轴稳定性校验。因此 presolve 暂不作为默认求解路径。

### Fixed — M3 (numerical robustness and a size gate that fails instead of crashing)

- **守卫式 Harris 比值检验**：第二遍按数值偏好选出主元行后先模拟这一步，
  若有基本变量会被推出可行性下限（`-tol·(1+|xb|)`）就退回严格最小比值行。
  Harris 的带宽是相对量，超调量却是"带宽 × 主元元素"，在比值与元素都在 1e5 量级的实例上
  足以把基本变量推负：`noswot` 因此以缩放负值 4.2e-4 报数值失败，修复后求到最优 `obj = -43.0`
  （官方 MIP 最优值 `-41.00000885`，松弛值更小，方向一致）；
- **`SimplexStatus::TooLarge` 与 `SimplexOptions::max_kernel_rows`（默认 4000）**：
  内核在分配稠密基逆**之前**按实测行数拒绝。每个有限上界都会变成一行，
  所以模型行数不能代表内核规模：`fast0507`（507 条约束、63009 个 0/1 变量）的内核规模是
  63516 行，稠密基逆需要约 30.8 GB，此前在 native 目标上以访问冲突（exit `0xC0000005`）退出，
  并因巨量换页拖垮整机；现在同一实例返回 `too-large` 并以 0 退出。
  这是边界声明，不是错误答案：拒绝带着实测数字回来，调用方可以据此行动；
- **`bench/report-solve.ps1` 拒绝写不能自证的报告**：内核退出码非零，
  或覆盖实例数与清单条数不符时直接失败。此前一次 native 崩溃被写成
  "optimal=17 skipped=5" 的完整报告（实际只跑了 22/33 个实例）。
  同时修正 `solve=` 列被空格截断的解析问题，并删掉清单里重复的 `danoint` 一行。
- **崩溃期的两个"结果异常"用探针排除**：`bienst2` 单独运行与跟在 `flugpl` 之后运行时
  结果完全一致（`obj = 11.724137931034488`，2054 次迭代），不存在跨求解状态泄漏；
  崩溃前出现的 `obj = 11` 与停在 `blp-ar98` 的访问冲突属于同一次巨大分配之后的同一个窗口。
  `blp-ar98` 单独运行正常（16021 变量 / 1128 约束，按行数上限正确跳过，8 秒干净退出）。

- **报告与内核重新对齐（提交 `d4f749a`）**：重跑 300 行口径全量清单 —— 32 个实例全覆盖、
  15 最优、17 跳过、**0 数值失败、0 规模拒绝**，单进程 native release 用时 83 秒；
  15 项松弛值对 MIPLIB 官方最优值表交叉校验 **0 违反**。`bench/solve-report.md` 重新生成，
  `bench/README.md` 的对拍表、`README.md` 的能力声明与 `docs/roadmap.md` 同步更新。
  这也是规模门禁落地后"全量清单不再崩溃"的一次实测，而不是推断。

### Changed — M3 (product-form / eta basis updates)

- **基逆不再被显式维护**：枢轴改为追加一个**稀疏 eta**（`B⁻¹ = E_k·…·E₁·B₀⁻¹`，
  `E = I + w·eᵣᵀ`，`w = (eᵣ − d)/dᵣ`，Sherman–Morrison 推导，符号写反会被差分测试立刻抓到），
  每枢轴从 O(m²) 的行操作降到 eta 的非零元个数；基逆只在重新分解时重建，
  重新分解间隔由 100 提升到 500；
- 正向/反向求解（FTRAN/BTRAN）按稀疏方式实现：基准逆按输入向量的非零元走，
  eta 按时间顺序/逆序施加；对偶值与方向计算改用共享 scratch buffer，迭代循环内不再分配；
- 结果：`mod010`（146 行 / 2655 列）**121.2s → 1.8s**，`22433`（198 行）**35.5s → 0.4s**，
  `khb05250` 0.5s → 0.2s（均为 native release）；
- 声明最优前的自检若失败，会**先折回分解（refactorize）再判一次**：eta 的累积舍入常在这一步被消除，
  只有仍然违反的才算数值失败。`blend2` 因此恢复为最优（obj 6.915675114009083），
  `noswot` 仍是真实失败（缩放负值 1.7e-4，是真实的非负性违反）。

### Changed — M3 (performance, measured rather than guessed)

- 定价与方向计算改为直接遍历 CSC 原始数组（新增
  `SparseMatrix::col_pointers` / `row_indices` / `values_view`），不再为每一列分配一个
  `(row, value)` 列表：`mod010` 的求解时间从 154.6s 降到 121.2s（约 20%）。先测量、再改，
  没有凭感觉优化；
- 人工变量驱逐改为用基逆的一行作权向量、按列非零元计算单个表元素（`tableau_entry`）；
  原实现为每个候选列构造完整方向，复杂度 O(m·nnz) 且每次都分配一个 m 向量。
  现在只有真正要枢轴的那一列才重建完整方向；
- **基准改用原生 release 目标**：同一实例（`mod010`，146 行 / 2655 列）实测
  wasm 默认目标 119.6s、native debug 203.6s、**native release 18.8s**（6.4×）。
  `bench/report-solve.ps1` 已切到 native release，报告里的数字全部来自该目标；
- 可行性的判定改为**按行缩放残差**（`|Ax−b| / (1+|b|+Σ|a·x|)`，负值同样按解的尺度缩放）：
  绝对残差 2.5e-7 出现在右端项上百的行上是舍入误差，而相对量级 2.0e-4 的负值才是真实违反。
  此前 `blend2` 因绝对口径被误判为数值失败，修正后它求到最优，`noswot` 仍被判为真实失败；
- 求解报告的行数上限由 200 提升到 300；eta 版落地后进一步提升到 1000，
  覆盖的实例与结果见 [`bench/solve-report.md`](bench/solve-report.md)。

### Added — M3 (sparse revised simplex kernel)

- `simplex`：**稀疏修正单纯形内核**，公开入口 `solve_standard` / `solve_model`
  （以及带显式选项的 `_with` 版本），状态区分 `Optimal` / `Infeasible` / `Unbounded` /
  `IterationLimit` / `NumericalFailure`；
- 数据结构：问题以 CSC 列存储，定价按列非零元走；基逆为稠密 `m×m`，
  用乘积形式行变换更新，并每 `refactorize_every` 次枢轴重新用部分主元 Gauss-Jordan 分解，
  奇异时上报 `NumericalFailure` 而不是继续跑；
- 单纯形过程：Phase I 最小化人工变量和，Phase II 优化真实目标并禁止人工变量入基；
  Dantzig 定价（停滞时自动切到 Bland 规则以保终止）、**Harris 两遍比值检验**、
  对偶值与检验数、比值检验忽略负的基本值以免反向迈步；
- **人工变量驱逐**：Phase I 结束后仍留在基里的零值人工变量会被换出，
  否则 Phase II 的枢轴会把它推成正数从而悄悄破坏对应行的可行性（此问题由差分测试发现）；
- **自检**：声明最优前用内核自己的矩阵重算行残差（不计人工列）与非负性，
  超容差即返回 `NumericalFailure` 并附测得数值；
- 模型侧变换：非零下界通过平移、自由变量拆成正负两部分、有限上界转为显式行，
  目标常数随平移一起记录；`SimplexOptions::relaxed()` 把整数变量当作连续变量，
  用于求 MILP 的 LP 松弛；
- 根包 `moonopt::solve` 不再使用稠密参考实现，改为调用内核；旧的适配层删除。
- `cmd/parse` 新增 `--solve` / `--relax` / `--max-rows`，可对解析出的模型直接求解。

### Differential testing — 2026-09-14

- 新增随机 LP 差分测试：确定性 LCG 生成 **200 个随机 LP**（2–5 变量、2–5 行、
  混合 `≤`/`≥`/`=`、含负右端项与负成本系数），内核与 `oracle` 在**状态、目标值与
  解的可行性**三方面必须一致；另有 41 个非整数系数（0.5 缩放 + 偏移）实例，
  用于覆盖符号正规化与比值检验的非整数路径。
- 测试总数 58 → **72**，`moon check --deny-warn` / `moon test --deny-warn` 与
  wasm-gc、js、native 三目标全绿。

### Benchmark — 2026-09-14 (kernel)

- 在 MIPLIB 2017 的 33 个实例上运行求解报告（LP 松弛模式，行数 ≤ 200）：
  **9 个求到最优**、23 个因行数超过当前稠密基逆的规模上限而跳过、
  1 个由内核自检判定为数值失败并如实记录残差与负值。完整表格见
  [`bench/solve-report.md`](bench/solve-report.md)。
- 规模上限是当前实现（稠密基逆）的真实边界，已写入 README 的能力表；
  稀疏 LU 基分解与更快的定价属于 M3 剩余工作。

### Added — M2 (standard model input)

- `format`：**MPS 读取器**（free 与 fixed 布局、`NAME` / `ROWS` / `COLUMNS` / `RHS` /
  `RANGES` / `BOUNDS` / `OBJSENSE` / `ENDATA`、`MARKER` 整数块、Fortran 风格指数 `1.5D+02`、
  额外的 `N` 行按 MPS 语义当作 free row 忽略、`RANGES` 展开为两条不等式）；
- `format`：**MPS 写出器**（列顺序保持稳定、整数列用 `INTORG`/`INTEND` 包裹、显式写出非默认上下界，
  因此 读→写→读 得到等价模型且文本稳定）；
- `format`：**LP 格式读写**（`Minimize`/`Maximize`、目标行、`Subject To`、`Bounds` 的全部常见写法、
  `Generals`/`Binary`、`End`，支持粘连写法 `3x`、`x+y<=4`、`1e-3` 与 `free`/`infinity` 等）；
- `format`：`ParseIssue`（行号、列号、token、原因）与 `split_tokens` / `parse_number` 工具；
  所有畸形输入只报错不 panic，错误信息带精确位置；
- `cmd/parse`：模型文件巡检 CLI —— 按扩展名或内容判定格式、打印规模统计与校验结果、
  `--manifest` 批量模式、失败时返回非零退出码；
- `bench/`：`fetch-instances.ps1`（下载 MIPLIB 2017 实例，gzip 解压，数据不入库）、
  `report-parse.ps1`（生成报告，含工具链版本与提交哈希）、`parse-report.md`。
- 模型层新增 `Model::set_var_bounds` / `Model::set_var_integer`：文件读取器在读到 `BOUNDS`
  段之后才确定变量上下界，需要这两个 setter。

### Benchmark — 2026-09-14

- 在 **MIPLIB 2017 的 33 个实例**上运行解析报告：**33 解析成功 / 0 失败**，
  合计 173 934 个变量、44 480 条约束、1 651 814 个非零元，最大实例 `fast0507`（472 358 非零元）。
  完整表格见 [`bench/parse-report.md`](bench/parse-report.md)。
- 报告脚本会记录工具链版本与提交哈希，数字可追溯。

### Notes

- 库包（`core` / `model` / `oracle` / `format` / 根包）保持零第三方依赖；
  仅 CLI（`cmd/parse`）依赖官方 `moonbitlang/x` 的 `fs` 与 `sys` 用于文件与命令行访问。
- `bench/` 下的脚本只用 ASCII 字符：Windows PowerShell 5.1 读取无 BOM 的 UTF-8 脚本时会按
  ANSI 解码，非 ASCII 字符会变成乱码。

### Added — 2026-09-14 (M1)

- 项目骨架：`moon.mod` / 多包布局 / CI（Linux、macOS、Windows + wasm-gc、js 目标）/ pre-commit hook / 文档。
- `core`：相对容差比较（`approx_eq` / `approx_zero` / `approx_positive` / `is_finite` / `is_nan`）、
  Neumaier 补偿求和、CSC 稀疏矩阵（构造、按列访问、转置、稠密化、矩阵向量乘）。
- `model`：`Sense` / `Rel` / `Var` / `Constraint` / `Model`
  （变量上下界、整数标记、约束、目标、模型校验）以及 MPS 关键字解析（`Sense::from_symbol` /
  `Rel::from_symbol`）。
- `oracle`：稠密两阶段单纯形（`≤` / `≥` / `=`，人工变量 Phase I，Bland 防循环），
  作为差分测试对照基准保留。
- `moonopt`（根包）：`solve` / `solve_with`、`SolveStatus`、`Solution`、`SolveOptions`；
  越出当前内核能力边界时返回 `NotSolved` 并给出明确原因。
- `cmd/main`：打印两个内置示例（生产计划、运输问题）与一个“当前不支持”的诚实示例；
  子命令（`solve` / `verify` / `fmt` / `bench`）在 M6 落地。
- `examples/production_plan`、`examples/transportation`：可直接 `moon run` 的示例。
- 测试：38 个（白盒 + 黑盒），`moon check --deny-warn` / `moon test --deny-warn` 全绿，
  `wasm-gc` / `js` / `native` 三个目标同样全绿。

### Known limitations

- 求解内核仍是 `oracle`（稠密参考实现）：非零变量下界与整数变量会返回 `NotSolved` + 原因。
- 尚无 MPS/LP 文件输入（M2）、无稀疏修正单纯形（M3）、无证书校验（M4）、无整数分支（M5）。
- CLI 尚未做定点数值格式化，示例直接打印 `Double` 的原始值。
