iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
AI Engineering

AI 寫的測試,誰來測?系列 第 6

【Day6】500 組差分,第一次就掛了

  • 分享至 

  • xImage
  •  

TL;DR

  • 差分測試第一次跑就 496/500 不符——而錯的是我,不是受測物
  • 修完之後 500 組全過,1500 個數字裡有 1182 個兩邊逐位元相同,最大浮點誤差 1.0e-14
  • 但這 500 組全過的測試,四個缺陷一個都沒抓到,而且註定抓不到

https://ithelp.ithome.com.tw/upload/images/20260906/20103826teYESnhUzb.jpg


前言

昨天把計算核心抽成了 oracle/calc.js,也寫了一支 Python 影子模型。兩邊都宣稱在做同一件事。今天驗證這句話。

(差分測試:拿兩個應該等價的實作,餵同樣的輸入,比對輸出。不需要知道正確答案是什麼,只需要知道「兩邊應該一樣」。)

驗收標準先講

昨天定的內標準:

  • 500 組隨機輸入,全部相符才算通過
  • 相符 = 相對誤差 < 1e-9
  • 不相符時,預設是我移植錯,不是受測物錯

第三條特別重要,等一下就會知道為什麼。

第一次跑

跑了 500 組,不符 992 組(rel_tol=1e-09)
  case   0 targetFund     js=  34,504,798.039014  py=  45,309,539.413736  rel=3.131e-01
  case   1 targetFund     js=  11,144,529.895791  py=  11,573,165.661014  rel=3.846e-02
  case   2 targetFund     js=  61,663,722.679646  py=  79,272,528.305568  rel=2.856e-01
按欄位: {'targetFund': 496, 'retirementGap': 496}

500 組裡 496 組不符。而且不是浮點精度那種第十五位小數的差異——相對誤差的中位數是 48%,最兇的一組差了 62 倍。

而且分布很有趣:

  • projectedSavings(累積期):0 組不符
  • targetFund(目標金額):496 組不符
  • retirementGap:496 組不符,但它是前兩者相減,所以不是獨立的錯

累積期完全正確,目標金額幾乎全錯。這種「一半乾淨,一半全爛」的分布,通常代表不是計算精度問題,而是某個路徑整段漏了。差成這樣,容許誤差設多少都會紅——第二條標準 rel_tol=1e-09 這一輪根本沒被考驗到。

錯的是我

去比對兩份程式碼,五分鐘就找到了。

oracle/calc.js 這邊(抽自受測物 312–318 行),退休後的收入有三個來源,各自有起領年齡:

let inflatedAnnualIncome = otherIncome * 12 * Math.pow(1 + inflationRate, yearsFromNow);
if (age >= laborInsuranceStartAge) {
    inflatedAnnualIncome += laborInsurancePension * 12 * Math.pow(1 + inflationRate, yearsFromNow);
}
if (age >= laborPensionStartAge) {
    inflatedAnnualIncome += laborPensionMonthly * 12 * Math.pow(1 + inflationRate, yearsFromNow);
}

受測物的第 312 行原文是 parseNumber(document.getElementById('otherIncome').value) * 12 * ...,連年度迴圈裡面都在讀 DOM,抽取時才換成參數。昨天說「計算邏輯的位置才是麻煩」,指的就是這種東西。

而我的 Python 只有一個欄位:

annual_income_after_retirement: float = 0.0

一個籠統的「退休後年收入」,沒有勞保、沒有勞退、沒有起領年齡。

所以 Python 算出來的目標金額,是「完全沒扣任何收入」的版本,當然比 JS 大,而且差多少取決於那組隨機輸入配了多少勞保、勞退。484 組的相對誤差從 0.52% 一路散到 6217.6%,中位數 48%,正好對應「這組有沒有收入、收入多大」。

剩下 12 組更極端:JS 那邊的目標金額是 0。

case  41   js=0.00   py=24,585,232.58
case 103   js=0.00   py=14,688,671.69
case 107   js=0.00   py=11,628,415.36

不是程式壞了。是第 320 行那個 Math.max(0, 膨脹支出 − 膨脹收入)——這幾組隨機配到的勞保加勞退蓋過了支出,每一年的淨支出都被夾成 0,累加起來目標金額當然是 0。而當時的 Python 沒有「收入」這回事,這 12 組照樣算出一百八十萬到兩千四百萬不等的目標金額。

順帶一提,這 12 組沒有「相對誤差」可言,分母是 0。如果不把它們挑出來,整批的最大誤差會是 2.5e+19,那個數字唯一的意思是「有人拿 0 當分母」。統計量自己也要被檢查一次。

原因很單純:我當時是先寫 Python 才去讀 HTML。寫的時候,我腦中的預設是「退休後主要是支出,可能有一點收入」。那只是我對退休試算的常識,並不是這支程式實際的邏輯。

這就是為什麼驗收標準第三條要寫「預設是我移植錯」,如果預設反過來,我現在大概已經發了一篇標題叫〈我發現有第五個缺陷〉的文章了。

修完再跑

把三個收入來源和起領年齡補進 Python:

def _inflated_income(p: Params, age: int, years_from_now: int) -> float:
    """312-318 行:三個收入來源,各自判斷起領年齡。"""
    infl = (1 + p.inflation_rate) ** years_from_now
    income = p.other_income * 12 * infl
    if age >= p.labor_insurance_start_age:
        income += p.labor_insurance_pension * 12 * infl
    if age >= p.labor_pension_start_age:
        income += p.labor_pension_monthly * 12 * infl
    return income
跑了 500 組,不符 0 組(rel_tol=1e-09)

那個 1e-9 定得對嗎

既然過了,看一下實際的誤差長什麼樣:

比對範圍: 500 組 × 3 個欄位 = 1500 個數字
完全逐位元相同: 1182 / 1500
有差異的 318 筆,相對誤差:
  最小 1.113e-16   中位 1.760e-16   最大 1.012e-14
  超過 1e-14 的筆數: 1

接近八成的數字兩邊逐位元完全相同。有差的那 318 筆,最大 1.0e-14,而且只有一筆踩到 1e-14。我昨天定的 1e-9,比實際需要鬆了五個數量級。

那要不要改緊?

現在不改。 這一輪的門檻是在看到結果之前定的,那是它唯一的價值;跑完再往下調,就算調得有道理,也已經是在照結果配標準了。我把這個數據記進 decisions.md,下一輪要重新定門檻的時候,它是有依據的參考。

兩邊會有 1.0e-14 這種差異,不是誰算錯,而是 JavaScript 和 Python 的浮點數都是 IEEE-754 double,同樣的運算做同樣的次數會得到同樣的位元。差異來自 Math.pow** 在極端指數下的實作細節。接近八成完全相同,剩下的差在最後一兩位,這個比例很正常。

但四個缺陷呢

500 組全過。所以受測物沒問題了?

把四個缺陷的情境直接餵進去(基準參數:42 歲、65 歲退休、85 歲壽命、零本金零月存、月支出 3 萬、退休前後報酬率 8%/4%、通膨 2%、無退休後收入;各列只改掉標題寫的那一項):

情境                         JS targetFund     PY targetFund  差分結果
------------------------------------------------------------------------
缺陷B 壽命60<退休65                     0.00              0.00  相符 → 測試通過
缺陷C 95歲200萬              11,078,990.15     11,078,990.15  相符 → 測試通過
缺陷A 正常輸入                  9,317,667.50      9,317,667.50  相符 → 測試通過

全部相符,全部通過。

壽命填反了,兩邊一起算出 0。95 歲的幽靈支出,兩邊一起計入目標金額、一起不畫在圖上。期末折現,兩邊一起少算一個 (1+r)

這不是差分測試「不夠好」,是在結構上就不可能抓到。

昨天選了機械式抽取,抽取規則第 4 條寫著「不修正任何已知缺陷」。既然兩邊照抄,兩邊當然一起錯。差分測試比對的是「兩個實作有沒有不一致」,而缺陷 A 在兩邊是一致的

不過表上只有三個,剩下的缺陷 D,以及負責掩護 A 的那塊遮羞布(A'),連上桌的機會都沒有:

  • 缺陷 DparseIntparseFloat 讀到空欄位拿到 NaN)活在 DOM 讀取層,也就是受測物的 275–290 行。昨天抽取的時候,那一層整段被換成了函式參數——差分測試不是抓不到它,是連碰都碰不到。
  • 缺陷 A'Math.max(0, ...) 夾住負餘額)不在目標金額這張表上,它發生在逐年餘額。兩邊一樣照抄了那行,所以結果也一樣:一起夾,一起過。

所以這裡其實有兩種盲區:一種是兩邊一起錯,一種是根本不在測試的射程內。

差分測試的天花板

開門見山地說,差分測試驗證的是一致性,不是正確性。

它能回答「這兩份程式有沒有做同一件事」,回答得非常好。今天五分鐘內就抓到我漏掉整個收入模組。但它永遠回答不了「這件事本身該不該這樣做」。那個問題需要別的東西:

  • 一份說清楚「什麼叫對」的規格——第二幕在挖
  • 幾條不需要知道答案也能檢查的不變量——第四幕的蛻變測試
  • 或者,故意把程式弄壞,看測試會不會發現——第三幕的變異測試

這也解釋了為什麼影子模型的兩種做法會長成完全不同的東西。如果昨天選了「重新實作」,今天這 500 組會有一大堆不符,而那些不符裡有一個是缺陷 A。但我會分不出來哪個不符是真缺陷、哪個是我自己對財務數學的理解不同。沒有規格的時候,不符只是不符,不是證據。

後記

第一次跑出 496 組不符的時候,我第一個念頭是「哇,抓到了」,但那個念頭持續了大概三秒。

如果驗收標準沒有事先寫下「預設是我移植錯」,我很可能會先花半小時去研究「受測物的目標金額為什麼會少算」,而不是去看自己的 Params 少了哪些欄位。而且我絕對找得到理由,因為任何差異都能編出一個故事,更何況我已經知道那支程式有四個缺陷了。

事先定好「誰是預設嫌疑犯」,省下的不是時間,是一個我本來會很樂意相信的錯誤結論


只帶走一件事
差分測試告訴你兩份程式有沒有做同一件事,不會告訴你那件事該不該做。


上一篇
【Day5】打造影子模型:抽取,還是重寫?
系列文
AI 寫的測試,誰來測?6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言