四次裡有一次才會現形的 bug
兩個注音符號悄悄共用了同一個例字。大約四分之一的頁面載入,測驗模式會同時秀出兩張一模一樣的表情符號跟例字——這個巧合,直到有人把機率算出來才真的現形。
背景
bopomofokids.com 教學 37 個注音符號時,每個都配一個
例字——例如 ㄈ 配「飛機」✈️。做法不是把一個字寫死配一個符號,而是每個符號帶一小組
候選例字,getWordVariant(item) 在符號第一次顯示時隨機挑一個,然後在整個
session 裡快取這個選擇。重新整理頁面可能會換到不同的字;但同一個 session 裡,
不管切到哪個模式都會維持一致。
有一條硬規則讓這件事變安全:資料集裡每一個 word 應該都是唯一的,不該有同一個
字出現在兩個不同符號的候選名單裡。但這條規則其實沒有在任何地方被真正檢查過——
只是一個「加新字的人應該要自己記得」的假設。
這個 bug
ㄈ 的例字名單裡有「飛機」(fēijī)。當時 ㄟ 的名單裡也有——因為「飛機」第二個字 確實含有 ㄟ 的音(f-ei-jī),而且兩個符號當時各自只有兩個候選字。單獨看, 兩邊的選擇都是對的。沒有人檢查過「同一個字出現在兩個不同符號底下」算不算問題, 因為在任何單一次頁面載入裡,這件事通常根本看不出來。
QuizMode 的錯誤選項,有一部分是從其他符號已經解析好的例字裡抽的。當一題剛好 同時牽涉到 ㄈ 跟 ㄟ,而這兩個符號剛好都在那個 session 裡隨機挑中了「飛機」, 測驗就會秀出兩張一模一樣的 ✈️ 表情跟一模一樣的字——不管是小孩、QA 測試、還是 截圖,都沒辦法分辨哪一張才是真正的答案。
為什麼這件事藏了一陣子
當時每個符號有兩個候選字,而且每個符號各自獨立抽選,ㄈ 跟 ㄟ 同時都抽中 共用字(飛機)的機率是:
P(ㄈ 抽中飛機) × P(ㄟ 抽中飛機) = 1/2 × 1/2 = 1/4 → 約 25% 的載入
真正的機率
四分之一的機率不算罕見——但這正好是那種能撐過隨性人工測試的機率。重新整理個
兩次、瞄一眼測驗畫面、兩次都看起來沒事就結案:在兩次獨立檢查裡漏看一個 25%
機率 bug 的機率是 0.75 × 0.75,大約 56%。你大概要連續重新整理十次左右,
「完全沒看到」的機率才會降到 6% 以下。沒有人會為了重新確認一個第一、二次看起來
都正確的測驗畫面,連按十次重新整理。
修法
立即的修法很窄:把 ㄟ 的第一候選字從「飛機」換成「杯子」(bēizi)——對 ㄟ 來說 發音一樣正確,但不再跟 ㄈ 共用。這讓這個特定的碰撞從此不可能發生,而不只是 機率變低。
真正的修法比較廣:「資料集裡每個字都該是唯一的」這條規則,從一個假設變成一條 有文件記載、真正會被檢查的不變量,加上一句明確指示——加新候選字之前,要先 grep 整份例字清單確認沒有重複。同一波修改裡,每個符號的候選字數也從兩個變成三個—— 這順帶降低了未來任何意外碰撞被漏看的機率,但那是附帶效果,不是修法本身。真正的 修法,是這種碰撞從此不被允許存在於資料裡。
這次學到什麼
一個四次裡出現一次的 bug,連續兩次隨性重新整理都漏看的機率超過一半。如果某段 程式碼的行為依賴隨機性,值得明確算一下「要試幾次才抓得到某個機率的問題」—— 在 25% 的情況下,「我看了一下,沒事」這句話幾乎沒有意義。
把候選字從兩個加到三個,讓類似的意外未來被看見的機率變低,但不代表它從此 不可能發生——兩個符號還是有可能巧合共用一個候選字。真正的修法,是把「字應該 唯一」從假設變成一條會被檢查的規則,讓這個 bug 在結構上不可能存在,而不只是 比較少見。
這裡的抽選邏輯本身設計得沒錯;bug 完全活在它抽選的那份資料裡。對一份沒被檢查過 的候選集合做隨機抽選,並不會讓資料完整性的問題消失——只會讓它變成間歇性的,而 間歇性的問題通常比固定發生的問題更難被注意到。