更新换行算法和APP-PUSH

This commit is contained in:
吕新雨
2026-02-10 11:39:33 +08:00
parent f03d36b5e9
commit ee2d9f44ea
105 changed files with 9967 additions and 233 deletions

View File

@@ -0,0 +1,162 @@
# breakpoint-candidates技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,实现“候选断点生成与裁剪”模块,输出**可控规模、确定性排序**的 breakpoints 集合,保证:
- EN/TC 断点生成口径一致(断点 `pos` 均是 token 边界索引)
- 同输入必定同输出(去重、排序、裁剪与过滤全流程确定性)
- 候选规模上限严格生效(尤其 TC
- 支持约束过滤:`forbiddenBreakRanges`(必须)与 `protectedPhrases`(可选优化)
本模块只产出 breakpoints不做组合搜索与评分。
## 2. 默认技术决策(本计划采用)
- **输出结构**`Array<{ pos, kind, priority }>`,最终按 `pos` 升序
- **去重策略**:同一 `pos` 若出现多个来源候选,保留 `priority` 更高者priority 相同按 `kind` 固定序优先)
- **裁剪策略TC**:先按“候选重要性排序”截断到 `tcMaxCandidateBreaks`,再按 `pos` 升序输出
- **过滤策略**
- 必做:`forbiddenBreakRanges` 命中直接剔除
- 可选优化:若已计算 `protectedPhrases` 的 span可在生成阶段剔除 span 内断点(否则交给评分阶段强惩罚淘汰)
## 3. 输入/输出与关键口径
### 3.1 输入(来自上游)
- `tokens: Token[]`
- ENWORD tokens不包含 SPACE token
- TCgrapheme cluster tokens允许包含空格 cluster `" "`,用于 SPACE 断点)
- `lang: 'TC' | 'EN'`
- `maxLines: number`
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
### 3.2 输出(确定性)
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
- `pos`token 边界索引,范围 `0..N`
- **最终输出必须按 `pos` 升序**
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
### 3.3 断点边界定义(统一口径)
- 候选断点只生成在“行内断点”位置:`pos ∈ [1, N-1]`
- `pos=0``pos=N` 由搜索器作为“起止边界”处理(不作为候选断点输出)
## 4. 生成规则(按语言)
### 4.1 EN词边界kind=SPACE
#### 规则
- tokens 仅为 WORD不生成 SPACE token
- 对每个词边界产生候选断点:
-`i in 1..N-1` 生成 `pos=i, kind='SPACE'`
- `priority` 固定为基础值(建议 `priority=10`
#### 验收要点
- `"I am so tired"`N=4→ breakpoints.pos 必为 `[1,2,3]`(升序)
### 4.2 TC标点/空格/BALANCE
#### 4.2.1 标点后断点kind=PUNCT最高优先级
-`tokens[i].text` 属于 `tcPunctuations`
- 生成 `pos=i+1, kind='PUNCT'`
- `priority` 建议最高(例如 `priority=30`
#### 4.2.2 空格后断点kind=SPACE中优先级
-`tokens[i].text === ' '`
- 生成 `pos=i+1, kind='SPACE'`
- `priority` 建议中等(例如 `priority=20`
> 注:若上游对 TC 也做了空白 NORMALIZE则空格通常不会连写断点仍保持确定性。
#### 4.2.3 BALANCE 断点kind=BALANCE低优先级
目的:在无标点时,仍在“接近理想位置”附近提供少量断点,提升可解性与观感。
**理想位置计算(确定性简化版)**
- `N = tokens.length`
- `targetLines = min(maxLines, N)`(至少 1且不超过 N
-`lineIndex in 1..targetLines-1`
- `idealPos = round((N * lineIndex) / targetLines)`
- 在区间 `[idealPos - balanceRange, idealPos + balanceRange]` 生成少量候选 `pos`
**生成细则**
- 候选 `pos` 必须落在 `[1, N-1]`
- 去重前可以允许重复(后续统一去重)
- `priority` 建议最低(例如 `priority=5`
> 裁决补充口径BALANCE 断点允许落在 emotionPhrase/protectedPhrases 的 span 内;是否可用由评分阶段强惩罚决定(本模块不做语义裁决)。
## 5. 过滤、去重、裁剪与排序(必须确定性)
### 5.1 forbiddenBreakRanges 过滤(必须)
-`pos` 落在任一 `forbiddenBreakRanges` 的区间内(按项目约定:`start <= pos <= end` 或半开区间,必须写死一种),则剔除该 breakpoint
- 过滤必须发生在最终输出前,保证确定性
### 5.2 去重(必须)
同一 `pos` 可能来自多个来源(例如 PUNCT 与 BALANCE
-`priority` 更高者
- `priority` 相同则按固定 kind 序:`PUNCT > SPACE > BALANCE > OTHER`
### 5.3 TC 规模上限裁剪(必须)
`lang=TC` 且候选数超过 `tcMaxCandidateBreaks`
1. 计算每个 pos 到最近 `idealPos` 的距离 `distToIdeal`(若无 idealPos 列表则设为大值)
2. 按以下 key 排序后截断(排序必须固定):
- `priority` 降序
- `distToIdeal` 升序
- `pos` 升序
3. 取前 `tcMaxCandidateBreaks`
最后再按 `pos` 升序输出(输出顺序固定)。
### 5.4 meta 输出
- `originalCount`:过滤/去重/裁剪前的候选数量
- `finalCount`:最终输出数量
- `pruned`是否发生过裁剪finalCount < originalCount
## 6. 测试计划Vitest
### 6.1 EN 断点生成
- 输入 tokens=[I, am, so, tired] → pos=[1,2,3](确定性)
### 6.2 TC 标点/空格断点
- `tokens=['我','好','累','','😮‍💨']``tcPunctuations` 包含 ``
- 必须包含 `pos=4(kind=PUNCT)`
### 6.3 BALANCE 与裁剪
- 构造无标点长文本balanceRange>0 且 `tcMaxCandidateBreaks` 很小
- 验证裁剪后数量上限生效
- 验证输出仍按 pos 升序
### 6.4 forbiddenBreakRanges
- 给定 ranges断言命中区间内的 pos 一律被剔除
### 6.5 确定性(关键)
- 同输入多次调用 breakpoints 输出完全一致(包括 meta
## 7. 完成定义DoD
- EN/TC 候选断点生成口径与裁剪规则写死并实现
- 去重/排序/裁剪/过滤流程完全确定性
- TC 上限 `tcMaxCandidateBreaks` 生效
- 单测覆盖EN/TC 基础、BALANCE、裁剪、forbiddenBreakRanges、确定性

View File

@@ -0,0 +1,51 @@
# breakpoint-candidates子模块规范
## 子模块名称
breakpoint-candidates候选断点生成与裁剪
## 目标描述
基于 token 序列生成“可控规模、确定性排序”的候选断点集合breakpoints并应用去重、排序与约束过滤确保后续搜索器复杂度可控且跨端一致。
本模块输出的是“断点候选集合”,不负责“组合搜索选最优”。
## 输入/输出定义
### 输入
- `tokens: Token[]`
- `lang: 'TC' | 'EN'`
- `maxLines: number`
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
### 输出
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
- **pos**token 边界索引0..N
- **排序**:按 `pos` 升序(最终输出必须确定性)
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
## 验收标准(可验证)
- **EN 口径**
- tokens 仅为 WORD不生成 SPACE token断点仅存在于词间
- 每个词边界产生 `kind=SPACE` 的候选断点(按配置可做裁剪,但必须确定性)
- **TC 口径**
- 标点后断点 `kind=PUNCT` 优先级最高
- 空格后断点 `kind=SPACE` 次之
- BALANCE 断点:围绕理想切分点附近生成少量断点(允许落在短语 span 内,是否可用交给评分惩罚)
- **去重/排序/过滤确定性**
- 同一 `pos` 多来源断点:保留 priority 更高者
- 输出按 `pos` 升序
- `forbiddenBreakRanges` 命中者必定被剔除
- **规模上限生效**
- TC 输出候选断点数不超过 `tcMaxCandidateBreaks`
- 截断策略确定性(按 priority + 距离理想位置等固定规则)
## 依赖与关联
- **依赖**`core-contract`token 与索引语义)、`grapheme-segmentation`TC tokens
- **被依赖**`search-engine-app``search-engine-widget`

View File

@@ -0,0 +1,164 @@
# breakpoint-candidates任务清单
> 对应计划:`spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 所有代码注释必须为简体中文,并把“去重/裁剪/排序/过滤”的**确定性口径**写死,避免后续模块漂移。
---
## 1. 前置对齐(口径必须写死)
- [x] 1.1 明确 `pos` 的边界范围:仅输出 `pos ∈ [1, N-1]`
- **原因**`pos=0/N` 属于搜索器的起止边界,不应作为候选断点
- **验收**:单测覆盖 `N=0/1/2` 等边界输入,不会输出非法 pos。
- [x] 1.2 明确 `forbiddenBreakRanges` 的区间口径(写死一种)
- **本任务采用**:闭区间 `start <= pos && pos <= end`
- **验收**单测能验证闭区间边界命中start/end 两端都被剔除)。
---
## 2. 目录与代码骨架(客户端侧实现)
- [x] 2.1 新建目录 `client/src/features/textWrap/breakpoints/`
- **产出**(建议文件):
- `types.ts`Breakpoint/Config/Constraints
- `generateBreakpoints.ts`(主入口,纯函数)
- `tcCandidates.ts`TCPUNCT/SPACE/BALANCE 生成)
- `enCandidates.ts`ENSPACE 断点生成)
- `filterAndDedup.ts`(过滤/去重/排序/裁剪)
- `__tests__/generateBreakpoints.test.ts`
- `index.ts`(统一导出)
- **验收**目录存在TS 可正常 import不报路径错误
- [x] 2.2 定义最小类型集合(只覆盖本模块)
- **必须包含**
- `Breakpoint = { pos: number; kind: 'PUNCT'|'SPACE'|'BALANCE'|'OTHER'; priority: number }`
- `BreakpointMeta = { pruned: boolean; originalCount: number; finalCount: number }`
- `Constraints = { forbiddenBreakRanges?: Array<{ start: number; end: number }>; protectedPhrases?: string[] }`
- `Config = { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
- **验收**:后续实现文件引用类型清晰,且不会引入无关依赖。
---
## 3. 断点生成(按语言)
- [x] 3.1 EN 候选断点生成kind=SPACEpriority 固定)
- **规则**
-`i in 1..N-1` 生成 `pos=i, kind='SPACE'`
- `priority=10`(写死)
- **验收**
- tokens=[I, am, so, tired] → pos=[1,2,3]
- [x] 3.2 TC标点后断点kind=PUNCT
- **规则**
-`tokens[i].text ∈ tcPunctuations`,生成 `pos=i+1, kind='PUNCT', priority=30`
- **验收**
- tokens=['我','好','累','','😮‍💨'] → 包含 `pos=4(kind=PUNCT)`
- [x] 3.3 TC空格后断点kind=SPACE
- **规则**
-`tokens[i].text === ' '`,生成 `pos=i+1, kind='SPACE', priority=20`
- **验收**:构造含空格 tokens断点生成稳定且不越界。
- [x] 3.4 TCBALANCE 断点生成kind=BALANCE
- **规则**
- `targetLines = min(maxLines, N)`
-`lineIndex in 1..targetLines-1`
- `idealPos = round((N * lineIndex) / targetLines)`
-`[idealPos-balanceRange, idealPos+balanceRange]` 内生成 pos裁剪到 `[1,N-1]`
- `priority=5`
- **验收**
- 无标点长文本:可生成接近理想位置的候选断点(数量受控、确定性)。
---
## 4. 过滤、去重、裁剪与最终排序(必须确定性)
- [x] 4.1 forbiddenBreakRanges 过滤(闭区间)
- **规则**:命中任一 range 则剔除该 `pos`
- **验收**range 边界 start/end 都会剔除。
- [x] 4.2 去重:同 pos 只保留一个 breakpoint
- **规则**
- priority 更高者优先
- priority 相同按 kind 固定序:`PUNCT > SPACE > BALANCE > OTHER`
- **验收**:构造同 pos 多来源候选,结果唯一且确定性。
- [x] 4.3 TC 裁剪:超过 `tcMaxCandidateBreaks` 时截断(确定性排序后截断)
- **排序 key写死**
- priority 降序
- distToIdeal 升序(到最近 idealPos 的距离;无 idealPos 时为大值)
- pos 升序
- **验收**
- 当候选数 > 上限时finalCount==tcMaxCandidateBreaks
- 截断结果稳定(同输入同输出)
- [x] 4.4 最终输出排序:按 `pos` 升序
- **验收**:无论内部裁剪排序如何,最终输出始终 `pos` 升序。
- [x] 4.5 meta 输出
- **规则**
- `originalCount`:过滤/去重/裁剪前的候选数量
- `finalCount`:最终输出数量
- `pruned = finalCount < originalCount`
- **验收**:单测断言 meta 与候选数量一致。
---
## 5. 单元测试Vitest
- [x] 5.1 新建 `generateBreakpoints.test.ts`,覆盖 EN 基础用例
- **验收**pos=[1,2,3] 且升序。
- [x] 5.2 覆盖 TCPUNCT/SPACE/BALANCE 生成
- **验收**:关键样例存在,且 BALANCE 不越界。
- [x] 5.3 覆盖 forbiddenBreakRanges闭区间
- **验收**start/end 命中都剔除。
- [x] 5.4 覆盖去重与 kind 优先级
- **验收**:同 pos 多候选时输出唯一且正确 kind。
- [x] 5.5 覆盖 TC 裁剪上限与确定性
- **验收**
- 数量上限严格生效
- 同输入多次调用输出完全一致(包括 meta
---
## 6. 最终自检清单(合入前)
- [x] 6.1 `npm test` 通过(包含本模块新增用例)
- **验收**:不影响现有测试文件。
- [x] 6.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
- **验收**:无类型错误。
- [x] 6.3 注释与口径自检(简体中文)
- **检查点**
- `pos` 边界范围 `[1,N-1]`
- forbiddenBreakRanges 闭区间口径
- 去重优先级与裁剪排序 key 的固定顺序
- **验收**:后续模块开发者只看代码也不会产生歧义。
---
## 7. 文档回写(任务清单执行完毕后必须做)
- [x] 7.1 在 `spec_kit/overview.md``Text Wrap` 条目下补充执行状态
- **建议写法**
- 增加一行:`- **已完成编码(阶段性)**breakpoint-candidates候选断点生成与裁剪`
- **验收**overview 能反映该子模块已完成,便于全局追踪。

View File

@@ -0,0 +1,111 @@
# core-contract技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,落地跨端一致的“基础口径与契约”,为后续断点生成、搜索与评分提供稳定输入与确定性工具,确保:
- EN/TC 的 **token 索引体系** 与断点 `pos` 语义固定
- 文本 **可重组**:任意 `[start..end)` 区间可稳定还原为行文本
- EN 关键词命中规则严格为 **全词等值匹配**(避免 substring 误伤)
- 空白归一化策略可配置但默认一致(推荐 NORMALIZE
- 提供可复用的 **确定性比较工具**(用于 breaks 字典序/tieKey 比较)
## 2. 默认技术决策(本计划采用)
- **空白策略**:默认 `whitespacePolicy=NORMALIZE`
- 行为:折叠连续空白为 1 个空格、去首尾空白
- 并在 meta后续模块中打点 `hadMultiWhitespace`(本模块先预留布尔返回位)
- **EN tokenize**:仅生成 WORD token不生成 SPACE token以空白分隔标点按“极简派”保留在词内
- **TC tokenize**:本模块只定义接口,具体分割由 `grapheme-segmentation` 提供
- **EN 关键词命中**`lowercase → strip 两端常见标点 → 等值比较`,禁止 contains/substring
- **确定性比较**breaks 字典序比较按“逐项比较 + 公共前缀相同则更短者更小”
## 3. 目录与产物
本子模块目录:
- `spec_kit/Text Wrap/modules/core-contract/spec.md`
- `spec_kit/Text Wrap/modules/core-contract/plan.md`(本文)
建议未来代码落位(实现阶段再定,不在本计划强制):
- `client/src/features/textWrap/core/`(或 `client/src/utils/textWrap/`
## 4. 设计与实现要点(按落地顺序)
### 4.1 文本预处理normalizeWhitespace
实现 `normalizeWhitespace(text) -> { normalizedText, hadMultiWhitespace }`
- 规则:
- 把任意连续空白(空格/制表/换行等)折叠为单个空格
- 去除首尾空白
- 注意:
- 该规范会改变输入文本;必须作为“算法契约”的一部分固定下来
- 若后续产品需要保留原始空白,则走 `PRESERVE` 分支并输出 `rawSeparators`(见 4.3
### 4.2 EN tokenizeword tokens极简派
实现 `tokenizeEN(normalizedText) -> tokens[]`
- 以空格分隔生成 token
- token 只包含 WORD标点视为词内字符例如 `tired.``Wait...``hello—world``don't` 都是单 token
- 输出 token 的 `text/start/end`start/end 为原始或 normalized 的字符区间,需固定口径;建议以 normalizedText 为基准)
### 4.3 文本重组joinTokens
实现 `joinTokens(tokens, start, end, separators?) -> string`
- EN 默认:使用单空格 `" "` join `[start..end)` 的 token.text
-`whitespacePolicy=PRESERVE`
- 需要 `rawSeparators[i]` 表示 tokens[i] 与 tokens[i+1] 间的原始分隔符
- 重组时按 separators 拼接(本计划仅定义接口与行为)
### 4.4 EN 关键词命中normalizeENKeyword + matchENKeyword
实现:
- `normalizeENKeyword(tokenText) -> string`
- `lowercase`
- `strip` 两端常见标点(集合需配置化并全端一致,默认参考文档:`, . ! ? : ; " ' … — ( ) [ ] { }`
- `matchENKeyword(tokenText, keyword) -> boolean`
- `normalizeENKeyword(tokenText) === normalizeENKeyword(keyword)`
- 禁止 `includes/contains` 类 substring 命中
### 4.5 断点/区间的索引契约
固化约定(后续模块必须复用,不得自行发挥):
- token 索引:`tokens[0..N-1]`
- 断点 `pos`:位于 token 边界,切分为 `[0..pos)``[pos..N)`
- 行区间:`[start..end)` 表示 `tokens[start] ... tokens[end-1]`
## 5. 回归用例与验证方式
### 5.1 必测示例EN
- `"I am so tired"` → tokens=`[I, am, so, tired]`
- `pos=2``"I am"` / `"so tired"`
- 标点极简派:
- `"tired."` 为单 token
- 关键词命中:
- keyword=`"but"``"but,"` 命中;`"rebuttal"` 不命中
### 5.2 确定性检查
- 同一输入在同一配置下多次调用:
- `normalizedText``tokens[]``joinTokens()``matchENKeyword()` 输出完全一致
## 6. 风险与规避
- **start/end 索引口径漂移**:若不同端选择以原始 text 或 normalizedText 计数,可能导致 span 对不齐
- 规避:本模块明确 start/end 以 normalizedText 为准(或在实现阶段统一选择一种并写入 README/注释)
- **标点集合不一致**strip 集合若跨端不同会导致命中差异
- 规避:将 `punctuationStripSetEN` 写入配置并版本化,禁止散落常量
## 7. 完成定义DoD
- `core-contract/spec.md` 中定义的输入/输出与验收条目均有可运行的最小实现或可验证的约束说明
- EN tokenize / 空白归一化 / 关键词命中 / breaks 字典序比较口径写清楚且可复现
- 关键示例用例可在本地/CI 以单元测试或脚本方式验证(实现阶段落地)

View File

@@ -0,0 +1,59 @@
# core-contract子模块规范
## 子模块名称
core-contract核心口径与契约
## 目标描述
定义并固化跨端一致的“基础口径”,为后续断点生成、搜索与评分提供统一契约,避免实现偏差:
- **索引体系**EN/TC 的 token 与断点 `pos` 语义
- **文本重组**:从 token 区间稳定重组回行文本
- **规范化**:空白归一化策略(默认折叠空白、去首尾)
- **EN 关键词命中规则**:全词等值匹配(`lowercase → strip 两端常见标点 → 等值比较`),禁止 substring/contains
- **配置与版本**`configVersion` 的语义与回溯字段;全端一致的默认值入口
- **确定性比较**layout tie-break 的字典序比较口径(作为后续模块复用工具)
本模块不负责“换行搜索”,只负责**定义数据结构与基础函数**。
## 输入/输出定义
### 输入
- `text: string`
- `lang: 'TC' | 'EN'`
- `options?: { preserveRawSeparators?: boolean }`
- `config: { punctuationStripSetEN: string[]; whitespacePolicy: 'NORMALIZE' | 'PRESERVE' }`
### 输出
- `normalizedText: string`
- `tokens: Array<{ text: string; start: number; end: number }>`
- ENtoken 仅为 WORD不产生 SPACE token标点视为词内字符
- TCtoken 为 grapheme cluster具体分割由 `grapheme-segmentation` 模块实现/提供)
- `rawSeparators?: string[]`
- 可选:当选择保留原始空白时,输出 token 间分隔符映射
- 基础工具函数(逻辑输出):
- `joinTokens(start, end) -> string`
- `normalizeENKeyword(tokenText) -> string`
- `matchENKeyword(tokenText, keyword) -> boolean`
## 验收标准(可验证)
- **索引语义一致**
- EN`"I am so tired"` tokens=`[I, am, so, tired]`,断点 `pos=2` 必然切为 `"I am"` / `"so tired"`
- TC断点 `pos` 表示在第 `pos` 个 grapheme 之前断开
- **EN 标点极简派一致**
- `"tired."` 作为一个 token断点只允许在词与词之间
- **EN 关键词命中无误伤**
- keyword=`"but"``"but,"` 命中;`"rebuttal"` 不命中
- **空白归一化确定性**
- 输入含多空格/首尾空白时,输出 `normalizedText` 可预测且稳定
- **工具函数确定性**:相同输入在多次调用与多端实现中输出一致
## 依赖与关联
- **被依赖**`breakpoint-candidates``scoring-tiebreak``search-engine-*``overflow-fallback``integration`
- **依赖**TC token 分割依赖 `grapheme-segmentation`

View File

@@ -0,0 +1,150 @@
# core-contract任务清单
> 对应计划:`spec_kit/Text Wrap/modules/core-contract/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 涉及“口径”的任务,必须在代码注释中写清楚(简体中文),避免后续模块实现漂移。
---
## 1. 文档对齐(先把口径写死,避免实现漂移)
- [x] 1.1 复核 `core-contract/spec.md``core-contract/plan.md` 的一致性
- **检查点**
- `whitespacePolicy`NORMALIZE/PRESERVE语义一致
- EN token 规则为“极简派”(不拆标点、不生成 SPACE token
- EN 关键词命中为“全词等值匹配”(禁止 substring
- breaks 字典序比较规则清晰且无歧义
- **验收**:两份文档无冲突描述;关键字段命名一致。
- [x] 1.2 明确 `start/end` 的索引口径(以 normalizedText 为基准)并写入代码注释与 README如有
- **原因**:跨端 span/调试定位会依赖该口径
- **验收**:任意 token 的 `start/end` 都可映射到同一份文本基准normalizedText
---
## 2. 目录与代码骨架(客户端侧优先落地)
> 说明:当前仓库已有 `client/src/features/*` 结构Text Wrap 建议也放到 `features/` 下,便于后续 Home/Widget 复用。
- [x] 2.1 新建目录 `client/src/features/textWrap/core/`
- **产出**(建议文件):
- `types.ts`Token/Config/Options 类型
- `normalizeWhitespace.ts`
- `tokenizeEN.ts`
- `joinTokens.ts`
- `enKeyword.ts`normalizeENKeyword/matchENKeyword
- `compare.ts`breaks 字典序比较)
- `index.ts`(统一导出)
- **验收**:目录存在且可被 TS 正常 import不报路径错误
- [x] 2.2 定义 `Token` 与基础配置类型(只含 core-contract 需要的字段)
- **要求**
- `Token` 至少包含 `text/start/end`
- `CoreConfig` 至少包含 `whitespacePolicy``punctuationStripSetEN`
- **验收**:类型定义满足后续函数签名需要,且命名清晰。
---
## 3. 核心函数实现(纯函数 + 确定性)
- [x] 3.1 实现 `normalizeWhitespace(text)`(默认 NORMALIZE
- **规则**
- 连续空白折叠为单个空格
- 去除首尾空白
- 返回 `hadMultiWhitespace`(用于后续 meta 打点)
- **验收**
- 输入 `" a b \n c "` 输出 `"a b c"`
- `hadMultiWhitespace` 在出现折叠/trim 时为 true
- [x] 3.2 实现 `tokenizeEN(normalizedText)`(极简派)
- **规则**
- 以空格切分为 WORD tokens
- 标点作为 token.text 的一部分(不拆)
- 不生成 SPACE token
- **验收**
- `"I am so tired"``[I, am, so, tired]`
- `"tired."` 为单 token
- [x] 3.3 实现 `joinTokens(tokens, start, end, separators?)`
- **规则**
- 默认用单空格 join `[start..end)` 的 token.text
- `start/end` 为半开区间,越界/空区间需有明确行为(建议:空区间返回空字符串,交由上层硬约束处理)
- **验收**
- tokens=`[I, am, so, tired]``join(0,2)``"I am"`
- [x] 3.4 实现 EN 关键词命中:`normalizeENKeyword` + `matchENKeyword`
- **规则**
- `lowercase`
- strip 两端常见标点(使用配置 `punctuationStripSetEN`,全端一致)
- 等值比较(禁止 substring
- **验收**
- keyword=`but``but,` 命中;`rebuttal` 不命中
- [x] 3.5 实现 breaks 字典序比较 `compareBreaksLexicographically(a, b)`
- **规则**
- 从 index=0 起逐项比较,首个不同元素更小者更小
- 公共前缀相同则更短数组更小
- **验收**
- `[2] < [3]`
- `[2] < [2, 5]`
- `[2, 3] > [2]`
---
## 4. 单元测试Vitest纯函数为主
- [x] 4.1 新建测试目录 `client/src/features/textWrap/core/__tests__/`
- **验收**:测试文件可被现有 test runner 发现。
- [x] 4.2 为 `normalizeWhitespace` 增加用例
- **覆盖**:多空格、换行、首尾空白、空字符串、全空白字符串
- **验收**:测试断言输出字符串与 `hadMultiWhitespace` 符合预期。
- [x] 4.3 为 `tokenizeEN` + `joinTokens` 增加用例
- **覆盖**:普通句子、带标点的 token、单词间多个空格先 normalize 再 tokenize
- **验收**tokens 序列与 join 后文本完全一致且确定。
- [x] 4.4 为 `matchENKeyword` 增加用例
- **覆盖**大小写、两端标点、误伤样例rebuttal vs but
- **验收**:命中与不命中行为符合 spec。
- [x] 4.5 为 breaks 字典序比较增加用例
- **验收**:比较规则在多组数组上输出稳定顺序。
---
## 5. 最终自检清单(合入前)
- [x] 5.1 `tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
- **验收**:无类型错误。
- [x] 5.2 `vitest` 通过(或项目既有测试命令通过)
- **验收**:新增用例全部通过,不影响现有测试。
- [x] 5.3 代码注释口径检查(简体中文)
- **检查点**
- EN 极简派与“断点只在词间”
- 全词等值匹配(禁止 substring
- `start/end` 基于 normalizedText 的口径说明
- **验收**:后续模块开发者只看代码也不会产生歧义。
---
## 6. 文档回写(任务清单执行完毕后必须做)
- [x] 6.1 在 `spec_kit/overview.md``Text Wrap` 条目下补充执行状态
- **建议写法**
- 增加一行:`- **已完成编码(阶段性)**core-contract核心口径与契约`
- **验收**overview 能反映该子模块已完成,便于全局追踪。

View File

@@ -0,0 +1,57 @@
# golden-tests技术计划
## 1. 计划目标
建立可持续回归体系,覆盖:
- **Golden Cases**:固定输入 → 固定输出lines/wrappedText/meta作为“可治理基线”
- **性质测试property tests**
- 确定性:同输入多次调用输出一致
- 近似单调性availableWidth 变小不会让任一行变得更宽(同测量口径下)
- maxLines 不变差maxLines 增加时至少不从可解变 overflow
本模块产出测试数据与测试规则,不产出业务功能。
## 2. 约束与策略
### 2.1 测量可控
- APP使用稳定的测量 mock例如 `width = text.length`)保证 CI 可运行
- WIDGET使用固定 profile 或 `widthMode=APPROX`(单位为 tokenCount/graphemeCount
### 2.2 Golden 的组织方式
- 采用 TS fixture便于类型校验与可读性
- Golden case 至少包含:
- `text/lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion`
- 期望:`expected.lines/expected.wrappedText`(可选 meta 断言)
### 2.3 覆盖面(首版)
首版优先覆盖“易回归且高价值”的样例集:
- EN/TC 各若干条:短/长、含标点/无标点、含 emoji、含 shift/accum/self、极窄宽度
> 说明:文档建议每种语言 20 条;首版先落最小可运行集合,后续迭代扩充但保持可解释与版本化。
## 3. 测试实现结构(客户端)
- `client/src/features/textWrap/golden/fixtures.ts`Golden cases
- `client/src/features/textWrap/golden/__tests__/golden.test.ts`
- Golden 断言lines/wrappedText
- 性质测试determinism/monotonic/maxLines
测试内暂时使用“测试版 wrapTextHarness”把已实现模块串起来
- normalize/tokenizeEN=tokenizeENTC=segmentGraphemes
- generateBreakpoints
- search-engine-app / search-engine-widget
待 integration 模块产出正式 `wrapText()` 后,再把测试入口切到正式函数(不改变期望数据)。
## 4. 完成定义DoD
- Golden fixtures + 测试可在 CI 一键运行
- 至少包含 EN/TC 的基础样例与 3 类性质测试
- overview 更新记录变更文件

View File

@@ -0,0 +1,57 @@
# golden-tests子模块规范
## 子模块名称
golden-testsGolden Cases 与性质测试)
## 目标描述
建立可持续的回归体系,保证换行算法满足:
- 同输入同输出(确定性)
- 跨端一致(在固定测量/宽度 profile 下)
- 规则变更可控(通过 `configVersion` 回溯)
本模块产出的是测试数据与测试规则,不产出业务功能。
## 输入/输出定义
### 输入
- Golden Case 集合(建议 JSON/TS fixture
- `text`
- `lang`
- `context`
- `availableWidth`
- `maxLines`
- `fontSpec?`APP
- `constraints?`
- `overflowMode?`
- `configVersion`
### 输出
- 对每个 case 的期望输出:
- `expected.lines: string[]`
- `expected.wrappedText: string`
- 可选:`expected.meta.breaks``expected.meta.fallback_type/overflow_type/reason`
并定义性质测试property tests
- **确定性**:同输入多次调用输出一致
- **近似单调性**`availableWidth` 变小不会让任一行变得更宽(在同测量模式下)
- **maxLines 不变差**`maxLines` 增加时至少不从可解变 overflow
## 验收标准(可验证)
- **覆盖度**
- 每种语言至少 20 个样例:短/长、含标点/无标点、含 emoji、含 protectedPhrases、含 shift/accum/self、极窄宽度
- **固定口径**
- 测量可控APP 用稳定的测量 mock或固定字体与平台WIDGET 用固定 width profile
- **CI 可运行**:在自动化环境可一键跑完(不依赖人工操作)
## 依赖与关联
- **依赖**`core-contract``search-engine-*``scoring-tiebreak``overflow-fallback`
- **被依赖**`integration`(接入后验收也可引用 Golden

View File

@@ -0,0 +1,50 @@
# golden-tests任务清单
> 目标:建立 Golden Cases 与性质测试determinism/monotonic/maxLines回归体系保证算法演进可控。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/plan.md`
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 16.1/16.2 章节
- [x] 创建客户端测试目录
- [x] 新建 `client/src/features/textWrap/golden/`
## 1. Golden fixtures
- [x] 新建 `client/src/features/textWrap/golden/fixtures.ts`
- [x] 定义 `GoldenCase` 类型text/lang/context/availableWidth/maxLines 等)
- [x] 补充 EN/TC 基础样例集合(首版最小可运行)
- [x] 对每个 case 写入 `expected.lines/expected.wrappedText`
## 2. 测试 harness临时串联
- [x] 在测试中实现 `wrapTextHarness()`(仅用于测试)
- [x] normalizeWhitespace
- [x] EN tokenizeEN / TC segmentGraphemes
- [x] 使用“全断点集合”1..N-1首版避免候选裁剪影响
- [x] APPsearchBestLayoutApp测量 mock=length
- [x] WIDGETsearchBestLayoutWidgetwidthMode=APPROX单位一致
## 3. Golden cases 测试
- [x] 新建 `client/src/features/textWrap/golden/__tests__/golden.test.ts`
- [x] 遍历 fixtures断言输出与 expected 完全一致
## 4. 性质测试property tests
- [x] 确定性:同输入运行多次输出一致
- [x] 近似单调性availableWidth 变小不会让任一行变得更宽(同测量 mock 下,用 width=string.length
- [x] maxLines 不变差maxLines 增大时至少不从可解变 overflow同测量 mock 下)
## 5. 收尾
- [x] 跑测试与类型检查
- [x] `npm test`
- [x] `npx tsc --noEmit`
- [x] 将本 `tasks.md` 全部勾选完成
- [x] 更新 `spec_kit/overview.md`
- [x] 标记 `golden-tests` 已完成(阶段性)
- [x] 写入变更文件清单

View File

@@ -0,0 +1,136 @@
# grapheme-segmentation技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`在客户端侧JS/TS落地**可复用且确定性**的 TC grapheme cluster字符簇分割能力用于 Text Wrap 的 TC tokens 生成,确保:
- 不拆 surrogate pair、ZWJ、VS16、肤色修饰符、国旗regional indicator flags、组合字符`é`
- 同输入同输出clusters 内容与顺序完全一致)
- 输出边界可作为 TC 断点边界(后续模块仅在 cluster 边界断行)
- 提供可解释 meta采用何种策略、是否降级
## 2. 默认技术决策(本计划采用)
> 说明:本模块要解决的是“分割口径”,不引入排版/搜索逻辑。
- **优先策略**:若运行环境支持 `Intl.Segmenter`,优先使用:
- `new Intl.Segmenter('zh-Hant', { granularity: 'grapheme' })`
- 取其 `segment(text)``segment` 字段作为 clusters
- **兜底策略(两种实现路径,默认选 A**
- **方案 A推荐**:引入轻量依赖 `grapheme-splitter` 作为 fallback避免手写不完整的 Unicode 规则导致漏拆/误拆
- **方案 B无依赖兜底**:实现“最低可用”分割(满足文档列出的组合不拆),但不承诺覆盖所有 Unicode 边界规则(风险较高)
> 本计划默认采用 **方案 A**。若后续明确“禁止新增依赖”,再切换到方案 B 并补齐更多回归。
## 3. 目录与产物
本子模块目录:
- `spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md`
- `spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`(本文)
建议未来代码落位(实现阶段落地):
- `client/src/features/textWrap/grapheme/`
- `segmentGraphemes.ts`
- `strategies/intlSegmenter.ts`
- `strategies/fallback.ts`
- `__tests__/segmentGraphemes.test.ts`
## 4. API 设计(实现阶段的稳定契约)
实现一个最小可用纯函数:
- `segmentGraphemes(text: string, mode: 'PREFERRED' | 'FALLBACK') => { clusters: string[]; meta: { strategy: 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean } }`
设计约束:
- `clusters.join('') === text`(不允许丢字符/改字符顺序)
- `clusters` 为空数组时必须是 `text==''`(不允许把空白当成 cluster 误输出)
## 5. 实现步骤(按落地顺序)
### 5.1 优先策略Intl.Segmenter
- 检测 `globalThis.Intl?.Segmenter` 是否可用
- 若可用且 `mode='PREFERRED'`
- 使用 `granularity='grapheme'` 分割
- 输出 `meta.strategy='INTL_SEGMENTER'``hadFallback=false`
注意:
- 需要确认 Expo/RN 的运行时是否始终具备 `Intl.Segmenter`(不同 JS 引擎/版本可能差异)
- 即使可用,也必须通过回归样例验证“不拆”要求
### 5.2 兜底策略Fallback
当出现以下任一情况时进入 fallback
- `mode='FALLBACK'`
- `Intl.Segmenter` 不存在
- `Intl.Segmenter` 运行抛错/返回异常结果(如空、丢字符)
#### 方案 A`grapheme-splitter`
- 新增依赖:`grapheme-splitter`
- 使用其分割能力输出 clusters
- 输出 `meta.strategy='FALLBACK'``hadFallback=true`
#### 方案 B最低可用手写规则仅在禁止依赖时启用
实现最低要求:
- 合并 surrogate pair
- 合并 ZWJ sequence`U+200D` 连接)
- 合并 variation selector`U+FE0F`
- 合并 skin tone modifier`U+1F3FB..U+1F3FF`
- 合并 regional indicator flags两两成对
- 合并组合字符combining marks与预组合等价形式至少覆盖 `e\u0301`
风险提示:
- 该实现容易漏掉其他扩展 grapheme cluster 规则;需要更高的测试覆盖与持续维护
## 6. 回归用例与测试计划Vitest
### 6.1 必须覆盖的样例(文档要求)
以下输入必须“不拆”为单个 cluster
- `👨‍👩‍👧‍👦`
- `🇸🇬`
- `👍🏽`
- `😮‍💨`
- `é`(至少覆盖 `e\u0301` 组合形式)
断言:
- `clusters.length === 1`
- `clusters[0] === input`
### 6.2 基础性质测试(建议)
- **可逆性**`clusters.join('') === input`
- **确定性**:同输入多次调用输出完全一致
- **空字符串**`'' -> []`
### 6.3 跨策略一致性(建议)
在支持 `Intl.Segmenter` 的环境中:
- 同一输入在 `PREFERRED``FALLBACK` 两种模式下输出 clusters 应一致
- 若出现差异,必须新增回归样例并明确差异原因(并在上层通过 configVersion 治理)
## 7. 性能与安全
- 单次分割复杂度应接近 \(O(n)\)
- 对超长文本(例如 > 2000 code units建议在上层模块触发裁剪或打点本模块仅保证不崩溃
- 任何异常必须被捕获并降级到 fallback保证“可用性优先”
## 8. 完成定义DoD
- `segmentGraphemes()` 在本地可运行,并通过必测回归样例
- 输出 meta 能区分 `INTL_SEGMENTER``FALLBACK`
- 单测覆盖:必测样例 + 可逆性 + 确定性
- 文档口径与实现一致不拆要求、fallback 触发条件、返回结构)

View File

@@ -0,0 +1,52 @@
# grapheme-segmentation子模块规范
## 子模块名称
grapheme-segmentationTC 字符簇分割)
## 目标描述
在 TC中文/繁中)场景下,统一“按 grapheme cluster字符簇切分”的实现口径确保
- 不拆分 surrogate pair代理对
- 不拆分 ZWJ 序列(家庭 emoji 等)
- 不拆分 variation selectorVS16 等)
- 不拆分 skin tone modifier肤色修饰符
- 不拆分 regional indicator flags国旗
- 覆盖组合字符(如 `é`
优先使用平台级 segmentation如 ICU / 系统 API / `Intl.Segmenter`),无库时提供最低可用兜底。
## 输入/输出定义
### 输入
- `text: string`(已完成空白归一化的文本,或原始文本)
- `mode: 'PREFERRED' | 'FALLBACK'`
### 输出
- `clusters: string[]`
- 每个元素为一个 grapheme cluster用于 TC tokens
- `meta?: { strategy: 'PLATFORM' | 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean }`
## 验收标准(可验证)
至少通过以下回归样例(每个样例都必须“单 token 不拆”):
- `👨‍👩‍👧‍👦`
- `🇸🇬`
- `👍🏽`
- `😮‍💨`
- `é`
并满足:
- **确定性**同输入同输出clusters 顺序与内容完全一致)
- **边界一致**:任意换行断点只允许发生在 `clusters` 边界
## 依赖与关联
- **被依赖**`core-contract`TC token 生成)、`breakpoint-candidates`
- **不依赖其他模块**

View File

@@ -0,0 +1,126 @@
# grapheme-segmentation任务清单
> 对应计划:`spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 所有代码注释必须为简体中文,且把“分割口径”写清楚,避免跨端实现漂移。
---
## 1. 前置检查(环境能力与策略选择)
- [x] 1.1 确认运行时是否支持 `Intl.Segmenter`Expo/RN 当前引擎)
- **方式**:在本地运行/测试环境中打印或断言 `globalThis.Intl?.Segmenter` 是否存在
- **验收**:记录结论:存在/不存在若不存在fallback 必须覆盖所有必测样例。
- [x] 1.2 确认 fallback 策略选择为“方案 A`grapheme-splitter`
- **要求**:若项目明确禁止新增依赖,需要在本任务中写明原因并切换到“方案 B手写最低可用同时补齐更高测试覆盖
- **验收**plan 与实际实现策略一致(不出现“文档写 A、代码做 B”的漂移
---
## 2. 依赖与目录骨架(客户端侧实现)
- [x] 2.1 新建目录 `client/src/features/textWrap/grapheme/`
- **产出**(建议文件):
- `segmentGraphemes.ts`(对外纯函数)
- `strategies/intlSegmenter.ts`
- `strategies/fallback.ts`
- `types.ts`(返回结构与 meta 类型)
- `__tests__/segmentGraphemes.test.ts`
- **验收**目录存在TS 可正常 import不报路径错误
- [x] 2.2(方案 A新增依赖 `grapheme-splitter` 并锁定到 `client/package.json`
- **验收**
- `npm install grapheme-splitter` 成功
- `npm test` 仍能通过(不破坏现有测试)
---
## 3. 纯函数实现(分割 + meta
- [x] 3.1 实现 `segmentGraphemes(text, mode)` 的返回契约
- **要求**
- 返回 `{ clusters, meta }`
- `clusters.join('') === text`(不丢字符/不改顺序)
- `text==''``clusters==[]`
- **验收**:为上述约束写入单测并通过。
- [x] 3.2 实现优先策略 `Intl.Segmenter``mode='PREFERRED'` 时优先)
- **要求**
- `meta.strategy='INTL_SEGMENTER'`
- `meta.hadFallback=false`
- **验收**:在支持该能力的环境中,至少 1 个常规输入能走到该策略(可通过 meta 断言)。
- [x] 3.3 实现 fallback 策略(满足所有必测“不拆”样例)
- **触发条件**(任一满足即 fallback
- `mode='FALLBACK'`
- `Intl.Segmenter` 不存在
- `Intl.Segmenter` 抛错/返回异常结果(空/丢字符)
- **要求**
- `meta.strategy='FALLBACK'`
- `meta.hadFallback=true`
- **验收**:必测样例全部通过(见 4.2)。
---
## 4. 单元测试Vitest
- [x] 4.1 基础性质测试
- **覆盖**
- 可逆性:`clusters.join('') === input`
- 确定性:同输入多次调用输出一致
- 空字符串:`'' -> []`
- **验收**:测试通过且不会出现偶现失败。
- [x] 4.2 必测回归样例(文档要求:每个都必须“不拆”为 1 个 cluster
- **样例**
- `👨‍👩‍👧‍👦`
- `🇸🇬`
- `👍🏽`
- `😮‍💨`
- `e\u0301`(组合字符形式)
- **断言**
- `clusters.length === 1`
- `clusters[0] === input`
- **验收**:在本地 `npm test` 中稳定通过。
- [x] 4.3 跨策略一致性测试(在支持 `Intl.Segmenter` 的环境中执行)
- **内容**:同一输入在 `PREFERRED` 与强制 `FALLBACK` 下输出 clusters 一致
- **验收**:一致;若不一致,必须新增回归样例并在文档中写明差异与治理方式(`configVersion`)。
---
## 5. 最终自检清单(合入前)
- [x] 5.1 `npm test` 通过(包含本模块新增用例)
- **验收**:不影响现有测试文件。
- [x] 5.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
- **验收**:无类型错误。
- [x] 5.3 注释与口径自检(简体中文)
- **检查点**
- 明确“字符簇不拆”的边界意义(后续断点仅能在 clusters 边界)
- 明确 fallback 触发条件与 meta 含义
- **验收**:后续模块开发者只看代码也不会产生歧义。
---
## 6. 文档回写(任务清单执行完毕后必须做)
- [x] 6.1 在 `spec_kit/overview.md``Text Wrap` 条目下补充执行状态
- **建议写法**
- 增加一行:`- **已完成编码(阶段性)**grapheme-segmentationTC 字符簇分割)`
- **验收**overview 能反映该子模块已完成,便于全局追踪。

View File

@@ -0,0 +1,77 @@
# integration技术计划
## 1. 计划目标
实现对外统一入口 `wrapText()`(纯函数风格),把已完成的子模块串成可复用算法模块,供 HomeAPP与 WidgetWIDGET调用
- 参数口径统一:`lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion/debug`
- 输出统一:`lines[]/wrappedText/meta`,且 meta 可用于治理与 UI 兜底
-`meta.fallback_type=SYSTEM_DEFAULT`UI 可选择交给系统排版(算法层仅标记,不擅自改变 UI 行为)
## 2. 入口签名(与大 spec 对齐)
实现并导出:
```ts
wrapText({
text,
lang,
availableWidth,
maxLines,
context,
fontSpec?,
overflowMode?,
lineMode?,
constraints?,
configVersion?,
debug?
}) => { lines, wrappedText, meta }
```
## 3. 组装流程(按执行顺序)
1. **normalizeWhitespace**core-contract
2. **tokenize**
- ENtokenizeEN
- TCsegmentGraphemes → Token[]
3. **breakpoint-candidates**
- generateBreakpoints传入 constraints.forbiddenBreakRanges
4. **search**
- APPsearchBestLayoutAppDP+TopK测量优先
- WIDGETsearchBestLayoutWidgetBeam默认 APPROX可选 MEASURE
5. **overflow-fallback**
- 当搜索失败或未覆盖到 N按 overflowMode 执行ELLIPSIS/CLIP/SYSTEM_DEFAULT
6. **meta 汇总**
- breaks若可得
- scoreTopTermsdebug=true 时)
- fallback_type/overflow_type/reason
- configVersion
## 4. 默认值与映射(固定,确定性)
- `overflowMode`
- APP 默认 `CLIP`
- WIDGET 默认 `ELLIPSIS`
- `lineMode`:默认 `AUTO`
- `configVersion`:默认 `'v1'`(若调用方未传)
- WIDGET widthMode默认 `APPROX`
## 5. 代码落位(客户端)
- `client/src/features/textWrap/`
- `wrapText.ts`(入口实现)
- `types.ts`(对外输入/输出类型)
- `index.ts`(统一导出)
## 6. 测试与回归
-`golden-tests` 的测试入口从测试 harness 切换为正式 `wrapText()`(期望不变)
- 增加 1 个 integration 单测:验证 `SYSTEM_DEFAULT` meta 语义不变
## 7. 完成定义DoD
- `wrapText()` 可在 APP/WIDGET 两种 context 下运行
- 输出 meta 字段可用于 UI 治理与回溯(含 configVersion
- 全量 `npm test``tsc --noEmit` 通过
- overview 更新记录变更文件

View File

@@ -0,0 +1,52 @@
# integration子模块规范
## 子模块名称
integrationHome / Widget 接入)
## 目标描述
`wrapText()` 模块以一致参数口径接入到两端渲染链路中,并定义 UI 侧对 meta 的处理规则,确保:
- Home 与 Widget 使用同一套配置/词库/版本号(`configVersion`
- 同场景下输出一致;不同测量能力导致差异时“可解释且可治理”
- SYSTEM_DEFAULT 的语义不被 UI 误用
## 输入/输出定义
### 输入
- HomeAPP侧输入
- `text/lang/availableWidth/maxLines/fontSpec/context='APP'`
- 可选:`constraints/overflowMode/lineMode/configVersion/debug`
- WidgetWIDGET侧输入
- `text/lang/availableWidth(profile)/maxLines/context='WIDGET'`
- 可选:`constraints/overflowMode/configVersion/debug`
### 输出
- UI 渲染消费:
- `lines[]`:逐行渲染或插入 `\n`
- `wrappedText`:用于一次性渲染或日志/缓存
- `meta`:用于调试、打点、兜底策略选择
## 验收标准(可验证)
- **参数映射一致**
- 两端对 `lang/context/availableWidth/maxLines/overflowMode/lineMode` 的默认值与映射一致
- `configVersion` 必须随结果一起回传到 meta 或打点
- **UI 兜底语义正确**
-`meta.fallback_type=SYSTEM_DEFAULT` 时:
- UI 允许选择“交给系统排版”(例如不插入 `\n` 或忽略 `lines[]`
- 但不改变算法层返回值
- **缓存与复用**
- Home 可对同文案同参数缓存 `wrappedText/lines/breaks`
- Widget 可对固定 profile 文案缓存结果(避免频繁计算)
- **回归可对齐**
- Golden Cases 在 Home 与 Widget固定 profile下可跑通并对齐预期
## 依赖与关联
- **依赖**:全部核心模块(`core-contract``breakpoint-candidates``search-engine-*``scoring-tiebreak``overflow-fallback``golden-tests`
- **被依赖**:无(最终落地层)

View File

@@ -0,0 +1,63 @@
# integration任务清单
> 目标:实现 `wrapText()` 统一入口并串联所有子模块,供 Home/Widget 使用。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/integration/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/integration/plan.md`
- [x] 阅读 `spec_kit/Text Wrap/spec.md` 的对外接口定义
## 1. 对外类型定义
- [x] 新建 `client/src/features/textWrap/types.ts`
- [x] 定义 `WrapTextInput`(与大 spec 对齐)
- [x] 定义 `WrapTextMeta`(包含 configVersion/fallback_type/overflow_type/reason/breaks/scoreTopTerms
- [x] 定义 `WrapTextOutput`
## 2. 实现 wrapText() 入口
- [x] 新建 `client/src/features/textWrap/wrapText.ts`
- [x] normalizeWhitespace固定策略 NORMALIZE
- [x] tokenize
- [x] ENtokenizeEN
- [x] TCsegmentGraphemes → Token[]
- [x] generateBreakpoints接入 forbiddenBreakRanges
- [x] 组装 scoring 的 lexicons
- [x] protectedPhrases 来自 constraints.protectedPhrases若有
- [x] 搜索:
- [x] APPsearchBestLayoutApp测量 mockablefontSpec 缺字段报错由 width-measurement 保证)
- [x] WIDGETsearchBestLayoutWidget默认 APPROX可选 MEASURE
- [x] 搜索失败/未覆盖 N
- [x] 调用 applyOverflowFallbackoverflowMode 默认APP=CLIPWIDGET=ELLIPSIS
- [x] 汇总输出 meta
- [x] configVersion
- [x] breaks若可得
- [x] scoreTopTermsdebug=true
- [x] fallback_type/overflow_type/reason
## 3. 导出
- [x] 新建 `client/src/features/textWrap/index.ts`
- [x] 导出 types 与 `wrapText`
## 4. 调整 Golden Tests 入口
- [x]`client/src/features/textWrap/golden/__tests__/golden.test.ts` 从测试 harness 切换为 `wrapText()`
- [x] 保持现有 fixtures 期望不变
## 5. 单测补充(最小)
- [x] 新建 `client/src/features/textWrap/__tests__/wrapText.integration.test.ts`
- [x] SYSTEM_DEFAULTmeta.fallback_type=SYSTEM_DEFAULT 且仍返回 lines/wrappedText
## 6. 收尾
- [x] `npm test`
- [x] `npx tsc --noEmit`
- [x] 将本 `tasks.md` 全部勾选完成
- [x] 更新 `spec_kit/overview.md`
- [x] 标记 `integration` 已完成(阶段性)
- [x] 写入变更文件清单

View File

@@ -0,0 +1,109 @@
# overflow-fallback技术计划
## 1. 计划目标
实现统一的“溢出与兜底”策略模块,用于在以下情况给出确定性且可解释的返回:
- 搜索器无法在 `<=maxLines` 覆盖到结束边界 `N`
- `lineMode=FIXED` 需要刚好 `maxLines` 但无解
- 测量不可用或失败导致无法执行宽度派约束(尤其 Widget
支持三种模式:
- `ELLIPSIS`:最后一行加省略号并保证不超宽(必要时回退移除 token 再加)
- `CLIP`:截断到 `maxLines`(不加省略号)
- `SYSTEM_DEFAULT`:算法层不插入换行,交给系统排版(仅在 meta 标记)
## 2. 对外输入/输出(与 spec 对齐)
### 2.1 输入
- `tokens: Token[]`
- `partialLayout?: { breaks: number[]; lines: Array<{ start; end; text? }> }`
- 若提供:表示搜索器的 best-effort可能未覆盖到 N
- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
- `availableWidth: number`
- `maxLines: number`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `ellipsisToken: string`(推荐 `"…"`,配置化)
- `measure?: { contextProfile; fontSpec; measureWidthImpl; widgetEnableMeasure? }`(用于“加省略号后再测量”)
- `reason: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string`
### 2.2 输出
统一返回:
- `result: { lines: string[]; wrappedText: string; meta: { fallback_type; overflow_type; reason } }`
其中:
- `fallback_type`: `NONE | RELAX_RULES | SYSTEM_DEFAULT`
- `overflow_type`: `NONE | ELLIPSIS | CLIP`
## 3. 核心行为细则(对应文档 12.x
### 3.1 overflow 判定
在本模块内不重新跑搜索,仅基于输入判断:
-`partialLayout` 覆盖到 `N`(即最后一行 `end==N`),则 overflow_type=NONE
- 否则为 overflow按 overflowMode 执行
### 3.2 SYSTEM_DEFAULT12.4A
- 返回 `lines=[原文单行]``wrappedText=原文`
- `meta.fallback_type='SYSTEM_DEFAULT'`
- `meta.overflow_type='NONE'`(因为不再输出算法换行;由 UI 决定是否完全交给系统)
- `meta.reason=输入 reason`
### 3.3 CLIP
-`partialLayout` 有 lines取前 `maxLines` 行,重组 `wrappedText=lines.join('\n')`
- 若无:返回单行原文
- `meta.overflow_type='CLIP'``fallback_type='NONE'``reason=输入 reason`
### 3.4 ELLIPSIS12.3/12.5
仅处理最后一行:
1. 选取 baseLines
- 优先使用 `partialLayout.lines`(若为空则把全文当单行)
- 截断到 `maxLines`(只在最后一行做 ellipsis
2. 清理规则(固定,确定性):
- 不输出 `" …"`:最后一行末尾空白先 trim
- 不输出 `",…"/"。…"`:若最后一行末尾是常见 TC 标点(`,。!?;:、`),则移除该标点再加 ellipsis
3. 宽度检查12.3
- 若提供测量能力且启用:
- 重新测量 `lastLine+ellipsisToken`
- 若超宽:按语言回退单位移除 token 再加省略号并重测
- EN按整词token
- TC按 graphemetoken
- 若测量不可用不做重测直接输出meta.reason 仍保留)
> 说明:首版不做“避免截断 emotionPhrase 的整段前移/整段省略”,该策略可在后续结合 scoring 决策升级,但必须保持确定性。
## 4. 代码落位(客户端)
- `client/src/features/textWrap/overflow/`
- `types.ts`
- `ellipsis.ts`
- `fallback.ts`入口applyOverflowFallback
- `index.ts`
- `__tests__/overflowFallback.test.ts`
## 5. 测试计划Vitest
- SYSTEM_DEFAULTlines 单行meta.fallback_type=SYSTEM_DEFAULT
- CLIP超过 maxLines 时截断行数
- ELLIPSIS
- 末尾空白去除,不输出 `" …"`
- 末尾 TC 标点去除,不输出 `",…"`
- 测量超宽时按 token 回退直到不超宽(用 mock measureWidthImpl
## 6. 完成定义DoD
- 三种 overflowMode 行为稳定且可解释
- ELLIPSIS 的清理与回退测量实现完毕(可测)
- 输出 meta 字段满足文档 12/14 的治理需求

View File

@@ -0,0 +1,52 @@
# overflow-fallback子模块规范
## 子模块名称
overflow-fallback溢出与兜底
## 目标描述
统一定义“无解/溢出”时的行为与返回语义,确保:
- APP 与 WIDGET 在无法覆盖全文时表现一致且可解释
- ELLIPSIS 的字符、清理规则与再次测量约束统一
- SYSTEM_DEFAULT 的返回语义明确(算法层不擅自改变 UI 行为)
## 输入/输出定义
### 输入
- `tokens: Token[]`
- `partialBest?: Layout`(搜索器找到的最佳 partial 或 best effort
- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
- `availableWidth: number`
- `maxLines: number`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `ellipsisToken: string`(推荐 `"…"`,必须配置化)
- `measureWidth?: fn`(用于“加省略号后再测量”)
### 输出
- `result: { lines: string[]; wrappedText: string; meta: { fallback_type: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'; overflow_type: 'NONE' | 'ELLIPSIS' | 'CLIP'; reason: string } }`
## 验收标准(可验证)
- **overflow 判定一致**
- 搜索无法在 `<=maxLines` 覆盖到结束边界 N即为 overflow
- **ELLIPSIS 规则一致**
- 仅处理最后一行
- 加省略号后必须再次检查不超宽(必要时回退移除 token 再加省略号)
- EN 回退单位为整词TC 回退单位为 grapheme
- 清理规则:不输出 `" …"`;不输出 `",…"/"。…"`(按配置决定是否移除末尾标点再加省略号,但必须固定)
- **SYSTEM_DEFAULT 语义一致**
- 仍返回 `lines/wrappedText`
- 仅在 meta 标记 `fallback_type=SYSTEM_DEFAULT`
- UI 可依据 meta 决定是否完全交给系统排版
- **确定性**:同输入同输出(含 meta
## 依赖与关联
- **依赖**`core-contract`(重组)、`width-measurement`(测量/降级)、`scoring-tiebreak`(避免截断短语的策略可复用评分)
- **被依赖**`search-engine-app``search-engine-widget``integration``golden-tests`

View File

@@ -0,0 +1,65 @@
# overflow-fallback任务清单
> 目标:统一实现 `ELLIPSIS/CLIP/SYSTEM_DEFAULT` 的溢出与兜底语义(文档 12.x输出稳定可解释的 meta。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/plan.md`
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 12.x/14 章节
- [x] 创建客户端模块目录
- [x] 新建 `client/src/features/textWrap/overflow/`
## 1. 类型与对外接口
- [x] 新建 `client/src/features/textWrap/overflow/types.ts`
- [x] 定义 `OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
- [x] 定义 `FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'`
- [x] 定义 `OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP'`
- [x] 定义 `OverflowReason`至少包含NO_CANDIDATE/WIDTH_UNKNOWN/TOO_LONG/WIDOW/PARTICLE
- [x] 定义 `PartialLayoutInput``breaks` + `lines[{start,end,text?}]`
- [x] 定义 `ApplyOverflowFallbackInput/Result`
- [x] 新建 `client/src/features/textWrap/overflow/index.ts`
- [x] 统一导出 types 与入口 `applyOverflowFallback()`
## 2. ELLIPSIS 核心逻辑
- [x] 新建 `client/src/features/textWrap/overflow/ellipsis.ts`
- [x] 实现 `cleanLineBeforeEllipsis(line, lang)`
- [x] trim 末尾空白,避免 `" …"`
- [x] TC若末尾是 `,。!?;:、`,移除该标点,避免 `",…"`
- [x] 实现 `applyEllipsisToLastLine(...)`
- [x] 仅处理最后一行
- [x] EN 回退单位=整词TC 回退单位=grapheme
- [x] 若提供测量能力:加省略号后必须重新测量,超宽则循环回退再测量
- [x] 若测量不可用:直接输出(确定性)
## 3. 入口applyOverflowFallback
- [x] 新建 `client/src/features/textWrap/overflow/fallback.ts`
- [x] 实现 `applyOverflowFallback(input)`
- [x] overflow 判定partialLayout 是否覆盖到 N
- [x] `SYSTEM_DEFAULT`12.4A):返回单行原文 + meta.fallback_type=SYSTEM_DEFAULT
- [x] `CLIP`:截断到 maxLines
- [x] `ELLIPSIS`:调用 ellipsis 逻辑
- [x] meta 输出:`fallback_type/overflow_type/reason`
## 4. 单测Vitest
- [x] 新建 `client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts`
- [x] SYSTEM_DEFAULT 语义
- [x] CLIP 截断行为
- [x] ELLIPSIS 清理规则(不输出 `" …"`、不输出 `",…"`
- [x] ELLIPSIS 测量回退mock measureWidthImpl让超宽时按 token 回退直到不超宽
## 5. 收尾
- [x] 跑测试与类型检查
- [x] `npm test`
- [x] `npx tsc --noEmit`
- [x] 将本 `tasks.md` 全部勾选完成
- [x] 更新 `spec_kit/overview.md`
- [x] 标记 `overflow-fallback` 已完成编码(阶段性)
- [x] 写入变更文件清单

View File

@@ -0,0 +1,179 @@
# scoring-tiebreak技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,在客户端实现可解释的评分模型与确定性 tie-break保证
- **整数评分**:所有评分项使用整数,避免浮点误差
- **权重写死客户端**:首版权重/词表内置在客户端代码(后续如需灰度,用 `configVersion` 管理)
- **短语匹配口径固定**emotionPhrases/protectedPhrases 必须是“连续 token 完全匹配”
- **更长短语优先**:采用方案 A——在触发拆分惩罚时按“被拆分短语长度”追加惩罚确定性
- **debug 可解释**:输出 score breakdowndebug=true 时输出 Top-3按规则优先级排序不是按 |delta|
- **tieKey 固定**:严格按文档第 11 节顺序构造 tieKeyWidget 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` 不命中)
- TCemoji/组合字符的 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 稳定性

View File

@@ -0,0 +1,54 @@
# scoring-tiebreak子模块规范
## 子模块名称
scoring-tiebreak评分模型与确定性裁决
## 目标描述
定义并实现可解释的评分模型Scoring Model与固定 tie-break 规则用于在“合法断点组合”中选出最优布局layout并保证跨端完全一致。
本模块不负责搜索DP/Beam但负责
- 每行/每断点的评分项计算顺序(必须固定)
- 情绪短语/受保护短语的匹配口径(连续 token 完全匹配)
- Debug breakdown 的结构与 Top-3 输出排序(按优先级而非 |delta|
- tieKey 的构造与比较规则(含 breaks 字典序定义)
## 输入/输出定义
### 输入
- `layoutCandidate: { breaks: number[]; lines: Array<{ start: number; end: number; text: string; width: number; tokenCount: number; charCount: number }> }`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `config: { weights: Record<string, number>; idealWidthRatio: { APP: number; WIDGET: number }; ellipsisToken: string; particleWhitelistTC: string[]; eps?: number }`
- `lexicons: { emotionPhrasesTC: string[]; emotionPhrasesEN: string[]; protectedPhrases?: string[]; shiftWordsTC: string[]; shiftWordsEN: string[]; accumWordsTC: string[]; accumWordsEN: string[]; selfWordsTC: string[]; selfWordsEN: string[]; emotionWordsTC?: string[]; emotionWordsEN?: string[] }`
### 输出
- `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 }> } }`
## 验收标准(可验证)
- **评分项顺序固定**:评分必须严格按算法文档 10.1 的顺序计算并记录(不可重排)
- **短语匹配口径正确**
- emotionPhrase/protectedPhrases连续 token 完全匹配(不允许跳 token、不允许模糊
- EN 关键词命中遵循 `core-contract` 的“全词等值匹配”
- **冲突裁决一致**
- emotionPhrases 与 protectedPhrases 同级强保护
- 同等条件下更长短语优先(需在惩罚或 tieKey 中体现,且确定性)
- **奖励折减规则一致**
- EmotionWord 与 Accum 同时命中时Accum 奖励按 0.5 倍(先算 EmotionWord 再折减)
- **tie-break 一致**
- 规则顺序固定emotionSplit/overflowed/lastLineWidth/均衡度/理想距离/breaks 字典序)
- breaks 字典序定义:逐项比较;公共前缀相同则更短数组更小
- **debug Top-3 输出排序一致**
- debug 模式关键项按优先级输出(非 |delta|
- **整数分优先**:尽量使用整数评分;若使用浮点必须定义 EPS并用 `abs(a-b)<=EPS` 判断近似相等
## 依赖与关联
- **依赖**`core-contract`索引、文本重组、EN 命中工具)
- **被依赖**`search-engine-app``search-engine-widget``overflow-fallback``golden-tests`

View File

@@ -0,0 +1,122 @@
# 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评分与 11tie-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/ENshift/accum/selfemotionWords 可选)
- [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] emotionSplitfalse 优先)
- [x] overflowedfalse 优先)
- [x] lastLineWidth更大优先采用 `-lastLineWidth` 进入 tieKey或在比较函数中反向比较二选一且写注释
- [x] 宽度分布均衡度:`maxWidth - minWidth`(更小优先)
- [x] 与理想切分点距离(更小优先;距离定义需与 breakpoints 模块/上游一致)
- [x] breaks 字典序(复用 `core-contract` 的 breaks 比较函数)
- [x] 单测覆盖:
- [x] 同分 layout通过 tieKey 能稳定选出同一个最优
- [x] Widget approx即使 width 近似wordCount/graphemeCounttieKey 仍可比较(不抛错)
## 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 两套 tokenizationEN 用 coreTC 用 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 中记录本次变更文件清单(至少包含新增的客户端文件与测试文件)

View File

@@ -0,0 +1,104 @@
# search-engine-app技术计划
## 1. 计划目标
**APP** 场景实现确定性的“断点组合搜索器”,使用 **DP + TopK** 生成候选 layout 并选出最优解:
- 输入:`tokens[]``breakpoints[]``availableWidth``maxLines``lineMode`
- 过程DP 状态转移 + TopK 维护(去重、排序、确定性 tie-break
- 评分:调用 `scoring-tiebreak``scoreLayout` + `buildTieKey`
- 输出:`bestLayout`breaks/lines/wrappedText/meta或返回 “无解/超长/宽度不可用” 的可解释原因overflow-fallback 模块将统一处理最终语义)
## 2. 约束与确定性口径
### 2.1 确定性(必须)
同输入(含 configVersion/权重/词表/断点列表)必须同输出:
- DP 遍历顺序固定pos 升序、linesUsed 升序)
- nextPos 枚举顺序固定(按 `pos` 升序;最后补一个 `N` 结束边界)
- TopK 的排序固定:`score` 降序;再按 `tieKey` 逐项比较11 节);再按 `breaks` 字典序11.0A
- 去重固定:同 `breaks` 序列只保留最优score 更大;若相同用 tieKey
### 2.2 复杂度(必须可控)
遵循文档 9.3 建议:
- 默认 `TopK=10`
- 默认 `maxLines<=3`(或 4
- 超长输入触发 `TOO_LONG`TC>60 grapheme 或 EN>30 words首版写死后续 configVersion 管理)
## 3. DP + TopK 设计(对应文档 9.1
### 3.1 状态定义
`N=tokens.length`token 边界为 `pos in [0..N]`
- `dp[pos][linesUsed] = TopK partial layouts ending at token boundary pos`
- partial layout 至少包含:
- `pos`:当前结束位置
- `breaks: number[]`:已选择的断点序列(升序)
- `lines: LineInfo[]`已生成的行信息start/end/text/width/tokenCount/charCount
- `score: number` + `tieKey: number[]`(由 scoring-tiebreak 构造)
### 3.2 转移(生成下一行)
从状态 `(pos, linesUsed)` 选择 `nextPos` 生成新行区间 `[pos..nextPos)`
- nextPos 取值来自 `breakpoints``b.pos``b.pos > pos`,并按 `pos` 升序枚举
- 必须额外允许 `nextPos = N`(结束边界)
- 每次转移需:
- 构造 lineText使用 `core-contract/joinTokens`
- 测量 lineWidth优先使用 `width-measurement/measureSliceWidthCached`(切片缓存),若不可用返回 `WIDTH_UNKNOWN`
- 应用硬约束过滤(见 3.3
- 计算新 layout 的评分与 tieKey调用 scoring-tiebreak
- 插入 `dp[nextPos][linesUsed+1]` 并维护 TopK去重、排序
### 3.3 硬约束(本模块先落地最小集)
为了让 DP 行为稳定且可解释,首版在搜索层执行以下硬约束(其余放到评分):
- **H1 不超宽**:若 lineWidth 可用且 `lineWidth > availableWidth`,该转移无效
- **H5 最大行数**`linesUsed+1 <= maxLines`
- **禁止空行**`nextPos > pos`(区间非空)
- **TC 禁止行首标点H6**
-`lang=TC``pos>0``tokens[pos]` 属于 `tcPunctuations`,则该断点不可用(跳过)
> 说明protectedPhrases 的“span 内断点剔除”属于候选断点优化(可选),首版不在搜索层做剔除,依赖 scoring 的强惩罚淘汰(文档 6.2A / 10.2A)。
### 3.4 结束条件与 lineMode
- 正常结束:到达 `pos=N`
- `lineMode=AUTO`
-`dp[N][1..maxLines]` 里选最优
- `lineMode=FIXED`
- 优先从 `dp[N][==maxLines]` 里选最优
- 若无解:返回 “无解” 的原因与必要 meta由 overflow-fallback 统一降级链路与打点)
## 4. 代码落位(客户端)
建议目录:
- `client/src/features/textWrap/searchApp/`
- `types.ts`
- `dpTopK.ts`DP 主流程 + TopK 维护)
- `topK.ts`(去重、插入、排序、截断)
- `constraints.ts`H1/H6/TOO_LONG 判定)
- `index.ts`
- `__tests__/searchApp.test.ts`
## 5. 测试计划Vitest
- **确定性**:同输入运行 3 次输出完全一致breaks/lines/wrappedText
- **TopK 去重**:构造两条路径得到相同 breaks断言只保留一个且为最优
- **lineMode=FIXED**:有解时必须返回刚好 `maxLines`;无解时返回 reason例如 `NO_CANDIDATE`
- **TC 行首标点**:构造 tokens 在断点后行首为 ``,断言该转移被禁止
- **TOO_LONG**TC>60 或 EN>30 时直接返回 `TOO_LONG`
## 6. 完成定义DoD
- DP+TopK 可运行,且确定性通过单测
- 使用 `scoring-tiebreak` 进行评分与 tieKey 裁决
- 搜索层硬约束最小集落地H1/H6/空行/maxLines
- 无解/超长/宽度不可用时返回明确 reason不直接在本模块做最终 overflow

View File

@@ -0,0 +1,51 @@
# search-engine-app子模块规范
## 子模块名称
search-engine-appAPP 搜索器DP + TopK
## 目标描述
在 APP 场景实现多行换行的确定性组合搜索:
- 使用 DP + TopK 生成候选 layout
- 对每个候选应用硬约束过滤
- 调用 `scoring-tiebreak` 计算评分与 tieKey
- 保留 TopK 并在结束时选最优
要求性能可控且输出完全确定性。
## 输入/输出定义
### 输入
- `tokens: Token[]`
- `breakpoints: Breakpoint[]`(来自 `breakpoint-candidates`
- `availableWidth: number`
- `maxLines: number`
- `lineMode: 'AUTO' | 'FIXED'`
- `context: 'APP'`
- `measure: { measureWidth?: fn; fontSpec?: any; cache: ... }`(来自 `width-measurement` 的实现)
- `config: { topK: number }`
### 输出
- `bestLayout: { breaks: number[]; lines: string[]; wrappedText: string; meta?: { breaks: number[]; scoreTopTerms?: ... } }`
- 若无解:返回可解释的 fallback`overflow-fallback` 统一处理语义)
## 验收标准(可验证)
- **确定性**同输入tokens/breakpoints/availableWidth/maxLines/configVersion必定同输出breaks 与 lines 完全一致)
- **TopK 维护一致**
- K 默认 10可配置但必须固定
- 排序score desctieKey 逐项比较
- 去重:同 breaks 序列只保留最高分
- **lineMode=FIXED 行为**
- 优先选刚好 `maxLines` 的解;无解时按固定降级链路处理并打点
- **复杂度可控**:超长输入触发裁剪/兜底并返回 reason`TOO_LONG`
## 依赖与关联
- **依赖**`core-contract``width-measurement``breakpoint-candidates``scoring-tiebreak``overflow-fallback`
- **被依赖**`integration``golden-tests`

View File

@@ -0,0 +1,92 @@
# 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] pos0..N
- [x] linesUsed0..maxLines-1
- [x] nextPos从 breakpoints 里取 `>pos` 的 pos 升序 + 追加 `N`
- [x] 每次转移:
- [x] 构造 lineText使用 `joinTokens`EN 默认空格TC 使用 rawSeparators 拼接)
- [x] 测量 lineWidth使用 `measureSliceWidthCached`
- [x] 过滤硬约束H1/空行/H6/maxLines
- [x] 构造 layoutCandidatebreaks + 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] 写入变更文件清单

View File

@@ -0,0 +1,115 @@
# search-engine-widget技术计划
## 1. 计划目标
**WIDGET** 场景实现确定性的 Beam Search文档 9.2
- 每一行扩展时只保留 TopK partial layoutsbeam
- 每步扩展断点数 M 受控(更强裁剪)
- 支持 `widthMode='MEASURE' | 'APPROX'`
- 结束时从 `pos==N` 的 beams 中选最优;若无解返回可解释的失败原因(最终由 `overflow-fallback` 统一语义)
## 2. 确定性与性能约束
### 2.1 确定性(必须)
- beams 的遍历顺序固定(按当前 beams 排序后的顺序)
- nextPos 候选顺序固定(`pos` 升序)
- pruning 固定(先按候选优先级/位置排序,再取前 M
- beam 的 TopK 维护固定:`score` 降序;再 `tieKey`;再 `breaks` 字典序
### 2.2 性能参数(写死默认值)
来自文档 9.3
- `beamK=5`
- `expandM=12`
## 3. Beam Search 设计(对应文档 9.2
`N=tokens.length`token 边界为 `pos in [0..N]`
### 3.1 beam 状态
一个 beampartial layout包含
- `pos`:当前已覆盖到的 token 边界
- `breaks: number[]`
- `lines: LineInfo[]`
- `score` + `tieKey`
- `meta`可选debug Top-3 terms、approx reason 等
### 3.2 扩展 nextPos每步最多 M
对每个 beam`pos` 扩展到若干 `nextPos`
- nextPos 来源:`breakpoints[].pos > pos`(按 pos 升序)
- 额外包含 `nextPos=N`(结束边界)
- 为控制复杂度:对候选 nextPos 进行裁剪,仅保留前 `expandM`
裁剪规则(确定性,首版简单可控):
- 先取 `nextPos` 最小的 `expandM` 个(最短前缀优先,利于早结束)
> 注:后续可升级为“优先级 + 距理想切分点近”裁剪,但首版以确定性与稳定为主。
### 3.3 宽度模式
- `widthMode='MEASURE'`
- 若提供测量能力:使用 `measureSliceWidthCached` 获取 lineWidth
- 若测量不可用/失败:降级为 approx记录 `reason=WIDTH_UNKNOWN``MEASURE_FAILED`lineWidth 采用近似值
- `widthMode='APPROX'`
- 不调用测量lineWidth 使用近似值:
- EN`tokenCount`
- TC`charCount`
- meta 标记 `reason=WIDTH_UNKNOWN`
### 3.4 硬约束(最小集)
与 App 一致的最小硬约束:
- 禁止空行:`nextPos > pos`
- 最大行数lineIndex 不超过 `maxLines`
- TC 禁止行首标点H6`tokens[nextPos]` 属于 `tcPunctuations` 则该转移无效
- 超宽约束:
- MEASURE 模式下若 width 可用且 `lineWidth > availableWidth`,转移无效
- APPROX 模式下不执行超宽硬过滤(因为单位不一致),交给评分处理
### 3.5 每轮保留 beamK
对所有扩展出来的新 beams
- 使用 TopK 维护(去重同 breaks排序按 score/tieKey/breaks
- 全局只保留 `beamK` 个作为下一轮 beams
### 3.6 结束选择
重复扩展最多 `maxLines` 轮后:
- 优先从 beams 中选 `pos==N` 的最优
- 若无 `pos==N`:返回 `NO_CANDIDATE`(由 overflow-fallback 统一兜底语义)
## 4. 代码落位(客户端)
- `client/src/features/textWrap/searchWidget/`
- `types.ts`
- `beam.ts`
- `topK.ts`(可复用 App 逻辑,首版复制一份避免耦合)
- `constraints.ts`
- `index.ts`
- `__tests__/searchWidget.test.ts`
## 5. 测试计划Vitest
- 确定性:同输入重复执行输出一致
- 性能约束beamK/expandM 生效(可通过构造大量断点断言扩展次数上限)
- widthMode=APPROX仍能产出结果且 meta.reason=WIDTH_UNKNOWN
- TC H6行首标点转移被禁止
## 6. 完成定义DoD
- Beam Search 可运行,且确定性单测通过
- 宽度模式与降级 meta 符合 spec
- 性能参数默认值写死且可覆盖
- 更新 overview 标记阶段性完成并记录变更文件

View File

@@ -0,0 +1,46 @@
# search-engine-widget子模块规范
## 子模块名称
search-engine-widgetWIDGET 搜索器Beam Search
## 目标描述
在 WIDGET 场景实现确定性 Beam Search
- 每一行扩展时只保留 TopK partial layoutsbeam
- 每步扩展断点数 M 受控(更强裁剪)
- 支持 width unknown / approx mode
- 最终选择 pos==N 的最优解;无解走 `overflow-fallback`
## 输入/输出定义
### 输入
- `tokens: Token[]`
- `breakpoints: Breakpoint[]`
- `availableWidth: number`(可为常量 profile
- `maxLines: number`
- `context: 'WIDGET'`
- `measure: { widthMode: 'MEASURE' | 'APPROX' }`
- `config: { beamK: number; expandM: number }`
### 输出
- `bestLayout: { breaks: number[]; lines: string[]; wrappedText: string; meta?: { breaks: number[]; scoreTopTerms?: ... } }`
## 验收标准(可验证)
- **确定性**beam 扩展、剪枝、排序规则固定;同输入同输出
- **性能约束生效**
- beamK 默认 5
- 每步扩展 M 默认不超过 12
- **宽度不可用降级一致**
- 进入 approx mode 时 meta 标记 `reason=WIDTH_UNKNOWN`
- 搜索仍可运行并输出可解释结果
## 依赖与关联
- **依赖**`core-contract``width-measurement``breakpoint-candidates``scoring-tiebreak``overflow-fallback`
- **被依赖**`integration``golden-tests`

View File

@@ -0,0 +1,78 @@
# search-engine-widget任务清单
> 目标:在 WIDGET 场景实现确定性 Beam Search文档 9.2),支持 `widthMode=MEASURE/APPROX`,并输出最优 layout 或可解释失败原因。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-widget/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-widget/plan.md`
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 9.2/9.3/11 章节
- [x] 创建客户端模块目录
- [x] 新建 `client/src/features/textWrap/searchWidget/`
## 1. 类型定义与导出
- [x] 新建 `client/src/features/textWrap/searchWidget/types.ts`
- [x] 定义 `WidthMode = 'MEASURE' | 'APPROX'`
- [x] 定义 `SearchWidgetConfig``beamK``expandM``tooLongThresholds`
- [x] 定义 `SearchWidgetInput`
- [x] `tokens/lang/breakpoints/availableWidth/maxLines/context='WIDGET'`
- [x] `measure: { widthMode; contextProfile?; fontSpec?; measureWidthImpl? }`
- [x] `scoring: { config; lexicons; debug? }`
- [x] 定义 `SearchWidgetResult`
- [x] 成功:`bestLayout { breaks; lines; wrappedText; meta }`
- [x] 失败:`{ ok:false; reason:'TOO_LONG'|'NO_CANDIDATE'; meta? }`
- [x] 新建 `client/src/features/textWrap/searchWidget/index.ts`
- [x] 统一导出 types 与入口 `searchBestLayoutWidget()`
## 2. TopK/BeamK 维护(确定性)
- [x] 新建 `client/src/features/textWrap/searchWidget/topK.ts`
- [x] 实现 comparescore desctieKey ascbreaks 字典序
- [x] 实现去重:同 breaks 只保留最优
- [x] 实现 `insertTopK`(用于全局 beamK 保留)
## 3. 约束与降级 meta
- [x] 新建 `client/src/features/textWrap/searchWidget/constraints.ts`
- [x] `isTooLong`TC>60 / EN>30
- [x] `isLineStartPunctTC(tokens, nextPos, tcPunctuations)`H6
- [x] `approxWidth(line, lang)`EN=tokenCount / TC=charCount
## 4. Beam Search 主流程
- [x] 新建 `client/src/features/textWrap/searchWidget/beam.ts`
- [x] 初始化 beams`[{pos:0, breaks:[], lines:[], score:0}]`
- [x] 循环 lineIndex=1..maxLines
- [x] 对每个 beam 枚举 nextPosbreakpoints>pos + N
- [x] 裁剪:仅取前 `expandM` 个 nextPospos 升序)
- [x] 构造行文本:`joinTokens`
- [x] 计算行宽:
- [x] widthMode=MEASURE尝试 `measureSliceWidthCached`;失败则降级 approx 并 meta.reason 标记
- [x] widthMode=APPROX直接 approx并 meta.reason=WIDTH_UNKNOWN
- [x] 硬约束过滤:
- [x] 禁止空行、maxLines、TC H6
- [x] 若 width 可用且 > availableWidth过滤仅 MEASURE 可信)
- [x] 评分:`scoreLayout` + `buildTieKey`
- [x] 全局 newBeams 维护 TopKbeamK
- [x] 结束:从 beams 中选 pos==N 最优,否则 NO_CANDIDATE
## 5. 单测Vitest
- [x] 新建 `client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts`
- [x] 确定性:同输入重复执行结果一致
- [x] widthMode=APPROX仍可输出且 meta.reason=WIDTH_UNKNOWN
- [x] 性能约束expandM/beamK 生效(可统计测量/扩展次数)
- [x] TC H6行首标点被禁止
## 6. 收尾
- [x] 跑测试与类型检查
- [x] `npm test`
- [x] `npx tsc --noEmit`
- [x] 将本 `tasks.md` 全部勾选完成
- [x] 更新 `spec_kit/overview.md`
- [x] 标记 `search-engine-widget` 已完成编码(阶段性)
- [x] 写入变更文件清单

View File

@@ -0,0 +1,138 @@
# width-measurement技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,落地 Text Wrap 的“宽度测量与降级approx mode”能力保证
- APP 场景可进行**可靠的文本宽度测量**(成熟方案,结果可缓存)
- WIDGET 场景允许不测量/测量失败时**确定性降级**EN=wordCountTC=charCount/graphemeCount
- 缓存 key、错误处理与降级路径固定保证**同输入同输出**(确定性)
- `fontSpec` 缺失字段直接报错(强制调用方补齐,避免隐式默认导致跨端漂移)
- `availableWidth` 由“本机设备侧/上层”传入,本模块不内置 widgetProfiles 常量
## 2. 默认技术决策(本计划采用)
### 2.1 测量方案(成熟方法)
- **APPRN/Expo**:采用成熟的“离屏文本测量”库实现 `measureWidth(text, fontSpec)`
- 建议选型:`react-native-text-size`(或团队已有的等价成熟方案)
- 理由:可直接测量给定字体参数下的文本宽度,避免用 UI 渲染 onLayout 造成异步/不确定性
> 说明:本计划把“测量实现”作为本子模块交付的一部分,而不是仅定义接口;但仍保留注入点,便于替换实现或做平台差异适配。
### 2.2 缓存策略(由我统一定义)
采用“两级缓存 + 有上限”的策略:
1. **字符串测量缓存**`(text, fontSpecKey, contextProfile) -> width`
2. **切片测量缓存**`(start, end, fontSpecKey, contextProfile) -> width`
并且:
- 缓存采用**模块级常驻缓存**(跨 `wrapText()` 多次调用复用),提升性能
- 缓存容量必须有上限(建议 LRU 或“超限清空 + 打点”),避免内存无限增长
### 2.3 fontSpec 严格性(缺失就报错)
`fontSpec` 在 APP 测量路径下必须包含:
- `fontSize`
- `fontFamily`
- `fontWeight`
缺失任意字段时:
- **直接抛错**(错误信息必须为简体中文,并指出缺失字段与调用方应补齐的位置)
- 不允许在测量层做“隐式默认值”(避免跨端不一致与线上难定位)
### 2.4 contextProfile 的口径
为保证缓存与确定性,本模块定义 `contextProfile`
- APP`APP|<platform>|<scale?>`(按需扩展,但必须固定字段顺序与拼接方式)
- WIDGET`WIDGET|<widgetSize?>`(如果上层传入 widgetSize可写入否则仅 WIDGET
> 注意:你已确认 `availableWidth` 由设备侧传入,本模块不内置 widgetProfiles但缓存仍需要一个 profile 字段区分 APP/WIDGET。
## 3. API 设计(实现阶段稳定契约)
### 3.1 对外接口(建议)
- `buildFontSpecKey(fontSpec) -> string`
- `measureWidthCached({ text, context, fontSpec, contextProfile }) -> { width: number | null; meta: { isApprox: boolean; reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' } }`
- `measureSliceWidthCached({ tokens, start, end, joinTokens, ... }) -> { width: number | null; meta: ... }`
### 3.2 approx mode 口径(必须确定性)
当出现任一情况时进入 approx mode返回 `width=null`,并由上层按 approx 口径计算 lineLen
- `measureWidth` 不可用
- `measureWidth` 抛错
- `measureWidth` 返回 NaN/Infinity/负数
- context=WIDGET 且上层选择不测量
approx 的“长度单位”口径固定为:
- EN`wordCount`
- TC`graphemeCount`(优先;若上层仅有 charCount则使用 charCount但必须在实现中写死选择
并必须打点 reason
- `WIDTH_UNKNOWN`:没有测量能力/不启用测量
- `MEASURE_FAILED`:测量抛错或返回非法值
## 4. 实现步骤(按落地顺序)
### 4.1 定义类型与错误
- 定义 `FontSpec``ContextProfile``MeasureResult` 类型
- 定义 `MissingFontSpecError`(或统一错误码),错误信息简体中文
### 4.2 实现 fontSpecKey
实现 `fontSpecKey = fontFamily|fontWeight|fontSize`
- 字段顺序固定
- `fontSize` 转为字符串(禁止浮点格式漂移:建议 `String(fontSize)`,并要求输入是 number 且有限)
### 4.3 实现缓存容器
- 实现一个带上限的缓存LRU 优先;若不引入依赖,先用 Map + 超限清空)
- Key 生成必须确定性:
- `textKey = <contextProfile>|<fontSpecKey>|<text>`
- `sliceKey = <contextProfile>|<fontSpecKey>|<start>|<end>`
### 4.4 接入成熟测量库APP
- 封装 `measureWidthImpl(text, fontSpec) -> number`
- 对返回值做校验(有限、非负)
- 失败捕获并走 approx mode并打 `MEASURE_FAILED`
### 4.5 WIDGET 策略
- 默认允许调用方传入 `measureWidthImpl`(如果 Widget 侧实现了测量)
- 若不传/不启用:直接 approx mode并 reason=`WIDTH_UNKNOWN`
## 5. 测试计划Vitest
### 5.1 纯函数与缓存测试(必须)
- `fontSpecKey`:同输入同输出;缺字段抛错
- 缓存命中:同 key 不重复调用底层 `measureWidthImpl`
- 缓存隔离:不同 `contextProfile/fontSpecKey` 不互相污染
### 5.2 降级路径测试(必须)
- 缺少 `measureWidthImpl` -> approx mode + reason=`WIDTH_UNKNOWN`
- `measureWidthImpl` 抛错/返回 NaN -> approx mode + reason=`MEASURE_FAILED`
> 说明:测量库本身的准确性不在单测中做像素级断言;单测只验证“缓存与降级语义确定性”。
## 6. 完成定义DoD
- APP/WIDGET 下 `width` 返回语义清晰:可测量返回 number不可测量返回 null
- `fontSpec` 缺字段必定报错(简体中文错误信息)
- 缓存 key 与容量策略确定性,且有上限
- approx mode 触发条件、reason 标记、EN/TC 近似口径写死
- 单测覆盖缓存命中/隔离与降级路径

View File

@@ -0,0 +1,51 @@
# width-measurement子模块规范
## 子模块名称
width-measurement宽度测量与降级
## 目标描述
提供统一的宽度测量接口与缓存策略并定义“宽度不可用”时的确定性降级行为approx mode确保 APP 与 WIDGET 在测量能力差异下仍能:
- 保持搜索/评分流程可运行
- 输出可解释meta 打点)
- 性能稳定(缓存与上限)
## 输入/输出定义
### 输入
- `context: 'APP' | 'WIDGET'`
- `fontSpec?: { fontSize: number; fontWeight?: string; fontFamily?: string }`
- `measureWidth?: (text: string, fontSpec) => number`(可选注入)
- `text: string`
- `slice?: { start: number; end: number }`(可选:段落切片测量)
### 输出
- `width: number | null`
- 当不可用/失败时返回 `null`(触发 approx mode
- `meta?: { isApprox: boolean; reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' }`
并提供缓存约定(逻辑输出):
- 字符串测量缓存 key`(text, fontSpecKey, contextProfile)`
- 切片测量缓存 key`(start, end, fontSpecKey, contextProfile)`
## 验收标准(可验证)
- **测量一致**:在 APP 注入 `measureWidth` 时,同一输入重复测量命中缓存(不会重复计算)
- **失败降级**`measureWidth` 缺失/抛错/返回 NaN 时:
- 输出 `width=null`
- meta 标记 `isApprox=true` 且 reason 可追踪
- **approx mode 口径一致**
- EN宽度近似值使用 `wordCount`
- TC宽度近似值使用 `charCount`(或 grapheme 数)
- **确定性**:相同输入在相同 contextProfile 下,测量与降级行为一致
## 依赖与关联
- **被依赖**`search-engine-app``search-engine-widget``overflow-fallback`
- **依赖**`core-contract`fontSpecKey 规范化、切片文本重组)

View File

@@ -0,0 +1,160 @@
# width-measurement任务清单
> 对应计划:`spec_kit/Text Wrap/modules/width-measurement/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 所有代码注释必须为简体中文,并把“缓存 key / 降级语义 / 报错口径”写死,避免后续模块漂移。
---
## 1. 前置检查(依赖与约束确认)
- [x] 1.1 确认 `availableWidth` 由设备侧传入(本模块不内置 widgetProfiles
- **验收**:在本模块实现中不引入任何固定宽度常量表;仅消费上层传入的宽度与 profile。
- [x] 1.2 确认 APP 测量方案采用成熟库(默认 `react-native-text-size`
- **验收**`client/package.json` 中存在该依赖(或团队等价成熟方案),并且测量封装函数仅依赖该库/注入点。
---
## 2. 依赖与目录骨架(客户端侧实现)
- [x] 2.1 新建目录 `client/src/features/textWrap/measure/`
- **产出**(建议文件):
- `types.ts`FontSpec/ContextProfile/MeasureResult
- `errors.ts`(缺字段报错)
- `fontSpecKey.ts`
- `cache.ts`(两级缓存 + 上限)
- `measureWidthImpl.ts`(对接成熟测量库)
- `measureWidthCached.ts`
- `measureSliceWidthCached.ts`
- `__tests__/widthMeasurement.test.ts`
- `index.ts`(统一导出)
- **验收**目录存在TS 可正常 import不报路径错误
- [x] 2.2 新增依赖 `react-native-text-size`(如项目未安装)
- **命令**(示例):
- `cd client && npm install react-native-text-size`
- **验收**
- 安装成功
- `npm test` 不受影响(后续任务再补充本模块测试)
---
## 3. fontSpec 强校验(缺失直接报错)
- [x] 3.1 定义 `FontSpec` 类型(必须字段:`fontSize/fontFamily/fontWeight`
- **验收**:类型层面可表达“必填字段”,并在运行时也做校验。
- [x] 3.2 实现运行时校验与错误(简体中文)
- **要求**
- 缺失任一字段直接抛错
- 错误信息包含:缺失字段名 + 建议调用方补齐的位置(例如 `wrapText({ fontSpec: ... })`
- 禁止隐式默认值
- **验收**:单测断言会抛错且错误信息包含缺失字段名。
---
## 4. fontSpecKey 与 contextProfile确定性 key 体系)
- [x] 4.1 实现 `buildFontSpecKey(fontSpec)`
- **规则**`fontFamily|fontWeight|fontSize`(顺序固定)
- **验收**同输入同输出fontSize 非有限数时报错。
- [x] 4.2 定义 `contextProfile` 拼接规则并实现 helper
- **要求**
- APP`APP|<platform>|<scale?>`(字段顺序固定)
- WIDGET`WIDGET|<widgetSize?>`(无 widgetSize 时为 `WIDGET`
- **验收**:输出字符串稳定;不同 profile 必须产生不同缓存 key。
---
## 5. 两级缓存(有上限 + 确定性)
- [x] 5.1 实现缓存容器(模块级常驻)
- **要求**
- 字符串测量缓存textKey
- 切片测量缓存sliceKey
- 容量上限策略LRU 优先;若不引入依赖,先 Map + 超限清空(并预留打点钩子)
- **验收**:单测可验证缓存命中会减少底层测量调用次数。
- [x] 5.2 定义 key 生成规则并实现
- **规则**
- `textKey = <contextProfile>|<fontSpecKey>|<text>`
- `sliceKey = <contextProfile>|<fontSpecKey>|<start>|<end>`
- **验收**key 生成不依赖对象遍历顺序;同输入同 key。
---
## 6. 测量实现与降级approx mode
- [x] 6.1 实现 `measureWidthImpl(text, fontSpec)`APP
- **要求**
- 依赖成熟库测量宽度
- 返回值必须校验:有限且非负
- **验收**:在单测中用 mock 替代真实库,验证封装逻辑与校验逻辑即可(不做像素级断言)。
- [x] 6.2 实现 `measureWidthCached(...)`
- **行为**
- 正常测量:返回 `{ width:number, meta:{ isApprox:false } }`
- 进入 approx返回 `{ width:null, meta:{ isApprox:true, reason } }`
- **approx 触发条件**(任一满足):
- 未提供测量能力 / context=WIDGET 且上层不启用测量 -> `WIDTH_UNKNOWN`
- 抛错/NaN/Infinity/负数 -> `MEASURE_FAILED`
- **验收**:单测覆盖两类 reason。
- [x] 6.3 实现 `measureSliceWidthCached(...)`(切片测量)
- **要求**
- 通过 `joinTokens(start,end)` 生成切片文本
- 使用切片缓存避免 DP 反复测量
- **验收**:单测验证同 sliceKey 不重复调用底层测量。
---
## 7. 单元测试Vitest
- [x] 7.1 新建 `__tests__/widthMeasurement.test.ts` 并覆盖以下用例
- **fontSpec 报错**:缺字段必抛错(错误信息含字段名)
- **缓存命中**:同 key 不重复调用底层测量 mock
- **缓存隔离**:不同 `contextProfile/fontSpecKey` 不互相污染
- **降级**
- 缺测量能力 -> `width=null` + `WIDTH_UNKNOWN`
- 测量抛错/NaN -> `width=null` + `MEASURE_FAILED`
- **验收**`npm test` 稳定通过。
---
## 8. 最终自检清单(合入前)
- [x] 8.1 `npm test` 通过(包含本模块新增用例)
- **验收**:不影响现有测试文件。
- [x] 8.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
- **验收**:无类型错误。
- [x] 8.3 注释与口径自检(简体中文)
- **检查点**
- `fontSpec` 缺字段“必须报错”
- key 生成规则与 contextProfile 口径
- approx mode 的 reason 语义WIDTH_UNKNOWN / MEASURE_FAILED
- **验收**:后续模块开发者只看代码也不会产生歧义。
---
## 9. 文档回写(任务清单执行完毕后必须做)
- [x] 9.1 在 `spec_kit/overview.md``Text Wrap` 条目下补充执行状态
- **建议写法**
- 增加一行:`- **已完成编码(阶段性)**width-measurement宽度测量与降级`
- **验收**overview 能反映该子模块已完成,便于全局追踪。

109
spec_kit/Text Wrap/spec.md Normal file
View File

@@ -0,0 +1,109 @@
# Text Wrap大需求总览
> 本文件只保留高层背景、总览与模块拆分;各子模块可独立实现与验收。
> 详细算法口径以 `设计说明文档/文档换行算法.md`v1.2.1)为准。
## 1. Overview背景/目标/非目标)
Home 页面与 iOS 桌面小组件Widget都会展示“情绪文案/正念短句”。若依赖系统默认换行,会出现不可控、不可解释、跨端不一致的问题。
本需求要求把“文案换行算法”从 UI 组件中**独立成一个可复用模块**,用于:
- Home 页面文案渲染
- Widget 文案渲染(允许测量能力不同,但规则与决策必须一致)
### 目标
- **确定性**:同输入(含配置版本)必定同输出
- **跨端一致口径**:索引体系、断点定义、关键词命中与 tie-break 规则完全一致
- **可治理**:支持 debug meta、线上打点、Golden Case 回归
- **可复用**算法作为纯函数核心UI 仅消费 `lines[]/wrappedText/meta`
### 非目标
- 不做语义理解/情绪识别/机器学习
- 不做通用排版引擎
- 不承诺 Widget 场景做到像 App 一样的像素级测量Widget 可使用估算/常量)
## 2. 对外接口(统一口径)
模块对外暴露 `wrapText()`(纯函数):
```ts
wrapText({
text: string,
lang: 'TC' | 'EN',
availableWidth: number,
maxLines: number,
context: 'APP' | 'WIDGET',
fontSpec?: { fontSize: number; fontWeight?: string; fontFamily?: string },
overflowMode?: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT',
lineMode?: 'AUTO' | 'FIXED',
constraints?: {
protectedPhrases?: string[];
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
},
configVersion?: string,
debug?: boolean
}) => {
lines: string[];
wrappedText: string;
meta?: {
configVersion?: string;
fallback_type?: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
overflow_type?: 'NONE' | 'ELLIPSIS' | 'CLIP';
reason?: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'WIDOW' | 'PARTICLE' | 'TOO_LONG';
breaks?: number[];
scoreTopTerms?: Array<{ key: string; delta: number; detail?: any }>;
}
}
```
## 3. 模块拆分modules与依赖关系
### 3.1 `modules/` 目录结构
```text
spec_kit/Text Wrap/
├ spec.md
└ modules/
├ core-contract/spec.md
├ grapheme-segmentation/spec.md
├ width-measurement/spec.md
├ breakpoint-candidates/spec.md
├ scoring-tiebreak/spec.md
├ search-engine-app/spec.md
├ search-engine-widget/spec.md
├ overflow-fallback/spec.md
├ golden-tests/spec.md
└ integration/spec.md
```
### 3.2 模块职责概览
- **`core-contract`**EN/TC token 索引体系、文本重组、EN 关键词命中“全词等值”、配置/版本化与确定性比较口径 ✅
- **`grapheme-segmentation`**TC 字符簇分割的推荐实现与无库兜底 + 回归样例 ✅
- **`width-measurement`**测量接口、缓存、宽度不可用降级approx mode与打点 ✅
- **`breakpoint-candidates`**候选断点生成PUNCT/SPACE/BALANCE、去重排序、约束过滤与规模上限 ✅
- **`scoring-tiebreak`**:评分项顺序、可解释 breakdown、tieKey/tie-break 规则与整数分/EPS ✅
- **`search-engine-app`**APP 场景 DP + TopK 的确定性实现
- **`search-engine-widget`**WIDGET 场景 Beam Search 的确定性实现
- **`overflow-fallback`**ELLIPSIS/CLIP/SYSTEM_DEFAULT 语义、ellipsis 细则与兜底链路
- **`golden-tests`**Golden Cases 与性质测试确定性、近似单调性、maxLines 不变差)
- **`integration`**Home/Widget 接入约定参数映射、渲染策略、fallback 语义对齐)
## 4. 实现顺序(推荐)
> 目标是“先锁口径,再做搜索与评分,再做溢出与回归”,避免后期重构。
1. `core-contract`
2. `grapheme-segmentation`
3. `width-measurement`
4. `breakpoint-candidates`
5. `scoring-tiebreak`
6. `search-engine-app`
7. `search-engine-widget`
8. `overflow-fallback`
9. `golden-tests`
10. `integration`

View File

@@ -90,6 +90,86 @@
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
- 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist``ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题)
## Text Wrap
- **目标**:将 Home 与 Widget 的文案换行算法从 UI 中独立成可复用模块,输出稳定、可控、可解释的换行结果(同输入同输出)
- **核心范围**`wrapText()` 纯函数入口、TC/EN token 口径与索引体系、候选断点生成、DP/Beam 搜索、硬约束/评分/tie-break、溢出与兜底、debug meta 与 Golden Cases
- **阶段产物**
- `spec_kit/Text Wrap/spec.md`
- **已完成编码(阶段性)**core-contract核心口径与契约
- **已完成编码(阶段性)**grapheme-segmentationTC 字符簇分割)
- **已完成编码(阶段性)**width-measurement宽度测量与降级
- **修复**`measureSliceWidthCached` 的切片缓存 key 增加 `sliceText` hash避免不同文本的相同 `(start,end)` 发生串缓存
- **已完成编码(阶段性)**breakpoint-candidates候选断点生成与裁剪
- **变更文件**
- `client/src/features/textWrap/breakpoints/types.ts`
- `client/src/features/textWrap/breakpoints/enCandidates.ts`
- `client/src/features/textWrap/breakpoints/tcCandidates.ts`
- `client/src/features/textWrap/breakpoints/filterAndDedup.ts`
- `client/src/features/textWrap/breakpoints/generateBreakpoints.ts`
- `client/src/features/textWrap/breakpoints/index.ts`
- `client/src/features/textWrap/breakpoints/__tests__/generateBreakpoints.test.ts`
- `spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md`
- `spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md`
- **已完成编码(阶段性)**scoring-tiebreak评分模型与确定性裁决
- **变更文件**
- `client/src/features/textWrap/scoring/types.ts`
- `client/src/features/textWrap/scoring/weights.ts`
- `client/src/features/textWrap/scoring/lexicons.ts`
- `client/src/features/textWrap/scoring/phraseMatch.ts`
- `client/src/features/textWrap/scoring/score.ts`
- `client/src/features/textWrap/scoring/tieKey.ts`
- `client/src/features/textWrap/scoring/index.ts`
- `client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts`
- `spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md`
- `spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md`
- **已完成编码(阶段性)**search-engine-appAPP 搜索器DP + TopK
- **变更文件**
- `client/src/features/textWrap/searchApp/types.ts`
- `client/src/features/textWrap/searchApp/topK.ts`
- `client/src/features/textWrap/searchApp/constraints.ts`
- `client/src/features/textWrap/searchApp/dpTopK.ts`
- `client/src/features/textWrap/searchApp/index.ts`
- `client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts`
- `spec_kit/Text Wrap/modules/search-engine-app/plan.md`
- `spec_kit/Text Wrap/modules/search-engine-app/tasks.md`
- `client/src/features/textWrap/measure/measureSliceWidthCached.ts`
- **已完成编码(阶段性)**search-engine-widgetWIDGET 搜索器Beam Search
- **变更文件**
- `client/src/features/textWrap/searchWidget/types.ts`
- `client/src/features/textWrap/searchWidget/topK.ts`
- `client/src/features/textWrap/searchWidget/constraints.ts`
- `client/src/features/textWrap/searchWidget/beam.ts`
- `client/src/features/textWrap/searchWidget/index.ts`
- `client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts`
- `spec_kit/Text Wrap/modules/search-engine-widget/plan.md`
- `spec_kit/Text Wrap/modules/search-engine-widget/tasks.md`
- **已完成编码(阶段性)**overflow-fallback溢出与兜底
- **变更文件**
- `client/src/features/textWrap/overflow/types.ts`
- `client/src/features/textWrap/overflow/ellipsis.ts`
- `client/src/features/textWrap/overflow/fallback.ts`
- `client/src/features/textWrap/overflow/index.ts`
- `client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts`
- `spec_kit/Text Wrap/modules/overflow-fallback/plan.md`
- `spec_kit/Text Wrap/modules/overflow-fallback/tasks.md`
- **已完成(阶段性)**golden-testsGolden Cases 与性质测试)
- **变更文件**
- `client/src/features/textWrap/golden/fixtures.ts`
- `client/src/features/textWrap/golden/__tests__/golden.test.ts`
- `spec_kit/Text Wrap/modules/golden-tests/plan.md`
- `spec_kit/Text Wrap/modules/golden-tests/tasks.md`
- **已完成编码(阶段性)**integrationHome / Widget 接入)
- **变更文件**
- `client/src/features/textWrap/types.ts`
- `client/src/features/textWrap/wrapText.ts`
- `client/src/features/textWrap/index.ts`
- `client/src/features/textWrap/__tests__/wrapText.integration.test.ts`
- `spec_kit/Text Wrap/modules/integration/plan.md`
- `spec_kit/Text Wrap/modules/integration/tasks.md`
- **接入情况**
- HomeAPP已在 `client/app/(app)/home.tsx` 接入 `wrapText()` 渲染 `wrappedText`(含 `\n`
## Splash Consent
- **目标**:实现开屏页(使用 `client/assets/images/index/` 图片资源),首次加载展示渐变同意按钮并提供隐私/协议入口