# scoring-tiebreak（任务清单）

> 目标：实现“评分模型 + 确定性 tie-break”，用于对候选 layout 打分并产出可比较的 `tieKey`。  
> 约束：**整数评分**、**权重/最小词表写死客户端**、短语匹配为**连续 token 完全匹配**、debug Top-3 按**优先级顺序**输出。

## 0. 准备与对齐

- [x] 阅读并对齐口径
  - [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md`
  - [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md`
  - [x] 复核 `设计说明文档/文档换行算法.md` 中 10.x（评分）与 11（tie-break）章节的条目顺序
- [x] 在客户端创建模块目录
  - [x] 新建 `client/src/features/textWrap/scoring/`
  - [x] 确认后续文件均落在该目录下，避免与 `core/`、`breakpoints/`、`measure/` 混放

## 1. 定义类型与对外接口

- [x] 新建 `client/src/features/textWrap/scoring/types.ts`
  - [x] 定义 `TextWrapContext = 'APP' | 'WIDGET'`
  - [x] 定义 `LayoutCandidate`（与 spec 一致：`breaks` + `lines[]`）
  - [x] 定义 `LineInfo`：`start/end/text/width/tokenCount/charCount`
  - [x] 定义 `ScoreTerm`：`{ key: string; delta: number; detail?: any }`
  - [x] 定义 `ScoreBreakdown`：`{ total: number; terms: ScoreTerm[] }`
  - [x] 定义 `ScoredLayout`：`{ score; flags; tieKey; scoreBreakdown? }`
  - [x] 明确评分函数需要的“可用宽度 availableWidth”传入方式（二选一，必须确定并全链路一致）
    - [x] 方案 A：作为 `scoreLayout({ availableWidth, ... })` 的必填字段
    - [x] 方案 B：作为 `config.availableWidth`（不推荐，但允许）
- [x] 新建 `client/src/features/textWrap/scoring/index.ts`
  - [x] 统一导出 types 与核心函数（后续实现）

## 2. 权重与最小词表（写死客户端）

- [x] 新建 `client/src/features/textWrap/scoring/weights.ts`
  - [x] 以对象形式集中定义权重常量（全部整数）
  - [x] 写入 plan.md 里的首版默认值（P_/R_ 系列）
  - [x] 提供 `DEFAULT_WEIGHTS`（只读）与可选的 `mergeWeights(overrides)`（用于调用方覆盖）
- [x] 新建 `client/src/features/textWrap/scoring/lexicons.ts`
  - [x] 定义 `Lexicons` 类型（与 spec 输入一致）
  - [x] 写入最小词表（TC/EN：shift/accum/self，emotionWords 可选）
  - [x] 提供 `DEFAULT_LEXICONS`（只读）与可选的 `mergeLexicons(overrides)`
  - [x] 明确 `emotionPhrasesTC/EN`、`protectedPhrases` 首版可为空数组

## 3. Phrase 匹配（连续 token 完全匹配）

- [x] 新建 `client/src/features/textWrap/scoring/phraseMatch.ts`
  - [x] 定义 `PhraseSpan = { start: number; end: number; length: number }`（end 半开）
  - [x] 实现 EN phrase 预处理
    - [x] 使用 `core-contract`：`normalizeWhitespace` + `tokenizeEN`
    - [x] phrase 输入为原始字符串：先 normalizeWhitespace，再按空格切分为词序列
  - [x] 实现 TC phrase 预处理
    - [x] 使用 `grapheme-segmentation`：`segmentGraphemes` 得到 clusters
    - [x] phrase 输入为原始字符串：按 grapheme clusters 切分
  - [x] 实现连续区间完全匹配查找（禁止跳 token / 禁止模糊）
    - [x] 输出所有命中的 spans（稳定顺序：按 start 升序，start 相同按 length 降序）
  - [x] 实现 `isSpanSplitByBreaks(span, breaks)`：判断 span 是否跨行（被拆分）
  - [x] 单测覆盖：
    - [x] EN：`but,` 命中 `but`；`rebuttal` 不命中 `but`
    - [x] TC：包含 emoji/组合字符时不应被拆（依赖 grapheme 模块；这里只验证匹配结果可逆）

## 4. 评分实现（按 10.1 固定顺序，整数累计）

- [x] 新建 `client/src/features/textWrap/scoring/score.ts`
  - [x] 定义 `scoreLayout(args)`（入参包含：layoutCandidate、lang、context、config、lexicons、availableWidth、debug?）
  - [x] 实现 score 累计（必须严格按文档 10.1 的顺序）
    - [x] 1) 情绪短语/受保护短语拆分惩罚（最高优先）
      - [x] 匹配 emotionPhrases + protectedPhrases spans
      - [x] 若 span 被拆分：`score -= P_*`
      - [x] 采用方案 A：追加“更长短语优先”惩罚：`score -= phraseLength`（k=1 写死）
      - [x] 设置 `flags.emotionSplit=true`（如需区分 protected 可扩展 detail，但保持确定性）
    - [x] 2) 行长度（ideal/overMaxLen/tooShort）
      - [x] `idealWidth = availableWidth * idealWidthRatio[context]`（取整规则必须固定：建议 `Math.round` 并写注释）
      - [x] width-based 主项：`score += -abs(line.width - idealWidth)`（确保整数）
      - [x] over/tooShort 按权重惩罚（阈值若来自文档，集中定义常量，禁止散落）
    - [x] 3) Widow / 单字行惩罚（基于 tokenCount/charCount 口径写死）
    - [x] 4) TC 标点断点奖励（使用断点 meta/kind 或基于行首字符推断；口径写注释）
    - [x] 5) Shift/Accum/Self 断点奖励（TC/EN 词表）
      - [x] EN 必须使用 `core-contract` 的全词等值匹配（复用 `normalizeENKeyword/matchENKeyword`）
      - [x] 实现 spec 要求的折减：EmotionWord 与 Accum 同时命中时，Accum 奖励按 0.5 倍
        - [x] 因为整体使用整数：折减采用“整除/四舍五入”的固定规则（建议 `Math.floor(reward/2)` 并写注释）
    - [x] 6) 尾行过短惩罚（SHORT_LASTLINE）
  - [x] 记录 `scoreBreakdown`
    - [x] 每个 term 写入 `{ key, delta, detail }`
    - [x] terms 的产生顺序必须与 10.1 顺序一致（用于 debug 输出排序）
  - [x] debug Top-3（不按 |delta|，按优先级）
    - [x] 若 `debug=true`：只保留前 3 个“优先级最高的关键项”
    - [x] 规则：先按 10.1 顺序，必要时同优先级保持稳定（按出现顺序）
  - [x] 单测覆盖：
    - [x] 评分顺序固定：构造 layout 触发 EmotionSplit，断言 breakdown 第一项为对应 key
    - [x] 整数性：断言所有 delta 与 total 都是整数

## 5. tieKey 构造与比较（按 11 节固定顺序）

- [x] 新建 `client/src/features/textWrap/scoring/tieKey.ts`
  - [x] 实现 `buildTieKey(scoredLayout, layoutCandidate, config)`，严格按 11 节顺序产出 `Array<number | string>`
    - [x] emotionSplit（false 优先）
    - [x] overflowed（false 优先）
    - [x] lastLineWidth（更大优先：采用 `-lastLineWidth` 进入 tieKey，或在比较函数中反向比较，二选一且写注释）
    - [x] 宽度分布均衡度：`maxWidth - minWidth`（更小优先）
    - [x] 与理想切分点距离（更小优先；距离定义需与 breakpoints 模块/上游一致）
    - [x] breaks 字典序（复用 `core-contract` 的 breaks 比较函数）
  - [x] 单测覆盖：
    - [x] 同分 layout：通过 tieKey 能稳定选出同一个最优
    - [x] Widget approx：即使 width 近似（wordCount/graphemeCount），tieKey 仍可比较（不抛错）

## 6. 组合导出与回归测试

- [x] 完成 `client/src/features/textWrap/scoring/index.ts` 导出
  - [x] 导出 `DEFAULT_WEIGHTS/DEFAULT_LEXICONS`
  - [x] 导出 `scoreLayout`、`buildTieKey`
- [x] 新建 `client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts`
  - [x] 覆盖 EN/TC 两套 tokenization（EN 用 core，TC 用 grapheme）
  - [x] 覆盖短语拆分惩罚 + 方案 A 的“更长短语更重惩罚”
  - [x] 覆盖 debug Top-3 按优先级输出（非 |delta|）
  - [x] 覆盖 tieKey 顺序项的比较方向（尤其 lastLineWidth）

## 7. 文档与收尾（完成后必须做）

- [x] 将 `spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md` 全部任务勾选完成
- [x] 更新 `spec_kit/overview.md`
  - [x] 标记 `scoring-tiebreak` 已完成编码（阶段性）
  - [x] 在 overview 中记录本次变更文件清单（至少包含新增的客户端文件与测试文件）

