# 阶段 3：`support_reason` —— 支持状态的可解释性（F3）

> 阶段：F3（用户 2026-11-05 06:22 定边界）
> 状态：已实现、已验收（34/34）、全量回归零漂移
> 契约：`knowledge-gateway.v1.schema.json` 的 `$defs.support_reason`（context 的可选字段，不进 required）

## 1. 边界（用户原话，作为验收标准）

> `support_reason` 解释 Context Builder 为什么得出当前 `support_status`，让调用者不仅知道结果，
> 还知道结果是由哪些具体条件导致的。它不负责判断用户答案是否正确。

- 只解释现有判定，**不改** `support_status`、证据、`provenance`、`answer_policy` 结论。
- 不重复包装别的字段：缺什么看 `missing`，引擎怎样看 `engine_states`，冲突怎样看 `conflict_status`。
- 一个状态可以有多个原因：`primary` 解释状态本身，`reasons[]` 保留其余诊断。
- 不引入语义推断：每条原因都能指回具体槽位 / 引擎 / 管线阶段。

## 2. 字段

```jsonc
"support_reason": {
  "primary": "partial_slot_coverage",          // 主原因码（解释 support_status 本身）
  "primary_label": "部分槽位有证据、部分缺失",
  "reasons": [                                  // 全部原因，按严重度排序，不因 primary 而丢弃
    {"code": "partial_slot_coverage", "label": "…", "detail": "缺失 1 个槽位：题量", "slots": ["numeric"]},
    {"code": "engine_degraded", "label": "…", "detail": "wiki_recall error（…）；search.py 仍返回了结果",
     "engines": ["wiki_recall"]}
  ]
}
```

`detail` 必须是人能读懂的一句话且能追溯（带槽位名/引擎名/错误串），不允许「看起来合理但无依据」的解释。

## 3. 原因码（11 个）

| code | 触发条件（只读现有字段） | 与状态的对应 |
|---|---|---|
| `conflicting_evidence` | `conflict_status == "conflicting"` 且有 `conflicts` | 优先于一切 |
| `no_candidates` | `metrics.candidate_count == 0` | 多半 insufficient |
| `no_usable_evidence` | 有候选但 `readable_count == 0`，或可读但 `evidence_count == 0` | 多半 insufficient |
| `recorded_unresolved` | `missing[].reason == "recorded_unresolved"` | insufficient / partial |
| `required_slots_missing` | 有证据但槽位没被覆盖（`missing` 非空且非 unresolved） | insufficient |
| `partial_slot_coverage` | 同上但 `support_status == partial` | partial |
| `conditional_evidence_only` | `evidence[].supports[].quote_support == "conditional"` 且未达 sufficient | partial |
| `scope_gap` | `incomparable` 非空（同槽位证据因范围/时间不可比） | 任意，**不是冲突** |
| `conflict_candidate` | `conflict_status == "candidate"` | 任意，**尚未确认** |
| `engine_degraded` | `engines[*].status ∈ {error, timeout}` | 任意，是附加诊断 |
| `all_required_slots_supported` | `support_status == "sufficient"` | sufficient |

说明两点与用户建议清单的差异：

- 新增 `recorded_unresolved`：用户清单里没有，但 `missing.reason` 早已有这个状态，且它和「库里没覆盖」
  是**性质不同**的两件事（有知识但未裁决），压进 `required_slots_missing` 会误导。
- 新增 `engine_degraded`：用户清单里没有，但验收第 3 条要求「引擎异常但另一引擎仍有证据时，原因能准确反映降级」，
  必须有个码承载它。它永远不参与决定 `support_status`（F1 已经保证这一点），只做诊断。
- `scope_gap` 的实现依据是 `conflict_check` 的 `incomparable`，**不是**新做的范围比较——不做跨句/语义推断。

## 4. 排序（为什么 primary 是它）

```
conflicting_evidence 0 → no_candidates 1 → no_usable_evidence 2 → recorded_unresolved 3
→ required_slots_missing 4 → partial_slot_coverage 5 → conditional_evidence_only 6
→ scope_gap 7 → conflict_candidate 8 → engine_degraded 9 → all_required_slots_supported 10
```

规则：**根源码（没候选 / 没可用证据）排在「缺槽位」之前**——槽位缺失是后果，不是原因。
真实效果：库外题 `support=insufficient, stage=no_candidates`，`primary` 由「缺 1 个槽位」纠正为 `no_candidates`，
`required_slots_missing` 仍留在 `reasons[]` 里不丢信息。

## 5. 实现

- 新文件 `<你的库根目录>\工具-tools\support_reason.py`：`explain(ctx) -> dict`（纯函数，只读 ctx）、
  `contract_text(ctx)`（给 LLM 的一句话翻译，不新增约束）。
- 接入点 `context_builder.py`：`ctx_out` 组装完、`answer_policy` 之前调用；**解释失败不拖垮检索**，
  异常时落 `{"primary":"unknown","reasons":[{"code":"unknown","detail":str(exc)}]}`。
- 契约：`$defs.support_reason` / `$defs.support_reason_item`（code 用 enum 锁死），
  `context.properties.support_reason` 为**可选**——老调用方不传不报错。样例 `samples/context.cq-shenlun.json` 已带实现值。

## 6. 验收（`_tools/eval/eval_support_reason.py`，34/34）

对应用户六条：

1. 四种状态各自正确原因：sufficient / partial / insufficient（有证据缺槽）/ conflicting 各 1–3 条断言。
2. 三种不足可区分：无候选（`no_candidates`）、页面读不出（`no_usable_evidence`）、有页面无证据（`no_usable_evidence` 但 detail 不同）、槽位缺失（`required_slots_missing`）。
3. 引擎故障：`wiki_recall error` + `search.py ok` → 有 `engine_degraded`，detail 同时写出「哪个挂了」和「谁还活着」，主因仍是覆盖不足。
4. 不被误判：`incomparable` 只出 `scope_gap`，不出 `conflicting_evidence`；`candidate` 只出 `conflict_candidate` 且措辞带「尚未确认」；两者都不夺走主因。
5. 稳定性：同一 ctx 连跑两次 `explain` 逐字节相同；真 ctx 重跑与 build 内的结果一致。
6. 无副作用：`support_status` / `evidence` / `provenance` / `answer_policy` 前后深比较相等。

## 7. 已知局限

1. `conditional_evidence_only` 依赖 `supports[].quote_support`；抽不到 conditional 标记时不会报。
2. `scope_gap` 只反映 `conflict_check` 认定的「不可比」，不做独立的范围比对；真正的范围判断在 `numeric_claim`（F2），两者不互相调用（依赖方向保持单向）。
3. `reasons[]` 目前只进 `contract_text` 文案，**没有**变成 `answer_policy` 的新规则——不新增拦截。
4. 原因码是稳定枚举，新增必须同步 schema 的 enum，否则契约校验会挂。

## 8. 本轮没碰

F5 子查询漂移、考试类型别名扩展、`page_changed` 是否降级、自动知识维护、SHA-256 措辞（「完整性校验失败」而非「人为修改」，仍待改）。
