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

24 KiB
Raw Permalink Blame History

0005:本地清洗实验运行与产物保存契约

状态

已批准并冻结(2026-08-22)。

本设计扩展 0002 中已划定的输入输出适配层,并落实 0003 中“由未来适配层决定是否保存成功输出”的边界。 它不修改 DocumentSnapshotProposedChangeChangeTransformResult 或组件契约,也不改变 0004 当时只读验证已经完成的历史事实。

0004 没有授权在那一轮验证中保存真实清洗产物,不等于永久禁止未来的本地实验输出。本设计批准后, 新的实验可以在本设计的位置、保留周期和隐私边界内保存产物。

1. 问题与可观察现象

当前内存核心已经能返回清洗后 Markdown 和逐项 Change,但仓库还没有文件读取、安全输出、JSON 审计或 人可读 diff。当前只能通过临时 Python 代码在内存中查看结果,会带来四个实际问题:

  1. 评审者无法直接打开清洗后 Markdown;
  2. 没有统一 diff,难以确认“只改了批准内容”;
  3. Change 只在 Python 对象中,进程结束后无法复核组件、理由、原文和改后文本;
  4. 批量处理时无法追溯实际输入、工具版本、组件顺序和每份文档的结束状态。

真实清洗流程必须能保存结果,但不能因此让组件读写文件,也不能把 failedunstable 的部分文本冒充 成功输出。保存的 diff、before / after 和清洗后 Markdown 都可能还原真实原文,因此也不能当成普通日志 提交到 Git。

2. 目标与非目标

2.1 目标

  • 建立只服务本地评审的最小实验运行层;
  • 显式读取调用方列出的 Markdown 文件,不递归猜测数据集;
  • 继续使用同一个 Pipeline.transform() 完成清洗,不在适配层实现第二套规则;
  • 对每份成功文档保存独立的清洗后 Markdown、机器可读审计和人可读 diff;
  • 保存整次运行清单,使输入范围、工具环境、组件顺序、哈希、状态和产物位置可追溯;
  • 从文件级别区分 successfailedunstable,不为后两者生成正式清洗文档;
  • 不覆盖、改名或移动任何输入;
  • 将含真实文本的产物限定为 Git 忽略的本地敏感数据。

2.2 非目标

  • 不实现 Inspector、Finding、人工建议或审核状态;
  • 不实现 Profile 对象、TOML/YAML/JSON 配置文件或组件自动发现;
  • 不提供安装后稳定的公共 CLI、服务接口、CI 集成或 Web 界面;
  • 不原地覆盖、备份或恢复输入文件;
  • 不把 partial_markdown 写成清洗产物;
  • 不建设数据库、远程对象存储、任务调度或长期审计系统;
  • 不引入 Markdown parser、AST、新清洗规则或多轮执行;
  • 不定义真实数据适合公开、共享或长期归档的条件;
  • 不处理 PDF、DOCX、图片、转换器 JSON 或文件间关联。

3. 当前基础和不改变的边界

当前 TransformResult 已经包含持久化所需的主要运行事实:

对象 已有信息 本设计的处理
ComponentInfo 组件标识、版本、参数、适用边界 写入运行清单
Change 候选引用、理由、范围、beforeafter、批次前后哈希 写入文档审计
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 固定为 dmpejhfjamasimspringer,输入只是 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

failedunstable,之前已经发生的 Change 仍必须写入审计,但不得生成名为 cleaned.mdoutput.md 或类似正式输出的文件。

7.2 批量级别

单份文档的核心失败或不稳定不中断其他已预检文档。运行层继续按调用方给定的顺序处理,并在清单中保留每份文档的 状态。整体状态使用以下优先级:

任一文档 failed       → 整体 failed
否则任一文档 unstable → 整体 unstable
否则                  → 整体 success

这是实验汇总状态,不改变核心 RunStatus。整体失败时,其他文档的成功产物可以保留,但 manifest.json 必须明确该批次并非全部成功。

7.3 预检与致命错误

在调用任何组件前,运行层必须完成:

  1. 验证运行日期、UTC 偏移、运行 ID 和所有文档 ID;
  2. 验证输出根目录不会落入任何输入文件路径;
  3. 确认最终运行目录不存在;
  4. 读取所有输入字节、严格解码并计算哈希;
  5. 验证所有文档清单字段。

任一预检失败都使整次运行立即失败,不调用 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.mdchanges.diff。即使零修改,cleaned.md 仍完整保存成功输出, changes.diff 是长度为零的文件。这使每份成功文档的产物结构一致。

failedunstable 文档目录只包含 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_utccompleted_at_utcretention_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_markdownpartial_markdown。成功全文只在 cleaned.md 中保存; 失败和不稳定的部分全文不落盘。changesresidual_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 中的 beforeafter 和残留候选;
  • manifest.json 中可能暴露数据集结构的文档名和来源标签。

这些产物:

  • 只能保存在本机 artifacts/ 下;
  • 不得提交、推送、发布、上传或复制到 Wiki;
  • 终端默认只输出运行 ID、状态、计数和产物目录,不输出 beforeafter 或 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
  • 本地脚本显式创建只包含 ArxivSubmissionStampComponentPipeline
  • 脚本启动时捕获一次本机日期、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 完整表示候选引用、修改理由、范围、位置、beforeafter 和哈希;
  • 多组件修改可以重放中间快照并正确派生行列,哈希不一致时拒绝发布;
  • unified diff 使用逻辑文档名,不包含绝对路径或时间;
  • 输出 Markdown 重新读取后的哈希等于核心 current_sha256
  • 运行前后输入文件的字节和哈希不变;
  • JSON 编码、schema 版本、顺序、文件末换行和清单计数符合契约;
  • 产物目录及文件权限不宽于第 11 节的边界;
  • 产物写入中途失败时不发布最终运行目录。

13.2 本地 5 份论文验收

实现并通过合成测试后,允许对 data/md/ 中 5 份本地副本运行实验脚本,并在本地 artifacts/<run_date>/runs/<run_id>/ 保存完整产物。验收必须确认:

  1. 清单列出 dmpejhfjamasimspringer 共 5 份输入;
  2. 流水线只有 paper.arxiv_submission_stamp 1.0.0,参数为空;
  3. 5 份文档都为 success
  4. simspringer 各有 1 条删除,其余文档零修改,合计 2 条 Change
  5. 两条修改的组件、理由、原文、空替换、位置和前后哈希都可从 result.json 查看;
  6. simspringer 的 diff 只包含已批准的提交戳删除,Springer 合法参考文献保留;
  7. 所有 cleaned.md 与对应成功哈希一致;
  8. 5 份输入文件在运行前后逐字节不变;
  9. 产物受 Git 忽略,提交前 git status 不列出任何实验产物。

真实实验产物只用于本地人工评审,不进入合成测试期望值、Wiki、Git 提交或终端输出。

14. 风险与代价

  • JSON 过早成为契约: 第一版只服务本地实验,不承诺向后兼容;字段语义变化时必须增加 schema_version,不静默改变旧文件语义。
  • 完整审计会复制真实片段: beforeafter 和 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 或发布。