# 0027 · 让助手推荐并验证（实验三）· 参考解

这一课把「用模型帮忙」这件事拆成一条**能被程序检查**的链：

```text
题面 ──build_prompt──▶ 提示词 ──call_model／fake_model──▶ 一段文本
     ──parse_naive／parse_robust──▶ 字典 ──classify（交叉检查）──▶ 候选算法族
     ──run_trial（拿暴力当尺子）──▶ 每个候选实现的结论 ──nothing_passed──▶ 这条链验出东西了吗
```

四个任务就是这条链的四段：

| 任务 | 文件 | 要交的东西 | 这一段在链上的位置 |
| --- | --- | --- | --- |
| 1 · 造提示词 | `task1_prompt.py` | `build_prompt` / `call_model` / `fake_model` / `recommend` | 题面 → 提示词 → **那道缝**（模型调用） |
| 2 · 解析返回文本 | `task2_parse.py` | `parse_naive` / `parse_robust` / `explain_samples` | 一段文本 → 能取键的字典 |
| 3 · 关键词打分 | `task3_classify.py` | `classify` / `explain_problems` | **不靠模型**的交叉检查 |
| 4 · 端到端验证 | `task4_pipeline.py` | `resolve` / `run_trial` / `nothing_passed` | 推荐 → 候选实现 → 暴力验证 → 结论 |

把这一份里的文件拷进你的任务目录就能直接跑（`tools.py` 与 `run_tests.py` 两侧内容完全相同）：

```text
py run_tests.py        # 在 lab/solutions/0027-lab-03-recommend-and-verify/ 目录里直接跑
```

这一课真正要拿走的三句话：

1. **提示词是程序的一部分**——它决定返回的文本长什么样，也就决定了后面解析器要对付几种形态。
   「四样约束」不是礼貌用语，是接口文档。
2. **模型调用要留一道缝**——网络、时间、随机都收在 `call_model` 这一个函数里；换掉它，
   整条链就变成纯逻辑，可以反复跑、可以对拍。这一课的判分自己就是这么干的。
3. **推荐不等于可信**——同一个算法名下写着不同实现，结论可能一个好一个坏；
   所以最后一步永远是「拿尺子量一遍」，而不是「模型说是贪心」。

## 逐题：为什么这么写

### 任务 1 · 提示词的四样约束，第 4 条最难察觉

| 约束 | 不写会怎样 | 谁受影响 |
| --- | --- | --- |
| (1) 输出格式（三个键名） | 拿到文本也不知道有哪些键可取 | 任务 2 / 任务 4 |
| (2) 不许有别的文字、不许用围栏 | 六种形态里立刻有四种要额外剥 | 任务 2 |
| (3) 取值范围（八张族名表） | 模型给出项目里没有对应实现的族名 | 任务 3 / 任务 4 |
| (4) 字符串里的引号必须转义 | **内容本身不是合法 JSON，解析器救不了** | 任务 2 / 任务 4 |

第 4 条为什么最难察觉：它坏的不是**格式**，是**内容**。围栏、前后解释这类浮动，
解析器多剥一层就处理掉了；而 `题目说"最小化最大值"` 那对没转义的引号让 JSON 本身不合法，
大括号是配平的、位置也对，找大括号的招数一条都用不上——**只有让模型别写出来这一条路**。

`call_model` 的职责就是**明确地失败**（`raise NotImplementedError('离线环境不联网…')`）。
不要写成「悄悄交回空串」：那样「忘了换掉这个函数」会静默地变成「模型返回空」，
后面每一段都在处理一个空字符串，查起来比直接报错难得多。

`recommend` 只到「拿到那段文本」为止，**不解析**。它交回的是 `str`，哪怕那是一段带围栏的文本——
解析是任务 2 的事。这三行就是那道缝：

```python
prompt = build_prompt(problem)
raw = model(prompt)          # 真跑时是 call_model，测试时是一个假的函数
return raw
```

### 任务 2 · 六种形态：朴素 1 种，加固 5 种

判分用的六种形态（`tools.sample_texts()`），内容是同一段判断、只有外形不同：

| 形态 | 朴素 `json.loads` | 加固版 |
| --- | --- | --- |
| 干净 JSON | 过 | 过 |
| 套了 `json` 围栏 | 失败 → 标记 | 过 |
| 围栏 + 前面一段话 | 失败 → 标记 | 过 |
| 围栏 + 后面一段话 | 失败 → 标记 | 过 |
| 没有围栏，但有前后解释 | 失败 → 标记 | 过 |
| **内容本身不是合法 JSON**（引号没转义） | 失败 → 标记 | **也失败 → 标记** |

**1 种 / 5 种这两个数是这一层的成绩**，判分逐字核对。

加固版的顺序不能换：

```python
text = raw.strip()                                  # 1 剥前后空白
# 2 剥围栏（正则带 re.S，否则 . 不匹配换行，JSON 是多行的）
# 3 text.find('{') 与 text.rfind('}')
# 4 先判 -1 再切片：find 找不到交回 -1，text[lo:hi+1] 会切出一段你没打算切的东西
# 5 json.loads(text[lo:hi + 1])
```

三个坑，对应三条最常见的错法：

- **正则忘了 `re.S`** → 「套了 json 围栏」这一种也过不去，成绩变成 1 种；
- **先取大括号、后剥围栏** → 等于没剥；
- **不判 `-1` 就切片** → 不报错，静默切错一段。

**失败也要是一种值。** 第六种两版都救不了，这时不许抛异常、也不许交回半个字典
（比如已经取到 `family` 就交回 `{'family': '二分答案'}`）。要交回
`'<JSONDecodeError>'` 这样的**字符串标记**，整条链才能带着「这一段失败了」继续走。
任务 4 的 `resolve` 吃的就是它。

### 任务 3 · 关键词表：定位是**交叉检查**，所以假阳性要留着

这一段完全不靠模型：拿题面里的关键词给每个族打分，交回**排好序的候选**，不是一个名字。
排序口径两级：先按命中数降序，再按族名字典序升序。第二级不是装饰——
「岛屿数量」上并查集与图的搜索都是 1 个命中，谁在前就靠字典序（图的搜索在前）。

六道题面的结果：

```text
题面「木材加工」首选 二分答案（命中 2 个：['最大化', '最长']）
题面「岛屿数量」首选 图的搜索（命中 1 个：['网格']）
    并列 并查集（命中 1 个：['连通块']）
题面「合并果子」首选 堆（命中 1 个：['每次取最小']）
    并列 背包（命中 1 个：['重量']）
题面「区间调度」首选 贪心（命中 3 个：['区间', '不相交', '按结束时间']）
题面「第 k 大」首选 堆（命中 1 个：['第 k 大']）
题面「跳石头」首选 二分答案（命中 1 个：['最短']）
```

「合并果子」那一行的**背包是假阳性**：正确答案是堆，但题面里有「重量」两个字。
**这一行要如实保留**——它正是这一段要暴露的东西：关键词表给出的候选里**会有错的那个**，
所以才不能拿它当判决，只能当交叉检查。把「重量」从表里删掉、或者给堆加几个词把它顶下去，
并列就没了，而判分拿的是**同一张表的另一个实现**（`tools.canonical_classify`），
表被动过会直接点名。所以：**别自己另写一份族名与关键词表**，用 `tools.FAMILIES`。

### 任务 4 · 名字对了，实现未必都对

推荐给出「贪心」，然后写两个实现——**都叫贪心，只是排序关键字不同**：

```text
数据 tools.case_intervals(6) = [(0, 60)] + [(2i+1, 2i+2) for i in range(6)]

实现 A（按开始时间排）选出 1 个: [(0, 60)]                     → 通过吗 False
实现 B（按结束时间排）选出 6 个: [(1, 2), (3, 4), (5, 6), (7, 8), (9, 10), (11, 12)]  → 通过吗 True
暴力验证给出的最优 6 个
```

那个 `(0, 60)` 就是**反例**：它又长又早，按开始时间排会先挑它，后面全被它挡住。
A 自己不是「算错了」，是**排序关键字选错了**——名字（贪心）没错，实现错了。
**所以验证环节验的是具体实现，不是算法名。**

`run_trial` 交回 `(verdicts, want)`：

- `want = brute_best(intervals)`——暴力是尺子，先让它算；
- `verdicts` 里每个实现一条 `(名字, 怎么排的, 选出个数, 通过吗, 选出那张表)`，
  **两个实现都跑，不短路**——这一层要看的正是「同族两个实现结论不同」；
- **第五格那张表每个实现都要交**：没通过的那一张就是能复现的反例；
  通过的那一张让判分核对「选出 1 个」「选出 6 个」这些数不是写死的。

`nothing_passed` 用 `not any(...)`，不是 `not all(...)`：写成 `not all` 的话
「全过」和「全不过」**都会**交回 `True`，等于这条结论行的两种极端情况判成一个。

判分在这一层做四件事，全是**另算一遍**：拿自己的样本调你的 `run_trial`；
换一组数据（区间数不是 6）看结论跟不跟着变；拿「两个实现都过」的假结论试 `nothing_passed`；
拿带没转义引号的假模型试 `resolve`，看它交回的是失败结论还是不声不响地当成通过。

## 常见错法（判分逐条盯着）

| 错法 | 症状 | 判分读的那一行 / 那一条 |
| --- | --- | --- |
| 提示词少写「转义」那一句 | 四样约束缺一条 | `四样约束齐全（…）` 那一条 PASS/FAIL |
| 题面没 `strip()` 就裹进去 | 首尾空白进了提示词 | 拿 `'  设备夹紧力计算问题  '` 那一次试探 |
| `build_prompt` 里真写了围栏 | 提示词自己带上了围栏标记 | `提示词里没有围栏标记…` |
| 源码里手写连续三个反引号 | 打断阅卷工具链 | `源码里没有连续三个反引号…`（用 `FENCE` / `make_fence()`） |
| `call_model` 吞掉异常、悄悄交回空串 | 「忘了换掉这个函数」变成「模型返回空」 | 断网那一层要求它**必须**抛 `NotImplementedError` |
| `recommend` 里把 `model` 写成默认值 | 缝被堵上，假模型换不进去 | `recommend(题面, 假模型) 没有把提示词交给…` |
| 正则忘了 `re.S` | 加固版只过 1 种 | `六种形态里加固版过了 N 种，期望 5 种` |
| 先取大括号后剥围栏 | 同上 | 同上 |
| 不判 `find` 交回的 `-1` 就切片 | 静默切错一段 | 探针里 `根本没有大括号`、`半截 JSON` 两组 |
| 第六种形态抛异常 / 交回半个字典 | 整条链在这里断 | `parse_naive/parse_robust 吃到第六种形态…`、探针 6 组 |
| `classify` 把没命中的族也放进候选 | 「首选」变成字典序第一个族 | `没命中任何关键词时交回空表 []` |
| `classify` 只按命中数排、没写第二级 | 「岛屿数量」首选变成并查集 | `岛屿数量首选` 期望 `图的搜索` |
| 自己改窄了关键词表（删掉「重量」） | 合并果子那一行的并列没了 | `题面「合并果子」的候选列表与另一个实现逐格对上` |
| `explain_problems` 第二行不区分「并列 / 次选」 | 打印行与课件不一致 | 六道题面的首选行逐字对照 |
| `run_trial` 只跑第一个实现就返回 | 结论条数不够，「同族两个实现」看不出来 | `交回的结论条数是 1，期望 2 条` |
| 「选出几个」写死成 1 / 6 | 换一组数据就露 | `run_trial(贪心, 区间数 3)` 那两组另算一遍 |
| `run_trial` 不交回那张选出的表 | 反例没证据，个数也无从核对 | `实现 A 交回的「选出那张表」` |
| `nothing_passed` 写成 `not all` | 全过也判成「没验出东西」 | 三条：全否 `True` / 全过 `False` / 一否一过 `False` |
| `resolve` 让异常跑出去 | 第六种形态把链打断 | `resolve(第六种形态) 交回 {"ok": False, …}` |
| 交回 `1` / `'True'` 而不是真布尔值 | 打印行对不上 | `实现 A 通过吗`、`首选与模型一致` |
| 改函数名 / 改 `CANDIDATES` / 改判分要读的 `print` 行 | 判分「没看到某一行」 | 所有 `没看到「…」` 的 FAIL |

## 判分在读什么

`run_tests.py` 分五层，缺一不可：

| 层 | 验什么 | 关键证据 |
| --- | --- | --- |
| 1 | 提示词四样约束、题面 `strip`、无围栏、第一行逐字 | 逐条认锚点，不看「像不像」 |
| 2 | 六种形态 **1 / 5**、第六种交回标记、判分自造 6 组探针 | 另算一遍的 `probe_oracle_*` |
| 3 | 六道题面的候选列表**逐格**对照、假阳性并列、`[]` 口径 | 同一张表的另一个实现 |
| 4 | 链路接缝、`resolve` 两态、`run_trial` 在**两组**数据上另算、`nothing_passed` 三种情形 | `canonical_trial` 现算，不是常数 |
| 5 | 四个脚本单跑退出码 0 且无 Traceback、判分要读的那些行逐字、**断网**跑通 | 假模型替掉唯一的联网点 |

两路证据缺一不可：

1. **跑任务脚本、读它自己打印的行**（主要证据）。脚本把每一格都打出来，
   判分**按标签找行**（不按编号），行里的值都是**从函数交回的东西里查出来的**——
   留白版照样把这些行原样打出来，只是值是占位的（`（还没接上）` / `0` / `False`）。
2. **另起探针 `import` 学生的函数，用判分自己造的数据试**（独立证据）。
   六种形态是**表**，探针是**另一批数据**（`tools.PROBE_SAMPLES`，6 组，3 组该拿下、3 组该失败），
   而且判分用自己另写的 `probe_oracle_naive` / `probe_oracle_robust` 现算期望——
   不吃 `tools` 里那两条口径。**只写 print 不写实现拿不到 PASS。**

**这一课的判分不碰网络。** 第 5 层把 `socket.socket` 换成会抛异常的版本跑一遍整条链，
并核对连接尝试次数为 0、`call_model` 必须抛 `NotImplementedError`、装上的假模型必须交回干净 JSON。
所以判分在任何一台没网的机器上都能跑，也不会因为网络抖动而不稳定。

判分里核对的是那些**确定量**：`1` 与 `5`（六种形态的成绩）、第六种的 `<JSONDecodeError>`、
`explain_samples()` 交回 `(1, 5)`、六道题面的首选行与命中词表、
`A 1 个 / B 6 个 / 最优 6 个`、`逐字` 的 `[(0, 60)]` 与那六个小区间、
三条 `nothing_passed`、以及**没有一条耗时断言**（本课一个时间都不量）。
留白版的成绩是 `0 层通过、5 层还没过`，参考解是 `5 层通过、0 层还没过（共 105 条 PASS、0 条 FAIL）`，
连跑三次都是退出码 0。
