
先把歸因釘死:這四個缺陷,是我用 AI 輔助的人工審查、逐行比對找出來的。 不是差分測試抓到的,不是變異測試抓到的——那些東西我還沒開始建。
之所以先講,是因為接下來的方法論很容易被寫成「我用了 X,然後 X 幫我找到四個 bug」。那是倒果為因。這些方法的職責是把已知行為固定住、建立回歸防護,不是事後邀功。
先有屍體,才有驗屍。
這個試算工具的所有輸入都是文字框。文字框裡可以打任何東西。
第 215 行有一個負責把文字轉成數字的函式:
const parseNumber = (str) => parseFloat(String(str).replace(/,/g, '')) || 0;
它會先把逗號拿掉——這是體貼的設計,使用者打 1,000,000 也能用。但後面那個 || 0 是問題所在:轉不出來就給 0。
所以當我在「每月投入金額」欄位打了 100萬——很自然的寫法——parseFloat 回傳 NaN,NaN || 0 得到 0。畫面不會提示,不會變紅,不會擋你。它就當你這個月存了 0 元,然後繼續往下算。
而年齡欄位更麻煩一點。大筆支出的年齡走的是 parseInt(第 327 行),轉不出來的時候會產生 NaN。而 JavaScript 有一條規則值得記住:
NaN 跟任何數字比大小,答案永遠是 false。
NaN >= 65 是 false,NaN < 65 也是 false,兩個都 false。
第 329 行的判斷式是 if (age >= retirementAge),所以那筆支出被整筆丟掉。不是算錯,是根本沒參與計算。因此,一個打錯字的使用者,會拿到一份完全乾淨、完全沒有警告,而且完全不對的試算結果。
第 293 行在算「退休後要活幾年」,就是一個減法,沒有任何檢查:
const retirementYears = lifeExpectancy - retirementAge;
如果使用者不小心把「預期壽命」填得比「退休年齡」還小——比如 65 歲退休、壽命填成 60——這個數字會變成 -5。
而第 306 行的迴圈長這樣:
for (let i = 1; i <= retirementYears; i++) {
1 <= -5 是 false,所以這個迴圈一次都不會跑。於是 targetFundForExpenses 就停在初始值 0,需要的目標金額變成了 0 元。最後系統拿你的累積資產去跟 0 比,你當然贏,畫面直接跳出:「恭喜您準備金充足」。
使用者輸入了一組在現實中不可能成立的年齡,系統沒有攔他,反而恭喜他。
這個工具可以填「退休後的大筆特殊支出」(醫療準備、長照、給小孩的一筆錢),填入年齡和金額後,它會把這筆錢算進你的目標金額。
但問題在於,它只檢查了下界:
if (age >= retirementAge) {
// 計入目標金額
}
它問了「這筆支出是不是在退休之後?」,但沒問「這筆支出是不是在你還活著的時候?」
而畫圖那邊的迴圈(第 349 行)只跑到 retirementYears,也就是最多到預期壽命。第 367 行用 === age 去比對哪一年該扣這筆錢——迴圈根本走不到 95 歲,所以永遠配不上。
所以我們可以填一筆「95 歲、200 萬」的支出,而你的預期壽命是 85 歲。系統會把這 200 萬算進目標金額,經過通膨換算,目標金額會變大 306 萬。畫面會告訴你缺口變大了,你得多存錢。
但接著看那張資產消耗折線圖——圖表在 85 歲就結束了,那筆 200 萬在圖上沒有留下任何痕跡。上面的數字說有,下面的圖說沒有,而兩邊都是同一支程式算的。
前面三個都還算好懂。這一個不是。
昨天講過,這個工具同時走兩條路:一條算資產累積,一條算退休目標金額。缺陷 A 就是這兩條路對不齊。
算目標金額時,會經過這一段:
for (let i = 1; i <= retirementYears; i++) {
// ...
const pvOfNetExpense = netExpenseForYear / Math.pow(1 + postRetirementReturn, i);
targetFundForExpenses += pvOfNetExpense;
}
i 從 1 開始,第一年的支出就被折現一整期。這是「每年年底才花錢」的假設。
而在模擬資產怎麼被花掉時:
remainingFund = (remainingFund - (netAnnualExpense + largeExpenseForYear)) * (1 + postRetirementReturn);
先扣錢,再滾利息。這是「每年年初就把生活費拿走」的假設。
同一份程式,同一個變數 postRetirementReturn,兩邊對「錢什麼時候離開帳戶」的答案不一樣。年初拿走跟年底拿走,差一年的利息。所以目標金額被系統性地低估了,而且低估的幅度不是隨便一個數,是正好一個 (1 + r) 倍(明天會把推導完整寫出來)。低估目標金額本身已經夠糟——它讓你以為需要準備的錢比實際少。但真正把這件事變成災難的是下一行。
在畫圖之前,把負數的餘額夾回 0:
assetData.push(Math.max(0, remainingFund));
於是當一個使用者剛好存到系統算出來的目標金額,畫面告訴他「缺口 0 元」,圖表也畫出一條漂亮地貼著地面滑行的曲線——而他 85 歲那年的真實帳戶餘額是 −862,034。

那條貼地的曲線不是「剛好花完」,是「已經透支了 86 萬,但我不打算讓你看到」。我把這兩個放在一起編號成 A 和 A',因為單獨看缺陷 A 只是一個少算的公式,加上第 374 行才變成一個會主動製造錯誤安全感的系統。
當把年報酬率除以 12,當成月報酬率:
const monthlyPreReturn = preRetirementReturn / 12;
嚴格來說這不是缺陷,是模型假設的簡化——年化 8% 對應的有效月利率應該是 (1 + 0.08)^(1/12) - 1,直接除以 12 會偏高一點點,每個月偏一點點,累積二十幾年就不只一點點。我實測下來,這個簡化讓累積資產高估了 5.0%,金額約 151 萬。
而且它不只出現在這一支程式裡。我把去年做的幾個工具全部 grep 過一遍,年報酬率 / 12 在三個獨立生成的檔案裡總共出現七次——每一次我要 AI 做月複利,它就給我這個寫法。這不是打錯字,是穩定行為。 完整比對留到 Day 19 的金融系統錯法目錄。
為什麼要在驗屍報告裡提它?因為它示範了一件事:不是所有的錯都是 bug。 有些錯是「你選了一個跟現實不完全相符的模型」,程式碼本身沒有語法問題,寫的人也不覺得自己寫錯了。這種錯特別難抓,因為它不會爆炸,只會慢慢偏。
它也是 Day 6 要講的「差分測試的天花板」的活體樣本。
Day 1 提過,我當年在文章裡示範的那個版本還在,而它一個缺陷都沒有。驗屍報告該附上對照組,所以我把兩份程式並排讀了一次。
| 缺陷 | 這一份 | 文章版 | 文章版為什麼躲掉 |
|---|---|---|---|
| B 壽命倒掛 | 293 行純減法 | 沒有 | 有一行明確驗證,壽命 ≤ 退休年齡就 alert() 然後 return |
| D 輸入靜默失敗 | 215 / 327 行 | 大致擋住 | 收集支出時明確 !isNaN() 過濾,主要欄位也一併檢查 |
| A 跨路徑假設不一致 | 306 / 321 vs 372 | 結構上不可能 | 它根本沒有目標金額折現路徑 |
| A' 負值遮蔽 | 374 行 | 不成立 | 夾 0 只用在圖表資料,缺口數字用真實餘額,負值會顯示紅色「資產耗盡」 |
| C 幽靈支出 | 329 行只檢查下界 | 沒有 | 沒有目標金額路徑,超壽命的支出上下都不算,一致 |
/12 |
297 行 | 沒有 | 純年複利 |
最值得看的是 A 那一列。文章版的原始碼裡有一行註解,是這樣寫的:
// 新的缺口計算方式:直接使用預計壽命結束時的最終資產
它不算目標金額,它就一路模擬到壽命終點,看帳戶裡還剩多少。只有一條路,所以沒有兩條路可以對不齊。
這件事的意思不是「文章版比較好」。它功能少得多,能回答的問題也少得多。真正的意思是:缺陷 A 不是「AI 不會寫年金公式」。 同一個模型,另一次生成,用一個更簡單的架構完全繞過了整個問題。它會寫。它只是在功能變多的時候,選了一條會分岔的路。
缺陷是架構選擇的後果,不是能力問題。而架構選擇沒有人在把關——當時的我只說了「幫我加功能」。
把它們排在一起看,會發現一件事:它們根本不是同一種東西。
| 層級 | 缺陷 | 該用什麼擋 | 修起來要多久 |
|---|---|---|---|
| 一:介面防呆 | D(輸入靜默失敗)、B(壽命倒掛) | 欄位驗證、一個 if | 一分鐘 |
| 二:規格與邊界 | C(幽靈支出) | PRD 寫清楚上下界、需求追溯 | 規格層的事 |
| 三:核心精算 | A(跨期假設不一致)、A'(負值遮蔽)、/12 假設 |
獨立參照實作、差分、蛻變 | 得先有第二個實作才看得見 |
D 和 B 用一行防呆就擋住了。為它們建一整套變異測試框架是拿大砲打蒼蠅。而 A 和 A' 你再怎麼防呆都擋不住——輸入完全合法、程式完全不報錯、每一條路單獨算都對。我們需要的是一個不是它的第二意見。
這張表就是整個系列為什麼要分幕的原因。不分級,你會把力氣花在最容易的地方,然後對最貴的缺陷毫無防備。
初稿裡,缺陷 B 我原本寫的是「壽命填反了,變成負數,然後被 Math.max 夾回 0」。結果回頭翻原始碼才發現,根本沒有 Math.max,那就是一個減法。真正的機制是負數讓迴圈一次都不執行,targetFundForExpenses 停在初始值。
只帶走一件事
最危險的那兩個缺陷,剛好是最便宜的手段完全碰不到的。
免責聲明:本文提及之退休試算工具與所有數字皆為假設性試算,不構成任何投資建議。該工具目前已知存在計算偏差,修正版製作中,結果僅供參考。