
前四天都在講「它哪裡壞了」。今天開始做「怎麼證明它壞了、以及往後不再壞」。
第一件工具是影子模型:一支獨立的參照實作,專門用來回答「這個數字到底該是多少」。
(影子模型:在金融業,重要的計價模型通常會有第二套獨立實作來對照,用來檢查主模型有沒有偏掉。這裡借用同樣的精神。)
受測物是一支 416 行的單一 HTML 檔。麻煩的不是大小,是「計算邏輯的位置」,整段包在一個按鈕的事件處理器裡面:
calculateBtn.addEventListener('click', () => {
const currentAge = parseInt(document.getElementById('currentAge').value);
// ...一百多行的計算,中間夾著 document.getElementById、
// querySelectorAll、textContent 指派
});
沒有一個叫 calculate() 的函式可以呼叫。要拿到計算結果,唯一的入口是「按下按鈕」。
所以想測它,我們得開瀏覽器、填表單、按按鈕、讀畫面上的數字。跑十組可能還行,跑五百組就會開始遇到渲染延遲、非同步、DOM 查詢失敗這些跟「計算對不對」完全無關的問題。
更糟的是昨天講過的那件事:畫面上的數字已經被美化過。程式碼在第 374 行將負餘額夾成 0,我們從圖表資料取值,拿到的是被夾過的結果。因此用畫面當測試對象,等於讓遮羞布同時騙過使用者和測試。所以第一步不是寫測試,是把計算核心從畫面裡剝出來。
把計算核心從畫面裡剝出來的方式有兩種,而它們會長成完全不同的東西。
Math.max(0, ...) 也照抄。兩種都叫「獨立參照實作」,但它們回答的是不同的問題:
| 機械式抽取 | 重新實作 | |
|---|---|---|
| 差分測試在測什麼 | 我有沒有搬錯 | 它有沒有寫對 |
| 缺陷 A 會不會被抓到 | 不會(兩邊都錯) | 會(兩邊必然不同) |
| 什麼時候有用 | 行為固定、回歸防護 | 驗證規格符合度 |
| 前提 | 不需要規格 | 需要規格 |
第二欄看起來比較好——它能直接抓到缺陷 A。但它有一個前提:你得知道「對」是什麼。
而這支工具沒有規格。沒有任何一份文件說過錢該在年初還是年底離開帳戶。如果我現在就「照財務數學重寫一份」,我寫的其實是「我自己認為」的正確版本,然後拿它去說原版錯了。那不是驗證,是用一個沒人審過的假設去否定另一個沒人審過的假設。
所以我選機械式抽取。
這個決定的代價要講清楚:明天的差分測試,抓不到四個缺陷裡的任何一個,兩邊會一起錯,而且錯得一模一樣。
那它有什麼用?
這支抽取出來的 oracle,回答的是一個基礎但關鍵的問題:這支程式「現在」到底是怎麼動的?
聽起來很廢,但這是所有後續工作的地基。等到後面我要修這四個缺陷的時候,我需要知道哪些行為改變是「修好了」,哪些是「順手改壞了別的」。沒有一份釘死現況的參照,改完之後你分不出來。
至於「它寫得對不對」——那個問題要等第二幕,我得先將規格挖出來,有了規格才有資格重寫,重寫的版本才有資格當裁判。
這也是為什麼這系列的順序是這樣排的:先釘行為(第一幕),再挖規格(第二幕),最後才談測試夠不夠兇(第三幕)。

讀完那 210 行 script,可以看出界線其實很清楚:
| 行數 | 內容 | 處置 |
|---|---|---|
| 274–290 | 從 DOM 讀十幾個欄位 | 改成函式參數 |
| 292–375 | 純計算 | 逐行照抄 |
| 377–402 | 寫回畫面、改 CSS class、畫圖表 | 改成回傳物件 |
所以抽取規則我寫成四條,放在 oracle/calc.js 的檔頭:
1. 292–375 行逐行照抄,運算順序、括號位置、變數名一律不動
2. 讀 DOM 的輸入改為函式參數
3. 寫 DOM 的輸出改為回傳物件
4. 不修正任何已知缺陷——包括 293 行的無防護減法、297 行的 /12、321 行的期末折現、329 行的缺上界、374 行的夾 0。這是 oracle,不是修正版
第 4 條最重要。抽取的時候手指頭會很癢,尤其看到 Math.max(0, remainingFund) 那行,但只要動它一下,oracle 就不再是「現況的紀錄」,而變成「我認為的正確版」(那就回到前面說的問題了)。
只有一個東西是新增的,而且我在註解裡標明「抽取時新增」:
balancesRaw.push(remainingFund); // 抽取時新增:未夾 0 的真實餘額
assetData.push(Math.max(0, remainingFund)); // 374 遮羞布,原樣保留
原版只有後面那行。我多留一份未經處理的餘額——不是修掉遮羞布,是在遮羞布旁邊放一臺不會說謊的儀器。
Python 這邊的骨架:
@dataclass(frozen=True)
class Params:
current_age: int
retirement_age: int
life_expectancy: int
current_savings: float
monthly_investment: float
monthly_expense_today: float
annual_income_after_retirement: float = 0.0 # 退休後年收入
pre_retirement_return: float = 0.08
post_retirement_return: float = 0.04
inflation_rate: float = 0.02
退休後多半還會有一點兼職或被動收入,所以我憑著直覺,順手多留了 annual_income_after_retirement 欄位。
三個刻意的設計:
frozen=True:參數進去之後不能改。金融計算最怕的就是「算到一半有人動了某個變數」,凍結它,這種 bug 從根上消失。balances: tuple[float, ...] # 逐年真實餘額
balances_charted: tuple[float, ...] # 圖表看到的(夾 0 後)
這是為了缺陷 A'。我需要能同時看到「真相」和「畫面呈現的東西」,才能量出兩者差多少。如果影子模型只回傳一種,我就等於把遮羞布也一起抄過來了。
這支 Python 是 AI 產的,那誰來檢查它有沒有搬錯?
規則是:每個 AI 產物都要有一個在它生成之前就由人定好、而且不是它自己給的驗收器。
影子模型的驗收器就是明天要做的差分測試,用五百組隨機輸入,Python 和 JavaScript 兩邊跑,逐筆比對。驗收標準我現在就定下來,不等看到結果再定:
最後那條是重點,跑完再定容許誤差,等於照著結果調標準。因此我先填 1e-9,明天就知道這個數字會不會打我的臉。
寫這篇的時候,我一直很想在抽取的同時「順手把 Math.max(0, ...) 拿掉」。
那行就在眼前,改它只要刪七個字元,而且我知道它是錯的——昨天才花一整篇在講它怎麼把 81.6 萬藏起來。
沒改的理由不是紀律,是我想不出改了之後 oracle 還能拿來做什麼。它一旦跟受測物不一樣,明天的差分測試就會噴出一堆「不符」,而那些不符全部來自我自己動的手,不是來自任何真正的問題。
oracle 的價值完全來自它跟受測物一模一樣,包括一模一樣地錯。
這件事說起來很順,但手放在鍵盤上的時候完全不是那樣。
只帶走一件事
沒有規格的時候,你的「獨立實作」只能證明自己抄得對,不能證明原版寫得對。
免責聲明:本文提及之退休試算工具與所有數字皆為假設性試算,不構成任何投資建議。該工具目前已知存在計算偏差,修正版製作中,結果僅供參考。