# scoring-tiebreak（技术计划）

## 1. 计划目标

基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`，在客户端实现可解释的评分模型与确定性 tie-break，保证：

- **整数评分**：所有评分项使用整数，避免浮点误差
- **权重写死客户端**：首版权重/词表内置在客户端代码（后续如需灰度，用 `configVersion` 管理）
- **短语匹配口径固定**：emotionPhrases/protectedPhrases 必须是“连续 token 完全匹配”
- **更长短语优先**：采用方案 A——在触发拆分惩罚时按“被拆分短语长度”追加惩罚（确定性）
- **debug 可解释**：输出 score breakdown；debug=true 时输出 Top-3，按规则优先级排序（不是按 |delta|）
- **tieKey 固定**：严格按文档第 11 节顺序构造 tieKey；Widget approx mode 仍使用数值 width（可为近似单位），确保 tie-break 可用

本模块不负责搜索（DP/Beam），只负责对“候选 layout”打分并给出可比较的 tieKey。

## 2. 默认技术决策（本计划采用）

### 2.1 评分数值体系

- 全部评分项使用 **整数**
- 不引入 EPS（因为不使用浮点）；分数相等即为相等

### 2.2 权重与默认值（写死客户端）

首版采用文档 10.3 的建议量级（可在实现中集中定义为常量/配置对象）：

- `P_EMOTION_SPLIT = 10000`
- `P_PROTECTED_SPLIT = 10000`
- `P_WIDOW_WORD = 800`
- `P_WIDOW_LINE = 500`
- `P_SHORT_LASTLINE = 300`
- `P_PARTICLE_ISO = 200`
- `R_PUNCT_BREAK = 80`
- `R_SHIFT_BREAK = 60`
- `R_ACCUM_BREAK = 40`
- `R_SELF_BREAK = 20`
- `P_OVER_MAXLEN = 30`
- `P_TOO_SHORT = 10`

> 说明：权重必须集中在一个文件，禁止散落在各函数内；后续调整通过 `configVersion` 记录。

### 2.3 最小词表（TC/EN）

首版提供最小可用词表（来源：文档第 7 节）：

- TC：
  - shift：`但/可是/然而/却/只是/偏偏`
  - accum：`已经/一直/曾经/终于/还是/到现在`
  - self：`你/我/自己/我们/别人`
  - emotionWords（可选）：`累/痛/怕/孤单/委屈/撑/崩溃/放弃`
- EN（全词等值匹配，使用 core-contract 的 normalize 规则）：
  - shift：`but/yet/so`（and 轻量可选）
  - accum：`already/still/even/just/really`
  - self：`you/yourself/me/we`
  - emotionWords（可选）：`tired/afraid/lonely/hurt/overwhelmed/give up`

emotionPhrasesTC/EN 与 protectedPhrases：

- 首版允许为空数组（先把匹配与惩罚机制写死）
- 若业务侧已有短语清单，后续直接填充并通过 Golden Cases 回归

### 2.4 宽度派（tieKey 使用 width）

- layoutCandidate.lines[*].width 字段作为 tieKey 的 width 来源
- 在 width unknown/approx mode 时，上游仍需给出“数值宽度”（例如用 wordCount/graphemeCount 作为 width 近似值），保证 tie-break 可执行

## 3. 目录与产物（客户端侧实现）

建议代码落位：

- `client/src/features/textWrap/scoring/`
  - `types.ts`（Layout/LineSegment/ScoreBreakdown）
  - `weights.ts`（权重常量，写死）
  - `lexicons.ts`（最小词表，写死）
  - `phraseMatch.ts`（连续 token 完全匹配 + span 预计算）
  - `score.ts`（按 10.1 顺序累计分数）
  - `tieKey.ts`（按 11 节构造 tieKey）
  - `debug.ts`（Top-3 提取与排序）
  - `index.ts`
  - `__tests__/scoringTiebreak.test.ts`

## 4. 输入/输出契约（实现阶段写死）

### 4.1 输入

- `layoutCandidate`：
  - `breaks: number[]`
  - `lines: Array<{ start; end; text; width; tokenCount; charCount }>`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `config`：
  - `weights: Record<string, number>`（首版由内置默认值生成）
  - `idealWidthRatio: { APP: 0.90; WIDGET: 0.95 }`（来自文档 8.3A）
  - `ellipsisToken: "…"`（用于溢出模块；本模块只保留配置入口）
  - `particleWhitelistTC: string[]`（语尾语助词白名单；惩罚减半）
- `lexicons`（首版使用内置最小词表；允许调用方覆盖）

### 4.2 输出

- `scoredLayout`：
  - `score: number`
  - `flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }`
  - `tieKey: Array<number | string>`
  - `scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> }`

## 5. 实现步骤（按落地顺序）

### 5.1 Phrase 匹配与 span 预计算（连续 token 完全匹配）

实现口径（必须）：

- EN：对输入 tokens 使用 `core-contract` 的 `normalizeWhitespace + tokenizeEN`
- TC：对输入 tokens 使用 `grapheme-segmentation` 的 clusters
- 对 phrase：
  - EN：先 normalizeWhitespace，再按空格切分为词序列（不做 substring）
  - TC：按 grapheme clusters 切分
- 匹配方式：在 token 序列中寻找 **连续区间** 完全匹配
- 输出 spans：`{ start, end, length }`（end 为半开区间）

### 5.2 评分项（严格按 10.1 顺序累计）

按文档 10.1 固定顺序计算并记录 breakdown：

1. **情绪短语/受保护短语拆分惩罚**（最高优先）
   - 若某 phrase span 被 breaks 拆到不同的行：
     - `score -= P_*`
     - 并按方案 A 追加惩罚：`score -= phraseLength`（或 `k * phraseLength`，k 写死为 1，确保确定性）
     - flags：`emotionSplit=true`（或 protectedSplit 也可复用同一 flag/额外字段，需写死）
2. **行长度（ideal/over/too short）**
   - `idealWidth = availableWidth * idealWidthRatio[context]`（availableWidth 由上游传入）
   - 以 width-based 作为主要项：`score += -abs(line.width - idealWidth)`
   - overMaxLen/tooShort 按配置阈值与权重惩罚（首版可先按文档默认区间落地）
3. **Widow / 单字行惩罚**
4. **TC 标点断点奖励**
5. **Shift/Accum/Self 断点奖励（含 EN 行首/行尾感知）**
6. **尾行过短惩罚（SHORT_LASTLINE）**

> 注意：必须保留每一项的确定性记录，不允许“覆盖”前序裁决结果（见文档 10.1A）。

### 5.3 Debug Top-3 输出

- 输出结构：`ScoreBreakdown = { total, terms[] }`
- debug=true 时只保留 Top-3 term，但排序规则必须按优先级（10.1 顺序），同优先级再按出现顺序稳定排序

### 5.4 tieKey 构造（按 11 节固定顺序）

按文档顺序生成 tieKey（比较时逐项比较）：

1. `emotionSplit=false` 优先（可用 `0/1`）
2. `overflowed=false` 优先
3. `lastLineWidth` 更大优先（因此 tieKey 可存 `-lastLineWidth` 或比较时反向）
4. 行宽分布更均匀优先（`maxWidth-minWidth` 更小优先）
5. 断点更接近理想切分点优先（距离和更小）
6. breaks 字典序更靠前优先（使用 core-contract 的 breaks 比较规则）

## 6. 测试计划（Vitest）

### 6.1 Phrase 匹配

- EN：确保全词等值匹配（`but,` 命中 `but`；`rebuttal` 不命中）
- TC：emoji/组合字符的 phrase 不被拆（依赖 grapheme-segmentation 已覆盖）

### 6.2 评分顺序与 breakdown

- 构造触发 EmotionSplit 的 layout，断言 breakdown 中第一项为 EMOTION_SPLIT（或对应 key），且其记录不会被后续项覆盖

### 6.3 tieKey 确定性

- 构造同分 layout，验证 tieKey 顺序能稳定选出同一个最优

## 7. 完成定义（DoD）

- 整数评分全链路跑通（无浮点）
- phrase 连续 token 完全匹配实现完毕（EN/TC）
- scoring 按 10.1 顺序累计，并输出可解释 breakdown
- debug Top-3 按优先级输出
- tieKey 按 11 节固定顺序构造与比较
- 单测覆盖匹配、评分顺序、tieKey 稳定性

