Files
mdpolish/research-wiki/design/0005-local-experiment-runner-and-artifacts.md
T

546 lines
24 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.
# 0005:本地清洗实验运行与产物保存契约
## 状态
已批准并冻结(2026-08-22)。
本设计扩展 `0002` 中已划定的输入输出适配层,并落实 `0003` 中“由未来适配层决定是否保存成功输出”的边界。
它不修改 `DocumentSnapshot``ProposedChange``Change``TransformResult` 或组件契约,也不改变
`0004` 当时只读验证已经完成的历史事实。
`0004` 没有授权在那一轮验证中保存真实清洗产物,不等于永久禁止未来的本地实验输出。本设计批准后,
新的实验可以在本设计的位置、保留周期和隐私边界内保存产物。
## 1. 问题与可观察现象
当前内存核心已经能返回清洗后 Markdown 和逐项 `Change`,但仓库还没有文件读取、安全输出、JSON 审计或
人可读 diff。当前只能通过临时 Python 代码在内存中查看结果,会带来四个实际问题:
1. 评审者无法直接打开清洗后 Markdown;
2. 没有统一 diff,难以确认“只改了批准内容”;
3. `Change` 只在 Python 对象中,进程结束后无法复核组件、理由、原文和改后文本;
4. 批量处理时无法追溯实际输入、工具版本、组件顺序和每份文档的结束状态。
真实清洗流程必须能保存结果,但不能因此让组件读写文件,也不能把 `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. 总体边界与依赖方向
```text
调用方显式列出文档和顺序
本地实验运行层
│ │
│ ├── 读取 UTF-8 Markdown
│ └── 记录文档身份与环境
Pipeline.transform(markdown)
TransformResult
reporter / 产物写入
│ │ │
▼ ▼ ▼
JSON cleaned.md unified diff
```
依赖必须保持从外向内:
```text
本地实验脚本
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 批量级别
单份文档的核心失败或不稳定不中断其他已预检文档。运行层继续按调用方给定的顺序处理,并在清单中保留每份文档的
状态。整体状态使用以下优先级:
```text
任一文档 failed → 整体 failed
否则任一文档 unstable → 整体 unstable
否则 → 整体 success
```
这是实验汇总状态,不改变核心 `RunStatus`。整体失败时,其他文档的成功产物可以保留,但 `manifest.json`
必须明确该批次并非全部成功。
### 7.3 预检与致命错误
在调用任何组件前,运行层必须完成:
1. 验证运行日期、UTC 偏移、运行 ID 和所有文档 ID;
2. 验证输出根目录不会落入任何输入文件路径;
3. 确认最终运行目录不存在;
4. 读取所有输入字节、严格解码并计算哈希;
5. 验证所有文档清单字段。
任一预检失败都使整次运行立即失败,不调用 `Pipeline`,不生成最终运行目录。产物写入、JSON 序列化或最终目录
发布失败同样是整次运行的致命错误,不得把不完整临时目录报告为已完成实验。
## 8. 产物目录与发布
### 8.1 固定位置
仓库内本地实验产物固定放在:
```text
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 目录结构
```text
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`
运行清单至少包含:
```text
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`
文档审计至少包含:
```text
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. 实现结构
批准后允许新增:
```text
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>/` 保存完整产物。验收必须确认:
1. 清单列出 `dmp``ejhf``jama``sim``springer` 共 5 份输入;
2. 流水线只有 `paper.arxiv_submission_stamp` `1.0.0`,参数为空;
3. 5 份文档都为 `success`
4. `sim``springer` 各有 1 条删除,其余文档零修改,合计 2 条 `Change`
5. 两条修改的组件、理由、原文、空替换、位置和前后哈希都可从 `result.json` 查看;
6. `sim``springer` 的 diff 只包含已批准的提交戳删除,Springer 合法参考文献保留;
7. 所有 `cleaned.md` 与对应成功哈希一致;
8. 5 份输入文件在运行前后逐字节不变;
9. 产物受 Git 忽略,提交前 `git status` 不列出任何实验产物。
真实实验产物只用于本地人工评审,不进入合成测试期望值、Wiki、Git 提交或终端输出。
## 14. 风险与代价
- **JSON 过早成为契约:** 第一版只服务本地实验,不承诺向后兼容;字段语义变化时必须增加
`schema_version`,不静默改变旧文件语义。
- **完整审计会复制真实片段:** `before``after` 和 diff 有意保留原文,换取可人工复核;代价是所有产物都必须
按敏感数据处理。
- **批量中部分文档成功:** 这便于查看每份输入,但调用方必须检查整体和文档状态,不能因为目录里有部分
`cleaned.md` 就宣称整批成功。
- **行列派生需要重放修改:** 它能在不改核心模型的情况下便于人定位,但增加 reporter 的复杂度。重放过程必须
完全验证哈希,失败就拒绝发布。
- **时间和环境使 manifest 不再字节级确定:** 这些是复现实验必需的外层证据,不影响相同文本和流水线的核心结果
确定性。
- **严格权限会降低跨平台便携性:** 第一版是本机 Linux 实验能力,不借本设计声称 Windows 或共享盘已支持。
- **固定 30 天只是本地实验保留约定:** 第一版不自动删除,所以仍需要用户定期确认和清理过期目录。
## 15. 批准后的实施边界
批准本设计后,只授权:
1. 新增第 12 节列出的模块、本地脚本和测试;
2. 按第 6 至 11 节实现显式输入、核心调用、JSON、diff、原子发布和保留信息;
3.`data/md/` 的 5 份本地论文副本运行只含 arXiv 组件的实验;
4. 将完整本地产物保存到 Git 忽略的 `artifacts/<run_date>/runs/<run_id>/`
5. 实现后根据真实行为更新 README、对应 `explanation/` 和经验证的 `guides/`
批准不授权:
- 修改或覆盖任何输入文件;
-`/home/lihaoze/gov_test_data` 或其他仓库外数据保存产物;
- 把实验产物加入 Git、Wiki、其他仓库、云存储或外部系统;
- 实现原地覆盖、公共 CLI、Profile、Inspector、parser、多轮执行或其他清洗组件;
- 提交、推送、创建 PR 或发布。