实现第一版内存清洗核心

落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
This commit is contained in:
2026-08-22 01:03:03 +08:00
parent 8ac4f1dd12
commit 3edeeaf30e
15 changed files with 2023 additions and 145 deletions
+32 -12
View File
@@ -6,8 +6,9 @@
格式噪声、结构损坏、内容异常、修改追踪和多用途派生问题。各项目共享通用清洗能力,再通过独立配置或
profile 表达论文、政务文档、RAG、文档对比等不同需求。
仓库当前仍处于研究和方案设计阶段:用于澄清问题、记录设计选择、积累可复核证据,并在方案获得确认后
再建立实现。它目前不是可安装的 Python 包,也不提供命令行工具或生产接口
仓库当前已从纯文档治理进入第一版核心实现阶段:已经提供可安装的 Python 内存处理包和测试,用于验证
组件组合、精确修改和审计协议。仓库仍不提供真实清洗规则、命令行工具、文件读写适配器或生产接口
因此目前还不是拿来即可清洗文档的成品工具。
## 当前阶段
@@ -25,8 +26,10 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
- 2026-08-21 明确本项目定位为实验室共用库;GovDoc 和论文清洗都是使用场景,不是核心边界。
- 2026-08-21 批准并冻结 `research-wiki/design/0002-composable-cleaning-pipeline.md`,确定只接收 Markdown、
项目显式组装组件、单轮修改加最终只读复查的总体组织方式。
- 2026-08-21 建立 `research-wiki/design/0003-first-executable-core-architecture.md` 草稿,等待评审第一版
Python 内存核心、精确修改协议、错误语义和测试边界
- 2026-08-22 批准并冻结 `research-wiki/design/0003-first-executable-core-architecture.md`,确定第一版只建设
Python 内存自动清洗核心:组件只提出确定可执行的精确修改,不同时建设独立检查、人工建议或真实清洗规则
- 2026-08-22 按 `0003` 实现第一版内存核心和测试:包括不可变数据契约、组件基类、原子修改执行器、
顺序流水线和最终稳定性复查;58 项测试以及 Ruff、mypy 检查均通过。
- 2026-08-21 完成 HTML 表格清洗专题调研
`research-wiki/scratch/html-table-cleaning-ecosystem-research-2026-08-21.md`):核实 Pandoc 表格
方言能力边界、Turndown 不处理合并单元格、Docling Markdown 导出重复合并单元格内容、MinerU 全
@@ -34,9 +37,10 @@ profile 表达论文、政务文档、RAG、文档对比等不同需求。
- 2026-08-21 仓库由 `govdoc-md-cleaner` 更名为 `mdpolish`,GitHub 远程仓库与本地目录同步改名;
冻结的 design 记录和带日期的 scratch 笔记保留当时的旧名。
当前没有清洗算法、可执行命令、运行依赖、测试套件或函数级输入输出契约。`0002` 只批准了总体组织方式;
调研报告中的解析器、内部 IR、具体 profile 和实施路线仍是候选方案,尚未批准。
目录存在只代表文档落点已经建立,不代表相应能力已经完成
当前已有只处理内存字符串的底层执行机制和函数级契约,运行时只依赖 Python 标准库。组件可以针对当前
Markdown 快照提出精确修改,流水线负责原子应用、审计记录、失败隔离和最终稳定性复查。仓库尚无任何正式
清洗组件,因此不能把测试专用假组件或核心执行成功理解为已经具备论文、GovDoc、表格或图片清洗能力
调研报告中的解析器、内部 IR、具体 profile 和真实清洗规则仍是候选方案,尚未批准。
## 服务对象与复用目标
@@ -51,10 +55,10 @@ GovDoc 目录、具体客户名称或某一转换器的固定输出路径。
## 面向复用的设计原则
- **通用核心**:只接收 Markdown,提供结构检查、异常检测和可审计变换,不读取 PDF、图片或转换器 JSON;
- **通用核心**:只接收 Markdown;第一版只执行确定、可审计的精确修改,不读取 PDF、图片或转换器 JSON;
- **输入边界**PDF/OCR/DOCX/HTML 转换和外部材料核验由使用项目或上游流程负责,不写入共用组件契约;
- **项目 profile**:论文、GovDoc、对比、RAG、公开脱敏等规则独立组合,不互相污染默认行为;
- **保真优先**:不确定内容默认保留或进入人工确认,不能为了格式整齐改写业务或学术内容
- **保真优先**:不确定内容默认保留;当前核心不猜测修改,也不承担人工确认流程
- **可复现**:规则、配置、输入哈希、输出和每次变更都可以追踪;
- **可扩展**:新增项目在自身边界处理上游适配,并主要组合或补充组件和 profile,而不是复制一套清洗器。
@@ -82,6 +86,9 @@ mdpolish/
├── AGENTS.md
├── CLAUDE.md
├── README.md
├── pyproject.toml # Python 包、构建和开发检查的唯一配置
├── src/mdpolish/ # 第一版内存核心;不含真实清洗组件
├── tests/ # 核心契约和组合行为测试
├── data/ # 本地项目数据;Git 忽略,未来按项目分区
└── research-wiki/
├── README.md
@@ -101,12 +108,23 @@ mdpolish/
3. `research-wiki/README.md`,确认文档应放在哪里;
4. 与任务直接相关的 `research-wiki/design/` 记录。
下一项实质工作开始前,应评审并批准 `research-wiki/design/0003-first-executable-core-architecture.md`
草稿尚不授权创建源码、测试、依赖或公共接口。
第一版核心的当前机制见 `research-wiki/explanation/first-executable-core.md`。下一项实质工作应从已经审计的问题中
选择一个边界明确、能够唯一修复的真实清洗规则,新增 design 说明其语义、适用范围、误改风险和验收样例,
经批准后再实现。解析器、CLI、文件适配器、profile 格式和独立检查能力仍需分别设计,不能从当前核心存在推导为
已经获批。
## 当前可用检查
```bash
# 建立隔离环境并安装包与开发检查工具
python -m venv .venv
.venv/bin/python -m pip install -e '.[dev]'
# 第一版核心的基础验收
.venv/bin/ruff check .
.venv/bin/mypy src tests
.venv/bin/pytest
# 两份 Agent 入口除标题外必须一致;无输出且退出码为 0 表示通过
diff -u <(tail -n +2 AGENTS.md) <(tail -n +2 CLAUDE.md)
@@ -117,4 +135,6 @@ find research-wiki -maxdepth 2 -type f | sort
git status --short
```
当前没有测试命令;在真实实现和测试体系获批并落地前,不应声明测试通过。
上述安装和三项基础验收已于 2026-08-22 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 9 个源码与
测试文件无问题,pytest 共 58 项测试通过。`requires-python` 仍以 `pyproject.toml` 声明的 Python 3.11 及以上为准;
本次结果不等于已经在每个受支持版本上完成兼容性验证。
+37
View File
@@ -0,0 +1,37 @@
[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"
[project]
name = "mdpolish"
version = "0.1.0"
description = "Deterministic in-memory core for composing Markdown cleaning components"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = [
"mypy>=1.15,<2",
"pytest>=8.3,<10",
"ruff>=0.11,<1",
]
[tool.hatch.build.targets.wheel]
packages = ["src/mdpolish"]
[tool.pytest.ini_options]
addopts = "-ra"
testpaths = ["tests"]
[tool.ruff]
target-version = "py311"
line-length = 120
extend-exclude = ["data", "reference"]
[tool.ruff.lint]
select = ["B", "E", "F", "I", "RUF", "UP"]
ignore = ["RUF001"]
[tool.mypy]
python_version = "3.11"
strict = true
@@ -2,41 +2,53 @@
## 状态
草稿,待批准。本设计细化已冻结的 `0002`,不替代或修改其中的选择
已批准并冻结(2026-08-22
本设计批准后,才授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。
在批准前,本仓库仍然没有可运行的清洗工具
`supersedes: 0002`(范围有限):本设计只替代 `0002` 中“所有组件必须提供公共 `check`”、
`detect_only` / `suggestion` / `auto_fix` 三种处理状态,以及第一版同时建设检查流程的选择
`0002` 已确定的 Markdown 单一输入、项目显式组装、组件顺序执行、精确修改、单轮清洗、最终只读复查、
文件适配层与项目 profile 边界继续有效。
本次批准授权创建这里列出的 Python 包、测试和工程配置,并实现不含真实清洗规则的最小核心。
批准本设计不等于实现已经存在;在对应代码和测试实际落地前,本仓库仍然没有可运行的清洗工具。
## 1. 问题
`0002` 已经确定:项目显式组合组件,流水线只接收 Markdown,组件先检查再提出精确修改,清洗只执行一轮
最后进行只读复查
`0002` 已经确定总体组织方式,但把检查和清洗同时放进第一版:组件先返回三类问题,检查流程报告全部问题
清洗流程只应用其中的 `auto_fix`
这些总体原则还不足以开始实现。目前尚未确定:
真实材料中确实存在幻觉、截断、损坏表格和断链图片等问题,但第一版核心只有 Markdown,没有原文、图片、
人工审核或回源能力。此时报告“确认异常但无法处理”的内容不能形成闭环;`suggestion` 也没有批准、拒绝、
快照复核和安全应用协议。提前把这些能力放入核心,会引入暂时没有消费者的 `Issue``CheckResult` 和状态分支。
- 组件通过什么 Python 接口报告问题;
第一版应先回答更小的问题:具体组件如何针对当前 Markdown 提出唯一、安全的精确修改,公共执行器如何保证
这些修改没有过期、冲突或部分执行,流水线又如何确认一次清洗已经稳定。
尚需确定:
- 组件通过什么 Python 接口提出确定修改;
- 中文文本的位置如何表示;
- 候选修改怎样绑定当前 Markdown,避免旧位置误改新文本;
- 修改怎样绑定当前 Markdown,避免旧位置误改新文本;
- 多项修改如何保证全部成功或全部不执行;
- 检查错误、清洗错误和最终未稳定如何返回
- 插入与替换在边界接触时如何判定冲突
- 组件错误、修改失败和最终未稳定如何返回;
- 第一版源码、测试和依赖边界是什么。
如果这些问题留给实现时临时决定,组件很容易各自返回不同格式,或者直接生成整篇新 Markdown,最终无法统一
验证和追踪修改。
## 2. 目标与非目标
目标:
- 建立只处理内存字符串的 Python 核心;
- 确定快照、问题、候选修改、实际改动和运行结果的职责
- 让具体组件只负责定位问题和提出精确修改,不直接改写整篇 Markdown
- 让公共修改执行器统一验证范围、冲突、原子性和实际改动记录;
- 实现组件独立调用、流水线检查、单轮清洗和最终只读复查;
- 建立只处理内存字符串的 Python 自动清洗核心;
- 让组件只提出能够立即自动执行的精确修改,不报告无法处理的疑似问题
- 确定快照、候选修改、文本编辑、实际改动、错误和运行结果的职责
- 让公共修改执行器统一验证范围、原文、冲突、原子性和实际改动记录;
- 实现项目显式组装、组件顺序执行、单轮清洗和最终稳定性复查;
- 使用测试专用组件验证组合机制,不把真实清洗语义混入架构实现。
非目标:
- 不实现独立文档检查、`detect_only`、人工建议或审核流程;
- 不提供公共 `check``inspect`、预览或 dry-run 接口;
- 不实现任何面向论文、GovDoc 或其他项目的真实清洗组件;
- 不引入 Markdown parser、AST、HTML parser 或其他运行依赖;
- 不读取或写入 Markdown 文件,不提供 CLI、批处理或服务接口;
@@ -47,7 +59,7 @@
## 3. 名称与技术边界
- 项目展示名暂定`mdpolish`
- 项目展示名为 `mdpolish`
- Python 分发名和导入名使用小写 `mdpolish`
- 源码包位于 `src/mdpolish/`
- 第一版支持 Python 3.11 及以上版本;
@@ -60,24 +72,31 @@
## 4. 方案比较与决定
### 4.1 组件直接返回整篇新 Markdown
### 4.1 同时建设检查、建议和自动修改
接口最简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。组件的检查逻辑
还可能与修改逻辑逐渐分离
这种方案能够描述长期可能需要的文档体检和人工确认,但第一版没有外部材料、审核入口和建议应用协议。
不可执行的问题不会参与清洗,候选建议又会在快照变化后过期
第一版不采用。以后确有独立检查需求时,新增平行的 Inspector 设计,不在本轮预留三段状态。
### 4.2 组件直接返回整篇新 Markdown
接口简单,但流水线无法确认组件实际改了哪里,也无法统一检查过期位置、范围冲突和部分失败。
组件还可能顺带改写没有被选中的内容。
不采用。
### 4.2 组件返回 AST,由流水线重新输出全文
### 4.3 组件返回 AST,由流水线重新输出全文
适合格式化器和结构化编译,但会让没有被组件选中的 Markdown 也发生书写形式变化。第一版还需要先选择 parser、
扩展方言和 renderer,超出了当前最小闭环。
不采用。
### 4.3 组件报告问题及精确文本修改
### 4.4 组件只提出能够自动执行的精确修改
组件读取当前快照,返回问题和可选候选修改;公共执行器验证并应用修改。这样可以保留原文、统一审计,
也可以拒绝过期或重叠修改
组件读取当前快照,返回前置条件严格、处理方式唯一的 `ProposedChange`公共执行器统一验证并应用修改。
如果某种输入存在歧义,组件直接忽略,不修改,也不在第一版中额外报告
采用此方案。
@@ -90,24 +109,23 @@ Markdown 字符串
DocumentSnapshot
Component._check_snapshot()
Component._propose_changes()
── Issue
└── 可选 ProposedChange
│ └── 一个或多个 TextEdit
── ProposedChange
└── 一个或多个 TextEdit
公共修改执行器
├── 验证快照、范围、原文和冲突
├── 原子应用当前组件的全部自动修改
├── 原子应用当前组件的全部修改
└── 生成 Change 和新 DocumentSnapshot
下一个 Component
最终快照只读复查
最终快照重新提议但不应用
TransformResult
@@ -115,12 +133,12 @@ TransformResult
核心分为四层:
- **数据模型:** 不可变地表达快照、范围、问题、修改、错误和结果;
- **组件基类:** 提供统一的 `check``transform` 行为,只把问题定位留给具体组件
- **数据模型:** 不可变地表达快照、范围、候选修改、实际改动、错误和结果;
- **组件基类:** 只定义组件身份、适用边界和 `_propose_changes()` 扩展点
- **修改执行器:** 是唯一能够把 `TextEdit` 应用到 Markdown 的位置;
- **流水线:** 负责组件顺序、检查错误汇总、清洗失败停止和最终只读复查。
- **流水线:** 负责组件顺序、快照刷新、错误、失败停止和最终稳定性复查。
文件读写、终端输出未来配置不进入这四层。
文件读写、终端输出未来检查能力和配置不进入这四层。
## 6. 快照与位置
@@ -147,37 +165,25 @@ TransformResult
Python 字符串下标是第一版唯一权威位置。行号和列号由快照与下标计算,只用于显示,不作为修改依据。
这里的位置按 Unicode 码点工作,不按 UTF-8 字节或用户看到的字形数量工作。
## 7. 问题与候选修改
## 7. 候选修改与文本编辑
### 7.1 处理能力
### 7.1 `ProposedChange`
问题的处理能力固定为三种
一个候选修改表示组件确认能够自动执行的一次完整动作。它至少包含
- `detect_only`:只报告,不包含候选修改
- `suggestion`:包含候选修改,但自动清洗不应用
- `auto_fix`:包含能够自动应用的候选修改
- 目标快照哈希
- 非空的修改理由
- 一个或多个 `TextEdit`
`detect_only` 如果携带候选修改,或者 `auto_fix` 没有候选修改,都属于组件契约错误。
同一候选修改中的编辑必须全部应用或全部不应用。组件必须以确定顺序返回候选修改;通常按首个编辑在原文中的
位置从前到后排列。流水线根据组件执行位置、目标快照哈希和候选修改序号分配当前结果内的确定性引用,
组件不生成随机 ID。
### 7.2 `Issue`
第一版没有“不自动应用的候选修改”。不能确认唯一正确改法时,组件不返回 `ProposedChange`
一条问题至少包含:
### 7.2 `TextEdit`
- 产生问题的快照哈希;
- 组件标识和组件版本;
- 问题范围;文档级问题可以没有具体范围;
- 简明说明和可复核证据;
- 处理能力;
- 可选的候选修改。
问题只描述某个快照中的事实。快照变化后,它可以继续作为历史记录,但不能直接用于修改新快照。
### 7.3 `ProposedChange` 与 `TextEdit`
一个候选修改表示解决一条问题所需的完整动作,可以包含一个或多个 `TextEdit`。同一候选修改中的编辑必须
全部应用或全部不应用。
每个 `TextEdit` 至少包含:
每个文本编辑至少包含:
- 目标快照哈希;
- `TextSpan`
@@ -187,50 +193,65 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照
插入使用 `start == end` 和空 `expected_text`;删除使用空 `replacement``replacement`
`expected_text` 完全相同的无效修改视为组件契约错误,不生成虚假的改动记录。
## 8. 组件接口
## 8. 组件接口与契约
`Component` 使用抽象基类,而不是仅使用结构化 `Protocol`。公共基类负责保持独立调用与流水线调用的语义一致。
`Component` 使用抽象基类。每个具体组件只实现以下扩展点:
每个具体组件只实现以下扩展点:
- 组件标识、组件版本和当前参数;
- `_check_snapshot(snapshot)`:读取快照并返回问题,不产生副作用。
- 稳定的组件标识;
- 组件版本;
- 当前参数;
- 非空的适用边界说明;
- `_propose_changes(snapshot)`:读取快照并返回能够自动执行的候选修改,不产生副作用。
组件标识使用稳定的小写字符串;同一语义不能因为改了 Python 类名就更换标识。组件版本使用
`MAJOR.MINOR.PATCH` 形式。组件参数必须能表示为确定的只读基础数据,流水线将实际参数记录到结果中。
公共基类提供:
适用边界至少说明组件处理的结构、严格前置条件和明确排除项。组件只对满足全部前置条件的内容提出修改;
相似但有歧义的内容直接忽略。
- `check(markdown)`:建立快照,执行该组件检查并返回检查结果;
- `transform(markdown)`:等价于只包含该组件的流水线清洗。
所有组件必须满足以下不变量:
具体组件不得重写公共 `check``transform` 或修改执行器。类型声明使用 `final` 标记这些入口;代码评审和测试
同时检查组件只实现规定扩展点。
- 相同 Markdown、组件版本和参数产生相同顺序的候选修改;
- 成功执行一次后再次执行,不产生新的实际改动;
- 不读取文件、网络、环境变量、当前时间或随机数;
- 不修改传入对象或外部状态;
- 不直接生成或改写整篇 Markdown,只提交精确 `TextEdit`
组件必须是确定性的:相同 Markdown、组件版本和参数必须产生相同问题及候选修改。组件不能读取文件、网络、
环境变量、当前时间或随机数,也不能修改传入对象和外部状态。
第一版组件没有公共 `check()``transform()`。单个组件通过只包含它的 `Pipeline` 独立运行:
```python
result = Pipeline([component]).transform(markdown)
```
这样避免 `component.py` 反向依赖 `pipeline.py`,也避免组件和流水线维护两套执行逻辑。
第一版流水线禁止出现两个相同组件标识的实例。需要用不同参数运行同一组件两次时,应由项目重新考虑组件边界,
不能依靠重复 ID 制造含义不清的执行记录。
## 9. 公共修改执行器
流水线不会把多个组件在旧快照上产生的修改集中到最后再应用。每个组件都针对当前快照检查;该组件结束后,
它的自动修改作为一个批次交给公共执行器。
每个组件都针对当前快照提出修改;该组件结束后,它的全部候选修改作为一个批次交给公共执行器。
执行器按以下顺序验证当前组件的整个批次:
1. 问题和编辑的快照哈希都等于当前快照哈希;
2. 所有范围合法
3. `markdown[start:end]``expected_text` 完全一致
4. 不存在重复编辑、范围重叠或同一位置的多个插入
5. 所有编辑都会实际改变内容。
1. 候选修改和编辑的快照哈希都等于当前快照哈希;
2. 每个候选修改理由非空并至少包含一个编辑
3. 所有范围合法
4. `markdown[start:end]``expected_text` 完全一致
5. 不存在重复编辑或下述范围冲突;
6. 所有编辑都会实际改变内容。
相邻但不重叠的范围可以同时修改。验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的
下标失效。任意一项验证失败,当前组件的整个批次都不应用。
范围冲突使用以下保守规则:
这里的“当前组件整个批次”包括该组件本次检查产生的所有 `auto_fix` 候选修改。`suggestion` `detect_only`
问题永远不进入自动修改批次。
- 两个非空范围真正重叠时冲突;相邻的 `[a, b)` `[b, c)` 可以同时修改;
- 两个插入位于同一位置时冲突,不同位置可以同时插入;
- 插入点位于另一个非空范围内部,或等于该范围的起点、终点时,均视为冲突。
最后一条有意比半开区间的数学重叠更严格,避免相同起点的执行顺序和边界插入语义不明确。组件如果确实需要
替换一段文字并在边界追加内容,应合并为一个 `TextEdit.replacement`
验证全部通过后,执行器按位置从后向前应用编辑,避免前面的修改使后面的下标失效。任意一项验证失败,
当前组件的整个批次都不应用。内部应用顺序不决定报告顺序;结果中的实际改动按原文位置从前到后排列。
### 9.1 `Change`
@@ -238,24 +259,40 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照
- 组件标识和版本;
- 组件在流水线中的执行位置;
- 候选修改在本次组件结果中的引用和编辑序号;
- 修改理由;
- 修改前范围、`before``after`
- 修改前后的快照哈希
- 所属候选修改,使一次多位置动作可以整体追踪。
- 修改前后的快照哈希
修改记录描述实际发生的变化,不复制未执行的建议,也不把问题记录冒充改动记录。
同一个 `ProposedChange` 产生的多条 `Change` 使用相同引用,使一次多位置动作可以整体追踪。同一组件批次中的
所有 `Change` 共享该批次修改前后的快照哈希,不制造并不存在的中间公开快照。
## 10. 检查流程
修改记录只描述实际发生的变化,不把未执行或验证失败的候选修改冒充实际改动。
`Pipeline.check(markdown)` 建立一个输入快照。所有组件按照项目给出的顺序检查同一个快照,Markdown 全程不变。
## 10. 模块依赖方向
- 一个组件正常完成后,流水线按原顺序收集问题;
- 一个组件抛出异常或返回违反契约的数据时,流水线记录结构化组件错误,然后继续检查后续组件;
- 检查结果记录输入哈希、实际组件顺序、版本、参数、问题和错误;
- 只要存在组件错误,检查结果就不能表示为完整成功,但已经获得的问题仍然保留。
第一版保持以下单向依赖:
组件异常不能被静默忽略。错误至少记录组件身份、错误阶段、异常类型和安全的错误说明。是否保存 traceback
留在内存实现中决定,不向未来报告格式作承诺。
```text
models.py
▲ ▲
│ │
component.py edits.py
▲ ▲
\ /
pipeline.py
```
- `models.py` 只依赖 Python 标准库;
- `component.py` 只依赖数据模型,不导入流水线或修改执行器;
- `edits.py` 只依赖数据模型,不调用组件或流水线;
- `pipeline.py` 可以依赖组件、修改执行器和数据模型;
- `__init__.py` 只导出批准的公共对象,不实现第二套逻辑。
修改执行器不理解 arXiv、表格、HTML 或其他业务语义,也不决定组件顺序和最终状态。组件不应用编辑,
不刷新快照,也不知道文件、CLI 或未来报告格式。
如果实现中发现这些边界导致机械性的循环依赖,可以拆分数据模型文件,但不能让底层模块反向导入流水线。
## 11. 清洗流程与运行状态
@@ -264,40 +301,57 @@ Python 字符串下标是第一版唯一权威位置。行号和列号由快照
`Pipeline.transform(markdown)` 按以下步骤运行:
1. 建立输入快照;
2. 按顺序让当前组件检查当前快照;
3. 收集该组件的 `auto_fix` 候选修改;
2.项目给出的顺序让当前组件针对当前快照提出修改
3. 验证组件元数据和候选修改契约
4. 原子验证并应用当前组件的整个修改批次;
5. 有修改时建立新快照,下一个组件只能读取新快照;
6. 所有组件各执行一次后,对最终快照运行最终只读复查。
6. 所有组件各执行一次后,对最终快照运行最终稳定性复查。
组件没有提出修改是正常情况,不产生空批次或虚假 `Change`
空组件列表也是合法输入:流水线对原输入建立快照后直接完成空的最终复查,返回零改动的 `success`
清洗阶段如果组件异常、返回无效数据或修改批次冲突,流水线立即停止,不继续运行后面的组件,也不进行最终复查。
之前组件已经完成的内存修改和 `Change` 保留在失败结果中,但不能作为成功输出。
### 11.2 最终只读复查
### 11.2 最终稳定性复查
最终复查让所有已选组件按照原顺序检查最终快照不应用任何修改。
最终复查让所有已选组件按照原顺序针对最终快照重新提出修改,但不应用任何修改。
- 发现仍可由已选组件 `auto_fix` 的问题:状态为 `unstable`
- 只剩 `detect_only``suggestion` 问题:保留为未解决问题,不妨碍核心流水线成为 `success`
- 最终复查发生组件错误:状态为 `failed`;复查继续检查其余组件,以汇总只读阶段的错误和问题。
复查阶段仍然验证组件元数据、候选修改、原文和整个组件批次的冲突;区别只是验证通过后不应用编辑。
每条有效残留修改连同组件标识、版本、执行位置和确定性引用一起记录,使调用方知道由哪个组件提出。
是否因为未解决的 `detect_only``suggestion` 问题阻止下游使用,由使用项目决定,不写死在共用核心中。
- 所有组件都不再提出修改:状态可以是 `success`
- 任一组件仍提出有效修改:状态为 `unstable`,并保留这些 `residual_proposals`
- 复查发生组件错误或返回无效数据:状态为 `failed`;复查继续调用其余组件,以汇总只读阶段的错误。
### 11.3 状态与文本字段
如果最终复查同时出现错误和其他组件的有效残留修改,最终状态以 `failed` 为准,但已经获得的
`residual_proposals` 仍然保留,不能因为另一个组件失败而丢失。
最终复查只回答“选中的自动清洗组件是否已经稳定”,不声称 Markdown 没有截断、幻觉、损坏表格或其他
第一版不处理的问题。
### 11.3 状态与结果字段
清洗状态至少包括:
- `success`:单轮清洗和最终复查完整完成,没有仍可自动修复的问题
- `failed`:组件执行、数据契约或修改验证失败;
- `unstable`:修改阶段没有错误,但最终复查仍发现已选组件可自动修复的问题
- `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. 源码与测试结构
@@ -321,35 +375,41 @@ tests/
```
- `models.py` 只放不可变的数据模型和状态枚举;
- `component.py` 放组件基类和组件契约验证
- `component.py` 放组件基类和组件元数据契约;
- `edits.py` 放纯文本修改验证与应用;
- `pipeline.py`检查、单轮清洗和最终复查编排;
- `pipeline.py` 放单轮清洗和最终稳定性复查编排;
- `__init__.py` 只导出第一版公共对象;
- `py.typed` 声明分发包提供类型信息;
- 测试辅助组件只存在于 `tests/`,不发布成示例清洗能力。
如果实现中发现这些边界导致循环依赖,可以在不改变公共职责的前提下机械拆分模块;新增新的业务层、运行依赖
或公共入口仍需要重新评审。
新增新的业务层、运行依赖或公共入口仍需要重新评审。
## 13. 测试专用组件与验收
第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生固定问题
建议修改、精确替换、组件异常和连锁影响。
第一版不借真实文档验证,也不把测试字符串包装成正式清洗规则。测试中建立最小假组件,分别产生零修改
固定修改、多位置修改、组件异常、无效候选和连锁影响。
至少覆盖:
- 空 Markdown、中文、换行和 Unicode 组合字符;
- 插入、删除、替换以及一次问题包含多个编辑
- 相邻范围可以应用,重叠范围、重复编辑和同点插入明确失败
- 哈希过期、范围越界、`expected_text` 不符和无效修改明确失败;
- 当前组件批次全部成功或全部不应用
- 检查流程记录错误后继续其他组件
- 清洗流程遇错立即停止,并区分成功输出与部分文本
- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,且不自动开始第二轮
- `suggestion``detect_only` 不被自动应用
- 组件独立运行与单组件流水线结果一致
- 空组件列表返回原文不变、零改动的 `success`
- 插入、删除、替换以及一次候选修改包含多个编辑
- 相邻非空范围可以应用,重叠范围和重复编辑明确失败;
- 不同位置插入可以应用,同点插入明确失败
- 插入位于非空范围内部、起点或终点时明确失败
- 哈希过期、范围越界、`expected_text` 不符、空候选、空理由和无效修改明确失败
- 当前组件批次全部成功或全部不应用,包括不同候选修改之间发生冲突
- 组件异常或契约错误使清洗立即停止,并区分成功输出与部分文本
- 后一个组件读取前一个组件修改后的新快照
- 后一个组件制造前一个组件的新问题时,最终复查返回 `unstable`,保留 `residual_proposals`,且不开始第二轮;
- 最终复查发生错误时继续调用其余组件并最终返回 `failed`
- 最终复查同时出现错误和有效残留修改时,两者都保留,状态为 `failed`
- 错误记录能够区分 `transform``final_review` 阶段;
- 同一个候选修改的多条 `Change` 共享引用、理由和批次前后哈希;
- 单个组件通过单组件 `Pipeline` 正常运行;
- 成功流水线再次运行不产生实际改动;
- 输入字符串不被修改,相同输入、组件和参数产生相同结果
- 输入字符串不被修改,相同输入、组件和参数产生相同顺序的结果
- 重复组件标识、无效版本、不可表示的参数和空适用边界明确失败。
批准并实现后,基础验证至少包括:
@@ -361,28 +421,45 @@ pytest
README 届时记录实际可用命令。只有这些命令真实运行成功后,才能报告对应检查通过。
## 14. 风险与代价
## 14. 未来检查能力如何扩展
- **没有公共 AST:** 结构复杂的组件以后可能需要重复解析;先保持解析细节为组件内部实现,等真实规则证明需要
共享分析后再设计。
第一版不为未来检查功能预留空枚举或可空候选修改。以后真实项目证明只读检查有独立消费者时,通过新 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 与之相同。
- **最终复查增加检查成本:** 选中的组件最多检查两次,但换来对组合连锁影响的明确判断,第一版接受此代价。
坐标语义,不能假设 JavaScript UTF-16 或 UTF-8 byte offset 与之相同。
- **最终复查增加检查成本:** 选中的组件最多提出两次修改,但换来对组合连锁影响的明确判断,第一版接受此代价。
- **组件级原子批次可能放弃部分正确修改:** 这是有意的保真选择;组件应修复自己的冲突,而不是让流水线猜测。
- **失败结果仍含部分 Markdown:** 使用独立字段并让成功输出为空,降低误用风险;未来文件适配层不得默认写出
`partial_markdown`
- **结果可能包含原文片段:** 第一版结果只存在内存,不建设日志和持久化;以后新增 reporter 时必须单独评审
脱敏和保存边界。
- **组件版本需要维护:** 组件语义、定位或修改行为变化时必须更新版本,不能只改代码而保留相同审计身份。
- **组件版本需要维护:** 组件语义、适用边界、定位或修改行为变化时必须更新版本,不能只改代码而保留相同身份。
## 15. 批准后的实施边界
## 16. 批准后的实施边界
批准本设计只授权:
1. 创建第 12 节列出的工程与测试文件;
2. 实现第 5 至 11 节描述的内存核心;
2. 实现第 5 至 11 节描述的内存自动清洗核心;
3. 创建测试专用假组件并完成第 13 节验证;
4. 根据真实实现更新 README 当前阶段和基础检查;
5. 实现完成后新增 `research-wiki/explanation/` 文档,解释当前实际架构。
批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、CLI、发布、提交或推送。
批准本设计不授权实现真实清洗规则,不授权读取真实数据,不授权文件覆盖、独立检查能力、CLI、发布、提交或推送。
View File
@@ -0,0 +1,149 @@
# 第一版内存清洗核心如何工作
## 1. 它解决什么问题
清洗组件如果直接返回一整篇新 Markdown,调用方只能看到修改后的结果,很难确认它实际改了哪里。组件保存的
旧位置还可能在文本变化后误中另一段内容;同一批修改发生重叠时,按不同顺序执行也可能得到不同结果。
当前核心把“判断应该改什么”和“安全地执行修改”分开:组件只描述绑定当前文本的精确修改,公共执行器统一
验证并应用。这样可以在不引入真实清洗规则、文件读写或 Markdown parser 的情况下,先让组合与审计协议可运行。
已经实现的范围来自已批准的
[`0003-first-executable-core-architecture.md`](../design/0003-first-executable-core-architecture.md)。精确类名、字段和
函数签名以 [`src/mdpolish/`](../../src/mdpolish/) 中的代码和测试为准,本文不维护第二份 API 清单。
## 2. 当前数据流
```text
输入 Markdown 字符串
带内容哈希的当前快照
组件提出精确修改 ──► 整批验证 ──► 整批应用 ──► 新快照
│ │
└──────── 按组件顺序重复 ◄──────────────┘
最终只重新提议,不再应用
┌────────────────┼────────────────┐
▼ ▼ ▼
success unstable failed
```
核心只有四层:
| 层次 | 当前职责 | 明确不负责 |
| --- | --- | --- |
| 数据模型 | 保存快照、范围、候选修改、实际改动、错误和结果 | 业务规则和文件路径 |
| 组件基类 | 声明身份、版本、参数、适用边界并提出修改 | 应用修改和组织流水线 |
| 修改执行器 | 统一验证并原子应用一个组件批次 | 判断 Markdown 业务语义 |
| 流水线 | 排列组件、刷新快照、处理失败并做最终复查 | 读取文件、选择项目 profile |
依赖保持单向:组件基类和修改执行器只依赖数据模型,流水线可以调用前三者,底层模块不反向调用流水线。
## 3. 为什么修改必须绑定快照
每个 Markdown 快照都带有根据完整字符串计算的 SHA-256。候选修改和其中每条文本编辑都必须指向这个哈希,
还要同时提供原文范围和该范围预期出现的文字。
执行时会再次检查:
1. 哈希仍然对应当前快照;
2. 范围没有越过字符串边界;
3. 当前位置的文字与组件声明的预期原文完全一致;
4. 替换后确实会改变内容。
任何一项不满足,当前组件的整个批次都不会执行。这使位置只在其产生时的快照内有效,不允许把旧候选修改悄悄
套到后来变化的 Markdown 上。
范围使用 Python 字符串下标,而不是 UTF-8 字节位置。核心保留输入的换行、Unicode 形式和末尾换行,不做隐式
规范化。
## 4. 组件为什么只提出自动修改
第一版组件只返回能够立即、唯一执行的候选修改。遇到不知道正确修法的截断、损坏表格、疑似幻觉或无法读取的
图片时,组件应忽略,不猜测修复,也不额外生成“仅检查”结果。
每个组件必须提供稳定标识、`MAJOR.MINOR.PATCH` 版本、可冻结的参数和非空适用边界。适用边界需要由组件作者说明
它处理什么结构、依赖哪些严格前置条件、明确排除什么。组件还必须满足确定、无副作用和幂等约束;不能读取文件、
网络、环境变量、当前时间或随机数。
最终复查能发现多个组件组合后仍会继续提出修改,但不能从有限输入证明一个组件对所有文本都幂等。因此,幂等性
既由最终复查保护当前运行,也必须由组件自己的针对性测试证明其适用范围内的行为。
独立检查和人工建议目前没有实现。以后只有出现明确消费者和闭环时,才通过新 design 增加平行接口,不在当前
组件结果中补可空字段或状态枚举。
## 5. 一个组件批次如何保证原子性
一个组件可以提出多个候选修改,每个候选修改又可以包含多个文本编辑。执行器先验证该组件本次提出的全部编辑,
只有整批通过才从后向前应用;任意一条失败,整批保持原样。这里的原子边界是“当前组件本次执行的全部修改”,
不是单独一条编辑。
当前冲突规则有意保守:
| 两项编辑的关系 | 结果 |
| --- | --- |
| 两个非空范围真正重叠 | 冲突 |
| 两个非空范围只相邻 | 允许 |
| 两次插入位于同一点 | 冲突 |
| 两次插入位于不同点 | 允许 |
| 插入点位于非空范围内部、起点或终点 | 冲突 |
如果业务动作需要替换一段文字并在边界追加内容,组件应把它表达成同一条替换,而不是依赖编辑执行顺序。
## 6. 流水线状态代表什么
流水线先对所有组件做元数据预检,避免运行到一半才发现重复标识或无效版本。之后每个组件只执行一次,后一个组件
只能读取前一个组件产生的新快照。清洗阶段出现异常、契约错误或编辑验证错误时会立即停止,且不再进行最终复查。
所有组件完成后,流水线让它们针对最终快照重新提出一次修改,但这一阶段只验证、不应用:
| 状态 | 含义 | Markdown 字段 |
| --- | --- | --- |
| `success` | 清洗和最终复查均完成,所选组件不再提出修改 | 只提供成功输出 |
| `unstable` | 清洗无错误,但最终复查仍有有效候选修改 | 只提供诊断用部分文本和残留候选 |
| `failed` | 清洗或最终复查发生错误 | 只提供诊断用部分文本和错误 |
最终复查是只读阶段,因此某个组件失败后仍会继续复查其余组件。错误与其他组件的有效残留修改可以同时保留,最终
状态以 `failed` 为准。流水线不会因为 `unstable` 自动开始第二轮。
`success` 只表示本次选中的自动清洗组件已经稳定,不表示文档没有截断、幻觉、表格损坏、图片断链或其他未实现
规则能够发现的问题。
## 7. 审计记录能回答什么
每条实际执行的文本编辑都会生成一条改动记录,说明:
- 是哪个组件、哪个版本和流水线位置执行的;
- 属于哪个候选修改,以及在该候选修改中的编辑序号;
- 组件给出的修改理由;
- 修改前范围、原文和替换内容;
- 当前组件批次修改前后的快照哈希。
同一候选修改中的多条记录共享候选引用,同一组件批次中的所有记录共享批次前后哈希。记录只描述已经发生的修改;
验证失败或最终复查中没有执行的候选不会冒充实际改动。
这些内容当前只存在于内存返回值中。仓库没有 reporter、审计文件格式或日志持久化,调用方也不能默认把失败结果中
的部分文本写回原文件。
## 8. 当前验证和剩余边界
核心测试使用短小的假组件,不包含或复制真实文档。测试已经覆盖空文本、中文和组合 Unicode、插入/删除/替换、
范围冲突、过期哈希、批次原子性、组件连锁影响、错误阶段、审计关联和成功结果再次运行等行为。
实际可用的安装与验收命令、最近一次验证日期和结果只在根目录
[`README.md`](../../README.md#当前可用检查) 维护。
当前仍然没有:
- 论文、GovDoc、HTML 表格或其他真实清洗组件;
- 独立文档检查、人工建议或审核流程;
- Markdown parser、AST 或共享业务中间表示;
- 文件读写、CLI、批处理、项目 profile 格式和生产集成;
- 审计结果的长期存储或脱敏输出协议。
这些边界中的任何一项要进入实现,都需要先用新的 design 明确语义、代价和验收方式。
+44
View File
@@ -0,0 +1,44 @@
"""Approved public API for the first mdpolish in-memory core."""
from mdpolish.component import Component, ComponentContractError
from mdpolish.edits import AppliedBatch, EditValidationError, apply_component_batch, validate_component_batch
from mdpolish.models import (
Change,
ComponentInfo,
DocumentSnapshot,
ErrorStage,
ProposalReference,
ProposedChange,
ResidualProposal,
RunError,
RunStatus,
TextEdit,
TextSpan,
TransformResult,
markdown_sha256,
)
from mdpolish.pipeline import Pipeline, PipelineContractError
__all__ = [
"AppliedBatch",
"Change",
"Component",
"ComponentContractError",
"ComponentInfo",
"DocumentSnapshot",
"EditValidationError",
"ErrorStage",
"Pipeline",
"PipelineContractError",
"ProposalReference",
"ProposedChange",
"ResidualProposal",
"RunError",
"RunStatus",
"TextEdit",
"TextSpan",
"TransformResult",
"apply_component_batch",
"markdown_sha256",
"validate_component_batch",
]
+105
View File
@@ -0,0 +1,105 @@
"""Component extension contract for the mdpolish core."""
from __future__ import annotations
import math
import re
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import cast, final
from mdpolish.models import ComponentInfo, DocumentSnapshot, Parameters, ParameterValue, ProposedChange
_COMPONENT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
_SEMVER_PATTERN = re.compile(r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$")
class ComponentContractError(ValueError):
"""A component does not satisfy the approved extension contract."""
def _freeze_parameter(value: object) -> ParameterValue:
if value is None or isinstance(value, (str, bool)) or type(value) is int:
return value
if isinstance(value, float):
if not math.isfinite(value):
raise ComponentContractError("component parameters cannot contain non-finite floats")
return value
if isinstance(value, Mapping):
pairs: list[tuple[str, ParameterValue]] = []
for key, nested_value in value.items():
if not isinstance(key, str) or not key:
raise ComponentContractError("component parameter mapping keys must be non-empty strings")
pairs.append((key, _freeze_parameter(nested_value)))
return tuple(sorted(pairs, key=lambda pair: pair[0]))
if isinstance(value, (list, tuple)):
return tuple(_freeze_parameter(item) for item in value)
raise ComponentContractError("component parameters must contain only deterministic basic data")
def _freeze_parameters(parameters: object) -> Parameters:
if not isinstance(parameters, Mapping):
raise ComponentContractError("component parameters must be a mapping")
frozen = _freeze_parameter(parameters)
if not isinstance(frozen, tuple):
raise AssertionError("a mapping must normalize to a tuple")
return cast(Parameters, frozen)
class Component(ABC):
"""Base class for deterministic, side-effect-free cleaning components."""
@property
@abstractmethod
def component_id(self) -> str:
"""Return the stable lowercase identity of this component."""
@property
@abstractmethod
def version(self) -> str:
"""Return this component's MAJOR.MINOR.PATCH version."""
@property
@abstractmethod
def parameters(self) -> Mapping[str, object]:
"""Return the current deterministic parameter values."""
@property
@abstractmethod
def applicability(self) -> str:
"""Describe handled structures, strict preconditions, and exclusions."""
@abstractmethod
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
"""Return exact, automatically applicable changes for this snapshot."""
@final
def _component_info(self) -> ComponentInfo:
component_id = self.component_id
version = self.version
applicability = self.applicability
if not isinstance(component_id, str) or _COMPONENT_ID_PATTERN.fullmatch(component_id) is None:
raise ComponentContractError("component_id must be a stable lowercase identifier")
if not isinstance(version, str) or _SEMVER_PATTERN.fullmatch(version) is None:
raise ComponentContractError("component version must use MAJOR.MINOR.PATCH")
if not isinstance(applicability, str) or not applicability.strip():
raise ComponentContractError("component applicability must be a non-empty string")
return ComponentInfo(
component_id=component_id,
version=version,
parameters=_freeze_parameters(self.parameters),
applicability=applicability,
)
@final
def _collect_proposals(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if not isinstance(snapshot, DocumentSnapshot):
raise ComponentContractError("components require a DocumentSnapshot")
proposals = self._propose_changes(snapshot)
if not isinstance(proposals, tuple):
raise ComponentContractError("_propose_changes must return a tuple")
if any(not isinstance(proposal, ProposedChange) for proposal in proposals):
raise ComponentContractError("_propose_changes must return only ProposedChange values")
return proposals
+150
View File
@@ -0,0 +1,150 @@
"""Pure validation and atomic application of exact text edits."""
from __future__ import annotations
from dataclasses import dataclass
from mdpolish.models import (
Change,
ComponentInfo,
DocumentSnapshot,
ProposalReference,
ProposedChange,
TextEdit,
)
class EditValidationError(ValueError):
"""A component edit batch cannot be applied safely."""
@dataclass(frozen=True, slots=True)
class AppliedBatch:
"""The new snapshot and audit entries from one atomic component batch."""
snapshot: DocumentSnapshot
changes: tuple[Change, ...]
@dataclass(frozen=True, slots=True)
class _IndexedEdit:
proposal_index: int
edit_index: int
reason: str
edit: TextEdit
def _edits_conflict(left: TextEdit, right: TextEdit) -> bool:
left_span = left.span
right_span = right.span
if left_span.is_empty and right_span.is_empty:
return left_span.start == right_span.start
if left_span.is_empty:
return right_span.start <= left_span.start <= right_span.end
if right_span.is_empty:
return left_span.start <= right_span.start <= left_span.end
return max(left_span.start, right_span.start) < min(left_span.end, right_span.end)
def validate_component_batch(
snapshot: DocumentSnapshot,
proposals: tuple[ProposedChange, ...],
) -> tuple[_IndexedEdit, ...]:
"""Validate a complete component batch without changing the snapshot."""
if not isinstance(snapshot, DocumentSnapshot):
raise TypeError("snapshot must be a DocumentSnapshot")
if not isinstance(proposals, tuple):
raise EditValidationError("component proposals must be a tuple")
indexed_edits: list[_IndexedEdit] = []
seen_edits: set[TextEdit] = set()
for proposal_index, proposal in enumerate(proposals):
if not isinstance(proposal, ProposedChange):
raise EditValidationError("a component batch must contain only ProposedChange values")
if proposal.snapshot_sha256 != snapshot.sha256:
raise EditValidationError("a proposal targets a stale document snapshot")
for edit_index, edit in enumerate(proposal.edits):
if edit.snapshot_sha256 != snapshot.sha256:
raise EditValidationError("an edit targets a stale document snapshot")
if edit.span.end > len(snapshot.markdown):
raise EditValidationError("an edit span is outside the document snapshot")
if snapshot.markdown[edit.span.start : edit.span.end] != edit.expected_text:
raise EditValidationError("an edit's expected_text does not match the document snapshot")
if edit in seen_edits:
raise EditValidationError("a component batch contains a duplicate edit")
seen_edits.add(edit)
indexed_edits.append(
_IndexedEdit(
proposal_index=proposal_index,
edit_index=edit_index,
reason=proposal.reason,
edit=edit,
)
)
for left_index, left in enumerate(indexed_edits):
for right in indexed_edits[left_index + 1 :]:
if _edits_conflict(left.edit, right.edit):
raise EditValidationError("a component batch contains conflicting edit ranges")
return tuple(indexed_edits)
def apply_component_batch(
snapshot: DocumentSnapshot,
proposals: tuple[ProposedChange, ...],
component: ComponentInfo,
component_position: int,
) -> AppliedBatch:
"""Atomically apply one fully validated component batch."""
indexed_edits = validate_component_batch(snapshot, proposals)
if not indexed_edits:
return AppliedBatch(snapshot=snapshot, changes=())
markdown = snapshot.markdown
application_order = sorted(
indexed_edits,
key=lambda item: (
item.edit.span.start,
item.edit.span.end,
item.proposal_index,
item.edit_index,
),
reverse=True,
)
for item in application_order:
edit = item.edit
markdown = markdown[: edit.span.start] + edit.replacement + markdown[edit.span.end :]
updated_snapshot = DocumentSnapshot(markdown)
report_order = sorted(
indexed_edits,
key=lambda item: (
item.edit.span.start,
item.edit.span.end,
item.proposal_index,
item.edit_index,
),
)
changes = tuple(
Change(
component_id=component.component_id,
component_version=component.version,
component_position=component_position,
proposal_ref=ProposalReference(
component_position=component_position,
snapshot_sha256=snapshot.sha256,
proposal_index=item.proposal_index,
),
edit_index=item.edit_index,
reason=item.reason,
span=item.edit.span,
before=item.edit.expected_text,
after=item.edit.replacement,
before_sha256=snapshot.sha256,
after_sha256=updated_snapshot.sha256,
)
for item in report_order
)
return AppliedBatch(snapshot=updated_snapshot, changes=changes)
+231
View File
@@ -0,0 +1,231 @@
"""Immutable values shared by the mdpolish core."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from hashlib import sha256
from string import hexdigits
from typing import TypeAlias
ParameterValue: TypeAlias = (
str
| int
| float
| bool
| tuple["ParameterValue", ...]
| tuple[tuple[str, "ParameterValue"], ...]
| None
)
Parameters: TypeAlias = tuple[tuple[str, ParameterValue], ...]
def markdown_sha256(markdown: str) -> str:
"""Return the authoritative digest for a Markdown string."""
if not isinstance(markdown, str):
raise TypeError("markdown must be a string")
return sha256(markdown.encode("utf-8")).hexdigest()
def _require_sha256(value: str, field_name: str) -> None:
if (
not isinstance(value, str)
or len(value) != 64
or any(character not in hexdigits for character in value)
or value.lower() != value
):
raise ValueError(f"{field_name} must be a lowercase SHA-256 digest")
def _require_nonempty(value: str, field_name: str) -> None:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field_name} must be a non-empty string")
@dataclass(frozen=True, slots=True)
class DocumentSnapshot:
"""An exact Markdown string and its library-computed digest."""
markdown: str
sha256: str = field(init=False)
def __post_init__(self) -> None:
object.__setattr__(self, "sha256", markdown_sha256(self.markdown))
@dataclass(frozen=True, slots=True, order=True)
class TextSpan:
"""A half-open range using Python string indexes."""
start: int
end: int
def __post_init__(self) -> None:
if type(self.start) is not int or type(self.end) is not int:
raise TypeError("span indexes must be integers")
if self.start < 0 or self.end < self.start:
raise ValueError("span must satisfy 0 <= start <= end")
@property
def is_empty(self) -> bool:
return self.start == self.end
@dataclass(frozen=True, slots=True)
class TextEdit:
"""An exact replacement bound to one document snapshot."""
snapshot_sha256: str
span: TextSpan
expected_text: str
replacement: str
def __post_init__(self) -> None:
_require_sha256(self.snapshot_sha256, "snapshot_sha256")
if not isinstance(self.span, TextSpan):
raise TypeError("span must be a TextSpan")
if not isinstance(self.expected_text, str) or not isinstance(self.replacement, str):
raise TypeError("expected_text and replacement must be strings")
if len(self.expected_text) != self.span.end - self.span.start:
raise ValueError("expected_text length must equal the span length")
if self.expected_text == self.replacement:
raise ValueError("a text edit must change the content")
@dataclass(frozen=True, slots=True)
class ProposedChange:
"""One atomic, automatically applicable action proposed by a component."""
snapshot_sha256: str
reason: str
edits: tuple[TextEdit, ...]
def __post_init__(self) -> None:
_require_sha256(self.snapshot_sha256, "snapshot_sha256")
_require_nonempty(self.reason, "reason")
if not isinstance(self.edits, tuple) or not self.edits:
raise ValueError("edits must be a non-empty tuple")
for edit in self.edits:
if not isinstance(edit, TextEdit):
raise TypeError("edits must contain only TextEdit values")
if edit.snapshot_sha256 != self.snapshot_sha256:
raise ValueError("an edit digest must match its proposal digest")
@dataclass(frozen=True, slots=True)
class ComponentInfo:
"""Validated, immutable component metadata recorded in a run."""
component_id: str
version: str
parameters: Parameters
applicability: str
@dataclass(frozen=True, slots=True)
class ProposalReference:
"""A deterministic reference scoped to one transform result."""
component_position: int
snapshot_sha256: str
proposal_index: int
def __post_init__(self) -> None:
if type(self.component_position) is not int or self.component_position < 0:
raise ValueError("component_position must be a non-negative integer")
if type(self.proposal_index) is not int or self.proposal_index < 0:
raise ValueError("proposal_index must be a non-negative integer")
_require_sha256(self.snapshot_sha256, "snapshot_sha256")
@dataclass(frozen=True, slots=True)
class Change:
"""One text edit that was actually applied."""
component_id: str
component_version: str
component_position: int
proposal_ref: ProposalReference
edit_index: int
reason: str
span: TextSpan
before: str
after: str
before_sha256: str
after_sha256: str
@dataclass(frozen=True, slots=True)
class ResidualProposal:
"""A valid proposal found by the final read-only stability review."""
component_id: str
component_version: str
component_position: int
proposal_ref: ProposalReference
proposal: ProposedChange
class RunStatus(StrEnum):
"""The terminal status of a pipeline transform."""
SUCCESS = "success"
FAILED = "failed"
UNSTABLE = "unstable"
class ErrorStage(StrEnum):
"""The pipeline phase in which an error occurred."""
TRANSFORM = "transform"
FINAL_REVIEW = "final_review"
@dataclass(frozen=True, slots=True)
class RunError:
"""A source-safe component, contract, or edit error."""
component_id: str
component_version: str
component_position: int
stage: ErrorStage
error_type: str
message: str
@dataclass(frozen=True, slots=True)
class TransformResult:
"""The immutable result of one transform and its final review."""
status: RunStatus
input_sha256: str
current_sha256: str
components: tuple[ComponentInfo, ...] = ()
changes: tuple[Change, ...] = ()
errors: tuple[RunError, ...] = ()
residual_proposals: tuple[ResidualProposal, ...] = ()
output_markdown: str | None = None
partial_markdown: str | None = None
def __post_init__(self) -> None:
_require_sha256(self.input_sha256, "input_sha256")
_require_sha256(self.current_sha256, "current_sha256")
if not isinstance(self.status, RunStatus):
raise TypeError("status must be a RunStatus")
for field_name in ("components", "changes", "errors", "residual_proposals"):
if not isinstance(getattr(self, field_name), tuple):
raise TypeError(f"{field_name} must be a tuple")
current_markdown = self.output_markdown if self.status is RunStatus.SUCCESS else self.partial_markdown
if current_markdown is None or markdown_sha256(current_markdown) != self.current_sha256:
raise ValueError("the current Markdown must match current_sha256")
if self.status is RunStatus.SUCCESS:
if self.partial_markdown is not None or self.errors or self.residual_proposals:
raise ValueError("a successful result cannot contain partial output, errors, or residual proposals")
elif self.status is RunStatus.FAILED:
if self.output_markdown is not None or not self.errors:
raise ValueError("a failed result requires errors and cannot contain successful output")
elif self.status is RunStatus.UNSTABLE:
if self.output_markdown is not None or self.errors or not self.residual_proposals:
raise ValueError("an unstable result requires residual proposals and cannot contain output or errors")
+303
View File
@@ -0,0 +1,303 @@
"""Sequential orchestration and final stability review."""
from __future__ import annotations
from collections.abc import Iterable
from mdpolish.component import Component, ComponentContractError
from mdpolish.edits import apply_component_batch, validate_component_batch
from mdpolish.models import (
Change,
ComponentInfo,
DocumentSnapshot,
ErrorStage,
ProposalReference,
ProposedChange,
ResidualProposal,
RunError,
RunStatus,
TransformResult,
)
class PipelineContractError(ValueError):
"""A pipeline composition does not satisfy the approved contract."""
class Pipeline:
"""Run selected components once, then review the final snapshot for stability."""
def __init__(self, components: Iterable[Component]) -> None:
self._components = tuple(components)
@property
def components(self) -> tuple[Component, ...]:
return self._components
def transform(self, markdown: str) -> TransformResult:
input_snapshot = DocumentSnapshot(markdown)
component_infos, preflight_error = self._preflight_components()
if preflight_error is not None:
return self._failed_result(
input_snapshot=input_snapshot,
current_snapshot=input_snapshot,
component_infos=component_infos,
changes=(),
errors=(preflight_error,),
)
current_snapshot = input_snapshot
changes: list[Change] = []
for position, (component, expected_info) in enumerate(zip(self._components, component_infos, strict=True)):
current_info, metadata_error = self._current_component_info(
component=component,
expected_info=expected_info,
position=position,
stage=ErrorStage.TRANSFORM,
)
if metadata_error is not None:
return self._failed_result(
input_snapshot=input_snapshot,
current_snapshot=current_snapshot,
component_infos=component_infos,
changes=tuple(changes),
errors=(metadata_error,),
)
proposals, proposal_error = self._proposals(
component=component,
component_info=current_info,
position=position,
stage=ErrorStage.TRANSFORM,
snapshot=current_snapshot,
)
if proposal_error is not None:
return self._failed_result(
input_snapshot=input_snapshot,
current_snapshot=current_snapshot,
component_infos=component_infos,
changes=tuple(changes),
errors=(proposal_error,),
)
try:
applied_batch = apply_component_batch(
snapshot=current_snapshot,
proposals=proposals,
component=current_info,
component_position=position,
)
except Exception as error:
return self._failed_result(
input_snapshot=input_snapshot,
current_snapshot=current_snapshot,
component_infos=component_infos,
changes=tuple(changes),
errors=(
self._run_error(
component_info=current_info,
position=position,
stage=ErrorStage.TRANSFORM,
error=error,
unexpected_message="component edit batch does not satisfy the edit contract",
),
),
)
current_snapshot = applied_batch.snapshot
changes.extend(applied_batch.changes)
review_errors: list[RunError] = []
residual_proposals: list[ResidualProposal] = []
for position, (component, expected_info) in enumerate(zip(self._components, component_infos, strict=True)):
current_info, metadata_error = self._current_component_info(
component=component,
expected_info=expected_info,
position=position,
stage=ErrorStage.FINAL_REVIEW,
)
if metadata_error is not None:
review_errors.append(metadata_error)
continue
proposals, proposal_error = self._proposals(
component=component,
component_info=current_info,
position=position,
stage=ErrorStage.FINAL_REVIEW,
snapshot=current_snapshot,
)
if proposal_error is not None:
review_errors.append(proposal_error)
continue
try:
validate_component_batch(current_snapshot, proposals)
except Exception as error:
review_errors.append(
self._run_error(
component_info=current_info,
position=position,
stage=ErrorStage.FINAL_REVIEW,
error=error,
unexpected_message="component edit batch does not satisfy the edit contract",
)
)
continue
residual_proposals.extend(
ResidualProposal(
component_id=current_info.component_id,
component_version=current_info.version,
component_position=position,
proposal_ref=ProposalReference(
component_position=position,
snapshot_sha256=current_snapshot.sha256,
proposal_index=proposal_index,
),
proposal=proposal,
)
for proposal_index, proposal in enumerate(proposals)
)
if review_errors:
return self._failed_result(
input_snapshot=input_snapshot,
current_snapshot=current_snapshot,
component_infos=component_infos,
changes=tuple(changes),
errors=tuple(review_errors),
residual_proposals=tuple(residual_proposals),
)
if residual_proposals:
return TransformResult(
status=RunStatus.UNSTABLE,
input_sha256=input_snapshot.sha256,
current_sha256=current_snapshot.sha256,
components=component_infos,
changes=tuple(changes),
residual_proposals=tuple(residual_proposals),
partial_markdown=current_snapshot.markdown,
)
return TransformResult(
status=RunStatus.SUCCESS,
input_sha256=input_snapshot.sha256,
current_sha256=current_snapshot.sha256,
components=component_infos,
changes=tuple(changes),
output_markdown=current_snapshot.markdown,
)
def _preflight_components(self) -> tuple[tuple[ComponentInfo, ...], RunError | None]:
component_infos: list[ComponentInfo] = []
seen_ids: set[str] = set()
for position, component in enumerate(self._components):
if not isinstance(component, Component):
error = ComponentContractError("pipeline entries must be Component instances")
return tuple(component_infos), self._run_error(
component_info=None,
position=position,
stage=ErrorStage.TRANSFORM,
error=error,
unexpected_message="a pipeline entry is not a Component instance",
)
try:
component_info = component._component_info()
except Exception as error:
return tuple(component_infos), self._run_error(
component_info=None,
position=position,
stage=ErrorStage.TRANSFORM,
error=error,
unexpected_message="component metadata does not satisfy the component contract",
)
component_infos.append(component_info)
if component_info.component_id in seen_ids:
duplicate_error = PipelineContractError("pipeline component_id values must be unique")
return tuple(component_infos), self._run_error(
component_info=component_info,
position=position,
stage=ErrorStage.TRANSFORM,
error=duplicate_error,
unexpected_message="pipeline component_id values must be unique",
)
seen_ids.add(component_info.component_id)
return tuple(component_infos), None
def _current_component_info(
self,
component: Component,
expected_info: ComponentInfo,
position: int,
stage: ErrorStage,
) -> tuple[ComponentInfo, RunError | None]:
try:
current_info = component._component_info()
if current_info != expected_info:
raise ComponentContractError("component metadata changed during pipeline execution")
except Exception as error:
return expected_info, self._run_error(
component_info=expected_info,
position=position,
stage=stage,
error=error,
unexpected_message="component metadata does not satisfy the component contract",
)
return current_info, None
def _proposals(
self,
component: Component,
component_info: ComponentInfo,
position: int,
stage: ErrorStage,
snapshot: DocumentSnapshot,
) -> tuple[tuple[ProposedChange, ...], RunError | None]:
try:
proposals = component._collect_proposals(snapshot)
except Exception as error:
return (), self._run_error(
component_info=component_info,
position=position,
stage=stage,
error=error,
unexpected_message="component could not produce contract-valid proposals",
)
return proposals, None
@staticmethod
def _run_error(
component_info: ComponentInfo | None,
position: int,
stage: ErrorStage,
error: Exception,
unexpected_message: str,
) -> RunError:
return RunError(
component_id=component_info.component_id if component_info is not None else "<invalid>",
component_version=component_info.version if component_info is not None else "<invalid>",
component_position=position,
stage=stage,
error_type=type(error).__name__,
message=unexpected_message,
)
@staticmethod
def _failed_result(
input_snapshot: DocumentSnapshot,
current_snapshot: DocumentSnapshot,
component_infos: tuple[ComponentInfo, ...],
changes: tuple[Change, ...],
errors: tuple[RunError, ...],
residual_proposals: tuple[ResidualProposal, ...] = (),
) -> TransformResult:
return TransformResult(
status=RunStatus.FAILED,
input_sha256=input_snapshot.sha256,
current_sha256=current_snapshot.sha256,
components=component_infos,
changes=changes,
errors=errors,
residual_proposals=residual_proposals,
partial_markdown=current_snapshot.markdown,
)
+1
View File
@@ -0,0 +1 @@
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import cast
import pytest
from mdpolish import Component, ComponentContractError, DocumentSnapshot, ProposedChange, TextEdit, TextSpan
class ExampleComponent(Component):
def __init__(
self,
*,
component_id: object = "test.example",
version: object = "1.2.3",
parameters: object = None,
applicability: object = "处理测试标记,要求精确匹配,排除所有其他内容。",
) -> None:
self._component_id = component_id
self._version = version
self._parameters = {} if parameters is None else parameters
self._applicability = applicability
@property
def component_id(self) -> str:
return cast(str, self._component_id)
@property
def version(self) -> str:
return cast(str, self._version)
@property
def parameters(self) -> Mapping[str, object]:
return cast(Mapping[str, object], self._parameters)
@property
def applicability(self) -> str:
return cast(str, self._applicability)
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if not snapshot.markdown:
return ()
edit = TextEdit(snapshot.sha256, TextSpan(0, 1), snapshot.markdown[0], "X")
return (ProposedChange(snapshot.sha256, "replace first character", (edit,)),)
class ListReturningComponent(ExampleComponent):
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
return cast(tuple[ProposedChange, ...], [])
class WrongValueComponent(ExampleComponent):
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
return cast(tuple[ProposedChange, ...], ("wrong",))
def test_component_metadata_is_validated_and_parameters_are_frozen_deterministically() -> None:
component = ExampleComponent(
parameters={
"z": [1, {"b": False, "a": None}],
"a": "value",
}
)
info = component._component_info()
assert info.component_id == "test.example"
assert info.version == "1.2.3"
assert info.parameters == (
("a", "value"),
("z", (1, (("a", None), ("b", False)))),
)
@pytest.mark.parametrize("component_id", ["", "Uppercase", "has space", "two..dots", "_leading"])
def test_invalid_component_id_is_a_contract_error(component_id: str) -> None:
with pytest.raises(ComponentContractError, match="component_id"):
ExampleComponent(component_id=component_id)._component_info()
@pytest.mark.parametrize("version", ["1", "1.2", "v1.2.3", "01.2.3", "1.2.3-alpha"])
def test_invalid_version_is_a_contract_error(version: str) -> None:
with pytest.raises(ComponentContractError, match=r"MAJOR.MINOR.PATCH"):
ExampleComponent(version=version)._component_info()
def test_empty_applicability_is_a_contract_error() -> None:
with pytest.raises(ComponentContractError, match="applicability"):
ExampleComponent(applicability=" \n")._component_info()
@pytest.mark.parametrize(
"parameters",
[
{"bad": {1, 2}},
{"bad": float("inf")},
{"bad": float("nan")},
{1: "non-string key"},
["not", "a", "mapping"],
],
)
def test_unrepresentable_parameters_are_contract_errors(parameters: object) -> None:
with pytest.raises(ComponentContractError, match="parameter"):
ExampleComponent(parameters=parameters)._component_info()
def test_collect_proposals_requires_a_tuple_of_proposed_changes() -> None:
snapshot = DocumentSnapshot("abc")
with pytest.raises(ComponentContractError, match="return a tuple"):
ListReturningComponent()._collect_proposals(snapshot)
with pytest.raises(ComponentContractError, match="only ProposedChange"):
WrongValueComponent()._collect_proposals(snapshot)
def test_component_exposes_no_public_check_or_transform_shortcut() -> None:
component = ExampleComponent()
assert not hasattr(component, "check")
assert not hasattr(component, "transform")
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import pytest
from mdpolish import (
ComponentInfo,
DocumentSnapshot,
EditValidationError,
ProposedChange,
TextEdit,
TextSpan,
apply_component_batch,
)
COMPONENT = ComponentInfo(
component_id="test.component",
version="1.2.3",
parameters=(),
applicability="测试精确文本编辑,只处理测试字符串,排除其他输入。",
)
def make_edit(snapshot: DocumentSnapshot, start: int, end: int, replacement: str) -> TextEdit:
return TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(start, end),
expected_text=snapshot.markdown[start:end],
replacement=replacement,
)
def make_proposal(snapshot: DocumentSnapshot, *edits: TextEdit, reason: str = "test reason") -> ProposedChange:
return ProposedChange(snapshot_sha256=snapshot.sha256, reason=reason, edits=edits)
def test_applies_insert_delete_and_replace() -> None:
insert_snapshot = DocumentSnapshot("ab")
delete_snapshot = DocumentSnapshot("abc")
replace_snapshot = DocumentSnapshot("abc")
inserted = apply_component_batch(
insert_snapshot,
(make_proposal(insert_snapshot, make_edit(insert_snapshot, 1, 1, "X")),),
COMPONENT,
0,
)
deleted = apply_component_batch(
delete_snapshot,
(make_proposal(delete_snapshot, make_edit(delete_snapshot, 1, 2, "")),),
COMPONENT,
0,
)
replaced = apply_component_batch(
replace_snapshot,
(make_proposal(replace_snapshot, make_edit(replace_snapshot, 1, 2, "X")),),
COMPONENT,
0,
)
assert inserted.snapshot.markdown == "aXb"
assert deleted.snapshot.markdown == "ac"
assert replaced.snapshot.markdown == "aXc"
def test_multiple_edits_apply_backwards_but_report_in_source_order() -> None:
snapshot = DocumentSnapshot("abcdef")
proposal = make_proposal(
snapshot,
make_edit(snapshot, 4, 6, "F"),
make_edit(snapshot, 0, 1, "A"),
reason="normalize two locations",
)
applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 3)
assert applied.snapshot.markdown == "AbcdF"
assert [change.span.start for change in applied.changes] == [0, 4]
assert {change.before_sha256 for change in applied.changes} == {snapshot.sha256}
assert {change.after_sha256 for change in applied.changes} == {applied.snapshot.sha256}
assert {change.proposal_ref for change in applied.changes} == {
applied.changes[0].proposal_ref,
}
assert {change.reason for change in applied.changes} == {"normalize two locations"}
assert [change.edit_index for change in applied.changes] == [1, 0]
assert all(change.component_position == 3 for change in applied.changes)
def test_adjacent_nonempty_ranges_are_allowed() -> None:
snapshot = DocumentSnapshot("abcd")
proposal = make_proposal(
snapshot,
make_edit(snapshot, 0, 2, "A"),
make_edit(snapshot, 2, 4, "D"),
)
applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
assert applied.snapshot.markdown == "AD"
def test_overlapping_ranges_fail_without_changing_snapshot() -> None:
snapshot = DocumentSnapshot("abcdef")
proposal = make_proposal(
snapshot,
make_edit(snapshot, 1, 4, "X"),
make_edit(snapshot, 3, 5, "Y"),
)
with pytest.raises(EditValidationError, match="conflicting"):
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
assert snapshot.markdown == "abcdef"
assert snapshot.sha256 == DocumentSnapshot("abcdef").sha256
def test_duplicate_edits_fail_explicitly() -> None:
snapshot = DocumentSnapshot("abc")
edit = make_edit(snapshot, 0, 1, "A")
proposal = make_proposal(snapshot, edit, edit)
with pytest.raises(EditValidationError, match="duplicate"):
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
def test_distinct_insert_points_are_allowed() -> None:
snapshot = DocumentSnapshot("abcd")
proposal = make_proposal(
snapshot,
make_edit(snapshot, 1, 1, "X"),
make_edit(snapshot, 3, 3, "Y"),
)
applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
assert applied.snapshot.markdown == "aXbcYd"
def test_same_insert_point_conflicts() -> None:
snapshot = DocumentSnapshot("abc")
proposal = make_proposal(
snapshot,
make_edit(snapshot, 1, 1, "X"),
make_edit(snapshot, 1, 1, "Y"),
)
with pytest.raises(EditValidationError, match="conflicting"):
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
@pytest.mark.parametrize("insert_position", [1, 2, 3])
def test_insert_at_start_inside_or_end_of_nonempty_range_conflicts(insert_position: int) -> None:
snapshot = DocumentSnapshot("abcd")
proposal = make_proposal(
snapshot,
make_edit(snapshot, 1, 3, "X"),
make_edit(snapshot, insert_position, insert_position, "Y"),
)
with pytest.raises(EditValidationError, match="conflicting"):
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
def test_stale_hash_out_of_range_and_expected_text_mismatch_fail() -> None:
original = DocumentSnapshot("abc")
current = DocumentSnapshot("abd")
stale = make_proposal(original, make_edit(original, 0, 1, "A"))
with pytest.raises(EditValidationError, match="stale"):
apply_component_batch(current, (stale,), COMPONENT, 0)
out_of_range_edit = TextEdit(current.sha256, TextSpan(2, 5), "dxx", "D")
out_of_range = make_proposal(current, out_of_range_edit)
with pytest.raises(EditValidationError, match="outside"):
apply_component_batch(current, (out_of_range,), COMPONENT, 0)
mismatch_edit = TextEdit(current.sha256, TextSpan(0, 1), "z", "A")
mismatch = make_proposal(current, mismatch_edit)
with pytest.raises(EditValidationError, match="expected_text"):
apply_component_batch(current, (mismatch,), COMPONENT, 0)
def test_conflict_across_proposals_rejects_whole_component_batch() -> None:
snapshot = DocumentSnapshot("abcdef")
first = make_proposal(snapshot, make_edit(snapshot, 0, 3, "X"), reason="first")
second = make_proposal(snapshot, make_edit(snapshot, 2, 4, "Y"), reason="second")
with pytest.raises(EditValidationError, match="conflicting"):
apply_component_batch(snapshot, (first, second), COMPONENT, 0)
assert snapshot.markdown == "abcdef"
def test_empty_component_batch_keeps_same_snapshot_and_records_nothing() -> None:
snapshot = DocumentSnapshot("abc")
applied = apply_component_batch(snapshot, (), COMPONENT, 0)
assert applied.snapshot is snapshot
assert applied.changes == ()
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import FrozenInstanceError
from hashlib import sha256
import pytest
from mdpolish import (
DocumentSnapshot,
ErrorStage,
ProposedChange,
ResidualProposal,
RunError,
RunStatus,
TextEdit,
TextSpan,
TransformResult,
)
from mdpolish.models import ProposalReference
def test_snapshot_preserves_exact_markdown_and_computes_hash() -> None:
markdown = "标题\r\nCafe\u0301\n🙂\n"
snapshot = DocumentSnapshot(markdown)
assert snapshot.markdown == markdown
assert snapshot.sha256 == sha256(markdown.encode("utf-8")).hexdigest()
def test_empty_snapshot_is_valid_and_hash_cannot_be_supplied() -> None:
snapshot = DocumentSnapshot("")
assert snapshot.sha256 == sha256(b"").hexdigest()
with pytest.raises(TypeError):
DocumentSnapshot("", sha256="0" * 64) # type: ignore[call-arg]
def test_snapshot_is_frozen() -> None:
snapshot = DocumentSnapshot("original")
with pytest.raises(FrozenInstanceError):
snapshot.markdown = "changed" # type: ignore[misc]
@pytest.mark.parametrize(
("start", "end", "error_type"),
[
(-1, 0, ValueError),
(2, 1, ValueError),
(True, 1, TypeError),
],
)
def test_span_rejects_invalid_indexes(start: int, end: int, error_type: type[Exception]) -> None:
with pytest.raises(error_type):
TextSpan(start, end)
def test_text_edit_supports_insert_delete_and_replace() -> None:
snapshot = DocumentSnapshot("中文abc")
insertion = TextEdit(snapshot.sha256, TextSpan(2, 2), "", "!")
deletion = TextEdit(snapshot.sha256, TextSpan(2, 3), "a", "")
replacement = TextEdit(snapshot.sha256, TextSpan(3, 5), "bc", "BC")
assert insertion.span.is_empty
assert deletion.replacement == ""
assert replacement.expected_text == "bc"
def test_text_edit_rejects_bad_digest_length_mismatch_and_no_op() -> None:
digest = DocumentSnapshot("abc").sha256
with pytest.raises(ValueError, match="SHA-256"):
TextEdit("bad", TextSpan(0, 1), "a", "b")
with pytest.raises(ValueError, match="length"):
TextEdit(digest, TextSpan(0, 2), "a", "b")
with pytest.raises(ValueError, match="must change"):
TextEdit(digest, TextSpan(0, 1), "a", "a")
def test_proposal_requires_reason_edits_and_one_matching_digest() -> None:
snapshot = DocumentSnapshot("abc")
other = DocumentSnapshot("xyz")
edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
other_edit = TextEdit(other.sha256, TextSpan(0, 1), "x", "X")
with pytest.raises(ValueError, match="reason"):
ProposedChange(snapshot.sha256, " ", (edit,))
with pytest.raises(ValueError, match="non-empty tuple"):
ProposedChange(snapshot.sha256, "reason", ())
with pytest.raises(ValueError, match="proposal digest"):
ProposedChange(snapshot.sha256, "reason", (other_edit,))
def test_transform_result_enforces_status_specific_output_fields() -> None:
snapshot = DocumentSnapshot("abc")
error = RunError("component", "1.0.0", 0, ErrorStage.TRANSFORM, "ExampleError", "safe")
edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
proposal = ProposedChange(snapshot.sha256, "reason", (edit,))
residual = ResidualProposal(
component_id="component",
component_version="1.0.0",
component_position=0,
proposal_ref=ProposalReference(0, snapshot.sha256, 0),
proposal=proposal,
)
success = TransformResult(
status=RunStatus.SUCCESS,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
output_markdown=snapshot.markdown,
)
failed = TransformResult(
status=RunStatus.FAILED,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
errors=(error,),
partial_markdown=snapshot.markdown,
)
unstable = TransformResult(
status=RunStatus.UNSTABLE,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
residual_proposals=(residual,),
partial_markdown=snapshot.markdown,
)
assert success.output_markdown == "abc"
assert failed.partial_markdown == "abc"
assert unstable.residual_proposals == (residual,)
with pytest.raises(ValueError, match="successful result"):
TransformResult(
status=RunStatus.SUCCESS,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
errors=(error,),
output_markdown=snapshot.markdown,
)
with pytest.raises(ValueError, match="failed result"):
TransformResult(
status=RunStatus.FAILED,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
partial_markdown=snapshot.markdown,
)
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import cast
import pytest
from mdpolish import (
Component,
DocumentSnapshot,
ErrorStage,
Pipeline,
ProposedChange,
RunStatus,
TextEdit,
TextSpan,
)
class ReplaceComponent(Component):
def __init__(
self,
needle: str,
replacement: str,
*,
component_id: str,
version: str = "1.0.0",
parameters: object = None,
applicability: str = "处理精确测试字符串,要求完整匹配,排除其他内容。",
) -> None:
self.needle = needle
self.replacement = replacement
self._component_id = component_id
self._version = version
self._parameters = {"needle": needle, "replacement": replacement} if parameters is None else parameters
self._applicability = applicability
@property
def component_id(self) -> str:
return self._component_id
@property
def version(self) -> str:
return self._version
@property
def parameters(self) -> Mapping[str, object]:
return cast(Mapping[str, object], self._parameters)
@property
def applicability(self) -> str:
return self._applicability
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
position = snapshot.markdown.find(self.needle)
if position < 0:
return ()
edit = TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(position, position + len(self.needle)),
expected_text=self.needle,
replacement=self.replacement,
)
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason=f"replace test token for {self.component_id}",
edits=(edit,),
),
)
class ExplodingComponent(ReplaceComponent):
def __init__(self, *, trigger: str | None = None, component_id: str = "test.exploding") -> None:
super().__init__("unused", "unused-replacement", component_id=component_id)
self.trigger = trigger
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if self.trigger is None or snapshot.markdown == self.trigger:
raise RuntimeError(f"SECRET source: {snapshot.markdown}")
return ()
class InvalidReturnComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("a", "A", component_id="test.invalid-return")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
return cast(tuple[ProposedChange, ...], [])
class StaleProposalComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("a", "A", component_id="test.stale")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
stale_snapshot = DocumentSnapshot(snapshot.markdown + "!")
edit = TextEdit(stale_snapshot.sha256, TextSpan(0, 1), stale_snapshot.markdown[0], "X")
return (ProposedChange(stale_snapshot.sha256, "stale test proposal", (edit,)),)
class OverlapComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("a", "A", component_id="test.overlap")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
first = TextEdit(snapshot.sha256, TextSpan(0, 3), snapshot.markdown[0:3], "X")
second = TextEdit(snapshot.sha256, TextSpan(2, 4), snapshot.markdown[2:4], "Y")
return (ProposedChange(snapshot.sha256, "overlapping test proposal", (first, second)),)
def test_empty_pipeline_returns_unchanged_success_for_empty_unicode_text() -> None:
for markdown in ("", "中文\nCafe\u0301\n🙂"):
result = Pipeline([]).transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == markdown
assert result.partial_markdown is None
assert result.changes == ()
assert result.components == ()
def test_later_component_reads_snapshot_produced_by_earlier_component() -> None:
pipeline = Pipeline(
[
ReplaceComponent("", "", component_id="test.first"),
ReplaceComponent("", "", component_id="test.second"),
]
)
result = pipeline.transform("")
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == ""
assert [change.component_id for change in result.changes] == ["test.first", "test.second"]
assert result.changes[1].before_sha256 == result.changes[0].after_sha256
def test_same_input_components_and_parameters_produce_same_ordered_result() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", component_id="test.first"),
ReplaceComponent("b", "c", component_id="test.second"),
]
)
assert pipeline.transform("a") == pipeline.transform("a")
def test_successful_pipeline_is_idempotent_on_its_output() -> None:
pipeline = Pipeline([ReplaceComponent("old", "new", component_id="test.replace")])
first = pipeline.transform("old value")
assert first.status is RunStatus.SUCCESS
assert first.output_markdown is not None
second = pipeline.transform(first.output_markdown)
assert second.status is RunStatus.SUCCESS
assert second.output_markdown == "new value"
assert second.changes == ()
def test_single_component_runs_through_pipeline_without_shortcut() -> None:
component = ReplaceComponent("a", "A", component_id="test.single")
result = Pipeline([component]).transform("a")
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == "A"
assert len(result.changes) == 1
def test_transform_error_stops_later_components_and_keeps_only_partial_text() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", component_id="test.first"),
ExplodingComponent(),
ReplaceComponent("b", "c", component_id="test.never-runs"),
]
)
result = pipeline.transform("a")
assert result.status is RunStatus.FAILED
assert result.output_markdown is None
assert result.partial_markdown == "b"
assert [change.component_id for change in result.changes] == ["test.first"]
assert len(result.errors) == 1
assert result.errors[0].stage is ErrorStage.TRANSFORM
assert result.residual_proposals == ()
def test_unexpected_component_error_does_not_leak_source_or_exception_message() -> None:
result = Pipeline([ExplodingComponent()]).transform("private markdown")
assert result.status is RunStatus.FAILED
assert result.errors[0].error_type == "RuntimeError"
assert "SECRET" not in result.errors[0].message
assert "private markdown" not in result.errors[0].message
def test_invalid_proposal_return_is_a_transform_contract_failure() -> None:
result = Pipeline([InvalidReturnComponent()]).transform("abc")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "abc"
assert result.errors[0].error_type == "ComponentContractError"
assert result.errors[0].stage is ErrorStage.TRANSFORM
@pytest.mark.parametrize("component", [StaleProposalComponent(), OverlapComponent()])
def test_invalid_edit_batch_fails_atomically(component: Component) -> None:
result = Pipeline([component]).transform("abcd")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "abcd"
assert result.changes == ()
assert result.errors[0].error_type == "EditValidationError"
def test_duplicate_component_ids_fail_during_preflight_before_modification() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", component_id="test.duplicate"),
ReplaceComponent("b", "c", component_id="test.duplicate"),
]
)
result = pipeline.transform("a")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "a"
assert result.changes == ()
assert result.errors[0].error_type == "PipelineContractError"
@pytest.mark.parametrize(
"component",
[
ReplaceComponent("a", "b", component_id="test.bad-version", version="1.0"),
ReplaceComponent("a", "b", component_id="test.bad-parameters", parameters={"bad": {1}}),
ReplaceComponent("a", "b", component_id="test.bad-applicability", applicability=""),
],
)
def test_invalid_component_metadata_fails_before_modification(component: Component) -> None:
result = Pipeline([component]).transform("a")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "a"
assert result.changes == ()
assert result.errors[0].stage is ErrorStage.TRANSFORM
def test_cross_component_chain_is_reported_unstable_without_a_second_round() -> None:
pipeline = Pipeline(
[
ReplaceComponent("bad", "good", component_id="test.to-good"),
ReplaceComponent("good", "bad", component_id="test.to-bad"),
]
)
result = pipeline.transform("bad")
assert result.status is RunStatus.UNSTABLE
assert result.output_markdown is None
assert result.partial_markdown == "bad"
assert len(result.changes) == 2
assert len(result.residual_proposals) == 1
assert result.residual_proposals[0].component_id == "test.to-good"
assert result.errors == ()
def test_final_review_continues_after_error_and_keeps_valid_residual_proposal() -> None:
pipeline = Pipeline(
[
ExplodingComponent(trigger="done", component_id="test.review-error"),
ReplaceComponent("done", "clean", component_id="test.residual"),
ReplaceComponent("start", "done", component_id="test.producer"),
]
)
result = pipeline.transform("start")
assert result.status is RunStatus.FAILED
assert result.output_markdown is None
assert result.partial_markdown == "done"
assert len(result.errors) == 1
assert result.errors[0].stage is ErrorStage.FINAL_REVIEW
assert result.errors[0].component_id == "test.review-error"
assert len(result.residual_proposals) == 1
assert result.residual_proposals[0].component_id == "test.residual"
assert result.residual_proposals[0].proposal_ref.snapshot_sha256 == result.current_sha256