# Scoring（软打分与惩罚项）｜Plan

> 对应规范：`spec_kit/Personalized Reco/modules/scoring/spec.md`
>
> 规则来源（必须严格对齐）：
>
> - `设计说明文档/個性化推薦算法規則.md`（Soft Scoring 公式、V1.2 缺失字段口径、场景默认权重）
> - `设计说明文档/句子文案打分規則.md`（risk_flags 语义与默认值口径）
>
> 模块边界参考：`spec_kit/Personalized Reco/overview.md`（由 `reco-engine` 串起候选→过滤→打分→重排）

---

## 1. 目标与交付物

### 1.1 目标

- 实现三种场景统一的软打分函数 `final_score(U, Cᵢ)`，并输出可观测的 `breakdown` 以便调参与回归测试。
- 严格实现 V1.2 缺失字段的保守策略（`S_need/S_context/S_emotion` 的默认值）。
- 实现 Push 默认启用的不确定性惩罚 `P_uncertainty`。
- 实现 Widget 情绪区间（0.4~0.8）的**软降权**（不硬过滤，但能明显压低分数）。

### 1.2 交付物

- `modules/scoring/plan.md`：本技术计划（本文件）。
- 代码实现（tasks 阶段落地）建议位置：
  - `server/app/features/personalized_reco/scoring/`
  - 包含：
    - 纯函数 `score_content(...) -> ScoreResult`
    - `ScoreConfig`（场景默认参数 + 可覆盖）
    - `ScoreBreakdown`（稳定字段集合，用于可观测）
- 单元测试（tasks 阶段落地）：
  - 缺失字段一致性
  - Push 不确定性惩罚生效
  - Widget 情绪软降权生效（区间外明显更低）

---

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

### 2.1 本模块负责

- 计算并返回：
  - `S_need/S_context/S_stage/S_emotion`
  - `S_core`
  - `S_personal`
  - `P_uncertainty`（按开关控制，Push 默认启用）
  - `P_widget_emotion_out_of_range`（Widget 专用软降权项，归入 `P_risk` 或单独字段均可；V1 建议单独字段，便于打点）
- 生成 `breakdown`，用于可观测与调参。

### 2.2 不在本模块实现（但接口预留/可注入）

为避免与 `rerank-freqcap` / `reco-engine` 的职责重叠，V1 约定以下项**由外部模块产出**并作为输入注入（若不提供，默认按 0 处理）：

- `S_fresh`：新鲜度/时间衰减相关（可由引擎或重排阶段计算）
- `P_fatigue`：疲劳惩罚（基于历史触达/浏览/频控）
- `P_repeat`：重复惩罚（同句/同作者/同模板等）
- `P_risk`：软风险惩罚（例如 `soft_health_sensitive` 等）

> 说明：硬过滤（Hard Filter）由引擎编排阶段执行，本模块只消费 `pass`（是否通过硬过滤）并在总分中乘上 \(\mathbb{I}[pass]\)。

---

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

### 3.1 输入（对齐 spec）

- `scene`: `feed | push | widget`
- `user_profile`（U，允许字段缺失/跳过）
- `content_profile`（Cᵢ）
- `now`：预留，用于 freshness/时间衰减（V1 可不实现具体公式）
- `config`：
  - `w_need/w_emotion/w_stage/w_context`
  - `alpha`：个性化加成系数
  - `beta`：不确定性惩罚系数
  - `enable_uncertainty_penalty`：是否启用 `P_uncertainty`（Push 默认 true）
  - `widget_emotion_soft_range`：Widget 情绪软区间（默认 `[0.4, 0.8]`）
  - `widget_emotion_penalty_gamma`：Widget 情绪软降权强度（V1 取“适中”默认 0.25）
- `pass`: `boolean`（来自 Hard Filter 结果；默认 true）
- `external_terms`（可选，来自其他模块注入）：
  - `S_fresh`、`P_fatigue`、`P_repeat`、`P_risk`
  - 若未提供则按 0 处理

### 3.2 输出

- `final_score: float`
- `breakdown`（建议始终返回，便于可观测与断言）：
  - `S_need/S_context/S_stage/S_emotion`
  - `S_core/S_personal/S_fresh`
  - `P_fatigue/P_repeat/P_risk/P_uncertainty`
  - `P_widget_emotion_out_of_range`（可选但建议保留）
  - `missing_fields: string[]`（`need/context/emotion`）
  - `pass: boolean`
  - `scene: feed|push|widget`

---

## 4. 核心公式与实现细则（必须对齐）

### 4.1 总分结构（线性加权 + 惩罚）

\[
final\_score(U,C_i)=\mathbb{I}[pass]\times\Big(S_{core}+S_{personal}+S_{fresh}-P_{fatigue}-P_{repeat}-P_{risk}-P_{uncertainty}\Big)
\]

\[
S_{core}=w_{need}S_{need}+w_{emotion}S_{emotion}+w_{stage}S_{stage}+w_{context}S_{context}
\]

> V1 约定：`S_fresh/P_fatigue/P_repeat/P_risk` 允许外部注入；若缺失则当作 0，以确保函数可用且输出结构稳定。

### 4.2 分解项：S_need / S_context（V1.2 缺失字段兜底）

- `S_need`：
  - 若 `U.need` 缺失（为空对象 `{}` 或不存在）→ `S_need = 0.5`
  - 否则 → `S_need = Cᵢ.need_suitability[U.need_key]`
- `S_context`：
  - 若 `U.context` 缺失（为空对象 `{}` 或不存在）→ `S_context = 0.5`
  - 否则 → `S_context = Cᵢ.context_suitability[U.context_key]`

> 工程约定：`U.need/U.context` 在客户端为稀疏 one-hot（最多一个 key=1）。实现时需提供一个“取唯一 key”的工具函数：若出现多个 key=1，按第一个（字典序或插入序）取值并记录告警（V1 可先 debug 日志）。

### 4.3 分解项：S_emotion（general=0.8；否则 1-|u-c|）

- 若 `U.emotion_score` 缺失 → `S_emotion = 0.8`
- 否则：
  - 若 `Cᵢ.emotion_score` 为 general（`None`）→ `S_emotion = 0.8`
  - 否则 → \(S_{emotion}=1-|U.emotion\_score - C_i.emotion\_score|\)

实现约束：

- 将 `S_emotion` clamp 到 `[0,1]`，避免异常值导致负分或溢出。

### 4.4 分解项：S_stage（对齐算法规则）

规则（按 `设计说明文档/個性化推薦算法規則.md`）：

- 若 `Cᵢ.stage == "general"` → `S_stage = 1`
- 若 `Cᵢ.stage` 命中用户阶段（例如用户 `expecting=1` 且内容 `stage="expecting"`）→ `S_stage = 1`
- 若用户阶段为 `unknown` 且内容阶段为非 unknown → `S_stage = 0.7`
- 其余 → `S_stage = 0`

### 4.5 个性化加成 S_personal（含降个性化约束）

\[
S_{personal}=\alpha \cdot C_i.personalization\_power \cdot \max(S_{need}, S_{context})
\]

降个性化约束（对齐 spec 与回退梯度）：

- 若 `fallback_level>=1` 或字段缺失明显/低置信度：
  - L1：限制 `personalization_power ≤ 0.5`
  - L2/L3：限制 `personalization_power = 0`

> 工程实现：本模块只做“限制后的 effective_personalization_power”，由调用方传入 `fallback_level`（或直接传入已限制后的 `content_profile.personalization_power`）。V1 建议：在 `content-repository`/引擎侧先做候选池约束，本模块再做一次保护性 clamp（防御式编程）。

### 4.6 不确定性惩罚 P_uncertainty（Push 默认启用）

\[
P_{uncertainty}=\beta \cdot (1-conf_U)\cdot(1-conf_{C_i})\cdot C_i.personalization\_power
\]

其中：

- `conf_U = user_profile.profile_confidence`
- `conf_{C_i} = content_profile.review_confidence`（缺省 0.7）

开关策略（V1）：

- `scene=push`：默认启用
- `scene=feed/widget`：默认关闭（可通过 config 打开）

### 4.7 Widget 情绪区间软降权（适中默认实现）

目标：当 `scene=widget` 且 `Cᵢ.emotion_score` 可计算（非 general）时，若超出 `[0.4, 0.8]` 不硬过滤，但应产生明显降权。

V1 选择一个“适中、可调参、可解释”的惩罚函数：

- 设区间为 `[lo, hi]`（默认 `0.4, 0.8`）
- 距离：
  - 若 `e < lo`，`d = lo - e`
  - 若 `e > hi`，`d = e - hi`
  - 否则 `d = 0`
- 惩罚：
  - \(P_{widget} = \gamma \cdot \frac{d}{(hi-lo)}\)
  - 默认 `γ = 0.25`（适中强度）
  - clamp 到 `[0, γ]`

落地方式：

- 将 `P_widget_emotion_out_of_range` 单独输出到 breakdown；
- 在总分中计入：
  - `P_risk_effective = external.P_risk + P_widget_emotion_out_of_range`

> 解释：当 `emotion_score` 达到区间边界外最大距离约为 0.4（例如 0 或 1）时，惩罚接近 `γ`，足以在 Widget 场景把“过低/过高情绪”的句子压到更靠后，但不会一刀切。

---

## 5. 场景默认参数（V1 建议）

对齐 `spec.md` 与算法规则：

- Feed：`w_need=0.35, w_emotion=0.20, w_stage=0.15, w_context=0.30`
- Push：`w_need=0.45, w_emotion=0.35, w_stage=0.15, w_context=0.05`，并默认 `enable_uncertainty_penalty=true`
- Widget：`w_need=0.25, w_emotion=0.25, w_stage=0.30, w_context=0.20`，并启用 `widget_emotion_soft_range=[0.4,0.8]`

推荐默认：

- `alpha=0.15`（可调，V1 用于让个性化加成“次要但可见”）
- `beta=0.30`（可调，V1 用于在低置信度时明显压低高个性化内容）
- `widget_emotion_penalty_gamma=0.25`（适中软降权强度）

> 注：`alpha/beta/gamma` 为工程默认建议值，后续应通过回归测试与线上指标调参；本模块必须允许 config 覆盖。

---

## 6. V1：S_fresh / P_fatigue / P_repeat 的处理方案（能落地且可演进）

### 6.1 V1 解决方式

- 在 `ScoreBreakdown` 中**保留** `S_fresh/P_fatigue/P_repeat` 字段；
- 本模块计算时：
  - 若上游未提供对应值，则默认按 0；
  - 若提供，则原样计入总分（本模块不解释其来源/计算方式）。

### 6.2 接口建议（为后续模块对接预留）

- `external_terms` 中携带：
  - `S_fresh`：例如时间衰减、跨日新鲜度（未来可由 `rerank-freqcap` 或 `reco-engine` 计算）
  - `P_fatigue/P_repeat`：由 `rerank-freqcap` 基于历史集合与冷却窗口计算

> 好处：V1 先保证“可排序 + 可解释 + 可插拔”；后续接入重排/频控时无需改动打分主干，只需注入外部项。

---

## 7. 测试计划（V1）

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

- 缺失字段一致性：
  - `U.need` 缺失 → `S_need=0.5`
  - `U.context` 缺失 → `S_context=0.5`
  - `U.emotion_score` 缺失 → `S_emotion=0.8`
- 不确定性惩罚（Push 默认启用）：
  - `conf_U`/`conf_C` 低且 `personalization_power` 高 → `final_score` 明显降低
  - 关闭开关后 `P_uncertainty=0`
- Widget 软降权：
  - `emotion_score=0.6`（区间内）→ `P_widget=0`
  - `emotion_score=0.0/1.0`（区间外）→ `P_widget` 接近 `gamma`，`final_score` 明显更低
- `pass=false`：
  - `final_score` 必须为 0（或按实现约定为 0），且 breakdown 中保留分解项（便于排查）

### 7.2 断言建议

- 断言 `breakdown` 字段集合稳定（不会因缺省而缺字段）。
- 断言所有分项均为有限数（非 NaN/Infinity），并在合理范围内（可对 `S_*` clamp 到 `[0,1]`）。

---

## 8. 风险与后续演进

### 8.1 已知风险

- `U.need/U.context` 若出现多个 key=1，会导致取值歧义；V1 需明确选择策略并记录告警，避免 silent bug。
- Widget 软降权强度（`gamma`）对结果影响较大，需要配合回归测试与线上指标调参。

### 8.2 后续演进方向（V1.1+）

- 将 `P_risk` 细化为可配置的多项惩罚（例如 health sensitive、过度个性化翻车风险等），并在 breakdown 中拆分输出。
- 引入 `S_fresh` 的时间衰减公式，并与 `rerank-freqcap` 的跨日多样性联动。

