# search-engine-app（任务清单）

> 目标：在 APP 场景实现 `DP + TopK` 的确定性组合搜索（对应文档 9.1），并接入 `scoring-tiebreak` 输出最优 layout。

## 0. 对齐与准备

- [x] 复核输入/输出与依赖
  - [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-app/spec.md`
  - [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-app/plan.md`
  - [x] 阅读 `设计说明文档/文档换行算法.md` 的 9.1/9.3/11 章节
- [x] 创建客户端模块目录
  - [x] 新建 `client/src/features/textWrap/searchApp/`

## 1. 类型定义与导出

- [x] 新建 `client/src/features/textWrap/searchApp/types.ts`
  - [x] 定义 `LineMode = 'AUTO' | 'FIXED'`
  - [x] 定义 `SearchAppConfig`（包含 `topK`、`tooLongThresholds` 等，首版给默认值）
  - [x] 定义 `SearchAppInput`：
    - [x] `tokens: Token[]`
    - [x] `lang: 'TC' | 'EN'`
    - [x] `breakpoints: Breakpoint[]`
    - [x] `availableWidth: number`
    - [x] `maxLines: number`
    - [x] `lineMode: LineMode`
    - [x] `measure: { contextProfile; fontSpec; measureWidthImpl }`（复用 width-measurement 类型）
    - [x] `scoring: { config; lexicons; debug? }`（复用 scoring-tiebreak 类型）
  - [x] 定义 `SearchAppResult`：
    - [x] 成功：`bestLayout { breaks; lines; wrappedText; meta }`
    - [x] 失败：`{ ok:false; reason:'TOO_LONG'|'WIDTH_UNKNOWN'|'NO_CANDIDATE'; meta }`
- [x] 新建 `client/src/features/textWrap/searchApp/index.ts`
  - [x] 统一导出 types 与核心入口 `searchBestLayoutApp()`

## 2. TopK 维护（确定性）

- [x] 新建 `client/src/features/textWrap/searchApp/topK.ts`
  - [x] 实现 `compareLayouts(a,b)`：
    - [x] `score` 降序
    - [x] `tieKey` 逐项比较（数值越小越优）
    - [x] `breaks` 字典序兜底（11.0A）
  - [x] 实现去重：同 breaks 仅保留最优
  - [x] 实现 `insertTopK(list, cand, K)`：插入、去重、排序、截断（全程确定性）

## 3. 约束与可解释失败原因

- [x] 新建 `client/src/features/textWrap/searchApp/constraints.ts`
  - [x] 实现 `isTooLong(tokens, lang)`：TC>60 或 EN>30（首版写死）
  - [x] 实现 `isLineStartPunctTC(tokens, pos, tcPunctuations)`：H6
  - [x] 实现 `isOverWidth(width, availableWidth)`：H1

## 4. DP + TopK 主流程

- [x] 新建 `client/src/features/textWrap/searchApp/dpTopK.ts`
  - [x] 实现入口 `searchBestLayoutApp(input)`
  - [x] 构造 dp：`dp[pos][linesUsed] = TopK[]`
  - [x] 枚举顺序固定：
    - [x] pos：0..N
    - [x] linesUsed：0..maxLines-1
    - [x] nextPos：从 breakpoints 里取 `>pos` 的 pos 升序 + 追加 `N`
  - [x] 每次转移：
    - [x] 构造 lineText：使用 `joinTokens`（EN 默认空格，TC 使用 rawSeparators 拼接）
    - [x] 测量 lineWidth：使用 `measureSliceWidthCached`
    - [x] 过滤硬约束：H1/空行/H6/maxLines
    - [x] 构造 layoutCandidate（breaks + lines[]）
    - [x] 调用 `scoreLayout` + `buildTieKey` 得到 score/tieKey
    - [x] 插入目标 dp 并维护 TopK
  - [x] 结束选择：
    - [x] AUTO：从 `dp[N][<=maxLines]` 选最优
    - [x] FIXED：优先 `dp[N][==maxLines]`，否则返回 `NO_CANDIDATE`（交给 overflow-fallback 再降级）
  - [x] meta 输出：
    - [x] `breaks`
    - [x] `scoreTopTerms`（若 debug=true，从 scoring 输出 Top-3）

## 5. 单测（Vitest）

- [x] 新建 `client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts`
  - [x] 确定性：同输入运行多次结果一致
  - [x] TopK 去重：两条路径同 breaks 只保留一个
  - [x] FIXED：必须刚好 maxLines，否则失败 reason=NO_CANDIDATE
  - [x] TC H6：行首标点导致的转移必须被禁止
  - [x] TOO_LONG：触发阈值直接返回 reason=TOO_LONG

## 6. 收尾

- [x] 跑测试与类型检查
  - [x] `npm test`
  - [x] `npx tsc --noEmit`
- [x] 将本 `tasks.md` 全部勾选完成
- [x] 更新 `spec_kit/overview.md`
  - [x] 标记 `search-engine-app` 已完成编码（阶段性）
  - [x] 写入变更文件清单

