
第一幕結束在一句話:我知道它現在怎麼動了,但還不知道它應該怎麼動。
今天開始處理後面那半句。
(規格考古,spec recovery:面對一個沒有需求文件的既有系統,從程式碼、UI 文案、預設值、歷史版本裡逆向反推出「它當初被期待做什麼」的過程。名字聽起來很酷,實際做起來像在整理別人的抽屜——而那個別人是去年的我。)
這支工具 2025 年 9 月上線,跑了一年。它沒有需求文件、沒有規格書、沒有驗收條件。有的只是一段我丟給 AI 的對話,和一份 416 行的 HTML。
沒有任何一句話寫過「退休那一年算不算支出」,但程式碼寫了。
第 306 行的迴圈從 i = 1 開始,age = retirementAge + i,所以第一個被計算的年份是 66 歲,也就是退休那年不計支出,壽命那年計入。這是一個決定,它已經生效一年了,只是從來沒有人做過它。
這就是第二幕要處理的東西。不是找 bug,是找那些沒有人做過、卻已經在生效的決定。
最直覺的做法是:把 oracle/calc.js 丟給 AI,說「幫我從這段程式碼整理出規格文件」。它會給你一份看起來很專業的東西。而那份東西的每一條,都只是把程式碼換成中文再寫一遍。
Day 5 我們拒絕過同一件事:那時候是「沒有規格,就不能用重寫的版本當裁判」。今天是它的鏡像:沒有規格,就不能把程式碼當規格。程式碼是被告,不能兼任證人。~~(裁判、球證、旁證、技術委員、主辦、協辦,所有單位都是我的人,你們怎麼跟我鬥!)~~如果那樣做,第 306 行的 i = 1 會變成規格書裡的「退休當年不計支出」,語氣篤定得像有人想過。實際上沒有。
所以我改用笨方法:把計算核心從頭到尾走一遍,每遇到一個「程式替使用者做了決定,而原始碼裡沒有任何一句話解釋為什麼」的地方,就記一筆,走完是十個。
| # | 現場 | 程式碼座標 | 程式默默決定了什麼 | 性質 |
|---|---|---|---|---|
| RC-01 | 退休當年與壽命當年 | 306、349 行 | 退休那年不計支出,壽命那年計入 | 沉默決定 |
| RC-02 | 折現與提領的時點 | 321 vs 372 行 | 目標金額用期末、提領用期初 | 已知缺陷 A |
| RC-03 | 名目月利率 | 297 行 | 用 r/12,不是有效月利率 |
歧義 |
| RC-04 | 同帳戶兩種複利頻率 | 296 vs 298 行 | 本金年複利、月存月複利 | 歧義 |
| RC-05 | 淨支出下限截斷 | 320、363 行 | 收入大於支出時,盈餘直接蒸發 | 歧義 |
| RC-06 | 大筆支出沒有上界 | 329 行 | 只檢查下界,身後的支出也計入 | 已知缺陷 C |
| RC-07 | 退休收入的通膨處理 | 312–318 行 | 勞保、勞退全額隨通膨逐年調升 | 假設 |
| RC-08 | 年支出的通膨基準 | 302–303、310 行 | 月支出×12 與年度支出共用同一組通膨指數 | 沉默決定 |
| RC-09 | 報酬率的切換點 | 296 vs 321、372 行 | 退休當年起,全部切換為退休後報酬率 | 沉默決定 |
| RC-10 | 輸入防呆 | 215、275–290 行 | 金額欄位靜默補 0、年齡欄位 NaN 整筆拋棄 |
已知缺陷 D |
數一下:十個裡面只有三個是已知缺陷。
另外七個,程式現在跑得好好的,測試全綠,使用者也沒有抱怨,但它們每一個都是一個沒被回答的問題。而缺陷 B 和 A' 不在這張表上——它們不是規格問題,是實作沒做好。壽命填相反要不要擋,跟「退休那年算不算支出」不是同一種問題:前者有標準答案,後者需要有人拍板。
/12 到底差多少第 297 行把年報酬率除以 12 當月利率。Day 3 提過它,但沒算過它值多少錢。
要先講清楚它用在哪裡:只用在月存那一段。 第 296 行的既有本金走的是 Math.pow(1 + r, 年數),正正經經的年複利;r/12 只出現在第 298 行的月存終值公式裡。
所以拿基準情境(42 歲、65 歲退休、報酬率 8%、月存 2 萬)實跑那一段:
用 r/12(現況) 15,774,622
用有效月利率 15,142,806
差額 631,816
**整整六十三萬。**這裡我刻意只寫絕對金額,不寫百分比。原因在 Day 4 提過:這 63 萬如果除以現況值是 4.01%,除以正確值卻是 4.17%,同一件事兩種說法,不如直接看金額。而這 63 萬的灌水,使用者在畫面上完全看不到。它唯一的功用,就是默默讓你的退休金看起來更充裕。
但它是不是錯的?我沒有明確答案。我的直覺是「銀行的定存、房貸都這樣算,這是業界通則」——但那只是直覺,我沒查過。
如果使用者心裡想的是「我的投資年化 8%」,那就高估了;如果想的是「每月 0.667%」,那就是對的。而沒有任何一份文件說過是哪一種,這才是「歧義」的意思。不是誰算錯,是問題本身沒被問完整。順帶一提,這一條跟 RC-04 是同一個現場的兩面:同一個帳戶、同一個報酬率,本金按年滾、月存按月滾。兩種頻率並存在相鄰的兩行。
第 312–318 行,勞保年金、勞退月領、其他收入,全部逐年乘上通膨係數:
inflatedAnnualIncome += laborInsurancePension * 12 * Math.pow(1 + inflationRate, yearsFromNow);
也就是說,程式假設勞保年金每一年都會足額跟上通膨。
現實不是這樣。依《勞工保險條例》第 65 條之 4,勞保年金要等到消費者物價指數累計成長率達 5% 才調整一次。實際運作起來是這樣:2026 年 5 月,2011、2012、2016、2017、2018、2022 這六個年度請領的人調升 6.46%,約 76 萬人受惠,而在那之前,他們已經好幾年沒調過了。程式是連續的,現實是階梯式的,中間那幾年的落差,這支工具看不到。
這一條我標成「假設」而不是「歧義」,因為它跟現實有明確的落差。只是這份落差並不是出於刻意簡化,而是因為我當初寫程式的時候,根本就沒有想到。
十個現場我不打算靠記憶管理,每一個都寫成同樣的格式,放進 spec/ 底下。以 RC-05 為例:
# RC-05:淨支出下限截斷
- **性質**:歧義
- **程式碼座標**:`sut/進階退休規劃試算.html` 320、363 行
## 現有實作
const netExpenseForYear = Math.max(0, inflatedAnnualExpense - inflatedAnnualIncome);
## 隱含假設
某年度收入大於支出時,該年淨支出以 0 計。多出來的錢既不抵扣其他年份,
也不滾入本金——直接消失。
## 待裁決
- A. 維持現況:盈餘不滾存。保守,避免高估晚年資產
- B. 允許淨支出為負:盈餘滾入本金並產生報酬
- C. 全期平準:累積期與提領期之間動態再平衡
## 裁決
最後那一格今天是空的。
分界在最後一欄:「待裁決」的選項可以由 AI 列,「裁決」那一欄不行。
上面那三個選項確實是 AI 幫我列的——列出技術上可行的做法,它做得比我快也比我期權。但「這個工具要對使用者做出什麼承諾」是產品決定,不是程式決定。這也是為什麼閘門 1 到明天才登場。今天只負責把問題找齊,不做答。找問題和做決定分開,是為了避免一邊挖一邊順手把自己喜歡的答案塞進去。
明天開始,每一條都要有一個裁決,而每一個裁決都要能變成一條可執行的測試。到那時候才會知道,Day 7 那 23 組 golden set 裡面,有多少組鎖住的是「對的行為」,多少組鎖住的只是「當初剛好那樣寫」。
我現在的猜測是後者比較多。
整理這十條的時候,我一直有一種很怪的感覺:我在幫一個不存在的人做會議紀錄。
那些決定確實被做出來了——程式在跑,使用者在用,數字有出來。但沒有任何一個時刻,有人坐下來說「我們決定退休當年不計支出」。它就那樣從 i = 1 裡長出來了,然後生效了一年。我去年寫那段程式碼的時候,腦袋裡在想的是「迴圈要從 1 還是從 0 開始」,一個純粹的語法問題。一個語法選擇,變成了一條沒人知道的業務規則。
只帶走一件事
沒有規格的測試,只是把程式碼的偏見換一種語法再寫一遍。
今天的 repo:https://github.com/eyelash500/2026_ironman_test_ai/tree/day08