昨天的 JWT,紅燈是因為時鐘。今天的題目是規格會變:規則改了,叫 AI 照新規則改,它改得對嗎?會不會幫你想到漏掉的地方?
先講結論:關鍵規則寫完整的時候,它幫得很好 —— 不到一分鐘改完,刻意製造的 2,000 組平手全部選對。規則有留白,它會先把疑點問出來,連「要不要提示客人」這種我沒想到的都問了;只是在你回答之前,它也會先替你選一個答案。
最後還有一種「會變」:規則沒改,是價格改了。這時候該跟著變的是新訂單,不該變的是舊訂單 —— 而這個錯,寫下去的當下什麼事都不會發生。
票價 1,000 元。早鳥 9 折、團體 9 折、優惠碼折 100 元。
請問應該付多少?
| 規則 | 過程 | 結果 |
|---|---|---|
| 疊加:折扣先乘再減 | 1000 × 0.9 × 0.9 − 100 | 710 |
| 疊加:先減再乘 | (1000 − 100) × 0.9 × 0.9 | 729 |
| 不疊加,取最優 | 三種各算一次,取最低 | 900 |
三個都是合理的商業規則,都會通過「金額是正整數」的檢查。沒有把規則寫成答案的測試,就判斷不了哪一個錯。
跟 Day 22、24 一樣,我先把規則和測試向量 commit(d247cbf),再開一個無菌室給 AI。prompt 只有欄位名稱,不帶單位、不提折扣順序。它 5 輪、$0.23、不到一分鐘交回 money.js(6c6f54b),自己選了先乘後減、整筆小計、優惠碼封頂 —— 跟我定的一樣,四組向量全對。
差別只在細節:它用整數「元」而不是分;四捨五入的對象不同,20,000 組隨機輸入裡 12 組差 1 分。另外靜態檢查在它的第 29 行亮紅:
src/domain/__ai_probe.js:29: return Math.round((amount * pct) / 100);
❌ 金額路徑出現裸的 /100 —— 顯示轉換應放 src/presentation/;
整數百分比要寫成 Math.floor((cents * pct + 50) / 100)
專案規定百分比折扣只能寫成上面那一種,四捨五入的方式寫死在式子裡;它寫的是 Math.round(… / 100)。這次結果沒算錯,但檢查擋的是寫法,不是結果。
同一天稍晚,我決定這個專案要的是折扣不疊加、自動套最划算的那一種:訂單上只會有一種折扣,客服一句話講得清楚。規格改成擇優:三種候選各算一次取最低;平手依序 優惠碼 → 早鳥 → 團體;沒被選中的優惠碼不算用掉。

AI 的程式一個字都沒改,它從「只有 12 組差 1 分」變成「將近兩成的訂單不符合新規格」。 它沒有犯錯,是「對」的定義換了。
最直接的做法是叫它照新規則改。我把它那份疊加版原封不動放回無菌室,跑了兩次 —— 起點是同一份舊程式,只有 prompt 不同:
| A(規則完整) | B(規則留白) | |
|---|---|---|
| 花了多少 | 8 輪、$0.30、46 秒 | 6 輪、$0.27、46 秒 |
| 總額跟我的擇優版比(20,000 組) | 12 組差 1 分(舊版就有) | 12 組差 1 分(舊版就有) |
| 刻意做 2,000 組平手,套用的是哪一種 | 全對 | 1,291 組不同 |
兩版都不到一分鐘改好,總額除了原本那 12 組差 1 分,全部一樣。A 還做了一件我沒要求的事:主動提醒兩個連動影響 —— group_applied 從「符合團體資格」變成「實際套用了團體折扣」;團體折扣的基準從扣過早鳥的金額,改成原價小計。這種「改了這裡,那裡會跟著變」的提醒,正是改規格時最容易漏的。
B 改完列了五點要我決定的事:
group_applied 的意思變了第 1 點其實包含了我從 B 刻意拿掉的兩條規則:平手順序,以及沒選中的優惠碼算不算用掉;第 4 點是我的規格裡根本沒有的。問題它問對了,還問出一個我沒想到的。
但在我回答之前,它已經先替我選了一個答案:平手時早鳥優先。總額一樣,可是 2,000 組平手裡有 1,291 組記成了不同的折扣 —— 報表、優惠碼核銷都會跟著錯。
還有兩件它沒做的事:四捨五入的對象和 / 100 那個沒照規定的寫法都原封不動,它沒有因為規則改了回頭檢查舊的寫法;A 和 B 都說「改好了,但還沒實際跑過」(我沒給它執行程式的權限),這點它照實講了。
我把五點的答案回給 B(平手依序選優惠碼 → 早鳥 → 團體、沒選中的碼不算用掉、其餘照它的改法),讓它在同一個對話裡再改一次:3 輪、14 秒,只調了平手時的優先順序,2,000 組平手從 1,291 組不同變成全對。它還多提醒一件事:quote() 只負責算錢、不會核銷優惠碼,「沒選中的碼不算用掉」要靠呼叫端判斷 —— 「我沒有去看呼叫端現在是怎麼判斷的。」
把三次實驗排在一起,就是一個可以照做的流程:

/ 100)它也不會主動回頭查。答案定下來之後,測試分兩層:第一層是不變條件,不管選哪個算法都必須成立的基本性質;第二層照規則重算答案。不變條件長這樣:
// tests/domain/money.fuzz.test.js:固定 seed,20,000 組隨機輸入
q.total_cents >= 0 // 不能是負的
q.total_cents <= q.subtotal_cents // 折扣後不能比原價貴
Number.isInteger(q.total_cents) // 整數
quote(i) 跑兩次結果一樣 // 同一組輸入永遠同一個輸出
小計 − 被套用那一種的折抵 === total // 明細加總等於總額
第二層是一套獨立寫的對照算法(輸出裡的 oracle):不共用 money.js 的任何一行,四捨五入用 BigInt 算,擇優與平手照規格寫成排序,兩邊逐組比總額,也比套用的是哪一種。兩層都重跑,所有違規計數都是 0:
money fuzz seed=20260927 n=20000
{"negative":0,"overSubtotal":0,"notInteger":0,"nondeterministic":0,"breakdown":0,"oracle":0}
但全綠不代表這套 fuzz 抓得到錯 —— 所以我故意改壞四個地方,看它抓不抓得到:
| 故意改壞 | 五條不變條件 | 對照算法 |
|---|---|---|
| 優惠碼不封頂(可以折成負的) | 2,628 組紅 | 2,628 組紅 |
| 平手改成後者優先 | 0 | 962 組紅 |
四捨五入 +50 改成 +49 |
0 | 383 組紅 |
| 改成四捨五入折扣金額(AI 版的做法) | 0 | 383 組紅 |
五條不變條件只抓到最離譜的那一個。 平手錯的是「套用了哪一種」,總額不變 —— 就是 B 那 1,291 組的錯法,不變條件看不到。內部自洽,不等於符合規則。
不變條件管得住「離譜」,管不住「差 1 分」和「選錯折扣」。
這兩種,都要靠另一份照著規格、從零寫起的對照算法。
靜態檢查也一樣要先驗過。之前我拿一支打分腳本量 AI,報了五筆違規,結果五筆全是誤報;所以現在每支檢查都要先通過自我測試(32 個該抓的全抓到、15 個不該抓的全沒抓)。今天第 29 行那個紅燈能放心說是 AI 違規,靠的就是這個證明。
規則會變,價格也會變。主辦可以改票價 —— 這是規格裡的功能,PATCH /ticket-types/:id。問題是:已經確認的訂單,金額要怎麼算?
如果明細只存 ticket_type_id,金額用 JOIN 即時算,就會發生下面這種事:

沒有人改過他的訂單,資料庫沒有任何一列被 UPDATE,帳單自己變了。 而且只要沒有人調價,這個 bug 就不會出現;它會在某一天有人調價之後,一次影響這個票種所有的歷史訂單。
這件事其實大家都很熟:網購下單之後,就算那個商品隔天降價,你訂單裡的金額也不會跟著變 —— 訂單記的是你下單那一刻的價格。這就是價格快照。
這一題 AI 沒上當。Day 22 那份只拿到一句需求的 schema,明細表自己就存了單價,欄位旁邊的註解寫著「下單當下的價格快照」。
規格對這件事寫得很窄:凍結的是訂單,不是保留。 還沒確認的保留不鎖價,改價之後才確認的就用新價;確認那一刻,訂單和每張票的金額在同一個交易裡寫進去。這條規則有三個裁判:
tests/schema.test.js ⑥:明細表要有自己的單價欄scripts/check-price-snapshot.sh:任何 UPDATE 都不准寫入金額欄 —— 事後回填這條路根本不存在tests/routes/holds.test.js:一筆改價前確認的、一筆改價後確認的,兩張訂單各用各的價it('確認時的票價取當下(改價後未確認的 hold 以新價計);已確認的訂單不變', async () => {
const o1 = await confirm(await hold(alice, ['A1'])) // 1,000
const h2 = await hold(bob, ['A2'])
await patchPrice(tt.id, 50000) // 改成 500
expect((await confirm(h2, bob)).total_cents).toBe(50000) // 新確認的用新價
expect((await getOrder(o1.id)).total_cents).toBe(100000) // 舊訂單不變
})
(為了閱讀,這段把 helper 簡化過;原文在 repo。)這支測試也要先證明自己抓得到:我把讀訂單的金額故意改成 JOIN 現行票價再跑,它馬上紅(expected 50000 to be 100000)。寫錯的當下什麼事都不會發生,但症狀可以在測試裡先演一次 —— 改價、再重讀舊訂單。
規則改了,同一份程式有將近兩成訂單不符;叫它照新規則改,它把我漏寫的問了出來,我答完之後,平手全對。
從這三次實驗看,AI 很適合陪你改規格:改得快,也會提醒你漏了什麼、改了哪裡會連動。但答案要你定,定了要寫進測試 —— 而那些測試,你一樣要先驗過。
價格改了也一樣:新訂單要跟著變,舊訂單不能。這個錯寫下去的當下不會出事,所以要在測試裡先演一次改價。
這一篇留下的心法:
規格會變,AI 跟得上,還會反問你漏了什麼;但它問完會先替你選一個答案,那個答案不一定是你要的。
所以改規格的順序是:讓它問、你來答、寫進測試、再讓它改。
明天:時間的那一刻 —— 開賣那一秒,和保留到期那一毫秒。後者有三方在搶同一個位子。
6c6f54b、改規則後重新量測 dc08708;連結是這個資料夾的最後一版):github.com/n913239/event-signup-lab/tree/5088a1d/devlog/raw/exp-02-money
ab4e761):github.com/n913239/event-signup-lab/tree/ab4e761/devlog/raw/exp-02b-money-change
5088a1d):github.com/n913239/event-signup-lab/blob/5088a1d/tests/domain/money.fuzz.test.js
58c9ac9):github.com/n913239/event-signup-lab/blob/58c9ac9/tests/routes/holds.test.js