fix(user_db): clamp denormal dee to restore Pack/Unpack round-trip - #1215
fix(user_db): clamp denormal dee to restore Pack/Unpack round-trip#1215fwonce wants to merge 2 commits into
Conversation
|
呼叫 @ksqsf 老師。 我正在度假,而您最近改過 user_db,能否先幫忙把把關? 另外,也許可以發散一下,衰減到極小值視同刪除的邏輯,能否與這個 PR 結合。 |
謝謝 @lotem ,度假愉快! 這個 PR 原本聚焦於解析的完備性——已寫入的值不該在讀回時拋異常。關於「衰減到極小值視同刪除」與本 PR 結合的想法,我的看法分兩層:
無論是哪一種,我願意參與討論和實施,期待 @ksqsf 老師的意見。 |
There was a problem hiding this comment.
Pull request overview
Fixes user database serialization/parsing of denormal decay weights.
Changes:
- Clamps aged-out weights during serialization.
- Uses non-throwing numeric parsers during deserialization.
- Adds shared threshold and round-trip tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/rime/dict/user_db.cc |
Updates weight serialization and numeric parsing. |
src/rime/dict/user_db.h |
Exposes the shared discard threshold. |
src/rime/dict/user_dictionary.cc |
Uses the shared threshold. |
test/user_db_test.cc |
Adds parsing and round-trip tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| long parsed = std::strtol(v.c_str(), &end, 10); | ||
| if (end == v.c_str() || errno == ERANGE) { | ||
| throw std::invalid_argument("bad commits"); | ||
| } | ||
| commits = static_cast<int>(parsed); |
| char* end = nullptr; | ||
| errno = 0; | ||
| long parsed = std::strtol(v.c_str(), &end, 10); | ||
| if (end == v.c_str() || errno == ERANGE) { | ||
| throw std::invalid_argument("bad commits"); | ||
| } | ||
| commits = static_cast<int>(parsed); |
There was a problem hiding this comment.
这里和之前的逻辑似乎没有变化?我的理解是都要求必须解析成功,不成功就 throw。如果是这样的话,这里不用改。
| char* end = nullptr; | ||
| errno = 0; | ||
| unsigned long parsed = std::strtoul(v.c_str(), &end, 10); | ||
| if (end == v.c_str() || errno == ERANGE) { | ||
| throw std::invalid_argument("bad tick"); | ||
| } | ||
| tick = static_cast<TickCount>(parsed); |
| char* end = nullptr; | ||
| errno = 0; | ||
| double parsed = std::strtod(v.c_str(), &end); | ||
| if (end == v.c_str()) { | ||
| throw std::invalid_argument("bad dee"); | ||
| } | ||
| // strtod returns 0 on underflow and HUGE_VAL on overflow; either | ||
| // way, clamp to [0, 10000] which is the valid dee range. | ||
| if (parsed < 0.0 || std::isnan(parsed)) | ||
| parsed = 0.0; | ||
| dee = (std::min)(10000.0, parsed); |
There was a problem hiding this comment.
这里也可以简化,
| char* end = nullptr; | |
| errno = 0; | |
| double parsed = std::strtod(v.c_str(), &end); | |
| if (end == v.c_str()) { | |
| throw std::invalid_argument("bad dee"); | |
| } | |
| // strtod returns 0 on underflow and HUGE_VAL on overflow; either | |
| // way, clamp to [0, 10000] which is the valid dee range. | |
| if (parsed < 0.0 || std::isnan(parsed)) | |
| parsed = 0.0; | |
| dee = (std::min)(10000.0, parsed); | |
| try { | |
| dee = (std::min)(10000.0, std::stod(v)); | |
| } catch (out_of_range) { | |
| dee = 0; | |
| } |
如果要考虑「旧词视同删除」逻辑,加个 if 就可以了:
| char* end = nullptr; | |
| errno = 0; | |
| double parsed = std::strtod(v.c_str(), &end); | |
| if (end == v.c_str()) { | |
| throw std::invalid_argument("bad dee"); | |
| } | |
| // strtod returns 0 on underflow and HUGE_VAL on overflow; either | |
| // way, clamp to [0, 10000] which is the valid dee range. | |
| if (parsed < 0.0 || std::isnan(parsed)) | |
| parsed = 0.0; | |
| dee = (std::min)(10000.0, parsed); | |
| try { | |
| dee = (std::min)(10000.0, std::stod(v)); | |
| } catch (out_of_range) { | |
| dee = 0; | |
| } | |
| if (dee <= threshold) | |
| return false; |
問題
載入使用者詞庫時,librime 會輸出類似以下的錯誤日誌:
凡是權重
dee經過多次衰減後落入**非正規(denormal)**雙精度浮點數範圍內的詞條,都會觸發此錯誤。根本原因——寫入/讀取不對稱
dee會隨著 tick 遞增,透過formula_d(d + da * exp((ta - t) / 200))不斷衰減。對於長期存在但很少使用的詞條,dee可能低於最小正規雙精度浮點數(std::numeric_limits<double>::min()≈ 2.2e-308),落入非正規範圍(最低約 4.9e-324)。Pack()透過std::ostringstream序列化dee,非正規數值會被如實輸出為9.88131e-324這樣的字串。Unpack()使用std::stod解析dee,而std::stod底層呼叫strtod,當結果下溢(errno == ERANGE)時會拋出std::out_of_range——非正規字面量恰好會觸發這種情況。因此 librime 自己寫入的值,無法再被讀回來。
影響
dee欄位。Unpack()捕捉異常後回傳false,且拋出異常會中斷解析迴圈,導致tick也來不及讀取。檢查回傳值的呼叫端會丟棄整筆記錄——最明顯的是UserDictionary::CreateDictEntry:UpdateEntry、UserDbMerger::Put、UserDbImporter::Put)則會拿著半解析的UserDbValue(dee/tick為預設值)繼續執行,可能污染後續的合併或覆寫。請注意這與
CreateDictEntry中設計上的老化淘汰(dee <= kDiscardThreshold,1e-200,於 de21e7d 中加入)是兩回事:非正規詞條理應走到那條乾淨的淘汰路徑,而不是先在解析器裡拋出異常。修復
共三個 commit,聚焦於解析的完備性,與淘汰策略正交:
Pack()鉗位:序列化前將已老化(dee <= kUserDbDiscardThreshold)以及負值的dee鉗位為0,從源頭杜絕非正規字串被寫入。鉗位點與 de21e7d 的載入時淘汰閾值共用同一常量(提升為user_db.h中的kUserDbDiscardThreshold),消除此前[1e-308, 1e-200)區間「載入時被忽略、寫盤時仍以微小數值序列化」的語義縫隙,讓存儲狀態與淘汰語義一致。Unpack()改用strtol/strtod/strtoul:strtod在下溢時回傳該值或0而不拋異常,因此使用者資料庫中既有的非正規詞條也能被乾淨地解析;dee隨後被鉗位到[0, 10000]。end-pointer 與errno == ERANGE檢查保留了對真正格式錯誤輸入的拒絕能力。CreateDictEntry改用共享常量,移除原本的地區型定義。修復後,老化的詞條會無錯誤地被解析,並透過設計上的淘汰路徑被忽略,而不是在解析器中炸開。此修復不會(也不應該)把這類詞條復活為候選——它們確實已經老化出局;修復的價值在於停止日誌刷錯,並避免誤傷其他合法記錄。真正的「視同刪除」(物理清除或刪除標記)屬於行為策略變更,建議另開 issue 討論。
測試
在
test/user_db_test.cc中新增RimeUserDbValueTest測試套件(7 個案例):PackUnpackRoundtripNormalPackUnpackRoundtripZeroPackUnpackRoundtripSmallNormalPackClampsAgedOutDeeUnpackSurvivesDenormal9.88131e-324可無錯誤解析PackThenUnpackDenormalIsClampeddenorm_min()打包後再解包,得到d=0的乾淨往返UnpackRejectsGarbage全部 7 個測試通過;完整測試套件 120/120 通過,無回歸。