24 KiB
0005:本地清洗实验运行与产物保存契约
状态
已批准并冻结(2026-08-22)。
本设计扩展 0002 中已划定的输入输出适配层,并落实 0003 中“由未来适配层决定是否保存成功输出”的边界。
它不修改 DocumentSnapshot、ProposedChange、Change、TransformResult 或组件契约,也不改变
0004 当时只读验证已经完成的历史事实。
0004 没有授权在那一轮验证中保存真实清洗产物,不等于永久禁止未来的本地实验输出。本设计批准后,
新的实验可以在本设计的位置、保留周期和隐私边界内保存产物。
1. 问题与可观察现象
当前内存核心已经能返回清洗后 Markdown 和逐项 Change,但仓库还没有文件读取、安全输出、JSON 审计或
人可读 diff。当前只能通过临时 Python 代码在内存中查看结果,会带来四个实际问题:
- 评审者无法直接打开清洗后 Markdown;
- 没有统一 diff,难以确认“只改了批准内容”;
Change只在 Python 对象中,进程结束后无法复核组件、理由、原文和改后文本;- 批量处理时无法追溯实际输入、工具版本、组件顺序和每份文档的结束状态。
真实清洗流程必须能保存结果,但不能因此让组件读写文件,也不能把 failed 或 unstable 的部分文本冒充
成功输出。保存的 diff、before / after 和清洗后 Markdown 都可能还原真实原文,因此也不能当成普通日志
提交到 Git。
2. 目标与非目标
2.1 目标
- 建立只服务本地评审的最小实验运行层;
- 显式读取调用方列出的 Markdown 文件,不递归猜测数据集;
- 继续使用同一个
Pipeline.transform()完成清洗,不在适配层实现第二套规则; - 对每份成功文档保存独立的清洗后 Markdown、机器可读审计和人可读 diff;
- 保存整次运行清单,使输入范围、工具环境、组件顺序、哈希、状态和产物位置可追溯;
- 从文件级别区分
success、failed和unstable,不为后两者生成正式清洗文档; - 不覆盖、改名或移动任何输入;
- 将含真实文本的产物限定为 Git 忽略的本地敏感数据。
2.2 非目标
- 不实现 Inspector、Finding、人工建议或审核状态;
- 不实现 Profile 对象、TOML/YAML/JSON 配置文件或组件自动发现;
- 不提供安装后稳定的公共 CLI、服务接口、CI 集成或 Web 界面;
- 不原地覆盖、备份或恢复输入文件;
- 不把
partial_markdown写成清洗产物; - 不建设数据库、远程对象存储、任务调度或长期审计系统;
- 不引入 Markdown parser、AST、新清洗规则或多轮执行;
- 不定义真实数据适合公开、共享或长期归档的条件;
- 不处理 PDF、DOCX、图片、转换器 JSON 或文件间关联。
3. 当前基础和不改变的边界
当前 TransformResult 已经包含持久化所需的主要运行事实:
| 对象 | 已有信息 | 本设计的处理 |
|---|---|---|
ComponentInfo |
组件标识、版本、参数、适用边界 | 写入运行清单 |
Change |
候选引用、理由、范围、before、after、批次前后哈希 |
写入文档审计 |
RunError |
组件、阶段、错误类型和安全说明 | 写入文档审计 |
ResidualProposal |
最终复查的有效残留修改 | 只写入 failed / unstable 审计 |
TransformResult |
状态、输入/当前哈希、修改、错误、成功或部分文本 | 决定可以生成哪些产物 |
本设计只在核心外补充文件身份、运行身份、环境和序列化信息。以下边界保持不变:
- 组件仍只接收
DocumentSnapshot并返回ProposedChange; - 组件不知道文件路径、运行 ID、输出目录或 JSON 格式;
Pipeline仍只处理内存 Markdown;- Python 字符下标仍是修改位置的唯一权威;
- reporter 不重新判断业务规则,只表示现有运行结果;
- 文件适配层不把读写成功冒充成清洗成功。
4. 方案比较
4.1 继续只返回内存结果
不增加新契约,但每次真实实验都需要临时代码,结果无法留存,人也无法方便查看完整 Markdown 和 diff。 不采用。
4.2 只保存清洗后 Markdown
人可以打开结果,但无法证明是哪个组件改了什么,也无法区分输入不同、组件不同还是工具不同。 输出文件与内存审计会脱节。不采用。
4.3 建设完整 CLI、Profile 和批处理平台
能够一次解决对外运行,但当前只有一个无参数组件,配置、退出码、安装命令和公共兼容都没有足够消费者与失败样本。 这会过早固定外部接口。不采用。
4.4 本地实验运行层加结构化产物
调用方显式提供文档和 Pipeline,外层只负责严格读取、调用核心、表示结果和写入新目录。它不提供稳定公共 CLI,
也不引入配置语言。每次运行同时保存成功文本、JSON 审计和 diff。
采用此方案。它能满足当前 5 份论文的人工复核,同时不迫使核心、组件和未来对外工具提前承担未经验证的接口。
5. 总体边界与依赖方向
调用方显式列出文档和顺序
│
▼
本地实验运行层
│ │
│ ├── 读取 UTF-8 Markdown
│ └── 记录文档身份与环境
▼
Pipeline.transform(markdown)
│
▼
TransformResult
│
▼
reporter / 产物写入
│ │ │
▼ ▼ ▼
JSON cleaned.md unified diff
依赖必须保持从外向内:
本地实验脚本
│
▼
experiment.py
├────────► pipeline.py ──────► 当前内存核心
├────────► reporting.py ─────► models.py
└────────► artifact_store.py
reporting.py可以依赖数据模型,不读文件,不调用组件;artifact_store.py只负责产物路径、权限、写入校验和原子发布,不调用流水线,不解释修改语义;experiment.py可以依赖Pipeline、reporter 和 artifact store,负责输入预检与文档级批量编排;- 业务组件和内存核心不反向导入这三个模块;
- 日期目录、文件名或未来存储位置的变化应限制在 artifact store,不改变 reporter 或流水线;
- 实验脚本可以显式组装 ClinDB 流水线,但通用模块不得硬编码
data/md/、DOI 或论文缩写。
6. 输入契约
6.1 显式文档清单
调用方必须按顺序提供非空文档清单。每项至少包含:
document_id:当前运行内唯一的稳定标识;source_path:实际读取路径;source_label:写入清单的可展示来源,用于避免无条件泄露绝对路径。
document_id 只允许小写 ASCII 字母、数字、.、_ 和 -,必须以字母或数字开头,不得包含路径分隔符或
.. 路径段。文档 ID 重复、源路径重复、源文件不存在或不是普通文件时,整次运行在产生最终产物目录前失败。
第一个实际实验的文档 ID 固定为 dmp、ejhf、jama、sim 和 springer,输入只是 data/md/ 中对应的
5 份本地副本。本设计不授权对仓库外 GovDoc 数据保存清洗产物。
6.2 编码和文本保真
- 输入以二进制读取,再使用严格 UTF-8 解码;
- 无效 UTF-8 使整次运行在预检阶段失败,不使用替换字符或自动猜测编码;
- 不剔除 UTF-8 BOM,不规范化 Unicode,不转换换行,不补文件末换行;
- 清洗后 Markdown 使用 UTF-8 严格编码,保留内存输出的所有字符。
核心快照哈希仍以 Markdown 字符串重新编码后的 UTF-8 字节为权威。适配层另外记录原始文件字节的 SHA-256; 对有效 UTF-8 输入,两者应当一致。如果不一致,视为适配层错误并停止运行。
6.3 流水线来源
实验运行层接收调用方已显式建立的 Pipeline。它不根据文件名、内容或安装环境选择组件,也不在不同文档之间改变
组件顺序。当前没有 Profile 身份;运行清单直接记录每次结果中已验证的有序 ComponentInfo。
7. 运行状态与批量语义
7.1 文档级别
每份文档独立调用同一个 Pipeline。文档结果直接沿用核心状态:
success:可以保存正式清洗 Markdown、文档审计和 diff;failed:只保存文档审计和错误,不持久化partial_markdown;unstable:只保存文档审计和残留候选,不持久化partial_markdown。
对 failed 和 unstable,之前已经发生的 Change 仍必须写入审计,但不得生成名为 cleaned.md、output.md
或类似正式输出的文件。
7.2 批量级别
单份文档的核心失败或不稳定不中断其他已预检文档。运行层继续按调用方给定的顺序处理,并在清单中保留每份文档的 状态。整体状态使用以下优先级:
任一文档 failed → 整体 failed
否则任一文档 unstable → 整体 unstable
否则 → 整体 success
这是实验汇总状态,不改变核心 RunStatus。整体失败时,其他文档的成功产物可以保留,但 manifest.json
必须明确该批次并非全部成功。
7.3 预检与致命错误
在调用任何组件前,运行层必须完成:
- 验证运行日期、UTC 偏移、运行 ID 和所有文档 ID;
- 验证输出根目录不会落入任何输入文件路径;
- 确认最终运行目录不存在;
- 读取所有输入字节、严格解码并计算哈希;
- 验证所有文档清单字段。
任一预检失败都使整次运行立即失败,不调用 Pipeline,不生成最终运行目录。产物写入、JSON 序列化或最终目录
发布失败同样是整次运行的致命错误,不得把不完整临时目录报告为已完成实验。
8. 产物目录与发布
8.1 固定位置
仓库内本地实验产物固定放在:
artifacts/<run_date>/runs/<run_id>/
artifacts/ 已由 .gitignore 忽略。本设计不授权从 Git 忽略中移除该目录,也不允许通过 git add -f
强制提交任何产物。
run_date 使用 YYYY-MM-DD,表示实验开始时本机时区中的日历日期。本地实验脚本在启动时只计算一次日期和
UTC 偏移,再把它们显式传给运行层;运行层不在处理不同文档时重新读取日期。run_date 必须能够按 ISO 8601
日历日期严格解析,日期目录不接受其他格式。
run_id 由调用方显式提供,适用与 document_id 相同的安全字符规则。不使用当前时间、随机数或文件名暗中生成
运行 ID。同一日期下的目标运行目录已存在时必须拒绝运行,不覆盖、合并或自动加后缀。同一个 run_id 可以在
不同日期下再次使用,两次运行仍由完整日期路径区分。
8.2 目录结构
artifacts/
└── <run_date>/
└── runs/
└── <run_id>/
├── manifest.json
└── documents/
└── <document_id>/
├── result.json
├── cleaned.md # 仅 success
└── changes.diff # 仅 success
所有 success 文档都生成 cleaned.md 和 changes.diff。即使零修改,cleaned.md 仍完整保存成功输出,
changes.diff 是长度为零的文件。这使每份成功文档的产物结构一致。
failed 和 unstable 文档目录只包含 result.json。不创建隐含部分结果的 Markdown 或 diff。
8.3 原子发布
artifact store 先在 artifacts/<run_date>/runs/ 下创建当次运行专用的临时目录,完成全部文件写入、
哈希校验和清单校验后,
再在同一文件系统内将它重命名为最终 <run_id> 目录。
- 发布前的临时目录不是成功产物;
- 任一写入或校验失败时尝试清理本次专用临时目录;
- 不对已存在的最终目录使用替换语义;
- 本设计只保证“不发布已知不完整的运行目录”,不承诺跨平台断电耐久性。
9. 持久化事实与 JSON 契约
9.1 事实权威
manifest.json是整次运行身份、环境、流水线、文档索引和汇总的权威;- 每份文档的
result.json是该文档状态、哈希、实际修改、错误和残留候选的权威; cleaned.md是成功文本内容的权威;changes.diff是从输入和成功输出派生的人工评审视图,不是第二份修改记录。
清单中的计数和路径是从文档结果派生的索引,发布前必须校验与对应 result.json 和文件存在性一致。
9.2 JSON 通用表示
两类 JSON 都使用:
- UTF-8,不写 BOM;
- 根对象的
schema_version固定为整数1; - 两空格缩进;
- 不把非 ASCII 字符转成反斜杠加
u的 Unicode 转义形式; - 文件末有一个
\n; - 数组顺序保留调用方文档顺序、组件顺序和核心修改顺序。
JSON 中的字段名和枚举值使用英文标识符。组件的中文修改理由和 before / after 保留原文。
9.3 manifest.json
运行清单至少包含:
schema_version
run
run_id
run_date
utc_offset
status
started_at_utc
completed_at_utc
retention_until
tool
name
package_version
python_version
platform
git_commit # 无法取得时为 null
git_dirty # 无法取得时为 null
pipeline
components[] # 实际有序 ComponentInfo
component_id
version
parameters
applicability
documents[]
document_id
source_label
status
input_sha256
current_sha256
change_count
result_path
cleaned_path # 非 success 为 null
diff_path # 非 success 为 null
summary
document_count
success_count
failed_count
unstable_count
change_count
run_date 必须等于产物路径中的日期。utc_offset 使用 +HH:MM 或 -HH:MM,说明计算该日期时的本机
UTC 偏移。started_at_utc、completed_at_utc 和 retention_until 使用带 Z 的 UTC ISO 8601 字符串。
时间、平台和 Git 信息属于外层运行证据,不进入组件参数,也不影响核心结果的确定性。清单不读取或保存环境变量、
用户名、主机名或其他可能包含秘密的全局环境信息。
pipeline.components 必须来自本次文档结果中的已验证元数据。同一次运行中各文档的组件元数据不一致时,
视为运行层错误,不发布最终目录。
9.4 result.json
文档审计至少包含:
schema_version
document
document_id
source_label
status
input_sha256
current_sha256
changes[]
component_id
component_version
component_position
proposal_ref
component_position
snapshot_sha256
proposal_index
edit_index
reason
span
start
end
location # 派生定位,不用于应用修改
line # 1-based,相对 before_sha256 快照
column # 1-based,相对 before_sha256 快照
before
after
before_sha256
after_sha256
errors[]
component_id
component_version
component_position
stage
error_type
message
residual_proposals[]
component_id
component_version
component_position
proposal_ref
proposal
snapshot_sha256
reason
edits[]
snapshot_sha256
span
start
end
expected_text
replacement
output
cleaned_path # 非 success 为 null
diff_path # 非 success 为 null
location 由 reporter 基于对应 before_sha256 快照派生,只便于人查看。span.start / span.end
仍是唯一修改权威。当多个组件产生中间快照时,reporter 必须按组件批次从输入重放已记录修改,每次都校验前后哈希;
无法重放时不得发布产物。
result.json 不内嵌完整 output_markdown 或 partial_markdown。成功全文只在 cleaned.md 中保存;
失败和不稳定的部分全文不落盘。changes、residual_proposals 中的局部原文仍属于完整本地审计的一部分。
10. diff 契约
changes.diff 使用原始输入和最终成功输出生成 unified diff:
- 旧文件标签固定为
a/<document_id>.md; - 新文件标签固定为
b/<document_id>.md; - 不把绝对路径和时间戳写入 diff 头;
- 上下文固定为 3 行;
- diff 自身使用
\n作为报告换行,不表示清洗后 Markdown 被转换为\n; - 零修改的成功文档产生空 diff;
- diff 只用于人工评审,不用于重放或应用修改。
统一 diff 只展示输入到最终输出的总变化。如果需要查看某个组件的原因和中间快照,以 result.json
中的 Change 审计为准。
11. 隐私、权限和保留周期
11.1 数据级别
本设计生成的所有产物都按“本地敏感实验数据”处理,包括:
cleaned.md中的完整文档;changes.diff中的原文上下文;result.json中的before、after和残留候选;manifest.json中可能暴露数据集结构的文档名和来源标签。
这些产物:
- 只能保存在本机
artifacts/下; - 不得提交、推送、发布、上传或复制到 Wiki;
- 终端默认只输出运行 ID、状态、计数和产物目录,不输出
before、after或 diff 片段; - 实验结束后不得自动拷贝到其他仓库或用户目录。
第一版创建运行目录时将目录权限设为只有当前用户可读、写和进入,普通产物文件只有当前用户可读写。 如果平台不支持这些权限语义,实验必须明确报错,不静默降级为宽松权限。
11.2 保留周期
本地产物默认保留 30 个日历日。manifest.json 记录根据 completed_at_utc 计算的 retention_until。
第一版不实现自动删除,避免在没有人工确认时执行破坏性操作。到期产物只标记为待清理;删除前必须由用户确认 具体运行目录。需要超过 30 天保留、跨机共享或长期归档时,必须先单独确认保存位置和数据边界。
12. 实现结构
批准后允许新增:
src/mdpolish/
├── artifact_store.py # 日期路径、权限、写入校验和原子产物发布
├── experiment.py # 显式文件输入、预检和批量运行
└── reporting.py # JSON 表示、位置派生和 unified diff
scripts/
└── run_clindb_arxiv_experiment.py # 仓库内已知 5 份论文的本地运行脚本
tests/
├── test_artifact_store.py
├── test_experiment.py
└── test_reporting.py
- 三个新模块不从顶层
mdpolish.__init__导出,暂不承诺稳定公共 API; - 本地脚本显式创建只包含
ArxivSubmissionStampComponent的Pipeline; - 脚本启动时捕获一次本机日期、UTC 偏移和 UTC 开始时间,不在文档循环中重新计算日期;
- 脚本输入是
data/md/中的 5 份缩写文件,输出为artifacts/<run_date>/runs/<run_id>/; - 脚本可以接收运行 ID,但不是通用文件清洗 CLI,不注册
project.scripts; - 精确命令和实际运行步骤在实现并验证后进入
guides/,README 只链接当前权威,不在多处复制。
第一版继续只使用 Python 标准库。不修改包安装命令,不增加运行依赖。
13. 测试和验收
13.1 合成测试
自动测试只使用 tmp_path 和小型虚构 Markdown,不读取或复制真实论文。至少覆盖:
- 空文档、中文、组合 Unicode、UTF-8 BOM、
\n、\r\n和无末尾换行; - 无效 UTF-8、缺失文件、非普通文件、重复文档 ID、重复源路径和非法运行 ID 在预检失败;
- 非法日期、日期路径与 manifest 不一致、同日重复运行 ID 明确失败;
- 目标运行目录已存在时拒绝,不覆盖其中任何文件;
success生成三类文档产物,零修改时仍生成完整cleaned.md和空 diff;failed/unstable只生成result.json,不保存部分全文;- 一份文档核心失败后继续处理其他文档,整体状态按第 7.2 节汇总;
result.json完整表示候选引用、修改理由、范围、位置、before、after和哈希;- 多组件修改可以重放中间快照并正确派生行列,哈希不一致时拒绝发布;
- unified diff 使用逻辑文档名,不包含绝对路径或时间;
- 输出 Markdown 重新读取后的哈希等于核心
current_sha256; - 运行前后输入文件的字节和哈希不变;
- JSON 编码、schema 版本、顺序、文件末换行和清单计数符合契约;
- 产物目录及文件权限不宽于第 11 节的边界;
- 产物写入中途失败时不发布最终运行目录。
13.2 本地 5 份论文验收
实现并通过合成测试后,允许对 data/md/ 中 5 份本地副本运行实验脚本,并在本地
artifacts/<run_date>/runs/<run_id>/ 保存完整产物。验收必须确认:
- 清单列出
dmp、ejhf、jama、sim和springer共 5 份输入; - 流水线只有
paper.arxiv_submission_stamp1.0.0,参数为空; - 5 份文档都为
success; sim和springer各有 1 条删除,其余文档零修改,合计 2 条Change;- 两条修改的组件、理由、原文、空替换、位置和前后哈希都可从
result.json查看; sim和springer的 diff 只包含已批准的提交戳删除,Springer 合法参考文献保留;- 所有
cleaned.md与对应成功哈希一致; - 5 份输入文件在运行前后逐字节不变;
- 产物受 Git 忽略,提交前
git status不列出任何实验产物。
真实实验产物只用于本地人工评审,不进入合成测试期望值、Wiki、Git 提交或终端输出。
14. 风险与代价
- JSON 过早成为契约: 第一版只服务本地实验,不承诺向后兼容;字段语义变化时必须增加
schema_version,不静默改变旧文件语义。 - 完整审计会复制真实片段:
before、after和 diff 有意保留原文,换取可人工复核;代价是所有产物都必须 按敏感数据处理。 - 批量中部分文档成功: 这便于查看每份输入,但调用方必须检查整体和文档状态,不能因为目录里有部分
cleaned.md就宣称整批成功。 - 行列派生需要重放修改: 它能在不改核心模型的情况下便于人定位,但增加 reporter 的复杂度。重放过程必须 完全验证哈希,失败就拒绝发布。
- 时间和环境使 manifest 不再字节级确定: 这些是复现实验必需的外层证据,不影响相同文本和流水线的核心结果 确定性。
- 严格权限会降低跨平台便携性: 第一版是本机 Linux 实验能力,不借本设计声称 Windows 或共享盘已支持。
- 固定 30 天只是本地实验保留约定: 第一版不自动删除,所以仍需要用户定期确认和清理过期目录。
15. 批准后的实施边界
批准本设计后,只授权:
- 新增第 12 节列出的模块、本地脚本和测试;
- 按第 6 至 11 节实现显式输入、核心调用、JSON、diff、原子发布和保留信息;
- 对
data/md/的 5 份本地论文副本运行只含 arXiv 组件的实验; - 将完整本地产物保存到 Git 忽略的
artifacts/<run_date>/runs/<run_id>/; - 实现后根据真实行为更新 README、对应
explanation/和经验证的guides/。
批准不授权:
- 修改或覆盖任何输入文件;
- 对
/home/lihaoze/gov_test_data或其他仓库外数据保存产物; - 把实验产物加入 Git、Wiki、其他仓库、云存储或外部系统;
- 实现原地覆盖、公共 CLI、Profile、Inspector、parser、多轮执行或其他清洗组件;
- 提交、推送、创建 PR 或发布。