# P1-07 经验飞轮 —— 把每次人工修正变成可训练数据

> 编制：2026-10-17（施工方）
> 代码：`D:\NX_AI\memory\flywheel.py`（纯标准库，独立 SQLite `状态\feedback.db`）
> 回归：`D:\NX_AI\tests\test_feedback_flywheel.py`（55 项断言）
> 自测：`python D:\NX_AI\memory\flywheel.py self-test`（59/59）
>
> **命名偏差说明**：任务卡原指定 `memory\feedback.py`，但 `core\feedback.py`
> 已存在且被 `tests\run_eval.py` 以 `import feedback` 引用，同名会因 sys.path
> 顺序被遮蔽（正是台账铁律"同一个 .py 不要放两处"记录过的事故）。按卡片首部
> "冲突时以台账/总纲为准"原则改名 `flywheel.py`，能力与验收口径不变。

---

## 1. 这条闭环为什么是护城河

护城河不是模型，是这条闭环：

```
人工修正 → 结构化经验 → 资产库 → 灰度发布 → 现场结果 → 再修正
   ↑__________________________________________________|
```

单次修正只值一次；把它**结构化、归并、审核、灰度**之后，它就变成了跨零件、
跨会话、跨 NX 版本可复用的资产，而且越用越准。本卡交付的是这条闭环的
**离线原型**：采集、归并、候选池、审核、灰度、回滚全部在本机跑通，断网可用。

### 与既有 `core\feedback.py` 的分工（不要混用）

| 模块 | 范围 | 修正是否立即生效 |
|---|---|---|
| `core\feedback.py`（P1 既有，未改动） | 工艺参数专用（rpm/feed/ap/ae/vc/fz/coolant），canonical key 精确命中 | **直接生效**到规则引擎（L2 覆盖层） |
| `memory\flywheel.py`（本卡） | 领域无关，context 为任意键值 dict | **绝不自动生效**：先入候选池 pending，人审 approve → publish |

两层关系：飞轮候选 `published` 之后，未来可作为 `core\feedback.py` 或规则 JSON
的**受控来源**（桥接在后续卡做）。当前两层物理隔离：不同的库、不同的状态机。

---

## 2. 数据模型

### 2.1 原始事件三元组 + 结果指标

`correction_events` 表（append-only，永不更新业务字段，outcome 除外）：

| 字段 | 含义 |
|---|---|
| `context_json` | **特征上下文**：工序/材料/刀具/刀径……任意键值，键名归一化为小写 |
| `suggestion_json` | **系统当时给的建议**（保留原值，才能算"改了多少"） |
| `final_json` | **人工最终值**（ground truth） |
| `signal` / `signal_source` | 信号（见下）/ explicit·implicit |
| `signal_detail` | 一行纠错的原文 |
| `edit_distance` | 建议→最终值的字段级归一化距离（0=原样采纳） |
| `success` / `elapsed_sec` / `warning_count` | 结果指标：是否成功、耗时、NX 警告数（可先采集后补 outcome） |
| `uploaded` | 出箱标记（不出机时永远 0） |
| `merged_into` | 被归入哪个候选（回滚时释放回 NULL） |

### 2.2 信号：显式 + 隐式

| 类别 | 信号 | 采集时机 |
|---|---|---|
| 显式 | `thumb_up` / `thumb_down` | 用户点赞/踩 |
| 显式 | `one_line_correction` | 用户一行纠错（必须带原文 detail） |
| 隐式 | `adopted` | 最终值与建议字段级距离 = 0（原样采纳） |
| 隐式 | `adopted_then_edited` | 距离 > 0（采纳后改过） |
| 隐式 | `undo` | 用户撤销 |
| 隐式 | `exec_failed` | 执行失败 |

隐式信号由 `infer_signal()` 自动判定，无需用户额外操作；显式信号优先级最高，
原样记录。距离计算见第 4 节。

### 2.3 候选池

`candidates` 表，状态机：

```
pending ──approve──→ approved ──publish──→ published ──→ superseded（被新候选取代）
   │                     ↑                       │
   └──reject──→ rejected ┘                       └──rollback──→ rolled_back
```

- **pending 是默认落点**：归并结果不直接生效，必须人审。
- `supersedes` / `superseded_by` 双向链：同上下文的新候选发布取代旧候选时建立，
  链上每一环都可追溯。
- `provenance_json`：候选的完整溯源（见 2.4）。

`merge_runs` 表审计每次归并作业（输入事件数、组数、候选数、dry-run 与否）。

### 2.4 溯源（provenance）长什么样

```json
{
  "merge_run_id": "作业 uuid",
  "event_ids": ["3 条原始事件的 id"],
  "event_count": 3,
  "support": 3,
  "confidence": 1.0,
  "session_ids": ["u1", "u2", "u3"],
  "ts_range": ["最早事件", "最晚事件"],
  "signals": {"adopted_then_edited": 2, "thumb_up": 1},
  "per_field_vote": {
    "ap_mm": {"winner_token": "3", "votes": 3, "total": 3, "conflict_tokens": []}
  },
  "outcome": {"success_rate": 0.6667, "avg_elapsed_sec": 13.0, "avg_warning_count": 1.0}
}
```

任何一条候选经验都能回答：**它由哪些原始修正、在哪些会话、以多大支持率、
伴随什么现场结果聚合而成。**

---

## 3. 完整流程

### 3.1 采集（写 correction_events）

```powershell
# JSON 一律走 @文件（PowerShell 5.1 会剥内联双引号，项目铁律）
python D:\NX_AI\memory\flywheel.py record-event `
  --ctx-json @ctx.json --suggestion-json @sug.json --final-json @fin.json `
  --session u1 --success 1 --elapsed 12.5 --warnings 0
python D:\NX_AI\memory\flywheel.py record-event ... --signal thumb_up
python D:\NX_AI\memory\flywheel.py outcome --event <id> --success 1 --elapsed 9 --warnings 1
```

record-event 支持 `--dry-run`：只打印将记录的信号与距离，不落库。

### 3.2 异步出箱（本地聚合，默认不出机）

- 设置读 `状态\app_settings.json` 的 `memory_local_only`（P2-03 主控台同款开关）。
- **文件缺失/损坏一律按"不出机"处理**（安全默认）。
- `AsyncUploader` 后台线程消费 uploaded=0 事件；开关开时不调用 sink、
  不置 uploaded，事件永久留本机；`pending-upload` 可随时查看积压。
- 代码中**没有任何网络调用**。默认 sink 是把事件追加到本机
  `状态\feedback_outbox.jsonl`（出箱缓冲，仍是本机文件）。未来 P1-06 云端
  落地时，只替换 sink 为 TLS + 设备凭证 + 任务白名单通道，采集层零改动。

### 3.3 离线归并（去重 → 冲突消解 → 候选池）

`python flywheel.py merge [--threshold 0.8] [--min-support 3] [--dry-run]`

1. 取全部 `merged_into IS NULL` 的事件；
2. **近重复分组**：上下文相似度 ≥ 阈值（默认 0.8）入同组（算法见第 4 节）；
   空上下文事件不参与（无特征不可归并）；
3. 组内事件数 < min_support 的进 `skipped_small_groups`，不产候选；
4. 达标的组**逐字段多数表决**（平票取时间最新，确定性可复现）；
5. 生成 pending 候选 + provenance，事件标记 `merged_into`；
6. 若同 `context_key` 已有现役候选（pending/approved/published），
   新候选写 `supersedes`，旧候选置 `superseded` 并回指 `superseded_by`。

### 3.4 审核

```powershell
python flywheel.py candidates                 # 看候选池（短 id + 上下文 + 表决）
python flywheel.py decide --id <短id> --approve
python flywheel.py decide --id <短id> --reject
```

短 id（前 8 位）自动唯一解析；0 条/多条匹配都有可读报错。

### 3.5 灰度发布

```powershell
python flywheel.py publish --id <短id>        # approved → published
```

只有 approved 能 publish；pending 直接 publish 被拒。`published` 是
"允许下游受控消费"的唯一状态——本卡不把它直接写进任何规则文件，
桥接规则引擎属于后续卡（见 P2-06 L2 接线）。

### 3.6 回滚

```powershell
python flywheel.py rollback --id <短id>
```

- 候选 → `rolled_back`；
- 其名下全部原始事件**释放**回未归并（可重新参与下一轮归并，数据不丢）；
- 若它曾取代旧候选且自身已发布，**直接前驱自动恢复 published**——
  一次回滚把线上规则还原到上一版，不需要人工记忆旧值。

---

## 4. 算法说明（全部纯标准库）

### 4.1 编辑距离

- `edit_distance(a,b)`：经典 Levenshtein（增/删/改代价 1），O(nm) 时间、
  O(m) 滚动数组空间。中文按字符计（"粗铣"→"精铣" = 1）。
- `normalized_edit_distance` = 距离 / max(len(a),len(b))，值域 [0,1]。
- `dict_edit_distance`：建议 dict vs 最终 dict 的字段级差异
  （added/removed/changed/归一化距离）；数值 `3` 与 `3.0` 视为同值。
- 专项测试覆盖：空串、自反、对称、三角不等式、kitten→sitting=3、
  saturday→sunday=3、中文增改、归一化边界。

### 4.2 上下文近重复

```
sim(A,B) = 0.5 × 键集合 Jaccard
         + 0.5 × 共有键值的平均相似度
```

- 键名 trim+小写后比较；
- 值相似度：数值走相对差 `1 - |a-b|/max(|a|,|b|,ε)`；
  bool 严格相等；字符串走 1 − 归一化编辑距离；
- 两个空上下文 sim=0（防止把"没有特征"的事件全归成一坨）；
- 分组用**确定性贪心**：事件按时间排序，依次与各组代表上下文（组内首条）
  比较，先达到阈值的组胜出——同样输入永远同样分组，测试与审计可复现。

### 4.3 冲突消解

逐字段独立多数表决：同值得票计数，票多者胜；平票取 `ts` 最新的取值。
**少数派不丢弃**，以 `conflict_tokens: [{token, votes}]` 进 provenance——
审核人能看到"这个值是 2:1 投出来的，还有 1 条现场给了另一个值"。

---

## 5. 为什么"自动写入必须可溯源、可回滚"

1. **错误经验会污染所有后续建议**。一条未经检验的"ap=3.0"自动生效后，
   每个相似零件都会被它影响；在机加工场景，错误参数意味着震刀、断刀甚至撞机。
   所以归并产物只能是 **pending 候选**，人审定罪后才许 publish。
2. **现场会变，经验会过时**。新证据推翻旧规则是常态，不是异常。
   supersedes/superseded_by 链让"新规则取代旧规则"成为显式、可审计的状态转移，
   而不是用新数据悄悄覆盖旧数据。
3. **回滚必须是一键且完整的**。回滚候选时要同时做到三件事：候选下线、
   线上规则恢复到前驱、原始事件释放可重新归并——任何一件漏了，
   飞轮都会卡在不一致状态。
4. **训练数据必须能自证清白**。每条候选都要回答"从哪来（event_ids/session）、
   多少人支持（votes/confidence）、现场效果如何（success_rate/elapsed/warnings）、
   哪次作业聚合的（merge_run_id）"。没有溯源的自动写入等于无法审计，
   无法审计就不能让它碰工艺参数。

---

## 6. 验证证据（2026-10-17）

| 验证 | 命令 | 结果 |
|---|---|---|
| 模块自测 | `python memory\flywheel.py self-test` | **59/59 PASSED** |
| 独立回归 | `python tests\test_feedback_flywheel.py` | **55/55 PASSED** |
| 验收主线 | 假数据 3 条相似修正 | 归并成 **1** 条 pending 候选，provenance 含 3 个 event_id / 3 会话 / 作业 id / 逐字段表决 / 结果指标 |
| 冲突消解 | 2 票 2.5 vs 1 票 3.0 | 2.5 胜出，3.0 少数派入溯源 |
| 取代链 | 同上下文第二批 3 条 | 新候选 supersedes 前辈；前辈 superseded 回指；回滚后前辈复活、3 事件释放 |
| 不出机 | enabled=False 出箱 | outbox 不产生、uploaded 保持 0 |
| dry-run | `merge --dry-run` | 只出计划，候选不落库、事件不归并 |
| CLI 链路 | record×3→merge→decide→publish→rollback（@文件传 JSON） | 全部退出码 0 |

自测过程抓到并修的 3 个真问题：
1. 小群体跳过用主库验证时，被 Case 7 回滚释放的事件干扰 → 改独立库验证；
2. CLI `candidates` 显示 8 位短 id 而审核命令要完整 UUID，人工无法复制
   → 加 `resolve_candidate_id` 前缀唯一解析；
3. PowerShell 5.1 内联 JSON 被剥双引号 → 文档与 help 统一走 `@文件` 通路
   （与项目既有铁律一致，未改代码）。

全程零写入用户机器状态：自测/回归全部使用临时目录 db，真实
`状态\feedback.db` 未创建（首次真实采集时才会生成）；无任何网络代码。
