血缘与可复现 — 跑两次,字节是否一致
目标
创建转换器 lineage.py,汇总每天投放的订单。把每个产出是由哪些输入(内容哈希)、哪份代码、哪些参数生成的,记录在清单里;用同样的输入运行两次,证明字节相同;并用实验确认某一列来自哪些输入列。
为什么重要
报告数字和会计对不上时,要回答的问题只有一个——这个数字是从哪里来的?靠文件名和修改时间是回答不了的。名字相同但被上游覆盖的情况很常见,而修改时间仅靠复制就会变。能作为依据的,是内容本身的哈希。 而“重新跑一遍看看”要行得通,相同的输入必须得到相同的输出。这不会自动成立。写进产出的当前时间、每次运行新生成的标识符、没有排序的文件列表和集合遍历、浮点数相加的顺序,都会悄悄破坏复现。本实验要把这四样逐个找出来并消除。 谱系也有粗细。数据集级只到“这张表来自那三个文件”,列级则到“这一列来自那几个输入列”。当上游通知说要改某一列时,能回答问题的粗细是后者。而且列谱系只要写下来就会很快过时,所以要用实验重新测量。 评分器不会相信你写出来的文字。它会在临时目录里摆好评分器生成的投放文件,真正运行你的转换器,把汇总值和清单与评分器亲自数出来的值相比对,运行两次比较字节,并逐个对输入列做扰动,确认你写出的列谱系是否相符。店铺名和金额每次运行都会变化。
步骤
- 创建并运行 /root/lineage/gen_drops.py,在 /root/lineage/drops 下生成按日期划分的三个投放文件。
- 在 /root/lineage/lineage.py 中实现
digest,输出内容哈希、大小和记录数。 - 加上
run,输出汇总产出 /root/lineage/out/shops.csv。 - 用
run --manifest一并输出清单 /root/lineage/out/shops.manifest.json。 - 修改成运行两次字节相同,并把结果留在 /root/lineage/repro.json 中。
- 加上
columns,输出列级谱系,并留在 /root/lineage/columns.json 中。 - 加上
verify,按清单重新生成并对照。 - 加上
trace,并写出 /root/lineage/lineage_report.md。
参考
- 运行契约:
python3 /root/lineage/lineage.py <명령> ...(占位符为命令)。结果以一个 JSON 对象输出到标准输出。成功时退出码为 0,文件不存在时为 3,verify、trace对不上时为 4,用法错误时为 2。 - 投放文件名是
orders-<날짜>.csv(占位符为日期),表头是order_id,shop,qty,amount,dropped_at。dropped_at是汇总不会用到的列。 - 汇总规则(顺序会改变答案):按文件名顺序读取文件,相同的
order_id只保留第一次出现的行(先做去重),然后丢弃qty小于min_qty的行,再按shop分组。min_qty的默认值是 1。 - 产出的表头是
shop,orders,qty,amount_cents,按shop升序排列。行尾是 LF。amount_cents是把amount换成整数分后相加的值——不用浮点数,相加的顺序就改变不了答案。 digest的响应是{"path": 절대경로, "sha256": 문자열, "bytes": 정수, "rows": 정수}(占位符依次为绝对路径、字符串、整数、整数)。rows是去掉表头之后的记录数。run的响应是{"out": 절대경로, "rows": 정수, "sha256": 문자열, "min_qty": 정수}(占位符依次为绝对路径、整数、字符串、整数),如果输出了清单,还会加上manifest和run_id。- 清单的键:
tool、code_sha256、drops_dir、params、inputs、output、run_id、created_at。inputs是按名字排序的列表,每一项是name、sha256、bytes、rows。output是name、path、sha256、rows。code_sha256是lineage.py自身的内容哈希。 - 复现比较中被排除的字段只有
created_at一个。其余的,运行两次也必须相同。 columns的响应和columns.json:以输出列名为键,以该列所依赖的输入列名的已排序列表为值。判定标准是实验——扰动某个输入列时,如果这个输出列的值变了,就是有依赖;如果输出的行(键)本身变了,那么所有输出列都依赖那个输入列。verify的响应是{"manifest": 절대경로, "ok": 참거짓, "input_missing": [이름], "input_changed": [이름], "code_changed": 참거짓, "output_changed": 참거짓}(占位符依次为绝对路径、布尔值、名称、名称、布尔值、布尔值)。只要有一项对不上,退出码就是 4。trace的响应是{"key": 상호, "orders": 정수, "qty": 정수, "amount_cents": 정수, "sources": [{"name": 이름, "sha256": 문자열, "rows": 정수}]}(占位符依次为店铺名、整数、整数、整数、名称、字符串、整数)。sources只按名字顺序放入真正为该店铺贡献了行的文件,rows是该文件贡献的行数。如果店铺不存在,退出码为 4。- 破坏复现的东西:产出里的当前时间、用随机数生成的标识符、
os.listdir和集合(set)的遍历顺序、浮点数相加的顺序。Python 每次运行的字符串哈希种子都不同,所以集合的遍历顺序在不同运行之间会变。 - 官方文档:hashlib · csv · json · os.listdir · PYTHONHASHSEED
- 常见错误:用文件名和修改时间判断相同与否、事后再补清单、不记录默认值参数、不排序就使用
os.listdir。 - 想用眼睛比较运行两次的结果,可以使用
cmp -s 파일A 파일B(占位符依次为文件 A、文件 B)或sha256sum 파일A 파일B。
生成每天投放的文件
创建并运行 /root/lineage/gen_drops.py,在 /root/lineage/drops 下生成按日期划分的三个投放文件。表头是 order_id,shop,qty,amount,dropped_at,并且必须混有数量为 0 的行,以及同一单据仅金额改变后再次到来的行。
在现场,是先有文件的。这里由我们来生成这些文件。店铺至少要有四个,每个店铺至少要有三行会被接受的行,并且有一个单据号要出现在两个不同的文件里。dropped_at 是汇总不会用到的列,值有多种就行。
用内容而不是名字来判断相同
在 /root/lineage/lineage.py 中实现 digest <파일>(占位符为文件),以 JSON 输出 path、sha256、bytes、rows。rows 是去掉表头之后的记录数。
以二进制方式分块读取文件,喂给 hashlib.sha256。把同样的内容复制成另一个名字,或者用 touch 只改修改时间,亲自确认哈希是不是保持不变。记录数要用 csv 来数,而不是数行数。
输出汇总产出
加上 run --drops <디렉터리> --out <파일> [--min-qty N](占位符依次为目录、文件),输出 /root/lineage/out/shops.csv。按文件名顺序读取文件,相同的单据只保留第一次出现的行,然后丢弃 qty 小于 min_qty 的行,按 shop 分组,以店铺升序写出 shop,orders,qty,amount_cents。
先去重、后做数量过滤,这一点会改变答案——如果第一次出现的行数量为 0,这个单据就会整个被排除。金额要把字符串换成整数分再相加。文件列表要排序后读取。os.listdir 的顺序是没有保证的。
一并输出产出和清单
加上 run --manifest <파일>(占位符为文件),一并输出 /root/lineage/out/shops.manifest.json。其中包含 tool、code_sha256、drops_dir、params、inputs、output、run_id、created_at 这八个键。
输入是按名字排序的列表,包含 name、sha256、bytes、rows;code_sha256 是 lineage.py 自身的哈希。参数的默认值也要记下来——默认值以后会变。事后再补一定会漏,所以要在与产出相同的函数里生成。
运行两次,字节相同吗
修改成:用同样的输入运行两次时,产出的字节相同,并且清单除 created_at 之外的所有字段都相同。然后把两次运行的结果以 run_a、run_b、identical、manifest_diff_keys 留在 /root/lineage/repro.json 中。
现在这一版用随机数生成运行标识符,所以清单每次都不同。标识符要从内容派生——把代码哈希、参数和输出哈希连起来再做哈希,相同的运行就会有相同的名字。还要再检查一下文件列表和分组列表是否排过序。Python 每次运行的字符串哈希种子都不同,所以集合的遍历顺序在不同运行之间会变。
用实验测出某一列来自哪里
加上 columns,为每个输出列输出它所依赖的输入列的已排序列表,并把同样的内容留在 /root/lineage/columns.json 中。
不要只写下来就完事,要用实验确认——让一个输入列发生扰动后重新运行,看哪些输出列变了。改变店铺名,输出的行本身就会不同,所以所有列都依赖它。把单据号改成与另一行相同,那一行就会被当作重复而排除,因此记录数、数量和金额会一起变动。汇总完全不读取的列,哪里都不应该出现。
按清单重新生成并对照
加上 verify <매니페스트>(占位符为清单),把输入哈希和代码哈希与当前值比较,并用清单中的输入和参数重新生成,对照输出哈希。只要有一项对不上,退出码就是 4。
对输入,要把消失的和内容改变的分开报告——对调查的人来说,这是两件不同的事。必须用清单中记录的参数重新运行。如果用现在的默认值运行,即使得出了与当时不同的答案,也无法指出原因。
回答这个数字来自哪些输入
加上 trace --manifest <파일> --key <상호>(占位符依次为文件、店铺名),输出该店铺的记录数、数量和金额,以及真正为它贡献了行的输入文件的名字、内容哈希和行数。然后在 /root/lineage/lineage_report.md 中写成四节,标题是 ## 이 숫자는 어디서 왔나(韩文,意为“这个数字从哪里来”)、## 이름과 시각은 왜 근거가 못 되나(韩文,意为“名字和时间为什么不能作为依据”)、## 두 번 돌려 같았는가(韩文,意为“运行两次是否相同”)、## 칼럼 하나가 어디서 왔나(韩文,意为“某一列来自哪里”)。
sources 里只放真正为该店铺贡献了行的文件——读了但一行都没贡献的文件,不属于谱系。哈希直接用清单里记录的值。报告里要写数字,要让调查的人能在自己的文件里把那一行打开看到。