
昨天結尾問:143 條關係,一條一條人工查得完嗎?
先講為什麼要把它寫成工具。不是因為慢——grep 一次三十秒。
是因為那份測試不是你寫的,而且你沒有作者可以問。同事寫的你可以走過去問一句「你知道這兩個欄位是同一個物件嗎」,模型不行。它交出檔案就走了,剩下的只有程式碼。
沒有人可以問的時候,就得有個東西替你問。
寫工具之前先修正一件事。
昨天我以為自己查過了,實際上只查了一對欄位——圖表餘額對真實餘額,因為那是我碰巧注意到的那一對。我從來沒問過:這支程式還有哪些欄位對是親戚?
工具第一層就在回答這個,而且跟測試無關:跑受測物 200 組隨機輸入,逐對檢查輸出欄位之間有沒有恆定關係。
親緣表|受測物 shadow/calc_fixed.py|200 組輸入|種子 20260928
balances_charted、balances_raw IDENTITY
projected_savings、retirement_gap、target_fund LINEAR
第二組我昨天連想都沒想過。
兩種關係的證據強度不一樣,報表要分開:
IDENTITY 是 is 判定,確鑿
LINEAR 是在這 200 組輸入上成立,那是推論不是證明,換一組輸入可能就不成立所以工具的價值不在找得比較全。同一個 grep,只要你知道要找哪兩個欄位,十四條裡抓得到十三條:漏的那條斷言裡連欄位名都沒有(等一下會看到)。
價值在於你不必先知道要找什麼。
第二層才去掃測試:逐條 assert,抽出兩邊碰到的欄位,只在兩邊用同一個結果物件時才比對親緣表。跨執行的斷言(res1.x 對 res2.y)不標:那是蛻變關係的正常形狀。
合計標記 14 條,掃了 10 個檔案。
14 / 232 條斷言,6.0%,十份全中。全部落在同一組親緣:圖表餘額對真實餘額,而那兩個欄位在受測的實作上是同一個 tuple(148 行算出來,154 與 156 行指過去)。
同一件事,五種寫法:
res.balances_charted == res.balances_raw # 10 條
list(r.balances_charted) == list(r.balances_raw)
len(res.balances_charted) == len(res.balances_raw)
res.balances_charted == pytest.approx(res.balances_raw)
for charted, raw in zip(res.balances_charted, res.balances_raw):
assert charted == raw
len(x) == len(x) 那條值得停一下:它比的是同一個物件的長度,跟它自己的長度。
親緣表的第二列標記了 0 條。沒有任何一份在同一個結果物件內寫出「缺口等於目標金額減資產」。一把尺如果只會說「有問題」,它沒有用;要看它在該閉嘴的時候閉不閉嘴。
上面那五種寫法,第一版的工具只認得四種。
它只會找 變數.欄位 的形狀。最後那種先用 zip 拆成迴圈變數再比較的,它看不見:報表顯示那個檔案「標記 1 條」,而實際上有 2 條。
assert len(res.balances_charted) == len(res.balances_raw) # 抓到
for charted, raw in zip(res.balances_charted, res.balances_raw):
assert charted == raw # 沒抓到
順帶一提,這也是 grep 抓不到的那一條:最後那行裡,兩個欄位名一個都沒出現。
漏掉的方式是安靜的。沒有錯誤訊息,沒有警告,就是一個小一點的數字。
修法有兩種,選錯了會很慘。補語法是錯的那種:補完 zip 還有先存進 list 再比、還有生成式、還有各種包一層。補到最後,這支工具就變成它本來要取代的那個 grep——永遠在追語法,而且永遠追不完,還會因為「掃過了、沒命中」給你錯誤的安心。
所以除了補上別名追蹤,還加了一道守衛,讓工具自己承認漏了幾處:把註解與字串剝掉,找出所有同時提到一組親緣欄位的程式行,扣掉已經解釋掉的,剩下的報出來叫人看。
而第一版的守衛,設計是錯的。
它拿「提到欄位的行數」去比「被標記的斷言數」:那是兩組不同的行。run-A-MR-03 的前者是 L261、L262,後者是 L261、L263:只有 L261 重疊,另外兩行各走各的,而兩邊都是 2。
十個檔案全部「相等」,守衛一次都沒響。我差點把這十個「相等」當成「什麼都沒漏」。
它其實沒有放跑任何東西:別名追蹤補上之後,那條 zip 確實被抓到了。問題出在別的地方——那兩個數字數的根本不是同一種東西,所以它們相等或不相等,都不帶任何資訊。 它給我的不是錯誤的結論,是憑空的安心。
一個從不觸發的守衛,跟壞掉的守衛長得一模一樣。
改成比對行號集合之後,再把別名處理關掉模擬第一版:
run-A-MR-03.txt|標記 1 條
⚠ 另有 1 行同時提到一組親緣欄位,但工具沒解釋:L262
(這是刻意退回第一版才看得到的輸出。真的跑那十份產物時,守衛一次都沒響。)
這件事寫成第 6 組 KAT:守衛必須被看過響一次,否則它沒有被驗收過。
昨天那條在正確實作上就紅掉的關係:固定年支出與其他收入同時加二十萬,主張目標金額不變,錯在單位(一個是年額,一個程式裡會再乘十二,差了十二倍)。
這支工具看不到它。 那兩個欄位確實來自不同的計算路徑,結構上完全合格。斷言檢核器也看不到,它給了那份 100% 忠實性。
所以分工線畫成三段:
| 誰做 | 查得多完整 | |
|---|---|---|
| 這支程式有哪些欄位對是親戚 | 工具(量程式行為) | 五個欄位兩兩查過、三個純量的加減也查過。但只查這幾種形式 |
| 哪些斷言碰到它們 | 工具(比對語法) | 不完備,所以要自報漏了幾處 |
| 這條關係在數學上對不對 | 人,而且要做完代數 | — |
第三段目前沒有任何一把尺看得到。
工具本身綁死在一個退休金計算機上,抄了沒用。可以抄的是順序——先量程式,再掃測試。
第一步,量你自己的回傳物件。 跑幾百組輸入,欄位兩兩比對,問三件事:
這張表跟測試無關,是程式的性質,做一次可以用很久。而且它會冒出你沒想到的那幾組——我就冒出一組。
第二步,拿那張表去掃測試。 分界線很清楚:同一次呼叫的兩個輸出互比,可疑;跨呼叫的互比,正常。 前者在問「這支程式對自己一致嗎」,後者才是在問「兩次執行之間的關係」。
第三步,讓你的掃描自己承認漏了幾處。 這步最容易省掉,也最不該省。
常見的可疑形狀:
| 形狀 | 要查的 |
|---|---|
resp["count"] == len(resp["list"]) |
count 是不是回傳前才 len() 出來的 |
summary.total == sum(rows) |
total 是不是把 rows 加一次而已 |
cached == fresh |
兩邊是不是同一個查詢 |
寫這支工具的時候,有一刻覺得自己在做白工。
昨天那個檢查真的只要三十秒。為了一個三十秒的動作寫六組 KAT、一道守衛、兩層報表,怎麼算都不划算。
划不划算的算式錯在一個地方:我拿工具跟「做得對的人工檢查」比。但昨天的事實是,我根本沒查對——我查了一對欄位就以為查完了。它便宜,但它的良率不是一。
比較刺的是後面那段。工具寫完,漏了一條;守衛寫完,是壞的。我一邊寫著「人會略過檢查而且自我感覺良好」,一邊做了一模一樣的事。
差別只有一個:程式碼可以再跑一次,而且跑出來的數字會不一樣。腦子裡的那次檢查不會。
這些數字不能拿來做什麼
LINEAR 那一列是 200 組輸入上的推論,不是證明。IDENTITY 是 is 判定,那一列才確鑿工具、親緣表與逐條輸出在 manifest.yaml。
只帶走一件事
要查的不是「這條斷言可疑嗎」,是「這支程式有哪些欄位對是親戚」。
前者要你先看出來,後者是量出來的。
這把尺量的是結構,而且它知道自己漏了什麼。
結構之外那一半,連漏了什麼都不知道。
今天的 repo:https://github.com/eyelash500/2026_ironman_test_ai/tree/day27