# Rerank & Freqcap（重排 / 去重 / 频控）｜Plan

> 对应规范：`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md`
>
> 规则来源（必须对齐）：
>
> - `设计说明文档/個性化推薦算法規則.md`（Feed MMR λ=0.7；Push/Widget 冷却口径）
> - `spec_kit/Personalized Reco/overview.md`（模块边界：本模块在 Soft Scoring 之后执行）

---

## 1. 目标与交付物

### 1.1 目标

- 将 Soft Scoring 后的候选集变为**可下发的最终排序**（长度 ≤ k）。
- 实现 V1 最小集合：
  - **去重**：排除 `already_recommended_ids ∪ touched_or_viewed_ids`
  - **Feed 序列多样性**：MMR 重排（离散特征版）
  - **Push/Widget 频控与冷却**：至少保证“同句不重复”；作者/模板按输入能力做增强
- 输出稳定的 `meta` 统计字段，用于定位 served_k 不足的原因（dedup/freqcap 导致清空等）。

### 1.2 交付物

- `modules/rerank-freqcap/plan.md`：本技术计划（本文件）。
- 代码实现（tasks 阶段落地）建议位置：
  - `server/app/features/personalized_reco/rerank_freqcap/`
  - 包含：
    - 纯函数 `rerank_and_freqcap(...) -> RerankResult`
    - `RerankConfig` 与默认参数（按 scene）
    - `Sim/Tag` 构造工具函数（Feed MMR）
- 单元测试（tasks 阶段落地）：
  - 去重正确性
  - Feed MMR 的 Top1 + 多样性选择
  - Push/Widget 冷却规则（在给定历史集合输入下）

---

## 2. 模块职责边界（V1 约定）

### 2.1 本模块负责

- **从 scored_candidates 中做过滤/重排**：
  - Dedup：按历史集合过滤
  - Freqcap：按冷却维度（句子/作者/模板）做“硬过滤或强约束”
  - Feed：MMR 生成序列（保证多样性）
- 输出 `ranked_items` 与 `meta`（候选规模、过滤数量、缺失输入统计等）。

### 2.2 不在本模块实现

- **不计算 Soft Scoring 分数**：只消费 `final_score`（或等价的 score）。
- **不做 Hard Filter**：Hard Filter 发生在更早阶段，本模块只处理已通过 Hard Filter 的候选。
- **不维护服务端长期历史**（V1）：冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”，或由未来服务端侧补齐。

> 说明（V1 冷却窗口语义）：本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留，便于未来接入服务端历史后真正按时间计算。

---

## 3. 输入/输出与数据结构（V1）

### 3.1 输入

- `scene`: `feed | push | widget`
- `scored_candidates`: `List[ScoredCandidate]`，至少包含：
  - `content_id: int`
  - `final_score: float`（或 `score`）
  - `author_id: str | None`
  - `template_id: str | None`
  - `content_profile`（用于 feed 标签：stage/need/context 等；缺失时可退化）
- `already_recommended_ids`: `List[str|int]`
- `touched_or_viewed_ids`: `List[str|int]`
- 可选历史（若客户端暂不传，V1 作为增强项）：
  - `recent_author_ids: List[str] | None`
  - `recent_template_ids: List[str] | None`
- `k`: 目标条数（feed 默认 30；push/widget 默认 1）
- `config`：
  - `mmr_lambda`（Feed 默认 0.7）
  - `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`（按场景默认）

### 3.2 输出

- `ranked_items`: `List[ScoredCandidate]`（长度 ≤ k）
- `meta`（V1 必须字段）：
  - `candidate_pool_size_after_dedup: int`
  - `candidate_pool_size_after_freqcap: int`
  - `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`（可选但建议）
  - `missing_history_fields: List[str]`（例如 `recent_author_ids` 未提供）

---

## 4. 关键技术决策（V1）

### 4.1 ID 归一化（避免 str/int 混用导致漏过滤）

由于输入历史集合可能是 `str|int`，V1 统一做：

- 尽量将 `content_id` 归一化为 `int`
- 无法转换的值忽略并记录 debug（不影响主流程）

### 4.2 Push/Widget 的频控策略：先保证“同句不重复”，再增强作者/模板

V1 选择“安全且可落地”的策略：

- **句子冷却（必做，硬过滤）**：
  - 若 `content_id` 出现在历史集合中，则直接过滤
- **作者/模板冷却（增强项）**：
  - 若 `recent_author_ids/recent_template_ids` 有输入，则对命中者执行硬过滤
  - 若无输入，则跳过该维度，但在 `meta.missing_history_fields` 记录缺失，便于可观测

> 说明：规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式，避免伪实现与误杀。

### 4.3 Feed 的多样性：MMR（离散特征版）

V1 实现 MMR 的离散相似度（不依赖 embedding）：

\[
MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s)
\]

- `Rel(c)`：使用 `final_score`
- `Sim(c,s)`：
  - `content_id` 相同：`Sim=1`
  - `template_id` 相同且非空：`Sim += 0.6`
  - `author_id` 相同且非空：`Sim += 0.3`
  - 标签重合（Jaccard）：`Sim += 0.1 * Jaccard(tags_c, tags_s)`
  - clamp 到 `[0,1]`

标签集合 `tags_*` 的 V1 落地定义（必须可算、且对缺字段鲁棒）：

- `stage:<stage>`（例如 `stage:general/expecting/parenting/unknown`）
- `need:<key>`：从 `need_suitability` 中取 **最大值的 key** 作为代表标签（若为空则跳过）
- `context:<key>`：从 `context_suitability` 中取 **最大值的 key** 作为代表标签（若为空则跳过）

> 说明：内容画像是 suitability（0/0.5/1）结构；V1 取 argmax 能保证标签集合小且稳定，便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。

---

## 5. 具体算法流程（V1）

### 5.1 Dedup（必做，三场景共用）

输入：

- `seen_ids = already_recommended_ids ∪ touched_or_viewed_ids`

处理：

- 过滤 `content_id ∈ seen_ids` 的候选

输出：

- `candidate_pool_size_after_dedup = len(filtered_candidates)`

### 5.2 Freqcap（Push/Widget 必做；Feed 可选）

V1 频控实现顺序（先句子，再作者/模板）：

1. 句子冷却：过滤 `content_id ∈ seen_ids`
2. 作者冷却（若提供 `recent_author_ids`）：过滤 `author_id ∈ recent_author_ids`
3. 模板冷却（若提供 `recent_template_ids`）：过滤 `template_id ∈ recent_template_ids`

输出：

- `candidate_pool_size_after_freqcap`
- `freqcap_filtered_counts`（按维度统计被过滤数量）

### 5.3 Feed：MMR 序列重排（建议实现）

步骤：

- Top1：直接取 `final_score` 最高者
- 对后续位置 t=2..k：
  - 对每个未选候选 c 计算 `MMR(c)`
  - 选择 `MMR` 最大者加入序列

性能与实现约束（V1）：

- 候选数 N（例如 200~500）时，朴素 \(O(kN^2)\) 仍可能偏大；V1 可采用：
  - 先截断到 `top_n_for_mmr`（例如 200）再做 MMR
  - 或缓存 `Sim(c,s)` 的最大值并增量更新（实现复杂度更高，V1 可不做）

### 5.4 Push/Widget：选 TopK

在 dedup+freqcap 后：

- 按 `final_score` 降序取前 k 条作为 `ranked_items`

---

## 6. 默认参数（V1 建议）

### 6.1 Feed

- `mmr_lambda = 0.7`
- `top_n_for_mmr = 200`（避免候选过大导致重排过慢）

### 6.2 Push（冷却窗口口径来自算法规则的工程默认）

- `cooldown_sentence_days = 14`（同句 14 天不重复）
- `cooldown_author_days = 7`（同作者 7 天不重复，需输入 `recent_author_ids` 才能执行）
- `cooldown_template_days = 7`（同模板 7 天不重复，需输入 `recent_template_ids` 才能执行）

### 6.3 Widget

- `cooldown_sentence_days = 7`
- `cooldown_author_days = 7`
- `cooldown_template_days = 7`

> 说明：V1 冷却天数在本模块主要用于配置与可观测字段；真正“按天”判断需要历史带时间戳或服务端持久化，后续迭代补齐。

---

## 7. 可观测与 meta（V1）

本模块建议输出（供 `observability` 子模块汇总）：

- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `freqcap_filtered_counts`（sentence/author/template）
- `missing_history_fields`：
  - 例如客户端未提供 `recent_author_ids` → 记录 `author`
  - 未提供 `recent_template_ids` → 记录 `template`

> 目标：当 served_k 过少时，能快速判断是 dedup/freqcap 导致，还是上游候选不足。

---

## 8. 测试计划（V1）

### 8.1 单元测试（纯函数）

- Dedup：
  - 输入历史包含某些 `content_id`，输出必须不包含这些 id
  - `str/int` 混用能正确归一化
- Freqcap：
  - 仅提供 `content_id` 历史时：句子冷却生效
  - 提供 `recent_author_ids` 时：作者维度过滤生效；未提供时 `meta.missing_history_fields` 正确
  - 提供 `recent_template_ids` 时：模板维度过滤生效；未提供时 `meta.missing_history_fields` 正确
- Feed MMR：
  - Top1 恒等于最高分
  - 后续序列在候选足够时避免连续同作者/同模板（可用统计阈值断言）
  - `tags` 缺失时仍能稳定运行（只使用可得字段）

### 8.2 最小集成验证（与 reco-engine 串联时）

- 输入一批 scored_candidates + 历史集合：
  - Feed：输出长度 ≤ k，且 meta 规模统计正确
  - Push/Widget：在历史命中时能过滤掉重复句子

---

## 9. 风险与后续演进

### 9.1 已知风险

- V1 冷却窗口“按天”无法严格执行：因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现，但需要在产品/客户端侧保证窗口裁剪正确。
- Feed MMR 的性能：候选过大时重排可能变慢；V1 用 `top_n_for_mmr` 截断兜底。

### 9.2 V1.1+ 演进方向

- 服务端侧持久化冷却历史（按用户维度记录 sentence/author/template 的最近触达时间），真正按 `cooldown_*_days` 判定。
- 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”，并在 meta 中记录“破例原因”（候选不足等）。
- 将 `P_repeat/P_fatigue` 由 `rerank-freqcap` 产出并注入 `scoring` 的 `external_terms`，实现更平滑的序列控制（而非一刀切过滤）。

