Files
mdpolish/research-wiki/design/0003-first-executable-core-architecture.md
T
Bepr4 3edeeaf30e 实现第一版内存清洗核心
落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
2026-08-22 01:03:03 +08:00

466 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 0003Mdpolish 第一版可执行核心架构
## 状态
已批准并冻结(2026-08-22)。
`supersedes: 0002`(范围有限):本设计只替代 `0002` 中“所有组件必须提供公共 `check`”、
`detect_only` / `suggestion` / `auto_fix` 三种处理状态,以及第一版同时建设检查流程的选择。
`0002` 已确定的 Markdown 单一输入、项目显式组装、组件顺序执行、精确修改、单轮清洗、最终只读复查、
文件适配层与项目 profile 边界继续有效。
本次批准授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。
批准本设计不等于实现已经存在;在对应代码和测试实际落地前,本仓库仍然没有可运行的清洗工具。
## 1. 问题
`0002` 已经确定总体组织方式,但把检查和清洗同时放进第一版:组件先返回三类问题,检查流程报告全部问题,
清洗流程只应用其中的 `auto_fix`
真实材料中确实存在幻觉、截断、损坏表格和断链图片等问题,但第一版核心只有 Markdown,没有原文、图片、
人工审核或回源能力。此时报告“确认异常但无法处理”的内容不能形成闭环;`suggestion` 也没有批准、拒绝、
快照复核和安全应用协议。提前把这些能力放入核心,会引入暂时没有消费者的 `Issue``CheckResult` 和状态分支。
第一版应先回答更小的问题:具体组件如何针对当前 Markdown 提出唯一、安全的精确修改,公共执行器如何保证
这些修改没有过期、冲突或部分执行,流水线又如何确认一次清洗已经稳定。
尚需确定:
- 组件通过什么 Python 接口提出确定修改;
- 中文文本的位置如何表示;
- 修改怎样绑定当前 Markdown,避免旧位置误改新文本;
- 多项修改如何保证全部成功或全部不执行;
- 插入与替换在边界接触时如何判定冲突;
- 组件错误、修改失败和最终未稳定如何返回;
- 第一版源码、测试和依赖边界是什么。
## 2. 目标与非目标
目标:
- 建立只处理内存字符串的 Python 自动清洗核心;
- 让组件只提出能够立即自动执行的精确修改,不报告无法处理的疑似问题;
- 确定快照、候选修改、文本编辑、实际改动、错误和运行结果的职责;
- 让公共修改执行器统一验证范围、原文、冲突、原子性和实际改动记录;
- 实现项目显式组装、组件顺序执行、单轮清洗和最终稳定性复查;
- 使用测试专用组件验证组合机制,不把真实清洗语义混入架构实现。
非目标:
- 不实现独立文档检查、`detect_only`、人工建议或审核流程;
- 不提供公共 `check``inspect`、预览或 dry-run 接口;
- 不实现任何面向论文、GovDoc 或其他项目的真实清洗组件;
- 不引入 Markdown parser、AST、HTML parser 或其他运行依赖;
- 不读取或写入 Markdown 文件,不提供 CLI、批处理或服务接口;
- 不建设配置文件、profile 文件格式、插件自动发现或第三方插件市场;
- 不定义 JSON、数据库或长期审计文件格式;
- 不读取 PDF、图片、转换器 JSON、项目目录或外部真实材料;
- 不承诺第一版组件扩展接口已经长期稳定。
## 3. 名称与技术边界
- 项目展示名为 `mdpolish`
- Python 分发名和导入名使用小写 `mdpolish`
- 源码包位于 `src/mdpolish/`
- 第一版支持 Python 3.11 及以上版本;
- 运行时只使用 Python 标准库;
- 使用 `pyproject.toml` 管理项目,构建后端采用 Hatchling;
- 开发检查使用 pytest、Ruff 和 mypy,具体依赖版本只在 `pyproject.toml` 中维护。
选择 Python 3.11 是为了使用现代类型能力,同时不把本地 Python 3.13 环境变成最低要求。第一版不引入解析器,
是为了先验证组件和修改协议;具体结构规则需要什么解析能力,由后续真实组件 design 决定。
## 4. 方案比较与决定
### 4.1 同时建设检查、建议和自动修改
这种方案能够描述长期可能需要的文档体检和人工确认,但第一版没有外部材料、审核入口和建议应用协议。
不可执行的问题不会参与清洗,候选建议又会在快照变化后过期。
第一版不采用。以后确有独立检查需求时,新增平行的 Inspector 设计,不在本轮预留三段状态。
### 4.2 组件直接返回整篇新 Markdown
接口简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。
组件还可能顺带改写没有被选中的内容。
不采用。
### 4.3 组件返回 AST,由流水线重新输出全文
适合格式化器和结构化编译,但会让没有被组件选中的 Markdown 也发生书写形式变化。第一版还需要先选择 parser、
扩展方言和 renderer,超出了当前最小闭环。
不采用。
### 4.4 组件只提出能够自动执行的精确修改
组件读取当前快照,只返回前置条件严格、处理方式唯一的 `ProposedChange`。公共执行器统一验证并应用修改。
如果某种输入存在歧义,组件直接忽略,不修改,也不在第一版中额外报告。
采用此方案。
## 5. 总体结构
```text
Markdown 字符串
DocumentSnapshot
Component._propose_changes()
└── ProposedChange
└── 一个或多个 TextEdit
公共修改执行器
├── 验证快照、范围、原文和冲突
├── 原子应用当前组件的全部修改
└── 生成 Change 和新 DocumentSnapshot
下一个 Component
最终快照重新提议但不应用
TransformResult
```
核心分为四层:
- **数据模型:** 不可变地表达快照、范围、候选修改、实际改动、错误和结果;
- **组件基类:** 只定义组件身份、适用边界和 `_propose_changes()` 扩展点;
- **修改执行器:** 是唯一能够把 `TextEdit` 应用到 Markdown 的位置;
- **流水线:** 负责组件顺序、快照刷新、错误、失败停止和最终稳定性复查。
文件读写、终端输出、未来检查能力和配置不进入这四层。
## 6. 快照与位置
### 6.1 `DocumentSnapshot`
快照至少包含:
- `markdown`:当前完整 Markdown 字符串;
- `sha256``markdown.encode("utf-8")` 的 SHA-256 十六进制摘要。
哈希由库根据 Markdown 计算,调用方不能传入一个自称匹配的哈希。空字符串是合法输入。库不自动改变编码、
换行符、Unicode 规范形式或文件末尾换行。
第一版快照不包含路径、文件名、PDF、图片、项目 ID、时间戳或任意外部元数据。
### 6.2 `TextSpan`
文本范围使用 Python 字符串下标:
- `start` 包含;
- `end` 不包含;
- 必须满足 `0 <= start <= end <= len(markdown)`
Python 字符串下标是第一版唯一权威位置。行号和列号由快照与下标计算,只用于显示,不作为修改依据。
这里的位置按 Unicode 码点工作,不按 UTF-8 字节或用户看到的字形数量工作。
## 7. 候选修改与文本编辑
### 7.1 `ProposedChange`
一个候选修改表示组件确认能够自动执行的一次完整动作。它至少包含:
- 目标快照哈希;
- 非空的修改理由;
- 一个或多个 `TextEdit`
同一候选修改中的编辑必须全部应用或全部不应用。组件必须以确定顺序返回候选修改;通常按首个编辑在原文中的
位置从前到后排列。流水线根据组件执行位置、目标快照哈希和候选修改序号分配当前结果内的确定性引用,
组件不生成随机 ID。
第一版没有“不自动应用的候选修改”。不能确认唯一正确改法时,组件不返回 `ProposedChange`
### 7.2 `TextEdit`
每个文本编辑至少包含:
- 目标快照哈希;
- `TextSpan`
- `expected_text`:修改前该范围必须准确等于的原文;
- `replacement`:替换内容。
插入使用 `start == end` 和空 `expected_text`;删除使用空 `replacement``replacement`
`expected_text` 完全相同的无效修改视为组件契约错误,不生成虚假的改动记录。
## 8. 组件接口与契约
`Component` 使用抽象基类。每个具体组件只实现以下扩展点:
- 稳定的组件标识;
- 组件版本;
- 当前参数;
- 非空的适用边界说明;
- `_propose_changes(snapshot)`:读取快照并返回能够自动执行的候选修改,不产生副作用。
组件标识使用稳定的小写字符串;同一语义不能因为改了 Python 类名就更换标识。组件版本使用
`MAJOR.MINOR.PATCH` 形式。组件参数必须能表示为确定的只读基础数据,流水线将实际参数记录到结果中。
适用边界至少说明组件处理的结构、严格前置条件和明确排除项。组件只对满足全部前置条件的内容提出修改;
相似但有歧义的内容直接忽略。
所有组件必须满足以下不变量:
- 相同 Markdown、组件版本和参数产生相同顺序的候选修改;
- 成功执行一次后再次执行,不产生新的实际改动;
- 不读取文件、网络、环境变量、当前时间或随机数;
- 不修改传入对象或外部状态;
- 不直接生成或改写整篇 Markdown,只提交精确 `TextEdit`
第一版组件没有公共 `check()``transform()`。单个组件通过只包含它的 `Pipeline` 独立运行:
```python
result = Pipeline([component]).transform(markdown)
```
这样避免 `component.py` 反向依赖 `pipeline.py`,也避免组件和流水线维护两套执行逻辑。
第一版流水线禁止出现两个相同组件标识的实例。需要用不同参数运行同一组件两次时,应由项目重新考虑组件边界,
不能依靠重复 ID 制造含义不清的执行记录。
## 9. 公共修改执行器
每个组件都针对当前快照提出修改;该组件结束后,它的全部候选修改作为一个批次交给公共执行器。
执行器按以下顺序验证当前组件的整个批次:
1. 候选修改和编辑的快照哈希都等于当前快照哈希;
2. 每个候选修改理由非空并至少包含一个编辑;
3. 所有范围合法;
4. `markdown[start:end]``expected_text` 完全一致;
5. 不存在重复编辑或下述范围冲突;
6. 所有编辑都会实际改变内容。
范围冲突使用以下保守规则:
- 两个非空范围真正重叠时冲突;相邻的 `[a, b)``[b, c)` 可以同时修改;
- 两个插入位于同一位置时冲突,不同位置可以同时插入;
- 插入点位于另一个非空范围内部,或等于该范围的起点、终点时,均视为冲突。
最后一条有意比半开区间的数学重叠更严格,避免相同起点的执行顺序和边界插入语义不明确。组件如果确实需要
替换一段文字并在边界追加内容,应合并为一个 `TextEdit.replacement`
验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的下标失效。任意一项验证失败,
当前组件的整个批次都不应用。内部应用顺序不决定报告顺序;结果中的实际改动按原文位置从前到后排列。
### 9.1 `Change`
每个实际应用的 `TextEdit` 产生一条 `Change`,至少记录:
- 组件标识和版本;
- 组件在流水线中的执行位置;
- 候选修改在本次组件结果中的引用和编辑序号;
- 修改理由;
- 修改前范围、`before``after`
- 修改前后的快照哈希。
同一个 `ProposedChange` 产生的多条 `Change` 使用相同引用,使一次多位置动作可以整体追踪。同一组件批次中的
所有 `Change` 共享该批次修改前后的快照哈希,不制造并不存在的中间公开快照。
修改记录只描述实际发生的变化,不把未执行或验证失败的候选修改冒充实际改动。
## 10. 模块依赖方向
第一版保持以下单向依赖:
```text
models.py
▲ ▲
│ │
component.py edits.py
▲ ▲
\ /
pipeline.py
```
- `models.py` 只依赖 Python 标准库;
- `component.py` 只依赖数据模型,不导入流水线或修改执行器;
- `edits.py` 只依赖数据模型,不调用组件或流水线;
- `pipeline.py` 可以依赖组件、修改执行器和数据模型;
- `__init__.py` 只导出批准的公共对象,不实现第二套逻辑。
修改执行器不理解 arXiv、表格、HTML 或其他业务语义,也不决定组件顺序和最终状态。组件不应用编辑,
不刷新快照,也不知道文件、CLI 或未来报告格式。
如果实现中发现这些边界导致机械性的循环依赖,可以拆分数据模型文件,但不能让底层模块反向导入流水线。
## 11. 清洗流程与运行状态
### 11.1 单轮清洗
`Pipeline.transform(markdown)` 按以下步骤运行:
1. 建立输入快照;
2. 按项目给出的顺序,让当前组件针对当前快照提出修改;
3. 验证组件元数据和候选修改契约;
4. 原子验证并应用当前组件的整个修改批次;
5. 有修改时建立新快照,下一个组件只能读取新快照;
6. 所有组件各执行一次后,对最终快照运行最终稳定性复查。
组件没有提出修改是正常情况,不产生空批次或虚假 `Change`
空组件列表也是合法输入:流水线对原输入建立快照后直接完成空的最终复查,返回零改动的 `success`
清洗阶段如果组件异常、返回无效数据或修改批次冲突,流水线立即停止,不继续运行后面的组件,也不进行最终复查。
之前组件已经完成的内存修改和 `Change` 保留在失败结果中,但不能作为成功输出。
### 11.2 最终稳定性复查
最终复查让所有已选组件按照原顺序针对最终快照重新提出修改,但不应用任何修改。
复查阶段仍然验证组件元数据、候选修改、原文和整个组件批次的冲突;区别只是验证通过后不应用编辑。
每条有效残留修改连同组件标识、版本、执行位置和确定性引用一起记录,使调用方知道由哪个组件提出。
- 所有组件都不再提出修改:状态可以是 `success`
- 任一组件仍提出有效修改:状态为 `unstable`,并保留这些 `residual_proposals`
- 复查发生组件错误或返回无效数据:状态为 `failed`;复查继续调用其余组件,以汇总只读阶段的错误。
如果最终复查同时出现错误和其他组件的有效残留修改,最终状态以 `failed` 为准,但已经获得的
`residual_proposals` 仍然保留,不能因为另一个组件失败而丢失。
最终复查只回答“选中的自动清洗组件是否已经稳定”,不声称 Markdown 没有截断、幻觉、损坏表格或其他
第一版不处理的问题。
### 11.3 状态与结果字段
清洗状态至少包括:
- `success`:单轮清洗和最终复查完整完成,选中组件不再提出修改;
- `failed`:组件执行、组件契约或修改验证失败;
- `unstable`:修改阶段没有错误,但最终复查仍产生有效候选修改。
为避免调用方忽略状态并误用半成品:
- `success` 只提供 `output_markdown``partial_markdown` 为空;
- `failed``unstable` 不提供 `output_markdown`,只提供诊断用的 `partial_markdown`
- 三种状态都记录输入哈希、当前内容哈希、组件清单、实际改动和错误;
- `residual_proposals` 记录最终复查已经验证有效的残留修改;它可以出现在 `unstable` 或最终复查阶段产生的
`failed` 结果中,在 `success` 和清洗阶段直接失败的结果中为空。
错误至少记录组件标识和版本、发生阶段(`transform``final_review`)、异常或契约错误类型,以及不泄露
额外原文的简明说明。组件异常、契约错误和修改验证错误都不能被静默忽略。
输入本身从不被原地覆盖。即使输出内容与输入完全相同,只要最终复查通过,也可以是零改动的 `success`
由于核心只返回内存结果、不写文件,第一版不再提供额外预览接口;调用方可以先审查 `TransformResult`
再由未来适配层决定是否保存成功输出。
## 12. 源码与测试结构
批准后创建以下最小结构:
```text
pyproject.toml
src/
└── mdpolish/
├── __init__.py
├── py.typed
├── component.py
├── edits.py
├── models.py
└── pipeline.py
tests/
├── test_component.py
├── test_edits.py
├── test_models.py
└── test_pipeline.py
```
- `models.py` 只放不可变的数据模型和状态枚举;
- `component.py` 放组件基类和组件元数据契约;
- `edits.py` 放纯文本修改验证与应用;
- `pipeline.py` 放单轮清洗和最终稳定性复查编排;
- `__init__.py` 只导出第一版公共对象;
- `py.typed` 声明分发包提供类型信息;
- 测试辅助组件只存在于 `tests/`,不发布成示例清洗能力。
新增新的业务层、运行依赖或公共入口仍需要重新评审。
## 13. 测试专用组件与验收
第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生零修改、
固定修改、多位置修改、组件异常、无效候选和连锁影响。
至少覆盖:
- 空 Markdown、中文、换行和 Unicode 组合字符;
- 空组件列表返回原文不变、零改动的 `success`
- 插入、删除、替换以及一次候选修改包含多个编辑;
- 相邻非空范围可以应用,重叠范围和重复编辑明确失败;
- 不同位置插入可以应用,同点插入明确失败;
- 插入位于非空范围内部、起点或终点时明确失败;
- 哈希过期、范围越界、`expected_text` 不符、空候选、空理由和无效修改明确失败;
- 当前组件批次全部成功或全部不应用,包括不同候选修改之间发生冲突;
- 组件异常或契约错误使清洗立即停止,并区分成功输出与部分文本;
- 后一个组件读取前一个组件修改后的新快照;
- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,保留 `residual_proposals`,且不开始第二轮;
- 最终复查发生错误时继续调用其余组件并最终返回 `failed`
- 最终复查同时出现错误和有效残留修改时,两者都保留,状态为 `failed`
- 错误记录能够区分 `transform``final_review` 阶段;
- 同一个候选修改的多条 `Change` 共享引用、理由和批次前后哈希;
- 单个组件通过单组件 `Pipeline` 正常运行;
- 成功流水线再次运行不产生实际改动;
- 输入字符串不被修改,相同输入、组件和参数产生相同顺序的结果;
- 重复组件标识、无效版本、不可表示的参数和空适用边界明确失败。
批准并实现后,基础验证至少包括:
```bash
ruff check .
mypy src tests
pytest
```
README 届时记录实际可用命令。只有这些命令真实运行成功后,才能报告对应检查通过。
## 14. 未来检查能力如何扩展
第一版不为未来检查功能预留空枚举或可空候选修改。以后真实项目证明只读检查有独立消费者时,通过新 design
增加平行接口,例如:
```text
Inspector.inspect(DocumentSnapshot) -> Finding
InspectionPipeline.inspect(markdown) -> InspectionResult
```
未来检查能力可以复用 `DocumentSnapshot``TextSpan` 和组件身份规则,但不修改 `TextEdit`
`ProposedChange`、公共修改执行器、`Pipeline.transform()``TransformResult`。某项能力同时需要检查和清洗时,
对应 Inspector 与 Component 可以在自身实现中复用定位函数,不需要让两个产品流程共享同一种结果模型。
人工建议以后还需要批准、拒绝、快照复核和应用协议,不能只增加一个 `suggestion` 枚举就视为完成。
## 15. 风险与代价
- **第一版不报告不可处理问题:** `success` 只表示选中组件执行稳定,不表示文档整体正确;README 和未来 API
文档必须明确这一点。
- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;等真实规则证明需要共享分析后再设计。
- **Python 字符位置不是跨语言协议:** 第一版只承诺 Python API;以后输出机器可读跨语言格式时,需要单独定义
坐标语义,不能假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。
- **最终复查增加检查成本:** 选中的组件最多提出两次修改,但换来对组合连锁影响的明确判断,第一版接受此代价。
- **组件级原子批次可能放弃部分正确修改:** 这是有意的保真选择;组件应修复自己的冲突,而不是让流水线猜测。
- **失败结果仍含部分 Markdown:** 使用独立字段并让成功输出为空,降低误用风险;未来文件适配层不得默认写出
`partial_markdown`
- **结果可能包含原文片段:** 第一版结果只存在内存,不建设日志和持久化;以后新增 reporter 时必须单独评审
脱敏和保存边界。
- **组件版本需要维护:** 组件语义、适用边界、定位或修改行为变化时必须更新版本,不能只改代码而保留相同身份。
## 16. 批准后的实施边界
批准本设计只授权:
1. 创建第 12 节列出的工程与测试文件;
2. 实现第 5 至 11 节描述的内存自动清洗核心;
3. 创建测试专用假组件并完成第 13 节验证;
4. 根据真实实现更新 README 当前阶段和基础检查;
5. 实现完成后新增 `research-wiki/explanation/` 文档,解释当前实际架构。
批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、独立检查能力、CLI、发布、提交或推送。